specrails-core 6.2.2 → 6.4.0

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.
Files changed (34) hide show
  1. package/README.md +2 -2
  2. package/dist/agent-runtime/cli.js +1 -1
  3. package/dist/agent-runtime/compact/environment.d.ts +19 -2
  4. package/dist/agent-runtime/compact/environment.js +71 -6
  5. package/dist/agent-runtime/config.js +68 -53
  6. package/dist/agent-runtime/engine/compiler.js +10 -2
  7. package/dist/agent-runtime/engine/contracts.d.ts +16 -0
  8. package/dist/agent-runtime/engine/pieces/control.js +11 -2
  9. package/dist/agent-runtime/engine/pieces/role-turn.js +35 -2
  10. package/dist/agent-runtime/engine/pieces/verify.d.ts +10 -3
  11. package/dist/agent-runtime/engine/pieces/verify.js +107 -30
  12. package/dist/agent-runtime/executor-types.d.ts +2 -0
  13. package/dist/agent-runtime/graph/nodes.js +9 -55
  14. package/dist/agent-runtime/prompts.d.ts +1 -1
  15. package/dist/agent-runtime/prompts.js +58 -35
  16. package/dist/agent-runtime/verification-repair.d.ts +26 -0
  17. package/dist/agent-runtime/verification-repair.js +88 -0
  18. package/dist/installer/commands/framework.d.ts +7 -3
  19. package/dist/installer/commands/framework.js +6 -8
  20. package/dist/installer/commands/init.d.ts +8 -15
  21. package/dist/installer/commands/init.js +22 -64
  22. package/dist/installer/phases/install-config.d.ts +8 -2
  23. package/dist/installer/phases/install-config.js +24 -17
  24. package/dist/installer/phases/scaffold.d.ts +27 -79
  25. package/dist/installer/phases/scaffold.js +100 -535
  26. package/integration-contract.json +7 -10
  27. package/package.json +1 -1
  28. package/schemas/agent-runtime.schema.json +9 -7
  29. package/templates/agents/sr-architect.md +0 -29
  30. package/templates/agents/sr-developer.md +0 -29
  31. package/templates/agents/sr-reviewer.md +0 -28
  32. package/templates/codex-skills/rails/sr-architect/SKILL.md +0 -29
  33. package/templates/codex-skills/rails/sr-developer/SKILL.md +0 -29
  34. package/templates/codex-skills/rails/sr-reviewer/SKILL.md +0 -28
