feat(v0.6): AGENTS.md seed + CLAUDE.md shim + handoff + context-archive scaffold

New workspaces get:
  - AGENTS.md (always): canonical agent instructions — handoff workflow,
    notes-archive convention (~300-500 line trigger), cross-workspace
    discovery, do-not-touch warnings. Project variant adds workspace-
    structure and git-conventions sections.
  - CLAUDE.md shim (claude type only per v0.6 spec §6 — opencode shim
    deferred until its instruction-file convention is verified).
  - handoff.md: minimal current-state template the agent updates per
    session.
  - context/notes-archive/.gitkeep: directory pinned for git tracking,
    ready for the agent to populate per the archive convention.

seed.ClaudeMD and seed.ClaudeMDProject removed — no callers remain.
Existing workspaces are NOT modified; this is strictly a ctask-new code
path. The seed-wins overlay rule still applies — a user seed dir's
AGENTS.md/CLAUDE.md overrides the built-in.
This commit is contained in:
2026-05-15 11:15:16 -04:00
parent a61f900c86
commit 0c6ed0c0cf
6 changed files with 322 additions and 268 deletions
+26 -15
View File
@@ -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)
+94 -9
View File
@@ -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()