Skip to content

Commit fdaf2b1

Browse files
josealekhineclaude
andcommitted
feat(cli): add ctx tasks archive and snapshot commands
Implement task archival and snapshot functionality: - `ctx tasks archive`: Move completed tasks ([x]) to timestamped archive file in .context/archive/tasks-YYYY-MM-DD.md, preserving Phase structure for traceability. Supports --dry-run for preview. - `ctx tasks snapshot`: Create point-in-time copy of TASKS.md without modification. Useful for milestones or before major changes. Archive directory: .context/archive/ Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent ac97ed8 commit fdaf2b1

3 files changed

Lines changed: 293 additions & 4 deletions

File tree

.context/TASKS.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,10 @@
5555
- [x] Verify built binary executes subcommands (not silently falling through to root help)
5656

5757
### Phase 8: Task Archival & Snapshots `#priority:medium` `#area:cli`
58-
- [ ] Implement `ctx tasks archive` — move completed tasks to timestamped archive file
59-
- [ ] Implement `ctx tasks snapshot` — create point-in-time snapshot of TASKS.md
60-
- [ ] Archive location: `.context/archive/tasks-YYYY-MM-DD.md`
61-
- [ ] Keep Phase structure in archives for traceability
58+
- [x] Implement `ctx tasks archive` — move completed tasks to timestamped archive file
59+
- [x] Implement `ctx tasks snapshot` — create point-in-time snapshot of TASKS.md
60+
- [x] Archive location: `.context/archive/tasks-YYYY-MM-DD.md`
61+
- [x] Keep Phase structure in archives for traceability
6262
- [ ] Update CONSTITUTION.md: archival is allowed, deletion is not
6363

6464
### Phase 9: Claude Slash Commands (Skills) `#priority:medium` `#area:cli`

cmd/ctx/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ func init() {
4242
rootCmd.AddCommand(cli.WatchCmd())
4343
rootCmd.AddCommand(cli.HookCmd())
4444
rootCmd.AddCommand(cli.SessionCmd())
45+
rootCmd.AddCommand(cli.TasksCmd())
4546
}
4647