@@ -0,0 +1,88 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { hostPrecondition, installEnvironment } from './compact/environment.js';
4
+ import { guardrailEnabled } from './guardrails.js';
5
+ const BLOCKER_KINDS = new Set(['network', 'credential', 'environment-variable', 'toolchain', 'setup', 'environment', 'scope']);
6
+ const BLOCKER_TEXT_LIMIT = 2_000;
7
+ /** Where a missing dependency install belongs: the nearest directory with a lockfile or manifest above the failing command, else the repository root. */
8
+ const INSTALL_MARKERS = ['package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lock', 'bun.lockb', 'requirements.txt', 'pyproject.toml', 'go.mod', 'Cargo.toml'];
9
+ export function installRoots(context, commands) {
10
+ const roots = [];
11
+ for (const command of commands) {
12
+ const repository = context.repositories.find(repo => repo.id === command.repositoryId);
13
+ if (!repository)
14
+ continue;
15
+ let directory = command.cwd;
16
+ let chosen = repository.path;
17
+ for (;;) {
18
+ const relative = path.relative(repository.path, directory);
19
+ if (relative.startsWith('..') || path.isAbsolute(relative))
20
+ break;
21
+ if (INSTALL_MARKERS.some(name => existsSync(path.join(directory, name)))) {
22
+ chosen = directory;
23
+ break;
24
+ }
25
+ if (!relative)
26
+ break;
27
+ directory = path.dirname(directory);
28
+ }
29
+ roots.push(chosen);
30
+ }
31
+ // Every repository root stays a candidate: its plan is a no-op when it is already installed.
32
+ return [...new Set([...roots, ...context.repositories.map(repository => repository.path)])];
33
+ }
34
+ /** A directory as the log should name it: relative to the checkout that contains it. */
35
+ export function checkoutRelative(context, directory) {
36
+ const repository = context.repositories.find(repo => { const relative = path.relative(repo.path, directory); return !relative.startsWith('..') && !path.isAbsolute(relative); });
37
+ return repository ? path.relative(repository.path, directory).split(path.sep).join('/') || '.' : directory;
38
+ }
39
+ /** The legacy host-facing sentence for a blocker; engine v2 hosts read the structured blocker instead. */
40
+ export function hostPreconditionMessage(reason, command, args, directory) {
41
+ return `Verification cannot run in this environment: ${reason} (\`${[command, ...args].join(' ')}\` in ${directory}). This is not a defect in the change, so no correction round was started. Make it available to Specrails (for example in the login shell profile Specrails loads, or with a refreshed registry token), then resume.`;
42
+ }
43
+ /** A failed check the host must unblock (credentials, registry access, a missing variable): no correction round can repair it. */
44
+ export function preconditionBlock(context, receipt) {
45
+ for (const command of receipt.commands) {
46
+ if (command.exitCode === 0)
47
+ continue;
48
+ const precondition = hostPrecondition(command.output);
49
+ if (precondition)
50
+ return boundedBlocker({ kind: precondition.kind, reason: precondition.reason, command: command.command, args: command.args, cwd: checkoutRelative(context, command.cwd),
51
+ requiredAction: precondition.requiredAction, ...(command.evidenceId ? { evidenceId: command.evidenceId } : {}) });
52
+ }
53
+ return undefined;
54
+ }
55
+ /**
56
+ * Runs the planned installs for the failed commands' roots once, narrating each
57
+ * step through `note` exactly as the legacy graph logs it (`[environment] npm
58
+ * install (app)`, `Environment: installed …`). An install the host cannot
59
+ * complete for a precondition reason becomes `refused`.
60
+ */
61
+ export function repairEnvironment(context, receipt, guardrails, note, spawn) {
62
+ const failed = receipt.commands.filter(command => command.exitCode !== 0);
63
+ const installs = installEnvironment(installRoots(context, failed), { failureOutput: receipt.commands.map(command => command.output).join('\n'), lockfileRepair: guardrailEnabled(guardrails, 'lockfile-repair'),
64
+ ...(spawn ? { spawn } : {}), onEvent: event => note(event.kind === 'text' ? event.text ?? '' : `[environment] ${event.tool ?? ''} ${event.detail ?? ''}`.trim()) });
65
+ const refused = installs.find(outcome => outcome.precondition);
66
+ if (!refused)
67
+ return { installs };
68
+ const precondition = hostPrecondition(refused.detail) ?? { kind: 'environment', reason: refused.precondition, requiredAction: 'Repair the verification environment, then retry the run.' };
69
+ const cwd = checkoutRelative(context, refused.root);
70
+ const command = [refused.command, ...refused.args].join(' ');
71
+ return { installs, refused: boundedBlocker({ kind: precondition.kind, reason: `installing its dependencies failed because ${refused.precondition}`, command: refused.command, args: refused.args, cwd,
72
+ // A network blocker on an install names the exact command to repeat with connectivity; other kinds keep the classifier's action.
73
+ requiredAction: precondition.kind === 'network' ? `Run \`${command}\` in ${cwd} with network access, then retry the run.` : precondition.requiredAction }) };
74
+ }
75
+ /** A blocker as a durable JSON value: known kind, bounded strings, at most 16 arguments. */
76
+ export function boundedBlocker(value) {
77
+ if (!value || typeof value !== 'object' || Array.isArray(value))
78
+ return undefined;
79
+ const raw = value;
80
+ if (typeof raw.kind !== 'string' || !BLOCKER_KINDS.has(raw.kind))
81
+ return undefined;
82
+ const bounded = (field, limit = BLOCKER_TEXT_LIMIT) => typeof field === 'string' ? field.slice(0, limit) : '';
83
+ const args = Array.isArray(raw.args) ? raw.args.slice(0, 16).map(arg => bounded(arg, 256)) : [];
84
+ // A correction role reports free-text `evidence` instead of a host reason.
85
+ const reason = bounded(raw.reason) || bounded(raw.evidence);
86
+ return { kind: raw.kind, reason, command: bounded(raw.command, 512), args, cwd: bounded(raw.cwd, 512), requiredAction: bounded(raw.requiredAction),
87
+ ...(typeof raw.evidenceId === 'string' && raw.evidenceId ? { evidenceId: bounded(raw.evidenceId, 128) } : {}) };
88
+ }
@@ -37,7 +37,11 @@ export interface AssembleFlags {
37
37
  provider?: string | boolean;
38
38
  version?: string | boolean;
39
39
  'code-root'?: string | boolean;
40
- /** Comma-separated per-project agent allow-list (links a subset of the superset). */
40
+ /**
41
+ * Deprecated since Core 6.3: roles are runtime-defined, so there is nothing
42
+ * to select. Still parsed so older Desktop builds keep working; ignored with
43
+ * a warning.
44
+ */
41
45
  'selected-agents'?: string | boolean;
42
46
  }
