thoth-agents 0.2.9 → 0.2.11

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.
@@ -10,6 +10,7 @@ import {
10
10
  DEFAULT_THOTH_COMMAND,
11
11
  GREP_APP_MCP_URL,
12
12
  appendPromptSections,
13
+ checkCustomSkillsNeedUpdate,
13
14
  claudeCodeSubagentType,
14
15
  composeAgentPrompt,
15
16
  createModelFamilySection,
@@ -17,9 +18,7 @@ import {
17
18
  createReadOnlySpecialistPromptSections,
18
19
  createStepBudgetSection,
19
20
  createWriteCapableSpecialistPromptSections,
20
- disableDefaultAgents,
21
21
  ensureConfigDir,
22
- ensureOpenCodeConfigDir,
23
22
  exa,
24
23
  findPackageRoot,
25
24
  generateLiteConfig,
@@ -35,9 +34,10 @@ import {
35
34
  parseConfig,
36
35
  renderPromptSection,
37
36
  renderRolePrompt,
37
+ updateOpenCodeMainConfig,
38
38
  writeConfig,
39
39
  writeLiteConfig
40
- } from "./chunk-YLRPDTU7.js";
40
+ } from "./chunk-OZE7JVGS.js";
41
41
 
42
42
  // src/cli/codex-paths.ts
43
43
  import { homedir } from "os";
@@ -611,26 +611,31 @@ var CODEX_SURFACES = [
611
611
  id: "per-agent-runtime-permissions",
612
612
  target: "permission-control",
613
613
  status: "unsupported",
614
- fields: ["per-agent tool allow/deny equivalent to OpenCode permissions"],
614
+ fields: [
615
+ "named installed-role selection",
616
+ "per-role permission enforcement",
617
+ "per-agent tool allow/deny equivalent to OpenCode permissions"
618
+ ],
615
619
  diagnosticCode: "codex.permission.memory.enforcement_gap",
616
- summary: "Codex sandbox and approval policy can constrain sessions, but OpenCode-style per-agent MCP/tool deny maps are not validated.",
620
+ summary: "Named installed-role selection and per-role permission enforcement are instruction-only because collaboration.spawn_agent has no role selector; OpenCode-style per-agent MCP/tool deny maps are not validated.",
617
621
  evidence: "Codex approvals/security docs cover sandbox and approval policies, not exact OpenCode per-agent permission maps.",
618
622
  fallback: "instruction-only"
619
623
  },
620
624
  {
621
625
  id: "programmatic-delegation-runtime",
622
626
  target: "delegation-runtime",
623
- status: "unsupported",
627
+ status: "validated",
624
628
  fields: [
625
- "task API",
626
- "background task sessions",
627
- "tmux lifecycle hooks",
628
- "automatic subagent session close"
629
+ "collaboration.spawn_agent",
630
+ "collaboration.wait_agent",
631
+ "collaboration.list_agents",
632
+ "collaboration.send_message",
633
+ "collaboration.followup_task",
634
+ "collaboration.interrupt_agent"
629
635
  ],
630
- diagnosticCode: "codex.delegation.runtime.unsupported",
631
- summary: "Codex subagents are user/instruction-triggered; no OpenCode plugin task API or automatic subagent session close parity is validated.",
632
- evidence: "Codex subagents docs describe manual spawning and agent threads, not this plugin runtime task API or a validated runtime close hook.",
633
- fallback: "instruction-only"
636
+ diagnosticCode: "codex.delegation.runtime.validated",
637
+ summary: "The current Codex collaboration surface supports generic programmatic delegation, mailbox waits, live task-tree inspection, messages, follow-up turns, and explicit interruption.",
638
+ evidence: "The callable runtime exposes collaboration.spawn_agent with task_name, message, and optional fork_turns plus wait_agent, list_agents, send_message, followup_task, and interrupt_agent."
634
639
  },
635
640
  {
636
641
  id: "parent-context-injection",
@@ -1432,15 +1437,18 @@ function renderCodexRolePrompt(roleName, config, model) {
1432
1437
  function codexInternalHandoffGuidance() {
1433
1438
  return [
1434
1439
  "<codex-delegation-guidance>",
1435
- "- The user has explicitly authorized this generated Codex orchestrator to use `multi_agent_v1.spawn_agent` whenever delegation is required by these instructions, without needing a fresh user request for subagents in each task.",
1436
- "- Delegate by calling `multi_agent_v1.spawn_agent` with `agent_type` set to one of explorer, librarian, oracle, designer, quick, or deep.",
1440
+ "- The user has explicitly authorized this generated Codex orchestrator to use `collaboration.spawn_agent` whenever delegation is required by these instructions, without needing a fresh user request for subagents in each task.",
1441
+ "- Collaboration tools are direct tools and must not be called from inside `functions.exec`.",
1442
+ "- Delegate with `collaboration.spawn_agent` using `task_name`, `message`, and optional `fork_turns`; `fork_turns` must be `none`, `all`, or a positive integer string.",
1443
+ "- Role behavior is carried by `task_name` and `message`; named installed-role selection and hard role/profile enforcement are instruction-level because this surface has no role selector.",
1437
1444
  "- Pass the self-contained delegated task instructions plus handoff retrieval instructions in `message`; do not embed the root-owned handoff summary body in `message`.",
1438
1445
  '- When memory recovery is delegated, include parent `session_id`, project, permissions, and the recovery funnel `mem_recall(mode="compact")` -> `mem_recall(mode="context")` -> `mem_get(...)`.',
1439
1446
  '- For that funnel, use `mem_recall` `limit` from 1 to 20; use `mem_get` with `kind="observation"|"prompt"`, `include_timeline=true` plus `before`/`after`, and `offset`/`max_length`; `mem_project(action="graph")` relations are `HAS_TYPE`, `IN_PROJECT`, `HAS_TOPIC_KEY`, `HAS_WHAT`, `HAS_WHY`, `HAS_WHERE`, and `HAS_LEARNED`.',
1440
- "- Do not include the handoff body in `message` or `items`, and do not pass both `message` and `items` for the same handoff.",
1441
- "- Use `items` only for structured attachments or mentions when they are truly required; do not use `items` as a handoff-summary payload.",
1442
- "- Leave `fork_context` omitted or false by default; set `fork_context: true` only when the exact current thread history is required.",
1443
- "- Use `multi_agent_v1.wait_agent` only when the root needs the result, `multi_agent_v1.send_input` for follow-up or redirect, `multi_agent_v1.resume_agent` only for a closed agent that must continue, and `multi_agent_v1.close_agent` after completion.",
1447
+ "- Do not include the handoff body in `message`.",
1448
+ "- `collaboration.wait_agent` waits for mailbox updates. A `collaboration.wait_agent` timeout or silence is nonterminal and remains in progress, so inspect `collaboration.list_agents` with the same task path before deciding what to do next.",
1449
+ "- `collaboration.send_message` delivers a message without triggering a turn; `collaboration.followup_task` triggers a turn when an idle task must continue.",
1450
+ "- Use `collaboration.interrupt_agent` only for explicit cancellation, a deadline, or supersession.",
1451
+ "- Do not invent result payloads or numeric wait, poll, or timeout rules.",
1444
1452
  "- Memory ownership, handoff recovery, permissions, and prompt-body exclusion are instruction-level unless the active Codex runtime documents stronger enforcement.",
1445
1453
  "</codex-delegation-guidance>"
1446
1454
  ].join("\n");
@@ -1488,8 +1496,7 @@ function renderCodexRootInstructions(config) {
1488
1496
  "- If metadata tooling is unavailable, or the required fields are missing, disclose that memory bootstrap could not run and continue without claiming memory was saved.",
1489
1497
  '- Before delegating after meaningful context changes, save or refresh the handoff body with root-owned mem_session(action="summary") or mem_save(kind="session_summary") when available; if unavailable, disclose that root-owned compaction could not be persisted.',
1490
1498
  "- Use the ambient Codex root session as the delegate-first root coordinator; do not generate or select an orchestrator TOML.",
1491
- "- Delegate by invoking `multi_agent_v1.spawn_agent` for the installed Codex role agents: explorer, librarian, oracle, designer, quick, and deep.",
1492
- "- After receiving a delegated subagent response, close that subagent session unless you will retry or intentionally keep using that exact same session; explorer and librarian sessions must always be closed immediately after their response, and retry sessions must be closed after the retry result unless explicit same-session reuse is still required.",
1499
+ "- Delegate by invoking `collaboration.spawn_agent`; use role-prefixed task names and explicit role instructions for the installed Codex role agents: explorer, librarian, oracle, designer, quick, and deep.",
1493
1500
  "- Use packaged thoth-agents plugin capabilities through Codex plugin, skill, MCP, and hook review surfaces after enabling them with /plugins and /hooks.",
1494
1501
  "- For blocking user decisions in Codex Default mode, use request_user_input after features.default_mode_request_user_input is enabled; do not ask those questions in plain prose.",
1495
1502
  "- Whenever the root orchestrator calls `request_user_input`, it MUST NEVER set or pass `autoResolutionMs`; omit the field entirely.",
@@ -4173,6 +4180,7 @@ function applyCodexPlan(plan) {
4173
4180
  }
4174
4181
 
4175
4182
  // src/cli/operations/opencode.ts
4183
+ import { createHash as createHash3 } from "crypto";
4176
4184
  import { existsSync as existsSync10 } from "fs";
4177
4185
  import { join as join10 } from "path";
4178
4186
 
@@ -4285,6 +4293,143 @@ var PACKAGE_NAME = "thoth-agents";
4285
4293
  var EXPECTED_PLUGIN = `${PACKAGE_NAME}@latest`;
4286
4294
  var OPENAI_PRESET = "openai";
4287
4295
  var ROLE_NAMES = [...ALL_AGENT_NAMES];
4296
+ var issuedOpenCodePlans = /* @__PURE__ */ new WeakMap();
4297
+ var NonCanonicalDataError = class extends Error {
4298
+ };
4299
+ function rejectSerializationHook(value, prototype) {
4300
+ if (Object.getOwnPropertyDescriptor(value, "toJSON")) {
4301
+ throw new NonCanonicalDataError("Own toJSON hooks are not canonical data.");
4302
+ }
4303
+ let current = prototype;
4304
+ while (current) {
4305
+ if (Object.getOwnPropertyDescriptor(current, "toJSON")) {
4306
+ throw new NonCanonicalDataError(
4307
+ "Inherited toJSON hooks are not canonical data."
4308
+ );
4309
+ }
4310
+ current = Object.getPrototypeOf(current);
4311
+ }
4312
+ }
4313
+ function canonicalData(value, active) {
4314
+ if (value === null) return "null";
4315
+ if (typeof value === "string") return JSON.stringify(value);
4316
+ if (typeof value === "boolean") return value ? "true" : "false";
4317
+ if (typeof value === "number") {
4318
+ if (!Number.isFinite(value)) {
4319
+ throw new NonCanonicalDataError("Non-finite numbers are not canonical.");
4320
+ }
4321
+ return JSON.stringify(value);
4322
+ }
4323
+ if (typeof value !== "object") {
4324
+ throw new NonCanonicalDataError("Only JSON-like data is canonical.");
4325
+ }
4326
+ if (active.has(value)) {
4327
+ throw new NonCanonicalDataError("Cycles are not canonical.");
4328
+ }
4329
+ const prototype = Object.getPrototypeOf(value);
4330
+ const array = Array.isArray(value);
4331
+ if (array && prototype !== Array.prototype || !array && prototype !== Object.prototype && prototype !== null) {
4332
+ throw new NonCanonicalDataError("Non-plain prototypes are not canonical.");
4333
+ }
4334
+ rejectSerializationHook(value, prototype);
4335
+ const ownKeys = Reflect.ownKeys(value);
4336
+ if (ownKeys.some((key) => typeof key === "symbol")) {
4337
+ throw new NonCanonicalDataError("Symbol keys are not canonical.");
4338
+ }
4339
+ const keys = ownKeys;
4340
+ active.add(value);
4341
+ try {
4342
+ if (array) {
4343
+ const lengthDescriptor = Object.getOwnPropertyDescriptor(value, "length");
4344
+ if (!lengthDescriptor || !("value" in lengthDescriptor)) {
4345
+ throw new NonCanonicalDataError("Array length must be data.");
4346
+ }
4347
+ const length = lengthDescriptor.value;
4348
+ if (!Number.isSafeInteger(length) || length < 0) {
4349
+ throw new NonCanonicalDataError("Array length is invalid.");
4350
+ }
4351
+ const indexKeys = keys.filter((key) => key !== "length");
4352
+ if (indexKeys.length !== length || indexKeys.some(
4353
+ (key) => !/^(0|[1-9]\d*)$/.test(key) || Number(key) >= length || String(Number(key)) !== key
4354
+ )) {
4355
+ throw new NonCanonicalDataError(
4356
+ "Sparse arrays and extra array properties are not canonical."
4357
+ );
4358
+ }
4359
+ const parts2 = [];
4360
+ for (let index = 0; index < length; index += 1) {
4361
+ const descriptor = Object.getOwnPropertyDescriptor(
4362
+ value,
4363
+ String(index)
4364
+ );
4365
+ if (!descriptor || !("value" in descriptor)) {
4366
+ throw new NonCanonicalDataError("Array accessors are not canonical.");
4367
+ }
4368
+ parts2.push(canonicalData(descriptor.value, active));
4369
+ }
4370
+ return `[${parts2.join(",")}]`;
4371
+ }
4372
+ const parts = [];
4373
+ for (const key of keys.sort()) {
4374
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
4375
+ if (!descriptor || !("value" in descriptor)) {
4376
+ throw new NonCanonicalDataError("Accessors are not canonical.");
4377
+ }
4378
+ parts.push(
4379
+ `${JSON.stringify(key)}:${canonicalData(descriptor.value, active)}`
4380
+ );
4381
+ }
4382
+ return `{${parts.join(",")}}`;
4383
+ } finally {
4384
+ active.delete(value);
4385
+ }
4386
+ }
4387
+ function canonicalDigest(value) {
4388
+ try {
4389
+ const serialized = canonicalData(value, /* @__PURE__ */ new Set());
4390
+ return {
4391
+ ok: true,
4392
+ digest: createHash3("sha256").update(serialized).digest("hex")
4393
+ };
4394
+ } catch {
4395
+ return { ok: false };
4396
+ }
4397
+ }
4398
+ function liveStatusDigest(status) {
4399
+ return canonicalDigest({
4400
+ state: status.state,
4401
+ diagnostics: status.diagnostics,
4402
+ targets: status.targets
4403
+ });
4404
+ }
4405
+ function immutableContext(context2) {
4406
+ return Object.freeze({
4407
+ cwd: context2.cwd,
4408
+ ...context2.env ? { env: Object.freeze({ ...context2.env }) } : {}
4409
+ });
4410
+ }
4411
+ function issueOpenCodePlan(plan, status, context2, payload) {
4412
+ const planDigest = canonicalDigest(plan);
4413
+ const statusDigest = liveStatusDigest(status);
4414
+ if (!planDigest.ok || !planDigest.digest || !statusDigest.ok || !statusDigest.digest) {
4415
+ return plan;
4416
+ }
4417
+ const immutablePayload = payload.kind === "fixed" ? Object.freeze({ kind: "fixed", action: payload.action }) : Object.freeze({
4418
+ kind: "model",
4419
+ roles: Object.freeze(
4420
+ payload.roles.map((role) => Object.freeze({ ...role }))
4421
+ )
4422
+ });
4423
+ issuedOpenCodePlans.set(plan, {
4424
+ planDigest: planDigest.digest,
4425
+ liveStatusDigest: statusDigest.digest,
4426
+ harness: "opencode",
4427
+ action: payload.kind === "model" ? "model-config" : payload.action,
4428
+ context: immutableContext(context2),
4429
+ payload: immutablePayload
4430
+ });
4431
+ return plan;
4432
+ }
4288
4433
  var openCodeActions = [
4289
4434
  {
4290
4435
  id: "opencode-status",
@@ -4353,7 +4498,7 @@ function targetForMainConfig(state) {
4353
4498
  kind: "config",
4354
4499
  path: getExistingConfigPath(),
4355
4500
  label: "OpenCode config",
4356
- state,
4501
+ ...state ? { state } : {},
4357
4502
  expected: `plugin includes ${EXPECTED_PLUGIN}`
4358
4503
  };
4359
4504
  }
@@ -4362,7 +4507,7 @@ function targetForLiteConfig(state) {
4362
4507
  kind: "config",
4363
4508
  path: getExistingLiteConfigPath(),
4364
4509
  label: "thoth-agents config",
4365
- state,
4510
+ ...state ? { state } : {},
4366
4511
  expected: `seven-agent ${OPENAI_PRESET} roster`
4367
4512
  };
4368
4513
  }
@@ -4377,7 +4522,7 @@ function titleCaseSkillName(value) {
4377
4522
  }).join("-");
4378
4523
  }
4379
4524
  function homeDirFromContext(context2) {
4380
- return context2.env?.HOME ?? context2.env?.USERPROFILE;
4525
+ return context2.env?.HOME ?? context2.env?.USERPROFILE ?? (context2.cwd !== process.cwd() ? join10(context2.cwd, "home") : void 0);
4381
4526
  }
4382
4527
  function openCodeSkillTargets(context2) {
4383
4528
  const homeDir = homeDirFromContext(context2);
@@ -4395,16 +4540,38 @@ function openCodeSkillTargets(context2) {
4395
4540
  };
4396
4541
  }
4397
4542
  );
4543
+ let staleSkillNames = /* @__PURE__ */ new Set();
4544
+ let removedSkillNames = [];
4545
+ let skillHealthError;
4546
+ try {
4547
+ const updateCheck = checkCustomSkillsNeedUpdate();
4548
+ staleSkillNames = new Set(
4549
+ updateCheck.skillsNeedingUpdate.map(({ skill }) => skill.name)
4550
+ );
4551
+ removedSkillNames = updateCheck.removedSkills;
4552
+ } catch (error) {
4553
+ skillHealthError = String(error);
4554
+ }
4398
4555
  const bundledTargets = CUSTOM_SKILLS.map((skill) => {
4399
4556
  const path4 = join10(getCustomSkillsDir(), skill.name, "SKILL.md");
4400
4557
  const installed = existsSync10(path4);
4558
+ const stale = installed && staleSkillNames.has(skill.name);
4559
+ let state = "installed";
4560
+ let observed = "bundled OpenCode skill installed and current";
4561
+ if (!installed) {
4562
+ state = "missing";
4563
+ observed = "bundled OpenCode skill missing";
4564
+ } else if (stale) {
4565
+ state = "drift";
4566
+ observed = "bundled OpenCode skill is outdated";
4567
+ }
4401
4568
  return {
4402
4569
  kind: "skill",
4403
4570
  path: path4,
4404
4571
  label: titleCaseSkillName(skill.name),
4405
- state: installed ? "installed" : "missing",
4572
+ state,
4406
4573
  expected: "bundled thoth-agents OpenCode skill",
4407
- observed: installed ? "bundled OpenCode skill installed" : "bundled OpenCode skill missing"
4574
+ observed
4408
4575
  };
4409
4576
  });
4410
4577
  const diagnostics = [];
@@ -4422,6 +4589,20 @@ function openCodeSkillTargets(context2) {
4422
4589
  code: "opencode-bundled-skills-missing"
4423
4590
  });
4424
4591
  }
