@ouro.bot/cli 0.1.0-alpha.990 → 0.1.0-alpha.991

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 (37) hide show
  1. package/changelog.json +6 -0
  2. package/deploy/unraid/Dockerfile +15 -2
  3. package/deploy/unraid/README.txt +44 -9
  4. package/deploy/unraid/github_known_hosts +3 -0
  5. package/deploy/unraid/ouro-sync-ssh +98 -0
  6. package/deploy/unraid/ouro-sync.sudoers +3 -0
  7. package/deploy/unraid/sanctuary-butler-upgrade.mjs +26 -2
  8. package/deploy/unraid/sanctuary-replay-gate.mjs +361 -8
  9. package/deploy/unraid/sanctuary-sync-cutover.sh +1039 -0
  10. package/deploy/unraid/sanctuary.ouro/agent.json +2 -1
  11. package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
  12. package/deploy/unraid/sanctuary.ouro/tool-profiles.json +3 -1
  13. package/deploy/unraid/sanctuary.xml +2 -1
  14. package/dist/heart/config.js +2 -1
  15. package/dist/heart/daemon/agent-discovery.js +5 -1
  16. package/dist/heart/daemon/agent-home.js +67 -31
  17. package/dist/heart/daemon/boot-sync-probe.js +36 -10
  18. package/dist/heart/daemon/cli-exec.js +79 -20
  19. package/dist/heart/daemon/cli-help.js +12 -0
  20. package/dist/heart/daemon/cli-parse.js +14 -0
  21. package/dist/heart/daemon/container-spec-auditor-main.js +22 -6
  22. package/dist/heart/daemon/container-spec-auditor.js +18 -2
  23. package/dist/heart/daemon/daemon-entry.js +22 -0
  24. package/dist/heart/daemon/sanctuary-acceptance-harness.js +1 -1
  25. package/dist/heart/daemon/sanctuary-bundle-migration.js +97 -1
  26. package/dist/heart/daemon/sanctuary-package-management.js +10 -0
  27. package/dist/heart/sync-lock.js +431 -0
  28. package/dist/heart/sync.js +1252 -399
  29. package/dist/mind/prompt.js +92 -19
  30. package/dist/repertoire/guardrails.js +285 -3
  31. package/dist/repertoire/tools-bundle.js +107 -185
  32. package/dist/repertoire/tools.js +5 -1
  33. package/dist/senses/pipeline.js +37 -3
  34. package/dist/senses/private-runtime.js +1 -6
  35. package/dist/senses/voice/twilio-phone.js +16 -16
  36. package/npm-shrinkwrap.json +2 -2
  37. package/package.json +1 -1
@@ -35,13 +35,14 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.bundleToolDefinitions = void 0;
37
37
  exports.__getConfirmationTokenStore = __getConfirmationTokenStore;