43
47
  interface InstallFrameworkOutcome {
@@ -77,8 +81,8 @@ export declare function runInstallFramework(flags: InstallFrameworkFlags): Promi
77
81
  export declare function runSwapCurrent(flags: SwapCurrentFlags): Promise<SwapCurrentOutcome>;
78
82
  /**
79
83
  * `specrails-core assemble` — SYMLINK the materialized framework subtrees into a
80
- * project workspace and seed the per-project layer (agent-memory, manifest,
81
- * instruction/settings files, gemini acks). NO network, NO openspec init.
84
+ * project workspace and seed the per-project layer (manifest,
85
+ * instruction/settings files). NO network, NO openspec init.
82
86
  */
83
87
  export declare function runAssemble(flags: AssembleFlags): Promise<AssembleOutcome>;
84
88
  /** Read the package version (the version the desktop should materialize). */
@@ -2,7 +2,7 @@ import path from 'node:path';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import { assertNoCoreDowngrade, currentFrameworkVersion, withFrameworkLifecycleLock } from '../util/install-transaction.js';
4
4
  import { InstallerError } from '../util/errors.js';
5
- import { ok, step } from '../util/logger.js';
5
+ import { ok, step, warn } from '../util/logger.js';
6
6
  import { pathExists, readTextFile } from '../util/fs.js';
7
7
  import { derivedPaths } from '../phases/provider-detect.js';
8
8
  import { assertFrameworkVersionComplete, resolveRequiredFrameworkProviders, } from '../phases/framework-lifecycle.js';
@@ -129,8 +129,8 @@ export async function runSwapCurrent(flags) {
129
129
  }
130
130
  /**
131
131
  * `specrails-core assemble` — SYMLINK the materialized framework subtrees into a
132
- * project workspace and seed the per-project layer (agent-memory, manifest,
133
- * instruction/settings files, gemini acks). NO network, NO openspec init.
132
+ * project workspace and seed the per-project layer (manifest,
133
+ * instruction/settings files). NO network, NO openspec init.
134
134
  */
135
135
  export async function runAssemble(flags) {
136
136
  const scriptDir = resolveScriptDir();
@@ -153,10 +153,9 @@ export async function runAssemble(flags) {
153
153
  throw new InstallerError(`framework not materialized: ${currentProviderDir} is missing — run install-framework first`, 41);
154
154
  }
155
155
  assertFrameworkVersionComplete(frameworkDir, version, [provider]);
156
- const selectedAgentsFlag = flags['selected-agents'];
157
- const selectedAgents = typeof selectedAgentsFlag === 'string' && selectedAgentsFlag.length > 0
158
- ? selectedAgentsFlag.split(',').map((s) => s.trim()).filter((s) => s.length > 0)
159
- : undefined;
156
+ if (flags['selected-agents'] !== undefined) {
157
+ warn('--selected-agents is ignored since Core 6.3: roles are runtime-defined and no role file is installed.');
158
+ }
160
159
  step(`Assembling workspace ${workspace} ← framework ${version} (${provider})`);
161
160
  assembleProjectWorkspace({
162
161
  workspace,
@@ -166,7 +165,6 @@ export async function runAssemble(flags) {
166
165
  version,
167
166
  codeRoot,
168
167
  scriptDir,
169
- selectedAgents,
170
168
  });
171
169
  ok(`Linked ${providerDir}/ from framework ${version} + seeded project layer`);
172
170
  return { workspace, frameworkDir, provider, version, codeRoot, providerDir };
@@ -6,7 +6,8 @@ import { type Provider } from '../phases/install-config.js';
6
6
  * --root-dir <path> Target repo (default: cwd)
7
7
  * --yes / -y Non-interactive; auto-init git + accept defaults
8
8
  * --provider <name> Force provider (claude, codex, gemini, or kimi)
9
- * --from-config [<p>] Read provider + agents from install-config.yaml
9
+ * --from-config [<p>] Read the provider from install-config.yaml (a legacy
10
+ * `agents` section is validated and ignored)
10
11
  * --relocate Relocate artifacts to the $HOME workspace (symlinked
11
12
  * from the bundled framework) instead of installing them
12
13
  * IN-REPO. Default is in-repo so a standalone user's
@@ -31,12 +32,12 @@ interface InitResult {
31
32
  }
32
33
  type WorkspaceProviderSelections = Partial<Record<Provider, string[]>>;
33
34
  /**
34
- * Snapshot the provider/role inventory exposed by one live workspace before a
35
- * global framework version swap. This matters on Windows: directory junctions
36
- * resolve their target when they are created, so a workspace link made through
37
- * `framework/current` must be recreated after `current` moves. Keeping the
38
- * exact visible `sr-*` set also avoids widening or narrowing a project's
39
- * optional role selection during that refresh.
35
+ * Snapshot the providers exposed by one live workspace before a global
36
+ * framework version swap. This matters on Windows: directory junctions resolve
37
+ * their target when they are created, so a workspace link made through
38
+ * `framework/current` must be recreated after `current` moves. Roles are
39
+ * runtime-defined since Core 6.3, so every provider reports an empty selection;
40
+ * the keys are what drive the multi-provider reassembly.
40
41
  */
41
42
  export declare function snapshotWorkspaceProviderSelections(workspace: string): WorkspaceProviderSelections;
42
43
  /**
@@ -45,20 +46,12 @@ export declare function snapshotWorkspaceProviderSelections(workspace: string):
45
46
  * on failure.
46
47
  */
47
48
  export declare function runInit(flags: InitFlags): Promise<InitResult>;
48
- /**
49
- * Warn (once per id) about `agents.selected` entries that have no shipped
50
- * template — typically a pre-v5 install-config.yaml still listing removed
51
- * agents. They are skipped at placement; the warning tells the user why and
52
- * points at the v5 extension path.
53
- */
54
- export declare function warnUnknownSelectedAgents(selected: string[] | undefined): void;
55
49
  interface EnsureFrameworkInput {
56
50
  scriptDir: string;
57
51
  frameworkDir: string;
58
52
  provider: Provider;
59
53
  providerDir: string;
60
54
  version: string;
61
- selectedAgents?: string[];
62
55
  /**
63
56
  * Additional providers that must remain available through the global
64
57
  * `framework/current` pointer after this version transition.
@@ -6,13 +6,13 @@ import { recordSuccessfulInstall } from '../util/registry.js';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { InstallerError } from '../util/errors.js';
8
8
  import { runCommand } from '../util/exec.js';
9
- import { info, ok, step, warn } from '../util/logger.js';
9
+ import { info, ok, step } from '../util/logger.js';
10
10
  import { isDir, isSymlink, listDir, pathExists, readTextFile, removePath, writeFileLf, } from '../util/fs.js';
11
11
  import { loadInstallConfig, resolveConfigPath, } from '../phases/install-config.js';
12
12
  import { checkPrerequisites } from '../phases/prereqs.js';
13
13
  import { derivedPaths } from '../phases/provider-detect.js';
14
14
  import { materializeFrameworkVersion } from '../phases/framework-lifecycle.js';
15
- import { CORE_AGENTS, assembleProjectWorkspace, installFramework, } from '../phases/scaffold.js';
15
+ import { assembleProjectWorkspace, installFramework, } from '../phases/scaffold.js';
16
16
  import { frameworkRoot, resolveArtifacts } from '../util/registry.js';
17
17
  const WORKSPACE_PROVIDER_ORDER = [
18
18
  'claude',
@@ -21,12 +21,12 @@ const WORKSPACE_PROVIDER_ORDER = [
21
21
  'kimi',
22
22
  ];
23
23
  /**
24
- * Snapshot the provider/role inventory exposed by one live workspace before a
25
- * global framework version swap. This matters on Windows: directory junctions
26
- * resolve their target when they are created, so a workspace link made through
27
- * `framework/current` must be recreated after `current` moves. Keeping the
28
- * exact visible `sr-*` set also avoids widening or narrowing a project's
29
- * optional role selection during that refresh.
24
+ * Snapshot the providers exposed by one live workspace before a global
25
+ * framework version swap. This matters on Windows: directory junctions resolve
26
+ * their target when they are created, so a workspace link made through
27
+ * `framework/current` must be recreated after `current` moves. Roles are
28
+ * runtime-defined since Core 6.3, so every provider reports an empty selection;
29
+ * the keys are what drive the multi-provider reassembly.
30
30
  */
31
31
  export function snapshotWorkspaceProviderSelections(workspace) {
32
32
  const selections = {};
@@ -36,44 +36,25 @@ export function snapshotWorkspaceProviderSelections(workspace) {
36
36
  if (Array.isArray(manifest.providers))
37
37
  recorded = manifest.providers.filter((value) => typeof value === 'string');
38
38
  }
39
- catch { /* Legacy manifests are recognized by the complete Core role trio. */ }
39
+ catch { /* Legacy manifests: a provider counts only through its managed links. */ }
40
40
  for (const provider of WORKSPACE_PROVIDER_ORDER) {
41
41
  const { providerDir } = derivedPaths(provider);
42
42
  const providerRoot = path.join(workspace, providerDir);
43
43
  if (!pathExists(providerRoot) ||
44
44
  (!workspaceHasManagedProviderLink(providerRoot, provider)
45
- && !(recorded.includes(provider) && hasCopiedCoreArtifacts(providerRoot, provider))
46
- && !hasCopiedCoreRoles(providerRoot, provider))) {
45
+ && !(recorded.includes(provider) && hasCopiedCoreArtifacts(providerRoot, provider)))) {
47
46
  continue;
48
47
  }
49
- const roleRoot = provider === 'codex'
50
- ? path.join(workspace, providerDir, 'skills', 'rails')
51
- : provider === 'kimi'
52
- ? path.join(workspace, providerDir, 'skills')
53
- : path.join(workspace, providerDir, 'agents');
54
- const ids = listDir(roleRoot)
55
- .filter((entry) => provider === 'claude' || provider === 'gemini'
56
- ? path.basename(entry).endsWith('.md')
57
- : isDir(entry))
58
- .map((entry) => {
59
- const name = path.basename(entry);
60
- return name.endsWith('.md') ? name.slice(0, -3) : name;
61
- })
62
- .filter((id) => /^sr-[a-z0-9-]+$/.test(id));
63
- selections[provider] = [...new Set(ids)].sort();
48
+ selections[provider] = [];
64
49
  }
65
50
  return selections;
66
51
  }
52
+ /** A copied (in-repo) install is recognised by its workflow commands or runtime, never by role files. */
67
53
  function hasCopiedCoreArtifacts(providerRoot, provider) {
68
- return hasCopiedCoreRoles(providerRoot, provider) || pathExists(provider === 'codex' ? path.join(providerRoot, 'skills', 'implement', 'SKILL.md')
54
+ return pathExists(provider === 'codex' ? path.join(providerRoot, 'skills', 'implement', 'SKILL.md')
69
55
  : provider === 'kimi' ? path.join(providerRoot, 'specrails', 'run-skill.mjs')
70
56
  : path.join(providerRoot, 'commands', 'specrails', provider === 'gemini' ? 'implement.toml' : 'implement.md'));
71
57
  }
72
- function hasCopiedCoreRoles(providerRoot, provider) {
73
- return [...CORE_AGENTS].every((id) => pathExists(provider === 'codex' ? path.join(providerRoot, 'skills', 'rails', id, 'SKILL.md')
74
- : provider === 'kimi' ? path.join(providerRoot, 'skills', id, 'SKILL.md')
75
- : path.join(providerRoot, 'agents', id + '.md')));
76
- }
77
58
  function workspaceHasManagedProviderLink(providerRoot, provider) {
78
59
  const directoryLinks = provider === 'claude'
79
60
  ? ['commands', 'skills', 'rules']
@@ -85,10 +66,11 @@ function workspaceHasManagedProviderLink(providerRoot, provider) {
85
66
  if (directoryLinks.some((relative) => isSymlink(path.join(providerRoot, relative)))) {
86
67
  return true;
87
68
  }
88
- const granularRoot = provider === 'kimi'
89
- ? path.join(providerRoot, 'skills')
90
- : path.join(providerRoot, 'agents');
91
- return listDir(granularRoot).some((entry) => isSymlink(entry));
69
+ // Kimi links its skills one directory at a time; claude/gemini have no
70
+ // granular region any more (role files are no longer linked per file).
71
+ if (provider !== 'kimi')
72
+ return false;
73
+ return listDir(path.join(providerRoot, 'skills')).some((entry) => isSymlink(entry));
92
74
  }
93
75
  /**
94
76
  * Refresh every provider already represented in the project plus the provider
@@ -110,9 +92,6 @@ function reassembleWorkspaceProviders(input) {
110
92
  version: input.version,
111
93
  codeRoot: input.codeRoot,
112
94
  scriptDir: input.scriptDir,
113
- selectedAgents: provider === input.selectedProvider
114
- ? input.selectedAgents ?? input.previousSelections[provider]
115
- : input.previousSelections[provider],
116
95
  copyStatics: input.copyStatics,
117
96
  });
118
97
  }
@@ -142,10 +121,10 @@ export async function runInit(flags) {
142
121
  }
143
122
  explicitProvider = flags.provider;
144
123
  }
145
- // --from-config: read provider + agents from yaml.
124
+ // --from-config: read the provider from yaml. The legacy `agents` section is
125
+ // validated (and warned about) by `loadInstallConfig`; roles are runtime-defined.
146
126
  const fromConfigFlag = flags['from-config'];
147
127
  let providerHint = explicitProvider;
148
- let selectedAgentsHint;
149
128
  if (fromConfigFlag !== undefined) {
150
129
  const explicitPath = typeof fromConfigFlag === 'string' ? fromConfigFlag : undefined;
151
130
  const resolved = resolveConfigPath(repoRoot, explicitPath);
@@ -155,9 +134,7 @@ export async function runInit(flags) {
155
134
  throw new InstallerError(`--provider '${explicitProvider}' conflicts with provider '${config.provider}' in ${resolved}`, 40);
156
135
  }
157
136
  providerHint = config.provider;
158
- selectedAgentsHint = config.agents.selected;
159
137
  info(`Loaded install config from ${resolved}`);
160
- warnUnknownSelectedAgents(selectedAgentsHint);
161
138
  }
162
139
  else {
163
140
  info(`install-config.yaml not found at ${resolved} — falling back to ${explicitProvider ? `explicit provider '${explicitProvider}'` : 'auto-detection'}`);
@@ -199,7 +176,7 @@ export async function runInit(flags) {
199
176
  // Bundled-framework flow: materialize the provider-INVARIANT framework ONCE
200
177
  // under `<home>/.specrails/framework/<version>/<providerDir>/`, point `current`
201
178
  // at it, then SYMLINK that copy into the workspace and seed the project layer
202
- // (agent-memory, manifest, instruction files, gemini acks). The framework
179
+ // (manifest, instruction files). The framework
203
180
  // source for standalone npx is the package's templates/+commands/ (scriptDir).
204
181
  step('Phase 2 & 3: Installing specrails artifacts');
205
182
  const { providerDir } = derivedPaths(prereqs.provider);
@@ -228,7 +205,6 @@ export async function runInit(flags) {
228
205
  provider: prereqs.provider,
229
206
  providerDir,
230
207
  version,
231
- selectedAgents: selectedAgentsHint,
232
208
  requiredProviders: Object.keys(previousSelections),
233
209
  });
234
210
  reassembleWorkspaceProviders({
@@ -238,7 +214,6 @@ export async function runInit(flags) {
238
214
  codeRoot,
239
215
  scriptDir,
240
216
  selectedProvider: prereqs.provider,
241
- selectedAgents: selectedAgentsHint,
242
217
  previousSelections,
243
218
  // In-repo: COPY the framework statics as real, committable files. Relocated:
244
219
  // symlink from the framework store (O(1) update on a version swap).
@@ -255,7 +230,7 @@ export async function runInit(flags) {
255
230
  if (artifactRoot !== codeRoot)
256
231
  recordSuccessfulInstall(repoRoot, { home: process.env.SPECRAILS_REGISTRY_HOME, providers: [prereqs.provider], coreVersion: version });
257
232
  step('Installation complete');
258
- info('Agents, commands, and rules were placed directly — no follow-up step required.');
233
+ info('Commands and skills were placed directly — no follow-up step required.');
259
234
  // Terminal sentinel for programmatic consumers (specrails-desktop's setup
260
235
  // wizard matches this exact line via regex to mark the "init complete"
261
236
  // checkpoint). The sentinel line below is FROZEN — the downstream setup
@@ -268,22 +243,6 @@ export async function runInit(flags) {
268
243
  });
269
244
  });
270
245
  }
271
- /**
272
- * Warn (once per id) about `agents.selected` entries that have no shipped
273
- * template — typically a pre-v5 install-config.yaml still listing removed
274
- * agents. They are skipped at placement; the warning tells the user why and
275
- * points at the v5 extension path.
276
- */
277
- export function warnUnknownSelectedAgents(selected) {
278
- if (!selected)
279
- return;
280
- for (const id of selected) {
281
- if (!CORE_AGENTS.has(id)) {
282
- warn(`install-config.yaml selects agent '${id}', which specrails-core no longer ships — ` +
283
- `skipping (removed in v5).`);
284
- }
285
- }
286
- }
287
246
  /**
288
247
  * Materialize the framework for `(version, provider)` if absent and (by default)
289
248
  * point `<frameworkDir>/current` at that version. Idempotent — `installFramework`
@@ -304,7 +263,6 @@ export function ensureFramework(input) {
304
263
  provider: input.provider,
305
264
  providerDir: input.providerDir,
306
265
  version: input.version,
307
- selectedAgents: input.selectedAgents,
308
266
  });
309
267
  return;
310
268
  }
@@ -20,7 +20,12 @@ interface InstallModelConfig {
20
20
  interface InstallConfig {
21
21
  version: 1;
22
22
  provider: Provider;
23
- agents: {
23
+ /**
24
+ * Deprecated since Core 6.3: roles are runtime-defined, so the selection has
25
+ * no effect on the installed artifacts. Validated for shape when present and
26
+ * echoed back for consumers that still read it; absent when not supplied.
27
+ */
28
+ agents?: {
24
29
  selected: string[];
25
30
  excluded?: string[];
26
31
  /** Legacy pre-models-section location, accepted for backward compatibility. */
@@ -32,7 +37,8 @@ interface InstallConfig {
32
37
  * Resolve a preset into provider-native identifiers. Kimi currently exposes no
33
38
  * Core-defined cost/capability tier mapping, so every named preset resolves to
34
39
  * its explicit `k3` default. User-supplied exact identifiers are handled later
35
- * and are never interpreted as Claude aliases.
40
+ * and are never interpreted as Claude aliases. No preset carries per-role
41
+ * overrides: Core ships no role ids, so `models.overrides` is Desktop's to fill.
36
42
  */
37
43
  export declare function resolveProviderModelConfig(provider: Provider, preset?: ModelPreset): InstallModelConfig;
38
44
  export declare class InvalidConfigError extends InstallerError {
@@ -2,6 +2,7 @@ import path from 'node:path';
2
2
  import yaml from 'js-yaml';
3
3
  import { FilesystemError, InstallerError } from '../util/errors.js';
4
4
  import { pathExists, readTextFile, writeFileLf } from '../util/fs.js';
5
+ import { warn } from '../util/logger.js';
5
6
  const PROVIDER_DEFAULT_MODELS = {
6
7
  claude: 'sonnet',
7
8
  codex: 'gpt-5.5-mini',
@@ -25,7 +26,8 @@ const SAFE_KIMI_MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9._/:-]{0,127}$/;
25
26
  * Resolve a preset into provider-native identifiers. Kimi currently exposes no
26
27
  * Core-defined cost/capability tier mapping, so every named preset resolves to
27
28
  * its explicit `k3` default. User-supplied exact identifiers are handled later
28
- * and are never interpreted as Claude aliases.
29
+ * and are never interpreted as Claude aliases. No preset carries per-role
30
+ * overrides: Core ships no role ids, so `models.overrides` is Desktop's to fill.
29
31
  */
30
32
  export function resolveProviderModelConfig(provider, preset = 'balanced') {
31
33
  if (provider === 'claude') {
@@ -33,11 +35,7 @@ export function resolveProviderModelConfig(provider, preset = 'balanced') {
33
35
  return { preset, defaults: { model: 'haiku' }, overrides: {} };
34
36
  }
35
37
  if (preset === 'max') {
36
- return {
37
- preset,
38
- defaults: { model: 'sonnet' },
39
- overrides: { 'sr-architect': 'opus' },
40
- };
38
+ return { preset, defaults: { model: 'sonnet' }, overrides: {} };
41
39
  }
42
40
  }
43
41
  return {
@@ -109,13 +107,18 @@ export function validateInstallConfig(raw) {
109
107
  // NOTE: a legacy `tier` field (`full`/`quick`) may still be present in pre-v5
110
108
  // configs. It is intentionally ignored (not rejected and not carried forward)
111
109
  // — v5 has no install tiers.
110
+ // The `agents` section is OPTIONAL and deprecated: roles are runtime-defined
111
+ // since Core 6.3. When present it is still validated for shape (safe ids, no
112
+ // overlap, known preset) so a malformed file fails fast, and a non-empty
113
+ // selection logs one warning instead of being rejected — Desktop builds
114
+ // paired with an older Core still write it.
112
115
  const agents = doc.agents;
113
116
  let selectedAgents = [];
114
117
  let excludedAgents;
115
- if (!agents || typeof agents !== 'object') {
116
- errors.push(`missing required 'agents' section with 'selected' list`);
118
+ if (doc.agents !== undefined && (!agents || typeof agents !== 'object' || Array.isArray(agents))) {
119
+ errors.push(`'agents' must be a mapping`);
117
120
  }
118
- else {
121
+ else if (agents) {
119
122
  if (!Array.isArray(agents.selected)) {
120
123
  errors.push(`'agents.selected' must be a list`);
121
124
  }
@@ -182,6 +185,10 @@ export function validateInstallConfig(raw) {
182
185
  if (errors.length > 0) {
183
186
  throw new InvalidConfigError(errors);
184
187
  }
188
+ if (selectedAgents.length > 0) {
189
+ warn(`install-config.yaml 'agents.selected' is ignored since Core 6.3: roles are ` +
190
+ `runtime-defined and no role file is installed (${selectedAgents.join(', ')}).`);
191
+ }
185
192
  const preset = (modelPreset ?? agents?.preset ?? 'balanced');
186
193
  const resolvedModels = resolveProviderModelConfig(provider, preset);
187
194
  if (typeof modelDefaults?.model === 'string') {
@@ -193,18 +200,18 @@ export function validateInstallConfig(raw) {
193
200
  const result = {
194
201
  version: 1,
195
202
  provider,
196
- agents: {
197
- selected: selectedAgents,
198
- },
199
203
  models: resolvedModels,
200
204
  };
201
205
  // NOTE: a legacy `agent_teams` field may still be present in older configs.
202
206
  // It is intentionally ignored (not rejected) for backward compatibility.
203
- if (agents?.preset !== undefined) {
204
- result.agents.preset = agents.preset;
205
- }
206
- if (excludedAgents !== undefined) {
207
- result.agents.excluded = excludedAgents;
207
+ if (agents) {
208
+ result.agents = { selected: selectedAgents };
209
+ if (agents.preset !== undefined) {
210
+ result.agents.preset = agents.preset;
211
+ }
212
+ if (excludedAgents !== undefined) {
213
+ result.agents.excluded = excludedAgents;
214
+ }
208
215
  }
209
216
  return result;
210
217
  }