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

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.
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "version": 2,
3
3
  "enabled": false,
4
+ "kind": "library",
4
5
  "humanFacing": { "provider": "anthropic", "model": "claude-opus-4-6" },
5
6
  "agentFacing": { "provider": "anthropic", "model": "claude-opus-4-6" },
6
7
  "context": {
package/changelog.json CHANGED
@@ -1,6 +1,29 @@
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.519",
6
+ "changes": [
7
+ "Layer 3 (final) of the harness-hardening sequence (1→4→2→3 from `docs/planning/2026-04-28-1900-planning-harness-hardening-and-repairguide.md`). Ships the `RepairGuide.ouro/` library bundle alongside `SerpentGuide.ouro/` containing five diagnostic skills (`diagnose-bootstrap-drift`, `diagnose-broken-remote`, `diagnose-sync-blocked`, `diagnose-vault-expired`, `diagnose-stacked-typed-issues`) plus `psyche/SOUL.md` + `psyche/IDENTITY.md`. Loaded by the existing `agentic-repair.ts` pipeline as a content source — not a runtime agent (no senses, no vault, no `ouro status` presence).",
8
+ "Introduces `kind: \"library\"` field on bundle `agent.json`. `agent-discovery.ts` filters bundles where `kind === \"library\"` so they're never instantiated as runtime agents. `SerpentGuide.ouro/agent.json` tagged with `kind: \"library\"` to formalize what was previously an implicit `enabled: false` convention.",
9
+ "Activation gate `shouldFireRepairGuide` consumes the existing `untypedDegraded` / `typedDegraded` partitioning at `cli-exec.ts:6693-6694`. Fires when `untypedDegraded.length > 0` OR `typedDegraded.length >= 3`. The existing `--no-repair` flag remains the operator escape hatch — no new env toggle.",
10
+ "Drops the `~/AgentBundles/SerpentGuide.ouro/` override fallback in `getSpecialistIdentitySourceDir` — the in-repo bundle is now the only source. Reasoning per the planning doc: drift surface we don't currently need; cleaner ownership; no override path to maintain. Five referencing files updated (`hatch-flow.ts`, `cli-defaults.ts`, plus their tests). Same constraint extends to RepairGuide from day one — no override mechanism.",
11
+ "`parseRepairProposals` typed parser maps RepairGuide's structured-proposal output into the existing `RepairAction` catalog from `readiness-repair.ts` (`vault-unlock`, `provider-auth`, `provider-use`, etc.). Backfills lane variants and missing fields where unambiguous; rejects malformed proposals.",
12
+ "Structured Layer-2 + Layer-4 findings threaded into the diagnostic prompt (post-review fix). `AgenticRepairDeps` gains optional `driftFindings: DriftFinding[]` and `syncFindings: BootSyncProbeFinding[]`. `cli-exec.ts` collects both at boot and passes them through to `runAgenticRepair`; `buildUserMessage` appends each as a JSON block when non-empty so the `diagnose-bootstrap-drift` / `diagnose-broken-remote` / `diagnose-sync-blocked` skills can reason over real structured shapes (`lane`, `intentProvider`, `intentModel`, `observedProvider`, `observedModel`, `repairCommand` for drift; `classification`, `conflictFiles`, `advisory`, etc. for sync). Synthetic test (`repair-guide-skill-types.test.ts`) asserts skill markdown references current TS field names so the contract can't silently drift.",
13
+ "Slugger-style compound integration fixture as canonical acceptance test (per O6): bad bootstrap state + expired creds + broken remote + drift between agent.json and state/providers.json simultaneously. Validates the full layer 1→4→2→3 pipeline end-to-end.",
14
+ "All gates green: tsc clean, lint clean, code coverage 100%, nerves audit pass."
15
+ ]
16
+ },
17
+ {
18
+ "version": "0.1.0-alpha.518",
19
+ "changes": [
20
+ "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).",
21
+ "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.",
22
+ "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).",
23
+ "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.",
24
+ "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."
25
+ ]
26
+ },
4
27
  {
5
28
  "version": "0.1.0-alpha.517",
6
29
  "changes": [
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.isLibraryKind = isLibraryKind;
36
37
  exports.listAllBundleAgents = listAllBundleAgents;
37
38
  exports.listEnabledBundleAgents = listEnabledBundleAgents;
38
39
  exports.listBundleSyncRows = listBundleSyncRows;
@@ -41,10 +42,18 @@ const path = __importStar(require("path"));
41
42
  const child_process_1 = require("child_process");
42
43
  const identity_1 = require("../identity");
43
44
  const runtime_1 = require("../../nerves/runtime");
45
+ /**
46
+ * True when the value is the string `"library"`. Library bundles are
47
+ * content-only resources — never run as agents, never appear in sync surfaces.
48
+ */
49
+ function isLibraryKind(kind) {
50
+ return kind === "library";
51
+ }
44
52
  /**
45
53
  * Walk the bundles root and return one row per `<name>.ouro` directory whose
46
54
  * `agent.json` is readable and parseable. Includes both enabled and disabled
47
- * agents the caller decides what to do with the `enabled` flag.
55
+ * agents AND library-kind bundles callers that need only real agents should
56
+ * use `listEnabledBundleAgents` (which filters both `enabled` and `kind`).
48
57
  *
49
58
  * Bundles whose `agent.json` is missing, malformed, or unreadable are skipped
50
59
  * silently (they aren't real agents from the harness's perspective).
@@ -76,22 +85,36 @@ function listAllBundleAgents(options = {}) {
76
85
  const agentName = entry.name.slice(0, -5);
77
86
  const configPath = path.join(bundlesRoot, entry.name, "agent.json");
78
87
  let enabled = true;
88
+ let kind;
79
89
  try {
80
90
  const raw = readFileSync(configPath, "utf-8");
81
91
  const parsed = JSON.parse(raw);
82
92
  if (typeof parsed.enabled === "boolean") {
83
93
  enabled = parsed.enabled;
84
94
  }
95
+ if (typeof parsed.kind === "string") {
96
+ kind = parsed.kind;
97
+ }
85
98
  }
86
99
  catch {
87
100
  continue;
88
101
  }
89
- discovered.push({ name: agentName, enabled });
102
+ const row = { name: agentName, enabled };
103
+ if (kind !== undefined)
104
+ row.kind = kind;
105
+ discovered.push(row);
90
106
  }
91
107
  return discovered.sort((left, right) => left.name.localeCompare(right.name));
92
108
  }
109
+ /**
110
+ * Real agents only — excludes both disabled bundles and library-kind bundles.
111
+ * Library bundles (SerpentGuide, RepairGuide, …) are content-only and must
112
+ * never appear in spawn lists, status rollups, or sync rows.
113
+ */
93
114
  function listEnabledBundleAgents(options = {}) {
94
- return listAllBundleAgents(options).filter((row) => row.enabled).map((row) => row.name);
115
+ return listAllBundleAgents(options)
116
+ .filter((row) => row.enabled && !isLibraryKind(row.kind))
117
+ .map((row) => row.name);
95
118
  }
96
119
  /**
97
120
  * Read the per-agent sync block from each enabled bundle's agent.json.
@@ -6,11 +6,50 @@
6
6
  * when no local repair was attempted and a working LLM provider is available.
7
7
  * This is a lightweight integration: one diagnostic LLM call, not a chat loop.
8
8
  */
9
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ var desc = Object.getOwnPropertyDescriptor(m, k);
12
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
13
+ desc = { enumerable: true, get: function() { return m[k]; } };
14
+ }
15
+ Object.defineProperty(o, k2, desc);
16
+ }) : (function(o, m, k, k2) {
17
+ if (k2 === undefined) k2 = k;
18
+ o[k2] = m[k];
19
+ }));
20
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
21
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
22
+ }) : function(o, v) {
23
+ o["default"] = v;
24
+ });
25
+ var __importStar = (this && this.__importStar) || (function () {
26
+ var ownKeys = function(o) {
27
+ ownKeys = Object.getOwnPropertyNames || function (o) {
28
+ var ar = [];
29
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
30
+ return ar;
31
+ };
32
+ return ownKeys(o);
33
+ };
34
+ return function (mod) {
35
+ if (mod && mod.__esModule) return mod;
36
+ var result = {};
37
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
38
+ __setModuleDefault(result, mod);
39
+ return result;
40
+ };
41
+ })();
9
42
  Object.defineProperty(exports, "__esModule", { value: true });