4592
+ if (bundledTargets.some((target) => target.state === "drift") || removedSkillNames.length > 0) {
4593
+ diagnostics.push({
4594
+ severity: "important",
4595
+ message: "Bundled thoth-agents OpenCode skills are outdated; run OpenCode install or sync to refresh managed skills.",
4596
+ code: "opencode-bundled-skills-outdated"
4597
+ });
4598
+ }
4599
+ if (skillHealthError) {
4600
+ diagnostics.push({
4601
+ severity: "critical",
4602
+ message: `Bundled skill health could not be classified: ${skillHealthError}`,
4603
+ code: "opencode-bundled-skills-health-unknown"
4604
+ });
4605
+ }
4425
4606
  return {
4426
4607
  targets: [...recommendedTargets, ...bundledTargets],
4427
4608
  diagnostics
@@ -4443,8 +4624,11 @@ function liteConfigMarker(config) {
4443
4624
  const preset = typeof config?.preset === "string" ? config.preset : void 0;
4444
4625
  const presets = config?.presets && typeof config.presets === "object" ? config.presets : {};
4445
4626
  const openaiPreset = presets[OPENAI_PRESET] && typeof presets[OPENAI_PRESET] === "object" ? presets[OPENAI_PRESET] : {};
4446
- const roles = ROLE_NAMES.filter((role) => role in openaiPreset);
4447
- return `preset: ${preset ?? "none"}; ${OPENAI_PRESET} roles: ${roles.join(", ")}`;
4627
+ const legacyAgents = config?.agents && typeof config.agents === "object" ? config.agents : {};
4628
+ const roles = ROLE_NAMES.filter(
4629
+ (role) => role in openaiPreset || role in legacyAgents
4630
+ );
4631
+ return `preset: ${preset ?? "none"}; observed roles: ${roles.join(", ")}`;
4448
4632
  }
4449
4633
  function hasSevenAgentPreset(config) {
4450
4634
  if (!config || config.preset !== OPENAI_PRESET) return false;
@@ -4458,8 +4642,122 @@ function hasSevenAgentPreset(config) {
4458
4642
  return value !== null && typeof value === "object" && typeof value.model === "string";
4459
4643
  });
4460
4644
  }
