arkaos 5.17.1 → 5.17.2

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/VERSION CHANGED
@@ -1 +1 @@
1
- 5.17.1
1
+ 5.17.2
@@ -70,10 +70,10 @@ Outside this skill's scope but worth surfacing alongside the summary:
70
70
  | Lever | Where | Effect |
71
71
  | --- | --- | --- |
72
72
  | `ARKAOS_LLM_PROVIDER` | env | Switch between Anthropic, OpenAI, Gemini adapters per the multi-LLM router. |
73
- | `fallbackModel` | `~/.claude/settings.json` (Claude Code 2.1.166+) | Up to three models tried in order when the primary is overloaded or unavailable. `npx arkaos install` / `update` seed `["claude-opus-5", "claude-sonnet-5"]` when the key is absent and never overwrite an operator chain; `/arka status` shows the configured chain. |
74
- | Schedule `model:` / `fallback_models:` | `~/.arkaos/schedules.yaml` | A pinned `model` goes to `claude --model`; without one the Model Fabric `strategy` role becomes `ANTHROPIC_DEFAULT_MODEL` (Claude Code 2.1.236+). `fallback_models` goes to `--fallback-model` (default `claude-opus-5 → claude-sonnet-5`) so one overload or model 404 no longer ends a nightly cycle. |
73
+ | `fallbackModel` | `~/.claude/settings.json` (Claude Code 2.1.166+) | Up to three models tried in order when the primary is overloaded or unavailable. `npx arkaos install` / `update` seed `["claude-opus-5-5", "claude-sonnet-5"]` when the key is absent, upgrade a chain that is exactly a previous ArkaOS default, and never overwrite an operator chain; `/arka status` shows the configured chain. |
74
+ | Schedule `model:` / `fallback_models:` | `~/.arkaos/schedules.yaml` | A pinned `model` goes to `claude --model`; without one the Model Fabric `strategy` role becomes `ANTHROPIC_DEFAULT_MODEL` (Claude Code 2.1.236+). `fallback_models` goes to `--fallback-model` (default `claude-opus-5-5 → claude-sonnet-5`) so one overload or model 404 no longer ends a nightly cycle. |
75
75
  | `ANTHROPIC_BEDROCK_SERVICE_TIER` | env (Claude Code 2.1.122+) | `default` / `flex` / `priority`. Only relevant when routing through AWS Bedrock — `flex` cuts cost ~50 % on non-urgent workloads at the price of higher tail latency. |
76
- | Agent `model:` field | `departments/*/agents/*.yaml` | Per-agent override per the model-routing matrix in CLAUDE.md. Mechanical roles default to `sonnet` at low effort, C-suite to `opus` (Opus 5); the Quality Gate row below lifts Marta / Eduardo / Francisca to the frontier tier. |
76
+ | Agent `model:` field | `departments/*/agents/*.yaml` | Per-agent override per the model-routing matrix in CLAUDE.md. Mechanical roles default to `sonnet` at low effort, C-suite to `opus` (Opus 5.5); the Quality Gate row below lifts Marta / Eduardo / Francisca to the frontier tier. |
77
77
  | Quality Gate model | constitution `quality_gate.model_policy` | Marta / Eduardo / Francisca run on the best model available (frontier tier) regardless of the cost ceiling — review quality is never the place to save tokens. |
78
78
 
79
79
  ## Non-negotiables
@@ -5,10 +5,10 @@
5
5
  #
6
6
  # Model pin and fallback chain (Runtime Sync PR3) — per schedule, optional:
7
7
  # model: claude-sonnet-5 # pinned: goes to `claude --model`
8
- # fallback_models: [claude-opus-5] # `--fallback-model a,b`; [] disables
8
+ # fallback_models: [claude-opus-5-5] # `--fallback-model a,b`; [] disables
9
9
  # Without `model`, the Model Fabric `strategy` role (~/.arkaos/models.yaml)
10
10
  # becomes ANTHROPIC_DEFAULT_MODEL for the run. Without `fallback_models`,
