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:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user