4461
- function classifyApplySafety(state) {
4462
- return state === "missing" || state === "installed";
4645
+ function hasSelectedNamedPreset(config) {
4646
+ if (!config || typeof config.preset !== "string" || config.preset.length === 0 || config.preset === OPENAI_PRESET || config.preset === "agents") {
4647
+ return false;
4648
+ }
4649
+ if (!config.presets || typeof config.presets !== "object" || Array.isArray(config.presets)) {
4650
+ return false;
4651
+ }
4652
+ const presets = config.presets;
4653
+ if (!Object.hasOwn(presets, config.preset)) return false;
4654
+ const selectedPreset = presets[config.preset];
4655
+ return selectedPreset !== null && typeof selectedPreset === "object" && !Array.isArray(selectedPreset);
4656
+ }
4657
+ function hasLegacySevenAgentRoster(config) {
4658
+ if (!config || config.preset !== "agents") return false;
4659
+ if (!config.agents || typeof config.agents !== "object") return false;
4660
+ const agents = config.agents;
4661
+ return ROLE_NAMES.every((role) => {
4662
+ const value = agents[role];
4663
+ return value !== null && typeof value === "object" && typeof value.model === "string";
4664
+ });
4665
+ }
4666
+ var ACTIVE_PRESET_SELECTED_CODE = "opencode-active-preset-selected";
4667
+ function activePresetSelectedDiagnostic() {
4668
+ return {
4669
+ severity: "important",
4670
+ message: "A valid named preset is active; model configuration preserves it while sync or install may repair the managed roster.",
4671
+ code: ACTIVE_PRESET_SELECTED_CODE
4672
+ };
4673
+ }
4674
+ var REPAIRABLE_DIAGNOSTIC_CODES = /* @__PURE__ */ new Set([
4675
+ "opencode-plugin-drift",
4676
+ "opencode-main-config-missing",
4677
+ "opencode-lite-config-missing",
4678
+ "opencode-roster-drift",
4679
+ ACTIVE_PRESET_SELECTED_CODE,
4680
+ "opencode-bundled-skills-missing",
4681
+ "opencode-bundled-skills-outdated"
4682
+ ]);
4683
+ var MODEL_PRECONFIG_SKILL_CODES = /* @__PURE__ */ new Set([
4684
+ "opencode-bundled-skills-missing",
4685
+ "opencode-bundled-skills-outdated"
4686
+ ]);
4687
+ function uniqueTargets(targets) {
4688
+ const seen = /* @__PURE__ */ new Set();
4689
+ return targets.filter((target) => {
4690
+ const key = JSON.stringify([
4691
+ target.kind,
4692
+ target.path,
4693
+ target.label,
4694
+ target.state,
4695
+ target.expected,
4696
+ target.observed
4697
+ ]);
4698
+ if (seen.has(key)) return false;
4699
+ seen.add(key);
4700
+ return true;
4701
+ });
4702
+ }
4703
+ function targetsForBlockingDiagnostic(diagnostic, status) {
4704
+ const main = status.targets.find(
4705
+ (target) => target.label === "OpenCode config"
4706
+ );
4707
+ const lite = status.targets.find(
4708
+ (target) => target.label === "thoth-agents config"
4709
+ );
4710
+ const code = diagnostic.code;
4711
+ if (code === "opencode-plugin-drift" || code === "opencode-main-config-missing" || code === "opencode-config-parse-error" || code === "opencode-unmanaged-config") {
4712
+ return main ? [main] : [];
4713
+ }
4714
+ if (code === "opencode-roster-drift" || code === ACTIVE_PRESET_SELECTED_CODE || code === "opencode-lite-config-missing" || code === "thoth-config-parse-error" || code === "opencode-roster-unrecognized") {
4715
+ return lite ? [lite] : [];
4716
+ }
4717
+ if (code === "opencode-bundled-skills-missing" || code === "opencode-bundled-skills-outdated" || code === "opencode-bundled-skills-health-unknown") {
4718
+ return status.targets.filter(
4719
+ (target) => target.expected === "bundled thoth-agents OpenCode skill" && (code === "opencode-bundled-skills-health-unknown" || target.state !== "installed")
4720
+ );
4721
+ }
4722
+ return [
4723
+ {
4724
+ kind: "unknown",
4725
+ label: "Unknown OpenCode blocker",
4726
+ state: "unknown",
4727
+ observed: code ? `${code}: ${diagnostic.message}` : diagnostic.message
4728
+ }
4729
+ ];
4730
+ }
4731
+ function blockingDetails(action, status, includeRepairable = false) {
4732
+ const main = status.targets.find(
4733
+ (target) => target.label === "OpenCode config"
4734
+ );
4735
+ const lite = status.targets.find(
4736
+ (target) => target.label === "thoth-agents config"
4737
+ );
4738
+ const allowModelPreconfiguration = action === "model-config" && main?.state === "missing" && lite?.state === "missing";
4739
+ const diagnostics = status.diagnostics.filter((diagnostic) => {
4740
+ if (diagnostic.severity === "critical") return true;
4741
+ if (diagnostic.severity === "minor") return false;
4742
+ if (!includeRepairable && action === "model-config" && diagnostic.code === ACTIVE_PRESET_SELECTED_CODE) {
4743
+ return false;
4744
+ }
4745
+ if (!includeRepairable && allowModelPreconfiguration && diagnostic.code !== void 0 && MODEL_PRECONFIG_SKILL_CODES.has(diagnostic.code)) {
4746
+ return false;
4747
+ }
4748
+ return includeRepairable || !((action === "sync" || action === "install") && diagnostic.code !== void 0 && REPAIRABLE_DIAGNOSTIC_CODES.has(diagnostic.code));
4749
+ });
4750
+ return {
4751
+ diagnostics,
4752
+ targets: uniqueTargets(
4753
+ diagnostics.flatMap(
4754
+ (diagnostic) => targetsForBlockingDiagnostic(diagnostic, status)
4755
+ )
4756
+ )
4757
+ };
4758
+ }
4759
+ function canApplyToManagedHealth(action, status) {
4760
+ return blockingDetails(action, status).diagnostics.length === 0;
4463
4761
  }
4464
4762
  function getOpenCodeStatus(context2 = { cwd: process.cwd() }) {
4465
4763
  const mainPath = getExistingConfigPath();
@@ -4515,6 +4813,18 @@ function getOpenCodeStatus(context2 = { cwd: process.cwd() }) {
4515
4813
  };
4516
4814
  if (!mainExists || !hasExpectedPlugin(main.config)) {
4517
4815
  if (hasManagedPluginDrift(main.config)) {
4816
+ const currentRoster = hasSevenAgentPreset(lite.config);
4817
+ const legacyRoster = hasLegacySevenAgentRoster(lite.config);
4818
+ const selectedNamedPreset = hasSelectedNamedPreset(lite.config);
4819
+ const recognizedRoster = currentRoster || legacyRoster || selectedNamedPreset;
4820
+ let liteState = "unknown";
4821
+ if (!liteExists) {
4822
+ liteState = "missing";
4823
+ } else if (currentRoster) {
4824
+ liteState = "installed";
4825
+ } else if (legacyRoster || selectedNamedPreset) {
4826
+ liteState = "drift";
4827
+ }
4518
4828
  return {
4519
4829
  harness: "opencode",
4520
4830
  displayName: "OpenCode",
@@ -4522,7 +4832,10 @@ function getOpenCodeStatus(context2 = { cwd: process.cwd() }) {
4522
4832
  summary: "OpenCode config has a managed thoth-agents plugin entry that is not latest.",
4523
4833
  targets: [
4524
4834
  { ...mainTarget, state: "drift" },
4525
- { ...liteTarget, state: liteExists ? "installed" : "missing" },
4835
+ {
4836
+ ...liteTarget,
4837
+ state: liteState
4838
+ },
4526
4839
  ...skillStatus.targets
4527
4840
  ],
4528
4841
  diagnostics: [
@@ -4531,6 +4844,132 @@ function getOpenCodeStatus(context2 = { cwd: process.cwd() }) {
4531
4844
  message: "Managed plugin drift requires an explicit repair before applying sync plans.",
4532
4845
  code: "opencode-plugin-drift"
4533
4846
  },
4847
+ ...legacyRoster ? [
4848
+ {
4849
+ severity: "important",
4850
+ message: "Roster drift requires repair before applying generated plans.",
4851
+ code: "opencode-roster-drift"
4852
+ }
4853
+ ] : [],
4854
+ ...selectedNamedPreset && !currentRoster ? [activePresetSelectedDiagnostic()] : [],
4855
+ ...!liteExists ? [
4856
+ {
4857
+ severity: "important",
4858
+ message: "The managed thoth-agents config is missing and must be restored by install or sync.",
4859
+ code: "opencode-lite-config-missing"
4860
+ }
4861
+ ] : [],
4862
+ ...!liteExists ? [
4863
+ {
4864
+ severity: "important",
4865
+ message: "The managed thoth-agents config is missing and must be restored by install or sync.",
4866
+ code: "opencode-lite-config-missing"
4867
+ }
4868
+ ] : [],
4869
+ ...liteExists && !recognizedRoster ? [
4870
+ {
4871
+ severity: "critical",
4872
+ message: "Existing thoth-agents config has an unrecognized roster shape.",
4873
+ code: "opencode-roster-unrecognized"
4874
+ }
4875
+ ] : [],
4876
+ ...skillStatus.diagnostics
4877
+ ],
4878
+ actions: openCodeActions
4879
+ };
4880
+ }
4881
+ if (!mainExists && liteExists) {
4882
+ const currentRoster = hasSevenAgentPreset(lite.config);
4883
+ const legacyRoster = hasLegacySevenAgentRoster(lite.config);
4884
+ const selectedNamedPreset = hasSelectedNamedPreset(lite.config);
4885
+ if (!currentRoster && !legacyRoster && !selectedNamedPreset) {
4886
+ return {
4887
+ harness: "opencode",
4888
+ displayName: "OpenCode",
4889
+ state: "unknown",
4890
+ summary: "Existing thoth-agents config has an unrecognized roster shape.",
4891
+ targets: [
4892
+ { ...mainTarget, state: "missing" },
4893
+ { ...liteTarget, state: "unknown" },
4894
+ ...skillStatus.targets
4895
+ ],
4896
+ diagnostics: [
4897
+ {
4898
+ severity: "critical",
4899
+ message: "Existing thoth-agents config is not a recognized current or legacy managed roster.",
4900
+ code: "opencode-roster-unrecognized"
4901
+ },
4902
+ ...skillStatus.diagnostics
4903
+ ],
4904
+ actions: openCodeActions
4905
+ };
4906
+ }
4907
+ if (selectedNamedPreset && !currentRoster) {
4908
+ return {
4909
+ harness: "opencode",
4910
+ displayName: "OpenCode",
4911
+ state: "drift",
4912
+ summary: "OpenCode plugin is missing and a valid named preset is active.",
4913
+ targets: [
4914
+ { ...mainTarget, state: "missing" },
4915
+ { ...liteTarget, state: "drift" },
4916
+ ...skillStatus.targets
4917
+ ],
4918
+ diagnostics: [
4919
+ {
4920
+ severity: "important",
4921
+ message: "The managed OpenCode main config is missing and must be restored by install or sync.",
4922
+ code: "opencode-main-config-missing"
4923
+ },
4924
+ activePresetSelectedDiagnostic(),
4925
+ ...skillStatus.diagnostics
4926
+ ],
4927
+ actions: openCodeActions
4928
+ };
4929
+ }
4930
+ if (legacyRoster) {
4931
+ return {
4932
+ harness: "opencode",
4933
+ displayName: "OpenCode",
4934
+ state: "drift",
4935
+ summary: "OpenCode plugin is missing and the managed roster uses the recognized legacy layout.",
4936
+ targets: [
4937
+ { ...mainTarget, state: "missing" },
4938
+ { ...liteTarget, state: "drift" },
4939
+ ...skillStatus.targets
4940
+ ],
4941
+ diagnostics: [
4942
+ {
4943
+ severity: "important",
4944
+ message: "The managed OpenCode main config is missing and must be restored by install or sync.",
4945
+ code: "opencode-main-config-missing"
4946
+ },
4947
+ {
4948
+ severity: "important",
4949
+ message: "Roster drift requires repair before applying generated plans.",
4950
+ code: "opencode-roster-drift"
4951
+ },
4952
+ ...skillStatus.diagnostics
4953
+ ],
4954
+ actions: openCodeActions
4955
+ };
4956
+ }
4957
+ return {
4958
+ harness: "opencode",
4959
+ displayName: "OpenCode",
4960
+ state: "missing",
4961
+ summary: "The managed roster is present, but the OpenCode main config is missing.",
4962
+ targets: [
4963
+ { ...mainTarget, state: "missing" },
4964
+ { ...liteTarget, state: "installed" },
4965
+ ...skillStatus.targets
4966
+ ],
4967
+ diagnostics: [
4968
+ {
4969
+ severity: "important",
4970
+ message: "The managed OpenCode main config is missing and must be restored by install or sync.",
4971
+ code: "opencode-main-config-missing"
4972
+ },
4534
4973
  ...skillStatus.diagnostics
4535
4974
  ],
4536
4975
  actions: openCodeActions
@@ -4546,7 +4985,17 @@ function getOpenCodeStatus(context2 = { cwd: process.cwd() }) {
4546
4985
  { ...liteTarget, state: liteExists ? "installed" : "missing" },
4547
4986
  ...skillStatus.targets
4548
4987
  ],
4549
- diagnostics: [...diagnostics, ...skillStatus.diagnostics],
4988
+ diagnostics: [
4989
+ ...diagnostics,
4990
+ ...mainExists ? [
4991
+ {
4992
+ severity: "critical",
4993
+ message: "Existing OpenCode config is not positively attributed to thoth-agents management.",
4994
+ code: "opencode-unmanaged-config"
4995
+ }
4996
+ ] : [],
4997
+ ...skillStatus.diagnostics
4998
+ ],
4550
4999
  actions: openCodeActions
4551
5000
  };
4552
5001
  }
@@ -4561,23 +5010,32 @@ function getOpenCodeStatus(context2 = { cwd: process.cwd() }) {
4561
5010
  { ...liteTarget, state: "missing" },
4562
5011
  ...skillStatus.targets
4563
5012
  ],
4564
- diagnostics: [...diagnostics, ...skillStatus.diagnostics],
5013
+ diagnostics: [
5014
+ ...diagnostics,
5015
+ {
5016
+ severity: "important",
5017
+ message: "The managed thoth-agents config is missing and must be restored by install or sync.",
5018
+ code: "opencode-lite-config-missing"
5019
+ },
5020
+ ...skillStatus.diagnostics
5021
+ ],
4565
5022
  actions: openCodeActions
4566
5023
  };
4567
5024
  }
4568
5025
  if (!hasSevenAgentPreset(lite.config)) {
5026
+ const selectedNamedPreset = hasSelectedNamedPreset(lite.config);
4569
5027
  return {
4570
5028
  harness: "opencode",
4571
5029
  displayName: "OpenCode",
4572
5030
  state: "drift",
4573
- summary: "thoth-agents config does not match the expected seven-agent roster.",
5031
+ summary: selectedNamedPreset ? "A valid named OpenCode preset is active outside the managed seven-agent roster." : "thoth-agents config does not match the expected seven-agent roster.",
4574
5032
  targets: [
4575
5033
  { ...mainTarget, state: "installed" },
4576
5034
  { ...liteTarget, state: "drift" },
4577
5035
  ...skillStatus.targets
4578
5036
  ],
4579
5037
  diagnostics: [
4580
- {
5038
+ selectedNamedPreset ? activePresetSelectedDiagnostic() : {
4581
5039
  severity: "important",
4582
5040
  message: "Roster drift requires repair before applying generated plans.",
4583
5041
  code: "opencode-roster-drift"
@@ -4608,6 +5066,37 @@ function defaultBackup(path4) {
4608
5066
  destinations: [{ path: `${path4}.bak`, label: "managed backup" }]
4609
5067
  };
4610
5068
  }
5069
+ function backupFromItems(items) {
5070
+ const backups = items.flatMap((item) => item.backup ? [item.backup] : []);
5071
+ const primary = backups.find((backup) => backup.required) ?? backups[0];
5072
+ const destinations = [];
5073
+ const seenPaths = /* @__PURE__ */ new Set();
5074
+ for (const backup of backups) {
5075
+ for (const destination of backup.destinations ?? []) {
5076
+ if (seenPaths.has(destination.path)) continue;
5077
+ seenPaths.add(destination.path);
5078
+ destinations.push(destination);
5079
+ }
5080
+ }
5081
+ return {
5082
+ required: backups.some((backup) => backup.required),
5083
+ strategy: primary?.strategy ?? "none",
5084
+ ...destinations.length > 0 ? { destinations } : {},
5085
+ ...primary?.description ? { description: primary.description } : {}
5086
+ };
5087
+ }
5088
+ function bundledSkillsRefreshItem() {
5089
+ const names = CUSTOM_SKILLS.map(({ name }) => name);
5090
+ return {
5091
+ title: "Refresh bundled thoth-agents OpenCode skills",
5092
+ target: {
5093
+ kind: "skill",
5094
+ label: "Bundled thoth-agents OpenCode skills",
5095
+ expected: `${names.length} managed bundled skills`
5096
+ },
5097
+ preview: JSON.stringify({ count: names.length, names }, null, 2)
5098
+ };
5099
+ }
4611
5100
  function defaultDisclaimers() {
4612
5101
  return [
4613
5102
  {
@@ -4616,9 +5105,10 @@ function defaultDisclaimers() {
4616
5105
  }
4617
5106
  ];
4618
5107
  }
4619
- function planFromItems(id, action, title, summary, items) {
4620
- const status = getOpenCodeStatus();
4621
- const safe = classifyApplySafety(status.state);
5108
+ function planFromItems(id, action, title, summary, items, context2) {
5109
+ const status = getOpenCodeStatus(context2);
5110
+ const blockers = blockingDetails(action, status);
5111
+ const safe = blockers.diagnostics.length === 0;
4622
5112
  const warnings = safe ? status.diagnostics : [
4623
5113
  ...status.diagnostics,
4624
5114
  {
@@ -4628,37 +5118,41 @@ function planFromItems(id, action, title, summary, items) {
4628
5118
  }
4629
5119
  ];
4630
5120
  return {
4631
- id,
4632
- harness: "opencode",
4633
- action,
4634
- title,
4635
- summary,
4636
- dryRun: true,
4637
- canApply: safe,
4638
- targets: status.targets,
4639
- surfaces: [
4640
- {
4641
- id: "opencode-config",
4642
- label: "OpenCode user config",
4643
- path: getExistingConfigPath(),
4644
- state: status.targets[0]?.state
4645
- },
4646
- {
4647
- id: "thoth-agents-config",
4648
- label: "thoth-agents plugin config",
4649
- path: getExistingLiteConfigPath(),
4650
- state: status.targets[1]?.state
4651
- }
4652
- ],
4653
- backup: defaultBackup(getExistingConfigPath()),
4654
- items,
4655
- warnings,
4656
- disclaimers: defaultDisclaimers()
5121
+ status,
5122
+ plan: {
5123
+ id,
5124
+ harness: "opencode",
5125
+ action,
5126
+ title,
5127
+ summary,
5128
+ dryRun: true,
5129
+ canApply: safe,
5130
+ targets: status.targets,
5131
+ blockerTargets: blockers.targets,
5132
+ surfaces: [
5133
+ {
5134
+ id: "opencode-config",
5135
+ label: "OpenCode user config",
5136
+ path: getExistingConfigPath(),
5137
+ state: status.targets[0]?.state
5138
+ },
5139
+ {
5140
+ id: "thoth-agents-config",
5141
+ label: "thoth-agents plugin config",
5142
+ path: getExistingLiteConfigPath(),
5143
+ state: status.targets[1]?.state
5144
+ }
5145
+ ],
5146
+ backup: backupFromItems(items),
5147
+ items,
5148
+ warnings,
5149
+ disclaimers: defaultDisclaimers()
5150
+ }
4657
5151
  };
4658
5152
  }
4659
5153
  function buildOpenCodeUpdatePlan(_context = { cwd: process.cwd() }) {
4660
5154
  const path4 = getExistingConfigPath();
4661
- return planFromItems(
5155
+ const { plan, status } = planFromItems(
4662
5156
  "opencode-update-preview",
4663
5157
  "update",
4664
5158
  "Update OpenCode managed plugin entry",
@@ -4667,12 +5161,17 @@ function buildOpenCodeUpdatePlan(_context = { cwd: process.cwd() }) {
4667
5161
  {
4668
5162
  title: "Ensure OpenCode plugin points at thoth-agents@latest",
4669
5163
  target: targetForMainConfig(),
4670
- state: getOpenCodeStatus().state,
5164
+ state: getOpenCodeStatus(_context).state,
4671
5165
  preview: `plugin: ["${EXPECTED_PLUGIN}"]`,
4672
5166
  backup: defaultBackup(path4)
4673
5167
  }
4674
- ]
5168
+ ],
5169
+ _context
4675
5170
  );
5171
+ return issueOpenCodePlan(plan, status, _context, {
5172
+ kind: "fixed",
5173
+ action: "update"
5174
+ });
4676
5175
  }
4677
5176
  function buildOpenCodeSyncPlan(_context = { cwd: process.cwd() }) {
4678
5177
  const generatedConfig = generateLiteConfig({
@@ -4684,7 +5183,7 @@ function buildOpenCodeSyncPlan(_context = { cwd: process.cwd() }) {
4684
5183
  reset: false
4685
5184
  });
4686
5185
  const litePath = getExistingLiteConfigPath();
4687
- return planFromItems(
5186
+ const { plan, status } = planFromItems(
4688
5187
  "opencode-sync-preview",
4689
5188
  "sync",
4690
5189
  "Sync OpenCode managed configuration",
@@ -4693,7 +5192,7 @@ function buildOpenCodeSyncPlan(_context = { cwd: process.cwd() }) {
4693
5192
  {
4694
5193
  title: "Ensure OpenCode plugin points at thoth-agents@latest",
4695
5194
  target: targetForMainConfig(),
4696
- state: getOpenCodeStatus().state,
5195
+ state: getOpenCodeStatus(_context).state,
4697
5196
  preview: `plugin: ["${EXPECTED_PLUGIN}"]`,
4698
5197
  backup: defaultBackup(getExistingConfigPath())
4699
5198
  },
@@ -4708,9 +5207,15 @@ function buildOpenCodeSyncPlan(_context = { cwd: process.cwd() }) {
4708
5207
  target: targetForLiteConfig(),
4709
5208
  preview: JSON.stringify(generatedConfig, null, 2),
4710
5209
  backup: defaultBackup(litePath)
4711
- }
4712
- ]
5210
+ },
5211
+ bundledSkillsRefreshItem()
5212
+ ],
5213
+ _context
4713
5214
  );
5215
+ return issueOpenCodePlan(plan, status, _context, {
5216
+ kind: "fixed",
5217
+ action: "sync"
5218
+ });
4714
5219
  }
4715
5220
  function tuiInstallConfig() {
4716
5221
  return {
@@ -4732,7 +5237,7 @@ function buildOpenCodeInstallPlan(_context = { cwd: process.cwd() }) {
4732
5237
  equivalentCommand: "install --agent=opencode --no-tui --tmux=no --skills=yes"
4733
5238
  };
4734
5239
  const litePath = getExistingLiteConfigPath();
4735
- return planFromItems(
5240
+ const { plan, status } = planFromItems(
4736
5241
  "opencode-install-preview",
4737
5242
  "install",
4738
5243
  "Preview install",
@@ -4744,7 +5249,7 @@ function buildOpenCodeInstallPlan(_context = { cwd: process.cwd() }) {
4744
5249
  kind: "config",
4745
5250
  path: getExistingConfigPath(),
4746
5251
  label: "OpenCode install options",
4747
- state: getOpenCodeStatus().state,
5252
+ state: getOpenCodeStatus(_context).state,
4748
5253
  expected: "--no-tui --tmux=no --skills=yes"
4749
5254
  },
4750
5255
  preview: JSON.stringify(installPreview, null, 2)
@@ -4752,7 +5257,7 @@ function buildOpenCodeInstallPlan(_context = { cwd: process.cwd() }) {
4752
5257
  {
4753
5258
  title: "Ensure OpenCode plugin points at thoth-agents@latest",
4754
5259
  target: targetForMainConfig(),
4755
- state: getOpenCodeStatus().state,
5260
+ state: getOpenCodeStatus(_context).state,
4756
5261
  preview: `plugin: ["${EXPECTED_PLUGIN}"]`,
4757
5262
  backup: defaultBackup(getExistingConfigPath())
4758
5263
  },
@@ -4768,6 +5273,7 @@ function buildOpenCodeInstallPlan(_context = { cwd: process.cwd() }) {
4768
5273
  preview: JSON.stringify(generatedConfig, null, 2),
4769
5274
  backup: defaultBackup(litePath)
4770
5275
  },
5276
+ bundledSkillsRefreshItem(),
4771
5277
  {
4772
5278
  title: "Install recommended external skills",
4773
5279
  target: {
@@ -4777,8 +5283,13 @@ function buildOpenCodeInstallPlan(_context = { cwd: process.cwd() }) {
4777
5283
  },
4778
5284
  preview: JSON.stringify({ installSkills: true }, null, 2)
4779
5285
  }
4780
- ]
5286
+ ],
5287
+ _context
4781
5288
  );
5289
+ return issueOpenCodePlan(plan, status, _context, {
5290
+ kind: "fixed",
5291
+ action: "install"
5292
+ });
4782
5293
  }
4783
5294
  function normalizeRoleModel(input) {
4784
5295
  if (input.provider && !input.model.includes("/")) {
@@ -4787,16 +5298,24 @@ function normalizeRoleModel(input) {
4787
5298
  return input.model;
4788
5299
  }
4789
5300
  function buildOpenCodeModelPlan(input, _context = { cwd: process.cwd() }) {
4790
- const items = input.roles.map((role) => {
5301
+ const normalizedRoles = input.roles.map((role) => {
4791
5302
  const model = normalizeRoleModel(role);
4792
5303
  const effort = resolveOpenCodeEffort(role);
5304
+ return Object.freeze({
5305
+ role: role.role,
5306
+ model,
5307
+ variant: effort.ok ? effort.variant ?? null : null,
5308
+ effort
5309
+ });
5310
+ });
5311
+ const items = normalizedRoles.map(({ role, model, variant, effort }) => {
4793
5312
  return {
4794
- title: `Set ${role.role} OpenCode model override`,
5313
+ title: `Set ${role} OpenCode model override`,
4795
5314
  target: targetForLiteConfig(),
4796
5315
  preview: JSON.stringify({
4797
- [role.role]: {
5316
+ [role]: {
4798
5317
  model,
4799
- variant: effort.ok ? effort.variant ?? null : null
5318
+ variant
4800
5319
  }
4801
5320
  }),
4802
5321
  backup: defaultBackup(getExistingLiteConfigPath()),
@@ -4811,12 +5330,13 @@ function buildOpenCodeModelPlan(input, _context = { cwd: process.cwd() }) {
4811
5330
  } : {}
4812
5331
  };
4813
5332
  });
4814
- const plan = planFromItems(
5333
+ const { plan, status } = planFromItems(
4815
5334
  "opencode-model-config-preview",
4816
5335
  "model-config",
4817
5336
  "Configure OpenCode role model overrides",
4818
5337
  "Preview thoth-agents role model overrides in the plugin config agents map.",
4819
- items
5338
+ items,
5339
+ _context
4820
5340
  );
4821
5341
  const effortWarnings = items.flatMap((item) => item.warnings ?? []);
4822
5342
  if (effortWarnings.length > 0) {
@@ -4824,7 +5344,7 @@ function buildOpenCodeModelPlan(input, _context = { cwd: process.cwd() }) {
4824
5344
  plan.warnings.push(...effortWarnings);
4825
5345
  }
4826
5346
  if (input.harness !== "opencode") {
4827
- return {
5347
+ const rejectedPlan = {
4828
5348
  ...plan,
4829
5349
  canApply: false,
4830
5350
  warnings: [
@@ -4836,12 +5356,13 @@ function buildOpenCodeModelPlan(input, _context = { cwd: process.cwd() }) {
4836
5356
  }
4837
5357
  ]
4838
5358
  };
5359
+ return rejectedPlan;
4839
5360
  }
4840
5361
  const unsupportedRoles = input.roles.filter(
4841
5362
  (role) => !ROLE_NAMES.includes(role.role)
4842
5363
  );
4843
5364
  if (input.roles.length === 0 || unsupportedRoles.length > 0) {
4844
- return {
5365
+ const rejectedPlan = {
4845
5366
  ...plan,
4846
5367
  canApply: false,
4847
5368
  warnings: [
@@ -4853,116 +5374,154 @@ function buildOpenCodeModelPlan(input, _context = { cwd: process.cwd() }) {
4853
5374
  }
4854
5375
  ]
4855
5376
  };
5377
+ return rejectedPlan;
4856
5378
  }
4857
- return plan;
5379
+ if (effortWarnings.length > 0) {
5380
+ return plan;
5381
+ }
5382
+ const payloadRoles = Object.freeze(
5383
+ normalizedRoles.map(
5384
+ ({ role, model, variant }) => Object.freeze({ role, model, variant })
5385
+ )
5386
+ );
5387
+ if (!plan.canApply || invalidModelPayloadReason(payloadRoles)) {
5388
+ return plan;
5389
+ }
5390
+ return issueOpenCodePlan(plan, status, _context, {
5391
+ kind: "model",
5392
+ roles: payloadRoles
5393
+ });
4858
5394
  }
4859
- function rejectPlan3(plan, message, severity = "critical") {
5395
+ function rejectPlan3(plan, message, severity = "critical", code = "opencode-plan-rejected", context2 = { cwd: process.cwd() }) {
5396
+ const issued = issuedOpenCodePlans.get(plan);
5397
+ const actionDescriptor = Object.getOwnPropertyDescriptor(plan, "action");
5398
+ const describedAction = actionDescriptor && "value" in actionDescriptor && typeof actionDescriptor.value === "string" ? actionDescriptor.value : void 0;
5399
+ const action = issued?.action ?? (describedAction === "install" || describedAction === "update" || describedAction === "sync" || describedAction === "model-config" ? describedAction : "repair");
5400
+ const liveStatus = getOpenCodeStatus(context2);
5401
+ const blockers = blockingDetails(
5402
+ action,
5403
+ liveStatus,
5404
+ code === "opencode-plan-live-state-changed"
5405
+ );
4860
5406
  return {
4861
- harness: plan.harness,
4862
- action: plan.action,
5407
+ harness: issued?.harness ?? "opencode",
5408
+ action,
4863
5409
  applied: false,
4864
5410
  summary: message,
4865
5411
  changedTargets: [],
5412
+ diagnosticTargets: blockers.targets,
4866
5413
  backups: [],
4867
- warnings: [{ severity, message }],
5414
+ warnings: [...liveStatus.diagnostics, { severity, code, message }],
4868
5415
  disclaimers: defaultDisclaimers()
4869
5416
  };
4870
5417
  }
4871
- function ensureLatestPluginEntry() {
4872
- const configPath = getExistingConfigPath();
4873
- try {
4874
- ensureOpenCodeConfigDir();
4875
- const { config: parsedConfig, error } = parseConfig(configPath);
4876
- if (error) {
4877
- return {
4878
- success: false,
4879
- configPath,
4880
- error: `Failed to parse config: ${error}`
4881
- };
4882
- }
4883
- const config = parsedConfig ?? {};
4884
- const plugins = Array.isArray(config.plugin) ? config.plugin : [];
4885
- config.plugin = [
4886
- ...plugins.filter(
4887
- (plugin) => plugin !== PACKAGE_NAME && !plugin.startsWith(`${PACKAGE_NAME}@`)
4888
- ),
4889
- EXPECTED_PLUGIN
4890
- ];
4891
- writeConfig(configPath, config);
4892
- return { success: true, configPath };
4893
- } catch (err) {
4894
- return {
4895
- success: false,
4896
- configPath,
4897
- error: `Failed to update OpenCode plugin config: ${err}`
4898
- };
4899
- }
4900
- }
4901
5418
  function validateApplyPlan(plan) {
5419
+ const issued = issuedOpenCodePlans.get(plan);
5420
+ if (!issued) {
5421
+ return rejectPlan3(
5422
+ plan,
5423
+ "OpenCode plan was not issued by this process.",
5424
+ "critical",
5425
+ "opencode-plan-unissued"
5426
+ );
5427
+ }
5428
+ const planDigest = canonicalDigest(plan);
5429
+ if (!planDigest.ok || !planDigest.digest) {
5430
+ return rejectPlan3(
5431
+ plan,
5432
+ "OpenCode plan contains noncanonical data and cannot be applied.",
5433
+ "critical",
5434
+ "opencode-plan-noncanonical",
5435
+ issued.context
5436
+ );
5437
+ }
5438
+ if (planDigest.digest !== issued.planDigest) {
5439
+ return rejectPlan3(
5440
+ plan,
5441
+ "OpenCode plan changed after it was issued.",
5442
+ "critical",
5443
+ "opencode-plan-mutated",
5444
+ issued.context
5445
+ );
5446
+ }
5447
+ const liveStatus = getOpenCodeStatus(issued.context);
5448
+ const statusDigest = liveStatusDigest(liveStatus);
5449
+ if (!statusDigest.ok || !statusDigest.digest || statusDigest.digest !== issued.liveStatusDigest) {
5450
+ return rejectPlan3(
5451
+ plan,
5452
+ "OpenCode managed state changed after plan preview.",
5453
+ "critical",
5454
+ "opencode-plan-live-state-changed",
5455
+ issued.context
5456
+ );
5457
+ }
4902
5458
  if (plan.harness !== "opencode") {
4903
- return rejectPlan3(plan, "Only OpenCode operation plans can be applied.");
5459
+ return rejectPlan3(
5460
+ plan,
5461
+ "Only OpenCode operation plans can be applied.",
5462
+ "critical",
5463
+ "opencode-plan-harness-mismatch",
5464
+ issued.context
5465
+ );
4904
5466
  }
4905
5467
  if (!plan.canApply) {
4906
5468
  return rejectPlan3(
4907
5469
  plan,
4908
- "OpenCode plan cannot be applied because canApply is false."
5470
+ "OpenCode plan cannot be applied because canApply is false.",
5471
+ "critical",
5472
+ "opencode-plan-blocked",
5473
+ issued.context
4909
5474
  );
4910
5475
  }
4911
5476
  if (!["install", "update", "sync", "model-config"].includes(plan.action)) {
4912
5477
  return rejectPlan3(
4913
5478
  plan,
4914
- `Unsupported OpenCode apply action: ${plan.action}.`
5479
+ `Unsupported OpenCode apply action: ${plan.action}.`,
5480
+ "critical",
5481
+ "opencode-plan-action-unsupported",
5482
+ issued.context
4915
5483
  );
4916
5484
  }
4917
5485
  if (plan.items.length === 0) {
4918
- return rejectPlan3(plan, "OpenCode plan has no items to apply.");
4919
- }
4920
- if (plan.items.some(
4921
- (item) => !item.title || !item.target.path && item.target.kind !== "skill" || item.state === "drift" || item.state === "unknown"
4922
- )) {
4923
5486
  return rejectPlan3(
4924
5487
  plan,
4925
- "OpenCode plan contains malformed or unsafe items."
5488
+ "OpenCode plan has no items to apply.",
5489
+ "critical",
5490
+ "opencode-plan-empty",
5491
+ issued.context
4926
5492
  );
4927
5493
  }
4928
5494
  return null;
4929
5495
  }
4930
- function applyModelPlan(plan) {
4931
- const roleModels = /* @__PURE__ */ new Map();
4932
- for (const item of plan.items) {
4933
- const match = /^Set (.+) OpenCode model override$/.exec(item.title);
4934
- if (!match) {
4935
- return rejectPlan3(
4936
- plan,
4937
- "OpenCode model plan contains an unrecognized item."
4938
- );
5496
+ function invalidModelPayloadReason(roles) {
5497
+ if (!Array.isArray(roles) || roles.length === 0) {
5498
+ return "Model payload must contain at least one role override.";
5499
+ }
5500
+ const seen = /* @__PURE__ */ new Set();
5501
+ for (const role of roles) {
5502
+ if (!role || typeof role !== "object" || typeof role.role !== "string" || !ROLE_NAMES.includes(role.role)) {
5503
+ return "Model payload contains an unsupported role.";
4939
5504
  }
4940
- let parsed2;
4941
- try {
4942
- parsed2 = item.preview ? JSON.parse(item.preview) : {};
4943
- } catch {
4944
- return rejectPlan3(
4945
- plan,
4946
- "OpenCode model plan contains malformed preview JSON."
4947
- );
5505
+ if (seen.has(role.role)) {
5506
+ return `Model payload contains duplicate role ${role.role}.`;
4948
5507
  }
4949
- const role = match[1] ?? "";
4950
- const model = parsed2[role]?.model;
4951
- const variant = parsed2[role]?.variant;
4952
- if (!ROLE_NAMES.includes(role) || typeof model !== "string" || variant !== null && typeof variant !== "string") {
4953
- return rejectPlan3(
4954
- plan,
4955
- "OpenCode model plan contains an invalid role or model."
4956
- );
5508
+ seen.add(role.role);
5509
+ if (typeof role.model !== "string" || role.model.trim().length === 0) {
5510
+ return `Model payload for ${role.role} has an empty model.`;
5511
+ }
5512
+ if (role.variant !== null && typeof role.variant !== "string") {
5513
+ return `Model payload for ${role.role} has an invalid variant.`;
4957
5514
  }
4958
- roleModels.set(role, { model, variant });
4959
- }
4960
- if (roleModels.size === 0) {
4961
- return rejectPlan3(
4962
- plan,
4963
- "OpenCode model plan does not contain any role overrides."
4964
- );
4965
5515
  }
5516
+ return void 0;
5517
+ }
5518
+ function applyModelPlan(roles) {
5519
+ const roleModels = new Map(
5520
+ roles.map((role) => [
5521
+ role.role,
5522
+ { model: role.model, variant: role.variant }
5523
+ ])
5524
+ );
4966
5525
  ensureConfigDir();
4967
5526
  const targetPath = getExistingLiteConfigPath();
4968
5527
  const parsed = parseConfig(targetPath);
@@ -5029,68 +5588,148 @@ function applyModelPlan(plan) {
5029
5588
  disclaimers: defaultDisclaimers()
5030
5589
  };
5031
5590
  }
5032
- function applyInstallSkills(plan) {
5591
+ function applyRecommendedSkills() {
5592
+ const warnings = [];
5033
5593
  for (const skill of RECOMMENDED_SKILLS) {
5034
- const result = installRecommendedSkill(skill);
5035
- if (result.status === "failed") {
5036
- return rejectPlan3(
5037
- plan,
5038
- `Failed to install recommended OpenCode skill: ${skill.name}.`
5039
- );
5594
+ try {
5595
+ const result = installRecommendedSkill(skill);
5596
+ if (result.status !== "failed") continue;
5597
+ warnings.push({
5598
+ severity: "important",
5599
+ code: "opencode-recommended-skill-failed",
5600
+ message: `Failed to install optional recommended OpenCode skill: ${skill.name}.`
5601
+ });
5602
+ } catch (error) {
5603
+ const reason = error instanceof Error ? error.message : String(error);
5604
+ warnings.push({
5605
+ severity: "important",
5606
+ code: "opencode-recommended-skill-failed",
5607
+ message: `Failed to install optional recommended OpenCode skill ${skill.name}: ${reason}.`
5608
+ });
5040
5609
  }
5041
5610
  }
5042
- const bundled = installCustomSkills();
5611
+ return warnings;
5612
+ }
5613
+ function bundledSkillsFailure(plan, changedTargets, backups, warnings, detail) {
5614
+ const message = `Failed to install bundled thoth-agents OpenCode skills: ${detail}.`;
5615
+ return {
5616
+ harness: plan.harness,
5617
+ action: plan.action,
5618
+ applied: false,
5619
+ summary: message,
5620
+ changedTargets,
5621
+ backups,
5622
+ warnings: [
5623
+ ...warnings,
5624
+ {
5625
+ severity: "critical",
5626
+ code: "opencode-bundled-skills-failed",
5627
+ message
5628
+ }
5629
+ ],
5630
+ disclaimers: defaultDisclaimers()
5631
+ };
5632
+ }
5633
+ function applyBundledSkills(plan, changedTargets, backups, warnings) {
5634
+ let bundled;
5635
+ try {
5636
+ bundled = installCustomSkills();
5637
+ } catch (error) {
5638
+ const detail = error instanceof Error ? error.message : String(error);
5639
+ return bundledSkillsFailure(
5640
+ plan,
5641
+ changedTargets,
5642
+ backups,
5643
+ warnings,
5644
+ detail
5645
+ );
5646
+ }
5043
5647
  if (!bundled.success) {
5044
- return rejectPlan3(
5648
+ const failedNames = bundled.failedSkills.map(({ skill }) => skill.name);
5649
+ const detail = failedNames.length > 0 ? failedNames.join(", ") : "unknown bundled skills";
5650
+ return bundledSkillsFailure(
5045
5651
  plan,
5046
- "Failed to install bundled thoth-agents OpenCode skills."
5652
+ changedTargets,
5653
+ backups,
5654
+ warnings,
5655
+ detail
5047
5656
  );
5048
5657
  }
5658
+ changedTargets.push({
5659
+ kind: "skill",
5660
+ label: "Bundled thoth-agents OpenCode skills",
5661
+ state: "installed",
5662
+ observed: "managed bundled skills refreshed"
5663
+ });
5049
5664
  return null;
5050
5665
  }
5051
5666
  function applyOpenCodePlan(plan) {
5052
5667
  const rejection = validateApplyPlan(plan);
5053
5668
  if (rejection) return rejection;
5054
- const status = getOpenCodeStatus();
5055
- if (!classifyApplySafety(status.state)) {
5669
+ const issued = issuedOpenCodePlans.get(plan);
5670
+ if (!issued) {
5671
+ return rejectPlan3(
5672
+ plan,
5673
+ "OpenCode plan provenance was lost before apply.",
5674
+ "critical",
5675
+ "opencode-plan-unissued"
5676
+ );
5677
+ }
5678
+ const status = getOpenCodeStatus(issued.context);
5679
+ if (!canApplyToManagedHealth(plan.action, status)) {
5056
5680
  return rejectPlan3(
5057
5681
  plan,
5058
- `OpenCode state is ${status.state}; refusing to apply plan without a safe status.`
5682
+ `OpenCode state is ${status.state}; refusing to apply plan without a safe status.`,
5683
+ "critical",
5684
+ "opencode-plan-live-state-blocked",
5685
+ issued.context
5059
5686
  );
5060
5687
  }
5061
- if (plan.action === "model-config") return applyModelPlan(plan);
5688
+ if (issued.payload.kind === "model") {
5689
+ const invalidPayload = invalidModelPayloadReason(issued.payload.roles);
5690
+ if (invalidPayload) {
5691
+ return rejectPlan3(
5692
+ plan,
5693
+ invalidPayload,
5694
+ "critical",
5695
+ "opencode-model-payload-invalid",
5696
+ issued.context
5697
+ );
5698
+ }
5699
+ return applyModelPlan(issued.payload.roles);
5700
+ }
5062
5701
  const changedTargets = [];
5063
5702
  const backups = [];
5064
- const pluginResult = ensureLatestPluginEntry();
5065
- if (!pluginResult.success) {
5703
+ const warnings = [];
5704
+ const mainConfigResult = updateOpenCodeMainConfig({
5705
+ ensurePlugin: true,
5706
+ disableDefaults: issued.payload.action === "sync" || issued.payload.action === "install"
5707
+ });
5708
+ if (!mainConfigResult.success) {
5066
5709
  return rejectPlan3(
5067
5710
  plan,
5068
- pluginResult.error ?? "Failed to update OpenCode plugin config."
5711
+ mainConfigResult.error ?? "Failed to update OpenCode main config.",
5712
+ "critical",
5713
+ "opencode-main-config-update-failed",
5714
+ issued.context
5069
5715
  );
5070
5716
  }
5071
5717
  changedTargets.push({
5072
5718
  ...targetForMainConfig("installed"),
5073
5719
  observed: `plugin includes ${EXPECTED_PLUGIN}`
5074
5720
  });
5075
- if (existsSync10(`${pluginResult.configPath}.bak`)) {
5721
+ if (existsSync10(`${mainConfigResult.configPath}.bak`)) {
5076
5722
  backups.push({
5077
- path: `${pluginResult.configPath}.bak`,
5723
+ path: `${mainConfigResult.configPath}.bak`,
5078
5724
  label: "OpenCode config backup"
5079
5725
  });
5080
5726
  }
5081
- if (plan.action === "sync" || plan.action === "install") {
5082
- const defaultAgentResult = disableDefaultAgents();
5083
- if (!defaultAgentResult.success) {
5084
- return rejectPlan3(
5085
- plan,
5086
- defaultAgentResult.error ?? "Failed to disable OpenCode default agents."
5087
- );
5088
- }
5727
+ if (issued.payload.action === "sync" || issued.payload.action === "install") {
5089
5728
  const liteResult = writeLiteConfig(
5090
5729
  {
5091
5730
  agent: "opencode",
5092
5731
  hasTmux: false,
5093
- installSkills: plan.action === "install",
5732
+ installSkills: issued.payload.action === "install",
5094
5733
  installCustomSkills: true,
5095
5734
  dryRun: false,
5096
5735
  reset: false
@@ -5113,15 +5752,23 @@ function applyOpenCodePlan(plan) {
5113
5752
  label: "thoth-agents config backup"
5114
5753
  });
5115
5754
  }
5116
- }
5117
- if (plan.action === "install") {
5118
- const skillsRejection = applyInstallSkills(plan);
5755
+ const skillsRejection = applyBundledSkills(
5756
+ plan,
5757
+ changedTargets,
5758
+ backups,
5759
+ warnings
5760
+ );
5119
5761
  if (skillsRejection) return skillsRejection;
5762
+ }
5763
+ if (issued.payload.action === "install") {
5764
+ warnings.push(...applyRecommendedSkills());
5120
5765
  changedTargets.push({
5121
5766
  kind: "skill",
5122
- label: "Recommended and bundled OpenCode skills",
5123
- state: "installed",
5124
- observed: "skills=yes processed"
5767
+ label: "Recommended OpenCode skills",
5768
+ state: warnings.some(
5769
+ (warning3) => warning3.code === "opencode-recommended-skill-failed"
5770
+ ) ? "drift" : "installed",
5771
+ observed: "optional skills=yes processed"
5125
5772
  });
5126
5773
  }
5127
5774
  return {
@@ -5131,7 +5778,7 @@ function applyOpenCodePlan(plan) {
5131
5778
  summary: plan.action === "install" ? "Applied OpenCode install plan." : plan.action === "sync" ? "Applied OpenCode managed configuration sync." : "Applied OpenCode plugin update.",
5132
5779
  changedTargets,
5133
5780
  backups,
5134
- warnings: [],
5781
+ warnings,
5135
5782
  disclaimers: defaultDisclaimers()
5136
5783
  };
5137
5784
  }
@@ -5469,12 +6116,12 @@ function getOpenCodeModelsInvocation(platform = process.platform) {
5469
6116
  };
5470
6117
  if (platform === "win32") {
5471
6118
  return {
5472
- command: "opencode models",
6119
+ command: "opencode models --pure",
5473
6120
  args: [],
5474
6121
  options: { ...options, shell: true }
5475
6122
  };
5476
6123
  }
5477
- return { command: "opencode", args: ["models"], options };
6124
+ return { command: "opencode", args: ["models", "--pure"], options };
5478
6125
  }
5479
6126
  function getNativeOpenCodeOptions() {
5480
6127
  try {
@@ -5629,13 +6276,15 @@ function readRoleField(config, role, field) {
5629
6276
  function getOpenCodeModelRoles() {
5630
6277
  const parsed = parseConfig(getExistingLiteConfigPath());
5631
6278
  const agents = parsed.config?.agents && typeof parsed.config.agents === "object" ? parsed.config.agents : void 0;
5632
- const presets = parsed.config?.presets && typeof parsed.config.presets === "object" ? parsed.config.presets : {};
5633
- const openaiPreset = presets.openai && typeof presets.openai === "object" ? presets.openai : void 0;
6279
+ const presets = parsed.config?.presets && typeof parsed.config.presets === "object" && !Array.isArray(parsed.config.presets) ? parsed.config.presets : {};
6280
+ const presetName = typeof parsed.config?.preset === "string" ? parsed.config.preset : void 0;
6281
+ const selectedPreset = presetName !== void 0 ? presets[presetName] : void 0;
6282
+ const activePreset = selectedPreset && typeof selectedPreset === "object" && !Array.isArray(selectedPreset) ? selectedPreset : void 0;
5634
6283
  return ALL_AGENT_NAMES.map((role) => {
5635
- const variant = readRoleField(agents, role, "variant") ?? readRoleField(openaiPreset, role, "variant");
6284
+ const variant = readRoleField(agents, role, "variant") ?? readRoleField(activePreset, role, "variant");
5636
6285
  return {
5637
6286
  role,
5638
- model: readRoleField(agents, role, "model") ?? readRoleField(openaiPreset, role, "model") ?? DEFAULT_MODELS[role] ?? "openai/gpt-5.4",
6287
+ model: readRoleField(agents, role, "model") ?? readRoleField(activePreset, role, "model") ?? DEFAULT_MODELS[role] ?? "openai/gpt-5.4",
5639
6288
  effort: variant ? { kind: "effort", value: variant } : { kind: "inherit" }
5640
6289
  };
5641
6290
  });