11
- # the chain is claude-opus-5 → claude-sonnet-5. The runtime caps chains at
11
+ # the chain is claude-opus-5-5 → claude-sonnet-5. The runtime caps chains at
12
12
  # three models after de-duplication, removes ids outside `availableModels`
13
13
  # before the walk and fails over past an id it cannot reach (a retired
14
14
  # model); the ArkaOS gateway plays no part in the chain.
@@ -462,25 +462,36 @@ def _check_worktree(report: DriftReport, settings: dict[str, Any]) -> None:
462
462
  )
463
463
 
464
464
 
465
+ def _fallback_finding(status: DriftStatus, detail: str) -> DriftFinding:
466
+ return DriftFinding("settings:fallbackModel", status, "fallbackModel", detail)
467
+
468
+
465
469
  def _check_fallback_model(report: DriftReport, settings: dict[str, Any]) -> None:
466
470
  """MISSING when the key is absent or JSON null (the seeder writes the
467
- default in both cases); ADOPTED for any other value that is not the
468
- default — an array, an empty array, or the legacy string form."""
469
- from core.runtime.claude_code import DEFAULT_FALLBACK_MODELS
471
+ default in both cases); DIVERGED when the chain is exactly a default
472
+ ArkaOS seeded in an earlier release (assert upgrades it); ADOPTED for
473
+ any other value that is not the default — an array, an empty array,
474
+ or the legacy string form."""
475
+ from core.runtime.claude_code import (
476
+ DEFAULT_FALLBACK_MODELS,
477
+ is_previous_fallback_default,
478
+ )
470
479
 
471
480
  chain = settings.get("fallbackModel")
472
481
  if chain is None:
473
482
  report.findings.append(
474
- DriftFinding(
475
- "settings:fallbackModel", DriftStatus.MISSING, "fallbackModel",
476
- "fallbackModel chain default not seeded",
477
- )
483
+ _fallback_finding(DriftStatus.MISSING, "fallbackModel chain default not seeded")
484
+ )
485
+ elif is_previous_fallback_default(chain):
486
+ detail = (
487
+ f"previous ArkaOS default chain ({' → '.join(chain)}); "
488
+ f"`arka harness assert` upgrades it to {' → '.join(DEFAULT_FALLBACK_MODELS)}"
478
489
  )
490
+ report.findings.append(_fallback_finding(DriftStatus.DIVERGED, detail))
479
491
  elif chain != list(DEFAULT_FALLBACK_MODELS):
480
492
  report.findings.append(
481
- DriftFinding(
482
- "settings:fallbackModel", DriftStatus.ADOPTED, "fallbackModel",
483
- "operator-configured fallback chain; seed policy adopts it",
493
+ _fallback_finding(
494
+ DriftStatus.ADOPTED, "operator-configured fallback chain; seed policy adopts it"
484
495
  )
485
496
  )
486
497
 
@@ -41,7 +41,10 @@ from pathlib import Path
41
41
  from core.harness import drift, json_store, paths
42
42
  from core.harness.manifest import OwnershipManifest
43
43
  from core.harness.spec import HookRegistration, spec_for
44
- from core.runtime.claude_code import DEFAULT_FALLBACK_MODELS
44
+ from core.runtime.claude_code import (
45
+ DEFAULT_FALLBACK_MODELS,
46
+ is_previous_fallback_default,
47
+ )
45
48
 
46
49
 
47
50
  @dataclass
@@ -49,8 +52,8 @@ class SurfaceAction:
49
52
  """One thing assert did (or deliberately did not do) to a surface."""
50
53
 
51
54
  surface: str
52
- # created | repaired | unrepaired | reseeded | adopted-skip
53
- # | refused | noop
55
+ # created | repaired | unrepaired | reseeded | upgraded
56
+ # | adopted-skip | refused | noop
54
57
  action: str
55
58
  detail: str = ""
56
59
 
@@ -353,13 +356,23 @@ class ClaudeConfigManager:
353
356
  def _assert_seed(
354
357
  self, settings: dict, report: AssertReport, reseed: bool
355
358
  ) -> None:
356
- for surface, key, default, is_ours in _seed_surfaces(self):
359
+ for surface, key, default, is_ours, is_previous in _seed_surfaces(self):
357
360
  current = settings.get(key)
358
361
  if current is None:
359
362
  settings[key] = default
360
363
  report.actions.append(SurfaceAction(surface, "created"))
361
364
  elif is_ours(current):
362
365
  report.actions.append(SurfaceAction(surface, "noop"))
366
+ elif is_previous(current):
367
+ # A chain ArkaOS itself seeded before is not an operator
368
+ # decision: move it to the current default.
369
+ settings[key] = default
370
+ report.actions.append(
371
+ SurfaceAction(
372
+ surface, "upgraded",
373
+ "previous ArkaOS default; moved to the current default",
374
+ )
375
+ )
363
376
  elif reseed:
364
377
  settings[key] = default
365
378
  report.actions.append(SurfaceAction(surface, "reseeded"))
@@ -531,20 +544,27 @@ def _user_deny_extensions(home: Path | None) -> list[str]:
531
544
  return [r for r in raw if isinstance(r, str) and r]
532
545
 
533
546
 
547
+ def _never_previous(_value: object) -> bool:
548
+ """Surfaces with no history of ArkaOS-authored defaults to upgrade."""
549
+ return False
550
+
551
+
534
552
  def _seed_surfaces(manager: ClaudeConfigManager):
535
553
  statusline = _statusline_default(manager)
536
554
  return (
537
555
  (
538
556
  "settings:statusLine", "statusLine", statusline,
539
- drift._is_arkaos_statusline,
557
+ drift._is_arkaos_statusline, _never_previous,
540
558
  ),
541
559
  (
542
560
  "settings:worktree", "worktree", {"baseRef": "head"},
543
561
  lambda v: isinstance(v, dict) and v.get("baseRef") == "head",
562
+ _never_previous,
544
563
  ),
545
564
  (
546
565
  "settings:fallbackModel", "fallbackModel", list(DEFAULT_FALLBACK_MODELS),
547
566
  lambda v: v == list(DEFAULT_FALLBACK_MODELS),
567
+ is_previous_fallback_default,
548
568
  ),
549
569
  )
550
570
 
@@ -108,7 +108,19 @@ _PROBE_TIMEOUT_SECONDS = 10
108
108
  # model is overloaded or unavailable; the runtime caps chains at three after
109
109
  # de-duplication. installer/fallback-model.js carries the same list —
110
110
  # tests/python/test_scheduler_daemon.py pins the two to each other.
111
- DEFAULT_FALLBACK_MODELS: tuple[str, ...] = ("claude-opus-5", "claude-sonnet-5")
111
+ DEFAULT_FALLBACK_MODELS: tuple[str, ...] = ("claude-opus-5-5", "claude-sonnet-5")
112
+ # Chains ArkaOS ITSELF seeded in earlier releases. A settings.json holding one
113
+ # of these byte for byte is not an operator decision, so the seeder and
114
+ # `arka harness assert` upgrade it to DEFAULT_FALLBACK_MODELS; any other
115
+ # present value is the operator's and stays. Append, never edit — the JS twin
116
+ # in installer/fallback-model.js is pinned to this list by the same test.
117
+ PREVIOUS_FALLBACK_DEFAULTS: tuple[tuple[str, ...], ...] = (("claude-opus-5", "claude-sonnet-5"),)
118
+
119
+
120
+ def is_previous_fallback_default(chain: object) -> bool:
121
+ """True when ``chain`` is exactly a chain ArkaOS seeded in the past."""
122
+ return isinstance(chain, list) and tuple(chain) in PREVIOUS_FALLBACK_DEFAULTS
123
+
112
124
 
113
125
  # Cache for the `claude --version` probe, keyed by the binary probed ("" =
114
126
  # the PATH lookup). In-memory only: a disk cache would let one session
@@ -51,10 +51,11 @@ _SLOT_ROLE: dict[str, str] = {
51
51
 
52
52
  # runtime-tier tokens -> real Anthropic model ids the proxy calls upstream.
53
53
  # Real ids (claude-fable-5-1, claude-*) pass straight through.
54
- # Runtime Sync 2026-09-03: `best` is Fable 5.1, `opus` is Opus 5, and the
55
- # weakest lane (`haiku`/`fast`) is Sonnet 5 — ArkaOS never routes to Haiku.
54
+ # Runtime Sync 2026-09-03 (+ Opus 5.5 sweep 2026-09-22): `best` is Fable 5.1,
55
+ # `opus` is Opus 5.5 (the id the Claude Code 2.1.280 alias resolves to), and
56
+ # the weakest lane (`haiku`/`fast`) is Sonnet 5 — ArkaOS never routes to Haiku.
56
57
  _RUNTIME_TO_ANTHROPIC: dict[str, str] = {
57
- "opus": "claude-opus-5",
58
+ "opus": "claude-opus-5-5",
58
59
  "best": "claude-fable-5-1",
59
60
  "fable": "claude-fable-5-1",
60
61
  "sonnet": "claude-sonnet-5",
@@ -55,18 +55,23 @@ KNOWN_EFFORTS = ("low", "medium", "high", "max")
55
55
 
56
56
  _ALIAS_NAMES = frozenset({"best", "default", "fast"})
57
57
 
58
- # Runtime Sync 2026-09-03 — ids that no longer exist, or lanes ArkaOS no
59
- # longer routes to (Opus 4.x → Opus 5, Fable 5 → Fable 5.1, Haiku → Sonnet 5).
60
- # Normalised when a role is RESOLVED; the operator's models.yaml is never
61
- # rewritten. One stderr notice per process per id, so a stale pin is
62
- # visible without becoming noise.
58
+ # Runtime Sync 2026-09-03 (Opus 4.x → Opus 5, Fable 5 → Fable 5.1, Haiku →
59
+ # Sonnet 5) and the Opus 5.5 sweep 2026-09-22 (Opus 5 → Opus 5.5) — ids that
60
+ # no longer exist, or lanes ArkaOS no longer routes to. Normalised when a
61
+ # role is RESOLVED; the operator's models.yaml is never rewritten. One
62
+ # stderr notice per process per id, so a stale pin is visible without
63
+ # becoming noise. Legacy ids map straight to the CURRENT lane — the lookup
64
+ # is a single dict read, not a transitive walk, so no value may be a key.
65
+ LEGACY_SYNC_DATE = "2026-09-22"
63
66
  LEGACY_MODEL_IDS: dict[str, str] = {
64
67
  "claude-fable-5": "claude-fable-5-1",
65
68
  "claude-fable-5[1m]": "claude-fable-5-1[1m]",
66
- "claude-opus-4-8": "claude-opus-5",
67
- "claude-opus-4-8[1m]": "claude-opus-5[1m]",
68
- "claude-opus-4-7": "claude-opus-5",
69
- "claude-opus-4-6": "claude-opus-5",
69
+ "claude-opus-5": "claude-opus-5-5",
70
+ "claude-opus-5[1m]": "claude-opus-5-5[1m]",
71
+ "claude-opus-4-8": "claude-opus-5-5",
72
+ "claude-opus-4-8[1m]": "claude-opus-5-5[1m]",
73
+ "claude-opus-4-7": "claude-opus-5-5",
74
+ "claude-opus-4-6": "claude-opus-5-5",
70
75
  "claude-haiku-4-5-20251001": "claude-sonnet-5",
71
76
  "claude-haiku-4-5": "claude-sonnet-5",
72
77
  "haiku": "sonnet",
@@ -89,7 +94,8 @@ def normalise_model_id(model: str, source: str = "models.yaml") -> str:
89
94
  _LEGACY_NOTICED.add((source, model))
90
95
  print(
91
96
  f"[arka:warn] {source} pins legacy model {model!r}; resolving as "
92
- f"{current!r} (Runtime Sync 2026-09-03). Update the pin to silence this.",
97
+ f"{current!r} (retired lane; last sweep {LEGACY_SYNC_DATE}). "
98
+ "Update the pin to silence this.",
93
99
  file=sys.stderr,
94
100
  )
95
101
  return current
@@ -6,7 +6,8 @@ their public per-token pricing. It is used to compute an optional
6
6
  any model-selection logic — that is explicitly forbidden by the
7
7
  LLM-agnostic contract.
8
8
 
9
- Snapshot 2026-09-03 (Runtime Sync PR-2): values sourced from the public
9
+ Snapshot 2026-09-03 (Runtime Sync PR-2), Opus 5.5 rows added 2026-09-22:
10
+ values sourced from the public
10
11
  Anthropic pricing page (https://platform.claude.com/docs/en/about-claude/pricing) and
11
12
  OpenAI (https://openai.com/api/pricing). Refresh when model families
12
13
  change. Unknown models return `None` from `estimate_cost_usd` and are
@@ -61,11 +62,32 @@ PRICING: dict[str, dict[str, float]] = {
61
62
  "cache_read": 1.00,
62
63
  "cache_write": 12.50,
63
64
  },
64
- # Snapshot 2026-08-09 (Gate Economy PR-8): drop-in at Opus 4.8's
65
- # rates per the official model catalog — 1M context is the default
66
- # with NO long-context premium, so the [1m] alias prices the same.
67
- # This row was missing while claude-opus-5 carried 28% of weekly
65
+ # Snapshot 2026-09-22 (Opus 5.5 sweep): $4 / $20 per MTok and cache
66
+ # reads at $0.20 (0.05x input) per the public pricing page; the Claude
67
+ # Code 2.1.280 alias `opus` resolves here, so this is the lane every
68
+ # `model: opus` agent runs on. cache_write is NOT published yet: 5.00
69
+ # is the standard 1.25x five-minute multiplier — the same assumption
70
+ # the Fable 5.1 row shipped with — replace it when the page lists it.
71
+ # 1M context is native; the [1m] alias prices the same.
72
+ "claude-opus-5-5": {
73
+ "input": 4.00,
74
+ "output": 20.00,
75
+ "cache_read": 0.20,
76
+ "cache_write": 5.00,
77
+ },
78
+ "claude-opus-5-5[1m]": {
79
+ "input": 4.00,
80
+ "output": 20.00,
81
+ "cache_read": 0.20,
82
+ "cache_write": 5.00,
83
+ },
84
+ # History — snapshot 2026-08-09 (Gate Economy PR-8): drop-in at Opus
85
+ # 4.8's rates per the official model catalog — 1M context is the
86
+ # default with NO long-context premium, so the [1m] alias prices the
87
+ # same. The row was missing while claude-opus-5 carried 28% of weekly
68
88
  # input tokens, pricing them at $0.00 and blinding the CostGovernor.
89
+ # Retained so 2026-08/09 sessions stay priced; the live lane is
90
+ # claude-opus-5-5 above.
69
91
  "claude-opus-5": {
70
92
  "input": 5.00,
71
93
  "output": 25.00,
@@ -3,14 +3,16 @@
3
3
  The Model Fabric ``runtime`` provider shells out to the active CLI. This
4
4
  module lists the concrete models that CLI accepts, with human labels and
5
5
  tier, so the dashboard dropdown and ``/arka-fusion`` can offer real
6
- choices (Fable 5.1, Opus 5, Sonnet 5) instead of only the abstract
6
+ choices (Fable 5.1, Opus 5.5, Sonnet 5) instead of only the abstract
7
7
  best/default/fast aliases. Haiku is deliberately absent: the weakest
8
8
  lane ArkaOS routes to is Sonnet 5 (operator decision, Runtime Sync 2026-09-03).
9
9
 
10
- Model IDs verified against the Claude Code changelog 2.1.257 (2026-09-01)
11
- and the model-config reference (2026-09). This is the single place they
12
- are hand-listed — update HERE when the runtime
13
- ships new models; the dashboard and CLI read from this module. Codex /
10
+ Model IDs verified against the Claude Code 2.1.280 alias table (2026-09-22:
11
+ `opus` → claude-opus-5-5) and the model-config reference (2026-09). This is the single place
12
+ the picker catalogue is hand-listed — update HERE when the runtime ships new
13
+ models; the dashboard and CLI read from this module. The ids also live in
14
+ pricing.py, model_router.py, claude_code.py, gateway/litellm_config.py and
15
+ installer/fallback-model.js (one sweep touches all six). Codex /
14
16
  Gemini / Cursor models are left empty on purpose: we do not hardcode
15
17
  another vendor's catalogue, and the UI falls back to a free-text field
16
18
  so the user types the exact id their runtime accepts.
@@ -24,8 +26,8 @@ from __future__ import annotations
24
26
  CLAUDE_CODE_MODELS: list[dict[str, str]] = [
25
27
  {"value": "claude-fable-5-1", "label": "Fable 5.1", "tier": "frontier",
26
28
  "note": "most capable — 1M context, cache reads $0.25/MTok"},
27
- {"value": "opus", "label": "Opus 5", "tier": "frontier",
28
- "note": "frontier"},
29
+ {"value": "opus", "label": "Opus 5.5", "tier": "frontier",
30
+ "note": "frontier — $4/$20 per MTok, cache reads $0.20"},
29
31
  {"value": "sonnet", "label": "Sonnet 5", "tier": "balanced",
30
32
  "note": "balanced speed/quality — also the mechanical lane"},
31
33
  ]
@@ -128,7 +128,7 @@ const targetOptions = computed(() => {
128
128
  const o = overview.value
129
129
  if (!o) return []
130
130
  const options: { label: string, value: string }[] = []
131
- // Concrete runtime models first (Fable 5.1, Opus 5, Sonnet 5) so the
131
+ // Concrete runtime models first (Fable 5.1, Opus 5.5, Sonnet 5) so the
132
132
  // operator picks a real model, not just an abstract alias.
133
133
  for (const m of o.runtime?.models ?? []) {
134
134
  options.push({ label: `runtime / ${m.label} · ${m.note}`, value: `runtime/${m.value}` })
@@ -5,64 +5,115 @@
5
5
  // unavailable (the runtime de-duplicates and caps the chain at three).
6
6
  // Without it a single overload or model 404 ends the session — the
7
7
  // scheduler's nightly cycles died exactly that way. ArkaOS seeds the
8
- // chain Opus 5 → Sonnet 5, the lanes below Fable 5.1 in the Model Fabric.
8
+ // chain Opus 5.5 → Sonnet 5, the lanes below Fable 5.1 in the Model Fabric.
9
9
  //
10
10
  // Behaviour (same contract as worktree-baseref.js):
11
11
  // - No-op when runtime is not Claude Code.
12
- // - Only sets the value when the key is absent or JSON null. An operator
13
- // chain — an array, an explicit empty array, or the legacy string form
14
- // — is preserved byte for byte (a default the operator may replace).
12
+ // - Sets the value when the key is absent or JSON null, and UPGRADES a
13
+ // chain that is exactly a default ArkaOS itself seeded in an earlier
14
+ // release (PREVIOUS_FALLBACK_DEFAULTS). Any other operator chain — an
15
+ // array, an explicit empty array, or the legacy string form — is
16
+ // preserved byte for byte.
15
17
  // - Atomic write via .tmp + rename.
16
18
  // - Never raises — failures are non-fatal.
17
19
  //
18
- // core/runtime/claude_code.py:DEFAULT_FALLBACK_MODELS carries the same
19
- // list; tests/python/test_scheduler_daemon.py pins the two to each other.
20
+ // core/runtime/claude_code.py carries the same DEFAULT_FALLBACK_MODELS and
21
+ // PREVIOUS_FALLBACK_DEFAULTS; tests/python/test_scheduler_daemon.py pins
22
+ // the two files to each other.
20
23
 
21
24
  import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
22
25
  import { homedir } from "node:os";
23
26
  import { join } from "node:path";
24
27
 
25
28
 
26
- export const DEFAULT_FALLBACK_MODELS = ["claude-opus-5", "claude-sonnet-5"];
29
+ export const DEFAULT_FALLBACK_MODELS = ["claude-opus-5-5", "claude-sonnet-5"];
30
+ // Chains ArkaOS itself seeded before — upgraded, not preserved. Append only;
31
+ // keep each literal on one line (the Python parity test JSON-parses it).
32
+ export const PREVIOUS_FALLBACK_DEFAULTS = [["claude-opus-5", "claude-sonnet-5"]];
27
33
 
34
+ function sameChain(a, b) {
35
+ return Array.isArray(a) && Array.isArray(b) && a.length === b.length
36
+ && a.every((id, i) => id === b[i]);
37
+ }
28
38
 
29
- export function seedFallbackModel({
30
- runtime = "claude-code",
31
- home = homedir(),
32
- defaultValue = DEFAULT_FALLBACK_MODELS,
33
- } = {}) {
34
- if (runtime !== "claude-code") {
35
- return { skipped: "runtime-not-claude-code", action: null };
36
- }
37
- const settingsPath = join(home, ".claude", "settings.json");
39
+ function isPreviousDefault(value) {
40
+ return PREVIOUS_FALLBACK_DEFAULTS.some((prev) => sameChain(prev, value));
41
+ }
42
+
43
+
44
+ // The settings object, or the `skipped` reason the seeder reports as-is.
45
+ function readSettingsObject(settingsPath) {
38
46
  if (!existsSync(settingsPath)) {
39
- return { skipped: "claude-settings-not-found", action: null };
47
+ return { skipped: "claude-settings-not-found" };
40
48
  }
41
49
  let settings;
42
50
  try {
43
51
  settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
44
52
  } catch {
45
- return { skipped: "settings-not-parseable", action: null };
53
+ return { skipped: "settings-not-parseable" };
46
54
  }
47
55
  if (typeof settings !== "object" || settings === null || Array.isArray(settings)) {
48
- return { skipped: "settings-not-object", action: null };
56
+ return { skipped: "settings-not-object" };
49
57
  }
50
- const existing = settings.fallbackModel;
51
- if (existing !== undefined && existing !== null) {
52
- // Present in any non-null shape is the operator's choice: an array, an
53
- // empty array (chain disabled on purpose) or the legacy single string.
54
- // JSON null reads as unset (the runtime, drift and the config manager
55
- // agree), so it is seeded like an absent key.
56
- return { skipped: null, action: "noop", value: existing };
58
+ return { settings };
59
+ }
60
+
61
+ // What the seeder does with the value already under `fallbackModel`:
62
+ // created — key absent or JSON null (the runtime, drift and the config
63
+ // manager all read null as unset);
64
+ // upgraded — exactly a chain ArkaOS itself seeded before, and not the
65
+ // chain being seeded now (a custom defaultValue may equal it):
66
+ // written by us, not by the operator, so it moves on;
67
+ // noop — any other present value is the operator's choice: an array,
68
+ // an empty array (chain disabled on purpose) or the legacy
69
+ // single string, preserved byte for byte.
70
+ function seedAction(existing, defaultValue) {
71
+ if (existing === undefined || existing === null) {
72
+ return "created";
57
73
  }
58
- const value = [...defaultValue];
59
- settings.fallbackModel = value;
74
+ if (isPreviousDefault(existing) && !sameChain(existing, defaultValue)) {
75
+ return "upgraded";
76
+ }
77
+ return "noop";
78
+ }
79
+
80
+ // Atomic write via .tmp + rename; false on any failure (the seeder never raises).
81
+ function writeSettingsAtomic(settingsPath, settings) {
60
82
  const tmp = `${settingsPath}.tmp-${process.pid}`;
61
83
  try {
62
84
  writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
63
85
  renameSync(tmp, settingsPath);
86
+ return true;
64
87
  } catch {
88
+ return false;
89
+ }
90
+ }
91
+
92
+ export function seedFallbackModel({
93
+ runtime = "claude-code",
94
+ home = homedir(),
95
+ defaultValue = DEFAULT_FALLBACK_MODELS,
96
+ } = {}) {
97
+ if (runtime !== "claude-code") {
98
+ return { skipped: "runtime-not-claude-code", action: null };
99
+ }
100
+ const settingsPath = join(home, ".claude", "settings.json");
101
+ const loaded = readSettingsObject(settingsPath);
102
+ if (loaded.skipped) {
103
+ return { skipped: loaded.skipped, action: null };
104
+ }
105
+ const { settings } = loaded;
106
+ const existing = settings.fallbackModel;
107
+ const action = seedAction(existing, defaultValue);
108
+ if (action === "noop") {
109
+ return { skipped: null, action, value: existing };
110
+ }
111
+ const value = [...defaultValue];
112
+ settings.fallbackModel = value;
113
+ if (!writeSettingsAtomic(settingsPath, settings)) {
65
114
  return { skipped: "write-failed", action: null };
66
115
  }
67
- return { skipped: null, action: "created", value };
116
+ return action === "upgraded"
117
+ ? { skipped: null, action, value, previous: existing }
118
+ : { skipped: null, action, value };
68
119
  }
@@ -433,15 +433,18 @@ export async function install({ runtime, path, force, skipSystem, withOllama, pr
433
433
  detail(` Warning: could not seed worktree.baseRef (${err.message})`);
434
434
  }
435
435
 
436
- // Runtime Sync PR3 — seed fallbackModel = ["claude-opus-5", "claude-sonnet-5"]
437
- // so an overloaded or unavailable primary degrades instead of failing the
438
- // session. If-absent only: an operator chain (array or legacy string) is
439
- // never touched.
436
+ // Runtime Sync PR3 — seed fallbackModel = DEFAULT_FALLBACK_MODELS (see
437
+ // fallback-model.js) so an overloaded or unavailable primary degrades
438
+ // instead of failing the session. Absent keys are seeded and a chain that
439
+ // is exactly a previous ArkaOS default is upgraded; an operator chain
440
+ // (array or legacy string) is never touched.
440
441
  try {
441
442
  const { seedFallbackModel } = await import("./fallback-model.js");
442
443
  const fbResult = seedFallbackModel({ runtime });
443
444
  if (!fbResult.skipped && fbResult.action === "created") {
444
445
  detail(` fallbackModel set to ${JSON.stringify(fbResult.value)}.`);
446
+ } else if (!fbResult.skipped && fbResult.action === "upgraded") {
447
+ detail(` fallbackModel upgraded from ${JSON.stringify(fbResult.previous)} to ${JSON.stringify(fbResult.value)}.`);
445
448
  }
446
449
  } catch (err) {
447
450
  detail(` Warning: could not seed fallbackModel (${err.message})`);
@@ -244,13 +244,16 @@ export async function update({ skillsFlag = "" } = {}) {
244
244
  warn(`Output style update failed (${err.message})`);
245
245
  }
246
246
  // Runtime Sync PR3 — seed fallbackModel on update too, so machines that
247
- // installed before the chain existed get it. If-absent only: an operator
248
- // chain (array, empty array or legacy string) is never touched.
247
+ // installed before the chain existed get it, and a chain that is exactly
248
+ // a previous ArkaOS default moves to the current one. An operator chain
249
+ // (array, empty array or legacy string) is never touched.
249
250
  try {
250
251
  const { seedFallbackModel } = await import("./fallback-model.js");
251
252
  const fbResult = seedFallbackModel({ runtime: manifest.runtime || "claude-code" });
252
253
  if (!fbResult.skipped && fbResult.action === "created") {
253
254
  ok(`fallbackModel set to ${JSON.stringify(fbResult.value)}`);
255
+ } else if (!fbResult.skipped && fbResult.action === "upgraded") {
256
+ ok(`fallbackModel upgraded from ${JSON.stringify(fbResult.previous)} to ${JSON.stringify(fbResult.value)}`);
254
257
  }
255
258
  } catch (err) {
256
259
  warn(`fallbackModel seed failed (${err.message})`);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "_meta": {
3
3
  "generator": "scripts/marketplace_gen.py",
4
- "version": "5.17.1",
4
+ "version": "5.17.2",
5
5
  "marketplace": "arkaos"
6
6
  },
7
7
  "structural": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "5.17.1",
3
+ "version": "5.17.2",
4
4
  "description": "The Operating System for AI Agent Teams",
5
5
  "type": "module",
6
6
  "bin": {
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arkaos-core"
3
- version = "5.17.1"
3
+ version = "5.17.2"
4
4
  description = "Core engine for ArkaOS — The Operating System for AI Agent Teams"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}