@ouro.bot/cli 0.1.0-alpha.517 → 0.1.0-alpha.518

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,16 @@
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.518",
6
+ "changes": [
7
+ "Layer 2 of the harness-hardening sequence (1→4→2→3 from `docs/planning/2026-04-28-1900-planning-harness-hardening-and-repairguide.md`). Wires a pre-flight `git pull` over every sync-enabled bundle into `ouro up`, before per-agent provider live-checks, so the post-pull `agent.json` is what live-check reads. First PR in the sequence that mutates working trees; does NOT write to `state/` (verified by a meta-test).",
8
+ "New sync failure taxonomy in `src/heart/sync-classification.ts`: `auth-failed`, `not-found-404`, `network-down`, `dirty-working-tree`, `non-fast-forward`, `merge-conflict`, `timeout-soft`, `timeout-hard`, `unknown` — extends `PendingSyncRecord.classification` additively (legacy `push_rejected`/`pull_rebase_conflict` still work). Pure pattern-matcher: priority order is abort → 404 → auth → network → dirty → conflict → non-fast-forward → unknown.",
9
+ "End-to-end `AbortSignal` plumbing. New `runWithTimeouts<T>` wrapper in `src/heart/timeouts.ts` (soft 8s warns, hard 15s aborts via `AbortController`); new async sibling `preTurnPullAsync` in `src/heart/sync.ts` that uses `child_process.execFile(..., { signal })` so the kernel kills the git child when the hard timeout fires. Original sync `preTurnPull` preserved for the per-turn pipeline. Two env knobs for the boot-sync probe: `OURO_BOOT_TIMEOUT_GIT_SOFT` (8000ms) and `OURO_BOOT_TIMEOUT_GIT_HARD` (15000ms).",
10
+ "New `runBootSyncProbe` orchestrator in `src/heart/daemon/boot-sync-probe.ts` aggregates per-bundle findings (each tagged `advisory: true|false`). Wired into `daemon.up` as a new \"sync probe\" boot phase between manual-clone-detection and provider checks. Failures during the probe itself are caught and surfaced as a warning event without blocking the boot. Tests inject `runBootSyncProbeImpl` to keep CI off the developer's home bundles.",
11
+ "9903 tests pass (518 files; +19 new). Coverage gate clean (cli-exec.ts 99.33% → 100%). Slow-remote integration test proves boot doesn't hang on a hung remote (probe aborts within `hardMs`). Unit 7 meta-test enforces no-state-writes invariant on the three new files."
12
+ ]
13
+ },
4
14
  {
5
15
  "version": "0.1.0-alpha.517",
6
16
  "changes": [
@@ -0,0 +1,197 @@
1
+ "use strict";
2
+ /**
3
+ * Boot sync probe — Layer 2 orchestrator for `ouro up`.
4
+ *
5
+ * For each sync-enabled bundle, runs `preTurnPullAsync` wrapped in
6
+ * `runWithTimeouts`, classifies failures via `classifySyncFailure`, and
7
+ * returns aggregated findings. Findings surface in the boot stdout
8
+ * summary written by `cli-exec.ts` after the probe phase.
9
+ *
10
+ * Layer 2 is intentionally a **boot-time preflight surface only** — it
11
+ * does NOT feed into the running daemon's rollup state. `computeDaemonRollup`
12
+ * is unaware of these findings; nothing is persisted to daemon health for
13
+ * the running daemon to consume. Layer 3 RepairGuide is the planned consumer
14
+ * (it reads the in-memory `BootSyncProbeFinding[]` at boot time and
15
+ * dispatches the right diagnostic skill).
16
+ *
17
+ * The probe NEVER:
18
+ * - Writes to `state/` (verified by Unit 7's grep gate).
19
+ * - Throws — every failure becomes a finding.
20
+ * - Hangs — `AbortSignal` from `runWithTimeouts` cuts hung remotes
21
+ * within `hardMs`.
22
+ *
23
+ * Advisory vs blocking classifications (consumed by layer 3, not by the
24
+ * layer 1 rollup — see comment above):
25
+ *
26
+ * | classification | advisory? | rationale |
27
+ * | ---------------------- | --------- | ------------------------------------------- |
28
+ * | auth-failed | no | agent can't sync; needs human intervention |
29
+ * | not-found-404 | no | remote is gone; needs human intervention |
30
+ * | network-down | no | agent can't reach the remote at all |
31
+ * | timeout-hard | no | abort cut the op; remote is hung |
32
+ * | dirty-working-tree | yes | local edits prevent merge; agent still runs |
33
+ * | non-fast-forward | yes | local commits ahead; agent still runs |
34
+ * | merge-conflict | yes | rebase failed; needs cleanup |
35
+ * | timeout-soft | yes | warning surfaced, op completed |
36
+ * | unknown | yes | unrecognised; surface for diagnosis |
37
+ *
38
+ * `advisory: true` is a hint for layer 3 ("warn-and-continue, agent likely
39
+ * still works") vs `advisory: false` ("blocking, agent can't sync until
40
+ * fixed"). Layer 3 will route the right diagnostic skill on this signal.
41
+ */
42
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
43
+ if (k2 === undefined) k2 = k;
44
+ var desc = Object.getOwnPropertyDescriptor(m, k);
45
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
46
+ desc = { enumerable: true, get: function() { return m[k]; } };
47
+ }
48
+ Object.defineProperty(o, k2, desc);
49
+ }) : (function(o, m, k, k2) {
50
+ if (k2 === undefined) k2 = k;
51
+ o[k2] = m[k];
52
+ }));
53
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
54
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
55
+ }) : function(o, v) {
56
+ o["default"] = v;
57
+ });
58
+ var __importStar = (this && this.__importStar) || (function () {
59
+ var ownKeys = function(o) {
60
+ ownKeys = Object.getOwnPropertyNames || function (o) {
61
+ var ar = [];
62
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
63
+ return ar;
64
+ };
65
+ return ownKeys(o);
66
+ };
67
+ return function (mod) {
68
+ if (mod && mod.__esModule) return mod;
69
+ var result = {};
70
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
71
+ __setModuleDefault(result, mod);
72
+ return result;
73
+ };
74
+ })();
75
+ Object.defineProperty(exports, "__esModule", { value: true });
76
+ exports.runBootSyncProbe = runBootSyncProbe;
77
+ const path = __importStar(require("path"));
78
+ const sync_1 = require("../sync");
79
+ const sync_classification_1 = require("../sync-classification");
80
+ const timeouts_1 = require("../timeouts");
81
+ const runtime_1 = require("../../nerves/runtime");
82
+ /** Default soft / hard timeout windows for the boot git op. Matches Layer 2 O1 lock. */
83
+ const DEFAULT_SOFT_MS = 8000;
84
+ const DEFAULT_HARD_MS = 15000;
85
+ const BLOCKING_CLASSIFICATIONS = new Set([
86
+ "auth-failed",
87
+ "not-found-404",
88
+ "network-down",
89
+ "timeout-hard",
90
+ ]);
91
+ function isAdvisory(classification) {
92
+ return !BLOCKING_CLASSIFICATIONS.has(classification);
93
+ }
94
+ /**
95
+ * Probe sync state for every enabled, git-initialised bundle. Returns
96
+ * findings for every non-clean result. Probes run sequentially (not in
97
+ * parallel) so the boot path's progress reporter has a stable per-agent
98
+ * narrative; the per-probe hard cap means worst-case total wait is
99
+ * `bundles.length * hardMs`, which is acceptable for typical 1-3 agent
100
+ * deployments.
101
+ */
102
+ async function runBootSyncProbe(bundles, options) {
103
+ const startedAt = Date.now();
104
+ const softMs = options.softMs ?? DEFAULT_SOFT_MS;
105
+ const hardMs = options.hardMs ?? DEFAULT_HARD_MS;
106
+ (0, runtime_1.emitNervesEvent)({
107
+ component: "daemon",
108
+ event: "daemon.boot_sync_probe_start",
109
+ message: "boot sync probe starting",
110
+ meta: { bundleCount: bundles.length, softMs, hardMs },
111
+ });
112
+ const findings = [];
113
+ for (const bundle of bundles) {
114
+ if (!bundle.enabled)
115
+ continue;
116
+ const agentRoot = path.join(options.bundlesRoot, `${bundle.agent}.ouro`);
117
+ // gitInitialized=false: bundle is enabled for sync but never had `git init`.
118
+ // Surface as advisory finding without invoking git.
119
+ if (bundle.gitInitialized === false) {
120
+ findings.push({
121
+ agent: bundle.agent,
122
+ classification: "unknown",
123
+ error: `bundle is not a git repo; run \`git init\` inside ${agentRoot} to enable sync (or disable sync in agent.json)`,
124
+ conflictFiles: [],
125
+ warnings: [],
126
+ advisory: true,
127
+ });
128
+ continue;
129
+ }
130
+ const syncConfig = { enabled: bundle.enabled, remote: bundle.remote };
131
+ const outcome = await (0, timeouts_1.runWithTimeouts)((signal) => (0, sync_1.preTurnPullAsync)(agentRoot, syncConfig, { signal }), { softMs, hardMs, label: `boot-sync-probe ${bundle.agent}`, envKey: "GIT" });
132
+ // Hard timeout — the probe was aborted. Synthesise a finding from the
133
+ // outcome's classification (the underlying `preTurnPullAsync` may also
134
+ // have rejected with an AbortError, but the wrapper already swallowed
135
+ // it).
136
+ if (outcome.classification === "timeout-hard") {
137
+ findings.push({
138
+ agent: bundle.agent,
139
+ classification: "timeout-hard",
140
+ error: `boot sync probe for ${bundle.agent} aborted after ${hardMs}ms hard timeout`,
141
+ conflictFiles: [],
142
+ warnings: outcome.warnings,
143
+ advisory: isAdvisory("timeout-hard"),
144
+ });
145
+ continue;
146
+ }
147
+ const result = outcome.result;
148
+ /* v8 ignore start -- defensive: exclusive state from runWithTimeouts contract — either result or classification, never both undefined @preserve */
149
+ if (!result) {
150
+ findings.push({
151
+ agent: bundle.agent,
152
+ classification: "unknown",
153
+ error: "boot sync probe returned without result or classification",
154
+ conflictFiles: [],
155
+ warnings: outcome.warnings,
156
+ advisory: true,
157
+ });
158
+ continue;
159
+ }
160
+ /* v8 ignore stop */
161
+ if (!result.ok) {
162
+ const classification = (0, sync_classification_1.classifySyncFailure)(new Error(result.error ?? "unknown sync error"), { agentRoot });
163
+ findings.push({
164
+ agent: bundle.agent,
165
+ classification: classification.classification,
166
+ error: classification.error,
167
+ conflictFiles: classification.conflictFiles,
168
+ warnings: outcome.warnings,
169
+ advisory: isAdvisory(classification.classification),
170
+ });
171
+ continue;
172
+ }
173
+ // Probe succeeded. If the soft warning fired, surface it as an advisory
174
+ // finding so the operator sees the slow-pull warning even on success.
175
+ if (outcome.warnings.length > 0) {
176
+ findings.push({
177
+ agent: bundle.agent,
178
+ classification: "timeout-soft",
179
+ error: outcome.warnings.join("; "),
180
+ conflictFiles: [],
181
+ warnings: outcome.warnings,
182
+ advisory: true,
183
+ });
184
+ }
185
+ }
186
+ // Stable order — sort by agent name so renderers and tests see the same
187
+ // sequence.
188
+ findings.sort((a, b) => a.agent.localeCompare(b.agent));
189
+ const durationMs = Date.now() - startedAt;
190
+ (0, runtime_1.emitNervesEvent)({
191
+ component: "daemon",
192
+ event: "daemon.boot_sync_probe_end",
193
+ message: "boot sync probe complete",
194
+ meta: { findingCount: findings.length, durationMs },
195
+ });
196
+ return { findings, durationMs };
197
+ }
@@ -42,6 +42,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
42
42
  exports.summarizeDaemonStartupFailure = summarizeDaemonStartupFailure;
