detached a1cd53c

fix(goals): let main agent start missions

commit
a1cd53cc19acb70f0d5683a07a19212ed67bcb2d
author
brice · · 2026-08-14 11:32 UTC
parent
0ee5de5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +16 -26
M README.md +2 -2
@@ -254,7 +254,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
254254 - `create_worktree`: create a new branch from the current `HEAD` and check it out persistently. A repository with no configured root and no `.worktrees` directory first returns bootstrap guidance without making changes; the agent asks whether to use the repository default or an external root, then retries with `bootstrap_root`. The choice is saved privately in local Git config. Choosing `.worktrees` creates `<repository-root>/.worktrees/<branch>` and adds `/.worktrees/` to the root `.gitignore`; an absolute external root leaves tracked repository files and the root `.gitignore` unchanged. The tool never copies uncommitted or ignored files, and it refuses an existing branch or worktree path.
255255 - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. See [docs/lsp.md](docs/lsp.md).
256256 - `web_search`: search the public web through DuckDuckGo HTML and return bounded source titles, URLs, and snippets. Enabled by default for normal CLI sessions; it is unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md).
257-- `update_goal`: set the manager's next bounded goal within the active mission, or mark the active goal `done` or `blocked`. Starting, pausing, resuming, and clearing missions remain user-controlled through `/goal`.
257+- `update_goal`: let the main agent start its own mission when none is active, set the next bounded goal within an active mission, or mark the active goal `done` or `blocked`. Pausing, resuming, and clearing missions remain user-controlled through `/goal`.
258258
259259 When the sandbox is on (see `/jail`), filesystem tools, `grep`, and LSP workspace edits refuse paths outside the session cwd. `create_worktree` also requires its repository root, configured worktree destination, optional `.gitignore`, and Git metadata to remain inside the jail. Jail does not sandbox web-search network egress.
260260
@@ -335,7 +335,7 @@ Extension-registered commands appear under a divider at the bottom of the popup,
335335
336336 ### `/goal`
337337
338-In interactive mode, the first user request establishes a durable user-owned mission. `/goal <objective>` explicitly starts or replaces its current user-owned goal and begins work immediately. While a goal is active, zut starts one leased hidden follow-up whenever the interactive thread becomes idle; queued user input always runs first. The manager can use `update_goal` to settle the current goal or set a bounded next goal within the same mission. The current state appears in the status bar and prior transitions are available through `/goal history`; both persist with the session.
338+In interactive mode, the first user request establishes a durable user-owned mission. `/goal <objective>` explicitly starts or replaces its current user-owned goal and begins work immediately. While a goal is active, zut starts one leased hidden follow-up whenever the interactive thread becomes idle; queued user input always runs first. When no mission is active, the main agent can use `update_goal` to start one of its own; within an active mission, it can settle the current goal or set a bounded next goal. Subagents cannot start or update missions. The current state appears in the status bar and prior transitions are available through `/goal history`; both persist with the session.
339339
340340 Goals are unlimited by default. Zut records token use for each goal but only enforces a budget when it is explicitly configured in `config.json`:
341341
@@ -254,7 +254,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
254 - `create_worktree`: create a new branch from the current `HEAD` and check it out persistently. A repository with no configured root and no `.worktrees` directory first returns bootstrap guidance without making changes; the agent asks whether to use the repository default or an external root, then retries with `bootstrap_root`. The choice is saved privately in local Git config. Choosing `.worktrees` creates `<repository-root>/.worktrees/<branch>` and adds `/.worktrees/` to the root `.gitignore`; an absolute external root leaves tracked repository files and the root `.gitignore` unchanged. The tool never copies uncommitted or ignored files, and it refuses an existing branch or worktree path.254 - `create_worktree`: create a new branch from the current `HEAD` and check it out persistently. A repository with no configured root and no `.worktrees` directory first returns bootstrap guidance without making changes; the agent asks whether to use the repository default or an external root, then retries with `bootstrap_root`. The choice is saved privately in local Git config. Choosing `.worktrees` creates `<repository-root>/.worktrees/<branch>` and adds `/.worktrees/` to the root `.gitignore`; an absolute external root leaves tracked repository files and the root `.gitignore` unchanged. The tool never copies uncommitted or ignored files, and it refuses an existing branch or worktree path.
255 - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. See [docs/lsp.md](docs/lsp.md).255 - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. See [docs/lsp.md](docs/lsp.md).
256 - `web_search`: search the public web through DuckDuckGo HTML and return bounded source titles, URLs, and snippets. Enabled by default for normal CLI sessions; it is unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md).256 - `web_search`: search the public web through DuckDuckGo HTML and return bounded source titles, URLs, and snippets. Enabled by default for normal CLI sessions; it is unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md).
257-- `update_goal`: set the manager's next bounded goal within the active mission, or mark the active goal `done` or `blocked`. Starting, pausing, resuming, and clearing missions remain user-controlled through `/goal`.257+- `update_goal`: let the main agent start its own mission when none is active, set the next bounded goal within an active mission, or mark the active goal `done` or `blocked`. Pausing, resuming, and clearing missions remain user-controlled through `/goal`.
258 258
259 When the sandbox is on (see `/jail`), filesystem tools, `grep`, and LSP workspace edits refuse paths outside the session cwd. `create_worktree` also requires its repository root, configured worktree destination, optional `.gitignore`, and Git metadata to remain inside the jail. Jail does not sandbox web-search network egress.259 When the sandbox is on (see `/jail`), filesystem tools, `grep`, and LSP workspace edits refuse paths outside the session cwd. `create_worktree` also requires its repository root, configured worktree destination, optional `.gitignore`, and Git metadata to remain inside the jail. Jail does not sandbox web-search network egress.
260 260
@@ -335,7 +335,7 @@ Extension-registered commands appear under a divider at the bottom of the popup,
335 335
336 ### `/goal`336 ### `/goal`
337 337
338-In interactive mode, the first user request establishes a durable user-owned mission. `/goal <objective>` explicitly starts or replaces its current user-owned goal and begins work immediately. While a goal is active, zut starts one leased hidden follow-up whenever the interactive thread becomes idle; queued user input always runs first. The manager can use `update_goal` to settle the current goal or set a bounded next goal within the same mission. The current state appears in the status bar and prior transitions are available through `/goal history`; both persist with the session.338+In interactive mode, the first user request establishes a durable user-owned mission. `/goal <objective>` explicitly starts or replaces its current user-owned goal and begins work immediately. While a goal is active, zut starts one leased hidden follow-up whenever the interactive thread becomes idle; queued user input always runs first. When no mission is active, the main agent can use `update_goal` to start one of its own; within an active mission, it can settle the current goal or set a bounded next goal. Subagents cannot start or update missions. The current state appears in the status bar and prior transitions are available through `/goal history`; both persist with the session.
339 339
340 Goals are unlimited by default. Zut records token use for each goal but only enforces a budget when it is explicitly configured in `config.json`:340 Goals are unlimited by default. Zut records token use for each goal but only enforces a budget when it is explicitly configured in `config.json`:
341 341
M packages/agent/cli.go +1 -7
@@ -695,7 +695,6 @@ func copyExtensionStates(states map[string]json.RawMessage) map[string]json.RawM
695695
696696 var (
697697 errGoalActiveReplacement = errors.New("active goal replacement")
698- errGoalMissionMissing = errors.New("goal mission missing")
699698 errGoalMissionMismatch = errors.New("goal mission mismatch")
700699 errGoalStorageUnavailable = errors.New("goal storage unavailable")
701700 errGoalStateWrite = errors.New("goal state write")
@@ -730,8 +729,6 @@ func safeGoalCommitMessage(err error) string {
730729 switch {
731730 case errors.Is(err, errGoalActiveReplacement):
732731 return "goal transition rejected: the current goal is still active"
733- case errors.Is(err, errGoalMissionMissing):
734- return "goal transition rejected: no active mission"
735732 case errors.Is(err, errGoalMissionMismatch):
736733 return "goal transition rejected: it does not belong to the active mission"
737734 case errors.Is(err, errGoalStorageUnavailable):
@@ -755,10 +752,7 @@ func persistGoalToolResult(sess *core.Session, result core.ToolResult, goalMaxTo
755752 if sess.Meta.Goal != nil && sess.Meta.Goal.Status == core.GoalActive {
756753 return errGoalActiveReplacement
757754 }
758- if sess.Meta.Mission == nil {
759- return errGoalMissionMissing
760- }
761- if update.MissionID == "" || update.MissionID != sess.Meta.Mission.ID {
755+ if sess.Meta.Mission != nil && update.MissionID != sess.Meta.Mission.ID {
762756 return errGoalMissionMismatch
763757 }
764758 goal := &core.SessionGoal{Objective: update.Objective, Status: core.GoalActive, Owner: core.GoalOwnerManager}
@@ -695,7 +695,6 @@ func copyExtensionStates(states map[string]json.RawMessage) map[string]json.RawM
695 695
696 var (696 var (
697 errGoalActiveReplacement = errors.New("active goal replacement")697 errGoalActiveReplacement = errors.New("active goal replacement")
698- errGoalMissionMissing = errors.New("goal mission missing")
699 errGoalMissionMismatch = errors.New("goal mission mismatch")698 errGoalMissionMismatch = errors.New("goal mission mismatch")
700 errGoalStorageUnavailable = errors.New("goal storage unavailable")699 errGoalStorageUnavailable = errors.New("goal storage unavailable")
701 errGoalStateWrite = errors.New("goal state write")700 errGoalStateWrite = errors.New("goal state write")
@@ -730,8 +729,6 @@ func safeGoalCommitMessage(err error) string {
730 switch {729 switch {
731 case errors.Is(err, errGoalActiveReplacement):730 case errors.Is(err, errGoalActiveReplacement):
732 return "goal transition rejected: the current goal is still active"731 return "goal transition rejected: the current goal is still active"
733- case errors.Is(err, errGoalMissionMissing):
734- return "goal transition rejected: no active mission"
735 case errors.Is(err, errGoalMissionMismatch):732 case errors.Is(err, errGoalMissionMismatch):
736 return "goal transition rejected: it does not belong to the active mission"733 return "goal transition rejected: it does not belong to the active mission"
737 case errors.Is(err, errGoalStorageUnavailable):734 case errors.Is(err, errGoalStorageUnavailable):
@@ -755,10 +752,7 @@ func persistGoalToolResult(sess *core.Session, result core.ToolResult, goalMaxTo
755 if sess.Meta.Goal != nil && sess.Meta.Goal.Status == core.GoalActive {752 if sess.Meta.Goal != nil && sess.Meta.Goal.Status == core.GoalActive {
756 return errGoalActiveReplacement753 return errGoalActiveReplacement
757 }754 }
758- if sess.Meta.Mission == nil {755+ if sess.Meta.Mission != nil && update.MissionID != sess.Meta.Mission.ID {
759- return errGoalMissionMissing
760- }
761- if update.MissionID == "" || update.MissionID != sess.Meta.Mission.ID {
762 return errGoalMissionMismatch756 return errGoalMissionMismatch
763 }757 }
764 goal := &core.SessionGoal{Objective: update.Objective, Status: core.GoalActive, Owner: core.GoalOwnerManager}758 goal := &core.SessionGoal{Objective: update.Objective, Status: core.GoalActive, Owner: core.GoalOwnerManager}
M packages/agent/cli_session_test.go +8 -12
@@ -150,12 +150,6 @@ func TestPersistToolResultStateUsesSafeGoalPersistenceCategories(t *testing.T) {
150150 update: tools.GoalUpdate{Status: core.GoalActive, Objective: privateGoal},
151151 message: "goal transition rejected: the current goal is still active",
152152 },
153- {
154- name: "missing mission",
155- setup: newSession,
156- update: tools.GoalUpdate{Status: core.GoalActive, Objective: privateGoal, MissionID: privateMission},
157- message: "goal transition rejected: no active mission",
158- },
159153 {
160154 name: "mismatched mission",
161155 setup: func(t *testing.T) *core.Session {
@@ -227,23 +221,25 @@ func TestPersistToolResultStateUsesSafeGoalPersistenceCategories(t *testing.T) {
227221 }
228222 }
229223
230-func TestPersistGoalToolResultCreatesActiveGoal(t *testing.T) {
224+func TestPersistGoalToolResultStartsManagerMission(t *testing.T) {
231225 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
232226 if err != nil {
233227 t.Fatal(err)
234228 }
235229 defer sess.Close()
236- if err := sess.EnsureMission("reproduce the reported failure", core.MissionSourceUser); err != nil {
237- t.Fatal(err)
238- }
239230
240- result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "reproduce the reported failure", MissionID: sess.Meta.Mission.ID}}
231+ result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "reproduce the reported failure"}}
241232 if err := persistGoalToolResult(sess, result); err != nil {
242233 t.Fatal(err)
243234 }
244- if goal := sess.Meta.Goal; goal == nil || goal.Status != core.GoalActive || goal.Objective != "reproduce the reported failure" || goal.Owner != core.GoalOwnerManager {
235+ goal := sess.Meta.Goal
236+ if goal == nil || goal.Status != core.GoalActive || goal.Objective != "reproduce the reported failure" || goal.Owner != core.GoalOwnerManager {
245237 t.Fatalf("persisted goal = %#v", goal)
246238 }
239+ mission := sess.Meta.Mission
240+ if mission == nil || mission.Source != core.MissionSourceManager || mission.Objective != goal.Objective || mission.ID != goal.MissionID || mission.ActiveGoalID != goal.ID {
241+ t.Fatalf("persisted mission = %#v, goal = %#v", mission, goal)
242+ }
247243 }
248244
249245 func TestPersistGoalToolResultAppliesConfiguredBudget(t *testing.T) {
@@ -150,12 +150,6 @@ func TestPersistToolResultStateUsesSafeGoalPersistenceCategories(t *testing.T) {
150 update: tools.GoalUpdate{Status: core.GoalActive, Objective: privateGoal},150 update: tools.GoalUpdate{Status: core.GoalActive, Objective: privateGoal},
151 message: "goal transition rejected: the current goal is still active",151 message: "goal transition rejected: the current goal is still active",
152 },152 },
153- {
154- name: "missing mission",
155- setup: newSession,
156- update: tools.GoalUpdate{Status: core.GoalActive, Objective: privateGoal, MissionID: privateMission},
157- message: "goal transition rejected: no active mission",
158- },
159 {153 {
160 name: "mismatched mission",154 name: "mismatched mission",
161 setup: func(t *testing.T) *core.Session {155 setup: func(t *testing.T) *core.Session {
@@ -227,23 +221,25 @@ func TestPersistToolResultStateUsesSafeGoalPersistenceCategories(t *testing.T) {
227 }221 }
228 }222 }
229 223
230-func TestPersistGoalToolResultCreatesActiveGoal(t *testing.T) {224+func TestPersistGoalToolResultStartsManagerMission(t *testing.T) {
231 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")225 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
232 if err != nil {226 if err != nil {
233 t.Fatal(err)227 t.Fatal(err)
234 }228 }
235 defer sess.Close()229 defer sess.Close()
236- if err := sess.EnsureMission("reproduce the reported failure", core.MissionSourceUser); err != nil {
237- t.Fatal(err)
238- }
239 230
240- result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "reproduce the reported failure", MissionID: sess.Meta.Mission.ID}}231+ result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "reproduce the reported failure"}}
241 if err := persistGoalToolResult(sess, result); err != nil {232 if err := persistGoalToolResult(sess, result); err != nil {
242 t.Fatal(err)233 t.Fatal(err)
243 }234 }
244- if goal := sess.Meta.Goal; goal == nil || goal.Status != core.GoalActive || goal.Objective != "reproduce the reported failure" || goal.Owner != core.GoalOwnerManager {235+ goal := sess.Meta.Goal
236+ if goal == nil || goal.Status != core.GoalActive || goal.Objective != "reproduce the reported failure" || goal.Owner != core.GoalOwnerManager {
245 t.Fatalf("persisted goal = %#v", goal)237 t.Fatalf("persisted goal = %#v", goal)
246 }238 }
239+ mission := sess.Meta.Mission
240+ if mission == nil || mission.Source != core.MissionSourceManager || mission.Objective != goal.Objective || mission.ID != goal.MissionID || mission.ActiveGoalID != goal.ID {
241+ t.Fatalf("persisted mission = %#v, goal = %#v", mission, goal)
242+ }
247 }243 }
248 244
249 func TestPersistGoalToolResultAppliesConfiguredBudget(t *testing.T) {245 func TestPersistGoalToolResultAppliesConfiguredBudget(t *testing.T) {
M packages/agent/tools/update_goal.go +5 -5
@@ -13,12 +13,12 @@ import (
1313
1414 const (
1515 UpdateGoalToolName = "update_goal"
16- updateGoalSchema = `{"type":"object","properties":{"status":{"type":"string","enum":["active","complete","blocked"],"description":"Set the next active goal, or mark the active goal complete or blocked."},"objective":{"type":"string","description":"Concrete objective for status active."},"mission_id":{"type":"string","description":"Mission identifier supplied in the active-goal context when setting a next goal."},"reason":{"type":"string","description":"Concise reason when the goal is blocked."}},"required":["status"],"additionalProperties":false}`
16+ updateGoalSchema = `{"type":"object","properties":{"status":{"type":"string","enum":["active","complete","blocked"],"description":"Start a mission or set its next active goal, or mark the active goal complete or blocked."},"objective":{"type":"string","description":"Concrete objective for status active."},"mission_id":{"type":"string","description":"Required identifier from the active-goal context when setting a next goal in an existing mission. Omit it when starting a mission."},"reason":{"type":"string","description":"Concise reason when the goal is blocked."}},"required":["status"],"additionalProperties":false}`
1717 )
1818
19-// UpdateGoalTool lets the manager set the next concrete active goal or settle
20-// the current goal. Host-side persistence validates that a transition remains
21-// within the session's active mission.
19+// UpdateGoalTool lets the main agent start a mission, set its next concrete
20+// active goal, or settle the current goal. Host-side persistence validates
21+// transitions within an existing session mission.
2222 type UpdateGoalTool struct{}
2323
2424 type updateGoalArgs struct {
@@ -39,7 +39,7 @@ type GoalUpdate struct {
3939 func (t *UpdateGoalTool) Name() string { return UpdateGoalToolName }
4040
4141 func (t *UpdateGoalTool) Description() string {
42- return "Set the next concrete goal needed for the active user mission, or mark the active goal complete or blocked. Do not broaden the user's mission."
42+ return "Start your own mission when none is active, set the next concrete goal within an active mission, or mark the active goal complete or blocked. Do not broaden an existing mission."
4343 }
4444
4545 func (t *UpdateGoalTool) Schema() json.RawMessage { return json.RawMessage(updateGoalSchema) }
@@ -13,12 +13,12 @@ import (
13 13
14 const (14 const (
15 UpdateGoalToolName = "update_goal"15 UpdateGoalToolName = "update_goal"
16- updateGoalSchema = `{"type":"object","properties":{"status":{"type":"string","enum":["active","complete","blocked"],"description":"Set the next active goal, or mark the active goal complete or blocked."},"objective":{"type":"string","description":"Concrete objective for status active."},"mission_id":{"type":"string","description":"Mission identifier supplied in the active-goal context when setting a next goal."},"reason":{"type":"string","description":"Concise reason when the goal is blocked."}},"required":["status"],"additionalProperties":false}`16+ updateGoalSchema = `{"type":"object","properties":{"status":{"type":"string","enum":["active","complete","blocked"],"description":"Start a mission or set its next active goal, or mark the active goal complete or blocked."},"objective":{"type":"string","description":"Concrete objective for status active."},"mission_id":{"type":"string","description":"Required identifier from the active-goal context when setting a next goal in an existing mission. Omit it when starting a mission."},"reason":{"type":"string","description":"Concise reason when the goal is blocked."}},"required":["status"],"additionalProperties":false}`
17 )17 )
18 18
19-// UpdateGoalTool lets the manager set the next concrete active goal or settle19+// UpdateGoalTool lets the main agent start a mission, set its next concrete
20-// the current goal. Host-side persistence validates that a transition remains20+// active goal, or settle the current goal. Host-side persistence validates
21-// within the session's active mission.21+// transitions within an existing session mission.
22 type UpdateGoalTool struct{}22 type UpdateGoalTool struct{}
23 23
24 type updateGoalArgs struct {24 type updateGoalArgs struct {
@@ -39,7 +39,7 @@ type GoalUpdate struct {
39 func (t *UpdateGoalTool) Name() string { return UpdateGoalToolName }39 func (t *UpdateGoalTool) Name() string { return UpdateGoalToolName }
40 40
41 func (t *UpdateGoalTool) Description() string {41 func (t *UpdateGoalTool) Description() string {
42- return "Set the next concrete goal needed for the active user mission, or mark the active goal complete or blocked. Do not broaden the user's mission."42+ return "Start your own mission when none is active, set the next concrete goal within an active mission, or mark the active goal complete or blocked. Do not broaden an existing mission."
43 }43 }
44 44
45 func (t *UpdateGoalTool) Schema() json.RawMessage { return json.RawMessage(updateGoalSchema) }45 func (t *UpdateGoalTool) Schema() json.RawMessage { return json.RawMessage(updateGoalSchema) }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close