diff --git a/internal/seed/agents.go b/internal/seed/agents.go new file mode 100644 index 0000000..a85ca41 --- /dev/null +++ b/internal/seed/agents.go @@ -0,0 +1,130 @@ +package seed + +// AgentsMD returns the canonical AGENTS.md content for a new workspace. +// This is the single source of truth for agent-facing instructions in +// v0.6+. Agent-specific files (CLAUDE.md, etc.) become thin shims +// pointing back here. Per v0.6 spec §6. +// +// isProject controls a small set of project-specific additions +// (workspace-structure section, git conventions). +func AgentsMD(isProject bool) string { + base := `# Workspace Instructions + +This workspace uses ctask. AGENTS.md is the canonical instruction file +for agents working here. Agent-specific files (CLAUDE.md, etc.) are +thin shims that defer to this document. + +## Session Workflow + +- Read handoff.md first when starting a session. It carries the + current-state briefing: last completed work, immediate next step, + blockers, and files to inspect first. +- Update notes.md with decisions, design rationale, and observations + during the session. +- Update handoff.md before ending the session so the next agent (or + the next instance of you) can pick up cleanly. Keep it short and + current -- handoff.md is not a history file. + +## Notes Archival + +When notes.md becomes too large to scan comfortably (roughly 300-500 +lines or 10-20 KB), archive older completed-phase sections: + +1. Create a new file: context/notes-archive/YYYY-MM-DD-topic.md +2. Move the older sections into it, preserving them exactly. +3. Add a pointer at the top of notes.md: + "Older notes archived in context/notes-archive/YYYY-MM-DD-topic.md" +4. Do not delete or summarize-away historical notes as the only + preservation mechanism. +5. Keep handoff.md short and current. It is not a history file. + +## Cross-Workspace Context + +Related work may exist in other ctask workspaces. Use: + + ctask list --all discover all workspaces, including archived + ctask info view metadata and status + ctask notes read another workspace's notes.md + ctask path get the filesystem path + +Treat other workspaces as read-only unless the user explicitly asks +otherwise. + +## Do Not Touch + +- Do not modify .ctask/ -- those are ctask internals (lease, manifests, + session summary, write lock). +- Do not edit task.yaml's metadata fields by hand. ctask owns them. +` + if !isProject { + return base + ` +## File Placement (task workspace) + +- Source code and scripts -> workspace root or src/ +- Documentation, summaries, reports -> docs/ +- Deliverables and exports -> output/ +- Reference material and imported files -> context/ +- Do not place non-code outputs in the workspace root. +` + } + return base + ` +## Workspace Structure (project workspace) + +This workspace uses ctask's project layout. The workspace root contains +ctask management files; your project code lives in the project +subdirectory (named after the workspace slug). When working on project +code, operate inside the project subdirectory. + +- Workspace root: ctask metadata, session logs, notes, reference material +- Project subdirectory: source code, project-specific configuration +- context/: reference material and imported specs (workspace level) +- output/: deliverables and exports (workspace level) +- logs/: session logs (managed by ctask) + +## Git Conventions + +This workspace uses a single git repository initialized at the workspace +root. The project subdirectory and all its contents are tracked by this +root repo. Do not initialize additional git repositories inside the +project subdirectory or elsewhere -- the repo root is the workspace root. +` +} + +// ClaudeShimMD returns the thin CLAUDE.md generated for new workspaces +// whose agent.type is "claude". It exists purely to point Claude Code at +// AGENTS.md; canonical instructions live there. Per v0.6 spec §6. +func ClaudeShimMD() string { + return ` + +# Claude Code Instructions + +This workspace uses AGENTS.md as the canonical agent guidance file. +Read AGENTS.md first. + +## Claude-specific notes + +- Use claude CLI conventions for file operations. +- Respect the workspace structure described in AGENTS.md. +` +} + +// HandoffMD returns the minimal handoff.md template seeded into new +// workspaces. The agent fills it in at session end; the next session +// reads it first. Per v0.6 spec §8. +func HandoffMD() string { + return `# Handoff + +## Current state + +New workspace. No work completed yet. + +## Next step + +[Describe the task or project goal here.] + +## Files to read first + +- AGENTS.md - workspace instructions and conventions +` +} diff --git a/internal/seed/agents_test.go b/internal/seed/agents_test.go new file mode 100644 index 0000000..ffffe5f --- /dev/null +++ b/internal/seed/agents_test.go @@ -0,0 +1,72 @@ +package seed + +import ( + "strings" + "testing" +) + +func TestAgentsMDContainsHandoffWorkflow(t *testing.T) { + body := AgentsMD(false) + for _, want := range []string{ + "handoff.md", // mentions the handoff file + "Read handoff.md first", // session-start convention + "notes.md", // mentions notes + "context/notes-archive/", // archive convention + "300", // size hint + "ctask list", // cross-workspace discovery + } { + if !strings.Contains(body, want) { + t.Errorf("AGENTS.md missing %q", want) + } + } +} + +func TestAgentsMDProjectVariantHasGitConventions(t *testing.T) { + body := AgentsMD(true) + for _, want := range []string{ + "git", // git mentioned for projects + "Workspace Structure", // project-specific section + } { + if !strings.Contains(body, want) { + t.Errorf("AGENTS.md (project) missing %q", want) + } + } +} + +func TestClaudeShimMDPointsAtAgentsMD(t *testing.T) { + body := ClaudeShimMD() + for _, want := range []string{ + "Generated by ctask", + "AGENTS.md", + "Read AGENTS.md first", + } { + if !strings.Contains(body, want) { + t.Errorf("CLAUDE.md shim missing %q", want) + } + } +} + +func TestHandoffMDIsMinimalTemplate(t *testing.T) { + body := HandoffMD() + for _, want := range []string{ + "# Handoff", + "Current state", + "Next step", + "AGENTS.md", + } { + if !strings.Contains(body, want) { + t.Errorf("handoff.md missing %q", want) + } + } +} + +func TestAgentsMDIsASCII(t *testing.T) { + for _, body := range []string{AgentsMD(false), AgentsMD(true), ClaudeShimMD(), HandoffMD()} { + for i, b := range []byte(body) { + if b > 127 { + t.Errorf("non-ASCII byte 0x%02x at position %d", b, i) + break + } + } + } +} diff --git a/internal/seed/templates.go b/internal/seed/templates.go index 195792c..116ef38 100644 --- a/internal/seed/templates.go +++ b/internal/seed/templates.go @@ -2,131 +2,6 @@ package seed import "fmt" -// ClaudeMD returns the built-in default CLAUDE.md content for a task workspace. -// Parameters are accepted for API compatibility but are not interpolated in v0.3. -func ClaudeMD(slug, category, workspacePath string) string { - _ = slug - _ = category - _ = workspacePath - return `# Workspace Guidelines - -This is a ctask workspace. Prefer operating inside this directory unless explicitly instructed otherwise. - -## File Placement - -- Source code and scripts -> workspace root or ` + "`src/`" + ` -- Documentation, summaries, reports -> ` + "`docs/`" + ` -- Deliverables and exports -> ` + "`output/`" + ` -- Reference material and imported files -> ` + "`context/`" + ` -- Do not place non-code outputs (docs, summaries, exports) in the workspace root - -## Conventions - -- Do not install global packages or modify system files unless asked -- Record important assumptions and actions in notes.md -- Keep the workspace root clean -- use subdirectories for organization - -## Cross-Workspace Context - -Related work may exist in other ctask workspaces. For project continuation, -migration, debugging, or building on prior work, inspect related workspaces -before making changes. - -Available commands: - - ctask list --all discover all workspaces, including archived - ctask info view metadata and status of any workspace - ctask notes read another workspace's notes.md - ctask path get the filesystem path to inspect files directly - -Treat other workspaces as read-only unless the user explicitly asks you to -modify them. - -## Session Handoff - -Before ending a session, append a brief summary to notes.md with: - -- What was accomplished -- Key decisions made -- Open follow-ups or unfinished work -- How to continue from here - -Keep it concise -- a few bullet points is enough. -` -} - -// ClaudeMDProject returns the built-in default CLAUDE.md content for a project workspace. -func ClaudeMDProject() string { - return `# Project Workspace Guidelines - -This is a ctask project workspace -- a long-lived working environment, not a disposable task. - -## Workspace Structure - -This is a ctask project workspace. The workspace root contains ctask management -files. Your project code lives in the project subdirectory. - -- Workspace root: ctask metadata, session logs, notes, reference material -- Project subdirectory: source code, project CLAUDE.md, project configuration -- ` + "`context/`" + `: reference material and imported specs (workspace level) -- ` + "`output/`" + `: deliverables and exports (workspace level) -- ` + "`logs/`" + `: session logs (managed by ctask) - -When working on project code, operate inside the project subdirectory. -Place project-specific CLAUDE.md, documentation, and configuration there. - -## File Placement - -- Source code -> ` + "`src/`" + ` (inside the project subdirectory) -- Documentation -> ` + "`docs/`" + ` (workspace level for general notes, project subdir for project docs) -- Deliverables and exports -> ` + "`output/`" + ` (workspace level) -- Reference material -> ` + "`context/`" + ` (workspace level) -- Tests -> ` + "`tests/`" + ` (inside the project subdirectory) -- Configuration files -> inside the project subdirectory -- Do not place non-code outputs in the workspace root - -## Conventions - -- This project uses git. Commit meaningful changes with clear messages. -- Do not install global packages or modify system files unless asked. -- Record important assumptions and actions in notes.md (workspace level). -- Keep the workspace root clean. - -## Git - -This workspace uses a single git repository initialized at the workspace root. -The project subdirectory and all its contents are tracked by this root repo. -Do not initialize additional git repositories inside the project subdirectory -or any other subdirectory. If you need to check git status or make commits, -the repo root is the workspace root. - -## Cross-Workspace Context - -Related work may exist in other ctask workspaces. For project continuation, -migration, debugging, or building on prior work, inspect related workspaces -before making changes. - -Available commands: - - ctask list --all discover all workspaces, including archived - ctask info view metadata and status of any workspace - ctask notes read another workspace's notes.md - ctask path get the filesystem path to inspect files directly - -Treat other workspaces as read-only unless the user explicitly asks you to -modify them. - -## Session Handoff - -Before ending a session, append a brief summary to notes.md with: - -- What was accomplished -- Key decisions made -- Open follow-ups or unfinished work -- How to continue from here -` -} - // NotesMD returns the skeleton notes.md content. func NotesMD(title string) string { return fmt.Sprintf(`# %s diff --git a/internal/seed/templates_test.go b/internal/seed/templates_test.go index 9f3bcf2..0a011ef 100644 --- a/internal/seed/templates_test.go +++ b/internal/seed/templates_test.go @@ -1,128 +1,9 @@ package seed import ( - "strings" "testing" ) -func TestClaudeMDIsASCII(t *testing.T) { - content := ClaudeMD("test-slug", "general", "/tmp/test") - for i, b := range []byte(content) { - if b > 127 { - t.Errorf("non-ASCII byte 0x%02x at position %d in CLAUDE.md template", b, i) - break - } - } -} - -func TestClaudeMDContainsV03Sections(t *testing.T) { - content := ClaudeMD("ignored", "ignored", "ignored") - for _, want := range []string{ - "# Workspace Guidelines", - "## File Placement", - "Source code and scripts", - "Documentation, summaries, reports", - "Deliverables and exports", - "Reference material and imported files", - "## Conventions", - "## Session Handoff", - "What was accomplished", - } { - if !strings.Contains(content, want) { - t.Errorf("CLAUDE.md missing required section: %q", want) - } - } -} - -func TestClaudeMDProjectIsASCII(t *testing.T) { - content := ClaudeMDProject() - for i, b := range []byte(content) { - if b > 127 { - t.Errorf("non-ASCII byte 0x%02x at position %d in project CLAUDE.md template", b, i) - break - } - } -} - -func TestClaudeMDProjectContainsRequiredSections(t *testing.T) { - content := ClaudeMDProject() - for _, want := range []string{ - "# Project Workspace Guidelines", - "long-lived working environment", - "## File Placement", - "Source code -> ", - "Tests -> ", - "## Conventions", - "This project uses git", - "## Session Handoff", - } { - if !strings.Contains(content, want) { - t.Errorf("project CLAUDE.md missing required section: %q", want) - } - } -} - -func TestClaudeMDProjectContainsNestedGitRule(t *testing.T) { - body := ClaudeMDProject() - for _, must := range []string{ - "single git repository", - "Do not initialize additional git repositories", - } { - if !strings.Contains(body, must) { - t.Errorf("ClaudeMDProject missing guidance %q", must) - } - } -} - -func TestClaudeMDProjectDescribesWorkspaceStructure(t *testing.T) { - body := ClaudeMDProject() - for _, must := range []string{ - "## Workspace Structure", - "Workspace root: ctask metadata", - "Project subdirectory", - "project CLAUDE.md", - "operate inside the project subdirectory", - } { - if !strings.Contains(body, must) { - t.Errorf("project CLAUDE.md missing marker %q", must) - } - } -} - -func TestClaudeMDContainsCrossWorkspaceSection(t *testing.T) { - // v0.5.2: both templates teach agents to use ctask's read-only context - // commands before starting work. - content := ClaudeMD("ignored", "ignored", "ignored") - for _, must := range []string{ - "## Cross-Workspace Context", - "ctask list --all", - "ctask info ", - "ctask notes ", - "ctask path ", - "Treat other workspaces as read-only", - } { - if !strings.Contains(content, must) { - t.Errorf("task CLAUDE.md missing cross-workspace marker %q", must) - } - } -} - -func TestClaudeMDProjectContainsCrossWorkspaceSection(t *testing.T) { - content := ClaudeMDProject() - for _, must := range []string{ - "## Cross-Workspace Context", - "ctask list --all", - "ctask info ", - "ctask notes ", - "ctask path ", - "Treat other workspaces as read-only", - } { - if !strings.Contains(content, must) { - t.Errorf("project CLAUDE.md missing cross-workspace marker %q", must) - } - } -} - func TestNotesMDIsASCII(t *testing.T) { content := NotesMD("test title") for i, b := range []byte(content) { diff --git a/internal/workspace/create.go b/internal/workspace/create.go index d3d0156..5672788 100644 --- a/internal/workspace/create.go +++ b/internal/workspace/create.go @@ -88,12 +88,16 @@ func Create(opts CreateOpts) (*CreateResult, error) { return nil, fmt.Errorf("creating workspace dir: %w", err) } - // Standard subdirectories - for _, sub := range []string{"context", "output", "logs"} { + // Standard subdirectories. v0.6: context/notes-archive/ is created + // (and pinned by .gitkeep) so the notes-archival convention in + // AGENTS.md has a ready destination directory. + for _, sub := range []string{"context", "context/notes-archive", "output", "logs"} { if err := os.MkdirAll(filepath.Join(wsDir, sub), 0755); err != nil { return nil, fmt.Errorf("creating %s dir: %w", sub, err) } } + gitkeepPath := filepath.Join(wsDir, "context", "notes-archive", ".gitkeep") + _ = os.WriteFile(gitkeepPath, nil, 0644) // Adjust title if slug was suffixed due to collision actualTitle := title @@ -108,7 +112,7 @@ func Create(opts CreateOpts) (*CreateResult, error) { } // Layer 1: built-in defaults (workspace is brand new, so this always writes them). - writeBuiltinDefaults(wsDir, actualTitle, opts.IsProject) + writeBuiltinDefaults(wsDir, actualTitle, opts.IsProject, opts.AgentSpec.Type) // Layer 2: general user seed (overwrites built-in defaults; skips task.yaml/.ctask). if opts.SeedDir != "" { @@ -168,19 +172,26 @@ func Create(opts CreateOpts) (*CreateResult, error) { return &CreateResult{Path: wsDir, Meta: meta}, nil } -// writeBuiltinDefaults writes the built-in CLAUDE.md and notes.md for a new workspace. -// These files are unconditionally replaceable by the seed overlay layers. -func writeBuiltinDefaults(wsDir, title string, isProject bool) { - claudePath := filepath.Join(wsDir, "CLAUDE.md") - var claudeBody string - if isProject { - claudeBody = seed.ClaudeMDProject() - } else { - // The slug/category/path args are kept for API compatibility but are - // not interpolated by the v0.3 template. - claudeBody = seed.ClaudeMD("", "", wsDir) +// writeBuiltinDefaults writes the v0.6 built-in workspace files for a new +// workspace: AGENTS.md (always), CLAUDE.md shim (claude type only), +// handoff.md (always), and notes.md (always). All four are unconditionally +// replaceable by the seed overlay layers — a user seed dir's AGENTS.md or +// CLAUDE.md wins. +// +// The CLAUDE.md shim is generated only for agentType == "claude" per v0.6 +// spec §6 (the opencode shim is deferred until its instruction-file +// convention is verified). +func writeBuiltinDefaults(wsDir, title string, isProject bool, agentType string) { + agentsPath := filepath.Join(wsDir, "AGENTS.md") + _ = os.WriteFile(agentsPath, []byte(seed.AgentsMD(isProject)), 0644) + + if agentType == "claude" { + claudePath := filepath.Join(wsDir, "CLAUDE.md") + _ = os.WriteFile(claudePath, []byte(seed.ClaudeShimMD()), 0644) } - _ = os.WriteFile(claudePath, []byte(claudeBody), 0644) + + handoffPath := filepath.Join(wsDir, "handoff.md") + _ = os.WriteFile(handoffPath, []byte(seed.HandoffMD()), 0644) notesPath := filepath.Join(wsDir, "notes.md") _ = os.WriteFile(notesPath, []byte(seed.NotesMD(title)), 0644) diff --git a/internal/workspace/create_test.go b/internal/workspace/create_test.go index 4b2990b..e4130ce 100644 --- a/internal/workspace/create_test.go +++ b/internal/workspace/create_test.go @@ -89,7 +89,7 @@ func TestCreateWorkspaceNoTitle(t *testing.T) { } } -func TestCreateProjectWritesProjectClaudeMD(t *testing.T) { +func TestCreateProjectWritesAgentsMDAndShim(t *testing.T) { root := t.TempDir() opts := CreateOpts{ Root: root, @@ -103,20 +103,26 @@ func TestCreateProjectWritesProjectClaudeMD(t *testing.T) { if err != nil { t.Fatalf("Create: %v", err) } - body, err := os.ReadFile(filepath.Join(ws.Path, "CLAUDE.md")) + agentsBody, err := os.ReadFile(filepath.Join(ws.Path, "AGENTS.md")) + if err != nil { + t.Fatalf("read AGENTS.md: %v", err) + } + if !strings.Contains(string(agentsBody), "Workspace Structure (project workspace)") { + t.Errorf("project AGENTS.md missing project-structure section, got:\n%s", agentsBody) + } + claudeBody, err := os.ReadFile(filepath.Join(ws.Path, "CLAUDE.md")) if err != nil { t.Fatalf("read CLAUDE.md: %v", err) } - content := string(body) - if !strings.Contains(content, "# Project Workspace Guidelines") { - t.Errorf("expected project CLAUDE.md, got:\n%s", content) + if !strings.Contains(string(claudeBody), "Read AGENTS.md first") { + t.Errorf("CLAUDE.md is not the AGENTS.md shim, got:\n%s", claudeBody) } if ws.Meta.Type != "project" { t.Errorf("Type: got %q, want \"project\"", ws.Meta.Type) } } -func TestCreateTaskWritesTaskClaudeMD(t *testing.T) { +func TestCreateTaskWritesAgentsMDAndShim(t *testing.T) { root := t.TempDir() opts := CreateOpts{ Root: root, @@ -129,15 +135,94 @@ func TestCreateTaskWritesTaskClaudeMD(t *testing.T) { if err != nil { t.Fatalf("Create: %v", err) } - body, _ := os.ReadFile(filepath.Join(ws.Path, "CLAUDE.md")) - if !strings.Contains(string(body), "# Workspace Guidelines") { - t.Errorf("expected task CLAUDE.md, got:\n%s", string(body)) + agentsBody, _ := os.ReadFile(filepath.Join(ws.Path, "AGENTS.md")) + if !strings.Contains(string(agentsBody), "File Placement (task workspace)") { + t.Errorf("task AGENTS.md missing task file-placement section, got:\n%s", agentsBody) + } + claudeBody, _ := os.ReadFile(filepath.Join(ws.Path, "CLAUDE.md")) + if !strings.Contains(string(claudeBody), "Read AGENTS.md first") { + t.Errorf("CLAUDE.md is not the AGENTS.md shim, got:\n%s", claudeBody) } if ws.Meta.Type != "task" { t.Errorf("Type: got %q, want \"task\"", ws.Meta.Type) } } +func TestCreateWritesAgentsMDAlways(t *testing.T) { + tmp := t.TempDir() + res, err := Create(CreateOpts{ + Root: tmp, + Title: "agents-md-test", + Category: "general", + AgentSpec: AgentSpec{Type: "opencode"}, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + if _, err := os.Stat(filepath.Join(res.Path, "AGENTS.md")); err != nil { + t.Errorf("AGENTS.md missing: %v", err) + } + // No CLAUDE.md for opencode in v0.6. + if _, err := os.Stat(filepath.Join(res.Path, "CLAUDE.md")); !os.IsNotExist(err) { + t.Errorf("CLAUDE.md should NOT exist for opencode (got %v)", err) + } +} + +func TestCreateWritesHandoffAndContextArchive(t *testing.T) { + tmp := t.TempDir() + res, err := Create(CreateOpts{ + Root: tmp, + Title: "scaffold-test", + Category: "general", + AgentSpec: AgentSpec{Type: "claude"}, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + if _, err := os.Stat(filepath.Join(res.Path, "handoff.md")); err != nil { + t.Errorf("handoff.md missing: %v", err) + } + archiveDir := filepath.Join(res.Path, "context", "notes-archive") + if info, err := os.Stat(archiveDir); err != nil || !info.IsDir() { + t.Errorf("context/notes-archive/ missing or not a dir: %v", err) + } + if _, err := os.Stat(filepath.Join(archiveDir, ".gitkeep")); err != nil { + t.Errorf(".gitkeep missing: %v", err) + } +} + +func TestCreateDoesNotModifyExistingWorkspace(t *testing.T) { + // Pin the no-retroactive-modification invariant: a pre-v0.6 workspace + // that already exists on disk must not acquire AGENTS.md, handoff.md, + // or context/notes-archive/ from any read-side code path. + tmp := t.TempDir() + wsDir := filepath.Join(tmp, "general", "2026-01-01_legacy-ws") + if err := os.MkdirAll(wsDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + yaml := []byte("id: 20260101-000000\nslug: legacy-ws\ntitle: legacy\n" + + "created_at: 2026-01-01T00:00:00Z\nupdated_at: 2026-01-01T00:00:00Z\n" + + "status: active\ncategory: general\ntype: task\nmode: local\nagent: claude\n") + if err := os.WriteFile(filepath.Join(wsDir, "task.yaml"), yaml, 0644); err != nil { + t.Fatalf("write task.yaml: %v", err) + } + + // Read it back through ReadMeta — the path every other ctask command + // uses. A read must not trigger any writes to the workspace. + if _, err := ReadMeta(filepath.Join(wsDir, "task.yaml")); err != nil { + t.Fatalf("ReadMeta: %v", err) + } + + for _, file := range []string{"AGENTS.md", "handoff.md"} { + if _, err := os.Stat(filepath.Join(wsDir, file)); !os.IsNotExist(err) { + t.Errorf("legacy workspace acquired %s after ReadMeta (got %v)", file, err) + } + } + if _, err := os.Stat(filepath.Join(wsDir, "context", "notes-archive")); !os.IsNotExist(err) { + t.Errorf("legacy workspace acquired context/notes-archive/ after ReadMeta (got %v)", err) + } +} + func TestCreateAppliesGeneralSeed(t *testing.T) { root := t.TempDir() seedDir := t.TempDir()