43
43
  exports.writeDriftAdvisorySummary = writeDriftAdvisorySummary;
44
44
  exports.collectAgentDriftAdvisories = collectAgentDriftAdvisories;
45
+ exports.writeSyncProbeSummary = writeSyncProbeSummary;
46
+ exports.summarizeSyncProbeFindings = summarizeSyncProbeFindings;
45
47
  exports.mergeStartupStability = mergeStartupStability;
46
48
  exports.ensureDaemonRunning = ensureDaemonRunning;
47
49
  exports.listGithubCopilotModels = listGithubCopilotModels;
@@ -110,6 +112,7 @@ const up_progress_1 = require("./up-progress");
110
112
  const provider_ping_progress_1 = require("./provider-ping-progress");
111
113
  const provider_ping_1 = require("../provider-ping");
112
114
  const agent_discovery_1 = require("./agent-discovery");
115
+ const boot_sync_probe_1 = require("./boot-sync-probe");
113
116
  const connect_bay_1 = require("./connect-bay");
114
117
  const runtime_capability_check_1 = require("../runtime-capability-check");
115
118
  const vault_items_1 = require("./vault-items");
@@ -471,10 +474,44 @@ function providerRepairCountSummary(count) {
471
474
  return "selected providers answered live checks";
472
475
  return `${count} ${count === 1 ? "needs" : "need"} attention`;
473
476
  }