4748
func main() {

internal/cli/tasks.go

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
// / Context: https://ctx.ist
2+
// ,'`./ do you remember?
3+
// `.,'\
4+
// \ Copyright 2025-present Context contributors.
5+
// SPDX-License-Identifier: Apache-2.0
6+
7+
package cli
8+
9+
import (
10+
"bufio"
11+
"fmt"
12+
"os"
13+
"path/filepath"
14+
"regexp"
15+
"strings"
16+
"time"
17+
18+
"github.com/fatih/color"
19+
"github.com/spf13/cobra"
20+
)
21+
22+
const (
23+
tasksFileName = ".context/TASKS.md"
24+
archiveDirName = ".context/archive"
25+
)
26+
27+
// TasksCmd returns the tasks command with subcommands.
28+
func TasksCmd() *cobra.Command {
29+
cmd := &cobra.Command{
30+
Use: "tasks",
31+
Short: "Manage task archival and snapshots",
32+
Long: `Manage task archival and snapshots.
33+
34+
Tasks can be archived to move completed items out of TASKS.md while
35+
preserving them for historical reference. Snapshots create point-in-time
36+
copies without modifying the original.
37+
38+
Subcommands:
39+
archive Move completed tasks to timestamped archive file
40+
snapshot Create point-in-time snapshot of TASKS.md`,
41+
}
42+
43+
cmd.AddCommand(tasksArchiveCmd())
44+
cmd.AddCommand(tasksSnapshotCmd())
45+
46+
return cmd
47+
}
48+
49+
var archiveDryRun bool
50+
51+
// tasksArchiveCmd returns the tasks archive subcommand.
52+
func tasksArchiveCmd() *cobra.Command {
53+
cmd := &cobra.Command{
54+
Use: "archive",
55+
Short: "Move completed tasks to timestamped archive file",
56+
Long: `Move completed tasks from TASKS.md to an archive file.
57+
58+
Archive files are stored in .context/archive/ with timestamped names:
59+
.context/archive/tasks-YYYY-MM-DD.md
60+
61+
The archive preserves Phase structure for traceability. Completed tasks
62+
(marked with [x]) are moved; pending tasks ([ ]) remain in TASKS.md.
63+
64+
Use --dry-run to preview changes without modifying files.`,
65+
RunE: runTasksArchive,
66+
}
67+
68+
cmd.Flags().BoolVar(&archiveDryRun, "dry-run", false, "Preview changes without modifying files")
69+
70+
return cmd
71+
}
72+
73+
func runTasksArchive(cmd *cobra.Command, args []string) error {
74+
green := color.New(color.FgGreen).SprintFunc()
75+
yellow := color.New(color.FgYellow).SprintFunc()
76+
77+
// Check if TASKS.md exists
78+
if _, err := os.Stat(tasksFileName); os.IsNotExist(err) {
79+
return fmt.Errorf("no %s found", tasksFileName)
80+
}
81+
82+
// Read TASKS.md
83+
content, err := os.ReadFile(tasksFileName)
84+
if err != nil {
85+
return fmt.Errorf("failed to read %s: %w", tasksFileName, err)
86+
}
87+
88+
// Parse and separate completed vs pending tasks
89+
remaining, archived, stats := separateTasks(string(content))
90+
91+
if stats.completed == 0 {
92+
fmt.Println("No completed tasks to archive.")
93+
return nil
94+
}
95+
96+
if archiveDryRun {
97+
fmt.Println(yellow("Dry run - no files modified"))
98+
fmt.Println()
99+
fmt.Printf("Would archive %d completed tasks (keeping %d pending)\n", stats.completed, stats.pending)
100+
fmt.Println()
101+
fmt.Println("Archived content preview:")
102+
fmt.Println("---")
103+
fmt.Println(archived)
104+
fmt.Println("---")
105+
return nil
106+
}
107+
108+
// Ensure archive directory exists
109+
if err := os.MkdirAll(archiveDirName, 0755); err != nil {
110+
return fmt.Errorf("failed to create archive directory: %w", err)
111+
}
112+
113+
// Generate archive filename
114+
now := time.Now()
115+
archiveFilename := fmt.Sprintf("tasks-%s.md", now.Format("2006-01-02"))
116+
archivePath := filepath.Join(archiveDirName, archiveFilename)
117+
118+
// Check if archive file already exists for today - append if so
119+
var archiveContent string
120+
if existingContent, err := os.ReadFile(archivePath); err == nil {
121+
archiveContent = string(existingContent) + "\n" + archived
122+
} else {
123+
archiveContent = fmt.Sprintf("# Task Archive — %s\n\nArchived from TASKS.md\n\n%s", now.Format("2006-01-02"), archived)
124+
}
125+
126+
// Write archive file
127+
if err := os.WriteFile(archivePath, []byte(archiveContent), 0644); err != nil {
128+
return fmt.Errorf("failed to write archive: %w", err)
129+
}
130+
131+
// Write updated TASKS.md
132+
if err := os.WriteFile(tasksFileName, []byte(remaining), 0644); err != nil {
133+
return fmt.Errorf("failed to update %s: %w", tasksFileName, err)
134+
}
135+
136+
fmt.Printf("%s Archived %d completed tasks to %s\n", green("✓"), stats.completed, archivePath)
137+
fmt.Printf(" %d pending tasks remain in TASKS.md\n", stats.pending)
138+
139+
return nil
140+
}
141+
142+
type taskStats struct {
143+
completed int
144+
pending int
145+
}
146+
147+
// separateTasks parses TASKS.md and separates completed from pending tasks.
148+
// Returns: remaining content (pending), archived content (completed), stats
149+
func separateTasks(content string) (string, string, taskStats) {
150+
var remaining strings.Builder
151+
var archived strings.Builder
152+
var stats taskStats
153+
154+
// Track current phase header
155+
var currentPhase string
156+
var phaseHasArchivedTasks bool
157+
var phaseArchiveBuffer strings.Builder
158+
159+
completedPattern := regexp.MustCompile(`^\s*-\s*\[x\]`)
160+
pendingPattern := regexp.MustCompile(`^\s*-\s*\[\s*\]`)
161+
phasePattern := regexp.MustCompile(`^###\s+Phase`)
162+
subTaskPattern := regexp.MustCompile(`^\s{2,}-\s*\[`)
163+
164+
scanner := bufio.NewScanner(strings.NewReader(content))
165+
var inCompletedTask bool
166+
167+
for scanner.Scan() {
168+
line := scanner.Text()
169+
170+
// Check for phase headers
171+
if phasePattern.MatchString(line) {
172+
// Flush previous phase's archived tasks
173+
if phaseHasArchivedTasks {
174+
archived.WriteString(currentPhase + "\n")
175+
archived.WriteString(phaseArchiveBuffer.String())
176+
archived.WriteString("\n")
177+
}
178+
179+
currentPhase = line
180+
phaseHasArchivedTasks = false
181+
phaseArchiveBuffer.Reset()
182+
remaining.WriteString(line + "\n")
183+
inCompletedTask = false
184+
continue
185+
}
186+
187+
// Check for completed tasks
188+
if completedPattern.MatchString(line) {
189+
stats.completed++
190+
phaseHasArchivedTasks = true
191+
phaseArchiveBuffer.WriteString(line + "\n")
192+
inCompletedTask = true
193+
continue
194+
}
195+
196+
// Check for pending tasks
197+
if pendingPattern.MatchString(line) {
198+
stats.pending++
199+
remaining.WriteString(line + "\n")
200+
inCompletedTask = false
201+
continue
202+
}
203+
204+
// Handle subtasks (indented task items)
205+
if subTaskPattern.MatchString(line) {
206+
if inCompletedTask {
207+
// Subtask of a completed task - archive it
208+
phaseArchiveBuffer.WriteString(line + "\n")
209+
} else {
210+
// Subtask of a pending task - keep it
211+
remaining.WriteString(line + "\n")
212+
}
213+
continue
214+
}
215+
216+
// Non-task lines go to remaining
217+
remaining.WriteString(line + "\n")
218+
inCompletedTask = false
219+
}
220+
221+
// Flush final phase's archived tasks
222+
if phaseHasArchivedTasks {
223+
archived.WriteString(currentPhase + "\n")
224+
archived.WriteString(phaseArchiveBuffer.String())
225+
}
226+
227+
return remaining.String(), archived.String(), stats
228+
}
229+
230+
// tasksSnapshotCmd returns the tasks snapshot subcommand.
231+
func tasksSnapshotCmd() *cobra.Command {
232+
cmd := &cobra.Command{
233+
Use: "snapshot [name]",
234+
Short: "Create point-in-time snapshot of TASKS.md",
235+
Long: `Create a point-in-time snapshot of TASKS.md without modifying the original.
236+
237+
Snapshots are stored in .context/archive/ with timestamped names:
238+
.context/archive/tasks-snapshot-YYYY-MM-DD-HHMM.md
239+
240+
Unlike archive, snapshot copies the entire file as-is.`,
241+
Args: cobra.MaximumNArgs(1),
242+
RunE: runTasksSnapshot,
243+
}
244+
245+
return cmd
246+
}
247+
248+
func runTasksSnapshot(cmd *cobra.Command, args []string) error {
249+
green := color.New(color.FgGreen).SprintFunc()
250+
251+
// Check if TASKS.md exists
252+
if _, err := os.Stat(tasksFileName); os.IsNotExist(err) {
253+
return fmt.Errorf("no %s found", tasksFileName)
254+
}
255+
256+
// Read TASKS.md
257+
content, err := os.ReadFile(tasksFileName)
258+
if err != nil {
259+
return fmt.Errorf("failed to read %s: %w", tasksFileName, err)
260+
}
261+
262+
// Ensure archive directory exists
263+
if err := os.MkdirAll(archiveDirName, 0755); err != nil {
264+
return fmt.Errorf("failed to create archive directory: %w", err)
265+
}
266+
267+
// Generate snapshot filename
268+
now := time.Now()
269+
name := "snapshot"
270+
if len(args) > 0 {
271+
name = sanitizeFilename(args[0])
272+
}
273+
snapshotFilename := fmt.Sprintf("tasks-%s-%s.md", name, now.Format("2006-01-02-1504"))
274+
snapshotPath := filepath.Join(archiveDirName, snapshotFilename)
275+
276+
// Add snapshot header
277+
snapshotContent := fmt.Sprintf("# TASKS.md Snapshot — %s\n\nCreated: %s\n\n---\n\n%s",
278+
name, now.Format(time.RFC3339), string(content))
279+
280+
// Write snapshot
281+
if err := os.WriteFile(snapshotPath, []byte(snapshotContent), 0644); err != nil {
282+
return fmt.Errorf("failed to write snapshot: %w", err)
283+
}
284+
285+
fmt.Printf("%s Snapshot saved to %s\n", green("✓"), snapshotPath)
286+
287+
return nil
288+
}

0 commit comments

Comments
 (0)