@ludi-uni/ludi-agent-kit 0.1.0 → 0.1.1

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/README.md CHANGED
@@ -77,7 +77,8 @@ Requirements for local validation: Windows, PowerShell 7, Git and Node.js with `
77
77
  ## Executable path (Phase 2)
78
78
 
79
79
  ```powershell
80
- Copy-Item adapters/pi/models.local.example.json adapters/pi/models.local.json # then edit with `pi --list-models` ids
80
+ New-Item -ItemType Directory -Force "$HOME/.pi/agent/ludi-agent-kit" | Out-Null
81
+ Copy-Item adapters/pi/models.local.example.json "$HOME/.pi/agent/ludi-agent-kit/models.local.json" # edit with `pi --list-models` ids; use $env:PI_CODING_AGENT_DIR/ludi-agent-kit if set
81
82
  node scripts/resolve-capabilities.mjs # chains + out/settings.proposal.json
82
83
  node scripts/run-pipeline.mjs --repo <fixture-copy> --task "Fix the failing test" --dry-run
83
84
  node scripts/run-pipeline.mjs --repo <fixture-copy> --task "Fix the failing test" # real: scout -> pack -> coder -> tests
@@ -90,7 +91,7 @@ never the same modelId twice, failures appended to `previous_attempts`. See `doc
90
91
 
91
92
  ## Orchestration and safety
92
93
 
93
- `node scripts/orchestrate.mjs --dry-run "<request>"` previews a rules-based plan without launching agents. A live invocation can call external models and consume quota; review the plan, model availability and `docs/orchestrator.md` before using it. `sync-pi.ps1` is dry-run by default; `-Apply` writes to the chosen pi agent directory. The optional real E2E scripts are not part of routine validation.
94
+ `node scripts/orchestrate.mjs --dry-run "<request>"` previews a rules-based plan without launching agents. A live invocation can call external models and consume quota; review the plan, model availability and `docs/orchestrator.md` before using it. Before creating a run, the orchestrator checks that each planned capability has at least one concrete model binding (and checks `orchestration` before model planning); a missing binding reports the user-level file to edit. This is a binding check, not an authentication/quota probe. `sync-pi.ps1` is dry-run by default; `-Apply` writes to the chosen pi agent directory. The optional real E2E scripts are not part of routine validation.
94
95
 
95
96
  ## Distribution checklist
96
97
 
@@ -6,7 +6,8 @@ Binds the neutral kit to a native-Windows pi installation (`~/.pi/agent`, or
6
6
  | File | Role |
7
7
  | --- | --- |
8
8
  | `models.json` | shared TEMPLATE: logical backend -> pi `provider`/`model`/`thinking`. Ships with `TODO-*` placeholders that the resolver skips. |
9
- | `models.local.json` | **gitignored** machine-local bindings; overrides `models.json` per backend and may add backends. Copy `models.local.example.json` and fill from `pi --list-models`. Allowed keys: `provider, model, thinking, vision, note`. Credentials are rejected by the validator. |
9
+ | `~/.pi/agent/ludi-agent-kit/models.local.json` | **Recommended durable user-level bindings**, outside the npm install (use `$env:PI_CODING_AGENT_DIR/ludi-agent-kit/models.local.json` when set). Copy `models.local.example.json` and fill from `pi --list-models`. Overrides package-local bindings per field and may add backends. Allowed keys: `provider, model, thinking, vision, note`; credentials are rejected. The kit reads this file but never writes it. |
10
+ | `models.local.json` | **gitignored package-local migration fallback**; still overrides `models.json` when present, but is overridden by the user-level file. Existing machine-local files continue to work without migration; a package upgrade may remove this file. |
10
11
  | `lib/invoke.mjs` | one-shot model call through the installed pi CLI (`node <pi cli.js> -p --model provider/id:thinking --no-tools --no-session --no-approve --system-prompt ...`). Uses pi's own auth store. Verified against `qoder/Qwen3.8-Flash` and `devin/swe-2-high` — no provider-specific runner is needed; any provider registered in pi's model store works. |
11
12
  | `lib/subagent.mjs` | tool-capable child (`pi --mode json -p`). **Do not pass `--no-extensions`**: providers registered by pi extensions (qoder, devin) are absent from the static model store and only resolve once their extension has loaded; with `--no-extensions` `--model` fails `Model ... not found`. Kit extensions the child needs are passed explicitly (`-e shell-gate`). |
12
13
  | `lib/settings-proposal.mjs` | renders resolved agents into `subagents.agentOverrides.<agent>.{model,thinking}` — the shape verified in pi-subagents 0.68.0 (`docs/models.md`, `src/agents/agents.ts parseBuiltinOverrideEntry`). pi-subagents removed `fallbackModels`, so fallback chains are *not* expressible there; the kit runner owns escalation. |
@@ -25,14 +26,15 @@ Binds the neutral kit to a native-Windows pi installation (`~/.pi/agent`, or
25
26
  ```
26
27
  agents/<name>.md capability
27
28
  -> routing/routing.json primary + fallback backends
28
- -> models.json + models.local.json provider/model/thinking per backend
29
+ -> models.json < package-local models.local.json < user-level models.local.json
30
+ provider/model/thinking per backend (later source overrides fields)
29
31
  -> "provider/model:thinking" (pi --model syntax / pi-subagents model syntax)