477
+ /**
478
+ * Layer 2: human-readable summary of boot-sync-probe findings, written to
479
+ * stdout when any non-clean finding surfaces. The order reads "blockers
480
+ * first, then advisories" so the operator's eye lands on the actionable
481
+ * items before the warnings.
482
+ */
483
+ function writeSyncProbeSummary(deps, findings) {
484
+ const lines = ["sync probe findings:"];
485
+ const sortKey = (f) => (f.advisory ? 1 : 0);
486
+ const sorted = [...findings].sort((a, b) => sortKey(a) - sortKey(b) || a.agent.localeCompare(b.agent));
487
+ for (const finding of sorted) {
488
+ const severity = finding.advisory ? "warn" : "block";
489
+ lines.push(` [${severity}] ${finding.agent}: ${finding.classification} — ${finding.error.split("\n")[0]}`);
490
+ }
491
+ deps.writeStdout(lines.join("\n"));
492
+ }
474
493
  function bootPhasePlan(daemonAlive) {
475
494
  return daemonAlive
476
- ? ["update check", "system setup", "starting daemon", "provider checks", "final daemon check"]
477
- : ["update check", "system setup", "provider checks", "starting daemon", "final daemon check"];
495
+ ? ["update check", "system setup", "sync probe", "starting daemon", "provider checks", "final daemon check"]
496
+ : ["update check", "system setup", "sync probe", "provider checks", "starting daemon", "final daemon check"];
497
+ }
498
+ /**
499
+ * Layer 2: brief, scannable summary of a boot-sync-probe finding for the
500
+ * progress reporter. Renderer-style hints (full repair guidance) live in
501
+ * `inner-status.ts` / `startup-tui.ts` consumers; this helper is just for
502
+ * the progress phase blurb.
503
+ */
504
+ function summarizeSyncProbeFindings(findings) {
505
+ if (findings.length === 0)
506
+ return "all sync-enabled bundles healthy";
507
+ const blocking = findings.filter((f) => !f.advisory).length;
508
+ const advisory = findings.filter((f) => f.advisory).length;
509
+ const parts = [];
510
+ if (blocking > 0)
511
+ parts.push(`${blocking} blocking`);
512
+ if (advisory > 0)
513
+ parts.push(`${advisory} advisory`);
514
+ return `${findings.length} finding${findings.length === 1 ? "" : "s"} (${parts.join(", ")})`;
478
515
  }
