From c243cecd285bb3b8a3f6daddd3383c4d96b9ed35 Mon Sep 17 00:00:00 2001 From: itseffi <15998472+itseffi@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:52:26 +0200 Subject: [PATCH 1/4] v3 Co-Authored-By: Claude Opus 5 (1M context) --- docs/workflow.md | 2 + internal/core/archive.go | 2 + internal/core/knowledge.go | 54 +++++++++++++++++++ internal/core/knowledge_test.go | 80 +++++++++++++++++++++++++++++ internal/core/model/workflow_ops.go | 3 ++ internal/core/prompt/prd.go | 1 + internal/core/prompt/prompt_test.go | 2 + 7 files changed, 144 insertions(+) create mode 100644 internal/core/knowledge.go create mode 100644 internal/core/knowledge_test.go diff --git a/docs/workflow.md b/docs/workflow.md index 1995cefa..0db502cd 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -133,6 +133,8 @@ productize archive Completed workflows move to `.productize/tasks/_archived/--`, keeping your active task list clean. Use `productize sync` first if you want the global DB reconciled with on-disk artifacts. +Archiving also refreshes project knowledge under `.productize/project/`, folding every accepted ADR and workflow memory file — active and archived — into `decisions.md`, `architecture.md`, `context.md`, and `conventions.md`. Later workflows read those documents as durable context, so decisions made in one PRD carry forward into the next. Only documents carrying the `productize:project-knowledge` marker are rewritten; anything you hand-author is left alone and reported as a warning. + --- ## Resuming & inspecting diff --git a/internal/core/archive.go b/internal/core/archive.go index 6ad4e9bc..d4569f22 100644 --- a/internal/core/archive.go +++ b/internal/core/archive.go @@ -74,6 +74,7 @@ func archiveTaskWorkflows(ctx context.Context, cfg ArchiveConfig) (*ArchiveResul if err := archiveWorkflow(ctx, db, workspace, target, cfg.Force, result, true); err != nil { return result, err } + refreshProjectKnowledgeAfterArchive(ctx, workspace.RootDir, result) sortArchiveResult(result) return result, nil } @@ -102,6 +103,7 @@ func archiveTaskWorkflows(ctx context.Context, cfg ArchiveConfig) (*ArchiveResul } } + refreshProjectKnowledgeAfterArchive(ctx, workspace.RootDir, result) sortArchiveResult(result) return result, nil } diff --git a/internal/core/knowledge.go b/internal/core/knowledge.go new file mode 100644 index 00000000..32563b20 --- /dev/null +++ b/internal/core/knowledge.go @@ -0,0 +1,54 @@ +package core + +import ( + "context" + "fmt" + "sort" + + "github.com/itseffi/productize/internal/core/model" +) + +// PromoteProjectKnowledge regenerates the durable project knowledge documents +// under .productize/project from the workspace's workflow ADRs and memory. +// +// It reuses the adoption scan, so accepted ADRs and workflow memory from both +// active and archived workflows are folded into the canonical documents. +// Documents that are not marked as Productize-generated are left untouched and +// surfaced as warnings, so hand-authored files are never overwritten. +func PromoteProjectKnowledge(ctx context.Context, workspaceRoot string) (*model.ProjectAdoptionResult, error) { + return adoptExistingProject(ctx, model.ProjectAdoptionConfig{WorkspaceRoot: workspaceRoot}) +} + +// refreshProjectKnowledgeAfterArchive folds a freshly archived workflow's +// accepted ADRs and memory into project knowledge. +// +// Archiving has already succeeded by the time this runs, so a refresh failure is +// recorded on the result rather than returned: a stale docs refresh must not +// fail an archive that already moved files on disk. +func refreshProjectKnowledgeAfterArchive( + ctx context.Context, + workspaceRoot string, + result *model.ArchiveResult, +) { + if result == nil || result.Archived == 0 { + return + } + + promoted, err := PromoteProjectKnowledge(ctx, workspaceRoot) + if err != nil { + result.ProjectKnowledgeWarnings = append( + result.ProjectKnowledgeWarnings, + fmt.Sprintf("project knowledge refresh failed: %v", err), + ) + return + } + if promoted == nil { + return + } + + updated := append([]string(nil), promoted.Created...) + updated = append(updated, promoted.Updated...) + sort.Strings(updated) + result.ProjectKnowledgeUpdated = updated + result.ProjectKnowledgeWarnings = append(result.ProjectKnowledgeWarnings, promoted.Warnings...) +} diff --git a/internal/core/knowledge_test.go b/internal/core/knowledge_test.go new file mode 100644 index 00000000..cd73873d --- /dev/null +++ b/internal/core/knowledge_test.go @@ -0,0 +1,80 @@ +package core + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/itseffi/productize/internal/core/model" +) + +func TestRefreshProjectKnowledgeAfterArchivePromotesAcceptedADRs(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeTestFile(t, root, "go.mod", "module example.com/app\n") + writeTestFile(t, root, ".productize/tasks/feature/adrs/adr-001.md", acceptedADR("Use durable project context")) + + result := &model.ArchiveResult{Archived: 1} + refreshProjectKnowledgeAfterArchive(context.Background(), root, result) + + decisions := readTestFile(t, root, ".productize/project/decisions.md") + if !strings.Contains(decisions, "adr-001.md") { + t.Fatalf("expected decisions doc to reference the accepted ADR, got:\n%s", decisions) + } + if len(result.ProjectKnowledgeUpdated) == 0 { + t.Fatal("expected archive result to record the regenerated project knowledge docs") + } + if !containsSuffix(result.ProjectKnowledgeUpdated, "decisions.md") { + t.Fatalf("expected decisions.md in %v", result.ProjectKnowledgeUpdated) + } +} + +func TestRefreshProjectKnowledgeAfterArchiveSkipsWhenNothingArchived(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeTestFile(t, root, "go.mod", "module example.com/app\n") + writeTestFile(t, root, ".productize/tasks/feature/adrs/adr-001.md", acceptedADR("Should not be promoted")) + + result := &model.ArchiveResult{Archived: 0} + refreshProjectKnowledgeAfterArchive(context.Background(), root, result) + + if _, err := os.Stat(filepath.Join(root, ".productize", "project")); !os.IsNotExist(err) { + t.Fatalf("expected no project knowledge dir when nothing was archived, stat err = %v", err) + } + if len(result.ProjectKnowledgeUpdated) != 0 || len(result.ProjectKnowledgeWarnings) != 0 { + t.Fatalf("expected untouched result, got updated=%v warnings=%v", + result.ProjectKnowledgeUpdated, result.ProjectKnowledgeWarnings) + } +} + +func TestRefreshProjectKnowledgeAfterArchiveRecordsWarningWhenRefreshFails(t *testing.T) { + t.Parallel() + + missing := filepath.Join(t.TempDir(), "does-not-exist") + + result := &model.ArchiveResult{Archived: 1} + refreshProjectKnowledgeAfterArchive(context.Background(), missing, result) + + if len(result.ProjectKnowledgeWarnings) == 0 { + t.Fatal("expected a warning when the knowledge refresh fails") + } + if !strings.Contains(result.ProjectKnowledgeWarnings[0], "project knowledge refresh failed") { + t.Fatalf("unexpected warning: %q", result.ProjectKnowledgeWarnings[0]) + } + if len(result.ProjectKnowledgeUpdated) != 0 { + t.Fatalf("expected no updated docs on failure, got %v", result.ProjectKnowledgeUpdated) + } +} + +func containsSuffix(values []string, suffix string) bool { + for _, value := range values { + if strings.HasSuffix(value, suffix) { + return true + } + } + return false +} diff --git a/internal/core/model/workflow_ops.go b/internal/core/model/workflow_ops.go index 41e520a7..08304e0a 100644 --- a/internal/core/model/workflow_ops.go +++ b/internal/core/model/workflow_ops.go @@ -97,4 +97,7 @@ type ArchiveResult struct { ArchivedPaths []string `json:"archived_paths,omitempty"` SkippedPaths []string `json:"skipped_paths,omitempty"` SkippedReasons map[string]string `json:"skipped_reasons,omitempty"` + + ProjectKnowledgeUpdated []string `json:"project_knowledge_updated,omitempty"` + ProjectKnowledgeWarnings []string `json:"project_knowledge_warnings,omitempty"` } diff --git a/internal/core/prompt/prd.go b/internal/core/prompt/prd.go index f4ab24d1..17e59788 100644 --- a/internal/core/prompt/prd.go +++ b/internal/core/prompt/prd.go @@ -164,6 +164,7 @@ func buildProjectKnowledgeSection(taskAbsPath string) string { }{ {label: "Project context", path: model.ProjectContextPathForWorkspace(workspaceRoot)}, {label: "Project conventions", path: model.ProjectConventionsPathForWorkspace(workspaceRoot)}, + {label: "Project architecture", path: model.ProjectArchitecturePathForWorkspace(workspaceRoot)}, {label: "Project decisions", path: model.ProjectDecisionsPathForWorkspace(workspaceRoot)}, } diff --git a/internal/core/prompt/prompt_test.go b/internal/core/prompt/prompt_test.go index 97225790..f41a1b73 100644 --- a/internal/core/prompt/prompt_test.go +++ b/internal/core/prompt/prompt_test.go @@ -202,6 +202,7 @@ func TestBuildPRDTaskPromptIncludesProjectKnowledgeWhenPresent(t *testing.T) { for _, path := range []string{ model.ProjectContextPathForWorkspace(root), model.ProjectConventionsPathForWorkspace(root), + model.ProjectArchitecturePathForWorkspace(root), model.ProjectDecisionsPathForWorkspace(root), } { if err := os.WriteFile(path, []byte("# Project Knowledge\n"), 0o644); err != nil { @@ -229,6 +230,7 @@ complexity: low "## Project Knowledge", "Project context: `" + model.ProjectContextPathForWorkspace(root) + "`", "Project conventions: `" + model.ProjectConventionsPathForWorkspace(root) + "`", + "Project architecture: `" + model.ProjectArchitecturePathForWorkspace(root) + "`", "Project decisions: `" + model.ProjectDecisionsPathForWorkspace(root) + "`", "Read these project knowledge files before implementation.", } { From c7bcf4c4a66fbe63aedc7c7ef8e670d608cdf579 Mon Sep 17 00:00:00 2001 From: itseffi <15998472+itseffi@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:11:09 +0200 Subject: [PATCH 2/4] v3 Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 12 ++++++------ docs/cli-reference.md | 1 - docs/workflow.md | 1 - internal/cli/init_command_test.go | 5 ++--- internal/cli/root_test.go | 1 - internal/core/adoption.go | 16 ---------------- internal/core/adoption_test.go | 25 +++++-------------------- internal/core/model/constants.go | 1 - internal/core/model/workspace_paths.go | 4 ---- 9 files changed, 13 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index b3a2f993..7f4b6b9b 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ For an existing repo, generate durable project knowledge before asking an agent productize init existing ``` -This writes `.productize/project/` docs for inventory, context, conventions, architecture, and promoted decisions. The command is deterministic and does not call a model. +This writes `.productize/project/` docs for context, conventions, architecture, and promoted decisions. The command is deterministic and does not call a model. ### 4. Run the lifecycle @@ -247,11 +247,11 @@ See [docs/configuration.md](docs/configuration.md) for every key. .productize/ ~/.productize/ ├── config.toml ├── config.toml ├── project/ ├── daemon/daemon.sock -│ ├── inventory.md ├── db/global.db -│ ├── context.md ├── catalog/skills.json -│ ├── conventions.md ├── runs/ -│ ├── architecture.md ├── logs/ -│ └── decisions.md └── agents/ +│ ├── context.md ├── db/global.db +│ ├── conventions.md ├── catalog/skills.json +│ ├── architecture.md ├── runs/ +│ └── decisions.md ├── logs/ +│ └── agents/ ├── tasks// │ ├── _prd.md │ ├── _techspec.md diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 9dcaab32..2e4cd989 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -22,7 +22,6 @@ creating PRDs, TechSpecs, or tasks in an existing codebase. Generated files: -- `.productize/project/inventory.md` - `.productize/project/context.md` - `.productize/project/conventions.md` - `.productize/project/architecture.md` diff --git a/docs/workflow.md b/docs/workflow.md index 0db502cd..7429ac40 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -26,7 +26,6 @@ This scans repository facts without invoking an AI model and writes: ``` .productize/project/ -├── inventory.md ├── context.md ├── conventions.md ├── architecture.md diff --git a/internal/cli/init_command_test.go b/internal/cli/init_command_test.go index 38f894b2..6fc1ebf3 100644 --- a/internal/cli/init_command_test.go +++ b/internal/cli/init_command_test.go @@ -49,7 +49,6 @@ func TestInitExistingWritesProjectKnowledgeForExplicitPath(t *testing.T) { t.Fatalf("expected text output to include promoted ADR count\n%s", output) } for _, name := range []string{ - model.ProjectInventoryFileName, model.ProjectContextFileName, model.ProjectConventionsName, model.ProjectArchitectureName, @@ -86,8 +85,8 @@ func TestInitExistingEmitsJSONResult(t *testing.T) { if result.ProjectDir != ".productize/project" { t.Fatalf("ProjectDir = %q, want .productize/project", result.ProjectDir) } - if len(result.Created) != 5 { - t.Fatalf("Created count = %d, want 5", len(result.Created)) + if len(result.Created) != 4 { + t.Fatalf("Created count = %d, want 4", len(result.Created)) } } diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 38d5015c..975c1d3f 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -549,7 +549,6 @@ func TestREADMEDocumentationMatchesCurrentContract(t *testing.T) { "productize setup --agent claude --agent codex --yes", "productize init existing", ".productize/project/", - "inventory.md", "context.md", "decisions.md", "`create-prd`", diff --git a/internal/core/adoption.go b/internal/core/adoption.go index 6bf62a41..761bc8f1 100644 --- a/internal/core/adoption.go +++ b/internal/core/adoption.go @@ -559,7 +559,6 @@ func sortProjectScan(scan *projectScan) { func renderProjectDocs(scan projectScan) []generatedProjectDoc { return []generatedProjectDoc{ - {Name: model.ProjectInventoryFileName, Content: renderInventoryDoc(scan)}, {Name: model.ProjectContextFileName, Content: renderContextDoc(scan)}, {Name: model.ProjectConventionsName, Content: renderConventionsDoc(scan)}, {Name: model.ProjectArchitectureName, Content: renderArchitectureDoc(scan)}, @@ -567,21 +566,6 @@ func renderProjectDocs(scan projectScan) []generatedProjectDoc { } } -func renderInventoryDoc(scan projectScan) string { - var b strings.Builder - writeDocHeader(&b, "Project Inventory") - writeListSection(&b, "Manifests", scan.Manifests, "No known manifest files detected.") - writeListSection(&b, "Package Managers", scan.PackageManagers, "No package manager detected.") - writeListSection(&b, "Build Commands", scan.BuildCommands, "No build commands detected.") - writeListSection(&b, "Test Commands", scan.TestCommands, "No test commands detected.") - writeListSection(&b, "Agent Instructions", scan.AgentInstructions, "No agent instruction files detected.") - writeListSection(&b, "Documentation", scan.Documentation, "No documentation files detected.") - writeListSection(&b, "Top-Level Directories", scan.TopLevelDirectories, "No top-level directories detected.") - writeListSection(&b, "Top-Level Files", scan.TopLevelFiles, "No top-level files detected.") - writeWorkflowSummary(&b, scan) - return b.String() -} - func renderContextDoc(scan projectScan) string { var b strings.Builder writeDocHeader(&b, "Project Context") diff --git a/internal/core/adoption_test.go b/internal/core/adoption_test.go index 31e6ae78..95211bca 100644 --- a/internal/core/adoption_test.go +++ b/internal/core/adoption_test.go @@ -59,7 +59,7 @@ Ignore this current state. if err != nil { t.Fatalf("adoptExistingProject: %v", err) } - if got, want := len(result.Created), 5; got != want { + if got, want := len(result.Created), 4; got != want { t.Fatalf("created count = %d, want %d: %#v", got, want, result.Created) } if result.PromotedADRs != 2 { @@ -69,23 +69,8 @@ Ignore this current state. t.Fatalf("PromotedMemoryItems = %d, want 4", result.PromotedMemoryItems) } - inventory := readTestFile(t, root, ".productize/project/inventory.md") - for _, snippet := range []string{ - "`go.mod`", - "`package.json`", - "`pnpm-lock.yaml`", - "`make verify`", - "`pnpm build`", - "`AGENTS.md`", - "Active workflows: 1", - "Archived workflows: 1", - } { - if !strings.Contains(inventory, snippet) { - t.Fatalf("expected inventory to include %q\n%s", snippet, inventory) - } - } - if strings.Contains(inventory, "node_modules") || strings.Contains(inventory, ".productize/runs") { - t.Fatalf("expected inventory to omit ignored directories\n%s", inventory) + if _, err := os.Stat(filepath.Join(root, ".productize", "project", "inventory.md")); !os.IsNotExist(err) { + t.Fatalf("expected no inventory doc to be generated, stat err = %v", err) } decisions := readTestFile(t, root, ".productize/project/decisions.md") @@ -121,8 +106,8 @@ func TestAdoptExistingProjectHonorsDryRunAndOverwriteSafety(t *testing.T) { if err != nil { t.Fatalf("adoptExistingProject(dry-run): %v", err) } - if len(dryRun.Created) != 5 { - t.Fatalf("dry-run Created count = %d, want 5", len(dryRun.Created)) + if len(dryRun.Created) != 4 { + t.Fatalf("dry-run Created count = %d, want 4", len(dryRun.Created)) } if _, err := os.Stat(model.ProjectBaseDirForWorkspace(root)); !os.IsNotExist(err) { t.Fatalf("expected dry-run to avoid creating project dir, stat err=%v", err) diff --git a/internal/core/model/constants.go b/internal/core/model/constants.go index 98c4912e..94c2f39d 100644 --- a/internal/core/model/constants.go +++ b/internal/core/model/constants.go @@ -26,7 +26,6 @@ const ( WorkflowTasksDirName = "tasks" WorkflowRunsDirName = "runs" ArchivedWorkflowDirName = "_archived" - ProjectInventoryFileName = "inventory.md" ProjectContextFileName = "context.md" ProjectConventionsName = "conventions.md" ProjectArchitectureName = "architecture.md" diff --git a/internal/core/model/workspace_paths.go b/internal/core/model/workspace_paths.go index 64c0aba8..1f02fceb 100644 --- a/internal/core/model/workspace_paths.go +++ b/internal/core/model/workspace_paths.go @@ -32,10 +32,6 @@ func ProjectBaseDirForWorkspace(workspaceRoot string) string { return filepath.Join(ProductizeDir(workspaceRoot), WorkflowProjectDirName) } -func ProjectInventoryPathForWorkspace(workspaceRoot string) string { - return filepath.Join(ProjectBaseDirForWorkspace(workspaceRoot), ProjectInventoryFileName) -} - func ProjectContextPathForWorkspace(workspaceRoot string) string { return filepath.Join(ProjectBaseDirForWorkspace(workspaceRoot), ProjectContextFileName) } From 1611b1cc2c7af2f9ff394b047e90de57fd8330c4 Mon Sep 17 00:00:00 2001 From: itseffi <15998472+itseffi@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:57:41 +0200 Subject: [PATCH 3/4] v3 Co-Authored-By: Claude Opus 5 (1M context) --- internal/core/adoption.go | 9 +++++++- internal/core/knowledge_test.go | 21 +++++++++++++++++++ skills/productize-runtime/SKILL.md | 1 - .../references/cli-reference.md | 2 +- .../references/workflow-guide.md | 2 +- 5 files changed, 31 insertions(+), 4 deletions(-) diff --git a/internal/core/adoption.go b/internal/core/adoption.go index 761bc8f1..89f928f3 100644 --- a/internal/core/adoption.go +++ b/internal/core/adoption.go @@ -396,9 +396,16 @@ func scanArchivedWorkflows(ctx context.Context, root string, archivedRoot string continue } scan.ArchivedWorkflows = append(scan.ArchivedWorkflows, entry.Name()) - if err := scanWorkflowADRs(ctx, root, filepath.Join(archivedRoot, entry.Name()), scan); err != nil { + workflowDir := filepath.Join(archivedRoot, entry.Name()) + if err := scanWorkflowADRs(ctx, root, workflowDir, scan); err != nil { return err } + // Archived workflows keep their durable memory: archiving moves the + // directory, so skipping this would drop a workflow's shared memory from + // project knowledge the moment it is archived. + if memory := scanWorkflowMemory(root, entry.Name(), workflowDir); len(memory.Sections) > 0 { + scan.WorkflowMemories = append(scan.WorkflowMemories, memory) + } } return nil } diff --git a/internal/core/knowledge_test.go b/internal/core/knowledge_test.go index cd73873d..c9a9ecca 100644 --- a/internal/core/knowledge_test.go +++ b/internal/core/knowledge_test.go @@ -32,6 +32,27 @@ func TestRefreshProjectKnowledgeAfterArchivePromotesAcceptedADRs(t *testing.T) { } } +// Archiving moves the workflow directory before the refresh runs, so this +// exercises the real post-archive layout rather than simulating it in place. +func TestRefreshProjectKnowledgeAfterArchivePromotesArchivedWorkflowMemory(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeTestFile(t, root, "go.mod", "module example.com/app\n") + writeTestFile(t, root, ".productize/tasks/_archived/1700000000-feature/adrs/adr-001.md", + acceptedADR("Keep archived decisions")) + writeTestFile(t, root, ".productize/tasks/_archived/1700000000-feature/memory/MEMORY.md", + "# Workflow Memory\n\n## Shared Decisions\n\n- Archived memory must survive archiving.\n") + + result := &model.ArchiveResult{Archived: 1} + refreshProjectKnowledgeAfterArchive(context.Background(), root, result) + + decisions := readTestFile(t, root, ".productize/project/decisions.md") + if !strings.Contains(decisions, "Archived memory must survive archiving.") { + t.Fatalf("expected archived workflow memory to be promoted, got:\n%s", decisions) + } +} + func TestRefreshProjectKnowledgeAfterArchiveSkipsWhenNothingArchived(t *testing.T) { t.Parallel() diff --git a/skills/productize-runtime/SKILL.md b/skills/productize-runtime/SKILL.md index a878e419..a9332c0f 100644 --- a/skills/productize-runtime/SKILL.md +++ b/skills/productize-runtime/SKILL.md @@ -145,7 +145,6 @@ For detailed skill descriptions and inputs/outputs, read `references/skills-refe .productize/ config.toml # Workspace configuration project/ - inventory.md # Deterministic repository inventory context.md # Project context pack for future work conventions.md # Detected commands and instruction sources architecture.md # Deterministic directory/package map diff --git a/skills/productize-runtime/references/cli-reference.md b/skills/productize-runtime/references/cli-reference.md index 68e5839c..fdc0e741 100644 --- a/skills/productize-runtime/references/cli-reference.md +++ b/skills/productize-runtime/references/cli-reference.md @@ -36,7 +36,7 @@ productize init existing ../my-app --dry-run productize init existing --format json ``` -Writes `.productize/project/inventory.md`, `context.md`, `conventions.md`, +Writes `.productize/project/context.md`, `conventions.md`, `architecture.md`, and `decisions.md`. The command is deterministic and does not invoke an AI model. diff --git a/skills/productize-runtime/references/workflow-guide.md b/skills/productize-runtime/references/workflow-guide.md index 6e560f19..2c0d6c11 100644 --- a/skills/productize-runtime/references/workflow-guide.md +++ b/skills/productize-runtime/references/workflow-guide.md @@ -20,7 +20,7 @@ docs, conventions, or prior Productize workflows. 2. Review generated docs under `.productize/project/`. 3. Re-run when repo structure, commands, conventions, ADRs, or shared workflow memory changes. -**Output:** `inventory.md`, `context.md`, `conventions.md`, `architecture.md`, and `decisions.md`. +**Output:** `context.md`, `conventions.md`, `architecture.md`, and `decisions.md`. The command is deterministic and does not invoke an AI model. From 18ce1bf990559a53919ae833af4dd7a60fab487f Mon Sep 17 00:00:00 2001 From: itseffi <15998472+itseffi@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:55:25 +0200 Subject: [PATCH 4/4] v3 --- README.md | 32 +- agents/productize-operator/AGENT.md | 10 +- docs/cli-reference.md | 27 +- docs/workflow.md | 41 +- .../idea-forge/skills/idea-forge/SKILL.md | 12 +- .../idea-forge/references/adr-template.md | 11 + internal/api/client/client_transport_test.go | 25 + internal/api/contract/contract_test.go | 89 ++++ internal/api/contract/types.go | 53 +- internal/api/core/interfaces.go | 1 + internal/api/core/openapi_contract_test.go | 33 ++ internal/cli/commands_simple.go | 126 +++++ internal/cli/daemon_commands_test.go | 153 ++++++ internal/cli/init_command_test.go | 5 +- internal/core/adoption.go | 457 +++++++++++++++--- internal/core/adoption_test.go | 237 ++++++++- internal/core/archive_test.go | 72 +++ internal/core/knowledge.go | 60 ++- internal/core/knowledge_test.go | 82 ++++ internal/core/model/constants.go | 2 + internal/core/model/workflow_ops.go | 40 +- internal/core/model/workspace_paths.go | 8 + internal/core/prompt/prd.go | 2 + internal/core/prompt/prompt_test.go | 4 + internal/core/sync.go | 35 +- internal/core/sync_test.go | 228 +++++++++ internal/daemon/transport_mappers.go | 20 + internal/daemon/transport_service_test.go | 57 +++ openapi/productize-daemon.json | 42 ++ skills/create-prd/SKILL.md | 14 +- skills/create-prd/references/adr-template.md | 11 + skills/create-tasks/SKILL.md | 8 +- skills/create-techspec/SKILL.md | 11 +- .../references/adr-template.md | 11 + skills/productize-runtime/SKILL.md | 39 +- .../references/cli-reference.md | 27 +- .../references/skills-reference.md | 6 + .../references/workflow-guide.md | 38 +- skills/productize/SKILL.md | 6 +- test/skills_bundle_test.go | 164 +++++++ 40 files changed, 2137 insertions(+), 162 deletions(-) diff --git a/README.md b/README.md index 7f4b6b9b..497f53f4 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ For an existing repo, generate durable project knowledge before asking an agent productize init existing ``` -This writes `.productize/project/` docs for context, conventions, architecture, and promoted decisions. The command is deterministic and does not call a model. +This refreshes five generated read models under `.productize/project/`: `context.md`, `conventions.md`, `architecture.md`, `decisions.md`, and `constraints.md`. The command is deterministic and does not call a model. Put human-authored project guidance in `.productize/project/manual.md`; refreshes never own that file. ### 4. Run the lifecycle @@ -145,6 +145,27 @@ steps, and recommends the exact next command before mutating files or running ag See [docs/reusable-agents.md](docs/reusable-agents.md). +### Project knowledge lifecycle + +The five canonical documents under `.productize/project/` are generated, portable +read models. Productize owns files carrying its `productize:project-knowledge` +marker and leaves unmarked files untouched. Keep deliberate human-authored +additions in `manual.md` so refreshes never compete with edits. + +- `productize init existing` creates or explicitly refreshes project knowledge. +- A successful `productize sync` reconciles workflow artifacts into + `~/.productize/db/global.db` and refreshes project knowledge from relevant + repository facts, ADRs, and durable shared workflow memory. +- `productize archive` moves an eligible workflow first, then refreshes knowledge + from both active and archived workflows so accepted, deprecated, or superseded + decisions survive archival. + +Knowledge refresh is a derived-read-model step. A sync or archive can complete +while the refresh result is marked `degraded`; text output prints every skipped +file and warning, and JSON exposes the optional `project_knowledge` object with +`updated`, `unchanged`, `skipped`, `warnings`, `source_checksum`, and `degraded`. +Run `productize sync` again after resolving the warning to repair stale knowledge. + ### ACP runtimes (execution backends) `setup` installs *skills* into 40+ editors, but **execution** (`tasks run`, `reviews fix`, `exec`) goes through an ACP-capable runtime. Productize speaks the Agent Client Protocol to whichever you choose: @@ -194,8 +215,8 @@ productize runs watch # stream a running job | `productize workspaces ...` | Manage daemon workspace registrations. | | `productize daemon start \| status \| stop` | Manage the background daemon. | | `productize migrate` | Convert legacy workflow artifacts to frontmatter. | -| `productize sync` | Reconcile workflow artifacts into the global DB. | -| `productize archive` | Move completed workflows to `_archived/`. | +| `productize sync` | Reconcile workflow artifacts into the global DB and refresh project knowledge. | +| `productize archive` | Move completed workflows to `_archived/` and refresh project knowledge. | | `productize upgrade` | Update the CLI to the latest release. | Run `productize --help` for full flags, or see the [CLI reference](docs/cli-reference.md). @@ -250,8 +271,9 @@ See [docs/configuration.md](docs/configuration.md) for every key. │ ├── context.md ├── db/global.db │ ├── conventions.md ├── catalog/skills.json │ ├── architecture.md ├── runs/ -│ └── decisions.md ├── logs/ -│ └── agents/ +│ ├── decisions.md ├── logs/ +│ ├── constraints.md └── agents/ +│ └── manual.md (optional) ├── tasks// │ ├── _prd.md │ ├── _techspec.md diff --git a/agents/productize-operator/AGENT.md b/agents/productize-operator/AGENT.md index 6800ea23..d0eda424 100644 --- a/agents/productize-operator/AGENT.md +++ b/agents/productize-operator/AGENT.md @@ -14,9 +14,13 @@ Always inspect existing context before recommending a route: 1. Read `.productize/project/context.md` when it exists. 2. Read `.productize/project/conventions.md` when it exists. -3. Read `.productize/project/decisions.md` when it exists. -4. Inspect `.productize/tasks/` for active workflow artifacts. -5. Treat tactical skills as internal implementation details unless the user asks +3. Read `.productize/project/architecture.md` when it exists. +4. Read `.productize/project/decisions.md` when it exists. +5. Read `.productize/project/constraints.md` when it exists. +6. Read `.productize/project/manual.md` when it exists; it contains human-authored + additions outside the generated project knowledge read models. +7. Inspect `.productize/tasks/` for active workflow artifacts. +8. Treat tactical skills as internal implementation details unless the user asks for a specific skill by name. ## Route Selection diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 2e4cd989..3436837e 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -10,8 +10,8 @@ Adopt a mature repository into Productize project knowledge. productize init existing [path] [flags] ``` -The command deterministically scans repository facts and writes durable context -under `.productize/project/`. It does not invoke an AI model. Use it before +The command deterministically scans repository facts and creates or refreshes +durable context under `.productize/project/`. It does not invoke an AI model. Use it before creating PRDs, TechSpecs, or tasks in an existing codebase. | Flag | Default | Description | @@ -20,12 +20,17 @@ creating PRDs, TechSpecs, or tasks in an existing codebase. | `--force` | `false` | Overwrite existing unmarked project knowledge files | | `--format` | `text` | Output format: `text` or `json` | -Generated files: +Generated or refreshed files: - `.productize/project/context.md` - `.productize/project/conventions.md` - `.productize/project/architecture.md` - `.productize/project/decisions.md` +- `.productize/project/constraints.md` + +These five files are generated read models owned only when they carry the +`productize:project-knowledge` marker. Keep human-authored additions in +`.productize/project/manual.md`, which refresh never overwrites. ## `productize setup` @@ -91,7 +96,8 @@ productize migrate [flags] ## `productize sync` -Reconcile workflow artifacts into daemon state. +Reconcile workflow artifacts into daemon state, then refresh canonical project +knowledge from repository facts, ADRs, and durable shared workflow memory. ```bash productize sync [flags] @@ -104,6 +110,12 @@ productize sync [flags] | `--tasks-dir` | | Restrict sync to one task workflow directory | | `--format` | `text` | Output format: `text` or `json` | +Text output reports project knowledge as `current` or `degraded`, followed by +the source checksum and any updated, unchanged, skipped, or warning entries. +JSON includes the optional `project_knowledge` object with `updated`, +`unchanged`, `skipped`, `warnings`, `source_checksum`, and `degraded`. If refresh +is degraded, resolve its warnings and run `productize sync` again to retry. + ## `productize daemon` Manage the shared home-scoped daemon. @@ -203,7 +215,8 @@ configured retention policy. ## `productize archive` -Move fully completed workflows into the archive root. +Move fully completed workflows into the archive root, then refresh canonical +project knowledge from both active and archived workflows. ```bash productize archive [flags] @@ -216,6 +229,10 @@ productize archive [flags] | `--tasks-dir` | | Restrict archiving to one task workflow directory | | `--format` | `text` | Output format: `text` or `json` | +Archiving can succeed while its derived project-knowledge refresh is degraded. +The command reports the same text and JSON project-knowledge fields as +`productize sync`; warnings are not silent, and the next sync is the repair path. + ## `productize exec` Execute one ad hoc prompt. diff --git a/docs/workflow.md b/docs/workflow.md index 7429ac40..a6b27c43 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -29,14 +29,25 @@ This scans repository facts without invoking an AI model and writes: ├── context.md ├── conventions.md ├── architecture.md -└── decisions.md -``` - -Future PRD, TechSpec, task-generation, and task-execution prompts read these -files when present. Re-run `productize init existing` when project structure, -commands, conventions, or Productize workflow memory changes. Use `--dry-run` -to preview and `--force` only when replacing hand-written project docs is -intentional. +├── decisions.md +├── constraints.md +└── manual.md # optional, human-authored additions +``` + +The first five files are canonical generated read models. Productize rewrites +only files carrying its `productize:project-knowledge` marker; put durable +human-authored additions in `manual.md`. Idea, PRD, TechSpec, task-generation, +and task-execution workflows read all five canonical files when present, plus +`manual.md` when present. PRD and idea authoring use technical knowledge only +to understand feasibility and constraints, not to leak implementation details +into business artifacts. + +`productize init existing` explicitly refreshes these read models. A successful +`productize sync` also refreshes them after cataloging on-disk workflow artifacts, +and `productize archive` refreshes them after moving the workflow so durable ADRs +and shared memory survive archival. Use `--dry-run` to preview adoption output; +use `--force` only to migrate intentionally replaceable legacy files into +Productize ownership. --- @@ -132,7 +143,19 @@ productize archive Completed workflows move to `.productize/tasks/_archived/--`, keeping your active task list clean. Use `productize sync` first if you want the global DB reconciled with on-disk artifacts. -Archiving also refreshes project knowledge under `.productize/project/`, folding every accepted ADR and workflow memory file — active and archived — into `decisions.md`, `architecture.md`, `context.md`, and `conventions.md`. Later workflows read those documents as durable context, so decisions made in one PRD carry forward into the next. Only documents carrying the `productize:project-knowledge` marker are rewritten; anything you hand-author is left alone and reported as a warning. +Archiving also refreshes project knowledge under `.productize/project/`, folding +durable ADR content and shared workflow memory from active and archived workflows +into `context.md`, `conventions.md`, `architecture.md`, `decisions.md`, and +`constraints.md`. Accepted, deprecated, and superseded ADRs remain visible; +proposed ADRs are not promoted. Later workflows read those documents as durable +context, so decisions made in one PRD carry forward into the next. + +Project knowledge is a derived read model, so archive or sync can complete even +when its refresh is degraded. Text output reports the degraded status, every +protected/skipped file, and every warning. JSON returns the optional +`project_knowledge` result with `updated`, `unchanged`, `skipped`, `warnings`, +`source_checksum`, and `degraded`. Resolve the warning and run `productize sync` +again to retry and repair stale knowledge; refresh failures are never silent. --- diff --git a/extensions/idea-forge/skills/idea-forge/SKILL.md b/extensions/idea-forge/skills/idea-forge/SKILL.md index 979e397f..c6dea1e5 100644 --- a/extensions/idea-forge/skills/idea-forge/SKILL.md +++ b/extensions/idea-forge/skills/idea-forge/SKILL.md @@ -50,6 +50,14 @@ You MUST create a task for each phase and complete them in order: - Derive the slug from the feature idea provided by the user. - Use `.productize/tasks//` as the target directory. - If `_idea.md` already exists in the target directory, read it and operate in update mode. + - Read every existing canonical project knowledge document before shaping the idea: + - `.productize/project/context.md` + - `.productize/project/conventions.md` + - `.productize/project/architecture.md` + - `.productize/project/decisions.md` + - `.productize/project/constraints.md` + - Read `.productize/project/manual.md` when present for human-authored additions that are intentionally kept outside the generated read models. + - Use architecture, decisions, and constraints to assess feasibility and product boundaries without copying implementation detail into the idea artifact. - If the directory does not exist, create it. - Create `.productize/tasks//adrs/` directory if it does not exist. @@ -62,6 +70,7 @@ You MUST create a task for each phase and complete them in order: - Complete at least one full clarification round before proceeding to research. 3. Discover context through parallel research. + - Seed codebase exploration with all five canonical project knowledge documents listed in step 1, plus `manual.md` when present, and verify relevant facts against the current repository. - Spawn one Agent tool call to explore the codebase for relevant patterns, existing features, and architecture. - Spawn a second Agent tool call to perform 3-7 web searches for market data and competitive intelligence. - Use any available web search tools. If none are available, note the limitation and proceed with codebase exploration only. @@ -120,7 +129,8 @@ You MUST create a task for each phase and complete them in order: - After the debate, create an ADR for the scope decision: - Read `references/adr-template.md`. - Determine the next ADR number by listing existing files in `.productize/tasks//adrs/`. - - Fill the template: recommended scope as "Decision", alternatives as "Alternatives Considered", trade-offs as "Consequences". Set Status to "Accepted" and Date to today. + - Fill the template: recommended scope as "Decision", alternatives as "Alternatives Considered", trade-offs as "Consequences". Set `kind` to `scope`, `status` to `accepted`, `date` to today, and `supersedes` to a YAML list of ADR references replaced by this decision (for example, `ADR-001`) or `[]`. Keep the Markdown Status and Date sections aligned with that metadata. + - When `supersedes` is non-empty, update each replaced ADR's metadata status to `superseded` and its Markdown Status to `Superseded by ADR-NNN`, naming the new ADR. - Write the ADR to `.productize/tasks//adrs/adr-NNN.md` (zero-padded 3-digit number). 6. Scan for opportunities. diff --git a/extensions/idea-forge/skills/idea-forge/references/adr-template.md b/extensions/idea-forge/skills/idea-forge/references/adr-template.md index 381ad3f2..23833107 100644 --- a/extensions/idea-forge/skills/idea-forge/references/adr-template.md +++ b/extensions/idea-forge/skills/idea-forge/references/adr-template.md @@ -1,3 +1,10 @@ +--- +kind: +status: +date: YYYY-MM-DD +supersedes: [] +--- + # ADR-XXX: [Title] ## Status @@ -46,6 +53,10 @@ YYYY-MM-DD - [List risks and mitigation strategies] +## Constraints + +- [List hard constraints established by this decision, or "None"] + ## Implementation Notes [Any specific implementation details, migration steps, or technical notes relevant to this decision.] diff --git a/internal/api/client/client_transport_test.go b/internal/api/client/client_transport_test.go index be66de54..298b9488 100644 --- a/internal/api/client/client_transport_test.go +++ b/internal/api/client/client_transport_test.go @@ -370,6 +370,14 @@ func TestClientOperatorRequestsUseCanonicalContract(t *testing.T) { return jsonStructResponse(t, http.StatusOK, contract.ArchiveResponse{ Archived: true, ArchivedAt: &syncedAt, + ProjectKnowledge: &contract.ProjectKnowledgeRefreshResult{ + Updated: []string{".productize/project/decisions.md"}, + Unchanged: []string{}, + Skipped: []string{}, + Warnings: []string{"project knowledge refresh failed"}, + SourceChecksum: "archive-checksum", + Degraded: true, + }, }), nil case http.MethodPost + " /api/sync": var payload contract.SyncRequest @@ -385,6 +393,13 @@ func TestClientOperatorRequestsUseCanonicalContract(t *testing.T) { SyncedAt: &syncedAt, WorkflowsScanned: 3, SyncedPaths: []string{"/tmp/workspace/.productize/tasks/demo"}, + ProjectKnowledge: &contract.ProjectKnowledgeRefreshResult{ + Updated: []string{}, + Unchanged: []string{".productize/project/context.md"}, + Skipped: []string{}, + Warnings: []string{}, + SourceChecksum: "sync-checksum", + }, }), nil default: t.Fatalf("unexpected request %s %s", req.Method, req.URL.RequestURI()) @@ -471,6 +486,11 @@ func TestClientOperatorRequestsUseCanonicalContract(t *testing.T) { if !archiveResult.Archived || archiveResult.ArchivedAt == nil || !archiveResult.ArchivedAt.Equal(syncedAt) { t.Fatalf("ArchiveTaskWorkflow() = %#v, want archived result", archiveResult) } + if archiveResult.ProjectKnowledge == nil || !archiveResult.ProjectKnowledge.Degraded || + archiveResult.ProjectKnowledge.SourceChecksum != "archive-checksum" || + len(archiveResult.ProjectKnowledge.Updated) != 1 || len(archiveResult.ProjectKnowledge.Warnings) != 1 { + t.Fatalf("ArchiveTaskWorkflow().ProjectKnowledge = %#v, want lossless result", archiveResult.ProjectKnowledge) + } syncResult, err := client.SyncWorkflow(context.Background(), apicore.SyncRequest{ Workspace: "/tmp/workspace", @@ -482,6 +502,11 @@ func TestClientOperatorRequestsUseCanonicalContract(t *testing.T) { if syncResult.WorkspaceID != workspace.ID || syncResult.WorkflowSlug != "demo" || syncResult.WorkflowsScanned != 3 { t.Fatalf("SyncWorkflow() = %#v, want canonical sync result", syncResult) } + if syncResult.ProjectKnowledge == nil || syncResult.ProjectKnowledge.Degraded || + syncResult.ProjectKnowledge.SourceChecksum != "sync-checksum" || + len(syncResult.ProjectKnowledge.Unchanged) != 1 { + t.Fatalf("SyncWorkflow().ProjectKnowledge = %#v, want lossless result", syncResult.ProjectKnowledge) + } if _, err := client.GetWorkspace( context.Background(), diff --git a/internal/api/contract/contract_test.go b/internal/api/contract/contract_test.go index e77431d5..6b807db9 100644 --- a/internal/api/contract/contract_test.go +++ b/internal/api/contract/contract_test.go @@ -440,6 +440,95 @@ func TestContractRoundTripsCanonicalResponses(t *testing.T) { }) } +func TestProjectKnowledgeRefreshResultRoundTripsThroughLifecycleResponses(t *testing.T) { + t.Parallel() + + projectKnowledge := &contract.ProjectKnowledgeRefreshResult{ + Updated: []string{".productize/project/decisions.md"}, + Unchanged: []string{".productize/project/context.md"}, + Skipped: []string{".productize/project/architecture.md"}, + Warnings: []string{"architecture.md is hand-authored"}, + SourceChecksum: "sha256:abc123", + Degraded: true, + } + testCases := []struct { + name string + value any + check func(*testing.T, []byte) + }{ + { + name: "archive response", + value: contract.ArchiveResponse{ + Archived: true, + ProjectKnowledge: projectKnowledge, + }, + check: func(t *testing.T, body []byte) { + t.Helper() + var decoded contract.ArchiveResponse + if err := json.Unmarshal(body, &decoded); err != nil { + t.Fatalf("json.Unmarshal(ArchiveResponse) error = %v", err) + } + assertProjectKnowledgeRefreshResult(t, decoded.ProjectKnowledge) + }, + }, + { + name: "sync response", + value: contract.SyncResponse{ + WorkflowSlug: "demo", + ProjectKnowledge: projectKnowledge, + }, + check: func(t *testing.T, body []byte) { + t.Helper() + var decoded contract.SyncResponse + if err := json.Unmarshal(body, &decoded); err != nil { + t.Fatalf("json.Unmarshal(SyncResponse) error = %v", err) + } + assertProjectKnowledgeRefreshResult(t, decoded.ProjectKnowledge) + }, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + body, err := json.Marshal(tt.value) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + for _, field := range []string{ + `"project_knowledge"`, + `"updated"`, + `"unchanged"`, + `"skipped"`, + `"warnings"`, + `"source_checksum"`, + `"degraded":true`, + } { + if !strings.Contains(string(body), field) { + t.Fatalf("encoded response = %s, want field %s", body, field) + } + } + tt.check(t, body) + }) + } +} + +func assertProjectKnowledgeRefreshResult(t *testing.T, result *contract.ProjectKnowledgeRefreshResult) { + t.Helper() + + if result == nil { + t.Fatal("project knowledge result = nil, want populated result") + } + if !result.Degraded || result.SourceChecksum != "sha256:abc123" || + !reflect.DeepEqual(result.Updated, []string{".productize/project/decisions.md"}) || + !reflect.DeepEqual(result.Unchanged, []string{".productize/project/context.md"}) || + !reflect.DeepEqual(result.Skipped, []string{".productize/project/architecture.md"}) || + !reflect.DeepEqual(result.Warnings, []string{"architecture.md is hand-authored"}) { + t.Fatalf("project knowledge result = %#v, want lossless round trip", result) + } +} + func TestCursorFormattingParsingAndOrderingRemainStable(t *testing.T) { t.Parallel() diff --git a/internal/api/contract/types.go b/internal/api/contract/types.go index 515bf122..2f191fc7 100644 --- a/internal/api/contract/types.go +++ b/internal/api/contract/types.go @@ -205,12 +205,24 @@ type ValidationSuccess struct { CheckedAt time.Time `json:"checked_at,omitempty"` } +// ProjectKnowledgeRefreshResult reports the state of canonical project +// knowledge after a lifecycle refresh. +type ProjectKnowledgeRefreshResult struct { + Updated []string `json:"updated"` + Unchanged []string `json:"unchanged"` + Skipped []string `json:"skipped"` + Warnings []string `json:"warnings"` + SourceChecksum string `json:"source_checksum"` + Degraded bool `json:"degraded"` +} + type ArchiveResult struct { - Archived bool `json:"archived"` - ArchivedAt *time.Time `json:"archived_at,omitempty"` - Forced bool `json:"forced,omitempty"` - CompletedTasks int `json:"completed_tasks,omitempty"` - ResolvedReviewIssues int `json:"resolved_review_issues,omitempty"` + Archived bool `json:"archived"` + ArchivedAt *time.Time `json:"archived_at,omitempty"` + Forced bool `json:"forced,omitempty"` + CompletedTasks int `json:"completed_tasks,omitempty"` + ResolvedReviewIssues int `json:"resolved_review_issues,omitempty"` + ProjectKnowledge *ProjectKnowledgeRefreshResult `json:"project_knowledge,omitempty"` } type ReviewFetchResult struct { @@ -488,21 +500,22 @@ type RunEventPage struct { } type SyncResult struct { - WorkspaceID string `json:"workspace_id,omitempty"` - WorkflowSlug string `json:"workflow_slug,omitempty"` - SyncedAt *time.Time `json:"synced_at,omitempty"` - Target string `json:"target,omitempty"` - WorkflowsScanned int `json:"workflows_scanned,omitempty"` - WorkflowsPruned int `json:"workflows_pruned,omitempty"` - SnapshotsUpserted int `json:"snapshots_upserted,omitempty"` - TaskItemsUpserted int `json:"task_items_upserted,omitempty"` - ReviewRoundsUpserted int `json:"review_rounds_upserted,omitempty"` - ReviewIssuesUpserted int `json:"review_issues_upserted,omitempty"` - CheckpointsUpdated int `json:"checkpoints_updated,omitempty"` - LegacyArtifactsRemoved int `json:"legacy_artifacts_removed,omitempty"` - SyncedPaths []string `json:"synced_paths,omitempty"` - PrunedWorkflows []string `json:"pruned_workflows,omitempty"` - Warnings []string `json:"warnings,omitempty"` + WorkspaceID string `json:"workspace_id,omitempty"` + WorkflowSlug string `json:"workflow_slug,omitempty"` + SyncedAt *time.Time `json:"synced_at,omitempty"` + Target string `json:"target,omitempty"` + WorkflowsScanned int `json:"workflows_scanned,omitempty"` + WorkflowsPruned int `json:"workflows_pruned,omitempty"` + SnapshotsUpserted int `json:"snapshots_upserted,omitempty"` + TaskItemsUpserted int `json:"task_items_upserted,omitempty"` + ReviewRoundsUpserted int `json:"review_rounds_upserted,omitempty"` + ReviewIssuesUpserted int `json:"review_issues_upserted,omitempty"` + CheckpointsUpdated int `json:"checkpoints_updated,omitempty"` + LegacyArtifactsRemoved int `json:"legacy_artifacts_removed,omitempty"` + SyncedPaths []string `json:"synced_paths,omitempty"` + PrunedWorkflows []string `json:"pruned_workflows,omitempty"` + Warnings []string `json:"warnings,omitempty"` + ProjectKnowledge *ProjectKnowledgeRefreshResult `json:"project_knowledge,omitempty"` } type DaemonStatusResponse struct { diff --git a/internal/api/core/interfaces.go b/internal/api/core/interfaces.go index 0a37a9b7..f4d78600 100644 --- a/internal/api/core/interfaces.go +++ b/internal/api/core/interfaces.go @@ -183,6 +183,7 @@ type WorkspaceSyncResult = contract.WorkspaceSyncResult type WorkflowSummary = contract.WorkflowSummary type TaskItem = contract.TaskItem type ValidationSuccess = contract.ValidationSuccess +type ProjectKnowledgeRefreshResult = contract.ProjectKnowledgeRefreshResult type ArchiveRequest = contract.WorkflowArchiveRequest type ArchiveResult = contract.ArchiveResult diff --git a/internal/api/core/openapi_contract_test.go b/internal/api/core/openapi_contract_test.go index 0660fb4a..1f4a6ed5 100644 --- a/internal/api/core/openapi_contract_test.go +++ b/internal/api/core/openapi_contract_test.go @@ -185,6 +185,39 @@ func TestOpenAPIContractKeepsWorkspaceContextAndProblemSemantics(t *testing.T) { t.Fatal("WorkflowArchiveRequest must expose force") } + projectKnowledgeSchema := getSchema(t, spec, "ProjectKnowledgeRefreshResult") + projectKnowledgeProperties := getMap(t, projectKnowledgeSchema, "properties") + for _, field := range []string{ + "updated", + "unchanged", + "skipped", + "warnings", + "source_checksum", + "degraded", + } { + if _, ok := projectKnowledgeProperties[field]; !ok { + t.Fatalf("ProjectKnowledgeRefreshResult must expose %s", field) + } + if !schemaRequires(projectKnowledgeSchema, field) { + t.Fatalf("ProjectKnowledgeRefreshResult must require %s", field) + } + } + for _, schemaName := range []string{"ArchiveResult", "SyncResult"} { + lifecycleSchema := getSchema(t, spec, schemaName) + lifecycleProperties := getMap(t, lifecycleSchema, "properties") + projectKnowledge, ok := lifecycleProperties["project_knowledge"].(map[string]any) + if !ok { + t.Fatalf( + "%s.project_knowledge = %T, want schema object", + schemaName, + lifecycleProperties["project_knowledge"], + ) + } + if got := projectKnowledge["$ref"]; got != "#/components/schemas/ProjectKnowledgeRefreshResult" { + t.Fatalf("%s.project_knowledge ref = %v, want project knowledge schema", schemaName, got) + } + } + runSnapshot := getSchema(t, spec, "RunSnapshotPayload") if !schemaRequires(runSnapshot, "run") { t.Fatal("RunSnapshotPayload must require run") diff --git a/internal/cli/commands_simple.go b/internal/cli/commands_simple.go index 7a4d6eb0..30d16ab6 100644 --- a/internal/cli/commands_simple.go +++ b/internal/cli/commands_simple.go @@ -341,6 +341,7 @@ func (s *archiveCommandState) archiveViaDaemon( result.Archived++ result.ArchivedPaths = append(result.ArchivedPaths, slug) } + mergeProjectKnowledgeRefreshResult(&result.ProjectKnowledge, archiveResult.ProjectKnowledge) continue } @@ -356,9 +357,60 @@ func (s *archiveCommandState) archiveViaDaemon( sort.Strings(result.ArchivedPaths) sort.Strings(result.SkippedPaths) + normalizeProjectKnowledgeRefreshResult(result.ProjectKnowledge) return result, nil } +func mergeProjectKnowledgeRefreshResult( + result **model.ProjectKnowledgeRefreshResult, + incoming *apicore.ProjectKnowledgeRefreshResult, +) { + if incoming == nil { + return + } + if *result == nil { + *result = &model.ProjectKnowledgeRefreshResult{} + } + + merged := *result + merged.Updated = append(merged.Updated, incoming.Updated...) + merged.Unchanged = append(merged.Unchanged, incoming.Unchanged...) + merged.Skipped = append(merged.Skipped, incoming.Skipped...) + merged.Warnings = append(merged.Warnings, incoming.Warnings...) + if strings.TrimSpace(incoming.SourceChecksum) != "" { + merged.SourceChecksum = incoming.SourceChecksum + } + merged.Degraded = merged.Degraded || incoming.Degraded +} + +func normalizeProjectKnowledgeRefreshResult(result *model.ProjectKnowledgeRefreshResult) { + if result == nil { + return + } + result.Updated = sortedUniqueProjectKnowledgeValues(result.Updated) + result.Unchanged = sortedUniqueProjectKnowledgeValues(result.Unchanged) + result.Skipped = sortedUniqueProjectKnowledgeValues(result.Skipped) + result.Warnings = sortedUniqueProjectKnowledgeValues(result.Warnings) +} + +func sortedUniqueProjectKnowledgeValues(values []string) []string { + if len(values) == 0 { + return []string{} + } + + seen := make(map[string]struct{}, len(values)) + unique := make([]string, 0, len(values)) + for _, value := range values { + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + unique = append(unique, value) + } + sort.Strings(unique) + return unique +} + func (s *archiveCommandState) archiveWorkflowSlugs() ([]string, error) { if strings.TrimSpace(s.name) != "" { return []string{strings.TrimSpace(s.name)}, nil @@ -457,6 +509,19 @@ func writeSyncOutput(cmd *cobra.Command, format string, result apicore.SyncResul return withExitCode(2, fmt.Errorf("write sync warning: %w", err)) } } + if result.ProjectKnowledge != nil { + if err := writeProjectKnowledgeOutput( + cmd, + result.ProjectKnowledge.Updated, + result.ProjectKnowledge.Unchanged, + result.ProjectKnowledge.Skipped, + result.ProjectKnowledge.Warnings, + result.ProjectKnowledge.SourceChecksum, + result.ProjectKnowledge.Degraded, + ); err != nil { + return withExitCode(2, fmt.Errorf("write sync project knowledge output: %w", err)) + } + } return nil } @@ -499,5 +564,66 @@ func writeArchiveOutput(cmd *cobra.Command, format string, result *core.ArchiveR return withExitCode(2, fmt.Errorf("write archive skip: %w", err)) } } + if result.ProjectKnowledge != nil { + if err := writeProjectKnowledgeOutput( + cmd, + result.ProjectKnowledge.Updated, + result.ProjectKnowledge.Unchanged, + result.ProjectKnowledge.Skipped, + result.ProjectKnowledge.Warnings, + result.ProjectKnowledge.SourceChecksum, + result.ProjectKnowledge.Degraded, + ); err != nil { + return withExitCode(2, fmt.Errorf("write archive project knowledge output: %w", err)) + } + } + return nil +} + +func writeProjectKnowledgeOutput( + cmd *cobra.Command, + updated []string, + unchanged []string, + skipped []string, + warnings []string, + sourceChecksum string, + degraded bool, +) error { + status := "current" + if degraded { + status = "degraded" + } + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Project knowledge status: %s\n", status); err != nil { + return fmt.Errorf("write status: %w", err) + } + if sourceChecksum != "" { + if _, err := fmt.Fprintf( + cmd.OutOrStdout(), + "Project knowledge source checksum: %s\n", + sourceChecksum, + ); err != nil { + return fmt.Errorf("write source checksum: %w", err) + } + } + for _, value := range updated { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Project knowledge updated: %s\n", value); err != nil { + return fmt.Errorf("write updated path: %w", err) + } + } + for _, value := range unchanged { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Project knowledge unchanged: %s\n", value); err != nil { + return fmt.Errorf("write unchanged path: %w", err) + } + } + for _, value := range skipped { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Project knowledge skipped: %s\n", value); err != nil { + return fmt.Errorf("write skipped path: %w", err) + } + } + for _, warning := range warnings { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Project knowledge warning: %s\n", warning); err != nil { + return fmt.Errorf("write warning: %w", err) + } + } return nil } diff --git a/internal/cli/daemon_commands_test.go b/internal/cli/daemon_commands_test.go index b6553ab9..37fb585a 100644 --- a/internal/cli/daemon_commands_test.go +++ b/internal/cli/daemon_commands_test.go @@ -9,6 +9,7 @@ import ( "io" "os" "path/filepath" + "slices" "strings" "sync" "testing" @@ -18,6 +19,7 @@ import ( apicore "github.com/itseffi/productize/internal/api/core" productizeconfig "github.com/itseffi/productize/internal/config" core "github.com/itseffi/productize/internal/core" + "github.com/itseffi/productize/internal/core/model" "github.com/itseffi/productize/internal/daemon" "github.com/spf13/cobra" ) @@ -1911,6 +1913,14 @@ func TestSyncCommandUsesDaemonBackedRequestAndJSONOutput(t *testing.T) { Target: filepath.Join(workspaceRoot, ".productize", "tasks", "demo"), WorkflowsScanned: 1, TaskItemsUpserted: 3, + ProjectKnowledge: &apicore.ProjectKnowledgeRefreshResult{ + Updated: []string{".productize/project/decisions.md"}, + Unchanged: []string{}, + Skipped: []string{".productize/project/architecture.md"}, + Warnings: []string{"architecture.md is protected"}, + SourceChecksum: "sync-checksum", + Degraded: true, + }, }, } installTestCLIReadyDaemonBootstrap(t, client) @@ -1939,6 +1949,47 @@ func TestSyncCommandUsesDaemonBackedRequestAndJSONOutput(t *testing.T) { if payload.WorkflowSlug != "demo" || payload.WorkflowsScanned != 1 || payload.TaskItemsUpserted != 3 { t.Fatalf("unexpected sync payload: %#v", payload) } + if payload.ProjectKnowledge == nil || !payload.ProjectKnowledge.Degraded || + payload.ProjectKnowledge.SourceChecksum != "sync-checksum" || + len(payload.ProjectKnowledge.Updated) != 1 || len(payload.ProjectKnowledge.Skipped) != 1 || + len(payload.ProjectKnowledge.Warnings) != 1 { + t.Fatalf("unexpected sync project knowledge payload: %#v", payload.ProjectKnowledge) + } +} + +func TestSyncCommandTextOutputSurfacesProjectKnowledgeState(t *testing.T) { + t.Parallel() + + cmd := &cobra.Command{} + var output bytes.Buffer + cmd.SetOut(&output) + result := apicore.SyncResult{ + Target: "demo", + ProjectKnowledge: &apicore.ProjectKnowledgeRefreshResult{ + Updated: []string{".productize/project/decisions.md"}, + Unchanged: []string{".productize/project/context.md"}, + Skipped: []string{".productize/project/architecture.md"}, + Warnings: []string{"architecture.md is protected"}, + SourceChecksum: "sync-checksum", + Degraded: true, + }, + } + + if err := writeSyncOutput(cmd, operatorOutputFormatText, result); err != nil { + t.Fatalf("writeSyncOutput() error = %v", err) + } + for _, line := range []string{ + "Project knowledge status: degraded", + "Project knowledge source checksum: sync-checksum", + "Project knowledge updated: .productize/project/decisions.md", + "Project knowledge unchanged: .productize/project/context.md", + "Project knowledge skipped: .productize/project/architecture.md", + "Project knowledge warning: architecture.md is protected", + } { + if !strings.Contains(output.String(), line+"\n") { + t.Fatalf("sync output missing %q:\n%s", line, output.String()) + } + } } func TestArchiveCommandWorkspaceWideSkipsConflictsDeterministically(t *testing.T) { @@ -2057,3 +2108,105 @@ func TestArchiveCommandWorkspaceWideUsesFilesystemWhenDaemonCatalogIsEmpty(t *te t.Fatalf("expected skip reason for beta, got %#v", payload.SkippedReasons) } } + +func TestArchiveCommandWorkspaceWideAggregatesProjectKnowledgeDeterministically(t *testing.T) { + t.Parallel() + + workspaceRoot := t.TempDir() + for _, slug := range []string{"alpha", "beta"} { + if err := os.MkdirAll(filepath.Join(workspaceRoot, ".productize", "tasks", slug), 0o755); err != nil { + t.Fatalf("mkdir workflow dir %q: %v", slug, err) + } + } + withWorkingDir(t, workspaceRoot) + + client := &stubDaemonCommandClient{ + health: apicore.DaemonHealth{Ready: true}, + archiveBySlug: map[string]apicore.ArchiveResult{ + "alpha": { + Archived: true, + ProjectKnowledge: &apicore.ProjectKnowledgeRefreshResult{ + Updated: []string{"decisions.md", "architecture.md"}, + Unchanged: []string{"context.md"}, + Skipped: []string{}, + Warnings: []string{"protected conventions.md"}, + SourceChecksum: "checksum-alpha", + }, + }, + "beta": { + Archived: true, + ProjectKnowledge: &apicore.ProjectKnowledgeRefreshResult{ + Updated: []string{"architecture.md", "constraints.md"}, + Unchanged: []string{"context.md"}, + Skipped: []string{"conventions.md", "conventions.md"}, + Warnings: []string{"protected conventions.md", "refresh degraded"}, + SourceChecksum: "checksum-beta", + Degraded: true, + }, + }, + }, + } + installTestCLIReadyDaemonBootstrap(t, client) + + output, err := executeCommandCombinedOutput(newArchiveCommand(newLazyRootDispatcher()), nil, "--format", "json") + if err != nil { + t.Fatalf("execute archive: %v\noutput:\n%s", err, output) + } + var payload core.ArchiveResult + if err := json.Unmarshal([]byte(output), &payload); err != nil { + t.Fatalf("decode archive payload: %v\noutput:\n%s", err, output) + } + if payload.ProjectKnowledge == nil { + t.Fatalf("archive project knowledge = nil, want aggregate: %#v", payload) + } + if got, want := payload.ProjectKnowledge.Updated, + []string{"architecture.md", "constraints.md", "decisions.md"}; !slices.Equal(got, want) { + t.Fatalf("updated project knowledge = %#v, want %#v", got, want) + } + if got, want := payload.ProjectKnowledge.Unchanged, []string{"context.md"}; !slices.Equal(got, want) { + t.Fatalf("unchanged project knowledge = %#v, want %#v", got, want) + } + if got, want := payload.ProjectKnowledge.Skipped, []string{"conventions.md"}; !slices.Equal(got, want) { + t.Fatalf("skipped project knowledge = %#v, want %#v", got, want) + } + if got, want := payload.ProjectKnowledge.Warnings, + []string{"protected conventions.md", "refresh degraded"}; !slices.Equal(got, want) { + t.Fatalf("project knowledge warnings = %#v, want %#v", got, want) + } + if !payload.ProjectKnowledge.Degraded || payload.ProjectKnowledge.SourceChecksum != "checksum-beta" { + t.Fatalf( + "archive project knowledge status = %#v, want final checksum and degraded state", + payload.ProjectKnowledge, + ) + } +} + +func TestArchiveTextOutputSurfacesProjectKnowledgeState(t *testing.T) { + t.Parallel() + + cmd := &cobra.Command{} + var output bytes.Buffer + cmd.SetOut(&output) + result := &core.ArchiveResult{ + ProjectKnowledge: &model.ProjectKnowledgeRefreshResult{ + Updated: []string{".productize/project/decisions.md"}, + Warnings: []string{"refresh failed"}, + SourceChecksum: "archive-checksum", + Degraded: true, + }, + } + + if err := writeArchiveOutput(cmd, operatorOutputFormatText, result); err != nil { + t.Fatalf("writeArchiveOutput() error = %v", err) + } + for _, line := range []string{ + "Project knowledge status: degraded", + "Project knowledge source checksum: archive-checksum", + "Project knowledge updated: .productize/project/decisions.md", + "Project knowledge warning: refresh failed", + } { + if !strings.Contains(output.String(), line+"\n") { + t.Fatalf("archive output missing %q:\n%s", line, output.String()) + } + } +} diff --git a/internal/cli/init_command_test.go b/internal/cli/init_command_test.go index 6fc1ebf3..4317419b 100644 --- a/internal/cli/init_command_test.go +++ b/internal/cli/init_command_test.go @@ -53,6 +53,7 @@ func TestInitExistingWritesProjectKnowledgeForExplicitPath(t *testing.T) { model.ProjectConventionsName, model.ProjectArchitectureName, model.ProjectDecisionsFileName, + model.ProjectConstraintsName, } { path := filepath.Join(model.ProjectBaseDirForWorkspace(root), name) content, err := os.ReadFile(path) @@ -85,8 +86,8 @@ func TestInitExistingEmitsJSONResult(t *testing.T) { if result.ProjectDir != ".productize/project" { t.Fatalf("ProjectDir = %q, want .productize/project", result.ProjectDir) } - if len(result.Created) != 4 { - t.Fatalf("Created count = %d, want 4", len(result.Created)) + if len(result.Created) != 5 { + t.Fatalf("Created count = %d, want 5", len(result.Created)) } } diff --git a/internal/core/adoption.go b/internal/core/adoption.go index 89f928f3..9d037bb4 100644 --- a/internal/core/adoption.go +++ b/internal/core/adoption.go @@ -1,7 +1,9 @@ package core import ( + "bytes" "context" + "crypto/sha256" "encoding/json" "errors" "fmt" @@ -11,13 +13,16 @@ import ( "sort" "strings" + "github.com/itseffi/productize/internal/core/frontmatter" "github.com/itseffi/productize/internal/core/model" ) -const projectKnowledgeMarker = "" +const ( + projectKnowledgeMarker = "" + adrStatusSuperseded = "superseded" +) type projectScan struct { - WorkspaceRoot string Manifests []string PackageManagers []string BuildCommands []string @@ -34,13 +39,24 @@ type projectScan struct { } type promotedADR struct { - SourcePath string - Title string + Identity string + SourcePath string + Title string + Kind string + Status string + Date string + Decision string + Consequences string + Risks string + Constraints string + Supersedes []string + SupersededBy string } type promotedWorkflowMemory struct { Workflow string Path string + Archived bool Sections []promotedMemorySection } @@ -54,6 +70,19 @@ type generatedProjectDoc struct { Content string } +type adrFrontmatter struct { + Kind string `yaml:"kind"` + Status string `yaml:"status"` + Date string `yaml:"date"` + Supersedes []string `yaml:"supersedes"` +} + +type workflowKnowledgeSource struct { + Name string + Dir string + Archived bool +} + // adoptExistingProject scans an existing repository and writes durable project // knowledge docs under .productize/project. func adoptExistingProject(ctx context.Context, cfg model.ProjectAdoptionConfig) (*model.ProjectAdoptionResult, error) { @@ -77,14 +106,17 @@ func adoptExistingProject(ctx context.Context, cfg model.ProjectAdoptionConfig) Warnings: append([]string(nil), scan.Warnings...), PromotedADRs: len(scan.AcceptedADRs), PromotedMemoryItems: countPromotedMemoryItems(scan.WorkflowMemories), + SourceChecksum: checksumProjectDocs(docs), } if err := writeProjectDocs(ctx, root, docs, cfg, result); err != nil { return result, err } sort.Strings(result.Created) sort.Strings(result.Updated) + sort.Strings(result.Unchanged) sort.Strings(result.Skipped) sort.Strings(result.Warnings) + result.Degraded = len(result.Skipped) > 0 || len(result.Warnings) > 0 return result, nil } @@ -112,7 +144,7 @@ func resolveProjectAdoptionRoot(workspaceRoot string) (string, error) { } func scanExistingProject(ctx context.Context, root string) (projectScan, error) { - scan := projectScan{WorkspaceRoot: root} + var scan projectScan entries, err := os.ReadDir(root) if err != nil { return scan, fmt.Errorf("read project root: %w", err) @@ -368,14 +400,12 @@ func scanWorkflowKnowledge(ctx context.Context, root string, scan *projectScan) if !model.IsActiveWorkflowDirName(name) { continue } - workflowDir := filepath.Join(tasksRoot, name) - scan.ActiveWorkflows = append(scan.ActiveWorkflows, name) - if err := scanWorkflowADRs(ctx, root, workflowDir, scan); err != nil { + if err := scanWorkflowKnowledgeSource(ctx, root, workflowKnowledgeSource{ + Name: name, + Dir: filepath.Join(tasksRoot, name), + }, scan); err != nil { return err } - if memory := scanWorkflowMemory(root, name, workflowDir); len(memory.Sections) > 0 { - scan.WorkflowMemories = append(scan.WorkflowMemories, memory) - } } return nil } @@ -395,22 +425,80 @@ func scanArchivedWorkflows(ctx context.Context, root string, archivedRoot string if !entry.IsDir() { continue } - scan.ArchivedWorkflows = append(scan.ArchivedWorkflows, entry.Name()) - workflowDir := filepath.Join(archivedRoot, entry.Name()) - if err := scanWorkflowADRs(ctx, root, workflowDir, scan); err != nil { + if err := scanWorkflowKnowledgeSource(ctx, root, workflowKnowledgeSource{ + Name: entry.Name(), + Dir: filepath.Join(archivedRoot, entry.Name()), + Archived: true, + }, scan); err != nil { return err } - // Archived workflows keep their durable memory: archiving moves the - // directory, so skipping this would drop a workflow's shared memory from - // project knowledge the moment it is archived. - if memory := scanWorkflowMemory(root, entry.Name(), workflowDir); len(memory.Sections) > 0 { - scan.WorkflowMemories = append(scan.WorkflowMemories, memory) - } } return nil } -func scanWorkflowADRs(ctx context.Context, root, workflowDir string, scan *projectScan) error { +func scanWorkflowKnowledgeSource( + ctx context.Context, + root string, + source workflowKnowledgeSource, + scan *projectScan, +) error { + workflow := canonicalWorkflowName(source.Name, source.Archived) + if source.Archived { + scan.ArchivedWorkflows = append(scan.ArchivedWorkflows, workflow) + } else { + scan.ActiveWorkflows = append(scan.ActiveWorkflows, workflow) + } + if err := scanWorkflowADRs(ctx, root, workflow, source.Dir, scan); err != nil { + return err + } + if memory := scanWorkflowMemory(root, workflow, source.Dir, source.Archived); len(memory.Sections) > 0 { + scan.WorkflowMemories = append(scan.WorkflowMemories, memory) + } + return nil +} + +func canonicalWorkflowName(name string, archived bool) string { + if !archived { + return name + } + parts := strings.SplitN(name, "-", 3) + if len(parts) != 3 { + return name + } + if isDecimalWithLength(parts[0], 13) && isLowerAlphaNumeric(parts[1], 1, 8) { + return parts[2] + } + if isDecimalWithLength(parts[0], 8) && isDecimalWithLength(parts[1], 6) { + return parts[2] + } + return name +} + +func isDecimalWithLength(value string, length int) bool { + if len(value) != length { + return false + } + for _, char := range value { + if char < '0' || char > '9' { + return false + } + } + return true +} + +func isLowerAlphaNumeric(value string, minLength, maxLength int) bool { + if len(value) < minLength || len(value) > maxLength { + return false + } + for _, char := range value { + if (char < '0' || char > '9') && (char < 'a' || char > 'z') { + return false + } + } + return true +} + +func scanWorkflowADRs(ctx context.Context, root, workflow, workflowDir string, scan *projectScan) error { adrsDir := filepath.Join(workflowDir, "adrs") entries, err := os.ReadDir(adrsDir) if err != nil { @@ -431,35 +519,142 @@ func scanWorkflowADRs(ctx context.Context, root, workflowDir string, scan *proje if err != nil { return fmt.Errorf("read workflow ADR %s: %w", path, err) } - if !isAcceptedADR(string(content)) { - continue - } rel, err := filepath.Rel(root, path) if err != nil { return fmt.Errorf("resolve workflow ADR path: %w", err) } - scan.AcceptedADRs = append(scan.AcceptedADRs, promotedADR{ - SourcePath: filepath.ToSlash(rel), - Title: extractMarkdownTitle(string(content), entry.Name()), - }) + adr, include, err := parsePromotedADR(string(content), entry.Name(), workflow, filepath.ToSlash(rel)) + if err != nil { + scan.Warnings = append(scan.Warnings, fmt.Sprintf("%s could not be parsed: %v", filepath.ToSlash(rel), err)) + continue + } + if include { + scan.AcceptedADRs = append(scan.AcceptedADRs, adr) + } } return nil } -func isAcceptedADR(content string) bool { - lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") - inStatus := false - for _, line := range lines { +func parsePromotedADR(content, filename, workflow, sourcePath string) (promotedADR, bool, error) { + body := content + var metadata adrFrontmatter + parsedBody, err := frontmatter.Parse(content, &metadata) + if err == nil { + body = parsedBody + } else if !errors.Is(err, frontmatter.ErrHeaderNotFound) { + return promotedADR{}, false, fmt.Errorf("parse ADR frontmatter: %w", err) + } + + statusValue := metadata.Status + if strings.TrimSpace(statusValue) == "" { + statusValue = extractMarkdownSection(body, "Status") + } + status, supersededBy := normalizeADRStatus(statusValue) + switch status { + case "accepted", "deprecated", adrStatusSuperseded: + case "proposed", "": + return promotedADR{}, false, nil + default: + return promotedADR{}, false, nil + } + + date := strings.TrimSpace(metadata.Date) + if date == "" { + date = firstMarkdownValue(extractMarkdownSection(body, "Date")) + } + number := strings.ToUpper(strings.TrimSuffix(filename, filepath.Ext(filename))) + adr := promotedADR{ + Identity: workflow + "/" + number, + SourcePath: sourcePath, + Title: extractMarkdownTitle(body, filename), + Kind: strings.ToLower(strings.TrimSpace(metadata.Kind)), + Status: status, + Date: date, + Decision: extractMarkdownSection(body, "Decision"), + Consequences: extractMarkdownSection(body, "Consequences"), + Risks: extractMarkdownSection(body, "Risks"), + Constraints: extractMarkdownSection(body, "Constraints"), + Supersedes: normalizeADRReferences(metadata.Supersedes), + SupersededBy: supersededBy, + } + return adr, true, nil +} + +func normalizeADRStatus(value string) (string, string) { + value = firstMarkdownValue(value) + value = strings.Trim(value, "[] `") + lower := strings.ToLower(value) + const supersededPrefix = "superseded by " + if strings.HasPrefix(lower, supersededPrefix) { + return adrStatusSuperseded, strings.TrimSpace(value[len(supersededPrefix):]) + } + switch lower { + case "accepted", "deprecated", adrStatusSuperseded, "proposed": + return lower, "" + default: + return "", "" + } +} + +func normalizeADRReferences(values []string) []string { + result := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + result = append(result, value) + } + } + sort.Strings(result) + return uniqueStrings(result) +} + +func firstMarkdownValue(content string) string { + for _, line := range strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") { trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "## ") { - inStatus = strings.EqualFold(strings.TrimSpace(strings.TrimPrefix(trimmed, "## ")), "Status") + if trimmed != "" { + return strings.TrimSpace(strings.TrimPrefix(trimmed, "- ")) + } + } + return "" +} + +func extractMarkdownSection(content, title string) string { + lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") + sectionLevel := 0 + start := -1 + for index, line := range lines { + level, heading := markdownHeading(line) + if level < 2 || !strings.EqualFold(heading, title) { continue } - if inStatus && strings.EqualFold(strings.Trim(trimmed, "[] "), "Accepted") { - return true + sectionLevel = level + start = index + 1 + break + } + if start < 0 { + return "" + } + end := len(lines) + for index := start; index < len(lines); index++ { + level, _ := markdownHeading(lines[index]) + if level > 0 && level <= sectionLevel { + end = index + break } } - return false + return strings.TrimSpace(strings.Join(lines[start:end], "\n")) +} + +func markdownHeading(line string) (int, string) { + trimmed := strings.TrimSpace(line) + level := 0 + for level < len(trimmed) && trimmed[level] == '#' { + level++ + } + if level == 0 || level >= len(trimmed) || trimmed[level] != ' ' { + return 0, "" + } + return level, strings.TrimSpace(trimmed[level+1:]) } func extractMarkdownTitle(content, fallback string) string { @@ -472,7 +667,7 @@ func extractMarkdownTitle(content, fallback string) string { return fallback } -func scanWorkflowMemory(root, workflow string, workflowDir string) promotedWorkflowMemory { +func scanWorkflowMemory(root, workflow string, workflowDir string, archived bool) promotedWorkflowMemory { path := filepath.Join(workflowDir, "memory", "MEMORY.md") content, err := os.ReadFile(path) if err != nil { @@ -485,6 +680,7 @@ func scanWorkflowMemory(root, workflow string, workflowDir string) promotedWorkf return promotedWorkflowMemory{ Workflow: workflow, Path: filepath.ToSlash(rel), + Archived: archived, Sections: extractDurableMemorySections(string(content)), } } @@ -551,7 +747,10 @@ func sortProjectScan(scan *projectScan) { sort.Strings(scan.ActiveWorkflows) sort.Strings(scan.ArchivedWorkflows) sort.SliceStable(scan.AcceptedADRs, func(i, j int) bool { - return scan.AcceptedADRs[i].SourcePath < scan.AcceptedADRs[j].SourcePath + if scan.AcceptedADRs[i].Identity == scan.AcceptedADRs[j].Identity { + return scan.AcceptedADRs[i].SourcePath < scan.AcceptedADRs[j].SourcePath + } + return scan.AcceptedADRs[i].Identity < scan.AcceptedADRs[j].Identity }) sort.SliceStable(scan.WorkflowMemories, func(i, j int) bool { return scan.WorkflowMemories[i].Path < scan.WorkflowMemories[j].Path @@ -570,6 +769,7 @@ func renderProjectDocs(scan projectScan) []generatedProjectDoc { {Name: model.ProjectConventionsName, Content: renderConventionsDoc(scan)}, {Name: model.ProjectArchitectureName, Content: renderArchitectureDoc(scan)}, {Name: model.ProjectDecisionsFileName, Content: renderDecisionsDoc(scan)}, + {Name: model.ProjectConstraintsName, Content: renderConstraintsDoc(scan)}, } } @@ -577,7 +777,6 @@ func renderContextDoc(scan projectScan) string { var b strings.Builder writeDocHeader(&b, "Project Context") fmt.Fprintf(&b, "## Summary\n\n") - fmt.Fprintf(&b, "- Workspace root: `%s`\n", scan.WorkspaceRoot) fmt.Fprintf(&b, "- Detected package managers: %s\n", inlineList(scan.PackageManagers, "none")) fmt.Fprintf(&b, "- Active Productize workflows: %d\n", len(scan.ActiveWorkflows)) fmt.Fprintf(&b, "- Archived Productize workflows: %d\n\n", len(scan.ArchivedWorkflows)) @@ -596,7 +795,26 @@ func renderContextDoc(scan projectScan) string { fmt.Fprintf(&b, "## How To Use This Context\n\n") fmt.Fprintf(&b, "- Read this file before creating PRDs, TechSpecs, tasks, or implementation prompts.\n") fmt.Fprintf(&b, "- Use `conventions.md` for repo rules and commands.\n") - fmt.Fprintf(&b, "- Use `decisions.md` for durable ADRs and shared workflow memory.\n") + fmt.Fprintf(&b, "- Use `architecture.md` for system structure and architectural decisions.\n") + fmt.Fprintf(&b, "- Use `decisions.md` for durable ADRs and shared decisions.\n") + fmt.Fprintf(&b, "- Use `constraints.md` for hard constraints and unresolved risks.\n") + fmt.Fprintf(&b, "- Use optional `manual.md` for hand-authored project knowledge.\n\n") + writeWorkflowMemorySections( + &b, + scan.WorkflowMemories, + "Shared Learnings", + "Shared Workflow Learnings", + false, + "No shared workflow learnings detected.", + ) + writeWorkflowMemorySections( + &b, + scan.WorkflowMemories, + "Handoffs", + "Active Workflow Handoffs", + true, + "No active workflow handoffs detected.", + ) return b.String() } @@ -630,6 +848,19 @@ func renderArchitectureDoc(scan projectScan) string { fmt.Fprintf(&b, "\n") } writeListSection(&b, "Documentation Surfaces", scan.Documentation, "No documentation surfaces detected.") + fmt.Fprintf(&b, "## Architectural Decisions\n\n") + wroteArchitecture := false + for index := range scan.AcceptedADRs { + adr := &scan.AcceptedADRs[index] + if adr.Kind != "architecture" { + continue + } + writePromotedADR(&b, adr, false) + wroteArchitecture = true + } + if !wroteArchitecture { + fmt.Fprintf(&b, "No architecture-classified ADRs detected.\n\n") + } writeWorkflowSummary(&b, scan) return b.String() } @@ -637,37 +868,120 @@ func renderArchitectureDoc(scan projectScan) string { func renderDecisionsDoc(scan projectScan) string { var b strings.Builder writeDocHeader(&b, "Project Decisions") - fmt.Fprintf(&b, "## Accepted ADRs\n\n") + fmt.Fprintf(&b, "## Durable ADRs\n\n") if len(scan.AcceptedADRs) == 0 { - fmt.Fprintf(&b, "No accepted ADRs detected.\n\n") + fmt.Fprintf(&b, "No accepted, deprecated, or superseded ADRs detected.\n\n") } else { - for _, adr := range scan.AcceptedADRs { - fmt.Fprintf(&b, "- `%s`: %s\n", adr.SourcePath, adr.Title) + for index := range scan.AcceptedADRs { + writePromotedADR(&b, &scan.AcceptedADRs[index], true) } - fmt.Fprintf(&b, "\n") } - fmt.Fprintf(&b, "## Workflow Memory\n\n") - if len(scan.WorkflowMemories) == 0 { - fmt.Fprintf(&b, "No durable workflow memory detected.\n") - return b.String() + writeWorkflowMemorySections( + &b, + scan.WorkflowMemories, + "Shared Decisions", + "Shared Workflow Decisions", + false, + "No shared workflow decisions detected.", + ) + return b.String() +} + +func renderConstraintsDoc(scan projectScan) string { + var b strings.Builder + writeDocHeader(&b, "Project Constraints") + fmt.Fprintf(&b, "## ADR Constraints And Risks\n\n") + wroteADR := false + for index := range scan.AcceptedADRs { + adr := &scan.AcceptedADRs[index] + if adr.Constraints == "" && adr.Risks == "" { + continue + } + fmt.Fprintf(&b, "### `%s`: %s\n\n", adr.Identity, adr.Title) + fmt.Fprintf(&b, "Source: `%s`\n\n", adr.SourcePath) + writeMarkdownSubsection(&b, "Constraints", adr.Constraints) + writeMarkdownSubsection(&b, "Risks", adr.Risks) + wroteADR = true + } + if !wroteADR { + fmt.Fprintf(&b, "No ADR constraints or risks detected.\n\n") + } + writeWorkflowMemorySections( + &b, + scan.WorkflowMemories, + "Open Risks", + "Open Workflow Risks", + false, + "No open workflow risks detected.", + ) + return b.String() +} + +func writePromotedADR(b *strings.Builder, adr *promotedADR, includeConsequences bool) { + fmt.Fprintf(b, "### `%s`: %s\n\n", adr.Identity, adr.Title) + fmt.Fprintf(b, "- Status: `%s`\n", adr.Status) + if adr.Kind != "" { + fmt.Fprintf(b, "- Kind: `%s`\n", adr.Kind) + } + if adr.Date != "" { + fmt.Fprintf(b, "- Date: `%s`\n", adr.Date) + } + fmt.Fprintf(b, "- Source: `%s`\n", adr.SourcePath) + if len(adr.Supersedes) > 0 { + fmt.Fprintf(b, "- Supersedes: %s\n", inlineList(adr.Supersedes, "none")) + } + if adr.SupersededBy != "" { + fmt.Fprintf(b, "- Superseded by: `%s`\n", adr.SupersededBy) } - for _, memory := range scan.WorkflowMemories { - fmt.Fprintf(&b, "### %s\n\n", memory.Workflow) - fmt.Fprintf(&b, "Source: `%s`\n\n", memory.Path) + fmt.Fprintf(b, "\n") + writeMarkdownSubsection(b, "Decision", adr.Decision) + if includeConsequences { + writeMarkdownSubsection(b, "Consequences", adr.Consequences) + } +} + +func writeMarkdownSubsection(b *strings.Builder, title, content string) { + if strings.TrimSpace(content) == "" { + return + } + fmt.Fprintf(b, "#### %s\n\n%s\n\n", title, content) +} + +func writeWorkflowMemorySections( + b *strings.Builder, + memories []promotedWorkflowMemory, + sectionTitle string, + heading string, + activeOnly bool, + empty string, +) { + fmt.Fprintf(b, "## %s\n\n", heading) + wrote := false + for _, memory := range memories { + if activeOnly && memory.Archived { + continue + } for _, section := range memory.Sections { - fmt.Fprintf(&b, "#### %s\n\n", section.Title) + if section.Title != sectionTitle { + continue + } + fmt.Fprintf(b, "### %s\n\n", memory.Workflow) + fmt.Fprintf(b, "Source: `%s`\n\n", memory.Path) for _, line := range section.Lines { - fmt.Fprintf(&b, "- %s\n", strings.TrimPrefix(line, "- ")) + fmt.Fprintf(b, "- %s\n", strings.TrimPrefix(line, "- ")) } - fmt.Fprintf(&b, "\n") + fmt.Fprintf(b, "\n") + wrote = true } } - return b.String() + if !wrote { + fmt.Fprintf(b, "%s\n\n", empty) + } } func writeDocHeader(b *strings.Builder, title string) { fmt.Fprintf(b, "# %s\n\n%s\n\n", title, projectKnowledgeMarker) - fmt.Fprintf(b, "Generated by `productize init existing`. Re-run that command to refresh this file.\n\n") + fmt.Fprintf(b, "Generated by Productize. Run `productize sync` to refresh this file.\n\n") } func writeListSection(b *strings.Builder, title string, values []string, empty string) { @@ -686,7 +1000,7 @@ func writeWorkflowSummary(b *strings.Builder, scan projectScan) { fmt.Fprintf(b, "## Productize Workflows\n\n") fmt.Fprintf(b, "- Active workflows: %d\n", len(scan.ActiveWorkflows)) fmt.Fprintf(b, "- Archived workflows: %d\n", len(scan.ArchivedWorkflows)) - fmt.Fprintf(b, "- Accepted ADRs promoted: %d\n", len(scan.AcceptedADRs)) + fmt.Fprintf(b, "- Durable ADRs promoted: %d\n", len(scan.AcceptedADRs)) fmt.Fprintf(b, "- Workflow memory items promoted: %d\n\n", countPromotedMemoryItems(scan.WorkflowMemories)) } @@ -704,7 +1018,7 @@ func writeProjectDocs( } path := filepath.Join(projectDir, doc.Name) rel := filepath.ToSlash(filepath.Join(model.WorkflowRootDirName, model.WorkflowProjectDirName, doc.Name)) - action, err := classifyProjectDocWrite(path, cfg.Force) + action, err := classifyProjectDocWrite(path, []byte(doc.Content), cfg.Force) if err != nil { return err } @@ -720,6 +1034,9 @@ func writeProjectDocs( result.Created = append(result.Created, rel) case "update": result.Updated = append(result.Updated, rel) + case "unchanged": + result.Unchanged = append(result.Unchanged, rel) + continue } if cfg.DryRun { continue @@ -734,7 +1051,7 @@ func writeProjectDocs( return nil } -func classifyProjectDocWrite(path string, force bool) (string, error) { +func classifyProjectDocWrite(path string, desired []byte, force bool) (string, error) { content, err := os.ReadFile(path) if err != nil { if errors.Is(err, os.ErrNotExist) { @@ -742,6 +1059,9 @@ func classifyProjectDocWrite(path string, force bool) (string, error) { } return "", fmt.Errorf("read existing project doc: %w", err) } + if bytes.Equal(content, desired) { + return "unchanged", nil + } if force || strings.Contains(string(content), projectKnowledgeMarker) { return "update", nil } @@ -782,12 +1102,27 @@ func countPromotedMemoryItems(memories []promotedWorkflowMemory) int { count := 0 for _, memory := range memories { for _, section := range memory.Sections { + if memory.Archived && section.Title == "Handoffs" { + continue + } count += len(section.Lines) } } return count } +func checksumProjectDocs(docs []generatedProjectDoc) string { + var source strings.Builder + for _, doc := range docs { + source.WriteString(doc.Name) + source.WriteByte(0) + source.WriteString(doc.Content) + source.WriteByte(0) + } + sum := sha256.Sum256([]byte(source.String())) + return fmt.Sprintf("%x", sum) +} + func pathExists(path string) bool { _, err := os.Stat(path) return err == nil diff --git a/internal/core/adoption_test.go b/internal/core/adoption_test.go index 95211bca..0072339f 100644 --- a/internal/core/adoption_test.go +++ b/internal/core/adoption_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "reflect" "strings" "testing" @@ -59,7 +60,7 @@ Ignore this current state. if err != nil { t.Fatalf("adoptExistingProject: %v", err) } - if got, want := len(result.Created), 4; got != want { + if got, want := len(result.Created), 5; got != want { t.Fatalf("created count = %d, want %d: %#v", got, want, result.Created) } if result.PromotedADRs != 2 { @@ -80,9 +81,6 @@ Ignore this current state. "_archived/1700000000-old/adrs/adr-002.md", "Keep archived decisions visible", "Use durable context before planning.", - "Existing Makefile owns verification.", - "Context can go stale.", - "Refresh before a major workflow.", } { if !strings.Contains(decisions, snippet) { t.Fatalf("expected decisions to include %q\n%s", snippet, decisions) @@ -91,6 +89,233 @@ Ignore this current state. if strings.Contains(decisions, "task-local detail") || strings.Contains(decisions, "Ignore this current state") { t.Fatalf("expected decisions to omit task-local or non-durable memory\n%s", decisions) } + contextDoc := readTestFile(t, root, ".productize/project/context.md") + for _, snippet := range []string{ + "Existing Makefile owns verification.", + "Refresh before a major workflow.", + } { + if !strings.Contains(contextDoc, snippet) { + t.Fatalf("expected context to include %q\n%s", snippet, contextDoc) + } + } + if strings.Contains(contextDoc, root) { + t.Fatalf("expected portable context without absolute root\n%s", contextDoc) + } + constraints := readTestFile(t, root, ".productize/project/constraints.md") + if !strings.Contains(constraints, "Context can go stale.") { + t.Fatalf("expected constraints to include open workflow risks\n%s", constraints) + } +} + +func TestAdoptExistingProjectParsesAndRoutesStructuredADRs(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeTestFile(t, root, ".productize/tasks/brain/adrs/adr-007.md", `--- +kind: architecture +status: accepted +date: 2026-08-05 +supersedes: + - ADR-002 +--- + +# ADR-007: Centralize project knowledge + +## Status + +Proposed + +## Date + +2020-01-01 + +## Decision + +Generate one canonical project knowledge read model. + +## Constraints + +- Generated knowledge must remain portable. + +## Consequences + +### Positive + +- Future workflows inherit durable decisions. + +### Risks + +- A failed refresh can leave derived knowledge stale. +`) + writeTestFile(t, root, ".productize/tasks/brain/adrs/adr-008.md", `# ADR-008: Retain historical decisions + +## Status + +Deprecated + +## Date + +2026-08-04 + +## Decision + +Retain deprecated decisions for historical context. + +## Consequences + +Readers can distinguish current from historical guidance. +`) + writeTestFile(t, root, ".productize/tasks/brain/adrs/adr-009.md", `# ADR-009: Old project context format + +## Status + +Superseded by ADR-007 + +## Decision + +Use the original project context format. +`) + writeTestFile(t, root, ".productize/tasks/brain/adrs/adr-010.md", `--- +kind: technical +status: proposed +date: 2026-08-05 +supersedes: [] +--- + +# ADR-010: Proposed implementation + +## Decision + +This proposal must not enter canonical knowledge. +`) + + result, err := adoptExistingProject(context.Background(), model.ProjectAdoptionConfig{WorkspaceRoot: root}) + if err != nil { + t.Fatalf("adoptExistingProject: %v", err) + } + if result.PromotedADRs != 3 { + t.Fatalf("PromotedADRs = %d, want 3", result.PromotedADRs) + } + + decisions := readTestFile(t, root, ".productize/project/decisions.md") + for _, snippet := range []string{ + "brain/ADR-007", + "Status: `accepted`", + "Date: `2026-08-05`", + "Supersedes: `ADR-002`", + "Generate one canonical project knowledge read model.", + "Future workflows inherit durable decisions.", + "Retain deprecated decisions for historical context.", + "Status: `deprecated`", + "Status: `superseded`", + "Superseded by: `ADR-007`", + } { + if !strings.Contains(decisions, snippet) { + t.Fatalf("expected decisions to include %q\n%s", snippet, decisions) + } + } + if strings.Contains(decisions, "This proposal must not enter canonical knowledge.") { + t.Fatalf("expected proposed ADR to be excluded\n%s", decisions) + } + + architecture := readTestFile(t, root, ".productize/project/architecture.md") + if !strings.Contains(architecture, "Centralize project knowledge") || + !strings.Contains(architecture, "Generate one canonical project knowledge read model.") { + t.Fatalf("expected architecture-classified ADR to be promoted\n%s", architecture) + } + if strings.Contains(architecture, "Retain historical decisions") { + t.Fatalf("expected non-architecture ADR to stay out of architecture doc\n%s", architecture) + } + + constraints := readTestFile(t, root, ".productize/project/constraints.md") + for _, snippet := range []string{ + "Generated knowledge must remain portable.", + "A failed refresh can leave derived knowledge stale.", + } { + if !strings.Contains(constraints, snippet) { + t.Fatalf("expected constraints to include %q\n%s", snippet, constraints) + } + } +} + +func TestWorkflowKnowledgeHasStableIdentityAndSemanticsAfterMove(t *testing.T) { + t.Parallel() + + root := t.TempDir() + activeDir := filepath.Join(root, ".productize", "tasks", "brain") + writeTestFile(t, activeDir, "adrs/adr-001.md", acceptedADR("Survive archive transition")) + writeTestFile(t, activeDir, "memory/MEMORY.md", `# Workflow Memory + +## Shared Decisions + +- Shared memory survives the directory move. + +## Handoffs + +- Active-only operational handoff. +`) + + before, err := scanExistingProject(context.Background(), root) + if err != nil { + t.Fatalf("scanExistingProject(active): %v", err) + } + if got := before.AcceptedADRs[0].Identity; got != "brain/ADR-001" { + t.Fatalf("active ADR identity = %q", got) + } + + archivedDir := filepath.Join(root, ".productize", "tasks", "_archived", "1777917856910-a13a5046-brain") + if err := os.MkdirAll(filepath.Dir(archivedDir), 0o755); err != nil { + t.Fatalf("mkdir archive root: %v", err) + } + if err := os.Rename(activeDir, archivedDir); err != nil { + t.Fatalf("move workflow into archive: %v", err) + } + + after, err := scanExistingProject(context.Background(), root) + if err != nil { + t.Fatalf("scanExistingProject(archived): %v", err) + } + if got := after.AcceptedADRs[0].Identity; got != "brain/ADR-001" { + t.Fatalf("archived ADR identity = %q, want stable identity", got) + } + activeADR := before.AcceptedADRs[0] + archivedADR := after.AcceptedADRs[0] + activeADR.SourcePath = "" + archivedADR.SourcePath = "" + if !reflect.DeepEqual(activeADR, archivedADR) { + t.Fatalf("ADR semantics changed after archive:\nactive=%#v\narchived=%#v", activeADR, archivedADR) + } + if len(after.WorkflowMemories) != 1 || !after.WorkflowMemories[0].Archived { + t.Fatalf("expected archived workflow memory, got %#v", after.WorkflowMemories) + } + activeMemory := before.WorkflowMemories[0] + archivedMemory := after.WorkflowMemories[0] + activeMemory.Path = "" + archivedMemory.Path = "" + activeMemory.Archived = false + archivedMemory.Archived = false + if !reflect.DeepEqual(activeMemory, archivedMemory) { + t.Fatalf( + "workflow memory semantics changed after archive:\nactive=%#v\narchived=%#v", + activeMemory, + archivedMemory, + ) + } + + if _, err := adoptExistingProject( + context.Background(), + model.ProjectAdoptionConfig{WorkspaceRoot: root}, + ); err != nil { + t.Fatalf("adoptExistingProject: %v", err) + } + decisions := readTestFile(t, root, ".productize/project/decisions.md") + if !strings.Contains(decisions, "Shared memory survives the directory move.") { + t.Fatalf("expected archived shared memory in decisions\n%s", decisions) + } + contextDoc := readTestFile(t, root, ".productize/project/context.md") + if strings.Contains(contextDoc, "Active-only operational handoff.") { + t.Fatalf("expected archived handoff to be excluded\n%s", contextDoc) + } } func TestAdoptExistingProjectHonorsDryRunAndOverwriteSafety(t *testing.T) { @@ -106,8 +331,8 @@ func TestAdoptExistingProjectHonorsDryRunAndOverwriteSafety(t *testing.T) { if err != nil { t.Fatalf("adoptExistingProject(dry-run): %v", err) } - if len(dryRun.Created) != 4 { - t.Fatalf("dry-run Created count = %d, want 4", len(dryRun.Created)) + if len(dryRun.Created) != 5 { + t.Fatalf("dry-run Created count = %d, want 5", len(dryRun.Created)) } if _, err := os.Stat(model.ProjectBaseDirForWorkspace(root)); !os.IsNotExist(err) { t.Fatalf("expected dry-run to avoid creating project dir, stat err=%v", err) diff --git a/internal/core/archive_test.go b/internal/core/archive_test.go index 2464c8de..bc9f1a00 100644 --- a/internal/core/archive_test.go +++ b/internal/core/archive_test.go @@ -18,6 +18,78 @@ import ( "github.com/itseffi/productize/internal/store/globaldb" ) +func TestArchiveTaskWorkflowPromotesMovedWorkflowKnowledge(t *testing.T) { + rootDir := archiveTestRoot(t) + workspaceRoot := filepath.Dir(filepath.Dir(rootDir)) + workflowDir := filepath.Join(rootDir, "project-brain") + writeArchiveTaskFile(t, workflowDir, "task_001.md", "completed") + writeSyncWorkflowFile(t, workflowDir, filepath.Join("adrs", "adr-001.md"), strings.Join([]string{ + "# ADR-001: Preserve archived project knowledge", + "", + "## Status", + "", + "Accepted", + "", + "## Date", + "", + "2026-08-05", + "", + "## Decision", + "", + "Archive refreshes knowledge after moving the workflow.", + "", + "## Consequences", + "", + "Archived decisions remain available to later workflows.", + "", + }, "\n")) + writeSyncWorkflowFile(t, workflowDir, filepath.Join("memory", "MEMORY.md"), strings.Join([]string{ + "# Workflow Memory", + "", + "## Shared Decisions", + "", + "- Archived shared memory remains durable.", + "", + "## Handoffs", + "", + "- This operational handoff must not enter project knowledge.", + "", + }, "\n")) + mustSyncArchiveWorkflow(t, workflowDir) + + result, err := Archive(context.Background(), ArchiveConfig{TasksDir: workflowDir}) + if err != nil { + t.Fatalf("Archive(): %v", err) + } + if result.ProjectKnowledge == nil || result.ProjectKnowledge.Degraded { + t.Fatalf("expected healthy archive knowledge refresh, got %#v", result.ProjectKnowledge) + } + if len(result.ArchivedPaths) != 1 { + t.Fatalf("expected one archived workflow path, got %#v", result.ArchivedPaths) + } + + decisions := mustReadFile(t, filepath.Join(workspaceRoot, ".productize", "project", "decisions.md")) + for _, want := range []string{ + "Archive refreshes knowledge after moving the workflow.", + "Archived decisions remain available to later workflows.", + "Archived shared memory remains durable.", + } { + if !strings.Contains(decisions, want) { + t.Fatalf("expected decisions.md to contain %q after archive\n%s", want, decisions) + } + } + if strings.Contains(decisions, "operational handoff") { + t.Fatalf("expected archived handoffs to be excluded from project knowledge\n%s", decisions) + } + archivedSource, err := filepath.Rel(workspaceRoot, result.ArchivedPaths[0]) + if err != nil { + t.Fatalf("resolve archived source path: %v", err) + } + if !strings.Contains(decisions, filepath.ToSlash(archivedSource)) { + t.Fatalf("expected decisions.md to reference archived source path\n%s", decisions) + } +} + func TestArchiveTaskWorkflowRequiresForceForPendingStateFromSyncedDBEvenWithStaleMeta(t *testing.T) { rootDir := archiveTestRoot(t) workflowDir := filepath.Join(rootDir, "beta") diff --git a/internal/core/knowledge.go b/internal/core/knowledge.go index 32563b20..f61339bf 100644 --- a/internal/core/knowledge.go +++ b/internal/core/knowledge.go @@ -19,6 +19,42 @@ func PromoteProjectKnowledge(ctx context.Context, workspaceRoot string) (*model. return adoptExistingProject(ctx, model.ProjectAdoptionConfig{WorkspaceRoot: workspaceRoot}) } +// RefreshProjectKnowledge regenerates the canonical project knowledge read +// models and reports which documents changed, remained identical, or could not +// be overwritten safely. +func RefreshProjectKnowledge( + ctx context.Context, + workspaceRoot string, +) (*model.ProjectKnowledgeRefreshResult, error) { + promoted, err := PromoteProjectKnowledge(ctx, workspaceRoot) + result := emptyProjectKnowledgeRefreshResult() + if promoted == nil { + result.Degraded = err != nil + return result, err + } + updated := append([]string{}, promoted.Created...) + updated = append(updated, promoted.Updated...) + sort.Strings(updated) + result = &model.ProjectKnowledgeRefreshResult{ + Updated: updated, + Unchanged: append([]string{}, promoted.Unchanged...), + Skipped: append([]string{}, promoted.Skipped...), + Warnings: append([]string{}, promoted.Warnings...), + SourceChecksum: promoted.SourceChecksum, + Degraded: promoted.Degraded || err != nil, + } + return result, err +} + +func emptyProjectKnowledgeRefreshResult() *model.ProjectKnowledgeRefreshResult { + return &model.ProjectKnowledgeRefreshResult{ + Updated: []string{}, + Unchanged: []string{}, + Skipped: []string{}, + Warnings: []string{}, + } +} + // refreshProjectKnowledgeAfterArchive folds a freshly archived workflow's // accepted ADRs and memory into project knowledge. // @@ -34,21 +70,23 @@ func refreshProjectKnowledgeAfterArchive( return } - promoted, err := PromoteProjectKnowledge(ctx, workspaceRoot) + refreshed, err := RefreshProjectKnowledge(ctx, workspaceRoot) if err != nil { - result.ProjectKnowledgeWarnings = append( - result.ProjectKnowledgeWarnings, - fmt.Sprintf("project knowledge refresh failed: %v", err), - ) + warning := fmt.Sprintf("project knowledge refresh failed: %v", err) + if refreshed == nil { + refreshed = emptyProjectKnowledgeRefreshResult() + } + refreshed.Degraded = true + refreshed.Warnings = append(refreshed.Warnings, warning) + result.ProjectKnowledge = refreshed + result.ProjectKnowledgeWarnings = append(result.ProjectKnowledgeWarnings, warning) return } - if promoted == nil { + if refreshed == nil { return } - updated := append([]string(nil), promoted.Created...) - updated = append(updated, promoted.Updated...) - sort.Strings(updated) - result.ProjectKnowledgeUpdated = updated - result.ProjectKnowledgeWarnings = append(result.ProjectKnowledgeWarnings, promoted.Warnings...) + result.ProjectKnowledge = refreshed + result.ProjectKnowledgeUpdated = append([]string(nil), refreshed.Updated...) + result.ProjectKnowledgeWarnings = append(result.ProjectKnowledgeWarnings, refreshed.Warnings...) } diff --git a/internal/core/knowledge_test.go b/internal/core/knowledge_test.go index c9a9ecca..ee5607b4 100644 --- a/internal/core/knowledge_test.go +++ b/internal/core/knowledge_test.go @@ -6,10 +6,86 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/itseffi/productize/internal/core/model" ) +func TestRefreshProjectKnowledgeReportsDeterministicWrites(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeTestFile(t, root, ".productize/tasks/feature/adrs/adr-001.md", acceptedADR("Use durable project context")) + + first, err := RefreshProjectKnowledge(context.Background(), root) + if err != nil { + t.Fatalf("RefreshProjectKnowledge(first): %v", err) + } + if first.Degraded { + t.Fatalf("expected healthy refresh, got %#v", first) + } + if first.SourceChecksum == "" { + t.Fatal("expected a source checksum") + } + if got, want := len(first.Updated), 5; got != want { + t.Fatalf("first updated count = %d, want %d: %#v", got, want, first.Updated) + } + + decisionsPath := model.ProjectDecisionsPathForWorkspace(root) + stableTime := time.Date(2026, 8, 5, 10, 0, 0, 0, time.UTC) + if err := os.Chtimes(decisionsPath, stableTime, stableTime); err != nil { + t.Fatalf("Chtimes(decisions.md): %v", err) + } + + second, err := RefreshProjectKnowledge(context.Background(), root) + if err != nil { + t.Fatalf("RefreshProjectKnowledge(second): %v", err) + } + if second.Degraded { + t.Fatalf("expected healthy idempotent refresh, got %#v", second) + } + if len(second.Updated) != 0 { + t.Fatalf("expected no rewrites, got %#v", second.Updated) + } + if got, want := len(second.Unchanged), 5; got != want { + t.Fatalf("unchanged count = %d, want %d: %#v", got, want, second.Unchanged) + } + if second.SourceChecksum != first.SourceChecksum { + t.Fatalf("checksum changed without input changes: %q != %q", second.SourceChecksum, first.SourceChecksum) + } + info, err := os.Stat(decisionsPath) + if err != nil { + t.Fatalf("stat decisions.md: %v", err) + } + if !info.ModTime().Equal(stableTime) { + t.Fatalf("decisions.md was needlessly rewritten: modtime=%s want=%s", info.ModTime(), stableTime) + } +} + +func TestRefreshProjectKnowledgeReportsProtectedFilesAsDegraded(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeTestFile(t, root, ".productize/project/constraints.md", "# Hand-authored constraints\n") + + result, err := RefreshProjectKnowledge(context.Background(), root) + if err != nil { + t.Fatalf("RefreshProjectKnowledge: %v", err) + } + if !result.Degraded { + t.Fatalf("expected protected generated path to degrade refresh, got %#v", result) + } + if !containsString(result.Skipped, ".productize/project/constraints.md") { + t.Fatalf("expected constraints.md to be reported as skipped: %#v", result.Skipped) + } + if len(result.Warnings) == 0 || !strings.Contains(result.Warnings[0], "not marked as Productize-generated") { + t.Fatalf("expected an actionable protected-file warning: %#v", result.Warnings) + } + if got := readTestFile(t, root, ".productize/project/constraints.md"); got != "# Hand-authored constraints\n" { + t.Fatalf("protected file was overwritten: %q", got) + } +} + func TestRefreshProjectKnowledgeAfterArchivePromotesAcceptedADRs(t *testing.T) { t.Parallel() @@ -30,6 +106,9 @@ func TestRefreshProjectKnowledgeAfterArchivePromotesAcceptedADRs(t *testing.T) { if !containsSuffix(result.ProjectKnowledgeUpdated, "decisions.md") { t.Fatalf("expected decisions.md in %v", result.ProjectKnowledgeUpdated) } + if result.ProjectKnowledge == nil || result.ProjectKnowledge.Degraded { + t.Fatalf("expected healthy nested refresh result, got %#v", result.ProjectKnowledge) + } } // Archiving moves the workflow directory before the refresh runs, so this @@ -89,6 +168,9 @@ func TestRefreshProjectKnowledgeAfterArchiveRecordsWarningWhenRefreshFails(t *te if len(result.ProjectKnowledgeUpdated) != 0 { t.Fatalf("expected no updated docs on failure, got %v", result.ProjectKnowledgeUpdated) } + if result.ProjectKnowledge == nil || !result.ProjectKnowledge.Degraded { + t.Fatalf("expected degraded nested refresh result, got %#v", result.ProjectKnowledge) + } } func containsSuffix(values []string, suffix string) bool { diff --git a/internal/core/model/constants.go b/internal/core/model/constants.go index 94c2f39d..5b2eafe4 100644 --- a/internal/core/model/constants.go +++ b/internal/core/model/constants.go @@ -30,6 +30,8 @@ const ( ProjectConventionsName = "conventions.md" ProjectArchitectureName = "architecture.md" ProjectDecisionsFileName = "decisions.md" + ProjectConstraintsName = "constraints.md" + ProjectManualFileName = "manual.md" ModeCodeReview = "pr-review" ModePRDTasks = "prd-tasks" ModeExec = "exec" diff --git a/internal/core/model/workflow_ops.go b/internal/core/model/workflow_ops.go index 08304e0a..7913e2cc 100644 --- a/internal/core/model/workflow_ops.go +++ b/internal/core/model/workflow_ops.go @@ -71,6 +71,7 @@ type SyncResult struct { SyncedPaths []string PrunedWorkflows []string Warnings []string + ProjectKnowledge *ProjectKnowledgeRefreshResult } type ProjectAdoptionResult struct { @@ -78,25 +79,40 @@ type ProjectAdoptionResult struct { ProjectDir string `json:"project_dir"` Created []string `json:"created"` Updated []string `json:"updated"` + Unchanged []string `json:"unchanged"` Skipped []string `json:"skipped"` Warnings []string `json:"warnings"` + SourceChecksum string `json:"source_checksum"` + Degraded bool `json:"degraded"` PromotedADRs int `json:"promoted_adrs"` PromotedMemoryItems int `json:"promoted_memory_items"` } +// ProjectKnowledgeRefreshResult describes a deterministic refresh of the +// generated project knowledge read models under .productize/project. +type ProjectKnowledgeRefreshResult struct { + Updated []string `json:"updated"` + Unchanged []string `json:"unchanged"` + Skipped []string `json:"skipped"` + Warnings []string `json:"warnings"` + SourceChecksum string `json:"source_checksum"` + Degraded bool `json:"degraded"` +} + type ArchiveResult struct { - Target string `json:"target"` - ArchiveRoot string `json:"archive_root"` - WorkflowsScanned int `json:"workflows_scanned"` - Archived int `json:"archived"` - Skipped int `json:"skipped"` - Forced bool `json:"forced,omitempty"` - CompletedTasks int `json:"completed_tasks,omitempty"` - ResolvedReviewIssues int `json:"resolved_review_issues,omitempty"` - ArchivedAt *time.Time `json:"archived_at,omitempty"` - ArchivedPaths []string `json:"archived_paths,omitempty"` - SkippedPaths []string `json:"skipped_paths,omitempty"` - SkippedReasons map[string]string `json:"skipped_reasons,omitempty"` + Target string `json:"target"` + ArchiveRoot string `json:"archive_root"` + WorkflowsScanned int `json:"workflows_scanned"` + Archived int `json:"archived"` + Skipped int `json:"skipped"` + Forced bool `json:"forced,omitempty"` + CompletedTasks int `json:"completed_tasks,omitempty"` + ResolvedReviewIssues int `json:"resolved_review_issues,omitempty"` + ArchivedAt *time.Time `json:"archived_at,omitempty"` + ArchivedPaths []string `json:"archived_paths,omitempty"` + SkippedPaths []string `json:"skipped_paths,omitempty"` + SkippedReasons map[string]string `json:"skipped_reasons,omitempty"` + ProjectKnowledge *ProjectKnowledgeRefreshResult `json:"project_knowledge,omitempty"` ProjectKnowledgeUpdated []string `json:"project_knowledge_updated,omitempty"` ProjectKnowledgeWarnings []string `json:"project_knowledge_warnings,omitempty"` diff --git a/internal/core/model/workspace_paths.go b/internal/core/model/workspace_paths.go index 1f02fceb..e135a93d 100644 --- a/internal/core/model/workspace_paths.go +++ b/internal/core/model/workspace_paths.go @@ -48,6 +48,14 @@ func ProjectDecisionsPathForWorkspace(workspaceRoot string) string { return filepath.Join(ProjectBaseDirForWorkspace(workspaceRoot), ProjectDecisionsFileName) } +func ProjectConstraintsPathForWorkspace(workspaceRoot string) string { + return filepath.Join(ProjectBaseDirForWorkspace(workspaceRoot), ProjectConstraintsName) +} + +func ProjectManualPathForWorkspace(workspaceRoot string) string { + return filepath.Join(ProjectBaseDirForWorkspace(workspaceRoot), ProjectManualFileName) +} + func TasksBaseDirForWorkspace(workspaceRoot string) string { return filepath.Join(ProductizeDir(workspaceRoot), WorkflowTasksDirName) } diff --git a/internal/core/prompt/prd.go b/internal/core/prompt/prd.go index 17e59788..62badf26 100644 --- a/internal/core/prompt/prd.go +++ b/internal/core/prompt/prd.go @@ -166,6 +166,8 @@ func buildProjectKnowledgeSection(taskAbsPath string) string { {label: "Project conventions", path: model.ProjectConventionsPathForWorkspace(workspaceRoot)}, {label: "Project architecture", path: model.ProjectArchitecturePathForWorkspace(workspaceRoot)}, {label: "Project decisions", path: model.ProjectDecisionsPathForWorkspace(workspaceRoot)}, + {label: "Project constraints", path: model.ProjectConstraintsPathForWorkspace(workspaceRoot)}, + {label: "Project manual additions", path: model.ProjectManualPathForWorkspace(workspaceRoot)}, } var existing []string diff --git a/internal/core/prompt/prompt_test.go b/internal/core/prompt/prompt_test.go index f41a1b73..374a5638 100644 --- a/internal/core/prompt/prompt_test.go +++ b/internal/core/prompt/prompt_test.go @@ -204,6 +204,8 @@ func TestBuildPRDTaskPromptIncludesProjectKnowledgeWhenPresent(t *testing.T) { model.ProjectConventionsPathForWorkspace(root), model.ProjectArchitecturePathForWorkspace(root), model.ProjectDecisionsPathForWorkspace(root), + model.ProjectConstraintsPathForWorkspace(root), + model.ProjectManualPathForWorkspace(root), } { if err := os.WriteFile(path, []byte("# Project Knowledge\n"), 0o644); err != nil { t.Fatalf("write project knowledge %s: %v", path, err) @@ -232,6 +234,8 @@ complexity: low "Project conventions: `" + model.ProjectConventionsPathForWorkspace(root) + "`", "Project architecture: `" + model.ProjectArchitecturePathForWorkspace(root) + "`", "Project decisions: `" + model.ProjectDecisionsPathForWorkspace(root) + "`", + "Project constraints: `" + model.ProjectConstraintsPathForWorkspace(root) + "`", + "Project manual additions: `" + model.ProjectManualPathForWorkspace(root) + "`", "Read these project knowledge files before implementation.", } { if !strings.Contains(promptText, snippet) { diff --git a/internal/core/sync.go b/internal/core/sync.go index 70cdbf60..09136ef3 100644 --- a/internal/core/sync.go +++ b/internal/core/sync.go @@ -39,7 +39,12 @@ func syncTaskMetadata(ctx context.Context, cfg SyncConfig) (*SyncResult, error) _ = db.Close() }() - return syncResolvedTarget(ctx, db, workspace.ID, target, singleWorkflow, result) + synced, err := syncResolvedTarget(ctx, db, workspace.ID, target, singleWorkflow, result) + if err != nil { + return synced, err + } + refreshProjectKnowledgeAfterSync(ctx, workspace.RootDir, synced) + return synced, nil } // SyncWithDB reconciles workflow artifacts into an already-open global.db. @@ -64,7 +69,33 @@ func SyncWithDB( if !syncTargetBelongsToWorkspace(target, workspace.RootDir) { return result, fmt.Errorf("mismatched workspace and sync target: %s is outside %s", target, workspace.RootDir) } - return syncResolvedTarget(ctx, db, workspaceID, target, singleWorkflow, result) + synced, err := syncResolvedTarget(ctx, db, workspaceID, target, singleWorkflow, result) + if err != nil { + return synced, err + } + refreshProjectKnowledgeAfterSync(ctx, workspace.RootDir, synced) + return synced, nil +} + +func refreshProjectKnowledgeAfterSync(ctx context.Context, workspaceRoot string, result *SyncResult) { + if result == nil { + return + } + + refreshed, err := RefreshProjectKnowledge(ctx, workspaceRoot) + if err != nil { + if refreshed == nil { + refreshed = emptyProjectKnowledgeRefreshResult() + } + refreshed.Warnings = append( + refreshed.Warnings, + fmt.Sprintf("project knowledge refresh failed: %v", err), + ) + refreshed.Degraded = true + result.ProjectKnowledge = refreshed + return + } + result.ProjectKnowledge = refreshed } func syncTargetBelongsToWorkspace(target string, workspaceRoot string) bool { diff --git a/internal/core/sync_test.go b/internal/core/sync_test.go index c11dd8f9..58e1f94d 100644 --- a/internal/core/sync_test.go +++ b/internal/core/sync_test.go @@ -9,11 +9,239 @@ import ( "reflect" "strings" "testing" + "time" productizeconfig "github.com/itseffi/productize/internal/config" "github.com/itseffi/productize/internal/store" ) +func TestSyncRefreshesProjectKnowledgeWithoutNeedlessRewrites(t *testing.T) { + workspaceRoot := t.TempDir() + setSyncTestHome(t) + + workflowDir := filepath.Join(workspaceRoot, ".productize", "tasks", "project-brain") + writeSyncWorkflowFile(t, workflowDir, "task_01.md", taskBody("pending", "Build project brain")) + writeSyncWorkflowFile(t, workflowDir, filepath.Join("adrs", "adr-001.md"), strings.Join([]string{ + "# ADR-001: Keep project knowledge durable", + "", + "## Status", + "", + "Accepted", + "", + "## Date", + "", + "2026-08-05", + "", + "## Decision", + "", + "Regenerate project knowledge from workflow artifacts.", + "", + "## Consequences", + "", + "Future workflows inherit accepted decisions.", + "", + "### Risks", + "", + "- Stale generated knowledge must be reported.", + "", + }, "\n")) + writeSyncWorkflowFile(t, workflowDir, filepath.Join("memory", "MEMORY.md"), strings.Join([]string{ + "# Workflow Memory", + "", + "## Shared Decisions", + "", + "- Project knowledge is a derived read model.", + "", + "## Shared Learnings", + "", + "- Sync is the repair path for stale knowledge.", + "", + "## Open Risks", + "", + "- Protected generated paths can leave knowledge degraded.", + "", + }, "\n")) + + first, err := Sync(context.Background(), SyncConfig{TasksDir: workflowDir}) + if err != nil { + t.Fatalf("Sync(first): %v", err) + } + if first.ProjectKnowledge == nil || first.ProjectKnowledge.Degraded { + t.Fatalf("expected a healthy project knowledge refresh, got %#v", first.ProjectKnowledge) + } + if first.ProjectKnowledge.SourceChecksum == "" { + t.Fatal("expected project knowledge source checksum") + } + + decisionsPath := filepath.Join(workspaceRoot, ".productize", "project", "decisions.md") + decisions := mustReadFile(t, decisionsPath) + for _, want := range []string{ + "Regenerate project knowledge from workflow artifacts.", + "Future workflows inherit accepted decisions.", + "Project knowledge is a derived read model.", + } { + if !strings.Contains(decisions, want) { + t.Fatalf("expected decisions.md to contain %q\n%s", want, decisions) + } + } + + constraintsPath := filepath.Join(workspaceRoot, ".productize", "project", "constraints.md") + constraints := mustReadFile(t, constraintsPath) + for _, want := range []string{ + "Stale generated knowledge must be reported.", + "Protected generated paths can leave knowledge degraded.", + } { + if !strings.Contains(constraints, want) { + t.Fatalf("expected constraints.md to contain %q\n%s", want, constraints) + } + } + + contextPath := filepath.Join(workspaceRoot, ".productize", "project", "context.md") + if contextBody := mustReadFile(t, contextPath); strings.Contains(contextBody, workspaceRoot) { + t.Fatalf("expected portable context without absolute workspace root\n%s", contextBody) + } + + stableTime := time.Date(2026, 8, 5, 10, 0, 0, 0, time.UTC) + if err := os.Chtimes(decisionsPath, stableTime, stableTime); err != nil { + t.Fatalf("Chtimes(decisions.md): %v", err) + } + + second, err := Sync(context.Background(), SyncConfig{TasksDir: workflowDir}) + if err != nil { + t.Fatalf("Sync(second): %v", err) + } + if second.ProjectKnowledge == nil || second.ProjectKnowledge.Degraded { + t.Fatalf("expected a healthy idempotent refresh, got %#v", second.ProjectKnowledge) + } + if len(second.ProjectKnowledge.Updated) != 0 { + t.Fatalf("expected no rewritten knowledge documents, got %#v", second.ProjectKnowledge.Updated) + } + if !containsSuffix(second.ProjectKnowledge.Unchanged, "decisions.md") { + t.Fatalf("expected decisions.md to be unchanged, got %#v", second.ProjectKnowledge.Unchanged) + } + info, err := os.Stat(decisionsPath) + if err != nil { + t.Fatalf("Stat(decisions.md): %v", err) + } + if !info.ModTime().Equal(stableTime) { + t.Fatalf("decisions.md modtime = %s, want unchanged %s", info.ModTime(), stableTime) + } + + writeSyncWorkflowFile(t, workflowDir, filepath.Join("adrs", "adr-001.md"), strings.Join([]string{ + "# ADR-001: Keep project knowledge durable", + "", + "## Status", + "", + "Accepted", + "", + "## Date", + "", + "2026-08-05", + "", + "## Decision", + "", + "Regenerate changed project knowledge from workflow artifacts.", + "", + "## Consequences", + "", + "Future workflows inherit the refreshed decision content.", + "", + "### Risks", + "", + "- Failed refreshes must remain observable.", + "", + }, "\n")) + writeSyncWorkflowFile(t, workflowDir, filepath.Join("memory", "MEMORY.md"), strings.Join([]string{ + "# Workflow Memory", + "", + "## Shared Decisions", + "", + "- Project knowledge remains a derived read model.", + "", + "## Shared Learnings", + "", + "- Sync promotes memory changes into future workflow context.", + "", + "## Open Risks", + "", + "- Protected generated paths can leave refreshed knowledge degraded.", + "", + }, "\n")) + + third, err := Sync(context.Background(), SyncConfig{TasksDir: workflowDir}) + if err != nil { + t.Fatalf("Sync(third after source changes): %v", err) + } + if third.ProjectKnowledge == nil || third.ProjectKnowledge.Degraded { + t.Fatalf("expected a healthy refresh after source changes, got %#v", third.ProjectKnowledge) + } + if third.ProjectKnowledge.SourceChecksum == second.ProjectKnowledge.SourceChecksum { + t.Fatal("expected source checksum to change after ADR and memory updates") + } + for _, name := range []string{"context.md", "decisions.md", "constraints.md"} { + if !containsSuffix(third.ProjectKnowledge.Updated, name) { + t.Fatalf("expected %s to update after source changes: %#v", name, third.ProjectKnowledge.Updated) + } + } + for path, want := range map[string]string{ + decisionsPath: "Regenerate changed project knowledge from workflow artifacts.", + constraintsPath: "Failed refreshes must remain observable.", + contextPath: "Sync promotes memory changes into future workflow context.", + } { + if content := mustReadFile(t, path); !strings.Contains(content, want) { + t.Fatalf("expected %s to contain refreshed content %q\n%s", path, want, content) + } + } +} + +func TestSyncRepairsProjectKnowledgeFromArchivedOnlyWorkspace(t *testing.T) { + workspaceRoot := t.TempDir() + setSyncTestHome(t) + + tasksRoot := filepath.Join(workspaceRoot, ".productize", "tasks") + archivedDir := filepath.Join(tasksRoot, "_archived", "1700000000-project-brain") + writeSyncWorkflowFile(t, archivedDir, filepath.Join("adrs", "adr-001.md"), strings.Join([]string{ + "# ADR-001: Retain archived-only knowledge", + "", + "## Status", + "", + "Accepted", + "", + "## Decision", + "", + "Sync repairs knowledge even when no active workflow remains.", + "", + }, "\n")) + writeSyncWorkflowFile(t, archivedDir, filepath.Join("memory", "MEMORY.md"), strings.Join([]string{ + "# Workflow Memory", + "", + "## Shared Decisions", + "", + "- Archived workflows remain project context.", + "", + }, "\n")) + + result, err := Sync(context.Background(), SyncConfig{RootDir: tasksRoot}) + if err != nil { + t.Fatalf("Sync(archived-only): %v", err) + } + if result.WorkflowsScanned != 0 { + t.Fatalf("WorkflowsScanned = %d, want 0 active workflows", result.WorkflowsScanned) + } + if result.ProjectKnowledge == nil || result.ProjectKnowledge.Degraded { + t.Fatalf("expected archived-only knowledge repair, got %#v", result.ProjectKnowledge) + } + decisions := mustReadFile(t, filepath.Join(workspaceRoot, ".productize", "project", "decisions.md")) + for _, want := range []string{ + "Sync repairs knowledge even when no active workflow remains.", + "Archived workflows remain project context.", + } { + if !strings.Contains(decisions, want) { + t.Fatalf("expected archived-only decisions to contain %q\n%s", want, decisions) + } + } +} + func TestSyncTaskMetadataSyncsSingleWorkflowIntoGlobalDBWithoutMutatingArtifacts(t *testing.T) { workspaceRoot := t.TempDir() setSyncTestHome(t) diff --git a/internal/daemon/transport_mappers.go b/internal/daemon/transport_mappers.go index 6572e711..dda1c7c9 100644 --- a/internal/daemon/transport_mappers.go +++ b/internal/daemon/transport_mappers.go @@ -10,6 +10,7 @@ import ( apicore "github.com/itseffi/productize/internal/api/core" corepkg "github.com/itseffi/productize/internal/core" + "github.com/itseffi/productize/internal/core/model" "github.com/itseffi/productize/internal/store/globaldb" "github.com/itseffi/productize/internal/store/rundb" eventspkg "github.com/itseffi/productize/pkg/productize/events" @@ -128,6 +129,7 @@ func transportSyncResult( out.SyncedPaths = append([]string(nil), result.SyncedPaths...) out.PrunedWorkflows = append([]string(nil), result.PrunedWorkflows...) out.Warnings = append([]string(nil), result.Warnings...) + out.ProjectKnowledge = transportProjectKnowledgeRefreshResult(result.ProjectKnowledge) return out } @@ -142,9 +144,27 @@ func transportArchiveResult(result *corepkg.ArchiveResult) apicore.ArchiveResult out.Forced = result.Forced out.CompletedTasks = result.CompletedTasks out.ResolvedReviewIssues = result.ResolvedReviewIssues + out.ProjectKnowledge = transportProjectKnowledgeRefreshResult(result.ProjectKnowledge) return out } +func transportProjectKnowledgeRefreshResult( + result *model.ProjectKnowledgeRefreshResult, +) *apicore.ProjectKnowledgeRefreshResult { + if result == nil { + return nil + } + + return &apicore.ProjectKnowledgeRefreshResult{ + Updated: append([]string{}, result.Updated...), + Unchanged: append([]string{}, result.Unchanged...), + Skipped: append([]string{}, result.Skipped...), + Warnings: append([]string{}, result.Warnings...), + SourceChecksum: result.SourceChecksum, + Degraded: result.Degraded, + } +} + func transportWorkflowOverview(payload WorkflowOverviewPayload) apicore.WorkflowOverviewPayload { var latestReview *apicore.ReviewSummary if payload.LatestReview != nil { diff --git a/internal/daemon/transport_service_test.go b/internal/daemon/transport_service_test.go index 1ccb674f..baad3780 100644 --- a/internal/daemon/transport_service_test.go +++ b/internal/daemon/transport_service_test.go @@ -2,6 +2,7 @@ package daemon import ( "context" + "encoding/json" "errors" "path/filepath" "strings" @@ -11,6 +12,7 @@ import ( "github.com/itseffi/productize/internal/api/contract" apicore "github.com/itseffi/productize/internal/api/core" corepkg "github.com/itseffi/productize/internal/core" + "github.com/itseffi/productize/internal/core/model" "github.com/itseffi/productize/internal/store/globaldb" ) @@ -409,6 +411,14 @@ func TestTransportSyncResult_ShouldMapStructuredFields(t *testing.T) { SyncedPaths: []string{"a", "b"}, PrunedWorkflows: []string{"stale"}, Warnings: []string{"warn"}, + ProjectKnowledge: &model.ProjectKnowledgeRefreshResult{ + Updated: []string{"decisions.md"}, + Unchanged: []string{"context.md"}, + Skipped: []string{"architecture.md"}, + Warnings: []string{"protected architecture.md"}, + SourceChecksum: "checksum-sync", + Degraded: true, + }, }) if result.WorkspaceID != "ws-123" || result.WorkflowSlug != "demo" { @@ -422,5 +432,52 @@ func TestTransportSyncResult_ShouldMapStructuredFields(t *testing.T) { result.PrunedWorkflows[0] != "stale" || len(result.Warnings) != 1 { t.Fatalf("unexpected sync slices: %#v", result) } + if result.ProjectKnowledge == nil || !result.ProjectKnowledge.Degraded || + result.ProjectKnowledge.SourceChecksum != "checksum-sync" || + len(result.ProjectKnowledge.Updated) != 1 || len(result.ProjectKnowledge.Unchanged) != 1 || + len(result.ProjectKnowledge.Skipped) != 1 || len(result.ProjectKnowledge.Warnings) != 1 { + t.Fatalf("unexpected sync project knowledge: %#v", result.ProjectKnowledge) + } + }) + + t.Run("Should preserve archive project knowledge", func(t *testing.T) { + t.Parallel() + + result := transportArchiveResult(&corepkg.ArchiveResult{ + Archived: 1, + ProjectKnowledge: &model.ProjectKnowledgeRefreshResult{ + Updated: []string{"decisions.md"}, + Unchanged: []string{"context.md"}, + Skipped: []string{"architecture.md"}, + Warnings: []string{"protected architecture.md"}, + SourceChecksum: "checksum-archive", + Degraded: true, + }, + }) + + if !result.Archived || result.ProjectKnowledge == nil || !result.ProjectKnowledge.Degraded || + result.ProjectKnowledge.SourceChecksum != "checksum-archive" || + len(result.ProjectKnowledge.Updated) != 1 || len(result.ProjectKnowledge.Unchanged) != 1 || + len(result.ProjectKnowledge.Skipped) != 1 || len(result.ProjectKnowledge.Warnings) != 1 { + t.Fatalf("unexpected archive result: %#v", result) + } + }) + + t.Run("Should encode empty project knowledge collections as arrays", func(t *testing.T) { + t.Parallel() + + result := transportArchiveResult(&corepkg.ArchiveResult{ + Archived: 1, + ProjectKnowledge: &model.ProjectKnowledgeRefreshResult{}, + }) + body, err := json.Marshal(result) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + for _, field := range []string{`"updated":[]`, `"unchanged":[]`, `"skipped":[]`, `"warnings":[]`} { + if !strings.Contains(string(body), field) { + t.Fatalf("transport archive JSON = %s, want %s", body, field) + } + } }) } diff --git a/openapi/productize-daemon.json b/openapi/productize-daemon.json index 2afccea0..48550b9e 100644 --- a/openapi/productize-daemon.json +++ b/openapi/productize-daemon.json @@ -215,6 +215,9 @@ "forced": { "type": "boolean" }, + "project_knowledge": { + "$ref": "#/components/schemas/ProjectKnowledgeRefreshResult" + }, "resolved_review_issues": { "type": "integer" } @@ -399,6 +402,42 @@ "required": ["document"], "type": "object" }, + "ProjectKnowledgeRefreshResult": { + "properties": { + "degraded": { + "type": "boolean" + }, + "skipped": { + "items": { + "type": "string" + }, + "type": "array" + }, + "source_checksum": { + "type": "string" + }, + "unchanged": { + "items": { + "type": "string" + }, + "type": "array" + }, + "updated": { + "items": { + "type": "string" + }, + "type": "array" + }, + "warnings": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": ["degraded", "skipped", "source_checksum", "unchanged", "updated", "warnings"], + "type": "object" + }, "ReviewDetailPayload": { "properties": { "document": { @@ -1212,6 +1251,9 @@ }, "type": "array" }, + "project_knowledge": { + "$ref": "#/components/schemas/ProjectKnowledgeRefreshResult" + }, "review_issues_upserted": { "type": "integer" }, diff --git a/skills/create-prd/SKILL.md b/skills/create-prd/SKILL.md index d73f99ca..cfc83c18 100644 --- a/skills/create-prd/SKILL.md +++ b/skills/create-prd/SKILL.md @@ -66,14 +66,21 @@ You MUST create a task for each phase and complete them in order: - Use `.productize/tasks//` as the target directory. - If `_idea.md` exists in the target directory, read it as primary context input. - If `_prd.md` already exists in the target directory, read it and operate in update mode. - - If `.productize/project/context.md`, `.productize/project/conventions.md`, or `.productize/project/decisions.md` exists, read those files as durable existing-project context before research. + - Read every existing canonical project knowledge document before research: + - `.productize/project/context.md` + - `.productize/project/conventions.md` + - `.productize/project/architecture.md` + - `.productize/project/decisions.md` + - `.productize/project/constraints.md` + - Read `.productize/project/manual.md` when present for human-authored additions that are intentionally kept outside the generated read models. + - Treat architecture, decisions, and constraints as feasibility and product-boundary context only. Do not copy technical implementation details into the PRD or turn them into implementation requirements. - If the directory does not exist, create it. - Create `.productize/tasks//adrs/` directory if it does not exist. 2. Discover context through parallel research. You MUST perform BOTH tracks before asking any questions. **Track A — Codebase exploration** (REQUIRED): - - Start with `.productize/project/context.md`, `.productize/project/conventions.md`, and `.productize/project/decisions.md` when present. + - Start with all five canonical project knowledge documents listed in step 1 when present, plus `manual.md` when present. - Search the codebase for files, patterns, and features related to the user's request. - Look for existing implementations, data models, and integration points that are relevant. - Summarize what you found in 3-5 bullet points. @@ -107,7 +114,8 @@ You MUST create a task for each phase and complete them in order: - After the user selects an approach, create an ADR for this decision: - Read `references/adr-template.md`. - Determine the next ADR number by listing existing files in `.productize/tasks//adrs/`. - - Fill the template: the selected approach as "Decision", rejected approaches as "Alternatives Considered" with their trade-offs, and outcomes as "Consequences". Set Status to "Accepted" and Date to today. + - Fill the template: the selected approach as "Decision", rejected approaches as "Alternatives Considered" with their trade-offs, and outcomes as "Consequences". Set `kind` to `product`, `status` to `accepted`, `date` to today, and `supersedes` to a YAML list of ADR references replaced by this decision (for example, `ADR-001`) or `[]`. Keep the Markdown Status and Date sections aligned with that metadata. + - When `supersedes` is non-empty, update each replaced ADR's metadata status to `superseded` and its Markdown Status to `Superseded by ADR-NNN`, naming the new ADR. - Write the ADR to `.productize/tasks//adrs/adr-NNN.md` (zero-padded 3-digit number, e.g., `adr-001.md`). 5. Draft the PRD. diff --git a/skills/create-prd/references/adr-template.md b/skills/create-prd/references/adr-template.md index 381ad3f2..23833107 100644 --- a/skills/create-prd/references/adr-template.md +++ b/skills/create-prd/references/adr-template.md @@ -1,3 +1,10 @@ +--- +kind: +status: +date: YYYY-MM-DD +supersedes: [] +--- + # ADR-XXX: [Title] ## Status @@ -46,6 +53,10 @@ YYYY-MM-DD - [List risks and mitigation strategies] +## Constraints + +- [List hard constraints established by this decision, or "None"] + ## Implementation Notes [Any specific implementation details, migration steps, or technical notes relevant to this decision.] diff --git a/skills/create-tasks/SKILL.md b/skills/create-tasks/SKILL.md index 1e6168d9..3593e815 100644 --- a/skills/create-tasks/SKILL.md +++ b/skills/create-tasks/SKILL.md @@ -21,7 +21,13 @@ Decompose requirements into detailed, actionable task files with codebase-inform - Otherwise use the built-in defaults: `frontend`, `backend`, `docs`, `test`, `infra`, `refactor`, `chore`, `bugfix`. 2. Load context. - - If `.productize/project/context.md`, `.productize/project/conventions.md`, or `.productize/project/decisions.md` exists, read those files first as durable existing-project context. + - Read every existing canonical project knowledge document first: + - `.productize/project/context.md` + - `.productize/project/conventions.md` + - `.productize/project/architecture.md` + - `.productize/project/decisions.md` + - `.productize/project/constraints.md` + - Read `.productize/project/manual.md` when present for human-authored additions that are intentionally kept outside the generated read models. - Read `_prd.md` and `_techspec.md` from `.productize/tasks//`. - Read existing ADRs from `.productize/tasks//adrs/` to understand the decision context behind requirements and design choices. - If `_techspec.md` is missing: diff --git a/skills/create-techspec/SKILL.md b/skills/create-techspec/SKILL.md index 9131c1dc..84d24e9b 100644 --- a/skills/create-techspec/SKILL.md +++ b/skills/create-techspec/SKILL.md @@ -50,7 +50,13 @@ You MUST create a task for each phase and complete them in order: ## Workflow 1. Gather context. - - If `.productize/project/context.md`, `.productize/project/conventions.md`, or `.productize/project/decisions.md` exists, read those files first as durable existing-project context. + - Read every existing canonical project knowledge document first: + - `.productize/project/context.md` + - `.productize/project/conventions.md` + - `.productize/project/architecture.md` + - `.productize/project/decisions.md` + - `.productize/project/constraints.md` + - Read `.productize/project/manual.md` when present for human-authored additions that are intentionally kept outside the generated read models. - Check for `_prd.md` in `.productize/tasks//`. If it exists, read it as the primary input. - If no PRD exists, ask the user for a description of what needs technical specification. - Read existing ADRs from `.productize/tasks//adrs/` to understand decisions already made during PRD creation. @@ -72,7 +78,8 @@ You MUST create a task for each phase and complete them in order: - For each significant decision (architecture pattern chosen, technology selected, data model approach, etc.): - Read `references/adr-template.md`. - Determine the next ADR number by listing existing files in `.productize/tasks//adrs/`. - - Fill the template: the chosen design as "Decision", rejected alternatives as "Alternatives Considered", and trade-offs as "Consequences". Set Status to "Accepted" and Date to today. + - Fill the template: the chosen design as "Decision", rejected alternatives as "Alternatives Considered", and trade-offs as "Consequences". Set `kind` to `architecture` for architectural decisions or `technical` for other implementation decisions, `status` to `accepted`, `date` to today, and `supersedes` to a YAML list of ADR references replaced by this decision (for example, `ADR-001`) or `[]`. Keep the Markdown Status and Date sections aligned with that metadata. + - When `supersedes` is non-empty, update each replaced ADR's metadata status to `superseded` and its Markdown Status to `Superseded by ADR-NNN`, naming the new ADR. - Write each ADR to `.productize/tasks//adrs/adr-NNN.md` (zero-padded 3-digit sequential number). 4. Draft the TechSpec. diff --git a/skills/create-techspec/references/adr-template.md b/skills/create-techspec/references/adr-template.md index 381ad3f2..23833107 100644 --- a/skills/create-techspec/references/adr-template.md +++ b/skills/create-techspec/references/adr-template.md @@ -1,3 +1,10 @@ +--- +kind: +status: +date: YYYY-MM-DD +supersedes: [] +--- + # ADR-XXX: [Title] ## Status @@ -46,6 +53,10 @@ YYYY-MM-DD - [List risks and mitigation strategies] +## Constraints + +- [List hard constraints established by this decision, or "None"] + ## Implementation Notes [Any specific implementation details, migration steps, or technical notes relevant to this decision.] diff --git a/skills/productize-runtime/SKILL.md b/skills/productize-runtime/SKILL.md index a9332c0f..0c2e6e59 100644 --- a/skills/productize-runtime/SKILL.md +++ b/skills/productize-runtime/SKILL.md @@ -24,7 +24,7 @@ Key characteristics: The standard development pipeline follows these phases in order. Each phase produces artifacts consumed by the next. 1. **Setup** -- `productize setup` installs the skill catalog into explicitly selected target agents plus any setup assets shipped by enabled extensions. -2. **Existing Project Adoption** (recommended for mature repos) -- `productize init existing` writes durable repo context under `.productize/project/`. +2. **Existing Project Adoption** (recommended for mature repos) -- `productize init existing` refreshes five canonical project knowledge read models under `.productize/project/`. 3. **Ideation** (optional) -- install and enable the first-party `idea-forge` extension, run `productize setup`, then use `/idea-forge` to expand a raw idea into a structured, research-backed spec at `.productize/tasks//_idea.md`. 4. **Requirements** -- `/create-prd` creates a business-focused Product Requirements Document at `.productize/tasks//_prd.md` with ADRs. 5. **Technical Design** -- `/create-techspec` translates the PRD into a technical specification at `.productize/tasks//_techspec.md` with ADRs. @@ -32,7 +32,7 @@ The standard development pipeline follows these phases in order. Each phase prod 7. **Execution** -- `productize tasks run --ide ` dispatches task files sequentially to the configured AI agent for implementation. 8. **Review** -- `/review-round` (manual AI review) or `productize reviews fetch --provider coderabbit --pr ` (external provider) produces review issue files under `reviews-NNN/`. 9. **Remediation** -- `productize reviews fix ` processes review issues, triages, fixes, and verifies each one. -10. **Archive** -- `productize archive --name ` moves fully completed workflows to `.productize/tasks/_archived/`. +10. **Archive** -- `productize archive --name ` moves fully completed workflows to `.productize/tasks/_archived/`, then refreshes project knowledge. Repeat phases 7-8 until the review is clean, then merge. @@ -83,8 +83,8 @@ For a detailed step-by-step walkthrough of each phase, read `references/workflow | `productize reviews fix` | Process review issue files | `--name`, `--round`, `--concurrent`, `--batch-size`, `--ide` | | **Utilities** | | | | `productize tasks validate` | Validate task file metadata | `--name`, `--tasks-dir`, `--format` | -| `productize sync` | Reconcile workflow artifacts into daemon `global.db` | `--name`, `--root-dir`, `--tasks-dir` | -| `productize archive` | Move daemon-eligible completed workflows to archive | `--name`, `--root-dir`, `--tasks-dir` | +| `productize sync` | Reconcile workflow artifacts into daemon `global.db`, then refresh project knowledge | `--name`, `--root-dir`, `--tasks-dir` | +| `productize archive` | Move daemon-eligible completed workflows to archive, then refresh project knowledge | `--name`, `--root-dir`, `--tasks-dir` | | `productize migrate` | Convert legacy artifacts to frontmatter | `--name`, `--dry-run`, `--reviews-dir` | | **Agent Management** | | | | `productize agents list` | List resolved reusable agents | | @@ -147,8 +147,10 @@ For detailed skill descriptions and inputs/outputs, read `references/skills-refe project/ context.md # Project context pack for future work conventions.md # Detected commands and instruction sources - architecture.md # Deterministic directory/package map - decisions.md # Promoted ADRs and durable workflow memory + architecture.md # Repository map and promoted architecture ADRs + decisions.md # Promoted durable ADR content and shared memory + constraints.md # Durable constraints and unresolved risks + manual.md # Optional human-authored additions; never generated tasks/ / # One directory per workflow _idea.md # Idea spec (from idea-forge) @@ -176,7 +178,30 @@ Global paths: - `~/.productize/agents//` -- global reusable agents (workspace overrides global) - `~/.productize/extensions/` -- user-scoped extensions - `~/.productize/runs//` -- daemon-managed run artifacts and persisted exec sessions -- `~/.productize/global.db` -- daemon workspace, workflow, task, and review catalog +- `~/.productize/db/global.db` -- daemon workspace, workflow, task, and review catalog + +## Project Knowledge Lifecycle + +`context.md`, `conventions.md`, `architecture.md`, `decisions.md`, and +`constraints.md` are canonical generated read models. Productize owns and +rewrites them only when they carry the `productize:project-knowledge` marker. +Keep deliberate human-authored additions in `.productize/project/manual.md`. + +- `productize init existing` explicitly creates or refreshes the read models. +- A successful `productize sync` catalogs authored workflow artifacts and then + refreshes project knowledge. +- `productize archive` moves an eligible workflow first and refreshes afterward, + preserving durable ADR content and shared memory from archived workflows. + +Idea, PRD, TechSpec, task-generation, and task-execution workflows read all five +canonical documents when present, plus `manual.md` when present. Product-facing +artifacts use architecture and constraints only for feasibility and boundaries. + +Sync or archive can succeed while the derived refresh reports `degraded: true`. +Text output exposes the status, checksum, skipped paths, and warnings; JSON uses +the optional `project_knowledge` object with `updated`, `unchanged`, `skipped`, +`warnings`, `source_checksum`, and `degraded`. Resolve the warnings and run +`productize sync` again to repair stale knowledge. ## Configuration diff --git a/skills/productize-runtime/references/cli-reference.md b/skills/productize-runtime/references/cli-reference.md index fdc0e741..bbd2fe96 100644 --- a/skills/productize-runtime/references/cli-reference.md +++ b/skills/productize-runtime/references/cli-reference.md @@ -36,9 +36,11 @@ productize init existing ../my-app --dry-run productize init existing --format json ``` -Writes `.productize/project/context.md`, `conventions.md`, -`architecture.md`, and `decisions.md`. The command is deterministic and does -not invoke an AI model. +Creates or refreshes `.productize/project/context.md`, `conventions.md`, `architecture.md`, +`decisions.md`, and `constraints.md`. These five files are generated read models +owned only when they carry the `productize:project-knowledge` marker. Keep +human-authored additions in `.productize/project/manual.md`, which refresh does +not overwrite. The command is deterministic and does not invoke an AI model. ### `productize setup` @@ -194,34 +196,49 @@ productize tasks validate --name my-feature ### `productize sync` -Reconcile authored workflow artifacts under `.productize/tasks/` into the daemon `global.db` catalog. +Reconcile authored workflow artifacts under `.productize/tasks/` into +`~/.productize/db/global.db`, then refresh canonical project knowledge. | Flag | Type | Default | Description | | --- | --- | --- | --- | | `--root-dir` | string | `.productize/tasks` | Workflow root to scan | | `--name` | string | | Restrict to one workflow | | `--tasks-dir` | string | | Restrict to one directory | +| `--format` | string | text | Output format: text or json | ``` productize sync productize sync --name my-feature ``` +Text output reports `Project knowledge status: current|degraded`, the source +checksum when present, and every updated, unchanged, skipped, or warning entry. +JSON includes the optional `project_knowledge` object with `updated`, +`unchanged`, `skipped`, `warnings`, `source_checksum`, and `degraded`. Resolve +warnings and run sync again to retry a degraded refresh. + ### `productize archive` -Move workflows that synced daemon state marks as complete to `.productize/tasks/_archived/-`. +Move workflows that synced daemon state marks as complete to +`.productize/tasks/_archived/-`, then refresh canonical project +knowledge from active and archived workflows. | Flag | Type | Default | Description | | --- | --- | --- | --- | | `--root-dir` | string | `.productize/tasks` | Workflow root to scan | | `--name` | string | | Restrict to one workflow | | `--tasks-dir` | string | | Restrict to one directory | +| `--format` | string | text | Output format: text or json | ``` productize archive productize archive --name my-feature ``` +Archive can succeed while its derived knowledge refresh is degraded. Text and +JSON report the same project-knowledge status and fields as `productize sync`; +the next sync retries and repairs stale knowledge after warnings are resolved. + ### `productize migrate` Convert legacy XML-tagged artifacts to YAML frontmatter format. diff --git a/skills/productize-runtime/references/skills-reference.md b/skills/productize-runtime/references/skills-reference.md index e3d1e748..8e0e988c 100644 --- a/skills/productize-runtime/references/skills-reference.md +++ b/skills/productize-runtime/references/skills-reference.md @@ -3,6 +3,12 @@ Detailed catalog of bundled Productize workflow skills plus the bundled Productize skill layer. +Lifecycle authoring skills read `.productize/project/context.md`, +`conventions.md`, `architecture.md`, `decisions.md`, and `constraints.md` when +present, plus the optional human-authored `manual.md`. Idea and PRD skills use +technical knowledge only to assess feasibility and constraints; they do not copy +implementation detail into product artifacts. + --- ## idea-forge diff --git a/skills/productize-runtime/references/workflow-guide.md b/skills/productize-runtime/references/workflow-guide.md index 2c0d6c11..cc13e57d 100644 --- a/skills/productize-runtime/references/workflow-guide.md +++ b/skills/productize-runtime/references/workflow-guide.md @@ -18,11 +18,19 @@ docs, conventions, or prior Productize workflows. 1. Run `productize init existing` from the workspace root, or pass an explicit path. 2. Review generated docs under `.productize/project/`. -3. Re-run when repo structure, commands, conventions, ADRs, or shared workflow memory changes. +3. Productize also refreshes these documents after a successful `productize sync` + and after `productize archive` moves a workflow. -**Output:** `context.md`, `conventions.md`, `architecture.md`, and `decisions.md`. +**Output:** `context.md`, `conventions.md`, `architecture.md`, `decisions.md`, +and `constraints.md`. -The command is deterministic and does not invoke an AI model. +The command is deterministic and does not invoke an AI model. These five files +are generated read models owned only when they carry the +`productize:project-knowledge` marker. Put human-authored additions in +`.productize/project/manual.md`; refresh never owns that file. Every lifecycle +authoring skill reads all five generated files when present, plus `manual.md` +when present. Product-facing idea and PRD artifacts treat technical knowledge as +feasibility and constraint context rather than implementation content. ## Phase 1: Ideation (Optional) @@ -89,7 +97,9 @@ Install flow: `productize ext install --yes itseffi/productize --remote github - 1. Productize reads task files from `.productize/tasks//` in order, respecting dependencies. 2. The CLI auto-starts the home-scoped daemon when needed and starts the run through daemon transport. -3. For each pending task, Productize constructs a prompt including the task spec, PRD, TechSpec, ADRs, and workflow memory. +3. For each pending task, Productize constructs a prompt including the five + canonical project knowledge documents, task spec, PRD, TechSpec, ADRs, and + workflow memory. 4. The configured ACP runtime executes the task using the `execute-task` skill. 5. Each task: read spec -> implement -> validate with `final-verify` -> update tracking -> optional commit. 6. Workflow memory is maintained across tasks via `workflow-memory`. @@ -143,6 +153,17 @@ Moves fully completed workflows from `.productize/tasks//` to `.productize **Eligibility:** Run `productize sync` first. Archive eligibility is computed from synced daemon state: all task items must be completed and all synced review issues must be resolved. +After the move, Productize refreshes project knowledge from active and archived +ADRs and durable shared workflow memory. Accepted, deprecated, and superseded +ADRs are promoted; proposed ADRs are excluded. This preserves durable decisions +for future workflows. + +Sync or archive can still succeed when its derived knowledge refresh reports a +degraded result. Text output prints protected/skipped paths and warnings. JSON +includes an optional `project_knowledge` object with `updated`, `unchanged`, +`skipped`, `warnings`, `source_checksum`, and `degraded`. Resolve the warning and +run `productize sync` again to retry and repair stale knowledge. + ## Ad Hoc Execution **Command:** `productize exec [prompt]` @@ -172,5 +193,12 @@ The `workflow-memory` skill maintains two tiers of context during task execution ADRs are created during ideation, PRD, and TechSpec phases to document significant decisions. - **Location:** `.productize/tasks//adrs/adr-NNN.md` (zero-padded 3-digit numbers). -- **Structure:** Status, Date, Context, Decision, Alternatives Considered, Consequences. +- **Structure:** YAML metadata (`kind`, `status`, `date`, `supersedes`) followed + by the human-readable Status, Date, Context, Decision, Alternatives Considered, + and Consequences sections retained for backward compatibility. +- **Metadata:** `kind` is `architecture`, `product`, `scope`, or `technical`; + `status` is `proposed`, `accepted`, `deprecated`, or `superseded`; + `supersedes` is a YAML list of ADR references such as `ADR-001`. +- **Identity:** Promotion qualifies the ADR number with its workflow, so ADRs in + different workflows remain distinct in canonical project knowledge. - **Referenced by:** PRDs, TechSpecs, and idea specs include an "Architecture Decision Records" section linking to all ADRs. diff --git a/skills/productize/SKILL.md b/skills/productize/SKILL.md index 513056ab..a2fcfa44 100644 --- a/skills/productize/SKILL.md +++ b/skills/productize/SKILL.md @@ -148,11 +148,13 @@ Use the smallest entry point that owns the cadence: decision mode. - If the user asks `/productize build `, use the operator build route. Read `.productize/project/context.md`, `.productize/project/conventions.md`, - and `.productize/project/decisions.md` when present. Inspect + `.productize/project/architecture.md`, `.productize/project/decisions.md`, and + `.productize/project/constraints.md` when present. Read + `.productize/project/manual.md` when present for human-authored additions. Inspect `.productize/tasks/` for existing PRD, TechSpec, task, and review artifacts. Return the required route-plan contract below and ask whether to approve or edit the route before mutating files or running agents. - - If the user asks to adopt, onboard, or initialize Productize for an existing repository, route to `/productize adopt`: run `productize init existing`, read `.productize/project/context.md`, `.productize/project/conventions.md`, and `.productize/project/decisions.md`, then recommend the next workflow route. + - If the user asks to adopt, onboard, or initialize Productize for an existing repository, route to `/productize adopt`: run `productize init existing`, read `.productize/project/context.md`, `.productize/project/conventions.md`, `.productize/project/architecture.md`, `.productize/project/decisions.md`, and `.productize/project/constraints.md`, plus `.productize/project/manual.md` when present, then recommend the next workflow route. 2. Route to the narrowest Productize skill that can produce the artifact or build step. If the request spans stages, sequence the skills and explain the order. 3. Use existing context first: attached docs, repo files, meeting notes, research, diff --git a/test/skills_bundle_test.go b/test/skills_bundle_test.go index ea3e96a2..01a045c5 100644 --- a/test/skills_bundle_test.go +++ b/test/skills_bundle_test.go @@ -348,6 +348,170 @@ func TestSharedReferenceFilesAreIdentical(t *testing.T) { } } +func TestLifecycleConsumersReadCanonicalProjectKnowledge(t *testing.T) { + t.Parallel() + + root := repoRoot(t) + consumerPaths := []string{ + "skills/create-prd/SKILL.md", + "skills/create-techspec/SKILL.md", + "skills/create-tasks/SKILL.md", + "skills/productize/SKILL.md", + "extensions/idea-forge/skills/idea-forge/SKILL.md", + "agents/productize-operator/AGENT.md", + } + canonicalPaths := []string{ + ".productize/project/context.md", + ".productize/project/conventions.md", + ".productize/project/architecture.md", + ".productize/project/decisions.md", + ".productize/project/constraints.md", + } + + for _, relativePath := range consumerPaths { + relativePath := relativePath + t.Run(relativePath, func(t *testing.T) { + t.Parallel() + + content, err := os.ReadFile(filepath.Join(root, relativePath)) + if err != nil { + t.Fatalf("read %s: %v", relativePath, err) + } + for _, canonicalPath := range canonicalPaths { + if !strings.Contains(string(content), canonicalPath) { + t.Errorf("expected %s to name canonical project knowledge file %s", relativePath, canonicalPath) + } + } + if !strings.Contains(string(content), ".productize/project/manual.md") { + t.Errorf("expected %s to read optional human-authored project knowledge", relativePath) + } + }) + } +} + +func TestProjectKnowledgeDocumentationUsesCanonicalContract(t *testing.T) { + t.Parallel() + + root := repoRoot(t) + docPaths := []string{ + "README.md", + "docs/workflow.md", + "docs/cli-reference.md", + "skills/productize-runtime/SKILL.md", + "skills/productize-runtime/references/workflow-guide.md", + "skills/productize-runtime/references/cli-reference.md", + "skills/productize-runtime/references/skills-reference.md", + } + canonicalNames := []string{ + "context.md", + "conventions.md", + "architecture.md", + "decisions.md", + "constraints.md", + "manual.md", + } + + for _, relativePath := range docPaths { + relativePath := relativePath + t.Run(relativePath, func(t *testing.T) { + t.Parallel() + + content, err := os.ReadFile(filepath.Join(root, relativePath)) + if err != nil { + t.Fatalf("read %s: %v", relativePath, err) + } + text := string(content) + for _, canonicalName := range canonicalNames { + if !strings.Contains(text, canonicalName) { + t.Errorf("expected %s to document canonical project knowledge file %s", relativePath, canonicalName) + } + } + for _, forbidden := range []string{"inventory.md", "~/.productize/global.db"} { + if strings.Contains(text, forbidden) { + t.Errorf("expected %s to omit stale project knowledge reference %q", relativePath, forbidden) + } + } + }) + } +} + +func TestProjectKnowledgeCLIReferencesDocumentRefreshResult(t *testing.T) { + t.Parallel() + + root := repoRoot(t) + paths := []string{ + "README.md", + "docs/cli-reference.md", + "skills/productize-runtime/SKILL.md", + "skills/productize-runtime/references/cli-reference.md", + } + requiredFields := []string{ + "project_knowledge", + "updated", + "unchanged", + "skipped", + "warnings", + "source_checksum", + "degraded", + } + + for _, relativePath := range paths { + relativePath := relativePath + t.Run(relativePath, func(t *testing.T) { + t.Parallel() + + content, err := os.ReadFile(filepath.Join(root, relativePath)) + if err != nil { + t.Fatalf("read %s: %v", relativePath, err) + } + for _, requiredField := range requiredFields { + if !strings.Contains(string(content), requiredField) { + t.Errorf("expected %s to document project knowledge result field %s", relativePath, requiredField) + } + } + }) + } +} + +func TestADRTemplatesIncludePromotionMetadataAndLegacySections(t *testing.T) { + t.Parallel() + + root := repoRoot(t) + templatePaths := []string{ + "skills/create-prd/references/adr-template.md", + "skills/create-techspec/references/adr-template.md", + "extensions/idea-forge/skills/idea-forge/references/adr-template.md", + } + requiredSnippets := []string{ + "kind: ", + "status: ", + "date: YYYY-MM-DD", + "supersedes: []", + "## Status", + "## Date", + "## Decision", + "## Consequences", + "## Constraints", + } + + for _, relativePath := range templatePaths { + relativePath := relativePath + t.Run(relativePath, func(t *testing.T) { + t.Parallel() + + content, err := os.ReadFile(filepath.Join(root, relativePath)) + if err != nil { + t.Fatalf("read %s: %v", relativePath, err) + } + for _, requiredSnippet := range requiredSnippets { + if !strings.Contains(string(content), requiredSnippet) { + t.Errorf("expected %s to include %q", relativePath, requiredSnippet) + } + } + }) + } +} + func snapshotTree(t *testing.T, root string) map[string]string { t.Helper()