38
- exports.isFirstPushToRemote = isFirstPushToRemote;
38
+ exports.probeFirstPush = probeFirstPush;
39
39
  /**
40
40
  * Agent-callable bundle management tools.
41
41
  *
42
42
  * These tools let the agent fix its own git state — initializing the
43
- * bundle as a repo, adding a remote, making the first commit, pushing,
44
- * and recovering from push rejections. The `bundleState` field in the
43
+ * bundle as a repo, adding a remote, making the first commit, and syncing
44
+ * with the remote (bundle_push and bundle_pull_rebase both run the shared
45
+ * merge-only `syncNow`; neither ever leaves a rebase, a merge or a stash). The `bundleState` field in the
45
46
  * start-of-turn packet (PR 5) points the agent at these tools whenever
46
47
  * it detects an unresolved git issue.
47
48
  *
@@ -57,8 +58,7 @@ exports.isFirstPushToRemote = isFirstPushToRemote;
57
58
  * ## Destructive-op refusal pattern (Directive B)
58
59
  *
59
60
  * Tools that could lose work (bundle_init_git on an existing repo,
60
- * bundle_add_remote on a configured remote, bundle_pull_rebase with
61
- * dirty tree, etc.) refuse by default and require an explicit `force`
61
+ * bundle_add_remote on a configured remote, etc.) refuse by default and require an explicit `force`
62
62
  * flag — mirroring Claude Code's `ExitWorktreeTool` safety pattern. The
63
63
  * refusal surface is the LLM's responsibility to bridge with the human:
64
64
  * when a tool refuses, the agent should ask the user for permission
@@ -169,19 +169,16 @@ const checkSyncStatusHandler = () => {
169
169
  .map((l) => l.trim())
170
170
  .filter((l) => l.length > 0).length;
171
171
  }
172
- /* v8 ignore start -- upstream tracking ahead/behind requires a live remote, not practical to cover in unit tests @preserve */
173
172
  if (hasRemote && firstCommitExists) {
174
- // Best-effort ahead/behind from git rev-list if an upstream is tracked.
175
- const counts = gitExec(bundleRoot, ["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]);
173
+ // Against the last fetched sync branch, named explicitly; no upstream config is assumed.
174
+ const config = bundleSyncConfig(bundleRoot);
175
+ const counts = gitExec(bundleRoot, ["rev-list", "--left-right", "--count", `HEAD...refs/remotes/${config.remote}/${config.branch}`]);
176
176
  if (counts.code === 0) {
177
177
  const [a, b] = counts.stdout.trim().split("\t").map((n) => parseInt(n, 10));
178
- if (!Number.isNaN(a) && !Number.isNaN(b)) {
179
- ahead = a;
180
- behind = b;
181
- }
178
+ ahead = a;
179
+ behind = b;
182
180
  }
183
181
  }
184
- /* v8 ignore stop */
185
182
  }
186
183
  const pendingSyncExists = fs.existsSync(path.join(bundleRoot, "state", "pending-sync.json"));
187
184
  const bundleStateIssues = (0, bundle_state_1.detectBundleState)(bundleRoot);
@@ -645,69 +642,78 @@ function makeFirstPushReviewHandler(deps = {}) {
645
642
  };
646
643
  }
647
644
  const firstPushReviewHandler = makeFirstPushReviewHandler();
648
- // ─── tool: bundle_push ─────────────────────────────────────────────────
649
- /* 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 */
650
- function classifyPushError(stderr) {
651
- const lower = stderr.toLowerCase();
652
- if (lower.includes("rejected") || lower.includes("non-fast-forward") || lower.includes("fetch first"))
653
- return "rejected";
654
- if (lower.includes("could not resolve") || lower.includes("network") || lower.includes("connection") || lower.includes("timeout"))
655
- return "network";
656
- if (lower.includes("authentication") || lower.includes("permission denied") || lower.includes("unauthorized") || lower.includes("403"))
657
- return "auth";
658
- return "unknown";
659
- }
660
- /* v8 ignore stop */
661
645
  /**
662
- * Detect whether this is a first push to the remote.
646
+ * Whether a push to `remote` would be the first push of `branch`: "first" when
647
+ * the remote has no such branch, "pushed" when it has. The remote is asked
648
+ * through sync's own ls-remote (the same flags and environment as sync's push,
649
+ * so in the container it goes through the ouro-sync ssh). A failed ls-remote
650
+ * is "unreachable", never taken for a first push or for a pushed one.
663
651
  *
664
- * Returns true if the remote branch does not yet exist (bundle has local
665
- * commits but has never been pushed). Also returns true — conservatively —
666
- * when git fails to probe the remote (network unreachable, git error).
667
- * The token requirement is a security gate; if we can't verify the remote
668
- * state, we assume the worst and force the agent to get confirmation. An
669
- * unreachable remote should NOT be a bypass vector.
670
- */
671
- /**
672
652
  * Exported for direct unit testing in bundle-push-first-push.test.ts.
673
- * The integration tests use unreachable remotes so they always hit the
674
- * network-failure branch. The unit tests mock child_process to exercise
675
- * the successful-probe branches (empty stdout = first push, non-empty
676
- * = subsequent push, symbolic-ref failure = conservative true).
677
653
  */