479
516
  function createHumanCommandProgress(deps, commandName) {
480
517
  return new up_progress_1.CommandProgress({
@@ -5707,6 +5744,38 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
5707
5744
  bundlesRoot: deps.bundlesRoot ?? bundlesRoot,
5708
5745
  promptInput: deps.promptInput,
5709
5746
  });
5747
+ // ── Layer 2 sync probe: pre-flight `git pull` for every sync-enabled bundle ──
5748
+ // Runs BEFORE provider checks so that any agent.json changes pulled from
5749
+ // the remote are visible to the live-check that follows. The probe has a
5750
+ // hard 15s per-agent timeout via `runWithTimeouts`, so a hung remote
5751
+ // can't stall the boot. `--no-repair` doesn't change probe behavior — it
5752
+ // gates layer 3's RepairGuide invocation, which lands in a separate PR.
5753
+ //
5754
+ // Errors in the probe path itself are caught and logged — they must not
5755
+ // break the boot, since the probe is best-effort visibility, not a gate.
5756
+ progress.startPhase("sync probe");
5757
+ try {
5758
+ const syncProbeImpl = deps.runBootSyncProbeImpl ?? boot_sync_probe_1.runBootSyncProbe;
5759
+ const syncRows = (0, agent_discovery_1.listBundleSyncRows)({ bundlesRoot: deps.bundlesRoot ?? bundlesRoot });
5760
+ const syncProbeResult = await syncProbeImpl(syncRows, {
5761
+ bundlesRoot: deps.bundlesRoot ?? bundlesRoot,
5762
+ });
5763
+ progress.completePhase("sync probe", summarizeSyncProbeFindings(syncProbeResult.findings));
5764
+ if (syncProbeResult.findings.length > 0) {
5765
+ writeSyncProbeSummary(deps, syncProbeResult.findings);
5766
+ }
5767
+ }
5768
+ catch (probeError) {
5769
+ const message = probeError instanceof Error ? probeError.message : String(probeError);
5770
+ (0, runtime_1.emitNervesEvent)({
5771
+ level: "warn",
5772
+ component: "daemon",
5773
+ event: "daemon.boot_sync_probe_failed",
5774
+ message: "boot sync probe failed; continuing boot",
5775
+ meta: { error: message },
5776
+ });
5777
+ progress.completePhase("sync probe", "skipped (probe error)");
5778
+ }
5710
5779
  const daemonAliveBeforeStart = await deps.checkSocketAlive(deps.socketPath);
5711
5780
  progress.setPhasePlan?.(bootPhasePlan(daemonAliveBeforeStart));
5712
5781
  let providerChecksAlreadyRun = false;
@@ -0,0 +1,176 @@
1
+ "use strict";
2
+ /**
3
+ * Sync failure taxonomy classifier — Layer 2 of the harness-hardening sequence.
4
+ *
5
+ * Pure pattern-matcher over (error, context) that turns common git failure
6
+ * shapes into the locked taxonomy variants. Used by `runBootSyncProbe` (the
7
+ * `ouro up` pre-flight pull orchestrator) and by `postTurnPush`'s legacy
8
+ * push-rejected/conflict path so both producers share one vocabulary.
9
+ *
10
+ * Pattern priority — most actionable / specific wins:
11
+ * 1. Abort signal (timeout-soft / timeout-hard) — caller explicitly aborted.
12
+ * 2. not-found-404 — 404 / "Repository not found": remote endpoint gone.
13
+ * 3. auth-failed — 401 / 403 / "Authentication failed".
14
+ * 4. network-down — ENOTFOUND / ECONNREFUSED / "Could not resolve host".
15
+ * 5. dirty-working-tree — "would be overwritten" / "stash them".
16
+ * 6. merge-conflict — CONFLICT marker in stderr (also collects file list).
17
+ * 7. non-fast-forward — "non-fast-forward" / "fetch first".
18
+ * 8. unknown — fallthrough.
19
+ *
20
+ * The classifier never throws and never writes to disk. It calls
21
+ * `git status --porcelain=v1` only when it has already classified the error
22
+ * as a merge conflict, to enumerate unmerged paths for the consumer.
23
+ */
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.classifySyncFailure = classifySyncFailure;
26
+ const child_process_1 = require("child_process");
27
+ /**
28
+ * Enumerate unmerged paths via `git status --porcelain=v1`. Pulled out as a
29
+ * pure helper so the classifier can be tested without a live git repo (the
30
+ * caller mocks `child_process.execFileSync`).
31
+ *
32
+ * Mirrors `sync.ts:collectRebaseConflictFiles` — kept as a separate copy here
33
+ * so this module has zero internal dep on `sync.ts`. The caller of
34
+ * `classifySyncFailure` doesn't see the duplication; the runtime cost is one
35
+ * git invocation per merge-conflict classification, same as before.
36
+ */
37
+ function collectConflictFiles(agentRoot) {
38
+ try {
39
+ const output = (0, child_process_1.execFileSync)("git", ["status", "--porcelain=v1"], {
40
+ cwd: agentRoot,
41
+ stdio: "pipe",
42
+ timeout: 5000,
43
+ }).toString();
44
+ const files = [];
45
+ for (const line of output.split("\n")) {
46
+ // Unmerged paths in porcelain v1 are prefixed with UU/AA/DD/AU/UA/DU/UD.
47
+ if (/^(UU|AA|DD|AU|UA|DU|UD) /.test(line)) {
48
+ files.push(line.slice(3).trim());
49
+ }
50
+ }
51
+ return files;
52
+ }
53
+ catch {
54
+ /* v8 ignore next -- defensive: git status failure inside a git repo would require a corrupt repo @preserve */
55
+ return [];
56
+ }
57
+ }
58
+ function isAbortError(error) {
59
+ if (typeof error !== "object" || error === null)
60
+ return false;
61
+ const candidate = error;
62
+ if (candidate.name === "AbortError")
63
+ return true;
64
+ if (candidate.code === "ABORT_ERR")
65
+ return true;
66
+ return false;
67
+ }
68
+ function readMessage(error) {
69
+ if (error instanceof Error)
70
+ return error.message;
71
+ if (typeof error === "string")
72
+ return error;
73
+ if (error === null || error === undefined)
74
+ return String(error);
75
+ try {
76
+ return JSON.stringify(error);
77
+ }
78
+ catch {
79
+ /* v8 ignore next -- defensive: JSON.stringify only fails on circular/BigInt; real-world git errors don't trigger it @preserve */
80
+ return String(error);
81
+ }
82
+ }
83
+ function readErrorCode(error) {
84
+ if (typeof error !== "object" || error === null)
85
+ return undefined;
86
+ const code = error.code;
87
+ return typeof code === "string" ? code : undefined;
88
+ }
89
+ /**
90
+ * Classify a sync failure into one of the locked taxonomy variants.
91
+ *
92
+ * Pure: never throws, never writes. Calls `git status` only when the error
93
+ * was already classified as a merge conflict, to enumerate unmerged files.
94
+ */
95
+ function classifySyncFailure(error, context) {
96
+ const message = readMessage(error);
97
+ const errorCode = readErrorCode(error);
98
+ // 1. Abort signal — highest priority. Caller's signal trumps content match.
99
+ if (isAbortError(error)) {
100
+ const reason = context.abortReason ?? "hard";
101
+ return {
102
+ classification: reason === "soft" ? "timeout-soft" : "timeout-hard",
103
+ error: message,
104
+ conflictFiles: [],
105
+ };
106
+ }
107
+ // Lowercased copy for case-insensitive substring matching.
108
+ const lower = message.toLowerCase();
109
+ // 2. Not-found-404 — most actionable diagnosis when both 404 and other
110
+ // signals are present. "404" and "Repository not found" are the canonical
111
+ // shapes.
112
+ if (lower.includes("404") || lower.includes("repository not found")) {
113
+ return {
114
+ classification: "not-found-404",
115
+ error: message,
116
+ conflictFiles: [],
117
+ };
118
+ }
119
+ // 3. Auth failed — 401 / 403 / "Authentication failed" / "Permission denied".
120
+ if (lower.includes("401")
121
+ || lower.includes("403")
122
+ || lower.includes("authentication failed")
123
+ || lower.includes("permission denied")) {
124
+ return {
125
+ classification: "auth-failed",
126
+ error: message,
127
+ conflictFiles: [],
128
+ };
129
+ }
130
+ // 4. Network down — DNS / connection errors.
131
+ if (errorCode === "ENOTFOUND"
132
+ || errorCode === "ECONNREFUSED"
133
+ || lower.includes("enotfound")
134
+ || lower.includes("econnrefused")
135
+ || lower.includes("could not resolve host")
136
+ || lower.includes("connection refused")) {
137
+ return {
138
+ classification: "network-down",
139
+ error: message,
140
+ conflictFiles: [],
141
+ };
142
+ }
143
+ // 5. Dirty working tree — pull / merge would clobber uncommitted changes.
144
+ if (lower.includes("would be overwritten")
145
+ || lower.includes("commit your changes or stash them")) {
146
+ return {
147
+ classification: "dirty-working-tree",
148
+ error: message,
149
+ conflictFiles: [],
150
+ };
151
+ }
152
+ // 6. Merge conflict — CONFLICT marker in stderr. Collect unmerged files.
153
+ if (lower.includes("conflict")) {
154
+ return {
155
+ classification: "merge-conflict",
156
+ error: message,
157
+ conflictFiles: collectConflictFiles(context.agentRoot),
158
+ };
159
+ }
160
+ // 7. Non-fast-forward — push rejected because remote moved.
161
+ if (lower.includes("non-fast-forward")
162
+ || lower.includes("fetch first")
163
+ || lower.includes("rejected")) {
164
+ return {
165
+ classification: "non-fast-forward",
166
+ error: message,
167
+ conflictFiles: [],
168
+ };
169
+ }
170
+ // 8. Fallthrough.
171
+ return {
172
+ classification: "unknown",
173
+ error: message,
174
+ conflictFiles: [],
175
+ };
176
+ }
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.preTurnPull = preTurnPull;
37
37
  exports.postTurnPush = postTurnPush;
38
+ exports.preTurnPullAsync = preTurnPullAsync;
38
39
  const child_process_1 = require("child_process");
39
40
  const fs = __importStar(require("fs"));
40
41
  const path = __importStar(require("path"));
@@ -330,3 +331,119 @@ function postTurnPush(agentRoot, config) {
330
331
  return { ok: false, error };
331
332
  }
332
333
  }
