1package main
2
3import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "sync"
8
9 "github.com/bnema/zut/packages/agent/ext"
10)
11
12const (
13 extensionName = "tasked-phases"
14 extensionVersion = "1.0.0"
15 toolName = "tasked_phases"
16 panelID = "tasked-phases"
17)
18
19const toolSchema = `{
20 "type": "object",
21 "additionalProperties": false,
22 "properties": {
23 "action": {
24 "type": "string",
25 "enum": ["get_status", "set_spec", "replace_plan", "add_phase", "update_phase", "remove_phase", "add_task", "update_task", "remove_task", "set_current_phase", "set_task_checked", "set_phase_checked", "clear"],
26 "description": "State operation to perform. Closed plans must be restarted with clear, or set_spec immediately followed by replace_plan."
27 },
28 "spec": {"type": "string", "description": "Spec text used by set_spec"},
29 "phaseId": {"type": "string", "description": "Target phase id, raw id without the brackets shown in summaries"},
30 "phaseTitle": {"type": "string", "description": "Phase title for add_phase or update_phase"},
31 "phaseGoal": {"type": "string", "description": "Phase goal for add_phase or update_phase"},
32 "taskId": {"type": "string", "description": "Target task id"},
33 "taskText": {"type": "string", "description": "Task text for add_task or update_task"},
34 "checked": {"type": "boolean", "description": "Checked state for set_task_checked or set_phase_checked"},
35 "phases": {
36 "type": "array",
37 "description": "Full plan replacement used by replace_plan",
38 "items": {
39 "type": "object",
40 "additionalProperties": false,
41 "properties": {
42 "id": {"type": "string", "description": "Optional phase id. If omitted, one is generated."},
43 "title": {"type": "string", "description": "Phase title"},
44 "goal": {"type": "string", "description": "Optional short goal for the phase"},
45 "tasks": {
46 "type": "array",
47 "description": "Checklist tasks for the phase",
48 "items": {
49 "type": "object",
50 "additionalProperties": false,
51 "properties": {
52 "id": {"type": "string", "description": "Optional task id. If omitted, one is generated."},
53 "text": {"type": "string", "description": "Checklist task text"},
54 "checked": {"type": "boolean", "description": "Whether the task starts checked"}
55 },
56 "required": ["text"]
57 }
58 }
59 },
60 "required": ["title"]
61 }
62 }
63 },
64 "required": ["action"]
65}`
66
67type app struct {
68 ext *ext.Extension
69 store *stateStore
70
71 panelMu sync.Mutex
72 panelOpen bool
73}
74
75func newApp(e *ext.Extension) *app {
76 return &app{ext: e, store: newStateStore()}
77}
78
79func main() {
80 e := ext.New(extensionName, extensionVersion)
81 a := newApp(e)
82
83 e.OnHello(func(host ext.HostInfo) {
84 dataDir := host.DataDir
85 if dataDir == "" {
86 dataDir = host.ExtensionDir
87 }
88 if err := a.store.load(dataDir, host.CWD); err != nil {
89 e.Logf("load project state: %v", err)
90 a.publishUnavailableChrome()
91 return
92 }
93 if state, err := a.store.snapshot(); err == nil {
94 a.publishChrome(state)
95 } else {
96 e.Logf("snapshot project state: %v", err)
97 a.publishUnavailableChrome()
98 }
99 })
100 restoreSession := func(event ext.Event) {
101 if err := a.store.activateSession(event.State); err != nil {
102 e.Logf("restore session state: %v", err)
103 a.publishUnavailableChrome()
104 return
105 }
106 if state, err := a.store.snapshot(); err == nil {
107 a.publishChrome(state)
108 a.renderPanelIfOpen(state)
109 } else {
110 e.Logf("restore session snapshot: %v", err)
111 a.publishUnavailableChrome()
112 }
113 }
114 e.On("session_opened", restoreSession)
115 e.On("session_switched", restoreSession)
116 e.On("session_forked", restoreSession)
117 e.InterceptTurnStart(func(_ int) ext.TurnStartDecision {
118 state, err := a.store.snapshot()
119 if err != nil {
120 e.Logf("load turn focus: %v", err)
121 return ext.TurnStartDecision{}
122 }
123 return ext.TurnStartDecision{Context: buildTurnContext(state)}
124 })
125
126 e.Tool(toolName,
127 "Persist and update a structured spec, phased plan, current phase, and checklist tasks. Keep the plan current throughout implementation: update it continuously, call set_task_checked immediately after each completed checklist task, and call set_current_phase when moving to another phase.",
128 json.RawMessage(toolSchema), a.handleTool)
129 e.Command("phases", "Show the current spec, phases, and checklist state", a.handleCommand)
130 e.OnPanelKey(panelID, a.handlePanelKey, a.handlePanelClose)
131
132 if err := e.Run(); err != nil {
133 fmt.Fprintln(os.Stderr, err)
134 os.Exit(1)
135 }
136}
137
138func (a *app) handleCommand(_ string) ext.Response {
139 state, err := a.store.snapshot()
140 if err != nil {
141 a.publishUnavailableChrome()
142 return ext.Errorf("load tasked phases: %v", err)
143 }
144 a.publishChrome(state)
145 a.panelMu.Lock()
146 a.panelOpen = true
147 a.panelMu.Unlock()
148 return ext.OpenPanel(panelID, "Tasked phases", buildViewLines(state), "Escape closes this panel")
149}
150
151func (a *app) handlePanelKey(key, _ string) {
152 if key == "esc" {
153 a.ext.ClosePanel(panelID)
154 a.setPanelOpen(false)
155 }
156}
157
158func (a *app) handlePanelClose() { a.setPanelOpen(false) }
159
160func (a *app) setPanelOpen(open bool) {
161 a.panelMu.Lock()
162 a.panelOpen = open
163 a.panelMu.Unlock()
164}
165
166func (a *app) renderPanelIfOpen(state PlanState) {
167 a.panelMu.Lock()
168 open := a.panelOpen
169 a.panelMu.Unlock()
170 if open {
171 a.ext.RenderPanel(panelID, "Tasked phases", buildViewLines(state), "Escape closes this panel")
172 }
173}
174
175func (a *app) handleTool(args json.RawMessage) ext.ToolResult {
176 var params toolArgs
177 if err := json.Unmarshal(args, ¶ms); err != nil {
178 return ext.TextErrorResult("invalid args: " + err.Error())
179 }
180 if !isKnownAction(params.Action) {
181 return ext.TextErrorResult("unsupported action: " + params.Action)
182 }
183
184 if params.Action == actionGetStatus {
185 state, err := a.store.snapshot()
186 if err != nil {
187 a.publishUnavailableChrome()
188 return toolResult(params.Action, PlanState{}, "", err)
189 }
190 a.publishChrome(state)
191 a.renderPanelIfOpen(state)
192 return toolResult(params.Action, state, "Current phased plan status", nil)
193 }
194
195 state, headline, err := a.store.transact(func(current PlanState) (PlanState, string, error) {
196 return applyAction(current, params)
197 })
198 // A zero state means transact could not obtain a loaded snapshot. Do not
199 // replace persistent chrome with an invented empty plan; clear it instead.
200 // For action and persistence errors, transact returns the unchanged usable
201 // state, which should remain visible to help the user recover.
202 if err == nil || state.Version != 0 {
203 a.publishChrome(state)
204 } else {
205 a.publishUnavailableChrome()
206 }
207 if err == nil {
208 a.renderPanelIfOpen(state)
209 }
210 return toolResult(params.Action, state, headline, err)
211}
212
213func (a *app) publishUnavailableChrome() {
214 clearChrome(a.ext)
215 a.panelMu.Lock()
216 open := a.panelOpen
217 a.panelMu.Unlock()
218 if open {
219 a.ext.RenderPanel(panelID, "Tasked phases", []string{"Plan state could not be restored for this session."}, "Escape closes this panel")
220 }
221}
222
223type chromeContent struct {
224 summary string
225 lines []string
226}
227
228const (
229 chromePhaseMaxChars = 32
230 chromeTaskMaxChars = 64
231)
232
233func buildChrome(state PlanState) (chromeContent, bool) {
234 // A spec-only state is still a plan in progress. Show its compact summary
235 // so the right bar opens as soon as planning starts, without exposing the
236 // spec text in persistent chrome.
237 if !hasStoredPlan(state) || isPlanComplete(state) || isPlanClosed(state) {
238 return chromeContent{}, false
239 }
240 return chromeContent{
241 summary: buildChromeSummary(state),
242 lines: buildChecklistLines(state),
243 }, true
244}
245
246func buildChromeSummary(state PlanState) string {
247 phaseDone := 0
248 for _, phase := range state.Phases {
249 if isPhaseDone(phase) {
250 phaseDone++
251 }
252 }
253 taskDone, taskTotal := getPlanProgress(state)
254 return fmt.Sprintf("p %d/%d | t %d/%d", phaseDone, len(state.Phases), taskDone, taskTotal)
255}
256
257func chromeLabel(value, fallback string, maxLength int) string {
258 value = truncatePlain(displaySingleLine(value), maxLength)
259 if value == "" {
260 return fallback
261 }
262 return value
263}
264
265func getChromePhase(state PlanState) *Phase {
266 if current := getCurrentPhase(state); current != nil && !isPhaseDone(*current) {
267 return current
268 }
269 for i := range state.Phases {
270 if !isPhaseDone(state.Phases[i]) {
271 return &state.Phases[i]
272 }
273 }
274 if len(state.Phases) == 0 {
275 return nil
276 }
277 return &state.Phases[len(state.Phases)-1]
278}
279
280func getChromeTask(phase Phase) *PhaseTask {
281 for i := range phase.Tasks {
282 if !phase.Tasks[i].Checked {
283 return &phase.Tasks[i]
284 }
285 }
286 return nil
287}
288
289func buildChecklistLines(state PlanState) []string {
290 activePhase := getChromePhase(state)
291 activeTaskID := ""
292 if activePhase != nil {
293 if activeTask := getChromeTask(*activePhase); activeTask != nil {
294 activeTaskID = activeTask.ID
295 }
296 }
297
298 lines := make([]string, 0)
299 for phaseIndex, phase := range state.Phases {
300 if phaseIndex > 0 {
301 lines = append(lines, "")
302 }
303 phaseDone, phaseTotal := getPhaseProgress(phase)
304 phaseMarker := "[ ]"
305 if isPhaseDone(phase) {
306 phaseMarker = "[x]"
307 } else if activePhase != nil && phase.ID == activePhase.ID {
308 phaseMarker = "[>]"
309 }
310 lines = append(lines, fmt.Sprintf("%s %s %d/%d", phaseMarker, chromeLabel(formatPhaseTitle(phase), "untitled phase", chromePhaseMaxChars), phaseDone, phaseTotal))
311 for _, task := range phase.Tasks {
312 taskMarker := "[ ]"
313 if task.Checked {
314 taskMarker = "[x]"
315 } else if activePhase != nil && phase.ID == activePhase.ID && task.ID == activeTaskID {
316 taskMarker = "[>]"
317 }
318 lines = append(lines, fmt.Sprintf(" %s %s", taskMarker, chromeLabel(formatTaskText(task), "untitled task", chromeTaskMaxChars)))
319 }
320 }
321 if len(lines) == 0 {
322 lines = append(lines, "No phases yet")
323 }
324 return lines
325}
326
327type chromeHost interface {
328 SetStatus(key, text string)
329 ClearStatus(key string)
330 SetWidget(id, position, title string, lines []string)
331 ClearWidget(id string)
332}
333
334func clearChrome(host chromeHost) {
335 host.ClearStatus("progress")
336 host.ClearWidget("plan")
337}
338
339func applyChrome(host chromeHost, state PlanState) {
340 chrome, ok := buildChrome(state)
341 if !ok {
342 clearChrome(host)
343 return
344 }
345 host.SetStatus("progress", chrome.summary)
346 host.SetWidget("plan", ext.WidgetPositionRightBar, chrome.summary, chrome.lines)
347}
348
349func (a *app) publishChrome(state PlanState) {
350 applyChrome(a.ext, state)
351}
352
353func toolResult(action string, state PlanState, headline string, err error) ext.ToolResult {
354 text := buildToolResultText(action, state, headline, err)
355 var result ext.ToolResult
356 if err != nil {
357 result = ext.TextErrorResult(text)
358 } else {
359 result = ext.TextResult(text)
360 }
361 result.Details = ext.JSONDetails(state)
362 return result
363}
364
365func isKnownAction(action string) bool {
366 for _, known := range toolActions {
367 if action == known {
368 return true
369 }
370 }
371 return false
372}