30
32
  ```
31
33
 
32
34
  Logical backends include `qoder` (cheap-first, bound to `qoder/Qwen3.8-Flash` on this
33
35
  machine) and `devin` (strong-first, bound to `devin/swe-2-high`). Both are ordinary
34
36
  pi providers — the kit never forks a runner per provider; bindings live only in
35
- `models.local.json` and facts in `model-catalog.json`. If Codex quota is exhausted,
37
+ the user-level `models.local.json` (or legacy package-local file) and facts in `model-catalog.json`. If Codex quota is exhausted,
36
38
  cheap-code still resolves `qoder -> cheap -> local -> sol` and strong-code
37
39
  `devin -> qoder -> sol -> codex -> local`, so the orchestrator keeps running on
38
40
  Qoder/Devin/FreeToken without any code change.
@@ -0,0 +1,12 @@
1
+ // Pi's durable model bindings live in the user agent directory, outside the npm package.
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { loadRegistry } from '../../../lib/registry.mjs';
5
+
6
+ export function piUserModelsPath(env = process.env, home = homedir()) {
7
+ return join(env.PI_CODING_AGENT_DIR || join(home, '.pi', 'agent'), 'ludi-agent-kit', 'models.local.json');
8
+ }
9
+
10
+ export function loadPiRegistry(kit, routing, { env = process.env, home = homedir() } = {}) {
11
+ return loadRegistry(join(kit, 'adapters/pi/models.json'), join(kit, 'adapters/pi/models.local.json'), routing, piUserModelsPath(env, home));
12
+ }
@@ -12,7 +12,7 @@ automatically.
12
12
  | --- | --- | --- |
13
13
  | routing | `routing/routing.json` | capabilities, backend tiers, `requires.vision` |
14
14
  | agents | `agents/*.md` | capability → agent names (for `affected.agents`) |
15
- | model bindings | `adapters/<x>/models.json` + `models.local.json` | current provider/model per backend |
15
+ | model bindings | `adapters/pi/models.json` < legacy `adapters/pi/models.local.json` < `~/.pi/agent/ludi-agent-kit/models.local.json` (`PI_CODING_AGENT_DIR` respected) | current provider/model per backend; user-level overrides survive npm upgrades |
16
16
  | catalog | `adapters/<x>/model-catalog.json` | model facts: status, cost, contextK, vision, toolUse, quality scores, `location`/`local` power metadata |
17
17
  | exec policy | `adapters/<x>/maintenance-policy.json` | Phase 2: requiredQuality per tier, capabilityRequirements (coding/reasoning floors), cost weights, electricity price, escalation thresholds |
18
18
  | events | `--events <file>` | what changed (see event types below) |
@@ -86,7 +86,7 @@ binding embedded plus the manual steps to restore it.
86
86
  ## Applying and rolling back
87
87
 
88
88
  The task writes only `adapters/<x>/out/model-maintenance.proposal.json`. To apply:
89
- edit `adapters/<x>/models.local.json` per `proposedModel`, re-run
89
+ edit the user-level `ludi-agent-kit/models.local.json` per `proposedModel`, re-run
90
90
  `scripts/resolve-capabilities.mjs`, then merge `out/settings.proposal.json` into
91
91
  `~/.pi/agent/settings.json` yourself. Rollback is the same flow using
92
92
  `rollback.previousBinding`. It never writes `settings.json`, `models.json`,
@@ -11,6 +11,8 @@ node scripts/orchestrate.mjs --repo <dir> --apply "..." # coder t
11
11
  [--planner rules|model] [--policy <file>] [--out <dir>] [--json] [--trace]
12
12
  ```
13
13
 
14
+ Before starting a persistent run, the planner's capability (for `--planner model`) and every capability in the validated task plan must have at least one non-placeholder binding. Otherwise the call fails without creating a run and reports the user-level `models.local.json` path. Existing runs can still be listed/shown; resuming pending tasks checks their bindings. The check does not guarantee provider authentication, quota, or model availability. Configure `~/.pi/agent/ludi-agent-kit/models.local.json` (or `$env:PI_CODING_AGENT_DIR/ludi-agent-kit/models.local.json`) and see `adapters/pi/README.md` for migration precedence.
15
+
14
16
  ## Response language
15
17
 
16
18
  All agents default to **Japanese** for user-facing output (summaries, reports, decisions,
@@ -2,7 +2,7 @@
2
2
  // the caller supplies `invoke` / `runner`. Natural-language UI can call these functions later.
3
3
  import { join } from 'node:path';
4
4
  import { loadRouting } from '../routing.mjs';
5
- import { loadRegistry } from '../registry.mjs';
5
+ import { loadPiRegistry, piUserModelsPath } from '../../adapters/pi/lib/model-registry.mjs';
6
6
  import { loadAgents } from '../agents.mjs';
7
7
  import { loadPolicy } from './policy.mjs';
8
8
  import { openStore } from './store.mjs';
@@ -15,15 +15,15 @@ export function defaultStorePath(kit, env = process.env) {
15
15
  return env.LUDI_ORCHESTRATION_STORE || join(kit, '.orchestration', 'state.db');
16
16
  }
17
17
 
18
- export function loadOrchestrationContext({ kit, storePath, policyPath = null, localPolicyPath = null, clientContext = null }) {
18
+ export function loadOrchestrationContext({ kit, storePath, policyPath = null, localPolicyPath = null, clientContext = null, modelOptions = {} }) {
19
19
  const routing = loadRouting(join(kit, 'routing/routing.json'));
20
- const { registry } = loadRegistry(join(kit, 'adapters/pi/models.json'), join(kit, 'adapters/pi/models.local.json'), routing);
20
+ const { registry } = loadPiRegistry(kit, routing, modelOptions);
21
21
  const { agents, errors } = loadAgents(join(kit, 'agents'), routing);
22
22
  const base = join(kit, 'orchestration/decision-policy.json');
23
23
  const { policy } = loadPolicy(base, policyPath ?? localPolicyPath);
24
24
  const session = openStore(storePath);
25
25
  const activity = createActivityTracker({ kit, clientContext });
26
- return { kit, routing, registry, agents, errors, policy, session, storePath, clientContext, activity };
26
+ return { kit, routing, registry, agents, errors, policy, session, storePath, clientContext, activity, bindingPath: piUserModelsPath(modelOptions.env, modelOptions.home) };
27
27
  }
28
28
 
29
29
  /** Public live activity for one run (in-memory projection; null when unknown). */
