specrails-core 4.0.8 → 4.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
@@ -177,6 +177,68 @@ AI product discovery using your personas. Evaluates ideas, creates tickets (loca
177
177
 
178
178
  ---
179
179
 
180
+ ## Agent profiles
181
+
182
+ > Available in `specrails-core >= 4.1.0`. Optional — without a profile, the pipeline behaves exactly as before.
183
+
184
+ Profiles are declarative JSON files that tell `/specrails:implement` which agents to use, which models to run them with, and how to route tasks to specialists. One project can define many profiles (e.g. `default`, `data-heavy`, `security-heavy`) and run different features with different profiles — useful for concurrent rails in `/specrails:batch-implement`.
185
+
186
+ ### File layout
187
+
188
+ ```
189
+ <project>/.specrails/
190
+ profiles/
191
+ default.json # checked into git, team-shared
192
+ data-heavy.json # checked into git, team-shared
193
+ .user-preferred.json # gitignored, your personal default
194
+ ```
195
+
196
+ ### Resolution order
197
+
198
+ When running the pipeline, the active profile is resolved in this order:
199
+
200
+ 1. `$SPECRAILS_PROFILE_PATH` environment variable (absolute path to a JSON snapshot)
201
+ 2. `<cwd>/.specrails/profiles/project-default.json`
202
+ 3. No profile — legacy behavior (identical to pre-4.1.0)
203
+
204
+ Tools such as [specrails-hub](https://github.com/fjpulidop/specrails-hub) set `$SPECRAILS_PROFILE_PATH` to a job-scoped snapshot so concurrent rails can run independent profiles.
205
+
206
+ ### Schema
207
+
208
+ The v1 profile schema is published at [`schemas/profile.v1.json`](./schemas/profile.v1.json). Example:
209
+
210
+ ```json
211
+ {
212
+ "schemaVersion": 1,
213
+ "name": "data-heavy",
214
+ "description": "Data engineering rail with stricter review",
215
+ "orchestrator": { "model": "opus" },
216
+ "agents": [
217
+ { "id": "sr-architect", "model": "opus", "required": true },
218
+ { "id": "sr-data-engineer", "model": "sonnet" },
219
+ { "id": "sr-developer", "model": "sonnet", "required": true },
220
+ { "id": "sr-reviewer", "model": "opus", "required": true }
221
+ ],
222
+ "routing": [
223
+ { "tags": ["etl", "schema", "data"], "agent": "sr-data-engineer" },
224
+ { "default": true, "agent": "sr-developer" }
225
+ ]
226
+ }
227
+ ```
228
+
229
+ Baseline agents (`sr-architect`, `sr-developer`, `sr-reviewer`) MUST appear in `agents[]`. The `routing` array is ordered — first rule whose `tags` intersects the task's tags wins; the terminal `default: true` rule catches everything else.
230
+
231
+ ### Reserved paths
232
+
233
+ The following paths are **reserved** — `update.sh` will never create, modify, or delete anything inside them:
234
+
235
+ - `.specrails/profiles/**` — profile JSON files (yours and hub-authored).
236
+ - `.claude/agents/custom-*.md` — your custom agents. Use the `custom-` prefix to opt in to this protection.
237
+
238
+ This contract is what lets you safely hand-author (or let specrails-hub author) profiles and custom agents without fear of the next `update` overwriting your work. Other paths managed by specrails-core (`.specrails/install-config.yaml`, `.specrails/specrails-version`, etc.) remain under `update.sh`'s control.
239
+
240
+ ---
241
+
180
242
  ## Local ticket management
181
243
 
182
244
  specrails-core ships with a built-in ticket system — no GitHub account or external tools required.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 4.0.7
1
+ 4.1.1
@@ -11,6 +11,7 @@ const COMMANDS = {
11
11
  "perf-check": "bin/perf-check.sh",
12
12
  enrich: null,
13
13
  version: null,
14
+ profile: null,
14
15
  };
15
16
 
16
17
  const args = process.argv.slice(2);
@@ -33,6 +34,7 @@ Usage:
33
34
  specrails-core doctor Run health checks
34
35
  specrails-core perf-check [--files <list>] Performance regression check (CI)
35
36
  specrails-core enrich [--from-config <path>] Run /specrails:enrich via Claude CLI
37
+ specrails-core profile <validate|show> [<path>] Validate or pretty-print a profile JSON
36
38
  specrails-core version Show installed version
37
39
 
38
40
  Flags for init:
@@ -65,6 +67,7 @@ const ALLOWED_FLAGS = {
65
67
  "perf-check": ["--files", "--context"],
66
68
  enrich: ["--from-config", "--quick"],
67
69
  version: [],
70
+ profile: [],
68
71
  };
69
72
 
70
73
  const subargs = args.slice(1);
@@ -85,6 +88,87 @@ if (subcommand === "version") {
85
88
  process.exit(0);
86
89
  }
87
90
 
91
+ // ─── profile subcommand ──────────────────────────────────────────────────────
92
+ // `specrails-core profile validate [path]` — validate a profile JSON against v1 schema
93
+ // `specrails-core profile show [path]` — pretty-print the resolved profile
94
+ // Resolution order when no path: $SPECRAILS_PROFILE_PATH → .specrails/profiles/project-default.json
95
+
96
+ if (subcommand === "profile") {
97
+ const { existsSync, readFileSync } = require("fs");
98
+ const action = subargs[0];
99
+ const pathArg = subargs[1];
100
+
101
+ if (!action || (action !== "validate" && action !== "show")) {
102
+ console.error("Usage: specrails-core profile validate [<path>]");
103
+ console.error(" specrails-core profile show [<path>]");
104
+ process.exit(1);
105
+ }
106
+
107
+ const resolveProfilePath = () => {
108
+ if (pathArg) return resolve(pathArg);
109
+ if (process.env.SPECRAILS_PROFILE_PATH) return resolve(process.env.SPECRAILS_PROFILE_PATH);
110
+ const projectDefault = resolve(process.cwd(), ".specrails/profiles/project-default.json");
111
+ if (existsSync(projectDefault)) return projectDefault;
112
+ return null;
113
+ };
114
+
115
+ const profilePath = resolveProfilePath();
116
+ if (!profilePath) {
117
+ console.error("No profile path given and none could be resolved.");
118
+ console.error("Pass an explicit path or set SPECRAILS_PROFILE_PATH, or place a profile at");
119
+ console.error(" .specrails/profiles/project-default.json");
120
+ process.exit(1);
121
+ }
122
+ if (!existsSync(profilePath)) {
123
+ console.error(`Profile file not found: ${profilePath}`);
124
+ process.exit(1);
125
+ }
126
+
127
+ let profile;
128
+ try {
129
+ profile = JSON.parse(readFileSync(profilePath, "utf8"));
130
+ } catch (e) {
131
+ console.error(`Profile is not valid JSON: ${e.message}`);
132
+ process.exit(1);
133
+ }
134
+
135
+ if (action === "show") {
136
+ console.log(JSON.stringify(profile, null, 2));
137
+ process.exit(0);
138
+ }
139
+
140
+ // action === "validate"
141
+ const schemaPath = resolve(ROOT, "schemas/profile.v1.json");
142
+ if (!existsSync(schemaPath)) {
143
+ console.error(`Schema not found at ${schemaPath} — install may be corrupt`);
144
+ process.exit(1);
145
+ }
146
+
147
+ let Ajv;
148
+ try {
149
+ Ajv = require("ajv/dist/2020.js").default;
150
+ } catch {
151
+ console.error("'ajv' is not installed. Run `npm install` in the specrails-core package directory first,");
152
+ console.error("or rely on the hub's own validator for user-facing flows.");
153
+ process.exit(1);
154
+ }
155
+
156
+ const schema = JSON.parse(readFileSync(schemaPath, "utf8"));
157
+ const ajv = new Ajv({ allErrors: true, strict: false });
158
+ const validate = ajv.compile(schema);
159
+
160
+ if (validate(profile)) {
161
+ console.log(`✓ ${profilePath} is a valid v1 profile.`);
162
+ process.exit(0);
163
+ } else {
164
+ console.error(`✗ ${profilePath} failed validation:`);
165
+ for (const err of validate.errors || []) {
166
+ console.error(` ${err.instancePath || "/"} ${err.message} (${JSON.stringify(err.params)})`);
167
+ }
168
+ process.exit(1);
169
+ }
170
+ }
171
+
88
172
  // ─── enrich subcommand ───────────────────────────────────────────────────────
89
173
  // Launches `claude --command "/specrails:enrich [flags]"` so the AI-powered
90
174
  // enrichment runs inside Claude Code with full model access.
@@ -64,10 +64,12 @@ const CORE_AGENTS = new Set([
64
64
  'sr-merge-resolver',
65
65
  ]);
66
66
 
67
+ // Only the CORE agents are pre-selected. Optional agents (product manager,
68
+ // test writer, layer specialists, reviewers, utilities) are opt-in so the
69
+ // default install is as lean as possible. Users can add optional agents via
70
+ // `/specrails:enrich` or by re-running init.
67
71
  const DEFAULT_SELECTED = new Set([
68
72
  ...CORE_AGENTS,
69
- 'sr-test-writer',
70
- 'sr-product-manager',
71
73
  ]);
72
74
 
73
75
  // ─── Model presets ────────────────────────────────────────────────────────────
@@ -178,12 +180,35 @@ function writeDefaultConfig(specrailsDir, provider) {
178
180
  async function run() {
179
181
  const rawArgs = process.argv.slice(2);
180
182
  const autoYes = rawArgs.includes('--yes') || rawArgs.includes('-y');
183
+ const withProfiles = rawArgs.includes('--with-profiles');
181
184
  const rootArg = rawArgs.find(a => !a.startsWith('-'));
182
185
  const inputDir = rootArg ? resolve(rootArg) : process.cwd();
183
186
  const rootDir = detectGitRoot(inputDir);
184
187
 
185
188
  const specrailsDir = resolve(rootDir, '.specrails');
186
189
 
190
+ // Optional: scaffold .specrails/profiles/project-default.json from the shipped template.
191
+ // Off by default to keep standalone installs zero-noise.
192
+ if (withProfiles) {
193
+ try {
194
+ const { readFileSync, writeFileSync, existsSync, mkdirSync } = await import('node:fs');
195
+ const { dirname } = await import('node:path');
196
+ const scriptDir = new URL('..', import.meta.url).pathname;
197
+ const templatePath = resolve(scriptDir, 'templates/profiles/default.json');
198
+ const profilesDir = resolve(specrailsDir, 'profiles');
199
+ const targetPath = resolve(profilesDir, 'project-default.json');
200
+ if (existsSync(templatePath) && !existsSync(targetPath)) {
201
+ mkdirSync(profilesDir, { recursive: true });
202
+ writeFileSync(targetPath, readFileSync(templatePath));
203
+ console.log(` ✓ Profile scaffolded at .specrails/profiles/project-default.json`);
204
+ } else if (existsSync(targetPath)) {
205
+ console.log(` ↷ Profile already exists at .specrails/profiles/project-default.json — skipped`);
206
+ }
207
+ } catch (e) {
208
+ console.warn(` ⚠ Could not scaffold profile: ${e.message}`);
209
+ }
210
+ }
211
+
187
212
  // Auto-yes: write defaults and exit (no TUI needed)
188
213
  //
189
214
  // Codex (OpenAI) support is currently being tested in our lab ("Coming Soon").
@@ -296,7 +321,8 @@ async function run() {
296
321
  message: 'Agents to install:',
297
322
  choices: buildCheckboxChoices(),
298
323
  pageSize: agentPageSize,
299
- validate: (selected) => selected.length > 0 || 'Select at least one agent.',
324
+ // Core agents are installed unconditionally (disabled rows above), so an
325
+ // empty optional selection is valid — means "only core, nothing extra".
300
326
  });
301
327
 
302
328
  // Core agents are always included regardless of checkbox state
package/install.sh CHANGED
@@ -4,6 +4,19 @@ set -euo pipefail
4
4
  # specrails installer
5
5
  # Installs the agent workflow system into any repository.
6
6
  # Step 1 of 2: Prerequisites + scaffold. Step 2: Run /specrails:enrich inside Claude Code.
7
+ #
8
+ # ─────────────────────────────────────────────────────────────────────────────
9
+ # Reserved paths (MUST NOT be created, modified, or deleted by this script):
10
+ # - <repo>/.specrails/profiles/** (project + hub-authored profile JSON)
11
+ # - <repo>/.claude/agents/custom-*.md (user-authored custom agents)
12
+ #
13
+ # Rationale: these paths hold user/team configuration that must survive
14
+ # re-running the installer. specrails-hub writes profile files here.
15
+ # Audited by tests/test-profiles.sh.
16
+ #
17
+ # Other paths under .specrails/ (install-config.yaml, specrails-version,
18
+ # specrails-manifest.json, setup-templates/) ARE managed by this script.
19
+ # ─────────────────────────────────────────────────────────────────────────────
7
20
 
8
21
  # Detect pipe mode (curl | bash) vs local execution
9
22
  if [[ -z "${BASH_SOURCE[0]:-}" || "${BASH_SOURCE[0]:-}" == "bash" ]]; then
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specrails-core",
3
- "version": "4.0.8",
3
+ "version": "4.1.1",
4
4
  "description": "AI agent workflow system for Claude Code — installs 12 specialized agents, orchestration commands, and persona-driven product discovery into any repository",
5
5
  "bin": {
6
6
  "specrails-core": "bin/specrails-core.js"
@@ -13,6 +13,7 @@
13
13
  ".claude/skills/",
14
14
  "commands/",
15
15
  "docs/",
16
+ "schemas/",
16
17
  "VERSION"
17
18
  ],
18
19
  "engines": {
@@ -58,6 +59,9 @@
58
59
  "dependencies": {
59
60
  "@inquirer/prompts": "^7.0.0"
60
61
  },
62
+ "devDependencies": {
63
+ "ajv": "^8.18.0"
64
+ },
61
65
  "license": "MIT",
62
66
  "author": "fjpulidop"
63
67
  }
@@ -0,0 +1,145 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://raw.githubusercontent.com/fjpulidop/specrails-core/main/schemas/profile.v1.json",
4
+ "title": "specrails agent profile (v1)",
5
+ "description": "Declarative configuration for the specrails implement pipeline. Describes which agents participate, which models they run with, and how tasks are routed to specialists. Consumed by `implement.md` and produced by tools like specrails-hub.",
6
+ "type": "object",
7
+ "required": ["schemaVersion", "name", "orchestrator", "agents", "routing"],
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "schemaVersion": {
11
+ "const": 1,
12
+ "description": "Profile schema version. v1 is the only supported value at this time."
13
+ },
14
+ "name": {
15
+ "type": "string",
16
+ "pattern": "^[a-z0-9][a-z0-9-]*$",
17
+ "minLength": 1,
18
+ "maxLength": 64,
19
+ "description": "Human-readable identifier for this profile (kebab-case)."
20
+ },
21
+ "description": {
22
+ "type": "string",
23
+ "maxLength": 512,
24
+ "description": "Optional short summary of when to use this profile."
25
+ },
26
+ "orchestrator": {
27
+ "type": "object",
28
+ "required": ["model"],
29
+ "additionalProperties": false,
30
+ "properties": {
31
+ "model": {
32
+ "$ref": "#/$defs/modelAlias",
33
+ "description": "Model used to run the top-level implement orchestrator."
34
+ }
35
+ }
36
+ },
37
+ "agents": {
38
+ "type": "array",
39
+ "minItems": 1,
40
+ "items": { "$ref": "#/$defs/agentEntry" },
41
+ "description": "Ordered chain of agents that participate in the pipeline when this profile is active.",
42
+ "allOf": [
43
+ {
44
+ "description": "Required baseline agent: sr-architect",
45
+ "contains": {
46
+ "type": "object",
47
+ "properties": { "id": { "const": "sr-architect" } },
48
+ "required": ["id"]
49
+ }
50
+ },
51
+ {
52
+ "description": "Required baseline agent: sr-developer",
53
+ "contains": {
54
+ "type": "object",
55
+ "properties": { "id": { "const": "sr-developer" } },
56
+ "required": ["id"]
57
+ }
58
+ },
59
+ {
60
+ "description": "Required baseline agent: sr-reviewer",
61
+ "contains": {
62
+ "type": "object",
63
+ "properties": { "id": { "const": "sr-reviewer" } },
64
+ "required": ["id"]
65
+ }
66
+ }
67
+ ]
68
+ },
69
+ "routing": {
70
+ "type": "array",
71
+ "minItems": 1,
72
+ "description": "Ordered routing rules. The first rule whose `tags` intersects the task's tag set wins. Exactly one terminal entry with `default: true` MUST appear as the last element.",
73
+ "items": { "$ref": "#/$defs/routingRule" }
74
+ }
75
+ },
76
+ "$defs": {
77
+ "modelAlias": {
78
+ "type": "string",
79
+ "enum": ["sonnet", "opus", "haiku"],
80
+ "description": "Accepted model alias. Resolved to the current concrete model ID by the pipeline."
81
+ },
82
+ "agentEntry": {
83
+ "type": "object",
84
+ "required": ["id"],
85
+ "additionalProperties": false,
86
+ "properties": {
87
+ "id": {
88
+ "type": "string",
89
+ "pattern": "^(sr|custom)-[a-z0-9][a-z0-9-]*$",
90
+ "description": "Agent identifier. MUST correspond to a file at `.claude/agents/<id>.md`."
91
+ },
92
+ "model": {
93
+ "$ref": "#/$defs/modelAlias",
94
+ "description": "Model override for this agent when this profile is active. When omitted, the agent's frontmatter `model:` is used as a fallback."
95
+ },
96
+ "required": {
97
+ "type": "boolean",
98
+ "default": false,
99
+ "description": "If true, the agent cannot be routed past. Baseline agents (sr-architect, sr-developer, sr-reviewer) SHOULD be marked required."
100
+ }
101
+ }
102
+ },
103
+ "routingRule": {
104
+ "oneOf": [
105
+ {
106
+ "type": "object",
107
+ "required": ["tags", "agent"],
108
+ "additionalProperties": false,
109
+ "properties": {
110
+ "tags": {
111
+ "type": "array",
112
+ "minItems": 1,
113
+ "items": {
114
+ "type": "string",
115
+ "pattern": "^[a-z0-9][a-z0-9-]*$"
116
+ },
117
+ "description": "Task tags that trigger this rule. Rule wins if any tag intersects."
118
+ },
119
+ "agent": {
120
+ "type": "string",
121
+ "pattern": "^(sr|custom)-[a-z0-9][a-z0-9-]*$",
122
+ "description": "Agent to route the task to when this rule wins."
123
+ }
124
+ }
125
+ },
126
+ {
127
+ "type": "object",
128
+ "required": ["default", "agent"],
129
+ "additionalProperties": false,
130
+ "properties": {
131
+ "default": {
132
+ "const": true,
133
+ "description": "Marks this rule as the terminal catch-all. Exactly one entry with `default: true` is allowed, and it MUST be the last element of `routing`."
134
+ },
135
+ "agent": {
136
+ "type": "string",
137
+ "pattern": "^(sr|custom)-[a-z0-9][a-z0-9-]*$",
138
+ "description": "Agent to route the task to when no earlier rule matched."
139
+ }
140
+ }
141
+ }
142
+ ]
143
+ }
144
+ }
145
+ }
@@ -34,6 +34,7 @@ Scan `$ARGUMENTS` for control flags:
34
34
  - If `--deps "<spec>"` is present: capture the quoted string as `DEPS_SPEC`. Strip from arguments.
35
35
  - If `--concurrency N` is present: set `CONCURRENCY=N` (integer ≥ 1). Default: 3.
36
36
  - If `--wave-size N` is present: set `WAVE_SIZE=N` (integer ≥ 1). Default: unlimited (no per-wave cap).
37
+ - If `--profiles "<spec>"` is present: parse a per-rail profile map of the form `ref=profile-name,ref=profile-name,...`. Capture as `PROFILE_MAP` and strip from arguments. Each mapped ref's `/specrails:implement` invocation will run under the named profile (the profile must exist at `.specrails/profiles/<profile-name>.json`). Unmapped refs inherit the batch-level profile resolution (see below).
37
38
 
38
39
  **If `DRY_RUN=true`**, print:
39
40
  ```
@@ -189,14 +190,18 @@ For each wave `W`:
189
190
 
190
191
  1. Print: `[wave W/TOTAL_WAVES] Starting — features: <ref-list>`
191
192
  2. For each feature batch of size ≤ `CONCURRENCY` within the wave:
193
+ - For every ref in the batch, determine the profile spawn env:
194
+ - If `PROFILE_MAP` contains an entry for this ref, set `SPECRAILS_PROFILE_PATH=<abs path to .specrails/profiles/<mapped-name>.json>` for this invocation only.
195
+ - Otherwise, inherit whatever `$SPECRAILS_PROFILE_PATH` was set by the caller (e.g. specrails-hub), or leave unset so the rail falls back to `.specrails/profiles/project-default.json` or legacy mode.
192
196
  - Invoke `/specrails:implement` with the feature refs and forwarded flags:
193
197
  ```
194
- /specrails:implement <ref1> <ref2> ... [--dry-run]
198
+ SPECRAILS_PROFILE_PATH=<resolved-per-rail-path> /specrails:implement <ref> [--dry-run]
195
199
  ```
200
+ - Each ref in the batch spawns with its own resolved profile path — **distinct rails in the same batch MAY use distinct profiles concurrently**.
196
201
  - Run invocations in the batch in parallel (`run_in_background: true`).
197
202
  - Wait for all in the batch to complete before starting the next batch.
198
203
  3. For each completed invocation, record outcome in `WAVE_RESULTS`:
199
- - `{ref, wave, status: "done" | "failed", error_summary: "..." | null}`
204
+ - `{ref, wave, status: "done" | "failed", profile: "<name or empty>", error_summary: "..." | null}`
200
205
 
201
206
  ### Failure isolation
202
207
 
@@ -61,13 +61,150 @@ which openspec && openspec --version
61
61
 
62
62
  #### 5. Agent discovery
63
63
 
64
- Scan the agents directory to determine which agents are installed:
64
+ Agent discovery runs in one of two modes: **profile mode** (a profile JSON is active) or **legacy mode** (no profile — identical to pre-4.1.0 behavior).
65
+
66
+ ##### Profile detection
67
+
68
+ A profile is active when either condition holds:
69
+
70
+ 1. The environment variable `SPECRAILS_PROFILE_PATH` is set AND points to a readable file. Tools like `specrails-hub` set this to a job-scoped snapshot.
71
+ 2. The file `.specrails/profiles/project-default.json` exists and is readable.
72
+
73
+ If condition 1 holds, use `$SPECRAILS_PROFILE_PATH` as the profile path. Otherwise, if condition 2 holds, use `.specrails/profiles/project-default.json`. Otherwise, fall through to **legacy mode**.
74
+
75
+ ##### Preflight: `jq` availability (profile mode only)
76
+
77
+ When running in profile mode, `jq` is required to read the profile JSON. Run:
78
+
79
+ ```bash
80
+ command -v jq >/dev/null 2>&1 || { echo "[error] 'jq' is required for profile-aware mode. Install with: brew install jq / apt install jq / https://stedolan.github.io/jq/"; exit 1; }
81
+ ```
82
+
83
+ ##### Profile mode — load, validate, populate
84
+
85
+ Read the profile:
86
+
87
+ ```bash
88
+ PROFILE="$(cat "$PROFILE_PATH")"
89
+ ```
90
+
91
+ Validate the schema version. Only `schemaVersion: 1` is supported:
92
+
93
+ ```bash
94
+ SCHEMA_VERSION="$(jq -r '.schemaVersion // empty' <<<"$PROFILE")"
95
+ case "$SCHEMA_VERSION" in
96
+ 1) ;;
97
+ "") echo "[error] profile validation failed: missing required field 'schemaVersion'"; exit 1 ;;
98
+ *) echo "[error] profile validation failed: unsupported schemaVersion '$SCHEMA_VERSION'. Supported: 1"; exit 1 ;;
99
+ esac
100
+ ```
101
+
102
+ Validate required top-level fields. Every valid v1 profile MUST contain `name`, `orchestrator.model`, `agents` (non-empty array), and `routing` (non-empty array):
103
+
104
+ ```bash
105
+ for field in name orchestrator agents routing; do
106
+ jq -e ".$field" <<<"$PROFILE" >/dev/null 2>&1 || { echo "[error] profile validation failed: missing required field '$field'"; exit 1; }
107
+ done
108
+ jq -e '.orchestrator.model' <<<"$PROFILE" >/dev/null 2>&1 || { echo "[error] profile validation failed: missing required field 'orchestrator.model'"; exit 1; }
109
+ jq -e '.agents | length > 0' <<<"$PROFILE" >/dev/null 2>&1 || { echo "[error] profile validation failed: 'agents' must be a non-empty array"; exit 1; }
110
+ jq -e '.routing | length > 0' <<<"$PROFILE" >/dev/null 2>&1 || { echo "[error] profile validation failed: 'routing' must be a non-empty array"; exit 1; }
111
+ ```
112
+
113
+ Validate baseline agents — `sr-architect`, `sr-developer`, and `sr-reviewer` MUST appear in `agents[]`:
114
+
115
+ ```bash
116
+ for required in sr-architect sr-developer sr-reviewer; do
117
+ jq -e --arg id "$required" '[.agents[].id] | index($id)' <<<"$PROFILE" >/dev/null 2>&1 \
118
+ || { echo "[error] profile validation failed: required baseline agent '$required' missing from 'agents[]'"; exit 1; }
119
+ done
120
+ ```
121
+
122
+ Validate routing terminal rule — exactly one entry SHALL have `default: true` and it MUST be the last element:
123
+
124
+ ```bash
125
+ DEFAULT_COUNT="$(jq '[.routing[] | select(.default == true)] | length' <<<"$PROFILE")"
126
+ if [[ "$DEFAULT_COUNT" -ne 1 ]]; then
127
+ echo "[error] profile validation failed: routing must contain exactly one entry with 'default: true' (found $DEFAULT_COUNT)"; exit 1
128
+ fi
129
+ IS_LAST="$(jq '(.routing | last | .default) == true' <<<"$PROFILE")"
130
+ if [[ "$IS_LAST" != "true" ]]; then
131
+ echo "[error] profile validation failed: the 'default: true' routing rule must be the last element of 'routing'"; exit 1
132
+ fi
133
+ ```
134
+
135
+ Populate `AVAILABLE_AGENTS` from the profile and verify each referenced agent file exists on disk:
136
+
137
+ ```bash
138
+ AVAILABLE_AGENTS="$(jq -r '.agents[].id' <<<"$PROFILE" | sort)"
139
+ for id in $AVAILABLE_AGENTS; do
140
+ [[ -f ".claude/agents/$id.md" ]] \
141
+ || { echo "[error] profile references agent '$id' but .claude/agents/$id.md does not exist"; exit 1; }
142
+ done
143
+ ```
144
+
145
+ Also store per-agent model overrides and the orchestrator model for use in later phases:
146
+
147
+ ```bash
148
+ # ORCHESTRATOR_MODEL is informational; the caller is responsible for spawning
149
+ # the orchestrator with this model (e.g. specrails-hub reads this field directly).
150
+ ORCHESTRATOR_MODEL="$(jq -r '.orchestrator.model' <<<"$PROFILE")"
151
+
152
+ # Per-agent model overrides keyed by agent id.
153
+ # Consumed by subagent invocation sites in later phases.
154
+ declare -A AGENT_MODEL
155
+ while IFS=$'\t' read -r id model; do
156
+ [[ -n "$model" && "$model" != "null" ]] && AGENT_MODEL[$id]="$model"
157
+ done < <(jq -r '.agents[] | [.id, (.model // "null")] | @tsv' <<<"$PROFILE")
158
+
159
+ # Routing rules (array), consumed by Phase 3b.
160
+ ROUTING="$(jq '.routing' <<<"$PROFILE")"
161
+
162
+ PROFILE_MODE="profile"
163
+ PROFILE_NAME="$(jq -r '.name' <<<"$PROFILE")"
164
+ ```
165
+
166
+ ##### Apply per-agent model overrides (profile mode only)
167
+
168
+ Claude Code's Agent tool determines a subagent's model from the `model:` line in the agent's `.md` frontmatter at invocation time — there is no per-call model parameter. When a profile is active, rewrite each agent's frontmatter `model:` value in-place to match `AGENT_MODEL[$id]`.
169
+
170
+ This rewrite is safe because:
171
+ - Multi-feature runs execute in **isolated git worktrees** (`isolation: worktree`), so each rail mutates its own copy of `.claude/agents/` without cross-rail contention.
172
+ - Single-feature runs are sequential within a single checkout.
173
+ - The hub writes a job-scoped snapshot of the profile and spawns `claude` with `$SPECRAILS_PROFILE_PATH` pointing at it; the frontmatter rewrite follows the snapshot, never the catalog.
174
+
175
+ ```bash
176
+ for id in "${!AGENT_MODEL[@]}"; do
177
+ model="${AGENT_MODEL[$id]}"
178
+ file=".claude/agents/$id.md"
179
+ [[ -f "$file" ]] || continue
180
+ # Rewrite the first `model:` line within the frontmatter block (lines between the
181
+ # first two `---` separators). Use sed with portable syntax (macOS + Linux).
182
+ awk -v new="$model" '
183
+ BEGIN { in_fm=0; done=0 }
184
+ /^---$/ { in_fm = !in_fm; print; next }
185
+ in_fm && !done && /^model:[[:space:]]/ { print "model: " new; done=1; next }
186
+ { print }
187
+ ' "$file" > "$file.tmp" && mv "$file.tmp" "$file"
188
+ done
189
+ ```
190
+
191
+ If a profile does not declare `model` for a given agent (the field is optional), that agent's frontmatter is left untouched.
192
+
193
+ ##### Legacy mode — preserve current behavior
194
+
195
+ If no profile is active, scan the agents directory exactly as before:
65
196
 
66
197
  ```bash