334
+ /**
335
+ * Layer 2 — async, signal-aware sibling of `preTurnPull`.
336
+ *
337
+ * Used by `runBootSyncProbe` (the `ouro up` boot orchestrator) to perform
338
+ * the pre-flight pull with end-to-end `AbortSignal` propagation. The
339
+ * underlying `child_process.execFile` accepts the signal and kills the git
340
+ * child process when it aborts, so a hung remote (DNS hole, slow server)
341
+ * can be cut by the boot timeout wrapper rather than hanging the whole
342
+ * boot.
343
+ *
344
+ * The legacy sync `preTurnPull` is preserved unchanged for the per-turn
345
+ * pipeline at `src/senses/pipeline.ts:522`. The two functions share the
346
+ * same `.git` and remote-availability gates — the only difference is the
347
+ * pull itself: `execFileSync` (no signal) vs `execFile` + `{ signal }`.
348
+ *
349
+ * Honour-the-signal contract:
350
+ * - If `options.signal` is already aborted at call time, the pull is
351
+ * skipped and the result is `{ ok: false, error: "aborted" }`.
352
+ * - If `options.signal` aborts mid-fetch, the child receives `SIGTERM`
353
+ * via Node's built-in AbortSignal handling, and the result is
354
+ * `{ ok: false, error: <abort message> }`.
355
+ * - With no signal supplied, behaviour matches the sync version (subject
356
+ * to the small differences listed above — same git-repo / no-remote
357
+ * gates and same nerves events).
358
+ */
359
+ function preTurnPullAsync(agentRoot, config, options = {}) {
360
+ (0, runtime_1.emitNervesEvent)({
361
+ component: "heart",
362
+ event: "heart.sync_pull_start",
363
+ message: "pre-turn pull starting (async)",
364
+ meta: { agentRoot, remote: config.remote },
365
+ });
366
+ // Bail early when the caller has already aborted — saves a git invocation
367
+ // and signals failure consistently.
368
+ if (options.signal?.aborted) {
369
+ (0, runtime_1.emitNervesEvent)({
370
+ level: "warn",
371
+ component: "heart",
372
+ event: "heart.sync_pull_aborted",
373
+ message: "pre-turn pull skipped: signal already aborted",
374
+ meta: { agentRoot },
375
+ });
376
+ return Promise.resolve({ ok: false, error: "aborted before pull started" });
377
+ }
378
+ // Same .git presence check as the sync version.
379
+ const repoCheck = ensureGitRepo(agentRoot);
380
+ if (!repoCheck.ok) {
381
+ (0, runtime_1.emitNervesEvent)({
382
+ level: "warn",
383
+ component: "heart",
384
+ event: "heart.sync_not_a_repo",
385
+ message: "pre-turn pull failed: bundle is not a git repo (async)",
386
+ meta: { agentRoot },
387
+ });
388
+ return Promise.resolve(repoCheck);
389
+ }
390
+ // Remote-presence check stays sync — it's a fast local op and doesn't
391
+ // need cancellation. The hangable op is the actual pull.
392
+ try {
393
+ const remoteOutput = (0, child_process_1.execFileSync)("git", ["remote"], {
394
+ cwd: agentRoot,
395
+ stdio: "pipe",
396
+ timeout: 5000,
397
+ }).toString().trim();
398
+ if (remoteOutput.length === 0) {
399
+ (0, runtime_1.emitNervesEvent)({
400
+ component: "heart",
401
+ event: "heart.sync_pull_end",
402
+ message: "pre-turn pull skipped: no remote configured (async)",
403
+ meta: { agentRoot },
404
+ });
405
+ return Promise.resolve({ ok: true });
406
+ }
407
+ }
408
+ catch (err) {
409
+ const error = err instanceof Error ? err.message : String(err);
410
+ (0, runtime_1.emitNervesEvent)({
411
+ component: "heart",
412
+ event: "heart.sync_pull_error",
413
+ message: "pre-turn pull failed: git remote check failed (async)",
414
+ meta: { agentRoot, error },
415
+ });
416
+ return Promise.resolve({ ok: false, error });
417
+ }
418
+ // The hangable op. `execFile` accepts `{ signal }` and kills the child
419
+ // when the signal aborts — that's the whole point of the async path.
420
+ const execOptions = {
421
+ cwd: agentRoot,
422
+ timeout: 30000,
423
+ };
424
+ if (options.signal) {
425
+ execOptions.signal = options.signal;
426
+ }
427
+ return new Promise((resolve) => {
428
+ (0, child_process_1.execFile)("git", ["pull", config.remote], execOptions, (err) => {
429
+ if (err) {
430
+ const error = err instanceof Error ? err.message : String(err);
431
+ (0, runtime_1.emitNervesEvent)({
432
+ component: "heart",
433
+ event: "heart.sync_pull_error",
434
+ message: "pre-turn pull failed (async)",
435
+ meta: { agentRoot, error },
436
+ });
437
+ resolve({ ok: false, error });
438
+ return;
439
+ }
440
+ (0, runtime_1.emitNervesEvent)({
441
+ component: "heart",
442
+ event: "heart.sync_pull_end",
443
+ message: "pre-turn pull complete (async)",
444
+ meta: { agentRoot },
445
+ });
446
+ resolve({ ok: true });
447
+ });
448
+ });
449
+ }
@@ -0,0 +1,101 @@
1
+ "use strict";
2
+ /**
3
+ * Soft/hard timeout pattern for boot-path operations — Layer 2.
4
+ *
5
+ * Soft timeout = "log a warning, keep going". The op continues; the consumer
6
+ * records the warning and moves on.
7
+ * Hard timeout = "abort the op via AbortSignal". The underlying op is
8
+ * expected to honour the signal (Node child_process accepts `{ signal }`,
9
+ * fetch accepts `{ signal }`, etc.). When the signal aborts, the op should
10
+ * reject with an AbortError, and the wrapper returns
11
+ * `{ classification: "timeout-hard" }` rather than re-throwing.
12
+ *
13
+ Two optional env overrides (currently only `GIT` is consumed):
14
+ * - `OURO_BOOT_TIMEOUT_GIT_SOFT` / `OURO_BOOT_TIMEOUT_GIT_HARD` — boot
15
+ * git operations (fetch / pull). Used when `envKey === "GIT"`.
16
+ *
17
+ * Env values are parsed as integer milliseconds. Non-numeric or non-positive
18
+ * values are ignored (the explicit `softMs` / `hardMs` defaults from the
19
+ * caller win in that case).
20
+ *
21
+ * Pattern guarantee: timers cleared on resolve / reject so the function
22
+ * holds no refs after settlement. Important because `ouro up` chains
23
+ * many of these and a leaking timer would block process exit.
24
+ */
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.runWithTimeouts = runWithTimeouts;
27
+ function readEnvMs(name) {
28
+ const raw = process.env[name];
29
+ if (raw === undefined || raw === null)
30
+ return null;
31
+ const parsed = Number.parseInt(raw, 10);
32
+ if (!Number.isFinite(parsed) || parsed <= 0)
33
+ return null;
34
+ return parsed;
35
+ }
36
+ function resolveTimeouts(options) {
37
+ let softMs = options.softMs;
38
+ let hardMs = options.hardMs;
39
+ if (options.envKey === "GIT") {
40
+ const envSoft = readEnvMs("OURO_BOOT_TIMEOUT_GIT_SOFT");
41
+ if (envSoft !== null)
42
+ softMs = envSoft;
43
+ const envHard = readEnvMs("OURO_BOOT_TIMEOUT_GIT_HARD");
44
+ if (envHard !== null)
45
+ hardMs = envHard;
46
+ }
47
+ return { softMs, hardMs };
48
+ }
49
+ /**
50
+ * Run `fn` with soft and hard timeouts.
51
+ *
52
+ * - Returns `{ result }` on success.
53
+ * - Returns `{ classification: "timeout-hard", warnings }` when aborted.
54
+ * - Returns `{ result, warnings: [...] }` when soft tripped but op completed.
55
+ * - Rejects when `fn` throws a non-abort error (callers can wrap with
56
+ * classifier).
57
+ */
58
+ async function runWithTimeouts(fn, options) {
59
+ const { softMs, hardMs } = resolveTimeouts(options);
60
+ const controller = new AbortController();
61
+ const warnings = [];
62
+ let softTimer = setTimeout(() => {
63
+ softTimer = null;
64
+ warnings.push(`${options.label}: soft timeout exceeded (${softMs}ms) — warning, continuing until hard cut`);
65
+ }, softMs);
66
+ let hardTimer = setTimeout(() => {
67
+ hardTimer = null;
68
+ controller.abort();
69
+ }, hardMs);
70
+ const cleanup = () => {
71
+ if (softTimer !== null) {
72
+ clearTimeout(softTimer);
73
+ softTimer = null;
74
+ }
75
+ if (hardTimer !== null) {
76
+ clearTimeout(hardTimer);
77
+ hardTimer = null;
78
+ }
79
+ };
80
+ try {
81
+ const result = await fn(controller.signal);
82
+ cleanup();
83
+ // If the abort fired and the op resolved gracefully (e.g., the inner
84
+ // function caught the AbortError and returned a structured result), we
85
+ // still classify the outcome as timeout-hard — the op was aborted from
86
+ // the caller's perspective even if no exception propagated. The caller
87
+ // can ignore the classification and use `result` if both are present.
88
+ if (controller.signal.aborted) {
89
+ return { classification: "timeout-hard", warnings };
90
+ }
91
+ return { result, warnings };
92
+ }
93
+ catch (err) {
94
+ cleanup();
95
+ if (controller.signal.aborted) {
96
+ // Hard timeout fired — abort wins over whatever error the op threw.
97
+ return { classification: "timeout-hard", warnings };
98
+ }
99
+ throw err;
100
+ }
101
+ }
@@ -149,6 +149,15 @@ const DISPATCH_EXEMPT_PATTERNS = [
149
149
  // Diagnostics-only utility; output is human-readable summary.
150
150
  "heart/session-stats-cli-main",
151
151
  "heart/session-stats",
152
+ // Layer 2 sync classifier: pure pattern-matcher mapping (error, context)
153
+ // to a SyncClassification. The orchestrator (boot-sync-probe.ts) owns
154
+ // observability via daemon.boot_sync_probe_start/end events; the
155
+ // post-turn push path (sync.ts) emits its own classification events.
156
+ "heart/sync-classification",
157
+ // Layer 2 timeout wrapper: pure soft/hard timeout abstraction over
158
+ // AbortController + setTimeout. Callers (boot-sync-probe.ts and any
159
+ // future consumer) own observability; the wrapper itself is mechanical.
160
+ "heart/timeouts",
152
161
  ];
153
162
  function isDispatchExempt(filePath) {
154
163
  return DISPATCH_EXEMPT_PATTERNS.some((pattern) => filePath.includes(pattern));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.517",
3
+ "version": "0.1.0-alpha.518",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",