@@ -137,14 +137,14 @@ export function createRunRunner(ctx, { invoke, runSubagent = null, repoRoot, out
137
137
  export async function startOrchestration(ctx, { request, repoRoot = null, planner = 'rules', plan = null, runner, invoke = null, health = null, projectStore }) {
138
138
  return orchestrate({
139
139
  request, planner, plan, agents: ctx.agents, routing: ctx.routing, registry: ctx.registry, policy: ctx.policy,
140
- runner, invoke, repoRoot, session: ctx.session, health, projectStore, activity: ctx.activity,
140
+ runner, invoke, repoRoot, session: ctx.session, health, projectStore, activity: ctx.activity, bindingPath: ctx.bindingPath,
141
141
  });
142
142
  }
143
143
 
144
144
  export async function resumeOrchestration(ctx, { runId, answers = [], repoRoot = null, runner, invoke = null, health = null, projectStore }) {
145
145
  return orchestrate({
146
146
  request: '', resumeRunId: runId, answers, agents: ctx.agents, routing: ctx.routing, registry: ctx.registry,
147
- policy: ctx.policy, runner, invoke, repoRoot, session: ctx.session, health, projectStore, activity: ctx.activity,
147
+ policy: ctx.policy, runner, invoke, repoRoot, session: ctx.session, health, projectStore, activity: ctx.activity, bindingPath: ctx.bindingPath,
148
148
  });
149
149
  }
150
150
 
@@ -8,6 +8,7 @@ import { evaluateResult } from './evaluator.mjs';
8
8
  import { evaluateDecision, decisionKey } from './escalation.mjs';
9
9
  import { classifyRun, REASSIGN_CLASSES, isProtocolFailure, shouldMarkTaskGlobalFailure } from './failures.mjs';
10
10
  import { declaredMode, accessOf, piToolsForAccess, workspaceOf } from './permissions.mjs';
11
+ import { resolveCapability } from '../resolve.mjs';
11
12
 
12
13
  const STOP = new Set(['failed', 'blocked', 'waiting_for_user']);
13
14
 
@@ -87,7 +88,7 @@ export async function orchestrate(options) {
87
88
  }
88
89
  }
89
90
 
90
- async function orchestrateImpl({ request: requestArg, planner = 'rules', plan = null, agents, routing, registry, policy, runner, invoke = null, repoRoot = null, projectStore = createNullProjectStore(), now = () => new Date().toISOString(), session = null, resumeRunId = null, answers = [], health = null, activity = null, onRunCreated = () => {} }) {
91
+ async function orchestrateImpl({ request: requestArg, planner = 'rules', plan = null, agents, routing, registry, policy, runner, invoke = null, repoRoot = null, projectStore = createNullProjectStore(), now = () => new Date().toISOString(), session = null, resumeRunId = null, answers = [], health = null, activity = null, bindingPath = null, onRunCreated = () => {} }) {
91
92
  let request = requestArg;
92
93
  let activePolicy = policy;
93
94
  let dp = activePolicy.decision_policy, limits = activePolicy.limits;
@@ -95,6 +96,13 @@ async function orchestrateImpl({ request: requestArg, planner = 'rules', plan =
95
96
  const trace = [], autoDecisions = [], escalations = [], unresolved = [], decisionLog = [];
96
97
  const limitsHit = new Set();
97
98
  let store = null, planning = true, recovered = [];
99
+ const requireModels = capabilities => {
100
+ for (const capability of new Set(capabilities)) {
101
+ if (!resolveCapability(routing, registry, capability).candidates.length) {
102
+ throw new Error(`No bound model for required capability "${capability}". Set a provider/model binding in ${bindingPath ?? 'your adapter models.local.json'} (see adapters/pi/models.local.example.json).`);
103
+ }
104
+ }
105
+ };
98
106
  const scopeKeyOf = () => repoRoot ?? (runId ? session?.getRun(runId)?.scopeKey : null) ?? 'default';
99
107
  // Live-activity bridge: the tracker decides whether an event type is significant
100
108
  // (persist to the run trace) or high-frequency (live snapshot only, e.g. turns).
@@ -141,6 +149,7 @@ async function orchestrateImpl({ request: requestArg, planner = 'rules', plan =
141
149
  if (res.idempotent && !res.same) unresolved.push(`decision ${a.decisionId} already answered; kept the first answer`);
142
150
  }
143
151
  store = session.openTaskStore(runId);
152
+ requireModels(store.list().filter(t => !['completed', 'failed', 'cancelled'].includes(t.status)).map(t => t.capability));
144
153
  releaseRecoverable(store);
145
154
  planning = store.size() === 0;
146
155
  for (const d of session.listDecisions(runId, 'pending')) escalations.push(toEscalation(d));
@@ -149,17 +158,18 @@ async function orchestrateImpl({ request: requestArg, planner = 'rules', plan =
149
158
 
150
159
  try {
151
160
  if (planning) {
161
+ if (!plan && planner === 'model') requireModels(['orchestration']);
162
+ p = await makePlan(request, { planner, plan, agents, routing, registry, policy: activePolicy, invoke, cwd: repoRoot ?? process.cwd(), trace, health });
163
+ const v = validatePlan(p.tasks, { agents, routing, policy: activePolicy });
164
+ if (!v.errors.length) requireModels(v.tasks.map(t => t.capability));
152
165
  if (session && !runId) {
153
166
  runId = session.createRun({ request, policy, repoRoot, planner, scopeKey: repoRoot ?? 'default' });
154
167
  onRunCreated(runId);
155
- // Bind the owning client immediately so its public snapshot exists from the start.
156
168
  activity?.bindRun?.({ runId, repoRoot });
157
169
  }
158
170
  health?.bindRun?.(runId);
159
171
  store = store ?? (session ? session.openTaskStore(runId) : createTaskStore());
160
- p = await makePlan(request, { planner, plan, agents, routing, registry, policy: activePolicy, invoke, cwd: repoRoot ?? process.cwd(), trace, health });
161
172
  if (p.fallbackReason) decide({ subject: 'planner', choice: 'rules planner', reason: `model plan unusable (${p.fallbackReason}); reversible`, step: 'reversible' });
162
- const v = validatePlan(p.tasks, { agents, routing, policy: activePolicy });
163
173
  event('plan', { planner: p.planner, tasks: v.tasks.map(t => ({ id: t.id, agent: t.assignedAgent, capability: t.capability, dependencies: t.dependencies })), errors: v.errors });
164
174
  if (v.errors.length) return finish('plan-invalid', v.errors);
165
175
  for (const t of v.tasks) {
package/lib/registry.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // Adapter model registry: models.json (shared template) overlaid by models.local.json (gitignored).
1
+ // Adapter model registry: shared template, package-local migration file, then durable user bindings.
2
2
  // Validates against routing backends. Contains no provider/model names.
3
3
  import { readFileSync, existsSync } from 'node:fs';
4
4
 
@@ -46,10 +46,10 @@ export function isPlaceholder(binding) {
46
46
  }
47
47
 
48
48
  /**
49
- * Load <adapterDir>/models.json overlaid with <adapterDir>/models.local.json (if present).
50
- * Throws on validation errors of either file or of the merged result.
49
+ * Load the template, then optional package-local and user-level bindings (highest priority).
50
+ * Throws on validation errors in any present file.
51
51
  */
52
- export function loadRegistry(modelsPath, localPath, routing) {
52
+ export function loadRegistry(modelsPath, localPath, routing, userPath = null) {
53
53
  const base = JSON.parse(readFileSync(modelsPath, 'utf8'));
54
54
  const errors = validateRegistry(base, routing, 'models.json');
55
55
  let local = null;
@@ -57,7 +57,12 @@ export function loadRegistry(modelsPath, localPath, routing) {
57
57
  local = JSON.parse(readFileSync(localPath, 'utf8'));
58
58
  errors.push(...validateRegistry(local, routing, 'models.local.json'));
59
59
  }
60
+ let user = null;
61
+ if (userPath && existsSync(userPath)) {
62
+ user = JSON.parse(readFileSync(userPath, 'utf8'));
63
+ errors.push(...validateRegistry(user, routing, userPath));
64
+ }
60
65
  if (errors.length) throw new Error(errors.join('\n'));
61
- const merged = mergeRegistries(base, local);
62
- return { registry: merged, sources: { models: modelsPath, local: local ? localPath : null } };
66
+ const merged = mergeRegistries(mergeRegistries(base, local), user);
67
+ return { registry: merged, sources: { models: modelsPath, local: local ? localPath : null, user: user ? userPath : null } };
63
68
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ludi-uni/ludi-agent-kit",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Windows-first, backend-neutral agent kit with Pi skills and extensions",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -10,7 +10,7 @@ import { join, resolve, dirname } from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
11
  import assert from 'node:assert/strict';
12
12
  import { loadRouting } from '../lib/routing.mjs';
13
- import { loadRegistry } from '../lib/registry.mjs';
13
+ import { loadPiRegistry } from '../adapters/pi/lib/model-registry.mjs';
14
14
  import { loadAgents } from '../lib/agents.mjs';
15
15
  import { loadPolicy, mergePolicy } from '../lib/orchestrator/policy.mjs';
16
16
  import { openStore } from '../lib/orchestrator/store.mjs';
@@ -35,7 +35,7 @@ const plan = [
35
35
  ];
36
36
 
37
37
  const routing = loadRouting(join(kit, 'routing/routing.json'));
38
- const { registry } = loadRegistry(join(kit, 'adapters/pi/models.json'), join(kit, 'adapters/pi/models.local.json'), routing);
38
+ const { registry } = loadPiRegistry(kit, routing);
39
39
  const { agents } = loadAgents(join(kit, 'agents'), routing);
40
40
  const { policy } = loadPolicy(join(kit, 'orchestration/decision-policy.json'));
41
41
  const active = mergePolicy(policy, { limits: { max_retries: 1 }, agent_runtime: { max_runtime_ms: 300000, max_tool_calls: 25, max_turns: 8 } });
@@ -18,6 +18,7 @@ import { resolve, join, dirname } from 'node:path';
18
18
  import { fileURLToPath } from 'node:url';
19
19
  import { loadRouting } from '../lib/routing.mjs';
20
20
  import { loadRegistry } from '../lib/registry.mjs';
21
+ import { piUserModelsPath } from '../adapters/pi/lib/model-registry.mjs';
21
22
  import { loadAgents } from '../lib/agents.mjs';
22
23
  import { loadCatalog } from '../lib/maintenance.mjs';
23
24
  import { loadExecPolicy } from '../lib/maintenance-exec.mjs';
@@ -32,7 +33,7 @@ const adapterDir = join(kit, 'adapters', adapter);
32
33
  const outDir = join(adapterDir, 'out');
33
34
 
34
35
  const routing = loadRouting(join(kit, 'routing/routing.json'));
35
- const { registry } = loadRegistry(join(adapterDir, 'models.json'), join(adapterDir, 'models.local.json'), routing);
36
+ const { registry } = loadRegistry(join(adapterDir, 'models.json'), join(adapterDir, 'models.local.json'), routing, adapter === 'pi' ? piUserModelsPath() : null);
36
37
  const { agents, errors } = loadAgents(join(kit, 'agents'), routing);
37
38
  if (errors.length) { console.error(errors.join('\n')); process.exit(1); }
38
39
  const catalog = loadCatalog(join(adapterDir, 'model-catalog.json'));
@@ -17,6 +17,7 @@ import { resolve, join, dirname } from 'node:path';
17
17
  import { fileURLToPath } from 'node:url';
18
18
  import { loadRouting } from '../lib/routing.mjs';
19
19
  import { loadRegistry } from '../lib/registry.mjs';
20
+ import { piUserModelsPath } from '../adapters/pi/lib/model-registry.mjs';
20
21
  import { loadAgents } from '../lib/agents.mjs';
21
22
  import { loadCatalog } from '../lib/maintenance.mjs';
22
23
  import { ingestObservations, loadObservationStore, productionObservations } from '../lib/observe/observation.mjs';
@@ -80,7 +81,7 @@ let previewFile = null;
80
81
  if (has('preview-maintenance')) {
81
82
  const hypothetical = applyProposalToCatalog(catalog, proposal);
82
83
  const routing = loadRouting(join(kit, 'routing/routing.json'));
83
- const { registry } = loadRegistry(join(adapterDir, 'models.json'), join(adapterDir, 'models.local.json'), routing);
84
+ const { registry } = loadRegistry(join(adapterDir, 'models.json'), join(adapterDir, 'models.local.json'), routing, adapter === 'pi' ? piUserModelsPath() : null);
84
85
  const { agents, errors } = loadAgents(join(kit, 'agents'), routing);
85
86
  if (errors.length) { console.error(errors.join('\n')); process.exit(1); }
86
87
  const policy = loadExecPolicy(join(adapterDir, 'maintenance-policy.json'));
@@ -18,7 +18,8 @@ import { resolve, join, dirname } from 'node:path';
18
18
  import { fileURLToPath } from 'node:url';
19
19
  import { mkdirSync, writeFileSync } from 'node:fs';
20
20
  import { loadRouting } from '../lib/routing.mjs';
21
- import { loadRegistry } from '../lib/registry.mjs';
21
+ import { loadPiRegistry, piUserModelsPath } from '../adapters/pi/lib/model-registry.mjs';
22
+ import { resolveCapability } from '../lib/resolve.mjs';
22
23
  import { loadAgents } from '../lib/agents.mjs';
23
24
  import { loadPolicy } from '../lib/orchestrator/policy.mjs';
24
25
  import { dryRun, formatPlan } from '../lib/orchestrator/orchestrator.mjs';
@@ -63,7 +64,7 @@ const planner = opts.planner ?? 'rules';
63
64
  if (!['rules', 'model'].includes(planner)) { console.error(`unknown planner "${planner}"`); process.exit(2); }
64
65
 
65
66
  const routing = loadRouting(join(kit, 'routing/routing.json'));
66
- const { registry } = loadRegistry(join(kit, 'adapters/pi/models.json'), join(kit, 'adapters/pi/models.local.json'), routing);
67
+ const { registry } = loadPiRegistry(kit, routing);
67
68
  const { agents, errors } = loadAgents(join(kit, 'agents'), routing);
68
69
  if (errors.length) { console.error(errors.join('\n')); process.exit(1); }
69
70
  const { policy } = opts.policy
@@ -74,6 +75,7 @@ const outDir = resolve(opts.out ?? join(kit, 'adapters/pi/out/orchestrate'));
74
75
 
75
76
  if (opts['dry-run'] && !cleanupMode) {
76
77
  const invoke = planner === 'model' ? createPiInvoker() : null;
78
+ if (planner === 'model' && !resolveCapability(routing, registry, 'orchestration').candidates.length) throw new Error(`No bound model for orchestration; edit ${piUserModelsPath()}`);
77
79
  const dry = await dryRun(request, { planner, agents, routing, registry, policy, invoke, cwd: repoRoot ?? process.cwd() });
78
80
  console.log(opts.json ? JSON.stringify(dry, null, 2) : formatPlan(dry));
79
81
  process.exitCode = dry.errors.length ? 1 : 0;
@@ -25,6 +25,7 @@ import { resolve, join, dirname } from 'node:path';
25
25
  import { fileURLToPath } from 'node:url';
26
26
  import { loadRouting } from '../lib/routing.mjs';
27
27
  import { loadRegistry } from '../lib/registry.mjs';
28
+ import { piUserModelsPath } from '../adapters/pi/lib/model-registry.mjs';
28
29
  import { loadAgents } from '../lib/agents.mjs';
29
30
  import { loadCatalog, loadEvents, loadAvailabilityFile } from '../lib/maintenance.mjs';
30
31
  import { runMaintenancePlan, loadExecPolicy } from '../lib/maintenance-exec.mjs';
@@ -37,7 +38,7 @@ const adapter = opt('adapter', 'pi');
37
38
  const adapterDir = join(kit, 'adapters', adapter);
38
39
 
39
40
  const routing = loadRouting(join(kit, 'routing/routing.json'));
40
- const { registry, sources } = loadRegistry(join(adapterDir, 'models.json'), join(adapterDir, 'models.local.json'), routing);
41
+ const { registry, sources } = loadRegistry(join(adapterDir, 'models.json'), join(adapterDir, 'models.local.json'), routing, adapter === 'pi' ? piUserModelsPath() : null);
41
42
  const { agents, errors } = loadAgents(join(kit, 'agents'), routing);
42
43
  if (errors.length) { console.error(errors.join('\n')); process.exit(1); }
43
44
  const catalog = loadCatalog(join(adapterDir, 'model-catalog.json'));
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url';
8
8
  import { homedir } from 'node:os';
9
9
  import { loadRouting } from '../lib/routing.mjs';
10
10
  import { loadRegistry } from '../lib/registry.mjs';
11
+ import { piUserModelsPath } from '../adapters/pi/lib/model-registry.mjs';
11
12
  import { loadAgents } from '../lib/agents.mjs';
12
13
  import { resolveAgents, resolveCapability } from '../lib/resolve.mjs';
13
14
 
@@ -17,7 +18,7 @@ const opt = (name, def) => { const i = args.indexOf(`--${name}`); return i >= 0
17
18
  const adapter = opt('adapter', 'pi');
18
19
  const adapterDir = join(kit, 'adapters', adapter);
19
20
  const routing = loadRouting(join(kit, 'routing/routing.json'));
20
- const { registry, sources } = loadRegistry(join(adapterDir, 'models.json'), join(adapterDir, 'models.local.json'), routing);
21
+ const { registry, sources } = loadRegistry(join(adapterDir, 'models.json'), join(adapterDir, 'models.local.json'), routing, adapter === 'pi' ? piUserModelsPath() : null);
21
22
  const { agents, errors } = loadAgents(join(kit, 'agents'), routing);
22
23
  if (errors.length) { console.error(errors.join('\n')); process.exit(1); }
23
24
 
@@ -8,7 +8,7 @@ import { resolve, join, dirname } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { writeFileSync, mkdirSync, readFileSync } from 'node:fs';
10
10
  import { loadRouting } from '../lib/routing.mjs';
11
- import { loadRegistry } from '../lib/registry.mjs';
11
+ import { loadPiRegistry } from '../adapters/pi/lib/model-registry.mjs';
12
12
  import { loadAgents } from '../lib/agents.mjs';
13
13
  import { resolveAgents, resolveCapability } from '../lib/resolve.mjs';
14
14
  import { runPipeline, surveyRepo, scoutPrompt, detectTestCommand, runTests, coderPrompt } from '../lib/pipeline.mjs';
@@ -25,7 +25,7 @@ const outDir = resolve(opt('out', join(kit, 'adapters/pi/out/pipeline')));
25
25
  const maxAttempts = Number(opt('max-attempts', 2));
26
26
 
27
27
  const routing = loadRouting(join(kit, 'routing/routing.json'));
28
- const { registry, sources } = loadRegistry(join(kit, 'adapters/pi/models.json'), join(kit, 'adapters/pi/models.local.json'), routing);
28
+ const { registry, sources } = loadPiRegistry(kit, routing);
29
29
  const { agents, errors } = loadAgents(join(kit, 'agents'), routing);
30
30
  if (errors.length) { console.error(errors.join('\n')); process.exit(1); }
31
31
  const resolved = resolveAgents(agents, routing, registry);
@@ -9,7 +9,7 @@ import { join, resolve, dirname } from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import assert from 'node:assert/strict';
11
11
  import { loadRouting } from '../lib/routing.mjs';
12
- import { loadRegistry } from '../lib/registry.mjs';
12
+ import { loadPiRegistry } from '../adapters/pi/lib/model-registry.mjs';
13
13
  import { loadAgents } from '../lib/agents.mjs';
14
14
  import { loadPolicy, mergePolicy } from '../lib/orchestrator/policy.mjs';
15
15
  import { openStore } from '../lib/orchestrator/store.mjs';
@@ -38,7 +38,7 @@ const plan = [
38
38
 
39
39
  function context(repo) {
40
40
  const routing = loadRouting(join(kit, 'routing/routing.json'));
41
- const { registry } = loadRegistry(join(kit, 'adapters/pi/models.json'), join(kit, 'adapters/pi/models.local.json'), routing);
41
+ const { registry } = loadPiRegistry(kit, routing);
42
42
  const { agents } = loadAgents(join(kit, 'agents'), routing);
43
43
  const { policy } = loadPolicy(join(kit, 'orchestration/decision-policy.json'));
44
44
  // Stay on cheap-code so a hosted usage limit falls through to the local candidate instead of the hosted-only ladder.
@@ -8,7 +8,7 @@ import { join, resolve, dirname } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import assert from 'node:assert/strict';
10
10
  import { loadRouting } from '../lib/routing.mjs';
11
- import { loadRegistry } from '../lib/registry.mjs';
11
+ import { loadPiRegistry } from '../adapters/pi/lib/model-registry.mjs';
12
12
  import { loadAgents } from '../lib/agents.mjs';
13
13
  import { loadPolicy } from '../lib/orchestrator/policy.mjs';
14
14
  import { createAgentRunner } from '../lib/orchestrator/runner.mjs';
@@ -17,7 +17,7 @@ import { createPiInvoker } from '../adapters/pi/lib/invoke.mjs';
17
17
 
18
18
  const kit = resolve(dirname(fileURLToPath(import.meta.url)), '..');
19
19
  const routing = loadRouting(join(kit, 'routing/routing.json'));
20
- const { registry } = loadRegistry(join(kit, 'adapters/pi/models.json'), join(kit, 'adapters/pi/models.local.json'), routing);
20
+ const { registry } = loadPiRegistry(kit, routing);
21
21
  const { agents } = loadAgents(join(kit, 'agents'), routing);
22
22
  const { policy } = loadPolicy(join(kit, 'orchestration/decision-policy.json'));
23
23
  const repo = mkdtempSync(join(tmpdir(), 'ludi-real-orch-'));
@@ -1,21 +1,21 @@
1
1
  // Opt-in real E2E through the installed pi CLI and the machine-local model registry.
2
2
  // Not part of `node --test`; it spends real model quota. Run:
3
3
  // node tests/e2e-real-pi.mjs
4
- // Requires adapters/pi/models.local.json with ready providers (`pi auth check --provider <p>`).
4
+ // Requires user-level (or legacy package-local) models.local.json with ready providers (`pi auth check --provider <p>`).
5
5
  import { mkdtempSync, cpSync } from 'node:fs';
6
6
  import { tmpdir } from 'node:os';
7
7
  import { join, resolve, dirname } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import assert from 'node:assert/strict';
10
10
  import { loadRouting } from '../lib/routing.mjs';
11
- import { loadRegistry } from '../lib/registry.mjs';
11
+ import { loadPiRegistry } from '../adapters/pi/lib/model-registry.mjs';
12
12
  import { loadAgents } from '../lib/agents.mjs';
13
13
  import { runPipeline, runTests } from '../lib/pipeline.mjs';
14
14
  import { createPiInvoker } from '../adapters/pi/lib/invoke.mjs';
15
15
 
16
16
  const kit = resolve(dirname(fileURLToPath(import.meta.url)), '..');
17
17
  const routing = loadRouting(join(kit, 'routing/routing.json'));
18
- const { registry } = loadRegistry(join(kit, 'adapters/pi/models.json'), join(kit, 'adapters/pi/models.local.json'), routing);
18
+ const { registry } = loadPiRegistry(kit, routing);
19
19
  const { agents } = loadAgents(join(kit, 'agents'), routing);
20
20
  const repo = mkdtempSync(join(tmpdir(), 'ludi-real-e2e-'));
21
21
  cpSync(join(kit, 'tests/fixtures/math-repo'), repo, { recursive: true });
@@ -9,7 +9,7 @@ import { join, resolve, dirname } from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import assert from 'node:assert/strict';
11
11
  import { loadRouting } from '../lib/routing.mjs';
12
- import { loadRegistry } from '../lib/registry.mjs';
12
+ import { loadPiRegistry } from '../adapters/pi/lib/model-registry.mjs';
13
13
  import { loadAgents } from '../lib/agents.mjs';
14
14
  import { loadPolicy, mergePolicy } from '../lib/orchestrator/policy.mjs';
15
15
  import { openStore } from '../lib/orchestrator/store.mjs';
@@ -37,7 +37,7 @@ const plan = [
37
37
  ];
38
38
 
39
39
  const routing = loadRouting(join(kit, 'routing/routing.json'));
40
- const { registry } = loadRegistry(join(kit, 'adapters/pi/models.json'), join(kit, 'adapters/pi/models.local.json'), routing);
40
+ const { registry } = loadPiRegistry(kit, routing);
41
41
  const { agents } = loadAgents(join(kit, 'agents'), routing);
42
42
  const { policy } = loadPolicy(join(kit, 'orchestration/decision-policy.json'));
43
43
  const active = mergePolicy(policy, { decision_policy: { reassign_on_failure: false }, limits: { max_retries: 1 }, agent_runtime: { max_runtime_ms: 180000, max_tool_calls: 25, max_turns: 8 } });
@@ -2,7 +2,7 @@
2
2
  import test from 'node:test';
3
3
  import assert from 'node:assert/strict';
4
4
  import { spawnSync } from 'node:child_process';
5
- import { mkdtempSync } from 'node:fs';
5
+ import { mkdtempSync, cpSync, mkdirSync, copyFileSync } from 'node:fs';
6
6
  import { tmpdir } from 'node:os';
7
7
  import { join, resolve, dirname } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
@@ -15,6 +15,7 @@ import { newTask } from '../lib/orchestrator/task-store.mjs';
15
15
  import { classifyBackendFailure, createHealthMonitor } from '../lib/orchestrator/health.mjs';
16
16
  import { evaluateDecision } from '../lib/orchestrator/escalation.mjs';
17
17
  import { orchestrate } from '../lib/orchestrator/orchestrator.mjs';
18
+ import { loadOrchestrationContext, startOrchestration } from '../lib/orchestrator/api.mjs';
18
19
 
19
20
  const kit = resolve(dirname(fileURLToPath(import.meta.url)), '..');
20
21
  const routing = loadRouting(join(kit, 'routing/routing.json'));
@@ -34,6 +35,42 @@ const completed = (task, extra = {}) => ({ ok: true, structured: true, modelId:
34
35
  const blocked = decisions => ({ ok: true, structured: true, result: { status: 'blocked', summary: 'need a choice', artifacts: [], verification: [], acceptance: [], remainingIssues: [], decisions, newTasks: [] } });
35
36
  const dbPath = () => join(mkdtempSync(join(tmpdir(), 'ludi-orch-')), 'state.db');
36
37
 
38
+ test('unbound task capability fails before a run is created with the user binding path', async () => {
39
+ const path = dbPath();
40
+ const session = openStore(path);
41
+ const userPath = join(dirname(path), 'agent', 'ludi-agent-kit', 'models.local.json');
42
+ try {
43
+ await assert.rejects(orchestrate({ request: 'implement', plan: [spec('a', 'coder')], agents, routing,
44
+ registry: { version: 1, backends: { devin: { provider: 'TODO-provider', model: 'TODO-model' } } },
45
+ bindingPath: userPath, policy: DEFAULT_POLICY, session, runner: { async run() { throw new Error('should not run'); } },
46
+ }), error => error.message.includes('strong-code') && error.message.includes(userPath));
47
+ assert.deepEqual(session.listRuns(), []);
48
+ await assert.rejects(orchestrate({ request: 'plan', planner: 'model', agents, routing,
49
+ registry: { version: 1, backends: {} }, bindingPath: userPath, policy: DEFAULT_POLICY, session,
50
+ invoke: async () => { throw new Error('should not plan'); },
51
+ }), /required capability "orchestration"/);
52
+ assert.deepEqual(session.listRuns(), []);
53
+ } finally { session.close(); }
54
+ });
55
+
56
+ test('API start uses the user-level binding path without touching the real agent directory', async () => {
57
+ const home = mkdtempSync(join(tmpdir(), 'ludi-model-home-'));
58
+ const isolatedKit = mkdtempSync(join(tmpdir(), 'ludi-model-kit-'));
59
+ cpSync(join(kit, 'routing'), join(isolatedKit, 'routing'), { recursive: true, filter: path => !path.endsWith('.local.json') });
60
+ cpSync(join(kit, 'agents'), join(isolatedKit, 'agents'), { recursive: true });
61
+ mkdirSync(join(isolatedKit, 'adapters', 'pi'), { recursive: true });
62
+ mkdirSync(join(isolatedKit, 'orchestration'), { recursive: true });
63
+ copyFileSync(join(kit, 'adapters/pi/models.json'), join(isolatedKit, 'adapters/pi/models.json'));
64
+ copyFileSync(join(kit, 'orchestration/decision-policy.json'), join(isolatedKit, 'orchestration/decision-policy.json'));
65
+ const ctx = loadOrchestrationContext({ kit: isolatedKit, storePath: dbPath(), modelOptions: { env: {}, home } });
66
+ try {
67
+ await assert.rejects(startOrchestration(ctx, { request: 'implement', plan: [spec('a', 'coder')],
68
+ runner: { async run() { throw new Error('should not run'); } } }),
69
+ error => error.message.includes(join(home, '.pi', 'agent', 'ludi-agent-kit', 'models.local.json')));
70
+ assert.deepEqual(ctx.session.listRuns(), []);
71
+ } finally { ctx.session.close(); }
72
+ });
73
+
37
74
  test('persistent run creation stores the run, tasks and trace', async () => {
38
75
  const session = openStore(dbPath());
39
76
  const r = await orchestrate({
@@ -2,7 +2,7 @@
2
2
  // and a fixture-repo E2E with a scripted invoker (no model calls).
3
3
  import test from 'node:test';
4
4
  import assert from 'node:assert/strict';
5
- import { mkdtempSync, cpSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
5
+ import { mkdtempSync, mkdirSync, rmSync, cpSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
6
6
  import { tmpdir } from 'node:os';
7
7
  import { join, resolve, dirname } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
@@ -14,6 +14,7 @@ import { normalizeContextPack, classifyPath, relativizeText } from '../lib/norma
14
14
  import { withEscalation, runPipeline, parseFileBlocks, runTests } from '../lib/pipeline.mjs';
15
15
  import { buildSettingsProposal } from '../adapters/pi/lib/settings-proposal.mjs';
16
16
  import { createScriptedInvoker } from '../adapters/pi/lib/invoke.mjs';
17
+ import { loadPiRegistry, piUserModelsPath } from '../adapters/pi/lib/model-registry.mjs';
17
18
 
18
19
  const kit = resolve(dirname(fileURLToPath(import.meta.url)), '..');
19
20
  const routing = loadRouting(join(kit, 'routing/routing.json'));
@@ -60,6 +61,31 @@ test('registry: loadRegistry merges files and rejects invalid local file', () =>
60
61
  assert.equal(shipped.sources.local, null);
61
62
  });
62
63
 
64
+ test('pi user bindings override package-local bindings without writing to the agent directory', () => {
65
+ const dir = mkdtempSync(join(tmpdir(), 'ludi-pi-reg-'));
66
+ const adapter = join(dir, 'adapters', 'pi');
67
+ const home = join(dir, 'home');
68
+ mkdirSync(adapter, { recursive: true });
69
+ const userPath = piUserModelsPath({}, home);
70
+ mkdirSync(dirname(userPath), { recursive: true });
71
+ const file = (path, backends) => writeFileSync(path, JSON.stringify({ version: 1, backends }));
72
+ file(join(adapter, 'models.json'), { devin: { provider: 'TODO-provider', model: 'TODO-model', thinking: 'low' } });
73
+ file(join(adapter, 'models.local.json'), { devin: { provider: 'package', model: 'old' }, sol: { provider: 'package', model: 'fallback' } });
74
+ file(userPath, { devin: { provider: 'user', model: 'new' } });
75
+ const merged = loadPiRegistry(dir, routing, { env: {}, home });
76
+ assert.equal(merged.registry.backends.devin.model, 'new');
77
+ assert.equal(merged.registry.backends.devin.thinking, 'low');
78
+ assert.equal(merged.registry.backends.sol.model, 'fallback');
79
+ assert.equal(merged.sources.user, userPath);
80
+ rmSync(join(adapter, 'models.local.json'));
81
+ assert.equal(loadPiRegistry(dir, routing, { env: {}, home }).registry.backends.devin.model, 'new');
82
+ file(join(adapter, 'models.local.json'), { devin: { provider: 'package', model: 'old' } });
83
+ assert.equal(loadPiRegistry(dir, routing, { env: { PI_CODING_AGENT_DIR: join(dir, 'empty') }, home }).sources.user, null);
84
+ assert.equal(piUserModelsPath({ PI_CODING_AGENT_DIR: join(dir, 'custom') }, home), join(dir, 'custom', 'ludi-agent-kit', 'models.local.json'));
85
+ file(userPath, { devin: { apiKey: 'bad' } });
86
+ assert.throws(() => loadPiRegistry(dir, routing, { env: {}, home }), /must not store credentials/);
87
+ });
88
+
63
89
  // ---------- resolution ----------
64
90
  test('resolve: agent -> capability -> backend chain -> concrete model; placeholders skipped', () => {
65
91
  const r = resolveAgents(agents, routing, REG);