67
- ls .claude/agents/sr-*.md 2>/dev/null | sed 's|.*/||;s|\.md$||' | sort
198
+ AVAILABLE_AGENTS="$(ls .claude/agents/sr-*.md 2>/dev/null | sed 's|.*/||;s|\.md$||' | sort)"
199
+ PROFILE_MODE="legacy"
200
+ PROFILE_NAME=""
68
201
  ```
69
202
 
70
- Store the result as `AVAILABLE_AGENTS` (a list of agent IDs). The pipeline adapts dynamically to the installed agents:
203
+ Per-agent model overrides are empty in legacy mode — subagent invocations inherit the `model:` value from each agent's `.md` frontmatter. Routing in Phase 3b uses the hardcoded legacy rules.
204
+
205
+ ##### Agent roles (both modes)
206
+
207
+ The pipeline adapts dynamically to the installed agents:
71
208
 
72
209
  | Agent | Role | Required? | Phase(s) affected |
73
210
  |-------|------|-----------|-------------------|
@@ -88,6 +225,7 @@ Store the result as `AVAILABLE_AGENTS` (a list of agent IDs). The pipeline adapt
88
225
  **Gate rules** (applied throughout the pipeline):
89
226
  - If an optional agent is NOT in `AVAILABLE_AGENTS`, **skip** that phase/sub-step silently and note `"<agent> not installed — skipping"`.
90
227
  - Core agents are guaranteed to exist. If a core agent is missing, **STOP** and print: `[error] Core agent <name> not found. Run /specrails:enrich or reinstall.`
228
+ - In **profile mode**, the profile's `agents[]` IS the source of truth for `AVAILABLE_AGENTS`. Agents not listed are considered unavailable regardless of what is on disk.
91
229
 
92
230
  ### Summary
93
231
 
@@ -518,7 +656,47 @@ Produce three sets: `FRONTEND_TASKS`, `BACKEND_TASKS`, `OTHER_TASKS`.
518
656
 
519
657
  **Step 2 — Route tasks to developer agents:**
520
658
 
521
- Evaluate available developer agents in `AVAILABLE_AGENTS` and apply these rules in priority order:
659
+ Routing operates in one of two modes depending on the value of `PROFILE_MODE` set in Phase -1.
660
+
661
+ ##### Profile mode (`PROFILE_MODE=profile`)
662
+
663
+ When a profile is active, apply `ROUTING` rules in their array order. For each task, collect its tag set (layer tags plus any explicit `[tag]` markers in tasks.md). The first rule whose `tags` array intersects the task's tag set wins. The terminal `default: true` rule catches tasks matched by no earlier rule.
664
+
665
+ Example (pseudocode):
666
+
667
+ ```bash
668
+ assigned_agent_for_task() {
669
+ local -a task_tags=("$@")
670
+ local rule_count
671
+ rule_count=$(jq 'length' <<<"$ROUTING")
672
+ local i=0
673
+ while [[ $i -lt $rule_count ]]; do
674
+ local is_default rule_tags agent
675
+ is_default=$(jq -r ".[$i].default // false" <<<"$ROUTING")
676
+ agent=$(jq -r ".[$i].agent" <<<"$ROUTING")
677
+ if [[ "$is_default" == "true" ]]; then
678
+ echo "$agent"
679
+ return
680
+ fi
681
+ rule_tags=$(jq -r ".[$i].tags[]" <<<"$ROUTING")
682
+ for rtag in $rule_tags; do
683
+ for ttag in "${task_tags[@]}"; do
684
+ if [[ "$rtag" == "$ttag" ]]; then
685
+ echo "$agent"
686
+ return
687
+ fi
688
+ done
689
+ done
690
+ i=$((i + 1))
691
+ done
692
+ }
693
+ ```
694
+
695
+ Produce `DEVELOPER_ROUTING` from the per-task decisions, grouping by assigned agent. An agent assigned by the profile SHALL only be used if it also appears in `AVAILABLE_AGENTS` (the profile's own `agents[]`). If the profile routes a task to an agent not present in `agents[]`, that is a profile configuration bug — **STOP** and print: `[error] profile routing references agent '<id>' which is not declared in profile.agents[]`.
696
+
697
+ ##### Legacy mode (`PROFILE_MODE=legacy`)
698
+
699
+ When no profile is active, evaluate available developer agents in `AVAILABLE_AGENTS` and apply these hardcoded rules in priority order:
522
700
 
523
701
  | Condition | Agent(s) selected | Mode |
524
702
  |-----------|-------------------|------|
@@ -531,6 +709,14 @@ Evaluate available developer agents in `AVAILABLE_AGENTS` and apply these rules
531
709
 
532
710
  Store the result as `DEVELOPER_ROUTING`: a map of `{agent_id: [task_list]}`.
533
711
 
712
+ ##### Routing trace (both modes)
713
+
714
+ After computing `DEVELOPER_ROUTING`, optionally emit a trace line to aid debugging:
715
+
716
+ ```
717
+ [phase-3b] routing decision: mode=$PROFILE_MODE profile=${PROFILE_NAME:-none} agents=[list]
718
+ ```
719
+
534
720
  **Step 3 — Print routing decision:**
535
721
 
536
722
  ```
@@ -0,0 +1,27 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "name": "default",
4
+ "description": "Baseline profile equivalent to pre-4.1.0 legacy behavior: full chain, per-agent models matching the shipped agent frontmatters, and legacy routing rules.",
5
+ "orchestrator": { "model": "sonnet" },
6
+ "agents": [
7
+ { "id": "sr-product-manager", "model": "opus" },
8
+ { "id": "sr-product-analyst", "model": "haiku" },
9
+ { "id": "sr-architect", "model": "sonnet", "required": true },
10
+ { "id": "sr-developer", "model": "sonnet", "required": true },
11
+ { "id": "sr-frontend-developer", "model": "sonnet" },
12
+ { "id": "sr-backend-developer", "model": "sonnet" },
13
+ { "id": "sr-test-writer", "model": "sonnet" },
14
+ { "id": "sr-doc-sync", "model": "sonnet" },
15
+ { "id": "sr-merge-resolver", "model": "sonnet" },
16
+ { "id": "sr-reviewer", "model": "sonnet", "required": true },
17
+ { "id": "sr-frontend-reviewer", "model": "sonnet" },
18
+ { "id": "sr-backend-reviewer", "model": "sonnet" },
19
+ { "id": "sr-security-reviewer", "model": "sonnet" },
20
+ { "id": "sr-performance-reviewer","model": "sonnet" }
21
+ ],
22
+ "routing": [
23
+ { "tags": ["frontend"], "agent": "sr-frontend-developer" },
24
+ { "tags": ["backend"], "agent": "sr-backend-developer" },
25
+ { "default": true, "agent": "sr-developer" }
26
+ ]
27
+ }
package/update.sh CHANGED
@@ -4,6 +4,19 @@ set -euo pipefail
4
4
  # specrails updater
5
5
  # Updates an existing specrails installation in a target repository.
6
6
  # Preserves project-specific customizations (agents, personas, rules).
7
+ #
8
+ # ─────────────────────────────────────────────────────────────────────────────
9
+ # Reserved paths (MUST NOT be created, modified, or deleted by this script):
10
+ # - <repo>/.specrails/profiles/** (project + hub-authored profile JSON)
11
+ # - <repo>/.claude/agents/custom-*.md (user-authored custom agents)
12
+ #
13
+ # Rationale: these paths hold user/team configuration that survives across
14
+ # specrails-core upgrades. specrails-hub writes profile files here. Breaking
15
+ # this contract silently destroys user work. Audited by tests/test-profiles.sh.
16
+ #
17
+ # Other paths under .specrails/ (install-config.yaml, specrails-version,
18
+ # specrails-manifest.json, setup-templates/) ARE managed by this script.
19
+ # ─────────────────────────────────────────────────────────────────────────────
7
20
 
8
21
  # Detect pipe mode (curl | bash) vs local execution
9
22
  if [[ -z "${BASH_SOURCE[0]:-}" || "${BASH_SOURCE[0]:-}" == "bash" ]]; then