detached 59c2524

feat(agent): persist mission-scoped goals (#113)

commit
59c252457014454f9b5f6b6bc7447547ae76623b
author
bnema · · 2026-08-11 06:09 UTC
committer
GitHub · · 2026-08-11 06:09 UTC
parent
c63670e
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
12 files · +609 -31
M README.md +3 -3
@@ -239,7 +239,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
239239 - `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.
240240 - `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).
241241 - `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).
242-- `update_goal`: mark an explicitly started autonomous session goal `done` or `blocked`. Starting, pausing, resuming, and clearing goals remain user-controlled through `/goal`.
242+- `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`.
243243
244244 When the sandbox is on (see `/jail`), filesystem tools 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.
245245
@@ -296,7 +296,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu
296296 | `/reasoning` | Set the reasoning level for subsequent model calls. |
297297 | `/fast` | Toggle fast mode for subsequent model calls. |
298298 | `/orchestrator` | Toggle automatic subagent orchestration. When disabled, subagent tools remain available for explicit delegation. |
299-| `/goal <objective>` | Start an autonomous interactive-session goal. Use `/goal` for status and `/goal pause`, `resume`, or `clear` for control. |
299+| `/goal <objective>` | Start an autonomous interactive-session mission. Use `/goal` for status, `/goal history` for prior goals, and `/goal pause`, `resume`, or `clear` for control. |
300300 | `/llama` | Connect to the configured llama.cpp router, load, unload, or remove cached models, and search/download GGUF models from Hugging Face with live progress. Shown after llama.cpp login is configured. |
301301 | `/sessions` | Resume a previous session for this directory. The picker omits branches created only for tree navigation. |
302302 | `/fork` | Pick a previous user message and fork the current session after that turn. The selected turn becomes branch context and no provider turn starts during the checkout. |
@@ -319,7 +319,7 @@ Extension-registered commands appear under a divider at the bottom of the popup,
319319
320320 ### `/goal`
321321
322-In interactive mode, `/goal <objective>` starts work immediately and keeps the objective active across normal provider turn boundaries. While the goal is active, zut starts a hidden follow-up whenever the interactive thread becomes idle; queued user input always runs first. The model marks the goal `done` or `blocked` with the built-in `update_goal` tool. The current state appears in the status bar and is persisted with the session. `esc` pauses an interrupted goal, and terminal provider errors block it instead of retrying indefinitely. Use `/goal` to inspect it, `/goal pause` or `/goal resume` to control execution, and `/goal clear` to remove it.
322+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 a 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. `esc` pauses an interrupted goal, and terminal provider errors block it instead of retrying indefinitely. Use `/goal` to inspect it, `/goal pause` or `/goal resume` to control execution, and `/goal clear` to remove it.
323323
324324 A branch that copies the complete transcript inherits the goal. Forking from an earlier turn clears it because the later objective did not necessarily exist at that point. Manual `/compact` leaves an active goal recorded but does not start a hidden follow-up; run `/goal resume` to continue it.
325325
@@ -239,7 +239,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
239 - `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.239 - `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.
240 - `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).240 - `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).
241 - `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).241 - `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).
242-- `update_goal`: mark an explicitly started autonomous session goal `done` or `blocked`. Starting, pausing, resuming, and clearing goals remain user-controlled through `/goal`.242+- `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`.
243 243
244 When the sandbox is on (see `/jail`), filesystem tools 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.244 When the sandbox is on (see `/jail`), filesystem tools 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.
245 245
@@ -296,7 +296,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu
296 | `/reasoning` | Set the reasoning level for subsequent model calls. |296 | `/reasoning` | Set the reasoning level for subsequent model calls. |
297 | `/fast` | Toggle fast mode for subsequent model calls. |297 | `/fast` | Toggle fast mode for subsequent model calls. |
298 | `/orchestrator` | Toggle automatic subagent orchestration. When disabled, subagent tools remain available for explicit delegation. |298 | `/orchestrator` | Toggle automatic subagent orchestration. When disabled, subagent tools remain available for explicit delegation. |
299-| `/goal <objective>` | Start an autonomous interactive-session goal. Use `/goal` for status and `/goal pause`, `resume`, or `clear` for control. |299+| `/goal <objective>` | Start an autonomous interactive-session mission. Use `/goal` for status, `/goal history` for prior goals, and `/goal pause`, `resume`, or `clear` for control. |
300 | `/llama` | Connect to the configured llama.cpp router, load, unload, or remove cached models, and search/download GGUF models from Hugging Face with live progress. Shown after llama.cpp login is configured. |300 | `/llama` | Connect to the configured llama.cpp router, load, unload, or remove cached models, and search/download GGUF models from Hugging Face with live progress. Shown after llama.cpp login is configured. |
301 | `/sessions` | Resume a previous session for this directory. The picker omits branches created only for tree navigation. |301 | `/sessions` | Resume a previous session for this directory. The picker omits branches created only for tree navigation. |
302 | `/fork` | Pick a previous user message and fork the current session after that turn. The selected turn becomes branch context and no provider turn starts during the checkout. |302 | `/fork` | Pick a previous user message and fork the current session after that turn. The selected turn becomes branch context and no provider turn starts during the checkout. |
@@ -319,7 +319,7 @@ Extension-registered commands appear under a divider at the bottom of the popup,
319 319
320 ### `/goal`320 ### `/goal`
321 321
322-In interactive mode, `/goal <objective>` starts work immediately and keeps the objective active across normal provider turn boundaries. While the goal is active, zut starts a hidden follow-up whenever the interactive thread becomes idle; queued user input always runs first. The model marks the goal `done` or `blocked` with the built-in `update_goal` tool. The current state appears in the status bar and is persisted with the session. `esc` pauses an interrupted goal, and terminal provider errors block it instead of retrying indefinitely. Use `/goal` to inspect it, `/goal pause` or `/goal resume` to control execution, and `/goal clear` to remove it.322+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 a 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. `esc` pauses an interrupted goal, and terminal provider errors block it instead of retrying indefinitely. Use `/goal` to inspect it, `/goal pause` or `/goal resume` to control execution, and `/goal clear` to remove it.
323 323
324 A branch that copies the complete transcript inherits the goal. Forking from an earlier turn clears it because the later objective did not necessarily exist at that point. Manual `/compact` leaves an active goal recorded but does not start a hidden follow-up; run `/goal resume` to continue it.324 A branch that copies the complete transcript inherits the goal. Forking from an earlier turn clears it because the later objective did not necessarily exist at that point. Manual `/compact` leaves an active goal recorded but does not start a hidden follow-up; run `/goal resume` to continue it.
325 325
M packages/agent/cli.go +33 -1
@@ -707,13 +707,25 @@ func persistToolResultState(mgr *extensions.Manager, sess *core.Session, result
707707 }
708708
709709 func persistGoalToolResult(sess *core.Session, result core.ToolResult) error {
710- if sess == nil || sess.Meta.Goal == nil || sess.Meta.Goal.Status != core.GoalActive {
710+ if sess == nil {
711711 return nil
712712 }
713713 update, ok := tools.GoalUpdateFromResult(result)
714714 if !ok {
715715 return nil
716716 }
717+ if update.Status == core.GoalActive {
718+ if sess.Meta.Goal != nil && sess.Meta.Goal.Status == core.GoalActive {
719+ return fmt.Errorf("cannot replace an active goal")
720+ }
721+ if sess.Meta.Mission != nil && update.MissionID != "" && update.MissionID != sess.Meta.Mission.ID {
722+ return fmt.Errorf("goal update does not belong to the active mission")
723+ }
724+ return sess.UpdateGoal(&core.SessionGoal{Objective: update.Objective, Status: core.GoalActive, Owner: core.GoalOwnerManager})
725+ }
726+ if sess.Meta.Goal == nil || sess.Meta.Goal.Status != core.GoalActive {
727+ return nil
728+ }
717729 goal := *sess.Meta.Goal
718730 goal.Status = update.Status
719731 goal.Reason = update.Reason
@@ -1852,6 +1864,16 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
18521864 }
18531865 return sess.UpdateGoal(goal)
18541866 }
1867+ ensureMission := func(objective string) error {
1868+ sessionTransitionMu.RLock()
1869+ defer sessionTransitionMu.RUnlock()
1870+ persistMu.Lock()
1871+ defer persistMu.Unlock()
1872+ if sess == nil {
1873+ return errors.New("session persistence is disabled")
1874+ }
1875+ return sess.EnsureMission(objective, core.MissionSourceUser)
1876+ }
18551877 currentGoal := func() *core.SessionGoal {
18561878 persistMu.Lock()
18571879 defer persistMu.Unlock()
@@ -1861,6 +1883,14 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
18611883 goal := *sess.Meta.Goal
18621884 return &goal
18631885 }
1886+ currentGoalHistory := func() []core.SessionGoal {
1887+ persistMu.Lock()
1888+ defer persistMu.Unlock()
1889+ if sess == nil {
1890+ return nil
1891+ }
1892+ return append([]core.SessionGoal(nil), sess.Meta.GoalHistory...)
1893+ }
18641894 bindAgentToolResultSession := func(a *core.Agent, boundSession *core.Session) {
18651895 if a == nil {
18661896 return
@@ -2474,6 +2504,8 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
24742504 CurrentCompactHandoff: currentCompactHandoff,
24752505 PersistGoal: persistGoal,
24762506 CurrentGoal: currentGoal,
2507+ CurrentGoalHistory: currentGoalHistory,
2508+ EnsureMission: ensureMission,
24772509 CurrentSessionPath: func() string {
24782510 persistMu.Lock()
24792511 defer persistMu.Unlock()
@@ -707,13 +707,25 @@ func persistToolResultState(mgr *extensions.Manager, sess *core.Session, result
707 }707 }
708 708
709 func persistGoalToolResult(sess *core.Session, result core.ToolResult) error {709 func persistGoalToolResult(sess *core.Session, result core.ToolResult) error {
710- if sess == nil || sess.Meta.Goal == nil || sess.Meta.Goal.Status != core.GoalActive {710+ if sess == nil {
711 return nil711 return nil
712 }712 }
713 update, ok := tools.GoalUpdateFromResult(result)713 update, ok := tools.GoalUpdateFromResult(result)
714 if !ok {714 if !ok {
715 return nil715 return nil
716 }716 }
717+ if update.Status == core.GoalActive {
718+ if sess.Meta.Goal != nil && sess.Meta.Goal.Status == core.GoalActive {
719+ return fmt.Errorf("cannot replace an active goal")
720+ }
721+ if sess.Meta.Mission != nil && update.MissionID != "" && update.MissionID != sess.Meta.Mission.ID {
722+ return fmt.Errorf("goal update does not belong to the active mission")
723+ }
724+ return sess.UpdateGoal(&core.SessionGoal{Objective: update.Objective, Status: core.GoalActive, Owner: core.GoalOwnerManager})
725+ }
726+ if sess.Meta.Goal == nil || sess.Meta.Goal.Status != core.GoalActive {
727+ return nil
728+ }
717 goal := *sess.Meta.Goal729 goal := *sess.Meta.Goal
718 goal.Status = update.Status730 goal.Status = update.Status
719 goal.Reason = update.Reason731 goal.Reason = update.Reason
@@ -1852,6 +1864,16 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1852 }1864 }
1853 return sess.UpdateGoal(goal)1865 return sess.UpdateGoal(goal)
1854 }1866 }
1867+ ensureMission := func(objective string) error {
1868+ sessionTransitionMu.RLock()
1869+ defer sessionTransitionMu.RUnlock()
1870+ persistMu.Lock()
1871+ defer persistMu.Unlock()
1872+ if sess == nil {
1873+ return errors.New("session persistence is disabled")
1874+ }
1875+ return sess.EnsureMission(objective, core.MissionSourceUser)
1876+ }
1855 currentGoal := func() *core.SessionGoal {1877 currentGoal := func() *core.SessionGoal {
1856 persistMu.Lock()1878 persistMu.Lock()
1857 defer persistMu.Unlock()1879 defer persistMu.Unlock()
@@ -1861,6 +1883,14 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1861 goal := *sess.Meta.Goal1883 goal := *sess.Meta.Goal
1862 return &goal1884 return &goal
1863 }1885 }
1886+ currentGoalHistory := func() []core.SessionGoal {
1887+ persistMu.Lock()
1888+ defer persistMu.Unlock()
1889+ if sess == nil {
1890+ return nil
1891+ }
1892+ return append([]core.SessionGoal(nil), sess.Meta.GoalHistory...)
1893+ }
1864 bindAgentToolResultSession := func(a *core.Agent, boundSession *core.Session) {1894 bindAgentToolResultSession := func(a *core.Agent, boundSession *core.Session) {
1865 if a == nil {1895 if a == nil {
1866 return1896 return
@@ -2474,6 +2504,8 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2474 CurrentCompactHandoff: currentCompactHandoff,2504 CurrentCompactHandoff: currentCompactHandoff,
2475 PersistGoal: persistGoal,2505 PersistGoal: persistGoal,
2476 CurrentGoal: currentGoal,2506 CurrentGoal: currentGoal,
2507+ CurrentGoalHistory: currentGoalHistory,
2508+ EnsureMission: ensureMission,
2477 CurrentSessionPath: func() string {2509 CurrentSessionPath: func() string {
2478 persistMu.Lock()2510 persistMu.Lock()
2479 defer persistMu.Unlock()2511 defer persistMu.Unlock()
M packages/agent/cli_session_test.go +73 -0
@@ -111,6 +111,79 @@ func TestToolResultPersistenceUsesBoundSession(t *testing.T) {
111111 }
112112 }
113113
114+func TestPersistGoalToolResultCreatesActiveGoal(t *testing.T) {
115+ sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
116+ if err != nil {
117+ t.Fatal(err)
118+ }
119+ defer sess.Close()
120+
121+ result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "reproduce the reported failure"}}
122+ if err := persistGoalToolResult(sess, result); err != nil {
123+ t.Fatal(err)
124+ }
125+ if goal := sess.Meta.Goal; goal == nil || goal.Status != core.GoalActive || goal.Objective != "reproduce the reported failure" || goal.Owner != core.GoalOwnerManager {
126+ t.Fatalf("persisted goal = %#v", goal)
127+ }
128+}
129+
130+func TestPersistGoalToolResultRejectsActiveGoalReplacement(t *testing.T) {
131+ sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
132+ if err != nil {
133+ t.Fatal(err)
134+ }
135+ defer sess.Close()
136+ if err := sess.UpdateGoal(&core.SessionGoal{Objective: "current work", Status: core.GoalActive}); err != nil {
137+ t.Fatal(err)
138+ }
139+
140+ err = persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "unrelated work"}})
141+ if err == nil {
142+ t.Fatal("active goal replacement succeeded")
143+ }
144+ if got := sess.Meta.Goal; got == nil || got.Objective != "current work" || got.Status != core.GoalActive {
145+ t.Fatalf("persisted goal = %#v", got)
146+ }
147+}
148+
149+func TestPersistGoalToolResultCreatesNextGoalInMission(t *testing.T) {
150+ sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
151+ if err != nil {
152+ t.Fatal(err)
153+ }
154+ defer sess.Close()
155+ if err := sess.UpdateGoal(&core.SessionGoal{Objective: "reproduce", Status: core.GoalActive}); err != nil {
156+ t.Fatal(err)
157+ }
158+ missionID := sess.Meta.Mission.ID
159+ if err := persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalDone}}); err != nil {
160+ t.Fatal(err)
161+ }
162+ if err := persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "implement fix", MissionID: missionID}}); err != nil {
163+ t.Fatal(err)
164+ }
165+ goal := sess.Meta.Goal
166+ if goal == nil || goal.Objective != "implement fix" || goal.MissionID != missionID || goal.Owner != core.GoalOwnerManager || goal.Ordinal != 3 {
167+ t.Fatalf("next goal = %#v", goal)
168+ }
169+}
170+
171+func TestPersistGoalToolResultRejectsMismatchedMission(t *testing.T) {
172+ sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
173+ if err != nil {
174+ t.Fatal(err)
175+ }
176+ defer sess.Close()
177+ if err := sess.EnsureMission("current work", core.MissionSourceUser); err != nil {
178+ t.Fatal(err)
179+ }
180+
181+ err = persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "next work", MissionID: "other-mission"}})
182+ if err == nil {
183+ t.Fatal("mismatched mission was accepted")
184+ }
185+}
186+
114187 func TestPersistGoalToolResultRejectsBlockedWithoutReason(t *testing.T) {
115188 sess, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
116189 if err != nil {
@@ -111,6 +111,79 @@ func TestToolResultPersistenceUsesBoundSession(t *testing.T) {
111 }111 }
112 }112 }
113 113
114+func TestPersistGoalToolResultCreatesActiveGoal(t *testing.T) {
115+ sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
116+ if err != nil {
117+ t.Fatal(err)
118+ }
119+ defer sess.Close()
120+
121+ result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "reproduce the reported failure"}}
122+ if err := persistGoalToolResult(sess, result); err != nil {
123+ t.Fatal(err)
124+ }
125+ if goal := sess.Meta.Goal; goal == nil || goal.Status != core.GoalActive || goal.Objective != "reproduce the reported failure" || goal.Owner != core.GoalOwnerManager {
126+ t.Fatalf("persisted goal = %#v", goal)
127+ }
128+}
129+
130+func TestPersistGoalToolResultRejectsActiveGoalReplacement(t *testing.T) {
131+ sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
132+ if err != nil {
133+ t.Fatal(err)
134+ }
135+ defer sess.Close()
136+ if err := sess.UpdateGoal(&core.SessionGoal{Objective: "current work", Status: core.GoalActive}); err != nil {
137+ t.Fatal(err)
138+ }
139+
140+ err = persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "unrelated work"}})
141+ if err == nil {
142+ t.Fatal("active goal replacement succeeded")
143+ }
144+ if got := sess.Meta.Goal; got == nil || got.Objective != "current work" || got.Status != core.GoalActive {
145+ t.Fatalf("persisted goal = %#v", got)
146+ }
147+}
148+
149+func TestPersistGoalToolResultCreatesNextGoalInMission(t *testing.T) {
150+ sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
151+ if err != nil {
152+ t.Fatal(err)
153+ }
154+ defer sess.Close()
155+ if err := sess.UpdateGoal(&core.SessionGoal{Objective: "reproduce", Status: core.GoalActive}); err != nil {
156+ t.Fatal(err)
157+ }
158+ missionID := sess.Meta.Mission.ID
159+ if err := persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalDone}}); err != nil {
160+ t.Fatal(err)
161+ }
162+ if err := persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "implement fix", MissionID: missionID}}); err != nil {
163+ t.Fatal(err)
164+ }
165+ goal := sess.Meta.Goal
166+ if goal == nil || goal.Objective != "implement fix" || goal.MissionID != missionID || goal.Owner != core.GoalOwnerManager || goal.Ordinal != 3 {
167+ t.Fatalf("next goal = %#v", goal)
168+ }
169+}
170+
171+func TestPersistGoalToolResultRejectsMismatchedMission(t *testing.T) {
172+ sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
173+ if err != nil {
174+ t.Fatal(err)
175+ }
176+ defer sess.Close()
177+ if err := sess.EnsureMission("current work", core.MissionSourceUser); err != nil {
178+ t.Fatal(err)
179+ }
180+
181+ err = persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "next work", MissionID: "other-mission"}})
182+ if err == nil {
183+ t.Fatal("mismatched mission was accepted")
184+ }
185+}
186+
114 func TestPersistGoalToolResultRejectsBlockedWithoutReason(t *testing.T) {187 func TestPersistGoalToolResultRejectsBlockedWithoutReason(t *testing.T) {
115 sess, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")188 sess, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
116 if err != nil {189 if err != nil {
M packages/agent/modes/goal.go +35 -3
@@ -31,6 +31,9 @@ func (i *Interactive) goalContinuationMessage() (provider.Message, bool) {
3131 }
3232 text := goal.Objective +
3333 "\n\nContinue working autonomously toward this active goal. Do not stop at a progress update. Continue until the goal is complete or blocked, then call update_goal."
34+ if goal.MissionID != "" {
35+ text += " When setting a next goal, use mission_id " + goal.MissionID + " and keep it within the same user mission."
36+ }
3437 return provider.Message{
3538 Role: provider.RoleUser,
3639 Content: []provider.Content{provider.TextBlock{Text: text}},
@@ -157,12 +160,41 @@ func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []st
157160 }
158161
159162 current := copySessionGoal(i.cfg.CurrentGoal())
163+ if len(parts) == 2 && strings.EqualFold(parts[1], "history") {
164+ if i.cfg.CurrentGoalHistory == nil {
165+ i.setGoalCommandError("goal: history is unavailable")
166+ return
167+ }
168+ history := i.cfg.CurrentGoalHistory()
169+ if len(history) == 0 {
170+ i.setGoalCommandStatus("no goal history")
171+ return
172+ }
173+ entries := make([]string, 0, len(history))
174+ for _, goal := range history {
175+ owner := goal.Owner
176+ if owner == "" {
177+ owner = core.GoalOwnerUser
178+ }
179+ entry := fmt.Sprintf("%s (%s): %s", goal.Status, owner, goal.Objective)
180+ if goal.Reason != "" {
181+ entry += " (" + goal.Reason + ")"
182+ }
183+ entries = append(entries, entry)
184+ }
185+ i.setGoalCommandStatus("goal history: " + strings.Join(entries, " → "))
186+ return
187+ }
160188 if len(parts) == 1 {
161189 if current == nil {
162190 i.setGoalCommandStatus("no autonomous goal")
163191 return
164192 }
165- status := fmt.Sprintf("goal %s: %s", current.Status, current.Objective)
193+ owner := current.Owner
194+ if owner == "" {
195+ owner = core.GoalOwnerUser
196+ }
197+ status := fmt.Sprintf("goal %s (%s): %s", current.Status, owner, current.Objective)
166198 if current.Reason != "" {
167199 status += " (" + current.Reason + ")"
168200 }
@@ -203,10 +235,10 @@ func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []st
203235 default:
204236 objective := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(cmd), parts[0]))
205237 if objective == "" {
206- i.setGoalCommandError("usage: /goal <objective>|pause|resume|clear")
238+ i.setGoalCommandError("usage: /goal <objective>|pause|resume|clear|history")
207239 return
208240 }
209- current = &core.SessionGoal{Objective: objective, Status: core.GoalActive}
241+ current = &core.SessionGoal{Objective: objective, Status: core.GoalActive, Owner: core.GoalOwnerUser}
210242 startIfIdle = true
211243 startTitle = true
212244 }
@@ -31,6 +31,9 @@ func (i *Interactive) goalContinuationMessage() (provider.Message, bool) {
31 }31 }
32 text := goal.Objective +32 text := goal.Objective +
33 "\n\nContinue working autonomously toward this active goal. Do not stop at a progress update. Continue until the goal is complete or blocked, then call update_goal."33 "\n\nContinue working autonomously toward this active goal. Do not stop at a progress update. Continue until the goal is complete or blocked, then call update_goal."
34+ if goal.MissionID != "" {
35+ text += " When setting a next goal, use mission_id " + goal.MissionID + " and keep it within the same user mission."
36+ }
34 return provider.Message{37 return provider.Message{
35 Role: provider.RoleUser,38 Role: provider.RoleUser,
36 Content: []provider.Content{provider.TextBlock{Text: text}},39 Content: []provider.Content{provider.TextBlock{Text: text}},
@@ -157,12 +160,41 @@ func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []st
157 }160 }
158 161
159 current := copySessionGoal(i.cfg.CurrentGoal())162 current := copySessionGoal(i.cfg.CurrentGoal())
163+ if len(parts) == 2 && strings.EqualFold(parts[1], "history") {
164+ if i.cfg.CurrentGoalHistory == nil {
165+ i.setGoalCommandError("goal: history is unavailable")
166+ return
167+ }
168+ history := i.cfg.CurrentGoalHistory()
169+ if len(history) == 0 {
170+ i.setGoalCommandStatus("no goal history")
171+ return
172+ }
173+ entries := make([]string, 0, len(history))
174+ for _, goal := range history {
175+ owner := goal.Owner
176+ if owner == "" {
177+ owner = core.GoalOwnerUser
178+ }
179+ entry := fmt.Sprintf("%s (%s): %s", goal.Status, owner, goal.Objective)
180+ if goal.Reason != "" {
181+ entry += " (" + goal.Reason + ")"
182+ }
183+ entries = append(entries, entry)
184+ }
185+ i.setGoalCommandStatus("goal history: " + strings.Join(entries, " → "))
186+ return
187+ }
160 if len(parts) == 1 {188 if len(parts) == 1 {
161 if current == nil {189 if current == nil {
162 i.setGoalCommandStatus("no autonomous goal")190 i.setGoalCommandStatus("no autonomous goal")
163 return191 return
164 }192 }
165- status := fmt.Sprintf("goal %s: %s", current.Status, current.Objective)193+ owner := current.Owner
194+ if owner == "" {
195+ owner = core.GoalOwnerUser
196+ }
197+ status := fmt.Sprintf("goal %s (%s): %s", current.Status, owner, current.Objective)
166 if current.Reason != "" {198 if current.Reason != "" {
167 status += " (" + current.Reason + ")"199 status += " (" + current.Reason + ")"
168 }200 }
@@ -203,10 +235,10 @@ func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []st
203 default:235 default:
204 objective := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(cmd), parts[0]))236 objective := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(cmd), parts[0]))
205 if objective == "" {237 if objective == "" {
206- i.setGoalCommandError("usage: /goal <objective>|pause|resume|clear")238+ i.setGoalCommandError("usage: /goal <objective>|pause|resume|clear|history")
207 return239 return
208 }240 }
209- current = &core.SessionGoal{Objective: objective, Status: core.GoalActive}241+ current = &core.SessionGoal{Objective: objective, Status: core.GoalActive, Owner: core.GoalOwnerUser}
210 startIfIdle = true242 startIfIdle = true
211 startTitle = true243 startTitle = true
212 }244 }
M packages/agent/modes/goal_test.go +39 -0
@@ -214,6 +214,27 @@ func TestToolResultPersistenceRunsOutsideTUILock(t *testing.T) {
214214 }
215215 }
216216
217+func TestGoalBadgeAcceptsNextPersistedManagerGoal(t *testing.T) {
218+ interactive := NewInteractive(InteractiveConfig{
219+ Agent: core.NewAgent(nil, "model", "system", goalToolRegistry()),
220+ CurrentGoal: func() *core.SessionGoal {
221+ return &core.SessionGoal{Objective: "implement the fix", Status: core.GoalActive, Owner: core.GoalOwnerManager}
222+ },
223+ })
224+ interactive.mu.Lock()
225+ interactive.goalStatus = core.GoalDone
226+ interactive.mu.Unlock()
227+
228+ interactive.handleEvent(core.EvToolResult{ID: "goal", Result: core.ToolResult{
229+ Details: agenttools.GoalUpdate{Status: core.GoalActive, Objective: "implement the fix"},
230+ }})
231+ interactive.mu.Lock()
232+ defer interactive.mu.Unlock()
233+ if interactive.goalStatus != core.GoalActive {
234+ t.Fatalf("goal badge = %q, want active", interactive.goalStatus)
235+ }
236+}
237+
217238 func TestGoalBadgeUsesDurableStatusAfterPersistenceFailure(t *testing.T) {
218239 goal := &core.SessionGoal{Objective: "finish issue 97", Status: core.GoalActive}
219240 interactive := NewInteractive(InteractiveConfig{
@@ -288,6 +309,24 @@ func TestGoalSlashCommandStartsAndControlsGoal(t *testing.T) {
288309 }
289310 }
290311
312+func TestGoalHistoryShowsOwnerAndReason(t *testing.T) {
313+ interactive := NewInteractive(InteractiveConfig{
314+ CurrentGoal: func() *core.SessionGoal { return nil },
315+ PersistGoal: func(*core.SessionGoal) error { return nil },
316+ CurrentGoalHistory: func() []core.SessionGoal {
317+ return []core.SessionGoal{
318+ {Objective: "reproduce", Status: core.GoalDone, Owner: core.GoalOwnerManager},
319+ {Objective: "await input", Status: core.GoalBlocked, Owner: core.GoalOwnerUser, Reason: "missing logs"},
320+ }
321+ },
322+ })
323+
324+ interactive.runSlash(context.Background(), "/goal history")
325+ if !strings.Contains(interactive.statusOK, "done (manager): reproduce") || !strings.Contains(interactive.statusOK, "blocked (user): await input (missing logs)") {
326+ t.Fatalf("goal history = %q", interactive.statusOK)
327+ }
328+}
329+
291330 func TestGoalControlRequiresSessionPersistence(t *testing.T) {
292331 interactive := NewInteractive(InteractiveConfig{})
293332 interactive.runSlash(context.Background(), "/goal pause")
@@ -214,6 +214,27 @@ func TestToolResultPersistenceRunsOutsideTUILock(t *testing.T) {
214 }214 }
215 }215 }
216 216
217+func TestGoalBadgeAcceptsNextPersistedManagerGoal(t *testing.T) {
218+ interactive := NewInteractive(InteractiveConfig{
219+ Agent: core.NewAgent(nil, "model", "system", goalToolRegistry()),
220+ CurrentGoal: func() *core.SessionGoal {
221+ return &core.SessionGoal{Objective: "implement the fix", Status: core.GoalActive, Owner: core.GoalOwnerManager}
222+ },
223+ })
224+ interactive.mu.Lock()
225+ interactive.goalStatus = core.GoalDone
226+ interactive.mu.Unlock()
227+
228+ interactive.handleEvent(core.EvToolResult{ID: "goal", Result: core.ToolResult{
229+ Details: agenttools.GoalUpdate{Status: core.GoalActive, Objective: "implement the fix"},
230+ }})
231+ interactive.mu.Lock()
232+ defer interactive.mu.Unlock()
233+ if interactive.goalStatus != core.GoalActive {
234+ t.Fatalf("goal badge = %q, want active", interactive.goalStatus)
235+ }
236+}
237+
217 func TestGoalBadgeUsesDurableStatusAfterPersistenceFailure(t *testing.T) {238 func TestGoalBadgeUsesDurableStatusAfterPersistenceFailure(t *testing.T) {
218 goal := &core.SessionGoal{Objective: "finish issue 97", Status: core.GoalActive}239 goal := &core.SessionGoal{Objective: "finish issue 97", Status: core.GoalActive}
219 interactive := NewInteractive(InteractiveConfig{240 interactive := NewInteractive(InteractiveConfig{
@@ -288,6 +309,24 @@ func TestGoalSlashCommandStartsAndControlsGoal(t *testing.T) {
288 }309 }
289 }310 }
290 311
312+func TestGoalHistoryShowsOwnerAndReason(t *testing.T) {
313+ interactive := NewInteractive(InteractiveConfig{
314+ CurrentGoal: func() *core.SessionGoal { return nil },
315+ PersistGoal: func(*core.SessionGoal) error { return nil },
316+ CurrentGoalHistory: func() []core.SessionGoal {
317+ return []core.SessionGoal{
318+ {Objective: "reproduce", Status: core.GoalDone, Owner: core.GoalOwnerManager},
319+ {Objective: "await input", Status: core.GoalBlocked, Owner: core.GoalOwnerUser, Reason: "missing logs"},
320+ }
321+ },
322+ })
323+
324+ interactive.runSlash(context.Background(), "/goal history")
325+ if !strings.Contains(interactive.statusOK, "done (manager): reproduce") || !strings.Contains(interactive.statusOK, "blocked (user): await input (missing logs)") {
326+ t.Fatalf("goal history = %q", interactive.statusOK)
327+ }
328+}
329+
291 func TestGoalControlRequiresSessionPersistence(t *testing.T) {330 func TestGoalControlRequiresSessionPersistence(t *testing.T) {
292 interactive := NewInteractive(InteractiveConfig{})331 interactive := NewInteractive(InteractiveConfig{})
293 interactive.runSlash(context.Background(), "/goal pause")332 interactive.runSlash(context.Background(), "/goal pause")
M packages/agent/modes/interactive.go +21 -4
@@ -377,8 +377,10 @@ type InteractiveConfig struct {
377377 // current session. CurrentGoal must return a copy, not the live session
378378 // pointer, so failed persistence cannot mutate in-memory state. The callbacks
379379 // follow session switches without coupling the TUI to the session file owner.
380- CurrentGoal func() *core.SessionGoal
381- PersistGoal func(goal *core.SessionGoal) error
380+ CurrentGoal func() *core.SessionGoal
381+ CurrentGoalHistory func() []core.SessionGoal
382+ EnsureMission func(objective string) error
383+ PersistGoal func(goal *core.SessionGoal) error
382384
383385 OnAssistant func(m provider.Message)
384386
@@ -4176,6 +4178,12 @@ func (i *Interactive) submitOrQueue(text string, images []provider.ImageBlock, u
41764178 return
41774179 }
41784180 i.mu.Unlock()
4181+ if userInput && i.cfg.EnsureMission != nil {
4182+ if err := i.cfg.EnsureMission(text); err != nil {
4183+ i.ReportError(fmt.Errorf("persist mission: %w", err))
4184+ return
4185+ }
4186+ }
41794187 // User input is an orchestration event, not merely a queue append. When
41804188 // the manager is dormant behind a sealed worker wave, the coordinator makes
41814189 // this the next manager turn and carries any completed worker summary with it.
@@ -8362,8 +8370,17 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
83628370 tc.Result = text.String()
83638371 i.bumpToolRevisionLocked(tc)
83648372 }
8365- if update, ok := tools.GoalUpdateFromResult(e.Result); ok && i.goalStatus == core.GoalActive {
8366- i.goalStatus = update.Status
8373+ if update, ok := tools.GoalUpdateFromResult(e.Result); ok {
8374+ if update.Status == core.GoalActive {
8375+ // A manager may advance a terminal goal to the next persisted
8376+ // goal in the same mission. Do not let a late tool result resume
8377+ // a goal explicitly paused by the user.
8378+ if i.goalStatus != core.GoalPaused {
8379+ i.goalStatus = core.GoalActive
8380+ }
8381+ } else if i.goalStatus == core.GoalActive {
8382+ i.goalStatus = update.Status
8383+ }
83678384 }
83688385 case core.EvUsage:
83698386 i.cumUsage = e.Cumulative
@@ -377,8 +377,10 @@ type InteractiveConfig struct {
377 // current session. CurrentGoal must return a copy, not the live session377 // current session. CurrentGoal must return a copy, not the live session
378 // pointer, so failed persistence cannot mutate in-memory state. The callbacks378 // pointer, so failed persistence cannot mutate in-memory state. The callbacks
379 // follow session switches without coupling the TUI to the session file owner.379 // follow session switches without coupling the TUI to the session file owner.
380- CurrentGoal func() *core.SessionGoal380+ CurrentGoal func() *core.SessionGoal
381- PersistGoal func(goal *core.SessionGoal) error381+ CurrentGoalHistory func() []core.SessionGoal
382+ EnsureMission func(objective string) error
383+ PersistGoal func(goal *core.SessionGoal) error
382 384
383 OnAssistant func(m provider.Message)385 OnAssistant func(m provider.Message)
384 386
@@ -4176,6 +4178,12 @@ func (i *Interactive) submitOrQueue(text string, images []provider.ImageBlock, u
4176 return4178 return
4177 }4179 }
4178 i.mu.Unlock()4180 i.mu.Unlock()
4181+ if userInput && i.cfg.EnsureMission != nil {
4182+ if err := i.cfg.EnsureMission(text); err != nil {
4183+ i.ReportError(fmt.Errorf("persist mission: %w", err))
4184+ return
4185+ }
4186+ }
4179 // User input is an orchestration event, not merely a queue append. When4187 // User input is an orchestration event, not merely a queue append. When
4180 // the manager is dormant behind a sealed worker wave, the coordinator makes4188 // the manager is dormant behind a sealed worker wave, the coordinator makes
4181 // this the next manager turn and carries any completed worker summary with it.4189 // this the next manager turn and carries any completed worker summary with it.
@@ -8362,8 +8370,17 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
8362 tc.Result = text.String()8370 tc.Result = text.String()
8363 i.bumpToolRevisionLocked(tc)8371 i.bumpToolRevisionLocked(tc)
8364 }8372 }
8365- if update, ok := tools.GoalUpdateFromResult(e.Result); ok && i.goalStatus == core.GoalActive {8373+ if update, ok := tools.GoalUpdateFromResult(e.Result); ok {
8366- i.goalStatus = update.Status8374+ if update.Status == core.GoalActive {
8375+ // A manager may advance a terminal goal to the next persisted
8376+ // goal in the same mission. Do not let a late tool result resume
8377+ // a goal explicitly paused by the user.
8378+ if i.goalStatus != core.GoalPaused {
8379+ i.goalStatus = core.GoalActive
8380+ }
8381+ } else if i.goalStatus == core.GoalActive {
8382+ i.goalStatus = update.Status
8383+ }
8367 }8384 }
8368 case core.EvUsage:8385 case core.EvUsage:
8369 i.cumUsage = e.Cumulative8386 i.cumUsage = e.Cumulative
M packages/agent/tools/update_goal.go +27 -10
@@ -13,28 +13,33 @@ import (
1313
1414 const (
1515 UpdateGoalToolName = "update_goal"
16- updateGoalSchema = `{"type":"object","properties":{"status":{"type":"string","enum":["complete","blocked"],"description":"Mark the active autonomous goal complete or blocked."},"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":"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}`
1717 )
1818
19-// UpdateGoalTool lets the model stop an explicitly user-started autonomous
20-// goal. Starting, pausing, and resuming remain user-controlled.
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.
2122 type UpdateGoalTool struct{}
2223
2324 type updateGoalArgs struct {
24- Status string `json:"status"`
25- Reason string `json:"reason,omitempty"`
25+ Status string `json:"status"`
26+ Objective string `json:"objective,omitempty"`
27+ MissionID string `json:"mission_id,omitempty"`
28+ Reason string `json:"reason,omitempty"`
2629 }
2730
2831 // GoalUpdate is trusted metadata returned only by UpdateGoalTool.
2932 type GoalUpdate struct {
30- Status core.GoalStatus
31- Reason string
33+ Status core.GoalStatus
34+ Objective string
35+ MissionID string
36+ Reason string
3237 }
3338
3439 func (t *UpdateGoalTool) Name() string { return UpdateGoalToolName }
3540
3641 func (t *UpdateGoalTool) Description() string {
37- return "Mark the active autonomous goal complete or blocked. Only use this for a goal explicitly started by the user with /goal."
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."
3843 }
3944
4045 func (t *UpdateGoalTool) Schema() json.RawMessage { return json.RawMessage(updateGoalSchema) }
@@ -50,10 +55,19 @@ func (t *UpdateGoalTool) Execute(_ context.Context, raw json.RawMessage, _ func(
5055 return goalToolError("invalid arguments"), nil
5156 }
5257 args.Status = strings.TrimSpace(args.Status)
58+ args.Objective = strings.TrimSpace(args.Objective)
59+ args.MissionID = strings.TrimSpace(args.MissionID)
5360 args.Reason = strings.TrimSpace(args.Reason)
5461
5562 var update GoalUpdate
5663 switch args.Status {
64+ case "active":
65+ if args.Objective == "" {
66+ return goalToolError("objective is required when setting a goal"), nil
67+ }
68+ update.Status = core.GoalActive
69+ update.Objective = args.Objective
70+ update.MissionID = args.MissionID
5771 case "complete":
5872 update.Status = core.GoalDone
5973 case "blocked":
@@ -63,7 +77,7 @@ func (t *UpdateGoalTool) Execute(_ context.Context, raw json.RawMessage, _ func(
6377 update.Status = core.GoalBlocked
6478 update.Reason = args.Reason
6579 default:
66- return goalToolError("status must be complete or blocked"), nil
80+ return goalToolError("status must be active, complete, or blocked"), nil
6781 }
6882
6983 text := "autonomous goal marked " + string(update.Status)
@@ -85,7 +99,10 @@ func GoalUpdateFromResult(result core.ToolResult) (GoalUpdate, bool) {
8599 if !ok {
86100 return GoalUpdate{}, false
87101 }
88- if update.Status != core.GoalDone && update.Status != core.GoalBlocked {
102+ if update.Status != core.GoalActive && update.Status != core.GoalDone && update.Status != core.GoalBlocked {
103+ return GoalUpdate{}, false
104+ }
105+ if update.Status == core.GoalActive && strings.TrimSpace(update.Objective) == "" {
89106 return GoalUpdate{}, false
90107 }
91108 if update.Status == core.GoalBlocked && strings.TrimSpace(update.Reason) == "" {
@@ -13,28 +13,33 @@ import (
13 13
14 const (14 const (
15 UpdateGoalToolName = "update_goal"15 UpdateGoalToolName = "update_goal"
16- updateGoalSchema = `{"type":"object","properties":{"status":{"type":"string","enum":["complete","blocked"],"description":"Mark the active autonomous goal complete or blocked."},"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":"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}`
17 )17 )
18 18
19-// UpdateGoalTool lets the model stop an explicitly user-started autonomous19+// UpdateGoalTool lets the manager set the next concrete active goal or settle
20-// goal. Starting, pausing, and resuming remain user-controlled.20+// the current goal. Host-side persistence validates that a transition remains
21+// within the session's active mission.
21 type UpdateGoalTool struct{}22 type UpdateGoalTool struct{}
22 23
23 type updateGoalArgs struct {24 type updateGoalArgs struct {
24- Status string `json:"status"`25+ Status string `json:"status"`
25- Reason string `json:"reason,omitempty"`26+ Objective string `json:"objective,omitempty"`
27+ MissionID string `json:"mission_id,omitempty"`
28+ Reason string `json:"reason,omitempty"`
26 }29 }
27 30
28 // GoalUpdate is trusted metadata returned only by UpdateGoalTool.31 // GoalUpdate is trusted metadata returned only by UpdateGoalTool.
29 type GoalUpdate struct {32 type GoalUpdate struct {
30- Status core.GoalStatus33+ Status core.GoalStatus
31- Reason string34+ Objective string
35+ MissionID string
36+ Reason string
32 }37 }
33 38
34 func (t *UpdateGoalTool) Name() string { return UpdateGoalToolName }39 func (t *UpdateGoalTool) Name() string { return UpdateGoalToolName }
35 40
36 func (t *UpdateGoalTool) Description() string {41 func (t *UpdateGoalTool) Description() string {
37- return "Mark the active autonomous goal complete or blocked. Only use this for a goal explicitly started by the user with /goal."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."
38 }43 }
39 44
40 func (t *UpdateGoalTool) Schema() json.RawMessage { return json.RawMessage(updateGoalSchema) }45 func (t *UpdateGoalTool) Schema() json.RawMessage { return json.RawMessage(updateGoalSchema) }
@@ -50,10 +55,19 @@ func (t *UpdateGoalTool) Execute(_ context.Context, raw json.RawMessage, _ func(
50 return goalToolError("invalid arguments"), nil55 return goalToolError("invalid arguments"), nil
51 }56 }
52 args.Status = strings.TrimSpace(args.Status)57 args.Status = strings.TrimSpace(args.Status)
58+ args.Objective = strings.TrimSpace(args.Objective)
59+ args.MissionID = strings.TrimSpace(args.MissionID)
53 args.Reason = strings.TrimSpace(args.Reason)60 args.Reason = strings.TrimSpace(args.Reason)
54 61
55 var update GoalUpdate62 var update GoalUpdate
56 switch args.Status {63 switch args.Status {
64+ case "active":
65+ if args.Objective == "" {
66+ return goalToolError("objective is required when setting a goal"), nil
67+ }
68+ update.Status = core.GoalActive
69+ update.Objective = args.Objective
70+ update.MissionID = args.MissionID
57 case "complete":71 case "complete":
58 update.Status = core.GoalDone72 update.Status = core.GoalDone
59 case "blocked":73 case "blocked":
@@ -63,7 +77,7 @@ func (t *UpdateGoalTool) Execute(_ context.Context, raw json.RawMessage, _ func(
63 update.Status = core.GoalBlocked77 update.Status = core.GoalBlocked
64 update.Reason = args.Reason78 update.Reason = args.Reason
65 default:79 default:
66- return goalToolError("status must be complete or blocked"), nil80+ return goalToolError("status must be active, complete, or blocked"), nil
67 }81 }
68 82
69 text := "autonomous goal marked " + string(update.Status)83 text := "autonomous goal marked " + string(update.Status)
@@ -85,7 +99,10 @@ func GoalUpdateFromResult(result core.ToolResult) (GoalUpdate, bool) {
85 if !ok {99 if !ok {
86 return GoalUpdate{}, false100 return GoalUpdate{}, false
87 }101 }
88- if update.Status != core.GoalDone && update.Status != core.GoalBlocked {102+ if update.Status != core.GoalActive && update.Status != core.GoalDone && update.Status != core.GoalBlocked {
103+ return GoalUpdate{}, false
104+ }
105+ if update.Status == core.GoalActive && strings.TrimSpace(update.Objective) == "" {
89 return GoalUpdate{}, false106 return GoalUpdate{}, false
90 }107 }
91 if update.Status == core.GoalBlocked && strings.TrimSpace(update.Reason) == "" {108 if update.Status == core.GoalBlocked && strings.TrimSpace(update.Reason) == "" {
M packages/agent/tools/update_goal_test.go +15 -0
@@ -30,6 +30,21 @@ func TestUpdateGoalToolReturnsPersistableStatus(t *testing.T) {
3030 }
3131 }
3232
33+func TestUpdateGoalToolReturnsPersistableManagerGoal(t *testing.T) {
34+ tool := &UpdateGoalTool{}
35+ result, err := tool.Execute(context.Background(), json.RawMessage(`{"status":"active","objective":"reproduce the reported failure"}`), nil)
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ update, ok := GoalUpdateFromResult(result)
40+ if !ok {
41+ t.Fatalf("details = %#v, want goal update", result.Details)
42+ }
43+ if update.Status != core.GoalActive || update.Objective != "reproduce the reported failure" {
44+ t.Fatalf("update = %#v", update)
45+ }
46+}
47+
3348 func TestUpdateGoalToolRejectsUnknownFields(t *testing.T) {
3449 tool := &UpdateGoalTool{}
3550 result, err := tool.Execute(context.Background(), json.RawMessage(`{"status":"complete","unexpected":true}`), nil)
@@ -30,6 +30,21 @@ func TestUpdateGoalToolReturnsPersistableStatus(t *testing.T) {
30 }30 }
31 }31 }
32 32
33+func TestUpdateGoalToolReturnsPersistableManagerGoal(t *testing.T) {
34+ tool := &UpdateGoalTool{}
35+ result, err := tool.Execute(context.Background(), json.RawMessage(`{"status":"active","objective":"reproduce the reported failure"}`), nil)
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ update, ok := GoalUpdateFromResult(result)
40+ if !ok {
41+ t.Fatalf("details = %#v, want goal update", result.Details)
42+ }
43+ if update.Status != core.GoalActive || update.Objective != "reproduce the reported failure" {
44+ t.Fatalf("update = %#v", update)
45+ }
46+}
47+
33 func TestUpdateGoalToolRejectsUnknownFields(t *testing.T) {48 func TestUpdateGoalToolRejectsUnknownFields(t *testing.T) {
34 tool := &UpdateGoalTool{}49 tool := &UpdateGoalTool{}
35 result, err := tool.Execute(context.Background(), json.RawMessage(`{"status":"complete","unexpected":true}`), nil)50 result, err := tool.Execute(context.Background(), json.RawMessage(`{"status":"complete","unexpected":true}`), nil)
M packages/core/core_test.go +159 -4
@@ -296,6 +296,155 @@ func TestSessionCompactHandoffRoundTripAndClear(t *testing.T) {
296296 }
297297 }
298298
299+func TestNormalizeSessionGoalMetaMigratesLegacyGoal(t *testing.T) {
300+ meta := SessionMeta{ID: "legacy-session", Goal: &SessionGoal{Objective: "finish migration", Status: GoalActive}}
301+ normalizeSessionGoalMeta(&meta)
302+ if meta.Mission == nil || meta.Mission.ID != "legacy-legacy-session" || meta.Mission.Source != MissionSourceUser || meta.Mission.ActiveGoalID != "legacy-goal-legacy-session" {
303+ t.Fatalf("mission = %#v", meta.Mission)
304+ }
305+ if meta.Goal.Owner != GoalOwnerUser || meta.Goal.ID != "legacy-goal-legacy-session" || meta.Goal.MissionID != meta.Mission.ID || meta.Goal.Ordinal != 1 {
306+ t.Fatalf("goal = %#v", meta.Goal)
307+ }
308+}
309+
310+func TestSessionGoalTransitionLimitRollsBackState(t *testing.T) {
311+ sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
312+ if err != nil {
313+ t.Fatal(err)
314+ }
315+ defer sess.Close()
316+ if err := sess.UpdateGoal(&SessionGoal{Objective: "initial", Status: GoalActive, Owner: GoalOwnerUser}); err != nil {
317+ t.Fatal(err)
318+ }
319+ for transition := 0; transition < maxMissionGoalTransitions; transition++ {
320+ done := *sess.Meta.Goal
321+ done.Status = GoalDone
322+ if err := sess.UpdateGoal(&done); err != nil {
323+ t.Fatalf("settle transition %d: %v", transition, err)
324+ }
325+ if err := sess.UpdateGoal(&SessionGoal{Objective: "next", Status: GoalActive, Owner: GoalOwnerManager}); err != nil {
326+ t.Fatalf("start transition %d: %v", transition, err)
327+ }
328+ }
329+ done := *sess.Meta.Goal
330+ done.Status = GoalDone
331+ if err := sess.UpdateGoal(&done); err != nil {
332+ t.Fatal(err)
333+ }
334+ previousMission := *sess.Meta.Mission
335+ previousGoal := *sess.Meta.Goal
336+ if err := sess.UpdateGoal(&SessionGoal{Objective: "one too many", Status: GoalActive, Owner: GoalOwnerManager}); err == nil {
337+ t.Fatal("transition limit was not enforced")
338+ }
339+ if *sess.Meta.Mission != previousMission || *sess.Meta.Goal != previousGoal {
340+ t.Fatalf("state changed after rejected transition: mission %#v, goal %#v", sess.Meta.Mission, sess.Meta.Goal)
341+ }
342+}
343+
344+func TestSessionGoalResumeDoesNotConsumeTransitionLimit(t *testing.T) {
345+ sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
346+ if err != nil {
347+ t.Fatal(err)
348+ }
349+ defer sess.Close()
350+ if err := sess.UpdateGoal(&SessionGoal{Objective: "initial", Status: GoalActive, Owner: GoalOwnerUser}); err != nil {
351+ t.Fatal(err)
352+ }
353+ done := *sess.Meta.Goal
354+ done.Status = GoalDone
355+ if err := sess.UpdateGoal(&done); err != nil {
356+ t.Fatal(err)
357+ }
358+ if err := sess.UpdateGoal(&SessionGoal{Objective: "manager work", Status: GoalActive, Owner: GoalOwnerManager}); err != nil {
359+ t.Fatal(err)
360+ }
361+ for cycle := 0; cycle < maxMissionGoalTransitions*2; cycle++ {
362+ paused := *sess.Meta.Goal
363+ paused.Status = GoalPaused
364+ if err := sess.UpdateGoal(&paused); err != nil {
365+ t.Fatalf("pause cycle %d: %v", cycle, err)
366+ }
367+ resumed := *sess.Meta.Goal
368+ resumed.Status = GoalActive
369+ if err := sess.UpdateGoal(&resumed); err != nil {
370+ t.Fatalf("resume cycle %d: %v", cycle, err)
371+ }
372+ }
373+ if got := sess.Meta.Mission.TransitionCount; got != 1 {
374+ t.Fatalf("transition count = %d, want 1", got)
375+ }
376+}
377+
378+func TestSessionLegacyGoalMigrationPersistsOnNextTransition(t *testing.T) {
379+ path := t.TempDir() + "/legacy.jsonl"
380+ meta := SessionMeta{ID: "legacy-session", CWD: "/tmp/project", Goal: &SessionGoal{Objective: "finish migration", Status: GoalActive}}
381+ line, err := json.Marshal(sessionLine{Type: "meta", Meta: &meta})
382+ if err != nil {
383+ t.Fatal(err)
384+ }
385+ if err := os.WriteFile(path, append(line, '\n'), 0o600); err != nil {
386+ t.Fatal(err)
387+ }
388+
389+ sess, _, err := OpenSession(path)
390+ if err != nil {
391+ t.Fatal(err)
392+ }
393+ if sess.Meta.Mission == nil || sess.Meta.Goal.MissionID != sess.Meta.Mission.ID {
394+ t.Fatalf("legacy migration = mission %#v, goal %#v", sess.Meta.Mission, sess.Meta.Goal)
395+ }
396+ done := *sess.Meta.Goal
397+ done.Status = GoalDone
398+ if err := sess.UpdateGoal(&done); err != nil {
399+ t.Fatal(err)
400+ }
401+ if err := sess.Close(); err != nil {
402+ t.Fatal(err)
403+ }
404+
405+ reopened, _, err := OpenSession(path)
406+ if err != nil {
407+ t.Fatal(err)
408+ }
409+ defer reopened.Close()
410+ if reopened.Meta.Mission == nil || reopened.Meta.Goal == nil || reopened.Meta.Goal.Status != GoalDone || reopened.Meta.Goal.MissionID != reopened.Meta.Mission.ID {
411+ t.Fatalf("persisted migration = mission %#v, goal %#v", reopened.Meta.Mission, reopened.Meta.Goal)
412+ }
413+}
414+
415+func TestSessionEnsureMissionCreatesUserBoundaryOnce(t *testing.T) {
416+ sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
417+ if err != nil {
418+ t.Fatal(err)
419+ }
420+ if err := sess.EnsureMission("fix the reported failure", MissionSourceUser); err != nil {
421+ t.Fatal(err)
422+ }
423+ if sess.Meta.Mission == nil || sess.Meta.Mission.Objective != "fix the reported failure" || sess.Meta.Mission.Source != MissionSourceUser {
424+ t.Fatalf("mission = %#v", sess.Meta.Mission)
425+ }
426+ missionID := sess.Meta.Mission.ID
427+ if err := sess.EnsureMission("unrelated follow-up", MissionSourceUser); err != nil {
428+ t.Fatal(err)
429+ }
430+ if sess.Meta.Mission.ID != missionID || sess.Meta.Mission.Objective != "fix the reported failure" {
431+ t.Fatalf("mission was replaced: %#v", sess.Meta.Mission)
432+ }
433+ path := sess.Path
434+ if err := sess.Close(); err != nil {
435+ t.Fatal(err)
436+ }
437+
438+ reopened, _, err := OpenSession(path)
439+ if err != nil {
440+ t.Fatal(err)
441+ }
442+ defer reopened.Close()
443+ if reopened.Meta.Mission == nil || reopened.Meta.Mission.ID != missionID {
444+ t.Fatalf("reopened mission = %#v", reopened.Meta.Mission)
445+ }
446+}
447+
299448 func TestSessionGoalRoundTripAndClear(t *testing.T) {
300449 sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
301450 if err != nil {
@@ -305,8 +454,11 @@ func TestSessionGoalRoundTripAndClear(t *testing.T) {
305454 if err := sess.UpdateGoal(goal); err != nil {
306455 t.Fatal(err)
307456 }
308- if sess.Meta.Goal == nil || *sess.Meta.Goal != *goal {
309- t.Fatalf("live goal = %#v, want %#v", sess.Meta.Goal, goal)
457+ if sess.Meta.Goal == nil || sess.Meta.Goal.Objective != goal.Objective || sess.Meta.Goal.Status != goal.Status || sess.Meta.Goal.Owner != GoalOwnerUser || sess.Meta.Goal.ID == "" || sess.Meta.Goal.MissionID == "" || sess.Meta.Goal.Ordinal != 1 {
458+ t.Fatalf("live goal = %#v", sess.Meta.Goal)
459+ }
460+ if sess.Meta.Mission == nil || sess.Meta.Mission.ID != sess.Meta.Goal.MissionID || sess.Meta.Mission.Objective != goal.Objective || sess.Meta.Mission.ActiveGoalID != sess.Meta.Goal.ID {
461+ t.Fatalf("live mission = %#v", sess.Meta.Mission)
310462 }
311463 if err := sess.Close(); err != nil {
312464 t.Fatal(err)
@@ -316,8 +468,8 @@ func TestSessionGoalRoundTripAndClear(t *testing.T) {
316468 if err != nil {
317469 t.Fatal(err)
318470 }
319- if reopened.Meta.Goal == nil || *reopened.Meta.Goal != *goal {
320- t.Fatalf("reopened goal = %#v, want %#v", reopened.Meta.Goal, goal)
471+ if reopened.Meta.Goal == nil || reopened.Meta.Goal.Objective != goal.Objective || reopened.Meta.Goal.Status != goal.Status || reopened.Meta.Goal.MissionID != sess.Meta.Goal.MissionID {
472+ t.Fatalf("reopened goal = %#v", reopened.Meta.Goal)
321473 }
322474 if err := reopened.UpdateGoal(nil); err != nil {
323475 t.Fatal(err)
@@ -334,6 +486,9 @@ func TestSessionGoalRoundTripAndClear(t *testing.T) {
334486 if reopened.Meta.Goal != nil {
335487 t.Fatalf("cleared goal = %#v, want nil", reopened.Meta.Goal)
336488 }
489+ if len(reopened.Meta.GoalHistory) != 1 || reopened.Meta.GoalHistory[0].Objective != goal.Objective || reopened.Meta.GoalHistory[0].Status != GoalActive {
490+ t.Fatalf("goal history = %#v", reopened.Meta.GoalHistory)
491+ }
337492 }
338493
339494 func TestCostAdd(t *testing.T) {
@@ -296,6 +296,155 @@ func TestSessionCompactHandoffRoundTripAndClear(t *testing.T) {
296 }296 }
297 }297 }
298 298
299+func TestNormalizeSessionGoalMetaMigratesLegacyGoal(t *testing.T) {
300+ meta := SessionMeta{ID: "legacy-session", Goal: &SessionGoal{Objective: "finish migration", Status: GoalActive}}
301+ normalizeSessionGoalMeta(&meta)
302+ if meta.Mission == nil || meta.Mission.ID != "legacy-legacy-session" || meta.Mission.Source != MissionSourceUser || meta.Mission.ActiveGoalID != "legacy-goal-legacy-session" {
303+ t.Fatalf("mission = %#v", meta.Mission)
304+ }
305+ if meta.Goal.Owner != GoalOwnerUser || meta.Goal.ID != "legacy-goal-legacy-session" || meta.Goal.MissionID != meta.Mission.ID || meta.Goal.Ordinal != 1 {
306+ t.Fatalf("goal = %#v", meta.Goal)
307+ }
308+}
309+
310+func TestSessionGoalTransitionLimitRollsBackState(t *testing.T) {
311+ sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
312+ if err != nil {
313+ t.Fatal(err)
314+ }
315+ defer sess.Close()
316+ if err := sess.UpdateGoal(&SessionGoal{Objective: "initial", Status: GoalActive, Owner: GoalOwnerUser}); err != nil {
317+ t.Fatal(err)
318+ }
319+ for transition := 0; transition < maxMissionGoalTransitions; transition++ {
320+ done := *sess.Meta.Goal
321+ done.Status = GoalDone
322+ if err := sess.UpdateGoal(&done); err != nil {
323+ t.Fatalf("settle transition %d: %v", transition, err)
324+ }
325+ if err := sess.UpdateGoal(&SessionGoal{Objective: "next", Status: GoalActive, Owner: GoalOwnerManager}); err != nil {
326+ t.Fatalf("start transition %d: %v", transition, err)
327+ }
328+ }
329+ done := *sess.Meta.Goal
330+ done.Status = GoalDone
331+ if err := sess.UpdateGoal(&done); err != nil {
332+ t.Fatal(err)
333+ }
334+ previousMission := *sess.Meta.Mission
335+ previousGoal := *sess.Meta.Goal
336+ if err := sess.UpdateGoal(&SessionGoal{Objective: "one too many", Status: GoalActive, Owner: GoalOwnerManager}); err == nil {
337+ t.Fatal("transition limit was not enforced")
338+ }
339+ if *sess.Meta.Mission != previousMission || *sess.Meta.Goal != previousGoal {
340+ t.Fatalf("state changed after rejected transition: mission %#v, goal %#v", sess.Meta.Mission, sess.Meta.Goal)
341+ }
342+}
343+
344+func TestSessionGoalResumeDoesNotConsumeTransitionLimit(t *testing.T) {
345+ sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
346+ if err != nil {
347+ t.Fatal(err)
348+ }
349+ defer sess.Close()
350+ if err := sess.UpdateGoal(&SessionGoal{Objective: "initial", Status: GoalActive, Owner: GoalOwnerUser}); err != nil {
351+ t.Fatal(err)
352+ }
353+ done := *sess.Meta.Goal
354+ done.Status = GoalDone
355+ if err := sess.UpdateGoal(&done); err != nil {
356+ t.Fatal(err)
357+ }
358+ if err := sess.UpdateGoal(&SessionGoal{Objective: "manager work", Status: GoalActive, Owner: GoalOwnerManager}); err != nil {
359+ t.Fatal(err)
360+ }
361+ for cycle := 0; cycle < maxMissionGoalTransitions*2; cycle++ {
362+ paused := *sess.Meta.Goal
363+ paused.Status = GoalPaused
364+ if err := sess.UpdateGoal(&paused); err != nil {
365+ t.Fatalf("pause cycle %d: %v", cycle, err)
366+ }
367+ resumed := *sess.Meta.Goal
368+ resumed.Status = GoalActive
369+ if err := sess.UpdateGoal(&resumed); err != nil {
370+ t.Fatalf("resume cycle %d: %v", cycle, err)
371+ }
372+ }
373+ if got := sess.Meta.Mission.TransitionCount; got != 1 {
374+ t.Fatalf("transition count = %d, want 1", got)
375+ }
376+}
377+
378+func TestSessionLegacyGoalMigrationPersistsOnNextTransition(t *testing.T) {
379+ path := t.TempDir() + "/legacy.jsonl"
380+ meta := SessionMeta{ID: "legacy-session", CWD: "/tmp/project", Goal: &SessionGoal{Objective: "finish migration", Status: GoalActive}}
381+ line, err := json.Marshal(sessionLine{Type: "meta", Meta: &meta})
382+ if err != nil {
383+ t.Fatal(err)
384+ }
385+ if err := os.WriteFile(path, append(line, '\n'), 0o600); err != nil {
386+ t.Fatal(err)
387+ }
388+
389+ sess, _, err := OpenSession(path)
390+ if err != nil {
391+ t.Fatal(err)
392+ }
393+ if sess.Meta.Mission == nil || sess.Meta.Goal.MissionID != sess.Meta.Mission.ID {
394+ t.Fatalf("legacy migration = mission %#v, goal %#v", sess.Meta.Mission, sess.Meta.Goal)
395+ }
396+ done := *sess.Meta.Goal
397+ done.Status = GoalDone
398+ if err := sess.UpdateGoal(&done); err != nil {
399+ t.Fatal(err)
400+ }
401+ if err := sess.Close(); err != nil {
402+ t.Fatal(err)
403+ }
404+
405+ reopened, _, err := OpenSession(path)
406+ if err != nil {
407+ t.Fatal(err)
408+ }
409+ defer reopened.Close()
410+ if reopened.Meta.Mission == nil || reopened.Meta.Goal == nil || reopened.Meta.Goal.Status != GoalDone || reopened.Meta.Goal.MissionID != reopened.Meta.Mission.ID {
411+ t.Fatalf("persisted migration = mission %#v, goal %#v", reopened.Meta.Mission, reopened.Meta.Goal)
412+ }
413+}
414+
415+func TestSessionEnsureMissionCreatesUserBoundaryOnce(t *testing.T) {
416+ sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
417+ if err != nil {
418+ t.Fatal(err)
419+ }
420+ if err := sess.EnsureMission("fix the reported failure", MissionSourceUser); err != nil {
421+ t.Fatal(err)
422+ }
423+ if sess.Meta.Mission == nil || sess.Meta.Mission.Objective != "fix the reported failure" || sess.Meta.Mission.Source != MissionSourceUser {
424+ t.Fatalf("mission = %#v", sess.Meta.Mission)
425+ }
426+ missionID := sess.Meta.Mission.ID
427+ if err := sess.EnsureMission("unrelated follow-up", MissionSourceUser); err != nil {
428+ t.Fatal(err)
429+ }
430+ if sess.Meta.Mission.ID != missionID || sess.Meta.Mission.Objective != "fix the reported failure" {
431+ t.Fatalf("mission was replaced: %#v", sess.Meta.Mission)
432+ }
433+ path := sess.Path
434+ if err := sess.Close(); err != nil {
435+ t.Fatal(err)
436+ }
437+
438+ reopened, _, err := OpenSession(path)
439+ if err != nil {
440+ t.Fatal(err)
441+ }
442+ defer reopened.Close()
443+ if reopened.Meta.Mission == nil || reopened.Meta.Mission.ID != missionID {
444+ t.Fatalf("reopened mission = %#v", reopened.Meta.Mission)
445+ }
446+}
447+
299 func TestSessionGoalRoundTripAndClear(t *testing.T) {448 func TestSessionGoalRoundTripAndClear(t *testing.T) {
300 sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")449 sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
301 if err != nil {450 if err != nil {
@@ -305,8 +454,11 @@ func TestSessionGoalRoundTripAndClear(t *testing.T) {
305 if err := sess.UpdateGoal(goal); err != nil {454 if err := sess.UpdateGoal(goal); err != nil {
306 t.Fatal(err)455 t.Fatal(err)
307 }456 }
308- if sess.Meta.Goal == nil || *sess.Meta.Goal != *goal {457+ if sess.Meta.Goal == nil || sess.Meta.Goal.Objective != goal.Objective || sess.Meta.Goal.Status != goal.Status || sess.Meta.Goal.Owner != GoalOwnerUser || sess.Meta.Goal.ID == "" || sess.Meta.Goal.MissionID == "" || sess.Meta.Goal.Ordinal != 1 {
309- t.Fatalf("live goal = %#v, want %#v", sess.Meta.Goal, goal)458+ t.Fatalf("live goal = %#v", sess.Meta.Goal)
459+ }
460+ if sess.Meta.Mission == nil || sess.Meta.Mission.ID != sess.Meta.Goal.MissionID || sess.Meta.Mission.Objective != goal.Objective || sess.Meta.Mission.ActiveGoalID != sess.Meta.Goal.ID {
461+ t.Fatalf("live mission = %#v", sess.Meta.Mission)
310 }462 }
311 if err := sess.Close(); err != nil {463 if err := sess.Close(); err != nil {
312 t.Fatal(err)464 t.Fatal(err)
@@ -316,8 +468,8 @@ func TestSessionGoalRoundTripAndClear(t *testing.T) {
316 if err != nil {468 if err != nil {
317 t.Fatal(err)469 t.Fatal(err)
318 }470 }
319- if reopened.Meta.Goal == nil || *reopened.Meta.Goal != *goal {471+ if reopened.Meta.Goal == nil || reopened.Meta.Goal.Objective != goal.Objective || reopened.Meta.Goal.Status != goal.Status || reopened.Meta.Goal.MissionID != sess.Meta.Goal.MissionID {
320- t.Fatalf("reopened goal = %#v, want %#v", reopened.Meta.Goal, goal)472+ t.Fatalf("reopened goal = %#v", reopened.Meta.Goal)
321 }473 }
322 if err := reopened.UpdateGoal(nil); err != nil {474 if err := reopened.UpdateGoal(nil); err != nil {
323 t.Fatal(err)475 t.Fatal(err)
@@ -334,6 +486,9 @@ func TestSessionGoalRoundTripAndClear(t *testing.T) {
334 if reopened.Meta.Goal != nil {486 if reopened.Meta.Goal != nil {
335 t.Fatalf("cleared goal = %#v, want nil", reopened.Meta.Goal)487 t.Fatalf("cleared goal = %#v, want nil", reopened.Meta.Goal)
336 }488 }
489+ if len(reopened.Meta.GoalHistory) != 1 || reopened.Meta.GoalHistory[0].Objective != goal.Objective || reopened.Meta.GoalHistory[0].Status != GoalActive {
490+ t.Fatalf("goal history = %#v", reopened.Meta.GoalHistory)
491+ }
337 }492 }
338 493
339 func TestCostAdd(t *testing.T) {494 func TestCostAdd(t *testing.T) {
M packages/core/session.go +169 -2
@@ -61,10 +61,53 @@ const (
6161 GoalDone GoalStatus = "done"
6262 )
6363
64+// GoalOwner identifies who established a session goal. Missing owners in
65+// legacy session data are interpreted as user-owned.
66+type GoalOwner string
67+
68+const (
69+ GoalOwnerUser GoalOwner = "user"
70+ GoalOwnerManager GoalOwner = "manager"
71+)
72+
73+// MissionStatus is the durable lifecycle state of the user intent that owns
74+// a linear sequence of manager and user goals.
75+type MissionStatus string
76+
77+const (
78+ MissionActive MissionStatus = "active"
79+ MissionPaused MissionStatus = "paused"
80+ MissionCompleted MissionStatus = "completed"
81+ MissionBlocked MissionStatus = "blocked"
82+)
83+
84+// MissionSource identifies who established the mission objective.
85+type MissionSource string
86+
87+const (
88+ MissionSourceUser MissionSource = "user"
89+ MissionSourceManager MissionSource = "manager"
90+)
91+
92+// SessionMission is the bounded, linear container for session goals.
93+type SessionMission struct {
94+ ID string `json:"id"`
95+ Objective string `json:"objective"`
96+ Status MissionStatus `json:"status"`
97+ Source MissionSource `json:"source"`
98+ ActiveGoalID string `json:"active_goal_id,omitempty"`
99+ TransitionCount int `json:"transition_count,omitempty"`
100+ Reason string `json:"reason,omitempty"`
101+}
102+
64103 // SessionGoal is the concise autonomous objective attached to one session.
65104 type SessionGoal struct {
105+ ID string `json:"id,omitempty"`
106+ MissionID string `json:"mission_id,omitempty"`
66107 Objective string `json:"objective"`
67108 Status GoalStatus `json:"status"`
109+ Owner GoalOwner `json:"owner,omitempty"`
110+ Ordinal int `json:"ordinal,omitempty"`
68111 Reason string `json:"reason,omitempty"`
69112 }
70113
@@ -86,9 +129,15 @@ type SessionMeta struct {
86129 // continuation. It is session metadata, never provider context.
87130 // Missing or invalid values are handled by the owning host as no handoff.
88131 CompactHandoff json.RawMessage `json:"compact_handoff,omitempty"`
132+ // Mission owns the linear history of autonomous goals. Legacy sessions with
133+ // only Goal are normalized on read.
134+ Mission *SessionMission `json:"mission,omitempty"`
89135 // Goal is the current autonomous objective for this session. Completed
90136 // goals remain inspectable until the user clears or replaces them.
91137 Goal *SessionGoal `json:"goal,omitempty"`
138+ // GoalHistory records prior durable goal states in chronological order.
139+ // It stays linear: there is only one current goal at a time.
140+ GoalHistory []SessionGoal `json:"goal_history,omitempty"`
92141
93142 // Parent is the ID of the session this one was forked from, or
94143 // empty for top-level sessions. The tree picker walks parents
@@ -466,6 +515,7 @@ func readSessionSnapshot(ctx context.Context, path string) (SessionSnapshot, err
466515 return SessionSnapshot{}, fmt.Errorf("session snapshot %q: file is empty", path)
467516 }
468517
518+ normalizeSessionGoalMeta(&snapshot.Meta)
469519 snapshot.CompactionGeneration = generation
470520 snapshot.ExtensionState = extensionState
471521
@@ -1335,24 +1385,141 @@ func (s *Session) UpdateCompactHandoff(state json.RawMessage) error {
13351385 }
13361386
13371387 // UpdateGoal records or clears the current autonomous session goal.
1388+const maxMissionGoalTransitions = 16
1389+
1390+// EnsureMission creates the durable user-intent boundary when this session has
1391+// none. It never replaces an existing mission; explicit user controls own
1392+// replacement and cancellation semantics.
1393+func (s *Session) EnsureMission(objective string, source MissionSource) error {
1394+ if s == nil || strings.TrimSpace(objective) == "" || s.Meta.Mission != nil {
1395+ return nil
1396+ }
1397+ previous := s.Meta.Mission
1398+ s.Meta.Mission = &SessionMission{
1399+ ID: uuid.NewString(),
1400+ Objective: strings.TrimSpace(objective),
1401+ Status: MissionActive,
1402+ Source: source,
1403+ }
1404+ if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil {
1405+ s.Meta.Mission = previous
1406+ return fmt.Errorf("ensure mission: %w", err)
1407+ }
1408+ return nil
1409+}
1410+
1411+// UpdateGoal persists one linear goal transition. It lazily migrates legacy
1412+// goal-only sessions into a mission and rejects unbounded manager progression.
13381413 func (s *Session) UpdateGoal(goal *SessionGoal) error {
13391414 if s == nil {
13401415 return nil
13411416 }
1342- previous := s.Meta.Goal
1417+ previous := cloneSessionGoal(s.Meta.Goal)
1418+ previousMission := cloneSessionMission(s.Meta.Mission)
1419+ previousHistory := append([]SessionGoal(nil), s.Meta.GoalHistory...)
1420+ if previous != nil {
1421+ s.Meta.GoalHistory = append(s.Meta.GoalHistory, *previous)
1422+ }
13431423 if goal == nil {
13441424 s.Meta.Goal = nil
1425+ s.Meta.Mission = nil
13451426 } else {
13461427 copyGoal := *goal
1428+ if err := s.assignGoalToMission(&copyGoal, previous); err != nil {
1429+ s.Meta.Mission = previousMission
1430+ s.Meta.GoalHistory = previousHistory
1431+ return err
1432+ }
13471433 s.Meta.Goal = &copyGoal
13481434 }
13491435 if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil {
13501436 s.Meta.Goal = previous
1437+ s.Meta.Mission = previousMission
1438+ s.Meta.GoalHistory = previousHistory
13511439 return fmt.Errorf("update goal: %w", err)
13521440 }
13531441 return nil
13541442 }
13551443
1444+func (s *Session) assignGoalToMission(goal, previous *SessionGoal) error {
1445+ if s.Meta.Mission == nil {
1446+ source := MissionSourceUser
1447+ if goal.Owner == GoalOwnerManager {
1448+ source = MissionSourceManager
1449+ }
1450+ s.Meta.Mission = &SessionMission{
1451+ ID: uuid.NewString(),
1452+ Objective: goal.Objective,
1453+ Status: MissionActive,
1454+ Source: source,
1455+ }
1456+ }
1457+ mission := s.Meta.Mission
1458+ if goal.ID == "" {
1459+ goal.ID = uuid.NewString()
1460+ }
1461+ isNewManagerGoal := goal.Status == GoalActive && goal.Owner == GoalOwnerManager && (previous == nil || goal.ID != previous.ID)
1462+ if isNewManagerGoal {
1463+ mission.TransitionCount++
1464+ if mission.TransitionCount > maxMissionGoalTransitions {
1465+ return fmt.Errorf("mission goal transition limit reached")
1466+ }
1467+ }
1468+ goal.MissionID = mission.ID
1469+ if goal.Ordinal == 0 {
1470+ goal.Ordinal = len(s.Meta.GoalHistory) + 1
1471+ }
1472+ if goal.Owner == "" {
1473+ goal.Owner = GoalOwnerUser
1474+ }
1475+ if goal.Status == GoalActive {
1476+ mission.ActiveGoalID = goal.ID
1477+ mission.Status = MissionActive
1478+ mission.Reason = ""
1479+ } else if mission.ActiveGoalID == goal.ID {
1480+ mission.ActiveGoalID = ""
1481+ }
1482+ return nil
1483+}
1484+
1485+func normalizeSessionGoalMeta(meta *SessionMeta) {
1486+ if meta == nil || meta.Goal == nil {
1487+ return
1488+ }
1489+ goal := meta.Goal
1490+ if goal.Owner == "" {
1491+ goal.Owner = GoalOwnerUser
1492+ }
1493+ if meta.Mission == nil {
1494+ meta.Mission = &SessionMission{
1495+ ID: "legacy-" + meta.ID,
1496+ Objective: goal.Objective,
1497+ Status: MissionActive,
1498+ Source: MissionSourceUser,
1499+ }
1500+ }
1501+ if goal.ID == "" {
1502+ goal.ID = "legacy-goal-" + meta.ID
1503+ }
1504+ if goal.MissionID == "" {
1505+ goal.MissionID = meta.Mission.ID
1506+ }
1507+ if goal.Ordinal == 0 {
1508+ goal.Ordinal = len(meta.GoalHistory) + 1
1509+ }
1510+ if goal.Status == GoalActive {
1511+ meta.Mission.ActiveGoalID = goal.ID
1512+ }
1513+}
1514+
1515+func cloneSessionMission(mission *SessionMission) *SessionMission {
1516+ if mission == nil {
1517+ return nil
1518+ }
1519+ clone := *mission
1520+ return &clone
1521+}
1522+
13561523 // UpdateTitle records a session title without adding anything to the
13571524 // conversation transcript. The title is kept in memory as well so the live
13581525 // session can restore it when the user switches sessions in the TUI.
@@ -1512,7 +1679,7 @@ func (s *Session) Close() error {
15121679 }
15131680 flushErr := s.Flush()
15141681 closeErr := s.writer.Close()
1515- if s.freshFile && s.messagesAppended == 0 && len(s.ExtensionState) == 0 && len(s.Meta.CompactHandoff) == 0 && s.Meta.Goal == nil {
1682+ if s.freshFile && s.messagesAppended == 0 && len(s.ExtensionState) == 0 && len(s.Meta.CompactHandoff) == 0 && s.Meta.Mission == nil && s.Meta.Goal == nil && len(s.Meta.GoalHistory) == 0 {
15161683 // Best-effort cleanup. We deliberately don't propagate the
15171684 // remove error: if it fails (file already gone, perms changed)
15181685 // the worst case is one stale empty file in the listing.
@@ -61,10 +61,53 @@ const (
61 GoalDone GoalStatus = "done"61 GoalDone GoalStatus = "done"
62 )62 )
63 63
64+// GoalOwner identifies who established a session goal. Missing owners in
65+// legacy session data are interpreted as user-owned.
66+type GoalOwner string
67+
68+const (
69+ GoalOwnerUser GoalOwner = "user"
70+ GoalOwnerManager GoalOwner = "manager"
71+)
72+
73+// MissionStatus is the durable lifecycle state of the user intent that owns
74+// a linear sequence of manager and user goals.
75+type MissionStatus string
76+
77+const (
78+ MissionActive MissionStatus = "active"
79+ MissionPaused MissionStatus = "paused"
80+ MissionCompleted MissionStatus = "completed"
81+ MissionBlocked MissionStatus = "blocked"
82+)
83+
84+// MissionSource identifies who established the mission objective.
85+type MissionSource string
86+
87+const (
88+ MissionSourceUser MissionSource = "user"
89+ MissionSourceManager MissionSource = "manager"
90+)
91+
92+// SessionMission is the bounded, linear container for session goals.
93+type SessionMission struct {
94+ ID string `json:"id"`
95+ Objective string `json:"objective"`
96+ Status MissionStatus `json:"status"`
97+ Source MissionSource `json:"source"`
98+ ActiveGoalID string `json:"active_goal_id,omitempty"`
99+ TransitionCount int `json:"transition_count,omitempty"`
100+ Reason string `json:"reason,omitempty"`
101+}
102+
64 // SessionGoal is the concise autonomous objective attached to one session.103 // SessionGoal is the concise autonomous objective attached to one session.
65 type SessionGoal struct {104 type SessionGoal struct {
105+ ID string `json:"id,omitempty"`
106+ MissionID string `json:"mission_id,omitempty"`
66 Objective string `json:"objective"`107 Objective string `json:"objective"`
67 Status GoalStatus `json:"status"`108 Status GoalStatus `json:"status"`
109+ Owner GoalOwner `json:"owner,omitempty"`
110+ Ordinal int `json:"ordinal,omitempty"`
68 Reason string `json:"reason,omitempty"`111 Reason string `json:"reason,omitempty"`
69 }112 }
70 113
@@ -86,9 +129,15 @@ type SessionMeta struct {
86 // continuation. It is session metadata, never provider context.129 // continuation. It is session metadata, never provider context.
87 // Missing or invalid values are handled by the owning host as no handoff.130 // Missing or invalid values are handled by the owning host as no handoff.
88 CompactHandoff json.RawMessage `json:"compact_handoff,omitempty"`131 CompactHandoff json.RawMessage `json:"compact_handoff,omitempty"`
132+ // Mission owns the linear history of autonomous goals. Legacy sessions with
133+ // only Goal are normalized on read.
134+ Mission *SessionMission `json:"mission,omitempty"`
89 // Goal is the current autonomous objective for this session. Completed135 // Goal is the current autonomous objective for this session. Completed
90 // goals remain inspectable until the user clears or replaces them.136 // goals remain inspectable until the user clears or replaces them.
91 Goal *SessionGoal `json:"goal,omitempty"`137 Goal *SessionGoal `json:"goal,omitempty"`
138+ // GoalHistory records prior durable goal states in chronological order.
139+ // It stays linear: there is only one current goal at a time.
140+ GoalHistory []SessionGoal `json:"goal_history,omitempty"`
92 141
93 // Parent is the ID of the session this one was forked from, or142 // Parent is the ID of the session this one was forked from, or
94 // empty for top-level sessions. The tree picker walks parents143 // empty for top-level sessions. The tree picker walks parents
@@ -466,6 +515,7 @@ func readSessionSnapshot(ctx context.Context, path string) (SessionSnapshot, err
466 return SessionSnapshot{}, fmt.Errorf("session snapshot %q: file is empty", path)515 return SessionSnapshot{}, fmt.Errorf("session snapshot %q: file is empty", path)
467 }516 }
468 517
518+ normalizeSessionGoalMeta(&snapshot.Meta)
469 snapshot.CompactionGeneration = generation519 snapshot.CompactionGeneration = generation
470 snapshot.ExtensionState = extensionState520 snapshot.ExtensionState = extensionState
471 521
@@ -1335,24 +1385,141 @@ func (s *Session) UpdateCompactHandoff(state json.RawMessage) error {
1335 }1385 }
1336 1386
1337 // UpdateGoal records or clears the current autonomous session goal.1387 // UpdateGoal records or clears the current autonomous session goal.
1388+const maxMissionGoalTransitions = 16
1389+
1390+// EnsureMission creates the durable user-intent boundary when this session has
1391+// none. It never replaces an existing mission; explicit user controls own
1392+// replacement and cancellation semantics.
1393+func (s *Session) EnsureMission(objective string, source MissionSource) error {
1394+ if s == nil || strings.TrimSpace(objective) == "" || s.Meta.Mission != nil {
1395+ return nil
1396+ }
1397+ previous := s.Meta.Mission
1398+ s.Meta.Mission = &SessionMission{
1399+ ID: uuid.NewString(),
1400+ Objective: strings.TrimSpace(objective),
1401+ Status: MissionActive,
1402+ Source: source,
1403+ }
1404+ if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil {
1405+ s.Meta.Mission = previous
1406+ return fmt.Errorf("ensure mission: %w", err)
1407+ }
1408+ return nil
1409+}
1410+
1411+// UpdateGoal persists one linear goal transition. It lazily migrates legacy
1412+// goal-only sessions into a mission and rejects unbounded manager progression.
1338 func (s *Session) UpdateGoal(goal *SessionGoal) error {1413 func (s *Session) UpdateGoal(goal *SessionGoal) error {
1339 if s == nil {1414 if s == nil {
1340 return nil1415 return nil
1341 }1416 }
1342- previous := s.Meta.Goal1417+ previous := cloneSessionGoal(s.Meta.Goal)
1418+ previousMission := cloneSessionMission(s.Meta.Mission)
1419+ previousHistory := append([]SessionGoal(nil), s.Meta.GoalHistory...)
1420+ if previous != nil {
1421+ s.Meta.GoalHistory = append(s.Meta.GoalHistory, *previous)
1422+ }
1343 if goal == nil {1423 if goal == nil {
1344 s.Meta.Goal = nil1424 s.Meta.Goal = nil
1425+ s.Meta.Mission = nil
1345 } else {1426 } else {
1346 copyGoal := *goal1427 copyGoal := *goal
1428+ if err := s.assignGoalToMission(&copyGoal, previous); err != nil {
1429+ s.Meta.Mission = previousMission
1430+ s.Meta.GoalHistory = previousHistory
1431+ return err
1432+ }
1347 s.Meta.Goal = &copyGoal1433 s.Meta.Goal = &copyGoal
1348 }1434 }
1349 if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil {1435 if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil {
1350 s.Meta.Goal = previous1436 s.Meta.Goal = previous
1437+ s.Meta.Mission = previousMission
1438+ s.Meta.GoalHistory = previousHistory
1351 return fmt.Errorf("update goal: %w", err)1439 return fmt.Errorf("update goal: %w", err)
1352 }1440 }
1353 return nil1441 return nil
1354 }1442 }
1355 1443
1444+func (s *Session) assignGoalToMission(goal, previous *SessionGoal) error {
1445+ if s.Meta.Mission == nil {
1446+ source := MissionSourceUser
1447+ if goal.Owner == GoalOwnerManager {
1448+ source = MissionSourceManager
1449+ }
1450+ s.Meta.Mission = &SessionMission{
1451+ ID: uuid.NewString(),
1452+ Objective: goal.Objective,
1453+ Status: MissionActive,
1454+ Source: source,
1455+ }
1456+ }
1457+ mission := s.Meta.Mission
1458+ if goal.ID == "" {
1459+ goal.ID = uuid.NewString()
1460+ }
1461+ isNewManagerGoal := goal.Status == GoalActive && goal.Owner == GoalOwnerManager && (previous == nil || goal.ID != previous.ID)
1462+ if isNewManagerGoal {
1463+ mission.TransitionCount++
1464+ if mission.TransitionCount > maxMissionGoalTransitions {
1465+ return fmt.Errorf("mission goal transition limit reached")
1466+ }
1467+ }
1468+ goal.MissionID = mission.ID
1469+ if goal.Ordinal == 0 {
1470+ goal.Ordinal = len(s.Meta.GoalHistory) + 1
1471+ }
1472+ if goal.Owner == "" {
1473+ goal.Owner = GoalOwnerUser
1474+ }
1475+ if goal.Status == GoalActive {
1476+ mission.ActiveGoalID = goal.ID
1477+ mission.Status = MissionActive
1478+ mission.Reason = ""
1479+ } else if mission.ActiveGoalID == goal.ID {
1480+ mission.ActiveGoalID = ""
1481+ }
1482+ return nil
1483+}
1484+
1485+func normalizeSessionGoalMeta(meta *SessionMeta) {
1486+ if meta == nil || meta.Goal == nil {
1487+ return
1488+ }
1489+ goal := meta.Goal
1490+ if goal.Owner == "" {
1491+ goal.Owner = GoalOwnerUser
1492+ }
1493+ if meta.Mission == nil {
1494+ meta.Mission = &SessionMission{
1495+ ID: "legacy-" + meta.ID,
1496+ Objective: goal.Objective,
1497+ Status: MissionActive,
1498+ Source: MissionSourceUser,
1499+ }
1500+ }
1501+ if goal.ID == "" {
1502+ goal.ID = "legacy-goal-" + meta.ID
1503+ }
1504+ if goal.MissionID == "" {
1505+ goal.MissionID = meta.Mission.ID
1506+ }
1507+ if goal.Ordinal == 0 {
1508+ goal.Ordinal = len(meta.GoalHistory) + 1
1509+ }
1510+ if goal.Status == GoalActive {
1511+ meta.Mission.ActiveGoalID = goal.ID
1512+ }
1513+}
1514+
1515+func cloneSessionMission(mission *SessionMission) *SessionMission {
1516+ if mission == nil {
1517+ return nil
1518+ }
1519+ clone := *mission
1520+ return &clone
1521+}
1522+
1356 // UpdateTitle records a session title without adding anything to the1523 // UpdateTitle records a session title without adding anything to the
1357 // conversation transcript. The title is kept in memory as well so the live1524 // conversation transcript. The title is kept in memory as well so the live
1358 // session can restore it when the user switches sessions in the TUI.1525 // session can restore it when the user switches sessions in the TUI.
@@ -1512,7 +1679,7 @@ func (s *Session) Close() error {
1512 }1679 }
1513 flushErr := s.Flush()1680 flushErr := s.Flush()
1514 closeErr := s.writer.Close()1681 closeErr := s.writer.Close()
1515- if s.freshFile && s.messagesAppended == 0 && len(s.ExtensionState) == 0 && len(s.Meta.CompactHandoff) == 0 && s.Meta.Goal == nil {1682+ if s.freshFile && s.messagesAppended == 0 && len(s.ExtensionState) == 0 && len(s.Meta.CompactHandoff) == 0 && s.Meta.Mission == nil && s.Meta.Goal == nil && len(s.Meta.GoalHistory) == 0 {
1516 // Best-effort cleanup. We deliberately don't propagate the1683 // Best-effort cleanup. We deliberately don't propagate the
1517 // remove error: if it fails (file already gone, perms changed)1684 // remove error: if it fails (file already gone, perms changed)
1518 // the worst case is one stale empty file in the listing.1685 // the worst case is one stale empty file in the listing.
M packages/core/session_portable.go +22 -0
@@ -228,7 +228,9 @@ func ImportSession(srcPath, root, cwd, version string) (string, error) {
228228 Timezone: timezone,
229229 TimezoneOffset: timezoneOffset,
230230 CompactHandoff: append(json.RawMessage(nil), snapshot.Meta.CompactHandoff...),
231+ Mission: cloneSessionMission(snapshot.Meta.Mission),
231232 Goal: cloneSessionGoal(snapshot.Meta.Goal),
233+ GoalHistory: cloneGoalHistory(snapshot.Meta.GoalHistory),
232234 }
233235 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &importMeta})
234236 if err != nil {
@@ -446,6 +448,13 @@ func goalAtBranch(parent SessionMeta, messages []provider.Message, limit int) *S
446448 return cloneSessionGoal(parent.Goal)
447449 }
448450
451+func missionAtBranch(parent SessionMeta, messages []provider.Message, limit int) *SessionMission {
452+ if limit != len(messages) {
453+ return nil
454+ }
455+ return cloneSessionMission(parent.Mission)
456+}
457+
449458 func cloneSessionGoal(goal *SessionGoal) *SessionGoal {
450459 if goal == nil {
451460 return nil
@@ -454,6 +463,17 @@ func cloneSessionGoal(goal *SessionGoal) *SessionGoal {
454463 return &clone
455464 }
456465
466+func cloneGoalHistory(history []SessionGoal) []SessionGoal {
467+ return append([]SessionGoal(nil), history...)
468+}
469+
470+func goalHistoryAtBranch(parent SessionMeta, messages []provider.Message, limit int) []SessionGoal {
471+ if limit != len(messages) {
472+ return nil
473+ }
474+ return cloneGoalHistory(parent.GoalHistory)
475+}
476+
457477 func writeBranchSession(root, cwd, version string, parent SessionMeta, messages []provider.Message, checkpoints []SessionUsageCheckpoint, limit int, hideFromSessions bool, extensionState map[string]json.RawMessage, compactHandoff json.RawMessage, goal *SessionGoal) (string, error) {
458478 dir := SessionsDir(root, cwd)
459479 if err := os.MkdirAll(dir, 0o755); err != nil {
@@ -496,7 +516,9 @@ func writeBranchSession(root, cwd, version string, parent SessionMeta, messages
496516 ForkPoint: limit,
497517 HideFromSessions: hideFromSessions,
498518 CompactHandoff: append(json.RawMessage(nil), compactHandoff...),
519+ Mission: missionAtBranch(parent, messages, limit),
499520 Goal: cloneSessionGoal(goal),
521+ GoalHistory: goalHistoryAtBranch(parent, messages, limit),
500522 }
501523 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta})
502524 if err != nil {
@@ -228,7 +228,9 @@ func ImportSession(srcPath, root, cwd, version string) (string, error) {
228 Timezone: timezone,228 Timezone: timezone,
229 TimezoneOffset: timezoneOffset,229 TimezoneOffset: timezoneOffset,
230 CompactHandoff: append(json.RawMessage(nil), snapshot.Meta.CompactHandoff...),230 CompactHandoff: append(json.RawMessage(nil), snapshot.Meta.CompactHandoff...),
231+ Mission: cloneSessionMission(snapshot.Meta.Mission),
231 Goal: cloneSessionGoal(snapshot.Meta.Goal),232 Goal: cloneSessionGoal(snapshot.Meta.Goal),
233+ GoalHistory: cloneGoalHistory(snapshot.Meta.GoalHistory),
232 }234 }
233 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &importMeta})235 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &importMeta})
234 if err != nil {236 if err != nil {
@@ -446,6 +448,13 @@ func goalAtBranch(parent SessionMeta, messages []provider.Message, limit int) *S
446 return cloneSessionGoal(parent.Goal)448 return cloneSessionGoal(parent.Goal)
447 }449 }
448 450
451+func missionAtBranch(parent SessionMeta, messages []provider.Message, limit int) *SessionMission {
452+ if limit != len(messages) {
453+ return nil
454+ }
455+ return cloneSessionMission(parent.Mission)
456+}
457+
449 func cloneSessionGoal(goal *SessionGoal) *SessionGoal {458 func cloneSessionGoal(goal *SessionGoal) *SessionGoal {
450 if goal == nil {459 if goal == nil {
451 return nil460 return nil
@@ -454,6 +463,17 @@ func cloneSessionGoal(goal *SessionGoal) *SessionGoal {
454 return &clone463 return &clone
455 }464 }
456 465
466+func cloneGoalHistory(history []SessionGoal) []SessionGoal {
467+ return append([]SessionGoal(nil), history...)
468+}
469+
470+func goalHistoryAtBranch(parent SessionMeta, messages []provider.Message, limit int) []SessionGoal {
471+ if limit != len(messages) {
472+ return nil
473+ }
474+ return cloneGoalHistory(parent.GoalHistory)
475+}
476+
457 func writeBranchSession(root, cwd, version string, parent SessionMeta, messages []provider.Message, checkpoints []SessionUsageCheckpoint, limit int, hideFromSessions bool, extensionState map[string]json.RawMessage, compactHandoff json.RawMessage, goal *SessionGoal) (string, error) {477 func writeBranchSession(root, cwd, version string, parent SessionMeta, messages []provider.Message, checkpoints []SessionUsageCheckpoint, limit int, hideFromSessions bool, extensionState map[string]json.RawMessage, compactHandoff json.RawMessage, goal *SessionGoal) (string, error) {
458 dir := SessionsDir(root, cwd)478 dir := SessionsDir(root, cwd)
459 if err := os.MkdirAll(dir, 0o755); err != nil {479 if err := os.MkdirAll(dir, 0o755); err != nil {
@@ -496,7 +516,9 @@ func writeBranchSession(root, cwd, version string, parent SessionMeta, messages
496 ForkPoint: limit,516 ForkPoint: limit,
497 HideFromSessions: hideFromSessions,517 HideFromSessions: hideFromSessions,
498 CompactHandoff: append(json.RawMessage(nil), compactHandoff...),518 CompactHandoff: append(json.RawMessage(nil), compactHandoff...),
519+ Mission: missionAtBranch(parent, messages, limit),
499 Goal: cloneSessionGoal(goal),520 Goal: cloneSessionGoal(goal),
521+ GoalHistory: goalHistoryAtBranch(parent, messages, limit),
500 }522 }
501 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta})523 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta})
502 if err != nil {524 if err != nil {
M packages/core/session_portable_test.go +13 -4
@@ -128,8 +128,11 @@ func TestSessionExportImportRoundTrip(t *testing.T) {
128128 if got := string(imported.Meta.CompactHandoff); got != string(handoff) {
129129 t.Errorf("compact handoff = %q, want %q", got, handoff)
130130 }
131- if imported.Meta.Goal == nil || *imported.Meta.Goal != *goal {
132- t.Errorf("goal = %#v, want %#v", imported.Meta.Goal, goal)
131+ if imported.Meta.Goal == nil || imported.Meta.Goal.Objective != goal.Objective || imported.Meta.Goal.Status != goal.Status || imported.Meta.Goal.MissionID == "" {
132+ t.Errorf("goal = %#v", imported.Meta.Goal)
133+ }
134+ if imported.Meta.Mission == nil || imported.Meta.Mission.ID != imported.Meta.Goal.MissionID || imported.Meta.Mission.Objective != goal.Objective {
135+ t.Errorf("mission = %#v", imported.Meta.Mission)
133136 }
134137 if len(msgs) != 2 {
135138 t.Fatalf("want 2 messages, got %d", len(msgs))
@@ -591,8 +594,11 @@ func TestBranchSessionCompactHandoffFollowsCopiedEffectiveTail(t *testing.T) {
591594 if got := string(full.Meta.CompactHandoff); got != string(handoff) {
592595 t.Fatalf("full branch compact handoff = %q, want %q", got, handoff)
593596 }
594- if full.Meta.Goal == nil || *full.Meta.Goal != *goal {
595- t.Fatalf("full branch goal = %#v, want %#v", full.Meta.Goal, goal)
597+ if full.Meta.Goal == nil || full.Meta.Goal.Objective != goal.Objective || full.Meta.Goal.Status != goal.Status || full.Meta.Goal.MissionID == "" {
598+ t.Fatalf("full branch goal = %#v", full.Meta.Goal)
599+ }
600+ if full.Meta.Mission == nil || full.Meta.Mission.ID != full.Meta.Goal.MissionID {
601+ t.Fatalf("full branch mission = %#v", full.Meta.Mission)
596602 }
597603
598604 prefixPath, err := BranchSession(parent.Path, root, cwd, "test", 1)
@@ -610,6 +616,9 @@ func TestBranchSessionCompactHandoffFollowsCopiedEffectiveTail(t *testing.T) {
610616 if prefix.Meta.Goal != nil {
611617 t.Fatalf("prefix branch goal = %#v, want nil", prefix.Meta.Goal)
612618 }
619+ if prefix.Meta.Mission != nil || len(prefix.Meta.GoalHistory) != 0 {
620+ t.Fatalf("prefix branch mission/history = %#v / %#v, want nil", prefix.Meta.Mission, prefix.Meta.GoalHistory)
621+ }
613622 }
614623
615624 func TestBranchSessionHiddenFromHistoryClearsCompactHandoff(t *testing.T) {
@@ -128,8 +128,11 @@ func TestSessionExportImportRoundTrip(t *testing.T) {
128 if got := string(imported.Meta.CompactHandoff); got != string(handoff) {128 if got := string(imported.Meta.CompactHandoff); got != string(handoff) {
129 t.Errorf("compact handoff = %q, want %q", got, handoff)129 t.Errorf("compact handoff = %q, want %q", got, handoff)
130 }130 }
131- if imported.Meta.Goal == nil || *imported.Meta.Goal != *goal {131+ if imported.Meta.Goal == nil || imported.Meta.Goal.Objective != goal.Objective || imported.Meta.Goal.Status != goal.Status || imported.Meta.Goal.MissionID == "" {
132- t.Errorf("goal = %#v, want %#v", imported.Meta.Goal, goal)132+ t.Errorf("goal = %#v", imported.Meta.Goal)
133+ }
134+ if imported.Meta.Mission == nil || imported.Meta.Mission.ID != imported.Meta.Goal.MissionID || imported.Meta.Mission.Objective != goal.Objective {
135+ t.Errorf("mission = %#v", imported.Meta.Mission)
133 }136 }
134 if len(msgs) != 2 {137 if len(msgs) != 2 {
135 t.Fatalf("want 2 messages, got %d", len(msgs))138 t.Fatalf("want 2 messages, got %d", len(msgs))
@@ -591,8 +594,11 @@ func TestBranchSessionCompactHandoffFollowsCopiedEffectiveTail(t *testing.T) {
591 if got := string(full.Meta.CompactHandoff); got != string(handoff) {594 if got := string(full.Meta.CompactHandoff); got != string(handoff) {
592 t.Fatalf("full branch compact handoff = %q, want %q", got, handoff)595 t.Fatalf("full branch compact handoff = %q, want %q", got, handoff)
593 }596 }
594- if full.Meta.Goal == nil || *full.Meta.Goal != *goal {597+ if full.Meta.Goal == nil || full.Meta.Goal.Objective != goal.Objective || full.Meta.Goal.Status != goal.Status || full.Meta.Goal.MissionID == "" {
595- t.Fatalf("full branch goal = %#v, want %#v", full.Meta.Goal, goal)598+ t.Fatalf("full branch goal = %#v", full.Meta.Goal)
599+ }
600+ if full.Meta.Mission == nil || full.Meta.Mission.ID != full.Meta.Goal.MissionID {
601+ t.Fatalf("full branch mission = %#v", full.Meta.Mission)
596 }602 }
597 603
598 prefixPath, err := BranchSession(parent.Path, root, cwd, "test", 1)604 prefixPath, err := BranchSession(parent.Path, root, cwd, "test", 1)
@@ -610,6 +616,9 @@ func TestBranchSessionCompactHandoffFollowsCopiedEffectiveTail(t *testing.T) {
610 if prefix.Meta.Goal != nil {616 if prefix.Meta.Goal != nil {
611 t.Fatalf("prefix branch goal = %#v, want nil", prefix.Meta.Goal)617 t.Fatalf("prefix branch goal = %#v, want nil", prefix.Meta.Goal)
612 }618 }
619+ if prefix.Meta.Mission != nil || len(prefix.Meta.GoalHistory) != 0 {
620+ t.Fatalf("prefix branch mission/history = %#v / %#v, want nil", prefix.Meta.Mission, prefix.Meta.GoalHistory)
621+ }
613 }622 }
614 623
615 func TestBranchSessionHiddenFromHistoryClearsCompactHandoff(t *testing.T) {624 func TestBranchSessionHiddenFromHistoryClearsCompactHandoff(t *testing.T) {

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