-Productize drives the product development lifecycle on top of the AI coding agents you
-already use: idea, PRD, tech spec, codebase-informed task breakdown, multi-agent
-execution, and review remediation. It ships 250+ routed product, design, QA, growth,
-and engineering skills plus review gates, and keeps every artifact as editable
-markdown in your repo while the daemon owns execution state — runs, streams, history,
-and hooks — under `~/.productize`.
-## What It Does
+# Productize
-- Author PRDs, TechSpecs, ADRs, tasks, reviews, and memory in `.productize/tasks//`.
-- Sync authored markdown into the daemon catalog at `~/.productize/db/global.db`.
-- Run ACP-capable agents through daemon-owned runs under `~/.productize/runs//`.
-- Reattach to live or completed work with snapshot-plus-stream observation.
-- Route work through product skills, reusable agents, review gates, and extension hooks.
+**Drive the full lifecycle of AI-assisted development — from idea to merged PR — with one CLI and the agent you already use.**
-## Why It Exists
+Productize installs a catalog of hundreds of battle-tested *skills* into your AI coding agent (Claude Code, Codex, Cursor, and more), then orchestrates the work: turn an idea into a PRD, a PRD into a tech spec, a tech spec into tasks, execute those tasks, and remediate code-review feedback — all reproducibly, from the command line. Every artifact stays as editable Markdown in your repo; the daemon owns execution state — runs, streams, and history — under `~/.productize`.
-A coding prompt gets you a diff. Shipping a product needs the work to hold together
-across many runs and many agents:
+No UI. No lock-in. Your agent does the thinking; Productize runs the lifecycle.
-- context survives between runs instead of restarting from scratch
-- long-running work can be supervised, reconnected, and replayed
-- review feedback becomes a tracked fix loop, not a copy-paste chore
-- task files, run logs, transcripts, and status stay in sync
-- product, design, QA, DX, metrics, release, and growth gates apply judgment — not just coding prompts
+```bash
+npm install -g @productize/cli # or: brew install --cask itseffi/productize/productize
+productize setup # install the skill catalog into your agent
+```
-Productize handles this: it runs the agents, persists run state, routes work to the
-right skill, and keeps the artifacts in your repo.
+---
-## Core Model
+## Why Productize
-| Layer | Lives In | Owns |
-| ----- | -------- | ---- |
-| `WORKFLOW` | `.productize/tasks//` | PRDs, TechSpecs, ADRs, task files, review issues, memory |
-| `RUNTIME` | Productize daemon | workspace registry, validation, run scheduling, streams, hooks |
-| `RUNS` | `~/.productize/runs//` | events, transcript projections, job state, token usage, integrity, terminal status |
+A coding prompt gets you a diff. Shipping a product needs the work to hold together across many runs and many agents. Productize makes the *process* a first-class artifact:
-Snapshots are rebuilt from persisted run data. Workspace markdown remains the
-authoring surface; the daemon owns execution state.
+- **A repeatable lifecycle** — `init existing → create-prd → create-techspec → create-tasks → run → review → archive`, each stage producing a versioned file under `.productize/`.
+- **Works with the agent you have** — Claude Code, Codex, Cursor, Droid, OpenCode, Gemini, Copilot, and Pi. Pick per-run with `--ide` and `--model`.
+- **Skills, not prompts** — a large catalog of reusable skills and review gates, installed once and invoked by name inside your editor.
+- **Headless orchestration** — a background daemon runs tasks and review-fix loops over the Agent Client Protocol (ACP), so long jobs survive your terminal and can be reattached and replayed.
+- **MIT licensed**, single static binary, zero runtime dependencies.
-## Quick Start
+---
-Install Productize:
+## Quickstart
+
+### 1. Install
```bash
-brew install --cask itseffi/productize/productize
-# or
+# npm (any platform, Node 18+)
npm install -g @productize/cli
-# or
+
+# Homebrew (macOS)
+brew install --cask itseffi/productize/productize
+
+# Go (build from source)
go install github.com/itseffi/productize/cmd/productize@latest
```
-Install Productize skills and reusable agents into your local AI tools:
+### 2. Install the skills into your agent
```bash
productize setup
```
-Run a workflow:
+`setup` detects your installed AI tools and installs the full skill catalog into each — it supports **40+ agents and editors** (Claude Code, Codex, Cursor, Droid, OpenCode, Pi, Gemini, Copilot, Windsurf, Amp, Continue, Goose, Roo Code, Cline, and more). Installing skills is separate from *executing* through a runtime: to run `tasks run`, `reviews fix`, or `exec` you need an ACP-capable runtime (see the table below). Common options:
```bash
-productize sync --name user-auth
-productize daemon start
-productize daemon status
-productize tasks validate --name user-auth
-productize tasks run user-auth --ide claude
-productize runs attach
-productize runs watch
+productize setup --list # show everything that would be installed
+productize setup --core-only # install only the core lifecycle skills
+productize setup --global # install for all projects (~/.productize) instead of just this one
+productize setup --agent claude --agent codex --yes
```
-Close a review loop:
+### 3. Adopt existing project context
+
+For an existing repo, generate durable project knowledge before asking an agent to plan new work:
```bash
-productize reviews fetch user-auth --provider coderabbit --pr 42
-productize reviews fix user-auth --ide claude --concurrent 2 --batch-size 3
-productize reviews watch user-auth
+productize init existing
```
-`productize tasks run` syncs the workflow before starting the daemon-owned run,
-so explicit `productize sync` is useful for inspection and reconciliation but is
-not required before every run.
+This writes `.productize/project/` docs for inventory, context, conventions, architecture, and promoted decisions. The command is deterministic and does not call a model.
-## Runtime Flow
+### 4. Run the lifecycle
-
-
-
+Inside your AI agent (e.g. Claude Code), invoke the lifecycle skills in order. Each writes its output under `.productize/tasks//`:
-## Productize-Specific Features
+| Step | Skill (run in your agent) | Produces |
+|------|---------------------------|----------|
+| 1. Requirements | `create-prd` | `_prd.md` + `adrs/` |
+| 2. Technical design | `create-techspec` | `_techspec.md` |
+| 3. Task breakdown | `create-tasks` | `_tasks.md` |
-- **Daemon-owned run state.** The daemon owns workspace registration, run lifecycle, run databases, health, metrics, and attach/watch streams under `~/.productize`.
-- **Snapshot plus stream reconnect.** `runs attach` and `runs watch` reconnect from persisted state, then continue through the live run stream.
-- **Markdown-authored workflows.** Product work remains diffable and editable in `.productize/tasks//`; the daemon stores indexes and run state.
-- **Routed skill catalog.** `productize setup` installs the bundled skill catalog plus extension-provided skills into supported agents and editors.
-- **Reusable agents.** Package prompts, runtime defaults, and optional agent-local MCP servers under `.productize/agents//` or `~/.productize/agents//`.
-- **Review normalization.** CodeRabbit and extension-backed providers normalize feedback into markdown issue files that `reviews fix` can triage and resolve.
-- **Review/fix/watch loops.** `reviews watch` waits for provider feedback, imports actionable rounds, starts child fix runs, and can optionally push committed fixes.
-- **Extension hooks.** Executable extensions can observe lifecycle events, mutate prompts, inject plan sources, modify agent sessions, gate retries, ship skills or reusable agents, and register review providers.
+Then orchestrate execution and review from the CLI:
-## Skills And Gates
-
-Productize routes product jobs to skills instead of asking every agent to
-improvise the process.
-
-Core workflow skills include:
+```bash
+productize tasks run # execute the task list via the daemon
+productize reviews fetch --pr 123 # pull PR review feedback
+productize reviews fix # auto-remediate review issues
+productize archive # file away completed work
+```
-| Skill | Owns |
-| ----- | ---- |
-| `create-prd` | product requirements with ADRs |
-| `create-techspec` | technical design and architecture exploration |
-| `create-tasks` | codebase-informed task decomposition |
-| `execute-task` | implementation, validation, status, and handoff |
-| `workflow-memory` | cross-run context and task-local memory |
-| `review-round` | structured code review issues |
-| `fix-reviews` | issue triage, fixes, verification, and provider resolution |
-| `final-verify` | evidence gate before completion claims |
+That's the whole loop. The rest of this README is reference.
-Product gates include engineering, design, QA, DX, docs, release, metrics,
-communications, and growth review skills. The broader Productize catalog covers
-strategy, research, design, analytics, finance, operations, experimentation,
-go-to-market, and AI product execution.
+---
-Optional first-party ideation lives in the `idea-forge` extension:
+## How it works
-```bash
-productize ext install --yes itseffi/productize --remote github --ref main --subdir extensions/idea-forge
-productize ext enable idea-forge
-productize setup
+```
+ you, in your AI agent productize CLI + daemon
+ ┌──────────────────────────────┐ ┌──────────────────────────────┐
+ │ create-prd │ │ │
+ │ create-techspec ───────────┼──────▶│ .productize/tasks// │
+ │ create-tasks │ write │ _prd.md, _techspec.md, │
+ └──────────────────────────────┘ files │ _tasks.md, reviews-NNN/ │
+ │ │
+ │ tasks run ─┐ │
+ │ reviews fix ├─▶ ACP runtime│
+ │ reviews watch┘ (your agent)│
+ └──────────────────────────────┘
```
-## Supported Agents
+- **Skills** are the lifecycle knowledge, installed into your agent by `productize setup`. You invoke them inside the editor.
+- **The CLI** owns orchestration, validation, and review automation. It does not call the model directly for lifecycle authoring — your agent does.
+- **The daemon** is a home-scoped singleton on a unix socket (`~/.productize/daemon/daemon.sock`). It runs tasks and review-fix loops, persists run state to SQLite, and is started/stopped automatically. You rarely touch it.
-Productize executes through ACP-capable runtimes:
+---
-| Runtime | `--ide` |
-| ------- | ------- |
-| Claude Code | `claude` |
-| Codex | `codex` |
-| GitHub Copilot | `copilot` |
-| Cursor | `cursor-agent` |
-| Droid | `droid` |
-| OpenCode | `opencode` |
-| Pi | `pi` |
-| Gemini | `gemini` |
+## Core concepts
-`productize setup` can install skills into 44 agents and editors, including
-Claude Code, Codex, Cursor, Droid, OpenCode, Pi, Gemini CLI, GitHub Copilot,
-Windsurf, Amp, Continue, Goose, Roo Code, Augment, Kiro CLI, Cline, and more.
+### Skills & the catalog
-Execution runtimes are separate from skill installation. To run `productize
-exec`, `productize tasks run`, or `productize reviews fix`, install the
-ACP-capable runtime or adapter for the `--ide` you choose.
+Skills are self-contained Markdown bundles (`SKILL.md` + optional `references/`, `templates/`, `agents/`). Productize ships hundreds of them, embedded in the binary. `setup` resolves an **effective catalog** (bundled skills + any extensions, with conflicts resolved core-first) and writes it to `~/.productize/catalog/skills.json` (or `.productize/catalog/skills.json` for a single project).
-## Authoring Details
+Skills are organized by a `tier:` frontmatter field:
-Each artifact type carries its own YAML frontmatter: task files, review issues,
-skills (`SKILL.md`), reusable agents (`AGENT.md`), and ADRs all have distinct fields.
-Task files use:
+- **Core** — the spine of the workflow: lifecycle skills (`create-prd`, `create-techspec`, `create-tasks`, `execute-task`, `review-round`, `fix-reviews`, `final-verify`, `workflow-memory`), the review gates (`productize-product-review`, `-design-review`, `-eng-review`, `-qa`, `-dx-review`, `-comms-review`, `-release`, `-docs`), and the top-level orchestration skills (`productize`, `productize-operate`, `productize-grow`, …). Installed even with `--core-only`.
+- **Tactical** — the rest of the catalog: domain advisors and analyses (competitive analysis, A/B-test design, and many more). Installed by default; opt out with `--no-tactical`.
-```md
----
-status: pending
-title: Add task validation preflight to tasks run
-type: backend
-complexity: medium
-dependencies:
- - task_02
----
-```
+See [docs/skills.md](docs/skills.md).
-`type` must come from `[tasks].types` in `.productize/config.toml` or the built-in
-defaults: `frontend`, `backend`, `docs`, `test`, `infra`, `refactor`, `chore`,
-and `bugfix`.
+### Reusable agents
-Validate task files at any time:
+Beyond skills, Productize ships **reusable agents** — named, purpose-built sub-agents you can inspect and invoke. Manage them with:
```bash
-productize tasks validate --name user-auth
+productize agents list
+productize agents inspect
```
-If you have older XML-tagged artifacts, run:
+See [docs/reusable-agents.md](docs/reusable-agents.md).
-```bash
-productize migrate
-```
+### 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:
+
+| `--ide` | Agent | Default model |
+|---------|-------|---------------|
+| `codex` | Codex *(default)* | `gpt-5.5` |
+| `claude` | Claude Code | `opus` |
+| `cursor-agent` | Cursor | `composer-1` |
+| `droid` | Droid | *(runtime default)* |
+| `opencode` | OpenCode | `anthropic/claude-opus-4-6` |
+| `gemini` | Gemini | `gemini-2.5-pro` |
+| `copilot` | GitHub Copilot | `claude-sonnet-4.6` |
+| `pi` | Pi | `anthropic/claude-opus-4-6` |
+
+Override per run: `productize tasks run --ide claude --model opus`.
-## Ad Hoc Exec
+### Workspaces & the daemon
-Use `productize exec` for one prompt through the same ACP-backed execution stack
-without creating a full workflow first.
+The daemon tracks **workspaces** (registered project directories) and persists run state to `~/.productize/db/global.db`. Workspaces are registered lazily, but you can manage them explicitly:
```bash
-productize exec "Summarize the current repository changes"
-productize exec --prompt-file prompt.md
-cat prompt.md | productize exec --format json
-productize exec --persist "Review the latest changes"
+productize daemon status
+productize workspaces list
+productize runs watch # stream a running job
```
-Persisted exec runs store resumable state under `~/.productize/runs//`:
+---
-```text
-~/.productize/runs//run.db
-~/.productize/runs//run.json
-~/.productize/runs//events.jsonl
-~/.productize/runs//turns/0001/prompt.md
-~/.productize/runs//turns/0001/result.json
-```
+## Command reference
+
+| Command | What it does |
+|---------|--------------|
+| `productize init existing` | Adopt an existing repo into `.productize/project/` knowledge docs. |
+| `productize setup` | Install the skill catalog and reusable agents into your AI agent(s). |
+| `productize exec [prompt]` | Run one ad-hoc prompt through an ACP runtime (headless). |
+| `productize tasks run ` | Execute a task workflow via the daemon. |
+| `productize tasks validate` | Validate task-workflow metadata. |
+| `productize reviews fetch ` | Import PR/review feedback into a review round. |
+| `productize reviews fix ` | Auto-remediate issues in a review round. |
+| `productize reviews watch ` | Continuously fetch + fix review rounds until clean. |
+| `productize reviews list \| show` | Inspect review rounds and issues. |
+| `productize runs watch \| attach \| purge` | Stream or clean up persisted runs. |
+| `productize agents list \| inspect` | Discover and inspect reusable agents. |
+| `productize ext ...` | Install, enable, and manage extensions. |
+| `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 upgrade` | Update the CLI to the latest release. |
+
+Run `productize --help` for full flags, or see the [CLI reference](docs/cli-reference.md).
-`productize exec` uses the same config merge rule as the rest of the CLI:
+---
-```text
-flags > workspace [exec] > workspace [defaults] > global [exec] > global [defaults] > built-in defaults
+## Configuration
+
+Productize reads TOML from two locations; the project file overrides the global one:
+
+- **Global:** `~/.productize/config.toml`
+- **Project:** `.productize/config.toml`
+
+```toml
+[defaults]
+ide = "codex" # default agent backend
+model = "gpt-5.5" # default model
+output_format = "text" # text | json | raw-json
+reasoning_effort = "medium"
+access_mode = "full"
+auto_commit = false
+
+[tasks.run]
+include_completed = false
+# Route certain task types to a specific agent/model:
+task_runtime_rules = [
+ { type = "frontend", ide = "codex", model = "gpt-5.5" },
+]
+
+[fetch_reviews]
+provider = "coderabbit"
+
+[watch_reviews]
+max_rounds = 5
+poll_interval = "30s"
+auto_push = false
+until_clean = false
+
+[runs]
+default_attach_mode = "auto" # auto | stream | detach
+keep_terminal_days = 7
```
-## Reusable Agents
+See [docs/configuration.md](docs/configuration.md) for every key.
+
+### Filesystem layout
+
+```
+.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/
+├── tasks//
+│ ├── _prd.md
+│ ├── _techspec.md
+│ ├── _tasks.md
+│ ├── reviews-001/
+│ └── adrs/
+├── catalog/skills.json
+└── tasks/_archived/
+```
-Reusable agents are filesystem bundles discovered from two scopes:
+---
-- workspace: `.productize/agents//`
-- global: `~/.productize/agents//`
+## Extending Productize
-Each agent contains a required `AGENT.md` and optional `mcp.json`. Run them with:
+- **Write a skill** — drop a `SKILL.md` (with `name`, `description`, and `tier:` frontmatter) into a skill directory; add `references/` or `templates/` as needed.
+- **Write a reusable agent** — define it under `agents/` and validate with `productize agents inspect`.
+- **Package an extension** — bundle skills and agents and install from a git URL or path:
-```bash
-productize agents list
-productize agents inspect reviewer
-productize exec --agent reviewer "Review the staged changes"
-```
+ ```bash
+ productize ext install
+ productize ext doctor
+ ```
-## Extensions
+See [docs/extensibility/](docs/extensibility/index.md) for the authoring guide.
-Extensions are JSON-RPC subprocess plugins. They can observe or modify runtime
-behavior without rebuilding Productize.
+---
-```bash
-productize ext list
-productize ext install
-productize ext enable
-productize ext doctor
-```
+## Documentation
-Extension SDKs:
+- [CLI reference](docs/cli-reference.md) — every command and flag
+- [Configuration](docs/configuration.md) — every config key and path
+- [The lifecycle workflow](docs/workflow.md) — PRD → tasks → review, end to end
+- [Skills & the catalog](docs/skills.md) — authoring, tiers, and the effective catalog
+- [Reusable agents](docs/reusable-agents.md)
+- [Extensions](docs/extensibility/index.md) — build and ship extensions
+- [Events](docs/events.md) — the run event stream and schema
+- [Run reader library](docs/reader-library.md) — read run state programmatically
-- [Go SDK](sdk/extension/)
-- [TypeScript SDK](sdk/extension-sdk-ts/)
+---
-## Development
+## License
-```bash
-make verify # Full pipeline: fmt, lint, test, build
-make fmt # Format code
-make lint # Lint with zero tolerance
-make test # Tests with race detector
-make build # Compile binary
-make deps # Install development dependencies
-make tidy # Tidy Go modules
-```
+MIT — see [LICENSE](LICENSE).
diff --git a/docs/cli-reference.md b/docs/cli-reference.md
new file mode 100644
index 00000000..06d2dd3a
--- /dev/null
+++ b/docs/cli-reference.md
@@ -0,0 +1,277 @@
+# CLI Reference
+
+This page keeps the long command tables out of the root README.
+
+## `productize init existing`
+
+Adopt a mature repository into Productize project knowledge.
+
+```bash
+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
+creating PRDs, TechSpecs, or tasks in an existing codebase.
+
+| Flag | Default | Description |
+| ---- | ------- | ----------- |
+| `--dry-run` | `false` | Preview generated project knowledge without writing files |
+| `--force` | `false` | Overwrite existing unmarked project knowledge files |
+| `--format` | `text` | Output format: `text` or `json` |
+
+Generated files:
+
+- `.productize/project/inventory.md`
+- `.productize/project/context.md`
+- `.productize/project/conventions.md`
+- `.productize/project/architecture.md`
+- `.productize/project/decisions.md`
+
+## `productize setup`
+
+Install Productize skills and reusable agents.
+
+```bash
+productize setup [flags]
+```
+
+| Flag | Default | Description |
+| ---- | ------- | ----------- |
+| `--agent`, `-a` | | Target agent name, repeatable |
+| `--skill`, `-s` | | Skill name to install, repeatable |
+| `--global`, `-g` | `false` | Install to user directory instead of project |
+| `--copy` | `false` | Copy files instead of symlinking |
+| `--list`, `-l` | `false` | List setup assets without installing |
+| `--yes`, `-y` | `false` | Skip confirmation prompts |
+| `--core-only` | `false` | Install only core workflow, lifecycle, and gate skills |
+| `--no-tactical` | `false` | Alias for `--core-only` |
+| `--all` | `false` | Deprecated: setup installs all skills by default; also skips prompts |
+
+## `productize upgrade`
+
+Upgrade Productize to the latest release.
+
+```bash
+productize upgrade
+```
+
+Package-manager installs print the command to run. Direct binary installs
+perform an in-place self-update.
+
+## `productize migrate`
+
+Convert legacy XML-tagged artifacts to frontmatter.
+
+```bash
+productize migrate [flags]
+```
+
+| Flag | Default | Description |
+| ---- | ------- | ----------- |
+| `--root-dir` | `.productize/tasks` | Workflow root to scan recursively |
+| `--name` | | Restrict migration to one workflow name |
+| `--tasks-dir` | | Restrict migration to one task workflow directory |
+| `--reviews-dir` | | Restrict migration to one review round directory |
+| `--dry-run` | `false` | Preview migrations without writing files |
+
+## `productize sync`
+
+Reconcile workflow artifacts into daemon state.
+
+```bash
+productize sync [flags]
+```
+
+| Flag | Default | Description |
+| ---- | ------- | ----------- |
+| `--root-dir` | `.productize/tasks` | Workflow root to scan |
+| `--name` | | Restrict sync to one workflow name |
+| `--tasks-dir` | | Restrict sync to one task workflow directory |
+| `--format` | `text` | Output format: `text` or `json` |
+
+## `productize daemon`
+
+Manage the shared home-scoped daemon.
+
+```bash
+productize daemon start
+productize daemon status
+productize daemon stop [--force]
+```
+
+Use `daemon start` for explicit bootstrap, `daemon status` for health and
+transport info, and `daemon stop` for graceful shutdown. Most workflow commands
+auto-start the daemon.
+
+## `productize workspaces`
+
+Manage daemon workspace registrations.
+
+```bash
+productize workspaces list [--format text|json]
+productize workspaces show [--format text|json]
+productize workspaces register [--name display-name] [--format text|json]
+productize workspaces unregister [--format text|json]
+productize workspaces resolve [--format text|json]
+```
+
+The daemon lazily registers workspaces on first use, but the `workspaces` family
+gives operators explicit control over the registry.
+
+## `productize tasks validate`
+
+Validate task metadata before execution.
+
+```bash
+productize tasks validate [--name my-feature | --tasks-dir .productize/tasks/my-feature] [--format text|json]
+```
+
+Use `tasks validate` to check every `task_*.md` file in a workflow directory
+against the task metadata schema before `tasks run`.
+
+## `productize tasks run`
+
+Start one daemon-backed workflow run.
+
+```bash
+productize tasks run [flags]
+```
+
+The CLI resolves workspace defaults locally, validates task metadata, auto-starts
+the daemon when needed, and starts the workflow through daemon transport.
+
+| Flag | Default | Description |
+| ---- | ------- | ----------- |
+| `--name` | | Workflow slug, defaults to positional slug |
+| `--include-completed` | `false` | Re-run completed tasks |
+| `--skip-validation` | `false` | Skip task metadata preflight; use only when validation already ran elsewhere |
+| `--force` | `false` | Continue after task metadata validation fails in non-interactive mode |
+| `--attach` | `auto` | Attach mode: `auto`, `stream`, or `detach`; legacy `ui` maps to `stream` |
+| `--ui` | `false` | Deprecated alias for `--stream` |
+| `--stream` | `false` | Force textual stream attach mode |
+| `--detach` | `false` | Start the run without attaching a client |
+| `--task-runtime` | | Per-task runtime override rule such as `type=...`, `id=...`, `ide=...`, `model=...` |
+
+## `productize reviews`
+
+Inspect and remediate review workflows.
+
+```bash
+productize reviews fetch [--provider coderabbit --pr 42 --round N]
+productize reviews list
+productize reviews show [round]
+productize reviews fix [flags]
+productize reviews watch [flags]
+```
+
+`reviews fetch` imports provider feedback into
+`.productize/tasks//reviews-NNN/`. `reviews fix` uses the same
+daemon-backed runtime model as `tasks run`, including `--attach`, `--stream`,
+and `--detach`; `--ui` remains a deprecated alias for `--stream`.
+
+`reviews watch` waits for provider feedback, imports actionable rounds, starts
+child review-fix runs, and can optionally push committed fixes.
+
+## `productize runs`
+
+Reattach, observe, and clean daemon-managed runs.
+
+```bash
+productize runs attach
+productize runs watch
+productize runs purge
+```
+
+Use `runs attach` or `runs watch` for textual streaming observation of an
+existing run. Use `runs purge` to delete terminal run artifacts according to the
+configured retention policy.
+
+## `productize archive`
+
+Move fully completed workflows into the archive root.
+
+```bash
+productize archive [flags]
+```
+
+| Flag | Default | Description |
+| ---- | ------- | ----------- |
+| `--root-dir` | `.productize/tasks` | Workflow root to scan |
+| `--name` | | Restrict archiving to one workflow name |
+| `--tasks-dir` | | Restrict archiving to one task workflow directory |
+| `--format` | `text` | Output format: `text` or `json` |
+
+## `productize exec`
+
+Execute one ad hoc prompt.
+
+```bash
+productize exec [prompt] [flags]
+```
+
+Provide exactly one prompt source: a positional prompt, `--prompt-file`, or
+`stdin`.
+
+`productize exec` is headless and ephemeral by default. Use `--agent ` to
+execute a reusable agent, `--persist` to create `~/.productize/runs//`
+for resumable sessions, `--run-id` to continue a persisted session,
+`--format json` for lean JSONL, and `--format raw-json` for the full raw event
+stream.
+
+| Flag | Default | Description |
+| ---- | ------- | ----------- |
+| `--ide` | `codex` | Runtime: `claude`, `codex`, `copilot`, `cursor-agent`, `droid`, `gemini`, `opencode`, `pi` |
+| `--model` | per IDE | Model override |
+| `--agent` | | Reusable agent to execute from `.productize/agents/` or `~/.productize/agents/` |
+| `--prompt-file` | | Read prompt text from a file |
+| `--format` | `text` | Output contract: `text`, `json`, or `raw-json` |
+| `--reasoning-effort` | `medium` | `low`, `medium`, `high`, `xhigh` |
+| `--access-mode` | `full` | `default` or `full` runtime access policy |
+| `--timeout` | `10m` | Activity timeout per job |
+| `--max-retries` | `2` | Retry execution-stage ACP failures or timeouts N times |
+| `--retry-backoff-multiplier` | `1.5` | Multiplier applied to the next timeout after each retry |
+| `--tail-lines` | `0` | Maximum log lines retained per job; `0` means full history |
+| `--add-dir` | | Additional directories to allow, repeatable |
+| `--auto-commit` | `false` | Include automatic commit instructions when the prompt asks for code changes |
+| `--extensions` | `false` | Enable executable extensions for this exec run |
+| `--verbose` | `false` | Emit operational runtime logs to stderr during exec |
+| `--tui` | `false` | Deprecated compatibility flag; new exec runs remain headless |
+| `--persist` | `false` | Persist exec artifacts under `~/.productize/runs//` |
+| `--run-id` | | Resume a previously persisted exec session by run id |
+| `--dry-run` | `false` | Preview prompts without executing |
+
+## `productize agents`
+
+Discover and inspect reusable agents.
+
+```bash
+productize agents list
+productize agents inspect
+```
+
+`productize agents list` prints resolved agents from workspace and global scope,
+then reports invalid definitions without hiding valid ones. `productize agents
+inspect ` prints source, runtime defaults, MCP summary, and validation
+status for one agent.
+
+## `productize ext`
+
+Manage executable extensions.
+
+```bash
+productize ext [flags]
+```
+
+| Subcommand | Description |
+| ---------- | ----------- |
+| `ext list` | List discovered extensions across all scopes |
+| `ext inspect ` | Show manifest, capabilities, and enablement status |
+| `ext install ` | Install from a local path or GitHub repo archive |
+| `ext uninstall ` | Remove a user-scoped extension |
+| `ext enable ` | Enable an extension on this machine |
+| `ext disable ` | Disable an extension on this machine |
+| `ext doctor` | Validate manifests and report health warnings |
+
+`ext install` accepts `--yes` / `-y`, `--remote local|github`, `--ref`, and
+`--subdir`.
diff --git a/docs/workflow.md b/docs/workflow.md
new file mode 100644
index 00000000..1995cefa
--- /dev/null
+++ b/docs/workflow.md
@@ -0,0 +1,143 @@
+# The lifecycle workflow
+
+Productize turns AI-assisted development into a repeatable pipeline. Each stage produces a versioned artifact under `.productize/tasks//`, so the process is reviewable, resumable, and reproducible.
+
+```
+init existing ─▶ idea ─▶ create-prd ─▶ create-techspec ─▶ create-tasks ─▶ tasks run ─▶ reviews fetch/fix/watch ─▶ archive
+ └────────── inside your AI agent ──────────┘ └──────────── productize CLI + daemon ────────────┘
+```
+
+The split matters:
+
+- **Authoring stages** (PRD, tech spec, tasks) run as **skills inside your AI agent** (Claude Code, Codex, …). Your agent does the reasoning; the skills give it the method and the output format.
+- **Execution & review stages** run through the **Productize CLI and daemon**, which drive your agent over ACP to do the actual work.
+
+---
+
+## 0. Adopt an existing project
+
+For mature repositories, start by generating durable project knowledge:
+
+```bash
+productize init existing
+```
+
+This scans repository facts without invoking an AI model and writes:
+
+```
+.productize/project/
+├── inventory.md
+├── 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.
+
+---
+
+## 1. Install the skills
+
+```bash
+productize setup
+```
+
+This puts the lifecycle skills into your agent. You only do it once per project (or `--global` once per machine).
+
+---
+
+## 2. PRD — `create-prd`
+
+Invoke `create-prd` in your agent with a feature name (and optionally an idea file). The skill drives codebase + web research, asks you clarifying questions, and explores multiple product approaches before writing:
+
+```
+.productize/tasks//
+├── _prd.md # the product requirements document
+└── adrs/ # architecture/decision records captured along the way
+```
+
+## 3. Tech spec — `create-techspec`
+
+`create-techspec` reads the PRD and produces the technical design:
+
+```
+.productize/tasks//_techspec.md
+```
+
+## 4. Task breakdown — `create-tasks`
+
+`create-tasks` decomposes the tech spec into an ordered, metadata-rich task list:
+
+```
+.productize/tasks//_tasks.md
+```
+
+Each task carries frontmatter (type, dependencies, priority) that the runner uses to schedule and route work.
+
+Validate before running:
+
+```bash
+productize tasks validate
+```
+
+---
+
+## 5. Execute — `productize tasks run`
+
+```bash
+productize tasks run
+```
+
+The daemon executes each task by driving your chosen agent over ACP. Useful flags:
+
+- `--attach stream` to watch live, or `--detach` to run in the background (re-attach later with `productize runs watch `).
+- `--include-completed` to re-run tasks already marked done; `--skip-validation` or `--force` to bypass the metadata preflight.
+- `--task-runtime type=frontend,ide=codex,model=gpt-5.5` to route a task type to a specific agent/model. Defaults also live in `[tasks.run].task_runtime_rules` in config.
+
+---
+
+## 6. Review & remediate — `productize reviews`
+
+Once you open a PR and a reviewer (human or a bot like CodeRabbit) leaves feedback:
+
+```bash
+productize reviews fetch --pr 123 # import feedback as review round NNN
+productize reviews list # see the summary
+productize reviews fix --round 1 # auto-remediate the round
+```
+
+Review rounds are stored as `.productize/tasks//reviews-001/`, `reviews-002/`, …
+
+To close the loop hands-free until the PR is clean:
+
+```bash
+productize reviews watch --until-clean --auto-push
+```
+
+`watch` polls for new feedback, fixes each round, and (optionally) pushes — repeating up to `--max-rounds`.
+
+---
+
+## 7. Archive
+
+When a workflow is fully complete:
+
+```bash
+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.
+
+---
+
+## Resuming & inspecting
+
+- `productize runs watch ` — re-attach to a detached run.
+- `productize runs purge` — clean up old terminal runs.
+- `productize daemon status` — check the orchestrator is healthy.
+- Everything is on disk under `.productize/` and in `~/.productize/db/global.db`; nothing is hidden in a cloud service.
diff --git a/internal/cli/init_command.go b/internal/cli/init_command.go
new file mode 100644
index 00000000..eb68369c
--- /dev/null
+++ b/internal/cli/init_command.go
@@ -0,0 +1,158 @@
+package cli
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "path/filepath"
+
+ core "github.com/itseffi/productize/internal/core"
+ "github.com/itseffi/productize/internal/core/workspace"
+ "github.com/spf13/cobra"
+)
+
+type initExistingCommandState struct {
+ dryRun bool
+ force bool
+ outputFormat string
+ adoptFn func(context.Context, core.ProjectAdoptionConfig) (*core.ProjectAdoptionResult, error)
+}
+
+func newInitCommand() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "init",
+ Short: "Initialize Productize project context",
+ SilenceUsage: true,
+ Args: cobra.NoArgs,
+ }
+ cmd.AddCommand(newInitExistingCommand())
+ return cmd
+}
+
+func newInitExistingCommand() *cobra.Command {
+ state := &initExistingCommandState{
+ adoptFn: core.AdoptExistingProject,
+ }
+ cmd := &cobra.Command{
+ Use: "existing [path]",
+ Short: "Adopt an existing repository into Productize project knowledge",
+ SilenceUsage: true,
+ Args: cobra.MaximumNArgs(1),
+ Long: `Scan an existing repository and write durable Productize project knowledge
+under .productize/project. The command is deterministic and does not invoke an AI model.`,
+ Example: ` productize init existing
+ productize init existing ../my-app --dry-run
+ productize init existing --format json
+ productize init existing --force`,
+ RunE: state.run,
+ }
+ cmd.Flags().BoolVar(&state.dryRun, "dry-run", false, "Preview generated project knowledge without writing files")
+ cmd.Flags().BoolVar(&state.force, "force", false, "Overwrite existing unmarked project knowledge files")
+ cmd.Flags().StringVar(&state.outputFormat, "format", operatorOutputFormatText, "Output format: text or json")
+ return cmd
+}
+
+func (s *initExistingCommandState) run(cmd *cobra.Command, args []string) error {
+ ctx, stop := signalCommandContext(cmd)
+ defer stop()
+
+ format, err := normalizeOperatorOutputFormat(s.outputFormat)
+ if err != nil {
+ return withExitCode(1, err)
+ }
+
+ root, err := resolveInitExistingRoot(ctx, args)
+ if err != nil {
+ return withExitCode(2, err)
+ }
+
+ adoptFn := s.adoptFn
+ if adoptFn == nil {
+ adoptFn = core.AdoptExistingProject
+ }
+ result, err := adoptFn(ctx, core.ProjectAdoptionConfig{
+ WorkspaceRoot: root,
+ DryRun: s.dryRun,
+ Force: s.force,
+ })
+ if err != nil {
+ return withExitCode(1, err)
+ }
+ return writeInitExistingOutput(cmd, format, s.dryRun, result)
+}
+
+func resolveInitExistingRoot(ctx context.Context, args []string) (string, error) {
+ if len(args) > 0 {
+ root, err := filepath.Abs(args[0])
+ if err != nil {
+ return "", fmt.Errorf("resolve project path: %w", err)
+ }
+ return root, nil
+ }
+ root, err := workspace.Discover(ctx, "")
+ if err != nil {
+ return "", fmt.Errorf("discover workspace root: %w", err)
+ }
+ return root, nil
+}
+
+func writeInitExistingOutput(
+ cmd *cobra.Command,
+ format string,
+ dryRun bool,
+ result *core.ProjectAdoptionResult,
+) error {
+ if result == nil {
+ return nil
+ }
+ if format == operatorOutputFormatJSON {
+ return writeOperatorJSON(cmd.OutOrStdout(), result)
+ }
+
+ out := cmd.OutOrStdout()
+ if _, err := fmt.Fprintf(out, "Project root: %s\n", result.WorkspaceRoot); err != nil {
+ return fmt.Errorf("write project adoption output: %w", err)
+ }
+ if _, err := fmt.Fprintf(out, "Project knowledge: %s\n", result.ProjectDir); err != nil {
+ return fmt.Errorf("write project adoption output: %w", err)
+ }
+ if dryRun {
+ if _, err := fmt.Fprintln(out, "Dry run: true"); err != nil {
+ return fmt.Errorf("write project adoption output: %w", err)
+ }
+ }
+ if err := writePathList(out, "Created", result.Created); err != nil {
+ return err
+ }
+ if err := writePathList(out, "Updated", result.Updated); err != nil {
+ return err
+ }
+ if err := writePathList(out, "Skipped", result.Skipped); err != nil {
+ return err
+ }
+ if _, err := fmt.Fprintf(out, "Promoted ADRs: %d\n", result.PromotedADRs); err != nil {
+ return fmt.Errorf("write project adoption output: %w", err)
+ }
+ if _, err := fmt.Fprintf(out, "Promoted memory items: %d\n", result.PromotedMemoryItems); err != nil {
+ return fmt.Errorf("write project adoption output: %w", err)
+ }
+ if err := writePathList(out, "Warnings", result.Warnings); err != nil {
+ return err
+ }
+ return nil
+}
+
+func writePathList(out io.Writer, title string, values []string) error {
+ if len(values) == 0 {
+ return nil
+ }
+ if _, err := fmt.Fprintf(out, "%s:\n", title); err != nil {
+ return fmt.Errorf("write %s list heading: %w", title, err)
+ }
+ for _, value := range values {
+ if _, err := fmt.Fprintf(out, "- %s\n", value); err != nil {
+ return fmt.Errorf("write %s list item: %w", title, err)
+ }
+ }
+ return nil
+}
diff --git a/internal/cli/init_command_test.go b/internal/cli/init_command_test.go
new file mode 100644
index 00000000..38f894b2
--- /dev/null
+++ b/internal/cli/init_command_test.go
@@ -0,0 +1,140 @@
+package cli
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ core "github.com/itseffi/productize/internal/core"
+ "github.com/itseffi/productize/internal/core/model"
+)
+
+func TestInitExistingHelpShowsAdoptionFlags(t *testing.T) {
+ t.Parallel()
+
+ output, err := executeRootCommand("init", "existing", "--help")
+ if err != nil {
+ t.Fatalf("execute init existing help: %v", err)
+ }
+ for _, snippet := range []string{
+ "productize init existing [path]",
+ "--dry-run",
+ "--force",
+ "--format",
+ ".productize/project",
+ } {
+ if !strings.Contains(output, snippet) {
+ t.Fatalf("expected help to include %q\noutput:\n%s", snippet, output)
+ }
+ }
+}
+
+func TestInitExistingWritesProjectKnowledgeForExplicitPath(t *testing.T) {
+ t.Parallel()
+
+ root := t.TempDir()
+ writeCLITestFile(t, root, "go.mod", "module example.com/app\n")
+ writeCLITestFile(t, root, "Makefile", "verify:\n\tgo test ./...\n")
+
+ output, err := executeRootCommand("init", "existing", root)
+ if err != nil {
+ t.Fatalf("execute init existing: %v\noutput:\n%s", err, output)
+ }
+ if !strings.Contains(output, "Project knowledge: .productize/project") {
+ t.Fatalf("expected text output to mention project knowledge dir\n%s", output)
+ }
+ if !strings.Contains(output, "Promoted ADRs: 0") {
+ 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,
+ model.ProjectDecisionsFileName,
+ } {
+ path := filepath.Join(model.ProjectBaseDirForWorkspace(root), name)
+ content, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read generated project doc %s: %v", path, err)
+ }
+ if !strings.Contains(string(content), "productize:project-knowledge") {
+ t.Fatalf("expected generated marker in %s", path)
+ }
+ }
+}
+
+func TestInitExistingEmitsJSONResult(t *testing.T) {
+ t.Parallel()
+
+ root := t.TempDir()
+ writeCLITestFile(t, root, "go.mod", "module example.com/app\n")
+
+ output, err := executeRootCommand("init", "existing", root, "--format", "json")
+ if err != nil {
+ t.Fatalf("execute init existing json: %v\noutput:\n%s", err, output)
+ }
+ var result core.ProjectAdoptionResult
+ if err := json.Unmarshal([]byte(output), &result); err != nil {
+ t.Fatalf("decode json output: %v\n%s", err, output)
+ }
+ if result.WorkspaceRoot != root {
+ t.Fatalf("WorkspaceRoot = %q, want %q", result.WorkspaceRoot, root)
+ }
+ 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))
+ }
+}
+
+func TestInitExistingDefaultsToDiscoveredWorkspaceRoot(t *testing.T) {
+ root := t.TempDir()
+ nested := filepath.Join(root, "internal", "feature")
+ if err := os.MkdirAll(nested, 0o755); err != nil {
+ t.Fatalf("mkdir nested dir: %v", err)
+ }
+ writeCLITestFile(t, root, ".productize/config.toml", "")
+ writeCLITestFile(t, root, "go.mod", "module example.com/app\n")
+
+ originalWD, err := os.Getwd()
+ if err != nil {
+ t.Fatalf("getwd: %v", err)
+ }
+ if err := os.Chdir(nested); err != nil {
+ t.Fatalf("chdir nested: %v", err)
+ }
+ defer func() {
+ _ = os.Chdir(originalWD)
+ }()
+
+ output, err := executeRootCommand("init", "existing", "--format", "json")
+ if err != nil {
+ t.Fatalf("execute init existing default root: %v\noutput:\n%s", err, output)
+ }
+ var result core.ProjectAdoptionResult
+ if err := json.Unmarshal([]byte(output), &result); err != nil {
+ t.Fatalf("decode json output: %v\n%s", err, output)
+ }
+ wantRoot, err := filepath.EvalSymlinks(root)
+ if err != nil {
+ t.Fatalf("eval root symlink: %v", err)
+ }
+ if result.WorkspaceRoot != wantRoot {
+ t.Fatalf("WorkspaceRoot = %q, want %q", result.WorkspaceRoot, wantRoot)
+ }
+}
+
+func writeCLITestFile(t *testing.T, root, rel, content string) {
+ t.Helper()
+ path := filepath.Join(root, filepath.FromSlash(rel))
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
+ }
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+ t.Fatalf("write %s: %v", path, err)
+ }
+}
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 6559e466..136c23f7 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -64,6 +64,7 @@ Defaults can be stored in ~/.productize/config.toml and overridden per workspace
.productize/config.toml. Explicit CLI flags always override values loaded from config files.
Use explicit workflow subcommands:
+ productize init Initialize Productize project context
productize setup Install bundled public skills for supported agents
productize agents Discover and inspect reusable agents
productize upgrade Update the CLI to the latest release
@@ -83,6 +84,7 @@ Use explicit workflow subcommands:
}
root.AddCommand(
+ newInitCommand(),
newSetupCommand(nil),
newAgentsCommand(),
newUpgradeCommand(),
diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go
index f4d5b470..38d5015c 100644
--- a/internal/cli/root_test.go
+++ b/internal/cli/root_test.go
@@ -33,6 +33,7 @@ func TestRootCommandShowsHelpAndWorkflowSubcommands(t *testing.T) {
}
required := []string{
+ "productize init",
"productize setup",
"productize agents",
"productize upgrade",
@@ -44,6 +45,7 @@ func TestRootCommandShowsHelpAndWorkflowSubcommands(t *testing.T) {
"productize runs",
"productize sync",
"productize archive",
+ "init",
"setup",
"agents",
"upgrade",
@@ -531,7 +533,7 @@ func TestHiddenMCPServeCommandIsRegisteredButHidden(t *testing.T) {
}
}
-func TestREADMEExecDocumentationMatchesCurrentContract(t *testing.T) {
+func TestREADMEDocumentationMatchesCurrentContract(t *testing.T) {
t.Parallel()
readmePath := mustCLIRepoRootPath(t, "README.md")
@@ -542,18 +544,22 @@ func TestREADMEExecDocumentationMatchesCurrentContract(t *testing.T) {
content := string(body)
required := []string{
- `## Ad Hoc Exec`,
- "productize exec \"Summarize the current repository changes\"",
- "productize exec --prompt-file prompt.md",
- "cat prompt.md | productize exec --format json",
- "productize exec --persist \"Review the latest changes\"",
- "~/.productize/runs//run.db",
- "~/.productize/runs//run.json",
- "~/.productize/runs//events.jsonl",
- "~/.productize/runs//turns/0001/prompt.md",
- "~/.productize/runs//turns/0001/result.json",
- "flags > workspace [exec] > workspace [defaults] > global [exec] > global [defaults] > built-in defaults",
- "[exec]",
+ "# Productize",
+ "npm install -g @productize/cli",
+ "productize setup --agent claude --agent codex --yes",
+ "productize init existing",
+ ".productize/project/",
+ "inventory.md",
+ "context.md",
+ "decisions.md",
+ "`create-prd`",
+ "`create-techspec`",
+ "`create-tasks`",
+ "`_tasks.md`",
+ "productize tasks run ",
+ "productize reviews fetch --pr 123",
+ "productize exec [prompt]",
+ "~/.productize/db/global.db",
"`copilot`",
"`cursor-agent`",
}
@@ -567,6 +573,7 @@ func TestREADMEExecDocumentationMatchesCurrentContract(t *testing.T) {
".tmp/codex-prompts",
"Agent: `claude`, `codex`, `cursor`, `droid`, `opencode`, `pi`",
"| `--tail-lines` | `30`",
+ "`tasks.md`",
}
for _, snippet := range forbidden {
if strings.Contains(content, snippet) {
@@ -636,15 +643,14 @@ func TestDaemonDocsUseCurrentCommandSurface(t *testing.T) {
}
readmeContent := string(readme)
requiredREADME := []string{
- "productize tasks validate --name user-auth",
- "productize tasks run user-auth --ide claude",
- "productize reviews fetch user-auth --provider coderabbit --pr 42",
- "productize reviews fix user-auth --ide claude --concurrent 2 --batch-size 3",
- "productize daemon start",
+ "productize init existing",
+ "productize tasks run ",
+ "productize reviews fetch --pr 123",
+ "productize reviews fix ",
"productize daemon status",
- "productize runs attach ",
"productize runs watch ",
- "~/.productize/runs/",
+ "productize workspaces list",
+ "~/.productize/db/global.db",
}
for _, snippet := range requiredREADME {
if !strings.Contains(readmeContent, snippet) {
@@ -656,7 +662,11 @@ func TestDaemonDocsUseCurrentCommandSurface(t *testing.T) {
if err != nil {
t.Fatalf("read architecture doc: %v", err)
}
- if !containsAll(string(architecture), "~/.productize/runs//run.db", "hook_runs") {
+ if !containsAll(
+ string(architecture),
+ "~/.productize/runs//run.db",
+ "Every hook dispatch and Host API call",
+ ) {
t.Fatalf("expected architecture doc to describe daemon run audit storage")
}
diff --git a/internal/core/adoption.go b/internal/core/adoption.go
new file mode 100644
index 00000000..6bf62a41
--- /dev/null
+++ b/internal/core/adoption.go
@@ -0,0 +1,835 @@
+package core
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "github.com/itseffi/productize/internal/core/model"
+)
+
+const projectKnowledgeMarker = ""
+
+type projectScan struct {
+ WorkspaceRoot string
+ Manifests []string
+ PackageManagers []string
+ BuildCommands []string
+ TestCommands []string
+ AgentInstructions []string
+ Documentation []string
+ TopLevelDirectories []string
+ TopLevelFiles []string
+ ActiveWorkflows []string
+ ArchivedWorkflows []string
+ AcceptedADRs []promotedADR
+ WorkflowMemories []promotedWorkflowMemory
+ Warnings []string
+}
+
+type promotedADR struct {
+ SourcePath string
+ Title string
+}
+
+type promotedWorkflowMemory struct {
+ Workflow string
+ Path string
+ Sections []promotedMemorySection
+}
+
+type promotedMemorySection struct {
+ Title string
+ Lines []string
+}
+
+type generatedProjectDoc struct {
+ Name string
+ Content string
+}
+
+// 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) {
+ root, err := resolveProjectAdoptionRoot(cfg.WorkspaceRoot)
+ if err != nil {
+ return nil, err
+ }
+ if err := ctx.Err(); err != nil {
+ return nil, fmt.Errorf("adopt existing project: %w", err)
+ }
+
+ scan, err := scanExistingProject(ctx, root)
+ if err != nil {
+ return nil, err
+ }
+
+ docs := renderProjectDocs(scan)
+ result := &model.ProjectAdoptionResult{
+ WorkspaceRoot: root,
+ ProjectDir: filepath.ToSlash(filepath.Join(model.WorkflowRootDirName, model.WorkflowProjectDirName)),
+ Warnings: append([]string(nil), scan.Warnings...),
+ PromotedADRs: len(scan.AcceptedADRs),
+ PromotedMemoryItems: countPromotedMemoryItems(scan.WorkflowMemories),
+ }
+ if err := writeProjectDocs(ctx, root, docs, cfg, result); err != nil {
+ return result, err
+ }
+ sort.Strings(result.Created)
+ sort.Strings(result.Updated)
+ sort.Strings(result.Skipped)
+ sort.Strings(result.Warnings)
+ return result, nil
+}
+
+func resolveProjectAdoptionRoot(workspaceRoot string) (string, error) {
+ root := strings.TrimSpace(workspaceRoot)
+ if root == "" {
+ cwd, err := os.Getwd()
+ if err != nil {
+ return "", fmt.Errorf("get working directory: %w", err)
+ }
+ root = cwd
+ }
+ absRoot, err := filepath.Abs(root)
+ if err != nil {
+ return "", fmt.Errorf("resolve project root: %w", err)
+ }
+ info, err := os.Stat(absRoot)
+ if err != nil {
+ return "", fmt.Errorf("stat project root: %w", err)
+ }
+ if !info.IsDir() {
+ return "", fmt.Errorf("project root is not a directory: %s", absRoot)
+ }
+ return filepath.Clean(absRoot), nil
+}
+
+func scanExistingProject(ctx context.Context, root string) (projectScan, error) {
+ scan := projectScan{WorkspaceRoot: root}
+ entries, err := os.ReadDir(root)
+ if err != nil {
+ return scan, fmt.Errorf("read project root: %w", err)
+ }
+ for _, entry := range entries {
+ if err := ctx.Err(); err != nil {
+ return scan, fmt.Errorf("scan project root: %w", err)
+ }
+ name := entry.Name()
+ if shouldIgnoreProjectPath(name, entry.IsDir()) {
+ continue
+ }
+ if entry.IsDir() {
+ scan.TopLevelDirectories = append(scan.TopLevelDirectories, name+"/")
+ continue
+ }
+ scan.TopLevelFiles = append(scan.TopLevelFiles, name)
+ }
+ sort.Strings(scan.TopLevelDirectories)
+ sort.Strings(scan.TopLevelFiles)
+
+ scanKnownProjectFiles(root, &scan)
+ scanMakefileCommands(root, &scan)
+ scanPackageJSON(root, &scan)
+ if err := scanAgentInstructions(ctx, root, &scan); err != nil {
+ return scan, err
+ }
+ if err := scanWorkflowKnowledge(ctx, root, &scan); err != nil {
+ return scan, err
+ }
+ sortProjectScan(&scan)
+ return scan, nil
+}
+
+func shouldIgnoreProjectPath(name string, isDir bool) bool {
+ switch name {
+ case ".git", ".hg", ".svn", "node_modules", "vendor", "dist", "build", "target",
+ ".next", "out", "coverage", ".cache", ".turbo":
+ return true
+ }
+ return isDir && strings.HasPrefix(name, ".") && name != model.WorkflowRootDirName && name != ".github"
+}
+
+func scanKnownProjectFiles(root string, scan *projectScan) {
+ manifestCandidates := []string{
+ "go.mod",
+ "go.sum",
+ "package.json",
+ "pnpm-lock.yaml",
+ "package-lock.json",
+ "yarn.lock",
+ "bun.lockb",
+ "Cargo.toml",
+ "pyproject.toml",
+ "requirements.txt",
+ "Makefile",
+ "justfile",
+ "Dockerfile",
+ "docker-compose.yml",
+ "tsconfig.json",
+ "deno.json",
+ }
+ for _, candidate := range manifestCandidates {
+ if regularFileExists(filepath.Join(root, candidate)) {
+ scan.Manifests = append(scan.Manifests, candidate)
+ }
+ }
+
+ switch {
+ case regularFileExists(filepath.Join(root, "go.mod")):
+ scan.PackageManagers = append(scan.PackageManagers, "go")
+ scan.TestCommands = append(scan.TestCommands, "go test ./...")
+ case regularFileExists(filepath.Join(root, "Cargo.toml")):
+ scan.PackageManagers = append(scan.PackageManagers, "cargo")
+ scan.BuildCommands = append(scan.BuildCommands, "cargo build")
+ scan.TestCommands = append(scan.TestCommands, "cargo test")
+ }
+ switch {
+ case regularFileExists(filepath.Join(root, "pnpm-lock.yaml")):
+ scan.PackageManagers = append(scan.PackageManagers, "pnpm")
+ case regularFileExists(filepath.Join(root, "yarn.lock")):
+ scan.PackageManagers = append(scan.PackageManagers, "yarn")
+ case regularFileExists(filepath.Join(root, "bun.lockb")):
+ scan.PackageManagers = append(scan.PackageManagers, "bun")
+ case regularFileExists(filepath.Join(root, "package-lock.json")),
+ regularFileExists(filepath.Join(root, "package.json")):
+ scan.PackageManagers = append(scan.PackageManagers, "npm")
+ }
+
+ docCandidates := []string{"README.md", "docs", "CONTRIBUTING.md", "CHANGELOG.md", "LICENSE"}
+ for _, candidate := range docCandidates {
+ if pathExists(filepath.Join(root, candidate)) {
+ scan.Documentation = append(scan.Documentation, candidate)
+ }
+ }
+}
+
+func scanMakefileCommands(root string, scan *projectScan) {
+ path := filepath.Join(root, "Makefile")
+ content, err := os.ReadFile(path)
+ if err != nil {
+ return
+ }
+ for _, line := range strings.Split(string(content), "\n") {
+ target, ok := parseMakeTarget(line)
+ if !ok {
+ continue
+ }
+ command := "make " + target
+ switch target {
+ case "build", "verify", "fmt", "lint":
+ scan.BuildCommands = append(scan.BuildCommands, command)
+ case "test":
+ scan.TestCommands = append(scan.TestCommands, command)
+ }
+ }
+}
+
+func parseMakeTarget(line string) (string, bool) {
+ trimmed := strings.TrimSpace(line)
+ if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(line, "\t") {
+ return "", false
+ }
+ idx := strings.Index(trimmed, ":")
+ if idx <= 0 {
+ return "", false
+ }
+ target := strings.TrimSpace(trimmed[:idx])
+ if target == "" || strings.ContainsAny(target, " \t$") || strings.HasPrefix(target, ".") {
+ return "", false
+ }
+ return target, true
+}
+
+func scanPackageJSON(root string, scan *projectScan) {
+ path := filepath.Join(root, "package.json")
+ content, err := os.ReadFile(path)
+ if err != nil {
+ return
+ }
+ var parsed struct {
+ Scripts map[string]string `json:"scripts"`
+ }
+ if err := json.Unmarshal(content, &parsed); err != nil {
+ scan.Warnings = append(scan.Warnings, "package.json could not be parsed")
+ return
+ }
+ for _, name := range []string{"build", "typecheck", "lint", "format"} {
+ if _, ok := parsed.Scripts[name]; ok {
+ scan.BuildCommands = append(scan.BuildCommands, packageRunCommand(scan.PackageManagers, name))
+ }
+ }
+ for _, name := range []string{"test", "test:unit", "test:integration"} {
+ if _, ok := parsed.Scripts[name]; ok {
+ scan.TestCommands = append(scan.TestCommands, packageRunCommand(scan.PackageManagers, name))
+ }
+ }
+}
+
+func packageRunCommand(packageManagers []string, script string) string {
+ for _, manager := range packageManagers {
+ switch manager {
+ case "pnpm":
+ return "pnpm " + script
+ case "yarn":
+ return "yarn " + script
+ case "bun":
+ return "bun run " + script
+ }
+ }
+ return "npm run " + script
+}
+
+func scanAgentInstructions(ctx context.Context, root string, scan *projectScan) error {
+ candidates := map[string]bool{
+ "AGENTS.md": true,
+ "CLAUDE.md": true,
+ ".cursorrules": true,
+ ".github/copilot-instructions.md": true,
+ }
+ return filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ rel, err := filepath.Rel(root, path)
+ if err != nil {
+ return fmt.Errorf("resolve project-relative instruction path: %w", err)
+ }
+ rel = filepath.ToSlash(rel)
+ if entry.IsDir() {
+ return handleInstructionDir(rel, entry.Name())
+ }
+ if isAgentInstructionFile(rel, candidates) {
+ scan.AgentInstructions = append(scan.AgentInstructions, rel)
+ }
+ return nil
+ })
+}
+
+func handleInstructionDir(rel string, name string) error {
+ switch {
+ case rel == ".":
+ return nil
+ case rel == ".productize/runs":
+ return filepath.SkipDir
+ case rel == ".cursor" || rel == ".codex":
+ return nil
+ case strings.HasPrefix(rel, ".cursor/") || strings.HasPrefix(rel, ".codex/"):
+ return nil
+ case shouldIgnoreProjectPath(name, true):
+ return filepath.SkipDir
+ default:
+ return nil
+ }
+}
+
+func isAgentInstructionFile(rel string, candidates map[string]bool) bool {
+ return candidates[rel] ||
+ isMarkdownFileUnder(rel, ".cursor/rules/") ||
+ isMarkdownFileUnder(rel, ".codex/")
+}
+
+func isMarkdownFileUnder(rel string, prefix string) bool {
+ return strings.HasPrefix(rel, prefix) && strings.HasSuffix(rel, ".md")
+}
+
+func scanWorkflowKnowledge(ctx context.Context, root string, scan *projectScan) error {
+ tasksRoot := model.TasksBaseDirForWorkspace(root)
+ entries, err := os.ReadDir(tasksRoot)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ return fmt.Errorf("read Productize tasks root: %w", err)
+ }
+ for _, entry := range entries {
+ if err := ctx.Err(); err != nil {
+ return fmt.Errorf("scan workflow knowledge: %w", err)
+ }
+ if !entry.IsDir() {
+ continue
+ }
+ name := entry.Name()
+ if name == model.ArchivedWorkflowDirName {
+ if err := scanArchivedWorkflows(ctx, root, filepath.Join(tasksRoot, name), scan); err != nil {
+ return err
+ }
+ continue
+ }
+ 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 {
+ return err
+ }
+ if memory := scanWorkflowMemory(root, name, workflowDir); len(memory.Sections) > 0 {
+ scan.WorkflowMemories = append(scan.WorkflowMemories, memory)
+ }
+ }
+ return nil
+}
+
+func scanArchivedWorkflows(ctx context.Context, root string, archivedRoot string, scan *projectScan) error {
+ entries, err := os.ReadDir(archivedRoot)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ return fmt.Errorf("read archived workflows: %w", err)
+ }
+ for _, entry := range entries {
+ if err := ctx.Err(); err != nil {
+ return fmt.Errorf("scan archived workflows: %w", err)
+ }
+ if !entry.IsDir() {
+ continue
+ }
+ scan.ArchivedWorkflows = append(scan.ArchivedWorkflows, entry.Name())
+ if err := scanWorkflowADRs(ctx, root, filepath.Join(archivedRoot, entry.Name()), scan); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func scanWorkflowADRs(ctx context.Context, root, workflowDir string, scan *projectScan) error {
+ adrsDir := filepath.Join(workflowDir, "adrs")
+ entries, err := os.ReadDir(adrsDir)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ return fmt.Errorf("read workflow ADRs: %w", err)
+ }
+ for _, entry := range entries {
+ if err := ctx.Err(); err != nil {
+ return fmt.Errorf("scan workflow ADRs: %w", err)
+ }
+ if entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".md") {
+ continue
+ }
+ path := filepath.Join(adrsDir, entry.Name())
+ content, err := os.ReadFile(path)
+ 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()),
+ })
+ }
+ return nil
+}
+
+func isAcceptedADR(content string) bool {
+ lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
+ inStatus := false
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+ if strings.HasPrefix(trimmed, "## ") {
+ inStatus = strings.EqualFold(strings.TrimSpace(strings.TrimPrefix(trimmed, "## ")), "Status")
+ continue
+ }
+ if inStatus && strings.EqualFold(strings.Trim(trimmed, "[] "), "Accepted") {
+ return true
+ }
+ }
+ return false
+}
+
+func extractMarkdownTitle(content, fallback string) string {
+ for _, line := range strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") {
+ trimmed := strings.TrimSpace(line)
+ if strings.HasPrefix(trimmed, "# ") {
+ return strings.TrimSpace(strings.TrimPrefix(trimmed, "# "))
+ }
+ }
+ return fallback
+}
+
+func scanWorkflowMemory(root, workflow string, workflowDir string) promotedWorkflowMemory {
+ path := filepath.Join(workflowDir, "memory", "MEMORY.md")
+ content, err := os.ReadFile(path)
+ if err != nil {
+ return promotedWorkflowMemory{}
+ }
+ rel, err := filepath.Rel(root, path)
+ if err != nil {
+ rel = path
+ }
+ return promotedWorkflowMemory{
+ Workflow: workflow,
+ Path: filepath.ToSlash(rel),
+ Sections: extractDurableMemorySections(string(content)),
+ }
+}
+
+func extractDurableMemorySections(content string) []promotedMemorySection {
+ allowed := map[string]bool{
+ "Shared Decisions": true,
+ "Shared Learnings": true,
+ "Open Risks": true,
+ "Handoffs": true,
+ }
+ var result []promotedMemorySection
+ var currentTitle string
+ var currentLines []string
+ flush := func() {
+ if currentTitle == "" {
+ return
+ }
+ lines := compactNonEmptyLines(currentLines)
+ if len(lines) > 0 {
+ result = append(result, promotedMemorySection{Title: currentTitle, Lines: lines})
+ }
+ }
+ for _, line := range strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") {
+ trimmed := strings.TrimSpace(line)
+ if strings.HasPrefix(trimmed, "## ") {
+ flush()
+ title := strings.TrimSpace(strings.TrimPrefix(trimmed, "## "))
+ if allowed[title] {
+ currentTitle = title
+ currentLines = nil
+ } else {
+ currentTitle = ""
+ currentLines = nil
+ }
+ continue
+ }
+ if currentTitle != "" {
+ currentLines = append(currentLines, line)
+ }
+ }
+ flush()
+ return result
+}
+
+func compactNonEmptyLines(lines []string) []string {
+ result := make([]string, 0, len(lines))
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+ if trimmed != "" {
+ result = append(result, trimmed)
+ }
+ }
+ return result
+}
+
+func sortProjectScan(scan *projectScan) {
+ sort.Strings(scan.Manifests)
+ sort.Strings(scan.PackageManagers)
+ sort.Strings(scan.BuildCommands)
+ sort.Strings(scan.TestCommands)
+ sort.Strings(scan.AgentInstructions)
+ sort.Strings(scan.Documentation)
+ 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
+ })
+ sort.SliceStable(scan.WorkflowMemories, func(i, j int) bool {
+ return scan.WorkflowMemories[i].Path < scan.WorkflowMemories[j].Path
+ })
+ scan.Manifests = uniqueStrings(scan.Manifests)
+ scan.PackageManagers = uniqueStrings(scan.PackageManagers)
+ scan.BuildCommands = uniqueStrings(scan.BuildCommands)
+ scan.TestCommands = uniqueStrings(scan.TestCommands)
+ scan.AgentInstructions = uniqueStrings(scan.AgentInstructions)
+ scan.Documentation = uniqueStrings(scan.Documentation)
+}
+
+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)},
+ {Name: model.ProjectDecisionsFileName, Content: renderDecisionsDoc(scan)},
+ }
+}
+
+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")
+ 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))
+ writeListSection(
+ &b,
+ "Read First",
+ append([]string{}, scan.AgentInstructions...),
+ "No project instruction files detected.",
+ )
+ writeListSection(
+ &b,
+ "Primary Commands",
+ append(append([]string{}, scan.BuildCommands...), scan.TestCommands...),
+ "No commands detected.",
+ )
+ 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")
+ return b.String()
+}
+
+func renderConventionsDoc(scan projectScan) string {
+ var b strings.Builder
+ writeDocHeader(&b, "Project Conventions")
+ writeListSection(&b, "Instruction Sources", scan.AgentInstructions, "No instruction sources detected.")
+ writeListSection(&b, "Build Commands", scan.BuildCommands, "No build commands detected.")
+ writeListSection(&b, "Test Commands", scan.TestCommands, "No test commands detected.")
+ fmt.Fprintf(&b, "## Notes\n\n")
+ fmt.Fprintf(
+ &b,
+ "- This file is generated from repository facts. Edit source instruction files for canonical rules.\n",
+ )
+ return b.String()
+}
+
+func renderArchitectureDoc(scan projectScan) string {
+ var b strings.Builder
+ writeDocHeader(&b, "Project Architecture")
+ fmt.Fprintf(&b, "## Top-Level Layout\n\n")
+ if len(scan.TopLevelDirectories) == 0 && len(scan.TopLevelFiles) == 0 {
+ fmt.Fprintf(&b, "No top-level project entries detected.\n\n")
+ } else {
+ for _, dir := range scan.TopLevelDirectories {
+ fmt.Fprintf(&b, "- `%s` directory\n", dir)
+ }
+ for _, file := range scan.TopLevelFiles {
+ fmt.Fprintf(&b, "- `%s` file\n", file)
+ }
+ fmt.Fprintf(&b, "\n")
+ }
+ writeListSection(&b, "Documentation Surfaces", scan.Documentation, "No documentation surfaces detected.")
+ writeWorkflowSummary(&b, scan)
+ return b.String()
+}
+
+func renderDecisionsDoc(scan projectScan) string {
+ var b strings.Builder
+ writeDocHeader(&b, "Project Decisions")
+ fmt.Fprintf(&b, "## Accepted ADRs\n\n")
+ if len(scan.AcceptedADRs) == 0 {
+ fmt.Fprintf(&b, "No accepted ADRs detected.\n\n")
+ } else {
+ for _, adr := range scan.AcceptedADRs {
+ fmt.Fprintf(&b, "- `%s`: %s\n", adr.SourcePath, adr.Title)
+ }
+ 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()
+ }
+ for _, memory := range scan.WorkflowMemories {
+ fmt.Fprintf(&b, "### %s\n\n", memory.Workflow)
+ fmt.Fprintf(&b, "Source: `%s`\n\n", memory.Path)
+ for _, section := range memory.Sections {
+ fmt.Fprintf(&b, "#### %s\n\n", section.Title)
+ for _, line := range section.Lines {
+ fmt.Fprintf(&b, "- %s\n", strings.TrimPrefix(line, "- "))
+ }
+ fmt.Fprintf(&b, "\n")
+ }
+ }
+ return b.String()
+}
+
+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")
+}
+
+func writeListSection(b *strings.Builder, title string, values []string, empty string) {
+ fmt.Fprintf(b, "## %s\n\n", title)
+ if len(values) == 0 {
+ fmt.Fprintf(b, "%s\n\n", empty)
+ return
+ }
+ for _, value := range values {
+ fmt.Fprintf(b, "- `%s`\n", value)
+ }
+ fmt.Fprintf(b, "\n")
+}
+
+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, "- Workflow memory items promoted: %d\n\n", countPromotedMemoryItems(scan.WorkflowMemories))
+}
+
+func writeProjectDocs(
+ ctx context.Context,
+ root string,
+ docs []generatedProjectDoc,
+ cfg model.ProjectAdoptionConfig,
+ result *model.ProjectAdoptionResult,
+) error {
+ projectDir := model.ProjectBaseDirForWorkspace(root)
+ for _, doc := range docs {
+ if err := ctx.Err(); err != nil {
+ return fmt.Errorf("write project docs: %w", err)
+ }
+ path := filepath.Join(projectDir, doc.Name)
+ rel := filepath.ToSlash(filepath.Join(model.WorkflowRootDirName, model.WorkflowProjectDirName, doc.Name))
+ action, err := classifyProjectDocWrite(path, cfg.Force)
+ if err != nil {
+ return err
+ }
+ switch action {
+ case "skip":
+ result.Skipped = append(result.Skipped, rel)
+ result.Warnings = append(
+ result.Warnings,
+ rel+" exists and is not marked as Productize-generated; use --force to overwrite",
+ )
+ continue
+ case "create":
+ result.Created = append(result.Created, rel)
+ case "update":
+ result.Updated = append(result.Updated, rel)
+ }
+ if cfg.DryRun {
+ continue
+ }
+ if err := os.MkdirAll(projectDir, 0o755); err != nil {
+ return fmt.Errorf("prepare project knowledge dir: %w", err)
+ }
+ if err := writeProjectDocAtomically(path, []byte(doc.Content)); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func classifyProjectDocWrite(path string, force bool) (string, error) {
+ content, err := os.ReadFile(path)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return "create", nil
+ }
+ return "", fmt.Errorf("read existing project doc: %w", err)
+ }
+ if force || strings.Contains(string(content), projectKnowledgeMarker) {
+ return "update", nil
+ }
+ return "skip", nil
+}
+
+func writeProjectDocAtomically(path string, content []byte) error {
+ dir := filepath.Dir(path)
+ tmp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp-*")
+ if err != nil {
+ return fmt.Errorf("create project doc temp file: %w", err)
+ }
+ tmpPath := tmp.Name()
+ cleanup := func() {
+ _ = tmp.Close()
+ _ = os.Remove(tmpPath)
+ }
+ if _, err := tmp.Write(content); err != nil {
+ cleanup()
+ return fmt.Errorf("write project doc temp file: %w", err)
+ }
+ if err := tmp.Close(); err != nil {
+ _ = os.Remove(tmpPath)
+ return fmt.Errorf("close project doc temp file: %w", err)
+ }
+ if err := os.Chmod(tmpPath, 0o644); err != nil {
+ _ = os.Remove(tmpPath)
+ return fmt.Errorf("chmod project doc temp file: %w", err)
+ }
+ if err := os.Rename(tmpPath, path); err != nil {
+ _ = os.Remove(tmpPath)
+ return fmt.Errorf("replace project doc: %w", err)
+ }
+ return nil
+}
+
+func countPromotedMemoryItems(memories []promotedWorkflowMemory) int {
+ count := 0
+ for _, memory := range memories {
+ for _, section := range memory.Sections {
+ count += len(section.Lines)
+ }
+ }
+ return count
+}
+
+func pathExists(path string) bool {
+ _, err := os.Stat(path)
+ return err == nil
+}
+
+func regularFileExists(path string) bool {
+ info, err := os.Stat(path)
+ return err == nil && info.Mode().IsRegular()
+}
+
+func inlineList(values []string, empty string) string {
+ if len(values) == 0 {
+ return empty
+ }
+ quoted := make([]string, 0, len(values))
+ for _, value := range values {
+ quoted = append(quoted, "`"+value+"`")
+ }
+ return strings.Join(quoted, ", ")
+}
+
+func uniqueStrings(values []string) []string {
+ if len(values) == 0 {
+ return nil
+ }
+ out := values[:0]
+ var previous string
+ for i, value := range values {
+ if i > 0 && value == previous {
+ continue
+ }
+ out = append(out, value)
+ previous = value
+ }
+ return out
+}
diff --git a/internal/core/adoption_test.go b/internal/core/adoption_test.go
new file mode 100644
index 00000000..31e6ae78
--- /dev/null
+++ b/internal/core/adoption_test.go
@@ -0,0 +1,196 @@
+package core
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/itseffi/productize/internal/core/model"
+)
+
+func TestAdoptExistingProjectCreatesProjectKnowledgeFromRepoFacts(t *testing.T) {
+ t.Parallel()
+
+ root := t.TempDir()
+ writeTestFile(t, root, "go.mod", "module example.com/app\n")
+ writeTestFile(t, root, "package.json", `{"scripts":{"build":"vite build","test":"vitest","lint":"eslint ."}}`)
+ writeTestFile(t, root, "pnpm-lock.yaml", "lockfileVersion: '9.0'\n")
+ writeTestFile(t, root, "Makefile", "verify:\n\tgo test ./...\ntest:\n\tgo test ./...\n")
+ writeTestFile(t, root, "README.md", "# Demo\n")
+ writeTestFile(t, root, "docs/overview.md", "# Overview\n")
+ writeTestFile(t, root, "AGENTS.md", "# Instructions\n")
+ writeTestFile(t, root, ".cursor/rules/style.md", "# Cursor rules\n")
+ writeTestFile(t, root, "node_modules/pkg/ignored.md", "ignored\n")
+ writeTestFile(t, root, ".productize/runs/run-1/ignored.md", "ignored\n")
+ writeTestFile(t, root, ".productize/tasks/feature/adrs/adr-001.md", acceptedADR("Use project context"))
+ writeTestFile(t, root, ".productize/tasks/feature/memory/MEMORY.md", `# Workflow Memory
+
+## Current State
+
+Ignore this current state.
+
+## Shared Decisions
+
+- Use durable context before planning.
+
+## Shared Learnings
+
+- Existing Makefile owns verification.
+
+## Open Risks
+
+- Context can go stale.
+
+## Handoffs
+
+- Refresh before a major workflow.
+`)
+ writeTestFile(t, root, ".productize/tasks/feature/memory/task_01.md", "task-local detail must not promote\n")
+ writeTestFile(
+ t,
+ root,
+ ".productize/tasks/_archived/1700000000-old/adrs/adr-002.md",
+ acceptedADR("Keep archived decisions visible"),
+ )
+
+ result, err := adoptExistingProject(context.Background(), model.ProjectAdoptionConfig{WorkspaceRoot: root})
+ if err != nil {
+ t.Fatalf("adoptExistingProject: %v", err)
+ }
+ 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 {
+ t.Fatalf("PromotedADRs = %d, want 2", result.PromotedADRs)
+ }
+ if result.PromotedMemoryItems != 4 {
+ 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)
+ }
+
+ decisions := readTestFile(t, root, ".productize/project/decisions.md")
+ for _, snippet := range []string{
+ "feature/adrs/adr-001.md",
+ "Use project context",
+ "_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)
+ }
+ }
+ 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)
+ }
+}
+
+func TestAdoptExistingProjectHonorsDryRunAndOverwriteSafety(t *testing.T) {
+ t.Parallel()
+
+ root := t.TempDir()
+ writeTestFile(t, root, "go.mod", "module example.com/app\n")
+
+ dryRun, err := adoptExistingProject(context.Background(), model.ProjectAdoptionConfig{
+ WorkspaceRoot: root,
+ DryRun: true,
+ })
+ 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 _, err := os.Stat(model.ProjectBaseDirForWorkspace(root)); !os.IsNotExist(err) {
+ t.Fatalf("expected dry-run to avoid creating project dir, stat err=%v", err)
+ }
+
+ writeTestFile(t, root, ".productize/project/context.md", "# Human Context\n")
+ result, err := adoptExistingProject(context.Background(), model.ProjectAdoptionConfig{WorkspaceRoot: root})
+ if err != nil {
+ t.Fatalf("adoptExistingProject(unmarked): %v", err)
+ }
+ if !containsString(result.Skipped, ".productize/project/context.md") {
+ t.Fatalf("expected unmarked context doc to be skipped: %#v", result.Skipped)
+ }
+ if content := readTestFile(t, root, ".productize/project/context.md"); content != "# Human Context\n" {
+ t.Fatalf("expected unmarked file to remain unchanged, got %q", content)
+ }
+
+ forced, err := adoptExistingProject(context.Background(), model.ProjectAdoptionConfig{
+ WorkspaceRoot: root,
+ Force: true,
+ })
+ if err != nil {
+ t.Fatalf("adoptExistingProject(force): %v", err)
+ }
+ if !containsString(forced.Updated, ".productize/project/context.md") {
+ t.Fatalf("expected forced context doc to be updated: %#v", forced.Updated)
+ }
+ if content := readTestFile(
+ t,
+ root,
+ ".productize/project/context.md",
+ ); !strings.Contains(
+ content,
+ projectKnowledgeMarker,
+ ) {
+ t.Fatalf("expected forced file to become generated project knowledge, got %q", content)
+ }
+}
+
+func acceptedADR(title string) string {
+ return "# ADR-001: " + title + "\n\n## Status\n\nAccepted\n\n## Decision\n\nUse it.\n"
+}
+
+func writeTestFile(t *testing.T, root, rel, content string) {
+ t.Helper()
+ path := filepath.Join(root, filepath.FromSlash(rel))
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
+ }
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+ t.Fatalf("write %s: %v", path, err)
+ }
+}
+
+func readTestFile(t *testing.T, root, rel string) string {
+ t.Helper()
+ content, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(rel)))
+ if err != nil {
+ t.Fatalf("read %s: %v", rel, err)
+ }
+ return string(content)
+}
+
+func containsString(values []string, want string) bool {
+ for _, value := range values {
+ if value == want {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/core/api.go b/internal/core/api.go
index 07ce4c82..ed966e63 100644
--- a/internal/core/api.go
+++ b/internal/core/api.go
@@ -171,10 +171,14 @@ type SyncConfig = model.SyncConfig
type ArchiveConfig = model.ArchiveConfig
+type ProjectAdoptionConfig = model.ProjectAdoptionConfig
+
type SyncResult = model.SyncResult
type ArchiveResult = model.ArchiveResult
+type ProjectAdoptionResult = model.ProjectAdoptionResult
+
// Validate ensures the configuration is internally consistent.
func (cfg Config) Validate() error {
if cfg.TailLines < 0 {
@@ -228,6 +232,10 @@ func Archive(ctx context.Context, cfg ArchiveConfig) (*ArchiveResult, error) {
return ArchiveDirect(ctx, cfg)
}
+func AdoptExistingProject(ctx context.Context, cfg ProjectAdoptionConfig) (*ProjectAdoptionResult, error) {
+ return adoptExistingProject(ctx, cfg)
+}
+
// FetchReviewsDirect preserves access to the pre-dispatch fetch implementation for kernel handlers.
func FetchReviewsDirect(ctx context.Context, cfg Config) (*FetchResult, error) {
return fetchReviews(ctx, cfg.runtime())
diff --git a/internal/core/model/constants.go b/internal/core/model/constants.go
index d4409c9c..98c4912e 100644
--- a/internal/core/model/constants.go
+++ b/internal/core/model/constants.go
@@ -22,9 +22,15 @@ const (
DefaultActivityTimeout = 10 * time.Minute
WorkflowRootDirName = ".productize"
WorkflowConfigFileName = "config.toml"
+ WorkflowProjectDirName = "project"
WorkflowTasksDirName = "tasks"
WorkflowRunsDirName = "runs"
ArchivedWorkflowDirName = "_archived"
+ ProjectInventoryFileName = "inventory.md"
+ ProjectContextFileName = "context.md"
+ ProjectConventionsName = "conventions.md"
+ ProjectArchitectureName = "architecture.md"
+ ProjectDecisionsFileName = "decisions.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 a1fb9d3e..41e520a7 100644
--- a/internal/core/model/workflow_ops.go
+++ b/internal/core/model/workflow_ops.go
@@ -50,6 +50,12 @@ type ArchiveConfig struct {
Force bool
}
+type ProjectAdoptionConfig struct {
+ WorkspaceRoot string
+ DryRun bool
+ Force bool
+}
+
type SyncResult struct {
Target string
WorkflowsScanned int
@@ -67,6 +73,17 @@ type SyncResult struct {
Warnings []string
}
+type ProjectAdoptionResult struct {
+ WorkspaceRoot string `json:"workspace_root"`
+ ProjectDir string `json:"project_dir"`
+ Created []string `json:"created"`
+ Updated []string `json:"updated"`
+ Skipped []string `json:"skipped"`
+ Warnings []string `json:"warnings"`
+ PromotedADRs int `json:"promoted_adrs"`
+ PromotedMemoryItems int `json:"promoted_memory_items"`
+}
+
type ArchiveResult struct {
Target string `json:"target"`
ArchiveRoot string `json:"archive_root"`
diff --git a/internal/core/model/workspace_paths.go b/internal/core/model/workspace_paths.go
index cbbcab97..64c0aba8 100644
--- a/internal/core/model/workspace_paths.go
+++ b/internal/core/model/workspace_paths.go
@@ -28,6 +28,30 @@ func ConfigPathForWorkspace(workspaceRoot string) string {
return filepath.Join(ProductizeDir(workspaceRoot), WorkflowConfigFileName)
}
+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)
+}
+
+func ProjectConventionsPathForWorkspace(workspaceRoot string) string {
+ return filepath.Join(ProjectBaseDirForWorkspace(workspaceRoot), ProjectConventionsName)
+}
+
+func ProjectArchitecturePathForWorkspace(workspaceRoot string) string {
+ return filepath.Join(ProjectBaseDirForWorkspace(workspaceRoot), ProjectArchitectureName)
+}
+
+func ProjectDecisionsPathForWorkspace(workspaceRoot string) string {
+ return filepath.Join(ProjectBaseDirForWorkspace(workspaceRoot), ProjectDecisionsFileName)
+}
+
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 a5e3dac1..f4ab24d1 100644
--- a/internal/core/prompt/prd.go
+++ b/internal/core/prompt/prd.go
@@ -2,6 +2,7 @@ package prompt
import (
"fmt"
+ "os"
"path/filepath"
"strings"
@@ -31,6 +32,7 @@ func buildPRDTaskPrompt(task model.IssueEntry, autoCommit bool, memory *Workflow
buildTaskContextSection(taskData),
buildPRDRequiredSkillsSection(),
buildPRDExecutionRulesSection(prdDir, autoCommit),
+ buildProjectKnowledgeSection(task.AbsPath),
buildWorkflowMemorySection(memory),
fmt.Sprintf("## Task Specification\n\n%s", task.Content),
buildTaskFilesSection(task.AbsPath, tasksFile, prdDir, autoCommit),
@@ -150,6 +152,59 @@ func buildWorkflowMemorySection(memory *WorkflowMemoryContext) string {
return sb.String()
}
+func buildProjectKnowledgeSection(taskAbsPath string) string {
+ workspaceRoot := workspaceRootFromTaskPath(taskAbsPath)
+ if workspaceRoot == "" {
+ return ""
+ }
+
+ paths := []struct {
+ label string
+ path string
+ }{
+ {label: "Project context", path: model.ProjectContextPathForWorkspace(workspaceRoot)},
+ {label: "Project conventions", path: model.ProjectConventionsPathForWorkspace(workspaceRoot)},
+ {label: "Project decisions", path: model.ProjectDecisionsPathForWorkspace(workspaceRoot)},
+ }
+
+ var existing []string
+ for _, item := range paths {
+ if _, err := os.Stat(item.path); err == nil {
+ existing = append(existing, fmt.Sprintf("- %s: `%s`", item.label, item.path))
+ }
+ }
+ if len(existing) == 0 {
+ return ""
+ }
+
+ var sb strings.Builder
+ sb.WriteString("## Project Knowledge\n\n")
+ sb.WriteString(strings.Join(existing, "\n"))
+ sb.WriteString("\n")
+ sb.WriteString("- Read these project knowledge files before implementation.\n")
+ sb.WriteString("- Treat them as durable repo context; task-specific PRD and TechSpec files remain ")
+ sb.WriteString("the source of truth for this task.\n")
+ return sb.String()
+}
+
+func workspaceRootFromTaskPath(taskAbsPath string) string {
+ clean := filepath.Clean(strings.TrimSpace(taskAbsPath))
+ if clean == "" || clean == "." {
+ return ""
+ }
+ normalized := filepath.ToSlash(clean)
+ needle := "/" + model.WorkflowRootDirName + "/" + model.WorkflowTasksDirName + "/"
+ idx := strings.Index(normalized, needle)
+ if idx < 0 {
+ return ""
+ }
+ root := normalized[:idx]
+ if root == "" {
+ return string(os.PathSeparator)
+ }
+ return filepath.Clean(filepath.FromSlash(root))
+}
+
func buildPRDSystemPromptAddendum(memory *WorkflowMemoryContext) string {
if memory == nil {
return ""
diff --git a/internal/core/prompt/prompt_test.go b/internal/core/prompt/prompt_test.go
index 347518c6..97225790 100644
--- a/internal/core/prompt/prompt_test.go
+++ b/internal/core/prompt/prompt_test.go
@@ -2,6 +2,8 @@ package prompt
import (
"context"
+ "os"
+ "path/filepath"
"reflect"
"regexp"
"strings"
@@ -189,6 +191,53 @@ complexity: low
}
}
+func TestBuildPRDTaskPromptIncludesProjectKnowledgeWhenPresent(t *testing.T) {
+ t.Parallel()
+
+ root := t.TempDir()
+ projectDir := model.ProjectBaseDirForWorkspace(root)
+ if err := os.MkdirAll(projectDir, 0o755); err != nil {
+ t.Fatalf("mkdir project dir: %v", err)
+ }
+ for _, path := range []string{
+ model.ProjectContextPathForWorkspace(root),
+ model.ProjectConventionsPathForWorkspace(root),
+ model.ProjectDecisionsPathForWorkspace(root),
+ } {
+ if err := os.WriteFile(path, []byte("# Project Knowledge\n"), 0o644); err != nil {
+ t.Fatalf("write project knowledge %s: %v", path, err)
+ }
+ }
+
+ taskPath := filepath.Join(root, ".productize", "tasks", "demo", "task_1.md")
+ task := model.IssueEntry{
+ Name: "task_1.md",
+ AbsPath: taskPath,
+ Content: `---
+status: pending
+title: Example
+type: backend
+complexity: low
+---
+
+# Task 1: Example
+`,
+ }
+
+ promptText := buildPRDTaskPrompt(task, false, nil)
+ for _, snippet := range []string{
+ "## Project Knowledge",
+ "Project context: `" + model.ProjectContextPathForWorkspace(root) + "`",
+ "Project conventions: `" + model.ProjectConventionsPathForWorkspace(root) + "`",
+ "Project decisions: `" + model.ProjectDecisionsPathForWorkspace(root) + "`",
+ "Read these project knowledge files before implementation.",
+ } {
+ if !strings.Contains(promptText, snippet) {
+ t.Fatalf("expected PRD prompt to include %q\n%s", snippet, promptText)
+ }
+ }
+}
+
func TestBuildPRDTaskPromptRespectsAutoCommitFlag(t *testing.T) {
t.Parallel()
diff --git a/skills/create-prd/SKILL.md b/skills/create-prd/SKILL.md
index 62a51e36..d73f99ca 100644
--- a/skills/create-prd/SKILL.md
+++ b/skills/create-prd/SKILL.md
@@ -66,12 +66,14 @@ 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.
- 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.
- 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.
diff --git a/skills/create-tasks/SKILL.md b/skills/create-tasks/SKILL.md
index bc80acfb..1e6168d9 100644
--- a/skills/create-tasks/SKILL.md
+++ b/skills/create-tasks/SKILL.md
@@ -21,6 +21,7 @@ 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 `_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:
@@ -31,6 +32,7 @@ Decompose requirements into detailed, actionable task files with codebase-inform
- Explicitly call out missing implementation detail gaps in the task body instead of inventing specifics.
- If both `_prd.md` and `_techspec.md` are missing, stop and ask the user to create at least one first.
- Spawn an Agent tool call to explore the codebase for files to create or modify, test patterns, and coding conventions.
+ - Use project knowledge files to seed exploration; verify relevant facts against the current repository before writing task details.
3. Break down into tasks.
- Decompose implementation sections from the TechSpec into granular, independently implementable tasks.
diff --git a/skills/create-techspec/SKILL.md b/skills/create-techspec/SKILL.md
index 1c1fe929..9131c1dc 100644
--- a/skills/create-techspec/SKILL.md
+++ b/skills/create-techspec/SKILL.md
@@ -50,6 +50,7 @@ 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.
- 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.
diff --git a/skills/productize-runtime/SKILL.md b/skills/productize-runtime/SKILL.md
index 92260fd3..7a750b7b 100644
--- a/skills/productize-runtime/SKILL.md
+++ b/skills/productize-runtime/SKILL.md
@@ -24,14 +24,15 @@ Key characteristics:
The standard development pipeline follows these phases in order. Each phase produces artifacts consumed by the next.
1. **Setup** -- `productize setup` installs core skills into target agents plus any setup assets shipped by enabled extensions.
-2. **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`.
-3. **Requirements** -- `/create-prd` creates a business-focused Product Requirements Document at `.productize/tasks//_prd.md` with ADRs.
-4. **Technical Design** -- `/create-techspec` translates the PRD into a technical specification at `.productize/tasks//_techspec.md` with ADRs.
-5. **Task Decomposition** -- `/create-tasks` breaks down the PRD and TechSpec into independently implementable task files (`task_01.md`, `task_02.md`, etc.) and a master list at `_tasks.md`.
-6. **Execution** -- `productize tasks run --ide ` dispatches task files sequentially to the configured AI agent for implementation.
-7. **Review** -- `/review-round` (manual AI review) or `productize reviews fetch --provider coderabbit --pr ` (external provider) produces review issue files under `reviews-NNN/`.
-8. **Remediation** -- `productize reviews fix ` processes review issues, triages, fixes, and verifies each one.
-9. **Archive** -- `productize archive --name ` moves fully completed workflows to `.productize/tasks/_archived/`.
+2. **Existing Project Adoption** (recommended for mature repos) -- `productize init existing` writes durable repo context 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.
+6. **Task Decomposition** -- `/create-tasks` breaks down the PRD and TechSpec into independently implementable task files (`task_01.md`, `task_02.md`, etc.) and a master list at `_tasks.md`.
+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/`.
Repeat phases 7-8 until the review is clean, then merge.
@@ -68,6 +69,7 @@ For a detailed step-by-step walkthrough of each phase, read `references/workflow
| Command | Purpose | Key Flags |
| --- | --- | --- |
| **Setup & Config** | | |
+| `productize init existing` | Adopt an existing repository into `.productize/project/` knowledge docs | `--dry-run`, `--force`, `--format` |
| `productize setup` | Install core skills and enabled extension assets | `--agent`, `--skill`, `--global`, `--copy`, `--list`, `--all`, `--yes` |
| `productize upgrade` | Update CLI to latest release | |
| **Workflow Execution** | | |
@@ -142,6 +144,12 @@ 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
+ decisions.md # Promoted ADRs and durable workflow memory
tasks/
/ # One directory per workflow
_idea.md # Idea spec (from idea-forge)
@@ -244,7 +252,8 @@ Management: `productize ext list`, `productize ext inspect `, `productize
## Common Patterns
- Run `productize setup` before starting any workflow to ensure core skills and enabled extension assets are installed.
-- Follow the pipeline in order: idea (optional) -> PRD -> TechSpec -> Tasks -> Execution -> Review -> Fix.
+- For mature repositories, run `productize init existing` before PRD creation so future skills can read `.productize/project/` context.
+- Follow the pipeline in order: adoption (existing repos) -> idea (optional) -> PRD -> TechSpec -> Tasks -> Execution -> Review -> Fix.
- Configure workspace defaults in `.productize/config.toml` to reduce repetitive CLI flags.
- Run `productize tasks validate --name ` before `productize tasks run` to catch metadata issues early.
- Use `productize archive` to clean up fully completed workflows and keep the tasks directory focused.
diff --git a/skills/productize-runtime/references/cli-reference.md b/skills/productize-runtime/references/cli-reference.md
index c5e4626f..c6525b76 100644
--- a/skills/productize-runtime/references/cli-reference.md
+++ b/skills/productize-runtime/references/cli-reference.md
@@ -19,6 +19,27 @@ These flags are shared by `tasks run`, `exec`, and `reviews fix`:
## Setup & Config
+### `productize init existing`
+
+Adopt an existing repository into Productize project knowledge.
+
+| Flag | Type | Default | Description |
+| --- | --- | --- | --- |
+| `[path]` | string | current workspace | Repository root to scan |
+| `--dry-run` | bool | false | Preview generated project knowledge without writing files |
+| `--force` | bool | false | Overwrite existing unmarked project knowledge files |
+| `--format` | string | text | Output format: text or json |
+
+```
+productize init existing
+productize init existing ../my-app --dry-run
+productize init existing --format json
+```
+
+Writes `.productize/project/inventory.md`, `context.md`, `conventions.md`,
+`architecture.md`, and `decisions.md`. The command is deterministic and does
+not invoke an AI model.
+
### `productize setup`
Install core workflow skills into target agents plus any setup assets shipped by enabled extensions.
diff --git a/skills/productize-runtime/references/workflow-guide.md b/skills/productize-runtime/references/workflow-guide.md
index 552d50e8..030d8619 100644
--- a/skills/productize-runtime/references/workflow-guide.md
+++ b/skills/productize-runtime/references/workflow-guide.md
@@ -9,6 +9,21 @@ End-to-end walkthrough of the Productize development pipeline from setup through
3. **Install optional ideation extension when needed.** To use `/idea-forge`, run `productize ext install --yes itseffi/productize --remote github --ref --subdir extensions/idea-forge`, then `productize ext enable idea-forge`, then `productize setup` again.
4. **Configure workspace (optional).** Create `.productize/config.toml` to set default IDE, model, and other preferences. Read `config-reference.md` for all fields.
+## Phase 0: Existing Project Adoption (Recommended for Mature Repos)
+
+**Command:** `productize init existing [path]`
+
+Use before PRD creation when the repository already has meaningful structure,
+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.
+
+**Output:** `inventory.md`, `context.md`, `conventions.md`, `architecture.md`, and `decisions.md`.
+
+The command is deterministic and does not invoke an AI model.
+
## Phase 1: Ideation (Optional)
**Skill:** `/idea-forge [feature-idea]`
diff --git a/skills/productize/SKILL.md b/skills/productize/SKILL.md
index 9546f1c7..ac2dc237 100644
--- a/skills/productize/SKILL.md
+++ b/skills/productize/SKILL.md
@@ -84,6 +84,7 @@ Use the smallest entry point that owns the cadence:
**Playbooks**
+- `/productize adopt`: existing repository adoption. Run `productize init existing`, read generated `.productize/project/` context, then recommend the next workflow route.
- `/productize-0-1`: new bet or new capability; closes at ship gate, pivot, pause, or kill.
- `/productize-operate`: production deploy; continuous operating loop that does not close.
- `/productize-grow`: stable product with activation evidence; closes when the growth target is hit or the strategy pivots.
@@ -113,6 +114,7 @@ Use the smallest entry point that owns the cadence:
| User intent | Route | Signals |
|---|---|---|
| I have an idea | Think | thesis framing, opportunity, wedge |
+| I need to use Productize on an existing repo | Adopt Existing Project | adopt, existing project, mature repo, repo context, project memory |
| I need positioning/growth | Strategize / Growth | positioning, brand, PMF, AARRR, CAC/LTV |
| I need a PRD/spec | Plan / Build With AI | PRDs, requirements, technical handoff, implementation plan |
| I need research | Discover | JTBD, interviews, assumptions, ICP, insight synthesis |
@@ -139,6 +141,7 @@ Use the smallest entry point that owns the cadence:
1. Classify the user's persona, product stage, artifact mode, evidence state, and
decision mode.
+ - 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.
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,
From 361398af522694faafe2728b27b725450b9e8ea4 Mon Sep 17 00:00:00 2001
From: itseffi <15998472+itseffi@users.noreply.github.com>
Date: Mon, 15 Jun 2026 13:30:02 +0200
Subject: [PATCH 2/3] fix ci lint constant
---
internal/setup/catalog_export.go | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/internal/setup/catalog_export.go b/internal/setup/catalog_export.go
index edbb55ec..ea39492c 100644
--- a/internal/setup/catalog_export.go
+++ b/internal/setup/catalog_export.go
@@ -19,6 +19,8 @@ type SkillsCatalogEntry struct {
Origin AssetOrigin `json:"origin"`
}
+const productizeSkillName = "productize"
+
// WriteSkillsCatalog writes a local Productize skill catalog for the selected
// setup scope and returns the path that was written.
func WriteSkillsCatalog(options ResolverOptions, global bool, skills []Skill) (string, error) {
@@ -64,7 +66,7 @@ func WriteSkillsCatalog(options ResolverOptions, global bool, skills []Skill) (s
// SkillCatalogTier returns the public catalog tier for a bundled/setup skill.
func SkillCatalogTier(name string) string {
switch name {
- case "productize",
+ case productizeSkillName,
"productize-0-1",
"productize-operate",
"productize-grow",
From e8a9a051097b7e4eb6cb9f2252eb17351b1c150d Mon Sep 17 00:00:00 2001
From: itseffi <15998472+itseffi@users.noreply.github.com>
Date: Mon, 15 Jun 2026 14:45:06 +0200
Subject: [PATCH 3/3] fix ci docs fixtures
---
docs/configuration.md | 108 +++
docs/events.md | 694 ++++++++++++++++++
docs/examples/agents/reviewer/AGENT.md | 10 +
docs/extensibility/architecture.md | 83 +++
docs/extensibility/capability-reference.md | 51 ++
docs/extensibility/getting-started.md | 100 +++
docs/extensibility/hello-world-go.md | 97 +++
docs/extensibility/hello-world-ts.md | 101 +++
docs/extensibility/hook-reference.md | 128 ++++
docs/extensibility/host-api-reference.md | 113 +++
docs/extensibility/index.md | 61 ++
docs/extensibility/migration-guide.md | 30 +
docs/extensibility/testing.md | 86 +++
docs/extensibility/trust-and-enablement.md | 72 ++
docs/reader-library.md | 138 ++++
docs/reusable-agents.md | 149 ++++
docs/skills.md | 70 ++
internal/cli/agents_commands_test.go | 3 +-
internal/cli/migrate_command_test.go | 4 +
.../cli/testdata/acp-integration/task_01.md | 10 +
.../cli/testdata/acp-integration/task_02.md | 12 +
.../cli/testdata/acp-integration/task_03.md | 12 +
22 files changed, 2130 insertions(+), 2 deletions(-)
create mode 100644 docs/configuration.md
create mode 100644 docs/events.md
create mode 100644 docs/examples/agents/reviewer/AGENT.md
create mode 100644 docs/extensibility/architecture.md
create mode 100644 docs/extensibility/capability-reference.md
create mode 100644 docs/extensibility/getting-started.md
create mode 100644 docs/extensibility/hello-world-go.md
create mode 100644 docs/extensibility/hello-world-ts.md
create mode 100644 docs/extensibility/hook-reference.md
create mode 100644 docs/extensibility/host-api-reference.md
create mode 100644 docs/extensibility/index.md
create mode 100644 docs/extensibility/migration-guide.md
create mode 100644 docs/extensibility/testing.md
create mode 100644 docs/extensibility/trust-and-enablement.md
create mode 100644 docs/reader-library.md
create mode 100644 docs/reusable-agents.md
create mode 100644 docs/skills.md
create mode 100644 internal/cli/testdata/acp-integration/task_01.md
create mode 100644 internal/cli/testdata/acp-integration/task_02.md
create mode 100644 internal/cli/testdata/acp-integration/task_03.md
diff --git a/docs/configuration.md b/docs/configuration.md
new file mode 100644
index 00000000..b556b034
--- /dev/null
+++ b/docs/configuration.md
@@ -0,0 +1,108 @@
+# Configuration Reference
+
+Productize can load global defaults from `~/.productize/config.toml` and
+override them per workspace with `.productize/config.toml`.
+
+- The CLI discovers the nearest `.productize/` directory by walking upward from
+ the current working directory.
+- If `~/.productize/config.toml` exists, Productize loads it once at command
+ startup.
+- If `.productize/config.toml` exists in the resolved workspace, it overrides
+ the global config field by field.
+- Explicit CLI flags always win over config values.
+
+Precedence:
+
+```text
+explicit flags > workspace command section > workspace [defaults] > global command section > global [defaults] > built-in defaults
+```
+
+Example:
+
+```toml
+[defaults]
+ide = "codex"
+model = "gpt-5.5"
+reasoning_effort = "medium"
+output_format = "text"
+access_mode = "full"
+timeout = "10m"
+tail_lines = 0
+add_dirs = ["../shared"]
+auto_commit = false
+max_retries = 2
+retry_backoff_multiplier = 1.5
+
+[tasks]
+types = ["frontend", "backend", "docs", "test", "infra", "refactor", "chore", "bugfix"]
+
+[tasks.run]
+include_completed = false
+output_format = "text"
+
+[exec]
+output_format = "text"
+verbose = false
+persist = false
+
+[fix_reviews]
+concurrent = 2
+batch_size = 3
+include_resolved = false
+output_format = "text"
+
+[fetch_reviews]
+provider = "coderabbit"
+nitpicks = false
+
+[watch_reviews]
+max_rounds = 5
+poll_interval = "30s"
+review_timeout = "30m"
+quiet_period = "2m"
+until_clean = true
+auto_push = false
+
+[runs]
+default_attach_mode = "stream"
+keep_terminal_days = 30
+keep_max = 500
+```
+
+## Supported Sections
+
+- `[defaults]` for shared execution defaults such as `ide`, `model`,
+ `reasoning_effort`, `output_format`, `access_mode`, `timeout`, `tail_lines`,
+ `add_dirs`, `auto_commit`, `max_retries`, and `retry_backoff_multiplier`
+- `[exec]` for `output_format`, `verbose`, `persist`, plus exec-specific
+ runtime overrides such as `ide`, `model`, `reasoning_effort`, `access_mode`,
+ `timeout`, `tail_lines`, `add_dirs`, `max_retries`, and
+ `retry_backoff_multiplier`
+- `[tasks]` for the allowed task `type` list used by `create-tasks` and
+ `productize tasks validate`
+- `[tasks.run]` for workflow-run defaults used by `productize tasks run`, such
+ as `include_completed`, `output_format`, and `task_runtime_rules`
+- `[fix_reviews]` for `concurrent`, `batch_size`, `include_resolved`, and
+ `output_format`
+- `[fetch_reviews]` for `provider` and `nitpicks`
+- `[watch_reviews]` for `max_rounds`, `poll_interval`, `review_timeout`,
+ `quiet_period`, `auto_push`, `until_clean`, `push_remote`, and `push_branch`
+- `[runs]` for `default_attach_mode`, `keep_terminal_days`, `keep_max`, and
+ `shutdown_drain_timeout`
+- `[sound]` for optional run-completion audio presets or absolute file paths
+
+## Notes
+
+- Both `~/.productize/config.toml` and `.productize/config.toml` are optional.
+ If both are absent, Productize keeps the built-in defaults.
+- `.productize/tasks` remains the fixed workflow root in this version.
+- Unknown keys and invalid value types are rejected during config loading.
+- Relative `add_dirs` are resolved against the owning config scope: the user
+ home directory for `~/.productize/config.toml` and the workspace root for
+ `.productize/config.toml`.
+- `max_retries` applies to execution-stage ACP failures and inactivity timeouts
+ for `productize exec`, `productize tasks run`, and `productize reviews fix`.
+- Built-in CLI defaults retry timed-out or transient ACP failures twice. Set
+ `max_retries = 0` or pass `--max-retries 0` to opt out.
+- `retry_backoff_multiplier` only increases the next attempt timeout. Retries
+ restart immediately and do not add a sleep delay.
diff --git a/docs/events.md b/docs/events.md
new file mode 100644
index 00000000..fa5f987a
--- /dev/null
+++ b/docs/events.md
@@ -0,0 +1,694 @@
+# Productize Event Taxonomy
+
+This document is the canonical public reference for the `pkg/productize/events` envelope and the payloads under `pkg/productize/events/kinds`.
+
+All event payload fields use their JSON tag names below. Fields tagged with `omitempty` are omitted when their value is empty or zero.
+
+## Envelope
+
+Every line in `events.jsonl` is one `events.Event` object:
+
+| Field | Type | Description |
+| ---------------- | ------------------- | ---------------------------------------------------------------------- |
+| `schema_version` | `string` | Current schema version. The current public value is `1.0`. |
+| `run_id` | `string` | Stable identifier for the workflow or exec run that emitted the event. |
+| `seq` | `uint64` | Monotonic sequence number within a run. |
+| `ts` | `RFC3339 timestamp` | Event timestamp in UTC. |
+| `kind` | `string` | One of the 51 public event kinds below. |
+| `payload` | `object` | Kind-specific payload from `pkg/productize/events/kinds`. |
+
+## Run Events
+
+### `run.queued`
+
+Payload type: `kinds.RunQueuedPayload`
+
+- `mode`: execution mode such as `prd-tasks`, `pr-review`, or `exec`
+- `name`: workflow name when the run is workflow-backed
+- `workspace_root`: resolved workspace root
+- `ide`: configured ACP runtime id
+- `model`: effective model name
+- `reasoning_effort`: effective reasoning level
+- `access_mode`: effective runtime access mode
+
+### `run.started`
+
+Payload type: `kinds.RunStartedPayload`
+
+- `mode`
+- `name`
+- `workspace_root`
+- `ide`
+- `model`
+- `reasoning_effort`
+- `access_mode`
+- `artifacts_dir`: run artifact directory under `~/.productize/runs/`
+- `jobs_total`: number of prepared jobs
+
+### `run.crashed`
+
+Payload type: `kinds.RunCrashedPayload`
+
+- `artifacts_dir`
+- `duration_ms`
+- `error`
+- `result_path`
+
+### `run.completed`
+
+Payload type: `kinds.RunCompletedPayload`
+
+- `artifacts_dir`
+- `jobs_total`
+- `jobs_succeeded`
+- `jobs_failed`
+- `jobs_canceled`
+- `duration_ms`
+- `result_path`: path to `result.json`
+- `summary_message`
+
+### `run.failed`
+
+Payload type: `kinds.RunFailedPayload`
+
+- `artifacts_dir`
+- `duration_ms`
+- `error`
+- `result_path`
+
+### `run.cancelled`
+
+Payload type: `kinds.RunCancelledPayload`
+
+- `reason`
+- `requested_by`
+- `duration_ms`
+
+## Job Events
+
+### `job.queued`
+
+Payload type: `kinds.JobQueuedPayload`
+
+- `index`: zero-based job index within the run
+- `code_file`: primary code file for a single-file job
+- `code_files`: grouped code files for a batch
+- `issues`: number of issue entries represented by the job
+- `task_title`: parsed PRD task title when available
+- `task_type`: parsed PRD task type when available
+- `safe_name`: artifact-safe job name
+- `out_log`: stdout log path
+- `err_log`: stderr log path
+
+### `job.started`
+
+Payload type: `kinds.JobStartedPayload`
+
+- `index`
+- `attempt`
+- `max_attempts`
+
+### `job.attempt_started`
+
+Payload type: `kinds.JobAttemptStartedPayload`
+
+- `index`
+- `attempt`
+- `max_attempts`
+
+### `job.attempt_finished`
+
+Payload type: `kinds.JobAttemptFinishedPayload`
+
+- `index`
+- `attempt`
+- `max_attempts`
+- `status`
+- `exit_code`
+- `retryable`
+- `error`
+
+### `job.retry_scheduled`
+
+Payload type: `kinds.JobRetryScheduledPayload`
+
+- `index`
+- `attempt`
+- `max_attempts`
+- `reason`
+
+### `job.completed`
+
+Payload type: `kinds.JobCompletedPayload`
+
+- `index`
+- `attempt`
+- `max_attempts`
+- `exit_code`
+- `duration_ms`
+
+### `job.failed`
+
+Payload type: `kinds.JobFailedPayload`
+
+- `index`
+- `attempt`
+- `max_attempts`
+- `code_file`
+- `exit_code`
+- `out_log`
+- `err_log`
+- `error`
+
+### `job.cancelled`
+
+Payload type: `kinds.JobCancelledPayload`
+
+- `index`
+- `attempt`
+- `max_attempts`
+- `reason`
+
+## Session Events
+
+### `session.started`
+
+Payload type: `kinds.SessionStartedPayload`
+
+- `index`
+- `acp_session_id`
+- `agent_session_id`
+- `resumed`
+
+### `session.update`
+
+Payload type: `kinds.SessionUpdatePayload`
+
+- `index`
+- `update`: `kinds.SessionUpdate`
+
+`kinds.SessionUpdate` fields:
+
+- `kind`: semantic update variant such as `agent_message_chunk`, `tool_call_started`, or `plan_updated`
+- `tool_call_id`
+- `tool_call_state`: one of `pending`, `in_progress`, `completed`, `failed`, `waiting_for_confirmation`
+- `blocks`: content blocks rendered to the user
+- `thought_blocks`: internal thought blocks when the runtime exposes them
+- `plan_entries`: plan rows with `content`, `priority`, and `status`
+- `available_commands`: slash-command style actions with `name`, `description`, and `argument_hint`
+- `current_mode_id`
+- `usage`: `kinds.Usage`
+- `status`: session lifecycle status, typically `running`, `completed`, or `failed`
+
+`blocks` and `thought_blocks` are `kinds.ContentBlock` values. Their `type` field determines the JSON payload shape:
+
+- `text`: `text`
+- `tool_use`: `id`, `name`, `title`, `tool_name`, `input`, `raw_input`
+- `tool_result`: `tool_use_id`, `content`, `is_error`
+- `diff`: `file_path`, `diff`, `old_text`, `new_text`
+- `terminal_output`: `command`, `output`, `exit_code`, `terminal_id`
+- `image`: `data`, `mime_type`, `uri`
+
+### `session.completed`
+
+Payload type: `kinds.SessionCompletedPayload`
+
+- `index`
+- `usage`: `kinds.Usage`
+
+### `session.failed`
+
+Payload type: `kinds.SessionFailedPayload`
+
+- `index`
+- `error`
+- `usage`: `kinds.Usage`
+
+## Reusable Agent Events
+
+### `reusable_agent.lifecycle`
+
+Payload type: `kinds.ReusableAgentLifecyclePayload`
+
+- `stage`: one of `resolved`, `prompt-assembled`, `mcp-merged`, `nested-started`, `nested-completed`, or `nested-blocked`
+- `agent_name`: resolved reusable-agent name for the stage being reported
+- `agent_source`: source scope such as `workspace` or `global`
+- `parent_agent_name`: parent reusable agent when the stage refers to a nested `run_agent` call
+- `available_agents`: number of other reusable agents visible to the assembled discovery catalog
+- `system_prompt_bytes`: byte size of the assembled reusable-agent system prompt
+- `mcp_servers`: ordered MCP server names attached to the ACP session after reserved-server merge
+- `resumed`: true when the reusable-agent lifecycle event belongs to a resumed ACP session
+- `tool_call_id`: ACP tool-call id when the stage is tied to `run_agent`
+- `nested_depth`: attempted child depth for nested execution
+- `max_nested_depth`: configured host-owned depth ceiling
+- `output_run_id`: nested child run id when the child run was started
+- `success`: nested child completion status
+- `blocked`: true when nested execution was blocked instead of run
+- `blocked_reason`: one of `depth-limit`, `cycle-detected`, `access-denied`, `invalid-agent`, or `invalid-mcp`
+- `error`: structured diagnostic text for blocked or failed nested runs
+
+## Tool Call Events
+
+### `tool_call.started`
+
+Payload type: `kinds.ToolCallStartedPayload`
+
+- `index`
+- `tool_call_id`
+- `name`
+- `title`
+- `tool_name`
+- `input`
+- `raw_input`
+
+### `tool_call.updated`
+
+Payload type: `kinds.ToolCallUpdatedPayload`
+
+- `index`
+- `tool_call_id`
+- `state`
+- `input`
+- `raw_input`
+
+### `tool_call.failed`
+
+Payload type: `kinds.ToolCallFailedPayload`
+
+- `index`
+- `tool_call_id`
+- `state`
+- `error`
+
+## Usage Events
+
+### `usage.updated`
+
+Payload type: `kinds.UsageUpdatedPayload`
+
+- `index`
+- `usage`: `kinds.Usage`
+
+### `usage.aggregated`
+
+Payload type: `kinds.UsageAggregatedPayload`
+
+- `usage`: `kinds.Usage`
+
+`kinds.Usage` fields:
+
+- `input_tokens`
+- `output_tokens`
+- `total_tokens`
+- `cache_reads`
+- `cache_writes`
+
+## Task Events
+
+### `task.file_updated`
+
+Payload type: `kinds.TaskFileUpdatedPayload`
+
+- `tasks_dir`
+- `task_name`
+- `file_path`
+- `old_status`
+- `new_status`
+
+### `task.file_skipped`
+
+Payload type: `kinds.TaskFileSkippedPayload`
+
+Emitted when an agent session ends cleanly but does not produce any
+workspace changes. The task frontmatter is left at its prior status so the
+runner will redispatch the same task on the next invocation. See issue #144.
+
+- `tasks_dir`
+- `task_name`
+- `file_path`
+- `preserved_status`
+- `reason` (currently always `no_workspace_changes`)
+
+### `task.metadata_refreshed`
+
+Payload type: `kinds.TaskMetadataRefreshedPayload`
+
+- `tasks_dir`
+- `created_at`
+- `updated_at`
+- `total`
+- `completed`
+- `pending`
+
+### `task.memory_updated`
+
+Payload type: `kinds.TaskMemoryUpdatedPayload`
+
+- `workflow`
+- `task_file`
+- `path`
+- `mode`
+- `bytes_written`
+
+## Artifact Events
+
+### `artifact.updated`
+
+Payload type: `kinds.ArtifactUpdatedPayload`
+
+- `path`
+- `bytes_written`
+
+## Extension Events
+
+### `extension.loaded`
+
+Payload type: `kinds.ExtensionLoadedPayload`
+
+- `extension`
+- `source`
+- `version`
+- `manifest_path`
+
+### `extension.ready`
+
+Payload type: `kinds.ExtensionReadyPayload`
+
+- `extension`
+- `source`
+- `version`
+- `protocol_version`
+- `accepted_capabilities`
+- `supported_hook_events`
+
+### `extension.failed`
+
+Payload type: `kinds.ExtensionFailedPayload`
+
+- `extension`
+- `source`
+- `version`
+- `phase`
+- `error`
+
+### `extension.event`
+
+Payload type: `kinds.ExtensionEventPayload`
+
+- `extension`
+- `kind`
+- `payload`
+
+## Review Events
+
+### `review.status_finalized`
+
+Payload type: `kinds.ReviewStatusFinalizedPayload`
+
+- `reviews_dir`
+- `issue_ids`
+
+### `review.round_refreshed`
+
+Payload type: `kinds.ReviewRoundRefreshedPayload`
+
+- `reviews_dir`
+- `provider`
+- `pr`
+- `round`
+- `created_at`
+- `total`
+- `resolved`
+- `unresolved`
+
+### `review.issue_resolved`
+
+Payload type: `kinds.ReviewIssueResolvedPayload`
+
+- `reviews_dir`
+- `issue_id`
+- `file_path`
+- `provider`
+- `pr`
+- `provider_ref`
+- `provider_posted`
+- `posted_at`
+
+Review-watch events are emitted by the daemon-owned parent run created by `productize reviews watch`. They are persisted
+in the parent run journal, streamed through the regular run stream APIs, and use `kinds.ReviewWatchPayload`.
+
+### `review.watch_started`
+
+Payload type: `kinds.ReviewWatchPayload`
+
+- `provider`
+- `pr`
+- `workflow`
+- `run_id`
+- `head_sha`
+- `remote`
+- `branch`
+- `dirty`
+- `unpushed_commits`
+
+### `review.watch_waiting`
+
+Payload type: `kinds.ReviewWatchPayload`
+
+- `provider`
+- `pr`
+- `workflow`
+- `run_id`
+- `head_sha`
+- `status`
+- `review_id`
+- `review_state`
+
+### `review.watch_round_fetched`
+
+Payload type: `kinds.ReviewWatchPayload`
+
+- `provider`
+- `pr`
+- `workflow`
+- `round`
+- `run_id`
+- `head_sha`
+- `total`
+- `resolved`
+- `unresolved`
+
+### `review.watch_fix_started`
+
+Payload type: `kinds.ReviewWatchPayload`
+
+- `provider`
+- `pr`
+- `workflow`
+- `round`
+- `run_id`
+- `child_run_id`
+- `head_sha`
+
+### `review.watch_fix_completed`
+
+Payload type: `kinds.ReviewWatchPayload`
+
+- `provider`
+- `pr`
+- `workflow`
+- `round`
+- `run_id`
+- `child_run_id`
+- `head_sha`
+- `status`
+- `error`
+
+### `review.watch_push_started`
+
+Payload type: `kinds.ReviewWatchPayload`
+
+- `provider`
+- `pr`
+- `workflow`
+- `round`
+- `run_id`
+- `head_sha`
+- `remote`
+- `branch`
+
+### `review.watch_push_completed`
+
+Payload type: `kinds.ReviewWatchPayload`
+
+- `provider`
+- `pr`
+- `workflow`
+- `round`
+- `run_id`
+- `head_sha`
+- `remote`
+- `branch`
+
+### `review.watch_push_failed`
+
+Payload type: `kinds.ReviewWatchPayload`
+
+- `provider`
+- `pr`
+- `workflow`
+- `round`
+- `run_id`
+- `head_sha`
+- `remote`
+- `branch`
+- `error`
+
+### `review.watch_clean`
+
+Payload type: `kinds.ReviewWatchPayload`
+
+- `provider`
+- `pr`
+- `workflow`
+- `round`
+- `run_id`
+- `head_sha`
+- `review_id`
+- `review_state`
+- `status`
+
+### `review.watch_max_rounds`
+
+Payload type: `kinds.ReviewWatchPayload`
+
+- `provider`
+- `pr`
+- `workflow`
+- `round`
+- `run_id`
+- `head_sha`
+- `status`
+
+## Provider Events
+
+### `provider.call_started`
+
+Payload type: `kinds.ProviderCallStartedPayload`
+
+- `call_id`
+- `provider`
+- `endpoint`
+- `method`
+- `pr`
+- `issue_count`
+
+### `provider.call_completed`
+
+Payload type: `kinds.ProviderCallCompletedPayload`
+
+- `call_id`
+- `provider`
+- `endpoint`
+- `method`
+- `status_code`
+- `duration_ms`
+- `payload_bytes`
+
+### `provider.call_failed`
+
+Payload type: `kinds.ProviderCallFailedPayload`
+
+- `call_id`
+- `provider`
+- `endpoint`
+- `method`
+- `status_code`
+- `duration_ms`
+- `payload_bytes`
+- `error`
+
+## Shutdown Events
+
+### `shutdown.requested`
+
+Payload type: `kinds.ShutdownRequestedPayload`
+
+- `source`
+- `requested_at`
+- `deadline_at`
+
+### `shutdown.draining`
+
+Payload type: `kinds.ShutdownDrainingPayload`
+
+- `source`
+- `requested_at`
+- `deadline_at`
+
+### `shutdown.terminated`
+
+Payload type: `kinds.ShutdownTerminatedPayload`
+
+- `source`
+- `requested_at`
+- `deadline_at`
+- `forced`
+
+## Event Streaming (CLI)
+
+Both the `exec` and workflow commands support real-time event streaming to stdout via the `--format` flag. When enabled, events are written as newline-delimited JSON (JSONL) to stdout.
+
+### Output formats
+
+| Flag value | Mode | Description |
+| ---------- | ------- | ----------------------------------------------------------------------- |
+| `text` | default | Human-readable text output. No event streaming. |
+| `json` | lean | Emits a filtered subset of high-signal events as compact JSONL objects. |
+| `raw-json` | raw | Emits every bus event as its full `events.Event` envelope. |
+
+### Lean mode (`--format json`)
+
+Lean mode streams only lifecycle and interactive events to keep output concise for CI pipelines and automation:
+
+**Included event kinds:**
+
+- `run.started`, `run.completed`, `run.failed`, `run.cancelled`
+- `job.started`, `job.retry_scheduled`, `job.completed`, `job.failed`, `job.cancelled`
+- `session.started`, `session.completed`, `session.failed`
+- `session.update` — only when the update kind is `user_message_chunk`, `agent_message_chunk`, `tool_call_started`, or `tool_call_updated`
+
+**Lean JSONL shape:**
+
+```json
+{"type":"run.started","run_id":"abc123","seq":1,"time":"2026-04-13T10:00:00Z","payload":{...}}
+```
+
+| Field | Type | Description |
+| --------- | --------- | ------------------------- |
+| `type` | `string` | Event kind |
+| `run_id` | `string` | Run identifier |
+| `seq` | `uint64` | Monotonic sequence number |
+| `time` | `RFC3339` | Event timestamp |
+| `payload` | `object` | Kind-specific payload |
+
+### Raw mode (`--format raw-json`)
+
+Raw mode streams the full `events.Event` envelope for every bus event, including internal events not shown in lean mode. The shape matches the envelope documented in the [Envelope](#envelope) section above.
+
+### Examples
+
+```bash
+# Stream lean events for a single-prompt exec run
+productize exec --format json "Refactor the auth middleware"
+
+# Stream all raw events for a daemon-backed review-fix workflow
+productize reviews fix my-feature --format raw-json
+
+# Pipe lean events to jq for filtering
+productize exec --format json "Fix the tests" | jq 'select(.type == "session.update")'
+```
+
+### Terminal event detection
+
+The streamer waits for a terminal event (`run.completed`, `run.failed`, or `run.cancelled`) before finalizing. If no terminal event arrives within 5 seconds after the bus closes, the streamer exits gracefully.
diff --git a/docs/examples/agents/reviewer/AGENT.md b/docs/examples/agents/reviewer/AGENT.md
new file mode 100644
index 00000000..17ee6069
--- /dev/null
+++ b/docs/examples/agents/reviewer/AGENT.md
@@ -0,0 +1,10 @@
+---
+title: Reviewer
+description: Reviews implementation plans and diffs before code lands.
+ide: codex
+reasoning_effort: high
+access_mode: default
+---
+
+Review the user's request, inspect the relevant diff or files, identify concrete risks first, and
+then propose the smallest safe next step. Keep the answer concise and actionable.
diff --git a/docs/extensibility/architecture.md b/docs/extensibility/architecture.md
new file mode 100644
index 00000000..4cb9364e
--- /dev/null
+++ b/docs/extensibility/architecture.md
@@ -0,0 +1,83 @@
+# Architecture
+
+The execution model is deliberately small: an executable extension is a subprocess that speaks JSON-RPC 2.0 over stdin/stdout. Declarative extensions have no process at all — they only contribute manifest-declared assets.
+
+## Subprocess + wire protocol
+
+- **Transport**: line-delimited UTF-8 JSON over the extension's stdin/stdout. One JSON object per line, each a JSON-RPC 2.0 message. There are no batch requests.
+- **Framing**: stdout carries protocol messages; the extension is free to use stderr for its own logs.
+- **Correlation**: responses are matched to requests by JSON-RPC `id`. Responses may arrive out of order.
+- **Process model**: every Productize run spawns a **fresh subprocess**. There is no restart or session resume in v1 — when the run ends, the process is shut down.
+
+The subprocess command comes from the manifest:
+
+```toml
+[subprocess]
+command = "node" # required if the extension declares hooks
+args = ["dist/index.js"]
+env = { LOG_LEVEL = "info" }
+shutdown_timeout = "5s"
+health_check_period = "30s"
+```
+
+## The initialize handshake
+
+`initialize` is the first request the host sends. The extension replies declaring:
+
+- the protocol version it speaks (`1`),
+- `accepted_capabilities` — a subset of the capabilities the host granted,
+- the hooks it serves,
+- `supports` flags such as `supports.on_event` (to receive bus events) and `supports.health_check`.
+
+The host sends the operator-confirmed capability list as `granted_capabilities`. If the extension accepts a capability it was not granted, the host rejects the session. No hook is dispatched and no Host API call is allowed until `initialize` succeeds.
+
+## Two traffic directions
+
+| Direction | Methods |
+| --------- | ------- |
+| host → extension | `initialize`, `execute_hook`, `on_event`, `health_check`, `shutdown` (plus `fetch_reviews` / `resolve_issues` for review providers) |
+| extension → host | `host.events.*`, `host.tasks.*`, `host.runs.*`, `host.artifacts.*`, `host.prompts.render`, `host.memory.*` |
+
+The extension-to-host direction is the [Host API](./host-api-reference.md): it exists so an extension never has to shell out to `productize` or write internal files by hand.
+
+## Capability-based security
+
+Capabilities are declared in the manifest, confirmed by the operator at install, and enforced by the runtime in two places:
+
+- when the extension registers or serves a hook, and
+- when the extension calls a Host API method.
+
+A denied call returns JSON-RPC error `-32001 capability_denied`. Two capabilities — `network.egress` and `subprocess.spawn` — are **advisory only**: they document intent and aid auditing, but the operating system does not sandbox them. See the [capability reference](./capability-reference.md).
+
+## Hook dispatch
+
+Hooks come in two kinds, and the dispatch semantics differ:
+
+- **Mutable** hooks form a **chain ordered by priority**. Each handler receives the current payload and may return a patch; the patched value becomes the input to the next handler. Lower-numbered priorities run first (0–1000, default 500). The final value flows back into the run.
+- **Observe-only** hooks are dispatched **concurrently**, best-effort. Any value they return is ignored. They are taps for logging, notification, and audit.
+
+A hook may be marked `required: true` in the manifest. If a required hook fails, the run aborts. Each dispatch has a timeout (default ~5s). The capability that gates a hook is checked **before** dispatch. See the [hook reference](./hook-reference.md).
+
+## Events
+
+An extension that accepts `events.read` and reports `supports.on_event = true` receives bus events through `on_event`. The subscription starts unfiltered; the extension can narrow it with `host.events.subscribe({ kinds })`. The event envelope and kinds are documented in [Events](../events.md).
+
+## Discovery and precedence
+
+Productize discovers extensions from three scopes, in this order:
+
+1. **bundled** — embedded in the binary
+2. **user** — `~/.productize/extensions//`
+3. **workspace** — `/.productize/extensions//`
+
+When names collide, effective **precedence is the reverse**: `workspace > user > bundled`. Discovery is separate from enablement — bundled extensions are enabled by default; user and workspace extensions are discovered but **disabled** until the operator enables them on that machine. See [Trust & enablement](./trust-and-enablement.md).
+
+## Observability
+
+Every hook dispatch and Host API call is recorded for daemon-managed runs in:
+
+```
+~/.productize/runs//run.db
+```
+
+The bus also emits extension lifecycle events such as `extension.loaded`, `extension.ready`, and `extension.failed`.
diff --git a/docs/extensibility/capability-reference.md b/docs/extensibility/capability-reference.md
new file mode 100644
index 00000000..15478770
--- /dev/null
+++ b/docs/extensibility/capability-reference.md
@@ -0,0 +1,51 @@
+# Capability reference
+
+Capabilities are how an extension declares what it is allowed to do. They are listed in the manifest, confirmed by the operator at install, sent to the extension as `granted_capabilities` during `initialize`, and enforced by the runtime on every hook and Host API call.
+
+```toml
+[security]
+capabilities = ["prompt.mutate", "tasks.read"]
+```
+
+The list may be empty — a pure observer that reads nothing and writes nothing needs only the `*.mutate` capability for the hook family it taps.
+
+## How enforcement works
+
+- The manifest **requests** capabilities.
+- The operator **confirms** them at install (`ext install` prints the list).
+- `initialize` sends the confirmed set as `granted_capabilities`; the extension replies with `accepted_capabilities`, which **must be a subset**. Accepting an ungranted capability fails the session.
+- At runtime, a hook is gated by its family capability and is checked **before** dispatch; a Host API call is gated per method. A denied call returns JSON-RPC `-32001 capability_denied`.
+
+Two capabilities — `subprocess.spawn` and `network.egress` — are **advisory only**. They document intent and aid the audit trail, but the OS does not sandbox them.
+
+## The 20 capabilities
+
+| Capability | Unlocks | Notes |
+| ---------- | ------- | ----- |
+| `events.read` | `on_event` delivery and `host.events.subscribe` | Also requires `supports.on_event = true` in `initialize`. |
+| `events.publish` | `host.events.publish` | Emits a custom `extension.event` onto the bus. |
+| `prompt.mutate` | All `prompt.*` hooks | Mutable and observe-only prompt hooks. |
+| `plan.mutate` | All `plan.*` hooks | Planning-time discovery, grouping, job-prep, runtime selection. |
+| `agent.mutate` | All `agent.*` hooks | Session create, resume, update, end callbacks. |
+| `job.mutate` | All `job.*` hooks | Includes retry veto/delay and observe-only job callbacks. |
+| `run.mutate` | All `run.*` hooks | Run lifecycle, including `run.post_shutdown`. |
+| `review.mutate` | All `review.*` hooks | Used by `reviews fix` and the `reviews watch` parent run. |
+| `artifacts.read` | `host.artifacts.read` | Workspace-scoped reads. |
+| `artifacts.write` | `host.artifacts.write` **and** all `artifact.*` hooks | Required to intercept artifact writes, not just to call write. |
+| `tasks.read` | `host.tasks.list`, `host.tasks.get` | Use instead of parsing task files yourself. |
+| `tasks.create` | `host.tasks.create` | Host owns numbering and index refresh. |
+| `runs.start` | `host.runs.start` | Subject to recursion-depth protection. |
+| `memory.read` | `host.memory.read` | Reads Markdown-backed workflow memory. |
+| `memory.write` | `host.memory.write` | Writes through the workflow-memory service. |
+| `providers.register` | `[[providers.*]]` manifest entries and `RegisterReviewProvider` | Required for IDE, review, and model provider overlays. Validated at discovery/install, enforced at `initialize`. |
+| `skills.ship` | `[resources] skills = [...]` | Lets the manifest ship skills into the catalog. Validated at discovery/install. |
+| `agents.ship` | `[resources] agents = [...]` | Lets the manifest ship reusable agents. Validated at discovery/install. |
+| `subprocess.spawn` | Declares intent to spawn child processes | **Advisory only** — not OS-enforced. |
+| `network.egress` | Declares intent to make outbound network calls | **Advisory only** — not OS-enforced. |
+
+## Guidance
+
+- Request the smallest set that makes the extension work. The operator sees this list and may decline.
+- Note the asymmetry on a few capabilities: a hook family is unlocked by its `*.mutate` capability, while the matching Host API methods need their own (`artifacts.write` covers both `artifact.*` hooks and `host.artifacts.write`; `tasks.read` covers no hooks, only Host reads).
+- Keep declarative concerns (`skills.ship`, `agents.ship`, `providers.register`) separate from executable concerns when you can — it makes the requested set easier to reason about.
+- Treat the two advisory capabilities as documentation, not a sandbox.
diff --git a/docs/extensibility/getting-started.md b/docs/extensibility/getting-started.md
new file mode 100644
index 00000000..4e2a863e
--- /dev/null
+++ b/docs/extensibility/getting-started.md
@@ -0,0 +1,100 @@
+# Getting started
+
+The fastest path from nothing to a working, enabled extension.
+
+## Prerequisites
+
+- **Node 18+** for TypeScript or JavaScript extensions
+- **Go 1.24+** for Go extensions
+- A Productize build that includes the v1 extension runtime
+
+## Scaffold
+
+`@productize/create-extension` generates a project from one of four starter templates: `lifecycle-observer`, `prompt-decorator`, `review-provider`, and `skill-pack`.
+
+```bash
+npx @productize/create-extension my-ext --template lifecycle-observer
+cd my-ext
+npm run build
+```
+
+The scaffolder runs `npm install` by default; pass `--skip-install` to manage dependencies yourself. For a Go executable extension, add `--runtime go --module example.com/my-ext`.
+
+## Project layout
+
+A scaffolded TypeScript extension looks like:
+
+```
+my-ext/
+├── extension.toml # manifest: identity, subprocess, capabilities, hooks
+├── package.json
+├── tsconfig.json
+├── src/
+│ ├── extension.ts # hook handlers (testable in isolation)
+│ └── index.ts # entrypoint: builds the Extension and calls start()
+└── test/ # SDK test-harness tests
+```
+
+The manifest is the contract. A minimal executable manifest:
+
+```toml
+[extension]
+name = "my-ext"
+version = "0.1.0"
+description = "What this extension does"
+min_productize_version = "0.1.10"
+
+[subprocess]
+command = "node"
+args = ["dist/index.js"]
+
+[security]
+capabilities = ["run.mutate"]
+
+[[hooks]]
+event = "run.post_shutdown"
+```
+
+See the [capability reference](./capability-reference.md) and [hook reference](./hook-reference.md) for the full set of values.
+
+## Install and enable
+
+```bash
+productize ext install --yes . # copy into ~/.productize/extensions//
+productize ext enable my-ext # turn it on, this machine only
+```
+
+`ext install` prints the capabilities the manifest requests before it installs — that printout is your review point. User- and workspace-scoped extensions install **disabled**; `ext enable` flips local operator state. Bundled extensions are enabled by default.
+
+`ext install` also accepts:
+
+| Flag | Meaning |
+| ---- | ------- |
+| `--yes`, `-y` | Skip the confirmation prompt |
+| `--remote local\|github` | Install from a local path or a GitHub repo archive |
+| `--ref ` | Branch, tag, or commit (with `--remote github`) |
+| `--subdir ` | Extension subdirectory inside the repo |
+
+## Verify
+
+```bash
+productize ext list # all discovered extensions across scopes
+productize ext inspect my-ext # winning manifest, capabilities, enablement
+productize ext doctor # validate manifests, report health warnings
+```
+
+## See it fire
+
+The `lifecycle-observer` template handles `run.post_shutdown`, so any run path exercises it:
+
+```bash
+productize exec --extensions "summarize the repository state"
+```
+
+Executable extensions run when extensions are enabled for the run. For `productize exec`, pass `--extensions`; daemon-backed workflow and review runs load enabled extensions automatically.
+
+## Next
+
+- [Hello world (Go)](./hello-world-go.md) / [Hello world (TypeScript)](./hello-world-ts.md)
+- [Architecture](./architecture.md)
+- [Testing](./testing.md)
diff --git a/docs/extensibility/hello-world-go.md b/docs/extensibility/hello-world-go.md
new file mode 100644
index 00000000..2e14e092
--- /dev/null
+++ b/docs/extensibility/hello-world-go.md
@@ -0,0 +1,97 @@
+# Hello world (Go)
+
+A minimal Go extension that logs the final status of every run. It uses the Go SDK at `github.com/itseffi/productize/sdk/extension`. For a fuller, real-world example, read [`extensions/qa-workflow/`](../../extensions/qa-workflow/) — a Go subprocess extension that ships in the repo.
+
+## 1. Create the project
+
+```bash
+mkdir hello-go && cd hello-go
+go mod init example.com/hello-go
+go get github.com/itseffi/productize/sdk/extension@v0.1.10
+go mod tidy
+```
+
+## 2. `main.go`
+
+```go
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ extension "github.com/itseffi/productize/sdk/extension"
+)
+
+func main() {
+ ext := extension.New("hello-go", "0.1.0").
+ OnRunPostShutdown(func(
+ _ context.Context,
+ _ extension.HookContext,
+ payload extension.RunPostShutdownPayload,
+ ) error {
+ fmt.Fprintf(os.Stderr, "run %s finished with %s\n", payload.RunID, payload.Summary.Status)
+ return nil
+ })
+
+ if err := ext.Start(context.Background()); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
+```
+
+`ext.Start` takes over stdin/stdout, runs the `initialize` handshake, and dispatches hooks until the host sends `shutdown`. Write your own logs to **stderr** — stdout carries the JSON-RPC protocol.
+
+## 3. `extension.toml`
+
+```toml
+[extension]
+name = "hello-go"
+version = "0.1.0"
+description = "Hello-world Go extension"
+min_productize_version = "0.1.10"
+
+[subprocess]
+command = "go"
+args = ["run", "."]
+
+[security]
+capabilities = ["run.mutate"]
+
+[[hooks]]
+event = "run.post_shutdown"
+```
+
+`run.post_shutdown` is an observe-only hook, so the handler's return value is ignored — it's purely a tap. It belongs to the `run.mutate` capability family. For production, compile a binary (`go build -o hello-go .`) and point `command` at it rather than using `go run`.
+
+## 4. Install and enable
+
+```bash
+productize ext install --yes .
+productize ext enable hello-go
+productize ext doctor # confirm the manifest is healthy
+```
+
+## 5. See it fire
+
+```bash
+productize exec --extensions "hello from the Go extension"
+```
+
+When the run shuts down you'll see the final status on stderr.
+
+## Faster path
+
+The scaffolder can generate this starting point for you:
+
+```bash
+npx @productize/create-extension hello-go --template lifecycle-observer --runtime go --module example.com/hello-go
+```
+
+## Next
+
+- [Hook reference](./hook-reference.md) — the other 31 hooks
+- [Host API reference](./host-api-reference.md) — calling back into Productize
+- [Testing](./testing.md) — the Go test harness
diff --git a/docs/extensibility/hello-world-ts.md b/docs/extensibility/hello-world-ts.md
new file mode 100644
index 00000000..c56b20e0
--- /dev/null
+++ b/docs/extensibility/hello-world-ts.md
@@ -0,0 +1,101 @@
+# Hello world (TypeScript)
+
+A minimal TypeScript extension that logs the final status of every run, using `@productize/extension-sdk` (source at `sdk/extension-sdk-ts/`).
+
+## 1. Create the project
+
+```bash
+mkdir hello-ts && cd hello-ts
+npm init -y
+npm install @productize/extension-sdk
+npm install --save-dev typescript @types/node
+```
+
+## 2. `tsconfig.json`
+
+```json
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "strict": true,
+ "rootDir": ".",
+ "outDir": "dist",
+ "types": ["node"]
+ },
+ "include": ["src/**/*.ts"]
+}
+```
+
+## 3. `src/index.ts`
+
+```ts
+import { Extension } from "@productize/extension-sdk";
+
+const extension = new Extension("hello-ts", "0.1.0").onRunPostShutdown(
+ async (_context, payload) => {
+ process.stderr.write(`run ${payload.run_id} finished with ${payload.summary.status}\n`);
+ }
+);
+
+extension.start().catch(error => {
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
+ process.exitCode = 1;
+});
+```
+
+`extension.start()` runs the `initialize` handshake and dispatches hooks over stdin/stdout. Keep your own output on **stderr** — stdout is the JSON-RPC channel.
+
+## 4. `extension.toml`
+
+```toml
+[extension]
+name = "hello-ts"
+version = "0.1.0"
+description = "Hello-world TypeScript extension"
+min_productize_version = "0.1.10"
+
+[subprocess]
+command = "node"
+args = ["dist/src/index.js"]
+
+[security]
+capabilities = ["run.mutate"]
+
+[[hooks]]
+event = "run.post_shutdown"
+```
+
+`run.post_shutdown` is observe-only (its return value is ignored) and lives in the `run.mutate` capability family. The `args` path must match your `outDir` — with the `tsconfig` above, `src/index.ts` compiles to `dist/src/index.js`.
+
+## 5. Build, install, enable
+
+```bash
+npx tsc -p tsconfig.json
+productize ext install --yes .
+productize ext enable hello-ts
+productize ext doctor
+```
+
+## 6. See it fire
+
+```bash
+productize exec --extensions "hello from the TypeScript extension"
+```
+
+The final run status appears on stderr at shutdown.
+
+## Faster path
+
+Start from the scaffolded template instead of writing the files by hand:
+
+```bash
+npx @productize/create-extension hello-ts --template lifecycle-observer
+```
+
+## Next
+
+- [Hook reference](./hook-reference.md)
+- [Host API reference](./host-api-reference.md)
+- [Testing](./testing.md)
diff --git a/docs/extensibility/hook-reference.md b/docs/extensibility/hook-reference.md
new file mode 100644
index 00000000..113a94b6
--- /dev/null
+++ b/docs/extensibility/hook-reference.md
@@ -0,0 +1,128 @@
+# Hook reference
+
+Hooks are the points in a run where Productize hands control to your extension. There are 32 of them, grouped into seven phases. Each hook belongs to a capability family and is either **mutable** or **observe-only**.
+
+## Mutable vs observe-only
+
+- **Mutable (M)** hooks run as a **chain ordered by priority**. Each handler gets the current payload and may return a patch; the patched value feeds the next handler and ultimately flows back into the run.
+- **Observe-only (O)** hooks are dispatched **concurrently**, best-effort. Any value returned is ignored — they are taps for logging, notification, and audit.
+
+The capability that gates a hook is checked **before** dispatch, so a hook your extension lacks the capability for is never delivered.
+
+## Per-hook controls (manifest)
+
+Each `[[hooks]]` entry supports:
+
+| Field | Default | Meaning |
+| ----- | ------- | ------- |
+| `event` | — | A valid hook name from the tables below (required). |
+| `priority` | `500` | Chain order for mutable hooks, `0`–`1000`; lower runs first. |
+| `required` | `false` | If `true`, a failure in this hook **aborts the run**. |
+| `timeout` | ~5s | Per-dispatch timeout. |
+
+```toml
+[[hooks]]
+event = "prompt.post_build"
+priority = 400
+required = false
+timeout = "5s"
+```
+
+## Registering handlers (SDK)
+
+In the TypeScript SDK you can register three ways: fluent helpers (`onPromptPostBuild(...)`), the generic `handle(HOOKS.promptPostBuild, handler)`, or lifecycle methods (`onEvent`, `onHealthCheck`, `onShutdown`). The Go SDK exposes the matching `OnPromptPostBuild(...)` helpers. Payload and patch types are exported from the SDK.
+
+---
+
+## Plan phase — `plan.mutate`
+
+Fires while Productize is planning a workflow run.
+
+| Event | Kind | When it fires |
+| ----- | ---- | ------------- |
+| `plan.pre_discover` | M | Before issue discovery; can add extra discovery sources. |
+| `plan.post_discover` | M | After discovery; can rewrite the discovered entry list. |
+| `plan.pre_group` | M | Before grouping; can adjust entries. |
+| `plan.post_group` | M | After grouping; can rewrite grouped batches. |
+| `plan.pre_prepare_jobs` | M | Before job creation; can adjust groups. |
+| `plan.pre_resolve_task_runtime` | M | The one supported seam to choose a task's effective runtime (PRD-only). |
+| `plan.post_prepare_jobs` | M | After jobs are prepared; can rewrite jobs, but not their runtime fields. |
+
+## Prompt phase — `prompt.mutate`
+
+| Event | Kind | When it fires |
+| ----- | ---- | ------------- |
+| `prompt.pre_build` | M | Before prompt construction; mutates build parameters. |
+| `prompt.post_build` | M | After the prompt text is built — the most common decorator hook. |
+| `prompt.pre_system` | M | Before session start; mutates the system addendum. |
+
+## Agent phase — `agent.mutate`
+
+| Event | Kind | When it fires |
+| ----- | ---- | ------------- |
+| `agent.pre_session_create` | M | Mutates the create-session request. |
+| `agent.post_session_create` | O | After a session is created. |
+| `agent.pre_session_resume` | M | Mutates the resume request. |
+| `agent.on_session_update` | O | Update tap on the live session path. |
+| `agent.post_session_end` | O | Final callback when a session ends. |
+
+## Job phase — `job.mutate`
+
+| Event | Kind | When it fires |
+| ----- | ---- | ------------- |
+| `job.pre_execute` | M | Rewrites one job before execution (not its planning-chosen runtime). |
+| `job.post_execute` | O | After a job completes. |
+| `job.pre_retry` | M | Before a retry; can veto or delay it. |
+
+## Run phase — `run.mutate`
+
+| Event | Kind | When it fires |
+| ----- | ---- | ------------- |
+| `run.pre_start` | M | Late, before execution begins; can tune timeout/retries/output, not planning state. |
+| `run.post_start` | O | Just after execution starts. |
+| `run.pre_shutdown` | O | Before the run shuts down. |
+| `run.post_shutdown` | O | After shutdown — common lifecycle-observer target. |
+
+## Review phase — `review.mutate`
+
+The fetch/fix hooks are active under `productize reviews fix`; the watch hooks under the daemon-owned `productize reviews watch` parent run.
+
+| Event | Kind | When it fires |
+| ----- | ---- | ------------- |
+| `review.pre_fetch` | M | Mutates review fetch configuration. |
+| `review.post_fetch` | M | Rewrites fetched review issues. |
+| `review.pre_batch` | M | Rewrites review issue batches. |
+| `review.post_fix` | O | After one issue is fixed. |
+| `review.pre_resolve` | M | Before remote resolution; can suppress it. |
+| `review.watch_pre_round` | M | After provider-current status, before fetch; `continue:false` must set `stop_reason`. |
+| `review.watch_post_round` | O | After child fix validation and optional push. |
+| `review.watch_pre_push` | M | Immediately before `git push`; `push:false` stops the watch and must set `stop_reason`. |
+| `review.watch_finished` | O | Terminal notification (clean, stopped, failed, cancelled, or max-round). |
+
+## Artifact phase — `artifacts.write`
+
+| Event | Kind | When it fires |
+| ----- | ---- | ------------- |
+| `artifact.pre_write` | M | Before a write; can rewrite the path or content, or cancel the write. |
+| `artifact.post_write` | O | After a write completes. |
+
+---
+
+## Event and lifecycle callbacks
+
+Not `execute_hook` events, but part of the author surface:
+
+| Method | Registration | Purpose |
+| ------ | ------------ | ------- |
+| `on_event` | `onEvent(handler, ...kinds)` | Bus events after `initialize`, if `events.read` was accepted. |
+| `health_check` | `onHealthCheck(handler)` | Answers host health probes. |
+| `shutdown` | `onShutdown(handler)` | Graceful cleanup before process exit. |
+
+## Rules worth remembering
+
+- Mutable hooks chain by priority (lower first); observe-only hooks are concurrent and their return value is discarded.
+- `{}` and `{"patch": {}}` are both no-op responses. Arrays in a patch **replace** wholesale — they are not merged.
+- A `required: true` hook that fails aborts the run.
+- `plan.pre_resolve_task_runtime` is the **only** seam for extension-driven runtime selection — don't try to change runtime in `plan.post_prepare_jobs` or `job.pre_execute`.
+- `run.pre_start` is late in the pipeline; it can tune execution settings but cannot rewrite fields planning already consumed.
+- Review-watch hooks cannot declare a PR clean or skip provider-current detection; immutable provider/head/status fields are rejected if a hook tries to mutate them.
diff --git a/docs/extensibility/host-api-reference.md b/docs/extensibility/host-api-reference.md
new file mode 100644
index 00000000..142af535
--- /dev/null
+++ b/docs/extensibility/host-api-reference.md
@@ -0,0 +1,113 @@
+# Host API reference
+
+The Host API is the extension → Productize callback surface. It lets an extension read and write Productize state through the runtime instead of shelling out or touching internal files. In the TypeScript SDK you reach it through `context.host` inside a handler (or `extension.host`); in the Go SDK through the `HostAPI` on the hook context.
+
+```ts
+const extension = new Extension("demo", "0.1.0").onPromptPostBuild(async (context, payload) => {
+ const tasks = await context.host.tasks.list({ workflow: "demo" });
+ return { prompt_text: `${payload.prompt_text}\nOpen tasks: ${tasks.length}` };
+});
+```
+
+## Global rules
+
+- Calls are allowed only **after `initialize` succeeds** (otherwise `-32003 not_initialized`).
+- Each method is gated by a capability. A call the accepted capabilities don't authorize returns `-32001 capability_denied`.
+- All calls are **rejected during shutdown draining** with `shutdown_in_progress` (`-32004`).
+- `host.artifacts.*` is scoped to the workspace root and `.productize/`; out-of-scope paths fail with `path_out_of_scope`.
+- Every call is recorded in `~/.productize/runs//run.db`.
+
+## Methods
+
+| Method | Params | Returns | Capability |
+| ------ | ------ | ------- | ---------- |
+| `host.events.subscribe` | `{kinds}` | `{subscription_id}` | `events.read` |
+| `host.events.publish` | `{kind, payload?}` | `{seq?}` | `events.publish` |
+| `host.tasks.list` | `{workflow}` | `Task[]` | `tasks.read` |
+| `host.tasks.get` | `{workflow, number}` | `Task` | `tasks.read` |
+| `host.tasks.create` | `{workflow, title, body?, frontmatter?, update_index?}` | `Task` | `tasks.create` |
+| `host.runs.start` | `{runtime}` | `{run_id, parent_run_id?}` | `runs.start` |
+| `host.artifacts.read` | `{path}` | `{path, content}` | `artifacts.read` |
+| `host.artifacts.write` | `{path, content}` | `{path, bytes_written}` | `artifacts.write` |
+| `host.prompts.render` | `{template, params?}` | `{rendered}` | none |
+| `host.memory.read` | `{workflow, task_file?}` | `{path, content, exists, needs_compaction}` | `memory.read` |
+| `host.memory.write` | `{workflow, task_file?, content, mode?}` | `{path, bytes_written}` | `memory.write` |
+
+## Notes by namespace
+
+### `host.events`
+
+The initial subscription is the unfiltered bus. `subscribe` narrows the filter and **replaces** any previous one. `publish` emits a custom event recorded as an `extension.event` on the bus.
+
+### `host.tasks`
+
+`create` is the correct way to add task files — it owns numbering and metadata refresh. Set `update_index: true` to append the new task to the workflow `_tasks.md` table. Do not write task files yourself via `host.artifacts.write`.
+
+### `host.runs`
+
+`start` returns once the child run is **accepted**, not when it finishes. The host appends the current run to the parent-run chain; calls are rejected with `recursion_depth_exceeded` once the chain reaches depth 3.
+
+### `host.artifacts`
+
+Paths must stay under the workspace root or `.productize/`. Reads and writes outside that scope fail with `path_out_of_scope`.
+
+### `host.prompts`
+
+`render` is a helper for the built-in prompt renderer. It is read-only and side-effect free, which is why it requires no capability.
+
+### `host.memory`
+
+Memory documents are Markdown files under `.productize/tasks//memory/`. Omitting `task_file` targets `MEMORY.md`. `mode: "append"` appends with a newline separator; the default replaces.
+
+## Review providers
+
+An extension with `providers.register` can register an executable review provider that Productize calls during `reviews fetch` and `reviews fix`. The host dispatches `fetch_reviews` and `resolve_issues` to the subprocess.
+
+```toml
+[security]
+capabilities = ["providers.register"]
+
+[[providers.review]]
+name = "my-reviews"
+kind = "extension"
+```
+
+**Go:**
+
+```go
+ext := extension.New("my-provider", "0.1.0").
+ RegisterReviewProvider("my-reviews", extension.ReviewProvider{
+ FetchReviewsFunc: func(ctx context.Context, rctx extension.ReviewProviderContext, req extension.FetchRequest) ([]extension.ReviewItem, error) {
+ return items, nil
+ },
+ ResolveIssuesFunc: func(ctx context.Context, rctx extension.ReviewProviderContext, req extension.ResolveIssuesRequest) error {
+ return nil
+ },
+ })
+```
+
+**TypeScript:**
+
+```ts
+const extension = new Extension("my-provider", "0.1.0").registerReviewProvider("my-reviews", {
+ fetchReviews: async (ctx, req) => { /* return ReviewItem[] */ },
+ resolveIssues: async (ctx, req) => { /* mark resolved */ },
+});
+```
+
+| JSON-RPC method | Request | Response | When |
+| --------------- | ------- | -------- | ---- |
+| `fetch_reviews` | `{provider, pr, include_nitpicks?}` | `ReviewItem[]` | Once per provider per fetch cycle |
+| `resolve_issues` | `{provider, pr, issues}` | `null` | After a successful fix round |
+
+If an extension session already exists for the workspace the host reuses it; otherwise it starts a standalone single-extension manager for the duration of the review workflow and closes it afterward.
+
+## Error codes
+
+| Code | Meaning |
+| ---- | ------- |
+| `-32001` | `capability_denied` |
+| `-32003` | `not_initialized` |
+| `-32004` | `shutdown_in_progress` |
+| `-32601` | `method_not_found` |
+| `-32603` | `internal_error` |
diff --git a/docs/extensibility/index.md b/docs/extensibility/index.md
new file mode 100644
index 00000000..b1221648
--- /dev/null
+++ b/docs/extensibility/index.md
@@ -0,0 +1,61 @@
+# Extensions
+
+Extensions let you change what Productize does at runtime — decorate prompts, add planning steps, intercept artifact writes, ship skills and reusable agents, or plug in your own review provider — without rebuilding the binary.
+
+## What an extension is
+
+An extension is a directory with a manifest (`extension.toml`) and, optionally, a program. There are two flavours, and one extension can be both:
+
+- **Executable** — a subprocess Productize spawns during a run. It speaks JSON-RPC over stdin/stdout, registers [hooks](./hook-reference.md) on lifecycle events, and can call back into Productize through the [Host API](./host-api-reference.md). Example: [`extensions/qa-workflow/`](../../extensions/qa-workflow/).
+- **Declarative** — no subprocess. The manifest just ships [skills](../skills.md) and/or [reusable agents](../reusable-agents.md) into the catalog. Example: [`extensions/idea-forge/`](../../extensions/idea-forge/).
+
+## When to build one
+
+| You want to… | Build an extension |
+| ------------ | ------------------ |
+| Add a prompt prefix, system addendum, or context to every run | Executable, `prompt.mutate` |
+| React to run/job/session lifecycle (log, notify, audit) | Executable, observe-only hooks |
+| Add or filter planning steps and task batches | Executable, `plan.mutate` |
+| Intercept, rewrite, or block artifact writes | Executable, `artifacts.write` |
+| Integrate a custom code-review source | Executable, `providers.register` + `review.mutate` |
+| Distribute a pack of skills or agents | Declarative, `skills.ship` / `agents.ship` |
+
+If you only want to write a single skill for your own project, you don't need an extension — see [Skills & the catalog](../skills.md). Extensions are for **executable behavior** and for **distributing** assets to other people.
+
+## Mental model
+
+Productize is the host. On every run it discovers extensions, spawns the enabled executable ones as fresh subprocesses, and negotiates an `initialize` handshake. From then on:
+
+- The host **dispatches hooks** to the extension at fixed points in the run.
+- The extension may **call back** into the host through the Host API (read tasks, write memory, publish events, start child runs).
+- Security is **capability-based**: the manifest declares what the extension may do, the operator confirms it at install, and the runtime enforces it on every hook and Host API call.
+
+There is no marketplace and no trust tiers. Trust comes from reviewing the capabilities an extension requests and deciding, on your own machine, whether to enable it.
+
+## Documentation
+
+| Page | Covers |
+| ---- | ------ |
+| [Getting started](./getting-started.md) | Scaffold, build, install, enable, verify |
+| [Architecture](./architecture.md) | Subprocess + JSON-RPC model, handshake, dispatch, discovery |
+| [Capability reference](./capability-reference.md) | All 20 capabilities and what each unlocks |
+| [Hook reference](./hook-reference.md) | All 32 hook events, phases, and mutate semantics |
+| [Host API reference](./host-api-reference.md) | Methods the extension can call back into |
+| [Hello world (Go)](./hello-world-go.md) | Minimal Go extension end to end |
+| [Hello world (TypeScript)](./hello-world-ts.md) | Minimal TypeScript extension end to end |
+| [Testing](./testing.md) | SDK test harnesses and what to assert |
+| [Trust & enablement](./trust-and-enablement.md) | Discovery scopes, precedence, consent model |
+| [Migration guide](./migration-guide.md) | Moving pre-SDK prototypes onto v1 |
+
+## Quick start
+
+```bash
+npx @productize/create-extension my-ext --template lifecycle-observer
+cd my-ext
+npm run build
+productize ext install --yes .
+productize ext enable my-ext
+productize ext doctor
+```
+
+See [Getting started](./getting-started.md) for the full walkthrough.
diff --git a/docs/extensibility/migration-guide.md b/docs/extensibility/migration-guide.md
new file mode 100644
index 00000000..fbd19877
--- /dev/null
+++ b/docs/extensibility/migration-guide.md
@@ -0,0 +1,30 @@
+# Migration guide
+
+> **Scope.** v1 is the first supported extension authoring surface: `extension.toml` manifests, JSON-RPC 2.0 over stdin/stdout, the official Go and TypeScript SDKs, and local enablement via `productize ext enable`. There is no earlier released format to migrate *from* — this page exists only for anyone who hand-rolled an extension against the raw protocol before the SDKs were published. If that isn't you, you can skip it and start from [Getting started](./getting-started.md).
+
+## If you wrote a pre-SDK prototype
+
+The shortest path is to keep your business logic and move everything around it onto the supported surface:
+
+1. **Manifest.** Put subprocess metadata in `extension.toml` (`command`, `args`, `env`) and declare capabilities under `[security]`.
+2. **Entrypoint.** Replace any hand-written stdio loop with the SDK: `new Extension(...).start()` (TypeScript) or `extension.New(...).Start(ctx)` (Go). Let the SDK negotiate `initialize` — protocol version, capability grants, and `supports` flags.
+3. **Side effects.** Replace direct filesystem writes for tasks, artifacts, and memory with [Host API](./host-api-reference.md) calls (`host.tasks.create`, `host.artifacts.write`, `host.memory.write`).
+4. **Recursion.** Replace any recursive `productize` shellout with `host.runs.start`, so parent-run propagation and recursion-depth protection stay correct.
+5. **Trust.** Stop relying on implicit activation by repository presence. Install and enable explicitly with `productize ext install` and `productize ext enable`.
+6. **Tests.** Add `initialize`, hook, and shutdown coverage with the SDK [test harness](./testing.md).
+
+## Pattern map
+
+| Prototype pattern | Supported v1 pattern |
+| ----------------- | -------------------- |
+| Hand-written stdio loop | `Extension(...).start()` / `extension.New(...).Start(ctx)` |
+| Custom `initialize` parsing | Let the SDK negotiate version, capabilities, and `supports` flags |
+| Writing `.productize/tasks/...` directly | `host.tasks.create` / `host.memory.write` |
+| Shelling out to `productize exec` | `host.runs.start` |
+| Implicit trust by repo presence | `productize ext install` + `productize ext enable` |
+
+## Declarative prototypes
+
+If you prototyped a provider or skill pack: move provider entries under `[[providers.ide]]`, `[[providers.review]]`, or `[[providers.model]]`; move skill packs under `[resources] skills = ["skills/*"]`; and declare `providers.register` or `skills.ship` explicitly. Then start from `@productize/create-extension` and keep tests on the official harness.
+
+Protocol version `1` is the supported wire contract for this release line. If your prototype used different method names or assumed a different `initialize` direction, update it to match the published protocol before sharing it.
diff --git a/docs/extensibility/testing.md b/docs/extensibility/testing.md
new file mode 100644
index 00000000..ff7e0d84
--- /dev/null
+++ b/docs/extensibility/testing.md
@@ -0,0 +1,86 @@
+# Testing extensions
+
+Test at two levels:
+
+1. **Fast in-process tests** against the SDK test harness — drive `initialize`, hook dispatch, events, health checks, and shutdown without spawning a process.
+2. **At least one real subprocess smoke test** against the Productize runtime, so you exercise actual stdin/stdout framing and manifest-driven startup.
+
+## TypeScript: `@productize/extension-sdk/testing`
+
+The testing entrypoint exports:
+
+- `TestHarness` — host-driven `initialize`, hook dispatch, event delivery, health checks, and shutdown.
+- `MockTransport` — lower-level transport for testing ID correlation, out-of-order responses, JSON-RPC error propagation, and EOF/shutdown behavior.
+
+```ts
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import { CAPABILITIES, HOOKS } from "@productize/extension-sdk";
+import { TestHarness } from "@productize/extension-sdk/testing";
+
+import { createPromptDecoratorExtension } from "../src/extension.js";
+
+test("decorates prompt text", async () => {
+ const harness = new TestHarness({
+ granted_capabilities: [CAPABILITIES.promptMutate],
+ });
+ const extension = createPromptDecoratorExtension();
+
+ const run = harness.run(extension);
+ await harness.initialize({ name: "demo", version: "0.1.0", source: "workspace" });
+
+ const response = await harness.dispatchHook(
+ "hook-1",
+ {
+ name: HOOKS.promptPostBuild,
+ event: HOOKS.promptPostBuild,
+ mutable: true,
+ required: false,
+ priority: 500,
+ timeout_ms: 5000,
+ },
+ {
+ run_id: "run-1",
+ job_id: "job-1",
+ prompt_text: "hello",
+ batch_params: { name: "demo" },
+ }
+ );
+
+ assert.deepEqual(response, { patch: { prompt_text: "hello\n\nDecorated by demo." } });
+
+ await harness.shutdown({ reason: "run_completed", deadline_ms: 1000 });
+ await run;
+});
+```
+
+## Go: `sdk/extension/testing`
+
+The Go SDK provides the equivalent harness and mock transport under `sdk/extension/testing` (`TestHarness`, `MockTransport`). Construct the harness with the granted capabilities, run the extension, drive `initialize` and `DispatchHook`, assert the returned patch, then `Shutdown`. Combine it with a standard `go test` for a real subprocess smoke test that builds and launches the manifest entrypoint.
+
+## When to add a real subprocess smoke test
+
+Add one whenever the extension:
+
+- depends on real stdin/stdout framing,
+- reads environment variables at runtime,
+- relies on manifest-driven command execution, or
+- you want coverage for startup and shutdown against the real manager.
+
+The `lifecycle-observer` starter template carries this kind of smoke coverage on the Go side as a reference.
+
+## Suggested test matrix
+
+- `initialize` succeeds with the expected accepted capabilities.
+- `initialize` fails on an unsupported protocol version.
+- Each mutable hook returns the patch shape you expect.
+- Event subscriptions narrow correctly.
+- Shutdown exits cleanly.
+- Any Host API call you depend on round-trips through the harness.
+
+## Common mistakes
+
+- Asserting only the patch shape and never running the subprocess.
+- Writing artifacts or memory files directly instead of exercising the Host API.
+- Assuming observe-only hook delivery is strictly ordered — it is concurrent and best-effort.
diff --git a/docs/extensibility/trust-and-enablement.md b/docs/extensibility/trust-and-enablement.md
new file mode 100644
index 00000000..37e763a5
--- /dev/null
+++ b/docs/extensibility/trust-and-enablement.md
@@ -0,0 +1,72 @@
+# Trust and enablement
+
+Productize has no marketplace and no trust tiers. Trust is local: you review the capabilities an extension requests and decide, on your own machine, whether to enable it. This page covers where extensions are discovered, which one wins on a name collision, and how enablement is recorded.
+
+## Discovery scopes
+
+| Scope | Location | Default state |
+| ----- | -------- | ------------- |
+| bundled | embedded in the Productize binary | **enabled** |
+| user | `~/.productize/extensions//` | **disabled** |
+| workspace | `/.productize/extensions//` | **disabled** |
+
+Discovery is separate from enablement. A user or workspace extension is found and listed by `ext list`, but it does not run until you enable it.
+
+## Precedence
+
+When the same name exists in more than one scope, the effective one is:
+
+```
+workspace > user > bundled
+```
+
+`productize ext inspect ` shows the winning manifest and notes anything it overrode. This applies to executable extensions, provider overlays, and skill/agent packs alike.
+
+## Why user and workspace start disabled
+
+Cloning a repository should not silently activate code from `.productize/extensions/` on another developer's machine. Keeping user and workspace extensions disabled by default makes discovery reproducible across machines while requiring explicit operator consent before anything runs.
+
+## Enablement state
+
+Enable/disable is machine-local and stored as state files under your home directory — never in the workspace repository:
+
+| Scope | State file |
+| ----- | ---------- |
+| user | `~/.productize/extensions//.productize-state.json` |
+| workspace | `~/.productize/state/workspace-extensions.json` (keyed by normalized workspace root) |
+
+```bash
+productize ext enable my-ext # flip on, this machine only
+productize ext disable my-ext # flip off
+```
+
+Because enablement state lives under your home directory rather than in the repository, enabling an extension in your checkout does not enable it for a teammate who clones the same repo.
+
+## Install origin
+
+`productize ext install` records where an extension came from in `.productize-origin.json` inside the extension directory:
+
+| Field | Meaning |
+| ----- | ------- |
+| `remote` | `local` or `github` |
+| `repository` | source repo (for GitHub installs) |
+| `ref` | branch, tag, or commit |
+| `subdir` | extension subdirectory inside the source |
+| `resolved_source` | the concrete resolved source |
+| `installed_at` | install timestamp |
+
+## The consent model
+
+Security is capability-based plus local operator consent — there are no remote trust signals to lean on. The review points are:
+
+1. **Install** prints the capabilities the manifest requests. Read them before confirming.
+2. **Enable** is a deliberate, machine-local action. Nothing executable runs until you take it.
+
+Pay particular attention to capabilities with side effects or reach: `artifacts.write`, `tasks.create`, `runs.start`, and the advisory `subprocess.spawn` / `network.egress`.
+
+## Operator practices
+
+- Keep enabled capabilities minimal; disable extensions you no longer use rather than leaving them on.
+- Review workspace extension manifests in code review — they ship in the repo.
+- Run `productize ext doctor` after adding provider or skill/agent-pack declarations.
+- Inspect `~/.productize/runs//run.db` to see which hooks ran, which Host API methods were called, and which capabilities were exercised.
diff --git a/docs/reader-library.md b/docs/reader-library.md
new file mode 100644
index 00000000..14f33aae
--- /dev/null
+++ b/docs/reader-library.md
@@ -0,0 +1,138 @@
+# Reader Library
+
+`pkg/productize/runs` is the public read-only library for inspecting daemon-managed runs through the shared snapshot, pagination, and stream APIs.
+
+The snippets below are mirrored by executable examples in `pkg/productize/runs/examples_test.go`, so `go test ./pkg/productize/runs` compiles and runs the same usage patterns.
+
+## Import
+
+```go
+import "github.com/itseffi/productize/pkg/productize/runs"
+```
+
+## List
+
+Use `List` to enumerate daemon-managed runs for one workspace.
+
+```go
+workspaceRoot := "/path/to/workspace"
+
+summaries, err := runs.List(workspaceRoot, runs.ListOptions{
+ Status: []string{"completed", "failed"},
+ Mode: []string{"prd-tasks", "pr-review"},
+ Limit: 20,
+})
+if err != nil {
+ return err
+}
+
+for _, summary := range summaries {
+ fmt.Printf("%s %s %s\n", summary.RunID, summary.Mode, summary.Status)
+}
+```
+
+## Open
+
+Use `Open` when you already know the run id and want the resolved metadata plus replay/tail access through the daemon transport.
+
+```go
+workspaceRoot := "/path/to/workspace"
+runID := "run-20260406-120000"
+
+run, err := runs.Open(workspaceRoot, runID)
+if err != nil {
+ return err
+}
+
+summary := run.Summary()
+fmt.Printf("%s %s %s\n", summary.RunID, summary.Mode, summary.Status)
+```
+
+## Replay
+
+Use `Replay` to iterate historical events from a sequence number.
+
+```go
+workspaceRoot := "/path/to/workspace"
+runID := "run-20260406-120000"
+
+run, err := runs.Open(workspaceRoot, runID)
+if err != nil {
+ return err
+}
+
+for event, replayErr := range run.Replay(0) {
+ if replayErr != nil {
+ return replayErr
+ }
+ fmt.Printf("%d %s\n", event.Seq, event.Kind)
+}
+```
+
+## Tail
+
+Use `Tail` to replay historical events and then follow newly appended events until the context is canceled.
+
+```go
+workspaceRoot := "/path/to/workspace"
+runID := "run-20260406-120000"
+
+run, err := runs.Open(workspaceRoot, runID)
+if err != nil {
+ return err
+}
+
+ctx, cancel := context.WithCancel(context.Background())
+defer cancel()
+
+eventsCh, errsCh := run.Tail(ctx, 0)
+for {
+ select {
+ case event, ok := <-eventsCh:
+ if !ok {
+ return nil
+ }
+ fmt.Printf("%d %s\n", event.Seq, event.Kind)
+ case err, ok := <-errsCh:
+ if ok && err != nil {
+ return err
+ }
+ }
+}
+```
+
+## WatchWorkspace
+
+Use `WatchWorkspace` to observe run lifecycle changes across the whole workspace.
+
+```go
+workspaceRoot := "/path/to/workspace"
+
+ctx, cancel := context.WithCancel(context.Background())
+defer cancel()
+
+eventsCh, errsCh := runs.WatchWorkspace(ctx, workspaceRoot)
+for {
+ select {
+ case event, ok := <-eventsCh:
+ if !ok {
+ return nil
+ }
+ status := ""
+ if event.Summary != nil {
+ status = event.Summary.Status
+ }
+ fmt.Printf("%s %s %s\n", event.Kind, event.RunID, status)
+ case err, ok := <-errsCh:
+ if ok && err != nil {
+ return err
+ }
+ }
+}
+```
+
+`WatchWorkspace` emits:
+
+- `created` when the daemon reports a new run for the workspace
+- `status_changed` when the daemon-backed `RunSummary` changes
+- `removed` when a run disappears from the daemon-backed workspace listing
diff --git a/docs/reusable-agents.md b/docs/reusable-agents.md
new file mode 100644
index 00000000..223c2777
--- /dev/null
+++ b/docs/reusable-agents.md
@@ -0,0 +1,149 @@
+# Reusable agents
+
+Reusable agents let you package a prompt, runtime defaults, and optional agent-local MCP servers into a directory that Productize can discover and execute.
+
+The first-party `idea-forge` extension ships the advisor roster below. Install and enable it, then run `productize setup` to provision those reusable agents in the selected scope:
+
+```bash
+productize ext install --yes itseffi/productize --remote github --ref --subdir extensions/idea-forge
+productize ext enable idea-forge
+productize setup
+```
+
+Advisors roster:
+
+- `architect-advisor`
+- `stress-tester`
+- `pragmatic-engineer`
+- `product-mind`
+- `security-advocate`
+- `the-thinker`
+
+Those extension-shipped advisor agents intentionally inherit the host runtime, which keeps advisor debates consistent across supported drivers.
+
+## Discovery and override rules
+
+Supported discovery scopes:
+
+- workspace: `.productize/agents//`
+- global: `~/.productize/agents//`
+
+Rules:
+
+- the directory name is the canonical agent id
+- names must match `^[a-z][a-z0-9-]{0,63}$`
+- `productize` is reserved and cannot be used as an agent name
+- when a workspace and global agent share the same name, the workspace directory wins as a whole
+- invalid agent directories are reported per-agent, but they do not prevent other valid agents from loading
+
+Supported v1 files inside an agent directory:
+
+- `AGENT.md`
+- optional `mcp.json`
+
+Deferred fields and folders stay out of scope in v1:
+
+- frontmatter fields `extends`, `uses`, `skills`, and `memory` are rejected
+- sibling `skills/` and `memory/` directories are ignored
+
+## `AGENT.md`
+
+`AGENT.md` uses YAML frontmatter plus a markdown body. Productize reads these frontmatter fields today:
+
+| Field | Purpose |
+| ----- | ------- |
+| `title` | Human-facing name shown in inspect output |
+| `description` | Short description shown in list output and the prompt-visible discovery catalog |
+| `ide` | Default runtime ide for this agent |
+| `model` | Default model override |
+| `reasoning_effort` | Default reasoning effort (`low`, `medium`, `high`, `xhigh`) |
+| `access_mode` | Default runtime access mode (`default` or `full`) |
+
+Other frontmatter keys are not part of the supported v1 contract. Avoid relying on them.
+
+Minimal example:
+
+```md
+---
+title: Reviewer
+description: Reviews implementation plans and diffs before code lands.
+ide: codex
+reasoning_effort: high
+access_mode: default
+---
+
+Review the user's request, inspect the relevant diff or files, identify concrete risks first, and
+then propose the smallest safe next step. Keep the answer concise and actionable.
+```
+
+Committed fixture: [`docs/examples/agents/reviewer/AGENT.md`](examples/agents/reviewer/AGENT.md)
+
+## `mcp.json`
+
+`mcp.json` is optional and uses the standard MCP config shape with a top-level `mcpServers` object.
+
+```json
+{
+ "mcpServers": {
+ "filesystem": {
+ "command": "npx",
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "${PROJECT_ROOT}"]
+ },
+ "github": {
+ "command": "npx",
+ "args": ["-y", "@modelcontextprotocol/server-github"],
+ "env": {
+ "GITHUB_TOKEN": "${GITHUB_TOKEN}"
+ }
+ }
+ }
+}
+```
+
+Committed fixtures:
+
+- [`docs/examples/agents/repo-copilot/AGENT.md`](examples/agents/repo-copilot/AGENT.md)
+- [`docs/examples/agents/repo-copilot/mcp.json`](examples/agents/repo-copilot/mcp.json)
+
+Validation and merge rules:
+
+- `${VAR}` placeholders expand in `command`, `args`, and `env` values when Productize loads the agent
+- a missing environment variable is a validation error; Productize fails closed before starting the ACP session
+- relative `command` paths are resolved against the agent directory
+- `mcp.json` cannot declare a server named `productize`
+- agent-local MCP servers are merged after the reserved host-owned `productize` MCP server
+
+The reserved `productize` MCP server is not configured in `mcp.json`. Productize injects it automatically into ACP sessions it creates so runtimes can call the host-owned `run_agent` tool. Keep the boundary straight:
+
+- `mcp.json` is for external, agent-local MCP servers that belong to one agent definition
+- the reserved `productize` server is a host capability owned by Productize itself
+
+Nested execution follows the same boundary:
+
+- a child agent gets the reserved `productize` server plus the child's own `mcp.json`
+- a child agent does not inherit the parent agent's local MCP servers implicitly
+
+That automatic host injection is what lets optional extension skills such as `idea-forge` run advisors through `run_agent` even when the top-level session was not started with `productize exec --agent ...`.
+
+## Commands
+
+```bash
+productize agents list # list resolved agents
+productize agents inspect reviewer # inspect one definition + validation status
+productize exec --agent reviewer "Review the staged changes"
+```
+
+Example `inspect` output (paths omitted):
+
+```text
+Agent: reviewer
+Status: valid
+Source: workspace
+Title: Reviewer
+Description: Reviews implementation plans and diffs before code lands.
+Runtime defaults: ide=codex model=gpt-5.5 reasoning=high access=default
+MCP servers: none
+Validation: OK
+```
+
+You can combine `--agent` with normal exec controls (`--model`, `--reasoning-effort`, `--format`, `--persist`, `--run-id`). Explicit CLI flags win over `AGENT.md` defaults, and `AGENT.md` runtime defaults win over workspace/global config files. When an inspected agent is invalid, `productize agents inspect ` prints the validation report and exits non-zero.
diff --git a/docs/skills.md b/docs/skills.md
new file mode 100644
index 00000000..ff0d67c9
--- /dev/null
+++ b/docs/skills.md
@@ -0,0 +1,70 @@
+# Skills & the catalog
+
+Skills are the unit of reusable knowledge in Productize. Each one is a self-contained Markdown bundle that teaches your AI agent how to do a specific thing — write a PRD, run a competitive analysis, apply a review gate. Productize ships 262 of them, embedded directly in the binary, and `productize setup` installs them into the AI agents you use.
+
+## Anatomy of a skill
+
+A skill is a directory with a `SKILL.md` and optional supporting files:
+
+```
+skills//
+├── SKILL.md # frontmatter + instructions (required)
+├── references/ # protocols, checklists, deeper guidance (optional)
+├── templates/ # output templates the skill fills in (optional)
+└── agents/ # per-provider agent interface configs, e.g. openai.yaml (optional)
+```
+
+`SKILL.md` starts with YAML frontmatter:
+
+```yaml
+---
+name: create-prd
+description: Turn a feature idea into a Product Requirements Document.
+tier: core
+---
+
+# instructions the agent follows when the skill is invoked…
+```
+
+| Field | Purpose |
+|-------|---------|
+| `name` | The identifier you invoke in your agent. |
+| `description` | One line shown in catalogs and pickers. |
+| `tier` | `core` or `tactical` — controls what `setup` installs. |
+
+## Tiers
+
+Skills are split into two tiers so you can choose how much to install:
+
+- **Core** — the spine of the workflow. The actual core set (from `SkillCatalogTier` in `internal/setup/catalog_export.go`) is: the lifecycle skills `create-prd`, `create-techspec`, `create-tasks`, `execute-task`, `review-round`, `fix-reviews`, `final-verify`, and `workflow-memory`; the review gates `productize-product-review`, `productize-design-review`, `productize-eng-review`, `productize-qa`, `productize-dx-review`, `productize-comms-review`, `productize-release`, and `productize-docs`; and the orchestration skills `productize`, `productize-0-1`, `productize-operate`, `productize-grow`, `productize-autoplan`, and `productize-thesis-review`. Always installed, including with `--core-only`.
+- **Tactical** — everything else: the broad library of domain advisors and analyses across product, design, research, QA, growth, metrics, finance, and engineering. Installed by default; skip them with `--no-tactical` (an alias for `--core-only`).
+
+By default, `productize setup` installs **everything** (the full-power default). Narrow it with:
+
+```bash
+productize setup --core-only # core tier only
+productize setup --skill create-prd --skill create-tasks # specific skills
+productize setup --list # preview the resolved catalog
+```
+
+## The effective catalog
+
+At setup time Productize resolves an **effective catalog** — the bundled skills plus any installed [extensions](extensibility/index.md), with conflicts resolved deterministically (core skills and higher-precedence sources win). The result is written to:
+
+- `~/.productize/catalog/skills.json` — global install
+- `.productize/catalog/skills.json` — project install
+
+This catalog is what your agents read to discover available skills. It's plain JSON; inspect it any time to see exactly what's installed and where each skill came from (bundled vs. extension).
+
+## How agents use skills
+
+Productize doesn't run a skill router. Once installed, skills live in your agent's own skills directory (symlinked by default, or copied with `--copy`). Your agent (Claude Code, Codex, Cursor, …) discovers the `SKILL.md` files and exposes them — typically as named commands you invoke during a session. Productize's job is to install, resolve, and keep the catalog coherent; your agent does the invoking.
+
+## Writing your own skill
+
+1. Create `skills//SKILL.md` (or put it in an extension) with `name`, `description`, and `tier` frontmatter.
+2. Add `references/` or `templates/` if the skill needs supporting material.
+3. Run `productize setup --list` to confirm it resolves into the catalog.
+4. Run `productize setup` to install it into your agents.
+
+To distribute skills to others, package them as an [extension](extensibility/index.md).
diff --git a/internal/cli/agents_commands_test.go b/internal/cli/agents_commands_test.go
index 358e8898..a33c8bff 100644
--- a/internal/cli/agents_commands_test.go
+++ b/internal/cli/agents_commands_test.go
@@ -257,9 +257,8 @@ func TestAgentsListShowsWorkspaceAndGlobalSources(t *testing.T) {
}
func TestAgentsInspectInvalidAgentPrintsValidationBeforeNonZero(t *testing.T) {
- t.Parallel()
-
workspaceRoot := t.TempDir()
+ t.Setenv("GITHUB_TOKEN", "test-token")
writeCLIWorkspaceConfig(t, workspaceRoot, "")
writeCLIWorkspaceAgent(
t,
diff --git a/internal/cli/migrate_command_test.go b/internal/cli/migrate_command_test.go
index 6a7976f5..d03ca1d0 100644
--- a/internal/cli/migrate_command_test.go
+++ b/internal/cli/migrate_command_test.go
@@ -79,5 +79,9 @@ func committedACPFixtureDir(repoRoot string) (string, error) {
return match, nil
}
}
+ testdataPath := filepath.Join(repoRoot, "internal", "cli", "testdata", "acp-integration")
+ if info, err := os.Stat(testdataPath); err == nil && info.IsDir() {
+ return testdataPath, nil
+ }
return "", os.ErrNotExist
}
diff --git a/internal/cli/testdata/acp-integration/task_01.md b/internal/cli/testdata/acp-integration/task_01.md
new file mode 100644
index 00000000..42973008
--- /dev/null
+++ b/internal/cli/testdata/acp-integration/task_01.md
@@ -0,0 +1,10 @@
+---
+status: completed
+title: ACP Agent Layer & Content Model
+type: backend
+complexity: high
+---
+
+# Task 1: ACP Agent Layer & Content Model
+
+Validate that committed ACP fixture tasks remain accepted by `productize tasks validate`.
diff --git a/internal/cli/testdata/acp-integration/task_02.md b/internal/cli/testdata/acp-integration/task_02.md
new file mode 100644
index 00000000..5f4649a3
--- /dev/null
+++ b/internal/cli/testdata/acp-integration/task_02.md
@@ -0,0 +1,12 @@
+---
+status: completed
+title: Execution & Logging Pipeline Migration
+type: backend
+complexity: critical
+dependencies:
+ - task_01
+---
+
+# Task 2: Execution & Logging Pipeline Migration
+
+Validate dependency metadata for committed ACP fixture tasks.
diff --git a/internal/cli/testdata/acp-integration/task_03.md b/internal/cli/testdata/acp-integration/task_03.md
new file mode 100644
index 00000000..6438d507
--- /dev/null
+++ b/internal/cli/testdata/acp-integration/task_03.md
@@ -0,0 +1,12 @@
+---
+status: completed
+title: UI Adaptation & Legacy Cleanup
+type: frontend
+complexity: high
+dependencies:
+ - task_02
+---
+
+# Task 3: UI Adaptation & Legacy Cleanup
+
+Validate frontend task metadata for committed ACP fixture tasks.