10
43
  exports.createAgenticDiagnosisProviderRuntime = createAgenticDiagnosisProviderRuntime;
11
44
  exports.runAgenticRepair = runAgenticRepair;
45
+ exports.shouldFireRepairGuide = shouldFireRepairGuide;
46
+ exports.loadRepairGuideContent = loadRepairGuideContent;
47
+ exports.parseRepairProposals = parseRepairProposals;
48
+ const fs = __importStar(require("fs"));
49
+ const path = __importStar(require("path"));
12
50
  const runtime_1 = require("../../nerves/runtime");
13
51
  const interactive_repair_1 = require("./interactive-repair");
52
+ const identity_1 = require("../identity");
14
53
  const provider_ping_1 = require("../provider-ping");
15
54
  const readiness_repair_1 = require("./readiness-repair");
16
55
  function buildSystemPrompt(degraded) {
@@ -29,20 +68,34 @@ function buildSystemPrompt(degraded) {
29
68
  "and the simplest fix the user can apply.",
30
69
  ].join("\n");
31
70
  }
32
- function buildUserMessage(degraded, logsTail) {
71
+ function buildUserMessage(degraded, logsTail, driftFindings = [], syncFindings = []) {
33
72
  const agentDetails = degraded
34
73
  .map((d) => `Agent: ${d.agent}\n Error: ${d.errorReason}\n Fix hint: ${d.fixHint}`)
35
74
  .join("\n\n");
36
- return [
75
+ const sections = [
37
76
  "Here are the degraded agents and recent daemon logs:",
38
77
  "",
39
78
  agentDetails,
40
79
  "",
41
80
  "Recent daemon logs:",
42
81
  logsTail,
43
- "",
44
- "What is the most likely cause and how should I fix it?",
45
- ].join("\n");
82
+ ];
83
+ // Layer 3: thread Layer 4's structured drift findings into the prompt as
84
+ // a JSON block. The `diagnose-bootstrap-drift` skill (loaded into the
85
+ // system prompt by `buildSystemPromptWithRepairGuide`) instructs the LLM
86
+ // how to read this shape and what `ouro use` repair to propose.
87
+ if (driftFindings.length > 0) {
88
+ sections.push("", "driftFindings (DriftFinding[]):", "```json", JSON.stringify(driftFindings, null, 2), "```");
89
+ }
90
+ // Layer 3: thread Layer 2's structured sync-probe findings into the
91
+ // prompt as a JSON block. `diagnose-broken-remote` (auth/404/network/
92
+ // timeout-hard) and `diagnose-sync-blocked` (dirty/non-fast-forward/
93
+ // merge-conflict/timeout-soft) consume this shape.
94
+ if (syncFindings.length > 0) {
95
+ sections.push("", "bootSyncFindings (BootSyncProbeFinding[]):", "```json", JSON.stringify(syncFindings, null, 2), "```");
96
+ }
97
+ sections.push("", "What is the most likely cause and how should I fix it?");
98
+ return sections.join("\n");
46
99
  }
47
100
  function makeInteractiveRepairDeps(deps) {
48
101
  return {
@@ -72,11 +125,40 @@ function createAgenticDiagnosisProviderRuntime(provider) {
72
125
  model: discoveredProviderModel(provider),
73
126
  });
74
127
  }
128
+ /**
129
+ * Layer 3: build the system prompt for the diagnostic call. Prepends
130
+ * RepairGuide bundle content (`psyche/SOUL.md`, `psyche/IDENTITY.md`, all
131
+ * skills) when the bundle is present and readable; falls back to today's
132
+ * pre-RepairGuide prompt when the loader returns null.
133
+ */
134
+ function buildSystemPromptWithRepairGuide(degraded, repairGuide) {
135
+ const basePrompt = buildSystemPrompt(degraded);
136
+ if (repairGuide === null)
137
+ return basePrompt;
138
+ const sections = [];
139
+ if (repairGuide.psyche.soul) {
140
+ sections.push("# RepairGuide SOUL\n\n" + repairGuide.psyche.soul);
141
+ }
142
+ if (repairGuide.psyche.identity) {
143
+ sections.push("# RepairGuide IDENTITY\n\n" + repairGuide.psyche.identity);
144
+ }
145
+ for (const [name, body] of Object.entries(repairGuide.skills)) {
146
+ sections.push(`# RepairGuide skill: ${name}\n\n${body}`);
147
+ }
148
+ if (sections.length === 0)
149
+ return basePrompt;
150
+ return [...sections, "---", basePrompt].join("\n\n");
151
+ }
75
152
  async function tryAgenticDiagnosis(degraded, provider, deps) {
76
153
  const logsTail = deps.readDaemonLogsTail();
77
154
  const runtime = deps.createProviderRuntime(provider);
78
- const systemPrompt = buildSystemPrompt(degraded);
79
- const userMessage = buildUserMessage(degraded, logsTail);
155
+ // Layer 3: prepend RepairGuide bundle content when present. Loader
156
+ // returns null on missing bundle / I/O error — caller silently falls back
157
+ // to today's pre-RepairGuide prompt.
158
+ const repoRoot = deps.repoRootOverride ?? (0, identity_1.getRepoRoot)();
159
+ const repairGuide = loadRepairGuideContent(repoRoot);
160
+ const systemPrompt = buildSystemPromptWithRepairGuide(degraded, repairGuide);
161
+ const userMessage = buildUserMessage(degraded, logsTail, deps.driftFindings, deps.syncFindings);
80
162
  const messages = [
81
163
  { role: "system", content: systemPrompt },
82
164
  { role: "user", content: userMessage },
@@ -98,11 +180,40 @@ async function tryAgenticDiagnosis(degraded, provider, deps) {
98
180
  callbacks: noopCallbacks,
99
181
  });
100
182
  if (result.content) {
101
- deps.writeStdout("");
102
- deps.writeStdout("--- AI Diagnosis ---");
103
- deps.writeStdout(result.content);
104
- deps.writeStdout("--- End Diagnosis ---");
105
- deps.writeStdout("");
183
+ // Layer 3: when RepairGuide content was prepended, parse the LLM output
184
+ // through the typed-action catalog. If actions are extracted, surface
185
+ // them as structured proposals; otherwise fall back to today's plain
186
+ // text-blob diagnosis. `parseRepairProposals` handles the unparseable
187
+ // case by returning `fallbackBlob` set to the raw output.
188
+ if (repairGuide !== null) {
189
+ const parsed = parseRepairProposals(result.content);
190
+ if (parsed.actions.length > 0) {
191
+ deps.writeStdout("");
192
+ deps.writeStdout("--- RepairGuide proposals ---");
193
+ for (const action of parsed.actions) {
194
+ deps.writeStdout(` • ${action.kind}: ${action.label}`);
195
+ }
196
+ for (const warning of parsed.warnings) {
197
+ deps.writeStdout(` (warning) ${warning}`);
198
+ }
199
+ deps.writeStdout("--- End RepairGuide proposals ---");
200
+ deps.writeStdout("");
201
+ }
202
+ else if (parsed.fallbackBlob !== undefined) {
203
+ deps.writeStdout("");
204
+ deps.writeStdout("--- AI Diagnosis ---");
205
+ deps.writeStdout(parsed.fallbackBlob);
206
+ deps.writeStdout("--- End Diagnosis ---");
207
+ deps.writeStdout("");
208
+ }
209
+ }
210
+ else {
211
+ deps.writeStdout("");
212
+ deps.writeStdout("--- AI Diagnosis ---");
213
+ deps.writeStdout(result.content);
214
+ deps.writeStdout("--- End Diagnosis ---");
215
+ deps.writeStdout("");
216
+ }
106
217
  }
107
218
  return true;
108
219
  }
@@ -119,16 +230,17 @@ async function runAgenticRepair(degraded, deps) {
119
230
  }
120
231
  const hasLocalRepair = degraded.some(interactive_repair_1.hasRunnableInteractiveRepair);
121
232
  const hasKnownTypedRepair = degraded.some((entry) => (0, readiness_repair_1.isKnownReadinessIssue)(entry.issue));
233
+ const forceDiagnosis = deps.forceDiagnosis === true;
122
234
  if (hasLocalRepair) {
123
235
  const interactiveResult = await runDeterministicRepair(degraded, deps);
124
236
  if (interactiveResult.repairsAttempted) {
125
237
  return { repairsAttempted: true, usedAgentic: false };
126
238
  }
127
- if (hasKnownTypedRepair) {
239
+ if (hasKnownTypedRepair && !forceDiagnosis) {
128
240
  return { repairsAttempted: false, usedAgentic: false };
129
241
  }
130
242
  }
131
- else if (hasKnownTypedRepair) {
243
+ else if (hasKnownTypedRepair && !forceDiagnosis) {
132
244
  return { repairsAttempted: false, usedAgentic: false };
133
245
  }
134
246
  // Try to discover a working provider for agentic diagnosis
@@ -214,3 +326,229 @@ async function runAgenticRepair(degraded, deps) {
214
326
  });
215
327
  return { repairsAttempted: interactiveResult.repairsAttempted, usedAgentic };
216
328
  }
329
+ // ──────────────────────────────────────────────────────────────────────
330
+ // Layer 3: RepairGuide activation contract
331
+ // ──────────────────────────────────────────────────────────────────────
332
+ /**
333
+ * Threshold for compound typed-degraded findings to activate RepairGuide.
334
+ * Set to 3 (not 2) so that common pairs — vault-locked + provider-auth-needed,
335
+ * for example — do NOT trigger the new path on every boot. Encoded once here;
336
+ * never duplicate at call sites.
337
+ */
338
+ const REPAIR_GUIDE_TYPED_THRESHOLD = 3;
339
+ /**
340
+ * Single decision function for whether to fire the RepairGuide-driven
341
+ * diagnostic flow.
342
+ *
343
+ * Contract (LOCKED, planning O4):
344
+ * - `noRepair: true` → false unconditionally (escape hatch).
345
+ * - `untypedDegraded.length > 0` → true (preserves today's gate at
346
+ * `cli-exec.ts:6706`).
347
+ * - `typedDegraded.length >= REPAIR_GUIDE_TYPED_THRESHOLD` → true (compound
348
+ * stack of typed issues; the new behavior this PR introduces).
349
+ * - Otherwise → false.
350
+ */
351
+ function shouldFireRepairGuide(input) {
352
+ if (input.noRepair)
353
+ return false;
354
+ if (input.untypedDegraded.length > 0)
355
+ return true;
356
+ if (input.typedDegraded.length >= REPAIR_GUIDE_TYPED_THRESHOLD)
357
+ return true;
358
+ return false;
359
+ }
360
+ /**
361
+ * Read `RepairGuide.ouro/{psyche,skills}/*.md` from the given repo root and
362
+ * return a structured shape suitable for prepending to a diagnostic LLM call.
363
+ *
364
+ * Behavior:
365
+ * - Returns `null` if `RepairGuide.ouro/` does not exist at all (graceful: caller
366
+ * should fall back to today's pre-RepairGuide pipeline).
367
+ * - Returns `null` on any I/O error (`readdirSync`/`readFileSync` throws).
368
+ * - Returns a populated `RepairGuideContent` when the bundle exists, even if
369
+ * psyche or skills are partially populated.
370
+ * - Skips files that are not `.md`, are not regular files, or have empty
371
+ * contents.
372
+ * - `skills` map is iterated in alphabetical order so callers can rely on
373
+ * deterministic prompt assembly.
374
+ *
375
+ * The loader is intentionally inline in `agentic-repair.ts` per the planning
376
+ * O5 lock — splitting into its own module is allowed only if the validator
377
+ * grows large.
378
+ */
379
+ function loadRepairGuideContent(repoRoot) {
380
+ const bundleRoot = path.join(repoRoot, "RepairGuide.ouro");
381
+ if (!fs.existsSync(bundleRoot))
382
+ return null;
383
+ try {
384
+ const psyche = {};
385
+ const skills = {};
386
+ const psycheDir = path.join(bundleRoot, "psyche");
387
+ if (fs.existsSync(psycheDir)) {
388
+ const psycheEntries = fs.readdirSync(psycheDir, { withFileTypes: true });
389
+ for (const entry of psycheEntries) {
390
+ if (!entry.isFile() || !entry.name.endsWith(".md"))
391
+ continue;
392
+ const content = fs.readFileSync(path.join(psycheDir, entry.name), "utf-8");
393
+ if (entry.name === "SOUL.md")
394
+ psyche.soul = content;
395
+ else if (entry.name === "IDENTITY.md")
396
+ psyche.identity = content;
397
+ }
398
+ }
399
+ const skillsDir = path.join(bundleRoot, "skills");
400
+ if (fs.existsSync(skillsDir)) {
401
+ const skillEntries = fs.readdirSync(skillsDir, { withFileTypes: true });
402
+ const sorted = skillEntries
403
+ .filter((e) => e.isFile() && e.name.endsWith(".md"))
404
+ .sort((a, b) => a.name.localeCompare(b.name));
405
+ for (const entry of sorted) {
406
+ const content = fs.readFileSync(path.join(skillsDir, entry.name), "utf-8");
407
+ if (content.length === 0)
408
+ continue;
409
+ skills[entry.name] = content;
410
+ }
411
+ }
412
+ return { psyche, skills };
413
+ }
414
+ catch {
415
+ // Best-effort: any I/O error in the bundle leads to null and the caller
416
+ // falls back to today's pre-RepairGuide diagnostic prompt.
417
+ return null;
418
+ }
419
+ }
420
+ // ──────────────────────────────────────────────────────────────────────
421
+ // Layer 3: RepairGuide LLM output parser → typed `RepairAction[]`
422
+ // ──────────────────────────────────────────────────────────────────────
423
+ /**
424
+ * The set of `RepairActionKind` literals the typed catalog recognizes.
425
+ * Encoded once here; the parser uses this for membership checks. Any other
426
+ * kind in the LLM output is dropped with a warning.
427
+ */
428
+ const KNOWN_REPAIR_ACTION_KINDS = new Set([
429
+ "vault-create",
430
+ "vault-unlock",
431
+ "vault-replace",
432
+ "vault-recover",
433
+ "provider-auth",
434
+ "provider-retry",
435
+ "provider-use",
436
+ ]);
437
+ function extractFirstJsonBlock(text) {
438
+ // Look for a triple-backtick fence labeled `json` and grab its body.
439
+ const fenceMatch = text.match(/```(?:json)?\s*\n([\s\S]*?)\n```/);
440
+ if (fenceMatch)
441
+ return fenceMatch[1];
442
+ // Fallback: the LLM may have emitted a bare JSON object (no fence).
443
+ const objectMatch = text.match(/\{[\s\S]*\}/);
444
+ if (objectMatch)
445
+ return objectMatch[0];
446
+ return null;
447
+ }
448
+ function isPlainObject(value) {
449
+ return typeof value === "object" && value !== null && !Array.isArray(value);
450
+ }
451
+ /**
452
+ * Parse a single LLM-emitted action object into a typed `RepairAction`. Returns
453
+ * `null` when the entry is shaped wrong — the caller logs a warning and drops
454
+ * it. The parser backfills `label`, `command`, and `actor` so the result plugs
455
+ * into the existing interactive-repair surface without further massaging.
456
+ */
457
+ function buildRepairAction(entry) {
458
+ const kind = entry.kind;
459
+ if (typeof kind !== "string")
460
+ return null;
461
+ if (!KNOWN_REPAIR_ACTION_KINDS.has(kind))
462
+ return null;
463
+ const reason = typeof entry.reason === "string" ? entry.reason : "(no reason given)";
464
+ const agent = typeof entry.agent === "string" ? entry.agent : "(unknown agent)";
465
+ const label = `RepairGuide: ${kind} for ${agent}`;
466
+ const command = `# ${kind} (${agent}): ${reason}`;
467
+ switch (kind) {
468
+ case "provider-auth": {
469
+ const provider = typeof entry.provider === "string" ? entry.provider : "anthropic";
470
+ return {
471
+ kind: "provider-auth",
472
+ label,
473
+ command,
474
+ actor: "human-required",
475
+ provider: provider,
476
+ };
477
+ }
478
+ case "provider-use": {
479
+ // `provider-use` may carry an optional `lane` — pass it through when
480
+ // present and validly typed (`outward` or `inner`).
481
+ const lane = entry.lane === "outward" || entry.lane === "inner" ? entry.lane : undefined;
482
+ const action = {
483
+ kind: "provider-use",
484
+ label,
485
+ command,
486
+ actor: "human-choice",
487
+ ...(lane ? { lane } : {}),
488
+ };
489
+ return action;
490
+ }
491
+ case "vault-create":
492
+ case "vault-unlock":
493
+ case "vault-replace":
494
+ case "vault-recover":
495
+ case "provider-retry": {
496
+ return {
497
+ kind: kind,
498
+ label,
499
+ command,
500
+ actor: "human-required",
501
+ };
502
+ }
503
+ /* v8 ignore next 4 -- exhaustiveness guard: unreachable since membership
504
+ * was checked above; left in place to satisfy `never` on future
505
+ * RepairActionKind extensions. @preserve */
506
+ default: {
507
+ return null;
508
+ }
509
+ }
510
+ }
511
+ /**
512
+ * Parse RepairGuide LLM output. The persona content (`SOUL.md`) instructs the
513
+ * model to emit exactly one ```json fenced block containing
514
+ * `{ actions: RepairAction[], notes?: string[] }`. The parser extracts that
515
+ * block, walks `actions[]`, drops entries with unknown kinds (with warnings),
516
+ * and falls back to the raw output when no JSON can be extracted at all
517
+ * (preserving today's text-blob behavior).
518
+ */
519
+ function parseRepairProposals(llmOutput) {
520
+ const block = extractFirstJsonBlock(llmOutput);
521
+ if (block === null) {
522
+ return { actions: [], warnings: [], fallbackBlob: llmOutput };
523
+ }
524
+ let parsed;
525
+ try {
526
+ parsed = JSON.parse(block);
527
+ }
528
+ catch {
529
+ return { actions: [], warnings: [], fallbackBlob: llmOutput };
530
+ }
531
+ if (!isPlainObject(parsed)) {
532
+ return { actions: [], warnings: [] };
533
+ }
534
+ const rawActions = parsed.actions;
535
+ if (!Array.isArray(rawActions)) {
536
+ return { actions: [], warnings: [] };
537
+ }
538
+ const actions = [];
539
+ const warnings = [];
540
+ for (const entry of rawActions) {
541
+ if (!isPlainObject(entry)) {
542
+ warnings.push(`dropped non-object entry from actions[]: ${JSON.stringify(entry)}`);
543
+ continue;
544
+ }
545
+ const built = buildRepairAction(entry);
546
+ if (built === null) {
547
+ const kindLabel = typeof entry.kind === "string" ? entry.kind : "(no kind)";
548
+ warnings.push(`dropped action with unknown or missing kind: ${kindLabel}`);
549
+ continue;
550
+ }
551
+ actions.push(built);
552
+ }
553
+ return { actions, warnings };
554
+ }