678
- function isFirstPushToRemote(bundleRoot, remote) {
679
- const branchResult = gitExec(bundleRoot, ["symbolic-ref", "--short", "HEAD"]);
680
- if (branchResult.code !== 0)
681
- return true;
682
- const branch = branchResult.stdout.trim();
683
- const lsRemote = gitExec(bundleRoot, ["ls-remote", "--heads", remote, branch], 10000);
684
- if (lsRemote.code !== 0)
685
- return true;
686
- return lsRemote.stdout.trim().length === 0;
654
+ async function probeFirstPush(bundleRoot, remote, branch) {
655
+ const listed = await (0, sync_1.listRemoteRefs)(bundleRoot, remote, [`refs/heads/${branch}`]);
656
+ if (!listed.ok)
657
+ return { kind: "unreachable", error: listed.error };
658
+ return listed.refs.length === 0 ? { kind: "first" } : { kind: "pushed" };
687
659
  }
688
- function makePushHandler(deps = {}) {
689
- return (args) => {
660
+ /** How long a bundle tool waits for the bundle sync lock before reporting busy. */
661
+ const BUNDLE_TOOL_SYNC_WAIT_MS = 10_000;
662
+ /** The bundle's sync remote and branch from agent.json: the agent cannot name another remote. */
663
+ function bundleSyncConfig(bundleRoot) {
664
+ let remote = "origin";
665
+ let branch = "main";
666
+ try {
667
+ const parsed = JSON.parse(fs.readFileSync(path.join(bundleRoot, "agent.json"), "utf-8"));
668
+ if (typeof parsed.sync?.remote === "string" && parsed.sync.remote.length > 0)
669
+ remote = parsed.sync.remote;
670
+ if (typeof parsed.sync?.branch === "string" && parsed.sync.branch.length > 0)
671
+ branch = parsed.sync.branch;
672
+ }
673
+ catch {
674
+ // No readable agent.json: the defaults.
675
+ }
676
+ return { enabled: true, remote, branch };
677
+ }
678
+ /**
679
+ * Both tools sync the bundle through `syncNow`: commit, fetch, merge, push,
680
+ * under the bundle lock. A conflict is aborted and recorded by sync itself,
681
+ * so no tool call leaves a merge, a rebase or a stash behind.
682
+ */
683
+ function makeSyncHandler(toolName, deps = {}) {
684
+ return async (args) => {
690
685
  const bundleRoot = (0, identity_1.getAgentRoot)();
691
- const remote = typeof args.remote === "string" && args.remote.length > 0 ? args.remote : "origin";
692
686
  const confirmationToken = typeof args.confirmation_token === "string"
693
687
  ? args.confirmation_token
694
688
  : undefined;
695
689
  const now = deps.now ?? Date.now;
690
+ const config = bundleSyncConfig(bundleRoot);
691
+ const remote = config.remote;
696
692
  (0, runtime_1.emitNervesEvent)({
697
693
  component: "repertoire",
698
- event: "repertoire.bundle_push_start",
699
- message: "pushing bundle to remote",
700
- meta: { bundleRoot, remote },
694
+ event: "repertoire.bundle_sync_tool_start",
695
+ message: "syncing bundle from a tool",
696
+ meta: { bundleRoot, tool: toolName, remote },
701
697
  });
702
698
  const finish = (ok, payload) => {
703
699
  (0, runtime_1.emitNervesEvent)({
704
700
  component: "repertoire",
705
- event: "repertoire.bundle_push_end",
706
- message: "bundle_push finished",
707
- meta: { bundleRoot, ok, ...payload },
701
+ event: "repertoire.bundle_sync_tool_end",
702
+ message: "bundle sync tool finished",
703
+ meta: { bundleRoot, tool: toolName, ok, ...payload },
708
704
  });
709
705
  return json({ ok, ...payload });
710
706
  };
707
+ const refuse = (message, error) => {
708
+ (0, runtime_1.emitNervesEvent)({
709
+ level: "warn",
710
+ component: "repertoire",
711
+ event: "repertoire.bundle_push_refused",
712
+ message,
713
+ meta: { bundleRoot, tool: toolName },
714
+ });
715
+ return finish(false, { error, kind: "confirmation_required" });
716
+ };
711
717
  if (!isGitRepo(bundleRoot)) {
712
718
  return finish(false, { error: "bundle is not a git repo" });
713
719
  }
@@ -722,50 +728,22 @@ function makePushHandler(deps = {}) {
722
728
  // previously called bundle_first_push_review and obtained a
723
729
  // confirmation token. This forces a PII-review gate between
724
730
  // "bundle built locally" and "bundle contents on the internet".
725
- const isFirstPush = isFirstPushToRemote(bundleRoot, remote);
726
- /* 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 */
731
+ const probe = await probeFirstPush(bundleRoot, remote, config.branch);
732
+ if (probe.kind === "unreachable") {
733
+ return finish(false, { error: `I couldn't reach GitHub to check whether this would be my first push, so I didn't sync: ${probe.error}`, kind: "remote_unreachable" });
734
+ }
735
+ const isFirstPush = probe.kind === "first";
727
736
  if (isFirstPush) {
728
737
  if (!confirmationToken) {
729
- (0, runtime_1.emitNervesEvent)({
730
- level: "warn",
731
- component: "repertoire",
732
- event: "repertoire.bundle_push_refused",
733
- message: "bundle_push refused: first push requires confirmation token",
734
- meta: { bundleRoot },
735
- });
736
- return finish(false, {
737
- 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",
738
- kind: "confirmation_required",
739
- });
738
+ return refuse(`${toolName} refused: first push requires confirmation token`, `refused: first push requires a confirmation token — call bundle_first_push_review, show the warning to the user, and pass the returned confirmationToken to ${toolName}`);
740
739
  }
741
- const currentTime = now();
742
- pruneExpiredTokens(currentTime);
740
+ pruneExpiredTokens(now());
743
741
  const entry = _confirmationTokens.get(confirmationToken);
744
742
  if (!entry) {
745
- (0, runtime_1.emitNervesEvent)({
746
- level: "warn",
747
- component: "repertoire",
748
- event: "repertoire.bundle_push_refused",
749
- message: "bundle_push refused: confirmation token invalid or expired",
750
- meta: { bundleRoot },
751
- });
752
- return finish(false, {
753
- error: "refused: confirmation token invalid or expired — call bundle_first_push_review again",
754
- kind: "confirmation_required",
755
- });
743
+ return refuse(`${toolName} refused: confirmation token invalid or expired`, "refused: confirmation token invalid or expired — call bundle_first_push_review again");
756
744
  }
757
745
  if (entry.bundleRoot !== bundleRoot) {
758
- (0, runtime_1.emitNervesEvent)({
759
- level: "warn",
760
- component: "repertoire",
761
- event: "repertoire.bundle_push_refused",
762
- message: "bundle_push refused: confirmation token bound to a different bundle",
763
- meta: { bundleRoot, tokenBundleRoot: entry.bundleRoot },
764
- });
765
- return finish(false, {
766
- error: "refused: confirmation token was issued for a different bundle",
767
- kind: "confirmation_required",
768
- });
746
+ return refuse(`${toolName} refused: confirmation token bound to a different bundle`, "refused: confirmation token was issued for a different bundle");
769
747
  }
770
748
  (0, runtime_1.emitNervesEvent)({
771
749
  component: "repertoire",
@@ -773,95 +751,41 @@ function makePushHandler(deps = {}) {
773
751
  message: "first push confirmed by valid token",
774
752
  meta: { bundleRoot },
775
753
  });
776
- // One-shot: consume the token on successful validation.
777
- _confirmationTokens.delete(confirmationToken);
778
754
  }
779
- const push = gitExec(bundleRoot, ["push", remote, "HEAD"], 30000);
780
- /* 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 */
781
- if (push.code === 0) {
782
- (0, sync_1.clearPendingSync)(bundleRoot);
783
- return finish(true, { remote, firstPush: isFirstPush });
755
+ const agentName = path.basename(bundleRoot).replace(/\.ouro$/, "");
756
+ // The valid token is the owner's recorded yes, which sync needs before a first push to an empty remote.
757
+ const result = await (0, sync_1.syncNow)(bundleRoot, config, agentName, BUNDLE_TOOL_SYNC_WAIT_MS, { firstPushApproved: isFirstPush });
758
+ const where = { remote, branch: config.branch, firstPush: isFirstPush };
759
+ if (result.paused)
760
+ return finish(false, { ...where, paused: true, pausedUntil: result.pausedUntil, error: `sync paused until ${result.pausedUntil}` });
761
+ if (result.busy)
762
+ return finish(false, { ...where, busy: true, holderPid: result.holderPid, error: result.error });
763
+ // One-shot: the token is spent once sync actually ran, not on a busy or paused refusal.
764
+ if (confirmationToken !== undefined && isFirstPush)
765
+ _confirmationTokens.delete(confirmationToken);
766
+ if (!result.ok) {
767
+ const failure = readPendingFailure(bundleRoot);
768
+ return finish(false, {
769
+ ...where,
770
+ error: result.error,
771
+ ...(failure?.classification ? { kind: failure.classification } : {}),
772
+ ...(failure?.conflictFiles && failure.conflictFiles.length > 0 ? { conflictFiles: failure.conflictFiles } : {}),
773
+ });
784
774
  }
785
- /* v8 ignore stop */
786
- const kind = classifyPushError(push.stderr);
787
- return finish(false, { error: push.stderr.trim(), kind });
775
+ return finish(true, where);
788
776
  };
789
777
  }
790
- const pushHandler = makePushHandler();
791
- // ─── tool: bundle_pull_rebase ──────────────────────────────────────────
792
- const pullRebaseHandler = (args) => {
793
- const bundleRoot = (0, identity_1.getAgentRoot)();
794
- const remote = typeof args.remote === "string" && args.remote.length > 0 ? args.remote : "origin";
795
- const discardChanges = args.discard_changes === "true" || args.discard_changes === true;
796
- (0, runtime_1.emitNervesEvent)({
797
- component: "repertoire",
798
- event: "repertoire.bundle_pull_rebase_start",
799
- message: "pulling with rebase",
800
- meta: { bundleRoot, remote, discardChanges },
801
- });
802
- const finish = (ok, payload) => {
803
- (0, runtime_1.emitNervesEvent)({
804
- component: "repertoire",
805
- event: "repertoire.bundle_pull_rebase_end",
806
- message: "bundle_pull_rebase finished",
807
- meta: { bundleRoot, ok, ...payload },
808
- });
809
- return json({ ok, ...payload });
810
- };
811
- if (!isGitRepo(bundleRoot)) {
812
- return finish(false, { error: "bundle is not a git repo" });
813
- }
814
- const remotes = listRemotes(bundleRoot);
815
- if (!remotes.includes(remote)) {
816
- return finish(false, { error: `remote "${remote}" not configured` });
817
- }
818
- // Uncommitted changes: refuse unless discard_changes was explicitly set.
819
- // Even with discard_changes, we stash+rebase+pop rather than nuke — the
820
- // user can manually `git stash drop` if they really want to discard.
821
- const status = gitExec(bundleRoot, ["status", "--porcelain"]);
822
- const isDirty = status.stdout.trim().length > 0;
823
- const stashed = { value: false };
824
- if (isDirty) {
825
- if (!discardChanges) {
826
- (0, runtime_1.emitNervesEvent)({
827
- level: "warn",
828
- component: "repertoire",
829
- event: "repertoire.bundle_pull_rebase_refused",
830
- message: "bundle_pull_rebase refused: uncommitted changes",
831
- meta: { bundleRoot },
832
- });
833
- return finish(false, { error: "refused: bundle has uncommitted changes — commit them or pass discard_changes: true (stash+rebase+pop)" });
834
- }
835
- /* v8 ignore start -- discardChanges=true stash path exercised in a dirty-tree test below but full rebase+pop requires reachable remote @preserve */
836
- const stash = gitExec(bundleRoot, ["stash", "push", "-u", "-m", "bundle_pull_rebase stash"]);
837
- if (stash.code !== 0) {
838
- return finish(false, { error: `git stash failed: ${stash.stderr}` });
839
- }
840
- stashed.value = true;
841
- /* v8 ignore stop */
778
+ /** The pending-sync record sync wrote for this failure, when readable. */
779
+ function readPendingFailure(bundleRoot) {
780
+ try {
781
+ return JSON.parse(fs.readFileSync(path.join(bundleRoot, "state", "pending-sync.json"), "utf-8"));
842
782
  }
843
- const rebase = gitExec(bundleRoot, ["pull", "--rebase", remote], 30000);
844
- /* v8 ignore start -- rebase success branch requires a reachable remote @preserve */
845
- if (rebase.code === 0) {
846
- if (stashed.value) {
847
- const pop = gitExec(bundleRoot, ["stash", "pop"]);
848
- if (pop.code !== 0) {
849
- return finish(false, { error: `git stash pop failed after successful rebase: ${pop.stderr}`, kind: "stash_conflict" });
850
- }
851
- }
852
- return finish(true, { remote, stashed: stashed.value });
853
- }
854
- /* v8 ignore stop */
855
- // Capture conflict files if the rebase left us mid-conflict.
856
- const conflictStatus = gitExec(bundleRoot, ["status", "--porcelain=v1"]);
857
- const conflictFiles = [];
858
- for (const line of conflictStatus.stdout.split("\n")) {
859
- /* v8 ignore next -- conflict marker extraction requires a real merge conflict, covered indirectly via the network-failure test which returns empty conflictStatus @preserve */
860
- if (/^(UU|AA|DD|AU|UA|DU|UD) /.test(line))
861
- conflictFiles.push(line.slice(3).trim());
862
- }
863
- return finish(false, { error: rebase.stderr.trim(), kind: "conflict", conflictFiles });
864
- };
783
+ catch {
784
+ return null;
785
+ }
786
+ }
787
+ const pushHandler = makeSyncHandler("bundle_push");
788
+ const pullRebaseHandler = makeSyncHandler("bundle_pull_rebase");
865
789
  // ─── registry ──────────────────────────────────────────────────────────
866
790
  exports.bundleToolDefinitions = [
867
791
  {
@@ -946,7 +870,7 @@ exports.bundleToolDefinitions = [
946
870
  type: "function",
947
871
  function: {
948
872
  name: "bundle_first_push_review",
949
- description: "Review my bundle for PII exposure before the first push to a new remote. Enumerates PII-bearing directories (friends, Desk record, Arc, 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.",
873
+ description: "Review my bundle for PII exposure before the first push to a new remote. Enumerates PII-bearing directories (friends, Desk record, Arc, 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 or bundle_pull_rebase on first push. Required before the first push to any new remote.",
950
874
  parameters: { type: "object", properties: {} },
951
875
  },
952
876
  },
@@ -957,11 +881,10 @@ exports.bundleToolDefinitions = [
957
881
  type: "function",
958
882
  function: {
959
883
  name: "bundle_push",
960
- 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.",
884
+ description: "Sync my bundle with its remote now.",
961
885
  parameters: {
962
886
  type: "object",
963
887
  properties: {
964
- remote: { type: "string", description: "Remote name. Defaults to 'origin'." },
965
888
  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." },
966
889
  },
967
890
  },
@@ -975,12 +898,11 @@ exports.bundleToolDefinitions = [
975
898
  type: "function",
976
899
  function: {
977
900
  name: "bundle_pull_rebase",
978
- 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.",
901
+ description: "Sync my bundle with its remote now.",
979
902
  parameters: {
980
903
  type: "object",
981
904
  properties: {
982
- remote: { type: "string", description: "Remote name. Defaults to 'origin'." },
983
- discard_changes: { type: "boolean", description: "Stash + rebase + pop even if the working tree is dirty. Default false." },
905
+ 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." },
984
906
  },
985
907
  },
986
908
  },
@@ -989,7 +911,7 @@ exports.bundleToolDefinitions = [
989
911
  riskProfile: {
990
912
  mutates: ["durable_state_write", "external_side_effect"],
991
913
  risk: "high",
992
- reason: "rebases bundle state from a remote",
914
+ reason: "syncs bundle state with a remote",
993
915
  },
994
916
  },
995
917
  ];
@@ -86,6 +86,8 @@ exports.SANCTUARY_OWNER_ADDITIONS = new Set([
86
86
  "web_search", "search_facts", "consult_diary", "consult_notes", "get_friend_note",
87
87
  "session_summary", "query_session", "set_reasoning_effort", "restart_runtime", "revive_sense",
88
88
  ]);
89
+ /** The bundle sync tools, granted to the Sanctuary owner alone. */
90
+ const SANCTUARY_OWNER_SYNC_TOOLS = new Set(["bundle_push", "bundle_pull_rebase"]);
89
91
  // Re-export types and constants used by the rest of the codebase
90
92
  var tools_base_2 = require("./tools-base");
91
93
  Object.defineProperty(exports, "tools", { enumerable: true, get: function () { return tools_base_2.tools; } });
@@ -158,7 +160,9 @@ function selectToolsForChannel(capabilities, toolPreferences, _context, provider
158
160
  ? [
159
161
  ...tools_base_1.baseToolDefinitions.filter((definition) => SANCTUARY_RELATIONSHIP_BASE_TOOLS.has(definition.tool.function.name)
160
162
  || (ownerLike && exports.SANCTUARY_OWNER_ADDITIONS.has(definition.tool.function.name))),
161
- ...tools_unraid_1.unraidToolDefinitions, tools_steward_policy_1.stewardPolicyToolDefinition, ...(ownerLike ? tools_house_care_1.houseCareToolDefinitions : []), ...(ownerLike ? mcp : []),
163
+ ...tools_unraid_1.unraidToolDefinitions, tools_steward_policy_1.stewardPolicyToolDefinition, ...(ownerLike ? tools_house_care_1.houseCareToolDefinitions : []),
164
+ ...(owner ? tools_bundle_1.bundleToolDefinitions.filter((definition) => SANCTUARY_OWNER_SYNC_TOOLS.has(definition.tool.function.name)) : []),
165
+ ...(ownerLike ? mcp : []),
162
166
  ]
163
167
  : [];
164
168
  }
@@ -40,6 +40,7 @@ var __importStar = (this && this.__importStar) || (function () {
40
40
  Object.defineProperty(exports, "__esModule", { value: true });
41
41
  exports.emitObligationTransitionEpisodes = emitObligationTransitionEpisodes;
42
42
  exports.selectCheckpointCurrentAsk = selectCheckpointCurrentAsk;
43
+ exports.startPostTurnSync = startPostTurnSync;
43
44
  exports.handleInboundTurn = handleInboundTurn;
44
45
  const fs = __importStar(require("fs"));
45
46
  const path = __importStar(require("path"));
@@ -500,6 +501,37 @@ function prepareInboundRunLedger(input) {
500
501
  return null;
501
502
  }
502
503
  }
504
+ /**
505
+ * Commit the turn's edits now, then let the push run without awaiting it, so
506
+ * the reply is not held for the network. A later edit is not in this commit;
507
+ * the next turn commits it whole.
508
+ */
509
+ function startPostTurnSync(agentRoot, config) {
510
+ let result;
511
+ try {
512
+ result = (0, sync_1.postTurnPush)(agentRoot, config);
513
+ }
514
+ catch (err) {
515
+ // In the turn's `finally`: a throw here would replace the turn's result.
516
+ (0, runtime_1.emitNervesEvent)({
517
+ level: "error",
518
+ component: "senses",
519
+ event: "senses.post_turn_sync_error",
520
+ message: "post-turn sync threw",
521
+ meta: { agentRoot, error: err instanceof Error ? err.message : String(err) },
522
+ });
523
+ return;
524
+ }
525
+ result.push?.catch((err) => {
526
+ (0, runtime_1.emitNervesEvent)({
527
+ level: "warn",
528
+ component: "senses",
529
+ event: "senses.post_turn_push_rejected",
530
+ message: "post-turn push rejected",
531
+ meta: { agentRoot, error: err instanceof Error ? err.message : String(err) },
532
+ });
533
+ });
534
+ }
503
535
  async function handleInboundTurn(input) {
504
536
  return (0, turn_execution_lease_1.withTurnExecutionLease)(() => handleInboundTurnExclusive(input));
505
537
  }
@@ -773,7 +805,7 @@ async function handleInboundTurnExclusive(input) {
773
805
  try {
774
806
  /* v8 ignore start -- sync-enabled branches tested in sync.test.ts, pipeline tests mock at module boundary @preserve */
775
807
  if (syncConfig.enabled && !liveLatencyMode) {
776
- const pullResult = (0, sync_1.preTurnPull)((0, identity_1.getAgentRoot)(), syncConfig);
808
+ const pullResult = await (0, sync_1.preTurnPull)((0, identity_1.getAgentRoot)(), syncConfig);
777
809
  if (!pullResult.ok) {
778
810
  syncFailure = pullResult.error;
779
811
  }
@@ -1253,9 +1285,11 @@ async function handleInboundTurnExclusive(input) {
1253
1285
  };
1254
1286
  }
1255
1287
  finally {
1256
- // Step 6b: Post-turn sync push (opt-in, git-status-based discovery).
1288
+ // Step 6b: Post-turn sync (opt-in). The commit is synchronous, so the
1289
+ // turn's edits are recorded before the reply goes out; the push runs on
1290
+ // without being awaited, holding the lock, and records its own failure.
1257
1291
  if (syncConfig.enabled && !liveLatencyMode && !declinedForHome) {
1258
- (0, sync_1.postTurnPush)((0, identity_1.getAgentRoot)(), syncConfig);
1292
+ startPostTurnSync((0, identity_1.getAgentRoot)(), syncConfig);
1259
1293
  }
1260
1294
  }
1261
1295
  }
@@ -224,12 +224,7 @@ async function resolveRelationshipAwaitAuthority(agentRoot, store, registry, coo
224
224
  return { authorization, friend };
225
225
  }
226
226
  function readAspirations(agentRoot) {
227
- try {
228
- return fs.readFileSync(path.join(agentRoot, "psyche", "ASPIRATIONS.md"), "utf8").trim();
229
- }
230
- catch {
231
- return "";
232
- }
227
+ return (0, prompt_1.loadPsycheAt)(agentRoot).aspirations;
233
228
  }
234
229
  function loadPrivateRuntimeInstincts() {
235
230
  return [...DEFAULT_PRIVATE_RUNTIME_INSTINCTS];
@@ -52,6 +52,7 @@ exports.computeOpenAIWebhookSignature = computeOpenAIWebhookSignature;
52
52
  exports.validateOpenAIWebhookSignature = validateOpenAIWebhookSignature;
53
53
  exports.tonesOnly = tonesOnly;
54
54
  exports.parseToolArguments = parseToolArguments;
55
+ exports.voicePsycheExcerpts = voicePsycheExcerpts;
55
56
  exports.computeTwilioSignature = computeTwilioSignature;
56
57
  exports.validateTwilioSignature = validateTwilioSignature;
57
58
  exports.twilioRecordingMediaUrl = twilioRecordingMediaUrl;
@@ -69,6 +70,7 @@ const http = __importStar(require("http"));
69
70
  const path = __importStar(require("path"));
70
71
  const ws_1 = require("ws");
71
72
  const context_1 = require("../../mind/context");
73
+ const prompt_1 = require("../../mind/prompt");
72
74
  const friends_1 = require("@ouro.bot/friends");
73
75
  const identity_1 = require("../../heart/identity");
74
76
  const mcp_manager_1 = require("../../repertoire/mcp-manager");
@@ -1601,24 +1603,22 @@ function looksLikeShortHumanPhoneGreeting(transcript) {
1601
1603
  return false;
1602
1604
  return /^(hi|hello|hey|yo|yes|yeah|yep)(\b|$)/.test(text);
1603
1605
  }
1604
- async function readOptionalText(filePath, maxChars) {
1605
- try {
1606
- const text = (await fs.readFile(filePath, "utf8")).trim();
1607
- if (text.length <= maxChars)
1608
- return text;
1609
- return `${text.slice(0, maxChars).trim()}\n[truncated for low-latency voice]`;
1610
- }
1611
- catch {
1612
- return "";
1613
- }
1606
+ function capVoiceText(text, maxChars) {
1607
+ if (text.length <= maxChars)
1608
+ return text;
1609
+ return `${text.slice(0, maxChars).trim()}\n[truncated for low-latency voice]`;
1610
+ }
1611
+ /** The psyche excerpts the live voice prompt carries, read through the shared psyche loader and capped for latency. */
1612
+ function voicePsycheExcerpts(agentRoot) {
1613
+ const psyche = (0, prompt_1.loadPsycheAt)(agentRoot);
1614
+ return {
1615
+ soul: capVoiceText(psyche.soul, 1_600),
1616
+ identity: capVoiceText(psyche.identity, 3_200),
1617
+ tacit: capVoiceText(psyche.tacitKnowledge, 1_400),
1618
+ };
1614
1619
  }
1615
1620
  async function buildRealtimeVoiceInstructions(options) {
1616
- const psycheDir = path.join(options.agentRoot, "psyche");
1617
- const [soul, identity, tacit] = await Promise.all([
1618
- readOptionalText(path.join(psycheDir, "SOUL.md"), 1_600),
1619
- readOptionalText(path.join(psycheDir, "IDENTITY.md"), 3_200),
1620
- readOptionalText(path.join(psycheDir, "TACIT.md"), 1_400),
1621
- ]);
1621
+ const { soul, identity, tacit } = voicePsycheExcerpts(options.agentRoot);
1622
1622
  return [
1623
1623
  `You are ${options.agentName} in the live Voice sense.`,
1624
1624
  "This is the same agent identity as every other Ouro surface. Voice is not a reduced or alternate self.",
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.990",
3
+ "version": "0.1.0-alpha.991",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@ouro.bot/cli",
9
- "version": "0.1.0-alpha.990",
9
+ "version": "0.1.0-alpha.991",
10
10
  "dependencies": {
11
11
  "@anthropic-ai/sdk": "^0.78.0",
12
12
  "@azure/identity": "^4.13.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.990",
3
+ "version": "0.1.0-alpha.991",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },