Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,18 @@ status: pending
assignee: bob
depends_on:
- oauth-flow
repo: webapp
agent: codex
model: gpt-5.5
reasoning_effort: high
branch: feat/payment-integration
```

`repo`/`repos` routes work to the right repository in multi-repo projects. `agent` routes a spec
to a specific installed agent (`claude-code`, `codex`, or `gemini`); if omitted, dispatch uses
`agent.type` from `sail.yaml`. `model` and `reasoning_effort` tune agents that support those
controls; unsupported combinations fail fast.

### Lifecycle

```
Expand Down Expand Up @@ -253,6 +262,7 @@ agent:
type: claude-code
install:
- claude-code
- codex
- gemini
security_audit:
enabled: true
Expand All @@ -261,7 +271,10 @@ agent:
enabled: true
```

Claude codes. Gemini reviews. Or the same agent does both with fresh context. The hooks fire at spec completion, not session stop — so reviews always see complete, coherent work.
Claude, Codex, or Gemini can execute a spec. Set `agent: codex` in `spec.yaml` to override the
project default for that unit of work. For Codex, `model` and `reasoning_effort` map to
`codex exec --model ... --config model_reasoning_effort=...`. The hooks fire at spec completion,
not session stop — so reviews always see complete, coherent work.

## Example `sail.yaml`

Expand Down
86 changes: 85 additions & 1 deletion sail-core/src/main/java/ai/singlr/sail/config/Spec.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@

package ai.singlr.sail.config;

import ai.singlr.sail.engine.AgentCli;
import ai.singlr.sail.engine.NameValidator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Pattern;

/**
* A single spec representing one unit of work. Each spec lives in its own directory inside the
Expand All @@ -22,6 +25,9 @@
* @param assignee engineer responsible (nullable, matches git identity)
* @param dependsOn IDs of specs that must be done first
* @param repos repository paths this spec should branch and work in
* @param agent agent CLI this spec should run with (nullable)
* @param model model this spec should run with (nullable)
* @param reasoningEffort model reasoning effort for this spec (nullable)
* @param branch git branch for this spec's work (nullable)
*/
public record Spec(
Expand All @@ -31,16 +37,46 @@ public record Spec(
String assignee,
List<String> dependsOn,
List<String> repos,
String agent,
String model,
String reasoningEffort,
String branch) {

private static final Pattern MODEL_PATTERN = Pattern.compile("[A-Za-z0-9._:/-]+");
private static final Set<String> REASONING_EFFORTS =
Set.of("none", "low", "medium", "high", "xhigh");

public Spec(
String id,
String title,
String status,
String assignee,
List<String> dependsOn,
String branch) {
this(id, title, status, assignee, dependsOn, List.of(), branch);
this(id, title, status, assignee, dependsOn, List.of(), null, null, null, branch);
}

public Spec(
String id,
String title,
String status,
String assignee,
List<String> dependsOn,
List<String> repos,
String branch) {
this(id, title, status, assignee, dependsOn, repos, null, null, null, branch);
}

public Spec(
String id,
String title,
String status,
String assignee,
List<String> dependsOn,
List<String> repos,
String agent,
String branch) {
this(id, title, status, assignee, dependsOn, repos, agent, null, null, branch);
}

@SuppressWarnings("unchecked")
Expand All @@ -55,6 +91,9 @@ public static Spec fromMap(Map<String, Object> map) {
var assignee = (String) map.get("assignee");
var dependsOn = (List<String>) map.get("depends_on");
var repos = reposFromMap(map);
var agent = validatedAgent((String) map.get("agent"));
var model = validatedModel((String) map.get("model"));
var reasoningEffort = validatedReasoningEffort((String) map.get("reasoning_effort"));
var branch = (String) map.get("branch");
return new Spec(
id,
Expand All @@ -63,6 +102,9 @@ public static Spec fromMap(Map<String, Object> map) {
assignee,
dependsOn != null ? List.copyOf(dependsOn) : List.of(),
repos,
agent,
model,
reasoningEffort,
branch);
}

Expand All @@ -84,6 +126,15 @@ public Map<String, Object> toMap() {
} else if (!repos.isEmpty()) {
map.put("repos", repos);
}
if (agent != null) {
map.put("agent", agent);
}
if (model != null) {
map.put("model", model);
}
if (reasoningEffort != null) {
map.put("reasoning_effort", reasoningEffort);
}
if (branch != null) {
map.put("branch", branch);
}
Expand All @@ -109,4 +160,37 @@ private static List<String> validatedRepos(List<String> repos) {
repos.forEach(repo -> NameValidator.requireSafePath(repo, "spec.repo"));
return List.copyOf(repos);
}

private static String validatedAgent(String agent) {
if (agent == null || agent.isBlank()) {
return null;
}
AgentCli.fromYamlName(agent);
return agent;
}

private static String validatedModel(String model) {
if (model == null || model.isBlank()) {
return null;
}
if (!MODEL_PATTERN.matcher(model).matches()) {
throw new IllegalArgumentException(
"Invalid spec.model: '" + model + "'. Use a model id without spaces or shell syntax.");
}
return model;
}

private static String validatedReasoningEffort(String reasoningEffort) {
if (reasoningEffort == null || reasoningEffort.isBlank()) {
return null;
}
if (!REASONING_EFFORTS.contains(reasoningEffort)) {
throw new IllegalArgumentException(
"Invalid spec.reasoning_effort: '"
+ reasoningEffort
+ "'. Must be one of: "
+ String.join(", ", REASONING_EFFORTS));
}
return reasoningEffort;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ public static List<Spec> updateStatus(List<Spec> specs, String specId, String ne
spec.assignee(),
spec.dependsOn(),
spec.repos(),
spec.agent(),
spec.model(),
spec.reasoningEffort(),
spec.branch())
: spec)
.toList();
Expand Down
27 changes: 26 additions & 1 deletion sail-core/src/main/java/ai/singlr/sail/engine/AgentCli.java
Original file line number Diff line number Diff line change
Expand Up @@ -95,17 +95,24 @@ public String displayName() {
* @param fullPermissions whether to auto-approve all actions
*/
public String headlessCommand(String taskFile, boolean fullPermissions) {
return headlessCommand(taskFile, fullPermissions, null, null);
}

public String headlessCommand(
String taskFile, boolean fullPermissions, String model, String reasoningEffort) {
var task = "\"$(cat " + taskFile + ")\"";
return switch (this) {
case CLAUDE_CODE -> {
requireNoModelOptions(model, reasoningEffort);
var perm = fullPermissions ? " --dangerously-skip-permissions" : "";
yield binaryName + " --print" + perm + " -p " + task;
}
case CODEX -> {
var perm = fullPermissions ? " --full-auto" : "";
yield binaryName + " exec" + perm + " " + task;
yield binaryName + " exec" + perm + codexModelOptions(model, reasoningEffort) + " " + task;
}
case GEMINI -> {
requireNoModelOptions(model, reasoningEffort);
var perm = fullPermissions ? " --yolo" : "";
yield binaryName + perm + " -p " + task;
}
Expand Down Expand Up @@ -143,4 +150,22 @@ public static AgentCli fromYamlName(String name) {
+ "'. Known agents: claude-code, codex, gemini."
+ "\n Check the 'install' list in your sail.yaml agent section.");
}

private static String codexModelOptions(String model, String reasoningEffort) {
var options = new StringBuilder();
if (model != null) {
options.append(" --model ").append(model);
}
if (reasoningEffort != null) {
options.append(" --config model_reasoning_effort='\"").append(reasoningEffort).append("\"'");
}
return options.toString();
}

private void requireNoModelOptions(String model, String reasoningEffort) {
if (model != null || reasoningEffort != null) {
throw new IllegalArgumentException(
yamlName + " does not support spec-level model or reasoning_effort options yet.");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -455,7 +455,7 @@ private static void appendSpecSection(StringBuilder sb, SailYaml config) {
```
%s/
├── oauth-flow/
│ ├── spec.yaml # Metadata: id, title, status, assignee, depends_on, repo/repos, branch
│ ├── spec.yaml # Metadata: id, title, status, assignee, depends_on, repo/repos, agent, branch
│ ├── spec.md # Detailed specification
│ └── plan.md # Optional implementation plan
└── search-api/
Expand All @@ -474,13 +474,24 @@ private static void appendSpecSection(StringBuilder sb, SailYaml config) {
assignee: claude-code
depends_on: []
repo: app
agent: codex
model: gpt-5.5
reasoning_effort: high
branch: feat/oauth-flow
```

Use `repo: <path>` for a single target repository and `repos: [api, web]` for cross-repo
work. The values must match `repos[].path` in `sail.yaml`. If a multi-repository
project omits repo targeting, `sail spec dispatch` will not auto-create a branch.

Use `agent: codex`, `agent: claude-code`, or `agent: gemini` when a spec should run on
a specific installed agent. If omitted, `sail spec dispatch` uses `agent.type` from
`sail.yaml`.

Use `model` and `reasoning_effort` when the selected agent supports those controls. Codex
supports both; unsupported combinations fail during dispatch instead of silently falling
back.

### Status Lifecycle
`pending` → `in_progress` → `review` → `done`
Spec status is managed by `sail`, not by you. Do not modify spec status directly during autonomous execution.
Expand Down
23 changes: 23 additions & 0 deletions sail-core/src/main/java/ai/singlr/sail/gen/SpecSkillGenerator.java
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,9 @@ private static String createInstructions(String specsDir) {
status: pending
depends_on: []
repo: <repo-path>
agent: <claude-code|codex|gemini>
model: <model-id>
reasoning_effort: <none|low|medium|high|xhigh>
```
4. Write `%1$s/<id>/spec.md` using the spec template
5. Confirm: "Created spec `<id>` — fill in the details in `%1$s/<id>/spec.md`"
Expand All @@ -256,6 +259,14 @@ private static String createInstructions(String specsDir) {
Ask which repository the spec targets when the project has multiple repos. Use \
`repo: <path>` for one repo and `repos: [repo-a, repo-b]` for cross-repo work. \
Values must match `repos[].path` in `sail.yaml`.

Ask which agent should execute the spec when the project has multiple installed agents. \
Use `agent: codex`, `agent: claude-code`, or `agent: gemini`. If omitted, Sail uses \
`agent.type` from `sail.yaml`.

Ask which model and reasoning effort to use when the selected agent supports them. \
For Codex, use `model: gpt-5.5` and `reasoning_effort: high` when the engineer wants \
GPT-5.5 with high reasoning.
"""
.formatted(specsDir);
}
Expand Down Expand Up @@ -308,6 +319,9 @@ private static String coreReference(String specsDir) {
assignee: claude-code
depends_on: []
repo: app
agent: codex
model: gpt-5.5
reasoning_effort: high
branch: feat/oauth-flow
```

Expand All @@ -331,12 +345,21 @@ private static String coreReference(String specsDir) {
- **depends_on** (optional): list of spec ids that must be done first
- **repo** (optional): single target repository path from `sail.yaml` `repos[].path`
- **repos** (optional): list of target repository paths for cross-repo work
- **agent** (optional): agent CLI for this spec (`claude-code`, `codex`, or `gemini`)
- **model** (optional): model id for agents that support model selection
- **reasoning_effort** (optional): `none`, `low`, `medium`, `high`, or `xhigh`
- **branch** (optional): git branch name for this spec's work

In multi-repo projects, always include `repo` or `repos` before dispatch so Sail can \
create the branch in the right repository. If omitted, dispatch only auto-branches when \
the project has exactly one configured repo.

In multi-agent projects, include `agent` when a spec should run on a non-default agent. \
If omitted, dispatch uses `agent.type` from `sail.yaml`.

Include `model` and `reasoning_effort` only for agents that support them. Sail passes \
these to Codex and rejects unsupported combinations for other agents.

### Directory Structure
```
%s/
Expand Down
39 changes: 39 additions & 0 deletions sail-core/src/test/java/ai/singlr/sail/config/SpecTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,42 @@ void toMapContainsAllFields() {
assertEquals("feat/auth", map.get("branch"));
}

@Test
void parsesAgent() {
var spec = Spec.fromMap(Map.of("id", "auth", "agent", "codex"));

assertEquals("codex", spec.agent());
}

@Test
void rejectsUnknownAgent() {
assertThrows(
IllegalArgumentException.class,
() -> Spec.fromMap(Map.of("id", "auth", "agent", "unknown-agent")));
}

@Test
void parsesModelAndReasoningEffort() {
var spec = Spec.fromMap(Map.of("id", "auth", "model", "gpt-5.5", "reasoning_effort", "high"));

assertEquals("gpt-5.5", spec.model());
assertEquals("high", spec.reasoningEffort());
}

@Test
void rejectsUnsafeModel() {
assertThrows(
IllegalArgumentException.class,
() -> Spec.fromMap(Map.of("id", "auth", "model", "gpt-5.5; rm -rf /")));
}

@Test
void rejectsUnknownReasoningEffort() {
assertThrows(
IllegalArgumentException.class,
() -> Spec.fromMap(Map.of("id", "auth", "reasoning_effort", "huge")));
}

@Test
void toMapWritesSingleRepoAsRepo() {
var spec =
Expand Down Expand Up @@ -164,6 +200,9 @@ void roundTrips() {
assertEquals(spec.assignee(), parsed.assignee());
assertEquals(spec.dependsOn(), parsed.dependsOn());
assertEquals(spec.repos(), parsed.repos());
assertEquals(spec.agent(), parsed.agent());
assertEquals(spec.model(), parsed.model());
assertEquals(spec.reasoningEffort(), parsed.reasoningEffort());
assertEquals(spec.branch(), parsed.branch());
}

Expand Down
Loading
Loading