diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index dd67139ca..f8d8a146e 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,25 +1,21 @@ - + -## Change +## Issue - + -## What & Why +## Definition of done - - -## Review focus - - + ## Verification - + ## Migration / breaking changes - + -## Deferred +## Out of scope - + diff --git a/AGENTS.md b/AGENTS.md index 8a2d60067..1cbeb1380 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ Guidelines for maintaining and extending Loaf - An Opinionated Agentic Framework See [README.md](README.md) for what Loaf is and how to install it. -> **New work is Change-first.** The Loaf Flow is **pitch → shape → implement → ship → release** (at change scale and project scale). `/pitch` is the human front door: it grills the problem space and authors a brief (`brief.md` via `loaf change init --brief`, or project `docs/BRIEF.md`). `/shape` consumes that brief (or runs full narrowing when none exists), materializes `shape.md` + `tasks/` (promoting a capture-only folder in place via ordinary `loaf change init `), and owns solution-space. `loaf change check` validates structure and derived executability (see `loaf change --help`). Existing `SPEC-*` and task records remain supported compatibility surfaces under `loaf spec` and `loaf task` until deliberately converted — not the default for new work. +> **New work is an Issue.** The Loaf Flow is **pitch → shape → implement → ship → release** (at issue scale and project scale). `/pitch` is the human front door: it grills the problem space and hands a problem narrative to shape, or authors project `docs/BRIEF.md`. `/shape` consumes that narrative (or runs full narrowing when none exists), mints the row with `loaf issue new`, and shapes it in place — problem body, definition-of-done criteria, and an explicit out-of-scope statement. `loaf issue check` validates readiness. Decomposition happens only when a criterion earns its own DoD (`loaf issue promote`). Work is built in a started worktree (`loaf issue start`). `/ship` is the sole quality gate: the PR body is `loaf issue render` output. Releases are retroactive (`loaf release suggest` / `loaf release cut`). ## Quick Start @@ -26,7 +26,7 @@ internal/ # CLI implementation (Go) │ ├── check.go # loaf check │ ├── install.go # loaf install │ └── journal.go # loaf journal -├── state/ # SQLite-backed state (journal, specs, tasks, findings, ...) +├── state/ # SQLite-backed state (journal, issues, findings, ...) └── project/ # Project identity and root resolution cli/ # Node-side build tooling (not the CLI itself) @@ -87,10 +87,10 @@ See [SOUL.md](../SOUL.md) for the Warden identity and fellowship conventions. User-invocable workflow skills must log their invocation to the project journal as their first action. Include context — arguments, intent, or what triggered the invocation: ```bash -loaf journal log "skill(shape): shaping auth token rotation idea into spec" +loaf journal log "skill(shape): shaping auth token rotation into LOAF-42" loaf journal log "skill(housekeeping): routine cleanup, no specific trigger" loaf journal log "skill(wrap): end-of-conversation checkpoint" -loaf journal log "skill(implement): TASK-042 — journal-first hook rewrite" +loaf journal log "skill(implement): LOAF-42 — journal-first hook rewrite" ``` There is no start step and no "active session" to find — the current branch and an opaque `harness_session_id` are attached automatically. This creates an audit trail of which skills ran; `/wrap` reads recent entries to check whether housekeeping or other periodic skills were run. @@ -101,7 +101,7 @@ The project journal is the canonical session model, and it is the only session-r **Wrap is an optional checkpoint, not a lifecycle transition.** Write a `wrap` entry only when a conversation holds synthesis worth saving — "tried X, abandoned because Y, next is Z" — the connective narrative that evaporates with the context window. Nothing is ever "unwrapped"; a conversation that ends without one leaves a perfectly valid journal. -**Continuity is derived and ephemeral.** At conversation start the SessionStart hook runs `loaf journal context --from-hook` to emit a layered digest — the latest project wrap, recent branch entries, and open tasks — computed at read time and never persisted. Subagent invocations exit silently and write nothing. +**Continuity is derived and ephemeral.** At conversation start the SessionStart hook runs `loaf journal context --from-hook` to emit a layered digest — the latest project wrap, recent branch entries, and open issues — computed at read time and never persisted. Subagent invocations exit silently and write nothing. ### Naming Conventions @@ -121,7 +121,7 @@ Use domain-focused names in gerund or noun-phrase form: ### Artifact Names Never Cite Their Work Unit -Artifacts are named for what they are, never for the work unit that produced them. Reference runs one way: a Change, spec, task, or issue points at its artifacts; an artifact never points back. The containing directory already supplies the provenance, so a work identity in the filename is both redundant and doomed — it has to be renamed to stay true, and the number outlives everyone's memory of what it meant. +Artifacts are named for what they are, never for the work unit that produced them. Reference runs one way: an issue points at its artifacts; an artifact never points back. The containing directory already supplies the provenance, so a work identity in the filename is both redundant and doomed — it has to be renamed to stay true, and the number outlives everyone's memory of what it meant. | Instead of | Write | |------------|-------| @@ -131,7 +131,7 @@ Artifacts are named for what they are, never for the work unit that produced the Record provenance in a front-matter field such as `source:` instead, where it is readable and updatable. -These are identity rather than citation and stay as they are: a **version** (`claude-code-2.1.218-plugin-startup-smoke.json`), a **timestamp** (`20260620-214448-skills-audit.md`), and a numbered record inside the directory that owns it (`.agents/specs/SPEC-042-slug.md`, `docs/decisions/ADR-007-slug.md`). +These are identity rather than citation and stay as they are: a **version** (`claude-code-2.1.218-plugin-startup-smoke.json`), a **timestamp** (`20260620-214448-skills-audit.md`), and a numbered record inside the directory that owns it (`docs/decisions/ADR-007-slug.md`). `loaf check --hook artifact-names` enforces this at commit. It judges tracked paths only, matches artifact directories by basename so relocating them needs no change, and grandfathers artifacts already marked `final` or `archived`. @@ -182,7 +182,7 @@ Use action verbs for workflows, nouns for knowledge: | Pattern | Use For | Examples | |---------|---------|----------| -| **Verb** (gerund) | Workflow skills | `implement`, `breakdown`, `research` | +| **Verb** (gerund) | Workflow skills | `implement`, `shape`, `research` | | **Noun** (domain) | Knowledge skills | `typescript-development`, `database-design` | **Why:** Skills that DO things get verbs. Skills that ARE things get nouns. This makes the distinction between "use me to act" and "reference me to know" immediately clear. @@ -221,7 +221,7 @@ description: >- - Good: "Covers...", "Establishes...", "Coordinates..." - Bad: "Use for...", "I can help...", "You can use this..." -2. **Include user-intent phrases**: +2. **Include user-intent phrases:** ```yaml description: >- Covers Python 3.12+ development... Use when building APIs, @@ -249,7 +249,7 @@ description: >- ### Templates -Artifact format templates (session renders, specs, ADRs, task files) live in `templates/` directories. SKILL.md references them with links instead of embedding inline. +Artifact format templates (session renders, ADRs, journal entries) live in `templates/` directories. SKILL.md references them with links instead of embedding inline. **Skill-specific templates:** `content/skills/{name}/templates/` — templates unique to one skill. @@ -423,7 +423,7 @@ There are no session statuses: nothing is `active`, `paused`, `stopped`, `done`, **Entry Format:** ```markdown -[YYYY-MM-DD HH:MM] skill(implement): implementing TASK-042 +[YYYY-MM-DD HH:MM] skill(implement): implementing LOAF-42 [YYYY-MM-DD HH:MM] decision(scope): chose X because Y [YYYY-MM-DD HH:MM] commit(abc1234): message [YYYY-MM-DD HH:MM] discover(scope): learned Z from file/path @@ -602,7 +602,7 @@ Configure target-specific behavior and sidecars. - [Claude Code Skills Best Practices](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices) - [Claude Code Skills Documentation](https://code.claude.com/docs/en/skills) - + ## Loaf Framework @@ -611,12 +611,12 @@ Configure target-specific behavior and sidecars. - `discover(scope)`: Something learned - `block(scope)` / `unblock(scope)`: Blockers and resolutions - `spark(scope)`: Ideas to promote via `/idea` -- `todo(scope)`: Action items to promote to tasks +- `todo(scope)`: Action items to file as issues **CLI Commands:** - `loaf journal log/recent/search/context` - Project journal - `loaf check` - Run enforcement hooks -- `loaf task/spec/kb` - Task and knowledge management +- `loaf issue/kb` - Issue and knowledge management **Journal Discipline:** Before completing any response that includes edits, commits, or significant decisions, log journal entries using `loaf journal log "type(scope): description"`. Entry types: `decision`, `discover`, `wrap`. Do not defer journaling - log before responding. diff --git a/README.md b/README.md index b30150c69..6b519166c 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,11 @@ Loaf is an opinionated agentic framework that gives AI coding assistants structu **Project journal model** — A single SQLite-backed journal captures decisions and progress across every conversation, project-scoped and correlated by an opaque harness id. There is no session entity to open or close, so concurrent conversations across branches and worktrees stay conflict-free. Handoff artifacts live separately in `.agents/handoffs/`. Work survives context loss, compaction, and `/clear`. -**Change-first workflow** — The Loaf Flow is pitch → shape → implement → ship → release. `/pitch` authors a problem-space brief; `/shape` bounds a Change under `docs/changes/YYYYMMDD-slug/` (promoting a capture in place when needed). `loaf change check` validates the contract before implementation, review, and shipping. +**Issue workflow** — The Loaf Flow is pitch → shape → implement → ship → release. `/pitch` authors a problem-space narrative; `/shape` bounds an Issue in place (`loaf issue new`, body + DoD + out-of-scope; decompose with `loaf issue promote` only when a criterion earns its own DoD). `loaf issue check` validates readiness; work is built in a started worktree (`loaf issue start`). `/ship` is the sole quality gate (PR body is `loaf issue render`); `/release` cuts retroactively (`loaf release suggest` / `cut`). **Profile-based agents** — Functional profiles are defined by tool access, not job titles. A Smith with `python-development` skills becomes a backend engineer; the same Smith with `infrastructure-management` becomes a DevOps engineer. Skills determine what an agent knows; the profile determines what it can touch. -**Conversation continuity** — Pick up exactly where you left off with full traceability. The project journal captures decisions and progress in SQLite; a derived, ephemeral digest (latest wrap + recent branch entries + open tasks) is emitted at conversation start. Explicit transfer packets live in `.agents/handoffs/` until housekeeping deletes them after deprecation. +**Conversation continuity** — Pick up exactly where you left off with full traceability. The project journal captures decisions and progress in SQLite; a derived, ephemeral digest (latest wrap + recent branch entries + open issues) is emitted at conversation start. Explicit transfer packets live in `.agents/handoffs/` until housekeeping deletes them after deprecation. **Hooks as quality gates** — Two hook types: enforcement hooks (pre-commit secrets scanning, pre-push linting) block bad commits automatically; skill instruction hooks inject context at tool invocation time. Language-aware and automatic. @@ -22,51 +22,44 @@ Loaf is an opinionated agentic framework that gives AI coding assistants structu Loaf keeps intent, implementation, and learning connected: +```mermaid +flowchart LR + idea["/idea · spark
capture — offline-safe, no ID"] --> triage["/triage"] + pitch["/pitch
problem discovery"] --> shape + triage -- promote --> shape["/shape
the Issue: body · DoD · out-of-scope"] + shape -- "criterion earns its own DoD
loaf issue promote" --> shape + shape -- "sharp question" --> decision["decision issue"] + decision -- answered --> shape + shape -- ready --> build["/implement
loaf issue start: worktree · branch · PR"] + build --> ship["/ship
the sole quality gate"] + ship -- merge --> main[("main")] + main -. "reads landed since last tag" .-> release["/release
suggest → cut"] + release -- "tag · notes · members as fact" --> main ``` -┌─────────────────────────────────────────────────────────────┐ -│ PITCH AND SHAPE │ -│ │ -│ /pitch → brief → /shape → Change (or /bootstrap) │ -│ │ -└─────────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────────┐ -│ IMPLEMENT AND SHIP │ -│ │ -│ /implement → review → /ship │ -│ │ -└─────────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────────┐ -│ RELEASE AND PRESERVE │ -│ │ -│ /release · journal · /reflect · optional /wrap │ -│ │ -└─────────────────────────────────────────────────────────────┘ -``` + +Everything left of ship plans **forward** and can be re-planned freely — issues are recursive (`loaf issue tree`), questions too foggy to act on stay as prose until they sharpen into decision issues, and Now/Next/Later are advisory buckets. The release track only ever reads **backward**: a release is cut from what actually landed, so it can never contain unimplemented work. The journal records every step; `/reflect` and an optional `/wrap` preserve what the work taught. ### Pitch and Shape -Discover the problem, author a brief, then bound an implementable Change (or bootstrap a project from a pitched BRIEF). +Discover the problem, author a brief, then bound an implementable Issue (or bootstrap a project from a pitched BRIEF). | Command | What It Does | |---------|--------------| -| `/pitch` | Human problem-discovery: authors a change `brief.md` or project `docs/BRIEF.md` | +| `/pitch` | Human problem-discovery: authors a problem narrative for shape, or project `docs/BRIEF.md` | | `/idea` | Quick capture of rough ideas for later triage / pitch / shape | -| `/shape` | Create or promote `docs/changes/YYYYMMDD-slug/` into a bounded contract (`shape.md` + `tasks/`) | -| `/bootstrap` | Populate operating docs; with `source: pitch`, gap-interview and series-prep captured changes | +| `/shape` | Bound an issue in place (body, DoD criteria, out-of-scope); `loaf issue check` validates readiness | +| `/bootstrap` | Populate operating docs; with `source: pitch`, gap-interview and file the initial issue arc | | `/strategy` | Discover and document strategic direction | ### Implement and Ship -Implement a coherent Change through contained branches and pull requests, review the result, and land it deliberately. +Implement a shaped issue through a started worktree and pull request, review the result, and land it through ship — the sole quality gate. | Command | What It Does | |---------|--------------| -| `/breakdown` | Decompose existing spec or task records when that compatibility workflow is in use | -| `/implement` | Execute a Change or compatible task/spec record with orchestrated agent delegation | -| `/ship` | Review, verify, and land one PR | -| `/release` | Publish a version from already-landed work | +| `/implement` | Execute a shaped issue with orchestrated agent delegation | +| `/ship` | Review, verify, and land one PR — the sole quality gate; PR body is `loaf issue render` | +| `/release` | Cut a retroactive release from already-landed issues (`loaf release suggest` / `cut`) | ### Preserve Learning @@ -76,7 +69,7 @@ Integrate outcomes into strategic knowledge. |---------|--------------| | `/housekeeping` | Review and archive or delete lifecycle-complete artifacts | | `/reflect` | Integrate learnings into strategic documents | -| `/handoff` | Package context for another agent, branch, task, or future conversation | +| `/handoff` | Package context for another agent, branch, issue, or future conversation | | `/wrap` | Optional checkpoint for synthesis that is not otherwise derivable from the journal | ### Supporting Commands @@ -90,13 +83,11 @@ CLI commands that support the workflow: | `loaf config check` | Validate project config and installed Loaf-managed hooks | | `loaf check` | Run enforcement hooks manually | | `loaf project` | Manage durable project identity (show, rename, move) | -| `loaf change` | Scaffold, validate, and inspect Change artifacts | -| `loaf task` | Manage project tasks (list, show, update, archive) | -| `loaf spec` | Manage existing spec records retained for compatibility | +| `loaf issue` | Create, shape, start, and inspect issues | | `loaf kb` | Knowledge base management | | `loaf journal` | Project journal: log, recent, search, show, context, export | | `loaf housekeeping` | Review and archive agent artifacts | -| `loaf release` | Publish a release: version bump, changelog, tag, and release artifacts | +| `loaf release` | Cut a retroactive release: `suggest` the range, `cut` the version | ## Profiles @@ -119,12 +110,11 @@ Skills you invoke directly to drive work forward. | Skill | Activates When | |-------|----------------| -| `pitch` | Human problem-discovery; authors a brief at change or project scale | -| `shape` | Shaping a brief or raw ask into a bounded Change | -| `breakdown` | Decomposing existing compatible spec/task records | -| `implement` | Implementing a Change or compatible task/spec record | -| `ship` | Reviewing, verifying, and landing one PR | -| `release` | Publishing a version from already-landed work | +| `pitch` | Human problem-discovery; authors a problem narrative at issue scale or `docs/BRIEF.md` at project scale | +| `shape` | Shaping a brief or raw ask into a bounded issue | +| `implement` | Implementing a shaped issue | +| `ship` | Reviewing, verifying, and landing one PR (the sole quality gate) | +| `release` | Cutting a retroactive release from already-landed issues | | `research` | Investigating questions, comparing options | | `strategy` | Discovering or updating strategic direction | | `architecture` | Creating Architecture Decision Records | @@ -133,7 +123,7 @@ Skills you invoke directly to drive work forward. | `reflect` | Integrating learnings into strategic docs | | `housekeeping` | Reviewing and archiving agent artifacts | | `handoff` | Creating disposable transfer packets in `.agents/handoffs/` | -| `bootstrap` | Bootstrapping new or existing projects (series-prep after pitched BRIEF) | +| `bootstrap` | Bootstrapping new or existing projects (initial issue arc after pitched BRIEF) | | `wrap` | Optional end-of-conversation checkpoint: shipped, pending, next | Explore and brainstorm are agent techniques (not user slash entry); agents reach for them when direction is undecided — human entry intent routes to `/pitch`. @@ -217,7 +207,7 @@ Detects installed tools, lets you select targets, and installs pre-built distrib ### Upgrading Existing Projects -Projects created with the older TypeScript runtime can keep using their existing `.agents/` Markdown files after installing the native Go runtime. If no SQLite database exists yet, Loaf runs supported task, spec, report, journal, and housekeeping commands in `markdown-only` compatibility mode. +Projects created with the older TypeScript runtime can keep using their existing `.agents/` Markdown files after installing the native Go runtime. If no SQLite database exists yet, Loaf runs supported report, journal, and housekeeping commands in `markdown-only` compatibility mode. Use this sequence when you are ready to adopt SQLite-backed state: @@ -228,7 +218,7 @@ loaf migrate markdown --apply loaf state status ``` -The dry run counts importable artifacts and skipped files without creating a database. The apply step imports `.agents/` Markdown into the XDG data-home SQLite database without rewriting the source Markdown files. Loaf uses one global SQLite file and partitions rows by stable project ID, so multiple projects share the same database path while project queries stay isolated. Project IDs are not bound to the checkout path or friendly name; use `loaf project rename ` for display names and `loaf project move --from ` after moving a checkout. Newer graph-oriented commands such as `loaf idea`, `loaf spark`, `loaf tag`, `loaf bundle`, and `loaf link` require initialized SQLite state; run `loaf state init` for a fresh project or `loaf migrate markdown --apply` for an existing Markdown project. +The dry run counts importable artifacts and skipped files without creating a database. The apply step imports `.agents/` Markdown into the XDG data-home SQLite database without rewriting the source Markdown files. Loaf uses one global SQLite file and partitions rows by stable project ID, so multiple projects share the same database path while project queries stay isolated. Project IDs are not bound to the checkout path or friendly name; use `loaf project rename ` for display names and `loaf project move --from ` after moving a checkout. Newer graph-oriented commands such as `loaf issue`, `loaf idea`, `loaf spark`, `loaf tag`, `loaf bundle`, and `loaf link` require initialized SQLite state; run `loaf state init` for a fresh project or `loaf migrate markdown --apply` for an existing Markdown project. ### Recovery Tiers and Isolated Restore diff --git a/bin/native/darwin-arm64/loaf b/bin/native/darwin-arm64/loaf index e0b6eebf1..2681c6c36 100755 Binary files a/bin/native/darwin-arm64/loaf and b/bin/native/darwin-arm64/loaf differ diff --git a/cli/scripts/eval-skill-routing.mjs b/cli/scripts/eval-skill-routing.mjs index 714addbb8..82c7b3a68 100755 --- a/cli/scripts/eval-skill-routing.mjs +++ b/cli/scripts/eval-skill-routing.mjs @@ -21,7 +21,7 @@ const API_URL = "https://api.anthropic.com/v1/messages"; const API_KEY = process.env.ANTHROPIC_API_KEY; const DEFAULT_MODEL = "claude-opus-4-6"; const MAX_DESC_CHARS = 250; -const EXPECTED_SKILL_COUNT = 36; +const EXPECTED_SKILL_COUNT = 35; const PRICING = { "claude-opus-4-6": { input: 15, output: 75 }, @@ -60,11 +60,6 @@ const TEST_CASES = { "Resume the workflow exploration where we left off", "Start an exploration of the caching options and checkpoint it", ], - breakdown: [ - "Break this spec into implementation tasks", - "Create tasks for SPEC-015", - "Decompose this plan into atomic work items", - ], "loaf-reference": [ "Which loaf command shows the recent project journal?", "Show me the Loaf CLI command for task status", @@ -193,9 +188,11 @@ const TEST_CASES = { "Perform a threat analysis on the auth flow", ], shape: [ - "Shape this idea into a Change", + "Shape this idea into an issue", "Write a bounded proposal for the auth feature", "Turn this rough concept into an implementable proposal", + "Break this issue into child issues", + "Decompose this plan into atomic work items", ], ship: [ "Ready to merge this PR", diff --git a/cmd/loaf/content_hygiene_test.go b/cmd/loaf/content_hygiene_test.go index 9863a3b89..a48e704bd 100644 --- a/cmd/loaf/content_hygiene_test.go +++ b/cmd/loaf/content_hygiene_test.go @@ -176,7 +176,6 @@ func TestOrchestrationDuplicateAuthorityReferencesRetired(t *testing.T) { for _, owner := range []string{ "../council/SKILL.md", "../shape/SKILL.md", - "../breakdown/SKILL.md", } { if !strings.Contains(orchestration, owner) { t.Fatalf("orchestration router missing owning skill link %q", owner) @@ -219,9 +218,9 @@ func TestPlanningVocabularyConverged(t *testing.T) { "spec-conversion-and-guidance-sweep", }, required: []string{ - "**New work is Change-first.**", - "Existing `SPEC-*` and task records remain supported compatibility surfaces", - "until deliberately converted — not the default for new work", + "**New work is an Issue.**", + "`loaf issue check` validates readiness", + "Releases are retroactive", }, }, { @@ -237,11 +236,11 @@ func TestPlanningVocabularyConverged(t *testing.T) { "### Phase 3:", }, required: []string{ - "**Change-first workflow**", + "**Issue workflow**", "## Workflow", - "### Pitch and Shape", - "### Implement and Ship", - "### Preserve Learning", + "`loaf issue new`", + "`loaf issue promote`", + "`loaf release suggest`", "/pitch", }, }, @@ -333,13 +332,13 @@ func TestPlanningVocabularyConverged(t *testing.T) { "Shape new bounded work", "Start implementing new bounded work", "Continue an existing task or spec record", - "`loaf task` and `loaf spec` remain supported for existing records", + "`loaf task` and `loaf spec` remain readable for legacy records; new work is issues", }, }, { rel: "content/skills/orchestration/references/context-management.md", forbidden: []string{"transitional tasks", "Markdown-to-native transition"}, - required: []string{"`transitional-tasks`", "Open task-board records retained for compatibility.", "Changes, task-board records, reports, ADRs, and commits"}, + required: []string{"`transitional-tasks`", "Leftover board records retained for compatibility", "Issues, reports, ADRs, and commits"}, }, { rel: "content/skills/orchestration/references/parallel-agents.md", diff --git a/cmd/loaf/main_test.go b/cmd/loaf/main_test.go index 3f8854d88..97a1ca538 100644 --- a/cmd/loaf/main_test.go +++ b/cmd/loaf/main_test.go @@ -154,18 +154,18 @@ func TestPublicBinaryDispatchesStateVersionAndReleasePreflightNatively(t *testin output, err = runBinary(binary, workingDir, envWith(), "release", "--post-merge") if err == nil { - t.Fatalf("loaf release --post-merge error = nil, want native candidate-first fail-closed failure\n%s", output) + t.Fatalf("loaf release --post-merge error = nil, want suggest/cut guidance\n%s", output) } - for _, want := range []string{"release blocked: cannot compute candidate version", "no version files detected"} { - if !strings.Contains(output, want) { - t.Fatalf("release output = %q, want %q", output, want) + for _, want := range []string{"suggest", "cut"} { + if !strings.Contains(output, want) && !strings.Contains(err.Error(), want) { + t.Fatalf("release output = %q error = %v, want %q", output, err, want) } } if strings.Contains(output, "Verifying post-merge state") { - t.Fatalf("release output = %q, want candidate-version preflight before post-merge actions", output) + t.Fatalf("release output = %q, want legacy post-merge path gone", output) } if strings.Contains(output, "TypeScript fallback") { - t.Fatalf("release output = %q, want native post-merge path without fallback lookup", output) + t.Fatalf("release output = %q, want native refusal without fallback lookup", output) } } diff --git a/config/hooks.yaml b/config/hooks.yaml index 534f346c4..d572b3c85 100644 --- a/config/hooks.yaml +++ b/config/hooks.yaml @@ -168,11 +168,11 @@ hooks: matcher: "Bash" if: "Bash(gh pr merge:*)" timeout: 5000 - description: Inject housekeeping checklist after a gh pr merge hook; command matching does not prove success + description: Inject issue-done, worktree-stop, and journal housekeeping after a gh pr merge hook; command matching does not prove success session: # SessionStart — emit the layered continuity digest (latest project wrap + - # recent branch entries + open tasks). --from-hook preserves the subagent + # recent branch entries + open issues). --from-hook preserves the subagent # silent-exit guard: no start marker is ever written. The session entity is # dead; the digest is a deterministic read-time query, never persisted. # The command below is the neutral declaration: each target's builder diff --git a/content/agents/background-runner.md b/content/agents/background-runner.md index cacd2ac3e..2c426e33f 100644 --- a/content/agents/background-runner.md +++ b/content/agents/background-runner.md @@ -20,7 +20,7 @@ The spawning agent provides: - Specific task to execute - Files or scope to analyze - Output location (`.agents/reports/YYYYMMDD-HHMMSS-.md`) -- Task/spec reference when available +- Issue reference when available ## Execution Process @@ -30,7 +30,7 @@ Extract from prompt: - What to do (audit, analyze, review) - Scope (files, directories) - Output location -- Task ID or spec ID when provided +- Issue ID when provided ### 2. Execute Work @@ -52,7 +52,7 @@ report: status: unprocessed created: "2026-01-23T14:30:00Z" background_agent_id: "bg-YYYYMMDD-HHMMSS-description" - task_reference: "task or spec reference when provided" + issue_reference: "issue reference when provided" --- # Report Title diff --git a/content/hooks/instructions/post-merge.md b/content/hooks/instructions/post-merge.md index 35d3b90d3..9c8f183e1 100644 --- a/content/hooks/instructions/post-merge.md +++ b/content/hooks/instructions/post-merge.md @@ -1,51 +1,37 @@ **Note:** If you used the ship workflow, these steps were already handled by the skill. This checklist is for manual merges. -# Pre-Merge Checklist +# Post-Merge Housekeeping -Complete these steps on the feature branch before creating the PR. +Complete these steps after a successful squash merge. Leave the started worktree before removing it — do not run `loaf issue stop` from inside that worktree. -1. **Close out spec artifacts** (so they're included in the squash merge): +1. **Switch to the PR base and pull:** ``` - loaf task update TASK-XXX --status done - loaf task archive --spec SPEC-XXX - loaf spec archive SPEC-XXX + git checkout + git pull --ff-only origin ``` - Write an optional `wrap(scope)` journal entry with `loaf journal log` if the work produced synthesis worth saving. - -2. **Update CHANGELOG.md when the PR has release-facing impact:** - Add curated entries under `[Unreleased]` describing what the PR lands. Do not move entries to a versioned section here; the release workflow publishes the batch later. -3. **Rebuild all targets:** +2. **Mark the bound issue done** — this is what "done" means; `loaf issue stop` does not change status: ``` - npx loaf build + loaf issue status done ``` -4. **Commit and push** the changelog and generated artifacts to the PR branch. - -5. **Create PR** with `gh pr create` — title + summary + test plan. - -6. **Squash merge** with a clean commit body: - - Let GitHub default the title: `PR title (#N)` - - Write a concise 2-4 sentence summary as `--body` (use a HEREDOC) - - **Never** use the automatic squash description that dumps all individual commit messages - ---- - -# Post-Merge Housekeeping - -Complete these steps on main after merging. +3. **Stop the started worktree** if one exists (`loaf issue list --started`). `loaf issue stop` removes the worktree, clears `started_branch` / `started_worktree`, and keeps the branch: + ``` + loaf issue stop + ``` + If the issue was never started, the command errors with `issue is not started` — treat that as already clean and continue. Do not pass `--force` without user confirmation. -1. **Switch to main and pull:** +4. **Delete the local feature branch** when safe: ``` - git checkout main && git pull --rebase + git branch -d ``` -2. **Delete merged feature branch:** +5. **Log the landing:** ``` - git branch -d feat/xxx - git push origin --delete feat/xxx + loaf journal log "decision(ship): PR #N landed via squash merge; done" + loaf journal log "commit(): " ``` -3. **Suggest reflection** if the session had key decisions or learnings. +6. **Suggest reflection** if the work produced key decisions or learnings. -4. **Suggest release only when appropriate** — if this PR completes a coherent batch or release branch, publish from the base branch after the landed work is present there. +7. **Suggest release only when appropriate** — if this PR completes a coherent batch, publish later with `loaf release suggest` / `loaf release cut`. The PR is landed, not released, until that cut. diff --git a/content/hooks/instructions/pre-pr-checklist.md b/content/hooks/instructions/pre-pr-checklist.md index d92539828..75129d985 100644 --- a/content/hooks/instructions/pre-pr-checklist.md +++ b/content/hooks/instructions/pre-pr-checklist.md @@ -50,13 +50,10 @@ No scope prefixes. No SPEC/TASK IDs in the title. ### 3. PR body -```markdown -## Summary -- Key changes (2-4 bullets) +The body is `loaf issue render ` output. No project headers, no hand-edited summary. Checkboxes stay unchecked until `loaf issue status done`. -## Test plan -- [ ] Tests added/updated -- [ ] Manual testing performed +``` +gh pr create --title "type: summary" --body "$(loaf issue render )" ``` ### 4. Merge strategy diff --git a/content/skills/bootstrap/SKILL.md b/content/skills/bootstrap/SKILL.md index 3ca83ca99..485d5cd17 100644 --- a/content/skills/bootstrap/SKILL.md +++ b/content/skills/bootstrap/SKILL.md @@ -29,7 +29,7 @@ First-contact project setup: detect state, interview the builder, populate proje - Guardrails - Related Skills -Series-prep lives under Finalization (phase between Knowledge Base Scaffolding and Next Steps). +Series-prep lives under Finalization (phase between Knowledge Base Scaffolding and Next Steps): the initial arc becomes backlog issues, not folders. **Input:** $ARGUMENTS @@ -42,8 +42,8 @@ Series-prep lives under Finalization (phase between Knowledge Base Scaffolding a - **Always interview** -- even with a rich brief, confirm understanding through structured questions — one at a time, with a recommendation, using your harness's structured question tool if it has one - **Pitched BRIEF is discovery-already-done** -- when `docs/BRIEF.md` has `source: pitch`, do not re-excavate the problem space; quote-back and gap-fill only for operating-document population - **BRIEF is input, not output** -- the BRIEF is raw intake. Extract every useful fact into VISION/STRATEGY/ARCHITECTURE/AGENTS during bootstrap. -- **BRIEF is archeological after bootstrap** -- once extraction completes (including series-prep reading scoped concepts from it), the BRIEF is a frozen historical snapshot. No skill, agent, command, or template should reference `docs/BRIEF.md` post-bootstrap. Operating documents and minted change briefs must stand on their own. -- **Series-prep never auto-shapes and never creates branches** -- every mint is user-confirmed; no priority, date, or dependency fields; concepts without a coarse `target_release` stay BRIEF lines, sparks, or Intents +- **BRIEF is archeological after bootstrap** -- once extraction completes (including series-prep reading scoped concepts from it), the BRIEF is a frozen historical snapshot. No skill, agent, command, or template should reference `docs/BRIEF.md` post-bootstrap. Operating documents and minted issue bodies must stand on their own. +- **Series-prep never auto-shapes and never creates branches** -- every mint is user-confirmed; no priority, date, or dependency fields; buckets are labels, never bindings; concepts that fail granularity stay BRIEF lines or sparks - **Suggest, don't execute** -- recommend next skills at the end, never auto-run them - **Log first** -- log invocation before interviewing: `loaf journal log "skill(bootstrap): "` - **Log outcome** -- log bootstrap completion to the project journal: `loaf journal log "decision(bootstrap): project bootstrapped, mode detected"` @@ -55,7 +55,7 @@ Series-prep lives under Finalization (phase between Knowledge Base Scaffolding a - All expected operating documents (`docs/VISION.md`, `AGENTS.md` at minimum) exist and contain populated content - Useful BRIEF content has been extracted into operating documents (no future reader should need to open the BRIEF) - When `source: pitch`, the interview was gap-only (no re-excavation of already-specific problem sections) -- When series-prep ran: each minted folder has `change.json` with stamped `target_release`, a standalone problem-space `brief.md`, zero-violation captured state via explicit-path `loaf change check --json`, and its own docs-only commit (never a batch); no branches created for the series; no auto-shape +- When series-prep ran: each minted row is a backlog issue (`loaf issue new "" --body "<problem narrative>" --status backlog`) with a standalone problem-space body; an advisory bucket (`loaf issue bucket <ref> now|next|later`) may be set — buckets are labels, never bindings; `loaf issue check <ref>` only when a capture is shaped enough to check, otherwise nothing (a backlog issue with a problem body needs no ceremony); no folders, no docs-only commits; no branches created for the series; no auto-shape - Root `AGENTS.md` is a real file; on Claude Code, the compatibility symlink `.claude/CLAUDE.md -> ../AGENTS.md` exists (see Finalization) - Key decisions and interview outcomes were logged with `loaf journal log` and are readable with `loaf journal recent` @@ -236,7 +236,7 @@ Pitch owned the problem-space grill. Bootstrap does not re-excavate. The pitch 1. **Acknowledge the pitch** — name that `docs/BRIEF.md` carries `source: pitch` and that problem discovery is already done. 2. **Summarize what pitch captured** — short section-by-section gist (problem, who, alternatives, value, constraints, sequencing, open questions). The builder should hear continuity with the pitch closing ceremony, not a cold restart. -3. **State what bootstrap will do now** — interview only on gaps for operating-document population (VISION, STRATEGY, ARCHITECTURE, AGENTS), then series-prep the initial arc of captured changes. Do not re-grill the problem space. +3. **State what bootstrap will do now** — interview only on gaps for operating-document population (VISION, STRATEGY, ARCHITECTURE, AGENTS), then series-prep the initial arc as backlog issues. Do not re-grill the problem space. Then continue: @@ -423,58 +423,62 @@ The journal should capture: Use [templates/journal.md](templates/journal.md) only as the rendered entry format reference; do not hand-author journal markdown as the source of truth. -### 4. Series-Prep (initial arc as captured changes) +### 4. Series-Prep (initial arc as backlog issues) -After operating documents are populated (and Knowledge Base Scaffolding above has run), close bootstrap by minting the BRIEF's initial arc as **captured promise carriers** — brief-only change folders bound to a coarse `target_release`, each landed as its own docs-only commit. Series-prep is not roadmap planning: no milestone entities, no dates, no priorities, no dependency fields. Sequencing is prose in each brief; cohort membership is the shared `target_release`. +After operating documents are populated (and Knowledge Base Scaffolding above has run), close bootstrap by minting the BRIEF's initial arc as **backlog issues** — SQLite rows with a problem-space body and an optional advisory bucket label (`loaf issue bucket <ref> now|next|later`). Buckets are labels, never bindings. Series-prep is not roadmap planning: no milestone entities, no dates, no priorities, no dependency fields. Sequencing is prose in each issue body. No folders, no docs-only commits per capture — rows, not files. **When to run** - Always offer series-prep when a project BRIEF exists and names more than one scoped concept (typical after a pitched BRIEF; also after a rich non-pitch brief). -- If the BRIEF is a single atomic concept with no series, say so and skip to Next Steps — one future shape or a single capture later is enough. -- Series-prep **reads** the BRIEF during this phase only. After bootstrap ends, nothing references `docs/BRIEF.md` again; minted change briefs and operating docs stand alone. +- If the BRIEF is a single atomic concept with no series, say so and skip to Next Steps — one future shape or a single issue later is enough. +- Series-prep **reads** the BRIEF during this phase only. After bootstrap ends, nothing references `docs/BRIEF.md` again; minted issue bodies and operating docs stand alone. **Procedure** 1. **Enumerate concepts** with the builder from the BRIEF's scoped problem space (Sequencing and Relationships, Open Questions, and distinct problem threads in Problem Statement). List candidates as recommendation-first options using your harness's structured question tool if it has one. -2. **Apply granularity** per [references/interview-guide.md](references/interview-guide.md) (Series-Prep Granularity): a concept earns its own captured change when it is independently shippable **and** its problem can be **stated precisely now** (the mint-time specifiability test — not answered now, stated now) without the others; otherwise it stays a BRIEF line, becomes a spark, or an Intent — never a half-minted folder. +2. **Apply granularity** per [references/interview-guide.md](references/interview-guide.md) (Series-Prep Granularity): a concept earns its own backlog issue when it is independently shippable **and** its problem can be **stated precisely now** (the mint-time specifiability test — not answered now, stated now) without the others; otherwise it stays a BRIEF line or becomes a spark — never a half-minted row. 3. **Per confirmed concept (one at a time — never batch):** - 1. Confirm mint with the builder (slug, coarse `target_release`, one-line problem restatement). If the builder will not bind even a coarse target, do not mint — park as spark/Intent/BRIEF line. - 2. Propose a **local slug** that names the concept, never another work unit (`spec-042`, task ids, change folder names). Confirm the slug. - 3. Run capture init: + 1. Confirm mint with the builder (title, optional advisory bucket, one-line problem restatement). Buckets are labels, never bindings — a missing bucket does not block mint. If the concept fails granularity, do not mint — park as spark or BRIEF line. + 2. Propose a **working title** that names the concept, never another work unit (issue aliases, task ids). Confirm the title. + 3. **Seed a problem-space-only narrative** from the BRIEF's content for that concept (Problem Statement, Who Has It, Current Alternatives, Value Proposition, Constraints, Sequencing and Relationships as prose order relative to the arc, Sources if any, Open Questions). Do not copy solution design. The seeded body must stand alone as intent for later shape — cold-read without the project BRIEF or this session. + 4. Mint the backlog issue: ```bash - loaf change init <slug> --brief + loaf issue new "<title>" --body "<problem narrative>" --status backlog ``` - Creates `docs/changes/YYYYMMDD-<slug>/` with `change.json` + `brief.md` only. - 4. **Seed `brief.md` problem-space-only** from the BRIEF's content for that concept (Problem Statement, Who Has It, Current Alternatives, Value Proposition, Constraints, Sequencing and Relationships as prose order relative to the arc, Sources if any, Open Questions). Do not copy solution design. The seeded brief must stand alone as intent for later shape — cold-read without the project BRIEF or this session. - 5. **Stamp `target_release`** on that folder's `change.json` with the builder's coarse binding (canonical `MAJOR.MINOR.PATCH`, no `v`, no prerelease). Series-prep mints only targeted captures (promise-carrier path). - 6. **Pre-landing guard** (required before every commit): + Creates a SQLite row, not a folder. Use `--body -` or `--body-file <path>` when the narrative is long (see `loaf issue new --help`). + 5. **Optionally set an advisory bucket** (a label, never a binding): ```bash - loaf change check <folder> --json + loaf issue bucket <ref> now|next|later ``` + 6. **Validate** only when the capture is shaped enough to check: - Must report zero violations and captured state. Then **read `<folder>/change.json` directly** and confirm the stamped `target_release` matches what the builder bound. Bare `loaf change check` resolves by branch and can miss a capture elsewhere — always pass the explicit folder path. - 7. **Land as its own docs-only commit on the default branch** (one commit per capture, never a batch). Example subject: `docs(change): capture <slug> brief`. Bootstrap prepares the commit; never push; never open a PR. + ```bash + loaf issue check <ref> + ``` + + A backlog issue with a problem body is capture-only and needs no ceremony — skip the check. Do not add criteria or an out-of-scope statement during series-prep (that is shape). + 7. **Do not land a docs-only commit.** The row is the artifact. Never push; never open a PR; never create a branch. 4. **Guards (hard):** - Every mint is user-confirmed — never auto-mint the whole list - - Never auto-run shape and never create slug branches during series-prep - - No priority, date, estimate, or dependency fields on captures - - No batching multiple captures into one commit - - Concepts without a coarse target stay BRIEF lines, sparks, or Intents + - Never auto-run shape and never create branches during series-prep + - No priority, date, estimate, or dependency fields on issues + - No folders, no docs-only commits per capture — rows, not files + - Concepts that fail granularity stay BRIEF lines or sparks; buckets are labels, never bindings **After the series** -Log the arc: `loaf journal log "decision(bootstrap): series-prep minted <n> captures for <cohort or targets>"`. Hand off by naming the first capture folder for shape when the builder is ready. +Log the arc: `loaf journal log "decision(bootstrap): series-prep minted <n> backlog issues"`. Hand off by naming the first issue ref for shape when the builder is ready. ### 5. Next Steps Suggest relevant next steps based on what was learned: -- shape -- on a series-prep capture (or any ready concept) to promote the folder and bound implementation +- shape -- on a series-prep backlog issue (or any ready concept) to bound implementation - pitch -- if a new concept still needs problem discovery (not for re-grilling the BRIEF) -- idea -- if specific feature ideas emerged during the interview and should not become captures yet +- idea -- if specific feature ideas emerged during the interview and should not become issues yet - research -- if there are open questions that need investigation - `loaf doctor` -- to verify the setup is healthy @@ -501,18 +505,18 @@ When the interactive interview path is unavailable, bootstrap the operating docu 2. **Always interview** -- even with a rich brief, confirm understanding; when `source: pitch`, gap-fill only 3. **Never overwrite** -- existing documents require explicit confirmation 4. **Draft, then review** -- present documents section-by-section -5. **Extract, don't preserve** -- pull every useful fact from the BRIEF into operating docs (and series-prep seeds change briefs from it once). The BRIEF is archeological after bootstrap; nothing should reference it again. +5. **Extract, don't preserve** -- pull every useful fact from the BRIEF into operating docs (and series-prep seeds issue bodies from it once). The BRIEF is archeological after bootstrap; nothing should reference it again. 6. **Record the session** -- decisions and rationale are preserved 7. **Suggest, don't execute** -- recommend next skills, don't auto-run them; series-prep never auto-shapes or creates branches 8. **Interview structured** -- one question at a time, with a recommendation, using your harness's structured question tool if it has one -9. **Series-prep is not roadmap planning** -- coarse `target_release` + prose sequencing only; no dates, priorities, or dependency fields +9. **Series-prep is not roadmap planning** -- advisory bucket labels (never bindings) + prose sequencing only; no dates, priorities, or dependency fields --- ## Related Skills -- **pitch** -- Authors a project-scale `docs/BRIEF.md` with `source: pitch` (or a change-scale brief); bootstrap consumes the pitched BRIEF with gap-only interview and series-prep -- **shape** -- Bound a captured change into a contract (promotes brief-only folders; often follows series-prep) +- **pitch** -- Authors a project-scale `docs/BRIEF.md` with `source: pitch` (or an issue-scale problem narrative); bootstrap consumes the pitched BRIEF with gap-only interview and series-prep +- **shape** -- Bound a backlog issue into a contract (often follows series-prep) - **explore** -- Agent technique when a concept that emerges during bootstrap is still undecided (not a user front door; prefer pitch for human problem discovery) - **research** -- Investigate topics and open questions - **idea** -- Quick-capture feature ideas that emerge during bootstrap diff --git a/content/skills/bootstrap/references/interview-guide.md b/content/skills/bootstrap/references/interview-guide.md index f3fc0c1ef..83f583396 100644 --- a/content/skills/bootstrap/references/interview-guide.md +++ b/content/skills/bootstrap/references/interview-guide.md @@ -358,33 +358,32 @@ Expect 6-10 questions total, mostly in Excavation and Sharpening. Grounding is l ## Series-Prep Granularity -Series-prep (bootstrap Finalization) turns the project BRIEF's scoped concepts into captured change folders. This section resolves when a concept earns a folder versus staying a BRIEF line, spark, or Intent. Full procedure: bootstrap SKILL.md Finalization → Series-Prep. +Series-prep (bootstrap Finalization) turns the project BRIEF's scoped concepts into backlog issues. This section resolves when a concept earns a row versus staying a BRIEF line or spark. Full procedure: bootstrap SKILL.md Finalization → Series-Prep. -### Earns its own captured change when both are true +### Earns its own backlog issue when both are true -1. **Independently shippable** — the concept could land as a meaningful release unit without waiting on the other scoped concepts to ship in the same commit or PR. Sharing a coarse `target_release` cohort is fine; hard entanglement is not. -2. **Specifiability (mint test)** — a concept mints only if its problem can be **stated precisely now** (not answered now): a cold reader can name the friction, who has it, the current alternative, and the value for *this* concept alone, without needing sibling folders or inventing what was meant. Everything vaguer stays a BRIEF line, spark, or Intent — never pre-sliced into a fake capture. +1. **Independently shippable** — the concept could land as a meaningful release unit without waiting on the other scoped concepts to ship in the same commit or PR. Sharing an advisory bucket is fine (buckets are labels, never bindings); hard entanglement is not. +2. **Specifiability (mint test)** — a concept mints only if its problem can be **stated precisely now** (not answered now): a cold reader can name the friction, who has it, the current alternative, and the value for *this* concept alone, without needing sibling issues or inventing what was meant. Everything vaguer stays a BRIEF line or spark — never pre-sliced into a fake row. -When both hold and the builder will bind a coarse `target_release`, mint: `loaf change init <slug> --brief`, seed problem-space-only, stamp the target, pre-landing check + `change.json` read-back, one docs-only commit on the default branch. +When both hold, mint: `loaf issue new "<title>" --body "<problem narrative>" --status backlog`, seed problem-space-only, optionally `loaf issue bucket <ref> now|next|later` (buckets are labels, never bindings). Run `loaf issue check <ref>` only when the capture is shaped enough to check; a backlog issue with a problem body needs no ceremony. No folders, no docs-only commits — rows, not files. -### Stays a BRIEF line, spark, or Intent when any is true +### Stays a BRIEF line or spark when any is true - The concept only makes sense as a clause of a larger problem (it cannot be stated alone). - Its problem cannot yet be stated precisely — coarse intent only; minting would invent precision. - It is a solution-space detail, implementation task, or "nice-to-have" flavor — not a shippable problem boundary. -- The builder will not bind even a coarse `target_release` (untargeted series members are not promise carriers on main; keep them as sparks/Intents or leave them as prose in the BRIEF until a target exists). -- Splitting would create two folders that always ship as one unit — keep one folder and name the sub-concerns in Sequencing prose. +- Splitting would create two issues that always ship as one unit — keep one issue and name the sub-concerns in Sequencing prose. ### Interview moves during series-prep -- Recommend a decomposition first (list candidate folders + what stays in the BRIEF), then confirm each mint one at a time. +- Recommend a decomposition first (list candidate issues + what stays in the BRIEF), then confirm each mint one at a time. - Challenge over-splitting: "Can this problem be stated precisely now without that one?" If no, merge candidates or leave as BRIEF lines. -- Challenge under-splitting: "Is this independently shippable, or are we smuggling two products into one capture?" -- Never invent priority ranks, dates, or machine dependency fields — sequencing is prose; cohort is `target_release`. +- Challenge under-splitting: "Is this independently shippable, or are we smuggling two products into one issue?" +- Never invent priority ranks, dates, or machine dependency fields — sequencing is prose; buckets are labels, never bindings. ### Standalone cold-read test (H3) -Each minted brief must name its problem without reference to the bootstrap session or to `docs/BRIEF.md`. If a draft says "as discussed" or "per the project brief," rewrite until the problem stands alone. +Each minted issue body must name its problem without reference to the bootstrap session or to `docs/BRIEF.md`. If a draft says "as discussed" or "per the project brief," rewrite until the problem stands alone. --- diff --git a/content/skills/bootstrap/templates/brief.md b/content/skills/bootstrap/templates/brief.md index d7b58d0db..5b550a307 100644 --- a/content/skills/bootstrap/templates/brief.md +++ b/content/skills/bootstrap/templates/brief.md @@ -39,7 +39,7 @@ archived: true # Always true -- BRIEF is a historical snapshot, not a w ## Sequencing and Relationships -[How the initial arc hangs together — which concepts belong as early changes, what depends on what, release cohort stated as prose. No machine relation fields; narrative order only.] +[How the initial arc hangs together — which problems become early backlog issues, what depends on what, sequencing stated as prose. No machine relation fields; narrative order only.] ## Sources and Research Links diff --git a/content/skills/breakdown/SKILL.claude-code.yaml b/content/skills/breakdown/SKILL.claude-code.yaml deleted file mode 100644 index 9f91c8a29..000000000 --- a/content/skills/breakdown/SKILL.claude-code.yaml +++ /dev/null @@ -1,3 +0,0 @@ -# Claude Code skill configuration -user-invocable: true -argument-hint: "[spec-file or topic]" diff --git a/content/skills/breakdown/SKILL.md b/content/skills/breakdown/SKILL.md deleted file mode 100644 index 9a73ccd95..000000000 --- a/content/skills/breakdown/SKILL.md +++ /dev/null @@ -1,329 +0,0 @@ ---- -name: breakdown -description: >- - Decomposes specifications into atomic tasks with dependencies and priorities. - Use when the user asks "break this down" or "create tasks for this spec." - Produces task files with estimates, dependencies, and acceptance criteria. - Not for shaping ideas (use shape) or implementation work (use implement). ---- - -# Breakdown - -Decompose specifications into atomic, implementable tasks. - -## Contents -- Critical Rules -- Verification -- Quick Reference -- Task Breakdown Philosophy -- Task Backend Detection -- Process -- Linear-Native Mode -- Local-Tasks Mode -- Priority Levels -- Guardrails -- Related Skills - -**Input:** $ARGUMENTS - ---- - -## Critical Rules - -- **One concern per task** -- never mix unrelated layers (backend + frontend) in a single task -- **Every task includes its own verification** -- no separate "verify" tasks; each task must have an observable done condition -- **Own the decisions** -- decide granularity and priorities autonomously; only ask the user when two equally valid orderings have genuinely different trade-offs -- **Keep tests with the code they test** -- never split implementation and tests into separate tasks -- **Update spec status** -- mark the spec as `implementing` after tasks are created -- **One backend only** -- in Linear-native mode create Linear issues and NO local `TASK-NNN.md`; in local mode create local tasks and make NO Linear calls -- **Spec file is always local** -- in both modes, the spec stays in `.agents/specs/`. The Linear parent issue, when present, is a rollup pointing to the spec, not a re-host of it -- **Log outcome** -- log breakdown to the project journal: `loaf journal log "decision(breakdown): SPEC-NNN → N tasks created"` - ---- - -## Verification - -- Each created task has a clear title, priority, file hints, verification command, and observable done condition -- The dependency graph has no cycles and reflects actual implementation order -- Spec status has been updated to `implementing` -- **Linear-native mode only:** parent issue exists, labeled `spec`, with description pointing to the local spec file; N sub-issues have `parentId` set; zero local task rows or `TASK-NNN.md` files were created; spec frontmatter has `linear_parent` and `linear_parent_url` populated -- **Local-tasks mode only:** N local tasks exist in `loaf task list` with compatibility `.md` files when configured; no Linear calls were made - ---- - -## Quick Reference - -### Priority Levels - -| Priority | Loaf | Linear Priority | -|----------|------|-----------------| -| P0 | Urgent/blocking -- drop everything | Urgent (1) | -| P1 | High -- work next | High (2) | -| P2 | Normal -- scheduled work (default) | Normal (3) | -| P3 | Low -- when time permits | Low (4) | - -### Right-Sizing Rules - -| Rule | Guideline | -|------|-----------| -| **One agent type** | Completable by a single implementer (after skills narrowing) | -| **One concern** | Touches one layer, service, or component | -| **Context-appropriate** | Fits in model context with room for exploration | -| **Not over-fragmented** | Don't split what naturally belongs together | - -### Mode Selection - -| `integrations.linear.enabled` in `.agents/loaf.json` | Mode | See | -|------------------------------------------------------|------|-----| -| `true` | Linear-native | [Linear-Native Mode](#linear-native-mode) | -| `false` or absent | Local-tasks | [Local-Tasks Mode](#local-tasks-mode) | - ---- - -## Task Breakdown Philosophy - -**Primary principle: separation of concerns.** - -### The Right Size Test - -1. Can a single implementer complete this? If no, split by concern -2. Does it touch multiple unrelated concerns? If yes, split by concern -3. Will the agent need too much context? If yes, split into smaller coherent units -4. Am I splitting just to have more tasks? If yes, merge back - -### Anti-Patterns - -| Don't | Do Instead | -|-------|------------| -| Split backend + tests into separate tasks | Keep tests with the code they test | -| Create a task per file | Group files by concern | -| Separate "implement" and "verify" tasks | Every task includes its own verification | -| Copy the full spec text into the Linear parent issue | Summarize + link to the local spec file | -| Create both local `TASK-NNN.md` and Linear sub-issues | Pick one backend; never mix | - ---- - -## Task Backend Detection - -Read `.agents/loaf.json`: - -```json -{ - "integrations": { - "linear": { "enabled": true } - } -} -``` - -If `integrations.linear.enabled` is `true`, proceed in **Linear-native mode**. -Otherwise, proceed in **Local-tasks mode**. - -If `.agents/loaf.json` is missing, default to local-tasks and note the -assumption in the project journal. - ---- - -## Process - -### Step 1: Parse Input - -`$ARGUMENTS` should reference a spec (e.g., "SPEC-001"). If unclear, list available specs. - -### Step 2: Read the Spec - -Extract: test conditions, scope, implementation notes, priority ordering, complexity size. - -### Step 3: Identify Task Boundaries - -Break down by concern (data layer, backend, frontend, infrastructure, etc.). One concern per task. Explicit dependencies for sequential tasks. - -### Step 4: Decide Priorities and Granularity - -Own the granularity and priority decisions. Apply the Right Size Test, assign priorities -based on dependencies, priority order, and go/no-go gates, and do a self-review pass. Do not -defer these decisions to the user — they trust agent judgment here. - -If genuinely uncertain (e.g., two equally valid orderings with different trade-offs), -ask. Otherwise, decide and move on. - -### Step 5: Draft Task List - -Draft tasks following [task template](templates/task.md). Each task needs: clear title, priority, file hints, verification command, observable done condition, labels (if routing by team). - -### Step 6: Present the Plan - -Show the dependency graph and task summary for awareness before creating anything. -Present it as "here's what I'm creating" not "which option do you prefer?" The user -can still adjust after creation, but the default is to proceed. - -### Step 7: Create Tasks (mode-specific) - -Detect the mode (see [Task Backend Detection](#task-backend-detection)) and follow the -matching section below. Do NOT mix modes. - -- Linear enabled → [Linear-Native Mode](#linear-native-mode) -- Linear disabled or missing → [Local-Tasks Mode](#local-tasks-mode) - -### Step 8: Update Spec and Announce - -Set spec status to `implementing`. In Linear-native mode, also write -`linear_parent` and `linear_parent_url` into the spec's frontmatter. Announce -created tasks and next steps. - ---- - -## Linear-Native Mode - -Spec files stay local and canonical in `.agents/specs/`. Tasks live in Linear -as sub-issues of a parent rollup issue representing the spec. No local -task rows or `TASK-NNN.md` files are created. - -### 7a. Ensure the `spec` label exists - -The `spec` label groups all spec-parent rollup issues so Linear users can -filter for them. - -1. Call `list_issue_labels` to check whether a label named `spec` exists. -2. If missing, create it via `create_issue_label`: - - `name`: `spec` - - `color`: `#5e6ad2` (Linear-ish indigo; implementer may adjust) - - `description`: `Parent rollup issue representing a design spec tracked in the repo at .agents/specs/` - - Prefer workspace-scoped so all teams can filter uniformly. If the MCP - only supports team-scoped labels, create on the default team. -3. Log whether the label was created this run or already existed. This - matters for first-time Loaf setup on a Linear workspace. - -### 7b. Resolve team, project, and state - -Read from `.agents/loaf.json`: - -- **Team:** `linear.default_team` (name) — resolve to team ID via - `list_teams` if not already cached in `known_teams`. -- **Project:** `linear.project.id`. -- **State:** call `list_issue_statuses` for the team, pick the - `unstarted`-type state (typically "Backlog" or "To-Do"). States are - **team-scoped**, not workspace-scoped — always pass the team. - -### 7c. Create the parent issue - -Use `create_issue` with: - -| Field | Value | -|-------|-------| -| `title` | `[SPEC-NNN] <spec title>` | -| `teamId` | from 7b | -| `projectId` | from 7b | -| `stateId` | unstarted state from 7b | -| `priority` | mapped from spec (default High = 2 if unspecified) | -| `labels` | `["spec"]` | -| `description` | Summary synthesized from the spec's Problem Statement + Solution Direction (1–3 paragraphs), ending with: `See .agents/specs/SPEC-NNN-<slug>.md for full text, council references, and strategic tensions.` | - -**Do NOT** copy the full spec body into the description. The local file is canonical. - -### 7d. Check label-group conflicts (pre-flight per sub-issue) - -Linear labels can belong to exclusive groups (e.g., a `type` group where -`feature`, `testing`, `docs`, `bug`, `refactor` are mutually exclusive). -Before creating each sub-issue: - -1. Inspect proposed labels against known group membership (from - `list_issue_labels` group metadata). -2. If a task has more than one label from the same exclusive group, pick the - most appropriate and drop the others. Warn the user about the drop. -3. Log the resolution so the user can override if desired. - -### 7e. Create sub-issues - -For each task, use `create_issue` with: - -| Field | Value | -|-------|-------| -| `parentId` | parent issue ID from 7c | -| `title` | task title | -| `description` | task description + acceptance criteria | -| `teamId` | routed from `team_keywords` or falling back to `default_team` | -| `projectId` | same as parent unless task explicitly belongs elsewhere | -| `stateId` | unstarted state for the target team | -| `priority` | mapped from task priority (see Priority Levels table) | -| `labels` | task labels after conflict resolution (7d) | - -Express dependencies from the spec's Priority Order / dependency graph via -`blockedBy` referencing sibling sub-issue IDs. Create in dependency order so -predecessors exist when referenced. - -### 7f. Do NOT create local task files - -Skip `loaf task create` entirely. Linear issue IDs are the task record. No -local task rows or `TASK-NNN.md` files for this spec's tasks. - -### 7g. Update spec frontmatter - -Add to the spec file's YAML frontmatter: - -```yaml -linear_parent: ENG-198 -linear_parent_url: https://linear.app/<workspace>/issue/ENG-198 -``` - -Use the actual parent issue identifier and URL returned from 7c. - ---- - -## Local-Tasks Mode - -Spec files and task files both live locally. No Linear calls. - -Use `loaf task create --spec SPEC-XXX --title "Task title" --priority P1` -for each task. In SQLite-backed projects, the CLI creates the operational state -row and any compatibility Markdown/index artifacts needed by the current -project. Then edit the `.md` body content (description, acceptance criteria) -only when an authored task prose artifact exists. - -Dependencies are expressed through CLI flags such as `--depends-on`, not by -hand-editing the compatibility index. Priority Order from the spec maps directly -to task `priority` fields. - -See [local-tasks reference](../orchestration/references/local-tasks.md) for -the full local-task model. - ---- - -## Priority Mapping (reference) - -| Loaf | Linear API value | Linear label | -|------|------------------|--------------| -| P0 | `1` | Urgent | -| P1 | `2` | High | -| P2 | `3` | Normal | -| P3 | `4` | Low | - ---- - -## Guardrails - -1. **One concern per task** -- don't mix backend + frontend -2. **Clear verification** -- how to prove it works -3. **Observable done condition** -- not subjective -4. **File hints** -- help session know where to look -5. **Own the decisions** -- decide granularity and priorities, don't defer -6. **Update spec status** -- mark as implementing -7. **One backend only** -- Linear-native creates Linear issues and no local tasks; local-tasks mode creates local tasks and no Linear calls -8. **Summary not copy** -- the Linear parent description summarizes + links; it does not re-host the spec - ---- - -## Suggests Next - -After breakdown completes, suggest implement to start working on the tasks. - -## Related Skills - -- **shape** -- Create specs that get broken down -- **implement** -- Start session for a task or coordinate multiple tasks - -## Topics - -| Topic | Reference | Use When | -|-------|-----------|----------| -| Linear Integration | `orchestration/references/linear.md` | Working out Linear issue structure, labels, parent/child | -| Local Task Model | `orchestration/references/local-tasks.md` | Local-tasks mode details and CLI flags | diff --git a/content/skills/breakdown/SKILL.opencode.yaml b/content/skills/breakdown/SKILL.opencode.yaml deleted file mode 100644 index f3db51518..000000000 --- a/content/skills/breakdown/SKILL.opencode.yaml +++ /dev/null @@ -1,2 +0,0 @@ -# OpenCode skill configuration -subtask: false diff --git a/content/skills/breakdown/templates/task.md b/content/skills/breakdown/templates/task.md deleted file mode 100644 index fe74a794f..000000000 --- a/content/skills/breakdown/templates/task.md +++ /dev/null @@ -1,28 +0,0 @@ -# Task Template - -**Create with:** `loaf task create --title "..." --spec SPEC-XXX` - -```yaml ---- -id: TASK-XXX -title: [Clear action] -spec: SPEC-001 -status: todo -priority: P2 -files: - - [likely file 1] - - [likely file 2] -verify: [command to verify] -done: [observable outcome] ---- - -## Description -[What needs to be done] - -## Acceptance Criteria -- [ ] [Criterion 1] -- [ ] [Criterion 2] - -## Context -See SPEC-001 for full context. -``` diff --git a/content/skills/council/SKILL.md b/content/skills/council/SKILL.md index 77f957f0a..22926f617 100644 --- a/content/skills/council/SKILL.md +++ b/content/skills/council/SKILL.md @@ -76,13 +76,13 @@ Councils stay **local**. Even when the workspace uses Linear-native mode, council files live in `.agents/councils/` — they are deliberation artifacts, not executable work, and belong with specs in git. -When a council resolves a spec's open questions: +When a council resolves an issue's open questions: -- Include the spec ID in council frontmatter (e.g., `spec: SPEC-024`). This +- Include the issue ID in council frontmatter (e.g., `issue: LOAF-42`). This is already the common pattern. -- If the spec's `linear_parent` has been populated by breakdown, also - include `linear_parent: ENG-198` in council frontmatter so a reader on - Linear can trace back to the deliberation. +- If the issue is tracked in Linear (tracker authority), also include the + tracker key (e.g., `linear_parent: ENG-198`) in council frontmatter so a + reader on Linear can trace back to the deliberation. - Do not post council content to the Linear parent issue. A brief one-line reference ("Resolved via council 2026-04-21 — see .agents/councils/…") in a sub-issue comment is sufficient if the council drove a specific task diff --git a/content/skills/documentation-standards/SKILL.md b/content/skills/documentation-standards/SKILL.md index a85820f73..7eefbaa6f 100644 --- a/content/skills/documentation-standards/SKILL.md +++ b/content/skills/documentation-standards/SKILL.md @@ -48,7 +48,7 @@ Standards for ADRs, API docs, changelogs, and diagrams. - Internal spec/task IDs - Verbatim commit or PR-title dumps - **Good examples:** - - "Add `loaf release --post-merge` guardrails for tagged GitHub releases" + - "Add `loaf release suggest` and `loaf release cut` for retroactive releases" - "Fix journal context routing when hook payloads are empty" - "Document worktree-aware `.agents/` storage for linked checkouts" - **Version protection:** diff --git a/content/skills/explore/SKILL.md b/content/skills/explore/SKILL.md index c3fc99329..80e8aaf64 100644 --- a/content/skills/explore/SKILL.md +++ b/content/skills/explore/SKILL.md @@ -1,10 +1,18 @@ --- name: explore description: >- - Conducts divergent inquiry as a durable Exploration with portable checkpoints, conversation provenance, and Intent capture that survive compaction and harness changes. - Agent technique — not a user entry point: route "explore this" and similar user asks to pitch; use this technique from inside pitch or other agent work when the direction is genuinely undecided, or when resuming a named Exploration. - Produces Exploration records, portable checkpoints, and tracked or deferred Intents; Exploration machinery and the four-field checkpoint contract stay intact. - Not for evidence gathering on a known question (use research), continuing implementation (use implement), processing the intake queue (use triage), shaping a bounded Change (use shape), problem discovery (use pitch), or quick capture (use idea). + Conducts divergent inquiry as a durable Exploration with portable + checkpoints, conversation provenance, and backlog-issue dispositions that + survive compaction and harness changes. Agent technique — not a user entry + point: route "explore this" and similar user asks to pitch; use this + technique from inside pitch or other agent work when the direction is + genuinely undecided, or when resuming a named Exploration. Produces + Exploration records, portable checkpoints, and backlog issues for + crystallized directions; Exploration machinery and the four-field checkpoint + contract stay intact. Not for evidence gathering on a known question (use + research), continuing implementation (use implement), processing the intake + queue (use triage), shaping a bounded issue (use shape), problem discovery + (use pitch), or quick capture (use idea). --- # Explore @@ -22,6 +30,7 @@ Divergent inquiry with durable continuity. An Exploration is a relational identi - Process - Checkpoint Discipline - Resumption +- Parking a direction - Techniques - Related Skills @@ -31,37 +40,39 @@ Divergent inquiry with durable continuity. An Exploration is a relational identi - You choose what an Exploration means and when to checkpoint; the CLI validates and performs the operation you request. Never expect the CLI to classify or decide for you. - Checkpoint before the context window gets hostile: every checkpoint must carry all four portable fields — purpose, conclusions, unresolved, next action — each self-sufficient without this conversation. - A conversation handle or log path is provenance, never context. Presence of handles does not make an Exploration resumable; only a portable checkpoint does. -- Capture crystallized directions as Intent (`loaf intent create`), deferred bodies with `--disposition deferred`; never leave a substantial direction only in prose. -- Never create Git artifacts, branches, worktrees, or Changes from Explore; when a direction is ready for problem discovery hand it to pitch, and when it is ready for bounded delivery hand it to shape. +- Capture crystallized directions as backlog issues (`loaf issue new "<title>" --status backlog`); park remaining unsharp questions on that issue with `--fog`. Never leave a substantial direction only in prose. +- Never create Git artifacts, branches, or worktrees from Explore; when a direction is ready for problem discovery hand it to pitch, and when it is ready for bounded delivery hand it to shape (issue preparation). - Never store transcripts, prompts, or tool output in checkpoints or items; curate semantic context instead. -- Not a user slash front door — human "explore this" / "where do I start" intent routes to pitch. +- Not a user slash front door — human "explore this" / "where do I start" routes to pitch. ## Verification - The Exploration exists with `portable_context_present: true` after the first checkpoint (`loaf exploration list`). - `loaf exploration context <ref> --json` returns the four-field core whole, and a fresh reader could identify the next action from it alone. -- Crystallized directions exist as Intents with derived dispositions (`loaf intent list`). +- Crystallized directions exist as backlog issues (`loaf issue list --status backlog`); issue aliases named in the checkpoint match those rows. - Conversation provenance, when recorded, carries harness and locality facts without any transcript content. ## Quick Reference | Operation | Command | |-----------|---------| -| Start an inquiry | `loaf exploration create --title <title> [--from <intent-or-source>]...` | +| Start an inquiry | `loaf exploration create --title <title> [--from <source>]...` | | Checkpoint | `loaf exploration checkpoint <ref> --purpose <p> --conclusions <c> --unresolved <u> --next <n> [--item candidate:<text>]... [--operation-id <key>]` | | Resume elsewhere | `loaf exploration context <ref> --json` | -| Track a direction | `loaf intent create --title <t> --body <b> --from <source>...` | -| Defer a direction | `loaf intent create --title <t> --body <b> --disposition deferred --why <w> --boundary <bd> --trigger <tr> --operation-id <key> [--from <source>]` | +| File a direction | `loaf issue new "<title>" --status backlog [--parent <ref>] [--kind delivery\|decision] [--fog <text>] [--body <text>]` | +| Optional bucket | `loaf issue bucket <ref> now\|next\|later\|none` | | Record provenance | `loaf conversation create --title <label>` then `loaf conversation handle add <id> --harness <h> --handle <opaque-id> [--locality <scope>] [--log-ref <path>]` | | Associate conversation | `loaf exploration conversation add <exploration> <conversation-id>` | +`--from` on create accepts journal entries, handoffs, reports, and findings. It does not accept issue, spark, or idea refs — name those in the checkpoint and in the issue body instead. Buckets are labels only and are never read as a constraint. `fog` is writeable only at create. + ## Process 1. **Orient.** If the input names an existing Exploration, run `loaf exploration context <ref>` and continue from its recommended next action. Otherwise check `loaf exploration list` before creating a duplicate inquiry. -2. **Create or resume.** New inquiries get `loaf exploration create` with `--from` links to the Intents, journal entries, reports, or findings that motivated them. +2. **Create or resume.** New inquiries get `loaf exploration create` with `--from` links to the journal entries, reports, findings, or handoffs that motivated them. 3. **Diverge.** Expand the option space before judging it. Use the brainstorm stance (below), research, scouting, prototypes, or spikes as the question demands. -4. **Capture as you go.** Incidental thoughts become sparks; explicit propositions become ideas; deliberately tracked directions become Intents with their sources linked. -5. **Checkpoint.** At every meaningful plateau — and always before ending a session — append a checkpoint with the four portable fields and optional `candidate:`/`evidence:` items. +4. **Capture as you go.** Incidental thoughts become sparks (`loaf spark capture --scope <scope> --text <text>`); explicit propositions become ideas (`loaf idea capture --title "..."`); directions worth keeping become backlog issues. Resolve the capture against the issue so the direction appears once: `loaf spark resolve <ref> --by <issue-ref>` or `loaf idea resolve <ref> --by <issue-ref>`. +5. **Checkpoint.** At every meaningful plateau — and always before ending a session — append a checkpoint with the four portable fields and optional `candidate:`/`evidence:` items. Name any filed issue aliases in conclusions or next. 6. **Record provenance when useful.** Machine-local conversation handles and log locators help forensic navigation later; add them explicitly, and never infer identity from the current session. ## Checkpoint Discipline @@ -73,17 +84,17 @@ The four fields are the portable contract; each is capped at 4096 UTF-8 bytes an - **unresolved** — the open question or decision the inquiry currently turns on. - **next** — the recommended next action, concrete enough for a fresh agent to execute without this conversation. -Larger detail belongs in ordered `--item candidate:` and `--item evidence:` entries or in related reports, not crammed into the core fields. +Larger detail belongs in ordered `--item candidate:` and `--item evidence:` entries or in related reports, not crammed into the core fields. When filing an issue, copy still-unsharp questions into `--fog`; they will not be editable on the issue after create. ## Resumption -A new conversation, harness, or machine resumes with `loaf exploration context <ref> --json`: the portable core returns whole, and each optional layer (items, intents, evidence, conversations) reports counts, truncation, and its exact expansion command. Source handles appear with their last observed availability; treat unavailable ones as lost without ceremony — the checkpoint is the context. If `portable_context_present` is false, the Exploration was never checkpointed: rebuild understanding from linked sources, then write the missing checkpoint first. +A new conversation, harness, or machine resumes with `loaf exploration context <ref> --json`: the portable core returns whole, and each optional layer reports counts, truncation, and its exact expansion command. Source handles appear with their last observed availability; treat unavailable ones as lost without ceremony — the checkpoint is the context. If `portable_context_present` is false, the Exploration was never checkpointed: rebuild understanding from linked sources, then write the missing checkpoint first. -Before continuing, inspect the linked Intents in the context. If an Intent this inquiry was developing has since been resolved, do not silently reopen it: acknowledge the resolution, and if the checkpoint's next action still matters, create a successor Intent, record why in its body, and relate the lineage with `loaf link create --from <new-intent-ref> --to <resolved-intent-ref> --type derived-from`. Continued evidence gathering that serves no unresolved Intent should say so in its next checkpoint. +Before continuing, inspect issue aliases named in the checkpoint. If an issue this inquiry was developing is now done, cancelled, or duplicate, do not silently reopen it: acknowledge the resolution, and if the checkpoint's next action still matters, file a successor backlog issue and record why in its body. Continued evidence gathering that serves no open issue should say so in its next checkpoint. -## Deferring +## Parking a direction -An Exploration is never deferred, paused, or closed — it has no lifecycle to transition. "Defer this exploration" means two concrete acts: checkpoint the current state honestly, then defer the direction it was developing as an Intent — `loaf intent defer` on the linked Intent, or `loaf intent create --disposition deferred` for a new one followed by `loaf link create --from <exploration-ref> --to <intent-ref> --type explores`. The deferred Intent carries the revisit trigger; the Exploration simply waits, resumable from its checkpoint whenever the Intent is resumed. +An Exploration is never paused or closed — it has no lifecycle to transition. When the user wants to park or set aside the inquiry, do two concrete acts: checkpoint the current state honestly, then file the direction it was developing as a backlog issue (`loaf issue new "<title>" --status backlog`, optional `--parent`, `--fog` for remaining unsharp questions, optional `loaf issue bucket <ref> later`). The issue is the revisit surface; the Exploration simply waits, resumable from its checkpoint. ## Techniques @@ -91,8 +102,8 @@ Brainstorm's full divergent stance lives inside Explore: generate options before ## Related Skills -- **pitch** — human problem-discovery front door; route user entry intent here; reach for explore from inside pitch when still undecided -- **triage** — processes the intake queue and may disposition items toward pitch, shape, or agent-side explore -- **shape** — narrows one well-understood direction into a bounded Change +- **pitch** — human problem-discovery front door; route user entry here; reach for explore from inside pitch when still undecided +- **triage** — processes the intake queue and may disposition items toward a backlog issue, pitch, shape, or agent-side explore +- **shape** — prepares a well-understood direction as a bounded issue - **research** — evidence gathering for a known question, usable inside an Exploration - **idea** — quick capture without inquiry diff --git a/content/skills/foundations/references/code-review.md b/content/skills/foundations/references/code-review.md index 2c37d2f0b..37c8a2e10 100644 --- a/content/skills/foundations/references/code-review.md +++ b/content/skills/foundations/references/code-review.md @@ -86,5 +86,5 @@ Project code review conventions and workflow. | Command | Code Review Role | |---------|-----------------| | implement | Self-review before marking complete | -| breakdown | Review task scope and approach | +| shape | Review issue scope and DoD before implementation | | reflect | Note review feedback patterns | diff --git a/content/skills/foundations/references/tdd.md b/content/skills/foundations/references/tdd.md index 28c883709..1ea6fe7c9 100644 --- a/content/skills/foundations/references/tdd.md +++ b/content/skills/foundations/references/tdd.md @@ -54,6 +54,6 @@ If the failing test points to a non-obvious root cause, or if your first fix att | Phase | TDD Role | |-------|----------| | shape | Test conditions become TDD test cases | -| breakdown | Each task should have clear test targets | +| shape | Each promoted issue should have clear test targets in its DoD | | implement | Follow TDD cycle for each task | | reflect | Note TDD friction points for improvement | diff --git a/content/skills/foundations/references/verification.md b/content/skills/foundations/references/verification.md index d3cf84e1f..142aa7dfc 100644 --- a/content/skills/foundations/references/verification.md +++ b/content/skills/foundations/references/verification.md @@ -123,7 +123,7 @@ npm run lint # Check: No errors or warnings | Command | Verification Point | |---------|-------------------| | implement | Before marking session complete | -| breakdown | Each task has verification criteria | +| shape | Each issue has verification criteria (V-tier DoD) | | shape | Test conditions define verification | | reflect | Note verification gaps discovered | diff --git a/content/skills/git-workflow/SKILL.md b/content/skills/git-workflow/SKILL.md index 659cf9e62..e85ad17f9 100644 --- a/content/skills/git-workflow/SKILL.md +++ b/content/skills/git-workflow/SKILL.md @@ -22,7 +22,7 @@ Git conventions for branching, commits, PRs, and merge workflow. - Use Conventional Commits format for all commit messages - Commit complete units of work -- don't commit partial or in-progress changes - Squash merge feature branches -- never merge commits directly -- One branch per spec/feature; branch name format: `feat/{slug}` +- One branch per issue; `loaf issue start` creates `issue/<alias-or-id>` (or use `feat/{slug}` / `fix/{slug}` when not starting from an issue) - Never force-push to `main` or shared branches - Never push without explicit user confirmation @@ -36,7 +36,7 @@ Git conventions for branching, commits, PRs, and merge workflow. | Action | Command/Pattern | |--------|----------------| -| Branch naming | `feat/{slug}`, `fix/{slug}`, `chore/{slug}` | +| Branch naming | `issue/<alias-or-id>` from `loaf issue start`; else `feat/{slug}`, `fix/{slug}`, `chore/{slug}` | | Commit format | `type: description` | | Squash merge | `gh pr merge --squash` | | PR creation | `gh pr create --title "..." --body "..."` | diff --git a/content/skills/git-workflow/references/commits.md b/content/skills/git-workflow/references/commits.md index a879b8709..3e9e3c00d 100644 --- a/content/skills/git-workflow/references/commits.md +++ b/content/skills/git-workflow/references/commits.md @@ -110,13 +110,14 @@ Closes BACK-123 ## Branch Naming ``` +issue/<alias-or-id> <type>/<description> -<type>/TASK-123-description ``` ### Types -- `feat/` - New features (e.g., `feat/spec-010-task-management-cli`) +- `issue/` - Started from `loaf issue start` (`issue/loaf-42`) +- `feat/` - New features (e.g., `feat/thermal-rating-cli`) - `fix/` - Bug fixes - `hotfix/` - Critical production fixes - `release/` - Release preparation @@ -126,7 +127,7 @@ Closes BACK-123 - Lowercase with hyphens (kebab-case) - Short but descriptive (max 50 chars) -- Include spec or task slug when applicable (e.g., `feat/spec-010-task-management-cli`) +- Prefer the started worktree branch from `loaf issue start` when implementing an issue ## Pull Request Format @@ -140,26 +141,10 @@ feat: add thermal rating calculation ### Description -Focus on **review context** — what changed, why, and how to test. Do not include squash merge commit text in the PR body. +The PR body is `loaf issue render <ref>` output — paste-ready, no manual editing. Definition-of-done criteria in the render are the review checklist. Do not include squash merge commit text in the PR body. -```markdown -## Summary - -Brief description of what this PR adds/changes and why. - -- Bullet points covering key changes -- Focus on what a reviewer needs to know - -## Test plan - -- [ ] Unit tests added/updated -- [ ] Integration tests pass -- [ ] Manual testing performed - -## Related Issues - -Closes BACK-123 -Refs BACK-124 +``` +gh pr create --title "type: summary" --body "$(loaf issue render <ref>)" ``` ### Merge Strategy @@ -182,10 +167,10 @@ published release notes read as user-facing prose, not an internal worklog. Internal terms that have no meaning outside the team's working context: -- Spec IDs and task IDs (`SPEC-024`, `TASK-042`) +- Internal work-unit numbering that is not the issue ID (issue IDs like `LOAF-42` belong in commits — release attribution reads them) - Session, sprint, or branch references - Internal terminology from skills/docs that isn't part of the user's mental model — e.g. `Q1`/`Q2`/`Q3` question numbers from a Triage Gate, internal gate-logic notation like `(Q1 OR Q2) AND Q3`, hook IDs that aren't user-facing -- "How the work got done" framing — interview steps, breakdown steps, review gates +- "How the work got done" framing — interview steps, decomposition steps, review gates ### Keep @@ -204,7 +189,7 @@ Internal terms that have no meaning outside the team's working context: ### Auto-generated Entries -When `loaf release` auto-generates the `[Unreleased]` section from commit history, those entries inherit any internal terms present in the commit messages. Treat the generated output as a draft: rewrite it under the curated path before bumping. The release skill preserves curated content when it's already in `[Unreleased]` — curate first, bump second. +`loaf release suggest` drafts notes from landed issues; `loaf release cut` prepends them into `CHANGELOG.md`. Treat drafted notes as a draft: rewrite internal terms before cutting. Curate `[Unreleased]` as PRs land so the later cut reads as user-facing prose. Before approving a release bump, compare `[Unreleased]` against the actual release range and remove scaffolding language introduced by specs, reviews, tasks, or session triage. If an entry only explains why the work was discovered or how the work was organized, it does not belong in the changelog. @@ -228,14 +213,15 @@ Before approving a release bump, compare `[Unreleased]` against the actual relea - Add agent attribution - Mix unrelated changes - Commit secrets or sensitive data -- Put SPEC or TASK IDs in commit subject (use human-readable names) +- Put work-unit IDs in the commit subject (use human-readable names). Issue aliases belong in the body so `loaf release suggest` can attribute the commit. ### ID References - **IDs belong in footer, not subject line** - - Bad: `feat: implement SPEC-002 invisible sessions` - - Good: `feat: implement invisible sessions and task board` + - Bad: `feat: implement LOAF-42 invisible sessions` + - Good: `feat: implement invisible sessions` - Use descriptive names that are understandable without looking up IDs +- Issue aliases (`LOAF-42`) go in the body so release attribution can find them - Linear issue IDs go in footer only (e.g., `Closes BACK-123`) ## Semantic Versioning @@ -277,6 +263,6 @@ BREAKING CHANGE: Description of breaking change. **Convention:** - Use standard SemVer pre-release identifiers (`alpha`, `beta`, or `rc`) when publishing pre-release versions. -- `loaf release` handles all bump types: `prerelease`, `release`, `major`, `minor`, `patch` +- `loaf release cut --bump` handles all bump types: `prerelease`, `release`, `major`, `minor`, `patch` **Not required** — projects using simple `MAJOR.MINOR.PATCH` versioning can ignore pre-release suffixes entirely. This convention is for projects publishing staged pre-releases before stable releases. diff --git a/content/skills/housekeeping/SKILL.md b/content/skills/housekeeping/SKILL.md index e7e0216c4..d505939a5 100644 --- a/content/skills/housekeeping/SKILL.md +++ b/content/skills/housekeeping/SKILL.md @@ -1,11 +1,12 @@ --- name: housekeeping description: >- - Reviews and maintains agent artifacts in .agents/ — specs, plans, drafts, - handoffs, councils, and reports. Use when the user asks "housekeeping," "clean up," - or "tidy up .agents/." Provides hygiene recommendations, - archives completed work, and ensures extracted knowledge is preserved. - Not for strategic reflection (use reflect) or knowledge management (use knowledge-base). + Reviews and maintains agent artifacts in .agents/ plus issue hygiene — + reports, handoffs, councils, archived issues, and stale started worktrees. + Use when the user asks "housekeeping," "clean up," or "tidy up .agents/." + Provides hygiene recommendations, archives completed work, and ensures + extracted knowledge is preserved. Not for strategic reflection (use reflect) + or knowledge management (use knowledge-base). --- # Housekeeping @@ -15,40 +16,43 @@ description: >- - Verification - Quick Reference - Mode-Aware Checks -- Process -- Guardrails -- Related Skills +- Suggests Next +- Topics +- Artifact Naming -Systematic review and archival of all `.agents/` artifacts with Linear-aware checks. +Systematic review of `.agents/` artifacts and issue workspaces. ## Critical Rules **Always** - Log invocation as the first action: `loaf journal log "skill(housekeeping): <scope or trigger>"` - Review EVERY file individually — never sample or average -- Check Linear issue status before archiving linked specs +- Check Loaf issue status (and Linear overlay, if enabled) before archiving linked artifacts - Extract lessons learned and decisions before archiving -- Use CLI (`loaf housekeeping`, `loaf task archive`, `loaf spec archive`) — never raw `mv` +- Use CLI (`loaf housekeeping`, `loaf report archive`, `loaf issue status` / `loaf issue stop`) — never raw `mv` - Treat `.agents/handoffs/` as first-class but disposable: keep active/final handoffs, delete only after confirmed deprecated status -- Check report `status` is `processed` before archiving reports (see [templates/report.md](templates/report.md)) -- In SQLite-backed projects, verify lifecycle changes through `loaf task list --json`, `loaf spec list --json`, and `loaf report list --json`; use `loaf task sync` only for Markdown compatibility repair +- Check report `status` is `done` (or `final`) before archiving reports (see [templates/report.md](templates/report.md)) +- In SQLite-backed projects, verify lifecycle through `loaf issue list --json`, `loaf issue list --started`, `loaf issue list --archived`, and `loaf report list --json` - When delegated subagents are available, use the `librarian` profile for - `.agents/`-scoped durable artifact tending: report/spec/handoff hygiene, + `.agents/`-scoped durable artifact tending: report/handoff hygiene, staleness notes, and lifecycle-safe cleanup recommendations. Housekeeping still owns user confirmation and final archive decisions. -- Log outcome to the project journal: `loaf journal log "decision(housekeeping): archived N specs, M reports"` +- Log outcome to the project journal: `loaf journal log "decision(housekeeping): archived N reports; stopped M stale worktrees"` **Never** - Auto-archive without user confirmation for each artifact - Skip spark extraction before deleting brainstorm drafts - Leave `archived_at` or `archived_by` fields empty in archived files +- Run `loaf issue stop` from inside the started worktree +- Dispatch cleanup agents into a live started worktree another agent occupies ## Verification After work completes, verify: -- Tasks archived via `loaf task archive` -- Specs archived via `loaf spec archive` -- SQLite-backed task/spec/report state reflects lifecycle changes when initialized +- Reports archived via `loaf report archive` after processing +- Archived issues reviewed via `loaf issue list --archived` (`cancelled` / `duplicate` archive through `loaf issue status`) +- Stale started worktrees reviewed via `loaf issue list --started` (a `(missing)` marker means the recorded path is gone) +- SQLite-backed report/issue state reflects lifecycle changes when initialized - Drafts checked for unprocessed sparks before deletion - Handoffs deleted only after explicit deprecation is confirmed - Summary table presented showing all actions taken @@ -60,11 +64,18 @@ After work completes, verify: ```bash loaf housekeeping --dry-run # Preview recommendations loaf housekeeping # Run artifact scanner -loaf task archive TASK-XXX # Archive single task -loaf spec archive SPEC-XXX # Archive single spec -loaf task sync # Compatibility diagnostic in SQLite-backed projects +loaf issue list --started # Started worktrees (alias, title, branch, path) +loaf issue list --archived # cancelled / duplicate rows +loaf issue stop <ref> # Remove worktree; keeps branch; does not change status +loaf issue status <ref> cancelled # Archive an abandoned issue +loaf issue status <ref> duplicate --duplicate-of <surviving> +loaf report archive <report> # Archive a processed report ``` +`loaf housekeeping` still prints leftover `specs` / `tasks` sections when those +SQLite tables have rows — compatibility scan only. Do not create new records +there. The `loaf task` / `loaf spec` CLI is legacy. + The project journal is append-only and never archived — it is not a housekeeping target. It is the canonical record housekeeping reads when extracting decisions before archiving other artifacts. @@ -73,19 +84,12 @@ before archiving other artifacts. | Artifact | Active Location | Archive | Action | |----------|-----------------|---------|--------| -| Tasks (local mode only) | SQLite state | SQLite archived status | `loaf task archive` | -| Specs | SQLite state + `.agents/specs/` authored prose | `archive/` | `loaf spec archive` | +| Issues | SQLite (`loaf issue list`) | `cancelled` / `duplicate` via `loaf issue status` | Confirm, then status; `done` is ship, not housekeeping | +| Started worktrees | `loaf issue list --started` | `loaf issue stop <ref>` | Stop stale or `(missing)` trees after confirmation | | Drafts / brainstorms | SQLite state | SQLite resolved/archived status | User decision (spark extraction first) | | Handoffs | `.agents/handoffs/` | delete | Delete after status is confirmed `deprecated` | | Reports | SQLite state + generated/authored report Markdown | `archive/` | `loaf report archive` after processing | -**Linear-native mode** (when `integrations.linear.enabled` is `true` in -`.agents/loaf.json`): local `TASK-NNN.md` files do not exist for new specs — -Linear issues are the task record. The "Tasks" row above is inert unless the -project has pre-Linear local tasks lingering (see [Mode-Aware Checks](#mode-aware-checks)). -Specs still archive locally — they are the canonical deliberation artifact in -every mode. - ## Cross-Branch Reconciliation If a stale branch reintroduces `.agents/{tasks,ideas,sparks,sessions,brainstorms,drafts}/` @@ -95,35 +99,30 @@ or `.agents/TASKS.json`, keep the deletion from the cutover branch and rerun ## Mode-Aware Checks -When `integrations.linear.enabled` is `true` in `.agents/loaf.json`, apply -these additional checks: +### Started worktrees -### Spec / Linear parent reconciliation +For each row from `loaf issue list --started`: -For each spec file (active and archive) with a `linear_parent:` frontmatter key: +1. If `(missing)`, flag as **stale started workspace** — the row still records a path that is gone. Offer `loaf issue stop <ref>` after confirmation. Stop does not mark the issue `done`. +2. If the path exists but the issue is `done` / `cancelled` / `duplicate`, flag as **worktree outlived the issue** — same offer. +3. If the path exists and status is `active`, leave it unless the user asks to stop. -1. Call `get_issue` with the issue identifier. If it 404s or returns - archived/deleted, flag as **orphaned linear_parent** — the local spec - references a Linear issue that no longer exists. -2. If the spec's local status is `done` (or legacy `complete`) or `archived`, - verify the Linear parent issue is in a `completed`-type state. If not - (e.g., still "In Progress"), flag as **status mismatch** — "Spec marked - complete locally but Linear parent ENG-198 is still 'In Progress'." -3. If the spec's local status is `in_progress` and the Linear parent is - already `completed`, flag the inverse — spec likely needs to be moved to - `done` and archived. +Treat these as **warnings**, not auto-fixes. -Treat all three as **warnings**, not auto-fixes. The user decides resolution. +### Linear overlay -### Pre-Linear local task detection +When `integrations.linear.enabled` is `true` in `.agents/loaf.json`, the tracker +adapter is not shipped. If a report or journal entry names a Linear id next to +a Loaf alias, you may `get_issue` and flag an obvious mismatch (Linear Done vs +Loaf still `active`, or the reverse). Warnings only. Do not drive Loaf status +from Linear. -If Linear is enabled but local task records exist in SQLite, -surface them with context: "Pre-Linear local tasks detected. These aren't -auto-migrated. Either continue using them, run a manual migration, or -archive if superseded by Linear issues." +### Leftover board rows -Do NOT auto-migrate. Migration is user-initiated and out of scope for -housekeeping. +If `loaf housekeeping --dry-run` still reports `tasks` or `specs` cleanup +candidates, surface them: "Legacy board rows are still in SQLite. They are not +the work unit. Archive only if the user confirms they are superseded by Loaf +issues." Do NOT auto-migrate. ## Suggests Next @@ -134,9 +133,9 @@ After housekeeping, suggest reflect if the session produced key decisions or lea | Topic | Reference | Use When | |-------|-----------|----------| | Report Template | [templates/report.md](templates/report.md) | Creating cleanup reports | -| Linear Integration | `orchestration/references/linear.md` | Checking external issue status | +| Linear Integration | `orchestration/references/linear.md` | Checking external tracker overlay | | Journal Continuity | `orchestration/references/journal.md` | Understanding the project journal model | ## Artifact Naming -Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or Change already records that provenance. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. +Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or the issue already records that provenance. Put the source in a front-matter field (`source: LOAF-42`), not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. diff --git a/content/skills/housekeeping/templates/report.md b/content/skills/housekeeping/templates/report.md index d0894e593..ca201e869 100644 --- a/content/skills/housekeeping/templates/report.md +++ b/content/skills/housekeeping/templates/report.md @@ -8,7 +8,7 @@ title: "Report: [Topic]" type: research | audit | analysis | council created: YYYY-MM-DDTHH:MM:SSZ status: draft | done | archived -source: SPEC-XXX | TASK-XXX | ad-hoc +source: LOAF-42 | ad-hoc finalized_at: YYYY-MM-DDTHH:MM:SSZ # Set when status → done archived_at: YYYY-MM-DDTHH:MM:SSZ # Set when status → archived archived_by: orchestrator diff --git a/content/skills/idea/SKILL.md b/content/skills/idea/SKILL.md index 63d41d1cb..da990ef65 100644 --- a/content/skills/idea/SKILL.md +++ b/content/skills/idea/SKILL.md @@ -1,13 +1,14 @@ --- name: idea description: >- - Captures ideas into structured nuggets for later evaluation. Use when the user - says "I have an idea" or "note this down." Also activate when a specific - actionable concept crystallizes during conversation. Ideas and sparks are - capture primitives routed through triage, which chooses dispositions such as - tracking an Intent or handing to pitch. Not for problem discovery (use pitch), - processing the intake queue (use triage), shaping (use shape), or agent-side - divergent inquiry when direction is undecided (use explore as a technique). + Captures ideas into structured nuggets for later evaluation. Use when the + user says "I have an idea" or "note this down." Also activate when a + specific actionable concept crystallizes during conversation. Ideas and + sparks stay capture primitives routed through triage, which files + worth-keeping items as backlog issues or hands them to pitch or shape. Not + for problem discovery (use pitch), processing the intake queue (use triage), + shaping a bounded issue (use shape), or agent-side divergent inquiry when + direction is undecided (use explore as a technique). --- # Idea @@ -24,7 +25,6 @@ Capture ideas quickly with minimal friction. - Quick Reference - Purpose - Process -- Idea Lifecycle - Guardrails - Related Skills @@ -34,7 +34,7 @@ Capture ideas quickly with minimal friction. - 2-3 questions maximum -- don't turn capture into an interview - Infer metadata automatically -- don't ask for tags, title, or links - One idea per captured row/artifact -- keep them atomic -- No shaping or pitching here -- problem discovery is pitch; bounding is shape +- No shaping, pitching, or filing issues here -- problem discovery is pitch; bounding is shape; dispositions are triage - Capture through `loaf idea capture --title ...` when SQLite state is initialized; log notable context with `loaf journal log` @@ -56,7 +56,7 @@ Capture ideas quickly with minimal friction. ## Purpose -Ideas are raw nuggets — unprocessed, unshaped, but worth remembering. The goal is **speed of capture**, not thoroughness. An idea is retained material, nothing more: tracking it as an Intent, handing it to pitch for problem discovery, handing it to shape when already well-understood, or archiving it are triage dispositions chosen later by the user. +Ideas are raw nuggets — unprocessed, unshaped, but worth remembering. The goal is **speed of capture**, not thoroughness. An idea is retained material, nothing more: filing it as a backlog issue, handing it to pitch for problem discovery, handing it to shape when already well-understood, or archiving it are triage dispositions chosen later by the user. --- @@ -80,7 +80,7 @@ Ideas are raw nuggets — unprocessed, unshaped, but worth remembering. The goal ## Related Skills -- **triage** — process the intake queue and choose dispositions (including hand to pitch or shape) +- **triage** — process the intake queue and choose dispositions (file as backlog issue, hand to pitch, or hand to shape) - **pitch** — problem-discovery ceremony when a captured idea needs a brief before shaping -- **shape** — develop a well-understood direction into a bounded Change +- **shape** — develop a well-understood direction into a bounded issue - **explore** — agent technique when direction is genuinely undecided (not a user front door) diff --git a/content/skills/idea/templates/idea.md b/content/skills/idea/templates/idea.md index b070494d3..b631ca832 100644 --- a/content/skills/idea/templates/idea.md +++ b/content/skills/idea/templates/idea.md @@ -11,7 +11,7 @@ title: "[Idea Title]" captured: YYYY-MM-DDTHH:MM:SSZ status: raw tags: [] -related: [] # Optional: spec IDs, idea filenames, or other references +related: [] # Optional: issue aliases, idea filenames, or other references origin: # Optional: draft filename this spark came from (e.g. drafts/YYYYMMDD-brainstorm-slug.md) --- @@ -31,5 +31,5 @@ origin: # Optional: draft filename this spark came from (e.g. dra --- -*Captured via idea -- shape with shape when ready* +*Captured via idea — triage later (backlog issue, pitch, or shape)* ``` diff --git a/content/skills/implement/SKILL.claude-code.yaml b/content/skills/implement/SKILL.claude-code.yaml index 6b07bcc07..d202bdeac 100644 --- a/content/skills/implement/SKILL.claude-code.yaml +++ b/content/skills/implement/SKILL.claude-code.yaml @@ -1,3 +1,3 @@ # Claude Code skill configuration user-invocable: true -argument-hint: "[TASK-XXX | SPEC-XXX | TASK-XXX..YYY | TASK-XXX,YYY | description]" +argument-hint: "[LOAF-42 | next | description]" diff --git a/content/skills/implement/SKILL.md b/content/skills/implement/SKILL.md index 005e0a757..288e3f231 100644 --- a/content/skills/implement/SKILL.md +++ b/content/skills/implement/SKILL.md @@ -1,16 +1,18 @@ --- name: implement description: >- - Orchestrates implementation work through agent delegation and batch execution. - Use for all implementation work — features, bug fixes, refactors, and code changes. - Picks Change task files when present and flips checkboxes in delivering commits. - Logs to the project journal and produces agent spawn plans and progress tracking. - Not for shaping (use shape), breakdown (use breakdown), research, or review. + Orchestrates implementation work through agent delegation and batch execution + against Loaf issues. Use for all implementation work — features, bug fixes, + refactors, and code changes. Picks the next issue from loaf issue frontier, + delegates one agent per started worktree, and treats definition-of-done + criteria as the completion contract. Logs to the project journal and produces + agent spawn plans and progress tracking. Not for shaping or decomposition + (use shape), research, or review. --- # Implement -You are the coordinator. Start by understanding the task: +You are the coordinator. Work units are issues. ## Contents - Critical Rules @@ -18,7 +20,7 @@ You are the coordinator. Start by understanding the task: - Quick Reference - Step 0: Context Check - Input Detection -- Linear-Native Routing +- Pick-up and Dispatch - Agent Spawning - Journal First - Guardrails @@ -36,27 +38,32 @@ You are the coordinator. Start by understanding the task: **You are the ORCHESTRATOR, not the implementer.** -- Log `loaf journal log "skill(implement): <task/spec/context>"` as the first action. -- **Change-first task packets:** prefer `docs/changes/<folder>/tasks/TASK-NNN-*.md` as the delegation brief. Flip checkboxes `- [ ]`→`- [x]` in the same commit that delivers the work (outside `docs/changes/` paths must land with the flip for provenance). Use `loaf change tasks --json` for the index. -- Commit task packets unchecked before executing them — a packet that first lands already-checked induces no flip transition, and the evidence trail never exists. -- Compatibility: existing `TASK-XXX` / `SPEC-XXX` SQLite records remain supported until converted; they are not the default for new work. +- Log `loaf journal log "skill(implement): LOAF-42 — <what>"` as the first action. Substitute the real alias (or opaque id) and a short intent. +- **Pick-up-next is `loaf issue frontier`.** That view is open (`triage` / `backlog` / `todo`), unblocked, and unclaimed (not `active`, no started worktree). Derived at read time. +- **The delegation brief is the issue row** — `loaf issue show <ref>` / `loaf issue render <ref>`: body, definition-of-done criteria, children. There is no other packet. +- **One agent, one worktree.** `loaf issue start <ref>` creates the branch and worktree and moves status to `active`. Before dispatch, run `loaf issue list --started`. Never send two agents into the same worktree. +- **Definition of done is the completion contract.** `loaf issue verify <ref>` runs V-tier criteria from the repository root and writes nothing. H-tier is reviewed by a human or this orchestrator. Completion is the work landing plus `loaf issue status <ref> done`. Do not flip checkboxes. Provenance is the delivering commits and the PR whose body is `loaf issue render <ref>`. +- Shape prepares issues. If `loaf issue check <ref>` does not report the delivery issue shaped (or the decision issue ready), stop and send the work to shape. Do not mint a new issue from this skill. ### Orchestrator Can Do Directly - Log journal entries, read journal context, create council files -- Use your harness's task/todo tracking surface; **if `integrations.linear.enabled` is `true` in `.agents/loaf.json`**, use Linear MCP tools when helpful +- Use your harness's todo tracking surface; **if `integrations.linear.enabled` is `true` in `.agents/loaf.json`**, Linear MCP is an overlay only — Loaf issues remain the work unit and Linear never drives Loaf status - Read any file for context - Ask clarifying questions +- Run `loaf issue` read commands, `loaf issue start` / `stop`, `loaf issue status`, and open a PR whose body is `loaf issue render` output ### Orchestrator MUST Delegate (via agent spawn) -**ALL code changes, documentation edits, and implementation work** to specialized agents. **No exceptions**, even for "trivial" 1-line fixes. +**ALL code changes, documentation edits, and implementation work** to specialized agents. **No exceptions**, even for "trivial" 1-line fixes. Spawn each agent into that issue's started worktree. ## Verification - The invocation is logged to the project journal before implementation work begins — no session start step, no "active session" precondition - All code changes delegated via your harness's agent-spawn mechanism -- no direct edits by orchestrator - The journal is continuously updated with spawns, progress, and decisions as work happens -- Spec artifacts closed out on branch before PR creation -- **Linear-native mode:** `blockedBy` of the target sub-issue is fully `completed` before work begins; starting a sub-issue also promotes an unstarted parent rollup to active; parent rollup is auto-closed only when all sub-issues are `completed` +- Each in-flight issue has exactly one started worktree; `loaf issue list --started` was checked before every spawn +- V-tier criteria pass `loaf issue verify <ref>` (writes nothing); H-tier criteria were reviewed by a human or this orchestrator +- The PR body is `loaf issue render <ref>` with no manual editing; checkboxes stay unchecked until status is `done` +- Completion is landing plus `loaf issue status <ref> done` (usually via ship) ## Quick Reference @@ -71,6 +78,15 @@ You are the coordinator. Start by understanding the task: | Code review/audit | reviewer | relevant domain skills | | Research/comparison | researcher | relevant domain skills | +| Moment | Command | +|--------|---------| +| Pick next | `loaf issue frontier` | +| Brief | `loaf issue show <ref>` / `loaf issue render <ref>` | +| Claim workspace | `loaf issue start <ref>` | +| Occupied trees | `loaf issue list --started` | +| V-tier gate | `loaf issue verify <ref>` | +| Landed | `loaf issue status <ref> done` | + --- ## Step 0: Context Check @@ -81,152 +97,51 @@ Before starting, evaluate context suitability. |---------|--------| | New command/skill added this conversation | **Restart required** (skills loaded at start) | | Conversation > 30 exchanges | Suggest restart | -| Just completed a different task/spec | Suggest clear | +| Just completed a different issue | Suggest clear | | About to start multi-file implementation | Check depth | If restart needed: log current state with `loaf journal log`, then ask the user to restart. A supported startup adapter may reconstruct continuity from the journal in the next conversation; when the exact current target mode is candidate or unsupported, explicitly run `loaf journal context` after restarting. ## Input Detection -Parse `$ARGUMENTS` to determine the work type: +Parse `$ARGUMENTS` to determine the work: | Input Pattern | Type | Action | |---------------|------|--------| -| `TASK-XXX` | Local task | Load via `loaf task show`, log the task coupling | -| `SPEC-XXX` | Spec orchestration | If spec frontmatter has `linear_parent`, resolve to that Linear parent and follow Linear-Native Routing. Otherwise resolve local tasks and build dependency-ready rounds | -| `TASK-XXX..YYY` | Task range | Expand range, build dependency-ready rounds | -| `TASK-XXX,YYY,ZZZ` | Task list | Parse list, build dependency-ready rounds | -| `PLT-123`, `ENG-198`, `PROJ-123` | Linear issue | **If `integrations.linear.enabled` is `true`:** fetch via `get_issue`, then branch on parent vs sub-issue — see [Linear-Native Routing](#linear-native-routing). **Otherwise:** treat as label text or create local task | -| Description text | Ad-hoc | Auto-create local task from description, then fall through to task-coupled flow | - -### Task-Coupled Work - -When starting from `TASK-XXX`: - -1. Load task metadata via `loaf task show TASK-XXX --json`; do not recreate `.agents/TASKS.json` after the SQLite cutover -2. Log the task coupling: `loaf journal log "decision(implement): implementing TASK-XXX"` -3. Load parent spec if task has `spec:` field - -### Ad-hoc Task Auto-Creation - -When input is free-text description (not matching any known pattern): - -1. **Parse the description:** - - Single sentence → use entire text as task title - - Multi-sentence → first sentence = title, remainder = acceptance criteria - - Split on `. ` followed by uppercase letter only (conservative — avoids false positives from URLs, abbreviations) -2. **Create the task:** `loaf task create --title "<parsed title>"` -3. **Write criteria** (if multi-sentence): edit the task `.md` file body to add the remaining sentences as acceptance criteria -4. **Fall through** to the task-coupled flow above — the result is a `TASK-XXX` ID that enters the existing planning pipeline unchanged - -**No user interaction required.** The description IS the task; invoking implement already expressed intent. +| `LOAF-42` or opaque id | Single issue | Load via `loaf issue show <ref>`; fall through to Pick-up and Dispatch | +| Parent ref with children | Tree | `loaf issue tree <ref>`; build rounds from children and `blocks` / `blocked_by` edges (see [batch-orchestration.md](references/batch-orchestration.md)) | +| Multiple refs | Batch | Same round construction across the named set | +| Empty / "next" | Frontier | `loaf issue frontier`; if one row, pick it; if several, ask (structured question tool if the harness has one); if none, stop | +| Description text | Ad-hoc | Match frontier by title. Do not mint. If nothing matches, stop and send to shape | +| Decision kind | Question | Not implementation. Surface the question; do not `loaf issue start` unless the user points at a delivery issue that records the decided answer | -### Non-Existent Task ID Error +### Missing ref -If input matches `TASK-XXX` pattern but `loaf task show` cannot resolve it: +If input looks like an issue ref but `loaf issue show` cannot resolve it: -1. Show error: `"TASK-XXX not found in local task state"` -2. Ask the user: `"Did you mean to create a new task? You can re-run with the description as free text."` -3. **Do not silently create** — the user likely has a typo +1. Show error: `"<ref> not found"` +2. Ask whether they meant a different alias, or to shape a new issue +3. **Do not silently create** --- -## Linear-Native Routing - -Applies when `integrations.linear.enabled` is `true` AND `$ARGUMENTS` -resolves to a Linear issue (direct Linear ID, or a `SPEC-XXX` whose -frontmatter has `linear_parent`). - -Fetch the issue once via `get_issue` and branch on its shape: - -### Parent rollup issue (has `spec` label) - -The issue represents a spec. Do **not** implement it directly — spec-level -"work" is always done via sub-issues. - -1. List sub-issues via `list_issues` with `parent: <parent-id>`. -2. Classify each by state: - - `in_progress` — active work - - `unstarted` + no open `blockedBy` — ready to start - - `unstarted` + open `blockedBy` — blocked - - `completed` — done, skip -3. Select the next work item: - - If one or more sub-issues are `in_progress`, pick the **lowest-ID** - in-progress sub-issue. Resume that. - - Else, if one unblocked `unstarted` sub-issue exists, pick it. - - Else, if multiple unblocked `unstarted` sub-issues exist, use - your harness's structured question tool (if it has one) to let the user choose: pick one, or delegate N in - parallel via parallel agents. List each sub-issue's title + ID. - - Else (all remaining sub-issues are blocked), refuse with a summary: - "All remaining sub-issues under <parent-id> are blocked. Blockers: - <list>." -4. Once a sub-issue is selected, recurse into the sub-issue flow below - with that ID. The parent itself is never the implementation target. - -### Sub-issue (has `parentId`, no `spec` label) - -The issue is an actual task. Implement it directly — with a pre-flight gate. - -1. **Pre-flight: verify `blockedBy` is clear.** For each issue in the - sub-issue's `blockedBy` field, call `get_issue` and confirm its state is - `completed`-type. If any blocker is not Done: - - **Refuse to start.** Do not begin work. Do not move the issue. - - Show the blockers: `"Cannot start <sub-issue-id>. Blocked by: <list - with IDs, titles, and current states>."` - - Suggest: `"Complete the blocker(s) first, or ask to override if the - blockedBy link is stale."` -2. If blockers are clear: - - Start the sub-issue as one logical Linear operation. This moves - the sub-issue to the team's `started`/In Progress state and, when the - parent rollup is still `backlog` or `unstarted`, promotes the parent to - the same `started`/In Progress state. - - If the parent is already active, leave it unchanged. If the parent is - `completed`, `canceled`, or archived, refuse to start unless the user - explicitly asks to override the protected parent state. - - If the child update succeeds but parent promotion fails, report a - reconciliation error naming the parent issue before continuing. - - Resolve branch name from the sub-issue's `branchName` field (Linear - auto-generates one) — see - [branch-and-completion.md](references/branch-and-completion.md). - - Log the task coupling, then continue with the standard Startup Checklist. - -### Completion (after implementer + reviewer finish cleanly) - -When the sub-issue's implementation passes review and tests: - -1. Move the sub-issue to the team's `completed`-type state via - `update_issue` (look up via `list_issue_statuses`, filter - `type: "completed"`). -2. Query the parent's sub-issues again: - - If **all** sub-issues are now `completed`-type, move the parent - rollup to `completed` as well. Also mark the local spec as - `complete` (see [Then Execute → AFTER](#then-execute)). - - If **some** remain, list them as "next available" for the user, - applying the same classification as step 2 of the parent flow above. - Offer to continue with the next one in this session, or stop here. -3. **Do not** close the parent while any sub-issue is open — not even if - only `blocked` ones remain. Blocked sub-issues are still in-flight - work from the spec's perspective. - -### Status flow summary - -| Moment | Sub-issue state | Parent state | -|--------|----------------|--------------| -| Implementation starts | `started` / In Progress | promoted to `started` / In Progress if still `backlog` or `unstarted` | -| Implementation + review pass | `completed` | check: close only if all sibs completed | -| Blocker discovered mid-work | `in_progress` + blocker comment | unchanged | - -### What Linear-native routing does NOT do - -- Does not pull down the full spec text. The parent's description already - links to `.agents/specs/SPEC-NNN-*.md`. Read the local file for shape, - rabbit holes, and strategic tensions. -- Does not create or rewrite sub-issues. That's breakdown's job. If - implementation reveals a missing task, surface it to the user; they - decide whether to run breakdown again or add an ad-hoc sub-issue. -- Does not sync in-progress state bidirectionally. Source of truth at any - moment: Linear for issue state, local files for spec content, the project - journal for current handoff. +## Pick-up and Dispatch + +1. **Confirm the issue is implementable.** `loaf issue check <ref>` must report a delivery issue shaped (or, if the user explicitly asked to resolve a decision issue, that it is ready). Unshaped work goes to shape. +2. **Honor the frontier.** An issue that is blocked does not appear on `loaf issue frontier`. `loaf issue link A blocks B` means A blocks B; B waits until A is `done`, `cancelled`, or `duplicate`. Do not start a blocked successor. Parent/child structure from `loaf issue tree` is not a sequencing edge — only `blocks` / `blocked_by` are. Use the tree to know who belongs in the batch; use the edges to order rounds. +3. **Parents with children are not the implementation target.** Dispatch leaf delivery children that are on the frontier. A parent executes through claimed child criteria, not by starting the parent worktree. +4. **Inspect occupied worktrees:** + ```bash + loaf issue list --started + ``` + Columns: alias, title, `started_branch`, `started_worktree`, optional `(missing)`. If this ref is already started, resume in that worktree with one agent. If the path is occupied by another issue, refuse. A `(missing)` marker means the recorded path is gone — `loaf issue stop <ref>` (not from inside the tree) before starting again. +5. **Start the workspace** (skip if already started and the path exists): + ```bash + loaf issue start <ref> + ``` + Creates branch `issue/<alias-or-id>` in lowercase (`issue/loaf-42`, id suffix when that name is claimed), a sibling worktree, and sets status to `active`. Base is the nearest started ancestor's branch, else the repository default branch. Start refuses archived rows and terminal statuses (`done`, `cancelled`, `duplicate`). +6. **Hand the agent the brief** from `loaf issue show <ref>` (body, criteria, children) and, when opening a PR, `loaf issue render <ref>`. Tell the agent to work only in `started_worktree`. +7. **Batch rounds.** When input is a parent or a set of refs, group unblocked delivery children into dependency-ready rounds from `blocked_by` edges and parent/child structure. Parallel only within a round, max 3, and only when each agent has its own worktree. See [batch-orchestration.md](references/batch-orchestration.md) for the round loop, `--dry-run` / `--parallel` / `--continue` / `--skip <ref>` / `--abort`, and blocked-state recovery. --- @@ -245,7 +160,7 @@ Spawn specialized agents with the appropriate profile: | Code review/audit | reviewer | relevant domain skills | | Research/comparison | researcher | relevant domain skills | -**Rules:** Be specific in prompts. One concern per agent. Include context. Parallel when independent, sequential when dependent. +**Rules:** Be specific in prompts. One concern per agent. Include the issue ref, `started_worktree`, body, and definition of done. Parallel when independent (separate worktrees), sequential when a `blocks` edge says so. --- @@ -254,15 +169,14 @@ Spawn specialized agents with the appropriate profile: There is no session to start — journaling is continuous. Your first action is to log the invocation: ```bash -loaf journal log "skill(implement): <task/spec/context>" +loaf journal log "skill(implement): LOAF-42 — <what>" ``` Entries are project-scoped and tagged with this conversation's harness id automatically. Continuity from prior conversations may arrive through a supported startup adapter; when the exact current target mode is candidate or unsupported, pull it explicitly with `loaf journal context`. Use `loaf journal recent` when you need a narrower timeline. -Suggest renaming the harness conversation with a meaningful name derived from context (use your harness's rename surface if it has one): -- From spec: `SPEC-027-session-stability` -- From task: `TASK-042-login-fix` -- From ad-hoc: `{short-slug-from-description}` +Suggest renaming the harness conversation with a meaningful name derived from the issue (use your harness's rename surface if it has one): +- From issue: `LOAF-42-login-fix` +- From ad-hoc match: `{alias}-{short-slug}` --- @@ -276,12 +190,14 @@ Suggest renaming the harness conversation with a meaningful name derived from co 6. **Journal continuously** -- log spawns, progress, blockers, and decisions with `loaf journal log` as they happen 7. **Clean up** -- no ephemeral files; write an optional `wrap` entry only when there's synthesis worth saving 8. **When in doubt, ask the user** +9. **Never `loaf issue stop` from inside the started worktree** -- stop does not change status; `--force` removes a dirty tree +10. **Do not tick definition-of-done boxes** -- `loaf issue verify` writes nothing; render checks a box only when status is already `done` ## Decision Tree ``` Is this a code/config/doc change? -+-- YES -> Spawn appropriate agent ++-- YES -> Spawn appropriate agent into the issue worktree +-- NO -> Is this a planning/coordination decision? +-- YES with clear path -> Proceed, log the decision +-- YES but ambiguous -> Ask user @@ -294,18 +210,16 @@ When multiple valid approaches exist: spawn council (5-7 agents, odd), present r ## Startup Checklist -1. [ ] Log the invocation: `loaf journal log "skill(implement): <context>"` -2. [ ] Parse input (task, Linear ID, or description) -3. [ ] If TASK-XXX: load task via `loaf task show TASK-XXX`, log task coupling, load parent spec -4. [ ] If Linear ID (or `SPEC-XXX` with `linear_parent`): follow [Linear-Native Routing](#linear-native-routing). Parent → walk sub-issues and select next. Sub-issue → verify `blockedBy` is clear, then start it as one logical Linear operation so the parent is promoted when needed -5. [ ] If description: auto-create task (see Ad-hoc Task Auto-Creation above) -6. [ ] Create dedicated branch (see [branch-and-completion.md](references/branch-and-completion.md)) -7. [ ] Suggest team based on task context -8. [ ] Log initial context and references with `loaf journal log` -9. [ ] Break down work using your harness's task/todo tracking surface -10. [ ] Identify needed specialized agents -11. [ ] Log next steps before spawning -12. [ ] **Get user approval** before spawning +1. [ ] Log the invocation: `loaf journal log "skill(implement): LOAF-42 — <what>"` +2. [ ] Parse input (issue ref, parent, set, frontier, or description) +3. [ ] Load `loaf issue show <ref>`; if children, `loaf issue tree <ref>` +4. [ ] `loaf issue check <ref>` — shaped/ready, or stop and send to shape +5. [ ] Confirm the ref is on `loaf issue frontier` (or already started for resume) +6. [ ] `loaf issue list --started` — one agent per worktree +7. [ ] `loaf issue start <ref>` unless already started +8. [ ] Suggest conversation rename (`LOAF-42-login-fix`) +9. [ ] Identify specialized agents; log next steps +10. [ ] **Get user approval** before spawning --- @@ -313,32 +227,28 @@ When multiple valid approaches exist: spawn council (5-7 agents, odd), present r ### BEFORE (Planning) 1. Log the invocation with `loaf journal log` -2. Set task status: `loaf task update TASK-XXX --status in_progress` -3. Break down work into agent-sized tasks -4. Identify spawn order (respect dependencies) +2. `loaf issue start <ref>` (status becomes `active` through start) +3. Slice work into agent-sized units that still belong to this one issue +4. Identify spawn order (respect `blocked_by` edges and parent/child rounds) 5. Get user approval ### DURING (Execution) -1. Spawn specialized agents via your harness's agent-spawn mechanism -2. Log each spawn with `loaf journal log "todo(agent): spawned <agent> for <task>"` -3. Update Linear with progress (no emoji, no file paths) -4. Keep journal entries handoff-ready -5. After each agent completes: log outcome, spawn next +1. Spawn specialized agents into `started_worktree` via your harness's agent-spawn mechanism +2. Log each spawn with `loaf journal log "todo(agent): spawned <agent> for <ref>"` +3. Keep journal entries handoff-ready +4. After each agent completes: log outcome, spawn next +5. If Linear overlay is enabled, you may comment there — Loaf status stays on `loaf issue` ### AFTER (Completion) 1. Code review pass (spawn `reviewer` agent) 2. Spawn implementer (with foundations + language skill) for final testing -3. **Close out spec artifacts on the branch** (included in the squash merge): - - **Local-tasks mode:** `loaf task update TASK-XXX --status done` (per task), then `loaf task archive --spec SPEC-XXX` - - **Linear-native mode:** `update_issue` the sub-issue to `completed`-type state. Then query the parent's sub-issues; if all are `completed`, also close the parent. If some remain, list them for the user (see [Linear-Native Routing → Completion](#completion-after-implementer--reviewer-finish-cleanly)) - - Mark spec complete and archive: `loaf spec archive SPEC-XXX` (both modes) - - Write a `wrap(scope)` journal entry if the work produced synthesis worth saving (next steps, abandoned paths); otherwise skip it - - Commit: `chore: close SPEC-XXX — archive tasks and spec` -4. If on a feature branch: push and create PR (`gh pr create`). Follow PR format and squash merge conventions in [commits reference](../git-workflow/references/commits.md). -5. After PR is created and approved, use ship to review, verify, and land the PR. Use release later when a coherent batch of landed work is ready to publish. -6. **Suggest reflection:** Check the journal for extractable learnings before closing out: +3. Run `loaf issue verify <ref>` (V-tier, writes nothing). Review every H-tier row yourself or with the user — a skip from verify is not a pass +4. Open or update the PR with body `loaf issue render <ref>` — no manual editing. Follow PR format and squash merge conventions in [commits reference](../git-workflow/references/commits.md) +5. After the PR is created, use ship to review, verify, land, mark `loaf issue status <ref> done`, and `loaf issue stop <ref>`. Use release later when a coherent batch of landed work is ready to publish +6. Write a `wrap(scope)` journal entry if the work produced synthesis worth saving; otherwise skip it +7. **Suggest reflection:** Check the journal for extractable learnings before closing out: - `decision(...)` entries are present - - ADRs, report verdicts, or spec changelog entries were recorded + - ADRs or report verdicts were recorded If any signal is present, suggest: *"This produced key decisions. Consider running reflect to update strategic docs."* If none are present, stay silent. --- @@ -347,18 +257,18 @@ When multiple valid approaches exist: spawn council (5-7 agents, odd), present r | Topic | Reference | Use When | |-------|-----------|----------| -| Batch Orchestration | [batch-orchestration.md](references/batch-orchestration.md) | Running specs, task ranges, or task lists with dependency-ready rounds | -| Branch and Completion | [branch-and-completion.md](references/branch-and-completion.md) | Branch management, team routing, diagrams, Linear sync, journaling, task completion | +| Batch Orchestration | [batch-orchestration.md](references/batch-orchestration.md) | Running a parent or a set of issue refs with dependency-ready rounds | +| Branch and Completion | [branch-and-completion.md](references/branch-and-completion.md) | Team routing, diagrams, exploration, journaling alongside `loaf issue start` / `stop` | +| Working issues locally | [../orchestration/references/local-tasks.md](../orchestration/references/local-tasks.md) | Frontier, started worktrees, status vocabulary, definition of done | --- ## Suggests Next -After all tasks are complete, suggest ship to land the PR. Suggest release only when the landed work forms a coherent release batch. +After the PR exists, suggest ship to land it. Suggest release only when the landed work forms a coherent release batch. ## Related Skills -- **shape** - Spec format and lifecycle -- **breakdown** - Turning specs into tasks -- **orchestration/local-tasks** - Task file format and lifecycle -- **orchestration/journal** - Project journal continuity model +- **shape** — Issue preparation and decomposition +- **orchestration/journal** — Project journal continuity model +- **orchestration/local-tasks** — Frontier, started worktrees, status, definition of done diff --git a/content/skills/implement/references/batch-orchestration.md b/content/skills/implement/references/batch-orchestration.md index 7ab48f755..7902f28f1 100644 --- a/content/skills/implement/references/batch-orchestration.md +++ b/content/skills/implement/references/batch-orchestration.md @@ -7,63 +7,64 @@ - Batch Execution Model - Blocked-State Recovery -Detailed reference for running specs, task ranges, or task lists with dependency-ready scheduling. +Detailed reference for running a parent issue or a set of issue refs with dependency-ready scheduling. ## Orchestration Options | Option | Behavior | |--------|----------| | `--dry-run` | Show dependency-ready execution plan, do not run agents | -| `--parallel` | Run tasks in the same dependency-ready group concurrently (max 3 at once) | -| `--continue` | Resume a blocked orchestration from the recorded task/group | -| `--skip TASK-XXX` | Mark one blocked task as skipped and continue | +| `--parallel` | Run issues in the same dependency-ready group concurrently (max 3 at once) | +| `--continue` | Resume a blocked orchestration from the recorded issue/group | +| `--skip <ref>` | Skip one blocked issue and continue | | `--abort` | Mark orchestration as aborted and stop remaining work | ## Batch Resolution and Dependency-Ready Scheduling -For `SPEC-XXX`, `TASK-XXX..YYY`, and `TASK-XXX,YYY,ZZZ`: +For a parent ref (`loaf issue tree <ref>`) or a named set of refs: -1. Resolve selected tasks and validate each task file exists. -2. Extract `depends_on` from each task and build a dependency graph. -3. Group tasks into dependency-ready rounds: - - First round: tasks with no unresolved dependencies - - Each subsequent round: tasks whose dependencies are completed in earlier rounds -4. If `--parallel` is set, allow parallel execution only within a dependency-ready round and only for non-conflicting tasks. -5. Present execution plan (tasks, dependency-ready rounds, mode, total count) and ask for confirmation unless `--dry-run`. -6. Track progress in the journal and in task statuses: log round boundaries and the current task with `loaf journal log`, and drive each task's status with `loaf task update`. The journal plus task statuses are the durable record of where the batch is. +1. Resolve the selected refs and validate each issue exists (`loaf issue show <ref>`). +2. Read `blocks` / `blocked_by` edges and parent/child structure. Parent/child is not a sequencing edge — only `blocks` / `blocked_by` are. +3. Group unblocked delivery children into dependency-ready rounds: + - First round: issues with no unresolved predecessors + - Each subsequent round: issues whose predecessors are `done`, `cancelled`, or `duplicate` +4. If `--parallel` is set, allow parallel execution only within a dependency-ready round, max 3, and only when each agent has its own started worktree. +5. Present execution plan (issues, dependency-ready rounds, mode, total count) and ask for confirmation unless `--dry-run`. +6. Track progress in the journal: log round boundaries and the current ref with `loaf journal log`. Status moves through `loaf issue start` (to `active`) and, after landing, `loaf issue status <ref> done`. The journal plus issue statuses are the durable record of where the batch is. + +Parents with children are not the implementation target. Dispatch leaf delivery children that are on `loaf issue frontier`. ## Option Handling (`--continue`, `--skip`, `--abort`) -1. Recover batch progress from the journal: `loaf journal recent --since-last-wrap` (or `loaf journal context`) plus `loaf task list --json` to see which tasks are still open. -2. If `--continue`: resume from the last logged dependency-ready round and task. -3. If `--skip TASK-XXX`: mark that task `skipped` via `loaf task update`, log the reason with `loaf journal log`, continue the same dependency-ready round. +1. Recover batch progress from the journal: `loaf journal recent --since-last-wrap` (or `loaf journal context`) plus `loaf issue list --json` and `loaf issue list --started` to see which issues are still open or claimed. +2. If `--continue`: resume from the last logged dependency-ready round and issue. +3. If `--skip <ref>`: log the reason with `loaf journal log`, continue the same dependency-ready round. Do not mark the skipped issue `done`. 4. If `--abort`: log `block(orchestration): aborted`, print a summary, and stop. 5. If no in-flight batch is evident from the journal, report that and ask for fresh selection input. ## Batch Execution Model -When input resolves to multiple tasks, run a dependency-ready round loop: +When input resolves to multiple issues, run a dependency-ready round loop: 1. Set orchestration mode (`sequential` by default, `parallel` only with `--parallel`). 2. For each dependency-ready round: - Log the round start with `loaf journal log` - - Run each task (sequentially, or concurrently within safety limits) - - For each task: set `in_progress` -> spawn agent -> run task verification -> mark `done`/`failed` via `loaf task update` -3. If any task fails verification, stop immediately and log `block(orchestration): <task> failed <reason>`. -4. Consider a round complete only when all its tasks are `done` or skipped. + - For each issue: `loaf issue list --started`, then `loaf issue start <ref>` unless already started, spawn one agent into `started_worktree`, run `loaf issue verify <ref>` (V-tier; writes nothing) +3. If any issue fails verification, stop immediately and log `block(orchestration): <ref> failed <reason>`. +4. Consider a round complete only when all its issues have landed (`loaf issue status <ref> done` via ship) or were skipped. 5. Continue until all rounds complete, then log a closing entry summarizing the batch. ## Blocked-State Recovery When blocked, always print: -- Failed task ID and title +- Failed issue ref and title - Dependency-ready round and current progress - Failure reason + key error output - Recovery options: Re-invoke the implement workflow with: -- `--continue` — after fixes are applied, retry from the blocked task -- `--skip TASK-XXX` — skip only the specified task and continue remaining tasks in the current dependency-ready round +- `--continue` — after fixes are applied, retry from the blocked issue +- `--skip <ref>` — skip only the specified issue and continue remaining issues in the current dependency-ready round - `--abort` — finalize the orchestration as aborted with no further execution diff --git a/content/skills/implement/references/branch-and-completion.md b/content/skills/implement/references/branch-and-completion.md index e4bea8b5d..5c89cdf02 100644 --- a/content/skills/implement/references/branch-and-completion.md +++ b/content/skills/implement/references/branch-and-completion.md @@ -8,7 +8,7 @@ - Linear Status Management - Handoff Readiness - Timestamps for User Context -- Task Completion +- Issue Completion Detailed reference for branch setup, Linear routing, and completion during implementation. @@ -18,28 +18,18 @@ Detailed reference for branch setup, Linear routing, and completion during imple ### Getting Branch Name -1. **If Linear issue exists**: Use the `branchName` field from `get_issue` response - - Linear auto-generates branch names like `username/plt-123-issue-title` - - These are pre-formatted and consistent with team conventions +`loaf issue start <ref>` is the claim. It creates `issue/<alias-or-id>` in lowercase (`issue/loaf-42`, id suffix when that name is claimed), a sibling worktree, and moves status to `active`. -2. **If no Linear issue**: Create branch name from the work description - - Format: `feature/<description>` or `fix/<description>` - - Use kebab-case, keep it concise +Do not `git checkout -b` as a substitute for start. Check `loaf issue list --started` first. Never send two agents into the same worktree. Do not run `loaf issue stop` from inside that worktree. ### Branch Workflow ```bash -# 1. Check current branch status -git status - -# 2. Create and checkout the branch (use Linear's branchName if available) -git checkout -b <branch-name> - -# 3. Confirm branch creation -git branch --show-current +loaf issue list --started +loaf issue start <ref> ``` -**Important:** All implementation agents will work on this branch. The branch should be ready for PR when work completes. Journal entries are tagged with the observed branch automatically, so continuity stays branch-scoped. +Work only in `started_worktree`. The branch should be ready for PR when work completes. Journal entries are tagged with the observed branch automatically. --- @@ -53,7 +43,7 @@ When creating Linear issues, suggest the appropriate team: > "This task seems best suited for the **Security** team (matched: 'auth', 'vulnerability'). > Security hasn't been used in this project yet. Add this team?" 4. **If user confirms**, add team to `known_teams` in config -5. **Create issue** with suggested team +5. **Create via `loaf issue new`** so identity can be delegated; do not create in Linear MCP and forget `loaf issue pull` ### Team Suggestion Example @@ -75,7 +65,7 @@ Use Linear MCP's `list_teams` (if configured) to get all workspace teams for val ## Diagram Consideration -For multi-file or multi-service changes, consider adding architecture diagrams to the linked spec, report, ADR, or implementation notes. +For multi-file or multi-service changes, consider adding architecture diagrams to the issue, a report, ADR, or implementation notes. ### When to Create Diagrams @@ -94,7 +84,7 @@ Ask yourself: 2. Is there a data flow that needs to be understood? 3. Would a visual help communicate the approach? -If yes to any, capture the diagram in a durable artifact such as a spec, report, ADR, or implementation note, and log the reference with `loaf journal log`. +If yes to any, capture the diagram in a durable artifact such as a report, ADR, or implementation note, and log the reference with `loaf journal log`. ### Diagram Template @@ -146,36 +136,23 @@ For complex tasks, explore before implementing: ## Linear Status Management -**Keep Linear status synchronized with actual work state:** - -| Work State | Linear Status (sub-issue) | -|------------|---------------------------| -| Work begun | In Progress | -| Blocked/waiting for user | In Progress (add blocker comment) | -| Work completed | Done (or In Review if PR pending) | +**Keep Loaf status synchronized with actual work state.** Linear is an overlay (`loaf issue pull` / `push` / `reconcile`); never drive Loaf status from Linear MCP tools. -### Parent rollup auto-close +| Work State | Loaf status | +|------------|-------------| +| Work begun | `active` via `loaf issue start` | +| Blocked/waiting | Stay `active`; log `block(scope)` and leave a Linear comment if the overlay is on | +| Work landed | `done` via `loaf issue status <ref> done` (usually ship), then `loaf issue stop <ref>` | -In Linear-native mode, the **parent** rollup issue (labeled `spec`) is not -moved manually during sub-issue work. It flips to Done automatically when -the last sub-issue flips to Done, and only then. Procedure: +### Parent vs children -1. After moving a sub-issue to a `completed`-type state, call - `list_issues` with `parent: <parent-id>`. -2. If every sub-issue is in a `completed`-type state, move the parent to - `completed` via `update_issue`. -3. If any sub-issue is still in an open state (including `blocked`), the - parent stays where it is — the spec is not done. +Parents with children are not the implementation target. Dispatch leaf delivery children on `loaf issue frontier`. A parent is not marked `done` because a child landed. -Never set the parent to In Progress manually — a parent in Linear-native -mode reflects a rollup of its sub-issues, not its own work. +`loaf issue link A blocks B` is the sequencing edge. An issue with an open predecessor does not appear on the frontier. Do not start a blocked successor. -### BlockedBy pre-flight +### Blocked-by pre-flight -Before moving a sub-issue to In Progress, confirm every issue in its -`blockedBy` field is in a `completed`-type state. If not, refuse to start -and report the blockers. This is a hard gate in Linear-native mode — -never implement through open `blockedBy`. +Before `loaf issue start`, confirm the ref is on `loaf issue frontier`. If it is blocked, refuse and report the predecessors. Never implement through an open `blocks` edge. --- @@ -184,7 +161,7 @@ never implement through open `blockedBy`. **The journal must ALWAYS be handoff-ready.** After every significant action: 1. Log what just happened with `loaf journal log` -2. Reference task/spec/report/commit IDs rather than duplicating long prose +2. Reference issue/report/commit IDs rather than duplicating long prose 3. Log completed agent work with outcomes 4. Ensure anyone could pick up the work immediately from `loaf journal recent` @@ -205,32 +182,18 @@ Generate with: `date -u +"%Y-%m-%d %H:%M UTC"` --- -## Task Completion +## Issue Completion -When a task-coupled unit of work completes: +When an issue-coupled unit of work completes: -1. **Update task status** (local file or Linear sub-issue) -2. **Check spec progress:** - - Local-tasks mode: list all tasks for the spec; if all done → mark - spec `complete`, else spec stays `implementing` - - Linear-native mode: query the parent rollup's sub-issues via - `list_issues` with `parent: <parent-id>`; if all are `completed`-type, - close the parent and mark the local spec `complete`, else both stay - in flight -3. **Write a `wrap` journal entry** if the conversation holds synthesis worth - saving (next steps, abandoned paths); skip it otherwise — nothing is - "closed," a conversation that ends without a wrap leaves a valid journal - -### Spec Completion Check +1. **Open or update the PR** with body `loaf issue render <ref>` — no manual editing +2. **Land via ship** — review definition of done, `loaf issue verify <ref>`, squash merge, then `loaf issue status <ref> done` and `loaf issue stop <ref>` +3. **Write a `wrap` journal entry** if the conversation holds synthesis worth saving (next steps, abandoned paths); skip it otherwise — nothing is "closed," a conversation that ends without a wrap leaves a valid journal ```bash -# Local-tasks mode: any open tasks for this spec? -loaf task list --spec SPEC-001 --status open --json - -# Linear-native mode: query the Linear parent's sub-issues -# (via get_issue + list_issues with parent filter) -# The parent itself only flips to Done when every sub-issue is Done. +loaf issue show <ref> +loaf issue tree <ref> +loaf issue list --started ``` -Never mark the local spec `complete` while its Linear parent still has -open sub-issues — the two sources of truth should agree on "done." +Do not mark a parent `done` while delivery children are still open. Do not flip Loaf status from Linear MCP tools; use `loaf issue reconcile` if the overlay has drifted. diff --git a/content/skills/loaf-reference/SKILL.md b/content/skills/loaf-reference/SKILL.md index 03c4e400a..8a21a742e 100644 --- a/content/skills/loaf-reference/SKILL.md +++ b/content/skills/loaf-reference/SKILL.md @@ -19,7 +19,7 @@ The Loaf operating manual for agents: how to discover commands, diagnose project ## Operating Rules - Get exact, current syntax live: `loaf --help` lists every command, `loaf <command> --help` details one. This index is a map, not the contract. -- Prefer `--json` surfaces when diagnosing: `loaf config check --json`, `loaf state doctor --json`, `loaf change check --json`. Parse the structured output instead of scraping human-readable text. +- Prefer `--json` surfaces when diagnosing: `loaf config check --json`, `loaf state doctor --json`. Parse the structured output instead of scraping human-readable text. - Run the deterministic CLI command before hand-editing anything it manages; the command owns its files. - Use `--fix` only for safe, mechanical repairs, and review what it changed. - Ask the user for project-owned choices — GitHub account, tracker or integration election, which harnesses to install — never guess them. @@ -58,17 +58,16 @@ Names and one-line purposes only. Run `loaf <command> --help` for options, argum | `loaf config` | Validate and refresh project Loaf config | check | | `loaf hooks` | Inspect and set which Loaf hooks project into an installed harness's hooks file | list, enable, disable | | `loaf init` | Initialize a project with Loaf structure | — | -| `loaf release` | Create a new release with changelog, version bump, and tag | — | +| `loaf release` | Cut a retroactive release from already-landed work | suggest, cut | | `loaf search` | Search SQLite artifact bodies, journal entries, and indexed docs | — | | `loaf docs` | Manage docs/ indexing | index | -| `loaf change` | Shape-first Change artifacts: git-canonical work context under docs/changes/ | init, check, list | | `loaf render` | Maintain committed durable Markdown renders | sweep | | `loaf state` | Manage native SQLite state | path, status, init, doctor, repair legacy-project-database, repair relationship-origin, repair journal-search, migrate markdown, migrate storage-home, migrate schema, migrate deferrals, migrate lifecycle-statuses, migrate alias-orphans, migrate journal-duplicates, migrate journal-first, backup, backup verify, backup restore, restore-ephemerals, verify-ephemerals, export, export all, export triage, export spec, export release-readiness | | `loaf journal` | Record and read the project-scoped journal (the durable record across all conversations) | log, recent, search, show, context, export, defer | | `loaf project` | Manage durable project identity | list, show, identity, rename, move, delete | | `loaf migrate` | Run native migration workflows | markdown, storage-home, schema, lifecycle-statuses, journal-first, worktree-storage | -| `loaf task` | Manage project tasks | list, show, status, create, update, archive, refresh, sync | -| `loaf spec` | Manage project specs | new, edit, list, show, status, render, finalize, archive, delete | +| `loaf task` | Manage project tasks; superseded by loaf issue for new work | list, show, status, create, update, archive, refresh, sync | +| `loaf issue` | Manage issues in native SQLite state | new, show, list, tree, frontier, start, stop, edit, status, dod, dod add, dod list, dod remove, dod claim, dod unclaim, promote, check, verify, bucket, link, render, export, pull, push, reconcile | | `loaf report` | Manage durable reports (research, audits, investigations) | list, show, render, generate, create, edit, finalize, archive | | `loaf finding` | Manage report findings and verdicts in native SQLite state | list, show, create, verdict, import-json | | `loaf run` | Manage provenance runs for generated findings and reports | list, show, create, complete | @@ -82,7 +81,7 @@ Names and one-line purposes only. Run `loaf <command> --help` for options, argum | `loaf trace` | Trace relationships for one state entity | — | | `loaf brainstorm` | Manage brainstorms in native SQLite state | capture, list, show, promote, archive | | `loaf idea` | Manage ideas in native SQLite state | list, show, capture, promote, resolve, archive | -| `loaf intent` | Manage tracked Intent in native SQLite state; disposition is derived from append-only facts | create, defer, resume, resolve, show, list | +| `loaf intent` | Manage tracked Intent in native SQLite state; disposition is derived from append-only facts; superseded by loaf issue for new work | create, defer, resume, resolve, show, list | | `loaf intake` | Read the deterministic local intake projection; triage judgment stays with humans and Skills | list | | `loaf exploration` | Manage relational Exploration continuity: immutable portable checkpoints, no lifecycle status, no current pointer | create, checkpoint, list, context, conversation | | `loaf conversation` | Manage logical conversations and machine-local provenance handles; handles never imply portable context | create, show, list, handle, observe | diff --git a/content/skills/loaf-reference/references/command-routing.md b/content/skills/loaf-reference/references/command-routing.md index e6856fd65..5f9d30bbb 100644 --- a/content/skills/loaf-reference/references/command-routing.md +++ b/content/skills/loaf-reference/references/command-routing.md @@ -6,15 +6,15 @@ Which command a task needs. For exact flags, run `loaf <command> --help`. | Intent | Route | |--------|-------| -| Shape new bounded work | `loaf change init <slug>`, then `loaf change check` | -| Start implementing new bounded work | the implement workflow after shaping and validating its Change | -| Continue an existing task or spec record | `loaf task` and `loaf spec` remain supported for existing records | +| Shape new bounded work | `loaf issue new <title>`, then `loaf issue dod add` and `loaf issue check <ref>` | +| Start implementing new bounded work | the implement workflow: pick from `loaf issue frontier`, then `loaf issue start <ref>` | +| Continue an existing task or spec record | `loaf task` and `loaf spec` remain readable for legacy records; new work is issues | | Continue after a restart | `loaf journal context` | | Skills or content changed | `loaf build && loaf install --to <target>` | -| See what is in progress | `loaf task list --active` | -| Archive completed work | `loaf task archive` | +| See what is in progress | `loaf issue list --status active` and `loaf issue list --started` | +| Remove finished-with work | `loaf issue status <ref> cancelled` or `duplicate --duplicate-of <ref>` (archives; record survives) | | Check knowledge freshness | `loaf kb check` | -| Validate a Change is structurally executable, not implementation-complete | `loaf change check --require-executable` | +| Validate an issue is shaped, covered, and contained | `loaf issue check <ref>` (non-zero exit names each failure) | | Import legacy `.agents` Markdown into SQLite | `loaf migrate markdown --dry-run` then `--apply` (see markdown-migration reference) | ## JSON diagnosis surfaces @@ -24,10 +24,10 @@ scraping human-readable text: - `loaf config check --json` — config file and installed hook config validity - `loaf state doctor --json` / `loaf state status --json` — SQLite health and readiness -- `loaf change check --json` — Change violations and derived executability +- `loaf issue check <ref> --json` — derived readiness, coverage, and containment - `loaf check --hook <id> --json` — one enforcement hook's result - `loaf kb check --json` — knowledge staleness against git history -- `loaf task list --json` / `loaf journal recent --json` — current work and timeline +- `loaf issue list --json` / `loaf journal recent --json` — current work and timeline - `loaf migrate markdown --dry-run --json` — `mode` (`simulation`/`inventory`) plus `import_report` when simulated Choosing between the `doctor` commands and `LOAF_DB` isolation are covered in diff --git a/content/skills/orchestration/SKILL.md b/content/skills/orchestration/SKILL.md index c0251c4ea..a04ac2794 100644 --- a/content/skills/orchestration/SKILL.md +++ b/content/skills/orchestration/SKILL.md @@ -41,9 +41,9 @@ Comprehensive patterns for orchestration: coordinating multi-agent work, keeping - Outcome-focused, self-contained, no local file references - Magic words in commit body, not subject -**If `integrations.linear.enabled` is `true` in `.agents/loaf.json`:** use Linear MCP workflows and [references/linear.md](references/linear.md) for issue updates and status. +**If `integrations.linear.enabled` is `true` in `.agents/loaf.json`:** Linear is an identity adapter — `loaf issue pull` / `push` / `reconcile`, not a second work unit. See [references/linear.md](references/linear.md). Linear MCP is an overlay; Loaf issues remain the work unit and Linear never drives Loaf status. -**Otherwise:** coordinate with the project journal and `loaf task` / file-based tracking only; do not assume Linear MCP tools are available. +**Otherwise:** coordinate with the project journal and `loaf issue` only; do not assume Linear MCP tools or identity delegation are available. ### Planning (Shape Up) - Complexity-based sizing (small / medium / large) @@ -70,15 +70,15 @@ Comprehensive patterns for orchestration: coordinating multi-agent work, keeping | Pre-compaction | On an exact target mode with supported compaction delivery, hooks may nudge a journal flush and emit the digest afterward; otherwise flush manually and run `loaf journal context` after compaction | | Durable artifact handling | Delegate `.agents/`-scoped report/spec/handoff/knowledge tending to `librarian` | | Low-priority work | Spawn background-runner (see Background Agents) | -| New feature workflow | Research -> Architecture -> Shape -> Breakdown -> Implement | +| New feature workflow | Pitch -> Shape -> Implement -> Ship -> Release | ## Topics | Topic | Reference | Use When | |-------|-----------|----------| -| Shaping Specs | [../shape/SKILL.md](../shape/SKILL.md) | Creating specs, shaping work, defining test conditions | -| Breaking Work Into Tasks | [../breakdown/SKILL.md](../breakdown/SKILL.md) | Turning shaped specs into implementation tasks | -| Local Tasks | [references/local-tasks.md](references/local-tasks.md) | Managing tasks locally or with Linear backend | +| Shaping Issues | [../shape/SKILL.md](../shape/SKILL.md) | Preparing issues: body, definition of done, out of scope | +| Decomposition | [../shape/SKILL.md](../shape/SKILL.md) | Promoting a criterion that earns its own DoD (`loaf issue promote`) | +| Working Issues | [references/local-tasks.md](references/local-tasks.md) | Frontier, started worktrees, status, definition of done | | Agent Delegation | [references/delegation.md](references/delegation.md) | Choosing agents, spawning subagents, decision trees | | Parallel Agents | [references/parallel-agents.md](references/parallel-agents.md) | Dispatching independent work concurrently | | Subagent Development | [references/subagent-development.md](references/subagent-development.md) | Delegating to specialized agents | @@ -95,7 +95,7 @@ Comprehensive patterns for orchestration: coordinating multi-agent work, keeping The orchestrator: 1. Creates issues and logs the orchestration intent for tracking -2. Breaks down work into delegable tasks +2. Picks from `loaf issue frontier` and starts one worktree per issue 3. Spawns specialized agents for implementation 4. Coordinates outcomes and updates external systems 5. Never implements code, tests, or documentation directly @@ -125,16 +125,16 @@ This skill uses paths from `.agents/loaf.json`: | Councils | `.agents/councils/` | `.agents/councils/archive/` | `YYYYMMDD-HHMMSS-topic.md` | | Handoffs | `.agents/handoffs/` | delete after deprecated | Created by handoff | | Reports | `.agents/reports/` | N/A | `YYYYMMDD-HHMMSS-subject.md` | -| Tasks | SQLite (`loaf task show/list`) | N/A | Per task manager conventions | +| Issues | SQLite (`loaf issue show/list`) | `cancelled` / `duplicate` via `loaf issue status` | Alias or opaque id | **Rule:** Agents write artifacts to disk, orchestrator reasons over artifacts, users retrieve from disk. ## Workflow by Lifecycle ### BEFORE (Planning) -- Create/check external issue (Linear, GitHub) +- Shape prepares issues; implement works the frontier. Decomposition is `loaf issue promote` inside shape. - Log the orchestration intent with `loaf journal log` -- Break down into tasks, identify agents, get user approval +- `loaf issue check <ref>` must report shaped (delivery) or ready (decision); identify agents; get user approval ### DURING (Execution) - Spawn specialized agents (never implement directly) @@ -143,6 +143,6 @@ This skill uses paths from `.agents/loaf.json`: ### AFTER (Completion) - Code review + QA testing -- Update external issue to Done +- Land via ship: `loaf issue status <ref> done`, then `loaf issue stop <ref>` - Ensure knowledge captured in permanent locations - Write an optional `wrap` journal entry if the conversation holds synthesis worth saving diff --git a/content/skills/orchestration/references/background-agents.md b/content/skills/orchestration/references/background-agents.md index 6aa2b3c61..95b4b3b54 100644 --- a/content/skills/orchestration/references/background-agents.md +++ b/content/skills/orchestration/references/background-agents.md @@ -43,7 +43,7 @@ Task( - src/services/ Write report to: .agents/reports/YYYYMMDD-HHMMSS-security-audit.md - Reference: TASK-123, SPEC-045 if relevant + Reference: LOAF-123 if relevant """, run_in_background=True ) @@ -51,12 +51,12 @@ Task( ### Cursor -Background agents are configured via the `is_background: true` YAML property. When spawning, specify the report destination and any task/spec IDs: +Background agents are configured via the `is_background: true` YAML property. When spawning, specify the report destination and any issue refs: ``` @background-runner Run security audit on backend codebase. Write report to .agents/reports/. -Reference TASK-123 if relevant. +Reference LOAF-123 if relevant. ``` The background agent's journal entries are tagged with its own harness id automatically — there is no session alias to pass. @@ -72,7 +72,7 @@ Track background work with durable references: 1. Log the spawn with `loaf journal log "todo(background): started <id> for <task>"`. 2. Ask the background agent to write a report under `.agents/reports/`. 3. When complete, log `discover(background): <id> wrote <report>`. -4. Process findings into tasks, specs, ADRs, or report verdicts as appropriate. +4. Process findings into issues, ADRs, or report verdicts as appropriate. Use a stable ID such as `bg-YYYYMMDD-HHMMSS-description` in the prompt and journal entries. @@ -85,7 +85,7 @@ Background agents write results to `.agents/reports/` with enough metadata to id 1. Orchestrator identifies non-blocking security audit work. 2. Orchestrator logs the background spawn to the journal. 3. Background agent writes `.agents/reports/YYYYMMDD-HHMMSS-auth-security.md`. -4. Orchestrator reviews the report, creates follow-up tasks, and logs the outcome. +4. Orchestrator reviews the report, creates follow-up issues, and logs the outcome. 5. Report state is finalized or archived through the report lifecycle. ## Anti-Patterns @@ -94,7 +94,7 @@ Background agents write results to `.agents/reports/` with enough metadata to id |-------|------------| | Use for blocking work | Keep blocking work in foreground | | Spawn without tracking | Log the spawn and require a report path | -| Ignore completed results | Process reports into tasks, findings, or decisions | +| Ignore completed results | Process reports into issues, findings, or decisions | | Use for interactive tasks | Reserve for autonomous work | | Spawn many concurrent background agents | Limit concurrency to avoid resource contention | | Skip result location in prompt | Always specify where output belongs | diff --git a/content/skills/orchestration/references/context-management.md b/content/skills/orchestration/references/context-management.md index 11ce34ac7..49151a167 100644 --- a/content/skills/orchestration/references/context-management.md +++ b/content/skills/orchestration/references/context-management.md @@ -19,28 +19,28 @@ Patterns for keeping long work resumable while using the project journal as exte Compaction is normal in long workflows. Design work that spans many exchanges so important state is already outside chat context. 1. **The journal is external memory.** Record decisions, discoveries, blockers, and next actions with `loaf journal log`. -2. **Artifacts carry detail.** Changes, task-board records, reports, ADRs, and commits hold rich detail; journal entries point to them. +2. **Artifacts carry detail.** Issues, reports, ADRs, and commits hold rich detail; journal entries point to them. 3. **Delegated work absorbs exploration.** Use delegated agents for broad investigation and return concise findings to the main context. -4. **`wrap` captures synthesis.** When meaningful work holds intentions or abandoned paths worth saving, write an optional `wrap` journal entry. +4. **`wrap` captures synthesis.** When meaningful work holds abandoned paths worth saving, write an optional `wrap` journal entry. ## Continuity Digest (contract v2) -`loaf journal context` is the contract-v2 active-truth digest and supersedes the retired three-part summary. Read its named layers and diagnostics; an absent item and an unavailable source are different states. +`loaf journal context` is the contract-v2 active-truth digest. Read its named layers and diagnostics; an absent item and an unavailable source are different states. Layer *names* below are the live CLI identifiers. | Layer | Truth and precedence | |-------|----------------------| | `project-synthesis` | The latest `wrap(project)` synthesis. This is the only wrap that represents project-wide synthesis. | | `scoped-checkpoint` | The latest non-project wrap, only when `project-synthesis` has no item. It is labeled as a fallback, not promoted to project synthesis. | -| `active-lineage` | Journal evidence associated with the active Change lineage. | +| `active-lineage` | Journal evidence associated with the active work lineage. | | `unresolved-blockers` | Blocks without a later exact-scope unblock. | -| `deferred-intent` | Open deferred-intent decision and spark pairs. | -| `active-changes` | Git-derived active Change evidence and worktree state. | +| `deferred-intent` | CLI layer for open deferred decision and spark pairs. | +| `active-changes` | Git-derived active worktree evidence. | | `branch-recency` | Recent branch entries after entries already surfaced as active truth are removed. | -| `transitional-tasks` | Open task-board records retained for compatibility. | +| `transitional-tasks` | Leftover board records retained for compatibility — not the work unit. Prefer `loaf issue frontier` / `loaf issue list --started`. | -Each returned layer includes `source_available`, `available_count`, `shown_count`, `truncated`, and `expand_command`; paginated layers also include a cursor. Treat `source_available: false` as an explicit unavailable source, never as “nothing is active.” If Change discovery is unavailable, `active-changes` and `active-lineage` are unavailable and the digest carries a diagnostic. +Each returned layer includes `source_available`, `available_count`, `shown_count`, `truncated`, and `expand_command`; paginated layers also include a cursor. Treat `source_available: false` as an explicit unavailable source, never as “nothing is active.” If git-derived discovery is unavailable, `active-changes` and `active-lineage` are unavailable and the digest carries a diagnostic. -Use `--branch` to select `branch-recency` scope and bind state cursors. It does not override active Change provenance or reasons, which always use the actual Git branch. Use `loaf journal context --layer <name>` to inspect one layer. `--limit` accepts 1 through 100 and requires `--layer`; `--cursor` also requires `--layer` and cannot be used with the intrinsic one-item `project-synthesis` or `scoped-checkpoint` layers. Follow the returned `expand_command` exactly: a cursor is bound to its layer, project, branch, snapshot, and limit. Use `--json` for automation; the human view preserves availability, counts, truncation, diagnostics, and expansion commands. +Use `--branch` to select `branch-recency` scope and bind state cursors. It does not override git provenance or reasons, which always use the actual Git branch. Use `loaf journal context --layer <name>` to inspect one layer. `--limit` accepts 1 through 100 and requires `--layer`; `--cursor` also requires `--layer` and cannot be used with the intrinsic one-item `project-synthesis` or `scoped-checkpoint` layers. Follow the returned `expand_command` exactly: a cursor is bound to its layer, project, branch, snapshot, and limit. Use `--json` for automation; the human view preserves availability, counts, truncation, diagnostics, and expansion commands. ## Context Commands @@ -53,21 +53,21 @@ Use `--branch` to select `branch-recency` scope and bind state cursors. It does ## When to Clear Context -Clear the conversation when starting a completely new task, after the previous task is complete, when debugging noise crowds out the current objective, or when switching between unrelated codebases. Avoid clearing mid-task until enough state is logged for recovery. +Clear the conversation when starting a completely new issue, after the previous issue is complete, when debugging noise crowds out the current objective, or when switching between unrelated codebases. Avoid clearing mid-issue until enough state is logged for recovery. ## Compaction Lifecycle PreCompact: 1. Flush unrecorded decisions, discoveries, blockers, and next actions with `loaf journal log`. -2. Reference Changes, task-board records, reports, commits, and files by stable ID or path. +2. Reference issues, reports, commits, and files by stable ID or path. 3. On an exact target mode with supported PreCompact delivery, let the hook nudge the flush; otherwise flush manually before compacting. PostCompact: 1. On an exact target mode with supported resumption delivery, read the continuity digest emitted by the hook; otherwise run `loaf journal context` explicitly. 2. Expand the named layer that needs more detail, or use `loaf journal recent` and `loaf journal search` for a different query. -3. Continue from the journal and linked artifacts. +3. Continue from the journal and linked artifacts (`loaf issue show <ref>`). This makes compaction survivable without relying on hand-maintained Markdown state. State not logged or captured in a durable artifact can be lost. @@ -79,10 +79,10 @@ Use delegated agents to investigate without filling the main context. |-----------|----------| | Quick file lookup | Direct read or search tool | | Multi-file exploration | Explorer or research agent | -| Implementation work | Implementer or task-focused agent | +| Implementation work | Implementer in the issue's started worktree | | Long audit | Background agent with report output | -Pass stable references to delegated agents: Change IDs, task IDs, branch names, and report paths. The harness ID is attached to journal entries automatically; there is no session alias to pass. +Pass stable references to delegated agents: issue IDs, branch names, worktree paths, and report paths. The harness ID is attached to journal entries automatically; there is no session alias to pass. Never send two agents to the same started worktree. ## Context Budget Guidelines @@ -109,12 +109,12 @@ No special management is usually needed. | Repeating same mistakes | Context pollution | Log current facts, then clear or compact | | Forgetting recent decisions | Overcrowded context | Read `loaf journal context` and expand the relevant layer | | Slow responses | Large context | Delegate exploration | -| Confusion about task | Too many pivots | Re-anchor on Change or task IDs | +| Confusion about the work | Too many pivots | Re-anchor on issue IDs (`loaf issue show`) | ## Best Practices 1. Log durable facts early with `loaf journal log`. 2. Use delegated agents for exploration-heavy work. -3. Clear between unrelated tasks. -4. Compact mid-task when the journal and artifacts are current. +3. Clear between unrelated issues. +4. Compact mid-issue when the journal and artifacts are current. 5. Scope tool calls so context stays focused. diff --git a/content/skills/orchestration/references/delegation.md b/content/skills/orchestration/references/delegation.md index b710244b6..a620c0ba7 100644 --- a/content/skills/orchestration/references/delegation.md +++ b/content/skills/orchestration/references/delegation.md @@ -131,8 +131,8 @@ Use when work is truly independent. Spawn multiple agents in the same turn when 1. **Be specific in prompts** - Include file paths, requirements, constraints 2. **One concern per agent** - Don't ask a backend implementer to also write tests -3. **Include context** - Task/spec IDs, issue ID, previous outcomes -4. **Reference durable artifacts** - Task, spec, and report IDs; the subagent's journal entries are harness-id tagged automatically +3. **Include context** - Issue refs (`LOAF-42` or opaque id), previous outcomes +4. **Reference durable artifacts** - Issue aliases and report IDs; the subagent's journal entries are harness-id tagged automatically 5. **Include skill hints** - Name the skills that should guide the agent's work ### Skill Hints @@ -171,8 +171,7 @@ Files: - src/api/users.py - src/models/user.py -Task: TASK-042 -Linear: BACK-123 +Issue: LOAF-42 ``` ## Anti-Patterns @@ -183,7 +182,7 @@ Linear: BACK-123 | Asking backend implementer for React | Spawn implementer with frontend skills | | Single agent for database + backend + tests | Sequential: implementer (database-design), implementer (language skill), implementer (foundations) | | Parallel spawns with hidden dependencies | Make dependencies explicit, spawn sequentially | -| Spawning without context | Reference task/spec/report IDs in prompts | +| Spawning without context | Reference issue aliases and report IDs in prompts | | Council for simple decisions | Single agent or orchestrator judgment | ## Agent Access Hierarchy diff --git a/content/skills/orchestration/references/journal.md b/content/skills/orchestration/references/journal.md index de2a9de3a..442f2b216 100644 --- a/content/skills/orchestration/references/journal.md +++ b/content/skills/orchestration/references/journal.md @@ -42,20 +42,20 @@ loaf journal log "spark(scope): possible follow-up idea" loaf journal log "todo(scope): concrete follow-up action" ``` -Log durable facts, not thoughts. Reference task IDs, spec IDs, report IDs, and +Log durable facts, not thoughts. Reference issue IDs, report IDs, and commit refs rather than pasting long prose. The journal should let another agent resume without reading the whole conversation. ## Codex Auto Mode -When the user has explicitly enabled Loaf's managed Codex Auto-journal capability, use the exact path-pinned command shown in the Loaf-managed block of `CODEX_HOME/AGENTS.md`; its form is `'<canonical-loaf-path>' journal log --execpolicy-safe "decision(scope): chose X because Y"`. Do not substitute a bare `loaf`, alternate executable, or shell/environment wrapper. The installed Codex rule authorizes only explicitly classified basic Loaf command leaves outside the workspace sandbox, including this hardened journal writer and approved readers; ordinary `journal log`, body/file-consuming leaves, and path-taking `change check` remain operator-gated, and the policy does not authorize a general Loaf data-directory writable root. Other harness adapters are not implied and continue to use their own ordinary surfaces. +When the user has explicitly enabled Loaf's managed Codex Auto-journal capability, use the exact path-pinned command shown in the Loaf-managed block of `CODEX_HOME/AGENTS.md`; its form is `'<canonical-loaf-path>' journal log --execpolicy-safe "decision(scope): chose X because Y"`. Do not substitute a bare `loaf`, alternate executable, or shell/environment wrapper. The installed Codex rule authorizes only explicitly classified basic Loaf command leaves outside the workspace sandbox, including this hardened journal writer and approved readers; ordinary `journal log`, body/file-consuming leaves, and other path-taking operator-gated leaves remain operator-gated, and the policy does not authorize a general Loaf data-directory writable root. Other harness adapters are not implied and continue to use their own ordinary surfaces. Enable the capability once with `loaf install --to codex --codex-basic-commands`. Installation is an explicit trust decision. If the rules are absent, stale, locally modified, or conflict with user-owned `loaf.rules`, Loaf reports the condition instead of overwriting it or asking for full system access. ## Wrap: Optional Checkpoint A `wrap` entry is a voluntary checkpoint, not a lifecycle transition. Write one -only when the conversation holds synthesis worth saving — intentions, abandoned +only when the conversation holds synthesis worth saving — abandoned paths, next steps — the connective narrative that evaporates with the context window. Almost everything else is derivable from raw entries. @@ -70,11 +70,12 @@ perfectly valid journal. A wrap reviews its own conversation's entries first: loaf journal recent --since-last-wrap ``` -See the `wrap` skill for the full checkpoint flow. +See the `wrap` skill for the full checkpoint flow. Loose ends name issues +(`loaf issue frontier`, `loaf issue list --started`), not board leftovers. ## Derived Continuity -Continuity is computed at read time and never persisted. On an exact target mode whose startup delivery capability is supported, the startup adapter may emit a layered digest with the latest project-level wrap, recent entries scoped to the current branch/worktree, and open (`in_progress`/`pending`) tasks. When startup delivery is candidate or unsupported for the current mode, explicitly run `loaf journal context` at conversation start. On demand, reproduce or extend the digest: +Continuity is computed at read time and never persisted. On an exact target mode whose startup delivery capability is supported, the startup adapter may emit a layered digest with the latest project-level wrap, recent entries scoped to the current branch/worktree, and open work. Bind that open work to `loaf issue frontier` and `loaf issue list --started`. When startup delivery is candidate or unsupported for the current mode, explicitly run `loaf journal context` at conversation start. On demand, reproduce or extend the digest: ```bash loaf journal context # the layered continuity digest @@ -82,7 +83,7 @@ loaf journal recent --branch <b> # recent entries for one branch loaf journal search <query> # find prior decisions by topic ``` -Pass task/spec/report references to background and delegated agents. The harness +Pass issue IDs and report references to background and delegated agents. The harness id is attached automatically — there is no session alias to pass along. ## Recovery @@ -91,7 +92,7 @@ After compaction, a branch switch, or a long gap: 1. Read `loaf journal context`; on an exact target mode with supported resumption delivery, the digest emitted by the adapter is equivalent continuity context. 2. Widen with `loaf journal recent` / `loaf journal search` when more is needed. -3. Compare against `git status`, `git log`, and the relevant specs/tasks. +3. Compare against `git status`, `git log`, `loaf issue frontier`, and `loaf issue list --started`. 4. If code and journal have drifted, log the reconciliation: `loaf journal log "decision(recovery): rewound to <commit>; replaying tests"`. @@ -108,7 +109,7 @@ After compaction, a branch switch, or a long gap: | Don't | Do Instead | |-------|------------| | Wait to log everything at the end | Log significant facts as they happen | -| Store decisions only in chat context | Log them and promote durable ones to ADR/spec/report/docs | +| Store decisions only in chat context | Log them and promote durable ones to ADR/issue body/report/docs | | Write a placeholder wrap out of ceremony | Wrap only when there's synthesis worth saving | | Treat a missing wrap as an open loop | A conversation without a wrap is complete and valid | | Pass a session alias to delegated agents | Nothing to pass — the harness id is automatic | diff --git a/content/skills/orchestration/references/linear.md b/content/skills/orchestration/references/linear.md index f488726cf..f00960ebc 100644 --- a/content/skills/orchestration/references/linear.md +++ b/content/skills/orchestration/references/linear.md @@ -7,8 +7,7 @@ Guidelines for writing Linear issue updates, comments, and commit messages with - Configuration - MCP Server Naming - Multi-Workspace Guidance -- Linear-Native Mode (Parent + Sub-Issues) -- The `spec` Label Convention +- Identity Adapter - Progress Update Format - Issue Description Format - Status Conventions @@ -97,96 +96,44 @@ Match the `linear.mcp_server_name` in each project's `.agents/loaf.json` to the name used in that project's `.mcp.json`. That way the Loaf skills invoke the right workspace automatically. -## Linear-Native Mode (Parent + Sub-Issues) +## Identity Adapter -In Linear-native mode (`integrations.linear.enabled: true`), each spec -produces one parent **rollup issue** and N sub-issues under it. +When `issue_identity.authority = linear`, Linear owns identity, title, status, +and assignment. Loaf owns shaping state: body, definition-of-done criteria, +claims, and the started worktree. The Loaf issue is the work unit. Linear MCP +is an overlay — never drive Loaf status from MCP tools. -``` -Agent framework alignment ← parent, label: `change` -├── Split reviewer profile into reviewer/auditor ← sub-issue, label: type/refactor -├── Harden MCP fallback path ← sub-issue, label: type/feature -└── Migrate legacy task references ← sub-issue, label: type/refactor -``` - -### Parent issue — what it is and isn't - -The parent issue is a **dashboard anchor**, not a re-hosting of the spec. - -- **Is:** a short summary (1–3 paragraphs) of the problem and solution - direction + a link to the canonical spec file in the repo. -- **Is not:** a copy of the spec's Scope / Rabbit Holes / Open Questions / - Risks sections. Those live in the local spec file and evolve there. - -### Sample parent description +`loaf issue new` delegates identity: Linear mints the identifier, and that +key becomes the local alias. The local counter is not advanced. If Linear is +offline, refuse — capture via `loaf spark` or `loaf idea`. Do not mint a +local alias as a fallback. -```markdown -## Summary -Align Loaf's agent profiles with the three-role model (implementer, reviewer, -researcher). Consolidate historical profile variants and add tool-boundary -tests so profiles can't drift without a test failing. - -## Context -See the canonical change file in the repository for full text, council -references, rabbit holes, and strategic tensions. +If Linear created an issue but the local bind failed, adopt it: -## Progress -Sub-issues track execution. +```text +loaf issue pull <linear-key> +loaf issue pull <linear-key> --tree ``` -### Sub-issues - -- Each sub-issue has `parentId` set to the parent issue ID. -- Cross-task dependencies use Linear's `blockedBy` field referencing sibling - sub-issue IDs. -- Sub-issue labels describe the task itself (type, team, area), not the - parent — don't label sub-issues with `spec`. -- Starting a sub-issue promotes the parent rollup from `backlog`/`unstarted` - to the team's `started`/In Progress state. Parent promotion is a state - invariant of the start operation, not a separate manual reminder. -- Do not silently reopen protected parents. If the parent is `completed`, - `canceled`, or archived, stop and ask for an explicit override before - starting the child. - -### Spec file remains canonical - -Even with the parent in Linear, the local spec file is the source of truth -for: +`--tree` also adopts the sub-issue tree with parent edges intact. -- Problem statement and solution direction -- Scope / in-scope / out-of-scope / rabbit holes / no-gos -- Risks and open questions -- Council references and strategic tensions +### Commands -When the spec evolves, edit the file and let git track it. The parent -issue's summary is a frozen entry point; only refresh it if the summary -itself (not the rabbit holes or risks) changes meaningfully. - -## The `spec` Label Convention - -Every spec-parent rollup issue carries a Linear label named `spec`. This lets -anyone in Linear filter for "all spec roots" across projects without having to -know which issues happen to be parents. - -| Field | Value | -|-------|-------| -| Name | `spec` | -| Color | `#5e6ad2` (suggested; implementer may adjust) | -| Description | `Parent rollup issue representing a design spec tracked in the repo at .agents/specs/` | -| Scope | Workspace-scoped preferred; fall back to team-scoped if the MCP requires it | - -### Who creates it - -breakdown creates the `spec` label on first Linear-native breakdown in a -workspace that doesn't already have it. Subsequent breakdowns reuse the -existing label. Log whether the label was created this run or already -existed — this matters for first-time setup. +```text +loaf issue pull <linear-key> [--tree] [--json] +loaf issue push <ref> [--json] +loaf issue reconcile [<ref>] [--take-local|--take-tracker] [--json] +``` -### Sub-issues never carry `spec` +| Command | Writes? | What it does | +|---------|---------|----------------| +| `loaf issue pull` | Yes | Adopt an existing Linear issue as a local row. The Linear key becomes the alias | +| `loaf issue push` | Yes | Write `loaf issue render` as the Linear description. Status is written only when the local status event is newer than the tracker. Never renames the Linear issue | +| `loaf issue reconcile` | Yes with a take flag | Compare local and Linear. Title drift updates the local title (tracker wins). Status drift is reported; `--take-local` or `--take-tracker` resolves it. Description drift is reported only | -`spec` applies only to parents. A sub-issue describing a task uses its own -labels (type groups like `feature`/`bug`/`refactor`, team labels, area -labels) — never `spec`. This keeps the "filter for spec roots" query clean. +Do not create records with `loaf task` or `loaf spec`. Parent/child structure +is `loaf issue promote` (or `loaf issue new --parent`), not a `spec`-labeled +Linear rollup. ## Progress Update Format @@ -222,18 +169,9 @@ None currently. ## Issue Description Format -```markdown -## Summary -Brief description of the work and its purpose. - -## Acceptance Criteria -- [ ] Criterion 1 -- [ ] Criterion 2 -- [ ] Criterion 3 +The Linear description is `loaf issue push` output — `loaf issue render`, not a hand-authored summary. Do not paste a competing description over the render. -## Notes -Any relevant context (keep brief). -``` +Comments (not the description) still follow the progress-update format above. **Rules:** - Concise and actionable @@ -243,6 +181,8 @@ Any relevant context (keep brief). ## Status Conventions +Loaf status is `loaf issue status`. Linear status is the tracker's. Resolve drift with `loaf issue reconcile` (`--take-local` or `--take-tracker`). Do not flip Loaf status from Linear MCP tools. + | State | When to Use | |-------|-------------| | **Backlog** | Issue created, not started | @@ -341,6 +281,8 @@ Use `scripts/suggest-team.py "task desc"` to get suggestions. ## When to Create Issues +Create through `loaf issue new` so identity can be delegated. Do not create in Linear MCP and then forget to `loaf issue pull`. + | Action | Create Issue? | |--------|---------------| | Features, bugs, refactoring | Yes | diff --git a/content/skills/orchestration/references/local-tasks.md b/content/skills/orchestration/references/local-tasks.md index 69c89df64..30d617f47 100644 --- a/content/skills/orchestration/references/local-tasks.md +++ b/content/skills/orchestration/references/local-tasks.md @@ -1,259 +1,173 @@ -# Local Task Management +# Working Issues Locally -Break specs into atomic tasks using SQLite-backed Loaf task records when Linear -isn't available. +Orchestration-facing reference for the Loaf issue CLI: pick-up-next, started +worktrees, status, definition of done, and advisory labels. Issue commands +require initialized SQLite state. ## Contents -- Task Abstraction Layer -- Local Task Records -- Task Lifecycle -- Creating Tasks from Specs -- Cutover Reconciliation -- Task ID Generation -- Archiving Tasks -- Journal Integration -- Task Sizing -- Priority Levels -- Listing Tasks -- Work Log Updates -- Verification -- Local vs Linear Comparison - -## Task Abstraction Layer - -Tasks work identically whether backed by Linear or local SQLite state. - -### Configuration - -```yaml -# .agents/loaf.yaml -task_management: - backend: linear # or "local" - - linear: - team: ProjectName - default_labels: [] - - local: - archive_completed: true -``` - -### Abstracted Operations - -| Operation | Linear | Local | -|-----------|--------|-------| -| Create task | Create issue | `loaf task create --spec SPEC-XXX --title "..." --priority P1` | -| Fetch task | Get issue | `loaf task show TASK-XXX` or `loaf task show TASK-XXX --json` | -| Update status | Update issue | `loaf task update TASK-XXX --status in_progress` | -| List tasks | List issues | `loaf task list` (or `loaf task list --json` for machine parsing) | -| Complete | Move to Done | `loaf task update TASK-XXX --status done` | - -## Local Task Records +- Frontier +- Started worktree +- Status vocabulary +- Relationships +- Definition of done +- Buckets +- Command cheat sheet +- LEGACY -SQLite is the operational source of truth for task metadata, status, priority, -dependencies, dates, and relationships. Use `loaf task` CLI commands for all -task mutations. `.agents/tasks/` and `.agents/TASKS.json` were removed by the -SPEC-045 cutover and are rollback material only. - -**Create with:** `loaf task create --title "..." --spec SPEC-XXX` - -## Task Lifecycle +## Frontier +```text +loaf issue frontier [--json] ``` -todo → in_progress → review → done - │ │ │ │ - └────────┴───────────┴────────┘ - can return to earlier states -``` - -| Status | Meaning | -|--------|---------| -| `todo` | Ready to work, not started | -| `in_progress` | Actively being worked | -| `review` | Implementation complete, needs verification | -| `done` | Verified complete, ready for archive | - -## Creating Tasks from Specs - -### Input -- Spec ID (e.g., `SPEC-001`) -- Optional: priority override +Pick-up-next. Derived at read time, never stored. Lists non-archived issues in +`triage`, `backlog`, or `todo` that are not blocked. -### Task Breakdown Rules +| Qualifier | Meaning | +|-----------|---------| +| Open | Status is `triage`, `backlog`, or `todo` — not `active`, `done`, `cancelled`, or `duplicate` | +| Unblocked | No open predecessor via `blocks` / `blocked_by`. A predecessor that is `done`, `cancelled`, or `duplicate` does not block | +| Unclaimed | Not `active` and no started worktree. `loaf issue start` is the claim | -1. **One concern per task** - Don't mix backend + tests + frontend -2. **Clear done condition** - Observable, verifiable outcome -3. **Verification command** - How to prove it works -4. **File hints** - Which files will likely be modified +Archived rows are excluded. Kind is not filtered: a `--kind decision` question +can appear; it is not delivery work. Buckets are not read. Prefer `--json` +when diagnosing rather than scraping the human-readable text. -### Example Breakdown +## Started worktree +```text +loaf issue start <ref> [--json] +loaf issue stop <ref> [--force] [--json] +loaf issue list --started [--json] ``` -SPEC-001: User Authentication with OAuth - ↓ -TASK-001: OAuth Provider Integration - - Google OAuth client setup - - GitHub OAuth client setup - - Token exchange logic - verify: pytest tests/auth/test_oauth.py - -TASK-002: Session Management - - Session cookie handling - - Session storage (Redis/DB) - - Session expiry logic - verify: pytest tests/auth/test_session.py - -TASK-003: Login UI Components - - Login page layout - - Provider buttons - - Error states - verify: npm run test:e2e -- auth -``` - -## Cutover Reconciliation - -If a stale branch reintroduces `.agents/tasks/`, `.agents/sessions/`, other -ephemeral roots, or `.agents/TASKS.json`, keep the deletion side from the -cutover branch and rerun `loaf check --hook ephemeral-provenance`. Use -`loaf state restore-ephemerals <backup-id>` only for an intentional rollback, -then re-import forward. - -## Task ID Generation -Format: `TASK-{number}-{slug}` +**Invariant:** one agent, one worktree. Check `loaf issue list --started` +before dispatch. Never send two agents into the same path. -Task IDs are auto-generated by `loaf task create`. In SQLite-backed projects, -the allocation is recorded in state. +`start` creates branch `issue/<alias-or-id>` in lowercase (`issue/loaf-42`, +disambiguated with an id suffix when that name is already claimed), adds a +sibling worktree, records `started_branch` / `started_worktree` on the row, and +moves status to `active` through the events path. Base is the nearest started +ancestor's branch, else the repository default branch. Start refuses an already +started row, an archived row, and terminal statuses (`done`, `cancelled`, +`duplicate`). Requires a git repository. -## Archiving Tasks +`list --started` prints alias, title, `started_branch`, `started_worktree`, and +`(missing)` when the recorded path is gone. -When a task is done: +`stop` removes the worktree and clears the started workspace on the row. It +keeps the branch and does not change status. `--force` removes a dirty +worktree. Do not run `stop` from inside the started worktree. -1. Mark complete via CLI: `loaf task update TASK-XXX --status done` -2. Archive: `loaf task archive TASK-XXX` +## Status vocabulary -## Journal Integration +Write statuses that update in place: `triage`, `backlog`, `todo`, `active`, +`done`. `cancelled` and `duplicate` archive through the remove path +(`loaf issue status <ref> duplicate --duplicate-of <surviving>`). -When the implement workflow starts on `TASK-001`: - -1. Load task metadata via `loaf task show TASK-001` for context -2. Read linked spec for full picture -3. Log the task coupling as the first action: +```text +loaf issue status <ref> <status> [--duplicate-of <ref>] [--json] +``` -```bash -loaf journal log "decision(implement): implementing TASK-001" +| Status | Meaning | +|--------|---------| +| `triage` | Default at create. Shaped is derived (`loaf issue check`), not a status | +| `backlog` | Filed, worth keeping | +| `todo` | Explicitly ready to work | +| `active` | Started. **Review is a display name for `active`** — there is no `review` write status | +| `done` | Work landed | +| `cancelled` | Archived; abandoned | +| `duplicate` | Archived; `--duplicate-of` required | + +There is **no `blocked` status**. Blocked is a relationship. Title and body stay +mutable at every status. + +```text +loaf issue list [--status <status>] [--kind delivery|decision] [--archived] [--started] [--json] ``` -There is no session to create or couple to. Task progress is read through -`loaf task show/list`; the surrounding decisions and blockers live in the -project journal (`loaf journal recent`, `loaf journal search`). +Archived rows are hidden unless `--archived`. `--status` accepts every value in +the table above. -## Task Sizing +## Relationships -### Separation of Concerns +```text +loaf issue link <from> blocks|relates-to <to> [--json] +loaf issue link <from> remove <type> <to> [--json] +``` -**The primary principle for task breakdown is separation of concerns.** +Stored types are `blocks` and `relates_to`. `loaf issue link A blocks B` means +A blocks B: B is absent from the frontier until A is `done`, `cancelled`, or +`duplicate`. `relates-to` is not a sequencing constraint. -| Rule | Guideline | -|------|-----------| -| **One agent type** | Task completable by ONE subagent (implementer, reviewer, researcher) | -| **One concern** | Task touches one layer, one service, or one component | -| **Context-appropriate** | Fits in model context with room for exploration | -| **Not over-fragmented** | Don't split what naturally belongs together | +Do not encode order in `loaf issue tree`. Parent/child is structure; `blocks` +is the dependency. `loaf issue export [--json]` dumps relationships (and +claims) when you need the graph. -### Right Size Test +## Definition of done -1. Can a single specialized agent complete this? → If no, split by agent type -2. Does it touch multiple unrelated concerns? → If yes, split by concern -3. Will the agent need too much context? → If yes, split into smaller coherent units -4. Am I splitting just to have more tasks? → If yes, merge back +Criteria live on the issue row. `loaf issue show <ref>` prints each as +`position. [V|H] text` with `command=` / `expect=` when present. -### Agent Scope +```text +loaf issue dod add <ref> <text> [--command <cmd>] [--expect <expect>] [--tier V|H] [--serves <parent-position>] [--json] +loaf issue dod list <ref> [--json] +loaf issue dod remove <ref> <position> [--json] +loaf issue dod claim <child> <child-position> <parent-position> [--json] +loaf issue dod unclaim <child> <child-position> <parent-position> [--json] +loaf issue promote <ref> <position> [--json] +loaf issue check <ref> [--json] [--human <reason>] +loaf issue verify <ref> [--json] +``` -| Agent | Typical Task Scope | -|-------|-------------------| -| implementer (backend) | One service/module, its tests, its docs | -| implementer (frontend) | One component/page, its tests, its styles | -| implementer (database) | One migration, related schema changes | -| implementer (testing) | Test suite for one feature/area | -| implementer (infra) | One infrastructure concern (CI, deploy, config) | +| Tier | When | Who checks | +|------|------|------------| +| V | `--command` present, unless `--tier` overrides | `loaf issue verify <ref>` from the repository root. Honors `exit <N>` and `` contains `text` ``. Writes nothing. Non-zero on failure | +| H | No `--command`, unless `--tier` overrides | Human or orchestrator. Verify skips H-tier; that skip is not a pass | -## Priority Levels +Claims: a child criterion serves a parent criterion. `promote` copies the +parent criterion onto a new delivery child and records the claim. +`--serves` claims a newly added child criterion. `claim` / `unclaim` retarget +an existing pair. Positions are 1-based. -| Priority | Meaning | Response | -|----------|---------|----------| -| P0 | Urgent/blocking | Drop everything | -| P1 | High | Work next | -| P2 | Normal | Scheduled work | -| P3 | Low | When time permits | +`check` is readiness (shape's gate): delivery is shaped with a nonempty body, +at least one criterion, and an out-of-scope statement; decision is ready on a +sharp `?`. Children add coverage (every parent criterion claimed — failure) +and containment (every child criterion claims a parent — report). `verify` is +implement's preflight and writes nothing — it does not set status and does not +tick boxes. -## Listing Tasks +`loaf issue render <ref>` emits the paste-ready PR body: title, body, +definition-of-done checkboxes (checked only when status is already `done`), +and children. No manual editing. -### All Active Tasks +## Buckets -```bash -loaf task list +```text +loaf issue bucket <ref> now|next|later|none [--json] ``` -### Tasks for a Spec - -```bash -loaf spec list # Show specs with task counts -loaf task list --json # Machine-parseable output, filter by spec -``` +Advisory Now/Next/Later labels. Never read as a constraint. Frontier, start, +and verify ignore them. `none` clears the label. -## Work Log Updates +## Command cheat sheet -As work progresses, append to the Work Log section: - -```markdown -## Work Log - -### 2026-01-23 14:30 UTC -Started OAuth integration. Set up Google OAuth client credentials. - -### 2026-01-23 15:45 UTC -Google OAuth working. Moving to GitHub integration. - -### 2026-01-23 17:00 UTC -Both providers working. Tests pass. Moving to review. +```text +loaf issue new <title> [--body <text>|--body -|--body-file <path>|--message <text>] + [--kind delivery|decision] [--parent <ref>] [--fog <text>] [--status <status>] [--json] +loaf issue show <ref> [--json] +loaf issue tree [<ref>] [--archived] [--json] +loaf issue edit <ref> [--body-file <path>|--body -|--message <text>] [--json] +loaf issue export [--json] ``` -## Verification +`new` default kind is `delivery`; default status is `triage`. `--status` on +create still records the initial triage event, then writes the requested +write-status. `--fog` exists only on create. `edit` replaces the body; there +is no patch form. -Before marking `done`: - -1. Run the `verify` command from frontmatter -2. Check all acceptance criteria are checked -3. Ensure no regressions in related tests - -```bash -# Run task verification -verify_cmd=$(grep '^verify:' TASK-001-*.md | cut -d: -f2-) -eval "$verify_cmd" -``` +## LEGACY -## Local vs Linear Comparison - -| Feature | Local | Linear | -|---------|-------|--------| -| No external dependency | yes | no | -| Rich UI | no | yes | -| Team collaboration | git-based | native | -| Notifications | none | email/slack | -| Reporting | manual | built-in | -| Offline work | yes | limited | - -**Use local when:** -- Solo project -- No Linear access -- Offline development -- Simple task tracking - -**Use Linear when:** -- Team collaboration needed -- Rich workflow automation -- Integration with other tools -- Reporting requirements +`loaf task` and `loaf spec` remain readable against leftover SQLite rows. They +mint nothing new. Do not create records there. Issues are the work unit. diff --git a/content/skills/orchestration/references/parallel-agents.md b/content/skills/orchestration/references/parallel-agents.md index f4ef48ab2..35a07a7d6 100644 --- a/content/skills/orchestration/references/parallel-agents.md +++ b/content/skills/orchestration/references/parallel-agents.md @@ -117,7 +117,7 @@ When streams complete: | Command | Parallel Opportunity | |---------|---------------------| -| breakdown | Identify parallelizable tasks during decomposition | +| shape | Identify parallelizable child issues during decomposition | | implement | Single task, usually sequential | | implement | Runs dependency-aware orchestration, including parallel-safe tasks | diff --git a/content/skills/orchestration/references/script-surface.md b/content/skills/orchestration/references/script-surface.md index 70206ab63..474b76354 100644 --- a/content/skills/orchestration/references/script-surface.md +++ b/content/skills/orchestration/references/script-surface.md @@ -20,7 +20,7 @@ script surface: - The source currently has 10 orchestration scripts out of 22 skill-local scripts overall. -- Several scripts overlap existing `loaf journal`, `loaf task`, `loaf check`, +- Several scripts overlap existing `loaf journal`, `loaf issue`, `loaf check`, and Linear-aware behavior. - Shell/Python helpers are harder to discover than `loaf <noun> <verb>` and are not consistently covered by CLI tests. diff --git a/content/skills/orchestration/references/subagent-development.md b/content/skills/orchestration/references/subagent-development.md index 2d36b6175..d2c46e2a1 100644 --- a/content/skills/orchestration/references/subagent-development.md +++ b/content/skills/orchestration/references/subagent-development.md @@ -211,7 +211,7 @@ After subagent completes: | Command | Subagent Role | |---------|---------------| -| breakdown | Tasks become subagent assignments | +| shape | Promoted child issues become subagent assignments | | implement | May dispatch subagents for specialized work | | implement | Automatically coordinates single-task and multi-task subagent work | diff --git a/content/skills/pitch/SKILL.md b/content/skills/pitch/SKILL.md index 45761ce7a..ea3206100 100644 --- a/content/skills/pitch/SKILL.md +++ b/content/skills/pitch/SKILL.md @@ -1,20 +1,20 @@ --- name: pitch description: >- - Runs the human problem-discovery ceremony at change or project scale: grills - problem, who has it, current alternatives, value proposition, and constraints, - then authors a brief (change brief.md via loaf change init --brief, or project - docs/BRIEF.md with source: pitch). Use when the user invokes pitch, starts - work on a raw concept, or triage dispositions an item as pitch. Produces an - authored problem-space brief and a shape-now or park offer — never shape.md, - tasks, or PRs. Not for solution shaping (use shape), queue processing (use - triage), quick capture (use idea), or open-ended divergent inquiry (use explore - as an agent technique when pitch reveals the direction is undecided). + Runs the human problem-discovery ceremony: grills problem, who has it, + current alternatives, value proposition, and constraints, then hands a + sharpened problem narrative to shape or authors project docs/BRIEF.md. + Use when the user invokes pitch, starts work on a raw concept, or triage + dispositions a spark or idea as pitch. Produces a problem-space narrative + and a shape-now or park offer — never a bounded issue, criteria, or PRs. + Not for quick capture (use idea), solution bounding (use shape), queue + processing (use triage), or open-ended divergent inquiry (use explore as + an agent technique when pitch reveals the direction is undecided). --- # Pitch -Human problem-discovery ceremony. Authors a brief at the matching scale so shape starts from a framed problem and bootstrap can consume a pitched project BRIEF. +Human problem-discovery ceremony. Narrows sparks and ideas into a framed problem so shape can mint an issue, and bootstrap can consume a pitched project BRIEF. ## Contents - Critical Rules @@ -31,61 +31,90 @@ Human problem-discovery ceremony. Authors a brief at the matching scale so shape ## Critical Rules 1. **Agents never initiate a pitch.** This ceremony is human-invoked only. On Claude Code the sidecar sets `disable-model-invocation: true`; on every target this rule binds behaviorally. Agent legwork *inside* a human-opened pitch (competitive scans, file writes the skill directs) is fine — opening one is not. -2. **Log invocation first** — `loaf journal log "skill(pitch): <idea, problem, or intake item>"` before interviewing. -3. **Problem-space only** — grill what, who, and why-valuable. Approach, architecture, decomposition, and verification design belong to shape. A brief that reads like a pseudo-shape is a failure; rewrite before landing. -4. **One question at a time, recommendation-first** — using your harness's structured question tool if it has one (otherwise one inline question per message). Never a multi-field form. Order by impact on the brief. Full mechanics: [references/interview-guide.md](references/interview-guide.md). -5. **Never write `shape.md`, seed `tasks/`, push, or open PRs** — pitch prepares commits and hands off; push and PR stay human. Never auto-run shape or bootstrap. -6. **Landing is validated, then committed once** — every capture landing runs explicit-path `loaf change check <folder> --json` (zero violations, expected captured state) and a direct read-back of that folder's `change.json` confirming intended `target_release` presence or absence, then one docs-only commit per capture. Never batch captures into one commit. -7. **Slug identity is local** — propose a slug that names the concept, never another work unit (no `spec-042`, no task ids). Provenance lives in frontmatter and the change folder. -8. **Log the outcome** — `loaf journal log "decision(pitch): <slug or project> — <shape-now|park-targeted|park-untargeted|handed-to-bootstrap>"`. +2. **Log invocation first** — `loaf journal log "skill(pitch): <idea, problem, spark, or intake item>"` before interviewing. +3. **Problem-space only** — grill what, who, and why-valuable. Approach, architecture, decomposition, and verification design belong to shape. A narrative that reads like a pseudo-shape is a failure; rewrite before landing. +4. **One question at a time, recommendation-first** — using your harness's structured question tool if it has one (otherwise one inline question per message). Never a multi-field form. Order by impact on the narrative. Full mechanics: [references/interview-guide.md](references/interview-guide.md). +5. **Never bound, never ship** — do not add definition-of-done criteria, do not write an out-of-scope statement, do not run `loaf issue check` or `loaf issue promote`, do not push, do not open PRs. Never auto-run shape or bootstrap. +6. **Shape mints on the happy path** — same-session shape-now hands the authored narrative; shape runs `loaf issue new` with that body. Pitch writes an issue body only when parking an unshaped row or when `$ARGUMENTS` already names an issue (`loaf issue edit` replaces the body). +7. **Titles name the concept** — propose a working title, never another work unit's alias. Provenance lives in the issue row, the spark/idea resolution, and frontmatter on `docs/BRIEF.md`. +8. **Log the outcome** — `loaf journal log "decision(pitch): <title, ref, or project> — <shape-now|park-issue|park-idea|handed-to-bootstrap>"`. --- ## Verification -- Change scale: `docs/changes/YYYYMMDD-slug/` holds `change.json` + authored `brief.md`; `loaf change check <folder> --json` reports zero violations and captured state; `change.json` read-back matches the intended target binding -- Project scale: `docs/BRIEF.md` exists with `source: pitch` and the shared problem-space skeleton -- Cold-read: problem, who, alternative, and value nameable in one pass; zero solution-space content -- No `shape.md` or `tasks/` written by this skill; no push; no PR +- Issue-scale: a problem narrative exists against the shared skeleton; it was handed to shape, written into an existing issue body, or minted as an unshaped triage row with that body and no criteria +- Project-scale: `docs/BRIEF.md` exists with `source: pitch` and the shared problem-space skeleton +- Cold-read: problem, who, alternative, and value nameable in one pass; zero solution-space content; no out-of-scope statement and no criteria added by this skill +- Named sparks were promoted to an idea when pitching them; ideas and sparks were resolved against the issue only after a row exists +- No push; no PR; shape and bootstrap were not auto-run - Journal shows skill invocation and outcome entries --- ## Quick Reference +| Harness | Invoke skill | +|---------|----------------| +| Claude Code (plugin) | `/loaf:pitch` | +| OpenCode, Cursor, Codex, Amp | `/pitch` | + ### Scale detection | Signal | Scale | Output | |--------|-------|--------| -| Existing project (git history, source, or Loaf state) + a concept | **Change** | `loaf change init <slug> --brief` → authored `brief.md` | -| Empty or minimal directory / greenfield intent | **Project** | `docs/BRIEF.md` with `source: pitch` → hand to bootstrap | +| Existing project (git history, source, or Loaf state) + a concept | **Issue** | Problem narrative → shape (`loaf issue new --body`) or an unshaped triage row | +| Empty or minimal directory / greenfield product pitch | **Project** | `docs/BRIEF.md` with `source: pitch` → hand to bootstrap | Detect and confirm briefly; let the human correct. When both could apply (repo exists but they want a new product pitch), ask once. -### Landing matrix (Decision 11) +### Landing offers -| Intent | Branch | Commit | Target | -|--------|--------|--------|--------| -| **Shape now** | Create the slug branch (`git switch -c <slug>`), stay there | Hand to shape for in-place promotion — do not park-commit first | Stamp `target_release` when known | -| **Park targeted** | Default branch | One docs-only commit on default (promise-carrier exception) | `target_release` present and confirmed by read-back | -| **Park untargeted** | Slug branch **or** remain intake (Intent/spark) | Docs-only commit on the slug branch if becoming a Change; else no Change folder | No `target_release`; untargeted captures never land on main | +| Offer | When to recommend | What pitch does | +|-------|-------------------|-----------------| +| **Shape now** | Framing is solid; they want to bound next | Hand the narrative; do not mint; do not auto-run shape | +| **Park as issue** | Framed, durable, not bounding yet | `loaf issue new "<title>" --body -` with the narrative only; status stays `triage` | +| **Park as idea** | Too thin to keep as a row, or might discard | `loaf idea capture --title "..."`; journal the gist | +| **Hand to bootstrap** | Project-scale BRIEF authored | Point at bootstrap; do not auto-run it | -Pitch prepares the commit; never pushes; never opens PRs. +Pitch never pushes; never opens PRs. There is nothing to commit at issue scale — the row lives in SQLite. Project-scale may commit `docs/BRIEF.md` if the human wants it durable. -### Pre-landing guard (every capture) +### Spark and idea promotion -```bash -loaf change check <folder> --json # zero violations; state is captured -# then read <folder>/change.json and confirm target_release presence/absence matches intent +| Input | Read | Then | +|-------|------|------| +| Spark | `loaf spark show <ref>` | `loaf idea capture --title "..."` then `loaf spark promote <spark> --to-idea <idea>`; grill from the idea | +| Idea | `loaf idea show <ref>` | Grill; after a row exists, `loaf idea resolve <idea> --by <ref>` | +| Existing issue | `loaf issue show <ref>` | Grill; `loaf issue edit <ref> --body -` writes the narrative (replaces the whole body) | +| Free text | — | Grill; shape-now hands text; park captures an idea or mints an unshaped row | + +Do not invent a pitch from the queue without human selection. When they name an intake item, read it (`loaf intake list` / the item's read command). + +`loaf idea promote --to-spec` is not this path. Resolve ideas against the minted issue. + +### Problem-narrative skeleton + +Author against these sections, problem-space sentences only. This text is what shape puts in `--body` (or what a park-as-issue row stores): + +```markdown +## Problem Statement +## Who Has It +## Current Alternatives +## Value Proposition +## Constraints +## Sequencing and Relationships +## Sources and Research Links +## Open Questions ``` -Bare `loaf change check` resolves by branch and can miss a capture landing elsewhere — always pass the explicit folder path. +Do not add an out-of-scope statement. Shape bounds; pitch frames. ### Defined terms -- **Brief** — the pitch output (problem-space). Superseded by `shape.md` when shaping starts; may accrete parked problem-space sentences until then; freezes when `shape.md` exists. -- **Accretion** — adding problem-space concepts to a parked brief is legal; solution prose is not. -- **Shape now** — slug branch + hand to shape, which promotes the capture in place via ordinary `loaf change init <slug>` (no `--brief`). +- **Problem narrative** — pitch's issue-scale output. Superseded as the working surface once shape mints and bounds the issue; may accrete parked problem-space sentences until then. +- **BRIEF** — project-scale `docs/BRIEF.md`. A project document, not a work container. +- **Accretion** — adding problem-space concepts to a parked narrative is legal; solution prose is not. +- **Shape now** — hand the narrative to shape, which mints via `loaf issue new` and owns bounding. --- @@ -94,81 +123,82 @@ Bare `loaf change check` resolves by branch and can miss a capture landing elsew ### Step 1: Log and parse input ```bash -loaf journal log "skill(pitch): <idea, problem, or intake item>" +loaf journal log "skill(pitch): <idea, problem, spark, or intake item>" ``` -Parse `$ARGUMENTS`: free text, an intake ref the human already chose, or empty (ask what to pitch). Read the named intake item when provided (`loaf intake list` / the item's read command). Do not invent a pitch from the queue without human selection. +Parse `$ARGUMENTS`: free text, a spark, an idea, an issue ref, an intake ref the human already chose, or empty (ask what to pitch). Read the named item when provided. Do not invent a pitch from the queue without human selection. ### Step 2: Detect scale -Apply the Quick Reference table. Confirm: "I'll treat this as a **change-scale** pitch on this repo" or "…as a **project-scale** pitch for a new BRIEF." Adjust if corrected. +Apply the Quick Reference table. Confirm: "I'll treat this as an **issue-scale** pitch on this repo" or "…as a **project-scale** pitch for a new BRIEF." Adjust if corrected. -### Step 3: Problem-discovery interview +### Step 3: Promote sparks; read ideas + +When the named input is a spark, promote it to an idea before grilling so the capture trail is one idea, not a dangling spark: + +```bash +loaf idea capture --title "<working title>" +loaf spark promote <spark> --to-idea <idea> +``` + +When the named input is already an idea, `loaf idea show` and grill. Leave resolution until an issue row exists. + +### Step 4: Problem-discovery interview Run the interview per [references/interview-guide.md](references/interview-guide.md): -- Pin a one-or-two-line **destination** before dimension grilling (fixes brief scope; project scale feeds VISION success criteria, change scale sharpens the eventual Hypothesis) +- Pin a one-or-two-line **destination** before dimension grilling (fixes narrative scope; project scale feeds VISION success criteria; issue scale sharpens what good looks like for the row) - Dimensions: problem, who has it, current alternatives / competitive landscape, value proposition, constraints (plus sequencing and open questions when needed) - Depth: scenario stress-testing, challenge stance, glossary-term hygiene; open questions must pass the specifiability test and carry HITL/AFK tags when precise - Applicability judgment: skip formal competitive analysis and deep personas when the pitch kind does not warrant them (bug fixes, internal chores) -- One question at a time, recommendation-first, ordered by brief impact +- One question at a time, recommendation-first, ordered by narrative impact - Stop on exit criteria or when answers stop changing the framing -If the direction is genuinely undecided mid-interview, offer the **explore** technique (agent-side; Explorations and checkpoints) rather than forcing a false brief. Pitch remains the human front door; explore is not re-opened as a slash alternative by this skill. +If the direction is genuinely undecided mid-interview, offer the **explore** technique (agent-side; Explorations and checkpoints) rather than forcing a false narrative. Pitch remains the human front door; explore is not re-opened as a slash alternative by this skill. -### Step 4: Evidence delegation (when warranted) +### Step 5: Evidence delegation (when warranted) -When competitive or landscape facts would change the brief and are not already known: +When competitive or landscape facts would change the narrative and are not already known: 1. Spawn a **researcher** subagent with a bounded question (competitors, substitutes, prior art — not solution design). 2. Land evidence: - - **Change scale:** files under the change folder's `research/` (create the folder with the change); link from Sources and Research Links - - **Project scale:** inline source links in `docs/BRIEF.md` Sources and Research Links (no change `research/` yet) -3. Resume the interview or brief draft with recommendations informed by the scan. + - **Issue scale:** source links in the narrative's Sources and Research Links. If a longer scan lands on disk, name it for the landscape, never for the work unit, and cite it from Sources. + - **Project scale:** inline source links in `docs/BRIEF.md` Sources and Research Links. +3. Resume the interview or draft with recommendations informed by the scan. Never fabricate competitive claims. If a scan is skipped, say so in Sources ("no external scan; alternative is internal workaround X"). -### Step 5a: Change-scale ceremony - -1. **Propose a slug** — lowercase, digits, single hyphens; names the concept locally. Confirm with the human. -2. **Initialize capture:** - - ```bash - loaf change init <slug> --brief - ``` - - Creates `docs/changes/YYYYMMDD-<slug>/` with `change.json` + `brief.md` scaffold only. -3. **Author `brief.md`** against the shared problem-space skeleton (shape's brief template / the scaffold just written): Problem Statement, Who Has It, Current Alternatives, Value Proposition, Constraints, Sequencing and Relationships, Sources and Research Links, Open Questions. Problem-space sentences only. -4. **Stamp `target_release` when known** — edit `change.json` with canonical `MAJOR.MINOR.PATCH` (no `v`, no prerelease). Omit the field when untargeted. Confirm with the human before stamping. -5. **Accretion note** — tell the human: parked problem-space concepts may accrete into this brief until shaping starts; once `shape.md` exists the brief freezes. -6. **Cold-read** the brief (interview guide test); revise with the human until it passes. -7. **Offer landing** (recommendation-first): +### Step 6a: Issue-scale ceremony - | Offer | When to recommend | - |-------|-------------------| - | **Shape now** | Framing is solid and they want to bound implementation next | - | **Park targeted** | Bound to a release cohort but not shaping yet — docs-only on default branch | - | **Park untargeted** | Worth capturing off-main, or not ready as a Change (stay intake) | +1. **Propose a working title** — names the concept locally. Confirm with the human. This becomes shape's `loaf issue new` title (or the park-as-issue title). +2. **Author the problem narrative** against the skeleton above. Problem-space sentences only. +3. **Accretion note** — tell the human: parked problem-space concepts may accrete until shaping starts; once the issue is minted, the body is the home. +4. **Cold-read** the narrative (interview guide test); revise with the human until it passes. +5. **Offer landing** (recommendation-first) using the Landing offers table. +6. **Execute the chosen landing:** -8. **Execute the chosen landing:** + - **Shape now:** hand the full narrative and any spark/idea refs. Shape runs `loaf issue new "<title>" --body -` (or `--body-file`) with that text. Do not mint, do not add criteria, do not open a PR. After shape mints, resolve intake: `loaf idea resolve <idea> --by <ref>` (and `loaf spark resolve <spark> --by <ref>` only if the spark was never promoted). + - **Park as issue:** mint the unshaped row yourself, then resolve intake against it: - - **Shape now:** `git switch -c <slug>` (from default unless already on a working branch the human prefers), ensure pre-landing guard would pass if they later park, hand to shape with the folder path — shape promotes in place. Do not open a PR. - - **Park targeted:** on the **default branch**, run pre-landing guard on the explicit folder, confirm `target_release` present in `change.json`, then one docs-only commit of the change folder (and any `research/` under it). - - **Park untargeted as Change:** `git switch -c <slug>`, pre-landing guard, confirm `target_release` **absent**, one docs-only commit on the slug branch. - - **Park as intake:** do not leave a half-written change folder; prefer Intent/spark retention and delete or never create the capture if the human backs out. + ```bash + loaf issue new "<title>" --body - + loaf idea resolve <idea> --by <ref> + ``` -9. **Commit message** (when parking): conventional, e.g. `docs(change): capture <slug> brief` — one commit per capture. + Paste the narrative on stdin. Do not add criteria. Do not write out-of-scope. Default status is `triage`. Read back with `loaf issue show <ref>`. + - **Park as idea:** if no idea exists yet, `loaf idea capture --title "<title>"`. Journal the gist (`loaf journal log "discover(pitch): <one-line problem>"`). Do not mint an issue. + - **Existing issue:** `loaf issue edit <ref> --body -` with the full narrative. Edit replaces the body; do not strip a row that is already bounded — if criteria already exist, hand the narrative to the human and let shape merge. -10. **Closing ceremony (required — never trail off).** After the landing is executed (or intake retained), announce completion with a full closing block: +7. **Closing ceremony (required — never trail off).** After the landing is executed, announce completion with a full closing block: - - **Recap the brief** — section-by-section gist (Problem, Who, Alternatives, Value, Constraints, Sequencing, Open Questions). Name the change folder path (`docs/changes/YYYYMMDD-<slug>/`) and what it holds (`change.json` + `brief.md`, plus any `research/`). + - **Recap the narrative** — section-by-section gist (Problem, Who, Alternatives, Value, Constraints, Sequencing, Open Questions). Name where it lives (handed to shape, unshaped issue `<ref>`, idea `<ref>`, or the conversation plus journal gist). - **Restate the landing actually taken** and what it means next: - - **Shape now** — you are on the slug branch; run shape next to promote the capture in place and bound implementation. No park-commit was made. - - **Park targeted** — the capture is a docs-only commit on the default branch with `target_release` stamped; it sits as a promise carrier for that cohort until shape is invoked later. - - **Park untargeted** — the capture lives on the slug branch (or remains intake) without `target_release`; it is off-main until retargeted or shaped. If intake-only, name the Intent/spark and that no change folder was left half-written. + - **Shape now** — run shape next to mint the issue from this narrative and bound implementation. No row was minted here. + - **Park as issue** — `<ref>` holds the problem in its body and is unshaped; run shape later on that ref. + - **Park as idea** — the idea remains open; re-invoke pitch or shape when ready. Name the idea ref. - **Announce completion** in plain language: "Pitch is complete." Do not end on a dangling offer or an unfinished sentence. -### Step 5b: Project-scale ceremony +### Step 6b: Project-scale ceremony 1. **Author `docs/BRIEF.md`** using bootstrap's brief skeleton with frontmatter: @@ -180,31 +210,31 @@ Never fabricate competitive claims. If a scan is skipped, say so in Sources ("no --- ``` - Same problem-space sections as change scale, at project altitude (Sequencing describes the initial arc as prose). + Same problem-space sections as issue scale, at project altitude (Sequencing describes the initial arc as prose). 2. **Cold-read** and revise with the human. 3. Optionally commit `docs/BRIEF.md` if the human wants it durable before bootstrap; still no push unless they ask outside this skill's duties — pitch itself never pushes. 4. **Closing ceremony (required — never trail off).** Announce completion with a full closing block — do not hand off in a half-sentence: - **Recap what was authored** — section-by-section gist of the BRIEF (Problem Statement, Who Has It, Current Alternatives, Value Proposition, Constraints, Sequencing and Relationships, Sources and Research Links, Open Questions). One or two sentences per section is enough; the human should hear what landed without reopening the file. - **Artifact path** — name `docs/BRIEF.md` explicitly, including that frontmatter carries `source: pitch`. - - **Explicit handoff** — state, verbatim in spirit: next, run bootstrap: it reads this BRIEF as discovery-done (`source: pitch`), interviews only on gaps, populates the operating documents (VISION, STRATEGY, ARCHITECTURE, AGENTS), and closes by proposing your initial arc of captured changes. Do not auto-run bootstrap. + - **Explicit handoff** — state, verbatim in spirit: next, run bootstrap: it reads this BRIEF as discovery-done (`source: pitch`), interviews only on gaps, and populates the operating documents (VISION, STRATEGY, ARCHITECTURE, AGENTS). Do not auto-run bootstrap. - **Announce completion** in plain language: "Pitch is complete." The ceremony ends with a period, never a trail-off. -### Step 6: Log the outcome +### Step 7: Log the outcome ```bash -loaf journal log "decision(pitch): <slug or project> — <shape-now|park-targeted|park-untargeted|handed-to-bootstrap>" +loaf journal log "decision(pitch): <title, ref, or project> — <shape-now|park-issue|park-idea|handed-to-bootstrap>" ``` -The journal line is mechanical; the human-facing close is the closing ceremony in Step 5a/5b. Never log-and-stop without that recap and next-step restatement. +The journal line is mechanical; the human-facing close is the closing ceremony in Step 6a/6b. Never log-and-stop without that recap and next-step restatement. --- ## Related Skills -- **shape** — solution-space narrowing from an existing brief (or full narrowing when no brief); promotes capture folders in place -- **bootstrap** — consumes `docs/BRIEF.md` (`source: pitch` → gap interview) and series-preps captured changes -- **triage** — queue dispositions; may hand an item to pitch when problem discovery is needed +- **shape** — solution-space bounding; mints the issue from the problem narrative (`loaf issue new`) and owns criteria, out-of-scope, and decomposition +- **bootstrap** — consumes `docs/BRIEF.md` (`source: pitch` → gap interview) and populates operating documents +- **triage** — queue dispositions; may hand a spark or idea to pitch when problem discovery is needed - **explore** — agent-side technique when pitch finds the direction still undecided - **idea** — quick capture without ceremony; not a substitute for pitch - **research** — patterns the researcher subagent follows for landscape scans @@ -217,4 +247,4 @@ The journal line is mechanical; the human-facing close is the closing ceremony i ## Artifact Naming -Name every artifact for what it is, never for the work unit that produced it. The change folder already records provenance. Put source in frontmatter, not the filename. See the `foundations` skill; `loaf check --hook artifact-names` enforces it at commit. +Name every on-disk artifact for what it is, never for the work unit that produced it. The issue row or `docs/BRIEF.md` already records provenance. Put source in frontmatter, not the filename. See the `foundations` skill; `loaf check --hook artifact-names` enforces it at commit. diff --git a/content/skills/pitch/references/interview-guide.md b/content/skills/pitch/references/interview-guide.md index 8014082fe..65780fb10 100644 --- a/content/skills/pitch/references/interview-guide.md +++ b/content/skills/pitch/references/interview-guide.md @@ -11,7 +11,7 @@ Problem-discovery interview for pitch. Borrows shape's grilling mechanics (one q - Open Questions: Specifiability and HITL/AFK - Exit Criteria - Anti-Patterns -- Brief Cold-Read +- Problem Cold-Read ## How This Guide Works @@ -23,22 +23,22 @@ The interview is adaptive, not a form. Strong answers skip dimensions; weak answ ## Destination Pinning -Before dimension grilling, converge on a **destination**: one or two lines naming the end state this pitch is aiming at — what becomes true in the world if the work succeeds. Pin it early; it fixes the brief's scope for the rest of the interview. +Before dimension grilling, converge on a **destination**: one or two lines naming the end state this pitch is aiming at — what becomes true in the world if the work succeeds. Pin it early; it fixes the narrative's scope for the rest of the interview. | Scale | Destination feeds | |-------|-------------------| | **Project** | VISION success criteria (bootstrap extracts it; pitch keeps it as the project's north star in the BRIEF) | -| **Change** | The eventual Hypothesis when shape promotes the capture — a sharper "what good looks like" than a feature list | +| **Issue** | What good looks like for the work — the problem statement shape will put in the issue body | **How to pin:** offer a recommendation-first draft from the human's opening words ("Destination: operators can ship a release without a manual config audit"). Confirm, tighten, or rewrite until both parties can restate it. Do not start deep dimension probes until the destination is on the table. -If the destination keeps shifting mid-interview, pause and re-pin — a moving destination means the brief's scope is still open. +If the destination keeps shifting mid-interview, pause and re-pin — a moving destination means the narrative's scope is still open. --- ## Problem-Discovery Dimensions -Grill these five dimensions. Order by what would change the brief most — a wrong "who" invalidates value and alternatives; cosmetic naming goes last. +Grill these five dimensions. Order by what would change the narrative most — a wrong "who" invalidates value and alternatives; cosmetic naming goes last. ### 1. Problem @@ -62,17 +62,17 @@ What do they do today? Existing tools, manual workarounds, cobbled scripts, or " Why is solving this worth it? What becomes true for the people who have the problem if this lands? One line: different AND better relative to the alternative — not a feature list, not an architecture sketch. -**Applicability:** always name value, but depth scales. A product pitch needs a crisp UVP; a small change can be "removes the weekly fire-drill so release day is boring." +**Applicability:** always name value, but depth scales. A product pitch needs a crisp UVP; a small internal pitch can be "removes the weekly fire-drill so release day is boring." ### 5. Constraints -Non-negotiable bounds: technical, legal, organizational, philosophical, sequencing. Things that limit the solution space before design begins. Capture as problem-space limits ("must not break the promise-carrier exception"), never as chosen designs ("use Postgres"). +Non-negotiable bounds: technical, legal, organizational, philosophical, sequencing. Things that limit the solution space before design begins. Capture as problem-space limits ("must not add a new human ceremony to the weekly path"), never as chosen designs ("use Postgres"). **Always ask lightly:** at least one real constraint or an explicit "none known yet." ### Secondary (only when signal demands) -- **Sequencing and relationships** — how this hangs with other work, release cohort as prose, series order. No machine relation fields. +- **Sequencing and relationships** — how this hangs with other work, series order. No machine relation fields. - **Open questions** — unresolved problem-space items that pass the [specifiability test](#open-questions-specifiability-and-hitlafk); each tagged HITL or AFK. - **Evidence of pain** — money, time, workarounds (Mom Test lens). When absent and the claim is large, challenge gently. @@ -105,13 +105,13 @@ Every question includes a recommended answer and a short rationale. The human ov Example shape: > **Who has this problem most often?** -> Recommendation: mid-size platform engineers who already run multi-service deploys — they feel config drift weekly. Rationale: your examples all came from that world; broader "all developers" would dilute the brief. +> Recommendation: mid-size platform engineers who already run multi-service deploys — they feel config drift weekly. Rationale: your examples all came from that world; broader "all developers" would dilute the narrative. ### Ordering -Prioritize answers that would rewrite the brief. Typical order: **destination pin** → problem → who → alternatives → value → constraints → sequencing/open questions. Reorder when the human's first utterance already settles an early dimension. +Prioritize answers that would rewrite the narrative. Typical order: **destination pin** → problem → who → alternatives → value → constraints → sequencing/open questions. Reorder when the human's first utterance already settles an early dimension. -Before asking, check whether reading resolves it — journal, prior Change, intake item body, BRIEF. Only ask what reading could not answer. +Before asking, check whether reading resolves it — journal, prior issue, intake item, BRIEF. Only ask what reading could not answer. ### Adaptive depth @@ -120,12 +120,12 @@ Before asking, check whether reading resolves it — journal, prior Change, inta | Crisp, specific answers | Confirm, move on; skip expand-if-needed probes | | Category answers ("developers need better tools") | Ask for a concrete story or last painful moment | | Solution-first ("I want a CLI that…") | Pause; reframe to problem and who | -| Energy dropping | Cut to synthesis; a brief with named gaps beats an exhausted interrogation | -| Direction genuinely undecided | Offer the explore technique from inside pitch; do not force a false brief | +| Energy dropping | Cut to synthesis; a narrative with named gaps beats an exhausted interrogation | +| Direction genuinely undecided | Offer the explore technique from inside pitch; do not force a false narrative | ### Scenario stress-testing -Probe the problem with **concrete lived scenarios**, not abstract categories. Prefer "walk me through the last time this bit you" over "how often does this happen?" Invent edge cases deliberately to force boundary precision: "What if the operator is on-call at 2am with half the logs missing — is that still this problem, or a different one?" Scenarios that collapse the framing expose missing constraints or a second concept that should stay out of this brief. +Probe the problem with **concrete lived scenarios**, not abstract categories. Prefer "walk me through the last time this bit you" over "how often does this happen?" Invent edge cases deliberately to force boundary precision: "What if the operator is on-call at 2am with half the logs missing — is that still this problem, or a different one?" Scenarios that collapse the framing expose missing constraints or a second concept that should stay out of this narrative. ### Challenge stance @@ -133,12 +133,12 @@ Demand specificity over generalization. Probe the rationale behind claims ("why Also enforce **canonical language** during the interview: -- Flag terms that conflict with `docs/knowledge/glossary.md` usage (e.g. calling a Change a "spec," a release cohort a "milestone," a skill a "module"). Offer the glossary term and get the human to accept the swap before it lands in the brief. +- Flag terms that conflict with `docs/knowledge/glossary.md` usage (e.g. calling an issue a "ticket," a skill a "module"). Offer the glossary term and get the human to accept the swap before it lands in the narrative. - Sharpen fuzzy project-local terms to a single canonical choice mid-interview ("you said both 'capture' and 'ticket' — pick one and stick to it"). Ambiguous vocabulary becomes solution fog later. ### Mid-interview evidence -When competitive landscape or external facts would change the brief and the human lacks them, pause the grill, delegate a researcher subagent, land evidence (change-scale: `research/` in the change folder; project-scale: links in Sources), then resume with a recommendation informed by the scan. Evidence supports framing; it does not become solution design. +When competitive landscape or external facts would change the narrative and the human lacks them, pause the grill, delegate a researcher subagent, land evidence (issue-scale: source links in the narrative; project-scale: links in Sources), then resume with a recommendation informed by the scan. Evidence supports framing; it does not become solution design. --- @@ -146,11 +146,11 @@ When competitive landscape or external facts would change the brief and the huma ### Specifiability test -An open question earns a **precise entry** in the brief only if it can be **stated precisely now** — not answered now. The bar is: a later reader (human or agent) could work the question without inventing what was meant. +An open question earns a **precise entry** in the narrative only if it can be **stated precisely now** — not answered now. The bar is: a later reader (human or agent) could work the question without inventing what was meant. | Passes (precise entry) | Fails (coarse note only) | |------------------------|--------------------------| -| "Does the operator need multi-region failover in v1, or is single-region acceptable for the first cohort?" | "Figure out reliability stuff" | +| "Does the operator need multi-region failover in v1, or is single-region acceptable until the first cut?" | "Figure out reliability stuff" | | "Which existing CLI command is the migration source of truth for config paths?" | "TBD on integration" | Everything vaguer stays a **coarse note** in Open Questions or Sequencing prose — never pre-sliced into fake precision. Do not invent enumerated options the human did not surface. @@ -164,7 +164,7 @@ Mark each precise open question with one of: | **HITL** | Needs the human live — judgment, taste, organizational call, or access only they hold | "Will legal accept the data-retention tradeoff?" | | **AFK** | Runnable by an agent without the human in the loop — research, codebase scan, competitive lookup | "What do the top three substitutes charge for the free tier?" | -Briefs carry the tag inline in the Open Questions section, e.g. `- [HITL, blocking] …` or `- [AFK, deferrable] …`. Blocking vs deferrable remains orthogonal: a HITL question can be deferrable; an AFK question can still block shaping if the answer rewrites scope. +Narratives carry the tag inline in the Open Questions section, e.g. `- [HITL, blocking] …` or `- [AFK, deferrable] …`. Blocking vs deferrable remains orthogonal: a HITL question can be deferrable; an AFK question can still block shaping if the answer rewrites scope. --- @@ -180,13 +180,13 @@ Stop interviewing when all of the following hold (or the human explicitly wants 6. **Constraints** are listed or explicitly empty. 7. Answers have stopped changing the framing — the last questions confirmed rather than rewrote. 8. Open questions that remain pass the specifiability test (or are coarse notes) and carry HITL/AFK tags when precise. -9. A cold reader could pass the brief cold-read test below. +9. A cold reader could pass the problem cold-read test below. -Open questions may remain; mark each blocking or deferrable, and HITL or AFK when precise. Blocking problem-space unknowns belong in the brief's Open Questions, not as invented answers. +Open questions may remain; mark each blocking or deferrable, and HITL or AFK when precise. Blocking problem-space unknowns belong in the narrative's Open Questions, not as invented answers. ### The pivot -Do not announce "the interview is over." Shift: "I think I have enough to draft the brief — tell me what I got wrong." Author the brief against the shared skeleton, then section-review with the human before any init or landing step. The **closing ceremony** (recap, path, next step) is owned by SKILL.md after landing — this guide owns interview depth only. +Do not announce "the interview is over." Shift: "I think I have enough to draft the problem narrative — tell me what I got wrong." Author the narrative against the shared skeleton, then section-review with the human before any mint or landing step. The **closing ceremony** (recap, path, next step) is owned by SKILL.md after landing — this guide owns interview depth only. --- @@ -196,7 +196,7 @@ Adopted from bootstrap's interview guide; binding on pitch. **The Form.** Running dimensions mechanically like a survey. If answer 2 covers dimension 4, confirm and skip. -**The 45-Minute Interrogation.** If energy drops, cut to draft. Gaps in the brief are honest; drained enthusiasm is not recoverable in the same session. +**The 45-Minute Interrogation.** If energy drops, cut to draft. Gaps in the narrative are honest; drained enthusiasm is not recoverable in the same session. **The Therapist.** Do not explore the builder's feelings about the product. User emotions (switching forces, pain) matter; builder therapy does not. @@ -212,7 +212,7 @@ Adopted from bootstrap's interview guide; binding on pitch. **Third Interview Idiom.** Do not invent pitch-specific interview machinery. Destination pinning, scenario stress-testing, and challenge stance deepen the same grilling mechanics — they are not a parallel framework. -**Pseudo-Shape in the Brief.** Approach, architecture, task breakdown, or verification design must not enter `brief.md` / `docs/BRIEF.md`. If it appears while drafting, move it out and note it for shape. +**Pseudo-Shape in the Narrative.** Approach, architecture, decomposition, or verification design must not enter the problem narrative or `docs/BRIEF.md`. If it appears while drafting, move it out and note it for shape. **Fake Precision.** Pre-slicing vague unknowns into numbered open questions that cannot yet be stated precisely. Coarse notes beat counterfeit clarity. @@ -220,9 +220,9 @@ Adopted from bootstrap's interview guide; binding on pitch. --- -## Brief Cold-Read +## Problem Cold-Read -Before offering shape-now or park, cold-read the authored brief. A stranger should name, in one pass: +Before offering shape-now or park, cold-read the authored narrative (issue-scale) or `docs/BRIEF.md` (project-scale). A stranger should name, in one pass: 1. The **destination** (or success end-state) 2. The **problem** @@ -230,4 +230,4 @@ Before offering shape-now or park, cold-read the authored brief. A stranger shou 4. The **current alternative** 5. The **value** of solving it -…and find **zero solution-space content** (no approach, stack, API shape, or task list). Precise open questions should carry HITL/AFK tags. If any of the five is missing or solution prose creeps in, revise before landing. +…and find **zero solution-space content** (no approach, stack, API shape, or implementation slices). Precise open questions should carry HITL/AFK tags. If any of the five is missing or solution prose creeps in, revise before landing. diff --git a/content/skills/refactor-deepen/SKILL.md b/content/skills/refactor-deepen/SKILL.md index 298a5b152..6eb0857d2 100644 --- a/content/skills/refactor-deepen/SKILL.md +++ b/content/skills/refactor-deepen/SKILL.md @@ -172,7 +172,7 @@ skill terminates by writing a PLAN file using [templates/plan.md](templates/plan > Plan saved to `.agents/plans/<filename>.md`. Workflow handoff is pending > the SPEC/PLAN/TASKS artifact taxonomy spec — for now, decide manually. -Do **not** recommend breakdown or implement as the next step. The +Do **not** recommend implement as the next step. The handoff design is downstream of a deferred taxonomy spec. ### Codex Review (Opt-In, Plugin-Gated) diff --git a/content/skills/refactor-deepen/templates/plan.md b/content/skills/refactor-deepen/templates/plan.md index 8be684590..0fe537186 100644 --- a/content/skills/refactor-deepen/templates/plan.md +++ b/content/skills/refactor-deepen/templates/plan.md @@ -28,8 +28,8 @@ write a new file rather than updating an existing one. | `title` | Yes | One-line description of the deepening, not the candidate name | | `created` | Yes | ISO 8601 UTC, e.g. `2026-05-02T01:30:00Z` (must match the filename timestamp) | | `status` | Yes | `drafting` on first write; this template does not define additional lifecycle states | -| `spec` | Yes | `SPEC-NNN` if the plan is scoped under a spec; `null` otherwise (do not omit the key) | -| `related` | No | List of related artifact IDs (`ADR-*`, `SPEC-*`, idea filenames, other plan filenames) | +| `issue` | Yes | `LOAF-NNN` if the plan is scoped under an issue; `null` otherwise (do not omit the key) | +| `related` | No | List of related artifact IDs (`ADR-*`, `LOAF-*`, idea filenames, other plan filenames) | PLAN files do **not** carry an `id` frontmatter field. The filename is the identity, mirroring councils and ideas. @@ -131,7 +131,7 @@ Filename: `.agents/plans/20260502-013000-deepen-journal-append.md` title: "Deepen journal append into a self-managing module" created: "2026-05-02T01:30:00Z" status: drafting -spec: SPEC-034 +issue: LOAF-34 related: - 20260501-231922-plan-lifecycle-cli-doctor-housekeeping --- diff --git a/content/skills/reflect/SKILL.claude-code.yaml b/content/skills/reflect/SKILL.claude-code.yaml index 29f822728..9b358aed4 100644 --- a/content/skills/reflect/SKILL.claude-code.yaml +++ b/content/skills/reflect/SKILL.claude-code.yaml @@ -1,3 +1,3 @@ # Claude Code skill configuration user-invocable: true -argument-hint: "[SPEC-ID or topic]" +argument-hint: "[issue ref or topic]" diff --git a/content/skills/reflect/SKILL.md b/content/skills/reflect/SKILL.md index 00cc286c5..fa56f135b 100644 --- a/content/skills/reflect/SKILL.md +++ b/content/skills/reflect/SKILL.md @@ -79,12 +79,12 @@ After completing work, reflect extracts learnings and proposes updates to strate ### Step 1: Parse Input -`$ARGUMENTS` can be: a spec ID (`SPEC-001`), a topic ("authentication learnings"), or empty (general reflection on recent work). +`$ARGUMENTS` can be: an issue ref (`LOAF-42`), a topic ("authentication learnings"), or empty (general reflection on recent work). ### Step 2: Gather Evidence Sources: -1. **Completed specs** (`.agents/specs/SPEC-*.md` with status `done`; legacy files may still read `complete`) -- look for "Lessons Learned" +1. **Completed issues** (`loaf issue list` / `loaf issue show <ref>` with status `done`) -- look for lessons in the issue body 2. **Project journal** (`loaf journal recent --json`, `loaf journal search <topic>`) -- insights, surprises, pivots 3. **Recent commits** (`git log --oneline -30`) 4. **Implementation reality** -- what was harder/easier than expected? What assumptions were wrong? diff --git a/content/skills/release/SKILL.md b/content/skills/release/SKILL.md index b1d844239..120a37cc2 100644 --- a/content/skills/release/SKILL.md +++ b/content/skills/release/SKILL.md @@ -1,32 +1,26 @@ --- name: release description: >- - Orchestrates standalone releases from already-landed work: release readiness, - version selection, changelog curation, release commit, tag, GitHub Release, - install verification, and post-release follow-up. Use when the user says - "cut a release," "publish a version," "release from main," or asks whether - enough landed work should become a release. Not for reviewing or merging a PR - (use ship). + Cuts a retroactive release from already-landed issues: loaf release suggest + reports the range, loaf release cut records the version. Use when the user + says "cut a release," "publish a version," "release from main," or asks what + landed since the last tag. Produces a recorded release row and members as + facts. Not for reviewing or merging a PR (use ship). --- # Release -Publish a coherent version from work that has already landed. +Cut a version from work that has already landed. ## Contents - Critical Rules - Verification - Quick Reference - Topics -- Context Detection -- Step 1: Release Readiness -- Step 2: Change Collection -- Step 3: Version + Changelog -- Step 4: Release Execution -- Step 5: Release-PR Flow -- Step 6: Publication Verification -- Step 7: Post-Release Follow-Up -- Hook Interaction +- Process +- Attribution +- Bump derivation +- Must-contain convention - Related Skills **Input:** $ARGUMENTS @@ -35,259 +29,227 @@ Publish a coherent version from work that has already landed. ## Critical Rules -- **Release is not merge** -- do not use release to review, approve, or land a feature PR. Use ship for PR correctness and landing. -- **Release from landed work** -- collect changes from the release base branch, normally the repo default branch, since the last release tag. -- **Release-PR flow is the default** -- prepare on a release branch with `loaf release --pre-merge`, squash-merge the release PR, then finalize with `loaf release --post-merge` on the base branch. Direct `--bump` on the base branch is a named exception used only on explicit user request. -- **Batch by intent** -- group release notes by user-facing outcome, `CR-*` change bundle, spec, or related PRs; do not mirror individual commits mechanically. -- **Keep landed and released distinct** -- a PR may be landed without being released; a release may contain multiple landed PRs. -- **Block on release-readiness failure** -- do not publish if build, tests, version files, changelog, tag, or GitHub release state is inconsistent. -- **Never push, tag, or publish without confirmation** -- present the exact actions first. -- **Use your harness's structured question tool (if it has one) for release decisions** -- version bump type, release PR handoff, push/tag/GitHub Release confirmation. -- **Log release** -- after publication, run `loaf journal log "decision(release): vX.Y.Z published from <base> with <summary>"`. +1. **Log invocation first** — `loaf journal log "skill(release): <what is being cut or suggested>"` before doing anything else. +2. **Release is not merge** — do not review, approve, or land a PR here. Verification authority is the ship workflow (PR review and CI at merge). If the user is asking to merge, stop and route to ship. +3. **A release is cut from what landed** — the surface is `loaf release suggest` and `loaf release cut`. Do not run unsubcommmanded `loaf release`, `--pre-merge`, or `--post-merge`; this skill does not own that path. +4. **Suggest writes nothing** — it reads `baseline-tag..HEAD` (or `--base <ref>..HEAD`), attributes commits to issues, rolls up through parents, reports partially-landed parents and unattributed commits as information, derives the bump, reports the advisory bucket delta, and drafts notes. +5. **Cut records facts** — it applies the version, prepends the drafted notes into `CHANGELOG.md`, tags, records the release row plus members, then attempts a GitHub Release draft. A `gh` failure degrades to a warning with a paste-ready retry command; the recorded row stays. +6. **No forward version stamp** — do not bind an issue to a future version. Members are what already landed. Buckets (`loaf issue bucket`) are advisory labels; planned-vs-landed is information only. +7. **No suite, no re-record, no publication stop in this skill** — ship already verified the merged work. Cut's operational refusals (dirty worktree, disagreeing version files, missing version, `--no-tag` without an existing tag) are command errors, not a substitute for ship. +8. **Confirm before cut** — present the suggest report (or `cut --dry-run`) first. Ask one question at a time, with a recommendation, using your harness's structured question tool if it has one. `--dry-run` previews everything and writes nothing. +9. **Log the outcome** — after a successful cut, `loaf journal log "decision(release): vX.Y.Z recorded from <base> with <summary>"`. + +--- ## Verification -- Release base branch is clean, current, and contains the intended landed PRs -- Pre-flight checks pass before versioning or publication -- Changelog entries are curated user-facing prose, not commit or PR-title dumps -- Version files, changelog heading, git tag, and GitHub Release all agree -- Tag points at the released base-branch commit or release commit, not an abandoned feature branch -- Downstream install path is verified when applicable, especially Homebrew for Loaf releases +- Journal contains the `skill(release)` invocation (and a `decision(release)` entry after a real cut) +- The work in the range already landed through ship (PR review and CI at merge); this skill did not re-verify or re-merge it +- `loaf release suggest` (or `cut --dry-run`) was shown: landed issues, partially-landed parents, unattributed commits, advisory buckets, derived bump, drafted notes +- Partially-landed parents, unattributed commits, and bucket drift were reported as information — not treated as a cut refusal +- Mutating `loaf release cut` updated version files, wrote the notes into `CHANGELOG.md`, created or reused tag `v<version>`, and recorded the release row with issue members (plus `--includes` release members when given) +- `cut --dry-run` left version files, changelog, tags, HEAD, and release rows untouched +- GitHub Release is a draft, was skipped with `--no-gh`, or failed with a warning plus a paste-ready `gh release create …` retry — never a silent rollback of the recorded row +- No issue was stamped with a future version + +--- ## Quick Reference -| Step | Gate | Blocking? | -|------|------|-----------| -| Readiness | clean/current base branch, no unresolved release collisions | Yes | -| Change Collection | landed work since last tag grouped into release themes | Yes | -| Version + Changelog | bump selected, notes curated, files updated | Yes | -| Execution | release commit prepared via `--pre-merge`, release PR landed, `--post-merge` finalizes | Yes | -| Verification | release and install paths checked | Yes | -| Follow-Up | reflect/housekeeping suggested when useful | No | +| Harness | Invoke skill | +|---------|----------------| +| Claude Code (plugin) | `/loaf:release` | +| OpenCode, Cursor, Codex, Amp | `/release` | + +### Commands + +```text +loaf release suggest [--base <ref>] [--json] +loaf release cut [--base <ref>] [--bump <type>] [--includes <version|tag>] [--no-tag] [--no-gh] [--dry-run] +loaf issue bucket <ref> now|next|later|none [--json] +loaf issue link <from> blocks|relates-to <to> [--json] +``` + +Both commands need initialized SQLite state (`loaf state init`, or `loaf state migrate markdown --apply`). They are not a git repository's optional extra — without SQLite they refuse. + +| Command | Writes? | What it does | +|---------|---------|----------------| +| `loaf release suggest` | No | Report landed work since the last version tag | +| `loaf release cut` | Yes (unless `--dry-run`) | Cut the retroactive release and record members as facts | +| `loaf release cut --dry-run` | No | Print the plan, including `--includes` rows, and write nothing | + +### `suggest` flags + +| Flag | Meaning | +|------|---------| +| `--base <ref>` | Commits since `<ref>` instead of the last tag | +| `--json` | Machine-readable suggestion | +| `-h`, `--help` | Help | + +`suggest` rejects `--dry-run` (it is already read-only) and rejects cut-only flags (`--bump`, `--includes`, `--no-tag`, `--no-gh`). + +### `cut` flags + +| Flag | Meaning | +|------|---------| +| `--base <ref>` | Commits since `<ref>` instead of the last tag | +| `--bump <type>` | Override the derived bump: `major`, `minor`, `patch`, `prerelease`, `release` | +| `--includes <version\|tag>` | Record a prior release as a member (repeatable). Use this to hang prerelease references on a stable | +| `--no-tag` | Do not create a git tag; tag `v<version>` must already exist | +| `--no-gh` | Skip the GitHub Release draft | +| `--dry-run` | Print the plan and write nothing | +| `-h`, `--help` | Help | + +`cut` rejects `--json`. `--bump prerelease` and `--bump release` only produce a version when the current version already has a prerelease suffix; otherwise cut fails with `could not compute a version to cut`. `--no-tag` is checked before `--dry-run`: the tag must already exist even for a preview. + +### Cut sequence (mutating) + +1. Recompute the same suggestion as `suggest` (then apply `--bump` if given, and redraft notes) +2. Resolve each `--includes` ref to an existing release +3. Require a clean worktree +4. Apply the version to detected version files (they must exist and agree) +5. Prepend drafted notes into `CHANGELOG.md` (after `[Unreleased]`, ahead of prior versions; creates the file if missing) +6. Commit `chore: release vX.Y.Z` +7. Unless `--no-tag`: create annotated tag `vX.Y.Z` (`git tag -a`). Signing follows git config (`tag.gpgSign`); cut never passes `-s` or `--no-sign` +8. Record the release row, issue members, and `--includes` members as facts +9. Unless `--no-gh`: `gh release create <tag> --draft --title <tag> --notes <notes>` (adds `--prerelease` when the version is a prerelease). Switches to the configured GitHub account first. On `gh` missing, account failure, or create failure: print `warning:` plus a POSIX-quoted `retry:` command; do not fail the cut + +Cut does not push the commit or the tag. + +--- ## Topics | Topic | Use When | |-------|----------| -| [Context Detection](#context-detection) | Determining release base, last tag, and current branch | -| [Release-PR Flow](#step-5-release-pr-flow) | Preparing, landing, and finalizing every release | -| [Hook Interaction](#hook-interaction) | Understanding coexistence with git hooks | +| [Process](#process) | Running suggest then cut in this conversation | +| [Attribution](#attribution) | How commits become issue members | +| [Bump derivation](#bump-derivation) | Why suggest picked major, minor, or patch | +| [Must-contain convention](#must-contain-convention) | The rare promise that named issues must land first | --- -## Context Detection - -Before anything, establish the release surface: - -1. Get current branch and repo default branch: - ```bash - git branch --show-current - gh repo view --json defaultBranchRef -q .defaultBranchRef.name - ``` -2. Parse `$ARGUMENTS` for an explicit base, tag, or version. If omitted, use the repo default branch as the release base. -3. Verify the current branch: - - If already on the release base, continue; the release-PR flow in Step 5 branches from here. - - If on a dedicated release branch, resume the release-PR flow at the matching step. - - If on a feature branch, stop and explain that release publishes from landed work. Offer ship if the active PR needs landing first. -4. Find the previous release tag: - ```bash - git describe --tags --abbrev=0 - ``` -5. Gather the candidate release range: - ```bash - git log --oneline <last-tag>..HEAD - git diff --stat <last-tag>..HEAD - ``` +## Process ---- +Parse `$ARGUMENTS` for a base, bump, version, `--includes`, `--no-tag`, `--no-gh`, or `--dry-run`. Default baseline is the last version tag; `--base` overrides. With neither a last tag nor `--base`, the range is all of `HEAD`. -## Step 1: Release Readiness - -Run release pre-flight checks before editing release files: - -1. Ensure worktree is clean: - ```bash - git status --short - ``` -2. Ensure the release base is current: - ```bash - git fetch --tags origin - git status --branch --short - ``` -3. Check for existing tag or GitHub Release collisions for the target version once known: - ```bash - git tag --list vX.Y.Z - gh release view vX.Y.Z - ``` -4. Run project checks: - - Node: `npm run typecheck`, `npm run test`, `npm run build` when scripts exist - - Go: `go vet ./...`, `go test ./...` when `go.mod` exists - - Python: `pytest`, `mypy .`, `ruff check .` when configured - - Rust: `cargo check`, `cargo test` when `Cargo.toml` exists - -If no checks are detected, warn explicitly. If a check fails, stop and fix before release. +### Step 1: Log and route ---- +```bash +loaf journal log "skill(release): <what is being cut or suggested>" +``` -## Step 2: Change Collection - -Collect landed work since the last release and group it for release notes. - -1. Inspect commits: - ```bash - git log --first-parent --oneline <last-tag>..HEAD - git log --oneline <last-tag>..HEAD - ``` -2. Inspect merged PRs when GitHub is available: - ```bash - gh pr list --state merged --base <base> --json number,title,mergedAt,url - ``` -3. Group changes by user-facing outcome: - - `CR-*` change bundle, when referenced - - spec or task family, when public enough to be useful - - feature/fix/documentation/build themes - - operational release work, when it affects users or maintainers -4. Drop noise: - - purely internal task labels - - reverted work that is not present in `HEAD` - - individual commit mechanics that collapse into one user-facing change - -Present the grouped release contents before choosing the bump. +If the user wants a PR reviewed or merged, stop and use ship. If the work is still on a feature branch, explain that a release is cut from landed `HEAD` since the baseline, and offer ship. ---- +### Step 2: Suggest -## Step 3: Version + Changelog - -Choose the bump and curate the changelog from the grouped landed work. - -1. Run a dry run: - ```bash - loaf release --dry-run - ``` - Use `--base <ref>` when the project expects a non-default release base. -2. Present: - - current version - - proposed next version - - detected version files - - release actions the CLI would perform - - draft changelog entries -3. Curate `CHANGELOG.md` before publishing: - - write from the upgrading user's perspective - - group under Common Changelog categories: `Changed`, `Added`, `Removed`, `Fixed` - - use one self-describing line per meaningful change - - include public PR, issue, ADR, release, or commit links when helpful - - avoid dumping commit subjects, task IDs, session mechanics, or internal gate language -4. Confirm the bump type: `prerelease`, `release`, `major`, `minor`, or `patch`. +```bash +loaf release suggest +# or +loaf release suggest --base <ref> +loaf release suggest --json +``` ---- +Present the report as-is: base, suggested bump and version, bump evidence, landed issues with commits, partially-landed parents (missing children), unattributed commits, advisory buckets (planned landed / planned not landed / unplanned landed), drafted notes. -## Step 4: Release Execution +Do not hide partial parents or unattributed commits, and do not refuse the cut because of them unless the operator is using the [must-contain convention](#must-contain-convention) and wants to wait. -Every release routes through the release-PR flow in Step 5: prepare the release commit on a release branch with `loaf release --pre-merge`, land the release PR, then finalize with `loaf release --post-merge` on the base branch. +### Step 3: Confirm -Release preparation should: +Show the exact `loaf release cut …` you would run. Recommend cutting the derived version when the landed set matches what the operator asked for. Use `--bump` only when they override. Use `--dry-run` when they want a preview: -1. Update version files -2. Convert `[Unreleased]` into `## [X.Y.Z] - YYYY-MM-DD` -3. Reinsert a fresh empty `[Unreleased]` section -4. Run configured release artifact commands -5. Create the release commit +```bash +loaf release cut --dry-run +loaf release cut --dry-run --no-gh +loaf release cut --dry-run --includes <version|tag> +``` -After preparation, verify generated artifacts are current: +### Step 4: Cut ```bash -npm run build -git diff --exit-code -- dist plugins content/skills/loaf-reference/SKILL.md +loaf release cut +loaf release cut --bump minor +loaf release cut --includes v1.1.0-alpha.1 +loaf release cut --no-tag --no-gh ``` -Adjust the path list to the project. For Loaf itself, tracked generated outputs under `dist/`, `plugins/`, and native binaries must match the source changes. +On success, report version files updated, changelog written, tag created or reused, release recorded (member count), and GitHub draft created / skipped / warned. If stderr has `retry:`, paste that command; the row is already recorded. -Capability receipts pin artifact SHA-256s, and the release rebuild version-stamps generated artifacts (`dist/opencode/plugins/hooks.ts` embeds `@version`, so every version bump stales the OpenCode receipt; Go changes additionally stale all binary-pinned receipts via `bin/native`). Therefore re-recording runs AFTER `loaf release --pre-merge` completes its artifact rebuild, on the release branch, before pushing the release PR — never before the bump. Verify with `go test ./internal/cli -run TestTargetCapabilityEvidence`. `loaf release` now enforces this mechanically on every mutating path (post-rebuild refusal in apply, guardrail 9 in `--post-merge`) — the rule explains WHY the gate fires; the gate makes skipping it impossible. +```bash +loaf journal log "decision(release): vX.Y.Z recorded from <base> with <summary>" +``` -### Direct Release (Named Exception) +### Step 5: After -`loaf release --bump <type> --yes` on the base branch prepares, commits, tags, and publishes in a single shot. Use it only when the user explicitly requests a direct release; never select it by default. Skipping the release PR means nothing runs the suite against the prepared tree before the tag exists — the v0.2.16 cut took this door and a capability-evidence canary surfaced only in tag CI, after publication. The same day, v0.2.17 re-recorded evidence minutes before the version bump; the release commit staled it, and the tag again published zero assets — ordering, not diligence, is the failure mode. The CLI prints a flow advisory when a mutating release starts on the default branch; treat it as a routing signal, not noise. +Suggest reflect when the cut produced durable product or workflow learnings, and housekeeping when temporary artifacts need cleanup. Capture leftover discoveries as issues or sparks — not as extra changelog lines. --- -## Step 5: Release-PR Flow +## Attribution -The default for every release: PR CI runs the full suite against the prepared tree, so evidence canaries surface before any tag or GitHub Release exists. This holds regardless of repository settings — where branch protection is enabled it is satisfied as a side effect, not the reason for the flow. +`suggest` (and `cut`, which recomputes the same suggestion) attributes each commit in the range to zero or more issues, then rolls up through parents. -1. Create a dedicated release branch from the release base. -2. Run `loaf release --pre-merge` on it: this creates the version/changelog/artifact release commit but no tag and no GitHub Release. -3. Open a release PR with a concise release-focused body. -4. Hand the PR to ship for review and landing; squash-merge it into one `chore: release vX.Y.Z (#PR)` commit carrying the curated changelog. -5. After the release PR lands, run `loaf release --post-merge` on the base branch to tag, publish the GitHub Release, and verify installability. +**Commit → issue**, first match wins: -If guardrail 9 fires on `--post-merge`, the merged tree itself carries stale evidence; recovery is to re-record against the merged tree, land the receipts as a single evidence-only commit on the base branch (the repair commit must not modify the capability registry), and rerun `loaf release --post-merge`. +1. Issue alias (`PREFIX-N`, e.g. `LOAF-42`) in the subject or body (prefix case-sensitive). URLs and code spans are stripped first. +2. Else the merge/branch rung: aliases in a `Merge …` subject, plus any alias anywhere in the body (case-insensitive). Squash subjects like `feat: add auth (#42)` often carry the alias only in the body. No network, no `gh`. An alias that lived only on a deleted branch name is unattributable. +3. Else a unique journal `commit(<hash>)` row whose message contains an alias (scope uniquely matching that commit). -Do not hide this handoff inside release: ship remains the PR correctness and merge gate. +Resolved aliases become **landed** issues (with the commits that named them). Commits that match nothing are **unattributed** — listed, and included under drafted notes as `### Unattributed`. They do not block the cut. ---- +**Parent rollup** (information): -## Step 6: Publication Verification +- For each landed issue that has a parent, if any sibling child is not `done`, the parent is **partially landed** and the missing children are listed. +- A parent is not auto-added to landed unless a commit attributed to it. -After publishing, verify the public release state: +Drafted notes are `## [version] - YYYY-MM-DD`, then one `### ALIAS — title` section per landed issue with commit subjects, then unattributed. -1. Confirm tag location: - ```bash - git show --stat vX.Y.Z - ``` -2. Confirm GitHub Release: - ```bash - gh release view vX.Y.Z - ``` -3. Confirm package or installer availability when applicable: - - npm: `npm view <package> version` - - Homebrew: `brew update && brew info <tap>/<formula>` - - project-specific deploy or artifact registry checks -4. For Loaf/Homebrew, report readiness only after the GitHub release exists, assets are uploaded, the tap formula is updated, and tap CI has passed. +--- -If publication partially completes, do not retag casually. Name the exact state and continue with the smallest repair or patch release path. +## Bump derivation ---- +Derived from the range, in order: -## Step 7: Post-Release Follow-Up +| Condition | Bump | +|-----------|------| +| Breaking marker (`type!:` in the subject, or `BREAKING CHANGE:` / `BREAKING-CHANGE:` in the body) | `major` | +| A **done** parent with **two or more** children, every child `done` and landed, and the parent's done timestamp **after** the baseline tag's committer time | `minor` (closed multi-child parent fully landed) | +| Else a conventional `feat` commit | `minor` | +| Else | `patch` (`fix` / other) | -After verification: +`--bump` on `cut` replaces the derived bump and redrafts notes; the evidence string becomes `overridden by --bump <type>`. -1. Log the release decision to the project journal: - ```bash - loaf journal log "decision(release): vX.Y.Z published from <base> with <summary>" - ``` -2. Suggest reflect when the release produced durable product or workflow learnings. -3. Suggest housekeeping when release branches or temporary reports need cleanup. -4. Keep future-work discoveries out of the release notes; capture them as tasks, ideas, or sparks instead. +Current version comes from agreeing version files, else from a semver last tag. Cut still requires version files on the mutating path. --- -## Hook Interaction +## Must-contain convention -This skill coexists with existing hooks. Git workflow hooks are advisory unless -configured otherwise; security and secret-scanning hooks remain blocking. +Rare. When the operator needs a named set of issues to land before a cut, create a **release-prep** issue and express `blocked_by` edges with issue mechanics: + +```bash +loaf issue new "Release prep for vX.Y.Z" --body "Must contain LOAF-12 and LOAF-15. Out of scope: the cut itself." +loaf issue link LOAF-12 blocks LOAF-99 +loaf issue link LOAF-15 blocks LOAF-99 +``` -| Hook | Type | When release Runs | -|------|------|---------------------| -| `github-account` | Force-switch | Switches to the configured GitHub account before `gh` release operations; blocks only if the switch fails | -| `validate-push` | Advisory | Cross-checks version bump, changelog, and build on push | -| `workflow-pre-pr` | Advisory | Fires when the release PR is opened | -| `workflow-pre-merge` | Advisory | Belongs to ship when a release PR must land | -| `workflow-post-merge` | Advisory | Belongs to ship after PR landing | -| `check-secrets` | Blocking | Always respected before writes or shell actions | +Stored write types are `blocks` and `relates_to`. `loaf issue link <must-land> blocks <release-prep>` is how you record that the prep issue is blocked by those that must land. `loaf issue frontier` and implement honor `blocks`. **`loaf release suggest` and `cut` do not read these edges** — convention, not schema. If the operator wants to wait, wait; if they cut anyway, cut records whatever actually landed. -Do not disable hooks to force a release through. +Buckets stay labels: ---- +```bash +loaf issue bucket LOAF-12 now +loaf issue bucket LOAF-12 none +``` -## Suggests Next +`suggest` prints `bucket:<name> ALIAS — title (landed|not landed)` and `unplanned ALIAS — title (landed)`. Never treat that delta as a constraint. -After a successful release, suggest reflect for durable learnings and housekeeping if temporary release artifacts need attention. +--- ## Related Skills -- **ship** -- Reviews, verifies, and lands a PR before it becomes release input -- **git-workflow** -- Branching, PR, commit, and squash merge conventions -- **documentation-standards** -- Changelog and release-note quality -- **reflect** -- Updates strategy from shipped/released learnings -- **housekeeping** -- Cleans up completed spec, report, and handoff artifacts +- **ship** — Reviews, verifies, and lands a PR. That merge is the verification authority for what this skill may later cut +- **git-workflow** — Branching, PRs, and any later push of the local tag (cut does not push) +- **documentation-standards** — Changelog prose if a human edits notes after the cut +- **reflect** — Durable learnings after a cut +- **housekeeping** — Cleanup of temporary artifacts diff --git a/content/skills/research/SKILL.md b/content/skills/research/SKILL.md index 9cd721895..f82c38419 100644 --- a/content/skills/research/SKILL.md +++ b/content/skills/research/SKILL.md @@ -93,7 +93,7 @@ Always check project context first. Rate findings: **High** (official/verified), **Trigger:** Empty input, "project state", "catch me up" 1. Read project documents: VISION.md, STRATEGY.md, ARCHITECTURE.md -2. Check ideas with `loaf idea list --json` and specs with `loaf spec list --json` +2. Check ideas with `loaf idea list --json` and issues with `loaf issue list --json` (or `loaf issue export` for the full graph) 3. Review recent journal activity with `loaf journal recent --json` and `loaf journal context` 4. Check recent commits: `git log --oneline -20` 5. Synthesize following [state-assessment template](templates/state-assessment.md) @@ -143,4 +143,4 @@ User-facing entry for a new concept is pitch (problem-discovery brief). Do not t ## Artifact Naming -Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or Change already records that provenance. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. +Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or the issue already records that provenance. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. diff --git a/content/skills/research/templates/report.md b/content/skills/research/templates/report.md index 2ff4c059c..f7cc5d7dd 100644 --- a/content/skills/research/templates/report.md +++ b/content/skills/research/templates/report.md @@ -14,7 +14,7 @@ title: "Report: [Topic]" type: research | audit | analysis | council created: YYYY-MM-DDTHH:MM:SSZ status: draft | done | archived -source: SPEC-XXX | TASK-XXX | ad-hoc +source: LOAF-42 | ad-hoc tags: [] --- diff --git a/content/skills/research/templates/state-assessment.md b/content/skills/research/templates/state-assessment.md index 65e426f3b..359392033 100644 --- a/content/skills/research/templates/state-assessment.md +++ b/content/skills/research/templates/state-assessment.md @@ -24,7 +24,7 @@ tags: [] - **Vision:** [Brief summary] - **Key personas:** [Who we're building for] -- **Current focus:** [Active specs/work] +- **Current focus:** [Active issues] ## Recent Progress @@ -32,10 +32,10 @@ tags: [] ## In Flight -| Spec/Task | Status | Notes | -|-----------|--------|-------| -| SPEC-001 | implementing | [progress] | -| SPEC-002 | approved | [next up] | +| Issue | Status | Notes | +|-------|--------|-------| +| LOAF-1 | active | [progress] | +| LOAF-2 | todo | [next up] | ## Ideas Pipeline diff --git a/content/skills/shape/SKILL.md b/content/skills/shape/SKILL.md index e3561d2c7..4bf1f425d 100644 --- a/content/skills/shape/SKILL.md +++ b/content/skills/shape/SKILL.md @@ -1,22 +1,20 @@ --- name: shape description: >- - Shapes messy input into a bounded, reviewable Change under docs/changes/YYYYMMDD-slug/ - (change.json + shape.md + tasks/), validated by loaf change check. Runs a fog-routed - narrowing protocol — gather context, optional blindspot pass, grilling, reaction artifacts — - seeds task-file vertical slices, runs a critique gate, and offers an opt-in draft PR. - Use when the user asks "shape this," "turn this into a Change," or an idea has enough - constraints to bound. Produces role-named narrative (shape.md required; brief/plan/design - optional) plus task packets — never a numbered spec. Teaches the problem-boundary test - (same problem → another task; different problem → Intent) and vertical-slice discipline. - Not for quick capture (use idea), problem discovery that should author a brief - first (use pitch), or open-ended divergent thinking (agent technique: explore / - brainstorm — user entry intent routes to pitch). + Shapes messy input into a bounded issue — problem body, definition-of-done + criteria, out-of-scope statement, and children when a criterion earns its own + DoD — validated by loaf issue check. Use when the user asks "shape this," + "turn this into an issue," or a diagnosed fix needs a row. Produces a shaped + issue — never a folder or a plan document. Teaches fog graduation (park, then + a decision child) and one-criterion sizing (one fresh context window, + verifiable alone). Not for quick capture (use idea), problem discovery that + should author a brief first (use pitch), or open-ended divergent thinking + (agent technique: explore / brainstorm — user entry routes to pitch). --- # Shape -Turn messy input into a bounded, reviewable Change. +Prepare a bounded, reviewable issue. ## Contents - Critical Rules @@ -32,29 +30,30 @@ Turn messy input into a bounded, reviewable Change. ## Critical Rules -1. **Log invocation first** — `loaf journal log "skill(shape): <input being shaped>"` before doing anything else. -2. **Produces a Change, never a spec** — `change.json` + `shape.md` (+ optional `brief.md`/`plan.md`/`design.md`) and `tasks/TASK-NNN-slug.md`. No sequentially-numbered spec file, no status-like fields anywhere. +1. **Log invocation first** — `loaf journal log "skill(shape): shaping <topic> into LOAF-42"` before doing anything else. If no issue exists yet, log `skill(shape): shaping <topic>` and add the alias in the outcome entry. +2. **Produces an issue, never a folder** — the deliverable is the issue row: problem in the body, definition of done as `loaf issue dod` criteria, an explicit out-of-scope statement in the body, children via `loaf issue promote` when a criterion earns its own DoD. No plan document is committed. The PR body, if a PR is opened, is `loaf issue render` output. 3. **The fog register routes, you don't guess** — every named unknown carries a quadrant tag that dispatches it to exactly one technique (see Quick Reference). Technique-by-vibes is the failure mode this replaces. -4. **Blindspot pass precedes grilling, offered not imposed** — run it when the territory is unfamiliar; skip it when the shaper is the domain expert. Never impose it as a mandatory step. -5. **Grill one question at a time, with a recommendation** — using your harness's structured question tool if it has one (otherwise one inline question per message — same semantics). Never a form to fill in one pass. Order by architectural impact: questions whose answer would change the architecture go first, cosmetic questions last. -6. **Techniques return fog entries, not decisions** — blindspot passes, grilling, and reaction artifacts hand back named unknowns and evidence for the human to adjudicate. Reconnaissance, not orders. -7. **Own the decomposition** — decide Implementation Unit boundaries and granularity autonomously (absorbed from the retired breakdown step); ask only when two orderings carry genuinely different trade-offs. -8. **Order units by likelihood-of-change** — data models, interfaces, and user-facing flows lead; mechanical work collapses at the bottom, so review attention lands on what's most likely to need changing. -9. **Surface misalignment, never silently adjust** — when the idea conflicts with strategic docs, prior Changes, or the journal, tell the user and let them decide. Don't quietly reshape their idea. -10. **Critique before finalizing** — run the Critique Gate as the last shaping step, before `loaf change check` and the PR offer. -11. **Get approval before `loaf change init`** — don't scaffold the folder without explicit confirmation of scope. -12. **Log the outcome** — `loaf journal log "decision(shape): <slug> shaped — N units, N open fog entries"`. +4. **Fog graduates instead of evaporating** — a question not yet sharp enough is parked in the issue's `fog` field (`loaf issue new --fog`). When it sharpens it becomes a `--kind decision` child, which is ready when it poses a sharp question (a `?` in the title or body). No plan required. +5. **Blindspot pass precedes grilling, offered not imposed** — run it when the territory is unfamiliar; skip it when the shaper is the domain expert. Never impose it as a mandatory step. +6. **Grill one question at a time, with a recommendation** — using your harness's structured question tool if it has one (otherwise one inline question per message — same semantics). Never a form to fill in one pass. Order by architectural impact: questions whose answer would change the architecture go first, cosmetic questions last. +7. **Techniques return fog entries, not decisions** — blindspot passes, grilling, and reaction artifacts hand back named unknowns and evidence for the human to adjudicate. Reconnaissance, not orders. +8. **Decomposition is the tail** — a parent gets children only when its DoD needs more than one coherent slice. A criterion becomes a child the moment it earns its own DoD, via `loaf issue promote`. Own those boundaries autonomously; ask only when two orderings carry genuinely different trade-offs. +9. **One sizing criterion** — a slice is right-sized when it fits one fresh context window and is verifiable alone. Expand–contract is the named exception for wide mechanical refactors. See [references/decomposition.md](references/decomposition.md). +10. **Surface misalignment, never silently adjust** — when the idea conflicts with strategic docs, prior issues, or the journal, tell the user and let them decide. Don't quietly reshape their idea. +11. **Critique before finalizing** — run the Critique Gate as the last shaping step, before `loaf issue check`. +12. **A diagnosed one-line fix is two commands** — `loaf issue new` with a body that states the problem and `Out of scope: …`, then one `loaf issue dod add`. No problem-space ceremony. Confirm scope with the user before `loaf issue new` on anything larger. +13. **Log the outcome** — `loaf journal log "decision(shape): LOAF-42 shaped — N children, N open fog entries"`. --- ## Verification -- `docs/changes/YYYYMMDD-slug/` has `change.json` + `shape.md` with Product Contract sections non-empty; task packets seeded under `tasks/` when decomposition is known -- Every Open Questions entry carries a quadrant tag (`[KU]`, `[UK]`, or `[UU]`) and a route -- `loaf change check` reports zero violations (no legacy deprecation on new layout); executability gaps were read, not ignored -- Problem-boundary test applied: discovered different problems become Intents, not TASK-007 -- The Critique Gate ran, and its answers changed the documents where they applied -- No status-like fields in `change.json` or task frontmatter +- The issue body states the problem and contains an explicit out-of-scope statement (`out of scope`, case-insensitive — that substring is what `loaf issue check` reads) +- At least one definition-of-done criterion exists; V-tier criteria carry `--command` (and `--expect` when the check is more than exit 0); H-tier otherwise +- Every open unknown is either parked in create-time `fog`, held in the session register until it sharpens, graduated to a `--kind decision` child (or sibling) with a sharp question, or written into the body as a decided answer +- `loaf issue check <ref>` reports the issue shaped (delivery) or ready (decision). When children exist, coverage failures were fixed and containment orphans were filed as sibling backlog issues using the printed remedy +- Problem-boundary test applied: a discovered different problem becomes a new backlog issue, not another criterion on this one +- The Critique Gate ran, and its answers changed the issue where they applied --- @@ -62,34 +61,46 @@ Turn messy input into a bounded, reviewable Change. ### Fog register format -Open Questions entries take one of three forms: +Open unknowns take one of three forms. Keep the register in the session. Park what is still unsharp in `--fog` at create; after create, unsharp entries stay in the session register (edit cannot mutate `fog`). Graduate what is sharp to a decision child or sibling, and write decided answers into the body. ```text -- [KU] <the unknown> → <route: grilling | research spike | owner section> -- [UK] <the recognize-it-when-seen criterion> → reaction artifact in research/ +- [KU] <the unknown> → <route: grilling | research spike | owner> +- [UK] <the recognize-it-when-seen criterion> → reaction artifact - [UU] <the suspected blind area> → blindspot pass over <territory> ``` -An entry resolves by becoming a Decision, a Planning Contract subsection, or a named follow-up — visible in the diff, never silently deleted. A `[UU]` that gets named becomes a `[KU]` or `[UK]` and re-routes through the table below. +An entry resolves by becoming a decision child, a body paragraph, a criterion, or remaining parked in `fog` — visible on `loaf issue show`, never silently deleted. A `[UU]` that gets named becomes a `[KU]` or `[UK]` and re-routes through the table below. ### Quadrant routing | Tag | Meaning | Routes to | |-----|---------|-----------| | `[KU]` known unknown | A question you can state precisely | [Grilling](references/grilling.md) (architecture-changing answers first) or a research spike | -| `[UK]` unknown known | You'd recognize the right answer if you saw it, but can't state it yet | [Reaction artifact](references/reaction-artifact.md) — a variant or mock in `research/`, react and pick | +| `[UK]` unknown known | You'd recognize the right answer if you saw it, but can't state it yet | [Reaction artifact](references/reaction-artifact.md) — a variant or mock, react and pick | | `[UU]` suspected blind spot | Unfamiliar territory; you don't yet know what you don't know | [Blindspot pass](references/blindspot-pass.md) | -No route names a skill invocation. Research re-interviews an already-scoped question and writes to `.agents/reports/`; brainstorm forces a strategic frame onto a Change-local question and sends resolutions to intake. Shape runs all three techniques itself, in-session, and writes evidence into the Change's own `research/` — never `.agents/reports/`. +No route names a skill invocation. Research re-interviews an already-scoped question; brainstorm forces a strategic frame onto an issue-local question and sends resolutions to intake. Shape runs all three techniques itself, in-session, and writes the captured answer onto the issue — never into `.agents/reports/`. ### Defined terms -- **Rabbit holes** — tempting expansions of scope that would consume disproportionate effort for marginal value; name them so nobody wanders in unknowingly. -- **No-gos** — approaches explicitly forbidden for this Change, stated so they aren't silently reconsidered mid-implementation. +- **Rabbit holes** — tempting expansions of scope that would consume disproportionate effort for marginal value; name them in the out-of-scope statement so nobody wanders in unknowingly. +- **No-gos** — approaches explicitly forbidden for this issue, stated so they aren't silently reconsidered mid-implementation. ### Source inputs recognized -Step 1 reads whatever `$ARGUMENTS` names, or asks. Recognized sources: a change `brief.md` (from pitch or capture), a journal entry (cite by ID), a spark, an idea, a brainstorm document, a Linear issue, a PR conversation, a prior Change, or plain conversation with no artifact behind it yet. +Step 1 reads whatever `$ARGUMENTS` names, or asks. Recognized sources: a brief from pitch, a journal entry (cite by ID), a spark, an idea, a brainstorm document, a Linear issue, a PR conversation, a prior issue, or plain conversation with no artifact behind it yet. + +### One-line entry + +A diagnosed fix that already has a problem and a done-check: + +```bash +loaf issue new "Fix missing --json in list help" --body "issue list --help omits --json. Out of scope: rewriting other help pages." +loaf issue dod add LOAF-42 "issue list help names --json" --command "loaf issue list --help" --expect "contains \`--json\`" +loaf issue check LOAF-42 +``` + +Two writes, then the readiness verdict. No grilling, no children, no files. --- @@ -97,53 +108,86 @@ Step 1 reads whatever `$ARGUMENTS` names, or asks. Recognized sources: a change ### Step 1: Gather Context -Parse `$ARGUMENTS` against the source inputs above. When the input names a Change folder that already has `brief.md` (or you find one for this work), treat the brief as primary: restate the problem from it, confirm with the user rather than re-discovering, and keep later grilling on solution-space (how, boundaries, verification) — pitch already framed the problem. When no brief exists, run full narrowing as today; pitch is the recommended front door for raw concepts, never a gate. Read the journal (`loaf journal recent` / `search`) for related history, and check for a prior Change touching the same area. When VISION.md, STRATEGY.md, and ARCHITECTURE.md exist, read them for strategic fit. Most consumer projects don't have them yet — when absent, shape against the journal, recent Changes, and the conversation instead, and say so in the Change's Source Inputs. +Parse `$ARGUMENTS` against the source inputs above. When a brief from pitch already frames the problem, restate it, confirm with the user rather than re-discovering, and keep later grilling on solution-space (how, boundaries, verification). When no brief exists, run full narrowing; pitch is the recommended front door for raw concepts, never a gate. Read the journal (`loaf journal recent` / `search`) for related history, and check for a prior issue touching the same area (`loaf issue list`, `loaf issue tree`). When VISION.md, STRATEGY.md, and ARCHITECTURE.md exist, read them for strategic fit. Most consumer projects don't have them yet — when absent, shape against the journal, recent issues, and the conversation instead, and say so in the issue body. ### Step 2: Evaluate Strategic Fit -When strategic docs exist, check: does this advance the vision, serve the target personas, fit technical constraints, avoid conflicting with in-flight Changes? On misalignment, **surface it to the user — never silently adjust the idea**. The user decides whether to proceed, narrow scope, or defer to reflect after this ships. +When strategic docs exist, check: does this advance the vision, serve the target personas, fit technical constraints, avoid conflicting with in-flight issues? On misalignment, **surface it to the user — never silently adjust the idea**. The user decides whether to proceed, narrow scope, or file the conflicting concern as its own backlog issue. -### Step 3: Name the Change and Initialize +### Step 3: Name the Issue and Write the Row -Once the shape of the work is nameable, confirm scope with the user, then: +Once the work is nameable, confirm scope with the user (skip this confirmation on the one-line path), then create the row. Prefer creating after the first narrowing pass so `--fog` can carry remaining unsharp questions — the CLI writes `fog` only at create. ```bash -loaf change init <slug> +loaf issue new "Rotate auth tokens on a sliding window" \ + --body "Sessions never expire while the tab stays open, so a stolen cookie is valid indefinitely. + +Out of scope: migrating existing sessions; third-party IdP support." \ + --fog "[KU] sliding-window length → grill; [UU] existing session-store conventions → blindspot pass" ``` -On a fresh slug this scaffolds `change.json` + `shape.md` + seeded `tasks/` from the embedded templates (see `templates/shape.md`, `templates/task.md`). On a capture-only folder that already has `change.json` + `brief.md` (from pitch or `init --brief`), the same command promotes in place — preserving brief and metadata verbatim while materializing `shape.md` and `tasks/` — never hand-copy templates into the folder; rely on that promotion path. Use `loaf change init <slug> --brief` only for capture-before-shape (emits `change.json` + `brief.md`). It does not switch branches — `git switch -c <slug>` yourself. Fill `shape.md` Product Contract sections as understanding solidifies; seed `tasks/TASK-NNN-slug.md` as vertical slices (a task is a commit, not a PR). Optional `plan.md`/`design.md` accrete when the how needs prose. See [references/cli-boundary.md](references/cli-boundary.md). +Default kind is `delivery`; default status is `triage`. `--status` accepts `triage`, `backlog`, `todo`, `active`, or `done`. Use `--body -` or `--body-file <path>` for a longer body; `loaf issue edit <ref>` later **replaces** the body, it does not patch it. + +A delivery issue is shaped when the body is nonempty (the problem), at least one criterion exists, and the body contains an explicit out-of-scope statement. Fill those as understanding solidifies — create can carry the first body; criteria come next. + +A discovered different problem is a new backlog issue, not a child of this one: + +```bash +loaf issue new --status backlog "Rewrite the session store" +``` ### Step 4: Narrow the Unknowns -Offer the blindspot pass when the territory is unfamiliar (a new domain, an unfamiliar subsystem, a first collaboration) — skip it when the shaper is the expert. Its fog entries, and any others surfaced in interview, route by quadrant (Quick Reference above). Loop: grill `[KU]` entries, react to `[UK]` entries, run blindspot reconnaissance on `[UU]` entries until each gets a name and re-routes. Stop grilling when no unrouted `[KU]` entries remain or answers stop changing the contract. Entries still open at the end of the session are fine — each names its owner (a section, a spike, a follow-up). +Offer the blindspot pass when the territory is unfamiliar (a new domain, an unfamiliar subsystem, a first collaboration) — skip it when the shaper is the expert. Its fog entries, and any others surfaced in interview, route by quadrant (Quick Reference above). Loop: grill `[KU]` entries, react to `[UK]` entries, run blindspot reconnaissance on `[UU]` entries until each gets a name and re-routes. Stop grilling when no unrouted `[KU]` entries remain or answers stop changing the issue. -### Step 5: Decompose into Implementation Units +When a parked question sharpens, graduate it — after the parent's DoD is written (Step 5). Attaching **any** child, including a decision, turns coverage on. -Absorbed from the retired breakdown step — see [references/decomposition.md](references/decomposition.md) for the Right Size Test and per-unit verification discipline, including the V-tier `Command:` / `Expect:` forms `loaf change verify` parses (commands run from the repository root; H-tier is never gate input). Order units by likelihood-of-change; state real sequencing constraints in prose, never by list order alone. +```bash +loaf issue new --kind decision --parent LOAF-42 "Should tokens live in httpOnly cookies?" +``` + +A decision issue is ready when the title or body contains `?`. It needs no criteria and no out-of-scope statement. A decision child does not claim a parent criterion, so promote (or otherwise claim) the parent's DoD before adding children, or keep the decision as a sibling (`loaf issue new --kind decision --status backlog`, no `--parent`) if the parent stays a leaf. Unsharp questions discovered after create stay in the session register until they graduate — there is no `--fog` on edit. See [references/decomposition.md](references/decomposition.md). + +### Step 5: Write Definition of Done (decomposition tail) + +Add criteria as the interrogation produces observable done-checks. V-tier when a command can disagree with the implementation; H-tier when only a human can tell. + +```bash +loaf issue dod add LOAF-42 "Sliding-window expiry is covered by tests" --command "go test ./internal/auth/..." --expect "exit 0" +loaf issue dod add LOAF-42 "Stolen-cookie writeup is reviewable" --tier H +``` + +`--command` implies V unless `--tier` overrides. `--expect` uses the verify grammar (`exit <N>`, `` contains `text` ``, joined by ` and `). Commands run from the repository root. See [references/cli-boundary.md](references/cli-boundary.md) and [references/decomposition.md](references/decomposition.md). + +A parent gets children only when its DoD needs more than one coherent slice. The moment a criterion earns its own DoD, promote it — the parent criterion stays, the child starts with a copy, and the claim is recorded so coverage holds for that position: -### Step 6: Fill the Planning Contract +```bash +loaf issue promote LOAF-42 1 +``` -Write the free-form `###` subsections the work actually needs (approach, placement, risks, sequencing) inside the Planning Contract container. Its subsection names are yours; the container itself, plus Implementation Units, Verification Contract, and Definition of Done, is what `loaf change check` looks for. Durable Outputs stays forward-looking here — name what a final spec, ADR, or knowledge doc will need to capture, but don't write it now. Durable artifacts get created after implementation proves what's true, not during shaping. +Then shape the child the same way (body, out-of-scope, its own criteria). Order children by likelihood-of-change when presenting them; state real sequencing with `loaf issue link <from> blocks <to>`, never by tree order alone. -### Step 7: Run the Critique Gate +### Step 6: Run the Critique Gate -Before finalizing, challenge the draft — see [references/critique-gate.md](references/critique-gate.md). Is scope still bounded, does every new command or state name its ceremony, is a status field creeping back in under another name, is the CLI/skill boundary drawn correctly, and could this be smaller and still deliver the Hypothesis? +Before finalizing, challenge the draft — see [references/critique-gate.md](references/critique-gate.md). Is scope still bounded, does every new command or state name its ceremony, is a second progress flag creeping into the body, is the CLI/skill boundary drawn correctly, and could this be smaller and still be verifiable in one fresh context window? -### Step 8: Validate +### Step 7: Validate ```bash -loaf change check +loaf issue check LOAF-42 ``` -Read violations (always block — fix them) separately from the executability report (derived, informational unless `--require-executable` is passed — that flag is implement's preflight and CI's non-draft gate, not shape's business). See [references/cli-boundary.md](references/cli-boundary.md). +A delivery issue that passes prints `issue LOAF-42 is shaped`; a decision issue prints `issue LOAF-42 is ready`. Failures always block (missing body, missing criterion, missing out-of-scope, no sharp question, uncovered parent criterion). Containment orphans are reported, not failed: each line includes a ready-to-paste remedy that files the orphan as a sibling backlog issue — run that command, do not invent a different disposition. + +`loaf issue verify <ref>` runs V-tier commands from the repository root and writes nothing. That is implement's preflight, not shape's gate. See [references/cli-boundary.md](references/cli-boundary.md). -### Step 9: Offer the Draft PR +### Step 8: Offer the Review Surface -Offer to push the branch and open a draft PR, using [the PR template](templates/pr.md) — opt-in, never automatic. `loaf change check` (with no `--require-executable`) plus `gh pr list` is the cross-branch index either way. +The issue lives in SQLite. There is no folder to commit and nothing plan-shaped to land. Offer `loaf issue show <ref>` and `loaf issue tree <ref>` as the review surface. If a PR is being opened for the work, its body is `loaf issue render <ref>` — paste-ready, no manual editing. Opt-in, never automatic. -### Step 10: Log the Outcome +### Step 9: Log the Outcome -`loaf journal log "decision(shape): <slug> shaped — N units, N open fog entries"`. +`loaf journal log "decision(shape): LOAF-42 shaped — N children, N open fog entries"`. --- @@ -151,8 +195,8 @@ Offer to push the branch and open a draft PR, using [the PR template](templates/ - **pitch** — Problem-discovery ceremony that authors a brief; preferred front door when the problem is not yet framed - **idea** — Quick capture; feeds into pitch or shape once a concept has enough weight -- **brainstorm** — Agent technique for divergent thinking (route user entry intent to pitch) -- **implement** — Starts execution once a Change is structurally executable; this does not prove implementation completion +- **brainstorm** — Agent technique for divergent thinking (route user entry to pitch) +- **implement** — Starts execution once `loaf issue check` reports the issue shaped; this does not prove implementation completion - **reflect** — Updates strategic docs after the shipped work proves what changed ## Topics @@ -162,10 +206,10 @@ Offer to push the branch and open a draft PR, using [the PR template](templates/ | Blindspot pass | [references/blindspot-pass.md](references/blindspot-pass.md) | Deciding whether to offer reconnaissance, and how to prompt it | | Grilling | [references/grilling.md](references/grilling.md) | Running the one-question-at-a-time interview for `[KU]` entries | | Reaction artifacts | [references/reaction-artifact.md](references/reaction-artifact.md) | Resolving `[UK]` entries with a variant, mock, or prototype | -| Decomposition | [references/decomposition.md](references/decomposition.md) | Sizing and ordering Implementation Units | -| CLI boundary | [references/cli-boundary.md](references/cli-boundary.md) | Reading `loaf change init`/`check`/`verify` output, or explaining `--require-executable` | +| Decomposition | [references/decomposition.md](references/decomposition.md) | Sizing slices, promoting criteria, reading coverage and containment | +| CLI boundary | [references/cli-boundary.md](references/cli-boundary.md) | Reading `loaf issue` output, authoring `--command`/`--expect`, or explaining `loaf issue check` | | Critique Gate | [references/critique-gate.md](references/critique-gate.md) | Self-challenging scope and boundaries before finalizing | ## Artifact Naming -Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or Change already records that provenance. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. +Shape's deliverable is the issue row. If a reaction artifact or spike note lands on disk, name it for what it is, never for the issue that produced it. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. diff --git a/content/skills/shape/references/blindspot-pass.md b/content/skills/shape/references/blindspot-pass.md index 7bbd329a4..616f544d5 100644 --- a/content/skills/shape/references/blindspot-pass.md +++ b/content/skills/shape/references/blindspot-pass.md @@ -12,7 +12,7 @@ Ask the user whether to run it; skip when they're the domain expert. Do not auto ## Prompt Shape -Ask, against the specific territory named by the Change: +Ask, against the specific territory named by the issue: > What would I not know to ask here — codebase history, domain conventions, prior art, potholes? @@ -29,6 +29,8 @@ A `[UU]` that gets named through this pass becomes: and re-routes through the quadrant table in the main skill body. +Park what is still unsharp in the issue's `fog` field at create (`--fog`). After create, keep unsharp entries in the session register — edit cannot mutate `fog`. When an entry sharpens, graduate it to a `--kind decision` child or sibling. Do not drop a named unknown on the floor. + ## Stopping The pass ends when the shaper (or the user) can name the territory's remaining risks as entries, not vague unease. A pass that keeps surfacing "something might be wrong here" without a nameable entry has run past its useful length — stop and proceed with what's been named. diff --git a/content/skills/shape/references/cli-boundary.md b/content/skills/shape/references/cli-boundary.md index b7ade652b..6ed241493 100644 --- a/content/skills/shape/references/cli-boundary.md +++ b/content/skills/shape/references/cli-boundary.md @@ -1,56 +1,118 @@ # CLI Boundary -Reading `loaf change init` and `loaf change check` — the skill teaches reading the CLI, not wrapping it. For the rest of the Loaf CLI surface, see the `loaf-reference` skill. +Reading `loaf issue` — the skill teaches reading the CLI, not wrapping it. For the rest of the Loaf CLI surface, see the `loaf-reference` skill. Issue commands require initialized SQLite state (`loaf state init`, or `loaf state migrate markdown --apply`). + +## Contents +- `loaf issue new` +- `loaf issue show` / `list` / `tree` / `frontier` +- `loaf issue edit` / `status` +- `loaf issue dod` +- `loaf issue promote` +- `loaf issue check` +- `loaf issue verify` +- `loaf issue link` / `bucket` +- `loaf issue render` +- `loaf release suggest` / `cut` +- What shape does not run + +## `loaf issue new <title> [options]` + +```text +loaf issue new <title> [--body <text>|--body -|--body-file <path>|--message <text>] + [--kind delivery|decision] [--parent <ref>] [--fog <text>] [--status <status>] [--json] +``` + +Creates the issue row. Default kind is `delivery`; default status is `triage`. `--status` accepts the write statuses `triage`, `backlog`, `todo`, `active`, `done` (it still records the initial triage event). `--fog` parks questions not yet sharp enough to be issues; this flag exists only on create — `loaf issue edit` replaces the body and does not mutate `fog`. + +`--body -` reads stdin; `--body-file` reads a UTF-8 file; `--message` is inline body at lower precedence than `--body-file` and `--body -`. A hyphen-leading title is positional after `--`: + +```bash +loaf issue new --parent LOAF-42 --status backlog -- "--help is missing from the man page" +``` -## `loaf change init <slug> [--brief]` +A delivery body must state the problem and, before `loaf issue check` will pass, contain the substring `out of scope` (case-insensitive). A decision issue needs a sharp question (`?` in the title or body), not a body contract. -Scaffolds `docs/changes/<YYYYMMDD>-<slug>/` from the Change template, where `<YYYYMMDD>` is the creation day (not a target date) and the branch is named by the bare slug — no date prefix on the branch. Ordinary init writes `change.json + shape.md + tasks/`; `--brief` is capture mode (`change.json + brief.md` only). The slug uses lowercase letters, digits, and single hyphens. +## `loaf issue show` / `list` / `tree` / `frontier` -**Capture promotion.** Re-running ordinary `loaf change init <slug>` (no `--brief`) against a structurally valid capture-only folder completes it in place: `brief.md` and every `change.json` value are preserved verbatim, and missing `shape.md` plus the seeded `tasks/` are published atomically (temp-write then rename; existing destinations are never overwritten; `shape.md` is the last rename and the promotion marker). A partial promotion that already holds the byte-identical seed task resumes by filling only the gaps. Everything else fails clearly and leaves the folder untouched — repeated `--brief`, `change.json`-only (missing brief), hybrid `change.md` + `change.json`, diverged `tasks/` content, malformed metadata, and fully-materialized folders (duplicate rejection unchanged). +```text +loaf issue show <ref> [--json] +loaf issue list [--status <status>] [--kind delivery|decision] [--archived] [--started] [--json] +loaf issue tree [<ref>] [--archived] [--json] +loaf issue frontier [--json] +``` -## `loaf change check [folder] [--require-executable] [--json]` +`show` prints identity, parent, fog, body, definition of done, and children. `list` hides archived issues unless `--archived`. `--status` filters by `triage`, `backlog`, `todo`, `active`, `done`, `cancelled`, `duplicate`. `tree` prints from a ref, or the whole project when omitted. `frontier` lists non-archived `triage`/`backlog`/`todo` issues that are not blocked — derived at read time, useful when checking whether this work is already covered. -Folder resolution: an explicit `[folder]` argument always wins; otherwise the current git branch is matched against the `branch:` frontmatter across every `docs/changes/*/change.json + shape.md`. Zero or multiple matches is an error naming the candidates found. +Prefer `--json` when diagnosing rather than scraping the human-readable text. -Output splits into two tiers: +## `loaf issue edit` / `status` -- **Violations** — always fail (exit code 2), regardless of flags: status-like frontmatter keys (`readiness`, `status`, `state`) or values matching the canonical change-state vocabulary; frontmatter not opening the file at byte one; malformed `YYYYMMDD-slug` folder naming; identity mismatch between `change:`/`created:` and the folder name; missing Product Contract sections (Problem, Hypothesis, Scope, Observable Workflow, Rabbit Holes and No-Gos). -- **Derived executability** — reported, never gating by default. A Change is executable when Planning Contract, Implementation Units, Verification Contract, and Definition of Done are all present and non-empty (bracket placeholders and HTML comments don't count as content). A Change with open gaps is incomplete and non-executable; the report just says what's still missing. +```text +loaf issue edit <ref> [--body-file <path>|--body -|--message <text>] [--json] +loaf issue status <ref> <status> [--duplicate-of <ref>] [--json] +``` -`--require-executable` turns structural executability into a gate (exit code 1 if not structurally executable); it does not prove implementation completion. This is implement's preflight and CI's non-draft-PR check, not something shape itself passes during shaping. +`edit` **replaces** the body. Rewrite the full problem-plus-out-of-scope text; there is no patch form. `status` write-statuses (`triage`, `backlog`, `todo`, `active`, `done`) update in place; `cancelled` and `duplicate` archive through the remove path (`--duplicate-of` is required when status is `duplicate`). Shape leaves status at `triage` unless the user asks otherwise — shaped is derived, not a status. -A branch/Change mismatch (current branch doesn't match the Change's `branch:` field) is a warning, never a violation. +## `loaf issue dod` -`--json` emits `{command, folder, passed, state, executable, exitCode, findings, warnings, gaps}` (plus optional `layout`, `captured`, `notices`) for scripted reads; prefer it when diagnosing rather than scraping the human-readable text. The landing guard reads `state` from this envelope — e.g. `"captured"` for a brief-only folder and `"shaped"` (or higher) once `shape.md` exists — and must not invent a second state surface. +```text +loaf issue dod add <ref> <text> [--command <cmd>] [--expect <expect>] [--tier V|H] [--serves <parent-position>] [--json] +loaf issue dod list <ref> [--json] +loaf issue dod remove <ref> <position> [--json] +loaf issue dod claim <child> <child-position> <parent-position> [--json] +loaf issue dod unclaim <child> <child-position> <parent-position> [--json] +``` +V-tier is used when `--command` is present, otherwise H, unless `--tier` overrides. `--serves` records that the new child criterion claims that parent position. Positions are 1-based and compact after `remove`. Authoring guidance and the expect grammar live in the Decomposition topic. -## `loaf change report new <slug> --kind <kind>` +## `loaf issue promote <ref> <position> [--json]` -Stamps `reports/YYYYMMDD-HHMMSS-<kind>-<slug>.html` with charset, provenance, and token skeleton; prints design-language guidance. Closed kinds: approval, review, visual, audit, note. +Promotes the criterion at the 1-based position into a child **delivery** issue. The parent criterion stays in place. The child is minted in `triage` with a copy of the criterion and a claim already recorded, so coverage for that parent position holds by construction. -## `loaf change verify [folder]` +## `loaf issue check <ref> [--json] [--human <reason>]` -Runs executable V-tier criteria declared in `shape.md` and writes `receipts/verify.json` (criteria digest, verified commit, cwd, per-criterion evidence). New-layout-only. +Derives readiness from the issue row, not from markdown headings. -Criteria forms (both parse): +- **Delivery** — shaped when the body is nonempty (the problem), at least one criterion exists, and the body contains an explicit out-of-scope statement. Prints `issue <ref> is shaped` when ready. +- **Decision** — ready when the title or body contains `?`. Prints `issue <ref> is ready`. +- **Children present** — coverage is a failure (every parent criterion must be claimed). Containment is a report (every child criterion must claim a parent criterion); each orphan prints a ready-to-paste `loaf issue new --parent … --status backlog -- …` remedy. -```markdown -- **V1.** Prose. Command: `exact command`. Expect: exit 0. -- **V1.** Prose. - - Command: `exact command` - - Expect: exit 0 -``` +`--human <reason>` publishes ready-for-human instead of ready-for-agent when a tracker authority is configured. Shape's own gate is the derived verdict, not the publication. + +`--json` emits `{issue, kind, shaped, covered, ready, failures, orphans, …}`. Exit code 1 when not ready. -`Expect` is enforced, with a deliberately minimal grammar: atoms join with ` and ` — `exit <N>` is the required exit code (omit the atom, or `Expect` entirely, and `exit 0` is enforced) and `` contains `text` `` requires the command's combined stdout+stderr to contain that backtick-delimited text (repeatable). A criterion passes when the command ran, the exit code matched, and every `contains` matched; the receipt records each atom and its outcome. +## `loaf issue verify <ref> [--json]` -```markdown -- **V1.** Prose. Command: `go test ./...`. Expect: exit 0 and contains `ok github.com/acme/pkg`. +Runs the issue's V-tier criteria (`--command` plus `--expect`) from the repository root. Honors `exit <N>` and `` contains `text` ``. Writes nothing; exits non-zero on any failure. H-tier rows are skipped. This is implement's preflight, not shape's gate. + +A criterion passes when the command ran, the exit code matched, and every `contains` matched. Unenforceable expect clauses are warned and recorded as advisory — never quietly decorative. + +## `loaf issue link` / `bucket` + +```text +loaf issue link <from> blocks|relates-to <to> [--json] +loaf issue link <from> remove <type> <to> [--json] +loaf issue bucket <ref> now|next|later|none [--json] ``` -Any other clause is unenforceable: verify prints a warning naming the criterion and the clause, records it on the criterion as advisory, and never lets it affect the result — an expectation is either checked or loudly not. +Stored relationship types are `blocks` and `relates_to`. Use `blocks` for a real sequencing constraint; do not encode order in `loaf issue tree`. Buckets are labels only and are never read as a constraint. + +## `loaf issue render <ref> [--json]` + +Emits markdown suitable to paste as a PR body with no manual editing: title, body, definition-of-done checkboxes (checked only when status is `done`), and children. Nothing plan-shaped is committed; if a PR is opened, this output *is* the body. + +## `loaf release suggest` / `cut` + +Releases are retroactive. Shape does not bind an issue to a version. + +```text +loaf release suggest [--base <ref>] [--json] +loaf release cut [--base <ref>] [--bump <type>] [--includes <version|tag>] [--no-tag] [--no-gh] [--dry-run] +``` -Commands run from the repository root; the receipt records that cwd. H-tier entries (`**H1.** …`) are never gate input. See [decomposition.md](decomposition.md) for authoring guidance. +`suggest` reports landed work since the last version tag and writes nothing. `cut` records a release from landed work. Neither is a shaping step. -## `loaf change tasks` / `show` +## What shape does not run -On-demand projections. See `loaf change --help`. +`loaf issue start` / `stop` create and remove the issue worktree — implement's job, after the issue is shaped. `loaf issue export` dumps the project snapshot. Do not call them from this skill. diff --git a/content/skills/shape/references/critique-gate.md b/content/skills/shape/references/critique-gate.md index 4e131243c..f01109327 100644 --- a/content/skills/shape/references/critique-gate.md +++ b/content/skills/shape/references/critique-gate.md @@ -1,14 +1,15 @@ # Critique Gate -The last shaping step, before `loaf change check` and the PR offer. An agent won't know to interrogate its own scope, boundary placement, or smuggled-in status words unless something makes it stop and ask. Instantiated from the shape-first pilot's own Critique Gate, generalized for any Change rather than that pilot's specific CLI-surface question. +The last shaping step, before `loaf issue check` and any review offer. An agent won't know to interrogate its own scope, boundary placement, or smuggled-in progress words unless something makes it stop and ask. Run through these before finalizing: -- **Is scope still bounded?** Has the draft crept beyond what the Problem and Hypothesis justify? Could this Change be smaller and still deliver the Hypothesis? +- **Is scope still bounded?** Has the draft crept beyond what the problem statement justifies? Could this issue be smaller and still be verifiable in one fresh context window? - **Does every new command, state, or lifecycle verb name its ceremony?** If a command or state can't name the ceremony that exercises it, cut it — don't build it now and hope a use appears. -- **Is a status field creeping back in under another name?** `readiness`, `phase`, `stage`, or anything else that reintroduces a declared progress flag `loaf change check` doesn't already ban by pattern. +- **Is a second progress flag creeping into the body?** `readiness`, `phase`, `stage`, or anything else that reintroduces a declared progress flag. Status lives on the issue row (`loaf issue status`). Shaped, covered, and ready are derived by `loaf issue check`. `loaf issue bucket` is a label only and is never read as a constraint. - **Is the CLI/skill boundary drawn correctly?** Is the skill doing deterministic work that belongs in the CLI, or is the CLI claiming judgment that belongs in the skill? -- **Which Verification Contract criteria are genuinely executable gates, and which are human review dressed up as automatable?** A criterion that can't disagree with the implementation isn't a gate. -- **Are the Rabbit Holes and No-Gos sections doing real work?** Or are they restating the Scope's Out list in different words? +- **Which criteria are genuinely executable gates, and which are human review dressed up as automatable?** A V-tier criterion needs `--command` (and `--expect` when exit 0 is not enough). A criterion that can't disagree with the implementation isn't a gate — make it H, or rewrite it against an independent source of truth. +- **Is the out-of-scope statement doing real work?** Rabbit holes and no-gos belong there as named exclusions. Restating the problem in different words is not an out-of-scope statement. +- **Did fog graduate, or evaporate?** Every named unknown is parked in create-time `fog`, held in the session register, sitting as a decision child (or sibling) with a sharp question, filed as a new backlog issue (a different problem), or written into the body as a decided answer. Silent deletion is the failure. -Answers that change the document go back into it — the Decisions log, the Planning Contract, or the relevant Product Contract section — before moving to `loaf change check`. An answer spoken but not written doesn't count. +Answers that change the issue go back into it — `loaf issue edit` for the body, `loaf issue dod add` / `remove` for criteria, `loaf issue promote` or `loaf issue new` for children — before moving to `loaf issue check`. An answer spoken but not written doesn't count. diff --git a/content/skills/shape/references/decomposition.md b/content/skills/shape/references/decomposition.md index 7deef4b13..b6839482b 100644 --- a/content/skills/shape/references/decomposition.md +++ b/content/skills/shape/references/decomposition.md @@ -1,53 +1,119 @@ # Decomposition -Shaping step absorbed from the retired breakdown skill: dependency awareness, granularity judgment, and acceptance-criteria thinking, now expressed as the Change's Implementation Units and Verification Contract instead of a separate task-minting pass. +Shaping's tail, not a separate ceremony: dependency awareness, granularity judgment, and acceptance-criteria thinking, expressed as definition-of-done criteria and — only when a criterion earns its own DoD — child issues created by `loaf issue promote`. -## What Survives +## Contents +- When to split +- The sizing rule +- Expand–contract +- Promote, don't mint +- Coverage and containment +- Authoring criteria +- Order by likelihood-of-change +- Own the decisions -- **The Right Size Test** — before finalizing a unit boundary, check: Can a single implementer complete this? If no, split by concern. Does it touch multiple unrelated concerns? If yes, split by concern. Will the agent need too much context? If yes, split into smaller coherent units. Are you splitting just to have more units? If yes, merge back. -- **Right-sizing rules** — one agent type per unit (completable by a single implementer), one concern per unit (one layer, service, or component), context-appropriate (fits in model context with room for exploration), not over-fragmented (don't split what naturally belongs together). -- **Per-unit verification discipline** — every unit includes its own observable done condition. Never a separate "verify" unit; keep tests with the code they test. -- **Own the decisions** — decide granularity and unit boundaries autonomously. Ask the user only when two orderings are genuinely equally valid with different trade-offs; otherwise decide and move on. +## When to split -## What Dies +A parent gets children only when its DoD needs more than one coherent slice. One criterion that is already right-sized stays on the parent. A checkbox becomes a sub-issue the moment it earns its own DoD — its own problem statement, its own out-of-scope, its own criteria. -Task-file minting, ID allocation, estimate fields, and ordering-for-execution as the default presentation. Implementation Units are in-document work packets — commit-boundary guides and review anchors — never tracked entities with IDs, statuses, or a persistence layer of their own. +Same problem, another slice → another criterion on this issue, or a promoted child if that slice now has its own DoD. A different problem discovered mid-shaping → a new backlog issue (`loaf issue new --status backlog`), not a child of this one. -## The New Principle: Order by Likelihood-of-Change +## The sizing rule -Breakdown ordered units for execution (dependency graph, then priority). Shaping orders units for review: data models, interfaces, and user-facing flows lead; mechanical refactors and boilerplate collapse at the bottom. The reviewer's attention at the draft-to-ready flip should land on what's most likely to need changing, not on whatever happens to run first. +One test, replacing the old four-question checklist: **a slice is right-sized when it fits one fresh context window and is verifiable alone.** -Sequencing constraints that genuinely exist — this unit must land before that one — are stated in prose within the unit description or a Planning Contract subsection. Never rely on list order alone to imply a dependency; a reviewer skimming units by likelihood-of-change won't read sequencing into position. +- If an implementer cannot pick the issue up in a new conversation and finish it without reading a sibling, split. +- If the done-check cannot run (or be reviewed) without another slice landing first, either split and `loaf issue link <predecessor> blocks <successor>`, or merge — do not leave a criterion that is only true in combination. +- If you are splitting just to have more rows, merge back. -## Authoring the Verification Contract +Per-slice verification stays with the slice. Never a separate "verify" child; keep tests with the code they test. -Split criteria into two groups, mirroring the Change template: +## Expand–contract -- **Executable (V-tier)** — bound to a command and an expected result; machine-checkable by `loaf change verify`. Two equivalent forms: +The named exception for wide mechanical refactors (a rename, a schema migration, an expand-then-remove of an old path). The slice may be wide in files touched and still be one issue, because it is one coherent mechanical motion and one verification: the suite still passes after the motion. - Inline (what the scaffold writes): +Do not use this exception to smuggle a second problem into the parent. A mechanical rename plus a behavior change is two slices. - ```markdown - - **V1.** What must be true. Command: `go test ./...`. Expect: exit 0. - - **V2.** Output-bound. Command: `loaf change check`. Expect: exit 0 and contains `executable`. - ``` +## Promote, don't mint - Or with an authoring checkbox still open: +```bash +loaf issue promote <ref> <position> +``` - ```markdown - - [**V1.** What must be true. Command: `go test ./...`. Expect: exit 0.] - ``` +The criterion at the 1-based position stays on the parent. A child **delivery** issue is created in `triage`, titled from the criterion text, with a copy of that criterion as its first DoD line and a claim already recorded from the copy to the parent. Coverage for that parent position holds by construction. + +Then shape the child: give it a problem body and an out-of-scope statement (`loaf issue edit` replaces the body), add the criteria that make *its* DoD complete, promote again if one of those earns its own DoD. + +`loaf issue promote` always mints a delivery child. Decision children are created separately: + +```bash +loaf issue new --kind decision --parent <ref> "Should the store be append-only?" +``` + +A decision child is ready when the title or body contains `?`. It does not claim a parent criterion. + +Once **any** child exists — delivery or decision — `loaf issue check` requires every parent criterion to be claimed. A leaf parent that grows a decision child without promoted (or otherwise claimed) criteria will fail coverage. Sequence the tail as: write DoD → promote every slice that will not execute on the parent → then add decision children. If the parent stays a leaf, leave remaining unsharp questions in create-time `fog` (there is no `--fog` on edit) or file sharpened ones as sibling decision issues (`loaf issue new --kind decision --status backlog`, no `--parent`). - Sub-bullet: +Manual claims, when a child criterion was added rather than promoted: - ```markdown - - **V1.** What must be true. - - Command: `go test ./...` - - Expect: exit 0 +```bash +loaf issue dod add <child> "Child done-check" --serves 1 +loaf issue dod claim <child> <child-position> <parent-position> +loaf issue dod unclaim <child> <child-position> <parent-position> +``` + +`--serves` claims the newly added child criterion against that parent position. `claim` / `unclaim` retarget an existing pair. + +## Coverage and containment + +`loaf issue check <ref>` runs these only when the issue has children. + +- **Coverage** (failure) — every parent criterion must be claimed by at least one child criterion. An uncovered position is named in the failure list; fix it by promoting that criterion or adding a claiming child criterion. +- **Containment** (report, not a failure) — every child criterion must claim a parent criterion. An orphan is printed with a ready-to-paste remedy that files it as a **sibling** backlog issue: + + ```bash + loaf issue new --parent '<parent>' --status backlog -- '<orphan text>' ``` - `Expect` is optional and enforced when present, with a deliberately minimal grammar: atoms join with ` and ` — `exit <N>` is the required exit code (an absent `Expect`, or an `Expect` with no exit atom, means `exit 0`) and `` contains `text` `` requires the combined stdout+stderr to contain that backtick-delimited text (repeatable). Any other clause is unenforceable: `loaf change verify` warns naming the criterion and the clause and records it as advisory, so an expectation is either checked or loudly not — never quietly decorative. Commands run from the **repository root** (never the change folder). Only V-entries that declare a fenced `Command:` value are gate input. + Run the printed remedy. Do not fold the orphan back onto the parent, and do not treat it as in-scope work that somehow escaped the DoD — it is a new backlog row under the same parent. + +A different problem (not an orphan criterion) is not a sibling of this decomposition. File it as a new backlog issue with no `--parent`. -- **Human review (H-tier)** — what a reviewer confirms that no command can. H-entries are review material and are **never** gate input; `loaf change verify` ignores them. +## Authoring criteria + +```bash +loaf issue dod add <ref> <text> [--command <cmd>] [--expect <expect>] [--tier V|H] [--serves <parent-position>] +``` + +- **V-tier** — used when `--command` is present, unless `--tier` overrides. Machine-checkable by `loaf issue verify <ref>`. Commands run from the **repository root**. +- **H-tier** — default when `--command` is absent. Human review; never gate input. `loaf issue verify` skips H-tier rows. + +`--expect` is optional and enforced when present, with a deliberately minimal grammar: atoms join with ` and ` — `exit <N>` is the required exit code (an absent `--expect`, or one with no exit atom, means `exit 0`) and `` contains `text` `` requires the combined stdout+stderr to contain that backtick-delimited text (repeatable). Any other clause is unenforceable: verify warns naming the criterion and the clause and records it as advisory, so an expectation is either checked or loudly not — never quietly decorative. + +```bash +loaf issue dod add LOAF-42 "Package tests pass" --command "go test ./..." --expect "exit 0" +loaf issue dod add LOAF-42 "Check names the uncovered criterion" --command "loaf issue check LOAF-42" --expect "exit 0 and contains \`uncovered\`" +loaf issue dod add LOAF-42 "The writeup is readable by someone new to the area" --tier H +``` A criterion whose check only restates the implementation (recomputing the expected value the way the code does) is vacuous — it can never disagree with the code under test. Prefer criteria with an independent source of truth. + +`loaf issue dod list <ref>` prints the current lines. `loaf issue dod remove <ref> <position>` removes one (positions then compact). + +## Order by likelihood-of-change + +Present children for review, not for execution order: data models, interfaces, and user-facing flows lead; mechanical refactors and boilerplate collapse at the bottom. The reviewer's attention should land on what's most likely to need changing, not on whatever happens to run first. + +Sequencing constraints that genuinely exist — this child must land before that one — are recorded as relationships: + +```bash +loaf issue link <from> blocks <to> +loaf issue link <from> relates-to <to> +loaf issue link <from> remove blocks <to> +``` + +Never rely on `loaf issue tree` order to imply a dependency; a reviewer skimming by likelihood-of-change won't read sequencing into position. `loaf issue bucket <ref> now|next|later|none` is an advisory label only and is never read as a constraint. + +## Own the decisions + +Decide granularity and slice boundaries autonomously. Ask the user only when two orderings are genuinely equally valid with different trade-offs; otherwise decide and move on. diff --git a/content/skills/shape/references/grilling.md b/content/skills/shape/references/grilling.md index cd4d6a575..4cf6437dd 100644 --- a/content/skills/shape/references/grilling.md +++ b/content/skills/shape/references/grilling.md @@ -1,8 +1,8 @@ # Grilling -The interview technique for `[KU]` entries — known unknowns precise enough to phrase as a question. Imported from the reviewed `grill-me` / `grill-with-docs` pattern (`docs/changes/20260704-shape-first-change-workflow/research/mattpocock-review/`), sharpened with the Field Guide's architectural-impact ordering. +The interview technique for `[KU]` entries — known unknowns precise enough to phrase as a question. Imported from the reviewed `grill-me` / `grill-with-docs` pattern, sharpened with architectural-impact ordering. -This is a distinct, shape-specific technique — not the shared `templates/grilling.md` used by `architecture` and `refactor-deepen`. That template's glossary-mutation duty doesn't fit shape's ambiguity-resolving step (see the deferral rationale it documents); shape's grilling stays scoped to fog entries and never writes to the domain glossary. +This is a distinct, shape-specific technique — not the shared `templates/grilling.md` used by `architecture` and `refactor-deepen`. That template's glossary-mutation duty doesn't fit shape's ambiguity-resolving step (see the rationale it documents); shape's grilling stays scoped to fog entries and never writes to the domain glossary. ## The Mechanic @@ -14,17 +14,21 @@ Every question carries a recommended answer with rationale — never "what do yo Prioritize questions whose answer would change the architecture. Cosmetic questions — naming, ordering, presentation — go last, even when they're easier to answer. An architecture-changing answer received late can invalidate everything decided in between; asking it first avoids that rework. -Before asking, check whether reading resolves the question — an existing ADR, a prior Change, a journal entry. Only ask what reading couldn't answer. +Before asking, check whether reading resolves the question — an existing ADR, a prior issue, a journal entry. Only ask what reading couldn't answer. ## Stop Condition Stop when either holds: - No unrouted `[KU]` entries remain. -- Answers stop changing the contract — the last several questions confirmed direction rather than altering it. +- Answers stop changing the issue — the last several questions confirmed direction rather than altering the body, the criteria, or the children. -Grilling never opens the session. Context gathering and the blindspot-pass offer come first; an interview without that groundwork asks questions the codebase or journal could have answered for free. +Write each accepted answer into the issue as it lands: `loaf issue edit` for the body, `loaf issue dod add` for a new done-check, `loaf issue new --kind decision --parent <ref>` when the answer is itself a sharp question that still needs a later call. Do not leave a resolved `[KU]` only in the conversation. ## Mid-Interview Reroute If a question turns out to need domain fluency the shaper doesn't have — the follow-up can't even be phrased — stop grilling it and route the entry to the blindspot pass instead of guessing at an answer. + +## Opening + +Grilling never opens the session. Context gathering and the blindspot-pass offer come first; an interview without that groundwork asks questions the codebase or journal could have answered for free. diff --git a/content/skills/shape/references/reaction-artifact.md b/content/skills/shape/references/reaction-artifact.md index d3254bed0..ca30f7485 100644 --- a/content/skills/shape/references/reaction-artifact.md +++ b/content/skills/shape/references/reaction-artifact.md @@ -8,11 +8,11 @@ Before building anything, state precisely what this artifact must let the user d ## Build the Smallest Thing That Lets the User React -Construct the minimum artifact that resolves the named unknown — competing layouts, a comparison of approaches, a rendered mock. Write it into the Change's own `research/` folder, never `.agents/reports/`; this is shape's own technique, executed in-session, not a handoff to the research skill. +Construct the minimum artifact that resolves the named unknown — competing layouts, a comparison of approaches, a rendered mock. Keep it in the session or in a throwaway file you will discard. This is shape's own technique, executed in-session, not a handoff to the research skill and not a committed plan document. Do not write it into `.agents/reports/`. ## Capture the Answer, Discard the Shell -Once the user reacts and picks, write the decision — with rationale — back into the Change: the Decisions log, or the relevant Planning Contract subsection. The artifact itself is not the deliverable; the choice it produced is. Discard or archive the shell once the answer is captured. If a piece of logic proved out along the way is worth keeping (a validated approach, not just a throwaway shell), note that explicitly — but don't let scaffolding survive under the pretense that it's production code. +Once the user reacts and picks, write the decision — with rationale — back onto the issue: the body via `loaf issue edit`, or a `--kind decision` child when the pick is itself a sharp question that still needs a later call. The artifact itself is not the deliverable; the choice it produced is. Discard the shell once the answer is captured. If a piece of logic proved out along the way is worth keeping (a validated approach, not just a throwaway shell), note that explicitly in the issue body — but don't let scaffolding survive under the pretense that it's production code. ## Never a Deliverable diff --git a/content/skills/shape/templates/brief.md b/content/skills/shape/templates/brief.md deleted file mode 100644 index 05016c8c8..000000000 --- a/content/skills/shape/templates/brief.md +++ /dev/null @@ -1,42 +0,0 @@ -<!-- brief.md is the optional archeological kickstart — the original unshaped ask. - May accrete parked problem-space concepts while the change is captured; freezes when shape.md exists. - Superseded by shape.md; never mechanically load-bearing. - A brief-only folder is legal and non-executable (captured, not shaped). --> - -# [Brief title] - -## Problem Statement - -[What friction, gap, or unmet need exists? Be specific about the pain — vague problems produce vague solutions. Problem-space only; do not design the approach here.] - -## Who Has It - -[Who experiences this problem? Role, context, and how often the pain shows up. Avoid unqualified "users" or "developers."] - -## Current Alternatives - -[What do they do today? Existing tools, manual workarounds, or "nothing" are all valid. Understanding the status quo clarifies what better means.] - -## Value Proposition - -[Why is solving this worth it? What becomes true for the people who have the problem if this lands? Describe value, not features or architecture.] - -## Constraints - -[Non-negotiable bounds: technical, legal, organizational, or philosophical. Things that limit the solution space before design begins.] - -- [Constraint 1] - -## Sequencing and Relationships - -[How this relates to other work — series order, release cohort, dependencies stated as prose. No machine relation fields; narrative order only.] - -## Sources and Research Links - -[Evidence that informed this framing — competitive scans, research notes, issue links, prior art. Link rather than paste.] - -## Open Questions - -[Unresolved problem-space items. Mark urgency: blocking (must resolve before shaping) or deferrable.] - -- [ ] [Question] — blocking | deferrable diff --git a/content/skills/shape/templates/change.md b/content/skills/shape/templates/change.md deleted file mode 100644 index 6ce5b9725..000000000 --- a/content/skills/shape/templates/change.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -change: [slug] -created: [YYYY-MM-DD] -branch: [slug] ---- - -<!-- Frontmatter must open the file at byte one — parsers depend on it. No status-like frontmatter (readiness/status/state): readiness is derived — a draft PR is shaping; `loaf change check` derives structural executability from the sections below. --> - -# [Change Title] - -## Problem - -[Why this work exists — the friction, gap, or rot being addressed.] - -## Hypothesis - -[The bet: what becomes true if this ships, and why it is worth making.] - -## Scope - -**In** - -- [What this Change delivers.] - -**Out** (deferred, not rejected) - -- [What is explicitly postponed, and where it went.] - -**Cut** (explicitly rejected) - -- [What this Change will not do, ever.] - -## Observable Workflow - -[What someone sees or does once this ships — commands, flows, UX. Concrete over abstract.] - -## Rabbit Holes and No-Gos - -[Boundaries: the ways this work could quietly grow into something it must not become.] - -## Decisions - -Provenance: [how each decision was accepted — interview, review, dogfooding.] - -1. **[Decision.]** [Rationale, and what it forecloses.] - -## Planning Contract - -[The HOW. Free-form `###` subsections named by the work — the container is the contract; the subsection names are yours.] - -### [Approach / Placement / Risks / Sequencing / Spike findings …] - -[...] - -## Implementation Units - -<!-- In-document work packets — commit-boundary guides and review anchors, not tracked entities. --> - -- [**U1 — Unit name.** What it delivers.] - -## Verification Contract - -<!-- Executable (machine-checkable): --> - -- [**V1.** Criterion bound to a command and an expected result.] - -<!-- Human review: --> - -- [**H1.** What a reviewer confirms that no command can.] - -## Definition of Done - -- [Derived from gates and review — never a status flag.] - -## Durable Outputs - -[Specs, ADRs, knowledge docs, or schema updates to create after implementation proves what is now true. A final spec describes reality, not a plan.] - -## Open Questions - -<!-- Fog register: tag entries [KU]/[UK]/[UU] with a route — see the shape skill's quadrant table. Tags are convention, never parsed by check. --> - -- [Known unknowns, each owned by a section, a spike, or a follow-up.] - -## Source Inputs - -- [Where this Change came from: journal entries (cite by ID), sparks, ideas, brainstorms, issues, conversations, prior Changes.] - -<!-- Optional sections, added when they earn their place: Background, Success Metrics (when validation matters), Follow-ups, Critique Gate. --> diff --git a/content/skills/shape/templates/design.md b/content/skills/shape/templates/design.md deleted file mode 100644 index 2244ccdb6..000000000 --- a/content/skills/shape/templates/design.md +++ /dev/null @@ -1,20 +0,0 @@ -<!-- design.md is the optional design surface for UI, protocol, or schema detail - that would crowd shape.md or plan.md. Accretive; not load-bearing for the gate. --> - -# Design — [Change Title] - -## Intent - -[What this design clarifies that shape.md does not.] - -## Surfaces - -[Screens, APIs, schemas, or protocols under design.] - -## Decisions - -1. **[Decision.]** [Rationale.] - -## Open questions - -- [What remains undecided.] diff --git a/content/skills/shape/templates/plan.md b/content/skills/shape/templates/plan.md deleted file mode 100644 index 6d17a7179..000000000 --- a/content/skills/shape/templates/plan.md +++ /dev/null @@ -1,21 +0,0 @@ -<!-- plan.md is the optional technical route (the corpus's plan sense). - Accretive during shaping; approach churn here never expires a cohort receipt. - Criteria live in shape.md — do not relocate Verification Contract here. --> - -# Plan — [Change Title] - -## Approach - -[How the work lands — architecture, sequencing, compatibility.] - -## Placement - -[Where code and docs live; what stays out of scope for this route.] - -## Risks - -[Failure modes and the safe failure direction.] - -## Sequencing - -[Ordered slices that leave main coherent at each landing.] diff --git a/content/skills/shape/templates/pr.md b/content/skills/shape/templates/pr.md deleted file mode 100644 index 91b4b7401..000000000 --- a/content/skills/shape/templates/pr.md +++ /dev/null @@ -1,25 +0,0 @@ -<!-- Draft = still shaping. Ready for review = structurally executable, not proof of implementation completion. --> - -## Change - -<!-- Link the Change folder this PR advances: docs/changes/YYYYMMDD-slug/ The Change artifact lives in this PR's diff — the draft PR is its shaping surface. During coexistence, legacy PRs implementing a numbered spec link the .agents/specs/ path here instead. Delete this section for PRs tied to neither. --> - -## What & Why - -<!-- What this PR does and the problem it solves. For a shaping (draft) PR, summarize the direction; for an implementation PR, summarize the delta. --> - -## Review focus - -<!-- Where reviewer attention pays off: decisions to challenge, boundaries to verify, criteria to confirm. --> - -## Verification - -<!-- What proves this works: gates run (`loaf change check`, `loaf check`), tests, commands a reviewer should re-run locally. On a draft, state what remains open instead. --> - -## Migration / breaking changes - -<!-- If this PR changes user-facing behavior, document the migration step. Otherwise: "None." --> - -## Deferred - -<!-- Intentionally out of scope, and where it went (follow-up Change, open question, issue). Delete if nothing was deferred. --> diff --git a/content/skills/shape/templates/shape.md b/content/skills/shape/templates/shape.md deleted file mode 100644 index 59aa9b89a..000000000 --- a/content/skills/shape/templates/shape.md +++ /dev/null @@ -1,77 +0,0 @@ -<!-- shape.md is the change contract. Identity lives in change.json — no status-like frontmatter. Readiness is derived: a draft PR is shaping; `loaf change check` derives structural executability from the sections below. --> - -# [Change Title] - -## Problem - -[Why this work exists — the friction, gap, or rot being addressed.] - -## Hypothesis - -[The bet: what becomes true if this ships, and why it is worth making.] - -## Scope - -**In** - -- [What this Change delivers.] - -**Out** (deferred, not rejected) - -- [What is explicitly postponed, and where it went.] - -**Cut** (explicitly rejected) - -- [What this Change will not do, ever.] - -## Observable Workflow - -[What someone sees or does once this ships — commands, flows, UX. Concrete over abstract.] - -## Rabbit Holes and No-Gos - -[Boundaries: the ways this work could quietly grow into something it must not become.] - -## Decisions - -Provenance: [how each decision was accepted — interview, review, dogfooding.] - -1. **[Decision.]** [Rationale, and what it forecloses.] - -## Planning Contract - -<!-- The HOW. Prefer plan.md/design.md when the route needs its own file; keep this container. Free-form ### subsections named by the work. --> - -### [Approach / Placement / Risks / Sequencing …] - -[…] - -## Implementation Units - -<!-- Task packets live in tasks/TASK-NNN-slug.md; this section may summarize the decomposition. --> - -- [**TASK-001 — Unit name.** What it delivers.] - -## Verification Contract - -<!-- Executable (machine-checkable): each V-entry declares Command and Expect for loaf change verify. Expect is a grammar, not prose: atoms join with " and " — `exit <N>` is the required exit code (omit the atom, or Expect entirely, for exit 0; a second exit atom is a contradiction and fails the criterion) and contains `text` requires the combined stdout+stderr to contain that backtick-delimited text (repeatable). Example: Expect: exit 0 and contains `all green`. Any other clause is unenforceable: verify warns naming the criterion and clause, records it as advisory, and never checks it. --> - -- [**V1.** What must be true. Command: `exact command`. Expect: exit 0.] - -<!-- Human review (H-tier): review material, never gate input. --> - -- [**H1.** What a reviewer confirms that no command can.] - -## Definition of Done - -- [Derived from gates and review — never a status flag.] - -## Durable Outputs - -[Specs, ADRs, knowledge docs, or schema updates to create after implementation proves what is now true.] - -## Open Questions - -<!-- Fog register: tag entries [KU]/[UK]/[UU] with a route. Tags are convention, never parsed by check. --> - -- [KU] [Known unknown → route to a task or later change] diff --git a/content/skills/shape/templates/task.md b/content/skills/shape/templates/task.md deleted file mode 100644 index 05de15307..000000000 --- a/content/skills/shape/templates/task.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -change: [slug] -id: TASK-NNN -title: [short title] -# Relations (closed set; targets are TASK-NNN within this change only): -# parent: TASK-NNN -# blocks: -# - TASK-NNN -# blocked-by: -# - TASK-NNN -# relates-to: -# - TASK-NNN ---- - -# TASK-NNN — [Title] - -## Objective - -[What this task delivers when its checkboxes are flipped.] - -## Scope boundaries - -**In:** [What this task may touch.] - -**Out:** [What this task must not touch — other tasks, other changes, deferred work.] - -## Context pointers - -- Contract: `shape.md` — [relevant sections] -- Research: [paths under research/ when cited] - -## Acquisition - -```bash -loaf journal log "skill(implement): TASK-NNN — [short intent]" -# [commands or files to load before editing] -``` - -## Steps - -- [ ] [Atomic step — one commit's worth when flipped with the delivering work] -- [ ] [Next step] - -## Verification - -- [Commands or checks that prove this task alone] -- The slug never cites other work units (`TASK-…`, `SPEC-…`, issue keys) — identity is local; provenance is in frontmatter and the change folder. diff --git a/content/skills/ship/SKILL.md b/content/skills/ship/SKILL.md index bc62cf0d9..227b74323 100644 --- a/content/skills/ship/SKILL.md +++ b/content/skills/ship/SKILL.md @@ -1,16 +1,19 @@ --- name: ship description: >- - Reviews, verifies, and lands one pull request. Use when the user says - "ship it," "merge this PR," "ready to merge," "land this branch," or asks for - a final merge gate. Produces a reviewed, squash-merged PR and post-merge - cleanup. Not for version bumps, tags, GitHub Releases, or install - verification (use release). + Reviews, verifies, and lands one pull request — the sole quality gate + before work can appear in a later release cut. Use when the user says + "ship it," "merge this PR," "ready to merge," "land this branch," or asks + for a final merge gate. Binds the PR to an issue: the body is + `loaf issue render` output, definition-of-done criteria are the review + checklist, and landing marks the issue done and stops its worktree. + Produces a reviewed, squash-merged PR and post-merge cleanup. Not for + version bumps, tags, GitHub Releases, or install verification (use release). --- # Ship -Review, verify, and land one PR. Shipping is the PR gate; releasing is the version-publication gate. +Review, verify, and land one PR. Ship's review is the quality gate for everything that will ever ship. Releases are retroactive — they cut a version from already-landed work. Nothing between merge and the next release cut re-checks the diff, the tests, or the issue. A rubber-stamped PR ships in the next cut with no second net. ## Contents - Critical Rules @@ -20,7 +23,7 @@ Review, verify, and land one PR. Shipping is the PR gate; releasing is the versi - Context Detection - Step 1: PR Readiness - Step 1b: Stacked PR Detection -- Step 2: Evidence Review +- Step 2: Definition-of-Done Review - Step 3: Local Verification - Step 4: Squash Merge - Step 5: Post-Merge Cleanup @@ -34,64 +37,94 @@ Review, verify, and land one PR. Shipping is the PR gate; releasing is the versi ## Critical Rules -- **Ship is not release** -- do not bump versions, create tags, publish GitHub Releases, or verify package installation here. -- **Keep PR quality local** -- smaller PRs are welcome, but ship must still verify correctness before merge. -- **Detect-first** -- auto-detect the PR from the current branch before asking for a PR number. -- **Review before merge** -- inspect code, docs, tests, changelog, PR body, and CI state before approval. -- **Never merge without explicit confirmation** -- present the PR, checks, findings, and squash body first. -- **Detect the stack before merging** -- another open PR may use this PR's head branch as its base. Find out before merge, never after, and never delete a head branch while a child PR still points at it. -- **Clean squash body** -- write an intentional squash commit body; never accept the automatic commit dump. -- **Keep landed and released distinct** -- after merge, describe the PR as landed or shipped, not necessarily released. -- **Log shipping** -- after merge, run `loaf journal log "decision(ship): PR #N landed via squash merge"`. +1. **Log invocation first** — `loaf journal log "skill(ship): shipping <ref or PR or current branch>"` before doing anything else. After merge, log `loaf journal log "decision(ship): PR #N landed via squash merge; <ref> done"`. +2. **Rigor is load-bearing** — this review is the only quality gate. Releases cut from landed work; they do not re-check. If the review is thin, the next cut still publishes it. +3. **Ship is not release** — do not bump versions, create tags, publish GitHub Releases, or verify package installation here. Use the release skill for that. +4. **Bind the PR to an issue** — the PR body is `loaf issue render <ref>` (paste-ready, no manual editing). The issue's definition-of-done criteria are the review checklist. `loaf issue verify <ref>` runs the executable (V-tier) rows and writes nothing. Landing means `loaf issue status <ref> done`. Then `loaf issue stop <ref>` removes the started worktree. +5. **Detect-first** — auto-detect the PR from the current branch, and the issue from `$ARGUMENTS` or the started workspace, before asking for a PR number or issue ref. +6. **Review before merge** — inspect code, docs, tests, changelog, the rendered issue body, definition of done, and CI state before approval. +7. **Never merge without explicit confirmation** — present the PR, checks, review notes, and squash body first, using your harness's structured question tool if it has one. +8. **Detect the stack before merging** — another open PR may use this PR's head branch as its base. Find out before merge, never after, and never delete a head branch while a child PR still points at it. +9. **Clean squash body** — write an intentional squash commit body; never accept the automatic commit dump. +10. **Keep landed and released distinct** — after merge, describe the PR as landed or shipped. It is not released until release publishes a version. + +--- ## Verification +- Invocation is logged to the project journal before review work begins - PR identity, base branch, and head branch are confirmed +- The PR is bound to one issue; `loaf issue show <ref>` is the issue surface +- PR body matches `loaf issue render <ref>` with no manual editing +- Every definition-of-done criterion was reviewed against the diff; H-tier by reading, V-tier by `loaf issue verify <ref>` (writes nothing; exit non-zero blocks) - CI status is passing or the user explicitly accepts named non-blocking checks - Relevant local checks pass or failures are fixed before merge - PR body and durable docs do not overclaim relative to the diff - Squash commit title/body are clean, conventional, and user-facing - Child PRs stacked on this PR's head branch are enumerated before merge, and each is retargeted, rebased, and re-verified after it -- Base branch is updated after merge and the feature branch cleanup state is known +- After merge: `loaf issue status <ref> done`, then `loaf issue stop <ref>` if a worktree was started, base branch updated, feature-branch cleanup state known +- A `decision(ship)` journal entry records the landing ## Quick Reference | Step | Gate | Blocking? | |------|------|-----------| +| Context Detection | PR and issue bound | Yes | | PR Readiness | PR exists, target base known, CI state reviewed | Yes | | Stacked PR Detection | child PRs on this head branch are enumerated | Yes | -| Evidence Review | findings resolved or explicitly accepted | Yes | -| Local Verification | relevant checks pass | Yes | +| Definition-of-Done Review | every criterion reviewed; `loaf issue verify` passes or reports no V-tier rows | Yes | +| Local Verification | relevant project checks pass | Yes | | Squash Merge | user approves body text | Yes | -| Cleanup | base pulled, children retargeted and rebased, branch deletion handled | Yes when a child exists | -| Release Suggestion | enough landed work may justify release | No | +| Cleanup | issue marked done, started worktree stopped, base pulled, children retargeted and rebased, branch deletion handled | Yes when a child PR exists; done + stop always | +| Release Suggestion | enough landed work may justify a later cut | No | ## Topics | Topic | Use When | |-------|----------| -| [Context Detection](#context-detection) | Determining current branch and PR state | +| [Context Detection](#context-detection) | Binding the current branch to a PR and an issue | +| [Definition-of-Done Review](#step-2-definition-of-done-review) | Using issue criteria as the merge checklist | | [Hook Interaction](#hook-interaction) | Understanding coexistence with git hooks | --- ## Context Detection -Before anything, detect the PR surface: +Log the invocation, then detect the PR and the issue. + +### PR 1. Get current branch and repo default branch: ```bash git branch --show-current gh repo view --json defaultBranchRef -q .defaultBranchRef.name ``` -2. Parse `$ARGUMENTS`: may be a PR number, PR URL, branch name, or empty. -3. If `$ARGUMENTS` is empty, auto-detect from the current branch: +2. Parse `$ARGUMENTS`: may be an issue ref (`LOAF-42`), a PR number, a PR URL, a branch name, or empty. +3. If `$ARGUMENTS` is empty or is not a PR identity, auto-detect from the current branch: ```bash gh pr view --json number,title,url,headRefName,baseRefName,state,mergeStateStatus,isDraft ``` 4. If no PR exists for the current branch, stop and offer to create one via `git-workflow` rather than silently merging a branch. 5. If already on the default branch, stop. There is no PR to ship from the current branch. -6. Confirm PR identity with the user before merge actions. + +### Issue + +Issue commands require initialized SQLite state. Bind exactly one issue: + +1. If `$ARGUMENTS` (or a remaining token) is an issue ref, load it: + ```bash + loaf issue show <ref> + ``` +2. Otherwise match the PR's `headRefName` to a started workspace: + ```bash + loaf issue list --started + ``` + Columns are alias, title, `started_branch`, `started_worktree`. The started branch from `loaf issue start` is `issue/<alias-or-id>` in lowercase (`issue/loaf-42`), disambiguated with an id suffix when that name is already claimed. +3. Confirm with `loaf issue show <ref>` — `started_branch` / `started_worktree` should match this PR when the issue was started. + +If nothing binds, stop and ask for the issue ref. Do not invent a row during ship. If `loaf issue show` reports the issue archived (`cancelled` or `duplicate`), stop. + +Confirm PR identity and the bound issue with the user before merge actions. --- @@ -103,6 +136,14 @@ Inspect the PR's declared state: gh pr view <N> --json number,title,body,url,headRefName,baseRefName,state,isDraft,mergeStateStatus,reviewDecision,statusCheckRollup ``` +The body must be `loaf issue render <ref>` output — title, issue body, definition-of-done checkboxes (checked only when status is already `done`), and children. No project headers, no hand-edited summary. If the live PR body differs, replace it: + +```bash +gh pr edit <N> --body "$(loaf issue render <ref>)" +``` + +Do not rewrite the markdown by hand. Checkboxes are unchecked until `loaf issue status <ref> done`; do not tick them in the PR body to fake completion. + Block or pause when: - PR is draft @@ -123,20 +164,27 @@ Before merging anything, find out whether another open PR uses this PR's head br gh pr list --state open --base <headRefName> --json number,title,headRefName ``` -Any result is a **child PR**, and this PR is the base of a stack. Record the list now, because after the merge the relationship is harder to see and easier to break. +Any result is a **child PR**, and this PR is the base of a stack. Record the list now, because after the merge the relationship is harder to see and easier to break. Child PRs are GitHub stacking, not child issues. -When a child exists: +When a child PR exists: - **Do not pass `--delete-branch` to the merge.** Removing the head branch while a child still points at it can close the child outright. Delete it only after every child has been retargeted, in Step 5. - Tell the user the stack exists and name the children before asking for merge confirmation. A stack changes what "merge this" means. -When no child exists, say so, and `--delete-branch` is safe. +When no child PR exists, say so, and `--delete-branch` is safe. --- -## Step 2: Evidence Review +## Step 2: Definition-of-Done Review + +The issue's definition of done is the merge checklist. Load it from the issue, not from memory: + +```bash +loaf issue dod list <ref> +loaf issue show <ref> +``` -Review the landing diff and durable prose together: +`show` prints each criterion as `position. [V|H] text` with `command=` / `expect=` when present. Walk every row against the landing diff. 1. Gather diff context: ```bash @@ -144,13 +192,19 @@ Review the landing diff and durable prose together: git diff --stat origin/<baseRefName>...HEAD git diff --name-only origin/<baseRefName>...HEAD ``` -2. Read the PR title/body and changed docs that make behavior claims. -3. Check for drift: - - PR body claims features that are not in the diff - - changelog entries mention unreleased or unrelated behavior +2. For each **H-tier** criterion, read the diff and durable prose and decide whether the text is met. `loaf issue verify` skips H-tier rows — that skip is not a pass; you are the check. +3. Run the **V-tier** rows: + ```bash + loaf issue verify <ref> + ``` + Commands run from the **repository root**. The command honors `exit <N>` and `` contains `text` ``. It writes nothing — it does not tick checkboxes, does not set status, and does not record a run. Non-zero exit blocks merge. `no executable V-tier criteria on <ref>` is not a failure; H-tier review still is. Unenforceable expect clauses print as advisory warnings and are never quietly decorative. +4. Check for drift: + - Rendered issue body claims features that are not in the diff + - changelog entries mention unrelated behavior - docs describe future work as already shipped - - comments or runbooks use stale internal vocabulary -4. Fix blocking drift before merge. For non-blocking polish, name it and let the user decide. + - comments or runbooks use stale vocabulary +5. If `loaf issue show` lists child issues that are not `done`, name them before asking to merge. Do not mark those children done unless this PR is theirs. +6. Fix blocking drift and unmet criteria before merge. For non-blocking polish, name it and let the user decide. For high-risk PRs, use the project's review skill or read-only review flow before proceeding. @@ -158,7 +212,7 @@ For high-risk PRs, use the project's review skill or read-only review flow befor ## Step 3: Local Verification -Run the checks the project supports. Examples: +Run the checks the project supports, in addition to `loaf issue verify`. Examples: - Node: `npm run typecheck`, `npm run test`, `npm run build` - Go: `go vet ./...`, `go test ./...` @@ -177,7 +231,7 @@ Use the repo's documented pre-commit or pre-PR checklist when present. Stop on f ## Step 4: Squash Merge -Draft a clean squash body from the reviewed diff and PR body: +Draft a clean squash body from the reviewed diff and the rendered issue: - One-line summary, then bullet points grouped by feature area - Plain text; use backticks only for code identifiers @@ -200,31 +254,41 @@ Let GitHub default the title from the PR title so the squash subject remains `ty ## Step 5: Post-Merge Cleanup -After a successful merge: +After a successful merge, leave the started worktree before removing it. Do not run `loaf issue stop` from inside that worktree. -1. Switch to the PR base branch: +1. Switch to the PR base branch in the repository checkout: ```bash git checkout <baseRefName> git pull --ff-only origin <baseRefName> ``` -2. Delete the local feature branch when safe: +2. Mark the bound issue done — this is what "done" means; `loaf issue stop` does not change status: + ```bash + loaf issue status <ref> done + ``` + Write statuses (`triage`, `backlog`, `todo`, `active`, `done`) update in place. Do not use `cancelled` or `duplicate` here. +3. Stop the started worktree if one exists. `loaf issue stop` removes the worktree, clears `started_branch` / `started_worktree` on the row, and **keeps the branch**: + ```bash + loaf issue stop <ref> + ``` + If the issue was never started, the command errors with `issue <ref> is not started` — treat that as already clean and continue. If the worktree is dirty, do not pass `--force` without user confirmation. +4. Delete the local feature branch when safe: ```bash git branch -d <headRefName> ``` -3. Confirm the remote branch deletion state from GitHub output or run: +5. Confirm the remote branch deletion state from GitHub output or run: ```bash gh pr view <N> --json headRefName,state ``` -4. Log the landing to the project journal: +6. Log the landing: ```bash - loaf journal log "decision(ship): PR #N landed via squash merge" + loaf journal log "decision(ship): PR #N landed via squash merge; <ref> done" ``` -If cleanup fails, report the exact residual state. Do not force-delete without user confirmation. +If cleanup fails, report the exact residual state (issue status, whether the worktree is still started, which branches remain). Do not force-delete without user confirmation. ### Stacked child PRs -Every child recorded in Step 1b needs three repairs before it can ship, and none of them happen on their own. +Every child PR recorded in Step 1b needs three repairs before it can ship, and none of them happen on their own. **Retarget the base.** GitHub does not reliably move a child's base when its base branch merges. Check, and move it explicitly: @@ -246,7 +310,7 @@ git diff --stat <baseRefName>...HEAD | tail -1 gh pr view <child> --json changedFiles -q .changedFiles ``` -Then force-push with a lease, wait for CI to finish on the **rebased head** rather than trusting the previous run, and only then ship the child. Delete the parent's head branch once no child points at it. +Then force-push with a lease, wait for CI to finish on the **rebased head** rather than trusting the previous run, and only then ship the child. Delete the parent's head branch once no child PR points at it. Report the numbers rather than asserting success: the before and after file counts, the commits the rebase skipped, and the CI conclusion on the new head. @@ -254,11 +318,11 @@ Report the numbers rather than asserting success: the before and after file coun ## Step 6: Release Suggestion -After landing, decide whether to suggest release: +After landing, decide whether to suggest the release skill. That cut will not re-run this review. -- Suggest release when the landed PR completes a coherent batch, user-facing feature, fix train, or release branch. +- Suggest release when the landed PR completes a coherent batch, user-facing feature, or fix train. - Do not suggest release for every small PR by default. -- If multiple related PRs are expected, say the PR is landed and can wait for a later batched release. +- If more related PRs are expected, say this PR is landed and can wait for a later cut. Use language carefully: the PR is **landed** or **shipped**; it is not **released** until release publishes a version. @@ -282,12 +346,12 @@ Do not disable hooks to force a PR through. ## Suggests Next -After a successful ship, suggest release only when the landed work forms a coherent release batch or the user asks to publish. +After a successful ship, suggest release only when the landed work forms a coherent batch or the user asks to publish. Release will not re-check the landed PR. ## Related Skills -- **release** -- Publishes a version from already-landed work -- **git-workflow** -- Branching, PR, commit, and squash merge conventions -- **foundations** -- Verification, code review, and production readiness -- **documentation-standards** -- Changelog, docs, and durable prose quality -- **reflect** -- Updates strategy from significant shipped work +- **release** — Publishes a version from already-landed work; does not re-review those PRs +- **git-workflow** — Branching, PR, commit, and squash merge conventions +- **foundations** — Verification, code review, and production readiness +- **documentation-standards** — Changelog, docs, and durable prose quality +- **reflect** — Updates strategy from significant shipped work diff --git a/content/skills/triage/SKILL.md b/content/skills/triage/SKILL.md index c3213e184..a6274e02c 100644 --- a/content/skills/triage/SKILL.md +++ b/content/skills/triage/SKILL.md @@ -1,7 +1,15 @@ --- name: triage description: >- - Processes the local intake queue from loaf intake list: unresolved sparks, ideas, brainstorms, tracked and deferred Intents, and unmigrated legacy deferrals. Use when the user asks "triage", "process my backlog", or wants dispositions chosen across intake items. Produces explicit dispositions: discard, retain, track as Intent, defer, resume, resolve, explore, hand to pitch, or hand to shape. Not for reading a single known item (use loaf intent show or journal directly), capturing new ideas (use idea), problem discovery (use pitch), or bounding one chosen direction (use shape). + Processes the local intake queue from loaf intake list: unresolved sparks, + ideas, and brainstorms. Use when the user asks "triage", "process my + backlog", or wants dispositions chosen across intake items. Produces + explicit dispositions: discard, retain as spark/idea, file as backlog + issue, resume exploration, resolve, hand to pitch, or hand to shape (issue + preparation). Not for reading a single known item (use loaf issue show, + loaf spark show, loaf idea show, or journal directly), capturing new ideas + (use idea), problem discovery (use pitch), or bounding one chosen direction + (use shape). --- # Triage @@ -18,7 +26,7 @@ Process the intake queue. Triage is the public funnel where captured material me - Quick Reference - Process - Dispositions -- Legacy Deferrals +- Leftover kinds - Guardrails - Related Skills @@ -28,62 +36,67 @@ Process the intake queue. Triage is the public funnel where captured material me - Read the queue with `loaf intake list --json`; it projects every unresolved logical item exactly once with its provenance and exact read command. - Present everything before acting — the user decides each disposition; never auto-promote, auto-discard, or auto-convert. - The CLI never classifies: you and the user interpret each item; commands perform the chosen operation deterministically. -- Capture, Intent, and Exploration are different claims: a spark or idea is retained material, a tracked Intent is deliberately tracked work, a deferral is an Intent disposition with an immutable payload, an Exploration is an inquiry. Do not conflate them to save a step. +- Capture, issue, and Exploration are different claims: a spark or idea is retained material, a backlog issue is deliberately tracked work, an Exploration is an inquiry. Do not conflate them to save a step. - One pass through the queue — don't loop or re-present items. -- **Two doors into a Change:** items needing problem discovery hand to pitch, which owns `loaf change init <slug> --brief` and brief authoring; well-understood directions hand to shape. When capture should precede shaping without a full pitch, run `loaf change init <slug> --brief` and seed `brief.md` with the original ask, then hand to shape. +- **Two doors into issue work:** items needing problem discovery hand to pitch; well-understood directions hand to shape (issue preparation). Worth keeping but not ready for either door files as a backlog issue (`loaf issue new "<title>" --status backlog`, optional `--parent`, optional `loaf issue bucket`). Triage never runs `loaf issue start`, never opens PRs, and never invents Git artifacts. ## Verification - Every presented item has a recorded disposition or an explicit "leave for next triage". -- Tracked and deferred choices exist as Intents with the expected derived disposition (`loaf intent list`). +- Filed directions exist as backlog issues (`loaf issue list --status backlog`) and no longer appear in `loaf intake list` once their captures are resolved or archived. - Discards are resolved or archived through their own commands and no longer appear in `loaf intake list`. -- No Linear or tracker operation was attempted; publication is a later concern outside this Change. +- No Linear or tracker operation was attempted; publication is a later concern outside triage. ## Quick Reference | Item kind | Comes from | Typical dispositions | |-----------|-----------|----------------------| -| spark | `loaf spark capture` moments | discard, promote to idea, track as Intent | -| idea | idea capture | archive, explore, track as Intent, hand to pitch, hand to shape | -| brainstorm | archived divergent sessions | archive, explore, promote, hand to pitch | -| intent (tracked) | `loaf intent create` | keep tracking, defer, resolve, explore, hand to pitch, hand to shape | -| intent (deferred) | `loaf intent defer` or adapter | resume, resolve, leave deferred | -| legacy_deferral | pre-conversion `journal defer` | read, then optionally convert (see Legacy Deferrals) | +| spark | `loaf spark capture --scope <scope> --text <text>` | discard, retain, promote to idea, file as backlog issue, resume exploration, resolve, hand to pitch, hand to shape | +| idea | `loaf idea capture --title "<title>"` | archive, retain, file as backlog issue, resume exploration, resolve, hand to pitch, hand to shape | +| brainstorm | `loaf brainstorm capture` | archive, retain, promote to idea, file as backlog issue, resume exploration, hand to pitch, hand to shape | ## Process 1. **Scan.** Run `loaf intake list --json`. Summarize counts by kind, then list each item with its title, disposition or status, and read command. 2. **Read on demand.** Use each item's `read_command` verbatim when the user wants detail before deciding. If a read command fails, record the exact command and error in the summary as `unreadable`, make no semantic disposition for that item, continue the pass, and offer a factual diagnostic step (`loaf state doctor --json`) afterward. Never persist unreadable as a status. 3. **Decide per item.** Present the applicable dispositions and perform exactly the chosen one. -4. **Summarize.** Report what was discarded, retained, tracked, deferred, resumed, resolved, or handed onward, and journal notable decisions. +4. **Summarize.** Report what was discarded, retained, filed as backlog issues, resumed as explorations, resolved, or handed to pitch or shape, and journal notable decisions. ## Dispositions - **Discard** — ideas and brainstorms: `loaf idea archive <ref> --reason <r>` or `loaf brainstorm archive <ref> --reason <r>`. A spark is resolved against the entity that addressed it (`loaf spark resolve <ref> --by <entity> --reason <r>`); a pure dead-end spark currently has no deterministic discard operation — leave it retained, journal the judgment, and never invent a resolving entity. -- **Retain as capture** — do nothing; open captures resurface next triage. -- **Track as Intent** — two steps: create the Intent with the capture as its source, then close the capture against it so the direction appears once. `loaf intent create --title <t> --body <self-sufficient body> --from <capture-ref>`, then `loaf spark resolve <capture-ref> --by <intent-ref>` or `loaf idea resolve <capture-ref> --by <intent-ref>` (brainstorms: `loaf brainstorm archive <ref> --reason "tracked as <intent-ref>"`). -- **Defer** — an existing Intent: `loaf intent defer <ref> --why <w> --boundary <b> --trigger <t> --operation-id <key>`; a new deferred direction needs the full skeleton: `loaf intent create --title <t> --body <b> --disposition deferred --why <w> --boundary <bd> --trigger <tr> --operation-id <key> [--from <source-ref>]`. -- **Resume** — `loaf intent resume <ref> --reason <why now>`; appends a tracked disposition linked to the deferral it supersedes. -- **Resolve** — `loaf intent resolve <ref> --reason <outcome>`; history is never rewritten. -- **Explore** — agent technique for genuinely undecided directions (Explorations and checkpoints); not a user slash entry — prefer pitch when the human needs problem discovery first, then reach for explore from inside that work if still undecided. -- **Pitch** — items needing problem discovery hand to pitch, which owns init and brief authoring; resolve the promoted item against the created change (`loaf spark resolve` / `loaf idea resolve` / archive brainstorm with the change as the reason). -- **Shape** — when a direction is already well-understood and ready for bounded delivery, hand it to shape; triage never creates fully-materialized Changes, branches, or worktrees (capture-only brief seeding is the Critical Rules exception above). +- **Retain as spark/idea** — do nothing to leave the capture open, or promote into the other capture primitive: capture the idea first (`loaf idea capture --title "..."`), then `loaf spark promote <spark> --to-idea <idea>` or `loaf brainstorm promote <brainstorm> --to-idea <idea>`. Open captures resurface next triage. +- **File as backlog issue** — two steps so the direction appears once. Create the issue, then close the capture against it: -## Legacy Deferrals + ```bash + loaf issue new "<title>" --status backlog [--parent <ref>] [--kind delivery|decision] [--fog <text>] [--body <text>] + loaf issue bucket <issue-ref> now|next|later # optional; labels only, never a constraint + loaf spark resolve <capture-ref> --by <issue-ref> + # or: loaf idea resolve <capture-ref> --by <issue-ref> + # brainstorms: loaf brainstorm archive <ref> --reason "filed as <issue-ref>" + ``` -Items of kind `legacy_deferral` are pre-conversion `journal defer` records. They stay visible and readable until the explicit, backup-first conversion is run; nothing disappears while migration is pending. When the user wants them converged, offer `loaf state migrate deferrals --dry-run` to preview the project-specific manifest and `--apply` only with explicit consent — apply verifies a whole-database backup first and preserves every legacy row. + Use `--kind decision` when filing a sharp question. Copy still-unsharp questions into `--fog` (create-time only). `--parent` nests under an existing issue; omit it for a different problem. +- **Resume exploration** — agent technique for genuinely undecided directions (Explorations and checkpoints); not a user slash entry. Prefer pitch when the human needs problem discovery first, then reach for explore from inside that work if still undecided. Resume with `loaf exploration context <ref>` when a named Exploration already exists. +- **Resolve** — the capture is already represented elsewhere. `loaf spark resolve <ref> --by <entity> --reason <r>` or `loaf idea resolve <ref> --by <entity>`. History is never rewritten. +- **Hand to pitch** — items needing problem discovery hand to pitch. Resolve the capture against the issue once one exists (`loaf spark resolve` / `loaf idea resolve --by <issue-ref>` / archive the brainstorm with that issue as the reason). +- **Hand to shape** — when a direction is already well-understood and ready for bounded delivery, hand it to shape for issue preparation. Triage never writes definition-of-done criteria, never runs `loaf issue check`, and never creates branches or worktrees. + +## Leftover kinds + +`loaf intake list` may still include `intent` and `legacy_deferral` items. Do not create new `intent` rows. Treat leftover directions like any other capture: file a backlog issue if worth keeping, or leave them for a later pass. Do not offer conversion commands that recreate the old tracked/deferred row. ## Guardrails 1. **User decides every disposition** — present, don't decide. 2. **Batch presentation, individual decisions** — show the full queue, then process one item at a time. 3. **Log everything** — no silent discards, promotions, or conversions. -4. **Deferred is not forgotten** — deferred Intents remain active truth in `loaf journal context` until resumed or resolved. +4. **Filed is not forgotten** — backlog issues remain on `loaf issue list` and may appear on `loaf issue frontier` until their status changes. Buckets are labels only. ## Related Skills - **idea** — capture a new idea (fast, minimal friction) - **pitch** — problem-discovery ceremony for items that need framing before shape - **explore** — agent technique for divergent inquiry with portable checkpoints -- **shape** — develop a well-understood direction into a bounded Change +- **shape** — develop a well-understood direction into a bounded issue - **housekeeping** — flags stale artifacts; does not choose dispositions diff --git a/content/skills/wrap/SKILL.md b/content/skills/wrap/SKILL.md index d0161ab5e..0350c4f9a 100644 --- a/content/skills/wrap/SKILL.md +++ b/content/skills/wrap/SKILL.md @@ -135,7 +135,7 @@ After the wrap-up report, suggest housekeeping if it wasn't run this session and ## Report Format -Use backtick formatting for code identifiers, file paths, spec/task IDs, version numbers, status values, and CLI commands. Use uppercase for spec and task IDs (`SPEC-029`, not `spec-029`). +Use backtick formatting for code identifiers, file paths, issue IDs, version numbers, status values, and CLI commands. Use uppercase for issue IDs (`LOAF-29`, not `loaf-29`). ```markdown ## Session Wrap-Up diff --git a/content/templates/journal.md b/content/templates/journal.md index caf7409fd..9a0a61f8f 100644 --- a/content/templates/journal.md +++ b/content/templates/journal.md @@ -13,7 +13,7 @@ not create or edit journal markdown as the source of truth — use ## Entries -[YYYY-MM-DD HH:MM] skill(implement): implementing TASK-042 +[YYYY-MM-DD HH:MM] skill(implement): implementing LOAF-42 [YYYY-MM-DD HH:MM] decision(scope): description of decision [YYYY-MM-DD HH:MM] discover(scope): something learned [YYYY-MM-DD HH:MM] block(scope): what is blocked diff --git a/dist/amp/.loaf-target-manifest.json b/dist/amp/.loaf-target-manifest.json index dfce6dc25..b24956738 100644 --- a/dist/amp/.loaf-target-manifest.json +++ b/dist/amp/.loaf-target-manifest.json @@ -11,7 +11,7 @@ "id": "managed-instructions", "kind": "instruction", "destination": "project-instructions", - "sha256": "ac6debb93fcd1b2d7806681c446f3b7d9691a43a872831a969c82a7470b0b30d" + "sha256": "21e91a6226ead7de1ef1d3d61c4e2060dc9763e8485192f6efc0060a09bbe66e" }, { "id": "plugin:.amp/plugins/loaf.ts", diff --git a/dist/amp/skills/bootstrap/SKILL.md b/dist/amp/skills/bootstrap/SKILL.md index 33ceb4e3f..e9ca2819a 100644 --- a/dist/amp/skills/bootstrap/SKILL.md +++ b/dist/amp/skills/bootstrap/SKILL.md @@ -30,7 +30,7 @@ First-contact project setup: detect state, interview the builder, populate proje - Guardrails - Related Skills -Series-prep lives under Finalization (phase between Knowledge Base Scaffolding and Next Steps). +Series-prep lives under Finalization (phase between Knowledge Base Scaffolding and Next Steps): the initial arc becomes backlog issues, not folders. **Input:** $ARGUMENTS @@ -43,8 +43,8 @@ Series-prep lives under Finalization (phase between Knowledge Base Scaffolding a - **Always interview** -- even with a rich brief, confirm understanding through structured questions — one at a time, with a recommendation, using your harness's structured question tool if it has one - **Pitched BRIEF is discovery-already-done** -- when `docs/BRIEF.md` has `source: pitch`, do not re-excavate the problem space; quote-back and gap-fill only for operating-document population - **BRIEF is input, not output** -- the BRIEF is raw intake. Extract every useful fact into VISION/STRATEGY/ARCHITECTURE/AGENTS during bootstrap. -- **BRIEF is archeological after bootstrap** -- once extraction completes (including series-prep reading scoped concepts from it), the BRIEF is a frozen historical snapshot. No skill, agent, command, or template should reference `docs/BRIEF.md` post-bootstrap. Operating documents and minted change briefs must stand on their own. -- **Series-prep never auto-shapes and never creates branches** -- every mint is user-confirmed; no priority, date, or dependency fields; concepts without a coarse `target_release` stay BRIEF lines, sparks, or Intents +- **BRIEF is archeological after bootstrap** -- once extraction completes (including series-prep reading scoped concepts from it), the BRIEF is a frozen historical snapshot. No skill, agent, command, or template should reference `docs/BRIEF.md` post-bootstrap. Operating documents and minted issue bodies must stand on their own. +- **Series-prep never auto-shapes and never creates branches** -- every mint is user-confirmed; no priority, date, or dependency fields; buckets are labels, never bindings; concepts that fail granularity stay BRIEF lines or sparks - **Suggest, don't execute** -- recommend next skills at the end, never auto-run them - **Log first** -- log invocation before interviewing: `loaf journal log "skill(bootstrap): <project or intake>"` - **Log outcome** -- log bootstrap completion to the project journal: `loaf journal log "decision(bootstrap): project bootstrapped, mode detected"` @@ -56,7 +56,7 @@ Series-prep lives under Finalization (phase between Knowledge Base Scaffolding a - All expected operating documents (`docs/VISION.md`, `AGENTS.md` at minimum) exist and contain populated content - Useful BRIEF content has been extracted into operating documents (no future reader should need to open the BRIEF) - When `source: pitch`, the interview was gap-only (no re-excavation of already-specific problem sections) -- When series-prep ran: each minted folder has `change.json` with stamped `target_release`, a standalone problem-space `brief.md`, zero-violation captured state via explicit-path `loaf change check <folder> --json`, and its own docs-only commit (never a batch); no branches created for the series; no auto-shape +- When series-prep ran: each minted row is a backlog issue (`loaf issue new "<title>" --body "<problem narrative>" --status backlog`) with a standalone problem-space body; an advisory bucket (`loaf issue bucket <ref> now|next|later`) may be set — buckets are labels, never bindings; `loaf issue check <ref>` only when a capture is shaped enough to check, otherwise nothing (a backlog issue with a problem body needs no ceremony); no folders, no docs-only commits; no branches created for the series; no auto-shape - Root `AGENTS.md` is a real file; on Claude Code, the compatibility symlink `.claude/CLAUDE.md -> ../AGENTS.md` exists (see Finalization) - Key decisions and interview outcomes were logged with `loaf journal log` and are readable with `loaf journal recent` @@ -237,7 +237,7 @@ Pitch owned the problem-space grill. Bootstrap does not re-excavate. The pitch 1. **Acknowledge the pitch** — name that `docs/BRIEF.md` carries `source: pitch` and that problem discovery is already done. 2. **Summarize what pitch captured** — short section-by-section gist (problem, who, alternatives, value, constraints, sequencing, open questions). The builder should hear continuity with the pitch closing ceremony, not a cold restart. -3. **State what bootstrap will do now** — interview only on gaps for operating-document population (VISION, STRATEGY, ARCHITECTURE, AGENTS), then series-prep the initial arc of captured changes. Do not re-grill the problem space. +3. **State what bootstrap will do now** — interview only on gaps for operating-document population (VISION, STRATEGY, ARCHITECTURE, AGENTS), then series-prep the initial arc as backlog issues. Do not re-grill the problem space. Then continue: @@ -424,58 +424,62 @@ The journal should capture: Use [templates/journal.md](templates/journal.md) only as the rendered entry format reference; do not hand-author journal markdown as the source of truth. -### 4. Series-Prep (initial arc as captured changes) +### 4. Series-Prep (initial arc as backlog issues) -After operating documents are populated (and Knowledge Base Scaffolding above has run), close bootstrap by minting the BRIEF's initial arc as **captured promise carriers** — brief-only change folders bound to a coarse `target_release`, each landed as its own docs-only commit. Series-prep is not roadmap planning: no milestone entities, no dates, no priorities, no dependency fields. Sequencing is prose in each brief; cohort membership is the shared `target_release`. +After operating documents are populated (and Knowledge Base Scaffolding above has run), close bootstrap by minting the BRIEF's initial arc as **backlog issues** — SQLite rows with a problem-space body and an optional advisory bucket label (`loaf issue bucket <ref> now|next|later`). Buckets are labels, never bindings. Series-prep is not roadmap planning: no milestone entities, no dates, no priorities, no dependency fields. Sequencing is prose in each issue body. No folders, no docs-only commits per capture — rows, not files. **When to run** - Always offer series-prep when a project BRIEF exists and names more than one scoped concept (typical after a pitched BRIEF; also after a rich non-pitch brief). -- If the BRIEF is a single atomic concept with no series, say so and skip to Next Steps — one future shape or a single capture later is enough. -- Series-prep **reads** the BRIEF during this phase only. After bootstrap ends, nothing references `docs/BRIEF.md` again; minted change briefs and operating docs stand alone. +- If the BRIEF is a single atomic concept with no series, say so and skip to Next Steps — one future shape or a single issue later is enough. +- Series-prep **reads** the BRIEF during this phase only. After bootstrap ends, nothing references `docs/BRIEF.md` again; minted issue bodies and operating docs stand alone. **Procedure** 1. **Enumerate concepts** with the builder from the BRIEF's scoped problem space (Sequencing and Relationships, Open Questions, and distinct problem threads in Problem Statement). List candidates as recommendation-first options using your harness's structured question tool if it has one. -2. **Apply granularity** per [references/interview-guide.md](references/interview-guide.md) (Series-Prep Granularity): a concept earns its own captured change when it is independently shippable **and** its problem can be **stated precisely now** (the mint-time specifiability test — not answered now, stated now) without the others; otherwise it stays a BRIEF line, becomes a spark, or an Intent — never a half-minted folder. +2. **Apply granularity** per [references/interview-guide.md](references/interview-guide.md) (Series-Prep Granularity): a concept earns its own backlog issue when it is independently shippable **and** its problem can be **stated precisely now** (the mint-time specifiability test — not answered now, stated now) without the others; otherwise it stays a BRIEF line or becomes a spark — never a half-minted row. 3. **Per confirmed concept (one at a time — never batch):** - 1. Confirm mint with the builder (slug, coarse `target_release`, one-line problem restatement). If the builder will not bind even a coarse target, do not mint — park as spark/Intent/BRIEF line. - 2. Propose a **local slug** that names the concept, never another work unit (`spec-042`, task ids, change folder names). Confirm the slug. - 3. Run capture init: + 1. Confirm mint with the builder (title, optional advisory bucket, one-line problem restatement). Buckets are labels, never bindings — a missing bucket does not block mint. If the concept fails granularity, do not mint — park as spark or BRIEF line. + 2. Propose a **working title** that names the concept, never another work unit (issue aliases, task ids). Confirm the title. + 3. **Seed a problem-space-only narrative** from the BRIEF's content for that concept (Problem Statement, Who Has It, Current Alternatives, Value Proposition, Constraints, Sequencing and Relationships as prose order relative to the arc, Sources if any, Open Questions). Do not copy solution design. The seeded body must stand alone as intent for later shape — cold-read without the project BRIEF or this session. + 4. Mint the backlog issue: ```bash - loaf change init <slug> --brief + loaf issue new "<title>" --body "<problem narrative>" --status backlog ``` - Creates `docs/changes/YYYYMMDD-<slug>/` with `change.json` + `brief.md` only. - 4. **Seed `brief.md` problem-space-only** from the BRIEF's content for that concept (Problem Statement, Who Has It, Current Alternatives, Value Proposition, Constraints, Sequencing and Relationships as prose order relative to the arc, Sources if any, Open Questions). Do not copy solution design. The seeded brief must stand alone as intent for later shape — cold-read without the project BRIEF or this session. - 5. **Stamp `target_release`** on that folder's `change.json` with the builder's coarse binding (canonical `MAJOR.MINOR.PATCH`, no `v`, no prerelease). Series-prep mints only targeted captures (promise-carrier path). - 6. **Pre-landing guard** (required before every commit): + Creates a SQLite row, not a folder. Use `--body -` or `--body-file <path>` when the narrative is long (see `loaf issue new --help`). + 5. **Optionally set an advisory bucket** (a label, never a binding): ```bash - loaf change check <folder> --json + loaf issue bucket <ref> now|next|later ``` + 6. **Validate** only when the capture is shaped enough to check: - Must report zero violations and captured state. Then **read `<folder>/change.json` directly** and confirm the stamped `target_release` matches what the builder bound. Bare `loaf change check` resolves by branch and can miss a capture elsewhere — always pass the explicit folder path. - 7. **Land as its own docs-only commit on the default branch** (one commit per capture, never a batch). Example subject: `docs(change): capture <slug> brief`. Bootstrap prepares the commit; never push; never open a PR. + ```bash + loaf issue check <ref> + ``` + + A backlog issue with a problem body is capture-only and needs no ceremony — skip the check. Do not add criteria or an out-of-scope statement during series-prep (that is shape). + 7. **Do not land a docs-only commit.** The row is the artifact. Never push; never open a PR; never create a branch. 4. **Guards (hard):** - Every mint is user-confirmed — never auto-mint the whole list - - Never auto-run shape and never create slug branches during series-prep - - No priority, date, estimate, or dependency fields on captures - - No batching multiple captures into one commit - - Concepts without a coarse target stay BRIEF lines, sparks, or Intents + - Never auto-run shape and never create branches during series-prep + - No priority, date, estimate, or dependency fields on issues + - No folders, no docs-only commits per capture — rows, not files + - Concepts that fail granularity stay BRIEF lines or sparks; buckets are labels, never bindings **After the series** -Log the arc: `loaf journal log "decision(bootstrap): series-prep minted <n> captures for <cohort or targets>"`. Hand off by naming the first capture folder for shape when the builder is ready. +Log the arc: `loaf journal log "decision(bootstrap): series-prep minted <n> backlog issues"`. Hand off by naming the first issue ref for shape when the builder is ready. ### 5. Next Steps Suggest relevant next steps based on what was learned: -- shape -- on a series-prep capture (or any ready concept) to promote the folder and bound implementation +- shape -- on a series-prep backlog issue (or any ready concept) to bound implementation - pitch -- if a new concept still needs problem discovery (not for re-grilling the BRIEF) -- idea -- if specific feature ideas emerged during the interview and should not become captures yet +- idea -- if specific feature ideas emerged during the interview and should not become issues yet - research -- if there are open questions that need investigation - `loaf doctor` -- to verify the setup is healthy @@ -502,18 +506,18 @@ When the interactive interview path is unavailable, bootstrap the operating docu 2. **Always interview** -- even with a rich brief, confirm understanding; when `source: pitch`, gap-fill only 3. **Never overwrite** -- existing documents require explicit confirmation 4. **Draft, then review** -- present documents section-by-section -5. **Extract, don't preserve** -- pull every useful fact from the BRIEF into operating docs (and series-prep seeds change briefs from it once). The BRIEF is archeological after bootstrap; nothing should reference it again. +5. **Extract, don't preserve** -- pull every useful fact from the BRIEF into operating docs (and series-prep seeds issue bodies from it once). The BRIEF is archeological after bootstrap; nothing should reference it again. 6. **Record the session** -- decisions and rationale are preserved 7. **Suggest, don't execute** -- recommend next skills, don't auto-run them; series-prep never auto-shapes or creates branches 8. **Interview structured** -- one question at a time, with a recommendation, using your harness's structured question tool if it has one -9. **Series-prep is not roadmap planning** -- coarse `target_release` + prose sequencing only; no dates, priorities, or dependency fields +9. **Series-prep is not roadmap planning** -- advisory bucket labels (never bindings) + prose sequencing only; no dates, priorities, or dependency fields --- ## Related Skills -- **pitch** -- Authors a project-scale `docs/BRIEF.md` with `source: pitch` (or a change-scale brief); bootstrap consumes the pitched BRIEF with gap-only interview and series-prep -- **shape** -- Bound a captured change into a contract (promotes brief-only folders; often follows series-prep) +- **pitch** -- Authors a project-scale `docs/BRIEF.md` with `source: pitch` (or an issue-scale problem narrative); bootstrap consumes the pitched BRIEF with gap-only interview and series-prep +- **shape** -- Bound a backlog issue into a contract (often follows series-prep) - **explore** -- Agent technique when a concept that emerges during bootstrap is still undecided (not a user front door; prefer pitch for human problem discovery) - **research** -- Investigate topics and open questions - **idea** -- Quick-capture feature ideas that emerge during bootstrap diff --git a/dist/amp/skills/bootstrap/references/interview-guide.md b/dist/amp/skills/bootstrap/references/interview-guide.md index f3fc0c1ef..83f583396 100644 --- a/dist/amp/skills/bootstrap/references/interview-guide.md +++ b/dist/amp/skills/bootstrap/references/interview-guide.md @@ -358,33 +358,32 @@ Expect 6-10 questions total, mostly in Excavation and Sharpening. Grounding is l ## Series-Prep Granularity -Series-prep (bootstrap Finalization) turns the project BRIEF's scoped concepts into captured change folders. This section resolves when a concept earns a folder versus staying a BRIEF line, spark, or Intent. Full procedure: bootstrap SKILL.md Finalization → Series-Prep. +Series-prep (bootstrap Finalization) turns the project BRIEF's scoped concepts into backlog issues. This section resolves when a concept earns a row versus staying a BRIEF line or spark. Full procedure: bootstrap SKILL.md Finalization → Series-Prep. -### Earns its own captured change when both are true +### Earns its own backlog issue when both are true -1. **Independently shippable** — the concept could land as a meaningful release unit without waiting on the other scoped concepts to ship in the same commit or PR. Sharing a coarse `target_release` cohort is fine; hard entanglement is not. -2. **Specifiability (mint test)** — a concept mints only if its problem can be **stated precisely now** (not answered now): a cold reader can name the friction, who has it, the current alternative, and the value for *this* concept alone, without needing sibling folders or inventing what was meant. Everything vaguer stays a BRIEF line, spark, or Intent — never pre-sliced into a fake capture. +1. **Independently shippable** — the concept could land as a meaningful release unit without waiting on the other scoped concepts to ship in the same commit or PR. Sharing an advisory bucket is fine (buckets are labels, never bindings); hard entanglement is not. +2. **Specifiability (mint test)** — a concept mints only if its problem can be **stated precisely now** (not answered now): a cold reader can name the friction, who has it, the current alternative, and the value for *this* concept alone, without needing sibling issues or inventing what was meant. Everything vaguer stays a BRIEF line or spark — never pre-sliced into a fake row. -When both hold and the builder will bind a coarse `target_release`, mint: `loaf change init <slug> --brief`, seed problem-space-only, stamp the target, pre-landing check + `change.json` read-back, one docs-only commit on the default branch. +When both hold, mint: `loaf issue new "<title>" --body "<problem narrative>" --status backlog`, seed problem-space-only, optionally `loaf issue bucket <ref> now|next|later` (buckets are labels, never bindings). Run `loaf issue check <ref>` only when the capture is shaped enough to check; a backlog issue with a problem body needs no ceremony. No folders, no docs-only commits — rows, not files. -### Stays a BRIEF line, spark, or Intent when any is true +### Stays a BRIEF line or spark when any is true - The concept only makes sense as a clause of a larger problem (it cannot be stated alone). - Its problem cannot yet be stated precisely — coarse intent only; minting would invent precision. - It is a solution-space detail, implementation task, or "nice-to-have" flavor — not a shippable problem boundary. -- The builder will not bind even a coarse `target_release` (untargeted series members are not promise carriers on main; keep them as sparks/Intents or leave them as prose in the BRIEF until a target exists). -- Splitting would create two folders that always ship as one unit — keep one folder and name the sub-concerns in Sequencing prose. +- Splitting would create two issues that always ship as one unit — keep one issue and name the sub-concerns in Sequencing prose. ### Interview moves during series-prep -- Recommend a decomposition first (list candidate folders + what stays in the BRIEF), then confirm each mint one at a time. +- Recommend a decomposition first (list candidate issues + what stays in the BRIEF), then confirm each mint one at a time. - Challenge over-splitting: "Can this problem be stated precisely now without that one?" If no, merge candidates or leave as BRIEF lines. -- Challenge under-splitting: "Is this independently shippable, or are we smuggling two products into one capture?" -- Never invent priority ranks, dates, or machine dependency fields — sequencing is prose; cohort is `target_release`. +- Challenge under-splitting: "Is this independently shippable, or are we smuggling two products into one issue?" +- Never invent priority ranks, dates, or machine dependency fields — sequencing is prose; buckets are labels, never bindings. ### Standalone cold-read test (H3) -Each minted brief must name its problem without reference to the bootstrap session or to `docs/BRIEF.md`. If a draft says "as discussed" or "per the project brief," rewrite until the problem stands alone. +Each minted issue body must name its problem without reference to the bootstrap session or to `docs/BRIEF.md`. If a draft says "as discussed" or "per the project brief," rewrite until the problem stands alone. --- diff --git a/dist/amp/skills/bootstrap/templates/brief.md b/dist/amp/skills/bootstrap/templates/brief.md index d7b58d0db..5b550a307 100644 --- a/dist/amp/skills/bootstrap/templates/brief.md +++ b/dist/amp/skills/bootstrap/templates/brief.md @@ -39,7 +39,7 @@ archived: true # Always true -- BRIEF is a historical snapshot, not a w ## Sequencing and Relationships -[How the initial arc hangs together — which concepts belong as early changes, what depends on what, release cohort stated as prose. No machine relation fields; narrative order only.] +[How the initial arc hangs together — which problems become early backlog issues, what depends on what, sequencing stated as prose. No machine relation fields; narrative order only.] ## Sources and Research Links diff --git a/dist/amp/skills/bootstrap/templates/journal.md b/dist/amp/skills/bootstrap/templates/journal.md index caf7409fd..9a0a61f8f 100644 --- a/dist/amp/skills/bootstrap/templates/journal.md +++ b/dist/amp/skills/bootstrap/templates/journal.md @@ -13,7 +13,7 @@ not create or edit journal markdown as the source of truth — use ## Entries -[YYYY-MM-DD HH:MM] skill(implement): implementing TASK-042 +[YYYY-MM-DD HH:MM] skill(implement): implementing LOAF-42 [YYYY-MM-DD HH:MM] decision(scope): description of decision [YYYY-MM-DD HH:MM] discover(scope): something learned [YYYY-MM-DD HH:MM] block(scope): what is blocked diff --git a/dist/amp/skills/breakdown/SKILL.md b/dist/amp/skills/breakdown/SKILL.md deleted file mode 100644 index e3a1260da..000000000 --- a/dist/amp/skills/breakdown/SKILL.md +++ /dev/null @@ -1,330 +0,0 @@ ---- -name: breakdown -description: >- - Decomposes specifications into atomic tasks with dependencies and priorities. - Use when the user asks "break this down" or "create tasks for this spec." - Produces task files with estimates, dependencies, and acceptance criteria. Not - for shaping ideas (use shape) or implementation work (use implement). -version: 0.2.21 ---- - -# Breakdown - -Decompose specifications into atomic, implementable tasks. - -## Contents -- Critical Rules -- Verification -- Quick Reference -- Task Breakdown Philosophy -- Task Backend Detection -- Process -- Linear-Native Mode -- Local-Tasks Mode -- Priority Levels -- Guardrails -- Related Skills - -**Input:** $ARGUMENTS - ---- - -## Critical Rules - -- **One concern per task** -- never mix unrelated layers (backend + frontend) in a single task -- **Every task includes its own verification** -- no separate "verify" tasks; each task must have an observable done condition -- **Own the decisions** -- decide granularity and priorities autonomously; only ask the user when two equally valid orderings have genuinely different trade-offs -- **Keep tests with the code they test** -- never split implementation and tests into separate tasks -- **Update spec status** -- mark the spec as `implementing` after tasks are created -- **One backend only** -- in Linear-native mode create Linear issues and NO local `TASK-NNN.md`; in local mode create local tasks and make NO Linear calls -- **Spec file is always local** -- in both modes, the spec stays in `.agents/specs/`. The Linear parent issue, when present, is a rollup pointing to the spec, not a re-host of it -- **Log outcome** -- log breakdown to the project journal: `loaf journal log "decision(breakdown): SPEC-NNN → N tasks created"` - ---- - -## Verification - -- Each created task has a clear title, priority, file hints, verification command, and observable done condition -- The dependency graph has no cycles and reflects actual implementation order -- Spec status has been updated to `implementing` -- **Linear-native mode only:** parent issue exists, labeled `spec`, with description pointing to the local spec file; N sub-issues have `parentId` set; zero local task rows or `TASK-NNN.md` files were created; spec frontmatter has `linear_parent` and `linear_parent_url` populated -- **Local-tasks mode only:** N local tasks exist in `loaf task list` with compatibility `.md` files when configured; no Linear calls were made - ---- - -## Quick Reference - -### Priority Levels - -| Priority | Loaf | Linear Priority | -|----------|------|-----------------| -| P0 | Urgent/blocking -- drop everything | Urgent (1) | -| P1 | High -- work next | High (2) | -| P2 | Normal -- scheduled work (default) | Normal (3) | -| P3 | Low -- when time permits | Low (4) | - -### Right-Sizing Rules - -| Rule | Guideline | -|------|-----------| -| **One agent type** | Completable by a single implementer (after skills narrowing) | -| **One concern** | Touches one layer, service, or component | -| **Context-appropriate** | Fits in model context with room for exploration | -| **Not over-fragmented** | Don't split what naturally belongs together | - -### Mode Selection - -| `integrations.linear.enabled` in `.agents/loaf.json` | Mode | See | -|------------------------------------------------------|------|-----| -| `true` | Linear-native | [Linear-Native Mode](#linear-native-mode) | -| `false` or absent | Local-tasks | [Local-Tasks Mode](#local-tasks-mode) | - ---- - -## Task Breakdown Philosophy - -**Primary principle: separation of concerns.** - -### The Right Size Test - -1. Can a single implementer complete this? If no, split by concern -2. Does it touch multiple unrelated concerns? If yes, split by concern -3. Will the agent need too much context? If yes, split into smaller coherent units -4. Am I splitting just to have more tasks? If yes, merge back - -### Anti-Patterns - -| Don't | Do Instead | -|-------|------------| -| Split backend + tests into separate tasks | Keep tests with the code they test | -| Create a task per file | Group files by concern | -| Separate "implement" and "verify" tasks | Every task includes its own verification | -| Copy the full spec text into the Linear parent issue | Summarize + link to the local spec file | -| Create both local `TASK-NNN.md` and Linear sub-issues | Pick one backend; never mix | - ---- - -## Task Backend Detection - -Read `.agents/loaf.json`: - -```json -{ - "integrations": { - "linear": { "enabled": true } - } -} -``` - -If `integrations.linear.enabled` is `true`, proceed in **Linear-native mode**. -Otherwise, proceed in **Local-tasks mode**. - -If `.agents/loaf.json` is missing, default to local-tasks and note the -assumption in the project journal. - ---- - -## Process - -### Step 1: Parse Input - -`$ARGUMENTS` should reference a spec (e.g., "SPEC-001"). If unclear, list available specs. - -### Step 2: Read the Spec - -Extract: test conditions, scope, implementation notes, priority ordering, complexity size. - -### Step 3: Identify Task Boundaries - -Break down by concern (data layer, backend, frontend, infrastructure, etc.). One concern per task. Explicit dependencies for sequential tasks. - -### Step 4: Decide Priorities and Granularity - -Own the granularity and priority decisions. Apply the Right Size Test, assign priorities -based on dependencies, priority order, and go/no-go gates, and do a self-review pass. Do not -defer these decisions to the user — they trust agent judgment here. - -If genuinely uncertain (e.g., two equally valid orderings with different trade-offs), -ask. Otherwise, decide and move on. - -### Step 5: Draft Task List - -Draft tasks following [task template](templates/task.md). Each task needs: clear title, priority, file hints, verification command, observable done condition, labels (if routing by team). - -### Step 6: Present the Plan - -Show the dependency graph and task summary for awareness before creating anything. -Present it as "here's what I'm creating" not "which option do you prefer?" The user -can still adjust after creation, but the default is to proceed. - -### Step 7: Create Tasks (mode-specific) - -Detect the mode (see [Task Backend Detection](#task-backend-detection)) and follow the -matching section below. Do NOT mix modes. - -- Linear enabled → [Linear-Native Mode](#linear-native-mode) -- Linear disabled or missing → [Local-Tasks Mode](#local-tasks-mode) - -### Step 8: Update Spec and Announce - -Set spec status to `implementing`. In Linear-native mode, also write -`linear_parent` and `linear_parent_url` into the spec's frontmatter. Announce -created tasks and next steps. - ---- - -## Linear-Native Mode - -Spec files stay local and canonical in `.agents/specs/`. Tasks live in Linear -as sub-issues of a parent rollup issue representing the spec. No local -task rows or `TASK-NNN.md` files are created. - -### 7a. Ensure the `spec` label exists - -The `spec` label groups all spec-parent rollup issues so Linear users can -filter for them. - -1. Call `list_issue_labels` to check whether a label named `spec` exists. -2. If missing, create it via `create_issue_label`: - - `name`: `spec` - - `color`: `#5e6ad2` (Linear-ish indigo; implementer may adjust) - - `description`: `Parent rollup issue representing a design spec tracked in the repo at .agents/specs/` - - Prefer workspace-scoped so all teams can filter uniformly. If the MCP - only supports team-scoped labels, create on the default team. -3. Log whether the label was created this run or already existed. This - matters for first-time Loaf setup on a Linear workspace. - -### 7b. Resolve team, project, and state - -Read from `.agents/loaf.json`: - -- **Team:** `linear.default_team` (name) — resolve to team ID via - `list_teams` if not already cached in `known_teams`. -- **Project:** `linear.project.id`. -- **State:** call `list_issue_statuses` for the team, pick the - `unstarted`-type state (typically "Backlog" or "To-Do"). States are - **team-scoped**, not workspace-scoped — always pass the team. - -### 7c. Create the parent issue - -Use `create_issue` with: - -| Field | Value | -|-------|-------| -| `title` | `[SPEC-NNN] <spec title>` | -| `teamId` | from 7b | -| `projectId` | from 7b | -| `stateId` | unstarted state from 7b | -| `priority` | mapped from spec (default High = 2 if unspecified) | -| `labels` | `["spec"]` | -| `description` | Summary synthesized from the spec's Problem Statement + Solution Direction (1–3 paragraphs), ending with: `See .agents/specs/SPEC-NNN-<slug>.md for full text, council references, and strategic tensions.` | - -**Do NOT** copy the full spec body into the description. The local file is canonical. - -### 7d. Check label-group conflicts (pre-flight per sub-issue) - -Linear labels can belong to exclusive groups (e.g., a `type` group where -`feature`, `testing`, `docs`, `bug`, `refactor` are mutually exclusive). -Before creating each sub-issue: - -1. Inspect proposed labels against known group membership (from - `list_issue_labels` group metadata). -2. If a task has more than one label from the same exclusive group, pick the - most appropriate and drop the others. Warn the user about the drop. -3. Log the resolution so the user can override if desired. - -### 7e. Create sub-issues - -For each task, use `create_issue` with: - -| Field | Value | -|-------|-------| -| `parentId` | parent issue ID from 7c | -| `title` | task title | -| `description` | task description + acceptance criteria | -| `teamId` | routed from `team_keywords` or falling back to `default_team` | -| `projectId` | same as parent unless task explicitly belongs elsewhere | -| `stateId` | unstarted state for the target team | -| `priority` | mapped from task priority (see Priority Levels table) | -| `labels` | task labels after conflict resolution (7d) | - -Express dependencies from the spec's Priority Order / dependency graph via -`blockedBy` referencing sibling sub-issue IDs. Create in dependency order so -predecessors exist when referenced. - -### 7f. Do NOT create local task files - -Skip `loaf task create` entirely. Linear issue IDs are the task record. No -local task rows or `TASK-NNN.md` files for this spec's tasks. - -### 7g. Update spec frontmatter - -Add to the spec file's YAML frontmatter: - -```yaml -linear_parent: ENG-198 -linear_parent_url: https://linear.app/<workspace>/issue/ENG-198 -``` - -Use the actual parent issue identifier and URL returned from 7c. - ---- - -## Local-Tasks Mode - -Spec files and task files both live locally. No Linear calls. - -Use `loaf task create --spec SPEC-XXX --title "Task title" --priority P1` -for each task. In SQLite-backed projects, the CLI creates the operational state -row and any compatibility Markdown/index artifacts needed by the current -project. Then edit the `.md` body content (description, acceptance criteria) -only when an authored task prose artifact exists. - -Dependencies are expressed through CLI flags such as `--depends-on`, not by -hand-editing the compatibility index. Priority Order from the spec maps directly -to task `priority` fields. - -See [local-tasks reference](../orchestration/references/local-tasks.md) for -the full local-task model. - ---- - -## Priority Mapping (reference) - -| Loaf | Linear API value | Linear label | -|------|------------------|--------------| -| P0 | `1` | Urgent | -| P1 | `2` | High | -| P2 | `3` | Normal | -| P3 | `4` | Low | - ---- - -## Guardrails - -1. **One concern per task** -- don't mix backend + frontend -2. **Clear verification** -- how to prove it works -3. **Observable done condition** -- not subjective -4. **File hints** -- help session know where to look -5. **Own the decisions** -- decide granularity and priorities, don't defer -6. **Update spec status** -- mark as implementing -7. **One backend only** -- Linear-native creates Linear issues and no local tasks; local-tasks mode creates local tasks and no Linear calls -8. **Summary not copy** -- the Linear parent description summarizes + links; it does not re-host the spec - ---- - -## Suggests Next - -After breakdown completes, suggest implement to start working on the tasks. - -## Related Skills - -- **shape** -- Create specs that get broken down -- **implement** -- Start session for a task or coordinate multiple tasks - -## Topics - -| Topic | Reference | Use When | -|-------|-----------|----------| -| Linear Integration | `orchestration/references/linear.md` | Working out Linear issue structure, labels, parent/child | -| Local Task Model | `orchestration/references/local-tasks.md` | Local-tasks mode details and CLI flags | diff --git a/dist/amp/skills/breakdown/templates/task.md b/dist/amp/skills/breakdown/templates/task.md deleted file mode 100644 index fe74a794f..000000000 --- a/dist/amp/skills/breakdown/templates/task.md +++ /dev/null @@ -1,28 +0,0 @@ -# Task Template - -**Create with:** `loaf task create --title "..." --spec SPEC-XXX` - -```yaml ---- -id: TASK-XXX -title: [Clear action] -spec: SPEC-001 -status: todo -priority: P2 -files: - - [likely file 1] - - [likely file 2] -verify: [command to verify] -done: [observable outcome] ---- - -## Description -[What needs to be done] - -## Acceptance Criteria -- [ ] [Criterion 1] -- [ ] [Criterion 2] - -## Context -See SPEC-001 for full context. -``` diff --git a/dist/amp/skills/council/SKILL.md b/dist/amp/skills/council/SKILL.md index 14d370f53..fa2cc2b4d 100644 --- a/dist/amp/skills/council/SKILL.md +++ b/dist/amp/skills/council/SKILL.md @@ -77,13 +77,13 @@ Councils stay **local**. Even when the workspace uses Linear-native mode, council files live in `.agents/councils/` — they are deliberation artifacts, not executable work, and belong with specs in git. -When a council resolves a spec's open questions: +When a council resolves an issue's open questions: -- Include the spec ID in council frontmatter (e.g., `spec: SPEC-024`). This +- Include the issue ID in council frontmatter (e.g., `issue: LOAF-42`). This is already the common pattern. -- If the spec's `linear_parent` has been populated by breakdown, also - include `linear_parent: ENG-198` in council frontmatter so a reader on - Linear can trace back to the deliberation. +- If the issue is tracked in Linear (tracker authority), also include the + tracker key (e.g., `linear_parent: ENG-198`) in council frontmatter so a + reader on Linear can trace back to the deliberation. - Do not post council content to the Linear parent issue. A brief one-line reference ("Resolved via council 2026-04-21 — see .agents/councils/…") in a sub-issue comment is sufficient if the council drove a specific task diff --git a/dist/amp/skills/documentation-standards/SKILL.md b/dist/amp/skills/documentation-standards/SKILL.md index c4aed9a36..da018c76f 100644 --- a/dist/amp/skills/documentation-standards/SKILL.md +++ b/dist/amp/skills/documentation-standards/SKILL.md @@ -49,7 +49,7 @@ Standards for ADRs, API docs, changelogs, and diagrams. - Internal spec/task IDs - Verbatim commit or PR-title dumps - **Good examples:** - - "Add `loaf release --post-merge` guardrails for tagged GitHub releases" + - "Add `loaf release suggest` and `loaf release cut` for retroactive releases" - "Fix journal context routing when hook payloads are empty" - "Document worktree-aware `.agents/` storage for linked checkouts" - **Version protection:** diff --git a/dist/amp/skills/explore/SKILL.md b/dist/amp/skills/explore/SKILL.md index 829912f8c..4586c741f 100644 --- a/dist/amp/skills/explore/SKILL.md +++ b/dist/amp/skills/explore/SKILL.md @@ -2,16 +2,16 @@ name: explore description: >- Conducts divergent inquiry as a durable Exploration with portable checkpoints, - conversation provenance, and Intent capture that survive compaction and - harness changes. Agent technique — not a user entry point: route "explore - this" and similar user asks to pitch; use this technique from inside pitch or - other agent work when the direction is genuinely undecided, or when resuming a - named Exploration. Produces Exploration records, portable checkpoints, and - tracked or deferred Intents; Exploration machinery and the four-field - checkpoint contract stay intact. Not for evidence gathering on a known - question (use research), continuing implementation (use implement), processing - the intake queue (use triage), shaping a bounded Change (use shape), problem - discovery (use pitch), or quick capture (use idea). + conversation provenance, and backlog-issue dispositions that survive + compaction and harness changes. Agent technique — not a user entry point: + route "explore this" and similar user asks to pitch; use this technique from + inside pitch or other agent work when the direction is genuinely undecided, or + when resuming a named Exploration. Produces Exploration records, portable + checkpoints, and backlog issues for crystallized directions; Exploration + machinery and the four-field checkpoint contract stay intact. Not for evidence + gathering on a known question (use research), continuing implementation (use + implement), processing the intake queue (use triage), shaping a bounded issue + (use shape), problem discovery (use pitch), or quick capture (use idea). version: 0.2.21 --- @@ -30,6 +30,7 @@ Divergent inquiry with durable continuity. An Exploration is a relational identi - Process - Checkpoint Discipline - Resumption +- Parking a direction - Techniques - Related Skills @@ -39,37 +40,39 @@ Divergent inquiry with durable continuity. An Exploration is a relational identi - You choose what an Exploration means and when to checkpoint; the CLI validates and performs the operation you request. Never expect the CLI to classify or decide for you. - Checkpoint before the context window gets hostile: every checkpoint must carry all four portable fields — purpose, conclusions, unresolved, next action — each self-sufficient without this conversation. - A conversation handle or log path is provenance, never context. Presence of handles does not make an Exploration resumable; only a portable checkpoint does. -- Capture crystallized directions as Intent (`loaf intent create`), deferred bodies with `--disposition deferred`; never leave a substantial direction only in prose. -- Never create Git artifacts, branches, worktrees, or Changes from Explore; when a direction is ready for problem discovery hand it to pitch, and when it is ready for bounded delivery hand it to shape. +- Capture crystallized directions as backlog issues (`loaf issue new "<title>" --status backlog`); park remaining unsharp questions on that issue with `--fog`. Never leave a substantial direction only in prose. +- Never create Git artifacts, branches, or worktrees from Explore; when a direction is ready for problem discovery hand it to pitch, and when it is ready for bounded delivery hand it to shape (issue preparation). - Never store transcripts, prompts, or tool output in checkpoints or items; curate semantic context instead. -- Not a user slash front door — human "explore this" / "where do I start" intent routes to pitch. +- Not a user slash front door — human "explore this" / "where do I start" routes to pitch. ## Verification - The Exploration exists with `portable_context_present: true` after the first checkpoint (`loaf exploration list`). - `loaf exploration context <ref> --json` returns the four-field core whole, and a fresh reader could identify the next action from it alone. -- Crystallized directions exist as Intents with derived dispositions (`loaf intent list`). +- Crystallized directions exist as backlog issues (`loaf issue list --status backlog`); issue aliases named in the checkpoint match those rows. - Conversation provenance, when recorded, carries harness and locality facts without any transcript content. ## Quick Reference | Operation | Command | |-----------|---------| -| Start an inquiry | `loaf exploration create --title <title> [--from <intent-or-source>]...` | +| Start an inquiry | `loaf exploration create --title <title> [--from <source>]...` | | Checkpoint | `loaf exploration checkpoint <ref> --purpose <p> --conclusions <c> --unresolved <u> --next <n> [--item candidate:<text>]... [--operation-id <key>]` | | Resume elsewhere | `loaf exploration context <ref> --json` | -| Track a direction | `loaf intent create --title <t> --body <b> --from <source>...` | -| Defer a direction | `loaf intent create --title <t> --body <b> --disposition deferred --why <w> --boundary <bd> --trigger <tr> --operation-id <key> [--from <source>]` | +| File a direction | `loaf issue new "<title>" --status backlog [--parent <ref>] [--kind delivery\|decision] [--fog <text>] [--body <text>]` | +| Optional bucket | `loaf issue bucket <ref> now\|next\|later\|none` | | Record provenance | `loaf conversation create --title <label>` then `loaf conversation handle add <id> --harness <h> --handle <opaque-id> [--locality <scope>] [--log-ref <path>]` | | Associate conversation | `loaf exploration conversation add <exploration> <conversation-id>` | +`--from` on create accepts journal entries, handoffs, reports, and findings. It does not accept issue, spark, or idea refs — name those in the checkpoint and in the issue body instead. Buckets are labels only and are never read as a constraint. `fog` is writeable only at create. + ## Process 1. **Orient.** If the input names an existing Exploration, run `loaf exploration context <ref>` and continue from its recommended next action. Otherwise check `loaf exploration list` before creating a duplicate inquiry. -2. **Create or resume.** New inquiries get `loaf exploration create` with `--from` links to the Intents, journal entries, reports, or findings that motivated them. +2. **Create or resume.** New inquiries get `loaf exploration create` with `--from` links to the journal entries, reports, findings, or handoffs that motivated them. 3. **Diverge.** Expand the option space before judging it. Use the brainstorm stance (below), research, scouting, prototypes, or spikes as the question demands. -4. **Capture as you go.** Incidental thoughts become sparks; explicit propositions become ideas; deliberately tracked directions become Intents with their sources linked. -5. **Checkpoint.** At every meaningful plateau — and always before ending a session — append a checkpoint with the four portable fields and optional `candidate:`/`evidence:` items. +4. **Capture as you go.** Incidental thoughts become sparks (`loaf spark capture --scope <scope> --text <text>`); explicit propositions become ideas (`loaf idea capture --title "..."`); directions worth keeping become backlog issues. Resolve the capture against the issue so the direction appears once: `loaf spark resolve <ref> --by <issue-ref>` or `loaf idea resolve <ref> --by <issue-ref>`. +5. **Checkpoint.** At every meaningful plateau — and always before ending a session — append a checkpoint with the four portable fields and optional `candidate:`/`evidence:` items. Name any filed issue aliases in conclusions or next. 6. **Record provenance when useful.** Machine-local conversation handles and log locators help forensic navigation later; add them explicitly, and never infer identity from the current session. ## Checkpoint Discipline @@ -81,17 +84,17 @@ The four fields are the portable contract; each is capped at 4096 UTF-8 bytes an - **unresolved** — the open question or decision the inquiry currently turns on. - **next** — the recommended next action, concrete enough for a fresh agent to execute without this conversation. -Larger detail belongs in ordered `--item candidate:` and `--item evidence:` entries or in related reports, not crammed into the core fields. +Larger detail belongs in ordered `--item candidate:` and `--item evidence:` entries or in related reports, not crammed into the core fields. When filing an issue, copy still-unsharp questions into `--fog`; they will not be editable on the issue after create. ## Resumption -A new conversation, harness, or machine resumes with `loaf exploration context <ref> --json`: the portable core returns whole, and each optional layer (items, intents, evidence, conversations) reports counts, truncation, and its exact expansion command. Source handles appear with their last observed availability; treat unavailable ones as lost without ceremony — the checkpoint is the context. If `portable_context_present` is false, the Exploration was never checkpointed: rebuild understanding from linked sources, then write the missing checkpoint first. +A new conversation, harness, or machine resumes with `loaf exploration context <ref> --json`: the portable core returns whole, and each optional layer reports counts, truncation, and its exact expansion command. Source handles appear with their last observed availability; treat unavailable ones as lost without ceremony — the checkpoint is the context. If `portable_context_present` is false, the Exploration was never checkpointed: rebuild understanding from linked sources, then write the missing checkpoint first. -Before continuing, inspect the linked Intents in the context. If an Intent this inquiry was developing has since been resolved, do not silently reopen it: acknowledge the resolution, and if the checkpoint's next action still matters, create a successor Intent, record why in its body, and relate the lineage with `loaf link create --from <new-intent-ref> --to <resolved-intent-ref> --type derived-from`. Continued evidence gathering that serves no unresolved Intent should say so in its next checkpoint. +Before continuing, inspect issue aliases named in the checkpoint. If an issue this inquiry was developing is now done, cancelled, or duplicate, do not silently reopen it: acknowledge the resolution, and if the checkpoint's next action still matters, file a successor backlog issue and record why in its body. Continued evidence gathering that serves no open issue should say so in its next checkpoint. -## Deferring +## Parking a direction -An Exploration is never deferred, paused, or closed — it has no lifecycle to transition. "Defer this exploration" means two concrete acts: checkpoint the current state honestly, then defer the direction it was developing as an Intent — `loaf intent defer` on the linked Intent, or `loaf intent create --disposition deferred` for a new one followed by `loaf link create --from <exploration-ref> --to <intent-ref> --type explores`. The deferred Intent carries the revisit trigger; the Exploration simply waits, resumable from its checkpoint whenever the Intent is resumed. +An Exploration is never paused or closed — it has no lifecycle to transition. When the user wants to park or set aside the inquiry, do two concrete acts: checkpoint the current state honestly, then file the direction it was developing as a backlog issue (`loaf issue new "<title>" --status backlog`, optional `--parent`, `--fog` for remaining unsharp questions, optional `loaf issue bucket <ref> later`). The issue is the revisit surface; the Exploration simply waits, resumable from its checkpoint. ## Techniques @@ -99,8 +102,8 @@ Brainstorm's full divergent stance lives inside Explore: generate options before ## Related Skills -- **pitch** — human problem-discovery front door; route user entry intent here; reach for explore from inside pitch when still undecided -- **triage** — processes the intake queue and may disposition items toward pitch, shape, or agent-side explore -- **shape** — narrows one well-understood direction into a bounded Change +- **pitch** — human problem-discovery front door; route user entry here; reach for explore from inside pitch when still undecided +- **triage** — processes the intake queue and may disposition items toward a backlog issue, pitch, shape, or agent-side explore +- **shape** — prepares a well-understood direction as a bounded issue - **research** — evidence gathering for a known question, usable inside an Exploration - **idea** — quick capture without inquiry diff --git a/dist/amp/skills/foundations/references/code-review.md b/dist/amp/skills/foundations/references/code-review.md index 2c37d2f0b..37c8a2e10 100644 --- a/dist/amp/skills/foundations/references/code-review.md +++ b/dist/amp/skills/foundations/references/code-review.md @@ -86,5 +86,5 @@ Project code review conventions and workflow. | Command | Code Review Role | |---------|-----------------| | implement | Self-review before marking complete | -| breakdown | Review task scope and approach | +| shape | Review issue scope and DoD before implementation | | reflect | Note review feedback patterns | diff --git a/dist/amp/skills/foundations/references/tdd.md b/dist/amp/skills/foundations/references/tdd.md index 28c883709..1ea6fe7c9 100644 --- a/dist/amp/skills/foundations/references/tdd.md +++ b/dist/amp/skills/foundations/references/tdd.md @@ -54,6 +54,6 @@ If the failing test points to a non-obvious root cause, or if your first fix att | Phase | TDD Role | |-------|----------| | shape | Test conditions become TDD test cases | -| breakdown | Each task should have clear test targets | +| shape | Each promoted issue should have clear test targets in its DoD | | implement | Follow TDD cycle for each task | | reflect | Note TDD friction points for improvement | diff --git a/dist/amp/skills/foundations/references/verification.md b/dist/amp/skills/foundations/references/verification.md index d3cf84e1f..142aa7dfc 100644 --- a/dist/amp/skills/foundations/references/verification.md +++ b/dist/amp/skills/foundations/references/verification.md @@ -123,7 +123,7 @@ npm run lint # Check: No errors or warnings | Command | Verification Point | |---------|-------------------| | implement | Before marking session complete | -| breakdown | Each task has verification criteria | +| shape | Each issue has verification criteria (V-tier DoD) | | shape | Test conditions define verification | | reflect | Note verification gaps discovered | diff --git a/dist/amp/skills/git-workflow/SKILL.md b/dist/amp/skills/git-workflow/SKILL.md index 798f55dfc..80a2ce2e1 100644 --- a/dist/amp/skills/git-workflow/SKILL.md +++ b/dist/amp/skills/git-workflow/SKILL.md @@ -24,7 +24,7 @@ Git conventions for branching, commits, PRs, and merge workflow. - Use Conventional Commits format for all commit messages - Commit complete units of work -- don't commit partial or in-progress changes - Squash merge feature branches -- never merge commits directly -- One branch per spec/feature; branch name format: `feat/{slug}` +- One branch per issue; `loaf issue start` creates `issue/<alias-or-id>` (or use `feat/{slug}` / `fix/{slug}` when not starting from an issue) - Never force-push to `main` or shared branches - Never push without explicit user confirmation @@ -38,7 +38,7 @@ Git conventions for branching, commits, PRs, and merge workflow. | Action | Command/Pattern | |--------|----------------| -| Branch naming | `feat/{slug}`, `fix/{slug}`, `chore/{slug}` | +| Branch naming | `issue/<alias-or-id>` from `loaf issue start`; else `feat/{slug}`, `fix/{slug}`, `chore/{slug}` | | Commit format | `type: description` | | Squash merge | `gh pr merge --squash` | | PR creation | `gh pr create --title "..." --body "..."` | diff --git a/dist/amp/skills/git-workflow/references/commits.md b/dist/amp/skills/git-workflow/references/commits.md index a879b8709..3e9e3c00d 100644 --- a/dist/amp/skills/git-workflow/references/commits.md +++ b/dist/amp/skills/git-workflow/references/commits.md @@ -110,13 +110,14 @@ Closes BACK-123 ## Branch Naming ``` +issue/<alias-or-id> <type>/<description> -<type>/TASK-123-description ``` ### Types -- `feat/` - New features (e.g., `feat/spec-010-task-management-cli`) +- `issue/` - Started from `loaf issue start` (`issue/loaf-42`) +- `feat/` - New features (e.g., `feat/thermal-rating-cli`) - `fix/` - Bug fixes - `hotfix/` - Critical production fixes - `release/` - Release preparation @@ -126,7 +127,7 @@ Closes BACK-123 - Lowercase with hyphens (kebab-case) - Short but descriptive (max 50 chars) -- Include spec or task slug when applicable (e.g., `feat/spec-010-task-management-cli`) +- Prefer the started worktree branch from `loaf issue start` when implementing an issue ## Pull Request Format @@ -140,26 +141,10 @@ feat: add thermal rating calculation ### Description -Focus on **review context** — what changed, why, and how to test. Do not include squash merge commit text in the PR body. +The PR body is `loaf issue render <ref>` output — paste-ready, no manual editing. Definition-of-done criteria in the render are the review checklist. Do not include squash merge commit text in the PR body. -```markdown -## Summary - -Brief description of what this PR adds/changes and why. - -- Bullet points covering key changes -- Focus on what a reviewer needs to know - -## Test plan - -- [ ] Unit tests added/updated -- [ ] Integration tests pass -- [ ] Manual testing performed - -## Related Issues - -Closes BACK-123 -Refs BACK-124 +``` +gh pr create --title "type: summary" --body "$(loaf issue render <ref>)" ``` ### Merge Strategy @@ -182,10 +167,10 @@ published release notes read as user-facing prose, not an internal worklog. Internal terms that have no meaning outside the team's working context: -- Spec IDs and task IDs (`SPEC-024`, `TASK-042`) +- Internal work-unit numbering that is not the issue ID (issue IDs like `LOAF-42` belong in commits — release attribution reads them) - Session, sprint, or branch references - Internal terminology from skills/docs that isn't part of the user's mental model — e.g. `Q1`/`Q2`/`Q3` question numbers from a Triage Gate, internal gate-logic notation like `(Q1 OR Q2) AND Q3`, hook IDs that aren't user-facing -- "How the work got done" framing — interview steps, breakdown steps, review gates +- "How the work got done" framing — interview steps, decomposition steps, review gates ### Keep @@ -204,7 +189,7 @@ Internal terms that have no meaning outside the team's working context: ### Auto-generated Entries -When `loaf release` auto-generates the `[Unreleased]` section from commit history, those entries inherit any internal terms present in the commit messages. Treat the generated output as a draft: rewrite it under the curated path before bumping. The release skill preserves curated content when it's already in `[Unreleased]` — curate first, bump second. +`loaf release suggest` drafts notes from landed issues; `loaf release cut` prepends them into `CHANGELOG.md`. Treat drafted notes as a draft: rewrite internal terms before cutting. Curate `[Unreleased]` as PRs land so the later cut reads as user-facing prose. Before approving a release bump, compare `[Unreleased]` against the actual release range and remove scaffolding language introduced by specs, reviews, tasks, or session triage. If an entry only explains why the work was discovered or how the work was organized, it does not belong in the changelog. @@ -228,14 +213,15 @@ Before approving a release bump, compare `[Unreleased]` against the actual relea - Add agent attribution - Mix unrelated changes - Commit secrets or sensitive data -- Put SPEC or TASK IDs in commit subject (use human-readable names) +- Put work-unit IDs in the commit subject (use human-readable names). Issue aliases belong in the body so `loaf release suggest` can attribute the commit. ### ID References - **IDs belong in footer, not subject line** - - Bad: `feat: implement SPEC-002 invisible sessions` - - Good: `feat: implement invisible sessions and task board` + - Bad: `feat: implement LOAF-42 invisible sessions` + - Good: `feat: implement invisible sessions` - Use descriptive names that are understandable without looking up IDs +- Issue aliases (`LOAF-42`) go in the body so release attribution can find them - Linear issue IDs go in footer only (e.g., `Closes BACK-123`) ## Semantic Versioning @@ -277,6 +263,6 @@ BREAKING CHANGE: Description of breaking change. **Convention:** - Use standard SemVer pre-release identifiers (`alpha`, `beta`, or `rc`) when publishing pre-release versions. -- `loaf release` handles all bump types: `prerelease`, `release`, `major`, `minor`, `patch` +- `loaf release cut --bump` handles all bump types: `prerelease`, `release`, `major`, `minor`, `patch` **Not required** — projects using simple `MAJOR.MINOR.PATCH` versioning can ignore pre-release suffixes entirely. This convention is for projects publishing staged pre-releases before stable releases. diff --git a/dist/amp/skills/housekeeping/SKILL.md b/dist/amp/skills/housekeeping/SKILL.md index e438c9632..d71c79537 100644 --- a/dist/amp/skills/housekeeping/SKILL.md +++ b/dist/amp/skills/housekeeping/SKILL.md @@ -1,12 +1,12 @@ --- name: housekeeping description: >- - Reviews and maintains agent artifacts in .agents/ — specs, plans, drafts, - handoffs, councils, and reports. Use when the user asks "housekeeping," "clean - up," or "tidy up .agents/." Provides hygiene recommendations, archives - completed work, and ensures extracted knowledge is preserved. Not for - strategic reflection (use reflect) or knowledge management (use - knowledge-base). + Reviews and maintains agent artifacts in .agents/ plus issue hygiene — + reports, handoffs, councils, archived issues, and stale started worktrees. Use + when the user asks "housekeeping," "clean up," or "tidy up .agents/." Provides + hygiene recommendations, archives completed work, and ensures extracted + knowledge is preserved. Not for strategic reflection (use reflect) or + knowledge management (use knowledge-base). version: 0.2.21 --- @@ -17,40 +17,43 @@ version: 0.2.21 - Verification - Quick Reference - Mode-Aware Checks -- Process -- Guardrails -- Related Skills +- Suggests Next +- Topics +- Artifact Naming -Systematic review and archival of all `.agents/` artifacts with Linear-aware checks. +Systematic review of `.agents/` artifacts and issue workspaces. ## Critical Rules **Always** - Log invocation as the first action: `loaf journal log "skill(housekeeping): <scope or trigger>"` - Review EVERY file individually — never sample or average -- Check Linear issue status before archiving linked specs +- Check Loaf issue status (and Linear overlay, if enabled) before archiving linked artifacts - Extract lessons learned and decisions before archiving -- Use CLI (`loaf housekeeping`, `loaf task archive`, `loaf spec archive`) — never raw `mv` +- Use CLI (`loaf housekeeping`, `loaf report archive`, `loaf issue status` / `loaf issue stop`) — never raw `mv` - Treat `.agents/handoffs/` as first-class but disposable: keep active/final handoffs, delete only after confirmed deprecated status -- Check report `status` is `processed` before archiving reports (see [templates/report.md](templates/report.md)) -- In SQLite-backed projects, verify lifecycle changes through `loaf task list --json`, `loaf spec list --json`, and `loaf report list --json`; use `loaf task sync` only for Markdown compatibility repair +- Check report `status` is `done` (or `final`) before archiving reports (see [templates/report.md](templates/report.md)) +- In SQLite-backed projects, verify lifecycle through `loaf issue list --json`, `loaf issue list --started`, `loaf issue list --archived`, and `loaf report list --json` - When delegated subagents are available, use the `librarian` profile for - `.agents/`-scoped durable artifact tending: report/spec/handoff hygiene, + `.agents/`-scoped durable artifact tending: report/handoff hygiene, staleness notes, and lifecycle-safe cleanup recommendations. Housekeeping still owns user confirmation and final archive decisions. -- Log outcome to the project journal: `loaf journal log "decision(housekeeping): archived N specs, M reports"` +- Log outcome to the project journal: `loaf journal log "decision(housekeeping): archived N reports; stopped M stale worktrees"` **Never** - Auto-archive without user confirmation for each artifact - Skip spark extraction before deleting brainstorm drafts - Leave `archived_at` or `archived_by` fields empty in archived files +- Run `loaf issue stop` from inside the started worktree +- Dispatch cleanup agents into a live started worktree another agent occupies ## Verification After work completes, verify: -- Tasks archived via `loaf task archive` -- Specs archived via `loaf spec archive` -- SQLite-backed task/spec/report state reflects lifecycle changes when initialized +- Reports archived via `loaf report archive` after processing +- Archived issues reviewed via `loaf issue list --archived` (`cancelled` / `duplicate` archive through `loaf issue status`) +- Stale started worktrees reviewed via `loaf issue list --started` (a `(missing)` marker means the recorded path is gone) +- SQLite-backed report/issue state reflects lifecycle changes when initialized - Drafts checked for unprocessed sparks before deletion - Handoffs deleted only after explicit deprecation is confirmed - Summary table presented showing all actions taken @@ -62,11 +65,18 @@ After work completes, verify: ```bash loaf housekeeping --dry-run # Preview recommendations loaf housekeeping # Run artifact scanner -loaf task archive TASK-XXX # Archive single task -loaf spec archive SPEC-XXX # Archive single spec -loaf task sync # Compatibility diagnostic in SQLite-backed projects +loaf issue list --started # Started worktrees (alias, title, branch, path) +loaf issue list --archived # cancelled / duplicate rows +loaf issue stop <ref> # Remove worktree; keeps branch; does not change status +loaf issue status <ref> cancelled # Archive an abandoned issue +loaf issue status <ref> duplicate --duplicate-of <surviving> +loaf report archive <report> # Archive a processed report ``` +`loaf housekeeping` still prints leftover `specs` / `tasks` sections when those +SQLite tables have rows — compatibility scan only. Do not create new records +there. The `loaf task` / `loaf spec` CLI is legacy. + The project journal is append-only and never archived — it is not a housekeeping target. It is the canonical record housekeeping reads when extracting decisions before archiving other artifacts. @@ -75,19 +85,12 @@ before archiving other artifacts. | Artifact | Active Location | Archive | Action | |----------|-----------------|---------|--------| -| Tasks (local mode only) | SQLite state | SQLite archived status | `loaf task archive` | -| Specs | SQLite state + `.agents/specs/` authored prose | `archive/` | `loaf spec archive` | +| Issues | SQLite (`loaf issue list`) | `cancelled` / `duplicate` via `loaf issue status` | Confirm, then status; `done` is ship, not housekeeping | +| Started worktrees | `loaf issue list --started` | `loaf issue stop <ref>` | Stop stale or `(missing)` trees after confirmation | | Drafts / brainstorms | SQLite state | SQLite resolved/archived status | User decision (spark extraction first) | | Handoffs | `.agents/handoffs/` | delete | Delete after status is confirmed `deprecated` | | Reports | SQLite state + generated/authored report Markdown | `archive/` | `loaf report archive` after processing | -**Linear-native mode** (when `integrations.linear.enabled` is `true` in -`.agents/loaf.json`): local `TASK-NNN.md` files do not exist for new specs — -Linear issues are the task record. The "Tasks" row above is inert unless the -project has pre-Linear local tasks lingering (see [Mode-Aware Checks](#mode-aware-checks)). -Specs still archive locally — they are the canonical deliberation artifact in -every mode. - ## Cross-Branch Reconciliation If a stale branch reintroduces `.agents/{tasks,ideas,sparks,sessions,brainstorms,drafts}/` @@ -97,35 +100,30 @@ or `.agents/TASKS.json`, keep the deletion from the cutover branch and rerun ## Mode-Aware Checks -When `integrations.linear.enabled` is `true` in `.agents/loaf.json`, apply -these additional checks: +### Started worktrees -### Spec / Linear parent reconciliation +For each row from `loaf issue list --started`: -For each spec file (active and archive) with a `linear_parent:` frontmatter key: +1. If `(missing)`, flag as **stale started workspace** — the row still records a path that is gone. Offer `loaf issue stop <ref>` after confirmation. Stop does not mark the issue `done`. +2. If the path exists but the issue is `done` / `cancelled` / `duplicate`, flag as **worktree outlived the issue** — same offer. +3. If the path exists and status is `active`, leave it unless the user asks to stop. -1. Call `get_issue` with the issue identifier. If it 404s or returns - archived/deleted, flag as **orphaned linear_parent** — the local spec - references a Linear issue that no longer exists. -2. If the spec's local status is `done` (or legacy `complete`) or `archived`, - verify the Linear parent issue is in a `completed`-type state. If not - (e.g., still "In Progress"), flag as **status mismatch** — "Spec marked - complete locally but Linear parent ENG-198 is still 'In Progress'." -3. If the spec's local status is `in_progress` and the Linear parent is - already `completed`, flag the inverse — spec likely needs to be moved to - `done` and archived. +Treat these as **warnings**, not auto-fixes. -Treat all three as **warnings**, not auto-fixes. The user decides resolution. +### Linear overlay -### Pre-Linear local task detection +When `integrations.linear.enabled` is `true` in `.agents/loaf.json`, the tracker +adapter is not shipped. If a report or journal entry names a Linear id next to +a Loaf alias, you may `get_issue` and flag an obvious mismatch (Linear Done vs +Loaf still `active`, or the reverse). Warnings only. Do not drive Loaf status +from Linear. -If Linear is enabled but local task records exist in SQLite, -surface them with context: "Pre-Linear local tasks detected. These aren't -auto-migrated. Either continue using them, run a manual migration, or -archive if superseded by Linear issues." +### Leftover board rows -Do NOT auto-migrate. Migration is user-initiated and out of scope for -housekeeping. +If `loaf housekeeping --dry-run` still reports `tasks` or `specs` cleanup +candidates, surface them: "Legacy board rows are still in SQLite. They are not +the work unit. Archive only if the user confirms they are superseded by Loaf +issues." Do NOT auto-migrate. ## Suggests Next @@ -136,9 +134,9 @@ After housekeeping, suggest reflect if the session produced key decisions or lea | Topic | Reference | Use When | |-------|-----------|----------| | Report Template | [templates/report.md](templates/report.md) | Creating cleanup reports | -| Linear Integration | `orchestration/references/linear.md` | Checking external issue status | +| Linear Integration | `orchestration/references/linear.md` | Checking external tracker overlay | | Journal Continuity | `orchestration/references/journal.md` | Understanding the project journal model | ## Artifact Naming -Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or Change already records that provenance. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. +Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or the issue already records that provenance. Put the source in a front-matter field (`source: LOAF-42`), not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. diff --git a/dist/amp/skills/housekeeping/templates/journal.md b/dist/amp/skills/housekeeping/templates/journal.md index caf7409fd..9a0a61f8f 100644 --- a/dist/amp/skills/housekeeping/templates/journal.md +++ b/dist/amp/skills/housekeeping/templates/journal.md @@ -13,7 +13,7 @@ not create or edit journal markdown as the source of truth — use ## Entries -[YYYY-MM-DD HH:MM] skill(implement): implementing TASK-042 +[YYYY-MM-DD HH:MM] skill(implement): implementing LOAF-42 [YYYY-MM-DD HH:MM] decision(scope): description of decision [YYYY-MM-DD HH:MM] discover(scope): something learned [YYYY-MM-DD HH:MM] block(scope): what is blocked diff --git a/dist/amp/skills/housekeeping/templates/report.md b/dist/amp/skills/housekeeping/templates/report.md index d0894e593..ca201e869 100644 --- a/dist/amp/skills/housekeeping/templates/report.md +++ b/dist/amp/skills/housekeeping/templates/report.md @@ -8,7 +8,7 @@ title: "Report: [Topic]" type: research | audit | analysis | council created: YYYY-MM-DDTHH:MM:SSZ status: draft | done | archived -source: SPEC-XXX | TASK-XXX | ad-hoc +source: LOAF-42 | ad-hoc finalized_at: YYYY-MM-DDTHH:MM:SSZ # Set when status → done archived_at: YYYY-MM-DDTHH:MM:SSZ # Set when status → archived archived_by: orchestrator diff --git a/dist/amp/skills/idea/SKILL.md b/dist/amp/skills/idea/SKILL.md index f1c268823..c5ba62f48 100644 --- a/dist/amp/skills/idea/SKILL.md +++ b/dist/amp/skills/idea/SKILL.md @@ -3,11 +3,12 @@ name: idea description: >- Captures ideas into structured nuggets for later evaluation. Use when the user says "I have an idea" or "note this down." Also activate when a specific - actionable concept crystallizes during conversation. Ideas and sparks are - capture primitives routed through triage, which chooses dispositions such as - tracking an Intent or handing to pitch. Not for problem discovery (use pitch), - processing the intake queue (use triage), shaping (use shape), or agent-side - divergent inquiry when direction is undecided (use explore as a technique). + actionable concept crystallizes during conversation. Ideas and sparks stay + capture primitives routed through triage, which files worth-keeping items as + backlog issues or hands them to pitch or shape. Not for problem discovery (use + pitch), processing the intake queue (use triage), shaping a bounded issue (use + shape), or agent-side divergent inquiry when direction is undecided (use + explore as a technique). version: 0.2.21 --- @@ -25,7 +26,6 @@ Capture ideas quickly with minimal friction. - Quick Reference - Purpose - Process -- Idea Lifecycle - Guardrails - Related Skills @@ -35,7 +35,7 @@ Capture ideas quickly with minimal friction. - 2-3 questions maximum -- don't turn capture into an interview - Infer metadata automatically -- don't ask for tags, title, or links - One idea per captured row/artifact -- keep them atomic -- No shaping or pitching here -- problem discovery is pitch; bounding is shape +- No shaping, pitching, or filing issues here -- problem discovery is pitch; bounding is shape; dispositions are triage - Capture through `loaf idea capture --title ...` when SQLite state is initialized; log notable context with `loaf journal log` @@ -57,7 +57,7 @@ Capture ideas quickly with minimal friction. ## Purpose -Ideas are raw nuggets — unprocessed, unshaped, but worth remembering. The goal is **speed of capture**, not thoroughness. An idea is retained material, nothing more: tracking it as an Intent, handing it to pitch for problem discovery, handing it to shape when already well-understood, or archiving it are triage dispositions chosen later by the user. +Ideas are raw nuggets — unprocessed, unshaped, but worth remembering. The goal is **speed of capture**, not thoroughness. An idea is retained material, nothing more: filing it as a backlog issue, handing it to pitch for problem discovery, handing it to shape when already well-understood, or archiving it are triage dispositions chosen later by the user. --- @@ -81,7 +81,7 @@ Ideas are raw nuggets — unprocessed, unshaped, but worth remembering. The goal ## Related Skills -- **triage** — process the intake queue and choose dispositions (including hand to pitch or shape) +- **triage** — process the intake queue and choose dispositions (file as backlog issue, hand to pitch, or hand to shape) - **pitch** — problem-discovery ceremony when a captured idea needs a brief before shaping -- **shape** — develop a well-understood direction into a bounded Change +- **shape** — develop a well-understood direction into a bounded issue - **explore** — agent technique when direction is genuinely undecided (not a user front door) diff --git a/dist/amp/skills/idea/templates/idea.md b/dist/amp/skills/idea/templates/idea.md index b070494d3..b631ca832 100644 --- a/dist/amp/skills/idea/templates/idea.md +++ b/dist/amp/skills/idea/templates/idea.md @@ -11,7 +11,7 @@ title: "[Idea Title]" captured: YYYY-MM-DDTHH:MM:SSZ status: raw tags: [] -related: [] # Optional: spec IDs, idea filenames, or other references +related: [] # Optional: issue aliases, idea filenames, or other references origin: # Optional: draft filename this spark came from (e.g. drafts/YYYYMMDD-brainstorm-slug.md) --- @@ -31,5 +31,5 @@ origin: # Optional: draft filename this spark came from (e.g. dra --- -*Captured via idea -- shape with shape when ready* +*Captured via idea — triage later (backlog issue, pitch, or shape)* ``` diff --git a/dist/amp/skills/implement/SKILL.md b/dist/amp/skills/implement/SKILL.md index 775c75488..6baaedb80 100644 --- a/dist/amp/skills/implement/SKILL.md +++ b/dist/amp/skills/implement/SKILL.md @@ -1,18 +1,19 @@ --- name: implement description: >- - Orchestrates implementation work through agent delegation and batch execution. - Use for all implementation work — features, bug fixes, refactors, and code - changes. Picks Change task files when present and flips checkboxes in - delivering commits. Logs to the project journal and produces agent spawn plans - and progress tracking. Not for shaping (use shape), breakdown (use breakdown), - research, or review. + Orchestrates implementation work through agent delegation and batch execution + against Loaf issues. Use for all implementation work — features, bug fixes, + refactors, and code changes. Picks the next issue from loaf issue frontier, + delegates one agent per started worktree, and treats definition-of-done + criteria as the completion contract. Logs to the project journal and produces + agent spawn plans and progress tracking. Not for shaping or decomposition (use + shape), research, or review. version: 0.2.21 --- # Implement -You are the coordinator. Start by understanding the task: +You are the coordinator. Work units are issues. ## Contents - Critical Rules @@ -20,7 +21,7 @@ You are the coordinator. Start by understanding the task: - Quick Reference - Step 0: Context Check - Input Detection -- Linear-Native Routing +- Pick-up and Dispatch - Agent Spawning - Journal First - Guardrails @@ -38,27 +39,32 @@ You are the coordinator. Start by understanding the task: **You are the ORCHESTRATOR, not the implementer.** -- Log `loaf journal log "skill(implement): <task/spec/context>"` as the first action. -- **Change-first task packets:** prefer `docs/changes/<folder>/tasks/TASK-NNN-*.md` as the delegation brief. Flip checkboxes `- [ ]`→`- [x]` in the same commit that delivers the work (outside `docs/changes/` paths must land with the flip for provenance). Use `loaf change tasks --json` for the index. -- Commit task packets unchecked before executing them — a packet that first lands already-checked induces no flip transition, and the evidence trail never exists. -- Compatibility: existing `TASK-XXX` / `SPEC-XXX` SQLite records remain supported until converted; they are not the default for new work. +- Log `loaf journal log "skill(implement): LOAF-42 — <what>"` as the first action. Substitute the real alias (or opaque id) and a short intent. +- **Pick-up-next is `loaf issue frontier`.** That view is open (`triage` / `backlog` / `todo`), unblocked, and unclaimed (not `active`, no started worktree). Derived at read time. +- **The delegation brief is the issue row** — `loaf issue show <ref>` / `loaf issue render <ref>`: body, definition-of-done criteria, children. There is no other packet. +- **One agent, one worktree.** `loaf issue start <ref>` creates the branch and worktree and moves status to `active`. Before dispatch, run `loaf issue list --started`. Never send two agents into the same worktree. +- **Definition of done is the completion contract.** `loaf issue verify <ref>` runs V-tier criteria from the repository root and writes nothing. H-tier is reviewed by a human or this orchestrator. Completion is the work landing plus `loaf issue status <ref> done`. Do not flip checkboxes. Provenance is the delivering commits and the PR whose body is `loaf issue render <ref>`. +- Shape prepares issues. If `loaf issue check <ref>` does not report the delivery issue shaped (or the decision issue ready), stop and send the work to shape. Do not mint a new issue from this skill. ### Orchestrator Can Do Directly - Log journal entries, read journal context, create council files -- Use your harness's task/todo tracking surface; **if `integrations.linear.enabled` is `true` in `.agents/loaf.json`**, use Linear MCP tools when helpful +- Use your harness's todo tracking surface; **if `integrations.linear.enabled` is `true` in `.agents/loaf.json`**, Linear MCP is an overlay only — Loaf issues remain the work unit and Linear never drives Loaf status - Read any file for context - Ask clarifying questions +- Run `loaf issue` read commands, `loaf issue start` / `stop`, `loaf issue status`, and open a PR whose body is `loaf issue render` output ### Orchestrator MUST Delegate (via agent spawn) -**ALL code changes, documentation edits, and implementation work** to specialized agents. **No exceptions**, even for "trivial" 1-line fixes. +**ALL code changes, documentation edits, and implementation work** to specialized agents. **No exceptions**, even for "trivial" 1-line fixes. Spawn each agent into that issue's started worktree. ## Verification - The invocation is logged to the project journal before implementation work begins — no session start step, no "active session" precondition - All code changes delegated via your harness's agent-spawn mechanism -- no direct edits by orchestrator - The journal is continuously updated with spawns, progress, and decisions as work happens -- Spec artifacts closed out on branch before PR creation -- **Linear-native mode:** `blockedBy` of the target sub-issue is fully `completed` before work begins; starting a sub-issue also promotes an unstarted parent rollup to active; parent rollup is auto-closed only when all sub-issues are `completed` +- Each in-flight issue has exactly one started worktree; `loaf issue list --started` was checked before every spawn +- V-tier criteria pass `loaf issue verify <ref>` (writes nothing); H-tier criteria were reviewed by a human or this orchestrator +- The PR body is `loaf issue render <ref>` with no manual editing; checkboxes stay unchecked until status is `done` +- Completion is landing plus `loaf issue status <ref> done` (usually via ship) ## Quick Reference @@ -73,6 +79,15 @@ You are the coordinator. Start by understanding the task: | Code review/audit | reviewer | relevant domain skills | | Research/comparison | researcher | relevant domain skills | +| Moment | Command | +|--------|---------| +| Pick next | `loaf issue frontier` | +| Brief | `loaf issue show <ref>` / `loaf issue render <ref>` | +| Claim workspace | `loaf issue start <ref>` | +| Occupied trees | `loaf issue list --started` | +| V-tier gate | `loaf issue verify <ref>` | +| Landed | `loaf issue status <ref> done` | + --- ## Step 0: Context Check @@ -83,152 +98,51 @@ Before starting, evaluate context suitability. |---------|--------| | New command/skill added this conversation | **Restart required** (skills loaded at start) | | Conversation > 30 exchanges | Suggest restart | -| Just completed a different task/spec | Suggest clear | +| Just completed a different issue | Suggest clear | | About to start multi-file implementation | Check depth | If restart needed: log current state with `loaf journal log`, then ask the user to restart. A supported startup adapter may reconstruct continuity from the journal in the next conversation; when the exact current target mode is candidate or unsupported, explicitly run `loaf journal context` after restarting. ## Input Detection -Parse `$ARGUMENTS` to determine the work type: +Parse `$ARGUMENTS` to determine the work: | Input Pattern | Type | Action | |---------------|------|--------| -| `TASK-XXX` | Local task | Load via `loaf task show`, log the task coupling | -| `SPEC-XXX` | Spec orchestration | If spec frontmatter has `linear_parent`, resolve to that Linear parent and follow Linear-Native Routing. Otherwise resolve local tasks and build dependency-ready rounds | -| `TASK-XXX..YYY` | Task range | Expand range, build dependency-ready rounds | -| `TASK-XXX,YYY,ZZZ` | Task list | Parse list, build dependency-ready rounds | -| `PLT-123`, `ENG-198`, `PROJ-123` | Linear issue | **If `integrations.linear.enabled` is `true`:** fetch via `get_issue`, then branch on parent vs sub-issue — see [Linear-Native Routing](#linear-native-routing). **Otherwise:** treat as label text or create local task | -| Description text | Ad-hoc | Auto-create local task from description, then fall through to task-coupled flow | - -### Task-Coupled Work - -When starting from `TASK-XXX`: - -1. Load task metadata via `loaf task show TASK-XXX --json`; do not recreate `.agents/TASKS.json` after the SQLite cutover -2. Log the task coupling: `loaf journal log "decision(implement): implementing TASK-XXX"` -3. Load parent spec if task has `spec:` field - -### Ad-hoc Task Auto-Creation - -When input is free-text description (not matching any known pattern): - -1. **Parse the description:** - - Single sentence → use entire text as task title - - Multi-sentence → first sentence = title, remainder = acceptance criteria - - Split on `. ` followed by uppercase letter only (conservative — avoids false positives from URLs, abbreviations) -2. **Create the task:** `loaf task create --title "<parsed title>"` -3. **Write criteria** (if multi-sentence): edit the task `.md` file body to add the remaining sentences as acceptance criteria -4. **Fall through** to the task-coupled flow above — the result is a `TASK-XXX` ID that enters the existing planning pipeline unchanged - -**No user interaction required.** The description IS the task; invoking implement already expressed intent. +| `LOAF-42` or opaque id | Single issue | Load via `loaf issue show <ref>`; fall through to Pick-up and Dispatch | +| Parent ref with children | Tree | `loaf issue tree <ref>`; build rounds from children and `blocks` / `blocked_by` edges (see [batch-orchestration.md](references/batch-orchestration.md)) | +| Multiple refs | Batch | Same round construction across the named set | +| Empty / "next" | Frontier | `loaf issue frontier`; if one row, pick it; if several, ask (structured question tool if the harness has one); if none, stop | +| Description text | Ad-hoc | Match frontier by title. Do not mint. If nothing matches, stop and send to shape | +| Decision kind | Question | Not implementation. Surface the question; do not `loaf issue start` unless the user points at a delivery issue that records the decided answer | -### Non-Existent Task ID Error +### Missing ref -If input matches `TASK-XXX` pattern but `loaf task show` cannot resolve it: +If input looks like an issue ref but `loaf issue show` cannot resolve it: -1. Show error: `"TASK-XXX not found in local task state"` -2. Ask the user: `"Did you mean to create a new task? You can re-run with the description as free text."` -3. **Do not silently create** — the user likely has a typo +1. Show error: `"<ref> not found"` +2. Ask whether they meant a different alias, or to shape a new issue +3. **Do not silently create** --- -## Linear-Native Routing - -Applies when `integrations.linear.enabled` is `true` AND `$ARGUMENTS` -resolves to a Linear issue (direct Linear ID, or a `SPEC-XXX` whose -frontmatter has `linear_parent`). - -Fetch the issue once via `get_issue` and branch on its shape: - -### Parent rollup issue (has `spec` label) - -The issue represents a spec. Do **not** implement it directly — spec-level -"work" is always done via sub-issues. - -1. List sub-issues via `list_issues` with `parent: <parent-id>`. -2. Classify each by state: - - `in_progress` — active work - - `unstarted` + no open `blockedBy` — ready to start - - `unstarted` + open `blockedBy` — blocked - - `completed` — done, skip -3. Select the next work item: - - If one or more sub-issues are `in_progress`, pick the **lowest-ID** - in-progress sub-issue. Resume that. - - Else, if one unblocked `unstarted` sub-issue exists, pick it. - - Else, if multiple unblocked `unstarted` sub-issues exist, use - your harness's structured question tool (if it has one) to let the user choose: pick one, or delegate N in - parallel via parallel agents. List each sub-issue's title + ID. - - Else (all remaining sub-issues are blocked), refuse with a summary: - "All remaining sub-issues under <parent-id> are blocked. Blockers: - <list>." -4. Once a sub-issue is selected, recurse into the sub-issue flow below - with that ID. The parent itself is never the implementation target. - -### Sub-issue (has `parentId`, no `spec` label) - -The issue is an actual task. Implement it directly — with a pre-flight gate. - -1. **Pre-flight: verify `blockedBy` is clear.** For each issue in the - sub-issue's `blockedBy` field, call `get_issue` and confirm its state is - `completed`-type. If any blocker is not Done: - - **Refuse to start.** Do not begin work. Do not move the issue. - - Show the blockers: `"Cannot start <sub-issue-id>. Blocked by: <list - with IDs, titles, and current states>."` - - Suggest: `"Complete the blocker(s) first, or ask to override if the - blockedBy link is stale."` -2. If blockers are clear: - - Start the sub-issue as one logical Linear operation. This moves - the sub-issue to the team's `started`/In Progress state and, when the - parent rollup is still `backlog` or `unstarted`, promotes the parent to - the same `started`/In Progress state. - - If the parent is already active, leave it unchanged. If the parent is - `completed`, `canceled`, or archived, refuse to start unless the user - explicitly asks to override the protected parent state. - - If the child update succeeds but parent promotion fails, report a - reconciliation error naming the parent issue before continuing. - - Resolve branch name from the sub-issue's `branchName` field (Linear - auto-generates one) — see - [branch-and-completion.md](references/branch-and-completion.md). - - Log the task coupling, then continue with the standard Startup Checklist. - -### Completion (after implementer + reviewer finish cleanly) - -When the sub-issue's implementation passes review and tests: - -1. Move the sub-issue to the team's `completed`-type state via - `update_issue` (look up via `list_issue_statuses`, filter - `type: "completed"`). -2. Query the parent's sub-issues again: - - If **all** sub-issues are now `completed`-type, move the parent - rollup to `completed` as well. Also mark the local spec as - `complete` (see [Then Execute → AFTER](#then-execute)). - - If **some** remain, list them as "next available" for the user, - applying the same classification as step 2 of the parent flow above. - Offer to continue with the next one in this session, or stop here. -3. **Do not** close the parent while any sub-issue is open — not even if - only `blocked` ones remain. Blocked sub-issues are still in-flight - work from the spec's perspective. - -### Status flow summary - -| Moment | Sub-issue state | Parent state | -|--------|----------------|--------------| -| Implementation starts | `started` / In Progress | promoted to `started` / In Progress if still `backlog` or `unstarted` | -| Implementation + review pass | `completed` | check: close only if all sibs completed | -| Blocker discovered mid-work | `in_progress` + blocker comment | unchanged | - -### What Linear-native routing does NOT do - -- Does not pull down the full spec text. The parent's description already - links to `.agents/specs/SPEC-NNN-*.md`. Read the local file for shape, - rabbit holes, and strategic tensions. -- Does not create or rewrite sub-issues. That's breakdown's job. If - implementation reveals a missing task, surface it to the user; they - decide whether to run breakdown again or add an ad-hoc sub-issue. -- Does not sync in-progress state bidirectionally. Source of truth at any - moment: Linear for issue state, local files for spec content, the project - journal for current handoff. +## Pick-up and Dispatch + +1. **Confirm the issue is implementable.** `loaf issue check <ref>` must report a delivery issue shaped (or, if the user explicitly asked to resolve a decision issue, that it is ready). Unshaped work goes to shape. +2. **Honor the frontier.** An issue that is blocked does not appear on `loaf issue frontier`. `loaf issue link A blocks B` means A blocks B; B waits until A is `done`, `cancelled`, or `duplicate`. Do not start a blocked successor. Parent/child structure from `loaf issue tree` is not a sequencing edge — only `blocks` / `blocked_by` are. Use the tree to know who belongs in the batch; use the edges to order rounds. +3. **Parents with children are not the implementation target.** Dispatch leaf delivery children that are on the frontier. A parent executes through claimed child criteria, not by starting the parent worktree. +4. **Inspect occupied worktrees:** + ```bash + loaf issue list --started + ``` + Columns: alias, title, `started_branch`, `started_worktree`, optional `(missing)`. If this ref is already started, resume in that worktree with one agent. If the path is occupied by another issue, refuse. A `(missing)` marker means the recorded path is gone — `loaf issue stop <ref>` (not from inside the tree) before starting again. +5. **Start the workspace** (skip if already started and the path exists): + ```bash + loaf issue start <ref> + ``` + Creates branch `issue/<alias-or-id>` in lowercase (`issue/loaf-42`, id suffix when that name is claimed), a sibling worktree, and sets status to `active`. Base is the nearest started ancestor's branch, else the repository default branch. Start refuses archived rows and terminal statuses (`done`, `cancelled`, `duplicate`). +6. **Hand the agent the brief** from `loaf issue show <ref>` (body, criteria, children) and, when opening a PR, `loaf issue render <ref>`. Tell the agent to work only in `started_worktree`. +7. **Batch rounds.** When input is a parent or a set of refs, group unblocked delivery children into dependency-ready rounds from `blocked_by` edges and parent/child structure. Parallel only within a round, max 3, and only when each agent has its own worktree. See [batch-orchestration.md](references/batch-orchestration.md) for the round loop, `--dry-run` / `--parallel` / `--continue` / `--skip <ref>` / `--abort`, and blocked-state recovery. --- @@ -247,7 +161,7 @@ Spawn specialized agents with the appropriate profile: | Code review/audit | reviewer | relevant domain skills | | Research/comparison | researcher | relevant domain skills | -**Rules:** Be specific in prompts. One concern per agent. Include context. Parallel when independent, sequential when dependent. +**Rules:** Be specific in prompts. One concern per agent. Include the issue ref, `started_worktree`, body, and definition of done. Parallel when independent (separate worktrees), sequential when a `blocks` edge says so. --- @@ -256,15 +170,14 @@ Spawn specialized agents with the appropriate profile: There is no session to start — journaling is continuous. Your first action is to log the invocation: ```bash -loaf journal log "skill(implement): <task/spec/context>" +loaf journal log "skill(implement): LOAF-42 — <what>" ``` Entries are project-scoped and tagged with this conversation's harness id automatically. Continuity from prior conversations may arrive through a supported startup adapter; when the exact current target mode is candidate or unsupported, pull it explicitly with `loaf journal context`. Use `loaf journal recent` when you need a narrower timeline. -Suggest renaming the harness conversation with a meaningful name derived from context (use your harness's rename surface if it has one): -- From spec: `SPEC-027-session-stability` -- From task: `TASK-042-login-fix` -- From ad-hoc: `{short-slug-from-description}` +Suggest renaming the harness conversation with a meaningful name derived from the issue (use your harness's rename surface if it has one): +- From issue: `LOAF-42-login-fix` +- From ad-hoc match: `{alias}-{short-slug}` --- @@ -278,12 +191,14 @@ Suggest renaming the harness conversation with a meaningful name derived from co 6. **Journal continuously** -- log spawns, progress, blockers, and decisions with `loaf journal log` as they happen 7. **Clean up** -- no ephemeral files; write an optional `wrap` entry only when there's synthesis worth saving 8. **When in doubt, ask the user** +9. **Never `loaf issue stop` from inside the started worktree** -- stop does not change status; `--force` removes a dirty tree +10. **Do not tick definition-of-done boxes** -- `loaf issue verify` writes nothing; render checks a box only when status is already `done` ## Decision Tree ``` Is this a code/config/doc change? -+-- YES -> Spawn appropriate agent ++-- YES -> Spawn appropriate agent into the issue worktree +-- NO -> Is this a planning/coordination decision? +-- YES with clear path -> Proceed, log the decision +-- YES but ambiguous -> Ask user @@ -296,18 +211,16 @@ When multiple valid approaches exist: spawn council (5-7 agents, odd), present r ## Startup Checklist -1. [ ] Log the invocation: `loaf journal log "skill(implement): <context>"` -2. [ ] Parse input (task, Linear ID, or description) -3. [ ] If TASK-XXX: load task via `loaf task show TASK-XXX`, log task coupling, load parent spec -4. [ ] If Linear ID (or `SPEC-XXX` with `linear_parent`): follow [Linear-Native Routing](#linear-native-routing). Parent → walk sub-issues and select next. Sub-issue → verify `blockedBy` is clear, then start it as one logical Linear operation so the parent is promoted when needed -5. [ ] If description: auto-create task (see Ad-hoc Task Auto-Creation above) -6. [ ] Create dedicated branch (see [branch-and-completion.md](references/branch-and-completion.md)) -7. [ ] Suggest team based on task context -8. [ ] Log initial context and references with `loaf journal log` -9. [ ] Break down work using your harness's task/todo tracking surface -10. [ ] Identify needed specialized agents -11. [ ] Log next steps before spawning -12. [ ] **Get user approval** before spawning +1. [ ] Log the invocation: `loaf journal log "skill(implement): LOAF-42 — <what>"` +2. [ ] Parse input (issue ref, parent, set, frontier, or description) +3. [ ] Load `loaf issue show <ref>`; if children, `loaf issue tree <ref>` +4. [ ] `loaf issue check <ref>` — shaped/ready, or stop and send to shape +5. [ ] Confirm the ref is on `loaf issue frontier` (or already started for resume) +6. [ ] `loaf issue list --started` — one agent per worktree +7. [ ] `loaf issue start <ref>` unless already started +8. [ ] Suggest conversation rename (`LOAF-42-login-fix`) +9. [ ] Identify specialized agents; log next steps +10. [ ] **Get user approval** before spawning --- @@ -315,32 +228,28 @@ When multiple valid approaches exist: spawn council (5-7 agents, odd), present r ### BEFORE (Planning) 1. Log the invocation with `loaf journal log` -2. Set task status: `loaf task update TASK-XXX --status in_progress` -3. Break down work into agent-sized tasks -4. Identify spawn order (respect dependencies) +2. `loaf issue start <ref>` (status becomes `active` through start) +3. Slice work into agent-sized units that still belong to this one issue +4. Identify spawn order (respect `blocked_by` edges and parent/child rounds) 5. Get user approval ### DURING (Execution) -1. Spawn specialized agents via your harness's agent-spawn mechanism -2. Log each spawn with `loaf journal log "todo(agent): spawned <agent> for <task>"` -3. Update Linear with progress (no emoji, no file paths) -4. Keep journal entries handoff-ready -5. After each agent completes: log outcome, spawn next +1. Spawn specialized agents into `started_worktree` via your harness's agent-spawn mechanism +2. Log each spawn with `loaf journal log "todo(agent): spawned <agent> for <ref>"` +3. Keep journal entries handoff-ready +4. After each agent completes: log outcome, spawn next +5. If Linear overlay is enabled, you may comment there — Loaf status stays on `loaf issue` ### AFTER (Completion) 1. Code review pass (spawn `reviewer` agent) 2. Spawn implementer (with foundations + language skill) for final testing -3. **Close out spec artifacts on the branch** (included in the squash merge): - - **Local-tasks mode:** `loaf task update TASK-XXX --status done` (per task), then `loaf task archive --spec SPEC-XXX` - - **Linear-native mode:** `update_issue` the sub-issue to `completed`-type state. Then query the parent's sub-issues; if all are `completed`, also close the parent. If some remain, list them for the user (see [Linear-Native Routing → Completion](#completion-after-implementer--reviewer-finish-cleanly)) - - Mark spec complete and archive: `loaf spec archive SPEC-XXX` (both modes) - - Write a `wrap(scope)` journal entry if the work produced synthesis worth saving (next steps, abandoned paths); otherwise skip it - - Commit: `chore: close SPEC-XXX — archive tasks and spec` -4. If on a feature branch: push and create PR (`gh pr create`). Follow PR format and squash merge conventions in [commits reference](../git-workflow/references/commits.md). -5. After PR is created and approved, use ship to review, verify, and land the PR. Use release later when a coherent batch of landed work is ready to publish. -6. **Suggest reflection:** Check the journal for extractable learnings before closing out: +3. Run `loaf issue verify <ref>` (V-tier, writes nothing). Review every H-tier row yourself or with the user — a skip from verify is not a pass +4. Open or update the PR with body `loaf issue render <ref>` — no manual editing. Follow PR format and squash merge conventions in [commits reference](../git-workflow/references/commits.md) +5. After the PR is created, use ship to review, verify, land, mark `loaf issue status <ref> done`, and `loaf issue stop <ref>`. Use release later when a coherent batch of landed work is ready to publish +6. Write a `wrap(scope)` journal entry if the work produced synthesis worth saving; otherwise skip it +7. **Suggest reflection:** Check the journal for extractable learnings before closing out: - `decision(...)` entries are present - - ADRs, report verdicts, or spec changelog entries were recorded + - ADRs or report verdicts were recorded If any signal is present, suggest: *"This produced key decisions. Consider running reflect to update strategic docs."* If none are present, stay silent. --- @@ -349,18 +258,18 @@ When multiple valid approaches exist: spawn council (5-7 agents, odd), present r | Topic | Reference | Use When | |-------|-----------|----------| -| Batch Orchestration | [batch-orchestration.md](references/batch-orchestration.md) | Running specs, task ranges, or task lists with dependency-ready rounds | -| Branch and Completion | [branch-and-completion.md](references/branch-and-completion.md) | Branch management, team routing, diagrams, Linear sync, journaling, task completion | +| Batch Orchestration | [batch-orchestration.md](references/batch-orchestration.md) | Running a parent or a set of issue refs with dependency-ready rounds | +| Branch and Completion | [branch-and-completion.md](references/branch-and-completion.md) | Team routing, diagrams, exploration, journaling alongside `loaf issue start` / `stop` | +| Working issues locally | [../orchestration/references/local-tasks.md](../orchestration/references/local-tasks.md) | Frontier, started worktrees, status vocabulary, definition of done | --- ## Suggests Next -After all tasks are complete, suggest ship to land the PR. Suggest release only when the landed work forms a coherent release batch. +After the PR exists, suggest ship to land it. Suggest release only when the landed work forms a coherent release batch. ## Related Skills -- **shape** - Spec format and lifecycle -- **breakdown** - Turning specs into tasks -- **orchestration/local-tasks** - Task file format and lifecycle -- **orchestration/journal** - Project journal continuity model +- **shape** — Issue preparation and decomposition +- **orchestration/journal** — Project journal continuity model +- **orchestration/local-tasks** — Frontier, started worktrees, status, definition of done diff --git a/dist/amp/skills/implement/references/batch-orchestration.md b/dist/amp/skills/implement/references/batch-orchestration.md index 7ab48f755..7902f28f1 100644 --- a/dist/amp/skills/implement/references/batch-orchestration.md +++ b/dist/amp/skills/implement/references/batch-orchestration.md @@ -7,63 +7,64 @@ - Batch Execution Model - Blocked-State Recovery -Detailed reference for running specs, task ranges, or task lists with dependency-ready scheduling. +Detailed reference for running a parent issue or a set of issue refs with dependency-ready scheduling. ## Orchestration Options | Option | Behavior | |--------|----------| | `--dry-run` | Show dependency-ready execution plan, do not run agents | -| `--parallel` | Run tasks in the same dependency-ready group concurrently (max 3 at once) | -| `--continue` | Resume a blocked orchestration from the recorded task/group | -| `--skip TASK-XXX` | Mark one blocked task as skipped and continue | +| `--parallel` | Run issues in the same dependency-ready group concurrently (max 3 at once) | +| `--continue` | Resume a blocked orchestration from the recorded issue/group | +| `--skip <ref>` | Skip one blocked issue and continue | | `--abort` | Mark orchestration as aborted and stop remaining work | ## Batch Resolution and Dependency-Ready Scheduling -For `SPEC-XXX`, `TASK-XXX..YYY`, and `TASK-XXX,YYY,ZZZ`: +For a parent ref (`loaf issue tree <ref>`) or a named set of refs: -1. Resolve selected tasks and validate each task file exists. -2. Extract `depends_on` from each task and build a dependency graph. -3. Group tasks into dependency-ready rounds: - - First round: tasks with no unresolved dependencies - - Each subsequent round: tasks whose dependencies are completed in earlier rounds -4. If `--parallel` is set, allow parallel execution only within a dependency-ready round and only for non-conflicting tasks. -5. Present execution plan (tasks, dependency-ready rounds, mode, total count) and ask for confirmation unless `--dry-run`. -6. Track progress in the journal and in task statuses: log round boundaries and the current task with `loaf journal log`, and drive each task's status with `loaf task update`. The journal plus task statuses are the durable record of where the batch is. +1. Resolve the selected refs and validate each issue exists (`loaf issue show <ref>`). +2. Read `blocks` / `blocked_by` edges and parent/child structure. Parent/child is not a sequencing edge — only `blocks` / `blocked_by` are. +3. Group unblocked delivery children into dependency-ready rounds: + - First round: issues with no unresolved predecessors + - Each subsequent round: issues whose predecessors are `done`, `cancelled`, or `duplicate` +4. If `--parallel` is set, allow parallel execution only within a dependency-ready round, max 3, and only when each agent has its own started worktree. +5. Present execution plan (issues, dependency-ready rounds, mode, total count) and ask for confirmation unless `--dry-run`. +6. Track progress in the journal: log round boundaries and the current ref with `loaf journal log`. Status moves through `loaf issue start` (to `active`) and, after landing, `loaf issue status <ref> done`. The journal plus issue statuses are the durable record of where the batch is. + +Parents with children are not the implementation target. Dispatch leaf delivery children that are on `loaf issue frontier`. ## Option Handling (`--continue`, `--skip`, `--abort`) -1. Recover batch progress from the journal: `loaf journal recent --since-last-wrap` (or `loaf journal context`) plus `loaf task list --json` to see which tasks are still open. -2. If `--continue`: resume from the last logged dependency-ready round and task. -3. If `--skip TASK-XXX`: mark that task `skipped` via `loaf task update`, log the reason with `loaf journal log`, continue the same dependency-ready round. +1. Recover batch progress from the journal: `loaf journal recent --since-last-wrap` (or `loaf journal context`) plus `loaf issue list --json` and `loaf issue list --started` to see which issues are still open or claimed. +2. If `--continue`: resume from the last logged dependency-ready round and issue. +3. If `--skip <ref>`: log the reason with `loaf journal log`, continue the same dependency-ready round. Do not mark the skipped issue `done`. 4. If `--abort`: log `block(orchestration): aborted`, print a summary, and stop. 5. If no in-flight batch is evident from the journal, report that and ask for fresh selection input. ## Batch Execution Model -When input resolves to multiple tasks, run a dependency-ready round loop: +When input resolves to multiple issues, run a dependency-ready round loop: 1. Set orchestration mode (`sequential` by default, `parallel` only with `--parallel`). 2. For each dependency-ready round: - Log the round start with `loaf journal log` - - Run each task (sequentially, or concurrently within safety limits) - - For each task: set `in_progress` -> spawn agent -> run task verification -> mark `done`/`failed` via `loaf task update` -3. If any task fails verification, stop immediately and log `block(orchestration): <task> failed <reason>`. -4. Consider a round complete only when all its tasks are `done` or skipped. + - For each issue: `loaf issue list --started`, then `loaf issue start <ref>` unless already started, spawn one agent into `started_worktree`, run `loaf issue verify <ref>` (V-tier; writes nothing) +3. If any issue fails verification, stop immediately and log `block(orchestration): <ref> failed <reason>`. +4. Consider a round complete only when all its issues have landed (`loaf issue status <ref> done` via ship) or were skipped. 5. Continue until all rounds complete, then log a closing entry summarizing the batch. ## Blocked-State Recovery When blocked, always print: -- Failed task ID and title +- Failed issue ref and title - Dependency-ready round and current progress - Failure reason + key error output - Recovery options: Re-invoke the implement workflow with: -- `--continue` — after fixes are applied, retry from the blocked task -- `--skip TASK-XXX` — skip only the specified task and continue remaining tasks in the current dependency-ready round +- `--continue` — after fixes are applied, retry from the blocked issue +- `--skip <ref>` — skip only the specified issue and continue remaining issues in the current dependency-ready round - `--abort` — finalize the orchestration as aborted with no further execution diff --git a/dist/amp/skills/implement/references/branch-and-completion.md b/dist/amp/skills/implement/references/branch-and-completion.md index e4bea8b5d..5c89cdf02 100644 --- a/dist/amp/skills/implement/references/branch-and-completion.md +++ b/dist/amp/skills/implement/references/branch-and-completion.md @@ -8,7 +8,7 @@ - Linear Status Management - Handoff Readiness - Timestamps for User Context -- Task Completion +- Issue Completion Detailed reference for branch setup, Linear routing, and completion during implementation. @@ -18,28 +18,18 @@ Detailed reference for branch setup, Linear routing, and completion during imple ### Getting Branch Name -1. **If Linear issue exists**: Use the `branchName` field from `get_issue` response - - Linear auto-generates branch names like `username/plt-123-issue-title` - - These are pre-formatted and consistent with team conventions +`loaf issue start <ref>` is the claim. It creates `issue/<alias-or-id>` in lowercase (`issue/loaf-42`, id suffix when that name is claimed), a sibling worktree, and moves status to `active`. -2. **If no Linear issue**: Create branch name from the work description - - Format: `feature/<description>` or `fix/<description>` - - Use kebab-case, keep it concise +Do not `git checkout -b` as a substitute for start. Check `loaf issue list --started` first. Never send two agents into the same worktree. Do not run `loaf issue stop` from inside that worktree. ### Branch Workflow ```bash -# 1. Check current branch status -git status - -# 2. Create and checkout the branch (use Linear's branchName if available) -git checkout -b <branch-name> - -# 3. Confirm branch creation -git branch --show-current +loaf issue list --started +loaf issue start <ref> ``` -**Important:** All implementation agents will work on this branch. The branch should be ready for PR when work completes. Journal entries are tagged with the observed branch automatically, so continuity stays branch-scoped. +Work only in `started_worktree`. The branch should be ready for PR when work completes. Journal entries are tagged with the observed branch automatically. --- @@ -53,7 +43,7 @@ When creating Linear issues, suggest the appropriate team: > "This task seems best suited for the **Security** team (matched: 'auth', 'vulnerability'). > Security hasn't been used in this project yet. Add this team?" 4. **If user confirms**, add team to `known_teams` in config -5. **Create issue** with suggested team +5. **Create via `loaf issue new`** so identity can be delegated; do not create in Linear MCP and forget `loaf issue pull` ### Team Suggestion Example @@ -75,7 +65,7 @@ Use Linear MCP's `list_teams` (if configured) to get all workspace teams for val ## Diagram Consideration -For multi-file or multi-service changes, consider adding architecture diagrams to the linked spec, report, ADR, or implementation notes. +For multi-file or multi-service changes, consider adding architecture diagrams to the issue, a report, ADR, or implementation notes. ### When to Create Diagrams @@ -94,7 +84,7 @@ Ask yourself: 2. Is there a data flow that needs to be understood? 3. Would a visual help communicate the approach? -If yes to any, capture the diagram in a durable artifact such as a spec, report, ADR, or implementation note, and log the reference with `loaf journal log`. +If yes to any, capture the diagram in a durable artifact such as a report, ADR, or implementation note, and log the reference with `loaf journal log`. ### Diagram Template @@ -146,36 +136,23 @@ For complex tasks, explore before implementing: ## Linear Status Management -**Keep Linear status synchronized with actual work state:** - -| Work State | Linear Status (sub-issue) | -|------------|---------------------------| -| Work begun | In Progress | -| Blocked/waiting for user | In Progress (add blocker comment) | -| Work completed | Done (or In Review if PR pending) | +**Keep Loaf status synchronized with actual work state.** Linear is an overlay (`loaf issue pull` / `push` / `reconcile`); never drive Loaf status from Linear MCP tools. -### Parent rollup auto-close +| Work State | Loaf status | +|------------|-------------| +| Work begun | `active` via `loaf issue start` | +| Blocked/waiting | Stay `active`; log `block(scope)` and leave a Linear comment if the overlay is on | +| Work landed | `done` via `loaf issue status <ref> done` (usually ship), then `loaf issue stop <ref>` | -In Linear-native mode, the **parent** rollup issue (labeled `spec`) is not -moved manually during sub-issue work. It flips to Done automatically when -the last sub-issue flips to Done, and only then. Procedure: +### Parent vs children -1. After moving a sub-issue to a `completed`-type state, call - `list_issues` with `parent: <parent-id>`. -2. If every sub-issue is in a `completed`-type state, move the parent to - `completed` via `update_issue`. -3. If any sub-issue is still in an open state (including `blocked`), the - parent stays where it is — the spec is not done. +Parents with children are not the implementation target. Dispatch leaf delivery children on `loaf issue frontier`. A parent is not marked `done` because a child landed. -Never set the parent to In Progress manually — a parent in Linear-native -mode reflects a rollup of its sub-issues, not its own work. +`loaf issue link A blocks B` is the sequencing edge. An issue with an open predecessor does not appear on the frontier. Do not start a blocked successor. -### BlockedBy pre-flight +### Blocked-by pre-flight -Before moving a sub-issue to In Progress, confirm every issue in its -`blockedBy` field is in a `completed`-type state. If not, refuse to start -and report the blockers. This is a hard gate in Linear-native mode — -never implement through open `blockedBy`. +Before `loaf issue start`, confirm the ref is on `loaf issue frontier`. If it is blocked, refuse and report the predecessors. Never implement through an open `blocks` edge. --- @@ -184,7 +161,7 @@ never implement through open `blockedBy`. **The journal must ALWAYS be handoff-ready.** After every significant action: 1. Log what just happened with `loaf journal log` -2. Reference task/spec/report/commit IDs rather than duplicating long prose +2. Reference issue/report/commit IDs rather than duplicating long prose 3. Log completed agent work with outcomes 4. Ensure anyone could pick up the work immediately from `loaf journal recent` @@ -205,32 +182,18 @@ Generate with: `date -u +"%Y-%m-%d %H:%M UTC"` --- -## Task Completion +## Issue Completion -When a task-coupled unit of work completes: +When an issue-coupled unit of work completes: -1. **Update task status** (local file or Linear sub-issue) -2. **Check spec progress:** - - Local-tasks mode: list all tasks for the spec; if all done → mark - spec `complete`, else spec stays `implementing` - - Linear-native mode: query the parent rollup's sub-issues via - `list_issues` with `parent: <parent-id>`; if all are `completed`-type, - close the parent and mark the local spec `complete`, else both stay - in flight -3. **Write a `wrap` journal entry** if the conversation holds synthesis worth - saving (next steps, abandoned paths); skip it otherwise — nothing is - "closed," a conversation that ends without a wrap leaves a valid journal - -### Spec Completion Check +1. **Open or update the PR** with body `loaf issue render <ref>` — no manual editing +2. **Land via ship** — review definition of done, `loaf issue verify <ref>`, squash merge, then `loaf issue status <ref> done` and `loaf issue stop <ref>` +3. **Write a `wrap` journal entry** if the conversation holds synthesis worth saving (next steps, abandoned paths); skip it otherwise — nothing is "closed," a conversation that ends without a wrap leaves a valid journal ```bash -# Local-tasks mode: any open tasks for this spec? -loaf task list --spec SPEC-001 --status open --json - -# Linear-native mode: query the Linear parent's sub-issues -# (via get_issue + list_issues with parent filter) -# The parent itself only flips to Done when every sub-issue is Done. +loaf issue show <ref> +loaf issue tree <ref> +loaf issue list --started ``` -Never mark the local spec `complete` while its Linear parent still has -open sub-issues — the two sources of truth should agree on "done." +Do not mark a parent `done` while delivery children are still open. Do not flip Loaf status from Linear MCP tools; use `loaf issue reconcile` if the overlay has drifted. diff --git a/dist/amp/skills/implement/templates/journal.md b/dist/amp/skills/implement/templates/journal.md index caf7409fd..9a0a61f8f 100644 --- a/dist/amp/skills/implement/templates/journal.md +++ b/dist/amp/skills/implement/templates/journal.md @@ -13,7 +13,7 @@ not create or edit journal markdown as the source of truth — use ## Entries -[YYYY-MM-DD HH:MM] skill(implement): implementing TASK-042 +[YYYY-MM-DD HH:MM] skill(implement): implementing LOAF-42 [YYYY-MM-DD HH:MM] decision(scope): description of decision [YYYY-MM-DD HH:MM] discover(scope): something learned [YYYY-MM-DD HH:MM] block(scope): what is blocked diff --git a/dist/amp/skills/loaf-reference/SKILL.md b/dist/amp/skills/loaf-reference/SKILL.md index 06baf7b87..8f58f82f2 100644 --- a/dist/amp/skills/loaf-reference/SKILL.md +++ b/dist/amp/skills/loaf-reference/SKILL.md @@ -25,7 +25,7 @@ The Loaf operating manual for agents: how to discover commands, diagnose project ## Operating Rules - Get exact, current syntax live: `loaf --help` lists every command, `loaf <command> --help` details one. This index is a map, not the contract. -- Prefer `--json` surfaces when diagnosing: `loaf config check --json`, `loaf state doctor --json`, `loaf change check --json`. Parse the structured output instead of scraping human-readable text. +- Prefer `--json` surfaces when diagnosing: `loaf config check --json`, `loaf state doctor --json`. Parse the structured output instead of scraping human-readable text. - Run the deterministic CLI command before hand-editing anything it manages; the command owns its files. - Use `--fix` only for safe, mechanical repairs, and review what it changed. - Ask the user for project-owned choices — GitHub account, tracker or integration election, which harnesses to install — never guess them. @@ -64,17 +64,16 @@ Names and one-line purposes only. Run `loaf <command> --help` for options, argum | `loaf config` | Validate and refresh project Loaf config | check | | `loaf hooks` | Inspect and set which Loaf hooks project into an installed harness's hooks file | list, enable, disable | | `loaf init` | Initialize a project with Loaf structure | — | -| `loaf release` | Create a new release with changelog, version bump, and tag | — | +| `loaf release` | Cut a retroactive release from already-landed work | suggest, cut | | `loaf search` | Search SQLite artifact bodies, journal entries, and indexed docs | — | | `loaf docs` | Manage docs/ indexing | index | -| `loaf change` | Shape-first Change artifacts: git-canonical work context under docs/changes/ | init, check, list | | `loaf render` | Maintain committed durable Markdown renders | sweep | | `loaf state` | Manage native SQLite state | path, status, init, doctor, repair legacy-project-database, repair relationship-origin, repair journal-search, migrate markdown, migrate storage-home, migrate schema, migrate deferrals, migrate lifecycle-statuses, migrate alias-orphans, migrate journal-duplicates, migrate journal-first, backup, backup verify, backup restore, restore-ephemerals, verify-ephemerals, export, export all, export triage, export spec, export release-readiness | | `loaf journal` | Record and read the project-scoped journal (the durable record across all conversations) | log, recent, search, show, context, export, defer | | `loaf project` | Manage durable project identity | list, show, identity, rename, move, delete | | `loaf migrate` | Run native migration workflows | markdown, storage-home, schema, lifecycle-statuses, journal-first, worktree-storage | -| `loaf task` | Manage project tasks | list, show, status, create, update, archive, refresh, sync | -| `loaf spec` | Manage project specs | new, edit, list, show, status, render, finalize, archive, delete | +| `loaf task` | Manage project tasks; superseded by loaf issue for new work | list, show, status, create, update, archive, refresh, sync | +| `loaf issue` | Manage issues in native SQLite state | new, show, list, tree, frontier, start, stop, edit, status, dod, dod add, dod list, dod remove, dod claim, dod unclaim, promote, check, verify, bucket, link, render, export, pull, push, reconcile | | `loaf report` | Manage durable reports (research, audits, investigations) | list, show, render, generate, create, edit, finalize, archive | | `loaf finding` | Manage report findings and verdicts in native SQLite state | list, show, create, verdict, import-json | | `loaf run` | Manage provenance runs for generated findings and reports | list, show, create, complete | @@ -88,7 +87,7 @@ Names and one-line purposes only. Run `loaf <command> --help` for options, argum | `loaf trace` | Trace relationships for one state entity | — | | `loaf brainstorm` | Manage brainstorms in native SQLite state | capture, list, show, promote, archive | | `loaf idea` | Manage ideas in native SQLite state | list, show, capture, promote, resolve, archive | -| `loaf intent` | Manage tracked Intent in native SQLite state; disposition is derived from append-only facts | create, defer, resume, resolve, show, list | +| `loaf intent` | Manage tracked Intent in native SQLite state; disposition is derived from append-only facts; superseded by loaf issue for new work | create, defer, resume, resolve, show, list | | `loaf intake` | Read the deterministic local intake projection; triage judgment stays with humans and Skills | list | | `loaf exploration` | Manage relational Exploration continuity: immutable portable checkpoints, no lifecycle status, no current pointer | create, checkpoint, list, context, conversation | | `loaf conversation` | Manage logical conversations and machine-local provenance handles; handles never imply portable context | create, show, list, handle, observe | diff --git a/dist/amp/skills/loaf-reference/references/command-routing.md b/dist/amp/skills/loaf-reference/references/command-routing.md index e6856fd65..5f9d30bbb 100644 --- a/dist/amp/skills/loaf-reference/references/command-routing.md +++ b/dist/amp/skills/loaf-reference/references/command-routing.md @@ -6,15 +6,15 @@ Which command a task needs. For exact flags, run `loaf <command> --help`. | Intent | Route | |--------|-------| -| Shape new bounded work | `loaf change init <slug>`, then `loaf change check` | -| Start implementing new bounded work | the implement workflow after shaping and validating its Change | -| Continue an existing task or spec record | `loaf task` and `loaf spec` remain supported for existing records | +| Shape new bounded work | `loaf issue new <title>`, then `loaf issue dod add` and `loaf issue check <ref>` | +| Start implementing new bounded work | the implement workflow: pick from `loaf issue frontier`, then `loaf issue start <ref>` | +| Continue an existing task or spec record | `loaf task` and `loaf spec` remain readable for legacy records; new work is issues | | Continue after a restart | `loaf journal context` | | Skills or content changed | `loaf build && loaf install --to <target>` | -| See what is in progress | `loaf task list --active` | -| Archive completed work | `loaf task archive` | +| See what is in progress | `loaf issue list --status active` and `loaf issue list --started` | +| Remove finished-with work | `loaf issue status <ref> cancelled` or `duplicate --duplicate-of <ref>` (archives; record survives) | | Check knowledge freshness | `loaf kb check` | -| Validate a Change is structurally executable, not implementation-complete | `loaf change check --require-executable` | +| Validate an issue is shaped, covered, and contained | `loaf issue check <ref>` (non-zero exit names each failure) | | Import legacy `.agents` Markdown into SQLite | `loaf migrate markdown --dry-run` then `--apply` (see markdown-migration reference) | ## JSON diagnosis surfaces @@ -24,10 +24,10 @@ scraping human-readable text: - `loaf config check --json` — config file and installed hook config validity - `loaf state doctor --json` / `loaf state status --json` — SQLite health and readiness -- `loaf change check --json` — Change violations and derived executability +- `loaf issue check <ref> --json` — derived readiness, coverage, and containment - `loaf check --hook <id> --json` — one enforcement hook's result - `loaf kb check --json` — knowledge staleness against git history -- `loaf task list --json` / `loaf journal recent --json` — current work and timeline +- `loaf issue list --json` / `loaf journal recent --json` — current work and timeline - `loaf migrate markdown --dry-run --json` — `mode` (`simulation`/`inventory`) plus `import_report` when simulated Choosing between the `doctor` commands and `LOAF_DB` isolation are covered in diff --git a/dist/amp/skills/orchestration/SKILL.md b/dist/amp/skills/orchestration/SKILL.md index 2013e84f9..37fd15126 100644 --- a/dist/amp/skills/orchestration/SKILL.md +++ b/dist/amp/skills/orchestration/SKILL.md @@ -42,9 +42,9 @@ Comprehensive patterns for orchestration: coordinating multi-agent work, keeping - Outcome-focused, self-contained, no local file references - Magic words in commit body, not subject -**If `integrations.linear.enabled` is `true` in `.agents/loaf.json`:** use Linear MCP workflows and [references/linear.md](references/linear.md) for issue updates and status. +**If `integrations.linear.enabled` is `true` in `.agents/loaf.json`:** Linear is an identity adapter — `loaf issue pull` / `push` / `reconcile`, not a second work unit. See [references/linear.md](references/linear.md). Linear MCP is an overlay; Loaf issues remain the work unit and Linear never drives Loaf status. -**Otherwise:** coordinate with the project journal and `loaf task` / file-based tracking only; do not assume Linear MCP tools are available. +**Otherwise:** coordinate with the project journal and `loaf issue` only; do not assume Linear MCP tools or identity delegation are available. ### Planning (Shape Up) - Complexity-based sizing (small / medium / large) @@ -71,15 +71,15 @@ Comprehensive patterns for orchestration: coordinating multi-agent work, keeping | Pre-compaction | On an exact target mode with supported compaction delivery, hooks may nudge a journal flush and emit the digest afterward; otherwise flush manually and run `loaf journal context` after compaction | | Durable artifact handling | Delegate `.agents/`-scoped report/spec/handoff/knowledge tending to `librarian` | | Low-priority work | Spawn background-runner (see Background Agents) | -| New feature workflow | Research -> Architecture -> Shape -> Breakdown -> Implement | +| New feature workflow | Pitch -> Shape -> Implement -> Ship -> Release | ## Topics | Topic | Reference | Use When | |-------|-----------|----------| -| Shaping Specs | [../shape/SKILL.md](../shape/SKILL.md) | Creating specs, shaping work, defining test conditions | -| Breaking Work Into Tasks | [../breakdown/SKILL.md](../breakdown/SKILL.md) | Turning shaped specs into implementation tasks | -| Local Tasks | [references/local-tasks.md](references/local-tasks.md) | Managing tasks locally or with Linear backend | +| Shaping Issues | [../shape/SKILL.md](../shape/SKILL.md) | Preparing issues: body, definition of done, out of scope | +| Decomposition | [../shape/SKILL.md](../shape/SKILL.md) | Promoting a criterion that earns its own DoD (`loaf issue promote`) | +| Working Issues | [references/local-tasks.md](references/local-tasks.md) | Frontier, started worktrees, status, definition of done | | Agent Delegation | [references/delegation.md](references/delegation.md) | Choosing agents, spawning subagents, decision trees | | Parallel Agents | [references/parallel-agents.md](references/parallel-agents.md) | Dispatching independent work concurrently | | Subagent Development | [references/subagent-development.md](references/subagent-development.md) | Delegating to specialized agents | @@ -96,7 +96,7 @@ Comprehensive patterns for orchestration: coordinating multi-agent work, keeping The orchestrator: 1. Creates issues and logs the orchestration intent for tracking -2. Breaks down work into delegable tasks +2. Picks from `loaf issue frontier` and starts one worktree per issue 3. Spawns specialized agents for implementation 4. Coordinates outcomes and updates external systems 5. Never implements code, tests, or documentation directly @@ -126,16 +126,16 @@ This skill uses paths from `.agents/loaf.json`: | Councils | `.agents/councils/` | `.agents/councils/archive/` | `YYYYMMDD-HHMMSS-topic.md` | | Handoffs | `.agents/handoffs/` | delete after deprecated | Created by handoff | | Reports | `.agents/reports/` | N/A | `YYYYMMDD-HHMMSS-subject.md` | -| Tasks | SQLite (`loaf task show/list`) | N/A | Per task manager conventions | +| Issues | SQLite (`loaf issue show/list`) | `cancelled` / `duplicate` via `loaf issue status` | Alias or opaque id | **Rule:** Agents write artifacts to disk, orchestrator reasons over artifacts, users retrieve from disk. ## Workflow by Lifecycle ### BEFORE (Planning) -- Create/check external issue (Linear, GitHub) +- Shape prepares issues; implement works the frontier. Decomposition is `loaf issue promote` inside shape. - Log the orchestration intent with `loaf journal log` -- Break down into tasks, identify agents, get user approval +- `loaf issue check <ref>` must report shaped (delivery) or ready (decision); identify agents; get user approval ### DURING (Execution) - Spawn specialized agents (never implement directly) @@ -144,6 +144,6 @@ This skill uses paths from `.agents/loaf.json`: ### AFTER (Completion) - Code review + QA testing -- Update external issue to Done +- Land via ship: `loaf issue status <ref> done`, then `loaf issue stop <ref>` - Ensure knowledge captured in permanent locations - Write an optional `wrap` journal entry if the conversation holds synthesis worth saving diff --git a/dist/amp/skills/orchestration/references/background-agents.md b/dist/amp/skills/orchestration/references/background-agents.md index 6aa2b3c61..95b4b3b54 100644 --- a/dist/amp/skills/orchestration/references/background-agents.md +++ b/dist/amp/skills/orchestration/references/background-agents.md @@ -43,7 +43,7 @@ Task( - src/services/ Write report to: .agents/reports/YYYYMMDD-HHMMSS-security-audit.md - Reference: TASK-123, SPEC-045 if relevant + Reference: LOAF-123 if relevant """, run_in_background=True ) @@ -51,12 +51,12 @@ Task( ### Cursor -Background agents are configured via the `is_background: true` YAML property. When spawning, specify the report destination and any task/spec IDs: +Background agents are configured via the `is_background: true` YAML property. When spawning, specify the report destination and any issue refs: ``` @background-runner Run security audit on backend codebase. Write report to .agents/reports/. -Reference TASK-123 if relevant. +Reference LOAF-123 if relevant. ``` The background agent's journal entries are tagged with its own harness id automatically — there is no session alias to pass. @@ -72,7 +72,7 @@ Track background work with durable references: 1. Log the spawn with `loaf journal log "todo(background): started <id> for <task>"`. 2. Ask the background agent to write a report under `.agents/reports/`. 3. When complete, log `discover(background): <id> wrote <report>`. -4. Process findings into tasks, specs, ADRs, or report verdicts as appropriate. +4. Process findings into issues, ADRs, or report verdicts as appropriate. Use a stable ID such as `bg-YYYYMMDD-HHMMSS-description` in the prompt and journal entries. @@ -85,7 +85,7 @@ Background agents write results to `.agents/reports/` with enough metadata to id 1. Orchestrator identifies non-blocking security audit work. 2. Orchestrator logs the background spawn to the journal. 3. Background agent writes `.agents/reports/YYYYMMDD-HHMMSS-auth-security.md`. -4. Orchestrator reviews the report, creates follow-up tasks, and logs the outcome. +4. Orchestrator reviews the report, creates follow-up issues, and logs the outcome. 5. Report state is finalized or archived through the report lifecycle. ## Anti-Patterns @@ -94,7 +94,7 @@ Background agents write results to `.agents/reports/` with enough metadata to id |-------|------------| | Use for blocking work | Keep blocking work in foreground | | Spawn without tracking | Log the spawn and require a report path | -| Ignore completed results | Process reports into tasks, findings, or decisions | +| Ignore completed results | Process reports into issues, findings, or decisions | | Use for interactive tasks | Reserve for autonomous work | | Spawn many concurrent background agents | Limit concurrency to avoid resource contention | | Skip result location in prompt | Always specify where output belongs | diff --git a/dist/amp/skills/orchestration/references/context-management.md b/dist/amp/skills/orchestration/references/context-management.md index 11ce34ac7..49151a167 100644 --- a/dist/amp/skills/orchestration/references/context-management.md +++ b/dist/amp/skills/orchestration/references/context-management.md @@ -19,28 +19,28 @@ Patterns for keeping long work resumable while using the project journal as exte Compaction is normal in long workflows. Design work that spans many exchanges so important state is already outside chat context. 1. **The journal is external memory.** Record decisions, discoveries, blockers, and next actions with `loaf journal log`. -2. **Artifacts carry detail.** Changes, task-board records, reports, ADRs, and commits hold rich detail; journal entries point to them. +2. **Artifacts carry detail.** Issues, reports, ADRs, and commits hold rich detail; journal entries point to them. 3. **Delegated work absorbs exploration.** Use delegated agents for broad investigation and return concise findings to the main context. -4. **`wrap` captures synthesis.** When meaningful work holds intentions or abandoned paths worth saving, write an optional `wrap` journal entry. +4. **`wrap` captures synthesis.** When meaningful work holds abandoned paths worth saving, write an optional `wrap` journal entry. ## Continuity Digest (contract v2) -`loaf journal context` is the contract-v2 active-truth digest and supersedes the retired three-part summary. Read its named layers and diagnostics; an absent item and an unavailable source are different states. +`loaf journal context` is the contract-v2 active-truth digest. Read its named layers and diagnostics; an absent item and an unavailable source are different states. Layer *names* below are the live CLI identifiers. | Layer | Truth and precedence | |-------|----------------------| | `project-synthesis` | The latest `wrap(project)` synthesis. This is the only wrap that represents project-wide synthesis. | | `scoped-checkpoint` | The latest non-project wrap, only when `project-synthesis` has no item. It is labeled as a fallback, not promoted to project synthesis. | -| `active-lineage` | Journal evidence associated with the active Change lineage. | +| `active-lineage` | Journal evidence associated with the active work lineage. | | `unresolved-blockers` | Blocks without a later exact-scope unblock. | -| `deferred-intent` | Open deferred-intent decision and spark pairs. | -| `active-changes` | Git-derived active Change evidence and worktree state. | +| `deferred-intent` | CLI layer for open deferred decision and spark pairs. | +| `active-changes` | Git-derived active worktree evidence. | | `branch-recency` | Recent branch entries after entries already surfaced as active truth are removed. | -| `transitional-tasks` | Open task-board records retained for compatibility. | +| `transitional-tasks` | Leftover board records retained for compatibility — not the work unit. Prefer `loaf issue frontier` / `loaf issue list --started`. | -Each returned layer includes `source_available`, `available_count`, `shown_count`, `truncated`, and `expand_command`; paginated layers also include a cursor. Treat `source_available: false` as an explicit unavailable source, never as “nothing is active.” If Change discovery is unavailable, `active-changes` and `active-lineage` are unavailable and the digest carries a diagnostic. +Each returned layer includes `source_available`, `available_count`, `shown_count`, `truncated`, and `expand_command`; paginated layers also include a cursor. Treat `source_available: false` as an explicit unavailable source, never as “nothing is active.” If git-derived discovery is unavailable, `active-changes` and `active-lineage` are unavailable and the digest carries a diagnostic. -Use `--branch` to select `branch-recency` scope and bind state cursors. It does not override active Change provenance or reasons, which always use the actual Git branch. Use `loaf journal context --layer <name>` to inspect one layer. `--limit` accepts 1 through 100 and requires `--layer`; `--cursor` also requires `--layer` and cannot be used with the intrinsic one-item `project-synthesis` or `scoped-checkpoint` layers. Follow the returned `expand_command` exactly: a cursor is bound to its layer, project, branch, snapshot, and limit. Use `--json` for automation; the human view preserves availability, counts, truncation, diagnostics, and expansion commands. +Use `--branch` to select `branch-recency` scope and bind state cursors. It does not override git provenance or reasons, which always use the actual Git branch. Use `loaf journal context --layer <name>` to inspect one layer. `--limit` accepts 1 through 100 and requires `--layer`; `--cursor` also requires `--layer` and cannot be used with the intrinsic one-item `project-synthesis` or `scoped-checkpoint` layers. Follow the returned `expand_command` exactly: a cursor is bound to its layer, project, branch, snapshot, and limit. Use `--json` for automation; the human view preserves availability, counts, truncation, diagnostics, and expansion commands. ## Context Commands @@ -53,21 +53,21 @@ Use `--branch` to select `branch-recency` scope and bind state cursors. It does ## When to Clear Context -Clear the conversation when starting a completely new task, after the previous task is complete, when debugging noise crowds out the current objective, or when switching between unrelated codebases. Avoid clearing mid-task until enough state is logged for recovery. +Clear the conversation when starting a completely new issue, after the previous issue is complete, when debugging noise crowds out the current objective, or when switching between unrelated codebases. Avoid clearing mid-issue until enough state is logged for recovery. ## Compaction Lifecycle PreCompact: 1. Flush unrecorded decisions, discoveries, blockers, and next actions with `loaf journal log`. -2. Reference Changes, task-board records, reports, commits, and files by stable ID or path. +2. Reference issues, reports, commits, and files by stable ID or path. 3. On an exact target mode with supported PreCompact delivery, let the hook nudge the flush; otherwise flush manually before compacting. PostCompact: 1. On an exact target mode with supported resumption delivery, read the continuity digest emitted by the hook; otherwise run `loaf journal context` explicitly. 2. Expand the named layer that needs more detail, or use `loaf journal recent` and `loaf journal search` for a different query. -3. Continue from the journal and linked artifacts. +3. Continue from the journal and linked artifacts (`loaf issue show <ref>`). This makes compaction survivable without relying on hand-maintained Markdown state. State not logged or captured in a durable artifact can be lost. @@ -79,10 +79,10 @@ Use delegated agents to investigate without filling the main context. |-----------|----------| | Quick file lookup | Direct read or search tool | | Multi-file exploration | Explorer or research agent | -| Implementation work | Implementer or task-focused agent | +| Implementation work | Implementer in the issue's started worktree | | Long audit | Background agent with report output | -Pass stable references to delegated agents: Change IDs, task IDs, branch names, and report paths. The harness ID is attached to journal entries automatically; there is no session alias to pass. +Pass stable references to delegated agents: issue IDs, branch names, worktree paths, and report paths. The harness ID is attached to journal entries automatically; there is no session alias to pass. Never send two agents to the same started worktree. ## Context Budget Guidelines @@ -109,12 +109,12 @@ No special management is usually needed. | Repeating same mistakes | Context pollution | Log current facts, then clear or compact | | Forgetting recent decisions | Overcrowded context | Read `loaf journal context` and expand the relevant layer | | Slow responses | Large context | Delegate exploration | -| Confusion about task | Too many pivots | Re-anchor on Change or task IDs | +| Confusion about the work | Too many pivots | Re-anchor on issue IDs (`loaf issue show`) | ## Best Practices 1. Log durable facts early with `loaf journal log`. 2. Use delegated agents for exploration-heavy work. -3. Clear between unrelated tasks. -4. Compact mid-task when the journal and artifacts are current. +3. Clear between unrelated issues. +4. Compact mid-issue when the journal and artifacts are current. 5. Scope tool calls so context stays focused. diff --git a/dist/amp/skills/orchestration/references/delegation.md b/dist/amp/skills/orchestration/references/delegation.md index b710244b6..a620c0ba7 100644 --- a/dist/amp/skills/orchestration/references/delegation.md +++ b/dist/amp/skills/orchestration/references/delegation.md @@ -131,8 +131,8 @@ Use when work is truly independent. Spawn multiple agents in the same turn when 1. **Be specific in prompts** - Include file paths, requirements, constraints 2. **One concern per agent** - Don't ask a backend implementer to also write tests -3. **Include context** - Task/spec IDs, issue ID, previous outcomes -4. **Reference durable artifacts** - Task, spec, and report IDs; the subagent's journal entries are harness-id tagged automatically +3. **Include context** - Issue refs (`LOAF-42` or opaque id), previous outcomes +4. **Reference durable artifacts** - Issue aliases and report IDs; the subagent's journal entries are harness-id tagged automatically 5. **Include skill hints** - Name the skills that should guide the agent's work ### Skill Hints @@ -171,8 +171,7 @@ Files: - src/api/users.py - src/models/user.py -Task: TASK-042 -Linear: BACK-123 +Issue: LOAF-42 ``` ## Anti-Patterns @@ -183,7 +182,7 @@ Linear: BACK-123 | Asking backend implementer for React | Spawn implementer with frontend skills | | Single agent for database + backend + tests | Sequential: implementer (database-design), implementer (language skill), implementer (foundations) | | Parallel spawns with hidden dependencies | Make dependencies explicit, spawn sequentially | -| Spawning without context | Reference task/spec/report IDs in prompts | +| Spawning without context | Reference issue aliases and report IDs in prompts | | Council for simple decisions | Single agent or orchestrator judgment | ## Agent Access Hierarchy diff --git a/dist/amp/skills/orchestration/references/journal.md b/dist/amp/skills/orchestration/references/journal.md index de2a9de3a..442f2b216 100644 --- a/dist/amp/skills/orchestration/references/journal.md +++ b/dist/amp/skills/orchestration/references/journal.md @@ -42,20 +42,20 @@ loaf journal log "spark(scope): possible follow-up idea" loaf journal log "todo(scope): concrete follow-up action" ``` -Log durable facts, not thoughts. Reference task IDs, spec IDs, report IDs, and +Log durable facts, not thoughts. Reference issue IDs, report IDs, and commit refs rather than pasting long prose. The journal should let another agent resume without reading the whole conversation. ## Codex Auto Mode -When the user has explicitly enabled Loaf's managed Codex Auto-journal capability, use the exact path-pinned command shown in the Loaf-managed block of `CODEX_HOME/AGENTS.md`; its form is `'<canonical-loaf-path>' journal log --execpolicy-safe "decision(scope): chose X because Y"`. Do not substitute a bare `loaf`, alternate executable, or shell/environment wrapper. The installed Codex rule authorizes only explicitly classified basic Loaf command leaves outside the workspace sandbox, including this hardened journal writer and approved readers; ordinary `journal log`, body/file-consuming leaves, and path-taking `change check` remain operator-gated, and the policy does not authorize a general Loaf data-directory writable root. Other harness adapters are not implied and continue to use their own ordinary surfaces. +When the user has explicitly enabled Loaf's managed Codex Auto-journal capability, use the exact path-pinned command shown in the Loaf-managed block of `CODEX_HOME/AGENTS.md`; its form is `'<canonical-loaf-path>' journal log --execpolicy-safe "decision(scope): chose X because Y"`. Do not substitute a bare `loaf`, alternate executable, or shell/environment wrapper. The installed Codex rule authorizes only explicitly classified basic Loaf command leaves outside the workspace sandbox, including this hardened journal writer and approved readers; ordinary `journal log`, body/file-consuming leaves, and other path-taking operator-gated leaves remain operator-gated, and the policy does not authorize a general Loaf data-directory writable root. Other harness adapters are not implied and continue to use their own ordinary surfaces. Enable the capability once with `loaf install --to codex --codex-basic-commands`. Installation is an explicit trust decision. If the rules are absent, stale, locally modified, or conflict with user-owned `loaf.rules`, Loaf reports the condition instead of overwriting it or asking for full system access. ## Wrap: Optional Checkpoint A `wrap` entry is a voluntary checkpoint, not a lifecycle transition. Write one -only when the conversation holds synthesis worth saving — intentions, abandoned +only when the conversation holds synthesis worth saving — abandoned paths, next steps — the connective narrative that evaporates with the context window. Almost everything else is derivable from raw entries. @@ -70,11 +70,12 @@ perfectly valid journal. A wrap reviews its own conversation's entries first: loaf journal recent --since-last-wrap ``` -See the `wrap` skill for the full checkpoint flow. +See the `wrap` skill for the full checkpoint flow. Loose ends name issues +(`loaf issue frontier`, `loaf issue list --started`), not board leftovers. ## Derived Continuity -Continuity is computed at read time and never persisted. On an exact target mode whose startup delivery capability is supported, the startup adapter may emit a layered digest with the latest project-level wrap, recent entries scoped to the current branch/worktree, and open (`in_progress`/`pending`) tasks. When startup delivery is candidate or unsupported for the current mode, explicitly run `loaf journal context` at conversation start. On demand, reproduce or extend the digest: +Continuity is computed at read time and never persisted. On an exact target mode whose startup delivery capability is supported, the startup adapter may emit a layered digest with the latest project-level wrap, recent entries scoped to the current branch/worktree, and open work. Bind that open work to `loaf issue frontier` and `loaf issue list --started`. When startup delivery is candidate or unsupported for the current mode, explicitly run `loaf journal context` at conversation start. On demand, reproduce or extend the digest: ```bash loaf journal context # the layered continuity digest @@ -82,7 +83,7 @@ loaf journal recent --branch <b> # recent entries for one branch loaf journal search <query> # find prior decisions by topic ``` -Pass task/spec/report references to background and delegated agents. The harness +Pass issue IDs and report references to background and delegated agents. The harness id is attached automatically — there is no session alias to pass along. ## Recovery @@ -91,7 +92,7 @@ After compaction, a branch switch, or a long gap: 1. Read `loaf journal context`; on an exact target mode with supported resumption delivery, the digest emitted by the adapter is equivalent continuity context. 2. Widen with `loaf journal recent` / `loaf journal search` when more is needed. -3. Compare against `git status`, `git log`, and the relevant specs/tasks. +3. Compare against `git status`, `git log`, `loaf issue frontier`, and `loaf issue list --started`. 4. If code and journal have drifted, log the reconciliation: `loaf journal log "decision(recovery): rewound to <commit>; replaying tests"`. @@ -108,7 +109,7 @@ After compaction, a branch switch, or a long gap: | Don't | Do Instead | |-------|------------| | Wait to log everything at the end | Log significant facts as they happen | -| Store decisions only in chat context | Log them and promote durable ones to ADR/spec/report/docs | +| Store decisions only in chat context | Log them and promote durable ones to ADR/issue body/report/docs | | Write a placeholder wrap out of ceremony | Wrap only when there's synthesis worth saving | | Treat a missing wrap as an open loop | A conversation without a wrap is complete and valid | | Pass a session alias to delegated agents | Nothing to pass — the harness id is automatic | diff --git a/dist/amp/skills/orchestration/references/linear.md b/dist/amp/skills/orchestration/references/linear.md index f488726cf..f00960ebc 100644 --- a/dist/amp/skills/orchestration/references/linear.md +++ b/dist/amp/skills/orchestration/references/linear.md @@ -7,8 +7,7 @@ Guidelines for writing Linear issue updates, comments, and commit messages with - Configuration - MCP Server Naming - Multi-Workspace Guidance -- Linear-Native Mode (Parent + Sub-Issues) -- The `spec` Label Convention +- Identity Adapter - Progress Update Format - Issue Description Format - Status Conventions @@ -97,96 +96,44 @@ Match the `linear.mcp_server_name` in each project's `.agents/loaf.json` to the name used in that project's `.mcp.json`. That way the Loaf skills invoke the right workspace automatically. -## Linear-Native Mode (Parent + Sub-Issues) +## Identity Adapter -In Linear-native mode (`integrations.linear.enabled: true`), each spec -produces one parent **rollup issue** and N sub-issues under it. +When `issue_identity.authority = linear`, Linear owns identity, title, status, +and assignment. Loaf owns shaping state: body, definition-of-done criteria, +claims, and the started worktree. The Loaf issue is the work unit. Linear MCP +is an overlay — never drive Loaf status from MCP tools. -``` -Agent framework alignment ← parent, label: `change` -├── Split reviewer profile into reviewer/auditor ← sub-issue, label: type/refactor -├── Harden MCP fallback path ← sub-issue, label: type/feature -└── Migrate legacy task references ← sub-issue, label: type/refactor -``` - -### Parent issue — what it is and isn't - -The parent issue is a **dashboard anchor**, not a re-hosting of the spec. - -- **Is:** a short summary (1–3 paragraphs) of the problem and solution - direction + a link to the canonical spec file in the repo. -- **Is not:** a copy of the spec's Scope / Rabbit Holes / Open Questions / - Risks sections. Those live in the local spec file and evolve there. - -### Sample parent description +`loaf issue new` delegates identity: Linear mints the identifier, and that +key becomes the local alias. The local counter is not advanced. If Linear is +offline, refuse — capture via `loaf spark` or `loaf idea`. Do not mint a +local alias as a fallback. -```markdown -## Summary -Align Loaf's agent profiles with the three-role model (implementer, reviewer, -researcher). Consolidate historical profile variants and add tool-boundary -tests so profiles can't drift without a test failing. - -## Context -See the canonical change file in the repository for full text, council -references, rabbit holes, and strategic tensions. +If Linear created an issue but the local bind failed, adopt it: -## Progress -Sub-issues track execution. +```text +loaf issue pull <linear-key> +loaf issue pull <linear-key> --tree ``` -### Sub-issues - -- Each sub-issue has `parentId` set to the parent issue ID. -- Cross-task dependencies use Linear's `blockedBy` field referencing sibling - sub-issue IDs. -- Sub-issue labels describe the task itself (type, team, area), not the - parent — don't label sub-issues with `spec`. -- Starting a sub-issue promotes the parent rollup from `backlog`/`unstarted` - to the team's `started`/In Progress state. Parent promotion is a state - invariant of the start operation, not a separate manual reminder. -- Do not silently reopen protected parents. If the parent is `completed`, - `canceled`, or archived, stop and ask for an explicit override before - starting the child. - -### Spec file remains canonical - -Even with the parent in Linear, the local spec file is the source of truth -for: +`--tree` also adopts the sub-issue tree with parent edges intact. -- Problem statement and solution direction -- Scope / in-scope / out-of-scope / rabbit holes / no-gos -- Risks and open questions -- Council references and strategic tensions +### Commands -When the spec evolves, edit the file and let git track it. The parent -issue's summary is a frozen entry point; only refresh it if the summary -itself (not the rabbit holes or risks) changes meaningfully. - -## The `spec` Label Convention - -Every spec-parent rollup issue carries a Linear label named `spec`. This lets -anyone in Linear filter for "all spec roots" across projects without having to -know which issues happen to be parents. - -| Field | Value | -|-------|-------| -| Name | `spec` | -| Color | `#5e6ad2` (suggested; implementer may adjust) | -| Description | `Parent rollup issue representing a design spec tracked in the repo at .agents/specs/` | -| Scope | Workspace-scoped preferred; fall back to team-scoped if the MCP requires it | - -### Who creates it - -breakdown creates the `spec` label on first Linear-native breakdown in a -workspace that doesn't already have it. Subsequent breakdowns reuse the -existing label. Log whether the label was created this run or already -existed — this matters for first-time setup. +```text +loaf issue pull <linear-key> [--tree] [--json] +loaf issue push <ref> [--json] +loaf issue reconcile [<ref>] [--take-local|--take-tracker] [--json] +``` -### Sub-issues never carry `spec` +| Command | Writes? | What it does | +|---------|---------|----------------| +| `loaf issue pull` | Yes | Adopt an existing Linear issue as a local row. The Linear key becomes the alias | +| `loaf issue push` | Yes | Write `loaf issue render` as the Linear description. Status is written only when the local status event is newer than the tracker. Never renames the Linear issue | +| `loaf issue reconcile` | Yes with a take flag | Compare local and Linear. Title drift updates the local title (tracker wins). Status drift is reported; `--take-local` or `--take-tracker` resolves it. Description drift is reported only | -`spec` applies only to parents. A sub-issue describing a task uses its own -labels (type groups like `feature`/`bug`/`refactor`, team labels, area -labels) — never `spec`. This keeps the "filter for spec roots" query clean. +Do not create records with `loaf task` or `loaf spec`. Parent/child structure +is `loaf issue promote` (or `loaf issue new --parent`), not a `spec`-labeled +Linear rollup. ## Progress Update Format @@ -222,18 +169,9 @@ None currently. ## Issue Description Format -```markdown -## Summary -Brief description of the work and its purpose. - -## Acceptance Criteria -- [ ] Criterion 1 -- [ ] Criterion 2 -- [ ] Criterion 3 +The Linear description is `loaf issue push` output — `loaf issue render`, not a hand-authored summary. Do not paste a competing description over the render. -## Notes -Any relevant context (keep brief). -``` +Comments (not the description) still follow the progress-update format above. **Rules:** - Concise and actionable @@ -243,6 +181,8 @@ Any relevant context (keep brief). ## Status Conventions +Loaf status is `loaf issue status`. Linear status is the tracker's. Resolve drift with `loaf issue reconcile` (`--take-local` or `--take-tracker`). Do not flip Loaf status from Linear MCP tools. + | State | When to Use | |-------|-------------| | **Backlog** | Issue created, not started | @@ -341,6 +281,8 @@ Use `scripts/suggest-team.py "task desc"` to get suggestions. ## When to Create Issues +Create through `loaf issue new` so identity can be delegated. Do not create in Linear MCP and then forget to `loaf issue pull`. + | Action | Create Issue? | |--------|---------------| | Features, bugs, refactoring | Yes | diff --git a/dist/amp/skills/orchestration/references/local-tasks.md b/dist/amp/skills/orchestration/references/local-tasks.md index 69c89df64..30d617f47 100644 --- a/dist/amp/skills/orchestration/references/local-tasks.md +++ b/dist/amp/skills/orchestration/references/local-tasks.md @@ -1,259 +1,173 @@ -# Local Task Management +# Working Issues Locally -Break specs into atomic tasks using SQLite-backed Loaf task records when Linear -isn't available. +Orchestration-facing reference for the Loaf issue CLI: pick-up-next, started +worktrees, status, definition of done, and advisory labels. Issue commands +require initialized SQLite state. ## Contents -- Task Abstraction Layer -- Local Task Records -- Task Lifecycle -- Creating Tasks from Specs -- Cutover Reconciliation -- Task ID Generation -- Archiving Tasks -- Journal Integration -- Task Sizing -- Priority Levels -- Listing Tasks -- Work Log Updates -- Verification -- Local vs Linear Comparison - -## Task Abstraction Layer - -Tasks work identically whether backed by Linear or local SQLite state. - -### Configuration - -```yaml -# .agents/loaf.yaml -task_management: - backend: linear # or "local" - - linear: - team: ProjectName - default_labels: [] - - local: - archive_completed: true -``` - -### Abstracted Operations - -| Operation | Linear | Local | -|-----------|--------|-------| -| Create task | Create issue | `loaf task create --spec SPEC-XXX --title "..." --priority P1` | -| Fetch task | Get issue | `loaf task show TASK-XXX` or `loaf task show TASK-XXX --json` | -| Update status | Update issue | `loaf task update TASK-XXX --status in_progress` | -| List tasks | List issues | `loaf task list` (or `loaf task list --json` for machine parsing) | -| Complete | Move to Done | `loaf task update TASK-XXX --status done` | - -## Local Task Records +- Frontier +- Started worktree +- Status vocabulary +- Relationships +- Definition of done +- Buckets +- Command cheat sheet +- LEGACY -SQLite is the operational source of truth for task metadata, status, priority, -dependencies, dates, and relationships. Use `loaf task` CLI commands for all -task mutations. `.agents/tasks/` and `.agents/TASKS.json` were removed by the -SPEC-045 cutover and are rollback material only. - -**Create with:** `loaf task create --title "..." --spec SPEC-XXX` - -## Task Lifecycle +## Frontier +```text +loaf issue frontier [--json] ``` -todo → in_progress → review → done - │ │ │ │ - └────────┴───────────┴────────┘ - can return to earlier states -``` - -| Status | Meaning | -|--------|---------| -| `todo` | Ready to work, not started | -| `in_progress` | Actively being worked | -| `review` | Implementation complete, needs verification | -| `done` | Verified complete, ready for archive | - -## Creating Tasks from Specs - -### Input -- Spec ID (e.g., `SPEC-001`) -- Optional: priority override +Pick-up-next. Derived at read time, never stored. Lists non-archived issues in +`triage`, `backlog`, or `todo` that are not blocked. -### Task Breakdown Rules +| Qualifier | Meaning | +|-----------|---------| +| Open | Status is `triage`, `backlog`, or `todo` — not `active`, `done`, `cancelled`, or `duplicate` | +| Unblocked | No open predecessor via `blocks` / `blocked_by`. A predecessor that is `done`, `cancelled`, or `duplicate` does not block | +| Unclaimed | Not `active` and no started worktree. `loaf issue start` is the claim | -1. **One concern per task** - Don't mix backend + tests + frontend -2. **Clear done condition** - Observable, verifiable outcome -3. **Verification command** - How to prove it works -4. **File hints** - Which files will likely be modified +Archived rows are excluded. Kind is not filtered: a `--kind decision` question +can appear; it is not delivery work. Buckets are not read. Prefer `--json` +when diagnosing rather than scraping the human-readable text. -### Example Breakdown +## Started worktree +```text +loaf issue start <ref> [--json] +loaf issue stop <ref> [--force] [--json] +loaf issue list --started [--json] ``` -SPEC-001: User Authentication with OAuth - ↓ -TASK-001: OAuth Provider Integration - - Google OAuth client setup - - GitHub OAuth client setup - - Token exchange logic - verify: pytest tests/auth/test_oauth.py - -TASK-002: Session Management - - Session cookie handling - - Session storage (Redis/DB) - - Session expiry logic - verify: pytest tests/auth/test_session.py - -TASK-003: Login UI Components - - Login page layout - - Provider buttons - - Error states - verify: npm run test:e2e -- auth -``` - -## Cutover Reconciliation - -If a stale branch reintroduces `.agents/tasks/`, `.agents/sessions/`, other -ephemeral roots, or `.agents/TASKS.json`, keep the deletion side from the -cutover branch and rerun `loaf check --hook ephemeral-provenance`. Use -`loaf state restore-ephemerals <backup-id>` only for an intentional rollback, -then re-import forward. - -## Task ID Generation -Format: `TASK-{number}-{slug}` +**Invariant:** one agent, one worktree. Check `loaf issue list --started` +before dispatch. Never send two agents into the same path. -Task IDs are auto-generated by `loaf task create`. In SQLite-backed projects, -the allocation is recorded in state. +`start` creates branch `issue/<alias-or-id>` in lowercase (`issue/loaf-42`, +disambiguated with an id suffix when that name is already claimed), adds a +sibling worktree, records `started_branch` / `started_worktree` on the row, and +moves status to `active` through the events path. Base is the nearest started +ancestor's branch, else the repository default branch. Start refuses an already +started row, an archived row, and terminal statuses (`done`, `cancelled`, +`duplicate`). Requires a git repository. -## Archiving Tasks +`list --started` prints alias, title, `started_branch`, `started_worktree`, and +`(missing)` when the recorded path is gone. -When a task is done: +`stop` removes the worktree and clears the started workspace on the row. It +keeps the branch and does not change status. `--force` removes a dirty +worktree. Do not run `stop` from inside the started worktree. -1. Mark complete via CLI: `loaf task update TASK-XXX --status done` -2. Archive: `loaf task archive TASK-XXX` +## Status vocabulary -## Journal Integration +Write statuses that update in place: `triage`, `backlog`, `todo`, `active`, +`done`. `cancelled` and `duplicate` archive through the remove path +(`loaf issue status <ref> duplicate --duplicate-of <surviving>`). -When the implement workflow starts on `TASK-001`: - -1. Load task metadata via `loaf task show TASK-001` for context -2. Read linked spec for full picture -3. Log the task coupling as the first action: +```text +loaf issue status <ref> <status> [--duplicate-of <ref>] [--json] +``` -```bash -loaf journal log "decision(implement): implementing TASK-001" +| Status | Meaning | +|--------|---------| +| `triage` | Default at create. Shaped is derived (`loaf issue check`), not a status | +| `backlog` | Filed, worth keeping | +| `todo` | Explicitly ready to work | +| `active` | Started. **Review is a display name for `active`** — there is no `review` write status | +| `done` | Work landed | +| `cancelled` | Archived; abandoned | +| `duplicate` | Archived; `--duplicate-of` required | + +There is **no `blocked` status**. Blocked is a relationship. Title and body stay +mutable at every status. + +```text +loaf issue list [--status <status>] [--kind delivery|decision] [--archived] [--started] [--json] ``` -There is no session to create or couple to. Task progress is read through -`loaf task show/list`; the surrounding decisions and blockers live in the -project journal (`loaf journal recent`, `loaf journal search`). +Archived rows are hidden unless `--archived`. `--status` accepts every value in +the table above. -## Task Sizing +## Relationships -### Separation of Concerns +```text +loaf issue link <from> blocks|relates-to <to> [--json] +loaf issue link <from> remove <type> <to> [--json] +``` -**The primary principle for task breakdown is separation of concerns.** +Stored types are `blocks` and `relates_to`. `loaf issue link A blocks B` means +A blocks B: B is absent from the frontier until A is `done`, `cancelled`, or +`duplicate`. `relates-to` is not a sequencing constraint. -| Rule | Guideline | -|------|-----------| -| **One agent type** | Task completable by ONE subagent (implementer, reviewer, researcher) | -| **One concern** | Task touches one layer, one service, or one component | -| **Context-appropriate** | Fits in model context with room for exploration | -| **Not over-fragmented** | Don't split what naturally belongs together | +Do not encode order in `loaf issue tree`. Parent/child is structure; `blocks` +is the dependency. `loaf issue export [--json]` dumps relationships (and +claims) when you need the graph. -### Right Size Test +## Definition of done -1. Can a single specialized agent complete this? → If no, split by agent type -2. Does it touch multiple unrelated concerns? → If yes, split by concern -3. Will the agent need too much context? → If yes, split into smaller coherent units -4. Am I splitting just to have more tasks? → If yes, merge back +Criteria live on the issue row. `loaf issue show <ref>` prints each as +`position. [V|H] text` with `command=` / `expect=` when present. -### Agent Scope +```text +loaf issue dod add <ref> <text> [--command <cmd>] [--expect <expect>] [--tier V|H] [--serves <parent-position>] [--json] +loaf issue dod list <ref> [--json] +loaf issue dod remove <ref> <position> [--json] +loaf issue dod claim <child> <child-position> <parent-position> [--json] +loaf issue dod unclaim <child> <child-position> <parent-position> [--json] +loaf issue promote <ref> <position> [--json] +loaf issue check <ref> [--json] [--human <reason>] +loaf issue verify <ref> [--json] +``` -| Agent | Typical Task Scope | -|-------|-------------------| -| implementer (backend) | One service/module, its tests, its docs | -| implementer (frontend) | One component/page, its tests, its styles | -| implementer (database) | One migration, related schema changes | -| implementer (testing) | Test suite for one feature/area | -| implementer (infra) | One infrastructure concern (CI, deploy, config) | +| Tier | When | Who checks | +|------|------|------------| +| V | `--command` present, unless `--tier` overrides | `loaf issue verify <ref>` from the repository root. Honors `exit <N>` and `` contains `text` ``. Writes nothing. Non-zero on failure | +| H | No `--command`, unless `--tier` overrides | Human or orchestrator. Verify skips H-tier; that skip is not a pass | -## Priority Levels +Claims: a child criterion serves a parent criterion. `promote` copies the +parent criterion onto a new delivery child and records the claim. +`--serves` claims a newly added child criterion. `claim` / `unclaim` retarget +an existing pair. Positions are 1-based. -| Priority | Meaning | Response | -|----------|---------|----------| -| P0 | Urgent/blocking | Drop everything | -| P1 | High | Work next | -| P2 | Normal | Scheduled work | -| P3 | Low | When time permits | +`check` is readiness (shape's gate): delivery is shaped with a nonempty body, +at least one criterion, and an out-of-scope statement; decision is ready on a +sharp `?`. Children add coverage (every parent criterion claimed — failure) +and containment (every child criterion claims a parent — report). `verify` is +implement's preflight and writes nothing — it does not set status and does not +tick boxes. -## Listing Tasks +`loaf issue render <ref>` emits the paste-ready PR body: title, body, +definition-of-done checkboxes (checked only when status is already `done`), +and children. No manual editing. -### All Active Tasks +## Buckets -```bash -loaf task list +```text +loaf issue bucket <ref> now|next|later|none [--json] ``` -### Tasks for a Spec - -```bash -loaf spec list # Show specs with task counts -loaf task list --json # Machine-parseable output, filter by spec -``` +Advisory Now/Next/Later labels. Never read as a constraint. Frontier, start, +and verify ignore them. `none` clears the label. -## Work Log Updates +## Command cheat sheet -As work progresses, append to the Work Log section: - -```markdown -## Work Log - -### 2026-01-23 14:30 UTC -Started OAuth integration. Set up Google OAuth client credentials. - -### 2026-01-23 15:45 UTC -Google OAuth working. Moving to GitHub integration. - -### 2026-01-23 17:00 UTC -Both providers working. Tests pass. Moving to review. +```text +loaf issue new <title> [--body <text>|--body -|--body-file <path>|--message <text>] + [--kind delivery|decision] [--parent <ref>] [--fog <text>] [--status <status>] [--json] +loaf issue show <ref> [--json] +loaf issue tree [<ref>] [--archived] [--json] +loaf issue edit <ref> [--body-file <path>|--body -|--message <text>] [--json] +loaf issue export [--json] ``` -## Verification +`new` default kind is `delivery`; default status is `triage`. `--status` on +create still records the initial triage event, then writes the requested +write-status. `--fog` exists only on create. `edit` replaces the body; there +is no patch form. -Before marking `done`: - -1. Run the `verify` command from frontmatter -2. Check all acceptance criteria are checked -3. Ensure no regressions in related tests - -```bash -# Run task verification -verify_cmd=$(grep '^verify:' TASK-001-*.md | cut -d: -f2-) -eval "$verify_cmd" -``` +## LEGACY -## Local vs Linear Comparison - -| Feature | Local | Linear | -|---------|-------|--------| -| No external dependency | yes | no | -| Rich UI | no | yes | -| Team collaboration | git-based | native | -| Notifications | none | email/slack | -| Reporting | manual | built-in | -| Offline work | yes | limited | - -**Use local when:** -- Solo project -- No Linear access -- Offline development -- Simple task tracking - -**Use Linear when:** -- Team collaboration needed -- Rich workflow automation -- Integration with other tools -- Reporting requirements +`loaf task` and `loaf spec` remain readable against leftover SQLite rows. They +mint nothing new. Do not create records there. Issues are the work unit. diff --git a/dist/amp/skills/orchestration/references/parallel-agents.md b/dist/amp/skills/orchestration/references/parallel-agents.md index f4ef48ab2..35a07a7d6 100644 --- a/dist/amp/skills/orchestration/references/parallel-agents.md +++ b/dist/amp/skills/orchestration/references/parallel-agents.md @@ -117,7 +117,7 @@ When streams complete: | Command | Parallel Opportunity | |---------|---------------------| -| breakdown | Identify parallelizable tasks during decomposition | +| shape | Identify parallelizable child issues during decomposition | | implement | Single task, usually sequential | | implement | Runs dependency-aware orchestration, including parallel-safe tasks | diff --git a/dist/amp/skills/orchestration/references/script-surface.md b/dist/amp/skills/orchestration/references/script-surface.md index 70206ab63..474b76354 100644 --- a/dist/amp/skills/orchestration/references/script-surface.md +++ b/dist/amp/skills/orchestration/references/script-surface.md @@ -20,7 +20,7 @@ script surface: - The source currently has 10 orchestration scripts out of 22 skill-local scripts overall. -- Several scripts overlap existing `loaf journal`, `loaf task`, `loaf check`, +- Several scripts overlap existing `loaf journal`, `loaf issue`, `loaf check`, and Linear-aware behavior. - Shell/Python helpers are harder to discover than `loaf <noun> <verb>` and are not consistently covered by CLI tests. diff --git a/dist/amp/skills/orchestration/references/subagent-development.md b/dist/amp/skills/orchestration/references/subagent-development.md index 2d36b6175..d2c46e2a1 100644 --- a/dist/amp/skills/orchestration/references/subagent-development.md +++ b/dist/amp/skills/orchestration/references/subagent-development.md @@ -211,7 +211,7 @@ After subagent completes: | Command | Subagent Role | |---------|---------------| -| breakdown | Tasks become subagent assignments | +| shape | Promoted child issues become subagent assignments | | implement | May dispatch subagents for specialized work | | implement | Automatically coordinates single-task and multi-task subagent work | diff --git a/dist/amp/skills/orchestration/templates/journal.md b/dist/amp/skills/orchestration/templates/journal.md index caf7409fd..9a0a61f8f 100644 --- a/dist/amp/skills/orchestration/templates/journal.md +++ b/dist/amp/skills/orchestration/templates/journal.md @@ -13,7 +13,7 @@ not create or edit journal markdown as the source of truth — use ## Entries -[YYYY-MM-DD HH:MM] skill(implement): implementing TASK-042 +[YYYY-MM-DD HH:MM] skill(implement): implementing LOAF-42 [YYYY-MM-DD HH:MM] decision(scope): description of decision [YYYY-MM-DD HH:MM] discover(scope): something learned [YYYY-MM-DD HH:MM] block(scope): what is blocked diff --git a/dist/amp/skills/pitch/SKILL.md b/dist/amp/skills/pitch/SKILL.md index f34f451ad..635c0c2cd 100644 --- a/dist/amp/skills/pitch/SKILL.md +++ b/dist/amp/skills/pitch/SKILL.md @@ -1,21 +1,21 @@ --- name: pitch description: >- - Runs the human problem-discovery ceremony at change or project scale: grills - problem, who has it, current alternatives, value proposition, and constraints, - then authors a brief (change brief.md via loaf change init --brief, or project - docs/BRIEF.md with source: pitch). Use when the user invokes pitch, starts - work on a raw concept, or triage dispositions an item as pitch. Produces an - authored problem-space brief and a shape-now or park offer — never shape.md, - tasks, or PRs. Not for solution shaping (use shape), queue processing (use - triage), quick capture (use idea), or open-ended divergent inquiry (use - explore as an agent technique when pitch reveals the direction is undecided). + Runs the human problem-discovery ceremony: grills problem, who has it, current + alternatives, value proposition, and constraints, then hands a sharpened + problem narrative to shape or authors project docs/BRIEF.md. Use when the user + invokes pitch, starts work on a raw concept, or triage dispositions a spark or + idea as pitch. Produces a problem-space narrative and a shape-now or park + offer — never a bounded issue, criteria, or PRs. Not for quick capture (use + idea), solution bounding (use shape), queue processing (use triage), or + open-ended divergent inquiry (use explore as an agent technique when pitch + reveals the direction is undecided). version: 0.2.21 --- # Pitch -Human problem-discovery ceremony. Authors a brief at the matching scale so shape starts from a framed problem and bootstrap can consume a pitched project BRIEF. +Human problem-discovery ceremony. Narrows sparks and ideas into a framed problem so shape can mint an issue, and bootstrap can consume a pitched project BRIEF. ## Contents - Critical Rules @@ -32,61 +32,90 @@ Human problem-discovery ceremony. Authors a brief at the matching scale so shape ## Critical Rules 1. **Agents never initiate a pitch.** This ceremony is human-invoked only. On Claude Code the sidecar sets `disable-model-invocation: true`; on every target this rule binds behaviorally. Agent legwork *inside* a human-opened pitch (competitive scans, file writes the skill directs) is fine — opening one is not. -2. **Log invocation first** — `loaf journal log "skill(pitch): <idea, problem, or intake item>"` before interviewing. -3. **Problem-space only** — grill what, who, and why-valuable. Approach, architecture, decomposition, and verification design belong to shape. A brief that reads like a pseudo-shape is a failure; rewrite before landing. -4. **One question at a time, recommendation-first** — using your harness's structured question tool if it has one (otherwise one inline question per message). Never a multi-field form. Order by impact on the brief. Full mechanics: [references/interview-guide.md](references/interview-guide.md). -5. **Never write `shape.md`, seed `tasks/`, push, or open PRs** — pitch prepares commits and hands off; push and PR stay human. Never auto-run shape or bootstrap. -6. **Landing is validated, then committed once** — every capture landing runs explicit-path `loaf change check <folder> --json` (zero violations, expected captured state) and a direct read-back of that folder's `change.json` confirming intended `target_release` presence or absence, then one docs-only commit per capture. Never batch captures into one commit. -7. **Slug identity is local** — propose a slug that names the concept, never another work unit (no `spec-042`, no task ids). Provenance lives in frontmatter and the change folder. -8. **Log the outcome** — `loaf journal log "decision(pitch): <slug or project> — <shape-now|park-targeted|park-untargeted|handed-to-bootstrap>"`. +2. **Log invocation first** — `loaf journal log "skill(pitch): <idea, problem, spark, or intake item>"` before interviewing. +3. **Problem-space only** — grill what, who, and why-valuable. Approach, architecture, decomposition, and verification design belong to shape. A narrative that reads like a pseudo-shape is a failure; rewrite before landing. +4. **One question at a time, recommendation-first** — using your harness's structured question tool if it has one (otherwise one inline question per message). Never a multi-field form. Order by impact on the narrative. Full mechanics: [references/interview-guide.md](references/interview-guide.md). +5. **Never bound, never ship** — do not add definition-of-done criteria, do not write an out-of-scope statement, do not run `loaf issue check` or `loaf issue promote`, do not push, do not open PRs. Never auto-run shape or bootstrap. +6. **Shape mints on the happy path** — same-session shape-now hands the authored narrative; shape runs `loaf issue new` with that body. Pitch writes an issue body only when parking an unshaped row or when `$ARGUMENTS` already names an issue (`loaf issue edit` replaces the body). +7. **Titles name the concept** — propose a working title, never another work unit's alias. Provenance lives in the issue row, the spark/idea resolution, and frontmatter on `docs/BRIEF.md`. +8. **Log the outcome** — `loaf journal log "decision(pitch): <title, ref, or project> — <shape-now|park-issue|park-idea|handed-to-bootstrap>"`. --- ## Verification -- Change scale: `docs/changes/YYYYMMDD-slug/` holds `change.json` + authored `brief.md`; `loaf change check <folder> --json` reports zero violations and captured state; `change.json` read-back matches the intended target binding -- Project scale: `docs/BRIEF.md` exists with `source: pitch` and the shared problem-space skeleton -- Cold-read: problem, who, alternative, and value nameable in one pass; zero solution-space content -- No `shape.md` or `tasks/` written by this skill; no push; no PR +- Issue-scale: a problem narrative exists against the shared skeleton; it was handed to shape, written into an existing issue body, or minted as an unshaped triage row with that body and no criteria +- Project-scale: `docs/BRIEF.md` exists with `source: pitch` and the shared problem-space skeleton +- Cold-read: problem, who, alternative, and value nameable in one pass; zero solution-space content; no out-of-scope statement and no criteria added by this skill +- Named sparks were promoted to an idea when pitching them; ideas and sparks were resolved against the issue only after a row exists +- No push; no PR; shape and bootstrap were not auto-run - Journal shows skill invocation and outcome entries --- ## Quick Reference +| Harness | Invoke skill | +|---------|----------------| +| Claude Code (plugin) | `/loaf:pitch` | +| OpenCode, Cursor, Codex, Amp | `/pitch` | + ### Scale detection | Signal | Scale | Output | |--------|-------|--------| -| Existing project (git history, source, or Loaf state) + a concept | **Change** | `loaf change init <slug> --brief` → authored `brief.md` | -| Empty or minimal directory / greenfield intent | **Project** | `docs/BRIEF.md` with `source: pitch` → hand to bootstrap | +| Existing project (git history, source, or Loaf state) + a concept | **Issue** | Problem narrative → shape (`loaf issue new --body`) or an unshaped triage row | +| Empty or minimal directory / greenfield product pitch | **Project** | `docs/BRIEF.md` with `source: pitch` → hand to bootstrap | Detect and confirm briefly; let the human correct. When both could apply (repo exists but they want a new product pitch), ask once. -### Landing matrix (Decision 11) +### Landing offers -| Intent | Branch | Commit | Target | -|--------|--------|--------|--------| -| **Shape now** | Create the slug branch (`git switch -c <slug>`), stay there | Hand to shape for in-place promotion — do not park-commit first | Stamp `target_release` when known | -| **Park targeted** | Default branch | One docs-only commit on default (promise-carrier exception) | `target_release` present and confirmed by read-back | -| **Park untargeted** | Slug branch **or** remain intake (Intent/spark) | Docs-only commit on the slug branch if becoming a Change; else no Change folder | No `target_release`; untargeted captures never land on main | +| Offer | When to recommend | What pitch does | +|-------|-------------------|-----------------| +| **Shape now** | Framing is solid; they want to bound next | Hand the narrative; do not mint; do not auto-run shape | +| **Park as issue** | Framed, durable, not bounding yet | `loaf issue new "<title>" --body -` with the narrative only; status stays `triage` | +| **Park as idea** | Too thin to keep as a row, or might discard | `loaf idea capture --title "..."`; journal the gist | +| **Hand to bootstrap** | Project-scale BRIEF authored | Point at bootstrap; do not auto-run it | -Pitch prepares the commit; never pushes; never opens PRs. +Pitch never pushes; never opens PRs. There is nothing to commit at issue scale — the row lives in SQLite. Project-scale may commit `docs/BRIEF.md` if the human wants it durable. -### Pre-landing guard (every capture) +### Spark and idea promotion -```bash -loaf change check <folder> --json # zero violations; state is captured -# then read <folder>/change.json and confirm target_release presence/absence matches intent +| Input | Read | Then | +|-------|------|------| +| Spark | `loaf spark show <ref>` | `loaf idea capture --title "..."` then `loaf spark promote <spark> --to-idea <idea>`; grill from the idea | +| Idea | `loaf idea show <ref>` | Grill; after a row exists, `loaf idea resolve <idea> --by <ref>` | +| Existing issue | `loaf issue show <ref>` | Grill; `loaf issue edit <ref> --body -` writes the narrative (replaces the whole body) | +| Free text | — | Grill; shape-now hands text; park captures an idea or mints an unshaped row | + +Do not invent a pitch from the queue without human selection. When they name an intake item, read it (`loaf intake list` / the item's read command). + +`loaf idea promote --to-spec` is not this path. Resolve ideas against the minted issue. + +### Problem-narrative skeleton + +Author against these sections, problem-space sentences only. This text is what shape puts in `--body` (or what a park-as-issue row stores): + +```markdown +## Problem Statement +## Who Has It +## Current Alternatives +## Value Proposition +## Constraints +## Sequencing and Relationships +## Sources and Research Links +## Open Questions ``` -Bare `loaf change check` resolves by branch and can miss a capture landing elsewhere — always pass the explicit folder path. +Do not add an out-of-scope statement. Shape bounds; pitch frames. ### Defined terms -- **Brief** — the pitch output (problem-space). Superseded by `shape.md` when shaping starts; may accrete parked problem-space sentences until then; freezes when `shape.md` exists. -- **Accretion** — adding problem-space concepts to a parked brief is legal; solution prose is not. -- **Shape now** — slug branch + hand to shape, which promotes the capture in place via ordinary `loaf change init <slug>` (no `--brief`). +- **Problem narrative** — pitch's issue-scale output. Superseded as the working surface once shape mints and bounds the issue; may accrete parked problem-space sentences until then. +- **BRIEF** — project-scale `docs/BRIEF.md`. A project document, not a work container. +- **Accretion** — adding problem-space concepts to a parked narrative is legal; solution prose is not. +- **Shape now** — hand the narrative to shape, which mints via `loaf issue new` and owns bounding. --- @@ -95,81 +124,82 @@ Bare `loaf change check` resolves by branch and can miss a capture landing elsew ### Step 1: Log and parse input ```bash -loaf journal log "skill(pitch): <idea, problem, or intake item>" +loaf journal log "skill(pitch): <idea, problem, spark, or intake item>" ``` -Parse `$ARGUMENTS`: free text, an intake ref the human already chose, or empty (ask what to pitch). Read the named intake item when provided (`loaf intake list` / the item's read command). Do not invent a pitch from the queue without human selection. +Parse `$ARGUMENTS`: free text, a spark, an idea, an issue ref, an intake ref the human already chose, or empty (ask what to pitch). Read the named item when provided. Do not invent a pitch from the queue without human selection. ### Step 2: Detect scale -Apply the Quick Reference table. Confirm: "I'll treat this as a **change-scale** pitch on this repo" or "…as a **project-scale** pitch for a new BRIEF." Adjust if corrected. +Apply the Quick Reference table. Confirm: "I'll treat this as an **issue-scale** pitch on this repo" or "…as a **project-scale** pitch for a new BRIEF." Adjust if corrected. -### Step 3: Problem-discovery interview +### Step 3: Promote sparks; read ideas + +When the named input is a spark, promote it to an idea before grilling so the capture trail is one idea, not a dangling spark: + +```bash +loaf idea capture --title "<working title>" +loaf spark promote <spark> --to-idea <idea> +``` + +When the named input is already an idea, `loaf idea show` and grill. Leave resolution until an issue row exists. + +### Step 4: Problem-discovery interview Run the interview per [references/interview-guide.md](references/interview-guide.md): -- Pin a one-or-two-line **destination** before dimension grilling (fixes brief scope; project scale feeds VISION success criteria, change scale sharpens the eventual Hypothesis) +- Pin a one-or-two-line **destination** before dimension grilling (fixes narrative scope; project scale feeds VISION success criteria; issue scale sharpens what good looks like for the row) - Dimensions: problem, who has it, current alternatives / competitive landscape, value proposition, constraints (plus sequencing and open questions when needed) - Depth: scenario stress-testing, challenge stance, glossary-term hygiene; open questions must pass the specifiability test and carry HITL/AFK tags when precise - Applicability judgment: skip formal competitive analysis and deep personas when the pitch kind does not warrant them (bug fixes, internal chores) -- One question at a time, recommendation-first, ordered by brief impact +- One question at a time, recommendation-first, ordered by narrative impact - Stop on exit criteria or when answers stop changing the framing -If the direction is genuinely undecided mid-interview, offer the **explore** technique (agent-side; Explorations and checkpoints) rather than forcing a false brief. Pitch remains the human front door; explore is not re-opened as a slash alternative by this skill. +If the direction is genuinely undecided mid-interview, offer the **explore** technique (agent-side; Explorations and checkpoints) rather than forcing a false narrative. Pitch remains the human front door; explore is not re-opened as a slash alternative by this skill. -### Step 4: Evidence delegation (when warranted) +### Step 5: Evidence delegation (when warranted) -When competitive or landscape facts would change the brief and are not already known: +When competitive or landscape facts would change the narrative and are not already known: 1. Spawn a **researcher** subagent with a bounded question (competitors, substitutes, prior art — not solution design). 2. Land evidence: - - **Change scale:** files under the change folder's `research/` (create the folder with the change); link from Sources and Research Links - - **Project scale:** inline source links in `docs/BRIEF.md` Sources and Research Links (no change `research/` yet) -3. Resume the interview or brief draft with recommendations informed by the scan. + - **Issue scale:** source links in the narrative's Sources and Research Links. If a longer scan lands on disk, name it for the landscape, never for the work unit, and cite it from Sources. + - **Project scale:** inline source links in `docs/BRIEF.md` Sources and Research Links. +3. Resume the interview or draft with recommendations informed by the scan. Never fabricate competitive claims. If a scan is skipped, say so in Sources ("no external scan; alternative is internal workaround X"). -### Step 5a: Change-scale ceremony - -1. **Propose a slug** — lowercase, digits, single hyphens; names the concept locally. Confirm with the human. -2. **Initialize capture:** - - ```bash - loaf change init <slug> --brief - ``` - - Creates `docs/changes/YYYYMMDD-<slug>/` with `change.json` + `brief.md` scaffold only. -3. **Author `brief.md`** against the shared problem-space skeleton (shape's brief template / the scaffold just written): Problem Statement, Who Has It, Current Alternatives, Value Proposition, Constraints, Sequencing and Relationships, Sources and Research Links, Open Questions. Problem-space sentences only. -4. **Stamp `target_release` when known** — edit `change.json` with canonical `MAJOR.MINOR.PATCH` (no `v`, no prerelease). Omit the field when untargeted. Confirm with the human before stamping. -5. **Accretion note** — tell the human: parked problem-space concepts may accrete into this brief until shaping starts; once `shape.md` exists the brief freezes. -6. **Cold-read** the brief (interview guide test); revise with the human until it passes. -7. **Offer landing** (recommendation-first): +### Step 6a: Issue-scale ceremony - | Offer | When to recommend | - |-------|-------------------| - | **Shape now** | Framing is solid and they want to bound implementation next | - | **Park targeted** | Bound to a release cohort but not shaping yet — docs-only on default branch | - | **Park untargeted** | Worth capturing off-main, or not ready as a Change (stay intake) | +1. **Propose a working title** — names the concept locally. Confirm with the human. This becomes shape's `loaf issue new` title (or the park-as-issue title). +2. **Author the problem narrative** against the skeleton above. Problem-space sentences only. +3. **Accretion note** — tell the human: parked problem-space concepts may accrete until shaping starts; once the issue is minted, the body is the home. +4. **Cold-read** the narrative (interview guide test); revise with the human until it passes. +5. **Offer landing** (recommendation-first) using the Landing offers table. +6. **Execute the chosen landing:** -8. **Execute the chosen landing:** + - **Shape now:** hand the full narrative and any spark/idea refs. Shape runs `loaf issue new "<title>" --body -` (or `--body-file`) with that text. Do not mint, do not add criteria, do not open a PR. After shape mints, resolve intake: `loaf idea resolve <idea> --by <ref>` (and `loaf spark resolve <spark> --by <ref>` only if the spark was never promoted). + - **Park as issue:** mint the unshaped row yourself, then resolve intake against it: - - **Shape now:** `git switch -c <slug>` (from default unless already on a working branch the human prefers), ensure pre-landing guard would pass if they later park, hand to shape with the folder path — shape promotes in place. Do not open a PR. - - **Park targeted:** on the **default branch**, run pre-landing guard on the explicit folder, confirm `target_release` present in `change.json`, then one docs-only commit of the change folder (and any `research/` under it). - - **Park untargeted as Change:** `git switch -c <slug>`, pre-landing guard, confirm `target_release` **absent**, one docs-only commit on the slug branch. - - **Park as intake:** do not leave a half-written change folder; prefer Intent/spark retention and delete or never create the capture if the human backs out. + ```bash + loaf issue new "<title>" --body - + loaf idea resolve <idea> --by <ref> + ``` -9. **Commit message** (when parking): conventional, e.g. `docs(change): capture <slug> brief` — one commit per capture. + Paste the narrative on stdin. Do not add criteria. Do not write out-of-scope. Default status is `triage`. Read back with `loaf issue show <ref>`. + - **Park as idea:** if no idea exists yet, `loaf idea capture --title "<title>"`. Journal the gist (`loaf journal log "discover(pitch): <one-line problem>"`). Do not mint an issue. + - **Existing issue:** `loaf issue edit <ref> --body -` with the full narrative. Edit replaces the body; do not strip a row that is already bounded — if criteria already exist, hand the narrative to the human and let shape merge. -10. **Closing ceremony (required — never trail off).** After the landing is executed (or intake retained), announce completion with a full closing block: +7. **Closing ceremony (required — never trail off).** After the landing is executed, announce completion with a full closing block: - - **Recap the brief** — section-by-section gist (Problem, Who, Alternatives, Value, Constraints, Sequencing, Open Questions). Name the change folder path (`docs/changes/YYYYMMDD-<slug>/`) and what it holds (`change.json` + `brief.md`, plus any `research/`). + - **Recap the narrative** — section-by-section gist (Problem, Who, Alternatives, Value, Constraints, Sequencing, Open Questions). Name where it lives (handed to shape, unshaped issue `<ref>`, idea `<ref>`, or the conversation plus journal gist). - **Restate the landing actually taken** and what it means next: - - **Shape now** — you are on the slug branch; run shape next to promote the capture in place and bound implementation. No park-commit was made. - - **Park targeted** — the capture is a docs-only commit on the default branch with `target_release` stamped; it sits as a promise carrier for that cohort until shape is invoked later. - - **Park untargeted** — the capture lives on the slug branch (or remains intake) without `target_release`; it is off-main until retargeted or shaped. If intake-only, name the Intent/spark and that no change folder was left half-written. + - **Shape now** — run shape next to mint the issue from this narrative and bound implementation. No row was minted here. + - **Park as issue** — `<ref>` holds the problem in its body and is unshaped; run shape later on that ref. + - **Park as idea** — the idea remains open; re-invoke pitch or shape when ready. Name the idea ref. - **Announce completion** in plain language: "Pitch is complete." Do not end on a dangling offer or an unfinished sentence. -### Step 5b: Project-scale ceremony +### Step 6b: Project-scale ceremony 1. **Author `docs/BRIEF.md`** using bootstrap's brief skeleton with frontmatter: @@ -181,31 +211,31 @@ Never fabricate competitive claims. If a scan is skipped, say so in Sources ("no --- ``` - Same problem-space sections as change scale, at project altitude (Sequencing describes the initial arc as prose). + Same problem-space sections as issue scale, at project altitude (Sequencing describes the initial arc as prose). 2. **Cold-read** and revise with the human. 3. Optionally commit `docs/BRIEF.md` if the human wants it durable before bootstrap; still no push unless they ask outside this skill's duties — pitch itself never pushes. 4. **Closing ceremony (required — never trail off).** Announce completion with a full closing block — do not hand off in a half-sentence: - **Recap what was authored** — section-by-section gist of the BRIEF (Problem Statement, Who Has It, Current Alternatives, Value Proposition, Constraints, Sequencing and Relationships, Sources and Research Links, Open Questions). One or two sentences per section is enough; the human should hear what landed without reopening the file. - **Artifact path** — name `docs/BRIEF.md` explicitly, including that frontmatter carries `source: pitch`. - - **Explicit handoff** — state, verbatim in spirit: next, run bootstrap: it reads this BRIEF as discovery-done (`source: pitch`), interviews only on gaps, populates the operating documents (VISION, STRATEGY, ARCHITECTURE, AGENTS), and closes by proposing your initial arc of captured changes. Do not auto-run bootstrap. + - **Explicit handoff** — state, verbatim in spirit: next, run bootstrap: it reads this BRIEF as discovery-done (`source: pitch`), interviews only on gaps, and populates the operating documents (VISION, STRATEGY, ARCHITECTURE, AGENTS). Do not auto-run bootstrap. - **Announce completion** in plain language: "Pitch is complete." The ceremony ends with a period, never a trail-off. -### Step 6: Log the outcome +### Step 7: Log the outcome ```bash -loaf journal log "decision(pitch): <slug or project> — <shape-now|park-targeted|park-untargeted|handed-to-bootstrap>" +loaf journal log "decision(pitch): <title, ref, or project> — <shape-now|park-issue|park-idea|handed-to-bootstrap>" ``` -The journal line is mechanical; the human-facing close is the closing ceremony in Step 5a/5b. Never log-and-stop without that recap and next-step restatement. +The journal line is mechanical; the human-facing close is the closing ceremony in Step 6a/6b. Never log-and-stop without that recap and next-step restatement. --- ## Related Skills -- **shape** — solution-space narrowing from an existing brief (or full narrowing when no brief); promotes capture folders in place -- **bootstrap** — consumes `docs/BRIEF.md` (`source: pitch` → gap interview) and series-preps captured changes -- **triage** — queue dispositions; may hand an item to pitch when problem discovery is needed +- **shape** — solution-space bounding; mints the issue from the problem narrative (`loaf issue new`) and owns criteria, out-of-scope, and decomposition +- **bootstrap** — consumes `docs/BRIEF.md` (`source: pitch` → gap interview) and populates operating documents +- **triage** — queue dispositions; may hand a spark or idea to pitch when problem discovery is needed - **explore** — agent-side technique when pitch finds the direction still undecided - **idea** — quick capture without ceremony; not a substitute for pitch - **research** — patterns the researcher subagent follows for landscape scans @@ -218,4 +248,4 @@ The journal line is mechanical; the human-facing close is the closing ceremony i ## Artifact Naming -Name every artifact for what it is, never for the work unit that produced it. The change folder already records provenance. Put source in frontmatter, not the filename. See the `foundations` skill; `loaf check --hook artifact-names` enforces it at commit. +Name every on-disk artifact for what it is, never for the work unit that produced it. The issue row or `docs/BRIEF.md` already records provenance. Put source in frontmatter, not the filename. See the `foundations` skill; `loaf check --hook artifact-names` enforces it at commit. diff --git a/dist/amp/skills/pitch/references/interview-guide.md b/dist/amp/skills/pitch/references/interview-guide.md index 8014082fe..65780fb10 100644 --- a/dist/amp/skills/pitch/references/interview-guide.md +++ b/dist/amp/skills/pitch/references/interview-guide.md @@ -11,7 +11,7 @@ Problem-discovery interview for pitch. Borrows shape's grilling mechanics (one q - Open Questions: Specifiability and HITL/AFK - Exit Criteria - Anti-Patterns -- Brief Cold-Read +- Problem Cold-Read ## How This Guide Works @@ -23,22 +23,22 @@ The interview is adaptive, not a form. Strong answers skip dimensions; weak answ ## Destination Pinning -Before dimension grilling, converge on a **destination**: one or two lines naming the end state this pitch is aiming at — what becomes true in the world if the work succeeds. Pin it early; it fixes the brief's scope for the rest of the interview. +Before dimension grilling, converge on a **destination**: one or two lines naming the end state this pitch is aiming at — what becomes true in the world if the work succeeds. Pin it early; it fixes the narrative's scope for the rest of the interview. | Scale | Destination feeds | |-------|-------------------| | **Project** | VISION success criteria (bootstrap extracts it; pitch keeps it as the project's north star in the BRIEF) | -| **Change** | The eventual Hypothesis when shape promotes the capture — a sharper "what good looks like" than a feature list | +| **Issue** | What good looks like for the work — the problem statement shape will put in the issue body | **How to pin:** offer a recommendation-first draft from the human's opening words ("Destination: operators can ship a release without a manual config audit"). Confirm, tighten, or rewrite until both parties can restate it. Do not start deep dimension probes until the destination is on the table. -If the destination keeps shifting mid-interview, pause and re-pin — a moving destination means the brief's scope is still open. +If the destination keeps shifting mid-interview, pause and re-pin — a moving destination means the narrative's scope is still open. --- ## Problem-Discovery Dimensions -Grill these five dimensions. Order by what would change the brief most — a wrong "who" invalidates value and alternatives; cosmetic naming goes last. +Grill these five dimensions. Order by what would change the narrative most — a wrong "who" invalidates value and alternatives; cosmetic naming goes last. ### 1. Problem @@ -62,17 +62,17 @@ What do they do today? Existing tools, manual workarounds, cobbled scripts, or " Why is solving this worth it? What becomes true for the people who have the problem if this lands? One line: different AND better relative to the alternative — not a feature list, not an architecture sketch. -**Applicability:** always name value, but depth scales. A product pitch needs a crisp UVP; a small change can be "removes the weekly fire-drill so release day is boring." +**Applicability:** always name value, but depth scales. A product pitch needs a crisp UVP; a small internal pitch can be "removes the weekly fire-drill so release day is boring." ### 5. Constraints -Non-negotiable bounds: technical, legal, organizational, philosophical, sequencing. Things that limit the solution space before design begins. Capture as problem-space limits ("must not break the promise-carrier exception"), never as chosen designs ("use Postgres"). +Non-negotiable bounds: technical, legal, organizational, philosophical, sequencing. Things that limit the solution space before design begins. Capture as problem-space limits ("must not add a new human ceremony to the weekly path"), never as chosen designs ("use Postgres"). **Always ask lightly:** at least one real constraint or an explicit "none known yet." ### Secondary (only when signal demands) -- **Sequencing and relationships** — how this hangs with other work, release cohort as prose, series order. No machine relation fields. +- **Sequencing and relationships** — how this hangs with other work, series order. No machine relation fields. - **Open questions** — unresolved problem-space items that pass the [specifiability test](#open-questions-specifiability-and-hitlafk); each tagged HITL or AFK. - **Evidence of pain** — money, time, workarounds (Mom Test lens). When absent and the claim is large, challenge gently. @@ -105,13 +105,13 @@ Every question includes a recommended answer and a short rationale. The human ov Example shape: > **Who has this problem most often?** -> Recommendation: mid-size platform engineers who already run multi-service deploys — they feel config drift weekly. Rationale: your examples all came from that world; broader "all developers" would dilute the brief. +> Recommendation: mid-size platform engineers who already run multi-service deploys — they feel config drift weekly. Rationale: your examples all came from that world; broader "all developers" would dilute the narrative. ### Ordering -Prioritize answers that would rewrite the brief. Typical order: **destination pin** → problem → who → alternatives → value → constraints → sequencing/open questions. Reorder when the human's first utterance already settles an early dimension. +Prioritize answers that would rewrite the narrative. Typical order: **destination pin** → problem → who → alternatives → value → constraints → sequencing/open questions. Reorder when the human's first utterance already settles an early dimension. -Before asking, check whether reading resolves it — journal, prior Change, intake item body, BRIEF. Only ask what reading could not answer. +Before asking, check whether reading resolves it — journal, prior issue, intake item, BRIEF. Only ask what reading could not answer. ### Adaptive depth @@ -120,12 +120,12 @@ Before asking, check whether reading resolves it — journal, prior Change, inta | Crisp, specific answers | Confirm, move on; skip expand-if-needed probes | | Category answers ("developers need better tools") | Ask for a concrete story or last painful moment | | Solution-first ("I want a CLI that…") | Pause; reframe to problem and who | -| Energy dropping | Cut to synthesis; a brief with named gaps beats an exhausted interrogation | -| Direction genuinely undecided | Offer the explore technique from inside pitch; do not force a false brief | +| Energy dropping | Cut to synthesis; a narrative with named gaps beats an exhausted interrogation | +| Direction genuinely undecided | Offer the explore technique from inside pitch; do not force a false narrative | ### Scenario stress-testing -Probe the problem with **concrete lived scenarios**, not abstract categories. Prefer "walk me through the last time this bit you" over "how often does this happen?" Invent edge cases deliberately to force boundary precision: "What if the operator is on-call at 2am with half the logs missing — is that still this problem, or a different one?" Scenarios that collapse the framing expose missing constraints or a second concept that should stay out of this brief. +Probe the problem with **concrete lived scenarios**, not abstract categories. Prefer "walk me through the last time this bit you" over "how often does this happen?" Invent edge cases deliberately to force boundary precision: "What if the operator is on-call at 2am with half the logs missing — is that still this problem, or a different one?" Scenarios that collapse the framing expose missing constraints or a second concept that should stay out of this narrative. ### Challenge stance @@ -133,12 +133,12 @@ Demand specificity over generalization. Probe the rationale behind claims ("why Also enforce **canonical language** during the interview: -- Flag terms that conflict with `docs/knowledge/glossary.md` usage (e.g. calling a Change a "spec," a release cohort a "milestone," a skill a "module"). Offer the glossary term and get the human to accept the swap before it lands in the brief. +- Flag terms that conflict with `docs/knowledge/glossary.md` usage (e.g. calling an issue a "ticket," a skill a "module"). Offer the glossary term and get the human to accept the swap before it lands in the narrative. - Sharpen fuzzy project-local terms to a single canonical choice mid-interview ("you said both 'capture' and 'ticket' — pick one and stick to it"). Ambiguous vocabulary becomes solution fog later. ### Mid-interview evidence -When competitive landscape or external facts would change the brief and the human lacks them, pause the grill, delegate a researcher subagent, land evidence (change-scale: `research/` in the change folder; project-scale: links in Sources), then resume with a recommendation informed by the scan. Evidence supports framing; it does not become solution design. +When competitive landscape or external facts would change the narrative and the human lacks them, pause the grill, delegate a researcher subagent, land evidence (issue-scale: source links in the narrative; project-scale: links in Sources), then resume with a recommendation informed by the scan. Evidence supports framing; it does not become solution design. --- @@ -146,11 +146,11 @@ When competitive landscape or external facts would change the brief and the huma ### Specifiability test -An open question earns a **precise entry** in the brief only if it can be **stated precisely now** — not answered now. The bar is: a later reader (human or agent) could work the question without inventing what was meant. +An open question earns a **precise entry** in the narrative only if it can be **stated precisely now** — not answered now. The bar is: a later reader (human or agent) could work the question without inventing what was meant. | Passes (precise entry) | Fails (coarse note only) | |------------------------|--------------------------| -| "Does the operator need multi-region failover in v1, or is single-region acceptable for the first cohort?" | "Figure out reliability stuff" | +| "Does the operator need multi-region failover in v1, or is single-region acceptable until the first cut?" | "Figure out reliability stuff" | | "Which existing CLI command is the migration source of truth for config paths?" | "TBD on integration" | Everything vaguer stays a **coarse note** in Open Questions or Sequencing prose — never pre-sliced into fake precision. Do not invent enumerated options the human did not surface. @@ -164,7 +164,7 @@ Mark each precise open question with one of: | **HITL** | Needs the human live — judgment, taste, organizational call, or access only they hold | "Will legal accept the data-retention tradeoff?" | | **AFK** | Runnable by an agent without the human in the loop — research, codebase scan, competitive lookup | "What do the top three substitutes charge for the free tier?" | -Briefs carry the tag inline in the Open Questions section, e.g. `- [HITL, blocking] …` or `- [AFK, deferrable] …`. Blocking vs deferrable remains orthogonal: a HITL question can be deferrable; an AFK question can still block shaping if the answer rewrites scope. +Narratives carry the tag inline in the Open Questions section, e.g. `- [HITL, blocking] …` or `- [AFK, deferrable] …`. Blocking vs deferrable remains orthogonal: a HITL question can be deferrable; an AFK question can still block shaping if the answer rewrites scope. --- @@ -180,13 +180,13 @@ Stop interviewing when all of the following hold (or the human explicitly wants 6. **Constraints** are listed or explicitly empty. 7. Answers have stopped changing the framing — the last questions confirmed rather than rewrote. 8. Open questions that remain pass the specifiability test (or are coarse notes) and carry HITL/AFK tags when precise. -9. A cold reader could pass the brief cold-read test below. +9. A cold reader could pass the problem cold-read test below. -Open questions may remain; mark each blocking or deferrable, and HITL or AFK when precise. Blocking problem-space unknowns belong in the brief's Open Questions, not as invented answers. +Open questions may remain; mark each blocking or deferrable, and HITL or AFK when precise. Blocking problem-space unknowns belong in the narrative's Open Questions, not as invented answers. ### The pivot -Do not announce "the interview is over." Shift: "I think I have enough to draft the brief — tell me what I got wrong." Author the brief against the shared skeleton, then section-review with the human before any init or landing step. The **closing ceremony** (recap, path, next step) is owned by SKILL.md after landing — this guide owns interview depth only. +Do not announce "the interview is over." Shift: "I think I have enough to draft the problem narrative — tell me what I got wrong." Author the narrative against the shared skeleton, then section-review with the human before any mint or landing step. The **closing ceremony** (recap, path, next step) is owned by SKILL.md after landing — this guide owns interview depth only. --- @@ -196,7 +196,7 @@ Adopted from bootstrap's interview guide; binding on pitch. **The Form.** Running dimensions mechanically like a survey. If answer 2 covers dimension 4, confirm and skip. -**The 45-Minute Interrogation.** If energy drops, cut to draft. Gaps in the brief are honest; drained enthusiasm is not recoverable in the same session. +**The 45-Minute Interrogation.** If energy drops, cut to draft. Gaps in the narrative are honest; drained enthusiasm is not recoverable in the same session. **The Therapist.** Do not explore the builder's feelings about the product. User emotions (switching forces, pain) matter; builder therapy does not. @@ -212,7 +212,7 @@ Adopted from bootstrap's interview guide; binding on pitch. **Third Interview Idiom.** Do not invent pitch-specific interview machinery. Destination pinning, scenario stress-testing, and challenge stance deepen the same grilling mechanics — they are not a parallel framework. -**Pseudo-Shape in the Brief.** Approach, architecture, task breakdown, or verification design must not enter `brief.md` / `docs/BRIEF.md`. If it appears while drafting, move it out and note it for shape. +**Pseudo-Shape in the Narrative.** Approach, architecture, decomposition, or verification design must not enter the problem narrative or `docs/BRIEF.md`. If it appears while drafting, move it out and note it for shape. **Fake Precision.** Pre-slicing vague unknowns into numbered open questions that cannot yet be stated precisely. Coarse notes beat counterfeit clarity. @@ -220,9 +220,9 @@ Adopted from bootstrap's interview guide; binding on pitch. --- -## Brief Cold-Read +## Problem Cold-Read -Before offering shape-now or park, cold-read the authored brief. A stranger should name, in one pass: +Before offering shape-now or park, cold-read the authored narrative (issue-scale) or `docs/BRIEF.md` (project-scale). A stranger should name, in one pass: 1. The **destination** (or success end-state) 2. The **problem** @@ -230,4 +230,4 @@ Before offering shape-now or park, cold-read the authored brief. A stranger shou 4. The **current alternative** 5. The **value** of solving it -…and find **zero solution-space content** (no approach, stack, API shape, or task list). Precise open questions should carry HITL/AFK tags. If any of the five is missing or solution prose creeps in, revise before landing. +…and find **zero solution-space content** (no approach, stack, API shape, or implementation slices). Precise open questions should carry HITL/AFK tags. If any of the five is missing or solution prose creeps in, revise before landing. diff --git a/dist/amp/skills/refactor-deepen/SKILL.md b/dist/amp/skills/refactor-deepen/SKILL.md index 4e0974049..06a4998e5 100644 --- a/dist/amp/skills/refactor-deepen/SKILL.md +++ b/dist/amp/skills/refactor-deepen/SKILL.md @@ -173,7 +173,7 @@ skill terminates by writing a PLAN file using [templates/plan.md](templates/plan > Plan saved to `.agents/plans/<filename>.md`. Workflow handoff is pending > the SPEC/PLAN/TASKS artifact taxonomy spec — for now, decide manually. -Do **not** recommend breakdown or implement as the next step. The +Do **not** recommend implement as the next step. The handoff design is downstream of a deferred taxonomy spec. ### Codex Review (Opt-In, Plugin-Gated) diff --git a/dist/amp/skills/refactor-deepen/templates/plan.md b/dist/amp/skills/refactor-deepen/templates/plan.md index 8be684590..0fe537186 100644 --- a/dist/amp/skills/refactor-deepen/templates/plan.md +++ b/dist/amp/skills/refactor-deepen/templates/plan.md @@ -28,8 +28,8 @@ write a new file rather than updating an existing one. | `title` | Yes | One-line description of the deepening, not the candidate name | | `created` | Yes | ISO 8601 UTC, e.g. `2026-05-02T01:30:00Z` (must match the filename timestamp) | | `status` | Yes | `drafting` on first write; this template does not define additional lifecycle states | -| `spec` | Yes | `SPEC-NNN` if the plan is scoped under a spec; `null` otherwise (do not omit the key) | -| `related` | No | List of related artifact IDs (`ADR-*`, `SPEC-*`, idea filenames, other plan filenames) | +| `issue` | Yes | `LOAF-NNN` if the plan is scoped under an issue; `null` otherwise (do not omit the key) | +| `related` | No | List of related artifact IDs (`ADR-*`, `LOAF-*`, idea filenames, other plan filenames) | PLAN files do **not** carry an `id` frontmatter field. The filename is the identity, mirroring councils and ideas. @@ -131,7 +131,7 @@ Filename: `.agents/plans/20260502-013000-deepen-journal-append.md` title: "Deepen journal append into a self-managing module" created: "2026-05-02T01:30:00Z" status: drafting -spec: SPEC-034 +issue: LOAF-34 related: - 20260501-231922-plan-lifecycle-cli-doctor-housekeeping --- diff --git a/dist/amp/skills/reflect/SKILL.md b/dist/amp/skills/reflect/SKILL.md index 44322c585..47153e788 100644 --- a/dist/amp/skills/reflect/SKILL.md +++ b/dist/amp/skills/reflect/SKILL.md @@ -81,12 +81,12 @@ After completing work, reflect extracts learnings and proposes updates to strate ### Step 1: Parse Input -`$ARGUMENTS` can be: a spec ID (`SPEC-001`), a topic ("authentication learnings"), or empty (general reflection on recent work). +`$ARGUMENTS` can be: an issue ref (`LOAF-42`), a topic ("authentication learnings"), or empty (general reflection on recent work). ### Step 2: Gather Evidence Sources: -1. **Completed specs** (`.agents/specs/SPEC-*.md` with status `done`; legacy files may still read `complete`) -- look for "Lessons Learned" +1. **Completed issues** (`loaf issue list` / `loaf issue show <ref>` with status `done`) -- look for lessons in the issue body 2. **Project journal** (`loaf journal recent --json`, `loaf journal search <topic>`) -- insights, surprises, pivots 3. **Recent commits** (`git log --oneline -30`) 4. **Implementation reality** -- what was harder/easier than expected? What assumptions were wrong? diff --git a/dist/amp/skills/release/SKILL.md b/dist/amp/skills/release/SKILL.md index 510f4ee1d..9dbab87b1 100644 --- a/dist/amp/skills/release/SKILL.md +++ b/dist/amp/skills/release/SKILL.md @@ -1,33 +1,27 @@ --- name: release description: >- - Orchestrates standalone releases from already-landed work: release readiness, - version selection, changelog curation, release commit, tag, GitHub Release, - install verification, and post-release follow-up. Use when the user says "cut - a release," "publish a version," "release from main," or asks whether enough - landed work should become a release. Not for reviewing or merging a PR (use - ship). + Cuts a retroactive release from already-landed issues: loaf release suggest + reports the range, loaf release cut records the version. Use when the user + says "cut a release," "publish a version," "release from main," or asks what + landed since the last tag. Produces a recorded release row and members as + facts. Not for reviewing or merging a PR (use ship). version: 0.2.21 --- # Release -Publish a coherent version from work that has already landed. +Cut a version from work that has already landed. ## Contents - Critical Rules - Verification - Quick Reference - Topics -- Context Detection -- Step 1: Release Readiness -- Step 2: Change Collection -- Step 3: Version + Changelog -- Step 4: Release Execution -- Step 5: Release-PR Flow -- Step 6: Publication Verification -- Step 7: Post-Release Follow-Up -- Hook Interaction +- Process +- Attribution +- Bump derivation +- Must-contain convention - Related Skills **Input:** $ARGUMENTS @@ -36,259 +30,227 @@ Publish a coherent version from work that has already landed. ## Critical Rules -- **Release is not merge** -- do not use release to review, approve, or land a feature PR. Use ship for PR correctness and landing. -- **Release from landed work** -- collect changes from the release base branch, normally the repo default branch, since the last release tag. -- **Release-PR flow is the default** -- prepare on a release branch with `loaf release --pre-merge`, squash-merge the release PR, then finalize with `loaf release --post-merge` on the base branch. Direct `--bump` on the base branch is a named exception used only on explicit user request. -- **Batch by intent** -- group release notes by user-facing outcome, `CR-*` change bundle, spec, or related PRs; do not mirror individual commits mechanically. -- **Keep landed and released distinct** -- a PR may be landed without being released; a release may contain multiple landed PRs. -- **Block on release-readiness failure** -- do not publish if build, tests, version files, changelog, tag, or GitHub release state is inconsistent. -- **Never push, tag, or publish without confirmation** -- present the exact actions first. -- **Use your harness's structured question tool (if it has one) for release decisions** -- version bump type, release PR handoff, push/tag/GitHub Release confirmation. -- **Log release** -- after publication, run `loaf journal log "decision(release): vX.Y.Z published from <base> with <summary>"`. +1. **Log invocation first** — `loaf journal log "skill(release): <what is being cut or suggested>"` before doing anything else. +2. **Release is not merge** — do not review, approve, or land a PR here. Verification authority is the ship workflow (PR review and CI at merge). If the user is asking to merge, stop and route to ship. +3. **A release is cut from what landed** — the surface is `loaf release suggest` and `loaf release cut`. Do not run unsubcommmanded `loaf release`, `--pre-merge`, or `--post-merge`; this skill does not own that path. +4. **Suggest writes nothing** — it reads `baseline-tag..HEAD` (or `--base <ref>..HEAD`), attributes commits to issues, rolls up through parents, reports partially-landed parents and unattributed commits as information, derives the bump, reports the advisory bucket delta, and drafts notes. +5. **Cut records facts** — it applies the version, prepends the drafted notes into `CHANGELOG.md`, tags, records the release row plus members, then attempts a GitHub Release draft. A `gh` failure degrades to a warning with a paste-ready retry command; the recorded row stays. +6. **No forward version stamp** — do not bind an issue to a future version. Members are what already landed. Buckets (`loaf issue bucket`) are advisory labels; planned-vs-landed is information only. +7. **No suite, no re-record, no publication stop in this skill** — ship already verified the merged work. Cut's operational refusals (dirty worktree, disagreeing version files, missing version, `--no-tag` without an existing tag) are command errors, not a substitute for ship. +8. **Confirm before cut** — present the suggest report (or `cut --dry-run`) first. Ask one question at a time, with a recommendation, using your harness's structured question tool if it has one. `--dry-run` previews everything and writes nothing. +9. **Log the outcome** — after a successful cut, `loaf journal log "decision(release): vX.Y.Z recorded from <base> with <summary>"`. + +--- ## Verification -- Release base branch is clean, current, and contains the intended landed PRs -- Pre-flight checks pass before versioning or publication -- Changelog entries are curated user-facing prose, not commit or PR-title dumps -- Version files, changelog heading, git tag, and GitHub Release all agree -- Tag points at the released base-branch commit or release commit, not an abandoned feature branch -- Downstream install path is verified when applicable, especially Homebrew for Loaf releases +- Journal contains the `skill(release)` invocation (and a `decision(release)` entry after a real cut) +- The work in the range already landed through ship (PR review and CI at merge); this skill did not re-verify or re-merge it +- `loaf release suggest` (or `cut --dry-run`) was shown: landed issues, partially-landed parents, unattributed commits, advisory buckets, derived bump, drafted notes +- Partially-landed parents, unattributed commits, and bucket drift were reported as information — not treated as a cut refusal +- Mutating `loaf release cut` updated version files, wrote the notes into `CHANGELOG.md`, created or reused tag `v<version>`, and recorded the release row with issue members (plus `--includes` release members when given) +- `cut --dry-run` left version files, changelog, tags, HEAD, and release rows untouched +- GitHub Release is a draft, was skipped with `--no-gh`, or failed with a warning plus a paste-ready `gh release create …` retry — never a silent rollback of the recorded row +- No issue was stamped with a future version + +--- ## Quick Reference -| Step | Gate | Blocking? | -|------|------|-----------| -| Readiness | clean/current base branch, no unresolved release collisions | Yes | -| Change Collection | landed work since last tag grouped into release themes | Yes | -| Version + Changelog | bump selected, notes curated, files updated | Yes | -| Execution | release commit prepared via `--pre-merge`, release PR landed, `--post-merge` finalizes | Yes | -| Verification | release and install paths checked | Yes | -| Follow-Up | reflect/housekeeping suggested when useful | No | +| Harness | Invoke skill | +|---------|----------------| +| Claude Code (plugin) | `/loaf:release` | +| OpenCode, Cursor, Codex, Amp | `/release` | + +### Commands + +```text +loaf release suggest [--base <ref>] [--json] +loaf release cut [--base <ref>] [--bump <type>] [--includes <version|tag>] [--no-tag] [--no-gh] [--dry-run] +loaf issue bucket <ref> now|next|later|none [--json] +loaf issue link <from> blocks|relates-to <to> [--json] +``` + +Both commands need initialized SQLite state (`loaf state init`, or `loaf state migrate markdown --apply`). They are not a git repository's optional extra — without SQLite they refuse. + +| Command | Writes? | What it does | +|---------|---------|----------------| +| `loaf release suggest` | No | Report landed work since the last version tag | +| `loaf release cut` | Yes (unless `--dry-run`) | Cut the retroactive release and record members as facts | +| `loaf release cut --dry-run` | No | Print the plan, including `--includes` rows, and write nothing | + +### `suggest` flags + +| Flag | Meaning | +|------|---------| +| `--base <ref>` | Commits since `<ref>` instead of the last tag | +| `--json` | Machine-readable suggestion | +| `-h`, `--help` | Help | + +`suggest` rejects `--dry-run` (it is already read-only) and rejects cut-only flags (`--bump`, `--includes`, `--no-tag`, `--no-gh`). + +### `cut` flags + +| Flag | Meaning | +|------|---------| +| `--base <ref>` | Commits since `<ref>` instead of the last tag | +| `--bump <type>` | Override the derived bump: `major`, `minor`, `patch`, `prerelease`, `release` | +| `--includes <version\|tag>` | Record a prior release as a member (repeatable). Use this to hang prerelease references on a stable | +| `--no-tag` | Do not create a git tag; tag `v<version>` must already exist | +| `--no-gh` | Skip the GitHub Release draft | +| `--dry-run` | Print the plan and write nothing | +| `-h`, `--help` | Help | + +`cut` rejects `--json`. `--bump prerelease` and `--bump release` only produce a version when the current version already has a prerelease suffix; otherwise cut fails with `could not compute a version to cut`. `--no-tag` is checked before `--dry-run`: the tag must already exist even for a preview. + +### Cut sequence (mutating) + +1. Recompute the same suggestion as `suggest` (then apply `--bump` if given, and redraft notes) +2. Resolve each `--includes` ref to an existing release +3. Require a clean worktree +4. Apply the version to detected version files (they must exist and agree) +5. Prepend drafted notes into `CHANGELOG.md` (after `[Unreleased]`, ahead of prior versions; creates the file if missing) +6. Commit `chore: release vX.Y.Z` +7. Unless `--no-tag`: create annotated tag `vX.Y.Z` (`git tag -a`). Signing follows git config (`tag.gpgSign`); cut never passes `-s` or `--no-sign` +8. Record the release row, issue members, and `--includes` members as facts +9. Unless `--no-gh`: `gh release create <tag> --draft --title <tag> --notes <notes>` (adds `--prerelease` when the version is a prerelease). Switches to the configured GitHub account first. On `gh` missing, account failure, or create failure: print `warning:` plus a POSIX-quoted `retry:` command; do not fail the cut + +Cut does not push the commit or the tag. + +--- ## Topics | Topic | Use When | |-------|----------| -| [Context Detection](#context-detection) | Determining release base, last tag, and current branch | -| [Release-PR Flow](#step-5-release-pr-flow) | Preparing, landing, and finalizing every release | -| [Hook Interaction](#hook-interaction) | Understanding coexistence with git hooks | +| [Process](#process) | Running suggest then cut in this conversation | +| [Attribution](#attribution) | How commits become issue members | +| [Bump derivation](#bump-derivation) | Why suggest picked major, minor, or patch | +| [Must-contain convention](#must-contain-convention) | The rare promise that named issues must land first | --- -## Context Detection - -Before anything, establish the release surface: - -1. Get current branch and repo default branch: - ```bash - git branch --show-current - gh repo view --json defaultBranchRef -q .defaultBranchRef.name - ``` -2. Parse `$ARGUMENTS` for an explicit base, tag, or version. If omitted, use the repo default branch as the release base. -3. Verify the current branch: - - If already on the release base, continue; the release-PR flow in Step 5 branches from here. - - If on a dedicated release branch, resume the release-PR flow at the matching step. - - If on a feature branch, stop and explain that release publishes from landed work. Offer ship if the active PR needs landing first. -4. Find the previous release tag: - ```bash - git describe --tags --abbrev=0 - ``` -5. Gather the candidate release range: - ```bash - git log --oneline <last-tag>..HEAD - git diff --stat <last-tag>..HEAD - ``` +## Process ---- +Parse `$ARGUMENTS` for a base, bump, version, `--includes`, `--no-tag`, `--no-gh`, or `--dry-run`. Default baseline is the last version tag; `--base` overrides. With neither a last tag nor `--base`, the range is all of `HEAD`. -## Step 1: Release Readiness - -Run release pre-flight checks before editing release files: - -1. Ensure worktree is clean: - ```bash - git status --short - ``` -2. Ensure the release base is current: - ```bash - git fetch --tags origin - git status --branch --short - ``` -3. Check for existing tag or GitHub Release collisions for the target version once known: - ```bash - git tag --list vX.Y.Z - gh release view vX.Y.Z - ``` -4. Run project checks: - - Node: `npm run typecheck`, `npm run test`, `npm run build` when scripts exist - - Go: `go vet ./...`, `go test ./...` when `go.mod` exists - - Python: `pytest`, `mypy .`, `ruff check .` when configured - - Rust: `cargo check`, `cargo test` when `Cargo.toml` exists - -If no checks are detected, warn explicitly. If a check fails, stop and fix before release. +### Step 1: Log and route ---- +```bash +loaf journal log "skill(release): <what is being cut or suggested>" +``` -## Step 2: Change Collection - -Collect landed work since the last release and group it for release notes. - -1. Inspect commits: - ```bash - git log --first-parent --oneline <last-tag>..HEAD - git log --oneline <last-tag>..HEAD - ``` -2. Inspect merged PRs when GitHub is available: - ```bash - gh pr list --state merged --base <base> --json number,title,mergedAt,url - ``` -3. Group changes by user-facing outcome: - - `CR-*` change bundle, when referenced - - spec or task family, when public enough to be useful - - feature/fix/documentation/build themes - - operational release work, when it affects users or maintainers -4. Drop noise: - - purely internal task labels - - reverted work that is not present in `HEAD` - - individual commit mechanics that collapse into one user-facing change - -Present the grouped release contents before choosing the bump. +If the user wants a PR reviewed or merged, stop and use ship. If the work is still on a feature branch, explain that a release is cut from landed `HEAD` since the baseline, and offer ship. ---- +### Step 2: Suggest -## Step 3: Version + Changelog - -Choose the bump and curate the changelog from the grouped landed work. - -1. Run a dry run: - ```bash - loaf release --dry-run - ``` - Use `--base <ref>` when the project expects a non-default release base. -2. Present: - - current version - - proposed next version - - detected version files - - release actions the CLI would perform - - draft changelog entries -3. Curate `CHANGELOG.md` before publishing: - - write from the upgrading user's perspective - - group under Common Changelog categories: `Changed`, `Added`, `Removed`, `Fixed` - - use one self-describing line per meaningful change - - include public PR, issue, ADR, release, or commit links when helpful - - avoid dumping commit subjects, task IDs, session mechanics, or internal gate language -4. Confirm the bump type: `prerelease`, `release`, `major`, `minor`, or `patch`. +```bash +loaf release suggest +# or +loaf release suggest --base <ref> +loaf release suggest --json +``` ---- +Present the report as-is: base, suggested bump and version, bump evidence, landed issues with commits, partially-landed parents (missing children), unattributed commits, advisory buckets (planned landed / planned not landed / unplanned landed), drafted notes. -## Step 4: Release Execution +Do not hide partial parents or unattributed commits, and do not refuse the cut because of them unless the operator is using the [must-contain convention](#must-contain-convention) and wants to wait. -Every release routes through the release-PR flow in Step 5: prepare the release commit on a release branch with `loaf release --pre-merge`, land the release PR, then finalize with `loaf release --post-merge` on the base branch. +### Step 3: Confirm -Release preparation should: +Show the exact `loaf release cut …` you would run. Recommend cutting the derived version when the landed set matches what the operator asked for. Use `--bump` only when they override. Use `--dry-run` when they want a preview: -1. Update version files -2. Convert `[Unreleased]` into `## [X.Y.Z] - YYYY-MM-DD` -3. Reinsert a fresh empty `[Unreleased]` section -4. Run configured release artifact commands -5. Create the release commit +```bash +loaf release cut --dry-run +loaf release cut --dry-run --no-gh +loaf release cut --dry-run --includes <version|tag> +``` -After preparation, verify generated artifacts are current: +### Step 4: Cut ```bash -npm run build -git diff --exit-code -- dist plugins content/skills/loaf-reference/SKILL.md +loaf release cut +loaf release cut --bump minor +loaf release cut --includes v1.1.0-alpha.1 +loaf release cut --no-tag --no-gh ``` -Adjust the path list to the project. For Loaf itself, tracked generated outputs under `dist/`, `plugins/`, and native binaries must match the source changes. +On success, report version files updated, changelog written, tag created or reused, release recorded (member count), and GitHub draft created / skipped / warned. If stderr has `retry:`, paste that command; the row is already recorded. -Capability receipts pin artifact SHA-256s, and the release rebuild version-stamps generated artifacts (`dist/opencode/plugins/hooks.ts` embeds `@version`, so every version bump stales the OpenCode receipt; Go changes additionally stale all binary-pinned receipts via `bin/native`). Therefore re-recording runs AFTER `loaf release --pre-merge` completes its artifact rebuild, on the release branch, before pushing the release PR — never before the bump. Verify with `go test ./internal/cli -run TestTargetCapabilityEvidence`. `loaf release` now enforces this mechanically on every mutating path (post-rebuild refusal in apply, guardrail 9 in `--post-merge`) — the rule explains WHY the gate fires; the gate makes skipping it impossible. +```bash +loaf journal log "decision(release): vX.Y.Z recorded from <base> with <summary>" +``` -### Direct Release (Named Exception) +### Step 5: After -`loaf release --bump <type> --yes` on the base branch prepares, commits, tags, and publishes in a single shot. Use it only when the user explicitly requests a direct release; never select it by default. Skipping the release PR means nothing runs the suite against the prepared tree before the tag exists — the v0.2.16 cut took this door and a capability-evidence canary surfaced only in tag CI, after publication. The same day, v0.2.17 re-recorded evidence minutes before the version bump; the release commit staled it, and the tag again published zero assets — ordering, not diligence, is the failure mode. The CLI prints a flow advisory when a mutating release starts on the default branch; treat it as a routing signal, not noise. +Suggest reflect when the cut produced durable product or workflow learnings, and housekeeping when temporary artifacts need cleanup. Capture leftover discoveries as issues or sparks — not as extra changelog lines. --- -## Step 5: Release-PR Flow +## Attribution -The default for every release: PR CI runs the full suite against the prepared tree, so evidence canaries surface before any tag or GitHub Release exists. This holds regardless of repository settings — where branch protection is enabled it is satisfied as a side effect, not the reason for the flow. +`suggest` (and `cut`, which recomputes the same suggestion) attributes each commit in the range to zero or more issues, then rolls up through parents. -1. Create a dedicated release branch from the release base. -2. Run `loaf release --pre-merge` on it: this creates the version/changelog/artifact release commit but no tag and no GitHub Release. -3. Open a release PR with a concise release-focused body. -4. Hand the PR to ship for review and landing; squash-merge it into one `chore: release vX.Y.Z (#PR)` commit carrying the curated changelog. -5. After the release PR lands, run `loaf release --post-merge` on the base branch to tag, publish the GitHub Release, and verify installability. +**Commit → issue**, first match wins: -If guardrail 9 fires on `--post-merge`, the merged tree itself carries stale evidence; recovery is to re-record against the merged tree, land the receipts as a single evidence-only commit on the base branch (the repair commit must not modify the capability registry), and rerun `loaf release --post-merge`. +1. Issue alias (`PREFIX-N`, e.g. `LOAF-42`) in the subject or body (prefix case-sensitive). URLs and code spans are stripped first. +2. Else the merge/branch rung: aliases in a `Merge …` subject, plus any alias anywhere in the body (case-insensitive). Squash subjects like `feat: add auth (#42)` often carry the alias only in the body. No network, no `gh`. An alias that lived only on a deleted branch name is unattributable. +3. Else a unique journal `commit(<hash>)` row whose message contains an alias (scope uniquely matching that commit). -Do not hide this handoff inside release: ship remains the PR correctness and merge gate. +Resolved aliases become **landed** issues (with the commits that named them). Commits that match nothing are **unattributed** — listed, and included under drafted notes as `### Unattributed`. They do not block the cut. ---- +**Parent rollup** (information): -## Step 6: Publication Verification +- For each landed issue that has a parent, if any sibling child is not `done`, the parent is **partially landed** and the missing children are listed. +- A parent is not auto-added to landed unless a commit attributed to it. -After publishing, verify the public release state: +Drafted notes are `## [version] - YYYY-MM-DD`, then one `### ALIAS — title` section per landed issue with commit subjects, then unattributed. -1. Confirm tag location: - ```bash - git show --stat vX.Y.Z - ``` -2. Confirm GitHub Release: - ```bash - gh release view vX.Y.Z - ``` -3. Confirm package or installer availability when applicable: - - npm: `npm view <package> version` - - Homebrew: `brew update && brew info <tap>/<formula>` - - project-specific deploy or artifact registry checks -4. For Loaf/Homebrew, report readiness only after the GitHub release exists, assets are uploaded, the tap formula is updated, and tap CI has passed. +--- -If publication partially completes, do not retag casually. Name the exact state and continue with the smallest repair or patch release path. +## Bump derivation ---- +Derived from the range, in order: -## Step 7: Post-Release Follow-Up +| Condition | Bump | +|-----------|------| +| Breaking marker (`type!:` in the subject, or `BREAKING CHANGE:` / `BREAKING-CHANGE:` in the body) | `major` | +| A **done** parent with **two or more** children, every child `done` and landed, and the parent's done timestamp **after** the baseline tag's committer time | `minor` (closed multi-child parent fully landed) | +| Else a conventional `feat` commit | `minor` | +| Else | `patch` (`fix` / other) | -After verification: +`--bump` on `cut` replaces the derived bump and redrafts notes; the evidence string becomes `overridden by --bump <type>`. -1. Log the release decision to the project journal: - ```bash - loaf journal log "decision(release): vX.Y.Z published from <base> with <summary>" - ``` -2. Suggest reflect when the release produced durable product or workflow learnings. -3. Suggest housekeeping when release branches or temporary reports need cleanup. -4. Keep future-work discoveries out of the release notes; capture them as tasks, ideas, or sparks instead. +Current version comes from agreeing version files, else from a semver last tag. Cut still requires version files on the mutating path. --- -## Hook Interaction +## Must-contain convention -This skill coexists with existing hooks. Git workflow hooks are advisory unless -configured otherwise; security and secret-scanning hooks remain blocking. +Rare. When the operator needs a named set of issues to land before a cut, create a **release-prep** issue and express `blocked_by` edges with issue mechanics: + +```bash +loaf issue new "Release prep for vX.Y.Z" --body "Must contain LOAF-12 and LOAF-15. Out of scope: the cut itself." +loaf issue link LOAF-12 blocks LOAF-99 +loaf issue link LOAF-15 blocks LOAF-99 +``` -| Hook | Type | When release Runs | -|------|------|---------------------| -| `github-account` | Force-switch | Switches to the configured GitHub account before `gh` release operations; blocks only if the switch fails | -| `validate-push` | Advisory | Cross-checks version bump, changelog, and build on push | -| `workflow-pre-pr` | Advisory | Fires when the release PR is opened | -| `workflow-pre-merge` | Advisory | Belongs to ship when a release PR must land | -| `workflow-post-merge` | Advisory | Belongs to ship after PR landing | -| `check-secrets` | Blocking | Always respected before writes or shell actions | +Stored write types are `blocks` and `relates_to`. `loaf issue link <must-land> blocks <release-prep>` is how you record that the prep issue is blocked by those that must land. `loaf issue frontier` and implement honor `blocks`. **`loaf release suggest` and `cut` do not read these edges** — convention, not schema. If the operator wants to wait, wait; if they cut anyway, cut records whatever actually landed. -Do not disable hooks to force a release through. +Buckets stay labels: ---- +```bash +loaf issue bucket LOAF-12 now +loaf issue bucket LOAF-12 none +``` -## Suggests Next +`suggest` prints `bucket:<name> ALIAS — title (landed|not landed)` and `unplanned ALIAS — title (landed)`. Never treat that delta as a constraint. -After a successful release, suggest reflect for durable learnings and housekeeping if temporary release artifacts need attention. +--- ## Related Skills -- **ship** -- Reviews, verifies, and lands a PR before it becomes release input -- **git-workflow** -- Branching, PR, commit, and squash merge conventions -- **documentation-standards** -- Changelog and release-note quality -- **reflect** -- Updates strategy from shipped/released learnings -- **housekeeping** -- Cleans up completed spec, report, and handoff artifacts +- **ship** — Reviews, verifies, and lands a PR. That merge is the verification authority for what this skill may later cut +- **git-workflow** — Branching, PRs, and any later push of the local tag (cut does not push) +- **documentation-standards** — Changelog prose if a human edits notes after the cut +- **reflect** — Durable learnings after a cut +- **housekeeping** — Cleanup of temporary artifacts diff --git a/dist/amp/skills/research/SKILL.md b/dist/amp/skills/research/SKILL.md index af98d9acd..f99be1eca 100644 --- a/dist/amp/skills/research/SKILL.md +++ b/dist/amp/skills/research/SKILL.md @@ -94,7 +94,7 @@ Always check project context first. Rate findings: **High** (official/verified), **Trigger:** Empty input, "project state", "catch me up" 1. Read project documents: VISION.md, STRATEGY.md, ARCHITECTURE.md -2. Check ideas with `loaf idea list --json` and specs with `loaf spec list --json` +2. Check ideas with `loaf idea list --json` and issues with `loaf issue list --json` (or `loaf issue export` for the full graph) 3. Review recent journal activity with `loaf journal recent --json` and `loaf journal context` 4. Check recent commits: `git log --oneline -20` 5. Synthesize following [state-assessment template](templates/state-assessment.md) @@ -144,4 +144,4 @@ User-facing entry for a new concept is pitch (problem-discovery brief). Do not t ## Artifact Naming -Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or Change already records that provenance. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. +Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or the issue already records that provenance. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. diff --git a/dist/amp/skills/research/templates/report.md b/dist/amp/skills/research/templates/report.md index 2ff4c059c..f7cc5d7dd 100644 --- a/dist/amp/skills/research/templates/report.md +++ b/dist/amp/skills/research/templates/report.md @@ -14,7 +14,7 @@ title: "Report: [Topic]" type: research | audit | analysis | council created: YYYY-MM-DDTHH:MM:SSZ status: draft | done | archived -source: SPEC-XXX | TASK-XXX | ad-hoc +source: LOAF-42 | ad-hoc tags: [] --- diff --git a/dist/amp/skills/research/templates/state-assessment.md b/dist/amp/skills/research/templates/state-assessment.md index 65e426f3b..359392033 100644 --- a/dist/amp/skills/research/templates/state-assessment.md +++ b/dist/amp/skills/research/templates/state-assessment.md @@ -24,7 +24,7 @@ tags: [] - **Vision:** [Brief summary] - **Key personas:** [Who we're building for] -- **Current focus:** [Active specs/work] +- **Current focus:** [Active issues] ## Recent Progress @@ -32,10 +32,10 @@ tags: [] ## In Flight -| Spec/Task | Status | Notes | -|-----------|--------|-------| -| SPEC-001 | implementing | [progress] | -| SPEC-002 | approved | [next up] | +| Issue | Status | Notes | +|-------|--------|-------| +| LOAF-1 | active | [progress] | +| LOAF-2 | todo | [next up] | ## Ideas Pipeline diff --git a/dist/amp/skills/shape/SKILL.md b/dist/amp/skills/shape/SKILL.md index 42fce86f5..c4fa4a21d 100644 --- a/dist/amp/skills/shape/SKILL.md +++ b/dist/amp/skills/shape/SKILL.md @@ -1,25 +1,21 @@ --- name: shape description: >- - Shapes messy input into a bounded, reviewable Change under - docs/changes/YYYYMMDD-slug/ (change.json + shape.md + tasks/), validated by - loaf change check. Runs a fog-routed narrowing protocol — gather context, - optional blindspot pass, grilling, reaction artifacts — seeds task-file - vertical slices, runs a critique gate, and offers an opt-in draft PR. Use when - the user asks "shape this," "turn this into a Change," or an idea has enough - constraints to bound. Produces role-named narrative (shape.md required; - brief/plan/design optional) plus task packets — never a numbered spec. - Teaches the problem-boundary test (same problem → another task; different - problem → Intent) and vertical-slice discipline. Not for quick capture (use - idea), problem discovery that should author a brief first (use pitch), or - open-ended divergent thinking (agent technique: explore / brainstorm — user - entry intent routes to pitch). + Shapes messy input into a bounded issue — problem body, definition-of-done + criteria, out-of-scope statement, and children when a criterion earns its own + DoD — validated by loaf issue check. Use when the user asks "shape this," + "turn this into an issue," or a diagnosed fix needs a row. Produces a shaped + issue — never a folder or a plan document. Teaches fog graduation (park, + then a decision child) and one-criterion sizing (one fresh context window, + verifiable alone). Not for quick capture (use idea), problem discovery that + should author a brief first (use pitch), or open-ended divergent thinking + (agent technique: explore / brainstorm — user entry routes to pitch). version: 0.2.21 --- # Shape -Turn messy input into a bounded, reviewable Change. +Prepare a bounded, reviewable issue. ## Contents - Critical Rules @@ -35,29 +31,30 @@ Turn messy input into a bounded, reviewable Change. ## Critical Rules -1. **Log invocation first** — `loaf journal log "skill(shape): <input being shaped>"` before doing anything else. -2. **Produces a Change, never a spec** — `change.json` + `shape.md` (+ optional `brief.md`/`plan.md`/`design.md`) and `tasks/TASK-NNN-slug.md`. No sequentially-numbered spec file, no status-like fields anywhere. +1. **Log invocation first** — `loaf journal log "skill(shape): shaping <topic> into LOAF-42"` before doing anything else. If no issue exists yet, log `skill(shape): shaping <topic>` and add the alias in the outcome entry. +2. **Produces an issue, never a folder** — the deliverable is the issue row: problem in the body, definition of done as `loaf issue dod` criteria, an explicit out-of-scope statement in the body, children via `loaf issue promote` when a criterion earns its own DoD. No plan document is committed. The PR body, if a PR is opened, is `loaf issue render` output. 3. **The fog register routes, you don't guess** — every named unknown carries a quadrant tag that dispatches it to exactly one technique (see Quick Reference). Technique-by-vibes is the failure mode this replaces. -4. **Blindspot pass precedes grilling, offered not imposed** — run it when the territory is unfamiliar; skip it when the shaper is the domain expert. Never impose it as a mandatory step. -5. **Grill one question at a time, with a recommendation** — using your harness's structured question tool if it has one (otherwise one inline question per message — same semantics). Never a form to fill in one pass. Order by architectural impact: questions whose answer would change the architecture go first, cosmetic questions last. -6. **Techniques return fog entries, not decisions** — blindspot passes, grilling, and reaction artifacts hand back named unknowns and evidence for the human to adjudicate. Reconnaissance, not orders. -7. **Own the decomposition** — decide Implementation Unit boundaries and granularity autonomously (absorbed from the retired breakdown step); ask only when two orderings carry genuinely different trade-offs. -8. **Order units by likelihood-of-change** — data models, interfaces, and user-facing flows lead; mechanical work collapses at the bottom, so review attention lands on what's most likely to need changing. -9. **Surface misalignment, never silently adjust** — when the idea conflicts with strategic docs, prior Changes, or the journal, tell the user and let them decide. Don't quietly reshape their idea. -10. **Critique before finalizing** — run the Critique Gate as the last shaping step, before `loaf change check` and the PR offer. -11. **Get approval before `loaf change init`** — don't scaffold the folder without explicit confirmation of scope. -12. **Log the outcome** — `loaf journal log "decision(shape): <slug> shaped — N units, N open fog entries"`. +4. **Fog graduates instead of evaporating** — a question not yet sharp enough is parked in the issue's `fog` field (`loaf issue new --fog`). When it sharpens it becomes a `--kind decision` child, which is ready when it poses a sharp question (a `?` in the title or body). No plan required. +5. **Blindspot pass precedes grilling, offered not imposed** — run it when the territory is unfamiliar; skip it when the shaper is the domain expert. Never impose it as a mandatory step. +6. **Grill one question at a time, with a recommendation** — using your harness's structured question tool if it has one (otherwise one inline question per message — same semantics). Never a form to fill in one pass. Order by architectural impact: questions whose answer would change the architecture go first, cosmetic questions last. +7. **Techniques return fog entries, not decisions** — blindspot passes, grilling, and reaction artifacts hand back named unknowns and evidence for the human to adjudicate. Reconnaissance, not orders. +8. **Decomposition is the tail** — a parent gets children only when its DoD needs more than one coherent slice. A criterion becomes a child the moment it earns its own DoD, via `loaf issue promote`. Own those boundaries autonomously; ask only when two orderings carry genuinely different trade-offs. +9. **One sizing criterion** — a slice is right-sized when it fits one fresh context window and is verifiable alone. Expand–contract is the named exception for wide mechanical refactors. See [references/decomposition.md](references/decomposition.md). +10. **Surface misalignment, never silently adjust** — when the idea conflicts with strategic docs, prior issues, or the journal, tell the user and let them decide. Don't quietly reshape their idea. +11. **Critique before finalizing** — run the Critique Gate as the last shaping step, before `loaf issue check`. +12. **A diagnosed one-line fix is two commands** — `loaf issue new` with a body that states the problem and `Out of scope: …`, then one `loaf issue dod add`. No problem-space ceremony. Confirm scope with the user before `loaf issue new` on anything larger. +13. **Log the outcome** — `loaf journal log "decision(shape): LOAF-42 shaped — N children, N open fog entries"`. --- ## Verification -- `docs/changes/YYYYMMDD-slug/` has `change.json` + `shape.md` with Product Contract sections non-empty; task packets seeded under `tasks/` when decomposition is known -- Every Open Questions entry carries a quadrant tag (`[KU]`, `[UK]`, or `[UU]`) and a route -- `loaf change check` reports zero violations (no legacy deprecation on new layout); executability gaps were read, not ignored -- Problem-boundary test applied: discovered different problems become Intents, not TASK-007 -- The Critique Gate ran, and its answers changed the documents where they applied -- No status-like fields in `change.json` or task frontmatter +- The issue body states the problem and contains an explicit out-of-scope statement (`out of scope`, case-insensitive — that substring is what `loaf issue check` reads) +- At least one definition-of-done criterion exists; V-tier criteria carry `--command` (and `--expect` when the check is more than exit 0); H-tier otherwise +- Every open unknown is either parked in create-time `fog`, held in the session register until it sharpens, graduated to a `--kind decision` child (or sibling) with a sharp question, or written into the body as a decided answer +- `loaf issue check <ref>` reports the issue shaped (delivery) or ready (decision). When children exist, coverage failures were fixed and containment orphans were filed as sibling backlog issues using the printed remedy +- Problem-boundary test applied: a discovered different problem becomes a new backlog issue, not another criterion on this one +- The Critique Gate ran, and its answers changed the issue where they applied --- @@ -65,34 +62,46 @@ Turn messy input into a bounded, reviewable Change. ### Fog register format -Open Questions entries take one of three forms: +Open unknowns take one of three forms. Keep the register in the session. Park what is still unsharp in `--fog` at create; after create, unsharp entries stay in the session register (edit cannot mutate `fog`). Graduate what is sharp to a decision child or sibling, and write decided answers into the body. ```text -- [KU] <the unknown> → <route: grilling | research spike | owner section> -- [UK] <the recognize-it-when-seen criterion> → reaction artifact in research/ +- [KU] <the unknown> → <route: grilling | research spike | owner> +- [UK] <the recognize-it-when-seen criterion> → reaction artifact - [UU] <the suspected blind area> → blindspot pass over <territory> ``` -An entry resolves by becoming a Decision, a Planning Contract subsection, or a named follow-up — visible in the diff, never silently deleted. A `[UU]` that gets named becomes a `[KU]` or `[UK]` and re-routes through the table below. +An entry resolves by becoming a decision child, a body paragraph, a criterion, or remaining parked in `fog` — visible on `loaf issue show`, never silently deleted. A `[UU]` that gets named becomes a `[KU]` or `[UK]` and re-routes through the table below. ### Quadrant routing | Tag | Meaning | Routes to | |-----|---------|-----------| | `[KU]` known unknown | A question you can state precisely | [Grilling](references/grilling.md) (architecture-changing answers first) or a research spike | -| `[UK]` unknown known | You'd recognize the right answer if you saw it, but can't state it yet | [Reaction artifact](references/reaction-artifact.md) — a variant or mock in `research/`, react and pick | +| `[UK]` unknown known | You'd recognize the right answer if you saw it, but can't state it yet | [Reaction artifact](references/reaction-artifact.md) — a variant or mock, react and pick | | `[UU]` suspected blind spot | Unfamiliar territory; you don't yet know what you don't know | [Blindspot pass](references/blindspot-pass.md) | -No route names a skill invocation. Research re-interviews an already-scoped question and writes to `.agents/reports/`; brainstorm forces a strategic frame onto a Change-local question and sends resolutions to intake. Shape runs all three techniques itself, in-session, and writes evidence into the Change's own `research/` — never `.agents/reports/`. +No route names a skill invocation. Research re-interviews an already-scoped question; brainstorm forces a strategic frame onto an issue-local question and sends resolutions to intake. Shape runs all three techniques itself, in-session, and writes the captured answer onto the issue — never into `.agents/reports/`. ### Defined terms -- **Rabbit holes** — tempting expansions of scope that would consume disproportionate effort for marginal value; name them so nobody wanders in unknowingly. -- **No-gos** — approaches explicitly forbidden for this Change, stated so they aren't silently reconsidered mid-implementation. +- **Rabbit holes** — tempting expansions of scope that would consume disproportionate effort for marginal value; name them in the out-of-scope statement so nobody wanders in unknowingly. +- **No-gos** — approaches explicitly forbidden for this issue, stated so they aren't silently reconsidered mid-implementation. ### Source inputs recognized -Step 1 reads whatever `$ARGUMENTS` names, or asks. Recognized sources: a change `brief.md` (from pitch or capture), a journal entry (cite by ID), a spark, an idea, a brainstorm document, a Linear issue, a PR conversation, a prior Change, or plain conversation with no artifact behind it yet. +Step 1 reads whatever `$ARGUMENTS` names, or asks. Recognized sources: a brief from pitch, a journal entry (cite by ID), a spark, an idea, a brainstorm document, a Linear issue, a PR conversation, a prior issue, or plain conversation with no artifact behind it yet. + +### One-line entry + +A diagnosed fix that already has a problem and a done-check: + +```bash +loaf issue new "Fix missing --json in list help" --body "issue list --help omits --json. Out of scope: rewriting other help pages." +loaf issue dod add LOAF-42 "issue list help names --json" --command "loaf issue list --help" --expect "contains \`--json\`" +loaf issue check LOAF-42 +``` + +Two writes, then the readiness verdict. No grilling, no children, no files. --- @@ -100,53 +109,86 @@ Step 1 reads whatever `$ARGUMENTS` names, or asks. Recognized sources: a change ### Step 1: Gather Context -Parse `$ARGUMENTS` against the source inputs above. When the input names a Change folder that already has `brief.md` (or you find one for this work), treat the brief as primary: restate the problem from it, confirm with the user rather than re-discovering, and keep later grilling on solution-space (how, boundaries, verification) — pitch already framed the problem. When no brief exists, run full narrowing as today; pitch is the recommended front door for raw concepts, never a gate. Read the journal (`loaf journal recent` / `search`) for related history, and check for a prior Change touching the same area. When VISION.md, STRATEGY.md, and ARCHITECTURE.md exist, read them for strategic fit. Most consumer projects don't have them yet — when absent, shape against the journal, recent Changes, and the conversation instead, and say so in the Change's Source Inputs. +Parse `$ARGUMENTS` against the source inputs above. When a brief from pitch already frames the problem, restate it, confirm with the user rather than re-discovering, and keep later grilling on solution-space (how, boundaries, verification). When no brief exists, run full narrowing; pitch is the recommended front door for raw concepts, never a gate. Read the journal (`loaf journal recent` / `search`) for related history, and check for a prior issue touching the same area (`loaf issue list`, `loaf issue tree`). When VISION.md, STRATEGY.md, and ARCHITECTURE.md exist, read them for strategic fit. Most consumer projects don't have them yet — when absent, shape against the journal, recent issues, and the conversation instead, and say so in the issue body. ### Step 2: Evaluate Strategic Fit -When strategic docs exist, check: does this advance the vision, serve the target personas, fit technical constraints, avoid conflicting with in-flight Changes? On misalignment, **surface it to the user — never silently adjust the idea**. The user decides whether to proceed, narrow scope, or defer to reflect after this ships. +When strategic docs exist, check: does this advance the vision, serve the target personas, fit technical constraints, avoid conflicting with in-flight issues? On misalignment, **surface it to the user — never silently adjust the idea**. The user decides whether to proceed, narrow scope, or file the conflicting concern as its own backlog issue. -### Step 3: Name the Change and Initialize +### Step 3: Name the Issue and Write the Row -Once the shape of the work is nameable, confirm scope with the user, then: +Once the work is nameable, confirm scope with the user (skip this confirmation on the one-line path), then create the row. Prefer creating after the first narrowing pass so `--fog` can carry remaining unsharp questions — the CLI writes `fog` only at create. ```bash -loaf change init <slug> +loaf issue new "Rotate auth tokens on a sliding window" \ + --body "Sessions never expire while the tab stays open, so a stolen cookie is valid indefinitely. + +Out of scope: migrating existing sessions; third-party IdP support." \ + --fog "[KU] sliding-window length → grill; [UU] existing session-store conventions → blindspot pass" ``` -On a fresh slug this scaffolds `change.json` + `shape.md` + seeded `tasks/` from the embedded templates (see `templates/shape.md`, `templates/task.md`). On a capture-only folder that already has `change.json` + `brief.md` (from pitch or `init --brief`), the same command promotes in place — preserving brief and metadata verbatim while materializing `shape.md` and `tasks/` — never hand-copy templates into the folder; rely on that promotion path. Use `loaf change init <slug> --brief` only for capture-before-shape (emits `change.json` + `brief.md`). It does not switch branches — `git switch -c <slug>` yourself. Fill `shape.md` Product Contract sections as understanding solidifies; seed `tasks/TASK-NNN-slug.md` as vertical slices (a task is a commit, not a PR). Optional `plan.md`/`design.md` accrete when the how needs prose. See [references/cli-boundary.md](references/cli-boundary.md). +Default kind is `delivery`; default status is `triage`. `--status` accepts `triage`, `backlog`, `todo`, `active`, or `done`. Use `--body -` or `--body-file <path>` for a longer body; `loaf issue edit <ref>` later **replaces** the body, it does not patch it. + +A delivery issue is shaped when the body is nonempty (the problem), at least one criterion exists, and the body contains an explicit out-of-scope statement. Fill those as understanding solidifies — create can carry the first body; criteria come next. + +A discovered different problem is a new backlog issue, not a child of this one: + +```bash +loaf issue new --status backlog "Rewrite the session store" +``` ### Step 4: Narrow the Unknowns -Offer the blindspot pass when the territory is unfamiliar (a new domain, an unfamiliar subsystem, a first collaboration) — skip it when the shaper is the expert. Its fog entries, and any others surfaced in interview, route by quadrant (Quick Reference above). Loop: grill `[KU]` entries, react to `[UK]` entries, run blindspot reconnaissance on `[UU]` entries until each gets a name and re-routes. Stop grilling when no unrouted `[KU]` entries remain or answers stop changing the contract. Entries still open at the end of the session are fine — each names its owner (a section, a spike, a follow-up). +Offer the blindspot pass when the territory is unfamiliar (a new domain, an unfamiliar subsystem, a first collaboration) — skip it when the shaper is the expert. Its fog entries, and any others surfaced in interview, route by quadrant (Quick Reference above). Loop: grill `[KU]` entries, react to `[UK]` entries, run blindspot reconnaissance on `[UU]` entries until each gets a name and re-routes. Stop grilling when no unrouted `[KU]` entries remain or answers stop changing the issue. -### Step 5: Decompose into Implementation Units +When a parked question sharpens, graduate it — after the parent's DoD is written (Step 5). Attaching **any** child, including a decision, turns coverage on. -Absorbed from the retired breakdown step — see [references/decomposition.md](references/decomposition.md) for the Right Size Test and per-unit verification discipline, including the V-tier `Command:` / `Expect:` forms `loaf change verify` parses (commands run from the repository root; H-tier is never gate input). Order units by likelihood-of-change; state real sequencing constraints in prose, never by list order alone. +```bash +loaf issue new --kind decision --parent LOAF-42 "Should tokens live in httpOnly cookies?" +``` + +A decision issue is ready when the title or body contains `?`. It needs no criteria and no out-of-scope statement. A decision child does not claim a parent criterion, so promote (or otherwise claim) the parent's DoD before adding children, or keep the decision as a sibling (`loaf issue new --kind decision --status backlog`, no `--parent`) if the parent stays a leaf. Unsharp questions discovered after create stay in the session register until they graduate — there is no `--fog` on edit. See [references/decomposition.md](references/decomposition.md). + +### Step 5: Write Definition of Done (decomposition tail) + +Add criteria as the interrogation produces observable done-checks. V-tier when a command can disagree with the implementation; H-tier when only a human can tell. + +```bash +loaf issue dod add LOAF-42 "Sliding-window expiry is covered by tests" --command "go test ./internal/auth/..." --expect "exit 0" +loaf issue dod add LOAF-42 "Stolen-cookie writeup is reviewable" --tier H +``` + +`--command` implies V unless `--tier` overrides. `--expect` uses the verify grammar (`exit <N>`, `` contains `text` ``, joined by ` and `). Commands run from the repository root. See [references/cli-boundary.md](references/cli-boundary.md) and [references/decomposition.md](references/decomposition.md). + +A parent gets children only when its DoD needs more than one coherent slice. The moment a criterion earns its own DoD, promote it — the parent criterion stays, the child starts with a copy, and the claim is recorded so coverage holds for that position: -### Step 6: Fill the Planning Contract +```bash +loaf issue promote LOAF-42 1 +``` -Write the free-form `###` subsections the work actually needs (approach, placement, risks, sequencing) inside the Planning Contract container. Its subsection names are yours; the container itself, plus Implementation Units, Verification Contract, and Definition of Done, is what `loaf change check` looks for. Durable Outputs stays forward-looking here — name what a final spec, ADR, or knowledge doc will need to capture, but don't write it now. Durable artifacts get created after implementation proves what's true, not during shaping. +Then shape the child the same way (body, out-of-scope, its own criteria). Order children by likelihood-of-change when presenting them; state real sequencing with `loaf issue link <from> blocks <to>`, never by tree order alone. -### Step 7: Run the Critique Gate +### Step 6: Run the Critique Gate -Before finalizing, challenge the draft — see [references/critique-gate.md](references/critique-gate.md). Is scope still bounded, does every new command or state name its ceremony, is a status field creeping back in under another name, is the CLI/skill boundary drawn correctly, and could this be smaller and still deliver the Hypothesis? +Before finalizing, challenge the draft — see [references/critique-gate.md](references/critique-gate.md). Is scope still bounded, does every new command or state name its ceremony, is a second progress flag creeping into the body, is the CLI/skill boundary drawn correctly, and could this be smaller and still be verifiable in one fresh context window? -### Step 8: Validate +### Step 7: Validate ```bash -loaf change check +loaf issue check LOAF-42 ``` -Read violations (always block — fix them) separately from the executability report (derived, informational unless `--require-executable` is passed — that flag is implement's preflight and CI's non-draft gate, not shape's business). See [references/cli-boundary.md](references/cli-boundary.md). +A delivery issue that passes prints `issue LOAF-42 is shaped`; a decision issue prints `issue LOAF-42 is ready`. Failures always block (missing body, missing criterion, missing out-of-scope, no sharp question, uncovered parent criterion). Containment orphans are reported, not failed: each line includes a ready-to-paste remedy that files the orphan as a sibling backlog issue — run that command, do not invent a different disposition. + +`loaf issue verify <ref>` runs V-tier commands from the repository root and writes nothing. That is implement's preflight, not shape's gate. See [references/cli-boundary.md](references/cli-boundary.md). -### Step 9: Offer the Draft PR +### Step 8: Offer the Review Surface -Offer to push the branch and open a draft PR, using [the PR template](templates/pr.md) — opt-in, never automatic. `loaf change check` (with no `--require-executable`) plus `gh pr list` is the cross-branch index either way. +The issue lives in SQLite. There is no folder to commit and nothing plan-shaped to land. Offer `loaf issue show <ref>` and `loaf issue tree <ref>` as the review surface. If a PR is being opened for the work, its body is `loaf issue render <ref>` — paste-ready, no manual editing. Opt-in, never automatic. -### Step 10: Log the Outcome +### Step 9: Log the Outcome -`loaf journal log "decision(shape): <slug> shaped — N units, N open fog entries"`. +`loaf journal log "decision(shape): LOAF-42 shaped — N children, N open fog entries"`. --- @@ -154,8 +196,8 @@ Offer to push the branch and open a draft PR, using [the PR template](templates/ - **pitch** — Problem-discovery ceremony that authors a brief; preferred front door when the problem is not yet framed - **idea** — Quick capture; feeds into pitch or shape once a concept has enough weight -- **brainstorm** — Agent technique for divergent thinking (route user entry intent to pitch) -- **implement** — Starts execution once a Change is structurally executable; this does not prove implementation completion +- **brainstorm** — Agent technique for divergent thinking (route user entry to pitch) +- **implement** — Starts execution once `loaf issue check` reports the issue shaped; this does not prove implementation completion - **reflect** — Updates strategic docs after the shipped work proves what changed ## Topics @@ -165,10 +207,10 @@ Offer to push the branch and open a draft PR, using [the PR template](templates/ | Blindspot pass | [references/blindspot-pass.md](references/blindspot-pass.md) | Deciding whether to offer reconnaissance, and how to prompt it | | Grilling | [references/grilling.md](references/grilling.md) | Running the one-question-at-a-time interview for `[KU]` entries | | Reaction artifacts | [references/reaction-artifact.md](references/reaction-artifact.md) | Resolving `[UK]` entries with a variant, mock, or prototype | -| Decomposition | [references/decomposition.md](references/decomposition.md) | Sizing and ordering Implementation Units | -| CLI boundary | [references/cli-boundary.md](references/cli-boundary.md) | Reading `loaf change init`/`check`/`verify` output, or explaining `--require-executable` | +| Decomposition | [references/decomposition.md](references/decomposition.md) | Sizing slices, promoting criteria, reading coverage and containment | +| CLI boundary | [references/cli-boundary.md](references/cli-boundary.md) | Reading `loaf issue` output, authoring `--command`/`--expect`, or explaining `loaf issue check` | | Critique Gate | [references/critique-gate.md](references/critique-gate.md) | Self-challenging scope and boundaries before finalizing | ## Artifact Naming -Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or Change already records that provenance. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. +Shape's deliverable is the issue row. If a reaction artifact or spike note lands on disk, name it for what it is, never for the issue that produced it. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. diff --git a/dist/amp/skills/shape/references/blindspot-pass.md b/dist/amp/skills/shape/references/blindspot-pass.md index 7bbd329a4..616f544d5 100644 --- a/dist/amp/skills/shape/references/blindspot-pass.md +++ b/dist/amp/skills/shape/references/blindspot-pass.md @@ -12,7 +12,7 @@ Ask the user whether to run it; skip when they're the domain expert. Do not auto ## Prompt Shape -Ask, against the specific territory named by the Change: +Ask, against the specific territory named by the issue: > What would I not know to ask here — codebase history, domain conventions, prior art, potholes? @@ -29,6 +29,8 @@ A `[UU]` that gets named through this pass becomes: and re-routes through the quadrant table in the main skill body. +Park what is still unsharp in the issue's `fog` field at create (`--fog`). After create, keep unsharp entries in the session register — edit cannot mutate `fog`. When an entry sharpens, graduate it to a `--kind decision` child or sibling. Do not drop a named unknown on the floor. + ## Stopping The pass ends when the shaper (or the user) can name the territory's remaining risks as entries, not vague unease. A pass that keeps surfacing "something might be wrong here" without a nameable entry has run past its useful length — stop and proceed with what's been named. diff --git a/dist/amp/skills/shape/references/cli-boundary.md b/dist/amp/skills/shape/references/cli-boundary.md index b7ade652b..6ed241493 100644 --- a/dist/amp/skills/shape/references/cli-boundary.md +++ b/dist/amp/skills/shape/references/cli-boundary.md @@ -1,56 +1,118 @@ # CLI Boundary -Reading `loaf change init` and `loaf change check` — the skill teaches reading the CLI, not wrapping it. For the rest of the Loaf CLI surface, see the `loaf-reference` skill. +Reading `loaf issue` — the skill teaches reading the CLI, not wrapping it. For the rest of the Loaf CLI surface, see the `loaf-reference` skill. Issue commands require initialized SQLite state (`loaf state init`, or `loaf state migrate markdown --apply`). + +## Contents +- `loaf issue new` +- `loaf issue show` / `list` / `tree` / `frontier` +- `loaf issue edit` / `status` +- `loaf issue dod` +- `loaf issue promote` +- `loaf issue check` +- `loaf issue verify` +- `loaf issue link` / `bucket` +- `loaf issue render` +- `loaf release suggest` / `cut` +- What shape does not run + +## `loaf issue new <title> [options]` + +```text +loaf issue new <title> [--body <text>|--body -|--body-file <path>|--message <text>] + [--kind delivery|decision] [--parent <ref>] [--fog <text>] [--status <status>] [--json] +``` + +Creates the issue row. Default kind is `delivery`; default status is `triage`. `--status` accepts the write statuses `triage`, `backlog`, `todo`, `active`, `done` (it still records the initial triage event). `--fog` parks questions not yet sharp enough to be issues; this flag exists only on create — `loaf issue edit` replaces the body and does not mutate `fog`. + +`--body -` reads stdin; `--body-file` reads a UTF-8 file; `--message` is inline body at lower precedence than `--body-file` and `--body -`. A hyphen-leading title is positional after `--`: + +```bash +loaf issue new --parent LOAF-42 --status backlog -- "--help is missing from the man page" +``` -## `loaf change init <slug> [--brief]` +A delivery body must state the problem and, before `loaf issue check` will pass, contain the substring `out of scope` (case-insensitive). A decision issue needs a sharp question (`?` in the title or body), not a body contract. -Scaffolds `docs/changes/<YYYYMMDD>-<slug>/` from the Change template, where `<YYYYMMDD>` is the creation day (not a target date) and the branch is named by the bare slug — no date prefix on the branch. Ordinary init writes `change.json + shape.md + tasks/`; `--brief` is capture mode (`change.json + brief.md` only). The slug uses lowercase letters, digits, and single hyphens. +## `loaf issue show` / `list` / `tree` / `frontier` -**Capture promotion.** Re-running ordinary `loaf change init <slug>` (no `--brief`) against a structurally valid capture-only folder completes it in place: `brief.md` and every `change.json` value are preserved verbatim, and missing `shape.md` plus the seeded `tasks/` are published atomically (temp-write then rename; existing destinations are never overwritten; `shape.md` is the last rename and the promotion marker). A partial promotion that already holds the byte-identical seed task resumes by filling only the gaps. Everything else fails clearly and leaves the folder untouched — repeated `--brief`, `change.json`-only (missing brief), hybrid `change.md` + `change.json`, diverged `tasks/` content, malformed metadata, and fully-materialized folders (duplicate rejection unchanged). +```text +loaf issue show <ref> [--json] +loaf issue list [--status <status>] [--kind delivery|decision] [--archived] [--started] [--json] +loaf issue tree [<ref>] [--archived] [--json] +loaf issue frontier [--json] +``` -## `loaf change check [folder] [--require-executable] [--json]` +`show` prints identity, parent, fog, body, definition of done, and children. `list` hides archived issues unless `--archived`. `--status` filters by `triage`, `backlog`, `todo`, `active`, `done`, `cancelled`, `duplicate`. `tree` prints from a ref, or the whole project when omitted. `frontier` lists non-archived `triage`/`backlog`/`todo` issues that are not blocked — derived at read time, useful when checking whether this work is already covered. -Folder resolution: an explicit `[folder]` argument always wins; otherwise the current git branch is matched against the `branch:` frontmatter across every `docs/changes/*/change.json + shape.md`. Zero or multiple matches is an error naming the candidates found. +Prefer `--json` when diagnosing rather than scraping the human-readable text. -Output splits into two tiers: +## `loaf issue edit` / `status` -- **Violations** — always fail (exit code 2), regardless of flags: status-like frontmatter keys (`readiness`, `status`, `state`) or values matching the canonical change-state vocabulary; frontmatter not opening the file at byte one; malformed `YYYYMMDD-slug` folder naming; identity mismatch between `change:`/`created:` and the folder name; missing Product Contract sections (Problem, Hypothesis, Scope, Observable Workflow, Rabbit Holes and No-Gos). -- **Derived executability** — reported, never gating by default. A Change is executable when Planning Contract, Implementation Units, Verification Contract, and Definition of Done are all present and non-empty (bracket placeholders and HTML comments don't count as content). A Change with open gaps is incomplete and non-executable; the report just says what's still missing. +```text +loaf issue edit <ref> [--body-file <path>|--body -|--message <text>] [--json] +loaf issue status <ref> <status> [--duplicate-of <ref>] [--json] +``` -`--require-executable` turns structural executability into a gate (exit code 1 if not structurally executable); it does not prove implementation completion. This is implement's preflight and CI's non-draft-PR check, not something shape itself passes during shaping. +`edit` **replaces** the body. Rewrite the full problem-plus-out-of-scope text; there is no patch form. `status` write-statuses (`triage`, `backlog`, `todo`, `active`, `done`) update in place; `cancelled` and `duplicate` archive through the remove path (`--duplicate-of` is required when status is `duplicate`). Shape leaves status at `triage` unless the user asks otherwise — shaped is derived, not a status. -A branch/Change mismatch (current branch doesn't match the Change's `branch:` field) is a warning, never a violation. +## `loaf issue dod` -`--json` emits `{command, folder, passed, state, executable, exitCode, findings, warnings, gaps}` (plus optional `layout`, `captured`, `notices`) for scripted reads; prefer it when diagnosing rather than scraping the human-readable text. The landing guard reads `state` from this envelope — e.g. `"captured"` for a brief-only folder and `"shaped"` (or higher) once `shape.md` exists — and must not invent a second state surface. +```text +loaf issue dod add <ref> <text> [--command <cmd>] [--expect <expect>] [--tier V|H] [--serves <parent-position>] [--json] +loaf issue dod list <ref> [--json] +loaf issue dod remove <ref> <position> [--json] +loaf issue dod claim <child> <child-position> <parent-position> [--json] +loaf issue dod unclaim <child> <child-position> <parent-position> [--json] +``` +V-tier is used when `--command` is present, otherwise H, unless `--tier` overrides. `--serves` records that the new child criterion claims that parent position. Positions are 1-based and compact after `remove`. Authoring guidance and the expect grammar live in the Decomposition topic. -## `loaf change report new <slug> --kind <kind>` +## `loaf issue promote <ref> <position> [--json]` -Stamps `reports/YYYYMMDD-HHMMSS-<kind>-<slug>.html` with charset, provenance, and token skeleton; prints design-language guidance. Closed kinds: approval, review, visual, audit, note. +Promotes the criterion at the 1-based position into a child **delivery** issue. The parent criterion stays in place. The child is minted in `triage` with a copy of the criterion and a claim already recorded, so coverage for that parent position holds by construction. -## `loaf change verify [folder]` +## `loaf issue check <ref> [--json] [--human <reason>]` -Runs executable V-tier criteria declared in `shape.md` and writes `receipts/verify.json` (criteria digest, verified commit, cwd, per-criterion evidence). New-layout-only. +Derives readiness from the issue row, not from markdown headings. -Criteria forms (both parse): +- **Delivery** — shaped when the body is nonempty (the problem), at least one criterion exists, and the body contains an explicit out-of-scope statement. Prints `issue <ref> is shaped` when ready. +- **Decision** — ready when the title or body contains `?`. Prints `issue <ref> is ready`. +- **Children present** — coverage is a failure (every parent criterion must be claimed). Containment is a report (every child criterion must claim a parent criterion); each orphan prints a ready-to-paste `loaf issue new --parent … --status backlog -- …` remedy. -```markdown -- **V1.** Prose. Command: `exact command`. Expect: exit 0. -- **V1.** Prose. - - Command: `exact command` - - Expect: exit 0 -``` +`--human <reason>` publishes ready-for-human instead of ready-for-agent when a tracker authority is configured. Shape's own gate is the derived verdict, not the publication. + +`--json` emits `{issue, kind, shaped, covered, ready, failures, orphans, …}`. Exit code 1 when not ready. -`Expect` is enforced, with a deliberately minimal grammar: atoms join with ` and ` — `exit <N>` is the required exit code (omit the atom, or `Expect` entirely, and `exit 0` is enforced) and `` contains `text` `` requires the command's combined stdout+stderr to contain that backtick-delimited text (repeatable). A criterion passes when the command ran, the exit code matched, and every `contains` matched; the receipt records each atom and its outcome. +## `loaf issue verify <ref> [--json]` -```markdown -- **V1.** Prose. Command: `go test ./...`. Expect: exit 0 and contains `ok github.com/acme/pkg`. +Runs the issue's V-tier criteria (`--command` plus `--expect`) from the repository root. Honors `exit <N>` and `` contains `text` ``. Writes nothing; exits non-zero on any failure. H-tier rows are skipped. This is implement's preflight, not shape's gate. + +A criterion passes when the command ran, the exit code matched, and every `contains` matched. Unenforceable expect clauses are warned and recorded as advisory — never quietly decorative. + +## `loaf issue link` / `bucket` + +```text +loaf issue link <from> blocks|relates-to <to> [--json] +loaf issue link <from> remove <type> <to> [--json] +loaf issue bucket <ref> now|next|later|none [--json] ``` -Any other clause is unenforceable: verify prints a warning naming the criterion and the clause, records it on the criterion as advisory, and never lets it affect the result — an expectation is either checked or loudly not. +Stored relationship types are `blocks` and `relates_to`. Use `blocks` for a real sequencing constraint; do not encode order in `loaf issue tree`. Buckets are labels only and are never read as a constraint. + +## `loaf issue render <ref> [--json]` + +Emits markdown suitable to paste as a PR body with no manual editing: title, body, definition-of-done checkboxes (checked only when status is `done`), and children. Nothing plan-shaped is committed; if a PR is opened, this output *is* the body. + +## `loaf release suggest` / `cut` + +Releases are retroactive. Shape does not bind an issue to a version. + +```text +loaf release suggest [--base <ref>] [--json] +loaf release cut [--base <ref>] [--bump <type>] [--includes <version|tag>] [--no-tag] [--no-gh] [--dry-run] +``` -Commands run from the repository root; the receipt records that cwd. H-tier entries (`**H1.** …`) are never gate input. See [decomposition.md](decomposition.md) for authoring guidance. +`suggest` reports landed work since the last version tag and writes nothing. `cut` records a release from landed work. Neither is a shaping step. -## `loaf change tasks` / `show` +## What shape does not run -On-demand projections. See `loaf change --help`. +`loaf issue start` / `stop` create and remove the issue worktree — implement's job, after the issue is shaped. `loaf issue export` dumps the project snapshot. Do not call them from this skill. diff --git a/dist/amp/skills/shape/references/critique-gate.md b/dist/amp/skills/shape/references/critique-gate.md index 4e131243c..f01109327 100644 --- a/dist/amp/skills/shape/references/critique-gate.md +++ b/dist/amp/skills/shape/references/critique-gate.md @@ -1,14 +1,15 @@ # Critique Gate -The last shaping step, before `loaf change check` and the PR offer. An agent won't know to interrogate its own scope, boundary placement, or smuggled-in status words unless something makes it stop and ask. Instantiated from the shape-first pilot's own Critique Gate, generalized for any Change rather than that pilot's specific CLI-surface question. +The last shaping step, before `loaf issue check` and any review offer. An agent won't know to interrogate its own scope, boundary placement, or smuggled-in progress words unless something makes it stop and ask. Run through these before finalizing: -- **Is scope still bounded?** Has the draft crept beyond what the Problem and Hypothesis justify? Could this Change be smaller and still deliver the Hypothesis? +- **Is scope still bounded?** Has the draft crept beyond what the problem statement justifies? Could this issue be smaller and still be verifiable in one fresh context window? - **Does every new command, state, or lifecycle verb name its ceremony?** If a command or state can't name the ceremony that exercises it, cut it — don't build it now and hope a use appears. -- **Is a status field creeping back in under another name?** `readiness`, `phase`, `stage`, or anything else that reintroduces a declared progress flag `loaf change check` doesn't already ban by pattern. +- **Is a second progress flag creeping into the body?** `readiness`, `phase`, `stage`, or anything else that reintroduces a declared progress flag. Status lives on the issue row (`loaf issue status`). Shaped, covered, and ready are derived by `loaf issue check`. `loaf issue bucket` is a label only and is never read as a constraint. - **Is the CLI/skill boundary drawn correctly?** Is the skill doing deterministic work that belongs in the CLI, or is the CLI claiming judgment that belongs in the skill? -- **Which Verification Contract criteria are genuinely executable gates, and which are human review dressed up as automatable?** A criterion that can't disagree with the implementation isn't a gate. -- **Are the Rabbit Holes and No-Gos sections doing real work?** Or are they restating the Scope's Out list in different words? +- **Which criteria are genuinely executable gates, and which are human review dressed up as automatable?** A V-tier criterion needs `--command` (and `--expect` when exit 0 is not enough). A criterion that can't disagree with the implementation isn't a gate — make it H, or rewrite it against an independent source of truth. +- **Is the out-of-scope statement doing real work?** Rabbit holes and no-gos belong there as named exclusions. Restating the problem in different words is not an out-of-scope statement. +- **Did fog graduate, or evaporate?** Every named unknown is parked in create-time `fog`, held in the session register, sitting as a decision child (or sibling) with a sharp question, filed as a new backlog issue (a different problem), or written into the body as a decided answer. Silent deletion is the failure. -Answers that change the document go back into it — the Decisions log, the Planning Contract, or the relevant Product Contract section — before moving to `loaf change check`. An answer spoken but not written doesn't count. +Answers that change the issue go back into it — `loaf issue edit` for the body, `loaf issue dod add` / `remove` for criteria, `loaf issue promote` or `loaf issue new` for children — before moving to `loaf issue check`. An answer spoken but not written doesn't count. diff --git a/dist/amp/skills/shape/references/decomposition.md b/dist/amp/skills/shape/references/decomposition.md index 7deef4b13..b6839482b 100644 --- a/dist/amp/skills/shape/references/decomposition.md +++ b/dist/amp/skills/shape/references/decomposition.md @@ -1,53 +1,119 @@ # Decomposition -Shaping step absorbed from the retired breakdown skill: dependency awareness, granularity judgment, and acceptance-criteria thinking, now expressed as the Change's Implementation Units and Verification Contract instead of a separate task-minting pass. +Shaping's tail, not a separate ceremony: dependency awareness, granularity judgment, and acceptance-criteria thinking, expressed as definition-of-done criteria and — only when a criterion earns its own DoD — child issues created by `loaf issue promote`. -## What Survives +## Contents +- When to split +- The sizing rule +- Expand–contract +- Promote, don't mint +- Coverage and containment +- Authoring criteria +- Order by likelihood-of-change +- Own the decisions -- **The Right Size Test** — before finalizing a unit boundary, check: Can a single implementer complete this? If no, split by concern. Does it touch multiple unrelated concerns? If yes, split by concern. Will the agent need too much context? If yes, split into smaller coherent units. Are you splitting just to have more units? If yes, merge back. -- **Right-sizing rules** — one agent type per unit (completable by a single implementer), one concern per unit (one layer, service, or component), context-appropriate (fits in model context with room for exploration), not over-fragmented (don't split what naturally belongs together). -- **Per-unit verification discipline** — every unit includes its own observable done condition. Never a separate "verify" unit; keep tests with the code they test. -- **Own the decisions** — decide granularity and unit boundaries autonomously. Ask the user only when two orderings are genuinely equally valid with different trade-offs; otherwise decide and move on. +## When to split -## What Dies +A parent gets children only when its DoD needs more than one coherent slice. One criterion that is already right-sized stays on the parent. A checkbox becomes a sub-issue the moment it earns its own DoD — its own problem statement, its own out-of-scope, its own criteria. -Task-file minting, ID allocation, estimate fields, and ordering-for-execution as the default presentation. Implementation Units are in-document work packets — commit-boundary guides and review anchors — never tracked entities with IDs, statuses, or a persistence layer of their own. +Same problem, another slice → another criterion on this issue, or a promoted child if that slice now has its own DoD. A different problem discovered mid-shaping → a new backlog issue (`loaf issue new --status backlog`), not a child of this one. -## The New Principle: Order by Likelihood-of-Change +## The sizing rule -Breakdown ordered units for execution (dependency graph, then priority). Shaping orders units for review: data models, interfaces, and user-facing flows lead; mechanical refactors and boilerplate collapse at the bottom. The reviewer's attention at the draft-to-ready flip should land on what's most likely to need changing, not on whatever happens to run first. +One test, replacing the old four-question checklist: **a slice is right-sized when it fits one fresh context window and is verifiable alone.** -Sequencing constraints that genuinely exist — this unit must land before that one — are stated in prose within the unit description or a Planning Contract subsection. Never rely on list order alone to imply a dependency; a reviewer skimming units by likelihood-of-change won't read sequencing into position. +- If an implementer cannot pick the issue up in a new conversation and finish it without reading a sibling, split. +- If the done-check cannot run (or be reviewed) without another slice landing first, either split and `loaf issue link <predecessor> blocks <successor>`, or merge — do not leave a criterion that is only true in combination. +- If you are splitting just to have more rows, merge back. -## Authoring the Verification Contract +Per-slice verification stays with the slice. Never a separate "verify" child; keep tests with the code they test. -Split criteria into two groups, mirroring the Change template: +## Expand–contract -- **Executable (V-tier)** — bound to a command and an expected result; machine-checkable by `loaf change verify`. Two equivalent forms: +The named exception for wide mechanical refactors (a rename, a schema migration, an expand-then-remove of an old path). The slice may be wide in files touched and still be one issue, because it is one coherent mechanical motion and one verification: the suite still passes after the motion. - Inline (what the scaffold writes): +Do not use this exception to smuggle a second problem into the parent. A mechanical rename plus a behavior change is two slices. - ```markdown - - **V1.** What must be true. Command: `go test ./...`. Expect: exit 0. - - **V2.** Output-bound. Command: `loaf change check`. Expect: exit 0 and contains `executable`. - ``` +## Promote, don't mint - Or with an authoring checkbox still open: +```bash +loaf issue promote <ref> <position> +``` - ```markdown - - [**V1.** What must be true. Command: `go test ./...`. Expect: exit 0.] - ``` +The criterion at the 1-based position stays on the parent. A child **delivery** issue is created in `triage`, titled from the criterion text, with a copy of that criterion as its first DoD line and a claim already recorded from the copy to the parent. Coverage for that parent position holds by construction. + +Then shape the child: give it a problem body and an out-of-scope statement (`loaf issue edit` replaces the body), add the criteria that make *its* DoD complete, promote again if one of those earns its own DoD. + +`loaf issue promote` always mints a delivery child. Decision children are created separately: + +```bash +loaf issue new --kind decision --parent <ref> "Should the store be append-only?" +``` + +A decision child is ready when the title or body contains `?`. It does not claim a parent criterion. + +Once **any** child exists — delivery or decision — `loaf issue check` requires every parent criterion to be claimed. A leaf parent that grows a decision child without promoted (or otherwise claimed) criteria will fail coverage. Sequence the tail as: write DoD → promote every slice that will not execute on the parent → then add decision children. If the parent stays a leaf, leave remaining unsharp questions in create-time `fog` (there is no `--fog` on edit) or file sharpened ones as sibling decision issues (`loaf issue new --kind decision --status backlog`, no `--parent`). - Sub-bullet: +Manual claims, when a child criterion was added rather than promoted: - ```markdown - - **V1.** What must be true. - - Command: `go test ./...` - - Expect: exit 0 +```bash +loaf issue dod add <child> "Child done-check" --serves 1 +loaf issue dod claim <child> <child-position> <parent-position> +loaf issue dod unclaim <child> <child-position> <parent-position> +``` + +`--serves` claims the newly added child criterion against that parent position. `claim` / `unclaim` retarget an existing pair. + +## Coverage and containment + +`loaf issue check <ref>` runs these only when the issue has children. + +- **Coverage** (failure) — every parent criterion must be claimed by at least one child criterion. An uncovered position is named in the failure list; fix it by promoting that criterion or adding a claiming child criterion. +- **Containment** (report, not a failure) — every child criterion must claim a parent criterion. An orphan is printed with a ready-to-paste remedy that files it as a **sibling** backlog issue: + + ```bash + loaf issue new --parent '<parent>' --status backlog -- '<orphan text>' ``` - `Expect` is optional and enforced when present, with a deliberately minimal grammar: atoms join with ` and ` — `exit <N>` is the required exit code (an absent `Expect`, or an `Expect` with no exit atom, means `exit 0`) and `` contains `text` `` requires the combined stdout+stderr to contain that backtick-delimited text (repeatable). Any other clause is unenforceable: `loaf change verify` warns naming the criterion and the clause and records it as advisory, so an expectation is either checked or loudly not — never quietly decorative. Commands run from the **repository root** (never the change folder). Only V-entries that declare a fenced `Command:` value are gate input. + Run the printed remedy. Do not fold the orphan back onto the parent, and do not treat it as in-scope work that somehow escaped the DoD — it is a new backlog row under the same parent. + +A different problem (not an orphan criterion) is not a sibling of this decomposition. File it as a new backlog issue with no `--parent`. -- **Human review (H-tier)** — what a reviewer confirms that no command can. H-entries are review material and are **never** gate input; `loaf change verify` ignores them. +## Authoring criteria + +```bash +loaf issue dod add <ref> <text> [--command <cmd>] [--expect <expect>] [--tier V|H] [--serves <parent-position>] +``` + +- **V-tier** — used when `--command` is present, unless `--tier` overrides. Machine-checkable by `loaf issue verify <ref>`. Commands run from the **repository root**. +- **H-tier** — default when `--command` is absent. Human review; never gate input. `loaf issue verify` skips H-tier rows. + +`--expect` is optional and enforced when present, with a deliberately minimal grammar: atoms join with ` and ` — `exit <N>` is the required exit code (an absent `--expect`, or one with no exit atom, means `exit 0`) and `` contains `text` `` requires the combined stdout+stderr to contain that backtick-delimited text (repeatable). Any other clause is unenforceable: verify warns naming the criterion and the clause and records it as advisory, so an expectation is either checked or loudly not — never quietly decorative. + +```bash +loaf issue dod add LOAF-42 "Package tests pass" --command "go test ./..." --expect "exit 0" +loaf issue dod add LOAF-42 "Check names the uncovered criterion" --command "loaf issue check LOAF-42" --expect "exit 0 and contains \`uncovered\`" +loaf issue dod add LOAF-42 "The writeup is readable by someone new to the area" --tier H +``` A criterion whose check only restates the implementation (recomputing the expected value the way the code does) is vacuous — it can never disagree with the code under test. Prefer criteria with an independent source of truth. + +`loaf issue dod list <ref>` prints the current lines. `loaf issue dod remove <ref> <position>` removes one (positions then compact). + +## Order by likelihood-of-change + +Present children for review, not for execution order: data models, interfaces, and user-facing flows lead; mechanical refactors and boilerplate collapse at the bottom. The reviewer's attention should land on what's most likely to need changing, not on whatever happens to run first. + +Sequencing constraints that genuinely exist — this child must land before that one — are recorded as relationships: + +```bash +loaf issue link <from> blocks <to> +loaf issue link <from> relates-to <to> +loaf issue link <from> remove blocks <to> +``` + +Never rely on `loaf issue tree` order to imply a dependency; a reviewer skimming by likelihood-of-change won't read sequencing into position. `loaf issue bucket <ref> now|next|later|none` is an advisory label only and is never read as a constraint. + +## Own the decisions + +Decide granularity and slice boundaries autonomously. Ask the user only when two orderings are genuinely equally valid with different trade-offs; otherwise decide and move on. diff --git a/dist/amp/skills/shape/references/grilling.md b/dist/amp/skills/shape/references/grilling.md index cd4d6a575..4cf6437dd 100644 --- a/dist/amp/skills/shape/references/grilling.md +++ b/dist/amp/skills/shape/references/grilling.md @@ -1,8 +1,8 @@ # Grilling -The interview technique for `[KU]` entries — known unknowns precise enough to phrase as a question. Imported from the reviewed `grill-me` / `grill-with-docs` pattern (`docs/changes/20260704-shape-first-change-workflow/research/mattpocock-review/`), sharpened with the Field Guide's architectural-impact ordering. +The interview technique for `[KU]` entries — known unknowns precise enough to phrase as a question. Imported from the reviewed `grill-me` / `grill-with-docs` pattern, sharpened with architectural-impact ordering. -This is a distinct, shape-specific technique — not the shared `templates/grilling.md` used by `architecture` and `refactor-deepen`. That template's glossary-mutation duty doesn't fit shape's ambiguity-resolving step (see the deferral rationale it documents); shape's grilling stays scoped to fog entries and never writes to the domain glossary. +This is a distinct, shape-specific technique — not the shared `templates/grilling.md` used by `architecture` and `refactor-deepen`. That template's glossary-mutation duty doesn't fit shape's ambiguity-resolving step (see the rationale it documents); shape's grilling stays scoped to fog entries and never writes to the domain glossary. ## The Mechanic @@ -14,17 +14,21 @@ Every question carries a recommended answer with rationale — never "what do yo Prioritize questions whose answer would change the architecture. Cosmetic questions — naming, ordering, presentation — go last, even when they're easier to answer. An architecture-changing answer received late can invalidate everything decided in between; asking it first avoids that rework. -Before asking, check whether reading resolves the question — an existing ADR, a prior Change, a journal entry. Only ask what reading couldn't answer. +Before asking, check whether reading resolves the question — an existing ADR, a prior issue, a journal entry. Only ask what reading couldn't answer. ## Stop Condition Stop when either holds: - No unrouted `[KU]` entries remain. -- Answers stop changing the contract — the last several questions confirmed direction rather than altering it. +- Answers stop changing the issue — the last several questions confirmed direction rather than altering the body, the criteria, or the children. -Grilling never opens the session. Context gathering and the blindspot-pass offer come first; an interview without that groundwork asks questions the codebase or journal could have answered for free. +Write each accepted answer into the issue as it lands: `loaf issue edit` for the body, `loaf issue dod add` for a new done-check, `loaf issue new --kind decision --parent <ref>` when the answer is itself a sharp question that still needs a later call. Do not leave a resolved `[KU]` only in the conversation. ## Mid-Interview Reroute If a question turns out to need domain fluency the shaper doesn't have — the follow-up can't even be phrased — stop grilling it and route the entry to the blindspot pass instead of guessing at an answer. + +## Opening + +Grilling never opens the session. Context gathering and the blindspot-pass offer come first; an interview without that groundwork asks questions the codebase or journal could have answered for free. diff --git a/dist/amp/skills/shape/references/reaction-artifact.md b/dist/amp/skills/shape/references/reaction-artifact.md index d3254bed0..ca30f7485 100644 --- a/dist/amp/skills/shape/references/reaction-artifact.md +++ b/dist/amp/skills/shape/references/reaction-artifact.md @@ -8,11 +8,11 @@ Before building anything, state precisely what this artifact must let the user d ## Build the Smallest Thing That Lets the User React -Construct the minimum artifact that resolves the named unknown — competing layouts, a comparison of approaches, a rendered mock. Write it into the Change's own `research/` folder, never `.agents/reports/`; this is shape's own technique, executed in-session, not a handoff to the research skill. +Construct the minimum artifact that resolves the named unknown — competing layouts, a comparison of approaches, a rendered mock. Keep it in the session or in a throwaway file you will discard. This is shape's own technique, executed in-session, not a handoff to the research skill and not a committed plan document. Do not write it into `.agents/reports/`. ## Capture the Answer, Discard the Shell -Once the user reacts and picks, write the decision — with rationale — back into the Change: the Decisions log, or the relevant Planning Contract subsection. The artifact itself is not the deliverable; the choice it produced is. Discard or archive the shell once the answer is captured. If a piece of logic proved out along the way is worth keeping (a validated approach, not just a throwaway shell), note that explicitly — but don't let scaffolding survive under the pretense that it's production code. +Once the user reacts and picks, write the decision — with rationale — back onto the issue: the body via `loaf issue edit`, or a `--kind decision` child when the pick is itself a sharp question that still needs a later call. The artifact itself is not the deliverable; the choice it produced is. Discard the shell once the answer is captured. If a piece of logic proved out along the way is worth keeping (a validated approach, not just a throwaway shell), note that explicitly in the issue body — but don't let scaffolding survive under the pretense that it's production code. ## Never a Deliverable diff --git a/dist/amp/skills/shape/templates/brief.md b/dist/amp/skills/shape/templates/brief.md deleted file mode 100644 index 05016c8c8..000000000 --- a/dist/amp/skills/shape/templates/brief.md +++ /dev/null @@ -1,42 +0,0 @@ -<!-- brief.md is the optional archeological kickstart — the original unshaped ask. - May accrete parked problem-space concepts while the change is captured; freezes when shape.md exists. - Superseded by shape.md; never mechanically load-bearing. - A brief-only folder is legal and non-executable (captured, not shaped). --> - -# [Brief title] - -## Problem Statement - -[What friction, gap, or unmet need exists? Be specific about the pain — vague problems produce vague solutions. Problem-space only; do not design the approach here.] - -## Who Has It - -[Who experiences this problem? Role, context, and how often the pain shows up. Avoid unqualified "users" or "developers."] - -## Current Alternatives - -[What do they do today? Existing tools, manual workarounds, or "nothing" are all valid. Understanding the status quo clarifies what better means.] - -## Value Proposition - -[Why is solving this worth it? What becomes true for the people who have the problem if this lands? Describe value, not features or architecture.] - -## Constraints - -[Non-negotiable bounds: technical, legal, organizational, or philosophical. Things that limit the solution space before design begins.] - -- [Constraint 1] - -## Sequencing and Relationships - -[How this relates to other work — series order, release cohort, dependencies stated as prose. No machine relation fields; narrative order only.] - -## Sources and Research Links - -[Evidence that informed this framing — competitive scans, research notes, issue links, prior art. Link rather than paste.] - -## Open Questions - -[Unresolved problem-space items. Mark urgency: blocking (must resolve before shaping) or deferrable.] - -- [ ] [Question] — blocking | deferrable diff --git a/dist/amp/skills/shape/templates/change.md b/dist/amp/skills/shape/templates/change.md deleted file mode 100644 index 6ce5b9725..000000000 --- a/dist/amp/skills/shape/templates/change.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -change: [slug] -created: [YYYY-MM-DD] -branch: [slug] ---- - -<!-- Frontmatter must open the file at byte one — parsers depend on it. No status-like frontmatter (readiness/status/state): readiness is derived — a draft PR is shaping; `loaf change check` derives structural executability from the sections below. --> - -# [Change Title] - -## Problem - -[Why this work exists — the friction, gap, or rot being addressed.] - -## Hypothesis - -[The bet: what becomes true if this ships, and why it is worth making.] - -## Scope - -**In** - -- [What this Change delivers.] - -**Out** (deferred, not rejected) - -- [What is explicitly postponed, and where it went.] - -**Cut** (explicitly rejected) - -- [What this Change will not do, ever.] - -## Observable Workflow - -[What someone sees or does once this ships — commands, flows, UX. Concrete over abstract.] - -## Rabbit Holes and No-Gos - -[Boundaries: the ways this work could quietly grow into something it must not become.] - -## Decisions - -Provenance: [how each decision was accepted — interview, review, dogfooding.] - -1. **[Decision.]** [Rationale, and what it forecloses.] - -## Planning Contract - -[The HOW. Free-form `###` subsections named by the work — the container is the contract; the subsection names are yours.] - -### [Approach / Placement / Risks / Sequencing / Spike findings …] - -[...] - -## Implementation Units - -<!-- In-document work packets — commit-boundary guides and review anchors, not tracked entities. --> - -- [**U1 — Unit name.** What it delivers.] - -## Verification Contract - -<!-- Executable (machine-checkable): --> - -- [**V1.** Criterion bound to a command and an expected result.] - -<!-- Human review: --> - -- [**H1.** What a reviewer confirms that no command can.] - -## Definition of Done - -- [Derived from gates and review — never a status flag.] - -## Durable Outputs - -[Specs, ADRs, knowledge docs, or schema updates to create after implementation proves what is now true. A final spec describes reality, not a plan.] - -## Open Questions - -<!-- Fog register: tag entries [KU]/[UK]/[UU] with a route — see the shape skill's quadrant table. Tags are convention, never parsed by check. --> - -- [Known unknowns, each owned by a section, a spike, or a follow-up.] - -## Source Inputs - -- [Where this Change came from: journal entries (cite by ID), sparks, ideas, brainstorms, issues, conversations, prior Changes.] - -<!-- Optional sections, added when they earn their place: Background, Success Metrics (when validation matters), Follow-ups, Critique Gate. --> diff --git a/dist/amp/skills/shape/templates/design.md b/dist/amp/skills/shape/templates/design.md deleted file mode 100644 index 2244ccdb6..000000000 --- a/dist/amp/skills/shape/templates/design.md +++ /dev/null @@ -1,20 +0,0 @@ -<!-- design.md is the optional design surface for UI, protocol, or schema detail - that would crowd shape.md or plan.md. Accretive; not load-bearing for the gate. --> - -# Design — [Change Title] - -## Intent - -[What this design clarifies that shape.md does not.] - -## Surfaces - -[Screens, APIs, schemas, or protocols under design.] - -## Decisions - -1. **[Decision.]** [Rationale.] - -## Open questions - -- [What remains undecided.] diff --git a/dist/amp/skills/shape/templates/plan.md b/dist/amp/skills/shape/templates/plan.md deleted file mode 100644 index 6d17a7179..000000000 --- a/dist/amp/skills/shape/templates/plan.md +++ /dev/null @@ -1,21 +0,0 @@ -<!-- plan.md is the optional technical route (the corpus's plan sense). - Accretive during shaping; approach churn here never expires a cohort receipt. - Criteria live in shape.md — do not relocate Verification Contract here. --> - -# Plan — [Change Title] - -## Approach - -[How the work lands — architecture, sequencing, compatibility.] - -## Placement - -[Where code and docs live; what stays out of scope for this route.] - -## Risks - -[Failure modes and the safe failure direction.] - -## Sequencing - -[Ordered slices that leave main coherent at each landing.] diff --git a/dist/amp/skills/shape/templates/pr.md b/dist/amp/skills/shape/templates/pr.md deleted file mode 100644 index 91b4b7401..000000000 --- a/dist/amp/skills/shape/templates/pr.md +++ /dev/null @@ -1,25 +0,0 @@ -<!-- Draft = still shaping. Ready for review = structurally executable, not proof of implementation completion. --> - -## Change - -<!-- Link the Change folder this PR advances: docs/changes/YYYYMMDD-slug/ The Change artifact lives in this PR's diff — the draft PR is its shaping surface. During coexistence, legacy PRs implementing a numbered spec link the .agents/specs/ path here instead. Delete this section for PRs tied to neither. --> - -## What & Why - -<!-- What this PR does and the problem it solves. For a shaping (draft) PR, summarize the direction; for an implementation PR, summarize the delta. --> - -## Review focus - -<!-- Where reviewer attention pays off: decisions to challenge, boundaries to verify, criteria to confirm. --> - -## Verification - -<!-- What proves this works: gates run (`loaf change check`, `loaf check`), tests, commands a reviewer should re-run locally. On a draft, state what remains open instead. --> - -## Migration / breaking changes - -<!-- If this PR changes user-facing behavior, document the migration step. Otherwise: "None." --> - -## Deferred - -<!-- Intentionally out of scope, and where it went (follow-up Change, open question, issue). Delete if nothing was deferred. --> diff --git a/dist/amp/skills/shape/templates/shape.md b/dist/amp/skills/shape/templates/shape.md deleted file mode 100644 index 59aa9b89a..000000000 --- a/dist/amp/skills/shape/templates/shape.md +++ /dev/null @@ -1,77 +0,0 @@ -<!-- shape.md is the change contract. Identity lives in change.json — no status-like frontmatter. Readiness is derived: a draft PR is shaping; `loaf change check` derives structural executability from the sections below. --> - -# [Change Title] - -## Problem - -[Why this work exists — the friction, gap, or rot being addressed.] - -## Hypothesis - -[The bet: what becomes true if this ships, and why it is worth making.] - -## Scope - -**In** - -- [What this Change delivers.] - -**Out** (deferred, not rejected) - -- [What is explicitly postponed, and where it went.] - -**Cut** (explicitly rejected) - -- [What this Change will not do, ever.] - -## Observable Workflow - -[What someone sees or does once this ships — commands, flows, UX. Concrete over abstract.] - -## Rabbit Holes and No-Gos - -[Boundaries: the ways this work could quietly grow into something it must not become.] - -## Decisions - -Provenance: [how each decision was accepted — interview, review, dogfooding.] - -1. **[Decision.]** [Rationale, and what it forecloses.] - -## Planning Contract - -<!-- The HOW. Prefer plan.md/design.md when the route needs its own file; keep this container. Free-form ### subsections named by the work. --> - -### [Approach / Placement / Risks / Sequencing …] - -[…] - -## Implementation Units - -<!-- Task packets live in tasks/TASK-NNN-slug.md; this section may summarize the decomposition. --> - -- [**TASK-001 — Unit name.** What it delivers.] - -## Verification Contract - -<!-- Executable (machine-checkable): each V-entry declares Command and Expect for loaf change verify. Expect is a grammar, not prose: atoms join with " and " — `exit <N>` is the required exit code (omit the atom, or Expect entirely, for exit 0; a second exit atom is a contradiction and fails the criterion) and contains `text` requires the combined stdout+stderr to contain that backtick-delimited text (repeatable). Example: Expect: exit 0 and contains `all green`. Any other clause is unenforceable: verify warns naming the criterion and clause, records it as advisory, and never checks it. --> - -- [**V1.** What must be true. Command: `exact command`. Expect: exit 0.] - -<!-- Human review (H-tier): review material, never gate input. --> - -- [**H1.** What a reviewer confirms that no command can.] - -## Definition of Done - -- [Derived from gates and review — never a status flag.] - -## Durable Outputs - -[Specs, ADRs, knowledge docs, or schema updates to create after implementation proves what is now true.] - -## Open Questions - -<!-- Fog register: tag entries [KU]/[UK]/[UU] with a route. Tags are convention, never parsed by check. --> - -- [KU] [Known unknown → route to a task or later change] diff --git a/dist/amp/skills/shape/templates/task.md b/dist/amp/skills/shape/templates/task.md deleted file mode 100644 index 05de15307..000000000 --- a/dist/amp/skills/shape/templates/task.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -change: [slug] -id: TASK-NNN -title: [short title] -# Relations (closed set; targets are TASK-NNN within this change only): -# parent: TASK-NNN -# blocks: -# - TASK-NNN -# blocked-by: -# - TASK-NNN -# relates-to: -# - TASK-NNN ---- - -# TASK-NNN — [Title] - -## Objective - -[What this task delivers when its checkboxes are flipped.] - -## Scope boundaries - -**In:** [What this task may touch.] - -**Out:** [What this task must not touch — other tasks, other changes, deferred work.] - -## Context pointers - -- Contract: `shape.md` — [relevant sections] -- Research: [paths under research/ when cited] - -## Acquisition - -```bash -loaf journal log "skill(implement): TASK-NNN — [short intent]" -# [commands or files to load before editing] -``` - -## Steps - -- [ ] [Atomic step — one commit's worth when flipped with the delivering work] -- [ ] [Next step] - -## Verification - -- [Commands or checks that prove this task alone] -- The slug never cites other work units (`TASK-…`, `SPEC-…`, issue keys) — identity is local; provenance is in frontmatter and the change folder. diff --git a/dist/amp/skills/ship/SKILL.md b/dist/amp/skills/ship/SKILL.md index 3b645f200..16c112267 100644 --- a/dist/amp/skills/ship/SKILL.md +++ b/dist/amp/skills/ship/SKILL.md @@ -1,17 +1,20 @@ --- name: ship description: >- - Reviews, verifies, and lands one pull request. Use when the user says "ship - it," "merge this PR," "ready to merge," "land this branch," or asks for a - final merge gate. Produces a reviewed, squash-merged PR and post-merge - cleanup. Not for version bumps, tags, GitHub Releases, or install verification - (use release). + Reviews, verifies, and lands one pull request — the sole quality gate before + work can appear in a later release cut. Use when the user says "ship it," + "merge this PR," "ready to merge," "land this branch," or asks for a final + merge gate. Binds the PR to an issue: the body is `loaf issue render` output, + definition-of-done criteria are the review checklist, and landing marks the + issue done and stops its worktree. Produces a reviewed, squash-merged PR and + post-merge cleanup. Not for version bumps, tags, GitHub Releases, or install + verification (use release). version: 0.2.21 --- # Ship -Review, verify, and land one PR. Shipping is the PR gate; releasing is the version-publication gate. +Review, verify, and land one PR. Ship's review is the quality gate for everything that will ever ship. Releases are retroactive — they cut a version from already-landed work. Nothing between merge and the next release cut re-checks the diff, the tests, or the issue. A rubber-stamped PR ships in the next cut with no second net. ## Contents - Critical Rules @@ -21,7 +24,7 @@ Review, verify, and land one PR. Shipping is the PR gate; releasing is the versi - Context Detection - Step 1: PR Readiness - Step 1b: Stacked PR Detection -- Step 2: Evidence Review +- Step 2: Definition-of-Done Review - Step 3: Local Verification - Step 4: Squash Merge - Step 5: Post-Merge Cleanup @@ -35,64 +38,94 @@ Review, verify, and land one PR. Shipping is the PR gate; releasing is the versi ## Critical Rules -- **Ship is not release** -- do not bump versions, create tags, publish GitHub Releases, or verify package installation here. -- **Keep PR quality local** -- smaller PRs are welcome, but ship must still verify correctness before merge. -- **Detect-first** -- auto-detect the PR from the current branch before asking for a PR number. -- **Review before merge** -- inspect code, docs, tests, changelog, PR body, and CI state before approval. -- **Never merge without explicit confirmation** -- present the PR, checks, findings, and squash body first. -- **Detect the stack before merging** -- another open PR may use this PR's head branch as its base. Find out before merge, never after, and never delete a head branch while a child PR still points at it. -- **Clean squash body** -- write an intentional squash commit body; never accept the automatic commit dump. -- **Keep landed and released distinct** -- after merge, describe the PR as landed or shipped, not necessarily released. -- **Log shipping** -- after merge, run `loaf journal log "decision(ship): PR #N landed via squash merge"`. +1. **Log invocation first** — `loaf journal log "skill(ship): shipping <ref or PR or current branch>"` before doing anything else. After merge, log `loaf journal log "decision(ship): PR #N landed via squash merge; <ref> done"`. +2. **Rigor is load-bearing** — this review is the only quality gate. Releases cut from landed work; they do not re-check. If the review is thin, the next cut still publishes it. +3. **Ship is not release** — do not bump versions, create tags, publish GitHub Releases, or verify package installation here. Use the release skill for that. +4. **Bind the PR to an issue** — the PR body is `loaf issue render <ref>` (paste-ready, no manual editing). The issue's definition-of-done criteria are the review checklist. `loaf issue verify <ref>` runs the executable (V-tier) rows and writes nothing. Landing means `loaf issue status <ref> done`. Then `loaf issue stop <ref>` removes the started worktree. +5. **Detect-first** — auto-detect the PR from the current branch, and the issue from `$ARGUMENTS` or the started workspace, before asking for a PR number or issue ref. +6. **Review before merge** — inspect code, docs, tests, changelog, the rendered issue body, definition of done, and CI state before approval. +7. **Never merge without explicit confirmation** — present the PR, checks, review notes, and squash body first, using your harness's structured question tool if it has one. +8. **Detect the stack before merging** — another open PR may use this PR's head branch as its base. Find out before merge, never after, and never delete a head branch while a child PR still points at it. +9. **Clean squash body** — write an intentional squash commit body; never accept the automatic commit dump. +10. **Keep landed and released distinct** — after merge, describe the PR as landed or shipped. It is not released until release publishes a version. + +--- ## Verification +- Invocation is logged to the project journal before review work begins - PR identity, base branch, and head branch are confirmed +- The PR is bound to one issue; `loaf issue show <ref>` is the issue surface +- PR body matches `loaf issue render <ref>` with no manual editing +- Every definition-of-done criterion was reviewed against the diff; H-tier by reading, V-tier by `loaf issue verify <ref>` (writes nothing; exit non-zero blocks) - CI status is passing or the user explicitly accepts named non-blocking checks - Relevant local checks pass or failures are fixed before merge - PR body and durable docs do not overclaim relative to the diff - Squash commit title/body are clean, conventional, and user-facing - Child PRs stacked on this PR's head branch are enumerated before merge, and each is retargeted, rebased, and re-verified after it -- Base branch is updated after merge and the feature branch cleanup state is known +- After merge: `loaf issue status <ref> done`, then `loaf issue stop <ref>` if a worktree was started, base branch updated, feature-branch cleanup state known +- A `decision(ship)` journal entry records the landing ## Quick Reference | Step | Gate | Blocking? | |------|------|-----------| +| Context Detection | PR and issue bound | Yes | | PR Readiness | PR exists, target base known, CI state reviewed | Yes | | Stacked PR Detection | child PRs on this head branch are enumerated | Yes | -| Evidence Review | findings resolved or explicitly accepted | Yes | -| Local Verification | relevant checks pass | Yes | +| Definition-of-Done Review | every criterion reviewed; `loaf issue verify` passes or reports no V-tier rows | Yes | +| Local Verification | relevant project checks pass | Yes | | Squash Merge | user approves body text | Yes | -| Cleanup | base pulled, children retargeted and rebased, branch deletion handled | Yes when a child exists | -| Release Suggestion | enough landed work may justify release | No | +| Cleanup | issue marked done, started worktree stopped, base pulled, children retargeted and rebased, branch deletion handled | Yes when a child PR exists; done + stop always | +| Release Suggestion | enough landed work may justify a later cut | No | ## Topics | Topic | Use When | |-------|----------| -| [Context Detection](#context-detection) | Determining current branch and PR state | +| [Context Detection](#context-detection) | Binding the current branch to a PR and an issue | +| [Definition-of-Done Review](#step-2-definition-of-done-review) | Using issue criteria as the merge checklist | | [Hook Interaction](#hook-interaction) | Understanding coexistence with git hooks | --- ## Context Detection -Before anything, detect the PR surface: +Log the invocation, then detect the PR and the issue. + +### PR 1. Get current branch and repo default branch: ```bash git branch --show-current gh repo view --json defaultBranchRef -q .defaultBranchRef.name ``` -2. Parse `$ARGUMENTS`: may be a PR number, PR URL, branch name, or empty. -3. If `$ARGUMENTS` is empty, auto-detect from the current branch: +2. Parse `$ARGUMENTS`: may be an issue ref (`LOAF-42`), a PR number, a PR URL, a branch name, or empty. +3. If `$ARGUMENTS` is empty or is not a PR identity, auto-detect from the current branch: ```bash gh pr view --json number,title,url,headRefName,baseRefName,state,mergeStateStatus,isDraft ``` 4. If no PR exists for the current branch, stop and offer to create one via `git-workflow` rather than silently merging a branch. 5. If already on the default branch, stop. There is no PR to ship from the current branch. -6. Confirm PR identity with the user before merge actions. + +### Issue + +Issue commands require initialized SQLite state. Bind exactly one issue: + +1. If `$ARGUMENTS` (or a remaining token) is an issue ref, load it: + ```bash + loaf issue show <ref> + ``` +2. Otherwise match the PR's `headRefName` to a started workspace: + ```bash + loaf issue list --started + ``` + Columns are alias, title, `started_branch`, `started_worktree`. The started branch from `loaf issue start` is `issue/<alias-or-id>` in lowercase (`issue/loaf-42`), disambiguated with an id suffix when that name is already claimed. +3. Confirm with `loaf issue show <ref>` — `started_branch` / `started_worktree` should match this PR when the issue was started. + +If nothing binds, stop and ask for the issue ref. Do not invent a row during ship. If `loaf issue show` reports the issue archived (`cancelled` or `duplicate`), stop. + +Confirm PR identity and the bound issue with the user before merge actions. --- @@ -104,6 +137,14 @@ Inspect the PR's declared state: gh pr view <N> --json number,title,body,url,headRefName,baseRefName,state,isDraft,mergeStateStatus,reviewDecision,statusCheckRollup ``` +The body must be `loaf issue render <ref>` output — title, issue body, definition-of-done checkboxes (checked only when status is already `done`), and children. No project headers, no hand-edited summary. If the live PR body differs, replace it: + +```bash +gh pr edit <N> --body "$(loaf issue render <ref>)" +``` + +Do not rewrite the markdown by hand. Checkboxes are unchecked until `loaf issue status <ref> done`; do not tick them in the PR body to fake completion. + Block or pause when: - PR is draft @@ -124,20 +165,27 @@ Before merging anything, find out whether another open PR uses this PR's head br gh pr list --state open --base <headRefName> --json number,title,headRefName ``` -Any result is a **child PR**, and this PR is the base of a stack. Record the list now, because after the merge the relationship is harder to see and easier to break. +Any result is a **child PR**, and this PR is the base of a stack. Record the list now, because after the merge the relationship is harder to see and easier to break. Child PRs are GitHub stacking, not child issues. -When a child exists: +When a child PR exists: - **Do not pass `--delete-branch` to the merge.** Removing the head branch while a child still points at it can close the child outright. Delete it only after every child has been retargeted, in Step 5. - Tell the user the stack exists and name the children before asking for merge confirmation. A stack changes what "merge this" means. -When no child exists, say so, and `--delete-branch` is safe. +When no child PR exists, say so, and `--delete-branch` is safe. --- -## Step 2: Evidence Review +## Step 2: Definition-of-Done Review + +The issue's definition of done is the merge checklist. Load it from the issue, not from memory: + +```bash +loaf issue dod list <ref> +loaf issue show <ref> +``` -Review the landing diff and durable prose together: +`show` prints each criterion as `position. [V|H] text` with `command=` / `expect=` when present. Walk every row against the landing diff. 1. Gather diff context: ```bash @@ -145,13 +193,19 @@ Review the landing diff and durable prose together: git diff --stat origin/<baseRefName>...HEAD git diff --name-only origin/<baseRefName>...HEAD ``` -2. Read the PR title/body and changed docs that make behavior claims. -3. Check for drift: - - PR body claims features that are not in the diff - - changelog entries mention unreleased or unrelated behavior +2. For each **H-tier** criterion, read the diff and durable prose and decide whether the text is met. `loaf issue verify` skips H-tier rows — that skip is not a pass; you are the check. +3. Run the **V-tier** rows: + ```bash + loaf issue verify <ref> + ``` + Commands run from the **repository root**. The command honors `exit <N>` and `` contains `text` ``. It writes nothing — it does not tick checkboxes, does not set status, and does not record a run. Non-zero exit blocks merge. `no executable V-tier criteria on <ref>` is not a failure; H-tier review still is. Unenforceable expect clauses print as advisory warnings and are never quietly decorative. +4. Check for drift: + - Rendered issue body claims features that are not in the diff + - changelog entries mention unrelated behavior - docs describe future work as already shipped - - comments or runbooks use stale internal vocabulary -4. Fix blocking drift before merge. For non-blocking polish, name it and let the user decide. + - comments or runbooks use stale vocabulary +5. If `loaf issue show` lists child issues that are not `done`, name them before asking to merge. Do not mark those children done unless this PR is theirs. +6. Fix blocking drift and unmet criteria before merge. For non-blocking polish, name it and let the user decide. For high-risk PRs, use the project's review skill or read-only review flow before proceeding. @@ -159,7 +213,7 @@ For high-risk PRs, use the project's review skill or read-only review flow befor ## Step 3: Local Verification -Run the checks the project supports. Examples: +Run the checks the project supports, in addition to `loaf issue verify`. Examples: - Node: `npm run typecheck`, `npm run test`, `npm run build` - Go: `go vet ./...`, `go test ./...` @@ -178,7 +232,7 @@ Use the repo's documented pre-commit or pre-PR checklist when present. Stop on f ## Step 4: Squash Merge -Draft a clean squash body from the reviewed diff and PR body: +Draft a clean squash body from the reviewed diff and the rendered issue: - One-line summary, then bullet points grouped by feature area - Plain text; use backticks only for code identifiers @@ -201,31 +255,41 @@ Let GitHub default the title from the PR title so the squash subject remains `ty ## Step 5: Post-Merge Cleanup -After a successful merge: +After a successful merge, leave the started worktree before removing it. Do not run `loaf issue stop` from inside that worktree. -1. Switch to the PR base branch: +1. Switch to the PR base branch in the repository checkout: ```bash git checkout <baseRefName> git pull --ff-only origin <baseRefName> ``` -2. Delete the local feature branch when safe: +2. Mark the bound issue done — this is what "done" means; `loaf issue stop` does not change status: + ```bash + loaf issue status <ref> done + ``` + Write statuses (`triage`, `backlog`, `todo`, `active`, `done`) update in place. Do not use `cancelled` or `duplicate` here. +3. Stop the started worktree if one exists. `loaf issue stop` removes the worktree, clears `started_branch` / `started_worktree` on the row, and **keeps the branch**: + ```bash + loaf issue stop <ref> + ``` + If the issue was never started, the command errors with `issue <ref> is not started` — treat that as already clean and continue. If the worktree is dirty, do not pass `--force` without user confirmation. +4. Delete the local feature branch when safe: ```bash git branch -d <headRefName> ``` -3. Confirm the remote branch deletion state from GitHub output or run: +5. Confirm the remote branch deletion state from GitHub output or run: ```bash gh pr view <N> --json headRefName,state ``` -4. Log the landing to the project journal: +6. Log the landing: ```bash - loaf journal log "decision(ship): PR #N landed via squash merge" + loaf journal log "decision(ship): PR #N landed via squash merge; <ref> done" ``` -If cleanup fails, report the exact residual state. Do not force-delete without user confirmation. +If cleanup fails, report the exact residual state (issue status, whether the worktree is still started, which branches remain). Do not force-delete without user confirmation. ### Stacked child PRs -Every child recorded in Step 1b needs three repairs before it can ship, and none of them happen on their own. +Every child PR recorded in Step 1b needs three repairs before it can ship, and none of them happen on their own. **Retarget the base.** GitHub does not reliably move a child's base when its base branch merges. Check, and move it explicitly: @@ -247,7 +311,7 @@ git diff --stat <baseRefName>...HEAD | tail -1 gh pr view <child> --json changedFiles -q .changedFiles ``` -Then force-push with a lease, wait for CI to finish on the **rebased head** rather than trusting the previous run, and only then ship the child. Delete the parent's head branch once no child points at it. +Then force-push with a lease, wait for CI to finish on the **rebased head** rather than trusting the previous run, and only then ship the child. Delete the parent's head branch once no child PR points at it. Report the numbers rather than asserting success: the before and after file counts, the commits the rebase skipped, and the CI conclusion on the new head. @@ -255,11 +319,11 @@ Report the numbers rather than asserting success: the before and after file coun ## Step 6: Release Suggestion -After landing, decide whether to suggest release: +After landing, decide whether to suggest the release skill. That cut will not re-run this review. -- Suggest release when the landed PR completes a coherent batch, user-facing feature, fix train, or release branch. +- Suggest release when the landed PR completes a coherent batch, user-facing feature, or fix train. - Do not suggest release for every small PR by default. -- If multiple related PRs are expected, say the PR is landed and can wait for a later batched release. +- If more related PRs are expected, say this PR is landed and can wait for a later cut. Use language carefully: the PR is **landed** or **shipped**; it is not **released** until release publishes a version. @@ -283,12 +347,12 @@ Do not disable hooks to force a PR through. ## Suggests Next -After a successful ship, suggest release only when the landed work forms a coherent release batch or the user asks to publish. +After a successful ship, suggest release only when the landed work forms a coherent batch or the user asks to publish. Release will not re-check the landed PR. ## Related Skills -- **release** -- Publishes a version from already-landed work -- **git-workflow** -- Branching, PR, commit, and squash merge conventions -- **foundations** -- Verification, code review, and production readiness -- **documentation-standards** -- Changelog, docs, and durable prose quality -- **reflect** -- Updates strategy from significant shipped work +- **release** — Publishes a version from already-landed work; does not re-review those PRs +- **git-workflow** — Branching, PR, commit, and squash merge conventions +- **foundations** — Verification, code review, and production readiness +- **documentation-standards** — Changelog, docs, and durable prose quality +- **reflect** — Updates strategy from significant shipped work diff --git a/dist/amp/skills/triage/SKILL.md b/dist/amp/skills/triage/SKILL.md index 0778654a2..b8ad99622 100644 --- a/dist/amp/skills/triage/SKILL.md +++ b/dist/amp/skills/triage/SKILL.md @@ -2,13 +2,13 @@ name: triage description: >- Processes the local intake queue from loaf intake list: unresolved sparks, - ideas, brainstorms, tracked and deferred Intents, and unmigrated legacy - deferrals. Use when the user asks "triage", "process my backlog", or wants - dispositions chosen across intake items. Produces explicit dispositions: - discard, retain, track as Intent, defer, resume, resolve, explore, hand to - pitch, or hand to shape. Not for reading a single known item (use loaf intent - show or journal directly), capturing new ideas (use idea), problem discovery - (use pitch), or bounding one chosen direction (use shape). + ideas, and brainstorms. Use when the user asks "triage", "process my backlog", + or wants dispositions chosen across intake items. Produces explicit + dispositions: discard, retain as spark/idea, file as backlog issue, resume + exploration, resolve, hand to pitch, or hand to shape (issue preparation). Not + for reading a single known item (use loaf issue show, loaf spark show, loaf + idea show, or journal directly), capturing new ideas (use idea), problem + discovery (use pitch), or bounding one chosen direction (use shape). version: 0.2.21 --- @@ -26,7 +26,7 @@ Process the intake queue. Triage is the public funnel where captured material me - Quick Reference - Process - Dispositions -- Legacy Deferrals +- Leftover kinds - Guardrails - Related Skills @@ -36,62 +36,67 @@ Process the intake queue. Triage is the public funnel where captured material me - Read the queue with `loaf intake list --json`; it projects every unresolved logical item exactly once with its provenance and exact read command. - Present everything before acting — the user decides each disposition; never auto-promote, auto-discard, or auto-convert. - The CLI never classifies: you and the user interpret each item; commands perform the chosen operation deterministically. -- Capture, Intent, and Exploration are different claims: a spark or idea is retained material, a tracked Intent is deliberately tracked work, a deferral is an Intent disposition with an immutable payload, an Exploration is an inquiry. Do not conflate them to save a step. +- Capture, issue, and Exploration are different claims: a spark or idea is retained material, a backlog issue is deliberately tracked work, an Exploration is an inquiry. Do not conflate them to save a step. - One pass through the queue — don't loop or re-present items. -- **Two doors into a Change:** items needing problem discovery hand to pitch, which owns `loaf change init <slug> --brief` and brief authoring; well-understood directions hand to shape. When capture should precede shaping without a full pitch, run `loaf change init <slug> --brief` and seed `brief.md` with the original ask, then hand to shape. +- **Two doors into issue work:** items needing problem discovery hand to pitch; well-understood directions hand to shape (issue preparation). Worth keeping but not ready for either door files as a backlog issue (`loaf issue new "<title>" --status backlog`, optional `--parent`, optional `loaf issue bucket`). Triage never runs `loaf issue start`, never opens PRs, and never invents Git artifacts. ## Verification - Every presented item has a recorded disposition or an explicit "leave for next triage". -- Tracked and deferred choices exist as Intents with the expected derived disposition (`loaf intent list`). +- Filed directions exist as backlog issues (`loaf issue list --status backlog`) and no longer appear in `loaf intake list` once their captures are resolved or archived. - Discards are resolved or archived through their own commands and no longer appear in `loaf intake list`. -- No Linear or tracker operation was attempted; publication is a later concern outside this Change. +- No Linear or tracker operation was attempted; publication is a later concern outside triage. ## Quick Reference | Item kind | Comes from | Typical dispositions | |-----------|-----------|----------------------| -| spark | `loaf spark capture` moments | discard, promote to idea, track as Intent | -| idea | idea capture | archive, explore, track as Intent, hand to pitch, hand to shape | -| brainstorm | archived divergent sessions | archive, explore, promote, hand to pitch | -| intent (tracked) | `loaf intent create` | keep tracking, defer, resolve, explore, hand to pitch, hand to shape | -| intent (deferred) | `loaf intent defer` or adapter | resume, resolve, leave deferred | -| legacy_deferral | pre-conversion `journal defer` | read, then optionally convert (see Legacy Deferrals) | +| spark | `loaf spark capture --scope <scope> --text <text>` | discard, retain, promote to idea, file as backlog issue, resume exploration, resolve, hand to pitch, hand to shape | +| idea | `loaf idea capture --title "<title>"` | archive, retain, file as backlog issue, resume exploration, resolve, hand to pitch, hand to shape | +| brainstorm | `loaf brainstorm capture` | archive, retain, promote to idea, file as backlog issue, resume exploration, hand to pitch, hand to shape | ## Process 1. **Scan.** Run `loaf intake list --json`. Summarize counts by kind, then list each item with its title, disposition or status, and read command. 2. **Read on demand.** Use each item's `read_command` verbatim when the user wants detail before deciding. If a read command fails, record the exact command and error in the summary as `unreadable`, make no semantic disposition for that item, continue the pass, and offer a factual diagnostic step (`loaf state doctor --json`) afterward. Never persist unreadable as a status. 3. **Decide per item.** Present the applicable dispositions and perform exactly the chosen one. -4. **Summarize.** Report what was discarded, retained, tracked, deferred, resumed, resolved, or handed onward, and journal notable decisions. +4. **Summarize.** Report what was discarded, retained, filed as backlog issues, resumed as explorations, resolved, or handed to pitch or shape, and journal notable decisions. ## Dispositions - **Discard** — ideas and brainstorms: `loaf idea archive <ref> --reason <r>` or `loaf brainstorm archive <ref> --reason <r>`. A spark is resolved against the entity that addressed it (`loaf spark resolve <ref> --by <entity> --reason <r>`); a pure dead-end spark currently has no deterministic discard operation — leave it retained, journal the judgment, and never invent a resolving entity. -- **Retain as capture** — do nothing; open captures resurface next triage. -- **Track as Intent** — two steps: create the Intent with the capture as its source, then close the capture against it so the direction appears once. `loaf intent create --title <t> --body <self-sufficient body> --from <capture-ref>`, then `loaf spark resolve <capture-ref> --by <intent-ref>` or `loaf idea resolve <capture-ref> --by <intent-ref>` (brainstorms: `loaf brainstorm archive <ref> --reason "tracked as <intent-ref>"`). -- **Defer** — an existing Intent: `loaf intent defer <ref> --why <w> --boundary <b> --trigger <t> --operation-id <key>`; a new deferred direction needs the full skeleton: `loaf intent create --title <t> --body <b> --disposition deferred --why <w> --boundary <bd> --trigger <tr> --operation-id <key> [--from <source-ref>]`. -- **Resume** — `loaf intent resume <ref> --reason <why now>`; appends a tracked disposition linked to the deferral it supersedes. -- **Resolve** — `loaf intent resolve <ref> --reason <outcome>`; history is never rewritten. -- **Explore** — agent technique for genuinely undecided directions (Explorations and checkpoints); not a user slash entry — prefer pitch when the human needs problem discovery first, then reach for explore from inside that work if still undecided. -- **Pitch** — items needing problem discovery hand to pitch, which owns init and brief authoring; resolve the promoted item against the created change (`loaf spark resolve` / `loaf idea resolve` / archive brainstorm with the change as the reason). -- **Shape** — when a direction is already well-understood and ready for bounded delivery, hand it to shape; triage never creates fully-materialized Changes, branches, or worktrees (capture-only brief seeding is the Critical Rules exception above). +- **Retain as spark/idea** — do nothing to leave the capture open, or promote into the other capture primitive: capture the idea first (`loaf idea capture --title "..."`), then `loaf spark promote <spark> --to-idea <idea>` or `loaf brainstorm promote <brainstorm> --to-idea <idea>`. Open captures resurface next triage. +- **File as backlog issue** — two steps so the direction appears once. Create the issue, then close the capture against it: -## Legacy Deferrals + ```bash + loaf issue new "<title>" --status backlog [--parent <ref>] [--kind delivery|decision] [--fog <text>] [--body <text>] + loaf issue bucket <issue-ref> now|next|later # optional; labels only, never a constraint + loaf spark resolve <capture-ref> --by <issue-ref> + # or: loaf idea resolve <capture-ref> --by <issue-ref> + # brainstorms: loaf brainstorm archive <ref> --reason "filed as <issue-ref>" + ``` -Items of kind `legacy_deferral` are pre-conversion `journal defer` records. They stay visible and readable until the explicit, backup-first conversion is run; nothing disappears while migration is pending. When the user wants them converged, offer `loaf state migrate deferrals --dry-run` to preview the project-specific manifest and `--apply` only with explicit consent — apply verifies a whole-database backup first and preserves every legacy row. + Use `--kind decision` when filing a sharp question. Copy still-unsharp questions into `--fog` (create-time only). `--parent` nests under an existing issue; omit it for a different problem. +- **Resume exploration** — agent technique for genuinely undecided directions (Explorations and checkpoints); not a user slash entry. Prefer pitch when the human needs problem discovery first, then reach for explore from inside that work if still undecided. Resume with `loaf exploration context <ref>` when a named Exploration already exists. +- **Resolve** — the capture is already represented elsewhere. `loaf spark resolve <ref> --by <entity> --reason <r>` or `loaf idea resolve <ref> --by <entity>`. History is never rewritten. +- **Hand to pitch** — items needing problem discovery hand to pitch. Resolve the capture against the issue once one exists (`loaf spark resolve` / `loaf idea resolve --by <issue-ref>` / archive the brainstorm with that issue as the reason). +- **Hand to shape** — when a direction is already well-understood and ready for bounded delivery, hand it to shape for issue preparation. Triage never writes definition-of-done criteria, never runs `loaf issue check`, and never creates branches or worktrees. + +## Leftover kinds + +`loaf intake list` may still include `intent` and `legacy_deferral` items. Do not create new `intent` rows. Treat leftover directions like any other capture: file a backlog issue if worth keeping, or leave them for a later pass. Do not offer conversion commands that recreate the old tracked/deferred row. ## Guardrails 1. **User decides every disposition** — present, don't decide. 2. **Batch presentation, individual decisions** — show the full queue, then process one item at a time. 3. **Log everything** — no silent discards, promotions, or conversions. -4. **Deferred is not forgotten** — deferred Intents remain active truth in `loaf journal context` until resumed or resolved. +4. **Filed is not forgotten** — backlog issues remain on `loaf issue list` and may appear on `loaf issue frontier` until their status changes. Buckets are labels only. ## Related Skills - **idea** — capture a new idea (fast, minimal friction) - **pitch** — problem-discovery ceremony for items that need framing before shape - **explore** — agent technique for divergent inquiry with portable checkpoints -- **shape** — develop a well-understood direction into a bounded Change +- **shape** — develop a well-understood direction into a bounded issue - **housekeeping** — flags stale artifacts; does not choose dispositions diff --git a/dist/amp/skills/wrap/SKILL.md b/dist/amp/skills/wrap/SKILL.md index ab64cc64d..52777e1bf 100644 --- a/dist/amp/skills/wrap/SKILL.md +++ b/dist/amp/skills/wrap/SKILL.md @@ -136,7 +136,7 @@ After the wrap-up report, suggest housekeeping if it wasn't run this session and ## Report Format -Use backtick formatting for code identifiers, file paths, spec/task IDs, version numbers, status values, and CLI commands. Use uppercase for spec and task IDs (`SPEC-029`, not `spec-029`). +Use backtick formatting for code identifiers, file paths, issue IDs, version numbers, status values, and CLI commands. Use uppercase for issue IDs (`LOAF-29`, not `loaf-29`). ```markdown ## Session Wrap-Up diff --git a/dist/codex/.loaf-target-manifest.json b/dist/codex/.loaf-target-manifest.json index ff6f5f69e..15eab9d80 100644 --- a/dist/codex/.loaf-target-manifest.json +++ b/dist/codex/.loaf-target-manifest.json @@ -11,7 +11,7 @@ "id": "managed-instructions", "kind": "instruction", "destination": "project-instructions", - "sha256": "ac6debb93fcd1b2d7806681c446f3b7d9691a43a872831a969c82a7470b0b30d" + "sha256": "21e91a6226ead7de1ef1d3d61c4e2060dc9763e8485192f6efc0060a09bbe66e" } ] } diff --git a/dist/codex/skills/bootstrap/SKILL.md b/dist/codex/skills/bootstrap/SKILL.md index 33ceb4e3f..e9ca2819a 100644 --- a/dist/codex/skills/bootstrap/SKILL.md +++ b/dist/codex/skills/bootstrap/SKILL.md @@ -30,7 +30,7 @@ First-contact project setup: detect state, interview the builder, populate proje - Guardrails - Related Skills -Series-prep lives under Finalization (phase between Knowledge Base Scaffolding and Next Steps). +Series-prep lives under Finalization (phase between Knowledge Base Scaffolding and Next Steps): the initial arc becomes backlog issues, not folders. **Input:** $ARGUMENTS @@ -43,8 +43,8 @@ Series-prep lives under Finalization (phase between Knowledge Base Scaffolding a - **Always interview** -- even with a rich brief, confirm understanding through structured questions — one at a time, with a recommendation, using your harness's structured question tool if it has one - **Pitched BRIEF is discovery-already-done** -- when `docs/BRIEF.md` has `source: pitch`, do not re-excavate the problem space; quote-back and gap-fill only for operating-document population - **BRIEF is input, not output** -- the BRIEF is raw intake. Extract every useful fact into VISION/STRATEGY/ARCHITECTURE/AGENTS during bootstrap. -- **BRIEF is archeological after bootstrap** -- once extraction completes (including series-prep reading scoped concepts from it), the BRIEF is a frozen historical snapshot. No skill, agent, command, or template should reference `docs/BRIEF.md` post-bootstrap. Operating documents and minted change briefs must stand on their own. -- **Series-prep never auto-shapes and never creates branches** -- every mint is user-confirmed; no priority, date, or dependency fields; concepts without a coarse `target_release` stay BRIEF lines, sparks, or Intents +- **BRIEF is archeological after bootstrap** -- once extraction completes (including series-prep reading scoped concepts from it), the BRIEF is a frozen historical snapshot. No skill, agent, command, or template should reference `docs/BRIEF.md` post-bootstrap. Operating documents and minted issue bodies must stand on their own. +- **Series-prep never auto-shapes and never creates branches** -- every mint is user-confirmed; no priority, date, or dependency fields; buckets are labels, never bindings; concepts that fail granularity stay BRIEF lines or sparks - **Suggest, don't execute** -- recommend next skills at the end, never auto-run them - **Log first** -- log invocation before interviewing: `loaf journal log "skill(bootstrap): <project or intake>"` - **Log outcome** -- log bootstrap completion to the project journal: `loaf journal log "decision(bootstrap): project bootstrapped, mode detected"` @@ -56,7 +56,7 @@ Series-prep lives under Finalization (phase between Knowledge Base Scaffolding a - All expected operating documents (`docs/VISION.md`, `AGENTS.md` at minimum) exist and contain populated content - Useful BRIEF content has been extracted into operating documents (no future reader should need to open the BRIEF) - When `source: pitch`, the interview was gap-only (no re-excavation of already-specific problem sections) -- When series-prep ran: each minted folder has `change.json` with stamped `target_release`, a standalone problem-space `brief.md`, zero-violation captured state via explicit-path `loaf change check <folder> --json`, and its own docs-only commit (never a batch); no branches created for the series; no auto-shape +- When series-prep ran: each minted row is a backlog issue (`loaf issue new "<title>" --body "<problem narrative>" --status backlog`) with a standalone problem-space body; an advisory bucket (`loaf issue bucket <ref> now|next|later`) may be set — buckets are labels, never bindings; `loaf issue check <ref>` only when a capture is shaped enough to check, otherwise nothing (a backlog issue with a problem body needs no ceremony); no folders, no docs-only commits; no branches created for the series; no auto-shape - Root `AGENTS.md` is a real file; on Claude Code, the compatibility symlink `.claude/CLAUDE.md -> ../AGENTS.md` exists (see Finalization) - Key decisions and interview outcomes were logged with `loaf journal log` and are readable with `loaf journal recent` @@ -237,7 +237,7 @@ Pitch owned the problem-space grill. Bootstrap does not re-excavate. The pitch 1. **Acknowledge the pitch** — name that `docs/BRIEF.md` carries `source: pitch` and that problem discovery is already done. 2. **Summarize what pitch captured** — short section-by-section gist (problem, who, alternatives, value, constraints, sequencing, open questions). The builder should hear continuity with the pitch closing ceremony, not a cold restart. -3. **State what bootstrap will do now** — interview only on gaps for operating-document population (VISION, STRATEGY, ARCHITECTURE, AGENTS), then series-prep the initial arc of captured changes. Do not re-grill the problem space. +3. **State what bootstrap will do now** — interview only on gaps for operating-document population (VISION, STRATEGY, ARCHITECTURE, AGENTS), then series-prep the initial arc as backlog issues. Do not re-grill the problem space. Then continue: @@ -424,58 +424,62 @@ The journal should capture: Use [templates/journal.md](templates/journal.md) only as the rendered entry format reference; do not hand-author journal markdown as the source of truth. -### 4. Series-Prep (initial arc as captured changes) +### 4. Series-Prep (initial arc as backlog issues) -After operating documents are populated (and Knowledge Base Scaffolding above has run), close bootstrap by minting the BRIEF's initial arc as **captured promise carriers** — brief-only change folders bound to a coarse `target_release`, each landed as its own docs-only commit. Series-prep is not roadmap planning: no milestone entities, no dates, no priorities, no dependency fields. Sequencing is prose in each brief; cohort membership is the shared `target_release`. +After operating documents are populated (and Knowledge Base Scaffolding above has run), close bootstrap by minting the BRIEF's initial arc as **backlog issues** — SQLite rows with a problem-space body and an optional advisory bucket label (`loaf issue bucket <ref> now|next|later`). Buckets are labels, never bindings. Series-prep is not roadmap planning: no milestone entities, no dates, no priorities, no dependency fields. Sequencing is prose in each issue body. No folders, no docs-only commits per capture — rows, not files. **When to run** - Always offer series-prep when a project BRIEF exists and names more than one scoped concept (typical after a pitched BRIEF; also after a rich non-pitch brief). -- If the BRIEF is a single atomic concept with no series, say so and skip to Next Steps — one future shape or a single capture later is enough. -- Series-prep **reads** the BRIEF during this phase only. After bootstrap ends, nothing references `docs/BRIEF.md` again; minted change briefs and operating docs stand alone. +- If the BRIEF is a single atomic concept with no series, say so and skip to Next Steps — one future shape or a single issue later is enough. +- Series-prep **reads** the BRIEF during this phase only. After bootstrap ends, nothing references `docs/BRIEF.md` again; minted issue bodies and operating docs stand alone. **Procedure** 1. **Enumerate concepts** with the builder from the BRIEF's scoped problem space (Sequencing and Relationships, Open Questions, and distinct problem threads in Problem Statement). List candidates as recommendation-first options using your harness's structured question tool if it has one. -2. **Apply granularity** per [references/interview-guide.md](references/interview-guide.md) (Series-Prep Granularity): a concept earns its own captured change when it is independently shippable **and** its problem can be **stated precisely now** (the mint-time specifiability test — not answered now, stated now) without the others; otherwise it stays a BRIEF line, becomes a spark, or an Intent — never a half-minted folder. +2. **Apply granularity** per [references/interview-guide.md](references/interview-guide.md) (Series-Prep Granularity): a concept earns its own backlog issue when it is independently shippable **and** its problem can be **stated precisely now** (the mint-time specifiability test — not answered now, stated now) without the others; otherwise it stays a BRIEF line or becomes a spark — never a half-minted row. 3. **Per confirmed concept (one at a time — never batch):** - 1. Confirm mint with the builder (slug, coarse `target_release`, one-line problem restatement). If the builder will not bind even a coarse target, do not mint — park as spark/Intent/BRIEF line. - 2. Propose a **local slug** that names the concept, never another work unit (`spec-042`, task ids, change folder names). Confirm the slug. - 3. Run capture init: + 1. Confirm mint with the builder (title, optional advisory bucket, one-line problem restatement). Buckets are labels, never bindings — a missing bucket does not block mint. If the concept fails granularity, do not mint — park as spark or BRIEF line. + 2. Propose a **working title** that names the concept, never another work unit (issue aliases, task ids). Confirm the title. + 3. **Seed a problem-space-only narrative** from the BRIEF's content for that concept (Problem Statement, Who Has It, Current Alternatives, Value Proposition, Constraints, Sequencing and Relationships as prose order relative to the arc, Sources if any, Open Questions). Do not copy solution design. The seeded body must stand alone as intent for later shape — cold-read without the project BRIEF or this session. + 4. Mint the backlog issue: ```bash - loaf change init <slug> --brief + loaf issue new "<title>" --body "<problem narrative>" --status backlog ``` - Creates `docs/changes/YYYYMMDD-<slug>/` with `change.json` + `brief.md` only. - 4. **Seed `brief.md` problem-space-only** from the BRIEF's content for that concept (Problem Statement, Who Has It, Current Alternatives, Value Proposition, Constraints, Sequencing and Relationships as prose order relative to the arc, Sources if any, Open Questions). Do not copy solution design. The seeded brief must stand alone as intent for later shape — cold-read without the project BRIEF or this session. - 5. **Stamp `target_release`** on that folder's `change.json` with the builder's coarse binding (canonical `MAJOR.MINOR.PATCH`, no `v`, no prerelease). Series-prep mints only targeted captures (promise-carrier path). - 6. **Pre-landing guard** (required before every commit): + Creates a SQLite row, not a folder. Use `--body -` or `--body-file <path>` when the narrative is long (see `loaf issue new --help`). + 5. **Optionally set an advisory bucket** (a label, never a binding): ```bash - loaf change check <folder> --json + loaf issue bucket <ref> now|next|later ``` + 6. **Validate** only when the capture is shaped enough to check: - Must report zero violations and captured state. Then **read `<folder>/change.json` directly** and confirm the stamped `target_release` matches what the builder bound. Bare `loaf change check` resolves by branch and can miss a capture elsewhere — always pass the explicit folder path. - 7. **Land as its own docs-only commit on the default branch** (one commit per capture, never a batch). Example subject: `docs(change): capture <slug> brief`. Bootstrap prepares the commit; never push; never open a PR. + ```bash + loaf issue check <ref> + ``` + + A backlog issue with a problem body is capture-only and needs no ceremony — skip the check. Do not add criteria or an out-of-scope statement during series-prep (that is shape). + 7. **Do not land a docs-only commit.** The row is the artifact. Never push; never open a PR; never create a branch. 4. **Guards (hard):** - Every mint is user-confirmed — never auto-mint the whole list - - Never auto-run shape and never create slug branches during series-prep - - No priority, date, estimate, or dependency fields on captures - - No batching multiple captures into one commit - - Concepts without a coarse target stay BRIEF lines, sparks, or Intents + - Never auto-run shape and never create branches during series-prep + - No priority, date, estimate, or dependency fields on issues + - No folders, no docs-only commits per capture — rows, not files + - Concepts that fail granularity stay BRIEF lines or sparks; buckets are labels, never bindings **After the series** -Log the arc: `loaf journal log "decision(bootstrap): series-prep minted <n> captures for <cohort or targets>"`. Hand off by naming the first capture folder for shape when the builder is ready. +Log the arc: `loaf journal log "decision(bootstrap): series-prep minted <n> backlog issues"`. Hand off by naming the first issue ref for shape when the builder is ready. ### 5. Next Steps Suggest relevant next steps based on what was learned: -- shape -- on a series-prep capture (or any ready concept) to promote the folder and bound implementation +- shape -- on a series-prep backlog issue (or any ready concept) to bound implementation - pitch -- if a new concept still needs problem discovery (not for re-grilling the BRIEF) -- idea -- if specific feature ideas emerged during the interview and should not become captures yet +- idea -- if specific feature ideas emerged during the interview and should not become issues yet - research -- if there are open questions that need investigation - `loaf doctor` -- to verify the setup is healthy @@ -502,18 +506,18 @@ When the interactive interview path is unavailable, bootstrap the operating docu 2. **Always interview** -- even with a rich brief, confirm understanding; when `source: pitch`, gap-fill only 3. **Never overwrite** -- existing documents require explicit confirmation 4. **Draft, then review** -- present documents section-by-section -5. **Extract, don't preserve** -- pull every useful fact from the BRIEF into operating docs (and series-prep seeds change briefs from it once). The BRIEF is archeological after bootstrap; nothing should reference it again. +5. **Extract, don't preserve** -- pull every useful fact from the BRIEF into operating docs (and series-prep seeds issue bodies from it once). The BRIEF is archeological after bootstrap; nothing should reference it again. 6. **Record the session** -- decisions and rationale are preserved 7. **Suggest, don't execute** -- recommend next skills, don't auto-run them; series-prep never auto-shapes or creates branches 8. **Interview structured** -- one question at a time, with a recommendation, using your harness's structured question tool if it has one -9. **Series-prep is not roadmap planning** -- coarse `target_release` + prose sequencing only; no dates, priorities, or dependency fields +9. **Series-prep is not roadmap planning** -- advisory bucket labels (never bindings) + prose sequencing only; no dates, priorities, or dependency fields --- ## Related Skills -- **pitch** -- Authors a project-scale `docs/BRIEF.md` with `source: pitch` (or a change-scale brief); bootstrap consumes the pitched BRIEF with gap-only interview and series-prep -- **shape** -- Bound a captured change into a contract (promotes brief-only folders; often follows series-prep) +- **pitch** -- Authors a project-scale `docs/BRIEF.md` with `source: pitch` (or an issue-scale problem narrative); bootstrap consumes the pitched BRIEF with gap-only interview and series-prep +- **shape** -- Bound a backlog issue into a contract (often follows series-prep) - **explore** -- Agent technique when a concept that emerges during bootstrap is still undecided (not a user front door; prefer pitch for human problem discovery) - **research** -- Investigate topics and open questions - **idea** -- Quick-capture feature ideas that emerge during bootstrap diff --git a/dist/codex/skills/bootstrap/references/interview-guide.md b/dist/codex/skills/bootstrap/references/interview-guide.md index f3fc0c1ef..83f583396 100644 --- a/dist/codex/skills/bootstrap/references/interview-guide.md +++ b/dist/codex/skills/bootstrap/references/interview-guide.md @@ -358,33 +358,32 @@ Expect 6-10 questions total, mostly in Excavation and Sharpening. Grounding is l ## Series-Prep Granularity -Series-prep (bootstrap Finalization) turns the project BRIEF's scoped concepts into captured change folders. This section resolves when a concept earns a folder versus staying a BRIEF line, spark, or Intent. Full procedure: bootstrap SKILL.md Finalization → Series-Prep. +Series-prep (bootstrap Finalization) turns the project BRIEF's scoped concepts into backlog issues. This section resolves when a concept earns a row versus staying a BRIEF line or spark. Full procedure: bootstrap SKILL.md Finalization → Series-Prep. -### Earns its own captured change when both are true +### Earns its own backlog issue when both are true -1. **Independently shippable** — the concept could land as a meaningful release unit without waiting on the other scoped concepts to ship in the same commit or PR. Sharing a coarse `target_release` cohort is fine; hard entanglement is not. -2. **Specifiability (mint test)** — a concept mints only if its problem can be **stated precisely now** (not answered now): a cold reader can name the friction, who has it, the current alternative, and the value for *this* concept alone, without needing sibling folders or inventing what was meant. Everything vaguer stays a BRIEF line, spark, or Intent — never pre-sliced into a fake capture. +1. **Independently shippable** — the concept could land as a meaningful release unit without waiting on the other scoped concepts to ship in the same commit or PR. Sharing an advisory bucket is fine (buckets are labels, never bindings); hard entanglement is not. +2. **Specifiability (mint test)** — a concept mints only if its problem can be **stated precisely now** (not answered now): a cold reader can name the friction, who has it, the current alternative, and the value for *this* concept alone, without needing sibling issues or inventing what was meant. Everything vaguer stays a BRIEF line or spark — never pre-sliced into a fake row. -When both hold and the builder will bind a coarse `target_release`, mint: `loaf change init <slug> --brief`, seed problem-space-only, stamp the target, pre-landing check + `change.json` read-back, one docs-only commit on the default branch. +When both hold, mint: `loaf issue new "<title>" --body "<problem narrative>" --status backlog`, seed problem-space-only, optionally `loaf issue bucket <ref> now|next|later` (buckets are labels, never bindings). Run `loaf issue check <ref>` only when the capture is shaped enough to check; a backlog issue with a problem body needs no ceremony. No folders, no docs-only commits — rows, not files. -### Stays a BRIEF line, spark, or Intent when any is true +### Stays a BRIEF line or spark when any is true - The concept only makes sense as a clause of a larger problem (it cannot be stated alone). - Its problem cannot yet be stated precisely — coarse intent only; minting would invent precision. - It is a solution-space detail, implementation task, or "nice-to-have" flavor — not a shippable problem boundary. -- The builder will not bind even a coarse `target_release` (untargeted series members are not promise carriers on main; keep them as sparks/Intents or leave them as prose in the BRIEF until a target exists). -- Splitting would create two folders that always ship as one unit — keep one folder and name the sub-concerns in Sequencing prose. +- Splitting would create two issues that always ship as one unit — keep one issue and name the sub-concerns in Sequencing prose. ### Interview moves during series-prep -- Recommend a decomposition first (list candidate folders + what stays in the BRIEF), then confirm each mint one at a time. +- Recommend a decomposition first (list candidate issues + what stays in the BRIEF), then confirm each mint one at a time. - Challenge over-splitting: "Can this problem be stated precisely now without that one?" If no, merge candidates or leave as BRIEF lines. -- Challenge under-splitting: "Is this independently shippable, or are we smuggling two products into one capture?" -- Never invent priority ranks, dates, or machine dependency fields — sequencing is prose; cohort is `target_release`. +- Challenge under-splitting: "Is this independently shippable, or are we smuggling two products into one issue?" +- Never invent priority ranks, dates, or machine dependency fields — sequencing is prose; buckets are labels, never bindings. ### Standalone cold-read test (H3) -Each minted brief must name its problem without reference to the bootstrap session or to `docs/BRIEF.md`. If a draft says "as discussed" or "per the project brief," rewrite until the problem stands alone. +Each minted issue body must name its problem without reference to the bootstrap session or to `docs/BRIEF.md`. If a draft says "as discussed" or "per the project brief," rewrite until the problem stands alone. --- diff --git a/dist/codex/skills/bootstrap/templates/brief.md b/dist/codex/skills/bootstrap/templates/brief.md index d7b58d0db..5b550a307 100644 --- a/dist/codex/skills/bootstrap/templates/brief.md +++ b/dist/codex/skills/bootstrap/templates/brief.md @@ -39,7 +39,7 @@ archived: true # Always true -- BRIEF is a historical snapshot, not a w ## Sequencing and Relationships -[How the initial arc hangs together — which concepts belong as early changes, what depends on what, release cohort stated as prose. No machine relation fields; narrative order only.] +[How the initial arc hangs together — which problems become early backlog issues, what depends on what, sequencing stated as prose. No machine relation fields; narrative order only.] ## Sources and Research Links diff --git a/dist/codex/skills/bootstrap/templates/journal.md b/dist/codex/skills/bootstrap/templates/journal.md index caf7409fd..9a0a61f8f 100644 --- a/dist/codex/skills/bootstrap/templates/journal.md +++ b/dist/codex/skills/bootstrap/templates/journal.md @@ -13,7 +13,7 @@ not create or edit journal markdown as the source of truth — use ## Entries -[YYYY-MM-DD HH:MM] skill(implement): implementing TASK-042 +[YYYY-MM-DD HH:MM] skill(implement): implementing LOAF-42 [YYYY-MM-DD HH:MM] decision(scope): description of decision [YYYY-MM-DD HH:MM] discover(scope): something learned [YYYY-MM-DD HH:MM] block(scope): what is blocked diff --git a/dist/codex/skills/breakdown/SKILL.md b/dist/codex/skills/breakdown/SKILL.md deleted file mode 100644 index e3a1260da..000000000 --- a/dist/codex/skills/breakdown/SKILL.md +++ /dev/null @@ -1,330 +0,0 @@ ---- -name: breakdown -description: >- - Decomposes specifications into atomic tasks with dependencies and priorities. - Use when the user asks "break this down" or "create tasks for this spec." - Produces task files with estimates, dependencies, and acceptance criteria. Not - for shaping ideas (use shape) or implementation work (use implement). -version: 0.2.21 ---- - -# Breakdown - -Decompose specifications into atomic, implementable tasks. - -## Contents -- Critical Rules -- Verification -- Quick Reference -- Task Breakdown Philosophy -- Task Backend Detection -- Process -- Linear-Native Mode -- Local-Tasks Mode -- Priority Levels -- Guardrails -- Related Skills - -**Input:** $ARGUMENTS - ---- - -## Critical Rules - -- **One concern per task** -- never mix unrelated layers (backend + frontend) in a single task -- **Every task includes its own verification** -- no separate "verify" tasks; each task must have an observable done condition -- **Own the decisions** -- decide granularity and priorities autonomously; only ask the user when two equally valid orderings have genuinely different trade-offs -- **Keep tests with the code they test** -- never split implementation and tests into separate tasks -- **Update spec status** -- mark the spec as `implementing` after tasks are created -- **One backend only** -- in Linear-native mode create Linear issues and NO local `TASK-NNN.md`; in local mode create local tasks and make NO Linear calls -- **Spec file is always local** -- in both modes, the spec stays in `.agents/specs/`. The Linear parent issue, when present, is a rollup pointing to the spec, not a re-host of it -- **Log outcome** -- log breakdown to the project journal: `loaf journal log "decision(breakdown): SPEC-NNN → N tasks created"` - ---- - -## Verification - -- Each created task has a clear title, priority, file hints, verification command, and observable done condition -- The dependency graph has no cycles and reflects actual implementation order -- Spec status has been updated to `implementing` -- **Linear-native mode only:** parent issue exists, labeled `spec`, with description pointing to the local spec file; N sub-issues have `parentId` set; zero local task rows or `TASK-NNN.md` files were created; spec frontmatter has `linear_parent` and `linear_parent_url` populated -- **Local-tasks mode only:** N local tasks exist in `loaf task list` with compatibility `.md` files when configured; no Linear calls were made - ---- - -## Quick Reference - -### Priority Levels - -| Priority | Loaf | Linear Priority | -|----------|------|-----------------| -| P0 | Urgent/blocking -- drop everything | Urgent (1) | -| P1 | High -- work next | High (2) | -| P2 | Normal -- scheduled work (default) | Normal (3) | -| P3 | Low -- when time permits | Low (4) | - -### Right-Sizing Rules - -| Rule | Guideline | -|------|-----------| -| **One agent type** | Completable by a single implementer (after skills narrowing) | -| **One concern** | Touches one layer, service, or component | -| **Context-appropriate** | Fits in model context with room for exploration | -| **Not over-fragmented** | Don't split what naturally belongs together | - -### Mode Selection - -| `integrations.linear.enabled` in `.agents/loaf.json` | Mode | See | -|------------------------------------------------------|------|-----| -| `true` | Linear-native | [Linear-Native Mode](#linear-native-mode) | -| `false` or absent | Local-tasks | [Local-Tasks Mode](#local-tasks-mode) | - ---- - -## Task Breakdown Philosophy - -**Primary principle: separation of concerns.** - -### The Right Size Test - -1. Can a single implementer complete this? If no, split by concern -2. Does it touch multiple unrelated concerns? If yes, split by concern -3. Will the agent need too much context? If yes, split into smaller coherent units -4. Am I splitting just to have more tasks? If yes, merge back - -### Anti-Patterns - -| Don't | Do Instead | -|-------|------------| -| Split backend + tests into separate tasks | Keep tests with the code they test | -| Create a task per file | Group files by concern | -| Separate "implement" and "verify" tasks | Every task includes its own verification | -| Copy the full spec text into the Linear parent issue | Summarize + link to the local spec file | -| Create both local `TASK-NNN.md` and Linear sub-issues | Pick one backend; never mix | - ---- - -## Task Backend Detection - -Read `.agents/loaf.json`: - -```json -{ - "integrations": { - "linear": { "enabled": true } - } -} -``` - -If `integrations.linear.enabled` is `true`, proceed in **Linear-native mode**. -Otherwise, proceed in **Local-tasks mode**. - -If `.agents/loaf.json` is missing, default to local-tasks and note the -assumption in the project journal. - ---- - -## Process - -### Step 1: Parse Input - -`$ARGUMENTS` should reference a spec (e.g., "SPEC-001"). If unclear, list available specs. - -### Step 2: Read the Spec - -Extract: test conditions, scope, implementation notes, priority ordering, complexity size. - -### Step 3: Identify Task Boundaries - -Break down by concern (data layer, backend, frontend, infrastructure, etc.). One concern per task. Explicit dependencies for sequential tasks. - -### Step 4: Decide Priorities and Granularity - -Own the granularity and priority decisions. Apply the Right Size Test, assign priorities -based on dependencies, priority order, and go/no-go gates, and do a self-review pass. Do not -defer these decisions to the user — they trust agent judgment here. - -If genuinely uncertain (e.g., two equally valid orderings with different trade-offs), -ask. Otherwise, decide and move on. - -### Step 5: Draft Task List - -Draft tasks following [task template](templates/task.md). Each task needs: clear title, priority, file hints, verification command, observable done condition, labels (if routing by team). - -### Step 6: Present the Plan - -Show the dependency graph and task summary for awareness before creating anything. -Present it as "here's what I'm creating" not "which option do you prefer?" The user -can still adjust after creation, but the default is to proceed. - -### Step 7: Create Tasks (mode-specific) - -Detect the mode (see [Task Backend Detection](#task-backend-detection)) and follow the -matching section below. Do NOT mix modes. - -- Linear enabled → [Linear-Native Mode](#linear-native-mode) -- Linear disabled or missing → [Local-Tasks Mode](#local-tasks-mode) - -### Step 8: Update Spec and Announce - -Set spec status to `implementing`. In Linear-native mode, also write -`linear_parent` and `linear_parent_url` into the spec's frontmatter. Announce -created tasks and next steps. - ---- - -## Linear-Native Mode - -Spec files stay local and canonical in `.agents/specs/`. Tasks live in Linear -as sub-issues of a parent rollup issue representing the spec. No local -task rows or `TASK-NNN.md` files are created. - -### 7a. Ensure the `spec` label exists - -The `spec` label groups all spec-parent rollup issues so Linear users can -filter for them. - -1. Call `list_issue_labels` to check whether a label named `spec` exists. -2. If missing, create it via `create_issue_label`: - - `name`: `spec` - - `color`: `#5e6ad2` (Linear-ish indigo; implementer may adjust) - - `description`: `Parent rollup issue representing a design spec tracked in the repo at .agents/specs/` - - Prefer workspace-scoped so all teams can filter uniformly. If the MCP - only supports team-scoped labels, create on the default team. -3. Log whether the label was created this run or already existed. This - matters for first-time Loaf setup on a Linear workspace. - -### 7b. Resolve team, project, and state - -Read from `.agents/loaf.json`: - -- **Team:** `linear.default_team` (name) — resolve to team ID via - `list_teams` if not already cached in `known_teams`. -- **Project:** `linear.project.id`. -- **State:** call `list_issue_statuses` for the team, pick the - `unstarted`-type state (typically "Backlog" or "To-Do"). States are - **team-scoped**, not workspace-scoped — always pass the team. - -### 7c. Create the parent issue - -Use `create_issue` with: - -| Field | Value | -|-------|-------| -| `title` | `[SPEC-NNN] <spec title>` | -| `teamId` | from 7b | -| `projectId` | from 7b | -| `stateId` | unstarted state from 7b | -| `priority` | mapped from spec (default High = 2 if unspecified) | -| `labels` | `["spec"]` | -| `description` | Summary synthesized from the spec's Problem Statement + Solution Direction (1–3 paragraphs), ending with: `See .agents/specs/SPEC-NNN-<slug>.md for full text, council references, and strategic tensions.` | - -**Do NOT** copy the full spec body into the description. The local file is canonical. - -### 7d. Check label-group conflicts (pre-flight per sub-issue) - -Linear labels can belong to exclusive groups (e.g., a `type` group where -`feature`, `testing`, `docs`, `bug`, `refactor` are mutually exclusive). -Before creating each sub-issue: - -1. Inspect proposed labels against known group membership (from - `list_issue_labels` group metadata). -2. If a task has more than one label from the same exclusive group, pick the - most appropriate and drop the others. Warn the user about the drop. -3. Log the resolution so the user can override if desired. - -### 7e. Create sub-issues - -For each task, use `create_issue` with: - -| Field | Value | -|-------|-------| -| `parentId` | parent issue ID from 7c | -| `title` | task title | -| `description` | task description + acceptance criteria | -| `teamId` | routed from `team_keywords` or falling back to `default_team` | -| `projectId` | same as parent unless task explicitly belongs elsewhere | -| `stateId` | unstarted state for the target team | -| `priority` | mapped from task priority (see Priority Levels table) | -| `labels` | task labels after conflict resolution (7d) | - -Express dependencies from the spec's Priority Order / dependency graph via -`blockedBy` referencing sibling sub-issue IDs. Create in dependency order so -predecessors exist when referenced. - -### 7f. Do NOT create local task files - -Skip `loaf task create` entirely. Linear issue IDs are the task record. No -local task rows or `TASK-NNN.md` files for this spec's tasks. - -### 7g. Update spec frontmatter - -Add to the spec file's YAML frontmatter: - -```yaml -linear_parent: ENG-198 -linear_parent_url: https://linear.app/<workspace>/issue/ENG-198 -``` - -Use the actual parent issue identifier and URL returned from 7c. - ---- - -## Local-Tasks Mode - -Spec files and task files both live locally. No Linear calls. - -Use `loaf task create --spec SPEC-XXX --title "Task title" --priority P1` -for each task. In SQLite-backed projects, the CLI creates the operational state -row and any compatibility Markdown/index artifacts needed by the current -project. Then edit the `.md` body content (description, acceptance criteria) -only when an authored task prose artifact exists. - -Dependencies are expressed through CLI flags such as `--depends-on`, not by -hand-editing the compatibility index. Priority Order from the spec maps directly -to task `priority` fields. - -See [local-tasks reference](../orchestration/references/local-tasks.md) for -the full local-task model. - ---- - -## Priority Mapping (reference) - -| Loaf | Linear API value | Linear label | -|------|------------------|--------------| -| P0 | `1` | Urgent | -| P1 | `2` | High | -| P2 | `3` | Normal | -| P3 | `4` | Low | - ---- - -## Guardrails - -1. **One concern per task** -- don't mix backend + frontend -2. **Clear verification** -- how to prove it works -3. **Observable done condition** -- not subjective -4. **File hints** -- help session know where to look -5. **Own the decisions** -- decide granularity and priorities, don't defer -6. **Update spec status** -- mark as implementing -7. **One backend only** -- Linear-native creates Linear issues and no local tasks; local-tasks mode creates local tasks and no Linear calls -8. **Summary not copy** -- the Linear parent description summarizes + links; it does not re-host the spec - ---- - -## Suggests Next - -After breakdown completes, suggest implement to start working on the tasks. - -## Related Skills - -- **shape** -- Create specs that get broken down -- **implement** -- Start session for a task or coordinate multiple tasks - -## Topics - -| Topic | Reference | Use When | -|-------|-----------|----------| -| Linear Integration | `orchestration/references/linear.md` | Working out Linear issue structure, labels, parent/child | -| Local Task Model | `orchestration/references/local-tasks.md` | Local-tasks mode details and CLI flags | diff --git a/dist/codex/skills/breakdown/templates/task.md b/dist/codex/skills/breakdown/templates/task.md deleted file mode 100644 index fe74a794f..000000000 --- a/dist/codex/skills/breakdown/templates/task.md +++ /dev/null @@ -1,28 +0,0 @@ -# Task Template - -**Create with:** `loaf task create --title "..." --spec SPEC-XXX` - -```yaml ---- -id: TASK-XXX -title: [Clear action] -spec: SPEC-001 -status: todo -priority: P2 -files: - - [likely file 1] - - [likely file 2] -verify: [command to verify] -done: [observable outcome] ---- - -## Description -[What needs to be done] - -## Acceptance Criteria -- [ ] [Criterion 1] -- [ ] [Criterion 2] - -## Context -See SPEC-001 for full context. -``` diff --git a/dist/codex/skills/council/SKILL.md b/dist/codex/skills/council/SKILL.md index 14d370f53..fa2cc2b4d 100644 --- a/dist/codex/skills/council/SKILL.md +++ b/dist/codex/skills/council/SKILL.md @@ -77,13 +77,13 @@ Councils stay **local**. Even when the workspace uses Linear-native mode, council files live in `.agents/councils/` — they are deliberation artifacts, not executable work, and belong with specs in git. -When a council resolves a spec's open questions: +When a council resolves an issue's open questions: -- Include the spec ID in council frontmatter (e.g., `spec: SPEC-024`). This +- Include the issue ID in council frontmatter (e.g., `issue: LOAF-42`). This is already the common pattern. -- If the spec's `linear_parent` has been populated by breakdown, also - include `linear_parent: ENG-198` in council frontmatter so a reader on - Linear can trace back to the deliberation. +- If the issue is tracked in Linear (tracker authority), also include the + tracker key (e.g., `linear_parent: ENG-198`) in council frontmatter so a + reader on Linear can trace back to the deliberation. - Do not post council content to the Linear parent issue. A brief one-line reference ("Resolved via council 2026-04-21 — see .agents/councils/…") in a sub-issue comment is sufficient if the council drove a specific task diff --git a/dist/codex/skills/documentation-standards/SKILL.md b/dist/codex/skills/documentation-standards/SKILL.md index c4aed9a36..da018c76f 100644 --- a/dist/codex/skills/documentation-standards/SKILL.md +++ b/dist/codex/skills/documentation-standards/SKILL.md @@ -49,7 +49,7 @@ Standards for ADRs, API docs, changelogs, and diagrams. - Internal spec/task IDs - Verbatim commit or PR-title dumps - **Good examples:** - - "Add `loaf release --post-merge` guardrails for tagged GitHub releases" + - "Add `loaf release suggest` and `loaf release cut` for retroactive releases" - "Fix journal context routing when hook payloads are empty" - "Document worktree-aware `.agents/` storage for linked checkouts" - **Version protection:** diff --git a/dist/codex/skills/explore/SKILL.md b/dist/codex/skills/explore/SKILL.md index 829912f8c..4586c741f 100644 --- a/dist/codex/skills/explore/SKILL.md +++ b/dist/codex/skills/explore/SKILL.md @@ -2,16 +2,16 @@ name: explore description: >- Conducts divergent inquiry as a durable Exploration with portable checkpoints, - conversation provenance, and Intent capture that survive compaction and - harness changes. Agent technique — not a user entry point: route "explore - this" and similar user asks to pitch; use this technique from inside pitch or - other agent work when the direction is genuinely undecided, or when resuming a - named Exploration. Produces Exploration records, portable checkpoints, and - tracked or deferred Intents; Exploration machinery and the four-field - checkpoint contract stay intact. Not for evidence gathering on a known - question (use research), continuing implementation (use implement), processing - the intake queue (use triage), shaping a bounded Change (use shape), problem - discovery (use pitch), or quick capture (use idea). + conversation provenance, and backlog-issue dispositions that survive + compaction and harness changes. Agent technique — not a user entry point: + route "explore this" and similar user asks to pitch; use this technique from + inside pitch or other agent work when the direction is genuinely undecided, or + when resuming a named Exploration. Produces Exploration records, portable + checkpoints, and backlog issues for crystallized directions; Exploration + machinery and the four-field checkpoint contract stay intact. Not for evidence + gathering on a known question (use research), continuing implementation (use + implement), processing the intake queue (use triage), shaping a bounded issue + (use shape), problem discovery (use pitch), or quick capture (use idea). version: 0.2.21 --- @@ -30,6 +30,7 @@ Divergent inquiry with durable continuity. An Exploration is a relational identi - Process - Checkpoint Discipline - Resumption +- Parking a direction - Techniques - Related Skills @@ -39,37 +40,39 @@ Divergent inquiry with durable continuity. An Exploration is a relational identi - You choose what an Exploration means and when to checkpoint; the CLI validates and performs the operation you request. Never expect the CLI to classify or decide for you. - Checkpoint before the context window gets hostile: every checkpoint must carry all four portable fields — purpose, conclusions, unresolved, next action — each self-sufficient without this conversation. - A conversation handle or log path is provenance, never context. Presence of handles does not make an Exploration resumable; only a portable checkpoint does. -- Capture crystallized directions as Intent (`loaf intent create`), deferred bodies with `--disposition deferred`; never leave a substantial direction only in prose. -- Never create Git artifacts, branches, worktrees, or Changes from Explore; when a direction is ready for problem discovery hand it to pitch, and when it is ready for bounded delivery hand it to shape. +- Capture crystallized directions as backlog issues (`loaf issue new "<title>" --status backlog`); park remaining unsharp questions on that issue with `--fog`. Never leave a substantial direction only in prose. +- Never create Git artifacts, branches, or worktrees from Explore; when a direction is ready for problem discovery hand it to pitch, and when it is ready for bounded delivery hand it to shape (issue preparation). - Never store transcripts, prompts, or tool output in checkpoints or items; curate semantic context instead. -- Not a user slash front door — human "explore this" / "where do I start" intent routes to pitch. +- Not a user slash front door — human "explore this" / "where do I start" routes to pitch. ## Verification - The Exploration exists with `portable_context_present: true` after the first checkpoint (`loaf exploration list`). - `loaf exploration context <ref> --json` returns the four-field core whole, and a fresh reader could identify the next action from it alone. -- Crystallized directions exist as Intents with derived dispositions (`loaf intent list`). +- Crystallized directions exist as backlog issues (`loaf issue list --status backlog`); issue aliases named in the checkpoint match those rows. - Conversation provenance, when recorded, carries harness and locality facts without any transcript content. ## Quick Reference | Operation | Command | |-----------|---------| -| Start an inquiry | `loaf exploration create --title <title> [--from <intent-or-source>]...` | +| Start an inquiry | `loaf exploration create --title <title> [--from <source>]...` | | Checkpoint | `loaf exploration checkpoint <ref> --purpose <p> --conclusions <c> --unresolved <u> --next <n> [--item candidate:<text>]... [--operation-id <key>]` | | Resume elsewhere | `loaf exploration context <ref> --json` | -| Track a direction | `loaf intent create --title <t> --body <b> --from <source>...` | -| Defer a direction | `loaf intent create --title <t> --body <b> --disposition deferred --why <w> --boundary <bd> --trigger <tr> --operation-id <key> [--from <source>]` | +| File a direction | `loaf issue new "<title>" --status backlog [--parent <ref>] [--kind delivery\|decision] [--fog <text>] [--body <text>]` | +| Optional bucket | `loaf issue bucket <ref> now\|next\|later\|none` | | Record provenance | `loaf conversation create --title <label>` then `loaf conversation handle add <id> --harness <h> --handle <opaque-id> [--locality <scope>] [--log-ref <path>]` | | Associate conversation | `loaf exploration conversation add <exploration> <conversation-id>` | +`--from` on create accepts journal entries, handoffs, reports, and findings. It does not accept issue, spark, or idea refs — name those in the checkpoint and in the issue body instead. Buckets are labels only and are never read as a constraint. `fog` is writeable only at create. + ## Process 1. **Orient.** If the input names an existing Exploration, run `loaf exploration context <ref>` and continue from its recommended next action. Otherwise check `loaf exploration list` before creating a duplicate inquiry. -2. **Create or resume.** New inquiries get `loaf exploration create` with `--from` links to the Intents, journal entries, reports, or findings that motivated them. +2. **Create or resume.** New inquiries get `loaf exploration create` with `--from` links to the journal entries, reports, findings, or handoffs that motivated them. 3. **Diverge.** Expand the option space before judging it. Use the brainstorm stance (below), research, scouting, prototypes, or spikes as the question demands. -4. **Capture as you go.** Incidental thoughts become sparks; explicit propositions become ideas; deliberately tracked directions become Intents with their sources linked. -5. **Checkpoint.** At every meaningful plateau — and always before ending a session — append a checkpoint with the four portable fields and optional `candidate:`/`evidence:` items. +4. **Capture as you go.** Incidental thoughts become sparks (`loaf spark capture --scope <scope> --text <text>`); explicit propositions become ideas (`loaf idea capture --title "..."`); directions worth keeping become backlog issues. Resolve the capture against the issue so the direction appears once: `loaf spark resolve <ref> --by <issue-ref>` or `loaf idea resolve <ref> --by <issue-ref>`. +5. **Checkpoint.** At every meaningful plateau — and always before ending a session — append a checkpoint with the four portable fields and optional `candidate:`/`evidence:` items. Name any filed issue aliases in conclusions or next. 6. **Record provenance when useful.** Machine-local conversation handles and log locators help forensic navigation later; add them explicitly, and never infer identity from the current session. ## Checkpoint Discipline @@ -81,17 +84,17 @@ The four fields are the portable contract; each is capped at 4096 UTF-8 bytes an - **unresolved** — the open question or decision the inquiry currently turns on. - **next** — the recommended next action, concrete enough for a fresh agent to execute without this conversation. -Larger detail belongs in ordered `--item candidate:` and `--item evidence:` entries or in related reports, not crammed into the core fields. +Larger detail belongs in ordered `--item candidate:` and `--item evidence:` entries or in related reports, not crammed into the core fields. When filing an issue, copy still-unsharp questions into `--fog`; they will not be editable on the issue after create. ## Resumption -A new conversation, harness, or machine resumes with `loaf exploration context <ref> --json`: the portable core returns whole, and each optional layer (items, intents, evidence, conversations) reports counts, truncation, and its exact expansion command. Source handles appear with their last observed availability; treat unavailable ones as lost without ceremony — the checkpoint is the context. If `portable_context_present` is false, the Exploration was never checkpointed: rebuild understanding from linked sources, then write the missing checkpoint first. +A new conversation, harness, or machine resumes with `loaf exploration context <ref> --json`: the portable core returns whole, and each optional layer reports counts, truncation, and its exact expansion command. Source handles appear with their last observed availability; treat unavailable ones as lost without ceremony — the checkpoint is the context. If `portable_context_present` is false, the Exploration was never checkpointed: rebuild understanding from linked sources, then write the missing checkpoint first. -Before continuing, inspect the linked Intents in the context. If an Intent this inquiry was developing has since been resolved, do not silently reopen it: acknowledge the resolution, and if the checkpoint's next action still matters, create a successor Intent, record why in its body, and relate the lineage with `loaf link create --from <new-intent-ref> --to <resolved-intent-ref> --type derived-from`. Continued evidence gathering that serves no unresolved Intent should say so in its next checkpoint. +Before continuing, inspect issue aliases named in the checkpoint. If an issue this inquiry was developing is now done, cancelled, or duplicate, do not silently reopen it: acknowledge the resolution, and if the checkpoint's next action still matters, file a successor backlog issue and record why in its body. Continued evidence gathering that serves no open issue should say so in its next checkpoint. -## Deferring +## Parking a direction -An Exploration is never deferred, paused, or closed — it has no lifecycle to transition. "Defer this exploration" means two concrete acts: checkpoint the current state honestly, then defer the direction it was developing as an Intent — `loaf intent defer` on the linked Intent, or `loaf intent create --disposition deferred` for a new one followed by `loaf link create --from <exploration-ref> --to <intent-ref> --type explores`. The deferred Intent carries the revisit trigger; the Exploration simply waits, resumable from its checkpoint whenever the Intent is resumed. +An Exploration is never paused or closed — it has no lifecycle to transition. When the user wants to park or set aside the inquiry, do two concrete acts: checkpoint the current state honestly, then file the direction it was developing as a backlog issue (`loaf issue new "<title>" --status backlog`, optional `--parent`, `--fog` for remaining unsharp questions, optional `loaf issue bucket <ref> later`). The issue is the revisit surface; the Exploration simply waits, resumable from its checkpoint. ## Techniques @@ -99,8 +102,8 @@ Brainstorm's full divergent stance lives inside Explore: generate options before ## Related Skills -- **pitch** — human problem-discovery front door; route user entry intent here; reach for explore from inside pitch when still undecided -- **triage** — processes the intake queue and may disposition items toward pitch, shape, or agent-side explore -- **shape** — narrows one well-understood direction into a bounded Change +- **pitch** — human problem-discovery front door; route user entry here; reach for explore from inside pitch when still undecided +- **triage** — processes the intake queue and may disposition items toward a backlog issue, pitch, shape, or agent-side explore +- **shape** — prepares a well-understood direction as a bounded issue - **research** — evidence gathering for a known question, usable inside an Exploration - **idea** — quick capture without inquiry diff --git a/dist/codex/skills/foundations/references/code-review.md b/dist/codex/skills/foundations/references/code-review.md index 2c37d2f0b..37c8a2e10 100644 --- a/dist/codex/skills/foundations/references/code-review.md +++ b/dist/codex/skills/foundations/references/code-review.md @@ -86,5 +86,5 @@ Project code review conventions and workflow. | Command | Code Review Role | |---------|-----------------| | implement | Self-review before marking complete | -| breakdown | Review task scope and approach | +| shape | Review issue scope and DoD before implementation | | reflect | Note review feedback patterns | diff --git a/dist/codex/skills/foundations/references/tdd.md b/dist/codex/skills/foundations/references/tdd.md index 28c883709..1ea6fe7c9 100644 --- a/dist/codex/skills/foundations/references/tdd.md +++ b/dist/codex/skills/foundations/references/tdd.md @@ -54,6 +54,6 @@ If the failing test points to a non-obvious root cause, or if your first fix att | Phase | TDD Role | |-------|----------| | shape | Test conditions become TDD test cases | -| breakdown | Each task should have clear test targets | +| shape | Each promoted issue should have clear test targets in its DoD | | implement | Follow TDD cycle for each task | | reflect | Note TDD friction points for improvement | diff --git a/dist/codex/skills/foundations/references/verification.md b/dist/codex/skills/foundations/references/verification.md index d3cf84e1f..142aa7dfc 100644 --- a/dist/codex/skills/foundations/references/verification.md +++ b/dist/codex/skills/foundations/references/verification.md @@ -123,7 +123,7 @@ npm run lint # Check: No errors or warnings | Command | Verification Point | |---------|-------------------| | implement | Before marking session complete | -| breakdown | Each task has verification criteria | +| shape | Each issue has verification criteria (V-tier DoD) | | shape | Test conditions define verification | | reflect | Note verification gaps discovered | diff --git a/dist/codex/skills/git-workflow/SKILL.md b/dist/codex/skills/git-workflow/SKILL.md index 798f55dfc..80a2ce2e1 100644 --- a/dist/codex/skills/git-workflow/SKILL.md +++ b/dist/codex/skills/git-workflow/SKILL.md @@ -24,7 +24,7 @@ Git conventions for branching, commits, PRs, and merge workflow. - Use Conventional Commits format for all commit messages - Commit complete units of work -- don't commit partial or in-progress changes - Squash merge feature branches -- never merge commits directly -- One branch per spec/feature; branch name format: `feat/{slug}` +- One branch per issue; `loaf issue start` creates `issue/<alias-or-id>` (or use `feat/{slug}` / `fix/{slug}` when not starting from an issue) - Never force-push to `main` or shared branches - Never push without explicit user confirmation @@ -38,7 +38,7 @@ Git conventions for branching, commits, PRs, and merge workflow. | Action | Command/Pattern | |--------|----------------| -| Branch naming | `feat/{slug}`, `fix/{slug}`, `chore/{slug}` | +| Branch naming | `issue/<alias-or-id>` from `loaf issue start`; else `feat/{slug}`, `fix/{slug}`, `chore/{slug}` | | Commit format | `type: description` | | Squash merge | `gh pr merge --squash` | | PR creation | `gh pr create --title "..." --body "..."` | diff --git a/dist/codex/skills/git-workflow/references/commits.md b/dist/codex/skills/git-workflow/references/commits.md index a879b8709..3e9e3c00d 100644 --- a/dist/codex/skills/git-workflow/references/commits.md +++ b/dist/codex/skills/git-workflow/references/commits.md @@ -110,13 +110,14 @@ Closes BACK-123 ## Branch Naming ``` +issue/<alias-or-id> <type>/<description> -<type>/TASK-123-description ``` ### Types -- `feat/` - New features (e.g., `feat/spec-010-task-management-cli`) +- `issue/` - Started from `loaf issue start` (`issue/loaf-42`) +- `feat/` - New features (e.g., `feat/thermal-rating-cli`) - `fix/` - Bug fixes - `hotfix/` - Critical production fixes - `release/` - Release preparation @@ -126,7 +127,7 @@ Closes BACK-123 - Lowercase with hyphens (kebab-case) - Short but descriptive (max 50 chars) -- Include spec or task slug when applicable (e.g., `feat/spec-010-task-management-cli`) +- Prefer the started worktree branch from `loaf issue start` when implementing an issue ## Pull Request Format @@ -140,26 +141,10 @@ feat: add thermal rating calculation ### Description -Focus on **review context** — what changed, why, and how to test. Do not include squash merge commit text in the PR body. +The PR body is `loaf issue render <ref>` output — paste-ready, no manual editing. Definition-of-done criteria in the render are the review checklist. Do not include squash merge commit text in the PR body. -```markdown -## Summary - -Brief description of what this PR adds/changes and why. - -- Bullet points covering key changes -- Focus on what a reviewer needs to know - -## Test plan - -- [ ] Unit tests added/updated -- [ ] Integration tests pass -- [ ] Manual testing performed - -## Related Issues - -Closes BACK-123 -Refs BACK-124 +``` +gh pr create --title "type: summary" --body "$(loaf issue render <ref>)" ``` ### Merge Strategy @@ -182,10 +167,10 @@ published release notes read as user-facing prose, not an internal worklog. Internal terms that have no meaning outside the team's working context: -- Spec IDs and task IDs (`SPEC-024`, `TASK-042`) +- Internal work-unit numbering that is not the issue ID (issue IDs like `LOAF-42` belong in commits — release attribution reads them) - Session, sprint, or branch references - Internal terminology from skills/docs that isn't part of the user's mental model — e.g. `Q1`/`Q2`/`Q3` question numbers from a Triage Gate, internal gate-logic notation like `(Q1 OR Q2) AND Q3`, hook IDs that aren't user-facing -- "How the work got done" framing — interview steps, breakdown steps, review gates +- "How the work got done" framing — interview steps, decomposition steps, review gates ### Keep @@ -204,7 +189,7 @@ Internal terms that have no meaning outside the team's working context: ### Auto-generated Entries -When `loaf release` auto-generates the `[Unreleased]` section from commit history, those entries inherit any internal terms present in the commit messages. Treat the generated output as a draft: rewrite it under the curated path before bumping. The release skill preserves curated content when it's already in `[Unreleased]` — curate first, bump second. +`loaf release suggest` drafts notes from landed issues; `loaf release cut` prepends them into `CHANGELOG.md`. Treat drafted notes as a draft: rewrite internal terms before cutting. Curate `[Unreleased]` as PRs land so the later cut reads as user-facing prose. Before approving a release bump, compare `[Unreleased]` against the actual release range and remove scaffolding language introduced by specs, reviews, tasks, or session triage. If an entry only explains why the work was discovered or how the work was organized, it does not belong in the changelog. @@ -228,14 +213,15 @@ Before approving a release bump, compare `[Unreleased]` against the actual relea - Add agent attribution - Mix unrelated changes - Commit secrets or sensitive data -- Put SPEC or TASK IDs in commit subject (use human-readable names) +- Put work-unit IDs in the commit subject (use human-readable names). Issue aliases belong in the body so `loaf release suggest` can attribute the commit. ### ID References - **IDs belong in footer, not subject line** - - Bad: `feat: implement SPEC-002 invisible sessions` - - Good: `feat: implement invisible sessions and task board` + - Bad: `feat: implement LOAF-42 invisible sessions` + - Good: `feat: implement invisible sessions` - Use descriptive names that are understandable without looking up IDs +- Issue aliases (`LOAF-42`) go in the body so release attribution can find them - Linear issue IDs go in footer only (e.g., `Closes BACK-123`) ## Semantic Versioning @@ -277,6 +263,6 @@ BREAKING CHANGE: Description of breaking change. **Convention:** - Use standard SemVer pre-release identifiers (`alpha`, `beta`, or `rc`) when publishing pre-release versions. -- `loaf release` handles all bump types: `prerelease`, `release`, `major`, `minor`, `patch` +- `loaf release cut --bump` handles all bump types: `prerelease`, `release`, `major`, `minor`, `patch` **Not required** — projects using simple `MAJOR.MINOR.PATCH` versioning can ignore pre-release suffixes entirely. This convention is for projects publishing staged pre-releases before stable releases. diff --git a/dist/codex/skills/housekeeping/SKILL.md b/dist/codex/skills/housekeeping/SKILL.md index e438c9632..d71c79537 100644 --- a/dist/codex/skills/housekeeping/SKILL.md +++ b/dist/codex/skills/housekeeping/SKILL.md @@ -1,12 +1,12 @@ --- name: housekeeping description: >- - Reviews and maintains agent artifacts in .agents/ — specs, plans, drafts, - handoffs, councils, and reports. Use when the user asks "housekeeping," "clean - up," or "tidy up .agents/." Provides hygiene recommendations, archives - completed work, and ensures extracted knowledge is preserved. Not for - strategic reflection (use reflect) or knowledge management (use - knowledge-base). + Reviews and maintains agent artifacts in .agents/ plus issue hygiene — + reports, handoffs, councils, archived issues, and stale started worktrees. Use + when the user asks "housekeeping," "clean up," or "tidy up .agents/." Provides + hygiene recommendations, archives completed work, and ensures extracted + knowledge is preserved. Not for strategic reflection (use reflect) or + knowledge management (use knowledge-base). version: 0.2.21 --- @@ -17,40 +17,43 @@ version: 0.2.21 - Verification - Quick Reference - Mode-Aware Checks -- Process -- Guardrails -- Related Skills +- Suggests Next +- Topics +- Artifact Naming -Systematic review and archival of all `.agents/` artifacts with Linear-aware checks. +Systematic review of `.agents/` artifacts and issue workspaces. ## Critical Rules **Always** - Log invocation as the first action: `loaf journal log "skill(housekeeping): <scope or trigger>"` - Review EVERY file individually — never sample or average -- Check Linear issue status before archiving linked specs +- Check Loaf issue status (and Linear overlay, if enabled) before archiving linked artifacts - Extract lessons learned and decisions before archiving -- Use CLI (`loaf housekeeping`, `loaf task archive`, `loaf spec archive`) — never raw `mv` +- Use CLI (`loaf housekeeping`, `loaf report archive`, `loaf issue status` / `loaf issue stop`) — never raw `mv` - Treat `.agents/handoffs/` as first-class but disposable: keep active/final handoffs, delete only after confirmed deprecated status -- Check report `status` is `processed` before archiving reports (see [templates/report.md](templates/report.md)) -- In SQLite-backed projects, verify lifecycle changes through `loaf task list --json`, `loaf spec list --json`, and `loaf report list --json`; use `loaf task sync` only for Markdown compatibility repair +- Check report `status` is `done` (or `final`) before archiving reports (see [templates/report.md](templates/report.md)) +- In SQLite-backed projects, verify lifecycle through `loaf issue list --json`, `loaf issue list --started`, `loaf issue list --archived`, and `loaf report list --json` - When delegated subagents are available, use the `librarian` profile for - `.agents/`-scoped durable artifact tending: report/spec/handoff hygiene, + `.agents/`-scoped durable artifact tending: report/handoff hygiene, staleness notes, and lifecycle-safe cleanup recommendations. Housekeeping still owns user confirmation and final archive decisions. -- Log outcome to the project journal: `loaf journal log "decision(housekeeping): archived N specs, M reports"` +- Log outcome to the project journal: `loaf journal log "decision(housekeeping): archived N reports; stopped M stale worktrees"` **Never** - Auto-archive without user confirmation for each artifact - Skip spark extraction before deleting brainstorm drafts - Leave `archived_at` or `archived_by` fields empty in archived files +- Run `loaf issue stop` from inside the started worktree +- Dispatch cleanup agents into a live started worktree another agent occupies ## Verification After work completes, verify: -- Tasks archived via `loaf task archive` -- Specs archived via `loaf spec archive` -- SQLite-backed task/spec/report state reflects lifecycle changes when initialized +- Reports archived via `loaf report archive` after processing +- Archived issues reviewed via `loaf issue list --archived` (`cancelled` / `duplicate` archive through `loaf issue status`) +- Stale started worktrees reviewed via `loaf issue list --started` (a `(missing)` marker means the recorded path is gone) +- SQLite-backed report/issue state reflects lifecycle changes when initialized - Drafts checked for unprocessed sparks before deletion - Handoffs deleted only after explicit deprecation is confirmed - Summary table presented showing all actions taken @@ -62,11 +65,18 @@ After work completes, verify: ```bash loaf housekeeping --dry-run # Preview recommendations loaf housekeeping # Run artifact scanner -loaf task archive TASK-XXX # Archive single task -loaf spec archive SPEC-XXX # Archive single spec -loaf task sync # Compatibility diagnostic in SQLite-backed projects +loaf issue list --started # Started worktrees (alias, title, branch, path) +loaf issue list --archived # cancelled / duplicate rows +loaf issue stop <ref> # Remove worktree; keeps branch; does not change status +loaf issue status <ref> cancelled # Archive an abandoned issue +loaf issue status <ref> duplicate --duplicate-of <surviving> +loaf report archive <report> # Archive a processed report ``` +`loaf housekeeping` still prints leftover `specs` / `tasks` sections when those +SQLite tables have rows — compatibility scan only. Do not create new records +there. The `loaf task` / `loaf spec` CLI is legacy. + The project journal is append-only and never archived — it is not a housekeeping target. It is the canonical record housekeeping reads when extracting decisions before archiving other artifacts. @@ -75,19 +85,12 @@ before archiving other artifacts. | Artifact | Active Location | Archive | Action | |----------|-----------------|---------|--------| -| Tasks (local mode only) | SQLite state | SQLite archived status | `loaf task archive` | -| Specs | SQLite state + `.agents/specs/` authored prose | `archive/` | `loaf spec archive` | +| Issues | SQLite (`loaf issue list`) | `cancelled` / `duplicate` via `loaf issue status` | Confirm, then status; `done` is ship, not housekeeping | +| Started worktrees | `loaf issue list --started` | `loaf issue stop <ref>` | Stop stale or `(missing)` trees after confirmation | | Drafts / brainstorms | SQLite state | SQLite resolved/archived status | User decision (spark extraction first) | | Handoffs | `.agents/handoffs/` | delete | Delete after status is confirmed `deprecated` | | Reports | SQLite state + generated/authored report Markdown | `archive/` | `loaf report archive` after processing | -**Linear-native mode** (when `integrations.linear.enabled` is `true` in -`.agents/loaf.json`): local `TASK-NNN.md` files do not exist for new specs — -Linear issues are the task record. The "Tasks" row above is inert unless the -project has pre-Linear local tasks lingering (see [Mode-Aware Checks](#mode-aware-checks)). -Specs still archive locally — they are the canonical deliberation artifact in -every mode. - ## Cross-Branch Reconciliation If a stale branch reintroduces `.agents/{tasks,ideas,sparks,sessions,brainstorms,drafts}/` @@ -97,35 +100,30 @@ or `.agents/TASKS.json`, keep the deletion from the cutover branch and rerun ## Mode-Aware Checks -When `integrations.linear.enabled` is `true` in `.agents/loaf.json`, apply -these additional checks: +### Started worktrees -### Spec / Linear parent reconciliation +For each row from `loaf issue list --started`: -For each spec file (active and archive) with a `linear_parent:` frontmatter key: +1. If `(missing)`, flag as **stale started workspace** — the row still records a path that is gone. Offer `loaf issue stop <ref>` after confirmation. Stop does not mark the issue `done`. +2. If the path exists but the issue is `done` / `cancelled` / `duplicate`, flag as **worktree outlived the issue** — same offer. +3. If the path exists and status is `active`, leave it unless the user asks to stop. -1. Call `get_issue` with the issue identifier. If it 404s or returns - archived/deleted, flag as **orphaned linear_parent** — the local spec - references a Linear issue that no longer exists. -2. If the spec's local status is `done` (or legacy `complete`) or `archived`, - verify the Linear parent issue is in a `completed`-type state. If not - (e.g., still "In Progress"), flag as **status mismatch** — "Spec marked - complete locally but Linear parent ENG-198 is still 'In Progress'." -3. If the spec's local status is `in_progress` and the Linear parent is - already `completed`, flag the inverse — spec likely needs to be moved to - `done` and archived. +Treat these as **warnings**, not auto-fixes. -Treat all three as **warnings**, not auto-fixes. The user decides resolution. +### Linear overlay -### Pre-Linear local task detection +When `integrations.linear.enabled` is `true` in `.agents/loaf.json`, the tracker +adapter is not shipped. If a report or journal entry names a Linear id next to +a Loaf alias, you may `get_issue` and flag an obvious mismatch (Linear Done vs +Loaf still `active`, or the reverse). Warnings only. Do not drive Loaf status +from Linear. -If Linear is enabled but local task records exist in SQLite, -surface them with context: "Pre-Linear local tasks detected. These aren't -auto-migrated. Either continue using them, run a manual migration, or -archive if superseded by Linear issues." +### Leftover board rows -Do NOT auto-migrate. Migration is user-initiated and out of scope for -housekeeping. +If `loaf housekeeping --dry-run` still reports `tasks` or `specs` cleanup +candidates, surface them: "Legacy board rows are still in SQLite. They are not +the work unit. Archive only if the user confirms they are superseded by Loaf +issues." Do NOT auto-migrate. ## Suggests Next @@ -136,9 +134,9 @@ After housekeeping, suggest reflect if the session produced key decisions or lea | Topic | Reference | Use When | |-------|-----------|----------| | Report Template | [templates/report.md](templates/report.md) | Creating cleanup reports | -| Linear Integration | `orchestration/references/linear.md` | Checking external issue status | +| Linear Integration | `orchestration/references/linear.md` | Checking external tracker overlay | | Journal Continuity | `orchestration/references/journal.md` | Understanding the project journal model | ## Artifact Naming -Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or Change already records that provenance. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. +Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or the issue already records that provenance. Put the source in a front-matter field (`source: LOAF-42`), not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. diff --git a/dist/codex/skills/housekeeping/templates/journal.md b/dist/codex/skills/housekeeping/templates/journal.md index caf7409fd..9a0a61f8f 100644 --- a/dist/codex/skills/housekeeping/templates/journal.md +++ b/dist/codex/skills/housekeeping/templates/journal.md @@ -13,7 +13,7 @@ not create or edit journal markdown as the source of truth — use ## Entries -[YYYY-MM-DD HH:MM] skill(implement): implementing TASK-042 +[YYYY-MM-DD HH:MM] skill(implement): implementing LOAF-42 [YYYY-MM-DD HH:MM] decision(scope): description of decision [YYYY-MM-DD HH:MM] discover(scope): something learned [YYYY-MM-DD HH:MM] block(scope): what is blocked diff --git a/dist/codex/skills/housekeeping/templates/report.md b/dist/codex/skills/housekeeping/templates/report.md index d0894e593..ca201e869 100644 --- a/dist/codex/skills/housekeeping/templates/report.md +++ b/dist/codex/skills/housekeeping/templates/report.md @@ -8,7 +8,7 @@ title: "Report: [Topic]" type: research | audit | analysis | council created: YYYY-MM-DDTHH:MM:SSZ status: draft | done | archived -source: SPEC-XXX | TASK-XXX | ad-hoc +source: LOAF-42 | ad-hoc finalized_at: YYYY-MM-DDTHH:MM:SSZ # Set when status → done archived_at: YYYY-MM-DDTHH:MM:SSZ # Set when status → archived archived_by: orchestrator diff --git a/dist/codex/skills/idea/SKILL.md b/dist/codex/skills/idea/SKILL.md index f1c268823..c5ba62f48 100644 --- a/dist/codex/skills/idea/SKILL.md +++ b/dist/codex/skills/idea/SKILL.md @@ -3,11 +3,12 @@ name: idea description: >- Captures ideas into structured nuggets for later evaluation. Use when the user says "I have an idea" or "note this down." Also activate when a specific - actionable concept crystallizes during conversation. Ideas and sparks are - capture primitives routed through triage, which chooses dispositions such as - tracking an Intent or handing to pitch. Not for problem discovery (use pitch), - processing the intake queue (use triage), shaping (use shape), or agent-side - divergent inquiry when direction is undecided (use explore as a technique). + actionable concept crystallizes during conversation. Ideas and sparks stay + capture primitives routed through triage, which files worth-keeping items as + backlog issues or hands them to pitch or shape. Not for problem discovery (use + pitch), processing the intake queue (use triage), shaping a bounded issue (use + shape), or agent-side divergent inquiry when direction is undecided (use + explore as a technique). version: 0.2.21 --- @@ -25,7 +26,6 @@ Capture ideas quickly with minimal friction. - Quick Reference - Purpose - Process -- Idea Lifecycle - Guardrails - Related Skills @@ -35,7 +35,7 @@ Capture ideas quickly with minimal friction. - 2-3 questions maximum -- don't turn capture into an interview - Infer metadata automatically -- don't ask for tags, title, or links - One idea per captured row/artifact -- keep them atomic -- No shaping or pitching here -- problem discovery is pitch; bounding is shape +- No shaping, pitching, or filing issues here -- problem discovery is pitch; bounding is shape; dispositions are triage - Capture through `loaf idea capture --title ...` when SQLite state is initialized; log notable context with `loaf journal log` @@ -57,7 +57,7 @@ Capture ideas quickly with minimal friction. ## Purpose -Ideas are raw nuggets — unprocessed, unshaped, but worth remembering. The goal is **speed of capture**, not thoroughness. An idea is retained material, nothing more: tracking it as an Intent, handing it to pitch for problem discovery, handing it to shape when already well-understood, or archiving it are triage dispositions chosen later by the user. +Ideas are raw nuggets — unprocessed, unshaped, but worth remembering. The goal is **speed of capture**, not thoroughness. An idea is retained material, nothing more: filing it as a backlog issue, handing it to pitch for problem discovery, handing it to shape when already well-understood, or archiving it are triage dispositions chosen later by the user. --- @@ -81,7 +81,7 @@ Ideas are raw nuggets — unprocessed, unshaped, but worth remembering. The goal ## Related Skills -- **triage** — process the intake queue and choose dispositions (including hand to pitch or shape) +- **triage** — process the intake queue and choose dispositions (file as backlog issue, hand to pitch, or hand to shape) - **pitch** — problem-discovery ceremony when a captured idea needs a brief before shaping -- **shape** — develop a well-understood direction into a bounded Change +- **shape** — develop a well-understood direction into a bounded issue - **explore** — agent technique when direction is genuinely undecided (not a user front door) diff --git a/dist/codex/skills/idea/templates/idea.md b/dist/codex/skills/idea/templates/idea.md index b070494d3..b631ca832 100644 --- a/dist/codex/skills/idea/templates/idea.md +++ b/dist/codex/skills/idea/templates/idea.md @@ -11,7 +11,7 @@ title: "[Idea Title]" captured: YYYY-MM-DDTHH:MM:SSZ status: raw tags: [] -related: [] # Optional: spec IDs, idea filenames, or other references +related: [] # Optional: issue aliases, idea filenames, or other references origin: # Optional: draft filename this spark came from (e.g. drafts/YYYYMMDD-brainstorm-slug.md) --- @@ -31,5 +31,5 @@ origin: # Optional: draft filename this spark came from (e.g. dra --- -*Captured via idea -- shape with shape when ready* +*Captured via idea — triage later (backlog issue, pitch, or shape)* ``` diff --git a/dist/codex/skills/implement/SKILL.md b/dist/codex/skills/implement/SKILL.md index 775c75488..6baaedb80 100644 --- a/dist/codex/skills/implement/SKILL.md +++ b/dist/codex/skills/implement/SKILL.md @@ -1,18 +1,19 @@ --- name: implement description: >- - Orchestrates implementation work through agent delegation and batch execution. - Use for all implementation work — features, bug fixes, refactors, and code - changes. Picks Change task files when present and flips checkboxes in - delivering commits. Logs to the project journal and produces agent spawn plans - and progress tracking. Not for shaping (use shape), breakdown (use breakdown), - research, or review. + Orchestrates implementation work through agent delegation and batch execution + against Loaf issues. Use for all implementation work — features, bug fixes, + refactors, and code changes. Picks the next issue from loaf issue frontier, + delegates one agent per started worktree, and treats definition-of-done + criteria as the completion contract. Logs to the project journal and produces + agent spawn plans and progress tracking. Not for shaping or decomposition (use + shape), research, or review. version: 0.2.21 --- # Implement -You are the coordinator. Start by understanding the task: +You are the coordinator. Work units are issues. ## Contents - Critical Rules @@ -20,7 +21,7 @@ You are the coordinator. Start by understanding the task: - Quick Reference - Step 0: Context Check - Input Detection -- Linear-Native Routing +- Pick-up and Dispatch - Agent Spawning - Journal First - Guardrails @@ -38,27 +39,32 @@ You are the coordinator. Start by understanding the task: **You are the ORCHESTRATOR, not the implementer.** -- Log `loaf journal log "skill(implement): <task/spec/context>"` as the first action. -- **Change-first task packets:** prefer `docs/changes/<folder>/tasks/TASK-NNN-*.md` as the delegation brief. Flip checkboxes `- [ ]`→`- [x]` in the same commit that delivers the work (outside `docs/changes/` paths must land with the flip for provenance). Use `loaf change tasks --json` for the index. -- Commit task packets unchecked before executing them — a packet that first lands already-checked induces no flip transition, and the evidence trail never exists. -- Compatibility: existing `TASK-XXX` / `SPEC-XXX` SQLite records remain supported until converted; they are not the default for new work. +- Log `loaf journal log "skill(implement): LOAF-42 — <what>"` as the first action. Substitute the real alias (or opaque id) and a short intent. +- **Pick-up-next is `loaf issue frontier`.** That view is open (`triage` / `backlog` / `todo`), unblocked, and unclaimed (not `active`, no started worktree). Derived at read time. +- **The delegation brief is the issue row** — `loaf issue show <ref>` / `loaf issue render <ref>`: body, definition-of-done criteria, children. There is no other packet. +- **One agent, one worktree.** `loaf issue start <ref>` creates the branch and worktree and moves status to `active`. Before dispatch, run `loaf issue list --started`. Never send two agents into the same worktree. +- **Definition of done is the completion contract.** `loaf issue verify <ref>` runs V-tier criteria from the repository root and writes nothing. H-tier is reviewed by a human or this orchestrator. Completion is the work landing plus `loaf issue status <ref> done`. Do not flip checkboxes. Provenance is the delivering commits and the PR whose body is `loaf issue render <ref>`. +- Shape prepares issues. If `loaf issue check <ref>` does not report the delivery issue shaped (or the decision issue ready), stop and send the work to shape. Do not mint a new issue from this skill. ### Orchestrator Can Do Directly - Log journal entries, read journal context, create council files -- Use your harness's task/todo tracking surface; **if `integrations.linear.enabled` is `true` in `.agents/loaf.json`**, use Linear MCP tools when helpful +- Use your harness's todo tracking surface; **if `integrations.linear.enabled` is `true` in `.agents/loaf.json`**, Linear MCP is an overlay only — Loaf issues remain the work unit and Linear never drives Loaf status - Read any file for context - Ask clarifying questions +- Run `loaf issue` read commands, `loaf issue start` / `stop`, `loaf issue status`, and open a PR whose body is `loaf issue render` output ### Orchestrator MUST Delegate (via agent spawn) -**ALL code changes, documentation edits, and implementation work** to specialized agents. **No exceptions**, even for "trivial" 1-line fixes. +**ALL code changes, documentation edits, and implementation work** to specialized agents. **No exceptions**, even for "trivial" 1-line fixes. Spawn each agent into that issue's started worktree. ## Verification - The invocation is logged to the project journal before implementation work begins — no session start step, no "active session" precondition - All code changes delegated via your harness's agent-spawn mechanism -- no direct edits by orchestrator - The journal is continuously updated with spawns, progress, and decisions as work happens -- Spec artifacts closed out on branch before PR creation -- **Linear-native mode:** `blockedBy` of the target sub-issue is fully `completed` before work begins; starting a sub-issue also promotes an unstarted parent rollup to active; parent rollup is auto-closed only when all sub-issues are `completed` +- Each in-flight issue has exactly one started worktree; `loaf issue list --started` was checked before every spawn +- V-tier criteria pass `loaf issue verify <ref>` (writes nothing); H-tier criteria were reviewed by a human or this orchestrator +- The PR body is `loaf issue render <ref>` with no manual editing; checkboxes stay unchecked until status is `done` +- Completion is landing plus `loaf issue status <ref> done` (usually via ship) ## Quick Reference @@ -73,6 +79,15 @@ You are the coordinator. Start by understanding the task: | Code review/audit | reviewer | relevant domain skills | | Research/comparison | researcher | relevant domain skills | +| Moment | Command | +|--------|---------| +| Pick next | `loaf issue frontier` | +| Brief | `loaf issue show <ref>` / `loaf issue render <ref>` | +| Claim workspace | `loaf issue start <ref>` | +| Occupied trees | `loaf issue list --started` | +| V-tier gate | `loaf issue verify <ref>` | +| Landed | `loaf issue status <ref> done` | + --- ## Step 0: Context Check @@ -83,152 +98,51 @@ Before starting, evaluate context suitability. |---------|--------| | New command/skill added this conversation | **Restart required** (skills loaded at start) | | Conversation > 30 exchanges | Suggest restart | -| Just completed a different task/spec | Suggest clear | +| Just completed a different issue | Suggest clear | | About to start multi-file implementation | Check depth | If restart needed: log current state with `loaf journal log`, then ask the user to restart. A supported startup adapter may reconstruct continuity from the journal in the next conversation; when the exact current target mode is candidate or unsupported, explicitly run `loaf journal context` after restarting. ## Input Detection -Parse `$ARGUMENTS` to determine the work type: +Parse `$ARGUMENTS` to determine the work: | Input Pattern | Type | Action | |---------------|------|--------| -| `TASK-XXX` | Local task | Load via `loaf task show`, log the task coupling | -| `SPEC-XXX` | Spec orchestration | If spec frontmatter has `linear_parent`, resolve to that Linear parent and follow Linear-Native Routing. Otherwise resolve local tasks and build dependency-ready rounds | -| `TASK-XXX..YYY` | Task range | Expand range, build dependency-ready rounds | -| `TASK-XXX,YYY,ZZZ` | Task list | Parse list, build dependency-ready rounds | -| `PLT-123`, `ENG-198`, `PROJ-123` | Linear issue | **If `integrations.linear.enabled` is `true`:** fetch via `get_issue`, then branch on parent vs sub-issue — see [Linear-Native Routing](#linear-native-routing). **Otherwise:** treat as label text or create local task | -| Description text | Ad-hoc | Auto-create local task from description, then fall through to task-coupled flow | - -### Task-Coupled Work - -When starting from `TASK-XXX`: - -1. Load task metadata via `loaf task show TASK-XXX --json`; do not recreate `.agents/TASKS.json` after the SQLite cutover -2. Log the task coupling: `loaf journal log "decision(implement): implementing TASK-XXX"` -3. Load parent spec if task has `spec:` field - -### Ad-hoc Task Auto-Creation - -When input is free-text description (not matching any known pattern): - -1. **Parse the description:** - - Single sentence → use entire text as task title - - Multi-sentence → first sentence = title, remainder = acceptance criteria - - Split on `. ` followed by uppercase letter only (conservative — avoids false positives from URLs, abbreviations) -2. **Create the task:** `loaf task create --title "<parsed title>"` -3. **Write criteria** (if multi-sentence): edit the task `.md` file body to add the remaining sentences as acceptance criteria -4. **Fall through** to the task-coupled flow above — the result is a `TASK-XXX` ID that enters the existing planning pipeline unchanged - -**No user interaction required.** The description IS the task; invoking implement already expressed intent. +| `LOAF-42` or opaque id | Single issue | Load via `loaf issue show <ref>`; fall through to Pick-up and Dispatch | +| Parent ref with children | Tree | `loaf issue tree <ref>`; build rounds from children and `blocks` / `blocked_by` edges (see [batch-orchestration.md](references/batch-orchestration.md)) | +| Multiple refs | Batch | Same round construction across the named set | +| Empty / "next" | Frontier | `loaf issue frontier`; if one row, pick it; if several, ask (structured question tool if the harness has one); if none, stop | +| Description text | Ad-hoc | Match frontier by title. Do not mint. If nothing matches, stop and send to shape | +| Decision kind | Question | Not implementation. Surface the question; do not `loaf issue start` unless the user points at a delivery issue that records the decided answer | -### Non-Existent Task ID Error +### Missing ref -If input matches `TASK-XXX` pattern but `loaf task show` cannot resolve it: +If input looks like an issue ref but `loaf issue show` cannot resolve it: -1. Show error: `"TASK-XXX not found in local task state"` -2. Ask the user: `"Did you mean to create a new task? You can re-run with the description as free text."` -3. **Do not silently create** — the user likely has a typo +1. Show error: `"<ref> not found"` +2. Ask whether they meant a different alias, or to shape a new issue +3. **Do not silently create** --- -## Linear-Native Routing - -Applies when `integrations.linear.enabled` is `true` AND `$ARGUMENTS` -resolves to a Linear issue (direct Linear ID, or a `SPEC-XXX` whose -frontmatter has `linear_parent`). - -Fetch the issue once via `get_issue` and branch on its shape: - -### Parent rollup issue (has `spec` label) - -The issue represents a spec. Do **not** implement it directly — spec-level -"work" is always done via sub-issues. - -1. List sub-issues via `list_issues` with `parent: <parent-id>`. -2. Classify each by state: - - `in_progress` — active work - - `unstarted` + no open `blockedBy` — ready to start - - `unstarted` + open `blockedBy` — blocked - - `completed` — done, skip -3. Select the next work item: - - If one or more sub-issues are `in_progress`, pick the **lowest-ID** - in-progress sub-issue. Resume that. - - Else, if one unblocked `unstarted` sub-issue exists, pick it. - - Else, if multiple unblocked `unstarted` sub-issues exist, use - your harness's structured question tool (if it has one) to let the user choose: pick one, or delegate N in - parallel via parallel agents. List each sub-issue's title + ID. - - Else (all remaining sub-issues are blocked), refuse with a summary: - "All remaining sub-issues under <parent-id> are blocked. Blockers: - <list>." -4. Once a sub-issue is selected, recurse into the sub-issue flow below - with that ID. The parent itself is never the implementation target. - -### Sub-issue (has `parentId`, no `spec` label) - -The issue is an actual task. Implement it directly — with a pre-flight gate. - -1. **Pre-flight: verify `blockedBy` is clear.** For each issue in the - sub-issue's `blockedBy` field, call `get_issue` and confirm its state is - `completed`-type. If any blocker is not Done: - - **Refuse to start.** Do not begin work. Do not move the issue. - - Show the blockers: `"Cannot start <sub-issue-id>. Blocked by: <list - with IDs, titles, and current states>."` - - Suggest: `"Complete the blocker(s) first, or ask to override if the - blockedBy link is stale."` -2. If blockers are clear: - - Start the sub-issue as one logical Linear operation. This moves - the sub-issue to the team's `started`/In Progress state and, when the - parent rollup is still `backlog` or `unstarted`, promotes the parent to - the same `started`/In Progress state. - - If the parent is already active, leave it unchanged. If the parent is - `completed`, `canceled`, or archived, refuse to start unless the user - explicitly asks to override the protected parent state. - - If the child update succeeds but parent promotion fails, report a - reconciliation error naming the parent issue before continuing. - - Resolve branch name from the sub-issue's `branchName` field (Linear - auto-generates one) — see - [branch-and-completion.md](references/branch-and-completion.md). - - Log the task coupling, then continue with the standard Startup Checklist. - -### Completion (after implementer + reviewer finish cleanly) - -When the sub-issue's implementation passes review and tests: - -1. Move the sub-issue to the team's `completed`-type state via - `update_issue` (look up via `list_issue_statuses`, filter - `type: "completed"`). -2. Query the parent's sub-issues again: - - If **all** sub-issues are now `completed`-type, move the parent - rollup to `completed` as well. Also mark the local spec as - `complete` (see [Then Execute → AFTER](#then-execute)). - - If **some** remain, list them as "next available" for the user, - applying the same classification as step 2 of the parent flow above. - Offer to continue with the next one in this session, or stop here. -3. **Do not** close the parent while any sub-issue is open — not even if - only `blocked` ones remain. Blocked sub-issues are still in-flight - work from the spec's perspective. - -### Status flow summary - -| Moment | Sub-issue state | Parent state | -|--------|----------------|--------------| -| Implementation starts | `started` / In Progress | promoted to `started` / In Progress if still `backlog` or `unstarted` | -| Implementation + review pass | `completed` | check: close only if all sibs completed | -| Blocker discovered mid-work | `in_progress` + blocker comment | unchanged | - -### What Linear-native routing does NOT do - -- Does not pull down the full spec text. The parent's description already - links to `.agents/specs/SPEC-NNN-*.md`. Read the local file for shape, - rabbit holes, and strategic tensions. -- Does not create or rewrite sub-issues. That's breakdown's job. If - implementation reveals a missing task, surface it to the user; they - decide whether to run breakdown again or add an ad-hoc sub-issue. -- Does not sync in-progress state bidirectionally. Source of truth at any - moment: Linear for issue state, local files for spec content, the project - journal for current handoff. +## Pick-up and Dispatch + +1. **Confirm the issue is implementable.** `loaf issue check <ref>` must report a delivery issue shaped (or, if the user explicitly asked to resolve a decision issue, that it is ready). Unshaped work goes to shape. +2. **Honor the frontier.** An issue that is blocked does not appear on `loaf issue frontier`. `loaf issue link A blocks B` means A blocks B; B waits until A is `done`, `cancelled`, or `duplicate`. Do not start a blocked successor. Parent/child structure from `loaf issue tree` is not a sequencing edge — only `blocks` / `blocked_by` are. Use the tree to know who belongs in the batch; use the edges to order rounds. +3. **Parents with children are not the implementation target.** Dispatch leaf delivery children that are on the frontier. A parent executes through claimed child criteria, not by starting the parent worktree. +4. **Inspect occupied worktrees:** + ```bash + loaf issue list --started + ``` + Columns: alias, title, `started_branch`, `started_worktree`, optional `(missing)`. If this ref is already started, resume in that worktree with one agent. If the path is occupied by another issue, refuse. A `(missing)` marker means the recorded path is gone — `loaf issue stop <ref>` (not from inside the tree) before starting again. +5. **Start the workspace** (skip if already started and the path exists): + ```bash + loaf issue start <ref> + ``` + Creates branch `issue/<alias-or-id>` in lowercase (`issue/loaf-42`, id suffix when that name is claimed), a sibling worktree, and sets status to `active`. Base is the nearest started ancestor's branch, else the repository default branch. Start refuses archived rows and terminal statuses (`done`, `cancelled`, `duplicate`). +6. **Hand the agent the brief** from `loaf issue show <ref>` (body, criteria, children) and, when opening a PR, `loaf issue render <ref>`. Tell the agent to work only in `started_worktree`. +7. **Batch rounds.** When input is a parent or a set of refs, group unblocked delivery children into dependency-ready rounds from `blocked_by` edges and parent/child structure. Parallel only within a round, max 3, and only when each agent has its own worktree. See [batch-orchestration.md](references/batch-orchestration.md) for the round loop, `--dry-run` / `--parallel` / `--continue` / `--skip <ref>` / `--abort`, and blocked-state recovery. --- @@ -247,7 +161,7 @@ Spawn specialized agents with the appropriate profile: | Code review/audit | reviewer | relevant domain skills | | Research/comparison | researcher | relevant domain skills | -**Rules:** Be specific in prompts. One concern per agent. Include context. Parallel when independent, sequential when dependent. +**Rules:** Be specific in prompts. One concern per agent. Include the issue ref, `started_worktree`, body, and definition of done. Parallel when independent (separate worktrees), sequential when a `blocks` edge says so. --- @@ -256,15 +170,14 @@ Spawn specialized agents with the appropriate profile: There is no session to start — journaling is continuous. Your first action is to log the invocation: ```bash -loaf journal log "skill(implement): <task/spec/context>" +loaf journal log "skill(implement): LOAF-42 — <what>" ``` Entries are project-scoped and tagged with this conversation's harness id automatically. Continuity from prior conversations may arrive through a supported startup adapter; when the exact current target mode is candidate or unsupported, pull it explicitly with `loaf journal context`. Use `loaf journal recent` when you need a narrower timeline. -Suggest renaming the harness conversation with a meaningful name derived from context (use your harness's rename surface if it has one): -- From spec: `SPEC-027-session-stability` -- From task: `TASK-042-login-fix` -- From ad-hoc: `{short-slug-from-description}` +Suggest renaming the harness conversation with a meaningful name derived from the issue (use your harness's rename surface if it has one): +- From issue: `LOAF-42-login-fix` +- From ad-hoc match: `{alias}-{short-slug}` --- @@ -278,12 +191,14 @@ Suggest renaming the harness conversation with a meaningful name derived from co 6. **Journal continuously** -- log spawns, progress, blockers, and decisions with `loaf journal log` as they happen 7. **Clean up** -- no ephemeral files; write an optional `wrap` entry only when there's synthesis worth saving 8. **When in doubt, ask the user** +9. **Never `loaf issue stop` from inside the started worktree** -- stop does not change status; `--force` removes a dirty tree +10. **Do not tick definition-of-done boxes** -- `loaf issue verify` writes nothing; render checks a box only when status is already `done` ## Decision Tree ``` Is this a code/config/doc change? -+-- YES -> Spawn appropriate agent ++-- YES -> Spawn appropriate agent into the issue worktree +-- NO -> Is this a planning/coordination decision? +-- YES with clear path -> Proceed, log the decision +-- YES but ambiguous -> Ask user @@ -296,18 +211,16 @@ When multiple valid approaches exist: spawn council (5-7 agents, odd), present r ## Startup Checklist -1. [ ] Log the invocation: `loaf journal log "skill(implement): <context>"` -2. [ ] Parse input (task, Linear ID, or description) -3. [ ] If TASK-XXX: load task via `loaf task show TASK-XXX`, log task coupling, load parent spec -4. [ ] If Linear ID (or `SPEC-XXX` with `linear_parent`): follow [Linear-Native Routing](#linear-native-routing). Parent → walk sub-issues and select next. Sub-issue → verify `blockedBy` is clear, then start it as one logical Linear operation so the parent is promoted when needed -5. [ ] If description: auto-create task (see Ad-hoc Task Auto-Creation above) -6. [ ] Create dedicated branch (see [branch-and-completion.md](references/branch-and-completion.md)) -7. [ ] Suggest team based on task context -8. [ ] Log initial context and references with `loaf journal log` -9. [ ] Break down work using your harness's task/todo tracking surface -10. [ ] Identify needed specialized agents -11. [ ] Log next steps before spawning -12. [ ] **Get user approval** before spawning +1. [ ] Log the invocation: `loaf journal log "skill(implement): LOAF-42 — <what>"` +2. [ ] Parse input (issue ref, parent, set, frontier, or description) +3. [ ] Load `loaf issue show <ref>`; if children, `loaf issue tree <ref>` +4. [ ] `loaf issue check <ref>` — shaped/ready, or stop and send to shape +5. [ ] Confirm the ref is on `loaf issue frontier` (or already started for resume) +6. [ ] `loaf issue list --started` — one agent per worktree +7. [ ] `loaf issue start <ref>` unless already started +8. [ ] Suggest conversation rename (`LOAF-42-login-fix`) +9. [ ] Identify specialized agents; log next steps +10. [ ] **Get user approval** before spawning --- @@ -315,32 +228,28 @@ When multiple valid approaches exist: spawn council (5-7 agents, odd), present r ### BEFORE (Planning) 1. Log the invocation with `loaf journal log` -2. Set task status: `loaf task update TASK-XXX --status in_progress` -3. Break down work into agent-sized tasks -4. Identify spawn order (respect dependencies) +2. `loaf issue start <ref>` (status becomes `active` through start) +3. Slice work into agent-sized units that still belong to this one issue +4. Identify spawn order (respect `blocked_by` edges and parent/child rounds) 5. Get user approval ### DURING (Execution) -1. Spawn specialized agents via your harness's agent-spawn mechanism -2. Log each spawn with `loaf journal log "todo(agent): spawned <agent> for <task>"` -3. Update Linear with progress (no emoji, no file paths) -4. Keep journal entries handoff-ready -5. After each agent completes: log outcome, spawn next +1. Spawn specialized agents into `started_worktree` via your harness's agent-spawn mechanism +2. Log each spawn with `loaf journal log "todo(agent): spawned <agent> for <ref>"` +3. Keep journal entries handoff-ready +4. After each agent completes: log outcome, spawn next +5. If Linear overlay is enabled, you may comment there — Loaf status stays on `loaf issue` ### AFTER (Completion) 1. Code review pass (spawn `reviewer` agent) 2. Spawn implementer (with foundations + language skill) for final testing -3. **Close out spec artifacts on the branch** (included in the squash merge): - - **Local-tasks mode:** `loaf task update TASK-XXX --status done` (per task), then `loaf task archive --spec SPEC-XXX` - - **Linear-native mode:** `update_issue` the sub-issue to `completed`-type state. Then query the parent's sub-issues; if all are `completed`, also close the parent. If some remain, list them for the user (see [Linear-Native Routing → Completion](#completion-after-implementer--reviewer-finish-cleanly)) - - Mark spec complete and archive: `loaf spec archive SPEC-XXX` (both modes) - - Write a `wrap(scope)` journal entry if the work produced synthesis worth saving (next steps, abandoned paths); otherwise skip it - - Commit: `chore: close SPEC-XXX — archive tasks and spec` -4. If on a feature branch: push and create PR (`gh pr create`). Follow PR format and squash merge conventions in [commits reference](../git-workflow/references/commits.md). -5. After PR is created and approved, use ship to review, verify, and land the PR. Use release later when a coherent batch of landed work is ready to publish. -6. **Suggest reflection:** Check the journal for extractable learnings before closing out: +3. Run `loaf issue verify <ref>` (V-tier, writes nothing). Review every H-tier row yourself or with the user — a skip from verify is not a pass +4. Open or update the PR with body `loaf issue render <ref>` — no manual editing. Follow PR format and squash merge conventions in [commits reference](../git-workflow/references/commits.md) +5. After the PR is created, use ship to review, verify, land, mark `loaf issue status <ref> done`, and `loaf issue stop <ref>`. Use release later when a coherent batch of landed work is ready to publish +6. Write a `wrap(scope)` journal entry if the work produced synthesis worth saving; otherwise skip it +7. **Suggest reflection:** Check the journal for extractable learnings before closing out: - `decision(...)` entries are present - - ADRs, report verdicts, or spec changelog entries were recorded + - ADRs or report verdicts were recorded If any signal is present, suggest: *"This produced key decisions. Consider running reflect to update strategic docs."* If none are present, stay silent. --- @@ -349,18 +258,18 @@ When multiple valid approaches exist: spawn council (5-7 agents, odd), present r | Topic | Reference | Use When | |-------|-----------|----------| -| Batch Orchestration | [batch-orchestration.md](references/batch-orchestration.md) | Running specs, task ranges, or task lists with dependency-ready rounds | -| Branch and Completion | [branch-and-completion.md](references/branch-and-completion.md) | Branch management, team routing, diagrams, Linear sync, journaling, task completion | +| Batch Orchestration | [batch-orchestration.md](references/batch-orchestration.md) | Running a parent or a set of issue refs with dependency-ready rounds | +| Branch and Completion | [branch-and-completion.md](references/branch-and-completion.md) | Team routing, diagrams, exploration, journaling alongside `loaf issue start` / `stop` | +| Working issues locally | [../orchestration/references/local-tasks.md](../orchestration/references/local-tasks.md) | Frontier, started worktrees, status vocabulary, definition of done | --- ## Suggests Next -After all tasks are complete, suggest ship to land the PR. Suggest release only when the landed work forms a coherent release batch. +After the PR exists, suggest ship to land it. Suggest release only when the landed work forms a coherent release batch. ## Related Skills -- **shape** - Spec format and lifecycle -- **breakdown** - Turning specs into tasks -- **orchestration/local-tasks** - Task file format and lifecycle -- **orchestration/journal** - Project journal continuity model +- **shape** — Issue preparation and decomposition +- **orchestration/journal** — Project journal continuity model +- **orchestration/local-tasks** — Frontier, started worktrees, status, definition of done diff --git a/dist/codex/skills/implement/references/batch-orchestration.md b/dist/codex/skills/implement/references/batch-orchestration.md index 7ab48f755..7902f28f1 100644 --- a/dist/codex/skills/implement/references/batch-orchestration.md +++ b/dist/codex/skills/implement/references/batch-orchestration.md @@ -7,63 +7,64 @@ - Batch Execution Model - Blocked-State Recovery -Detailed reference for running specs, task ranges, or task lists with dependency-ready scheduling. +Detailed reference for running a parent issue or a set of issue refs with dependency-ready scheduling. ## Orchestration Options | Option | Behavior | |--------|----------| | `--dry-run` | Show dependency-ready execution plan, do not run agents | -| `--parallel` | Run tasks in the same dependency-ready group concurrently (max 3 at once) | -| `--continue` | Resume a blocked orchestration from the recorded task/group | -| `--skip TASK-XXX` | Mark one blocked task as skipped and continue | +| `--parallel` | Run issues in the same dependency-ready group concurrently (max 3 at once) | +| `--continue` | Resume a blocked orchestration from the recorded issue/group | +| `--skip <ref>` | Skip one blocked issue and continue | | `--abort` | Mark orchestration as aborted and stop remaining work | ## Batch Resolution and Dependency-Ready Scheduling -For `SPEC-XXX`, `TASK-XXX..YYY`, and `TASK-XXX,YYY,ZZZ`: +For a parent ref (`loaf issue tree <ref>`) or a named set of refs: -1. Resolve selected tasks and validate each task file exists. -2. Extract `depends_on` from each task and build a dependency graph. -3. Group tasks into dependency-ready rounds: - - First round: tasks with no unresolved dependencies - - Each subsequent round: tasks whose dependencies are completed in earlier rounds -4. If `--parallel` is set, allow parallel execution only within a dependency-ready round and only for non-conflicting tasks. -5. Present execution plan (tasks, dependency-ready rounds, mode, total count) and ask for confirmation unless `--dry-run`. -6. Track progress in the journal and in task statuses: log round boundaries and the current task with `loaf journal log`, and drive each task's status with `loaf task update`. The journal plus task statuses are the durable record of where the batch is. +1. Resolve the selected refs and validate each issue exists (`loaf issue show <ref>`). +2. Read `blocks` / `blocked_by` edges and parent/child structure. Parent/child is not a sequencing edge — only `blocks` / `blocked_by` are. +3. Group unblocked delivery children into dependency-ready rounds: + - First round: issues with no unresolved predecessors + - Each subsequent round: issues whose predecessors are `done`, `cancelled`, or `duplicate` +4. If `--parallel` is set, allow parallel execution only within a dependency-ready round, max 3, and only when each agent has its own started worktree. +5. Present execution plan (issues, dependency-ready rounds, mode, total count) and ask for confirmation unless `--dry-run`. +6. Track progress in the journal: log round boundaries and the current ref with `loaf journal log`. Status moves through `loaf issue start` (to `active`) and, after landing, `loaf issue status <ref> done`. The journal plus issue statuses are the durable record of where the batch is. + +Parents with children are not the implementation target. Dispatch leaf delivery children that are on `loaf issue frontier`. ## Option Handling (`--continue`, `--skip`, `--abort`) -1. Recover batch progress from the journal: `loaf journal recent --since-last-wrap` (or `loaf journal context`) plus `loaf task list --json` to see which tasks are still open. -2. If `--continue`: resume from the last logged dependency-ready round and task. -3. If `--skip TASK-XXX`: mark that task `skipped` via `loaf task update`, log the reason with `loaf journal log`, continue the same dependency-ready round. +1. Recover batch progress from the journal: `loaf journal recent --since-last-wrap` (or `loaf journal context`) plus `loaf issue list --json` and `loaf issue list --started` to see which issues are still open or claimed. +2. If `--continue`: resume from the last logged dependency-ready round and issue. +3. If `--skip <ref>`: log the reason with `loaf journal log`, continue the same dependency-ready round. Do not mark the skipped issue `done`. 4. If `--abort`: log `block(orchestration): aborted`, print a summary, and stop. 5. If no in-flight batch is evident from the journal, report that and ask for fresh selection input. ## Batch Execution Model -When input resolves to multiple tasks, run a dependency-ready round loop: +When input resolves to multiple issues, run a dependency-ready round loop: 1. Set orchestration mode (`sequential` by default, `parallel` only with `--parallel`). 2. For each dependency-ready round: - Log the round start with `loaf journal log` - - Run each task (sequentially, or concurrently within safety limits) - - For each task: set `in_progress` -> spawn agent -> run task verification -> mark `done`/`failed` via `loaf task update` -3. If any task fails verification, stop immediately and log `block(orchestration): <task> failed <reason>`. -4. Consider a round complete only when all its tasks are `done` or skipped. + - For each issue: `loaf issue list --started`, then `loaf issue start <ref>` unless already started, spawn one agent into `started_worktree`, run `loaf issue verify <ref>` (V-tier; writes nothing) +3. If any issue fails verification, stop immediately and log `block(orchestration): <ref> failed <reason>`. +4. Consider a round complete only when all its issues have landed (`loaf issue status <ref> done` via ship) or were skipped. 5. Continue until all rounds complete, then log a closing entry summarizing the batch. ## Blocked-State Recovery When blocked, always print: -- Failed task ID and title +- Failed issue ref and title - Dependency-ready round and current progress - Failure reason + key error output - Recovery options: Re-invoke the implement workflow with: -- `--continue` — after fixes are applied, retry from the blocked task -- `--skip TASK-XXX` — skip only the specified task and continue remaining tasks in the current dependency-ready round +- `--continue` — after fixes are applied, retry from the blocked issue +- `--skip <ref>` — skip only the specified issue and continue remaining issues in the current dependency-ready round - `--abort` — finalize the orchestration as aborted with no further execution diff --git a/dist/codex/skills/implement/references/branch-and-completion.md b/dist/codex/skills/implement/references/branch-and-completion.md index e4bea8b5d..5c89cdf02 100644 --- a/dist/codex/skills/implement/references/branch-and-completion.md +++ b/dist/codex/skills/implement/references/branch-and-completion.md @@ -8,7 +8,7 @@ - Linear Status Management - Handoff Readiness - Timestamps for User Context -- Task Completion +- Issue Completion Detailed reference for branch setup, Linear routing, and completion during implementation. @@ -18,28 +18,18 @@ Detailed reference for branch setup, Linear routing, and completion during imple ### Getting Branch Name -1. **If Linear issue exists**: Use the `branchName` field from `get_issue` response - - Linear auto-generates branch names like `username/plt-123-issue-title` - - These are pre-formatted and consistent with team conventions +`loaf issue start <ref>` is the claim. It creates `issue/<alias-or-id>` in lowercase (`issue/loaf-42`, id suffix when that name is claimed), a sibling worktree, and moves status to `active`. -2. **If no Linear issue**: Create branch name from the work description - - Format: `feature/<description>` or `fix/<description>` - - Use kebab-case, keep it concise +Do not `git checkout -b` as a substitute for start. Check `loaf issue list --started` first. Never send two agents into the same worktree. Do not run `loaf issue stop` from inside that worktree. ### Branch Workflow ```bash -# 1. Check current branch status -git status - -# 2. Create and checkout the branch (use Linear's branchName if available) -git checkout -b <branch-name> - -# 3. Confirm branch creation -git branch --show-current +loaf issue list --started +loaf issue start <ref> ``` -**Important:** All implementation agents will work on this branch. The branch should be ready for PR when work completes. Journal entries are tagged with the observed branch automatically, so continuity stays branch-scoped. +Work only in `started_worktree`. The branch should be ready for PR when work completes. Journal entries are tagged with the observed branch automatically. --- @@ -53,7 +43,7 @@ When creating Linear issues, suggest the appropriate team: > "This task seems best suited for the **Security** team (matched: 'auth', 'vulnerability'). > Security hasn't been used in this project yet. Add this team?" 4. **If user confirms**, add team to `known_teams` in config -5. **Create issue** with suggested team +5. **Create via `loaf issue new`** so identity can be delegated; do not create in Linear MCP and forget `loaf issue pull` ### Team Suggestion Example @@ -75,7 +65,7 @@ Use Linear MCP's `list_teams` (if configured) to get all workspace teams for val ## Diagram Consideration -For multi-file or multi-service changes, consider adding architecture diagrams to the linked spec, report, ADR, or implementation notes. +For multi-file or multi-service changes, consider adding architecture diagrams to the issue, a report, ADR, or implementation notes. ### When to Create Diagrams @@ -94,7 +84,7 @@ Ask yourself: 2. Is there a data flow that needs to be understood? 3. Would a visual help communicate the approach? -If yes to any, capture the diagram in a durable artifact such as a spec, report, ADR, or implementation note, and log the reference with `loaf journal log`. +If yes to any, capture the diagram in a durable artifact such as a report, ADR, or implementation note, and log the reference with `loaf journal log`. ### Diagram Template @@ -146,36 +136,23 @@ For complex tasks, explore before implementing: ## Linear Status Management -**Keep Linear status synchronized with actual work state:** - -| Work State | Linear Status (sub-issue) | -|------------|---------------------------| -| Work begun | In Progress | -| Blocked/waiting for user | In Progress (add blocker comment) | -| Work completed | Done (or In Review if PR pending) | +**Keep Loaf status synchronized with actual work state.** Linear is an overlay (`loaf issue pull` / `push` / `reconcile`); never drive Loaf status from Linear MCP tools. -### Parent rollup auto-close +| Work State | Loaf status | +|------------|-------------| +| Work begun | `active` via `loaf issue start` | +| Blocked/waiting | Stay `active`; log `block(scope)` and leave a Linear comment if the overlay is on | +| Work landed | `done` via `loaf issue status <ref> done` (usually ship), then `loaf issue stop <ref>` | -In Linear-native mode, the **parent** rollup issue (labeled `spec`) is not -moved manually during sub-issue work. It flips to Done automatically when -the last sub-issue flips to Done, and only then. Procedure: +### Parent vs children -1. After moving a sub-issue to a `completed`-type state, call - `list_issues` with `parent: <parent-id>`. -2. If every sub-issue is in a `completed`-type state, move the parent to - `completed` via `update_issue`. -3. If any sub-issue is still in an open state (including `blocked`), the - parent stays where it is — the spec is not done. +Parents with children are not the implementation target. Dispatch leaf delivery children on `loaf issue frontier`. A parent is not marked `done` because a child landed. -Never set the parent to In Progress manually — a parent in Linear-native -mode reflects a rollup of its sub-issues, not its own work. +`loaf issue link A blocks B` is the sequencing edge. An issue with an open predecessor does not appear on the frontier. Do not start a blocked successor. -### BlockedBy pre-flight +### Blocked-by pre-flight -Before moving a sub-issue to In Progress, confirm every issue in its -`blockedBy` field is in a `completed`-type state. If not, refuse to start -and report the blockers. This is a hard gate in Linear-native mode — -never implement through open `blockedBy`. +Before `loaf issue start`, confirm the ref is on `loaf issue frontier`. If it is blocked, refuse and report the predecessors. Never implement through an open `blocks` edge. --- @@ -184,7 +161,7 @@ never implement through open `blockedBy`. **The journal must ALWAYS be handoff-ready.** After every significant action: 1. Log what just happened with `loaf journal log` -2. Reference task/spec/report/commit IDs rather than duplicating long prose +2. Reference issue/report/commit IDs rather than duplicating long prose 3. Log completed agent work with outcomes 4. Ensure anyone could pick up the work immediately from `loaf journal recent` @@ -205,32 +182,18 @@ Generate with: `date -u +"%Y-%m-%d %H:%M UTC"` --- -## Task Completion +## Issue Completion -When a task-coupled unit of work completes: +When an issue-coupled unit of work completes: -1. **Update task status** (local file or Linear sub-issue) -2. **Check spec progress:** - - Local-tasks mode: list all tasks for the spec; if all done → mark - spec `complete`, else spec stays `implementing` - - Linear-native mode: query the parent rollup's sub-issues via - `list_issues` with `parent: <parent-id>`; if all are `completed`-type, - close the parent and mark the local spec `complete`, else both stay - in flight -3. **Write a `wrap` journal entry** if the conversation holds synthesis worth - saving (next steps, abandoned paths); skip it otherwise — nothing is - "closed," a conversation that ends without a wrap leaves a valid journal - -### Spec Completion Check +1. **Open or update the PR** with body `loaf issue render <ref>` — no manual editing +2. **Land via ship** — review definition of done, `loaf issue verify <ref>`, squash merge, then `loaf issue status <ref> done` and `loaf issue stop <ref>` +3. **Write a `wrap` journal entry** if the conversation holds synthesis worth saving (next steps, abandoned paths); skip it otherwise — nothing is "closed," a conversation that ends without a wrap leaves a valid journal ```bash -# Local-tasks mode: any open tasks for this spec? -loaf task list --spec SPEC-001 --status open --json - -# Linear-native mode: query the Linear parent's sub-issues -# (via get_issue + list_issues with parent filter) -# The parent itself only flips to Done when every sub-issue is Done. +loaf issue show <ref> +loaf issue tree <ref> +loaf issue list --started ``` -Never mark the local spec `complete` while its Linear parent still has -open sub-issues — the two sources of truth should agree on "done." +Do not mark a parent `done` while delivery children are still open. Do not flip Loaf status from Linear MCP tools; use `loaf issue reconcile` if the overlay has drifted. diff --git a/dist/codex/skills/implement/templates/journal.md b/dist/codex/skills/implement/templates/journal.md index caf7409fd..9a0a61f8f 100644 --- a/dist/codex/skills/implement/templates/journal.md +++ b/dist/codex/skills/implement/templates/journal.md @@ -13,7 +13,7 @@ not create or edit journal markdown as the source of truth — use ## Entries -[YYYY-MM-DD HH:MM] skill(implement): implementing TASK-042 +[YYYY-MM-DD HH:MM] skill(implement): implementing LOAF-42 [YYYY-MM-DD HH:MM] decision(scope): description of decision [YYYY-MM-DD HH:MM] discover(scope): something learned [YYYY-MM-DD HH:MM] block(scope): what is blocked diff --git a/dist/codex/skills/loaf-reference/SKILL.md b/dist/codex/skills/loaf-reference/SKILL.md index 06baf7b87..8f58f82f2 100644 --- a/dist/codex/skills/loaf-reference/SKILL.md +++ b/dist/codex/skills/loaf-reference/SKILL.md @@ -25,7 +25,7 @@ The Loaf operating manual for agents: how to discover commands, diagnose project ## Operating Rules - Get exact, current syntax live: `loaf --help` lists every command, `loaf <command> --help` details one. This index is a map, not the contract. -- Prefer `--json` surfaces when diagnosing: `loaf config check --json`, `loaf state doctor --json`, `loaf change check --json`. Parse the structured output instead of scraping human-readable text. +- Prefer `--json` surfaces when diagnosing: `loaf config check --json`, `loaf state doctor --json`. Parse the structured output instead of scraping human-readable text. - Run the deterministic CLI command before hand-editing anything it manages; the command owns its files. - Use `--fix` only for safe, mechanical repairs, and review what it changed. - Ask the user for project-owned choices — GitHub account, tracker or integration election, which harnesses to install — never guess them. @@ -64,17 +64,16 @@ Names and one-line purposes only. Run `loaf <command> --help` for options, argum | `loaf config` | Validate and refresh project Loaf config | check | | `loaf hooks` | Inspect and set which Loaf hooks project into an installed harness's hooks file | list, enable, disable | | `loaf init` | Initialize a project with Loaf structure | — | -| `loaf release` | Create a new release with changelog, version bump, and tag | — | +| `loaf release` | Cut a retroactive release from already-landed work | suggest, cut | | `loaf search` | Search SQLite artifact bodies, journal entries, and indexed docs | — | | `loaf docs` | Manage docs/ indexing | index | -| `loaf change` | Shape-first Change artifacts: git-canonical work context under docs/changes/ | init, check, list | | `loaf render` | Maintain committed durable Markdown renders | sweep | | `loaf state` | Manage native SQLite state | path, status, init, doctor, repair legacy-project-database, repair relationship-origin, repair journal-search, migrate markdown, migrate storage-home, migrate schema, migrate deferrals, migrate lifecycle-statuses, migrate alias-orphans, migrate journal-duplicates, migrate journal-first, backup, backup verify, backup restore, restore-ephemerals, verify-ephemerals, export, export all, export triage, export spec, export release-readiness | | `loaf journal` | Record and read the project-scoped journal (the durable record across all conversations) | log, recent, search, show, context, export, defer | | `loaf project` | Manage durable project identity | list, show, identity, rename, move, delete | | `loaf migrate` | Run native migration workflows | markdown, storage-home, schema, lifecycle-statuses, journal-first, worktree-storage | -| `loaf task` | Manage project tasks | list, show, status, create, update, archive, refresh, sync | -| `loaf spec` | Manage project specs | new, edit, list, show, status, render, finalize, archive, delete | +| `loaf task` | Manage project tasks; superseded by loaf issue for new work | list, show, status, create, update, archive, refresh, sync | +| `loaf issue` | Manage issues in native SQLite state | new, show, list, tree, frontier, start, stop, edit, status, dod, dod add, dod list, dod remove, dod claim, dod unclaim, promote, check, verify, bucket, link, render, export, pull, push, reconcile | | `loaf report` | Manage durable reports (research, audits, investigations) | list, show, render, generate, create, edit, finalize, archive | | `loaf finding` | Manage report findings and verdicts in native SQLite state | list, show, create, verdict, import-json | | `loaf run` | Manage provenance runs for generated findings and reports | list, show, create, complete | @@ -88,7 +87,7 @@ Names and one-line purposes only. Run `loaf <command> --help` for options, argum | `loaf trace` | Trace relationships for one state entity | — | | `loaf brainstorm` | Manage brainstorms in native SQLite state | capture, list, show, promote, archive | | `loaf idea` | Manage ideas in native SQLite state | list, show, capture, promote, resolve, archive | -| `loaf intent` | Manage tracked Intent in native SQLite state; disposition is derived from append-only facts | create, defer, resume, resolve, show, list | +| `loaf intent` | Manage tracked Intent in native SQLite state; disposition is derived from append-only facts; superseded by loaf issue for new work | create, defer, resume, resolve, show, list | | `loaf intake` | Read the deterministic local intake projection; triage judgment stays with humans and Skills | list | | `loaf exploration` | Manage relational Exploration continuity: immutable portable checkpoints, no lifecycle status, no current pointer | create, checkpoint, list, context, conversation | | `loaf conversation` | Manage logical conversations and machine-local provenance handles; handles never imply portable context | create, show, list, handle, observe | diff --git a/dist/codex/skills/loaf-reference/references/command-routing.md b/dist/codex/skills/loaf-reference/references/command-routing.md index e6856fd65..5f9d30bbb 100644 --- a/dist/codex/skills/loaf-reference/references/command-routing.md +++ b/dist/codex/skills/loaf-reference/references/command-routing.md @@ -6,15 +6,15 @@ Which command a task needs. For exact flags, run `loaf <command> --help`. | Intent | Route | |--------|-------| -| Shape new bounded work | `loaf change init <slug>`, then `loaf change check` | -| Start implementing new bounded work | the implement workflow after shaping and validating its Change | -| Continue an existing task or spec record | `loaf task` and `loaf spec` remain supported for existing records | +| Shape new bounded work | `loaf issue new <title>`, then `loaf issue dod add` and `loaf issue check <ref>` | +| Start implementing new bounded work | the implement workflow: pick from `loaf issue frontier`, then `loaf issue start <ref>` | +| Continue an existing task or spec record | `loaf task` and `loaf spec` remain readable for legacy records; new work is issues | | Continue after a restart | `loaf journal context` | | Skills or content changed | `loaf build && loaf install --to <target>` | -| See what is in progress | `loaf task list --active` | -| Archive completed work | `loaf task archive` | +| See what is in progress | `loaf issue list --status active` and `loaf issue list --started` | +| Remove finished-with work | `loaf issue status <ref> cancelled` or `duplicate --duplicate-of <ref>` (archives; record survives) | | Check knowledge freshness | `loaf kb check` | -| Validate a Change is structurally executable, not implementation-complete | `loaf change check --require-executable` | +| Validate an issue is shaped, covered, and contained | `loaf issue check <ref>` (non-zero exit names each failure) | | Import legacy `.agents` Markdown into SQLite | `loaf migrate markdown --dry-run` then `--apply` (see markdown-migration reference) | ## JSON diagnosis surfaces @@ -24,10 +24,10 @@ scraping human-readable text: - `loaf config check --json` — config file and installed hook config validity - `loaf state doctor --json` / `loaf state status --json` — SQLite health and readiness -- `loaf change check --json` — Change violations and derived executability +- `loaf issue check <ref> --json` — derived readiness, coverage, and containment - `loaf check --hook <id> --json` — one enforcement hook's result - `loaf kb check --json` — knowledge staleness against git history -- `loaf task list --json` / `loaf journal recent --json` — current work and timeline +- `loaf issue list --json` / `loaf journal recent --json` — current work and timeline - `loaf migrate markdown --dry-run --json` — `mode` (`simulation`/`inventory`) plus `import_report` when simulated Choosing between the `doctor` commands and `LOAF_DB` isolation are covered in diff --git a/dist/codex/skills/orchestration/SKILL.md b/dist/codex/skills/orchestration/SKILL.md index 2013e84f9..37fd15126 100644 --- a/dist/codex/skills/orchestration/SKILL.md +++ b/dist/codex/skills/orchestration/SKILL.md @@ -42,9 +42,9 @@ Comprehensive patterns for orchestration: coordinating multi-agent work, keeping - Outcome-focused, self-contained, no local file references - Magic words in commit body, not subject -**If `integrations.linear.enabled` is `true` in `.agents/loaf.json`:** use Linear MCP workflows and [references/linear.md](references/linear.md) for issue updates and status. +**If `integrations.linear.enabled` is `true` in `.agents/loaf.json`:** Linear is an identity adapter — `loaf issue pull` / `push` / `reconcile`, not a second work unit. See [references/linear.md](references/linear.md). Linear MCP is an overlay; Loaf issues remain the work unit and Linear never drives Loaf status. -**Otherwise:** coordinate with the project journal and `loaf task` / file-based tracking only; do not assume Linear MCP tools are available. +**Otherwise:** coordinate with the project journal and `loaf issue` only; do not assume Linear MCP tools or identity delegation are available. ### Planning (Shape Up) - Complexity-based sizing (small / medium / large) @@ -71,15 +71,15 @@ Comprehensive patterns for orchestration: coordinating multi-agent work, keeping | Pre-compaction | On an exact target mode with supported compaction delivery, hooks may nudge a journal flush and emit the digest afterward; otherwise flush manually and run `loaf journal context` after compaction | | Durable artifact handling | Delegate `.agents/`-scoped report/spec/handoff/knowledge tending to `librarian` | | Low-priority work | Spawn background-runner (see Background Agents) | -| New feature workflow | Research -> Architecture -> Shape -> Breakdown -> Implement | +| New feature workflow | Pitch -> Shape -> Implement -> Ship -> Release | ## Topics | Topic | Reference | Use When | |-------|-----------|----------| -| Shaping Specs | [../shape/SKILL.md](../shape/SKILL.md) | Creating specs, shaping work, defining test conditions | -| Breaking Work Into Tasks | [../breakdown/SKILL.md](../breakdown/SKILL.md) | Turning shaped specs into implementation tasks | -| Local Tasks | [references/local-tasks.md](references/local-tasks.md) | Managing tasks locally or with Linear backend | +| Shaping Issues | [../shape/SKILL.md](../shape/SKILL.md) | Preparing issues: body, definition of done, out of scope | +| Decomposition | [../shape/SKILL.md](../shape/SKILL.md) | Promoting a criterion that earns its own DoD (`loaf issue promote`) | +| Working Issues | [references/local-tasks.md](references/local-tasks.md) | Frontier, started worktrees, status, definition of done | | Agent Delegation | [references/delegation.md](references/delegation.md) | Choosing agents, spawning subagents, decision trees | | Parallel Agents | [references/parallel-agents.md](references/parallel-agents.md) | Dispatching independent work concurrently | | Subagent Development | [references/subagent-development.md](references/subagent-development.md) | Delegating to specialized agents | @@ -96,7 +96,7 @@ Comprehensive patterns for orchestration: coordinating multi-agent work, keeping The orchestrator: 1. Creates issues and logs the orchestration intent for tracking -2. Breaks down work into delegable tasks +2. Picks from `loaf issue frontier` and starts one worktree per issue 3. Spawns specialized agents for implementation 4. Coordinates outcomes and updates external systems 5. Never implements code, tests, or documentation directly @@ -126,16 +126,16 @@ This skill uses paths from `.agents/loaf.json`: | Councils | `.agents/councils/` | `.agents/councils/archive/` | `YYYYMMDD-HHMMSS-topic.md` | | Handoffs | `.agents/handoffs/` | delete after deprecated | Created by handoff | | Reports | `.agents/reports/` | N/A | `YYYYMMDD-HHMMSS-subject.md` | -| Tasks | SQLite (`loaf task show/list`) | N/A | Per task manager conventions | +| Issues | SQLite (`loaf issue show/list`) | `cancelled` / `duplicate` via `loaf issue status` | Alias or opaque id | **Rule:** Agents write artifacts to disk, orchestrator reasons over artifacts, users retrieve from disk. ## Workflow by Lifecycle ### BEFORE (Planning) -- Create/check external issue (Linear, GitHub) +- Shape prepares issues; implement works the frontier. Decomposition is `loaf issue promote` inside shape. - Log the orchestration intent with `loaf journal log` -- Break down into tasks, identify agents, get user approval +- `loaf issue check <ref>` must report shaped (delivery) or ready (decision); identify agents; get user approval ### DURING (Execution) - Spawn specialized agents (never implement directly) @@ -144,6 +144,6 @@ This skill uses paths from `.agents/loaf.json`: ### AFTER (Completion) - Code review + QA testing -- Update external issue to Done +- Land via ship: `loaf issue status <ref> done`, then `loaf issue stop <ref>` - Ensure knowledge captured in permanent locations - Write an optional `wrap` journal entry if the conversation holds synthesis worth saving diff --git a/dist/codex/skills/orchestration/references/background-agents.md b/dist/codex/skills/orchestration/references/background-agents.md index 6aa2b3c61..95b4b3b54 100644 --- a/dist/codex/skills/orchestration/references/background-agents.md +++ b/dist/codex/skills/orchestration/references/background-agents.md @@ -43,7 +43,7 @@ Task( - src/services/ Write report to: .agents/reports/YYYYMMDD-HHMMSS-security-audit.md - Reference: TASK-123, SPEC-045 if relevant + Reference: LOAF-123 if relevant """, run_in_background=True ) @@ -51,12 +51,12 @@ Task( ### Cursor -Background agents are configured via the `is_background: true` YAML property. When spawning, specify the report destination and any task/spec IDs: +Background agents are configured via the `is_background: true` YAML property. When spawning, specify the report destination and any issue refs: ``` @background-runner Run security audit on backend codebase. Write report to .agents/reports/. -Reference TASK-123 if relevant. +Reference LOAF-123 if relevant. ``` The background agent's journal entries are tagged with its own harness id automatically — there is no session alias to pass. @@ -72,7 +72,7 @@ Track background work with durable references: 1. Log the spawn with `loaf journal log "todo(background): started <id> for <task>"`. 2. Ask the background agent to write a report under `.agents/reports/`. 3. When complete, log `discover(background): <id> wrote <report>`. -4. Process findings into tasks, specs, ADRs, or report verdicts as appropriate. +4. Process findings into issues, ADRs, or report verdicts as appropriate. Use a stable ID such as `bg-YYYYMMDD-HHMMSS-description` in the prompt and journal entries. @@ -85,7 +85,7 @@ Background agents write results to `.agents/reports/` with enough metadata to id 1. Orchestrator identifies non-blocking security audit work. 2. Orchestrator logs the background spawn to the journal. 3. Background agent writes `.agents/reports/YYYYMMDD-HHMMSS-auth-security.md`. -4. Orchestrator reviews the report, creates follow-up tasks, and logs the outcome. +4. Orchestrator reviews the report, creates follow-up issues, and logs the outcome. 5. Report state is finalized or archived through the report lifecycle. ## Anti-Patterns @@ -94,7 +94,7 @@ Background agents write results to `.agents/reports/` with enough metadata to id |-------|------------| | Use for blocking work | Keep blocking work in foreground | | Spawn without tracking | Log the spawn and require a report path | -| Ignore completed results | Process reports into tasks, findings, or decisions | +| Ignore completed results | Process reports into issues, findings, or decisions | | Use for interactive tasks | Reserve for autonomous work | | Spawn many concurrent background agents | Limit concurrency to avoid resource contention | | Skip result location in prompt | Always specify where output belongs | diff --git a/dist/codex/skills/orchestration/references/context-management.md b/dist/codex/skills/orchestration/references/context-management.md index 11ce34ac7..49151a167 100644 --- a/dist/codex/skills/orchestration/references/context-management.md +++ b/dist/codex/skills/orchestration/references/context-management.md @@ -19,28 +19,28 @@ Patterns for keeping long work resumable while using the project journal as exte Compaction is normal in long workflows. Design work that spans many exchanges so important state is already outside chat context. 1. **The journal is external memory.** Record decisions, discoveries, blockers, and next actions with `loaf journal log`. -2. **Artifacts carry detail.** Changes, task-board records, reports, ADRs, and commits hold rich detail; journal entries point to them. +2. **Artifacts carry detail.** Issues, reports, ADRs, and commits hold rich detail; journal entries point to them. 3. **Delegated work absorbs exploration.** Use delegated agents for broad investigation and return concise findings to the main context. -4. **`wrap` captures synthesis.** When meaningful work holds intentions or abandoned paths worth saving, write an optional `wrap` journal entry. +4. **`wrap` captures synthesis.** When meaningful work holds abandoned paths worth saving, write an optional `wrap` journal entry. ## Continuity Digest (contract v2) -`loaf journal context` is the contract-v2 active-truth digest and supersedes the retired three-part summary. Read its named layers and diagnostics; an absent item and an unavailable source are different states. +`loaf journal context` is the contract-v2 active-truth digest. Read its named layers and diagnostics; an absent item and an unavailable source are different states. Layer *names* below are the live CLI identifiers. | Layer | Truth and precedence | |-------|----------------------| | `project-synthesis` | The latest `wrap(project)` synthesis. This is the only wrap that represents project-wide synthesis. | | `scoped-checkpoint` | The latest non-project wrap, only when `project-synthesis` has no item. It is labeled as a fallback, not promoted to project synthesis. | -| `active-lineage` | Journal evidence associated with the active Change lineage. | +| `active-lineage` | Journal evidence associated with the active work lineage. | | `unresolved-blockers` | Blocks without a later exact-scope unblock. | -| `deferred-intent` | Open deferred-intent decision and spark pairs. | -| `active-changes` | Git-derived active Change evidence and worktree state. | +| `deferred-intent` | CLI layer for open deferred decision and spark pairs. | +| `active-changes` | Git-derived active worktree evidence. | | `branch-recency` | Recent branch entries after entries already surfaced as active truth are removed. | -| `transitional-tasks` | Open task-board records retained for compatibility. | +| `transitional-tasks` | Leftover board records retained for compatibility — not the work unit. Prefer `loaf issue frontier` / `loaf issue list --started`. | -Each returned layer includes `source_available`, `available_count`, `shown_count`, `truncated`, and `expand_command`; paginated layers also include a cursor. Treat `source_available: false` as an explicit unavailable source, never as “nothing is active.” If Change discovery is unavailable, `active-changes` and `active-lineage` are unavailable and the digest carries a diagnostic. +Each returned layer includes `source_available`, `available_count`, `shown_count`, `truncated`, and `expand_command`; paginated layers also include a cursor. Treat `source_available: false` as an explicit unavailable source, never as “nothing is active.” If git-derived discovery is unavailable, `active-changes` and `active-lineage` are unavailable and the digest carries a diagnostic. -Use `--branch` to select `branch-recency` scope and bind state cursors. It does not override active Change provenance or reasons, which always use the actual Git branch. Use `loaf journal context --layer <name>` to inspect one layer. `--limit` accepts 1 through 100 and requires `--layer`; `--cursor` also requires `--layer` and cannot be used with the intrinsic one-item `project-synthesis` or `scoped-checkpoint` layers. Follow the returned `expand_command` exactly: a cursor is bound to its layer, project, branch, snapshot, and limit. Use `--json` for automation; the human view preserves availability, counts, truncation, diagnostics, and expansion commands. +Use `--branch` to select `branch-recency` scope and bind state cursors. It does not override git provenance or reasons, which always use the actual Git branch. Use `loaf journal context --layer <name>` to inspect one layer. `--limit` accepts 1 through 100 and requires `--layer`; `--cursor` also requires `--layer` and cannot be used with the intrinsic one-item `project-synthesis` or `scoped-checkpoint` layers. Follow the returned `expand_command` exactly: a cursor is bound to its layer, project, branch, snapshot, and limit. Use `--json` for automation; the human view preserves availability, counts, truncation, diagnostics, and expansion commands. ## Context Commands @@ -53,21 +53,21 @@ Use `--branch` to select `branch-recency` scope and bind state cursors. It does ## When to Clear Context -Clear the conversation when starting a completely new task, after the previous task is complete, when debugging noise crowds out the current objective, or when switching between unrelated codebases. Avoid clearing mid-task until enough state is logged for recovery. +Clear the conversation when starting a completely new issue, after the previous issue is complete, when debugging noise crowds out the current objective, or when switching between unrelated codebases. Avoid clearing mid-issue until enough state is logged for recovery. ## Compaction Lifecycle PreCompact: 1. Flush unrecorded decisions, discoveries, blockers, and next actions with `loaf journal log`. -2. Reference Changes, task-board records, reports, commits, and files by stable ID or path. +2. Reference issues, reports, commits, and files by stable ID or path. 3. On an exact target mode with supported PreCompact delivery, let the hook nudge the flush; otherwise flush manually before compacting. PostCompact: 1. On an exact target mode with supported resumption delivery, read the continuity digest emitted by the hook; otherwise run `loaf journal context` explicitly. 2. Expand the named layer that needs more detail, or use `loaf journal recent` and `loaf journal search` for a different query. -3. Continue from the journal and linked artifacts. +3. Continue from the journal and linked artifacts (`loaf issue show <ref>`). This makes compaction survivable without relying on hand-maintained Markdown state. State not logged or captured in a durable artifact can be lost. @@ -79,10 +79,10 @@ Use delegated agents to investigate without filling the main context. |-----------|----------| | Quick file lookup | Direct read or search tool | | Multi-file exploration | Explorer or research agent | -| Implementation work | Implementer or task-focused agent | +| Implementation work | Implementer in the issue's started worktree | | Long audit | Background agent with report output | -Pass stable references to delegated agents: Change IDs, task IDs, branch names, and report paths. The harness ID is attached to journal entries automatically; there is no session alias to pass. +Pass stable references to delegated agents: issue IDs, branch names, worktree paths, and report paths. The harness ID is attached to journal entries automatically; there is no session alias to pass. Never send two agents to the same started worktree. ## Context Budget Guidelines @@ -109,12 +109,12 @@ No special management is usually needed. | Repeating same mistakes | Context pollution | Log current facts, then clear or compact | | Forgetting recent decisions | Overcrowded context | Read `loaf journal context` and expand the relevant layer | | Slow responses | Large context | Delegate exploration | -| Confusion about task | Too many pivots | Re-anchor on Change or task IDs | +| Confusion about the work | Too many pivots | Re-anchor on issue IDs (`loaf issue show`) | ## Best Practices 1. Log durable facts early with `loaf journal log`. 2. Use delegated agents for exploration-heavy work. -3. Clear between unrelated tasks. -4. Compact mid-task when the journal and artifacts are current. +3. Clear between unrelated issues. +4. Compact mid-issue when the journal and artifacts are current. 5. Scope tool calls so context stays focused. diff --git a/dist/codex/skills/orchestration/references/delegation.md b/dist/codex/skills/orchestration/references/delegation.md index b710244b6..a620c0ba7 100644 --- a/dist/codex/skills/orchestration/references/delegation.md +++ b/dist/codex/skills/orchestration/references/delegation.md @@ -131,8 +131,8 @@ Use when work is truly independent. Spawn multiple agents in the same turn when 1. **Be specific in prompts** - Include file paths, requirements, constraints 2. **One concern per agent** - Don't ask a backend implementer to also write tests -3. **Include context** - Task/spec IDs, issue ID, previous outcomes -4. **Reference durable artifacts** - Task, spec, and report IDs; the subagent's journal entries are harness-id tagged automatically +3. **Include context** - Issue refs (`LOAF-42` or opaque id), previous outcomes +4. **Reference durable artifacts** - Issue aliases and report IDs; the subagent's journal entries are harness-id tagged automatically 5. **Include skill hints** - Name the skills that should guide the agent's work ### Skill Hints @@ -171,8 +171,7 @@ Files: - src/api/users.py - src/models/user.py -Task: TASK-042 -Linear: BACK-123 +Issue: LOAF-42 ``` ## Anti-Patterns @@ -183,7 +182,7 @@ Linear: BACK-123 | Asking backend implementer for React | Spawn implementer with frontend skills | | Single agent for database + backend + tests | Sequential: implementer (database-design), implementer (language skill), implementer (foundations) | | Parallel spawns with hidden dependencies | Make dependencies explicit, spawn sequentially | -| Spawning without context | Reference task/spec/report IDs in prompts | +| Spawning without context | Reference issue aliases and report IDs in prompts | | Council for simple decisions | Single agent or orchestrator judgment | ## Agent Access Hierarchy diff --git a/dist/codex/skills/orchestration/references/journal.md b/dist/codex/skills/orchestration/references/journal.md index de2a9de3a..442f2b216 100644 --- a/dist/codex/skills/orchestration/references/journal.md +++ b/dist/codex/skills/orchestration/references/journal.md @@ -42,20 +42,20 @@ loaf journal log "spark(scope): possible follow-up idea" loaf journal log "todo(scope): concrete follow-up action" ``` -Log durable facts, not thoughts. Reference task IDs, spec IDs, report IDs, and +Log durable facts, not thoughts. Reference issue IDs, report IDs, and commit refs rather than pasting long prose. The journal should let another agent resume without reading the whole conversation. ## Codex Auto Mode -When the user has explicitly enabled Loaf's managed Codex Auto-journal capability, use the exact path-pinned command shown in the Loaf-managed block of `CODEX_HOME/AGENTS.md`; its form is `'<canonical-loaf-path>' journal log --execpolicy-safe "decision(scope): chose X because Y"`. Do not substitute a bare `loaf`, alternate executable, or shell/environment wrapper. The installed Codex rule authorizes only explicitly classified basic Loaf command leaves outside the workspace sandbox, including this hardened journal writer and approved readers; ordinary `journal log`, body/file-consuming leaves, and path-taking `change check` remain operator-gated, and the policy does not authorize a general Loaf data-directory writable root. Other harness adapters are not implied and continue to use their own ordinary surfaces. +When the user has explicitly enabled Loaf's managed Codex Auto-journal capability, use the exact path-pinned command shown in the Loaf-managed block of `CODEX_HOME/AGENTS.md`; its form is `'<canonical-loaf-path>' journal log --execpolicy-safe "decision(scope): chose X because Y"`. Do not substitute a bare `loaf`, alternate executable, or shell/environment wrapper. The installed Codex rule authorizes only explicitly classified basic Loaf command leaves outside the workspace sandbox, including this hardened journal writer and approved readers; ordinary `journal log`, body/file-consuming leaves, and other path-taking operator-gated leaves remain operator-gated, and the policy does not authorize a general Loaf data-directory writable root. Other harness adapters are not implied and continue to use their own ordinary surfaces. Enable the capability once with `loaf install --to codex --codex-basic-commands`. Installation is an explicit trust decision. If the rules are absent, stale, locally modified, or conflict with user-owned `loaf.rules`, Loaf reports the condition instead of overwriting it or asking for full system access. ## Wrap: Optional Checkpoint A `wrap` entry is a voluntary checkpoint, not a lifecycle transition. Write one -only when the conversation holds synthesis worth saving — intentions, abandoned +only when the conversation holds synthesis worth saving — abandoned paths, next steps — the connective narrative that evaporates with the context window. Almost everything else is derivable from raw entries. @@ -70,11 +70,12 @@ perfectly valid journal. A wrap reviews its own conversation's entries first: loaf journal recent --since-last-wrap ``` -See the `wrap` skill for the full checkpoint flow. +See the `wrap` skill for the full checkpoint flow. Loose ends name issues +(`loaf issue frontier`, `loaf issue list --started`), not board leftovers. ## Derived Continuity -Continuity is computed at read time and never persisted. On an exact target mode whose startup delivery capability is supported, the startup adapter may emit a layered digest with the latest project-level wrap, recent entries scoped to the current branch/worktree, and open (`in_progress`/`pending`) tasks. When startup delivery is candidate or unsupported for the current mode, explicitly run `loaf journal context` at conversation start. On demand, reproduce or extend the digest: +Continuity is computed at read time and never persisted. On an exact target mode whose startup delivery capability is supported, the startup adapter may emit a layered digest with the latest project-level wrap, recent entries scoped to the current branch/worktree, and open work. Bind that open work to `loaf issue frontier` and `loaf issue list --started`. When startup delivery is candidate or unsupported for the current mode, explicitly run `loaf journal context` at conversation start. On demand, reproduce or extend the digest: ```bash loaf journal context # the layered continuity digest @@ -82,7 +83,7 @@ loaf journal recent --branch <b> # recent entries for one branch loaf journal search <query> # find prior decisions by topic ``` -Pass task/spec/report references to background and delegated agents. The harness +Pass issue IDs and report references to background and delegated agents. The harness id is attached automatically — there is no session alias to pass along. ## Recovery @@ -91,7 +92,7 @@ After compaction, a branch switch, or a long gap: 1. Read `loaf journal context`; on an exact target mode with supported resumption delivery, the digest emitted by the adapter is equivalent continuity context. 2. Widen with `loaf journal recent` / `loaf journal search` when more is needed. -3. Compare against `git status`, `git log`, and the relevant specs/tasks. +3. Compare against `git status`, `git log`, `loaf issue frontier`, and `loaf issue list --started`. 4. If code and journal have drifted, log the reconciliation: `loaf journal log "decision(recovery): rewound to <commit>; replaying tests"`. @@ -108,7 +109,7 @@ After compaction, a branch switch, or a long gap: | Don't | Do Instead | |-------|------------| | Wait to log everything at the end | Log significant facts as they happen | -| Store decisions only in chat context | Log them and promote durable ones to ADR/spec/report/docs | +| Store decisions only in chat context | Log them and promote durable ones to ADR/issue body/report/docs | | Write a placeholder wrap out of ceremony | Wrap only when there's synthesis worth saving | | Treat a missing wrap as an open loop | A conversation without a wrap is complete and valid | | Pass a session alias to delegated agents | Nothing to pass — the harness id is automatic | diff --git a/dist/codex/skills/orchestration/references/linear.md b/dist/codex/skills/orchestration/references/linear.md index f488726cf..f00960ebc 100644 --- a/dist/codex/skills/orchestration/references/linear.md +++ b/dist/codex/skills/orchestration/references/linear.md @@ -7,8 +7,7 @@ Guidelines for writing Linear issue updates, comments, and commit messages with - Configuration - MCP Server Naming - Multi-Workspace Guidance -- Linear-Native Mode (Parent + Sub-Issues) -- The `spec` Label Convention +- Identity Adapter - Progress Update Format - Issue Description Format - Status Conventions @@ -97,96 +96,44 @@ Match the `linear.mcp_server_name` in each project's `.agents/loaf.json` to the name used in that project's `.mcp.json`. That way the Loaf skills invoke the right workspace automatically. -## Linear-Native Mode (Parent + Sub-Issues) +## Identity Adapter -In Linear-native mode (`integrations.linear.enabled: true`), each spec -produces one parent **rollup issue** and N sub-issues under it. +When `issue_identity.authority = linear`, Linear owns identity, title, status, +and assignment. Loaf owns shaping state: body, definition-of-done criteria, +claims, and the started worktree. The Loaf issue is the work unit. Linear MCP +is an overlay — never drive Loaf status from MCP tools. -``` -Agent framework alignment ← parent, label: `change` -├── Split reviewer profile into reviewer/auditor ← sub-issue, label: type/refactor -├── Harden MCP fallback path ← sub-issue, label: type/feature -└── Migrate legacy task references ← sub-issue, label: type/refactor -``` - -### Parent issue — what it is and isn't - -The parent issue is a **dashboard anchor**, not a re-hosting of the spec. - -- **Is:** a short summary (1–3 paragraphs) of the problem and solution - direction + a link to the canonical spec file in the repo. -- **Is not:** a copy of the spec's Scope / Rabbit Holes / Open Questions / - Risks sections. Those live in the local spec file and evolve there. - -### Sample parent description +`loaf issue new` delegates identity: Linear mints the identifier, and that +key becomes the local alias. The local counter is not advanced. If Linear is +offline, refuse — capture via `loaf spark` or `loaf idea`. Do not mint a +local alias as a fallback. -```markdown -## Summary -Align Loaf's agent profiles with the three-role model (implementer, reviewer, -researcher). Consolidate historical profile variants and add tool-boundary -tests so profiles can't drift without a test failing. - -## Context -See the canonical change file in the repository for full text, council -references, rabbit holes, and strategic tensions. +If Linear created an issue but the local bind failed, adopt it: -## Progress -Sub-issues track execution. +```text +loaf issue pull <linear-key> +loaf issue pull <linear-key> --tree ``` -### Sub-issues - -- Each sub-issue has `parentId` set to the parent issue ID. -- Cross-task dependencies use Linear's `blockedBy` field referencing sibling - sub-issue IDs. -- Sub-issue labels describe the task itself (type, team, area), not the - parent — don't label sub-issues with `spec`. -- Starting a sub-issue promotes the parent rollup from `backlog`/`unstarted` - to the team's `started`/In Progress state. Parent promotion is a state - invariant of the start operation, not a separate manual reminder. -- Do not silently reopen protected parents. If the parent is `completed`, - `canceled`, or archived, stop and ask for an explicit override before - starting the child. - -### Spec file remains canonical - -Even with the parent in Linear, the local spec file is the source of truth -for: +`--tree` also adopts the sub-issue tree with parent edges intact. -- Problem statement and solution direction -- Scope / in-scope / out-of-scope / rabbit holes / no-gos -- Risks and open questions -- Council references and strategic tensions +### Commands -When the spec evolves, edit the file and let git track it. The parent -issue's summary is a frozen entry point; only refresh it if the summary -itself (not the rabbit holes or risks) changes meaningfully. - -## The `spec` Label Convention - -Every spec-parent rollup issue carries a Linear label named `spec`. This lets -anyone in Linear filter for "all spec roots" across projects without having to -know which issues happen to be parents. - -| Field | Value | -|-------|-------| -| Name | `spec` | -| Color | `#5e6ad2` (suggested; implementer may adjust) | -| Description | `Parent rollup issue representing a design spec tracked in the repo at .agents/specs/` | -| Scope | Workspace-scoped preferred; fall back to team-scoped if the MCP requires it | - -### Who creates it - -breakdown creates the `spec` label on first Linear-native breakdown in a -workspace that doesn't already have it. Subsequent breakdowns reuse the -existing label. Log whether the label was created this run or already -existed — this matters for first-time setup. +```text +loaf issue pull <linear-key> [--tree] [--json] +loaf issue push <ref> [--json] +loaf issue reconcile [<ref>] [--take-local|--take-tracker] [--json] +``` -### Sub-issues never carry `spec` +| Command | Writes? | What it does | +|---------|---------|----------------| +| `loaf issue pull` | Yes | Adopt an existing Linear issue as a local row. The Linear key becomes the alias | +| `loaf issue push` | Yes | Write `loaf issue render` as the Linear description. Status is written only when the local status event is newer than the tracker. Never renames the Linear issue | +| `loaf issue reconcile` | Yes with a take flag | Compare local and Linear. Title drift updates the local title (tracker wins). Status drift is reported; `--take-local` or `--take-tracker` resolves it. Description drift is reported only | -`spec` applies only to parents. A sub-issue describing a task uses its own -labels (type groups like `feature`/`bug`/`refactor`, team labels, area -labels) — never `spec`. This keeps the "filter for spec roots" query clean. +Do not create records with `loaf task` or `loaf spec`. Parent/child structure +is `loaf issue promote` (or `loaf issue new --parent`), not a `spec`-labeled +Linear rollup. ## Progress Update Format @@ -222,18 +169,9 @@ None currently. ## Issue Description Format -```markdown -## Summary -Brief description of the work and its purpose. - -## Acceptance Criteria -- [ ] Criterion 1 -- [ ] Criterion 2 -- [ ] Criterion 3 +The Linear description is `loaf issue push` output — `loaf issue render`, not a hand-authored summary. Do not paste a competing description over the render. -## Notes -Any relevant context (keep brief). -``` +Comments (not the description) still follow the progress-update format above. **Rules:** - Concise and actionable @@ -243,6 +181,8 @@ Any relevant context (keep brief). ## Status Conventions +Loaf status is `loaf issue status`. Linear status is the tracker's. Resolve drift with `loaf issue reconcile` (`--take-local` or `--take-tracker`). Do not flip Loaf status from Linear MCP tools. + | State | When to Use | |-------|-------------| | **Backlog** | Issue created, not started | @@ -341,6 +281,8 @@ Use `scripts/suggest-team.py "task desc"` to get suggestions. ## When to Create Issues +Create through `loaf issue new` so identity can be delegated. Do not create in Linear MCP and then forget to `loaf issue pull`. + | Action | Create Issue? | |--------|---------------| | Features, bugs, refactoring | Yes | diff --git a/dist/codex/skills/orchestration/references/local-tasks.md b/dist/codex/skills/orchestration/references/local-tasks.md index 69c89df64..30d617f47 100644 --- a/dist/codex/skills/orchestration/references/local-tasks.md +++ b/dist/codex/skills/orchestration/references/local-tasks.md @@ -1,259 +1,173 @@ -# Local Task Management +# Working Issues Locally -Break specs into atomic tasks using SQLite-backed Loaf task records when Linear -isn't available. +Orchestration-facing reference for the Loaf issue CLI: pick-up-next, started +worktrees, status, definition of done, and advisory labels. Issue commands +require initialized SQLite state. ## Contents -- Task Abstraction Layer -- Local Task Records -- Task Lifecycle -- Creating Tasks from Specs -- Cutover Reconciliation -- Task ID Generation -- Archiving Tasks -- Journal Integration -- Task Sizing -- Priority Levels -- Listing Tasks -- Work Log Updates -- Verification -- Local vs Linear Comparison - -## Task Abstraction Layer - -Tasks work identically whether backed by Linear or local SQLite state. - -### Configuration - -```yaml -# .agents/loaf.yaml -task_management: - backend: linear # or "local" - - linear: - team: ProjectName - default_labels: [] - - local: - archive_completed: true -``` - -### Abstracted Operations - -| Operation | Linear | Local | -|-----------|--------|-------| -| Create task | Create issue | `loaf task create --spec SPEC-XXX --title "..." --priority P1` | -| Fetch task | Get issue | `loaf task show TASK-XXX` or `loaf task show TASK-XXX --json` | -| Update status | Update issue | `loaf task update TASK-XXX --status in_progress` | -| List tasks | List issues | `loaf task list` (or `loaf task list --json` for machine parsing) | -| Complete | Move to Done | `loaf task update TASK-XXX --status done` | - -## Local Task Records +- Frontier +- Started worktree +- Status vocabulary +- Relationships +- Definition of done +- Buckets +- Command cheat sheet +- LEGACY -SQLite is the operational source of truth for task metadata, status, priority, -dependencies, dates, and relationships. Use `loaf task` CLI commands for all -task mutations. `.agents/tasks/` and `.agents/TASKS.json` were removed by the -SPEC-045 cutover and are rollback material only. - -**Create with:** `loaf task create --title "..." --spec SPEC-XXX` - -## Task Lifecycle +## Frontier +```text +loaf issue frontier [--json] ``` -todo → in_progress → review → done - │ │ │ │ - └────────┴───────────┴────────┘ - can return to earlier states -``` - -| Status | Meaning | -|--------|---------| -| `todo` | Ready to work, not started | -| `in_progress` | Actively being worked | -| `review` | Implementation complete, needs verification | -| `done` | Verified complete, ready for archive | - -## Creating Tasks from Specs - -### Input -- Spec ID (e.g., `SPEC-001`) -- Optional: priority override +Pick-up-next. Derived at read time, never stored. Lists non-archived issues in +`triage`, `backlog`, or `todo` that are not blocked. -### Task Breakdown Rules +| Qualifier | Meaning | +|-----------|---------| +| Open | Status is `triage`, `backlog`, or `todo` — not `active`, `done`, `cancelled`, or `duplicate` | +| Unblocked | No open predecessor via `blocks` / `blocked_by`. A predecessor that is `done`, `cancelled`, or `duplicate` does not block | +| Unclaimed | Not `active` and no started worktree. `loaf issue start` is the claim | -1. **One concern per task** - Don't mix backend + tests + frontend -2. **Clear done condition** - Observable, verifiable outcome -3. **Verification command** - How to prove it works -4. **File hints** - Which files will likely be modified +Archived rows are excluded. Kind is not filtered: a `--kind decision` question +can appear; it is not delivery work. Buckets are not read. Prefer `--json` +when diagnosing rather than scraping the human-readable text. -### Example Breakdown +## Started worktree +```text +loaf issue start <ref> [--json] +loaf issue stop <ref> [--force] [--json] +loaf issue list --started [--json] ``` -SPEC-001: User Authentication with OAuth - ↓ -TASK-001: OAuth Provider Integration - - Google OAuth client setup - - GitHub OAuth client setup - - Token exchange logic - verify: pytest tests/auth/test_oauth.py - -TASK-002: Session Management - - Session cookie handling - - Session storage (Redis/DB) - - Session expiry logic - verify: pytest tests/auth/test_session.py - -TASK-003: Login UI Components - - Login page layout - - Provider buttons - - Error states - verify: npm run test:e2e -- auth -``` - -## Cutover Reconciliation - -If a stale branch reintroduces `.agents/tasks/`, `.agents/sessions/`, other -ephemeral roots, or `.agents/TASKS.json`, keep the deletion side from the -cutover branch and rerun `loaf check --hook ephemeral-provenance`. Use -`loaf state restore-ephemerals <backup-id>` only for an intentional rollback, -then re-import forward. - -## Task ID Generation -Format: `TASK-{number}-{slug}` +**Invariant:** one agent, one worktree. Check `loaf issue list --started` +before dispatch. Never send two agents into the same path. -Task IDs are auto-generated by `loaf task create`. In SQLite-backed projects, -the allocation is recorded in state. +`start` creates branch `issue/<alias-or-id>` in lowercase (`issue/loaf-42`, +disambiguated with an id suffix when that name is already claimed), adds a +sibling worktree, records `started_branch` / `started_worktree` on the row, and +moves status to `active` through the events path. Base is the nearest started +ancestor's branch, else the repository default branch. Start refuses an already +started row, an archived row, and terminal statuses (`done`, `cancelled`, +`duplicate`). Requires a git repository. -## Archiving Tasks +`list --started` prints alias, title, `started_branch`, `started_worktree`, and +`(missing)` when the recorded path is gone. -When a task is done: +`stop` removes the worktree and clears the started workspace on the row. It +keeps the branch and does not change status. `--force` removes a dirty +worktree. Do not run `stop` from inside the started worktree. -1. Mark complete via CLI: `loaf task update TASK-XXX --status done` -2. Archive: `loaf task archive TASK-XXX` +## Status vocabulary -## Journal Integration +Write statuses that update in place: `triage`, `backlog`, `todo`, `active`, +`done`. `cancelled` and `duplicate` archive through the remove path +(`loaf issue status <ref> duplicate --duplicate-of <surviving>`). -When the implement workflow starts on `TASK-001`: - -1. Load task metadata via `loaf task show TASK-001` for context -2. Read linked spec for full picture -3. Log the task coupling as the first action: +```text +loaf issue status <ref> <status> [--duplicate-of <ref>] [--json] +``` -```bash -loaf journal log "decision(implement): implementing TASK-001" +| Status | Meaning | +|--------|---------| +| `triage` | Default at create. Shaped is derived (`loaf issue check`), not a status | +| `backlog` | Filed, worth keeping | +| `todo` | Explicitly ready to work | +| `active` | Started. **Review is a display name for `active`** — there is no `review` write status | +| `done` | Work landed | +| `cancelled` | Archived; abandoned | +| `duplicate` | Archived; `--duplicate-of` required | + +There is **no `blocked` status**. Blocked is a relationship. Title and body stay +mutable at every status. + +```text +loaf issue list [--status <status>] [--kind delivery|decision] [--archived] [--started] [--json] ``` -There is no session to create or couple to. Task progress is read through -`loaf task show/list`; the surrounding decisions and blockers live in the -project journal (`loaf journal recent`, `loaf journal search`). +Archived rows are hidden unless `--archived`. `--status` accepts every value in +the table above. -## Task Sizing +## Relationships -### Separation of Concerns +```text +loaf issue link <from> blocks|relates-to <to> [--json] +loaf issue link <from> remove <type> <to> [--json] +``` -**The primary principle for task breakdown is separation of concerns.** +Stored types are `blocks` and `relates_to`. `loaf issue link A blocks B` means +A blocks B: B is absent from the frontier until A is `done`, `cancelled`, or +`duplicate`. `relates-to` is not a sequencing constraint. -| Rule | Guideline | -|------|-----------| -| **One agent type** | Task completable by ONE subagent (implementer, reviewer, researcher) | -| **One concern** | Task touches one layer, one service, or one component | -| **Context-appropriate** | Fits in model context with room for exploration | -| **Not over-fragmented** | Don't split what naturally belongs together | +Do not encode order in `loaf issue tree`. Parent/child is structure; `blocks` +is the dependency. `loaf issue export [--json]` dumps relationships (and +claims) when you need the graph. -### Right Size Test +## Definition of done -1. Can a single specialized agent complete this? → If no, split by agent type -2. Does it touch multiple unrelated concerns? → If yes, split by concern -3. Will the agent need too much context? → If yes, split into smaller coherent units -4. Am I splitting just to have more tasks? → If yes, merge back +Criteria live on the issue row. `loaf issue show <ref>` prints each as +`position. [V|H] text` with `command=` / `expect=` when present. -### Agent Scope +```text +loaf issue dod add <ref> <text> [--command <cmd>] [--expect <expect>] [--tier V|H] [--serves <parent-position>] [--json] +loaf issue dod list <ref> [--json] +loaf issue dod remove <ref> <position> [--json] +loaf issue dod claim <child> <child-position> <parent-position> [--json] +loaf issue dod unclaim <child> <child-position> <parent-position> [--json] +loaf issue promote <ref> <position> [--json] +loaf issue check <ref> [--json] [--human <reason>] +loaf issue verify <ref> [--json] +``` -| Agent | Typical Task Scope | -|-------|-------------------| -| implementer (backend) | One service/module, its tests, its docs | -| implementer (frontend) | One component/page, its tests, its styles | -| implementer (database) | One migration, related schema changes | -| implementer (testing) | Test suite for one feature/area | -| implementer (infra) | One infrastructure concern (CI, deploy, config) | +| Tier | When | Who checks | +|------|------|------------| +| V | `--command` present, unless `--tier` overrides | `loaf issue verify <ref>` from the repository root. Honors `exit <N>` and `` contains `text` ``. Writes nothing. Non-zero on failure | +| H | No `--command`, unless `--tier` overrides | Human or orchestrator. Verify skips H-tier; that skip is not a pass | -## Priority Levels +Claims: a child criterion serves a parent criterion. `promote` copies the +parent criterion onto a new delivery child and records the claim. +`--serves` claims a newly added child criterion. `claim` / `unclaim` retarget +an existing pair. Positions are 1-based. -| Priority | Meaning | Response | -|----------|---------|----------| -| P0 | Urgent/blocking | Drop everything | -| P1 | High | Work next | -| P2 | Normal | Scheduled work | -| P3 | Low | When time permits | +`check` is readiness (shape's gate): delivery is shaped with a nonempty body, +at least one criterion, and an out-of-scope statement; decision is ready on a +sharp `?`. Children add coverage (every parent criterion claimed — failure) +and containment (every child criterion claims a parent — report). `verify` is +implement's preflight and writes nothing — it does not set status and does not +tick boxes. -## Listing Tasks +`loaf issue render <ref>` emits the paste-ready PR body: title, body, +definition-of-done checkboxes (checked only when status is already `done`), +and children. No manual editing. -### All Active Tasks +## Buckets -```bash -loaf task list +```text +loaf issue bucket <ref> now|next|later|none [--json] ``` -### Tasks for a Spec - -```bash -loaf spec list # Show specs with task counts -loaf task list --json # Machine-parseable output, filter by spec -``` +Advisory Now/Next/Later labels. Never read as a constraint. Frontier, start, +and verify ignore them. `none` clears the label. -## Work Log Updates +## Command cheat sheet -As work progresses, append to the Work Log section: - -```markdown -## Work Log - -### 2026-01-23 14:30 UTC -Started OAuth integration. Set up Google OAuth client credentials. - -### 2026-01-23 15:45 UTC -Google OAuth working. Moving to GitHub integration. - -### 2026-01-23 17:00 UTC -Both providers working. Tests pass. Moving to review. +```text +loaf issue new <title> [--body <text>|--body -|--body-file <path>|--message <text>] + [--kind delivery|decision] [--parent <ref>] [--fog <text>] [--status <status>] [--json] +loaf issue show <ref> [--json] +loaf issue tree [<ref>] [--archived] [--json] +loaf issue edit <ref> [--body-file <path>|--body -|--message <text>] [--json] +loaf issue export [--json] ``` -## Verification +`new` default kind is `delivery`; default status is `triage`. `--status` on +create still records the initial triage event, then writes the requested +write-status. `--fog` exists only on create. `edit` replaces the body; there +is no patch form. -Before marking `done`: - -1. Run the `verify` command from frontmatter -2. Check all acceptance criteria are checked -3. Ensure no regressions in related tests - -```bash -# Run task verification -verify_cmd=$(grep '^verify:' TASK-001-*.md | cut -d: -f2-) -eval "$verify_cmd" -``` +## LEGACY -## Local vs Linear Comparison - -| Feature | Local | Linear | -|---------|-------|--------| -| No external dependency | yes | no | -| Rich UI | no | yes | -| Team collaboration | git-based | native | -| Notifications | none | email/slack | -| Reporting | manual | built-in | -| Offline work | yes | limited | - -**Use local when:** -- Solo project -- No Linear access -- Offline development -- Simple task tracking - -**Use Linear when:** -- Team collaboration needed -- Rich workflow automation -- Integration with other tools -- Reporting requirements +`loaf task` and `loaf spec` remain readable against leftover SQLite rows. They +mint nothing new. Do not create records there. Issues are the work unit. diff --git a/dist/codex/skills/orchestration/references/parallel-agents.md b/dist/codex/skills/orchestration/references/parallel-agents.md index f4ef48ab2..35a07a7d6 100644 --- a/dist/codex/skills/orchestration/references/parallel-agents.md +++ b/dist/codex/skills/orchestration/references/parallel-agents.md @@ -117,7 +117,7 @@ When streams complete: | Command | Parallel Opportunity | |---------|---------------------| -| breakdown | Identify parallelizable tasks during decomposition | +| shape | Identify parallelizable child issues during decomposition | | implement | Single task, usually sequential | | implement | Runs dependency-aware orchestration, including parallel-safe tasks | diff --git a/dist/codex/skills/orchestration/references/script-surface.md b/dist/codex/skills/orchestration/references/script-surface.md index 70206ab63..474b76354 100644 --- a/dist/codex/skills/orchestration/references/script-surface.md +++ b/dist/codex/skills/orchestration/references/script-surface.md @@ -20,7 +20,7 @@ script surface: - The source currently has 10 orchestration scripts out of 22 skill-local scripts overall. -- Several scripts overlap existing `loaf journal`, `loaf task`, `loaf check`, +- Several scripts overlap existing `loaf journal`, `loaf issue`, `loaf check`, and Linear-aware behavior. - Shell/Python helpers are harder to discover than `loaf <noun> <verb>` and are not consistently covered by CLI tests. diff --git a/dist/codex/skills/orchestration/references/subagent-development.md b/dist/codex/skills/orchestration/references/subagent-development.md index 2d36b6175..d2c46e2a1 100644 --- a/dist/codex/skills/orchestration/references/subagent-development.md +++ b/dist/codex/skills/orchestration/references/subagent-development.md @@ -211,7 +211,7 @@ After subagent completes: | Command | Subagent Role | |---------|---------------| -| breakdown | Tasks become subagent assignments | +| shape | Promoted child issues become subagent assignments | | implement | May dispatch subagents for specialized work | | implement | Automatically coordinates single-task and multi-task subagent work | diff --git a/dist/codex/skills/orchestration/templates/journal.md b/dist/codex/skills/orchestration/templates/journal.md index caf7409fd..9a0a61f8f 100644 --- a/dist/codex/skills/orchestration/templates/journal.md +++ b/dist/codex/skills/orchestration/templates/journal.md @@ -13,7 +13,7 @@ not create or edit journal markdown as the source of truth — use ## Entries -[YYYY-MM-DD HH:MM] skill(implement): implementing TASK-042 +[YYYY-MM-DD HH:MM] skill(implement): implementing LOAF-42 [YYYY-MM-DD HH:MM] decision(scope): description of decision [YYYY-MM-DD HH:MM] discover(scope): something learned [YYYY-MM-DD HH:MM] block(scope): what is blocked diff --git a/dist/codex/skills/pitch/SKILL.md b/dist/codex/skills/pitch/SKILL.md index f34f451ad..635c0c2cd 100644 --- a/dist/codex/skills/pitch/SKILL.md +++ b/dist/codex/skills/pitch/SKILL.md @@ -1,21 +1,21 @@ --- name: pitch description: >- - Runs the human problem-discovery ceremony at change or project scale: grills - problem, who has it, current alternatives, value proposition, and constraints, - then authors a brief (change brief.md via loaf change init --brief, or project - docs/BRIEF.md with source: pitch). Use when the user invokes pitch, starts - work on a raw concept, or triage dispositions an item as pitch. Produces an - authored problem-space brief and a shape-now or park offer — never shape.md, - tasks, or PRs. Not for solution shaping (use shape), queue processing (use - triage), quick capture (use idea), or open-ended divergent inquiry (use - explore as an agent technique when pitch reveals the direction is undecided). + Runs the human problem-discovery ceremony: grills problem, who has it, current + alternatives, value proposition, and constraints, then hands a sharpened + problem narrative to shape or authors project docs/BRIEF.md. Use when the user + invokes pitch, starts work on a raw concept, or triage dispositions a spark or + idea as pitch. Produces a problem-space narrative and a shape-now or park + offer — never a bounded issue, criteria, or PRs. Not for quick capture (use + idea), solution bounding (use shape), queue processing (use triage), or + open-ended divergent inquiry (use explore as an agent technique when pitch + reveals the direction is undecided). version: 0.2.21 --- # Pitch -Human problem-discovery ceremony. Authors a brief at the matching scale so shape starts from a framed problem and bootstrap can consume a pitched project BRIEF. +Human problem-discovery ceremony. Narrows sparks and ideas into a framed problem so shape can mint an issue, and bootstrap can consume a pitched project BRIEF. ## Contents - Critical Rules @@ -32,61 +32,90 @@ Human problem-discovery ceremony. Authors a brief at the matching scale so shape ## Critical Rules 1. **Agents never initiate a pitch.** This ceremony is human-invoked only. On Claude Code the sidecar sets `disable-model-invocation: true`; on every target this rule binds behaviorally. Agent legwork *inside* a human-opened pitch (competitive scans, file writes the skill directs) is fine — opening one is not. -2. **Log invocation first** — `loaf journal log "skill(pitch): <idea, problem, or intake item>"` before interviewing. -3. **Problem-space only** — grill what, who, and why-valuable. Approach, architecture, decomposition, and verification design belong to shape. A brief that reads like a pseudo-shape is a failure; rewrite before landing. -4. **One question at a time, recommendation-first** — using your harness's structured question tool if it has one (otherwise one inline question per message). Never a multi-field form. Order by impact on the brief. Full mechanics: [references/interview-guide.md](references/interview-guide.md). -5. **Never write `shape.md`, seed `tasks/`, push, or open PRs** — pitch prepares commits and hands off; push and PR stay human. Never auto-run shape or bootstrap. -6. **Landing is validated, then committed once** — every capture landing runs explicit-path `loaf change check <folder> --json` (zero violations, expected captured state) and a direct read-back of that folder's `change.json` confirming intended `target_release` presence or absence, then one docs-only commit per capture. Never batch captures into one commit. -7. **Slug identity is local** — propose a slug that names the concept, never another work unit (no `spec-042`, no task ids). Provenance lives in frontmatter and the change folder. -8. **Log the outcome** — `loaf journal log "decision(pitch): <slug or project> — <shape-now|park-targeted|park-untargeted|handed-to-bootstrap>"`. +2. **Log invocation first** — `loaf journal log "skill(pitch): <idea, problem, spark, or intake item>"` before interviewing. +3. **Problem-space only** — grill what, who, and why-valuable. Approach, architecture, decomposition, and verification design belong to shape. A narrative that reads like a pseudo-shape is a failure; rewrite before landing. +4. **One question at a time, recommendation-first** — using your harness's structured question tool if it has one (otherwise one inline question per message). Never a multi-field form. Order by impact on the narrative. Full mechanics: [references/interview-guide.md](references/interview-guide.md). +5. **Never bound, never ship** — do not add definition-of-done criteria, do not write an out-of-scope statement, do not run `loaf issue check` or `loaf issue promote`, do not push, do not open PRs. Never auto-run shape or bootstrap. +6. **Shape mints on the happy path** — same-session shape-now hands the authored narrative; shape runs `loaf issue new` with that body. Pitch writes an issue body only when parking an unshaped row or when `$ARGUMENTS` already names an issue (`loaf issue edit` replaces the body). +7. **Titles name the concept** — propose a working title, never another work unit's alias. Provenance lives in the issue row, the spark/idea resolution, and frontmatter on `docs/BRIEF.md`. +8. **Log the outcome** — `loaf journal log "decision(pitch): <title, ref, or project> — <shape-now|park-issue|park-idea|handed-to-bootstrap>"`. --- ## Verification -- Change scale: `docs/changes/YYYYMMDD-slug/` holds `change.json` + authored `brief.md`; `loaf change check <folder> --json` reports zero violations and captured state; `change.json` read-back matches the intended target binding -- Project scale: `docs/BRIEF.md` exists with `source: pitch` and the shared problem-space skeleton -- Cold-read: problem, who, alternative, and value nameable in one pass; zero solution-space content -- No `shape.md` or `tasks/` written by this skill; no push; no PR +- Issue-scale: a problem narrative exists against the shared skeleton; it was handed to shape, written into an existing issue body, or minted as an unshaped triage row with that body and no criteria +- Project-scale: `docs/BRIEF.md` exists with `source: pitch` and the shared problem-space skeleton +- Cold-read: problem, who, alternative, and value nameable in one pass; zero solution-space content; no out-of-scope statement and no criteria added by this skill +- Named sparks were promoted to an idea when pitching them; ideas and sparks were resolved against the issue only after a row exists +- No push; no PR; shape and bootstrap were not auto-run - Journal shows skill invocation and outcome entries --- ## Quick Reference +| Harness | Invoke skill | +|---------|----------------| +| Claude Code (plugin) | `/loaf:pitch` | +| OpenCode, Cursor, Codex, Amp | `/pitch` | + ### Scale detection | Signal | Scale | Output | |--------|-------|--------| -| Existing project (git history, source, or Loaf state) + a concept | **Change** | `loaf change init <slug> --brief` → authored `brief.md` | -| Empty or minimal directory / greenfield intent | **Project** | `docs/BRIEF.md` with `source: pitch` → hand to bootstrap | +| Existing project (git history, source, or Loaf state) + a concept | **Issue** | Problem narrative → shape (`loaf issue new --body`) or an unshaped triage row | +| Empty or minimal directory / greenfield product pitch | **Project** | `docs/BRIEF.md` with `source: pitch` → hand to bootstrap | Detect and confirm briefly; let the human correct. When both could apply (repo exists but they want a new product pitch), ask once. -### Landing matrix (Decision 11) +### Landing offers -| Intent | Branch | Commit | Target | -|--------|--------|--------|--------| -| **Shape now** | Create the slug branch (`git switch -c <slug>`), stay there | Hand to shape for in-place promotion — do not park-commit first | Stamp `target_release` when known | -| **Park targeted** | Default branch | One docs-only commit on default (promise-carrier exception) | `target_release` present and confirmed by read-back | -| **Park untargeted** | Slug branch **or** remain intake (Intent/spark) | Docs-only commit on the slug branch if becoming a Change; else no Change folder | No `target_release`; untargeted captures never land on main | +| Offer | When to recommend | What pitch does | +|-------|-------------------|-----------------| +| **Shape now** | Framing is solid; they want to bound next | Hand the narrative; do not mint; do not auto-run shape | +| **Park as issue** | Framed, durable, not bounding yet | `loaf issue new "<title>" --body -` with the narrative only; status stays `triage` | +| **Park as idea** | Too thin to keep as a row, or might discard | `loaf idea capture --title "..."`; journal the gist | +| **Hand to bootstrap** | Project-scale BRIEF authored | Point at bootstrap; do not auto-run it | -Pitch prepares the commit; never pushes; never opens PRs. +Pitch never pushes; never opens PRs. There is nothing to commit at issue scale — the row lives in SQLite. Project-scale may commit `docs/BRIEF.md` if the human wants it durable. -### Pre-landing guard (every capture) +### Spark and idea promotion -```bash -loaf change check <folder> --json # zero violations; state is captured -# then read <folder>/change.json and confirm target_release presence/absence matches intent +| Input | Read | Then | +|-------|------|------| +| Spark | `loaf spark show <ref>` | `loaf idea capture --title "..."` then `loaf spark promote <spark> --to-idea <idea>`; grill from the idea | +| Idea | `loaf idea show <ref>` | Grill; after a row exists, `loaf idea resolve <idea> --by <ref>` | +| Existing issue | `loaf issue show <ref>` | Grill; `loaf issue edit <ref> --body -` writes the narrative (replaces the whole body) | +| Free text | — | Grill; shape-now hands text; park captures an idea or mints an unshaped row | + +Do not invent a pitch from the queue without human selection. When they name an intake item, read it (`loaf intake list` / the item's read command). + +`loaf idea promote --to-spec` is not this path. Resolve ideas against the minted issue. + +### Problem-narrative skeleton + +Author against these sections, problem-space sentences only. This text is what shape puts in `--body` (or what a park-as-issue row stores): + +```markdown +## Problem Statement +## Who Has It +## Current Alternatives +## Value Proposition +## Constraints +## Sequencing and Relationships +## Sources and Research Links +## Open Questions ``` -Bare `loaf change check` resolves by branch and can miss a capture landing elsewhere — always pass the explicit folder path. +Do not add an out-of-scope statement. Shape bounds; pitch frames. ### Defined terms -- **Brief** — the pitch output (problem-space). Superseded by `shape.md` when shaping starts; may accrete parked problem-space sentences until then; freezes when `shape.md` exists. -- **Accretion** — adding problem-space concepts to a parked brief is legal; solution prose is not. -- **Shape now** — slug branch + hand to shape, which promotes the capture in place via ordinary `loaf change init <slug>` (no `--brief`). +- **Problem narrative** — pitch's issue-scale output. Superseded as the working surface once shape mints and bounds the issue; may accrete parked problem-space sentences until then. +- **BRIEF** — project-scale `docs/BRIEF.md`. A project document, not a work container. +- **Accretion** — adding problem-space concepts to a parked narrative is legal; solution prose is not. +- **Shape now** — hand the narrative to shape, which mints via `loaf issue new` and owns bounding. --- @@ -95,81 +124,82 @@ Bare `loaf change check` resolves by branch and can miss a capture landing elsew ### Step 1: Log and parse input ```bash -loaf journal log "skill(pitch): <idea, problem, or intake item>" +loaf journal log "skill(pitch): <idea, problem, spark, or intake item>" ``` -Parse `$ARGUMENTS`: free text, an intake ref the human already chose, or empty (ask what to pitch). Read the named intake item when provided (`loaf intake list` / the item's read command). Do not invent a pitch from the queue without human selection. +Parse `$ARGUMENTS`: free text, a spark, an idea, an issue ref, an intake ref the human already chose, or empty (ask what to pitch). Read the named item when provided. Do not invent a pitch from the queue without human selection. ### Step 2: Detect scale -Apply the Quick Reference table. Confirm: "I'll treat this as a **change-scale** pitch on this repo" or "…as a **project-scale** pitch for a new BRIEF." Adjust if corrected. +Apply the Quick Reference table. Confirm: "I'll treat this as an **issue-scale** pitch on this repo" or "…as a **project-scale** pitch for a new BRIEF." Adjust if corrected. -### Step 3: Problem-discovery interview +### Step 3: Promote sparks; read ideas + +When the named input is a spark, promote it to an idea before grilling so the capture trail is one idea, not a dangling spark: + +```bash +loaf idea capture --title "<working title>" +loaf spark promote <spark> --to-idea <idea> +``` + +When the named input is already an idea, `loaf idea show` and grill. Leave resolution until an issue row exists. + +### Step 4: Problem-discovery interview Run the interview per [references/interview-guide.md](references/interview-guide.md): -- Pin a one-or-two-line **destination** before dimension grilling (fixes brief scope; project scale feeds VISION success criteria, change scale sharpens the eventual Hypothesis) +- Pin a one-or-two-line **destination** before dimension grilling (fixes narrative scope; project scale feeds VISION success criteria; issue scale sharpens what good looks like for the row) - Dimensions: problem, who has it, current alternatives / competitive landscape, value proposition, constraints (plus sequencing and open questions when needed) - Depth: scenario stress-testing, challenge stance, glossary-term hygiene; open questions must pass the specifiability test and carry HITL/AFK tags when precise - Applicability judgment: skip formal competitive analysis and deep personas when the pitch kind does not warrant them (bug fixes, internal chores) -- One question at a time, recommendation-first, ordered by brief impact +- One question at a time, recommendation-first, ordered by narrative impact - Stop on exit criteria or when answers stop changing the framing -If the direction is genuinely undecided mid-interview, offer the **explore** technique (agent-side; Explorations and checkpoints) rather than forcing a false brief. Pitch remains the human front door; explore is not re-opened as a slash alternative by this skill. +If the direction is genuinely undecided mid-interview, offer the **explore** technique (agent-side; Explorations and checkpoints) rather than forcing a false narrative. Pitch remains the human front door; explore is not re-opened as a slash alternative by this skill. -### Step 4: Evidence delegation (when warranted) +### Step 5: Evidence delegation (when warranted) -When competitive or landscape facts would change the brief and are not already known: +When competitive or landscape facts would change the narrative and are not already known: 1. Spawn a **researcher** subagent with a bounded question (competitors, substitutes, prior art — not solution design). 2. Land evidence: - - **Change scale:** files under the change folder's `research/` (create the folder with the change); link from Sources and Research Links - - **Project scale:** inline source links in `docs/BRIEF.md` Sources and Research Links (no change `research/` yet) -3. Resume the interview or brief draft with recommendations informed by the scan. + - **Issue scale:** source links in the narrative's Sources and Research Links. If a longer scan lands on disk, name it for the landscape, never for the work unit, and cite it from Sources. + - **Project scale:** inline source links in `docs/BRIEF.md` Sources and Research Links. +3. Resume the interview or draft with recommendations informed by the scan. Never fabricate competitive claims. If a scan is skipped, say so in Sources ("no external scan; alternative is internal workaround X"). -### Step 5a: Change-scale ceremony - -1. **Propose a slug** — lowercase, digits, single hyphens; names the concept locally. Confirm with the human. -2. **Initialize capture:** - - ```bash - loaf change init <slug> --brief - ``` - - Creates `docs/changes/YYYYMMDD-<slug>/` with `change.json` + `brief.md` scaffold only. -3. **Author `brief.md`** against the shared problem-space skeleton (shape's brief template / the scaffold just written): Problem Statement, Who Has It, Current Alternatives, Value Proposition, Constraints, Sequencing and Relationships, Sources and Research Links, Open Questions. Problem-space sentences only. -4. **Stamp `target_release` when known** — edit `change.json` with canonical `MAJOR.MINOR.PATCH` (no `v`, no prerelease). Omit the field when untargeted. Confirm with the human before stamping. -5. **Accretion note** — tell the human: parked problem-space concepts may accrete into this brief until shaping starts; once `shape.md` exists the brief freezes. -6. **Cold-read** the brief (interview guide test); revise with the human until it passes. -7. **Offer landing** (recommendation-first): +### Step 6a: Issue-scale ceremony - | Offer | When to recommend | - |-------|-------------------| - | **Shape now** | Framing is solid and they want to bound implementation next | - | **Park targeted** | Bound to a release cohort but not shaping yet — docs-only on default branch | - | **Park untargeted** | Worth capturing off-main, or not ready as a Change (stay intake) | +1. **Propose a working title** — names the concept locally. Confirm with the human. This becomes shape's `loaf issue new` title (or the park-as-issue title). +2. **Author the problem narrative** against the skeleton above. Problem-space sentences only. +3. **Accretion note** — tell the human: parked problem-space concepts may accrete until shaping starts; once the issue is minted, the body is the home. +4. **Cold-read** the narrative (interview guide test); revise with the human until it passes. +5. **Offer landing** (recommendation-first) using the Landing offers table. +6. **Execute the chosen landing:** -8. **Execute the chosen landing:** + - **Shape now:** hand the full narrative and any spark/idea refs. Shape runs `loaf issue new "<title>" --body -` (or `--body-file`) with that text. Do not mint, do not add criteria, do not open a PR. After shape mints, resolve intake: `loaf idea resolve <idea> --by <ref>` (and `loaf spark resolve <spark> --by <ref>` only if the spark was never promoted). + - **Park as issue:** mint the unshaped row yourself, then resolve intake against it: - - **Shape now:** `git switch -c <slug>` (from default unless already on a working branch the human prefers), ensure pre-landing guard would pass if they later park, hand to shape with the folder path — shape promotes in place. Do not open a PR. - - **Park targeted:** on the **default branch**, run pre-landing guard on the explicit folder, confirm `target_release` present in `change.json`, then one docs-only commit of the change folder (and any `research/` under it). - - **Park untargeted as Change:** `git switch -c <slug>`, pre-landing guard, confirm `target_release` **absent**, one docs-only commit on the slug branch. - - **Park as intake:** do not leave a half-written change folder; prefer Intent/spark retention and delete or never create the capture if the human backs out. + ```bash + loaf issue new "<title>" --body - + loaf idea resolve <idea> --by <ref> + ``` -9. **Commit message** (when parking): conventional, e.g. `docs(change): capture <slug> brief` — one commit per capture. + Paste the narrative on stdin. Do not add criteria. Do not write out-of-scope. Default status is `triage`. Read back with `loaf issue show <ref>`. + - **Park as idea:** if no idea exists yet, `loaf idea capture --title "<title>"`. Journal the gist (`loaf journal log "discover(pitch): <one-line problem>"`). Do not mint an issue. + - **Existing issue:** `loaf issue edit <ref> --body -` with the full narrative. Edit replaces the body; do not strip a row that is already bounded — if criteria already exist, hand the narrative to the human and let shape merge. -10. **Closing ceremony (required — never trail off).** After the landing is executed (or intake retained), announce completion with a full closing block: +7. **Closing ceremony (required — never trail off).** After the landing is executed, announce completion with a full closing block: - - **Recap the brief** — section-by-section gist (Problem, Who, Alternatives, Value, Constraints, Sequencing, Open Questions). Name the change folder path (`docs/changes/YYYYMMDD-<slug>/`) and what it holds (`change.json` + `brief.md`, plus any `research/`). + - **Recap the narrative** — section-by-section gist (Problem, Who, Alternatives, Value, Constraints, Sequencing, Open Questions). Name where it lives (handed to shape, unshaped issue `<ref>`, idea `<ref>`, or the conversation plus journal gist). - **Restate the landing actually taken** and what it means next: - - **Shape now** — you are on the slug branch; run shape next to promote the capture in place and bound implementation. No park-commit was made. - - **Park targeted** — the capture is a docs-only commit on the default branch with `target_release` stamped; it sits as a promise carrier for that cohort until shape is invoked later. - - **Park untargeted** — the capture lives on the slug branch (or remains intake) without `target_release`; it is off-main until retargeted or shaped. If intake-only, name the Intent/spark and that no change folder was left half-written. + - **Shape now** — run shape next to mint the issue from this narrative and bound implementation. No row was minted here. + - **Park as issue** — `<ref>` holds the problem in its body and is unshaped; run shape later on that ref. + - **Park as idea** — the idea remains open; re-invoke pitch or shape when ready. Name the idea ref. - **Announce completion** in plain language: "Pitch is complete." Do not end on a dangling offer or an unfinished sentence. -### Step 5b: Project-scale ceremony +### Step 6b: Project-scale ceremony 1. **Author `docs/BRIEF.md`** using bootstrap's brief skeleton with frontmatter: @@ -181,31 +211,31 @@ Never fabricate competitive claims. If a scan is skipped, say so in Sources ("no --- ``` - Same problem-space sections as change scale, at project altitude (Sequencing describes the initial arc as prose). + Same problem-space sections as issue scale, at project altitude (Sequencing describes the initial arc as prose). 2. **Cold-read** and revise with the human. 3. Optionally commit `docs/BRIEF.md` if the human wants it durable before bootstrap; still no push unless they ask outside this skill's duties — pitch itself never pushes. 4. **Closing ceremony (required — never trail off).** Announce completion with a full closing block — do not hand off in a half-sentence: - **Recap what was authored** — section-by-section gist of the BRIEF (Problem Statement, Who Has It, Current Alternatives, Value Proposition, Constraints, Sequencing and Relationships, Sources and Research Links, Open Questions). One or two sentences per section is enough; the human should hear what landed without reopening the file. - **Artifact path** — name `docs/BRIEF.md` explicitly, including that frontmatter carries `source: pitch`. - - **Explicit handoff** — state, verbatim in spirit: next, run bootstrap: it reads this BRIEF as discovery-done (`source: pitch`), interviews only on gaps, populates the operating documents (VISION, STRATEGY, ARCHITECTURE, AGENTS), and closes by proposing your initial arc of captured changes. Do not auto-run bootstrap. + - **Explicit handoff** — state, verbatim in spirit: next, run bootstrap: it reads this BRIEF as discovery-done (`source: pitch`), interviews only on gaps, and populates the operating documents (VISION, STRATEGY, ARCHITECTURE, AGENTS). Do not auto-run bootstrap. - **Announce completion** in plain language: "Pitch is complete." The ceremony ends with a period, never a trail-off. -### Step 6: Log the outcome +### Step 7: Log the outcome ```bash -loaf journal log "decision(pitch): <slug or project> — <shape-now|park-targeted|park-untargeted|handed-to-bootstrap>" +loaf journal log "decision(pitch): <title, ref, or project> — <shape-now|park-issue|park-idea|handed-to-bootstrap>" ``` -The journal line is mechanical; the human-facing close is the closing ceremony in Step 5a/5b. Never log-and-stop without that recap and next-step restatement. +The journal line is mechanical; the human-facing close is the closing ceremony in Step 6a/6b. Never log-and-stop without that recap and next-step restatement. --- ## Related Skills -- **shape** — solution-space narrowing from an existing brief (or full narrowing when no brief); promotes capture folders in place -- **bootstrap** — consumes `docs/BRIEF.md` (`source: pitch` → gap interview) and series-preps captured changes -- **triage** — queue dispositions; may hand an item to pitch when problem discovery is needed +- **shape** — solution-space bounding; mints the issue from the problem narrative (`loaf issue new`) and owns criteria, out-of-scope, and decomposition +- **bootstrap** — consumes `docs/BRIEF.md` (`source: pitch` → gap interview) and populates operating documents +- **triage** — queue dispositions; may hand a spark or idea to pitch when problem discovery is needed - **explore** — agent-side technique when pitch finds the direction still undecided - **idea** — quick capture without ceremony; not a substitute for pitch - **research** — patterns the researcher subagent follows for landscape scans @@ -218,4 +248,4 @@ The journal line is mechanical; the human-facing close is the closing ceremony i ## Artifact Naming -Name every artifact for what it is, never for the work unit that produced it. The change folder already records provenance. Put source in frontmatter, not the filename. See the `foundations` skill; `loaf check --hook artifact-names` enforces it at commit. +Name every on-disk artifact for what it is, never for the work unit that produced it. The issue row or `docs/BRIEF.md` already records provenance. Put source in frontmatter, not the filename. See the `foundations` skill; `loaf check --hook artifact-names` enforces it at commit. diff --git a/dist/codex/skills/pitch/references/interview-guide.md b/dist/codex/skills/pitch/references/interview-guide.md index 8014082fe..65780fb10 100644 --- a/dist/codex/skills/pitch/references/interview-guide.md +++ b/dist/codex/skills/pitch/references/interview-guide.md @@ -11,7 +11,7 @@ Problem-discovery interview for pitch. Borrows shape's grilling mechanics (one q - Open Questions: Specifiability and HITL/AFK - Exit Criteria - Anti-Patterns -- Brief Cold-Read +- Problem Cold-Read ## How This Guide Works @@ -23,22 +23,22 @@ The interview is adaptive, not a form. Strong answers skip dimensions; weak answ ## Destination Pinning -Before dimension grilling, converge on a **destination**: one or two lines naming the end state this pitch is aiming at — what becomes true in the world if the work succeeds. Pin it early; it fixes the brief's scope for the rest of the interview. +Before dimension grilling, converge on a **destination**: one or two lines naming the end state this pitch is aiming at — what becomes true in the world if the work succeeds. Pin it early; it fixes the narrative's scope for the rest of the interview. | Scale | Destination feeds | |-------|-------------------| | **Project** | VISION success criteria (bootstrap extracts it; pitch keeps it as the project's north star in the BRIEF) | -| **Change** | The eventual Hypothesis when shape promotes the capture — a sharper "what good looks like" than a feature list | +| **Issue** | What good looks like for the work — the problem statement shape will put in the issue body | **How to pin:** offer a recommendation-first draft from the human's opening words ("Destination: operators can ship a release without a manual config audit"). Confirm, tighten, or rewrite until both parties can restate it. Do not start deep dimension probes until the destination is on the table. -If the destination keeps shifting mid-interview, pause and re-pin — a moving destination means the brief's scope is still open. +If the destination keeps shifting mid-interview, pause and re-pin — a moving destination means the narrative's scope is still open. --- ## Problem-Discovery Dimensions -Grill these five dimensions. Order by what would change the brief most — a wrong "who" invalidates value and alternatives; cosmetic naming goes last. +Grill these five dimensions. Order by what would change the narrative most — a wrong "who" invalidates value and alternatives; cosmetic naming goes last. ### 1. Problem @@ -62,17 +62,17 @@ What do they do today? Existing tools, manual workarounds, cobbled scripts, or " Why is solving this worth it? What becomes true for the people who have the problem if this lands? One line: different AND better relative to the alternative — not a feature list, not an architecture sketch. -**Applicability:** always name value, but depth scales. A product pitch needs a crisp UVP; a small change can be "removes the weekly fire-drill so release day is boring." +**Applicability:** always name value, but depth scales. A product pitch needs a crisp UVP; a small internal pitch can be "removes the weekly fire-drill so release day is boring." ### 5. Constraints -Non-negotiable bounds: technical, legal, organizational, philosophical, sequencing. Things that limit the solution space before design begins. Capture as problem-space limits ("must not break the promise-carrier exception"), never as chosen designs ("use Postgres"). +Non-negotiable bounds: technical, legal, organizational, philosophical, sequencing. Things that limit the solution space before design begins. Capture as problem-space limits ("must not add a new human ceremony to the weekly path"), never as chosen designs ("use Postgres"). **Always ask lightly:** at least one real constraint or an explicit "none known yet." ### Secondary (only when signal demands) -- **Sequencing and relationships** — how this hangs with other work, release cohort as prose, series order. No machine relation fields. +- **Sequencing and relationships** — how this hangs with other work, series order. No machine relation fields. - **Open questions** — unresolved problem-space items that pass the [specifiability test](#open-questions-specifiability-and-hitlafk); each tagged HITL or AFK. - **Evidence of pain** — money, time, workarounds (Mom Test lens). When absent and the claim is large, challenge gently. @@ -105,13 +105,13 @@ Every question includes a recommended answer and a short rationale. The human ov Example shape: > **Who has this problem most often?** -> Recommendation: mid-size platform engineers who already run multi-service deploys — they feel config drift weekly. Rationale: your examples all came from that world; broader "all developers" would dilute the brief. +> Recommendation: mid-size platform engineers who already run multi-service deploys — they feel config drift weekly. Rationale: your examples all came from that world; broader "all developers" would dilute the narrative. ### Ordering -Prioritize answers that would rewrite the brief. Typical order: **destination pin** → problem → who → alternatives → value → constraints → sequencing/open questions. Reorder when the human's first utterance already settles an early dimension. +Prioritize answers that would rewrite the narrative. Typical order: **destination pin** → problem → who → alternatives → value → constraints → sequencing/open questions. Reorder when the human's first utterance already settles an early dimension. -Before asking, check whether reading resolves it — journal, prior Change, intake item body, BRIEF. Only ask what reading could not answer. +Before asking, check whether reading resolves it — journal, prior issue, intake item, BRIEF. Only ask what reading could not answer. ### Adaptive depth @@ -120,12 +120,12 @@ Before asking, check whether reading resolves it — journal, prior Change, inta | Crisp, specific answers | Confirm, move on; skip expand-if-needed probes | | Category answers ("developers need better tools") | Ask for a concrete story or last painful moment | | Solution-first ("I want a CLI that…") | Pause; reframe to problem and who | -| Energy dropping | Cut to synthesis; a brief with named gaps beats an exhausted interrogation | -| Direction genuinely undecided | Offer the explore technique from inside pitch; do not force a false brief | +| Energy dropping | Cut to synthesis; a narrative with named gaps beats an exhausted interrogation | +| Direction genuinely undecided | Offer the explore technique from inside pitch; do not force a false narrative | ### Scenario stress-testing -Probe the problem with **concrete lived scenarios**, not abstract categories. Prefer "walk me through the last time this bit you" over "how often does this happen?" Invent edge cases deliberately to force boundary precision: "What if the operator is on-call at 2am with half the logs missing — is that still this problem, or a different one?" Scenarios that collapse the framing expose missing constraints or a second concept that should stay out of this brief. +Probe the problem with **concrete lived scenarios**, not abstract categories. Prefer "walk me through the last time this bit you" over "how often does this happen?" Invent edge cases deliberately to force boundary precision: "What if the operator is on-call at 2am with half the logs missing — is that still this problem, or a different one?" Scenarios that collapse the framing expose missing constraints or a second concept that should stay out of this narrative. ### Challenge stance @@ -133,12 +133,12 @@ Demand specificity over generalization. Probe the rationale behind claims ("why Also enforce **canonical language** during the interview: -- Flag terms that conflict with `docs/knowledge/glossary.md` usage (e.g. calling a Change a "spec," a release cohort a "milestone," a skill a "module"). Offer the glossary term and get the human to accept the swap before it lands in the brief. +- Flag terms that conflict with `docs/knowledge/glossary.md` usage (e.g. calling an issue a "ticket," a skill a "module"). Offer the glossary term and get the human to accept the swap before it lands in the narrative. - Sharpen fuzzy project-local terms to a single canonical choice mid-interview ("you said both 'capture' and 'ticket' — pick one and stick to it"). Ambiguous vocabulary becomes solution fog later. ### Mid-interview evidence -When competitive landscape or external facts would change the brief and the human lacks them, pause the grill, delegate a researcher subagent, land evidence (change-scale: `research/` in the change folder; project-scale: links in Sources), then resume with a recommendation informed by the scan. Evidence supports framing; it does not become solution design. +When competitive landscape or external facts would change the narrative and the human lacks them, pause the grill, delegate a researcher subagent, land evidence (issue-scale: source links in the narrative; project-scale: links in Sources), then resume with a recommendation informed by the scan. Evidence supports framing; it does not become solution design. --- @@ -146,11 +146,11 @@ When competitive landscape or external facts would change the brief and the huma ### Specifiability test -An open question earns a **precise entry** in the brief only if it can be **stated precisely now** — not answered now. The bar is: a later reader (human or agent) could work the question without inventing what was meant. +An open question earns a **precise entry** in the narrative only if it can be **stated precisely now** — not answered now. The bar is: a later reader (human or agent) could work the question without inventing what was meant. | Passes (precise entry) | Fails (coarse note only) | |------------------------|--------------------------| -| "Does the operator need multi-region failover in v1, or is single-region acceptable for the first cohort?" | "Figure out reliability stuff" | +| "Does the operator need multi-region failover in v1, or is single-region acceptable until the first cut?" | "Figure out reliability stuff" | | "Which existing CLI command is the migration source of truth for config paths?" | "TBD on integration" | Everything vaguer stays a **coarse note** in Open Questions or Sequencing prose — never pre-sliced into fake precision. Do not invent enumerated options the human did not surface. @@ -164,7 +164,7 @@ Mark each precise open question with one of: | **HITL** | Needs the human live — judgment, taste, organizational call, or access only they hold | "Will legal accept the data-retention tradeoff?" | | **AFK** | Runnable by an agent without the human in the loop — research, codebase scan, competitive lookup | "What do the top three substitutes charge for the free tier?" | -Briefs carry the tag inline in the Open Questions section, e.g. `- [HITL, blocking] …` or `- [AFK, deferrable] …`. Blocking vs deferrable remains orthogonal: a HITL question can be deferrable; an AFK question can still block shaping if the answer rewrites scope. +Narratives carry the tag inline in the Open Questions section, e.g. `- [HITL, blocking] …` or `- [AFK, deferrable] …`. Blocking vs deferrable remains orthogonal: a HITL question can be deferrable; an AFK question can still block shaping if the answer rewrites scope. --- @@ -180,13 +180,13 @@ Stop interviewing when all of the following hold (or the human explicitly wants 6. **Constraints** are listed or explicitly empty. 7. Answers have stopped changing the framing — the last questions confirmed rather than rewrote. 8. Open questions that remain pass the specifiability test (or are coarse notes) and carry HITL/AFK tags when precise. -9. A cold reader could pass the brief cold-read test below. +9. A cold reader could pass the problem cold-read test below. -Open questions may remain; mark each blocking or deferrable, and HITL or AFK when precise. Blocking problem-space unknowns belong in the brief's Open Questions, not as invented answers. +Open questions may remain; mark each blocking or deferrable, and HITL or AFK when precise. Blocking problem-space unknowns belong in the narrative's Open Questions, not as invented answers. ### The pivot -Do not announce "the interview is over." Shift: "I think I have enough to draft the brief — tell me what I got wrong." Author the brief against the shared skeleton, then section-review with the human before any init or landing step. The **closing ceremony** (recap, path, next step) is owned by SKILL.md after landing — this guide owns interview depth only. +Do not announce "the interview is over." Shift: "I think I have enough to draft the problem narrative — tell me what I got wrong." Author the narrative against the shared skeleton, then section-review with the human before any mint or landing step. The **closing ceremony** (recap, path, next step) is owned by SKILL.md after landing — this guide owns interview depth only. --- @@ -196,7 +196,7 @@ Adopted from bootstrap's interview guide; binding on pitch. **The Form.** Running dimensions mechanically like a survey. If answer 2 covers dimension 4, confirm and skip. -**The 45-Minute Interrogation.** If energy drops, cut to draft. Gaps in the brief are honest; drained enthusiasm is not recoverable in the same session. +**The 45-Minute Interrogation.** If energy drops, cut to draft. Gaps in the narrative are honest; drained enthusiasm is not recoverable in the same session. **The Therapist.** Do not explore the builder's feelings about the product. User emotions (switching forces, pain) matter; builder therapy does not. @@ -212,7 +212,7 @@ Adopted from bootstrap's interview guide; binding on pitch. **Third Interview Idiom.** Do not invent pitch-specific interview machinery. Destination pinning, scenario stress-testing, and challenge stance deepen the same grilling mechanics — they are not a parallel framework. -**Pseudo-Shape in the Brief.** Approach, architecture, task breakdown, or verification design must not enter `brief.md` / `docs/BRIEF.md`. If it appears while drafting, move it out and note it for shape. +**Pseudo-Shape in the Narrative.** Approach, architecture, decomposition, or verification design must not enter the problem narrative or `docs/BRIEF.md`. If it appears while drafting, move it out and note it for shape. **Fake Precision.** Pre-slicing vague unknowns into numbered open questions that cannot yet be stated precisely. Coarse notes beat counterfeit clarity. @@ -220,9 +220,9 @@ Adopted from bootstrap's interview guide; binding on pitch. --- -## Brief Cold-Read +## Problem Cold-Read -Before offering shape-now or park, cold-read the authored brief. A stranger should name, in one pass: +Before offering shape-now or park, cold-read the authored narrative (issue-scale) or `docs/BRIEF.md` (project-scale). A stranger should name, in one pass: 1. The **destination** (or success end-state) 2. The **problem** @@ -230,4 +230,4 @@ Before offering shape-now or park, cold-read the authored brief. A stranger shou 4. The **current alternative** 5. The **value** of solving it -…and find **zero solution-space content** (no approach, stack, API shape, or task list). Precise open questions should carry HITL/AFK tags. If any of the five is missing or solution prose creeps in, revise before landing. +…and find **zero solution-space content** (no approach, stack, API shape, or implementation slices). Precise open questions should carry HITL/AFK tags. If any of the five is missing or solution prose creeps in, revise before landing. diff --git a/dist/codex/skills/refactor-deepen/SKILL.md b/dist/codex/skills/refactor-deepen/SKILL.md index 4e0974049..06a4998e5 100644 --- a/dist/codex/skills/refactor-deepen/SKILL.md +++ b/dist/codex/skills/refactor-deepen/SKILL.md @@ -173,7 +173,7 @@ skill terminates by writing a PLAN file using [templates/plan.md](templates/plan > Plan saved to `.agents/plans/<filename>.md`. Workflow handoff is pending > the SPEC/PLAN/TASKS artifact taxonomy spec — for now, decide manually. -Do **not** recommend breakdown or implement as the next step. The +Do **not** recommend implement as the next step. The handoff design is downstream of a deferred taxonomy spec. ### Codex Review (Opt-In, Plugin-Gated) diff --git a/dist/codex/skills/refactor-deepen/templates/plan.md b/dist/codex/skills/refactor-deepen/templates/plan.md index 8be684590..0fe537186 100644 --- a/dist/codex/skills/refactor-deepen/templates/plan.md +++ b/dist/codex/skills/refactor-deepen/templates/plan.md @@ -28,8 +28,8 @@ write a new file rather than updating an existing one. | `title` | Yes | One-line description of the deepening, not the candidate name | | `created` | Yes | ISO 8601 UTC, e.g. `2026-05-02T01:30:00Z` (must match the filename timestamp) | | `status` | Yes | `drafting` on first write; this template does not define additional lifecycle states | -| `spec` | Yes | `SPEC-NNN` if the plan is scoped under a spec; `null` otherwise (do not omit the key) | -| `related` | No | List of related artifact IDs (`ADR-*`, `SPEC-*`, idea filenames, other plan filenames) | +| `issue` | Yes | `LOAF-NNN` if the plan is scoped under an issue; `null` otherwise (do not omit the key) | +| `related` | No | List of related artifact IDs (`ADR-*`, `LOAF-*`, idea filenames, other plan filenames) | PLAN files do **not** carry an `id` frontmatter field. The filename is the identity, mirroring councils and ideas. @@ -131,7 +131,7 @@ Filename: `.agents/plans/20260502-013000-deepen-journal-append.md` title: "Deepen journal append into a self-managing module" created: "2026-05-02T01:30:00Z" status: drafting -spec: SPEC-034 +issue: LOAF-34 related: - 20260501-231922-plan-lifecycle-cli-doctor-housekeeping --- diff --git a/dist/codex/skills/reflect/SKILL.md b/dist/codex/skills/reflect/SKILL.md index 44322c585..47153e788 100644 --- a/dist/codex/skills/reflect/SKILL.md +++ b/dist/codex/skills/reflect/SKILL.md @@ -81,12 +81,12 @@ After completing work, reflect extracts learnings and proposes updates to strate ### Step 1: Parse Input -`$ARGUMENTS` can be: a spec ID (`SPEC-001`), a topic ("authentication learnings"), or empty (general reflection on recent work). +`$ARGUMENTS` can be: an issue ref (`LOAF-42`), a topic ("authentication learnings"), or empty (general reflection on recent work). ### Step 2: Gather Evidence Sources: -1. **Completed specs** (`.agents/specs/SPEC-*.md` with status `done`; legacy files may still read `complete`) -- look for "Lessons Learned" +1. **Completed issues** (`loaf issue list` / `loaf issue show <ref>` with status `done`) -- look for lessons in the issue body 2. **Project journal** (`loaf journal recent --json`, `loaf journal search <topic>`) -- insights, surprises, pivots 3. **Recent commits** (`git log --oneline -30`) 4. **Implementation reality** -- what was harder/easier than expected? What assumptions were wrong? diff --git a/dist/codex/skills/release/SKILL.md b/dist/codex/skills/release/SKILL.md index 510f4ee1d..9dbab87b1 100644 --- a/dist/codex/skills/release/SKILL.md +++ b/dist/codex/skills/release/SKILL.md @@ -1,33 +1,27 @@ --- name: release description: >- - Orchestrates standalone releases from already-landed work: release readiness, - version selection, changelog curation, release commit, tag, GitHub Release, - install verification, and post-release follow-up. Use when the user says "cut - a release," "publish a version," "release from main," or asks whether enough - landed work should become a release. Not for reviewing or merging a PR (use - ship). + Cuts a retroactive release from already-landed issues: loaf release suggest + reports the range, loaf release cut records the version. Use when the user + says "cut a release," "publish a version," "release from main," or asks what + landed since the last tag. Produces a recorded release row and members as + facts. Not for reviewing or merging a PR (use ship). version: 0.2.21 --- # Release -Publish a coherent version from work that has already landed. +Cut a version from work that has already landed. ## Contents - Critical Rules - Verification - Quick Reference - Topics -- Context Detection -- Step 1: Release Readiness -- Step 2: Change Collection -- Step 3: Version + Changelog -- Step 4: Release Execution -- Step 5: Release-PR Flow -- Step 6: Publication Verification -- Step 7: Post-Release Follow-Up -- Hook Interaction +- Process +- Attribution +- Bump derivation +- Must-contain convention - Related Skills **Input:** $ARGUMENTS @@ -36,259 +30,227 @@ Publish a coherent version from work that has already landed. ## Critical Rules -- **Release is not merge** -- do not use release to review, approve, or land a feature PR. Use ship for PR correctness and landing. -- **Release from landed work** -- collect changes from the release base branch, normally the repo default branch, since the last release tag. -- **Release-PR flow is the default** -- prepare on a release branch with `loaf release --pre-merge`, squash-merge the release PR, then finalize with `loaf release --post-merge` on the base branch. Direct `--bump` on the base branch is a named exception used only on explicit user request. -- **Batch by intent** -- group release notes by user-facing outcome, `CR-*` change bundle, spec, or related PRs; do not mirror individual commits mechanically. -- **Keep landed and released distinct** -- a PR may be landed without being released; a release may contain multiple landed PRs. -- **Block on release-readiness failure** -- do not publish if build, tests, version files, changelog, tag, or GitHub release state is inconsistent. -- **Never push, tag, or publish without confirmation** -- present the exact actions first. -- **Use your harness's structured question tool (if it has one) for release decisions** -- version bump type, release PR handoff, push/tag/GitHub Release confirmation. -- **Log release** -- after publication, run `loaf journal log "decision(release): vX.Y.Z published from <base> with <summary>"`. +1. **Log invocation first** — `loaf journal log "skill(release): <what is being cut or suggested>"` before doing anything else. +2. **Release is not merge** — do not review, approve, or land a PR here. Verification authority is the ship workflow (PR review and CI at merge). If the user is asking to merge, stop and route to ship. +3. **A release is cut from what landed** — the surface is `loaf release suggest` and `loaf release cut`. Do not run unsubcommmanded `loaf release`, `--pre-merge`, or `--post-merge`; this skill does not own that path. +4. **Suggest writes nothing** — it reads `baseline-tag..HEAD` (or `--base <ref>..HEAD`), attributes commits to issues, rolls up through parents, reports partially-landed parents and unattributed commits as information, derives the bump, reports the advisory bucket delta, and drafts notes. +5. **Cut records facts** — it applies the version, prepends the drafted notes into `CHANGELOG.md`, tags, records the release row plus members, then attempts a GitHub Release draft. A `gh` failure degrades to a warning with a paste-ready retry command; the recorded row stays. +6. **No forward version stamp** — do not bind an issue to a future version. Members are what already landed. Buckets (`loaf issue bucket`) are advisory labels; planned-vs-landed is information only. +7. **No suite, no re-record, no publication stop in this skill** — ship already verified the merged work. Cut's operational refusals (dirty worktree, disagreeing version files, missing version, `--no-tag` without an existing tag) are command errors, not a substitute for ship. +8. **Confirm before cut** — present the suggest report (or `cut --dry-run`) first. Ask one question at a time, with a recommendation, using your harness's structured question tool if it has one. `--dry-run` previews everything and writes nothing. +9. **Log the outcome** — after a successful cut, `loaf journal log "decision(release): vX.Y.Z recorded from <base> with <summary>"`. + +--- ## Verification -- Release base branch is clean, current, and contains the intended landed PRs -- Pre-flight checks pass before versioning or publication -- Changelog entries are curated user-facing prose, not commit or PR-title dumps -- Version files, changelog heading, git tag, and GitHub Release all agree -- Tag points at the released base-branch commit or release commit, not an abandoned feature branch -- Downstream install path is verified when applicable, especially Homebrew for Loaf releases +- Journal contains the `skill(release)` invocation (and a `decision(release)` entry after a real cut) +- The work in the range already landed through ship (PR review and CI at merge); this skill did not re-verify or re-merge it +- `loaf release suggest` (or `cut --dry-run`) was shown: landed issues, partially-landed parents, unattributed commits, advisory buckets, derived bump, drafted notes +- Partially-landed parents, unattributed commits, and bucket drift were reported as information — not treated as a cut refusal +- Mutating `loaf release cut` updated version files, wrote the notes into `CHANGELOG.md`, created or reused tag `v<version>`, and recorded the release row with issue members (plus `--includes` release members when given) +- `cut --dry-run` left version files, changelog, tags, HEAD, and release rows untouched +- GitHub Release is a draft, was skipped with `--no-gh`, or failed with a warning plus a paste-ready `gh release create …` retry — never a silent rollback of the recorded row +- No issue was stamped with a future version + +--- ## Quick Reference -| Step | Gate | Blocking? | -|------|------|-----------| -| Readiness | clean/current base branch, no unresolved release collisions | Yes | -| Change Collection | landed work since last tag grouped into release themes | Yes | -| Version + Changelog | bump selected, notes curated, files updated | Yes | -| Execution | release commit prepared via `--pre-merge`, release PR landed, `--post-merge` finalizes | Yes | -| Verification | release and install paths checked | Yes | -| Follow-Up | reflect/housekeeping suggested when useful | No | +| Harness | Invoke skill | +|---------|----------------| +| Claude Code (plugin) | `/loaf:release` | +| OpenCode, Cursor, Codex, Amp | `/release` | + +### Commands + +```text +loaf release suggest [--base <ref>] [--json] +loaf release cut [--base <ref>] [--bump <type>] [--includes <version|tag>] [--no-tag] [--no-gh] [--dry-run] +loaf issue bucket <ref> now|next|later|none [--json] +loaf issue link <from> blocks|relates-to <to> [--json] +``` + +Both commands need initialized SQLite state (`loaf state init`, or `loaf state migrate markdown --apply`). They are not a git repository's optional extra — without SQLite they refuse. + +| Command | Writes? | What it does | +|---------|---------|----------------| +| `loaf release suggest` | No | Report landed work since the last version tag | +| `loaf release cut` | Yes (unless `--dry-run`) | Cut the retroactive release and record members as facts | +| `loaf release cut --dry-run` | No | Print the plan, including `--includes` rows, and write nothing | + +### `suggest` flags + +| Flag | Meaning | +|------|---------| +| `--base <ref>` | Commits since `<ref>` instead of the last tag | +| `--json` | Machine-readable suggestion | +| `-h`, `--help` | Help | + +`suggest` rejects `--dry-run` (it is already read-only) and rejects cut-only flags (`--bump`, `--includes`, `--no-tag`, `--no-gh`). + +### `cut` flags + +| Flag | Meaning | +|------|---------| +| `--base <ref>` | Commits since `<ref>` instead of the last tag | +| `--bump <type>` | Override the derived bump: `major`, `minor`, `patch`, `prerelease`, `release` | +| `--includes <version\|tag>` | Record a prior release as a member (repeatable). Use this to hang prerelease references on a stable | +| `--no-tag` | Do not create a git tag; tag `v<version>` must already exist | +| `--no-gh` | Skip the GitHub Release draft | +| `--dry-run` | Print the plan and write nothing | +| `-h`, `--help` | Help | + +`cut` rejects `--json`. `--bump prerelease` and `--bump release` only produce a version when the current version already has a prerelease suffix; otherwise cut fails with `could not compute a version to cut`. `--no-tag` is checked before `--dry-run`: the tag must already exist even for a preview. + +### Cut sequence (mutating) + +1. Recompute the same suggestion as `suggest` (then apply `--bump` if given, and redraft notes) +2. Resolve each `--includes` ref to an existing release +3. Require a clean worktree +4. Apply the version to detected version files (they must exist and agree) +5. Prepend drafted notes into `CHANGELOG.md` (after `[Unreleased]`, ahead of prior versions; creates the file if missing) +6. Commit `chore: release vX.Y.Z` +7. Unless `--no-tag`: create annotated tag `vX.Y.Z` (`git tag -a`). Signing follows git config (`tag.gpgSign`); cut never passes `-s` or `--no-sign` +8. Record the release row, issue members, and `--includes` members as facts +9. Unless `--no-gh`: `gh release create <tag> --draft --title <tag> --notes <notes>` (adds `--prerelease` when the version is a prerelease). Switches to the configured GitHub account first. On `gh` missing, account failure, or create failure: print `warning:` plus a POSIX-quoted `retry:` command; do not fail the cut + +Cut does not push the commit or the tag. + +--- ## Topics | Topic | Use When | |-------|----------| -| [Context Detection](#context-detection) | Determining release base, last tag, and current branch | -| [Release-PR Flow](#step-5-release-pr-flow) | Preparing, landing, and finalizing every release | -| [Hook Interaction](#hook-interaction) | Understanding coexistence with git hooks | +| [Process](#process) | Running suggest then cut in this conversation | +| [Attribution](#attribution) | How commits become issue members | +| [Bump derivation](#bump-derivation) | Why suggest picked major, minor, or patch | +| [Must-contain convention](#must-contain-convention) | The rare promise that named issues must land first | --- -## Context Detection - -Before anything, establish the release surface: - -1. Get current branch and repo default branch: - ```bash - git branch --show-current - gh repo view --json defaultBranchRef -q .defaultBranchRef.name - ``` -2. Parse `$ARGUMENTS` for an explicit base, tag, or version. If omitted, use the repo default branch as the release base. -3. Verify the current branch: - - If already on the release base, continue; the release-PR flow in Step 5 branches from here. - - If on a dedicated release branch, resume the release-PR flow at the matching step. - - If on a feature branch, stop and explain that release publishes from landed work. Offer ship if the active PR needs landing first. -4. Find the previous release tag: - ```bash - git describe --tags --abbrev=0 - ``` -5. Gather the candidate release range: - ```bash - git log --oneline <last-tag>..HEAD - git diff --stat <last-tag>..HEAD - ``` +## Process ---- +Parse `$ARGUMENTS` for a base, bump, version, `--includes`, `--no-tag`, `--no-gh`, or `--dry-run`. Default baseline is the last version tag; `--base` overrides. With neither a last tag nor `--base`, the range is all of `HEAD`. -## Step 1: Release Readiness - -Run release pre-flight checks before editing release files: - -1. Ensure worktree is clean: - ```bash - git status --short - ``` -2. Ensure the release base is current: - ```bash - git fetch --tags origin - git status --branch --short - ``` -3. Check for existing tag or GitHub Release collisions for the target version once known: - ```bash - git tag --list vX.Y.Z - gh release view vX.Y.Z - ``` -4. Run project checks: - - Node: `npm run typecheck`, `npm run test`, `npm run build` when scripts exist - - Go: `go vet ./...`, `go test ./...` when `go.mod` exists - - Python: `pytest`, `mypy .`, `ruff check .` when configured - - Rust: `cargo check`, `cargo test` when `Cargo.toml` exists - -If no checks are detected, warn explicitly. If a check fails, stop and fix before release. +### Step 1: Log and route ---- +```bash +loaf journal log "skill(release): <what is being cut or suggested>" +``` -## Step 2: Change Collection - -Collect landed work since the last release and group it for release notes. - -1. Inspect commits: - ```bash - git log --first-parent --oneline <last-tag>..HEAD - git log --oneline <last-tag>..HEAD - ``` -2. Inspect merged PRs when GitHub is available: - ```bash - gh pr list --state merged --base <base> --json number,title,mergedAt,url - ``` -3. Group changes by user-facing outcome: - - `CR-*` change bundle, when referenced - - spec or task family, when public enough to be useful - - feature/fix/documentation/build themes - - operational release work, when it affects users or maintainers -4. Drop noise: - - purely internal task labels - - reverted work that is not present in `HEAD` - - individual commit mechanics that collapse into one user-facing change - -Present the grouped release contents before choosing the bump. +If the user wants a PR reviewed or merged, stop and use ship. If the work is still on a feature branch, explain that a release is cut from landed `HEAD` since the baseline, and offer ship. ---- +### Step 2: Suggest -## Step 3: Version + Changelog - -Choose the bump and curate the changelog from the grouped landed work. - -1. Run a dry run: - ```bash - loaf release --dry-run - ``` - Use `--base <ref>` when the project expects a non-default release base. -2. Present: - - current version - - proposed next version - - detected version files - - release actions the CLI would perform - - draft changelog entries -3. Curate `CHANGELOG.md` before publishing: - - write from the upgrading user's perspective - - group under Common Changelog categories: `Changed`, `Added`, `Removed`, `Fixed` - - use one self-describing line per meaningful change - - include public PR, issue, ADR, release, or commit links when helpful - - avoid dumping commit subjects, task IDs, session mechanics, or internal gate language -4. Confirm the bump type: `prerelease`, `release`, `major`, `minor`, or `patch`. +```bash +loaf release suggest +# or +loaf release suggest --base <ref> +loaf release suggest --json +``` ---- +Present the report as-is: base, suggested bump and version, bump evidence, landed issues with commits, partially-landed parents (missing children), unattributed commits, advisory buckets (planned landed / planned not landed / unplanned landed), drafted notes. -## Step 4: Release Execution +Do not hide partial parents or unattributed commits, and do not refuse the cut because of them unless the operator is using the [must-contain convention](#must-contain-convention) and wants to wait. -Every release routes through the release-PR flow in Step 5: prepare the release commit on a release branch with `loaf release --pre-merge`, land the release PR, then finalize with `loaf release --post-merge` on the base branch. +### Step 3: Confirm -Release preparation should: +Show the exact `loaf release cut …` you would run. Recommend cutting the derived version when the landed set matches what the operator asked for. Use `--bump` only when they override. Use `--dry-run` when they want a preview: -1. Update version files -2. Convert `[Unreleased]` into `## [X.Y.Z] - YYYY-MM-DD` -3. Reinsert a fresh empty `[Unreleased]` section -4. Run configured release artifact commands -5. Create the release commit +```bash +loaf release cut --dry-run +loaf release cut --dry-run --no-gh +loaf release cut --dry-run --includes <version|tag> +``` -After preparation, verify generated artifacts are current: +### Step 4: Cut ```bash -npm run build -git diff --exit-code -- dist plugins content/skills/loaf-reference/SKILL.md +loaf release cut +loaf release cut --bump minor +loaf release cut --includes v1.1.0-alpha.1 +loaf release cut --no-tag --no-gh ``` -Adjust the path list to the project. For Loaf itself, tracked generated outputs under `dist/`, `plugins/`, and native binaries must match the source changes. +On success, report version files updated, changelog written, tag created or reused, release recorded (member count), and GitHub draft created / skipped / warned. If stderr has `retry:`, paste that command; the row is already recorded. -Capability receipts pin artifact SHA-256s, and the release rebuild version-stamps generated artifacts (`dist/opencode/plugins/hooks.ts` embeds `@version`, so every version bump stales the OpenCode receipt; Go changes additionally stale all binary-pinned receipts via `bin/native`). Therefore re-recording runs AFTER `loaf release --pre-merge` completes its artifact rebuild, on the release branch, before pushing the release PR — never before the bump. Verify with `go test ./internal/cli -run TestTargetCapabilityEvidence`. `loaf release` now enforces this mechanically on every mutating path (post-rebuild refusal in apply, guardrail 9 in `--post-merge`) — the rule explains WHY the gate fires; the gate makes skipping it impossible. +```bash +loaf journal log "decision(release): vX.Y.Z recorded from <base> with <summary>" +``` -### Direct Release (Named Exception) +### Step 5: After -`loaf release --bump <type> --yes` on the base branch prepares, commits, tags, and publishes in a single shot. Use it only when the user explicitly requests a direct release; never select it by default. Skipping the release PR means nothing runs the suite against the prepared tree before the tag exists — the v0.2.16 cut took this door and a capability-evidence canary surfaced only in tag CI, after publication. The same day, v0.2.17 re-recorded evidence minutes before the version bump; the release commit staled it, and the tag again published zero assets — ordering, not diligence, is the failure mode. The CLI prints a flow advisory when a mutating release starts on the default branch; treat it as a routing signal, not noise. +Suggest reflect when the cut produced durable product or workflow learnings, and housekeeping when temporary artifacts need cleanup. Capture leftover discoveries as issues or sparks — not as extra changelog lines. --- -## Step 5: Release-PR Flow +## Attribution -The default for every release: PR CI runs the full suite against the prepared tree, so evidence canaries surface before any tag or GitHub Release exists. This holds regardless of repository settings — where branch protection is enabled it is satisfied as a side effect, not the reason for the flow. +`suggest` (and `cut`, which recomputes the same suggestion) attributes each commit in the range to zero or more issues, then rolls up through parents. -1. Create a dedicated release branch from the release base. -2. Run `loaf release --pre-merge` on it: this creates the version/changelog/artifact release commit but no tag and no GitHub Release. -3. Open a release PR with a concise release-focused body. -4. Hand the PR to ship for review and landing; squash-merge it into one `chore: release vX.Y.Z (#PR)` commit carrying the curated changelog. -5. After the release PR lands, run `loaf release --post-merge` on the base branch to tag, publish the GitHub Release, and verify installability. +**Commit → issue**, first match wins: -If guardrail 9 fires on `--post-merge`, the merged tree itself carries stale evidence; recovery is to re-record against the merged tree, land the receipts as a single evidence-only commit on the base branch (the repair commit must not modify the capability registry), and rerun `loaf release --post-merge`. +1. Issue alias (`PREFIX-N`, e.g. `LOAF-42`) in the subject or body (prefix case-sensitive). URLs and code spans are stripped first. +2. Else the merge/branch rung: aliases in a `Merge …` subject, plus any alias anywhere in the body (case-insensitive). Squash subjects like `feat: add auth (#42)` often carry the alias only in the body. No network, no `gh`. An alias that lived only on a deleted branch name is unattributable. +3. Else a unique journal `commit(<hash>)` row whose message contains an alias (scope uniquely matching that commit). -Do not hide this handoff inside release: ship remains the PR correctness and merge gate. +Resolved aliases become **landed** issues (with the commits that named them). Commits that match nothing are **unattributed** — listed, and included under drafted notes as `### Unattributed`. They do not block the cut. ---- +**Parent rollup** (information): -## Step 6: Publication Verification +- For each landed issue that has a parent, if any sibling child is not `done`, the parent is **partially landed** and the missing children are listed. +- A parent is not auto-added to landed unless a commit attributed to it. -After publishing, verify the public release state: +Drafted notes are `## [version] - YYYY-MM-DD`, then one `### ALIAS — title` section per landed issue with commit subjects, then unattributed. -1. Confirm tag location: - ```bash - git show --stat vX.Y.Z - ``` -2. Confirm GitHub Release: - ```bash - gh release view vX.Y.Z - ``` -3. Confirm package or installer availability when applicable: - - npm: `npm view <package> version` - - Homebrew: `brew update && brew info <tap>/<formula>` - - project-specific deploy or artifact registry checks -4. For Loaf/Homebrew, report readiness only after the GitHub release exists, assets are uploaded, the tap formula is updated, and tap CI has passed. +--- -If publication partially completes, do not retag casually. Name the exact state and continue with the smallest repair or patch release path. +## Bump derivation ---- +Derived from the range, in order: -## Step 7: Post-Release Follow-Up +| Condition | Bump | +|-----------|------| +| Breaking marker (`type!:` in the subject, or `BREAKING CHANGE:` / `BREAKING-CHANGE:` in the body) | `major` | +| A **done** parent with **two or more** children, every child `done` and landed, and the parent's done timestamp **after** the baseline tag's committer time | `minor` (closed multi-child parent fully landed) | +| Else a conventional `feat` commit | `minor` | +| Else | `patch` (`fix` / other) | -After verification: +`--bump` on `cut` replaces the derived bump and redrafts notes; the evidence string becomes `overridden by --bump <type>`. -1. Log the release decision to the project journal: - ```bash - loaf journal log "decision(release): vX.Y.Z published from <base> with <summary>" - ``` -2. Suggest reflect when the release produced durable product or workflow learnings. -3. Suggest housekeeping when release branches or temporary reports need cleanup. -4. Keep future-work discoveries out of the release notes; capture them as tasks, ideas, or sparks instead. +Current version comes from agreeing version files, else from a semver last tag. Cut still requires version files on the mutating path. --- -## Hook Interaction +## Must-contain convention -This skill coexists with existing hooks. Git workflow hooks are advisory unless -configured otherwise; security and secret-scanning hooks remain blocking. +Rare. When the operator needs a named set of issues to land before a cut, create a **release-prep** issue and express `blocked_by` edges with issue mechanics: + +```bash +loaf issue new "Release prep for vX.Y.Z" --body "Must contain LOAF-12 and LOAF-15. Out of scope: the cut itself." +loaf issue link LOAF-12 blocks LOAF-99 +loaf issue link LOAF-15 blocks LOAF-99 +``` -| Hook | Type | When release Runs | -|------|------|---------------------| -| `github-account` | Force-switch | Switches to the configured GitHub account before `gh` release operations; blocks only if the switch fails | -| `validate-push` | Advisory | Cross-checks version bump, changelog, and build on push | -| `workflow-pre-pr` | Advisory | Fires when the release PR is opened | -| `workflow-pre-merge` | Advisory | Belongs to ship when a release PR must land | -| `workflow-post-merge` | Advisory | Belongs to ship after PR landing | -| `check-secrets` | Blocking | Always respected before writes or shell actions | +Stored write types are `blocks` and `relates_to`. `loaf issue link <must-land> blocks <release-prep>` is how you record that the prep issue is blocked by those that must land. `loaf issue frontier` and implement honor `blocks`. **`loaf release suggest` and `cut` do not read these edges** — convention, not schema. If the operator wants to wait, wait; if they cut anyway, cut records whatever actually landed. -Do not disable hooks to force a release through. +Buckets stay labels: ---- +```bash +loaf issue bucket LOAF-12 now +loaf issue bucket LOAF-12 none +``` -## Suggests Next +`suggest` prints `bucket:<name> ALIAS — title (landed|not landed)` and `unplanned ALIAS — title (landed)`. Never treat that delta as a constraint. -After a successful release, suggest reflect for durable learnings and housekeeping if temporary release artifacts need attention. +--- ## Related Skills -- **ship** -- Reviews, verifies, and lands a PR before it becomes release input -- **git-workflow** -- Branching, PR, commit, and squash merge conventions -- **documentation-standards** -- Changelog and release-note quality -- **reflect** -- Updates strategy from shipped/released learnings -- **housekeeping** -- Cleans up completed spec, report, and handoff artifacts +- **ship** — Reviews, verifies, and lands a PR. That merge is the verification authority for what this skill may later cut +- **git-workflow** — Branching, PRs, and any later push of the local tag (cut does not push) +- **documentation-standards** — Changelog prose if a human edits notes after the cut +- **reflect** — Durable learnings after a cut +- **housekeeping** — Cleanup of temporary artifacts diff --git a/dist/codex/skills/research/SKILL.md b/dist/codex/skills/research/SKILL.md index af98d9acd..f99be1eca 100644 --- a/dist/codex/skills/research/SKILL.md +++ b/dist/codex/skills/research/SKILL.md @@ -94,7 +94,7 @@ Always check project context first. Rate findings: **High** (official/verified), **Trigger:** Empty input, "project state", "catch me up" 1. Read project documents: VISION.md, STRATEGY.md, ARCHITECTURE.md -2. Check ideas with `loaf idea list --json` and specs with `loaf spec list --json` +2. Check ideas with `loaf idea list --json` and issues with `loaf issue list --json` (or `loaf issue export` for the full graph) 3. Review recent journal activity with `loaf journal recent --json` and `loaf journal context` 4. Check recent commits: `git log --oneline -20` 5. Synthesize following [state-assessment template](templates/state-assessment.md) @@ -144,4 +144,4 @@ User-facing entry for a new concept is pitch (problem-discovery brief). Do not t ## Artifact Naming -Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or Change already records that provenance. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. +Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or the issue already records that provenance. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. diff --git a/dist/codex/skills/research/templates/report.md b/dist/codex/skills/research/templates/report.md index 2ff4c059c..f7cc5d7dd 100644 --- a/dist/codex/skills/research/templates/report.md +++ b/dist/codex/skills/research/templates/report.md @@ -14,7 +14,7 @@ title: "Report: [Topic]" type: research | audit | analysis | council created: YYYY-MM-DDTHH:MM:SSZ status: draft | done | archived -source: SPEC-XXX | TASK-XXX | ad-hoc +source: LOAF-42 | ad-hoc tags: [] --- diff --git a/dist/codex/skills/research/templates/state-assessment.md b/dist/codex/skills/research/templates/state-assessment.md index 65e426f3b..359392033 100644 --- a/dist/codex/skills/research/templates/state-assessment.md +++ b/dist/codex/skills/research/templates/state-assessment.md @@ -24,7 +24,7 @@ tags: [] - **Vision:** [Brief summary] - **Key personas:** [Who we're building for] -- **Current focus:** [Active specs/work] +- **Current focus:** [Active issues] ## Recent Progress @@ -32,10 +32,10 @@ tags: [] ## In Flight -| Spec/Task | Status | Notes | -|-----------|--------|-------| -| SPEC-001 | implementing | [progress] | -| SPEC-002 | approved | [next up] | +| Issue | Status | Notes | +|-------|--------|-------| +| LOAF-1 | active | [progress] | +| LOAF-2 | todo | [next up] | ## Ideas Pipeline diff --git a/dist/codex/skills/shape/SKILL.md b/dist/codex/skills/shape/SKILL.md index 42fce86f5..c4fa4a21d 100644 --- a/dist/codex/skills/shape/SKILL.md +++ b/dist/codex/skills/shape/SKILL.md @@ -1,25 +1,21 @@ --- name: shape description: >- - Shapes messy input into a bounded, reviewable Change under - docs/changes/YYYYMMDD-slug/ (change.json + shape.md + tasks/), validated by - loaf change check. Runs a fog-routed narrowing protocol — gather context, - optional blindspot pass, grilling, reaction artifacts — seeds task-file - vertical slices, runs a critique gate, and offers an opt-in draft PR. Use when - the user asks "shape this," "turn this into a Change," or an idea has enough - constraints to bound. Produces role-named narrative (shape.md required; - brief/plan/design optional) plus task packets — never a numbered spec. - Teaches the problem-boundary test (same problem → another task; different - problem → Intent) and vertical-slice discipline. Not for quick capture (use - idea), problem discovery that should author a brief first (use pitch), or - open-ended divergent thinking (agent technique: explore / brainstorm — user - entry intent routes to pitch). + Shapes messy input into a bounded issue — problem body, definition-of-done + criteria, out-of-scope statement, and children when a criterion earns its own + DoD — validated by loaf issue check. Use when the user asks "shape this," + "turn this into an issue," or a diagnosed fix needs a row. Produces a shaped + issue — never a folder or a plan document. Teaches fog graduation (park, + then a decision child) and one-criterion sizing (one fresh context window, + verifiable alone). Not for quick capture (use idea), problem discovery that + should author a brief first (use pitch), or open-ended divergent thinking + (agent technique: explore / brainstorm — user entry routes to pitch). version: 0.2.21 --- # Shape -Turn messy input into a bounded, reviewable Change. +Prepare a bounded, reviewable issue. ## Contents - Critical Rules @@ -35,29 +31,30 @@ Turn messy input into a bounded, reviewable Change. ## Critical Rules -1. **Log invocation first** — `loaf journal log "skill(shape): <input being shaped>"` before doing anything else. -2. **Produces a Change, never a spec** — `change.json` + `shape.md` (+ optional `brief.md`/`plan.md`/`design.md`) and `tasks/TASK-NNN-slug.md`. No sequentially-numbered spec file, no status-like fields anywhere. +1. **Log invocation first** — `loaf journal log "skill(shape): shaping <topic> into LOAF-42"` before doing anything else. If no issue exists yet, log `skill(shape): shaping <topic>` and add the alias in the outcome entry. +2. **Produces an issue, never a folder** — the deliverable is the issue row: problem in the body, definition of done as `loaf issue dod` criteria, an explicit out-of-scope statement in the body, children via `loaf issue promote` when a criterion earns its own DoD. No plan document is committed. The PR body, if a PR is opened, is `loaf issue render` output. 3. **The fog register routes, you don't guess** — every named unknown carries a quadrant tag that dispatches it to exactly one technique (see Quick Reference). Technique-by-vibes is the failure mode this replaces. -4. **Blindspot pass precedes grilling, offered not imposed** — run it when the territory is unfamiliar; skip it when the shaper is the domain expert. Never impose it as a mandatory step. -5. **Grill one question at a time, with a recommendation** — using your harness's structured question tool if it has one (otherwise one inline question per message — same semantics). Never a form to fill in one pass. Order by architectural impact: questions whose answer would change the architecture go first, cosmetic questions last. -6. **Techniques return fog entries, not decisions** — blindspot passes, grilling, and reaction artifacts hand back named unknowns and evidence for the human to adjudicate. Reconnaissance, not orders. -7. **Own the decomposition** — decide Implementation Unit boundaries and granularity autonomously (absorbed from the retired breakdown step); ask only when two orderings carry genuinely different trade-offs. -8. **Order units by likelihood-of-change** — data models, interfaces, and user-facing flows lead; mechanical work collapses at the bottom, so review attention lands on what's most likely to need changing. -9. **Surface misalignment, never silently adjust** — when the idea conflicts with strategic docs, prior Changes, or the journal, tell the user and let them decide. Don't quietly reshape their idea. -10. **Critique before finalizing** — run the Critique Gate as the last shaping step, before `loaf change check` and the PR offer. -11. **Get approval before `loaf change init`** — don't scaffold the folder without explicit confirmation of scope. -12. **Log the outcome** — `loaf journal log "decision(shape): <slug> shaped — N units, N open fog entries"`. +4. **Fog graduates instead of evaporating** — a question not yet sharp enough is parked in the issue's `fog` field (`loaf issue new --fog`). When it sharpens it becomes a `--kind decision` child, which is ready when it poses a sharp question (a `?` in the title or body). No plan required. +5. **Blindspot pass precedes grilling, offered not imposed** — run it when the territory is unfamiliar; skip it when the shaper is the domain expert. Never impose it as a mandatory step. +6. **Grill one question at a time, with a recommendation** — using your harness's structured question tool if it has one (otherwise one inline question per message — same semantics). Never a form to fill in one pass. Order by architectural impact: questions whose answer would change the architecture go first, cosmetic questions last. +7. **Techniques return fog entries, not decisions** — blindspot passes, grilling, and reaction artifacts hand back named unknowns and evidence for the human to adjudicate. Reconnaissance, not orders. +8. **Decomposition is the tail** — a parent gets children only when its DoD needs more than one coherent slice. A criterion becomes a child the moment it earns its own DoD, via `loaf issue promote`. Own those boundaries autonomously; ask only when two orderings carry genuinely different trade-offs. +9. **One sizing criterion** — a slice is right-sized when it fits one fresh context window and is verifiable alone. Expand–contract is the named exception for wide mechanical refactors. See [references/decomposition.md](references/decomposition.md). +10. **Surface misalignment, never silently adjust** — when the idea conflicts with strategic docs, prior issues, or the journal, tell the user and let them decide. Don't quietly reshape their idea. +11. **Critique before finalizing** — run the Critique Gate as the last shaping step, before `loaf issue check`. +12. **A diagnosed one-line fix is two commands** — `loaf issue new` with a body that states the problem and `Out of scope: …`, then one `loaf issue dod add`. No problem-space ceremony. Confirm scope with the user before `loaf issue new` on anything larger. +13. **Log the outcome** — `loaf journal log "decision(shape): LOAF-42 shaped — N children, N open fog entries"`. --- ## Verification -- `docs/changes/YYYYMMDD-slug/` has `change.json` + `shape.md` with Product Contract sections non-empty; task packets seeded under `tasks/` when decomposition is known -- Every Open Questions entry carries a quadrant tag (`[KU]`, `[UK]`, or `[UU]`) and a route -- `loaf change check` reports zero violations (no legacy deprecation on new layout); executability gaps were read, not ignored -- Problem-boundary test applied: discovered different problems become Intents, not TASK-007 -- The Critique Gate ran, and its answers changed the documents where they applied -- No status-like fields in `change.json` or task frontmatter +- The issue body states the problem and contains an explicit out-of-scope statement (`out of scope`, case-insensitive — that substring is what `loaf issue check` reads) +- At least one definition-of-done criterion exists; V-tier criteria carry `--command` (and `--expect` when the check is more than exit 0); H-tier otherwise +- Every open unknown is either parked in create-time `fog`, held in the session register until it sharpens, graduated to a `--kind decision` child (or sibling) with a sharp question, or written into the body as a decided answer +- `loaf issue check <ref>` reports the issue shaped (delivery) or ready (decision). When children exist, coverage failures were fixed and containment orphans were filed as sibling backlog issues using the printed remedy +- Problem-boundary test applied: a discovered different problem becomes a new backlog issue, not another criterion on this one +- The Critique Gate ran, and its answers changed the issue where they applied --- @@ -65,34 +62,46 @@ Turn messy input into a bounded, reviewable Change. ### Fog register format -Open Questions entries take one of three forms: +Open unknowns take one of three forms. Keep the register in the session. Park what is still unsharp in `--fog` at create; after create, unsharp entries stay in the session register (edit cannot mutate `fog`). Graduate what is sharp to a decision child or sibling, and write decided answers into the body. ```text -- [KU] <the unknown> → <route: grilling | research spike | owner section> -- [UK] <the recognize-it-when-seen criterion> → reaction artifact in research/ +- [KU] <the unknown> → <route: grilling | research spike | owner> +- [UK] <the recognize-it-when-seen criterion> → reaction artifact - [UU] <the suspected blind area> → blindspot pass over <territory> ``` -An entry resolves by becoming a Decision, a Planning Contract subsection, or a named follow-up — visible in the diff, never silently deleted. A `[UU]` that gets named becomes a `[KU]` or `[UK]` and re-routes through the table below. +An entry resolves by becoming a decision child, a body paragraph, a criterion, or remaining parked in `fog` — visible on `loaf issue show`, never silently deleted. A `[UU]` that gets named becomes a `[KU]` or `[UK]` and re-routes through the table below. ### Quadrant routing | Tag | Meaning | Routes to | |-----|---------|-----------| | `[KU]` known unknown | A question you can state precisely | [Grilling](references/grilling.md) (architecture-changing answers first) or a research spike | -| `[UK]` unknown known | You'd recognize the right answer if you saw it, but can't state it yet | [Reaction artifact](references/reaction-artifact.md) — a variant or mock in `research/`, react and pick | +| `[UK]` unknown known | You'd recognize the right answer if you saw it, but can't state it yet | [Reaction artifact](references/reaction-artifact.md) — a variant or mock, react and pick | | `[UU]` suspected blind spot | Unfamiliar territory; you don't yet know what you don't know | [Blindspot pass](references/blindspot-pass.md) | -No route names a skill invocation. Research re-interviews an already-scoped question and writes to `.agents/reports/`; brainstorm forces a strategic frame onto a Change-local question and sends resolutions to intake. Shape runs all three techniques itself, in-session, and writes evidence into the Change's own `research/` — never `.agents/reports/`. +No route names a skill invocation. Research re-interviews an already-scoped question; brainstorm forces a strategic frame onto an issue-local question and sends resolutions to intake. Shape runs all three techniques itself, in-session, and writes the captured answer onto the issue — never into `.agents/reports/`. ### Defined terms -- **Rabbit holes** — tempting expansions of scope that would consume disproportionate effort for marginal value; name them so nobody wanders in unknowingly. -- **No-gos** — approaches explicitly forbidden for this Change, stated so they aren't silently reconsidered mid-implementation. +- **Rabbit holes** — tempting expansions of scope that would consume disproportionate effort for marginal value; name them in the out-of-scope statement so nobody wanders in unknowingly. +- **No-gos** — approaches explicitly forbidden for this issue, stated so they aren't silently reconsidered mid-implementation. ### Source inputs recognized -Step 1 reads whatever `$ARGUMENTS` names, or asks. Recognized sources: a change `brief.md` (from pitch or capture), a journal entry (cite by ID), a spark, an idea, a brainstorm document, a Linear issue, a PR conversation, a prior Change, or plain conversation with no artifact behind it yet. +Step 1 reads whatever `$ARGUMENTS` names, or asks. Recognized sources: a brief from pitch, a journal entry (cite by ID), a spark, an idea, a brainstorm document, a Linear issue, a PR conversation, a prior issue, or plain conversation with no artifact behind it yet. + +### One-line entry + +A diagnosed fix that already has a problem and a done-check: + +```bash +loaf issue new "Fix missing --json in list help" --body "issue list --help omits --json. Out of scope: rewriting other help pages." +loaf issue dod add LOAF-42 "issue list help names --json" --command "loaf issue list --help" --expect "contains \`--json\`" +loaf issue check LOAF-42 +``` + +Two writes, then the readiness verdict. No grilling, no children, no files. --- @@ -100,53 +109,86 @@ Step 1 reads whatever `$ARGUMENTS` names, or asks. Recognized sources: a change ### Step 1: Gather Context -Parse `$ARGUMENTS` against the source inputs above. When the input names a Change folder that already has `brief.md` (or you find one for this work), treat the brief as primary: restate the problem from it, confirm with the user rather than re-discovering, and keep later grilling on solution-space (how, boundaries, verification) — pitch already framed the problem. When no brief exists, run full narrowing as today; pitch is the recommended front door for raw concepts, never a gate. Read the journal (`loaf journal recent` / `search`) for related history, and check for a prior Change touching the same area. When VISION.md, STRATEGY.md, and ARCHITECTURE.md exist, read them for strategic fit. Most consumer projects don't have them yet — when absent, shape against the journal, recent Changes, and the conversation instead, and say so in the Change's Source Inputs. +Parse `$ARGUMENTS` against the source inputs above. When a brief from pitch already frames the problem, restate it, confirm with the user rather than re-discovering, and keep later grilling on solution-space (how, boundaries, verification). When no brief exists, run full narrowing; pitch is the recommended front door for raw concepts, never a gate. Read the journal (`loaf journal recent` / `search`) for related history, and check for a prior issue touching the same area (`loaf issue list`, `loaf issue tree`). When VISION.md, STRATEGY.md, and ARCHITECTURE.md exist, read them for strategic fit. Most consumer projects don't have them yet — when absent, shape against the journal, recent issues, and the conversation instead, and say so in the issue body. ### Step 2: Evaluate Strategic Fit -When strategic docs exist, check: does this advance the vision, serve the target personas, fit technical constraints, avoid conflicting with in-flight Changes? On misalignment, **surface it to the user — never silently adjust the idea**. The user decides whether to proceed, narrow scope, or defer to reflect after this ships. +When strategic docs exist, check: does this advance the vision, serve the target personas, fit technical constraints, avoid conflicting with in-flight issues? On misalignment, **surface it to the user — never silently adjust the idea**. The user decides whether to proceed, narrow scope, or file the conflicting concern as its own backlog issue. -### Step 3: Name the Change and Initialize +### Step 3: Name the Issue and Write the Row -Once the shape of the work is nameable, confirm scope with the user, then: +Once the work is nameable, confirm scope with the user (skip this confirmation on the one-line path), then create the row. Prefer creating after the first narrowing pass so `--fog` can carry remaining unsharp questions — the CLI writes `fog` only at create. ```bash -loaf change init <slug> +loaf issue new "Rotate auth tokens on a sliding window" \ + --body "Sessions never expire while the tab stays open, so a stolen cookie is valid indefinitely. + +Out of scope: migrating existing sessions; third-party IdP support." \ + --fog "[KU] sliding-window length → grill; [UU] existing session-store conventions → blindspot pass" ``` -On a fresh slug this scaffolds `change.json` + `shape.md` + seeded `tasks/` from the embedded templates (see `templates/shape.md`, `templates/task.md`). On a capture-only folder that already has `change.json` + `brief.md` (from pitch or `init --brief`), the same command promotes in place — preserving brief and metadata verbatim while materializing `shape.md` and `tasks/` — never hand-copy templates into the folder; rely on that promotion path. Use `loaf change init <slug> --brief` only for capture-before-shape (emits `change.json` + `brief.md`). It does not switch branches — `git switch -c <slug>` yourself. Fill `shape.md` Product Contract sections as understanding solidifies; seed `tasks/TASK-NNN-slug.md` as vertical slices (a task is a commit, not a PR). Optional `plan.md`/`design.md` accrete when the how needs prose. See [references/cli-boundary.md](references/cli-boundary.md). +Default kind is `delivery`; default status is `triage`. `--status` accepts `triage`, `backlog`, `todo`, `active`, or `done`. Use `--body -` or `--body-file <path>` for a longer body; `loaf issue edit <ref>` later **replaces** the body, it does not patch it. + +A delivery issue is shaped when the body is nonempty (the problem), at least one criterion exists, and the body contains an explicit out-of-scope statement. Fill those as understanding solidifies — create can carry the first body; criteria come next. + +A discovered different problem is a new backlog issue, not a child of this one: + +```bash +loaf issue new --status backlog "Rewrite the session store" +``` ### Step 4: Narrow the Unknowns -Offer the blindspot pass when the territory is unfamiliar (a new domain, an unfamiliar subsystem, a first collaboration) — skip it when the shaper is the expert. Its fog entries, and any others surfaced in interview, route by quadrant (Quick Reference above). Loop: grill `[KU]` entries, react to `[UK]` entries, run blindspot reconnaissance on `[UU]` entries until each gets a name and re-routes. Stop grilling when no unrouted `[KU]` entries remain or answers stop changing the contract. Entries still open at the end of the session are fine — each names its owner (a section, a spike, a follow-up). +Offer the blindspot pass when the territory is unfamiliar (a new domain, an unfamiliar subsystem, a first collaboration) — skip it when the shaper is the expert. Its fog entries, and any others surfaced in interview, route by quadrant (Quick Reference above). Loop: grill `[KU]` entries, react to `[UK]` entries, run blindspot reconnaissance on `[UU]` entries until each gets a name and re-routes. Stop grilling when no unrouted `[KU]` entries remain or answers stop changing the issue. -### Step 5: Decompose into Implementation Units +When a parked question sharpens, graduate it — after the parent's DoD is written (Step 5). Attaching **any** child, including a decision, turns coverage on. -Absorbed from the retired breakdown step — see [references/decomposition.md](references/decomposition.md) for the Right Size Test and per-unit verification discipline, including the V-tier `Command:` / `Expect:` forms `loaf change verify` parses (commands run from the repository root; H-tier is never gate input). Order units by likelihood-of-change; state real sequencing constraints in prose, never by list order alone. +```bash +loaf issue new --kind decision --parent LOAF-42 "Should tokens live in httpOnly cookies?" +``` + +A decision issue is ready when the title or body contains `?`. It needs no criteria and no out-of-scope statement. A decision child does not claim a parent criterion, so promote (or otherwise claim) the parent's DoD before adding children, or keep the decision as a sibling (`loaf issue new --kind decision --status backlog`, no `--parent`) if the parent stays a leaf. Unsharp questions discovered after create stay in the session register until they graduate — there is no `--fog` on edit. See [references/decomposition.md](references/decomposition.md). + +### Step 5: Write Definition of Done (decomposition tail) + +Add criteria as the interrogation produces observable done-checks. V-tier when a command can disagree with the implementation; H-tier when only a human can tell. + +```bash +loaf issue dod add LOAF-42 "Sliding-window expiry is covered by tests" --command "go test ./internal/auth/..." --expect "exit 0" +loaf issue dod add LOAF-42 "Stolen-cookie writeup is reviewable" --tier H +``` + +`--command` implies V unless `--tier` overrides. `--expect` uses the verify grammar (`exit <N>`, `` contains `text` ``, joined by ` and `). Commands run from the repository root. See [references/cli-boundary.md](references/cli-boundary.md) and [references/decomposition.md](references/decomposition.md). + +A parent gets children only when its DoD needs more than one coherent slice. The moment a criterion earns its own DoD, promote it — the parent criterion stays, the child starts with a copy, and the claim is recorded so coverage holds for that position: -### Step 6: Fill the Planning Contract +```bash +loaf issue promote LOAF-42 1 +``` -Write the free-form `###` subsections the work actually needs (approach, placement, risks, sequencing) inside the Planning Contract container. Its subsection names are yours; the container itself, plus Implementation Units, Verification Contract, and Definition of Done, is what `loaf change check` looks for. Durable Outputs stays forward-looking here — name what a final spec, ADR, or knowledge doc will need to capture, but don't write it now. Durable artifacts get created after implementation proves what's true, not during shaping. +Then shape the child the same way (body, out-of-scope, its own criteria). Order children by likelihood-of-change when presenting them; state real sequencing with `loaf issue link <from> blocks <to>`, never by tree order alone. -### Step 7: Run the Critique Gate +### Step 6: Run the Critique Gate -Before finalizing, challenge the draft — see [references/critique-gate.md](references/critique-gate.md). Is scope still bounded, does every new command or state name its ceremony, is a status field creeping back in under another name, is the CLI/skill boundary drawn correctly, and could this be smaller and still deliver the Hypothesis? +Before finalizing, challenge the draft — see [references/critique-gate.md](references/critique-gate.md). Is scope still bounded, does every new command or state name its ceremony, is a second progress flag creeping into the body, is the CLI/skill boundary drawn correctly, and could this be smaller and still be verifiable in one fresh context window? -### Step 8: Validate +### Step 7: Validate ```bash -loaf change check +loaf issue check LOAF-42 ``` -Read violations (always block — fix them) separately from the executability report (derived, informational unless `--require-executable` is passed — that flag is implement's preflight and CI's non-draft gate, not shape's business). See [references/cli-boundary.md](references/cli-boundary.md). +A delivery issue that passes prints `issue LOAF-42 is shaped`; a decision issue prints `issue LOAF-42 is ready`. Failures always block (missing body, missing criterion, missing out-of-scope, no sharp question, uncovered parent criterion). Containment orphans are reported, not failed: each line includes a ready-to-paste remedy that files the orphan as a sibling backlog issue — run that command, do not invent a different disposition. + +`loaf issue verify <ref>` runs V-tier commands from the repository root and writes nothing. That is implement's preflight, not shape's gate. See [references/cli-boundary.md](references/cli-boundary.md). -### Step 9: Offer the Draft PR +### Step 8: Offer the Review Surface -Offer to push the branch and open a draft PR, using [the PR template](templates/pr.md) — opt-in, never automatic. `loaf change check` (with no `--require-executable`) plus `gh pr list` is the cross-branch index either way. +The issue lives in SQLite. There is no folder to commit and nothing plan-shaped to land. Offer `loaf issue show <ref>` and `loaf issue tree <ref>` as the review surface. If a PR is being opened for the work, its body is `loaf issue render <ref>` — paste-ready, no manual editing. Opt-in, never automatic. -### Step 10: Log the Outcome +### Step 9: Log the Outcome -`loaf journal log "decision(shape): <slug> shaped — N units, N open fog entries"`. +`loaf journal log "decision(shape): LOAF-42 shaped — N children, N open fog entries"`. --- @@ -154,8 +196,8 @@ Offer to push the branch and open a draft PR, using [the PR template](templates/ - **pitch** — Problem-discovery ceremony that authors a brief; preferred front door when the problem is not yet framed - **idea** — Quick capture; feeds into pitch or shape once a concept has enough weight -- **brainstorm** — Agent technique for divergent thinking (route user entry intent to pitch) -- **implement** — Starts execution once a Change is structurally executable; this does not prove implementation completion +- **brainstorm** — Agent technique for divergent thinking (route user entry to pitch) +- **implement** — Starts execution once `loaf issue check` reports the issue shaped; this does not prove implementation completion - **reflect** — Updates strategic docs after the shipped work proves what changed ## Topics @@ -165,10 +207,10 @@ Offer to push the branch and open a draft PR, using [the PR template](templates/ | Blindspot pass | [references/blindspot-pass.md](references/blindspot-pass.md) | Deciding whether to offer reconnaissance, and how to prompt it | | Grilling | [references/grilling.md](references/grilling.md) | Running the one-question-at-a-time interview for `[KU]` entries | | Reaction artifacts | [references/reaction-artifact.md](references/reaction-artifact.md) | Resolving `[UK]` entries with a variant, mock, or prototype | -| Decomposition | [references/decomposition.md](references/decomposition.md) | Sizing and ordering Implementation Units | -| CLI boundary | [references/cli-boundary.md](references/cli-boundary.md) | Reading `loaf change init`/`check`/`verify` output, or explaining `--require-executable` | +| Decomposition | [references/decomposition.md](references/decomposition.md) | Sizing slices, promoting criteria, reading coverage and containment | +| CLI boundary | [references/cli-boundary.md](references/cli-boundary.md) | Reading `loaf issue` output, authoring `--command`/`--expect`, or explaining `loaf issue check` | | Critique Gate | [references/critique-gate.md](references/critique-gate.md) | Self-challenging scope and boundaries before finalizing | ## Artifact Naming -Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or Change already records that provenance. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. +Shape's deliverable is the issue row. If a reaction artifact or spike note lands on disk, name it for what it is, never for the issue that produced it. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. diff --git a/dist/codex/skills/shape/references/blindspot-pass.md b/dist/codex/skills/shape/references/blindspot-pass.md index 7bbd329a4..616f544d5 100644 --- a/dist/codex/skills/shape/references/blindspot-pass.md +++ b/dist/codex/skills/shape/references/blindspot-pass.md @@ -12,7 +12,7 @@ Ask the user whether to run it; skip when they're the domain expert. Do not auto ## Prompt Shape -Ask, against the specific territory named by the Change: +Ask, against the specific territory named by the issue: > What would I not know to ask here — codebase history, domain conventions, prior art, potholes? @@ -29,6 +29,8 @@ A `[UU]` that gets named through this pass becomes: and re-routes through the quadrant table in the main skill body. +Park what is still unsharp in the issue's `fog` field at create (`--fog`). After create, keep unsharp entries in the session register — edit cannot mutate `fog`. When an entry sharpens, graduate it to a `--kind decision` child or sibling. Do not drop a named unknown on the floor. + ## Stopping The pass ends when the shaper (or the user) can name the territory's remaining risks as entries, not vague unease. A pass that keeps surfacing "something might be wrong here" without a nameable entry has run past its useful length — stop and proceed with what's been named. diff --git a/dist/codex/skills/shape/references/cli-boundary.md b/dist/codex/skills/shape/references/cli-boundary.md index b7ade652b..6ed241493 100644 --- a/dist/codex/skills/shape/references/cli-boundary.md +++ b/dist/codex/skills/shape/references/cli-boundary.md @@ -1,56 +1,118 @@ # CLI Boundary -Reading `loaf change init` and `loaf change check` — the skill teaches reading the CLI, not wrapping it. For the rest of the Loaf CLI surface, see the `loaf-reference` skill. +Reading `loaf issue` — the skill teaches reading the CLI, not wrapping it. For the rest of the Loaf CLI surface, see the `loaf-reference` skill. Issue commands require initialized SQLite state (`loaf state init`, or `loaf state migrate markdown --apply`). + +## Contents +- `loaf issue new` +- `loaf issue show` / `list` / `tree` / `frontier` +- `loaf issue edit` / `status` +- `loaf issue dod` +- `loaf issue promote` +- `loaf issue check` +- `loaf issue verify` +- `loaf issue link` / `bucket` +- `loaf issue render` +- `loaf release suggest` / `cut` +- What shape does not run + +## `loaf issue new <title> [options]` + +```text +loaf issue new <title> [--body <text>|--body -|--body-file <path>|--message <text>] + [--kind delivery|decision] [--parent <ref>] [--fog <text>] [--status <status>] [--json] +``` + +Creates the issue row. Default kind is `delivery`; default status is `triage`. `--status` accepts the write statuses `triage`, `backlog`, `todo`, `active`, `done` (it still records the initial triage event). `--fog` parks questions not yet sharp enough to be issues; this flag exists only on create — `loaf issue edit` replaces the body and does not mutate `fog`. + +`--body -` reads stdin; `--body-file` reads a UTF-8 file; `--message` is inline body at lower precedence than `--body-file` and `--body -`. A hyphen-leading title is positional after `--`: + +```bash +loaf issue new --parent LOAF-42 --status backlog -- "--help is missing from the man page" +``` -## `loaf change init <slug> [--brief]` +A delivery body must state the problem and, before `loaf issue check` will pass, contain the substring `out of scope` (case-insensitive). A decision issue needs a sharp question (`?` in the title or body), not a body contract. -Scaffolds `docs/changes/<YYYYMMDD>-<slug>/` from the Change template, where `<YYYYMMDD>` is the creation day (not a target date) and the branch is named by the bare slug — no date prefix on the branch. Ordinary init writes `change.json + shape.md + tasks/`; `--brief` is capture mode (`change.json + brief.md` only). The slug uses lowercase letters, digits, and single hyphens. +## `loaf issue show` / `list` / `tree` / `frontier` -**Capture promotion.** Re-running ordinary `loaf change init <slug>` (no `--brief`) against a structurally valid capture-only folder completes it in place: `brief.md` and every `change.json` value are preserved verbatim, and missing `shape.md` plus the seeded `tasks/` are published atomically (temp-write then rename; existing destinations are never overwritten; `shape.md` is the last rename and the promotion marker). A partial promotion that already holds the byte-identical seed task resumes by filling only the gaps. Everything else fails clearly and leaves the folder untouched — repeated `--brief`, `change.json`-only (missing brief), hybrid `change.md` + `change.json`, diverged `tasks/` content, malformed metadata, and fully-materialized folders (duplicate rejection unchanged). +```text +loaf issue show <ref> [--json] +loaf issue list [--status <status>] [--kind delivery|decision] [--archived] [--started] [--json] +loaf issue tree [<ref>] [--archived] [--json] +loaf issue frontier [--json] +``` -## `loaf change check [folder] [--require-executable] [--json]` +`show` prints identity, parent, fog, body, definition of done, and children. `list` hides archived issues unless `--archived`. `--status` filters by `triage`, `backlog`, `todo`, `active`, `done`, `cancelled`, `duplicate`. `tree` prints from a ref, or the whole project when omitted. `frontier` lists non-archived `triage`/`backlog`/`todo` issues that are not blocked — derived at read time, useful when checking whether this work is already covered. -Folder resolution: an explicit `[folder]` argument always wins; otherwise the current git branch is matched against the `branch:` frontmatter across every `docs/changes/*/change.json + shape.md`. Zero or multiple matches is an error naming the candidates found. +Prefer `--json` when diagnosing rather than scraping the human-readable text. -Output splits into two tiers: +## `loaf issue edit` / `status` -- **Violations** — always fail (exit code 2), regardless of flags: status-like frontmatter keys (`readiness`, `status`, `state`) or values matching the canonical change-state vocabulary; frontmatter not opening the file at byte one; malformed `YYYYMMDD-slug` folder naming; identity mismatch between `change:`/`created:` and the folder name; missing Product Contract sections (Problem, Hypothesis, Scope, Observable Workflow, Rabbit Holes and No-Gos). -- **Derived executability** — reported, never gating by default. A Change is executable when Planning Contract, Implementation Units, Verification Contract, and Definition of Done are all present and non-empty (bracket placeholders and HTML comments don't count as content). A Change with open gaps is incomplete and non-executable; the report just says what's still missing. +```text +loaf issue edit <ref> [--body-file <path>|--body -|--message <text>] [--json] +loaf issue status <ref> <status> [--duplicate-of <ref>] [--json] +``` -`--require-executable` turns structural executability into a gate (exit code 1 if not structurally executable); it does not prove implementation completion. This is implement's preflight and CI's non-draft-PR check, not something shape itself passes during shaping. +`edit` **replaces** the body. Rewrite the full problem-plus-out-of-scope text; there is no patch form. `status` write-statuses (`triage`, `backlog`, `todo`, `active`, `done`) update in place; `cancelled` and `duplicate` archive through the remove path (`--duplicate-of` is required when status is `duplicate`). Shape leaves status at `triage` unless the user asks otherwise — shaped is derived, not a status. -A branch/Change mismatch (current branch doesn't match the Change's `branch:` field) is a warning, never a violation. +## `loaf issue dod` -`--json` emits `{command, folder, passed, state, executable, exitCode, findings, warnings, gaps}` (plus optional `layout`, `captured`, `notices`) for scripted reads; prefer it when diagnosing rather than scraping the human-readable text. The landing guard reads `state` from this envelope — e.g. `"captured"` for a brief-only folder and `"shaped"` (or higher) once `shape.md` exists — and must not invent a second state surface. +```text +loaf issue dod add <ref> <text> [--command <cmd>] [--expect <expect>] [--tier V|H] [--serves <parent-position>] [--json] +loaf issue dod list <ref> [--json] +loaf issue dod remove <ref> <position> [--json] +loaf issue dod claim <child> <child-position> <parent-position> [--json] +loaf issue dod unclaim <child> <child-position> <parent-position> [--json] +``` +V-tier is used when `--command` is present, otherwise H, unless `--tier` overrides. `--serves` records that the new child criterion claims that parent position. Positions are 1-based and compact after `remove`. Authoring guidance and the expect grammar live in the Decomposition topic. -## `loaf change report new <slug> --kind <kind>` +## `loaf issue promote <ref> <position> [--json]` -Stamps `reports/YYYYMMDD-HHMMSS-<kind>-<slug>.html` with charset, provenance, and token skeleton; prints design-language guidance. Closed kinds: approval, review, visual, audit, note. +Promotes the criterion at the 1-based position into a child **delivery** issue. The parent criterion stays in place. The child is minted in `triage` with a copy of the criterion and a claim already recorded, so coverage for that parent position holds by construction. -## `loaf change verify [folder]` +## `loaf issue check <ref> [--json] [--human <reason>]` -Runs executable V-tier criteria declared in `shape.md` and writes `receipts/verify.json` (criteria digest, verified commit, cwd, per-criterion evidence). New-layout-only. +Derives readiness from the issue row, not from markdown headings. -Criteria forms (both parse): +- **Delivery** — shaped when the body is nonempty (the problem), at least one criterion exists, and the body contains an explicit out-of-scope statement. Prints `issue <ref> is shaped` when ready. +- **Decision** — ready when the title or body contains `?`. Prints `issue <ref> is ready`. +- **Children present** — coverage is a failure (every parent criterion must be claimed). Containment is a report (every child criterion must claim a parent criterion); each orphan prints a ready-to-paste `loaf issue new --parent … --status backlog -- …` remedy. -```markdown -- **V1.** Prose. Command: `exact command`. Expect: exit 0. -- **V1.** Prose. - - Command: `exact command` - - Expect: exit 0 -``` +`--human <reason>` publishes ready-for-human instead of ready-for-agent when a tracker authority is configured. Shape's own gate is the derived verdict, not the publication. + +`--json` emits `{issue, kind, shaped, covered, ready, failures, orphans, …}`. Exit code 1 when not ready. -`Expect` is enforced, with a deliberately minimal grammar: atoms join with ` and ` — `exit <N>` is the required exit code (omit the atom, or `Expect` entirely, and `exit 0` is enforced) and `` contains `text` `` requires the command's combined stdout+stderr to contain that backtick-delimited text (repeatable). A criterion passes when the command ran, the exit code matched, and every `contains` matched; the receipt records each atom and its outcome. +## `loaf issue verify <ref> [--json]` -```markdown -- **V1.** Prose. Command: `go test ./...`. Expect: exit 0 and contains `ok github.com/acme/pkg`. +Runs the issue's V-tier criteria (`--command` plus `--expect`) from the repository root. Honors `exit <N>` and `` contains `text` ``. Writes nothing; exits non-zero on any failure. H-tier rows are skipped. This is implement's preflight, not shape's gate. + +A criterion passes when the command ran, the exit code matched, and every `contains` matched. Unenforceable expect clauses are warned and recorded as advisory — never quietly decorative. + +## `loaf issue link` / `bucket` + +```text +loaf issue link <from> blocks|relates-to <to> [--json] +loaf issue link <from> remove <type> <to> [--json] +loaf issue bucket <ref> now|next|later|none [--json] ``` -Any other clause is unenforceable: verify prints a warning naming the criterion and the clause, records it on the criterion as advisory, and never lets it affect the result — an expectation is either checked or loudly not. +Stored relationship types are `blocks` and `relates_to`. Use `blocks` for a real sequencing constraint; do not encode order in `loaf issue tree`. Buckets are labels only and are never read as a constraint. + +## `loaf issue render <ref> [--json]` + +Emits markdown suitable to paste as a PR body with no manual editing: title, body, definition-of-done checkboxes (checked only when status is `done`), and children. Nothing plan-shaped is committed; if a PR is opened, this output *is* the body. + +## `loaf release suggest` / `cut` + +Releases are retroactive. Shape does not bind an issue to a version. + +```text +loaf release suggest [--base <ref>] [--json] +loaf release cut [--base <ref>] [--bump <type>] [--includes <version|tag>] [--no-tag] [--no-gh] [--dry-run] +``` -Commands run from the repository root; the receipt records that cwd. H-tier entries (`**H1.** …`) are never gate input. See [decomposition.md](decomposition.md) for authoring guidance. +`suggest` reports landed work since the last version tag and writes nothing. `cut` records a release from landed work. Neither is a shaping step. -## `loaf change tasks` / `show` +## What shape does not run -On-demand projections. See `loaf change --help`. +`loaf issue start` / `stop` create and remove the issue worktree — implement's job, after the issue is shaped. `loaf issue export` dumps the project snapshot. Do not call them from this skill. diff --git a/dist/codex/skills/shape/references/critique-gate.md b/dist/codex/skills/shape/references/critique-gate.md index 4e131243c..f01109327 100644 --- a/dist/codex/skills/shape/references/critique-gate.md +++ b/dist/codex/skills/shape/references/critique-gate.md @@ -1,14 +1,15 @@ # Critique Gate -The last shaping step, before `loaf change check` and the PR offer. An agent won't know to interrogate its own scope, boundary placement, or smuggled-in status words unless something makes it stop and ask. Instantiated from the shape-first pilot's own Critique Gate, generalized for any Change rather than that pilot's specific CLI-surface question. +The last shaping step, before `loaf issue check` and any review offer. An agent won't know to interrogate its own scope, boundary placement, or smuggled-in progress words unless something makes it stop and ask. Run through these before finalizing: -- **Is scope still bounded?** Has the draft crept beyond what the Problem and Hypothesis justify? Could this Change be smaller and still deliver the Hypothesis? +- **Is scope still bounded?** Has the draft crept beyond what the problem statement justifies? Could this issue be smaller and still be verifiable in one fresh context window? - **Does every new command, state, or lifecycle verb name its ceremony?** If a command or state can't name the ceremony that exercises it, cut it — don't build it now and hope a use appears. -- **Is a status field creeping back in under another name?** `readiness`, `phase`, `stage`, or anything else that reintroduces a declared progress flag `loaf change check` doesn't already ban by pattern. +- **Is a second progress flag creeping into the body?** `readiness`, `phase`, `stage`, or anything else that reintroduces a declared progress flag. Status lives on the issue row (`loaf issue status`). Shaped, covered, and ready are derived by `loaf issue check`. `loaf issue bucket` is a label only and is never read as a constraint. - **Is the CLI/skill boundary drawn correctly?** Is the skill doing deterministic work that belongs in the CLI, or is the CLI claiming judgment that belongs in the skill? -- **Which Verification Contract criteria are genuinely executable gates, and which are human review dressed up as automatable?** A criterion that can't disagree with the implementation isn't a gate. -- **Are the Rabbit Holes and No-Gos sections doing real work?** Or are they restating the Scope's Out list in different words? +- **Which criteria are genuinely executable gates, and which are human review dressed up as automatable?** A V-tier criterion needs `--command` (and `--expect` when exit 0 is not enough). A criterion that can't disagree with the implementation isn't a gate — make it H, or rewrite it against an independent source of truth. +- **Is the out-of-scope statement doing real work?** Rabbit holes and no-gos belong there as named exclusions. Restating the problem in different words is not an out-of-scope statement. +- **Did fog graduate, or evaporate?** Every named unknown is parked in create-time `fog`, held in the session register, sitting as a decision child (or sibling) with a sharp question, filed as a new backlog issue (a different problem), or written into the body as a decided answer. Silent deletion is the failure. -Answers that change the document go back into it — the Decisions log, the Planning Contract, or the relevant Product Contract section — before moving to `loaf change check`. An answer spoken but not written doesn't count. +Answers that change the issue go back into it — `loaf issue edit` for the body, `loaf issue dod add` / `remove` for criteria, `loaf issue promote` or `loaf issue new` for children — before moving to `loaf issue check`. An answer spoken but not written doesn't count. diff --git a/dist/codex/skills/shape/references/decomposition.md b/dist/codex/skills/shape/references/decomposition.md index 7deef4b13..b6839482b 100644 --- a/dist/codex/skills/shape/references/decomposition.md +++ b/dist/codex/skills/shape/references/decomposition.md @@ -1,53 +1,119 @@ # Decomposition -Shaping step absorbed from the retired breakdown skill: dependency awareness, granularity judgment, and acceptance-criteria thinking, now expressed as the Change's Implementation Units and Verification Contract instead of a separate task-minting pass. +Shaping's tail, not a separate ceremony: dependency awareness, granularity judgment, and acceptance-criteria thinking, expressed as definition-of-done criteria and — only when a criterion earns its own DoD — child issues created by `loaf issue promote`. -## What Survives +## Contents +- When to split +- The sizing rule +- Expand–contract +- Promote, don't mint +- Coverage and containment +- Authoring criteria +- Order by likelihood-of-change +- Own the decisions -- **The Right Size Test** — before finalizing a unit boundary, check: Can a single implementer complete this? If no, split by concern. Does it touch multiple unrelated concerns? If yes, split by concern. Will the agent need too much context? If yes, split into smaller coherent units. Are you splitting just to have more units? If yes, merge back. -- **Right-sizing rules** — one agent type per unit (completable by a single implementer), one concern per unit (one layer, service, or component), context-appropriate (fits in model context with room for exploration), not over-fragmented (don't split what naturally belongs together). -- **Per-unit verification discipline** — every unit includes its own observable done condition. Never a separate "verify" unit; keep tests with the code they test. -- **Own the decisions** — decide granularity and unit boundaries autonomously. Ask the user only when two orderings are genuinely equally valid with different trade-offs; otherwise decide and move on. +## When to split -## What Dies +A parent gets children only when its DoD needs more than one coherent slice. One criterion that is already right-sized stays on the parent. A checkbox becomes a sub-issue the moment it earns its own DoD — its own problem statement, its own out-of-scope, its own criteria. -Task-file minting, ID allocation, estimate fields, and ordering-for-execution as the default presentation. Implementation Units are in-document work packets — commit-boundary guides and review anchors — never tracked entities with IDs, statuses, or a persistence layer of their own. +Same problem, another slice → another criterion on this issue, or a promoted child if that slice now has its own DoD. A different problem discovered mid-shaping → a new backlog issue (`loaf issue new --status backlog`), not a child of this one. -## The New Principle: Order by Likelihood-of-Change +## The sizing rule -Breakdown ordered units for execution (dependency graph, then priority). Shaping orders units for review: data models, interfaces, and user-facing flows lead; mechanical refactors and boilerplate collapse at the bottom. The reviewer's attention at the draft-to-ready flip should land on what's most likely to need changing, not on whatever happens to run first. +One test, replacing the old four-question checklist: **a slice is right-sized when it fits one fresh context window and is verifiable alone.** -Sequencing constraints that genuinely exist — this unit must land before that one — are stated in prose within the unit description or a Planning Contract subsection. Never rely on list order alone to imply a dependency; a reviewer skimming units by likelihood-of-change won't read sequencing into position. +- If an implementer cannot pick the issue up in a new conversation and finish it without reading a sibling, split. +- If the done-check cannot run (or be reviewed) without another slice landing first, either split and `loaf issue link <predecessor> blocks <successor>`, or merge — do not leave a criterion that is only true in combination. +- If you are splitting just to have more rows, merge back. -## Authoring the Verification Contract +Per-slice verification stays with the slice. Never a separate "verify" child; keep tests with the code they test. -Split criteria into two groups, mirroring the Change template: +## Expand–contract -- **Executable (V-tier)** — bound to a command and an expected result; machine-checkable by `loaf change verify`. Two equivalent forms: +The named exception for wide mechanical refactors (a rename, a schema migration, an expand-then-remove of an old path). The slice may be wide in files touched and still be one issue, because it is one coherent mechanical motion and one verification: the suite still passes after the motion. - Inline (what the scaffold writes): +Do not use this exception to smuggle a second problem into the parent. A mechanical rename plus a behavior change is two slices. - ```markdown - - **V1.** What must be true. Command: `go test ./...`. Expect: exit 0. - - **V2.** Output-bound. Command: `loaf change check`. Expect: exit 0 and contains `executable`. - ``` +## Promote, don't mint - Or with an authoring checkbox still open: +```bash +loaf issue promote <ref> <position> +``` - ```markdown - - [**V1.** What must be true. Command: `go test ./...`. Expect: exit 0.] - ``` +The criterion at the 1-based position stays on the parent. A child **delivery** issue is created in `triage`, titled from the criterion text, with a copy of that criterion as its first DoD line and a claim already recorded from the copy to the parent. Coverage for that parent position holds by construction. + +Then shape the child: give it a problem body and an out-of-scope statement (`loaf issue edit` replaces the body), add the criteria that make *its* DoD complete, promote again if one of those earns its own DoD. + +`loaf issue promote` always mints a delivery child. Decision children are created separately: + +```bash +loaf issue new --kind decision --parent <ref> "Should the store be append-only?" +``` + +A decision child is ready when the title or body contains `?`. It does not claim a parent criterion. + +Once **any** child exists — delivery or decision — `loaf issue check` requires every parent criterion to be claimed. A leaf parent that grows a decision child without promoted (or otherwise claimed) criteria will fail coverage. Sequence the tail as: write DoD → promote every slice that will not execute on the parent → then add decision children. If the parent stays a leaf, leave remaining unsharp questions in create-time `fog` (there is no `--fog` on edit) or file sharpened ones as sibling decision issues (`loaf issue new --kind decision --status backlog`, no `--parent`). - Sub-bullet: +Manual claims, when a child criterion was added rather than promoted: - ```markdown - - **V1.** What must be true. - - Command: `go test ./...` - - Expect: exit 0 +```bash +loaf issue dod add <child> "Child done-check" --serves 1 +loaf issue dod claim <child> <child-position> <parent-position> +loaf issue dod unclaim <child> <child-position> <parent-position> +``` + +`--serves` claims the newly added child criterion against that parent position. `claim` / `unclaim` retarget an existing pair. + +## Coverage and containment + +`loaf issue check <ref>` runs these only when the issue has children. + +- **Coverage** (failure) — every parent criterion must be claimed by at least one child criterion. An uncovered position is named in the failure list; fix it by promoting that criterion or adding a claiming child criterion. +- **Containment** (report, not a failure) — every child criterion must claim a parent criterion. An orphan is printed with a ready-to-paste remedy that files it as a **sibling** backlog issue: + + ```bash + loaf issue new --parent '<parent>' --status backlog -- '<orphan text>' ``` - `Expect` is optional and enforced when present, with a deliberately minimal grammar: atoms join with ` and ` — `exit <N>` is the required exit code (an absent `Expect`, or an `Expect` with no exit atom, means `exit 0`) and `` contains `text` `` requires the combined stdout+stderr to contain that backtick-delimited text (repeatable). Any other clause is unenforceable: `loaf change verify` warns naming the criterion and the clause and records it as advisory, so an expectation is either checked or loudly not — never quietly decorative. Commands run from the **repository root** (never the change folder). Only V-entries that declare a fenced `Command:` value are gate input. + Run the printed remedy. Do not fold the orphan back onto the parent, and do not treat it as in-scope work that somehow escaped the DoD — it is a new backlog row under the same parent. + +A different problem (not an orphan criterion) is not a sibling of this decomposition. File it as a new backlog issue with no `--parent`. -- **Human review (H-tier)** — what a reviewer confirms that no command can. H-entries are review material and are **never** gate input; `loaf change verify` ignores them. +## Authoring criteria + +```bash +loaf issue dod add <ref> <text> [--command <cmd>] [--expect <expect>] [--tier V|H] [--serves <parent-position>] +``` + +- **V-tier** — used when `--command` is present, unless `--tier` overrides. Machine-checkable by `loaf issue verify <ref>`. Commands run from the **repository root**. +- **H-tier** — default when `--command` is absent. Human review; never gate input. `loaf issue verify` skips H-tier rows. + +`--expect` is optional and enforced when present, with a deliberately minimal grammar: atoms join with ` and ` — `exit <N>` is the required exit code (an absent `--expect`, or one with no exit atom, means `exit 0`) and `` contains `text` `` requires the combined stdout+stderr to contain that backtick-delimited text (repeatable). Any other clause is unenforceable: verify warns naming the criterion and the clause and records it as advisory, so an expectation is either checked or loudly not — never quietly decorative. + +```bash +loaf issue dod add LOAF-42 "Package tests pass" --command "go test ./..." --expect "exit 0" +loaf issue dod add LOAF-42 "Check names the uncovered criterion" --command "loaf issue check LOAF-42" --expect "exit 0 and contains \`uncovered\`" +loaf issue dod add LOAF-42 "The writeup is readable by someone new to the area" --tier H +``` A criterion whose check only restates the implementation (recomputing the expected value the way the code does) is vacuous — it can never disagree with the code under test. Prefer criteria with an independent source of truth. + +`loaf issue dod list <ref>` prints the current lines. `loaf issue dod remove <ref> <position>` removes one (positions then compact). + +## Order by likelihood-of-change + +Present children for review, not for execution order: data models, interfaces, and user-facing flows lead; mechanical refactors and boilerplate collapse at the bottom. The reviewer's attention should land on what's most likely to need changing, not on whatever happens to run first. + +Sequencing constraints that genuinely exist — this child must land before that one — are recorded as relationships: + +```bash +loaf issue link <from> blocks <to> +loaf issue link <from> relates-to <to> +loaf issue link <from> remove blocks <to> +``` + +Never rely on `loaf issue tree` order to imply a dependency; a reviewer skimming by likelihood-of-change won't read sequencing into position. `loaf issue bucket <ref> now|next|later|none` is an advisory label only and is never read as a constraint. + +## Own the decisions + +Decide granularity and slice boundaries autonomously. Ask the user only when two orderings are genuinely equally valid with different trade-offs; otherwise decide and move on. diff --git a/dist/codex/skills/shape/references/grilling.md b/dist/codex/skills/shape/references/grilling.md index cd4d6a575..4cf6437dd 100644 --- a/dist/codex/skills/shape/references/grilling.md +++ b/dist/codex/skills/shape/references/grilling.md @@ -1,8 +1,8 @@ # Grilling -The interview technique for `[KU]` entries — known unknowns precise enough to phrase as a question. Imported from the reviewed `grill-me` / `grill-with-docs` pattern (`docs/changes/20260704-shape-first-change-workflow/research/mattpocock-review/`), sharpened with the Field Guide's architectural-impact ordering. +The interview technique for `[KU]` entries — known unknowns precise enough to phrase as a question. Imported from the reviewed `grill-me` / `grill-with-docs` pattern, sharpened with architectural-impact ordering. -This is a distinct, shape-specific technique — not the shared `templates/grilling.md` used by `architecture` and `refactor-deepen`. That template's glossary-mutation duty doesn't fit shape's ambiguity-resolving step (see the deferral rationale it documents); shape's grilling stays scoped to fog entries and never writes to the domain glossary. +This is a distinct, shape-specific technique — not the shared `templates/grilling.md` used by `architecture` and `refactor-deepen`. That template's glossary-mutation duty doesn't fit shape's ambiguity-resolving step (see the rationale it documents); shape's grilling stays scoped to fog entries and never writes to the domain glossary. ## The Mechanic @@ -14,17 +14,21 @@ Every question carries a recommended answer with rationale — never "what do yo Prioritize questions whose answer would change the architecture. Cosmetic questions — naming, ordering, presentation — go last, even when they're easier to answer. An architecture-changing answer received late can invalidate everything decided in between; asking it first avoids that rework. -Before asking, check whether reading resolves the question — an existing ADR, a prior Change, a journal entry. Only ask what reading couldn't answer. +Before asking, check whether reading resolves the question — an existing ADR, a prior issue, a journal entry. Only ask what reading couldn't answer. ## Stop Condition Stop when either holds: - No unrouted `[KU]` entries remain. -- Answers stop changing the contract — the last several questions confirmed direction rather than altering it. +- Answers stop changing the issue — the last several questions confirmed direction rather than altering the body, the criteria, or the children. -Grilling never opens the session. Context gathering and the blindspot-pass offer come first; an interview without that groundwork asks questions the codebase or journal could have answered for free. +Write each accepted answer into the issue as it lands: `loaf issue edit` for the body, `loaf issue dod add` for a new done-check, `loaf issue new --kind decision --parent <ref>` when the answer is itself a sharp question that still needs a later call. Do not leave a resolved `[KU]` only in the conversation. ## Mid-Interview Reroute If a question turns out to need domain fluency the shaper doesn't have — the follow-up can't even be phrased — stop grilling it and route the entry to the blindspot pass instead of guessing at an answer. + +## Opening + +Grilling never opens the session. Context gathering and the blindspot-pass offer come first; an interview without that groundwork asks questions the codebase or journal could have answered for free. diff --git a/dist/codex/skills/shape/references/reaction-artifact.md b/dist/codex/skills/shape/references/reaction-artifact.md index d3254bed0..ca30f7485 100644 --- a/dist/codex/skills/shape/references/reaction-artifact.md +++ b/dist/codex/skills/shape/references/reaction-artifact.md @@ -8,11 +8,11 @@ Before building anything, state precisely what this artifact must let the user d ## Build the Smallest Thing That Lets the User React -Construct the minimum artifact that resolves the named unknown — competing layouts, a comparison of approaches, a rendered mock. Write it into the Change's own `research/` folder, never `.agents/reports/`; this is shape's own technique, executed in-session, not a handoff to the research skill. +Construct the minimum artifact that resolves the named unknown — competing layouts, a comparison of approaches, a rendered mock. Keep it in the session or in a throwaway file you will discard. This is shape's own technique, executed in-session, not a handoff to the research skill and not a committed plan document. Do not write it into `.agents/reports/`. ## Capture the Answer, Discard the Shell -Once the user reacts and picks, write the decision — with rationale — back into the Change: the Decisions log, or the relevant Planning Contract subsection. The artifact itself is not the deliverable; the choice it produced is. Discard or archive the shell once the answer is captured. If a piece of logic proved out along the way is worth keeping (a validated approach, not just a throwaway shell), note that explicitly — but don't let scaffolding survive under the pretense that it's production code. +Once the user reacts and picks, write the decision — with rationale — back onto the issue: the body via `loaf issue edit`, or a `--kind decision` child when the pick is itself a sharp question that still needs a later call. The artifact itself is not the deliverable; the choice it produced is. Discard the shell once the answer is captured. If a piece of logic proved out along the way is worth keeping (a validated approach, not just a throwaway shell), note that explicitly in the issue body — but don't let scaffolding survive under the pretense that it's production code. ## Never a Deliverable diff --git a/dist/codex/skills/shape/templates/brief.md b/dist/codex/skills/shape/templates/brief.md deleted file mode 100644 index 05016c8c8..000000000 --- a/dist/codex/skills/shape/templates/brief.md +++ /dev/null @@ -1,42 +0,0 @@ -<!-- brief.md is the optional archeological kickstart — the original unshaped ask. - May accrete parked problem-space concepts while the change is captured; freezes when shape.md exists. - Superseded by shape.md; never mechanically load-bearing. - A brief-only folder is legal and non-executable (captured, not shaped). --> - -# [Brief title] - -## Problem Statement - -[What friction, gap, or unmet need exists? Be specific about the pain — vague problems produce vague solutions. Problem-space only; do not design the approach here.] - -## Who Has It - -[Who experiences this problem? Role, context, and how often the pain shows up. Avoid unqualified "users" or "developers."] - -## Current Alternatives - -[What do they do today? Existing tools, manual workarounds, or "nothing" are all valid. Understanding the status quo clarifies what better means.] - -## Value Proposition - -[Why is solving this worth it? What becomes true for the people who have the problem if this lands? Describe value, not features or architecture.] - -## Constraints - -[Non-negotiable bounds: technical, legal, organizational, or philosophical. Things that limit the solution space before design begins.] - -- [Constraint 1] - -## Sequencing and Relationships - -[How this relates to other work — series order, release cohort, dependencies stated as prose. No machine relation fields; narrative order only.] - -## Sources and Research Links - -[Evidence that informed this framing — competitive scans, research notes, issue links, prior art. Link rather than paste.] - -## Open Questions - -[Unresolved problem-space items. Mark urgency: blocking (must resolve before shaping) or deferrable.] - -- [ ] [Question] — blocking | deferrable diff --git a/dist/codex/skills/shape/templates/change.md b/dist/codex/skills/shape/templates/change.md deleted file mode 100644 index 6ce5b9725..000000000 --- a/dist/codex/skills/shape/templates/change.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -change: [slug] -created: [YYYY-MM-DD] -branch: [slug] ---- - -<!-- Frontmatter must open the file at byte one — parsers depend on it. No status-like frontmatter (readiness/status/state): readiness is derived — a draft PR is shaping; `loaf change check` derives structural executability from the sections below. --> - -# [Change Title] - -## Problem - -[Why this work exists — the friction, gap, or rot being addressed.] - -## Hypothesis - -[The bet: what becomes true if this ships, and why it is worth making.] - -## Scope - -**In** - -- [What this Change delivers.] - -**Out** (deferred, not rejected) - -- [What is explicitly postponed, and where it went.] - -**Cut** (explicitly rejected) - -- [What this Change will not do, ever.] - -## Observable Workflow - -[What someone sees or does once this ships — commands, flows, UX. Concrete over abstract.] - -## Rabbit Holes and No-Gos - -[Boundaries: the ways this work could quietly grow into something it must not become.] - -## Decisions - -Provenance: [how each decision was accepted — interview, review, dogfooding.] - -1. **[Decision.]** [Rationale, and what it forecloses.] - -## Planning Contract - -[The HOW. Free-form `###` subsections named by the work — the container is the contract; the subsection names are yours.] - -### [Approach / Placement / Risks / Sequencing / Spike findings …] - -[...] - -## Implementation Units - -<!-- In-document work packets — commit-boundary guides and review anchors, not tracked entities. --> - -- [**U1 — Unit name.** What it delivers.] - -## Verification Contract - -<!-- Executable (machine-checkable): --> - -- [**V1.** Criterion bound to a command and an expected result.] - -<!-- Human review: --> - -- [**H1.** What a reviewer confirms that no command can.] - -## Definition of Done - -- [Derived from gates and review — never a status flag.] - -## Durable Outputs - -[Specs, ADRs, knowledge docs, or schema updates to create after implementation proves what is now true. A final spec describes reality, not a plan.] - -## Open Questions - -<!-- Fog register: tag entries [KU]/[UK]/[UU] with a route — see the shape skill's quadrant table. Tags are convention, never parsed by check. --> - -- [Known unknowns, each owned by a section, a spike, or a follow-up.] - -## Source Inputs - -- [Where this Change came from: journal entries (cite by ID), sparks, ideas, brainstorms, issues, conversations, prior Changes.] - -<!-- Optional sections, added when they earn their place: Background, Success Metrics (when validation matters), Follow-ups, Critique Gate. --> diff --git a/dist/codex/skills/shape/templates/design.md b/dist/codex/skills/shape/templates/design.md deleted file mode 100644 index 2244ccdb6..000000000 --- a/dist/codex/skills/shape/templates/design.md +++ /dev/null @@ -1,20 +0,0 @@ -<!-- design.md is the optional design surface for UI, protocol, or schema detail - that would crowd shape.md or plan.md. Accretive; not load-bearing for the gate. --> - -# Design — [Change Title] - -## Intent - -[What this design clarifies that shape.md does not.] - -## Surfaces - -[Screens, APIs, schemas, or protocols under design.] - -## Decisions - -1. **[Decision.]** [Rationale.] - -## Open questions - -- [What remains undecided.] diff --git a/dist/codex/skills/shape/templates/plan.md b/dist/codex/skills/shape/templates/plan.md deleted file mode 100644 index 6d17a7179..000000000 --- a/dist/codex/skills/shape/templates/plan.md +++ /dev/null @@ -1,21 +0,0 @@ -<!-- plan.md is the optional technical route (the corpus's plan sense). - Accretive during shaping; approach churn here never expires a cohort receipt. - Criteria live in shape.md — do not relocate Verification Contract here. --> - -# Plan — [Change Title] - -## Approach - -[How the work lands — architecture, sequencing, compatibility.] - -## Placement - -[Where code and docs live; what stays out of scope for this route.] - -## Risks - -[Failure modes and the safe failure direction.] - -## Sequencing - -[Ordered slices that leave main coherent at each landing.] diff --git a/dist/codex/skills/shape/templates/pr.md b/dist/codex/skills/shape/templates/pr.md deleted file mode 100644 index 91b4b7401..000000000 --- a/dist/codex/skills/shape/templates/pr.md +++ /dev/null @@ -1,25 +0,0 @@ -<!-- Draft = still shaping. Ready for review = structurally executable, not proof of implementation completion. --> - -## Change - -<!-- Link the Change folder this PR advances: docs/changes/YYYYMMDD-slug/ The Change artifact lives in this PR's diff — the draft PR is its shaping surface. During coexistence, legacy PRs implementing a numbered spec link the .agents/specs/ path here instead. Delete this section for PRs tied to neither. --> - -## What & Why - -<!-- What this PR does and the problem it solves. For a shaping (draft) PR, summarize the direction; for an implementation PR, summarize the delta. --> - -## Review focus - -<!-- Where reviewer attention pays off: decisions to challenge, boundaries to verify, criteria to confirm. --> - -## Verification - -<!-- What proves this works: gates run (`loaf change check`, `loaf check`), tests, commands a reviewer should re-run locally. On a draft, state what remains open instead. --> - -## Migration / breaking changes - -<!-- If this PR changes user-facing behavior, document the migration step. Otherwise: "None." --> - -## Deferred - -<!-- Intentionally out of scope, and where it went (follow-up Change, open question, issue). Delete if nothing was deferred. --> diff --git a/dist/codex/skills/shape/templates/shape.md b/dist/codex/skills/shape/templates/shape.md deleted file mode 100644 index 59aa9b89a..000000000 --- a/dist/codex/skills/shape/templates/shape.md +++ /dev/null @@ -1,77 +0,0 @@ -<!-- shape.md is the change contract. Identity lives in change.json — no status-like frontmatter. Readiness is derived: a draft PR is shaping; `loaf change check` derives structural executability from the sections below. --> - -# [Change Title] - -## Problem - -[Why this work exists — the friction, gap, or rot being addressed.] - -## Hypothesis - -[The bet: what becomes true if this ships, and why it is worth making.] - -## Scope - -**In** - -- [What this Change delivers.] - -**Out** (deferred, not rejected) - -- [What is explicitly postponed, and where it went.] - -**Cut** (explicitly rejected) - -- [What this Change will not do, ever.] - -## Observable Workflow - -[What someone sees or does once this ships — commands, flows, UX. Concrete over abstract.] - -## Rabbit Holes and No-Gos - -[Boundaries: the ways this work could quietly grow into something it must not become.] - -## Decisions - -Provenance: [how each decision was accepted — interview, review, dogfooding.] - -1. **[Decision.]** [Rationale, and what it forecloses.] - -## Planning Contract - -<!-- The HOW. Prefer plan.md/design.md when the route needs its own file; keep this container. Free-form ### subsections named by the work. --> - -### [Approach / Placement / Risks / Sequencing …] - -[…] - -## Implementation Units - -<!-- Task packets live in tasks/TASK-NNN-slug.md; this section may summarize the decomposition. --> - -- [**TASK-001 — Unit name.** What it delivers.] - -## Verification Contract - -<!-- Executable (machine-checkable): each V-entry declares Command and Expect for loaf change verify. Expect is a grammar, not prose: atoms join with " and " — `exit <N>` is the required exit code (omit the atom, or Expect entirely, for exit 0; a second exit atom is a contradiction and fails the criterion) and contains `text` requires the combined stdout+stderr to contain that backtick-delimited text (repeatable). Example: Expect: exit 0 and contains `all green`. Any other clause is unenforceable: verify warns naming the criterion and clause, records it as advisory, and never checks it. --> - -- [**V1.** What must be true. Command: `exact command`. Expect: exit 0.] - -<!-- Human review (H-tier): review material, never gate input. --> - -- [**H1.** What a reviewer confirms that no command can.] - -## Definition of Done - -- [Derived from gates and review — never a status flag.] - -## Durable Outputs - -[Specs, ADRs, knowledge docs, or schema updates to create after implementation proves what is now true.] - -## Open Questions - -<!-- Fog register: tag entries [KU]/[UK]/[UU] with a route. Tags are convention, never parsed by check. --> - -- [KU] [Known unknown → route to a task or later change] diff --git a/dist/codex/skills/shape/templates/task.md b/dist/codex/skills/shape/templates/task.md deleted file mode 100644 index 05de15307..000000000 --- a/dist/codex/skills/shape/templates/task.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -change: [slug] -id: TASK-NNN -title: [short title] -# Relations (closed set; targets are TASK-NNN within this change only): -# parent: TASK-NNN -# blocks: -# - TASK-NNN -# blocked-by: -# - TASK-NNN -# relates-to: -# - TASK-NNN ---- - -# TASK-NNN — [Title] - -## Objective - -[What this task delivers when its checkboxes are flipped.] - -## Scope boundaries - -**In:** [What this task may touch.] - -**Out:** [What this task must not touch — other tasks, other changes, deferred work.] - -## Context pointers - -- Contract: `shape.md` — [relevant sections] -- Research: [paths under research/ when cited] - -## Acquisition - -```bash -loaf journal log "skill(implement): TASK-NNN — [short intent]" -# [commands or files to load before editing] -``` - -## Steps - -- [ ] [Atomic step — one commit's worth when flipped with the delivering work] -- [ ] [Next step] - -## Verification - -- [Commands or checks that prove this task alone] -- The slug never cites other work units (`TASK-…`, `SPEC-…`, issue keys) — identity is local; provenance is in frontmatter and the change folder. diff --git a/dist/codex/skills/ship/SKILL.md b/dist/codex/skills/ship/SKILL.md index 3b645f200..16c112267 100644 --- a/dist/codex/skills/ship/SKILL.md +++ b/dist/codex/skills/ship/SKILL.md @@ -1,17 +1,20 @@ --- name: ship description: >- - Reviews, verifies, and lands one pull request. Use when the user says "ship - it," "merge this PR," "ready to merge," "land this branch," or asks for a - final merge gate. Produces a reviewed, squash-merged PR and post-merge - cleanup. Not for version bumps, tags, GitHub Releases, or install verification - (use release). + Reviews, verifies, and lands one pull request — the sole quality gate before + work can appear in a later release cut. Use when the user says "ship it," + "merge this PR," "ready to merge," "land this branch," or asks for a final + merge gate. Binds the PR to an issue: the body is `loaf issue render` output, + definition-of-done criteria are the review checklist, and landing marks the + issue done and stops its worktree. Produces a reviewed, squash-merged PR and + post-merge cleanup. Not for version bumps, tags, GitHub Releases, or install + verification (use release). version: 0.2.21 --- # Ship -Review, verify, and land one PR. Shipping is the PR gate; releasing is the version-publication gate. +Review, verify, and land one PR. Ship's review is the quality gate for everything that will ever ship. Releases are retroactive — they cut a version from already-landed work. Nothing between merge and the next release cut re-checks the diff, the tests, or the issue. A rubber-stamped PR ships in the next cut with no second net. ## Contents - Critical Rules @@ -21,7 +24,7 @@ Review, verify, and land one PR. Shipping is the PR gate; releasing is the versi - Context Detection - Step 1: PR Readiness - Step 1b: Stacked PR Detection -- Step 2: Evidence Review +- Step 2: Definition-of-Done Review - Step 3: Local Verification - Step 4: Squash Merge - Step 5: Post-Merge Cleanup @@ -35,64 +38,94 @@ Review, verify, and land one PR. Shipping is the PR gate; releasing is the versi ## Critical Rules -- **Ship is not release** -- do not bump versions, create tags, publish GitHub Releases, or verify package installation here. -- **Keep PR quality local** -- smaller PRs are welcome, but ship must still verify correctness before merge. -- **Detect-first** -- auto-detect the PR from the current branch before asking for a PR number. -- **Review before merge** -- inspect code, docs, tests, changelog, PR body, and CI state before approval. -- **Never merge without explicit confirmation** -- present the PR, checks, findings, and squash body first. -- **Detect the stack before merging** -- another open PR may use this PR's head branch as its base. Find out before merge, never after, and never delete a head branch while a child PR still points at it. -- **Clean squash body** -- write an intentional squash commit body; never accept the automatic commit dump. -- **Keep landed and released distinct** -- after merge, describe the PR as landed or shipped, not necessarily released. -- **Log shipping** -- after merge, run `loaf journal log "decision(ship): PR #N landed via squash merge"`. +1. **Log invocation first** — `loaf journal log "skill(ship): shipping <ref or PR or current branch>"` before doing anything else. After merge, log `loaf journal log "decision(ship): PR #N landed via squash merge; <ref> done"`. +2. **Rigor is load-bearing** — this review is the only quality gate. Releases cut from landed work; they do not re-check. If the review is thin, the next cut still publishes it. +3. **Ship is not release** — do not bump versions, create tags, publish GitHub Releases, or verify package installation here. Use the release skill for that. +4. **Bind the PR to an issue** — the PR body is `loaf issue render <ref>` (paste-ready, no manual editing). The issue's definition-of-done criteria are the review checklist. `loaf issue verify <ref>` runs the executable (V-tier) rows and writes nothing. Landing means `loaf issue status <ref> done`. Then `loaf issue stop <ref>` removes the started worktree. +5. **Detect-first** — auto-detect the PR from the current branch, and the issue from `$ARGUMENTS` or the started workspace, before asking for a PR number or issue ref. +6. **Review before merge** — inspect code, docs, tests, changelog, the rendered issue body, definition of done, and CI state before approval. +7. **Never merge without explicit confirmation** — present the PR, checks, review notes, and squash body first, using your harness's structured question tool if it has one. +8. **Detect the stack before merging** — another open PR may use this PR's head branch as its base. Find out before merge, never after, and never delete a head branch while a child PR still points at it. +9. **Clean squash body** — write an intentional squash commit body; never accept the automatic commit dump. +10. **Keep landed and released distinct** — after merge, describe the PR as landed or shipped. It is not released until release publishes a version. + +--- ## Verification +- Invocation is logged to the project journal before review work begins - PR identity, base branch, and head branch are confirmed +- The PR is bound to one issue; `loaf issue show <ref>` is the issue surface +- PR body matches `loaf issue render <ref>` with no manual editing +- Every definition-of-done criterion was reviewed against the diff; H-tier by reading, V-tier by `loaf issue verify <ref>` (writes nothing; exit non-zero blocks) - CI status is passing or the user explicitly accepts named non-blocking checks - Relevant local checks pass or failures are fixed before merge - PR body and durable docs do not overclaim relative to the diff - Squash commit title/body are clean, conventional, and user-facing - Child PRs stacked on this PR's head branch are enumerated before merge, and each is retargeted, rebased, and re-verified after it -- Base branch is updated after merge and the feature branch cleanup state is known +- After merge: `loaf issue status <ref> done`, then `loaf issue stop <ref>` if a worktree was started, base branch updated, feature-branch cleanup state known +- A `decision(ship)` journal entry records the landing ## Quick Reference | Step | Gate | Blocking? | |------|------|-----------| +| Context Detection | PR and issue bound | Yes | | PR Readiness | PR exists, target base known, CI state reviewed | Yes | | Stacked PR Detection | child PRs on this head branch are enumerated | Yes | -| Evidence Review | findings resolved or explicitly accepted | Yes | -| Local Verification | relevant checks pass | Yes | +| Definition-of-Done Review | every criterion reviewed; `loaf issue verify` passes or reports no V-tier rows | Yes | +| Local Verification | relevant project checks pass | Yes | | Squash Merge | user approves body text | Yes | -| Cleanup | base pulled, children retargeted and rebased, branch deletion handled | Yes when a child exists | -| Release Suggestion | enough landed work may justify release | No | +| Cleanup | issue marked done, started worktree stopped, base pulled, children retargeted and rebased, branch deletion handled | Yes when a child PR exists; done + stop always | +| Release Suggestion | enough landed work may justify a later cut | No | ## Topics | Topic | Use When | |-------|----------| -| [Context Detection](#context-detection) | Determining current branch and PR state | +| [Context Detection](#context-detection) | Binding the current branch to a PR and an issue | +| [Definition-of-Done Review](#step-2-definition-of-done-review) | Using issue criteria as the merge checklist | | [Hook Interaction](#hook-interaction) | Understanding coexistence with git hooks | --- ## Context Detection -Before anything, detect the PR surface: +Log the invocation, then detect the PR and the issue. + +### PR 1. Get current branch and repo default branch: ```bash git branch --show-current gh repo view --json defaultBranchRef -q .defaultBranchRef.name ``` -2. Parse `$ARGUMENTS`: may be a PR number, PR URL, branch name, or empty. -3. If `$ARGUMENTS` is empty, auto-detect from the current branch: +2. Parse `$ARGUMENTS`: may be an issue ref (`LOAF-42`), a PR number, a PR URL, a branch name, or empty. +3. If `$ARGUMENTS` is empty or is not a PR identity, auto-detect from the current branch: ```bash gh pr view --json number,title,url,headRefName,baseRefName,state,mergeStateStatus,isDraft ``` 4. If no PR exists for the current branch, stop and offer to create one via `git-workflow` rather than silently merging a branch. 5. If already on the default branch, stop. There is no PR to ship from the current branch. -6. Confirm PR identity with the user before merge actions. + +### Issue + +Issue commands require initialized SQLite state. Bind exactly one issue: + +1. If `$ARGUMENTS` (or a remaining token) is an issue ref, load it: + ```bash + loaf issue show <ref> + ``` +2. Otherwise match the PR's `headRefName` to a started workspace: + ```bash + loaf issue list --started + ``` + Columns are alias, title, `started_branch`, `started_worktree`. The started branch from `loaf issue start` is `issue/<alias-or-id>` in lowercase (`issue/loaf-42`), disambiguated with an id suffix when that name is already claimed. +3. Confirm with `loaf issue show <ref>` — `started_branch` / `started_worktree` should match this PR when the issue was started. + +If nothing binds, stop and ask for the issue ref. Do not invent a row during ship. If `loaf issue show` reports the issue archived (`cancelled` or `duplicate`), stop. + +Confirm PR identity and the bound issue with the user before merge actions. --- @@ -104,6 +137,14 @@ Inspect the PR's declared state: gh pr view <N> --json number,title,body,url,headRefName,baseRefName,state,isDraft,mergeStateStatus,reviewDecision,statusCheckRollup ``` +The body must be `loaf issue render <ref>` output — title, issue body, definition-of-done checkboxes (checked only when status is already `done`), and children. No project headers, no hand-edited summary. If the live PR body differs, replace it: + +```bash +gh pr edit <N> --body "$(loaf issue render <ref>)" +``` + +Do not rewrite the markdown by hand. Checkboxes are unchecked until `loaf issue status <ref> done`; do not tick them in the PR body to fake completion. + Block or pause when: - PR is draft @@ -124,20 +165,27 @@ Before merging anything, find out whether another open PR uses this PR's head br gh pr list --state open --base <headRefName> --json number,title,headRefName ``` -Any result is a **child PR**, and this PR is the base of a stack. Record the list now, because after the merge the relationship is harder to see and easier to break. +Any result is a **child PR**, and this PR is the base of a stack. Record the list now, because after the merge the relationship is harder to see and easier to break. Child PRs are GitHub stacking, not child issues. -When a child exists: +When a child PR exists: - **Do not pass `--delete-branch` to the merge.** Removing the head branch while a child still points at it can close the child outright. Delete it only after every child has been retargeted, in Step 5. - Tell the user the stack exists and name the children before asking for merge confirmation. A stack changes what "merge this" means. -When no child exists, say so, and `--delete-branch` is safe. +When no child PR exists, say so, and `--delete-branch` is safe. --- -## Step 2: Evidence Review +## Step 2: Definition-of-Done Review + +The issue's definition of done is the merge checklist. Load it from the issue, not from memory: + +```bash +loaf issue dod list <ref> +loaf issue show <ref> +``` -Review the landing diff and durable prose together: +`show` prints each criterion as `position. [V|H] text` with `command=` / `expect=` when present. Walk every row against the landing diff. 1. Gather diff context: ```bash @@ -145,13 +193,19 @@ Review the landing diff and durable prose together: git diff --stat origin/<baseRefName>...HEAD git diff --name-only origin/<baseRefName>...HEAD ``` -2. Read the PR title/body and changed docs that make behavior claims. -3. Check for drift: - - PR body claims features that are not in the diff - - changelog entries mention unreleased or unrelated behavior +2. For each **H-tier** criterion, read the diff and durable prose and decide whether the text is met. `loaf issue verify` skips H-tier rows — that skip is not a pass; you are the check. +3. Run the **V-tier** rows: + ```bash + loaf issue verify <ref> + ``` + Commands run from the **repository root**. The command honors `exit <N>` and `` contains `text` ``. It writes nothing — it does not tick checkboxes, does not set status, and does not record a run. Non-zero exit blocks merge. `no executable V-tier criteria on <ref>` is not a failure; H-tier review still is. Unenforceable expect clauses print as advisory warnings and are never quietly decorative. +4. Check for drift: + - Rendered issue body claims features that are not in the diff + - changelog entries mention unrelated behavior - docs describe future work as already shipped - - comments or runbooks use stale internal vocabulary -4. Fix blocking drift before merge. For non-blocking polish, name it and let the user decide. + - comments or runbooks use stale vocabulary +5. If `loaf issue show` lists child issues that are not `done`, name them before asking to merge. Do not mark those children done unless this PR is theirs. +6. Fix blocking drift and unmet criteria before merge. For non-blocking polish, name it and let the user decide. For high-risk PRs, use the project's review skill or read-only review flow before proceeding. @@ -159,7 +213,7 @@ For high-risk PRs, use the project's review skill or read-only review flow befor ## Step 3: Local Verification -Run the checks the project supports. Examples: +Run the checks the project supports, in addition to `loaf issue verify`. Examples: - Node: `npm run typecheck`, `npm run test`, `npm run build` - Go: `go vet ./...`, `go test ./...` @@ -178,7 +232,7 @@ Use the repo's documented pre-commit or pre-PR checklist when present. Stop on f ## Step 4: Squash Merge -Draft a clean squash body from the reviewed diff and PR body: +Draft a clean squash body from the reviewed diff and the rendered issue: - One-line summary, then bullet points grouped by feature area - Plain text; use backticks only for code identifiers @@ -201,31 +255,41 @@ Let GitHub default the title from the PR title so the squash subject remains `ty ## Step 5: Post-Merge Cleanup -After a successful merge: +After a successful merge, leave the started worktree before removing it. Do not run `loaf issue stop` from inside that worktree. -1. Switch to the PR base branch: +1. Switch to the PR base branch in the repository checkout: ```bash git checkout <baseRefName> git pull --ff-only origin <baseRefName> ``` -2. Delete the local feature branch when safe: +2. Mark the bound issue done — this is what "done" means; `loaf issue stop` does not change status: + ```bash + loaf issue status <ref> done + ``` + Write statuses (`triage`, `backlog`, `todo`, `active`, `done`) update in place. Do not use `cancelled` or `duplicate` here. +3. Stop the started worktree if one exists. `loaf issue stop` removes the worktree, clears `started_branch` / `started_worktree` on the row, and **keeps the branch**: + ```bash + loaf issue stop <ref> + ``` + If the issue was never started, the command errors with `issue <ref> is not started` — treat that as already clean and continue. If the worktree is dirty, do not pass `--force` without user confirmation. +4. Delete the local feature branch when safe: ```bash git branch -d <headRefName> ``` -3. Confirm the remote branch deletion state from GitHub output or run: +5. Confirm the remote branch deletion state from GitHub output or run: ```bash gh pr view <N> --json headRefName,state ``` -4. Log the landing to the project journal: +6. Log the landing: ```bash - loaf journal log "decision(ship): PR #N landed via squash merge" + loaf journal log "decision(ship): PR #N landed via squash merge; <ref> done" ``` -If cleanup fails, report the exact residual state. Do not force-delete without user confirmation. +If cleanup fails, report the exact residual state (issue status, whether the worktree is still started, which branches remain). Do not force-delete without user confirmation. ### Stacked child PRs -Every child recorded in Step 1b needs three repairs before it can ship, and none of them happen on their own. +Every child PR recorded in Step 1b needs three repairs before it can ship, and none of them happen on their own. **Retarget the base.** GitHub does not reliably move a child's base when its base branch merges. Check, and move it explicitly: @@ -247,7 +311,7 @@ git diff --stat <baseRefName>...HEAD | tail -1 gh pr view <child> --json changedFiles -q .changedFiles ``` -Then force-push with a lease, wait for CI to finish on the **rebased head** rather than trusting the previous run, and only then ship the child. Delete the parent's head branch once no child points at it. +Then force-push with a lease, wait for CI to finish on the **rebased head** rather than trusting the previous run, and only then ship the child. Delete the parent's head branch once no child PR points at it. Report the numbers rather than asserting success: the before and after file counts, the commits the rebase skipped, and the CI conclusion on the new head. @@ -255,11 +319,11 @@ Report the numbers rather than asserting success: the before and after file coun ## Step 6: Release Suggestion -After landing, decide whether to suggest release: +After landing, decide whether to suggest the release skill. That cut will not re-run this review. -- Suggest release when the landed PR completes a coherent batch, user-facing feature, fix train, or release branch. +- Suggest release when the landed PR completes a coherent batch, user-facing feature, or fix train. - Do not suggest release for every small PR by default. -- If multiple related PRs are expected, say the PR is landed and can wait for a later batched release. +- If more related PRs are expected, say this PR is landed and can wait for a later cut. Use language carefully: the PR is **landed** or **shipped**; it is not **released** until release publishes a version. @@ -283,12 +347,12 @@ Do not disable hooks to force a PR through. ## Suggests Next -After a successful ship, suggest release only when the landed work forms a coherent release batch or the user asks to publish. +After a successful ship, suggest release only when the landed work forms a coherent batch or the user asks to publish. Release will not re-check the landed PR. ## Related Skills -- **release** -- Publishes a version from already-landed work -- **git-workflow** -- Branching, PR, commit, and squash merge conventions -- **foundations** -- Verification, code review, and production readiness -- **documentation-standards** -- Changelog, docs, and durable prose quality -- **reflect** -- Updates strategy from significant shipped work +- **release** — Publishes a version from already-landed work; does not re-review those PRs +- **git-workflow** — Branching, PR, commit, and squash merge conventions +- **foundations** — Verification, code review, and production readiness +- **documentation-standards** — Changelog, docs, and durable prose quality +- **reflect** — Updates strategy from significant shipped work diff --git a/dist/codex/skills/triage/SKILL.md b/dist/codex/skills/triage/SKILL.md index 0778654a2..b8ad99622 100644 --- a/dist/codex/skills/triage/SKILL.md +++ b/dist/codex/skills/triage/SKILL.md @@ -2,13 +2,13 @@ name: triage description: >- Processes the local intake queue from loaf intake list: unresolved sparks, - ideas, brainstorms, tracked and deferred Intents, and unmigrated legacy - deferrals. Use when the user asks "triage", "process my backlog", or wants - dispositions chosen across intake items. Produces explicit dispositions: - discard, retain, track as Intent, defer, resume, resolve, explore, hand to - pitch, or hand to shape. Not for reading a single known item (use loaf intent - show or journal directly), capturing new ideas (use idea), problem discovery - (use pitch), or bounding one chosen direction (use shape). + ideas, and brainstorms. Use when the user asks "triage", "process my backlog", + or wants dispositions chosen across intake items. Produces explicit + dispositions: discard, retain as spark/idea, file as backlog issue, resume + exploration, resolve, hand to pitch, or hand to shape (issue preparation). Not + for reading a single known item (use loaf issue show, loaf spark show, loaf + idea show, or journal directly), capturing new ideas (use idea), problem + discovery (use pitch), or bounding one chosen direction (use shape). version: 0.2.21 --- @@ -26,7 +26,7 @@ Process the intake queue. Triage is the public funnel where captured material me - Quick Reference - Process - Dispositions -- Legacy Deferrals +- Leftover kinds - Guardrails - Related Skills @@ -36,62 +36,67 @@ Process the intake queue. Triage is the public funnel where captured material me - Read the queue with `loaf intake list --json`; it projects every unresolved logical item exactly once with its provenance and exact read command. - Present everything before acting — the user decides each disposition; never auto-promote, auto-discard, or auto-convert. - The CLI never classifies: you and the user interpret each item; commands perform the chosen operation deterministically. -- Capture, Intent, and Exploration are different claims: a spark or idea is retained material, a tracked Intent is deliberately tracked work, a deferral is an Intent disposition with an immutable payload, an Exploration is an inquiry. Do not conflate them to save a step. +- Capture, issue, and Exploration are different claims: a spark or idea is retained material, a backlog issue is deliberately tracked work, an Exploration is an inquiry. Do not conflate them to save a step. - One pass through the queue — don't loop or re-present items. -- **Two doors into a Change:** items needing problem discovery hand to pitch, which owns `loaf change init <slug> --brief` and brief authoring; well-understood directions hand to shape. When capture should precede shaping without a full pitch, run `loaf change init <slug> --brief` and seed `brief.md` with the original ask, then hand to shape. +- **Two doors into issue work:** items needing problem discovery hand to pitch; well-understood directions hand to shape (issue preparation). Worth keeping but not ready for either door files as a backlog issue (`loaf issue new "<title>" --status backlog`, optional `--parent`, optional `loaf issue bucket`). Triage never runs `loaf issue start`, never opens PRs, and never invents Git artifacts. ## Verification - Every presented item has a recorded disposition or an explicit "leave for next triage". -- Tracked and deferred choices exist as Intents with the expected derived disposition (`loaf intent list`). +- Filed directions exist as backlog issues (`loaf issue list --status backlog`) and no longer appear in `loaf intake list` once their captures are resolved or archived. - Discards are resolved or archived through their own commands and no longer appear in `loaf intake list`. -- No Linear or tracker operation was attempted; publication is a later concern outside this Change. +- No Linear or tracker operation was attempted; publication is a later concern outside triage. ## Quick Reference | Item kind | Comes from | Typical dispositions | |-----------|-----------|----------------------| -| spark | `loaf spark capture` moments | discard, promote to idea, track as Intent | -| idea | idea capture | archive, explore, track as Intent, hand to pitch, hand to shape | -| brainstorm | archived divergent sessions | archive, explore, promote, hand to pitch | -| intent (tracked) | `loaf intent create` | keep tracking, defer, resolve, explore, hand to pitch, hand to shape | -| intent (deferred) | `loaf intent defer` or adapter | resume, resolve, leave deferred | -| legacy_deferral | pre-conversion `journal defer` | read, then optionally convert (see Legacy Deferrals) | +| spark | `loaf spark capture --scope <scope> --text <text>` | discard, retain, promote to idea, file as backlog issue, resume exploration, resolve, hand to pitch, hand to shape | +| idea | `loaf idea capture --title "<title>"` | archive, retain, file as backlog issue, resume exploration, resolve, hand to pitch, hand to shape | +| brainstorm | `loaf brainstorm capture` | archive, retain, promote to idea, file as backlog issue, resume exploration, hand to pitch, hand to shape | ## Process 1. **Scan.** Run `loaf intake list --json`. Summarize counts by kind, then list each item with its title, disposition or status, and read command. 2. **Read on demand.** Use each item's `read_command` verbatim when the user wants detail before deciding. If a read command fails, record the exact command and error in the summary as `unreadable`, make no semantic disposition for that item, continue the pass, and offer a factual diagnostic step (`loaf state doctor --json`) afterward. Never persist unreadable as a status. 3. **Decide per item.** Present the applicable dispositions and perform exactly the chosen one. -4. **Summarize.** Report what was discarded, retained, tracked, deferred, resumed, resolved, or handed onward, and journal notable decisions. +4. **Summarize.** Report what was discarded, retained, filed as backlog issues, resumed as explorations, resolved, or handed to pitch or shape, and journal notable decisions. ## Dispositions - **Discard** — ideas and brainstorms: `loaf idea archive <ref> --reason <r>` or `loaf brainstorm archive <ref> --reason <r>`. A spark is resolved against the entity that addressed it (`loaf spark resolve <ref> --by <entity> --reason <r>`); a pure dead-end spark currently has no deterministic discard operation — leave it retained, journal the judgment, and never invent a resolving entity. -- **Retain as capture** — do nothing; open captures resurface next triage. -- **Track as Intent** — two steps: create the Intent with the capture as its source, then close the capture against it so the direction appears once. `loaf intent create --title <t> --body <self-sufficient body> --from <capture-ref>`, then `loaf spark resolve <capture-ref> --by <intent-ref>` or `loaf idea resolve <capture-ref> --by <intent-ref>` (brainstorms: `loaf brainstorm archive <ref> --reason "tracked as <intent-ref>"`). -- **Defer** — an existing Intent: `loaf intent defer <ref> --why <w> --boundary <b> --trigger <t> --operation-id <key>`; a new deferred direction needs the full skeleton: `loaf intent create --title <t> --body <b> --disposition deferred --why <w> --boundary <bd> --trigger <tr> --operation-id <key> [--from <source-ref>]`. -- **Resume** — `loaf intent resume <ref> --reason <why now>`; appends a tracked disposition linked to the deferral it supersedes. -- **Resolve** — `loaf intent resolve <ref> --reason <outcome>`; history is never rewritten. -- **Explore** — agent technique for genuinely undecided directions (Explorations and checkpoints); not a user slash entry — prefer pitch when the human needs problem discovery first, then reach for explore from inside that work if still undecided. -- **Pitch** — items needing problem discovery hand to pitch, which owns init and brief authoring; resolve the promoted item against the created change (`loaf spark resolve` / `loaf idea resolve` / archive brainstorm with the change as the reason). -- **Shape** — when a direction is already well-understood and ready for bounded delivery, hand it to shape; triage never creates fully-materialized Changes, branches, or worktrees (capture-only brief seeding is the Critical Rules exception above). +- **Retain as spark/idea** — do nothing to leave the capture open, or promote into the other capture primitive: capture the idea first (`loaf idea capture --title "..."`), then `loaf spark promote <spark> --to-idea <idea>` or `loaf brainstorm promote <brainstorm> --to-idea <idea>`. Open captures resurface next triage. +- **File as backlog issue** — two steps so the direction appears once. Create the issue, then close the capture against it: -## Legacy Deferrals + ```bash + loaf issue new "<title>" --status backlog [--parent <ref>] [--kind delivery|decision] [--fog <text>] [--body <text>] + loaf issue bucket <issue-ref> now|next|later # optional; labels only, never a constraint + loaf spark resolve <capture-ref> --by <issue-ref> + # or: loaf idea resolve <capture-ref> --by <issue-ref> + # brainstorms: loaf brainstorm archive <ref> --reason "filed as <issue-ref>" + ``` -Items of kind `legacy_deferral` are pre-conversion `journal defer` records. They stay visible and readable until the explicit, backup-first conversion is run; nothing disappears while migration is pending. When the user wants them converged, offer `loaf state migrate deferrals --dry-run` to preview the project-specific manifest and `--apply` only with explicit consent — apply verifies a whole-database backup first and preserves every legacy row. + Use `--kind decision` when filing a sharp question. Copy still-unsharp questions into `--fog` (create-time only). `--parent` nests under an existing issue; omit it for a different problem. +- **Resume exploration** — agent technique for genuinely undecided directions (Explorations and checkpoints); not a user slash entry. Prefer pitch when the human needs problem discovery first, then reach for explore from inside that work if still undecided. Resume with `loaf exploration context <ref>` when a named Exploration already exists. +- **Resolve** — the capture is already represented elsewhere. `loaf spark resolve <ref> --by <entity> --reason <r>` or `loaf idea resolve <ref> --by <entity>`. History is never rewritten. +- **Hand to pitch** — items needing problem discovery hand to pitch. Resolve the capture against the issue once one exists (`loaf spark resolve` / `loaf idea resolve --by <issue-ref>` / archive the brainstorm with that issue as the reason). +- **Hand to shape** — when a direction is already well-understood and ready for bounded delivery, hand it to shape for issue preparation. Triage never writes definition-of-done criteria, never runs `loaf issue check`, and never creates branches or worktrees. + +## Leftover kinds + +`loaf intake list` may still include `intent` and `legacy_deferral` items. Do not create new `intent` rows. Treat leftover directions like any other capture: file a backlog issue if worth keeping, or leave them for a later pass. Do not offer conversion commands that recreate the old tracked/deferred row. ## Guardrails 1. **User decides every disposition** — present, don't decide. 2. **Batch presentation, individual decisions** — show the full queue, then process one item at a time. 3. **Log everything** — no silent discards, promotions, or conversions. -4. **Deferred is not forgotten** — deferred Intents remain active truth in `loaf journal context` until resumed or resolved. +4. **Filed is not forgotten** — backlog issues remain on `loaf issue list` and may appear on `loaf issue frontier` until their status changes. Buckets are labels only. ## Related Skills - **idea** — capture a new idea (fast, minimal friction) - **pitch** — problem-discovery ceremony for items that need framing before shape - **explore** — agent technique for divergent inquiry with portable checkpoints -- **shape** — develop a well-understood direction into a bounded Change +- **shape** — develop a well-understood direction into a bounded issue - **housekeeping** — flags stale artifacts; does not choose dispositions diff --git a/dist/codex/skills/wrap/SKILL.md b/dist/codex/skills/wrap/SKILL.md index ab64cc64d..52777e1bf 100644 --- a/dist/codex/skills/wrap/SKILL.md +++ b/dist/codex/skills/wrap/SKILL.md @@ -136,7 +136,7 @@ After the wrap-up report, suggest housekeeping if it wasn't run this session and ## Report Format -Use backtick formatting for code identifiers, file paths, spec/task IDs, version numbers, status values, and CLI commands. Use uppercase for spec and task IDs (`SPEC-029`, not `spec-029`). +Use backtick formatting for code identifiers, file paths, issue IDs, version numbers, status values, and CLI commands. Use uppercase for issue IDs (`LOAF-29`, not `loaf-29`). ```markdown ## Session Wrap-Up diff --git a/dist/cursor/.loaf-target-manifest.json b/dist/cursor/.loaf-target-manifest.json index ad334f315..867694605 100644 --- a/dist/cursor/.loaf-target-manifest.json +++ b/dist/cursor/.loaf-target-manifest.json @@ -12,7 +12,7 @@ "kind": "hook-file", "source_path": "hooks/instructions/post-merge.md", "destination": "hooks/instructions/post-merge.md", - "sha256": "f728a0a9a004ea1ea76b70ca3292996c798baa2838633806e2fb4250118203b6", + "sha256": "4f712c30a821a1b5d971f9fd8bf17dfc8888634b6bb8f307339f9e5a47c05551", "mode": 420 }, { @@ -28,7 +28,7 @@ "kind": "hook-file", "source_path": "hooks/instructions/pre-pr-checklist.md", "destination": "hooks/instructions/pre-pr-checklist.md", - "sha256": "234b5e37846adf226ae501ac65a62139fed61c42dc62bbf9c78fc8b885debbad", + "sha256": "64a647e40d2d7f52224a60b978012265f80414c8eb2c893e61a97faa74375dd3", "mode": 420 }, { @@ -59,7 +59,7 @@ "id": "managed-instructions", "kind": "instruction", "destination": "project-instructions", - "sha256": "ac6debb93fcd1b2d7806681c446f3b7d9691a43a872831a969c82a7470b0b30d" + "sha256": "21e91a6226ead7de1ef1d3d61c4e2060dc9763e8485192f6efc0060a09bbe66e" } ] } diff --git a/dist/cursor/agents/background-runner.md b/dist/cursor/agents/background-runner.md index 95e52e7a4..adf04560c 100644 --- a/dist/cursor/agents/background-runner.md +++ b/dist/cursor/agents/background-runner.md @@ -34,7 +34,7 @@ The spawning agent provides: - Specific task to execute - Files or scope to analyze - Output location (`.agents/reports/YYYYMMDD-HHMMSS-<name>.md`) -- Task/spec reference when available +- Issue reference when available ## Execution Process @@ -44,7 +44,7 @@ Extract from prompt: - What to do (audit, analyze, review) - Scope (files, directories) - Output location -- Task ID or spec ID when provided +- Issue ID when provided ### 2. Execute Work @@ -66,7 +66,7 @@ report: status: unprocessed created: "2026-01-23T14:30:00Z" background_agent_id: "bg-YYYYMMDD-HHMMSS-description" - task_reference: "task or spec reference when provided" + issue_reference: "issue reference when provided" --- # Report Title diff --git a/dist/cursor/hooks/instructions/post-merge.md b/dist/cursor/hooks/instructions/post-merge.md index 35d3b90d3..9c8f183e1 100644 --- a/dist/cursor/hooks/instructions/post-merge.md +++ b/dist/cursor/hooks/instructions/post-merge.md @@ -1,51 +1,37 @@ **Note:** If you used the ship workflow, these steps were already handled by the skill. This checklist is for manual merges. -# Pre-Merge Checklist +# Post-Merge Housekeeping -Complete these steps on the feature branch before creating the PR. +Complete these steps after a successful squash merge. Leave the started worktree before removing it — do not run `loaf issue stop` from inside that worktree. -1. **Close out spec artifacts** (so they're included in the squash merge): +1. **Switch to the PR base and pull:** ``` - loaf task update TASK-XXX --status done - loaf task archive --spec SPEC-XXX - loaf spec archive SPEC-XXX + git checkout <baseRefName> + git pull --ff-only origin <baseRefName> ``` - Write an optional `wrap(scope)` journal entry with `loaf journal log` if the work produced synthesis worth saving. - -2. **Update CHANGELOG.md when the PR has release-facing impact:** - Add curated entries under `[Unreleased]` describing what the PR lands. Do not move entries to a versioned section here; the release workflow publishes the batch later. -3. **Rebuild all targets:** +2. **Mark the bound issue done** — this is what "done" means; `loaf issue stop` does not change status: ``` - npx loaf build + loaf issue status <ref> done ``` -4. **Commit and push** the changelog and generated artifacts to the PR branch. - -5. **Create PR** with `gh pr create` — title + summary + test plan. - -6. **Squash merge** with a clean commit body: - - Let GitHub default the title: `PR title (#N)` - - Write a concise 2-4 sentence summary as `--body` (use a HEREDOC) - - **Never** use the automatic squash description that dumps all individual commit messages - ---- - -# Post-Merge Housekeeping - -Complete these steps on main after merging. +3. **Stop the started worktree** if one exists (`loaf issue list --started`). `loaf issue stop` removes the worktree, clears `started_branch` / `started_worktree`, and keeps the branch: + ``` + loaf issue stop <ref> + ``` + If the issue was never started, the command errors with `issue <ref> is not started` — treat that as already clean and continue. Do not pass `--force` without user confirmation. -1. **Switch to main and pull:** +4. **Delete the local feature branch** when safe: ``` - git checkout main && git pull --rebase + git branch -d <headRefName> ``` -2. **Delete merged feature branch:** +5. **Log the landing:** ``` - git branch -d feat/xxx - git push origin --delete feat/xxx + loaf journal log "decision(ship): PR #N landed via squash merge; <ref> done" + loaf journal log "commit(<hash>): <squash subject>" ``` -3. **Suggest reflection** if the session had key decisions or learnings. +6. **Suggest reflection** if the work produced key decisions or learnings. -4. **Suggest release only when appropriate** — if this PR completes a coherent batch or release branch, publish from the base branch after the landed work is present there. +7. **Suggest release only when appropriate** — if this PR completes a coherent batch, publish later with `loaf release suggest` / `loaf release cut`. The PR is landed, not released, until that cut. diff --git a/dist/cursor/hooks/instructions/pre-pr-checklist.md b/dist/cursor/hooks/instructions/pre-pr-checklist.md index d92539828..75129d985 100644 --- a/dist/cursor/hooks/instructions/pre-pr-checklist.md +++ b/dist/cursor/hooks/instructions/pre-pr-checklist.md @@ -50,13 +50,10 @@ No scope prefixes. No SPEC/TASK IDs in the title. ### 3. PR body -```markdown -## Summary -- Key changes (2-4 bullets) +The body is `loaf issue render <ref>` output. No project headers, no hand-edited summary. Checkboxes stay unchecked until `loaf issue status <ref> done`. -## Test plan -- [ ] Tests added/updated -- [ ] Manual testing performed +``` +gh pr create --title "type: summary" --body "$(loaf issue render <ref>)" ``` ### 4. Merge strategy diff --git a/dist/cursor/skills/bootstrap/SKILL.md b/dist/cursor/skills/bootstrap/SKILL.md index 33ceb4e3f..e9ca2819a 100644 --- a/dist/cursor/skills/bootstrap/SKILL.md +++ b/dist/cursor/skills/bootstrap/SKILL.md @@ -30,7 +30,7 @@ First-contact project setup: detect state, interview the builder, populate proje - Guardrails - Related Skills -Series-prep lives under Finalization (phase between Knowledge Base Scaffolding and Next Steps). +Series-prep lives under Finalization (phase between Knowledge Base Scaffolding and Next Steps): the initial arc becomes backlog issues, not folders. **Input:** $ARGUMENTS @@ -43,8 +43,8 @@ Series-prep lives under Finalization (phase between Knowledge Base Scaffolding a - **Always interview** -- even with a rich brief, confirm understanding through structured questions — one at a time, with a recommendation, using your harness's structured question tool if it has one - **Pitched BRIEF is discovery-already-done** -- when `docs/BRIEF.md` has `source: pitch`, do not re-excavate the problem space; quote-back and gap-fill only for operating-document population - **BRIEF is input, not output** -- the BRIEF is raw intake. Extract every useful fact into VISION/STRATEGY/ARCHITECTURE/AGENTS during bootstrap. -- **BRIEF is archeological after bootstrap** -- once extraction completes (including series-prep reading scoped concepts from it), the BRIEF is a frozen historical snapshot. No skill, agent, command, or template should reference `docs/BRIEF.md` post-bootstrap. Operating documents and minted change briefs must stand on their own. -- **Series-prep never auto-shapes and never creates branches** -- every mint is user-confirmed; no priority, date, or dependency fields; concepts without a coarse `target_release` stay BRIEF lines, sparks, or Intents +- **BRIEF is archeological after bootstrap** -- once extraction completes (including series-prep reading scoped concepts from it), the BRIEF is a frozen historical snapshot. No skill, agent, command, or template should reference `docs/BRIEF.md` post-bootstrap. Operating documents and minted issue bodies must stand on their own. +- **Series-prep never auto-shapes and never creates branches** -- every mint is user-confirmed; no priority, date, or dependency fields; buckets are labels, never bindings; concepts that fail granularity stay BRIEF lines or sparks - **Suggest, don't execute** -- recommend next skills at the end, never auto-run them - **Log first** -- log invocation before interviewing: `loaf journal log "skill(bootstrap): <project or intake>"` - **Log outcome** -- log bootstrap completion to the project journal: `loaf journal log "decision(bootstrap): project bootstrapped, mode detected"` @@ -56,7 +56,7 @@ Series-prep lives under Finalization (phase between Knowledge Base Scaffolding a - All expected operating documents (`docs/VISION.md`, `AGENTS.md` at minimum) exist and contain populated content - Useful BRIEF content has been extracted into operating documents (no future reader should need to open the BRIEF) - When `source: pitch`, the interview was gap-only (no re-excavation of already-specific problem sections) -- When series-prep ran: each minted folder has `change.json` with stamped `target_release`, a standalone problem-space `brief.md`, zero-violation captured state via explicit-path `loaf change check <folder> --json`, and its own docs-only commit (never a batch); no branches created for the series; no auto-shape +- When series-prep ran: each minted row is a backlog issue (`loaf issue new "<title>" --body "<problem narrative>" --status backlog`) with a standalone problem-space body; an advisory bucket (`loaf issue bucket <ref> now|next|later`) may be set — buckets are labels, never bindings; `loaf issue check <ref>` only when a capture is shaped enough to check, otherwise nothing (a backlog issue with a problem body needs no ceremony); no folders, no docs-only commits; no branches created for the series; no auto-shape - Root `AGENTS.md` is a real file; on Claude Code, the compatibility symlink `.claude/CLAUDE.md -> ../AGENTS.md` exists (see Finalization) - Key decisions and interview outcomes were logged with `loaf journal log` and are readable with `loaf journal recent` @@ -237,7 +237,7 @@ Pitch owned the problem-space grill. Bootstrap does not re-excavate. The pitch 1. **Acknowledge the pitch** — name that `docs/BRIEF.md` carries `source: pitch` and that problem discovery is already done. 2. **Summarize what pitch captured** — short section-by-section gist (problem, who, alternatives, value, constraints, sequencing, open questions). The builder should hear continuity with the pitch closing ceremony, not a cold restart. -3. **State what bootstrap will do now** — interview only on gaps for operating-document population (VISION, STRATEGY, ARCHITECTURE, AGENTS), then series-prep the initial arc of captured changes. Do not re-grill the problem space. +3. **State what bootstrap will do now** — interview only on gaps for operating-document population (VISION, STRATEGY, ARCHITECTURE, AGENTS), then series-prep the initial arc as backlog issues. Do not re-grill the problem space. Then continue: @@ -424,58 +424,62 @@ The journal should capture: Use [templates/journal.md](templates/journal.md) only as the rendered entry format reference; do not hand-author journal markdown as the source of truth. -### 4. Series-Prep (initial arc as captured changes) +### 4. Series-Prep (initial arc as backlog issues) -After operating documents are populated (and Knowledge Base Scaffolding above has run), close bootstrap by minting the BRIEF's initial arc as **captured promise carriers** — brief-only change folders bound to a coarse `target_release`, each landed as its own docs-only commit. Series-prep is not roadmap planning: no milestone entities, no dates, no priorities, no dependency fields. Sequencing is prose in each brief; cohort membership is the shared `target_release`. +After operating documents are populated (and Knowledge Base Scaffolding above has run), close bootstrap by minting the BRIEF's initial arc as **backlog issues** — SQLite rows with a problem-space body and an optional advisory bucket label (`loaf issue bucket <ref> now|next|later`). Buckets are labels, never bindings. Series-prep is not roadmap planning: no milestone entities, no dates, no priorities, no dependency fields. Sequencing is prose in each issue body. No folders, no docs-only commits per capture — rows, not files. **When to run** - Always offer series-prep when a project BRIEF exists and names more than one scoped concept (typical after a pitched BRIEF; also after a rich non-pitch brief). -- If the BRIEF is a single atomic concept with no series, say so and skip to Next Steps — one future shape or a single capture later is enough. -- Series-prep **reads** the BRIEF during this phase only. After bootstrap ends, nothing references `docs/BRIEF.md` again; minted change briefs and operating docs stand alone. +- If the BRIEF is a single atomic concept with no series, say so and skip to Next Steps — one future shape or a single issue later is enough. +- Series-prep **reads** the BRIEF during this phase only. After bootstrap ends, nothing references `docs/BRIEF.md` again; minted issue bodies and operating docs stand alone. **Procedure** 1. **Enumerate concepts** with the builder from the BRIEF's scoped problem space (Sequencing and Relationships, Open Questions, and distinct problem threads in Problem Statement). List candidates as recommendation-first options using your harness's structured question tool if it has one. -2. **Apply granularity** per [references/interview-guide.md](references/interview-guide.md) (Series-Prep Granularity): a concept earns its own captured change when it is independently shippable **and** its problem can be **stated precisely now** (the mint-time specifiability test — not answered now, stated now) without the others; otherwise it stays a BRIEF line, becomes a spark, or an Intent — never a half-minted folder. +2. **Apply granularity** per [references/interview-guide.md](references/interview-guide.md) (Series-Prep Granularity): a concept earns its own backlog issue when it is independently shippable **and** its problem can be **stated precisely now** (the mint-time specifiability test — not answered now, stated now) without the others; otherwise it stays a BRIEF line or becomes a spark — never a half-minted row. 3. **Per confirmed concept (one at a time — never batch):** - 1. Confirm mint with the builder (slug, coarse `target_release`, one-line problem restatement). If the builder will not bind even a coarse target, do not mint — park as spark/Intent/BRIEF line. - 2. Propose a **local slug** that names the concept, never another work unit (`spec-042`, task ids, change folder names). Confirm the slug. - 3. Run capture init: + 1. Confirm mint with the builder (title, optional advisory bucket, one-line problem restatement). Buckets are labels, never bindings — a missing bucket does not block mint. If the concept fails granularity, do not mint — park as spark or BRIEF line. + 2. Propose a **working title** that names the concept, never another work unit (issue aliases, task ids). Confirm the title. + 3. **Seed a problem-space-only narrative** from the BRIEF's content for that concept (Problem Statement, Who Has It, Current Alternatives, Value Proposition, Constraints, Sequencing and Relationships as prose order relative to the arc, Sources if any, Open Questions). Do not copy solution design. The seeded body must stand alone as intent for later shape — cold-read without the project BRIEF or this session. + 4. Mint the backlog issue: ```bash - loaf change init <slug> --brief + loaf issue new "<title>" --body "<problem narrative>" --status backlog ``` - Creates `docs/changes/YYYYMMDD-<slug>/` with `change.json` + `brief.md` only. - 4. **Seed `brief.md` problem-space-only** from the BRIEF's content for that concept (Problem Statement, Who Has It, Current Alternatives, Value Proposition, Constraints, Sequencing and Relationships as prose order relative to the arc, Sources if any, Open Questions). Do not copy solution design. The seeded brief must stand alone as intent for later shape — cold-read without the project BRIEF or this session. - 5. **Stamp `target_release`** on that folder's `change.json` with the builder's coarse binding (canonical `MAJOR.MINOR.PATCH`, no `v`, no prerelease). Series-prep mints only targeted captures (promise-carrier path). - 6. **Pre-landing guard** (required before every commit): + Creates a SQLite row, not a folder. Use `--body -` or `--body-file <path>` when the narrative is long (see `loaf issue new --help`). + 5. **Optionally set an advisory bucket** (a label, never a binding): ```bash - loaf change check <folder> --json + loaf issue bucket <ref> now|next|later ``` + 6. **Validate** only when the capture is shaped enough to check: - Must report zero violations and captured state. Then **read `<folder>/change.json` directly** and confirm the stamped `target_release` matches what the builder bound. Bare `loaf change check` resolves by branch and can miss a capture elsewhere — always pass the explicit folder path. - 7. **Land as its own docs-only commit on the default branch** (one commit per capture, never a batch). Example subject: `docs(change): capture <slug> brief`. Bootstrap prepares the commit; never push; never open a PR. + ```bash + loaf issue check <ref> + ``` + + A backlog issue with a problem body is capture-only and needs no ceremony — skip the check. Do not add criteria or an out-of-scope statement during series-prep (that is shape). + 7. **Do not land a docs-only commit.** The row is the artifact. Never push; never open a PR; never create a branch. 4. **Guards (hard):** - Every mint is user-confirmed — never auto-mint the whole list - - Never auto-run shape and never create slug branches during series-prep - - No priority, date, estimate, or dependency fields on captures - - No batching multiple captures into one commit - - Concepts without a coarse target stay BRIEF lines, sparks, or Intents + - Never auto-run shape and never create branches during series-prep + - No priority, date, estimate, or dependency fields on issues + - No folders, no docs-only commits per capture — rows, not files + - Concepts that fail granularity stay BRIEF lines or sparks; buckets are labels, never bindings **After the series** -Log the arc: `loaf journal log "decision(bootstrap): series-prep minted <n> captures for <cohort or targets>"`. Hand off by naming the first capture folder for shape when the builder is ready. +Log the arc: `loaf journal log "decision(bootstrap): series-prep minted <n> backlog issues"`. Hand off by naming the first issue ref for shape when the builder is ready. ### 5. Next Steps Suggest relevant next steps based on what was learned: -- shape -- on a series-prep capture (or any ready concept) to promote the folder and bound implementation +- shape -- on a series-prep backlog issue (or any ready concept) to bound implementation - pitch -- if a new concept still needs problem discovery (not for re-grilling the BRIEF) -- idea -- if specific feature ideas emerged during the interview and should not become captures yet +- idea -- if specific feature ideas emerged during the interview and should not become issues yet - research -- if there are open questions that need investigation - `loaf doctor` -- to verify the setup is healthy @@ -502,18 +506,18 @@ When the interactive interview path is unavailable, bootstrap the operating docu 2. **Always interview** -- even with a rich brief, confirm understanding; when `source: pitch`, gap-fill only 3. **Never overwrite** -- existing documents require explicit confirmation 4. **Draft, then review** -- present documents section-by-section -5. **Extract, don't preserve** -- pull every useful fact from the BRIEF into operating docs (and series-prep seeds change briefs from it once). The BRIEF is archeological after bootstrap; nothing should reference it again. +5. **Extract, don't preserve** -- pull every useful fact from the BRIEF into operating docs (and series-prep seeds issue bodies from it once). The BRIEF is archeological after bootstrap; nothing should reference it again. 6. **Record the session** -- decisions and rationale are preserved 7. **Suggest, don't execute** -- recommend next skills, don't auto-run them; series-prep never auto-shapes or creates branches 8. **Interview structured** -- one question at a time, with a recommendation, using your harness's structured question tool if it has one -9. **Series-prep is not roadmap planning** -- coarse `target_release` + prose sequencing only; no dates, priorities, or dependency fields +9. **Series-prep is not roadmap planning** -- advisory bucket labels (never bindings) + prose sequencing only; no dates, priorities, or dependency fields --- ## Related Skills -- **pitch** -- Authors a project-scale `docs/BRIEF.md` with `source: pitch` (or a change-scale brief); bootstrap consumes the pitched BRIEF with gap-only interview and series-prep -- **shape** -- Bound a captured change into a contract (promotes brief-only folders; often follows series-prep) +- **pitch** -- Authors a project-scale `docs/BRIEF.md` with `source: pitch` (or an issue-scale problem narrative); bootstrap consumes the pitched BRIEF with gap-only interview and series-prep +- **shape** -- Bound a backlog issue into a contract (often follows series-prep) - **explore** -- Agent technique when a concept that emerges during bootstrap is still undecided (not a user front door; prefer pitch for human problem discovery) - **research** -- Investigate topics and open questions - **idea** -- Quick-capture feature ideas that emerge during bootstrap diff --git a/dist/cursor/skills/bootstrap/references/interview-guide.md b/dist/cursor/skills/bootstrap/references/interview-guide.md index f3fc0c1ef..83f583396 100644 --- a/dist/cursor/skills/bootstrap/references/interview-guide.md +++ b/dist/cursor/skills/bootstrap/references/interview-guide.md @@ -358,33 +358,32 @@ Expect 6-10 questions total, mostly in Excavation and Sharpening. Grounding is l ## Series-Prep Granularity -Series-prep (bootstrap Finalization) turns the project BRIEF's scoped concepts into captured change folders. This section resolves when a concept earns a folder versus staying a BRIEF line, spark, or Intent. Full procedure: bootstrap SKILL.md Finalization → Series-Prep. +Series-prep (bootstrap Finalization) turns the project BRIEF's scoped concepts into backlog issues. This section resolves when a concept earns a row versus staying a BRIEF line or spark. Full procedure: bootstrap SKILL.md Finalization → Series-Prep. -### Earns its own captured change when both are true +### Earns its own backlog issue when both are true -1. **Independently shippable** — the concept could land as a meaningful release unit without waiting on the other scoped concepts to ship in the same commit or PR. Sharing a coarse `target_release` cohort is fine; hard entanglement is not. -2. **Specifiability (mint test)** — a concept mints only if its problem can be **stated precisely now** (not answered now): a cold reader can name the friction, who has it, the current alternative, and the value for *this* concept alone, without needing sibling folders or inventing what was meant. Everything vaguer stays a BRIEF line, spark, or Intent — never pre-sliced into a fake capture. +1. **Independently shippable** — the concept could land as a meaningful release unit without waiting on the other scoped concepts to ship in the same commit or PR. Sharing an advisory bucket is fine (buckets are labels, never bindings); hard entanglement is not. +2. **Specifiability (mint test)** — a concept mints only if its problem can be **stated precisely now** (not answered now): a cold reader can name the friction, who has it, the current alternative, and the value for *this* concept alone, without needing sibling issues or inventing what was meant. Everything vaguer stays a BRIEF line or spark — never pre-sliced into a fake row. -When both hold and the builder will bind a coarse `target_release`, mint: `loaf change init <slug> --brief`, seed problem-space-only, stamp the target, pre-landing check + `change.json` read-back, one docs-only commit on the default branch. +When both hold, mint: `loaf issue new "<title>" --body "<problem narrative>" --status backlog`, seed problem-space-only, optionally `loaf issue bucket <ref> now|next|later` (buckets are labels, never bindings). Run `loaf issue check <ref>` only when the capture is shaped enough to check; a backlog issue with a problem body needs no ceremony. No folders, no docs-only commits — rows, not files. -### Stays a BRIEF line, spark, or Intent when any is true +### Stays a BRIEF line or spark when any is true - The concept only makes sense as a clause of a larger problem (it cannot be stated alone). - Its problem cannot yet be stated precisely — coarse intent only; minting would invent precision. - It is a solution-space detail, implementation task, or "nice-to-have" flavor — not a shippable problem boundary. -- The builder will not bind even a coarse `target_release` (untargeted series members are not promise carriers on main; keep them as sparks/Intents or leave them as prose in the BRIEF until a target exists). -- Splitting would create two folders that always ship as one unit — keep one folder and name the sub-concerns in Sequencing prose. +- Splitting would create two issues that always ship as one unit — keep one issue and name the sub-concerns in Sequencing prose. ### Interview moves during series-prep -- Recommend a decomposition first (list candidate folders + what stays in the BRIEF), then confirm each mint one at a time. +- Recommend a decomposition first (list candidate issues + what stays in the BRIEF), then confirm each mint one at a time. - Challenge over-splitting: "Can this problem be stated precisely now without that one?" If no, merge candidates or leave as BRIEF lines. -- Challenge under-splitting: "Is this independently shippable, or are we smuggling two products into one capture?" -- Never invent priority ranks, dates, or machine dependency fields — sequencing is prose; cohort is `target_release`. +- Challenge under-splitting: "Is this independently shippable, or are we smuggling two products into one issue?" +- Never invent priority ranks, dates, or machine dependency fields — sequencing is prose; buckets are labels, never bindings. ### Standalone cold-read test (H3) -Each minted brief must name its problem without reference to the bootstrap session or to `docs/BRIEF.md`. If a draft says "as discussed" or "per the project brief," rewrite until the problem stands alone. +Each minted issue body must name its problem without reference to the bootstrap session or to `docs/BRIEF.md`. If a draft says "as discussed" or "per the project brief," rewrite until the problem stands alone. --- diff --git a/dist/cursor/skills/bootstrap/templates/brief.md b/dist/cursor/skills/bootstrap/templates/brief.md index d7b58d0db..5b550a307 100644 --- a/dist/cursor/skills/bootstrap/templates/brief.md +++ b/dist/cursor/skills/bootstrap/templates/brief.md @@ -39,7 +39,7 @@ archived: true # Always true -- BRIEF is a historical snapshot, not a w ## Sequencing and Relationships -[How the initial arc hangs together — which concepts belong as early changes, what depends on what, release cohort stated as prose. No machine relation fields; narrative order only.] +[How the initial arc hangs together — which problems become early backlog issues, what depends on what, sequencing stated as prose. No machine relation fields; narrative order only.] ## Sources and Research Links diff --git a/dist/cursor/skills/bootstrap/templates/journal.md b/dist/cursor/skills/bootstrap/templates/journal.md index caf7409fd..9a0a61f8f 100644 --- a/dist/cursor/skills/bootstrap/templates/journal.md +++ b/dist/cursor/skills/bootstrap/templates/journal.md @@ -13,7 +13,7 @@ not create or edit journal markdown as the source of truth — use ## Entries -[YYYY-MM-DD HH:MM] skill(implement): implementing TASK-042 +[YYYY-MM-DD HH:MM] skill(implement): implementing LOAF-42 [YYYY-MM-DD HH:MM] decision(scope): description of decision [YYYY-MM-DD HH:MM] discover(scope): something learned [YYYY-MM-DD HH:MM] block(scope): what is blocked diff --git a/dist/cursor/skills/breakdown/SKILL.md b/dist/cursor/skills/breakdown/SKILL.md deleted file mode 100644 index e3a1260da..000000000 --- a/dist/cursor/skills/breakdown/SKILL.md +++ /dev/null @@ -1,330 +0,0 @@ ---- -name: breakdown -description: >- - Decomposes specifications into atomic tasks with dependencies and priorities. - Use when the user asks "break this down" or "create tasks for this spec." - Produces task files with estimates, dependencies, and acceptance criteria. Not - for shaping ideas (use shape) or implementation work (use implement). -version: 0.2.21 ---- - -# Breakdown - -Decompose specifications into atomic, implementable tasks. - -## Contents -- Critical Rules -- Verification -- Quick Reference -- Task Breakdown Philosophy -- Task Backend Detection -- Process -- Linear-Native Mode -- Local-Tasks Mode -- Priority Levels -- Guardrails -- Related Skills - -**Input:** $ARGUMENTS - ---- - -## Critical Rules - -- **One concern per task** -- never mix unrelated layers (backend + frontend) in a single task -- **Every task includes its own verification** -- no separate "verify" tasks; each task must have an observable done condition -- **Own the decisions** -- decide granularity and priorities autonomously; only ask the user when two equally valid orderings have genuinely different trade-offs -- **Keep tests with the code they test** -- never split implementation and tests into separate tasks -- **Update spec status** -- mark the spec as `implementing` after tasks are created -- **One backend only** -- in Linear-native mode create Linear issues and NO local `TASK-NNN.md`; in local mode create local tasks and make NO Linear calls -- **Spec file is always local** -- in both modes, the spec stays in `.agents/specs/`. The Linear parent issue, when present, is a rollup pointing to the spec, not a re-host of it -- **Log outcome** -- log breakdown to the project journal: `loaf journal log "decision(breakdown): SPEC-NNN → N tasks created"` - ---- - -## Verification - -- Each created task has a clear title, priority, file hints, verification command, and observable done condition -- The dependency graph has no cycles and reflects actual implementation order -- Spec status has been updated to `implementing` -- **Linear-native mode only:** parent issue exists, labeled `spec`, with description pointing to the local spec file; N sub-issues have `parentId` set; zero local task rows or `TASK-NNN.md` files were created; spec frontmatter has `linear_parent` and `linear_parent_url` populated -- **Local-tasks mode only:** N local tasks exist in `loaf task list` with compatibility `.md` files when configured; no Linear calls were made - ---- - -## Quick Reference - -### Priority Levels - -| Priority | Loaf | Linear Priority | -|----------|------|-----------------| -| P0 | Urgent/blocking -- drop everything | Urgent (1) | -| P1 | High -- work next | High (2) | -| P2 | Normal -- scheduled work (default) | Normal (3) | -| P3 | Low -- when time permits | Low (4) | - -### Right-Sizing Rules - -| Rule | Guideline | -|------|-----------| -| **One agent type** | Completable by a single implementer (after skills narrowing) | -| **One concern** | Touches one layer, service, or component | -| **Context-appropriate** | Fits in model context with room for exploration | -| **Not over-fragmented** | Don't split what naturally belongs together | - -### Mode Selection - -| `integrations.linear.enabled` in `.agents/loaf.json` | Mode | See | -|------------------------------------------------------|------|-----| -| `true` | Linear-native | [Linear-Native Mode](#linear-native-mode) | -| `false` or absent | Local-tasks | [Local-Tasks Mode](#local-tasks-mode) | - ---- - -## Task Breakdown Philosophy - -**Primary principle: separation of concerns.** - -### The Right Size Test - -1. Can a single implementer complete this? If no, split by concern -2. Does it touch multiple unrelated concerns? If yes, split by concern -3. Will the agent need too much context? If yes, split into smaller coherent units -4. Am I splitting just to have more tasks? If yes, merge back - -### Anti-Patterns - -| Don't | Do Instead | -|-------|------------| -| Split backend + tests into separate tasks | Keep tests with the code they test | -| Create a task per file | Group files by concern | -| Separate "implement" and "verify" tasks | Every task includes its own verification | -| Copy the full spec text into the Linear parent issue | Summarize + link to the local spec file | -| Create both local `TASK-NNN.md` and Linear sub-issues | Pick one backend; never mix | - ---- - -## Task Backend Detection - -Read `.agents/loaf.json`: - -```json -{ - "integrations": { - "linear": { "enabled": true } - } -} -``` - -If `integrations.linear.enabled` is `true`, proceed in **Linear-native mode**. -Otherwise, proceed in **Local-tasks mode**. - -If `.agents/loaf.json` is missing, default to local-tasks and note the -assumption in the project journal. - ---- - -## Process - -### Step 1: Parse Input - -`$ARGUMENTS` should reference a spec (e.g., "SPEC-001"). If unclear, list available specs. - -### Step 2: Read the Spec - -Extract: test conditions, scope, implementation notes, priority ordering, complexity size. - -### Step 3: Identify Task Boundaries - -Break down by concern (data layer, backend, frontend, infrastructure, etc.). One concern per task. Explicit dependencies for sequential tasks. - -### Step 4: Decide Priorities and Granularity - -Own the granularity and priority decisions. Apply the Right Size Test, assign priorities -based on dependencies, priority order, and go/no-go gates, and do a self-review pass. Do not -defer these decisions to the user — they trust agent judgment here. - -If genuinely uncertain (e.g., two equally valid orderings with different trade-offs), -ask. Otherwise, decide and move on. - -### Step 5: Draft Task List - -Draft tasks following [task template](templates/task.md). Each task needs: clear title, priority, file hints, verification command, observable done condition, labels (if routing by team). - -### Step 6: Present the Plan - -Show the dependency graph and task summary for awareness before creating anything. -Present it as "here's what I'm creating" not "which option do you prefer?" The user -can still adjust after creation, but the default is to proceed. - -### Step 7: Create Tasks (mode-specific) - -Detect the mode (see [Task Backend Detection](#task-backend-detection)) and follow the -matching section below. Do NOT mix modes. - -- Linear enabled → [Linear-Native Mode](#linear-native-mode) -- Linear disabled or missing → [Local-Tasks Mode](#local-tasks-mode) - -### Step 8: Update Spec and Announce - -Set spec status to `implementing`. In Linear-native mode, also write -`linear_parent` and `linear_parent_url` into the spec's frontmatter. Announce -created tasks and next steps. - ---- - -## Linear-Native Mode - -Spec files stay local and canonical in `.agents/specs/`. Tasks live in Linear -as sub-issues of a parent rollup issue representing the spec. No local -task rows or `TASK-NNN.md` files are created. - -### 7a. Ensure the `spec` label exists - -The `spec` label groups all spec-parent rollup issues so Linear users can -filter for them. - -1. Call `list_issue_labels` to check whether a label named `spec` exists. -2. If missing, create it via `create_issue_label`: - - `name`: `spec` - - `color`: `#5e6ad2` (Linear-ish indigo; implementer may adjust) - - `description`: `Parent rollup issue representing a design spec tracked in the repo at .agents/specs/` - - Prefer workspace-scoped so all teams can filter uniformly. If the MCP - only supports team-scoped labels, create on the default team. -3. Log whether the label was created this run or already existed. This - matters for first-time Loaf setup on a Linear workspace. - -### 7b. Resolve team, project, and state - -Read from `.agents/loaf.json`: - -- **Team:** `linear.default_team` (name) — resolve to team ID via - `list_teams` if not already cached in `known_teams`. -- **Project:** `linear.project.id`. -- **State:** call `list_issue_statuses` for the team, pick the - `unstarted`-type state (typically "Backlog" or "To-Do"). States are - **team-scoped**, not workspace-scoped — always pass the team. - -### 7c. Create the parent issue - -Use `create_issue` with: - -| Field | Value | -|-------|-------| -| `title` | `[SPEC-NNN] <spec title>` | -| `teamId` | from 7b | -| `projectId` | from 7b | -| `stateId` | unstarted state from 7b | -| `priority` | mapped from spec (default High = 2 if unspecified) | -| `labels` | `["spec"]` | -| `description` | Summary synthesized from the spec's Problem Statement + Solution Direction (1–3 paragraphs), ending with: `See .agents/specs/SPEC-NNN-<slug>.md for full text, council references, and strategic tensions.` | - -**Do NOT** copy the full spec body into the description. The local file is canonical. - -### 7d. Check label-group conflicts (pre-flight per sub-issue) - -Linear labels can belong to exclusive groups (e.g., a `type` group where -`feature`, `testing`, `docs`, `bug`, `refactor` are mutually exclusive). -Before creating each sub-issue: - -1. Inspect proposed labels against known group membership (from - `list_issue_labels` group metadata). -2. If a task has more than one label from the same exclusive group, pick the - most appropriate and drop the others. Warn the user about the drop. -3. Log the resolution so the user can override if desired. - -### 7e. Create sub-issues - -For each task, use `create_issue` with: - -| Field | Value | -|-------|-------| -| `parentId` | parent issue ID from 7c | -| `title` | task title | -| `description` | task description + acceptance criteria | -| `teamId` | routed from `team_keywords` or falling back to `default_team` | -| `projectId` | same as parent unless task explicitly belongs elsewhere | -| `stateId` | unstarted state for the target team | -| `priority` | mapped from task priority (see Priority Levels table) | -| `labels` | task labels after conflict resolution (7d) | - -Express dependencies from the spec's Priority Order / dependency graph via -`blockedBy` referencing sibling sub-issue IDs. Create in dependency order so -predecessors exist when referenced. - -### 7f. Do NOT create local task files - -Skip `loaf task create` entirely. Linear issue IDs are the task record. No -local task rows or `TASK-NNN.md` files for this spec's tasks. - -### 7g. Update spec frontmatter - -Add to the spec file's YAML frontmatter: - -```yaml -linear_parent: ENG-198 -linear_parent_url: https://linear.app/<workspace>/issue/ENG-198 -``` - -Use the actual parent issue identifier and URL returned from 7c. - ---- - -## Local-Tasks Mode - -Spec files and task files both live locally. No Linear calls. - -Use `loaf task create --spec SPEC-XXX --title "Task title" --priority P1` -for each task. In SQLite-backed projects, the CLI creates the operational state -row and any compatibility Markdown/index artifacts needed by the current -project. Then edit the `.md` body content (description, acceptance criteria) -only when an authored task prose artifact exists. - -Dependencies are expressed through CLI flags such as `--depends-on`, not by -hand-editing the compatibility index. Priority Order from the spec maps directly -to task `priority` fields. - -See [local-tasks reference](../orchestration/references/local-tasks.md) for -the full local-task model. - ---- - -## Priority Mapping (reference) - -| Loaf | Linear API value | Linear label | -|------|------------------|--------------| -| P0 | `1` | Urgent | -| P1 | `2` | High | -| P2 | `3` | Normal | -| P3 | `4` | Low | - ---- - -## Guardrails - -1. **One concern per task** -- don't mix backend + frontend -2. **Clear verification** -- how to prove it works -3. **Observable done condition** -- not subjective -4. **File hints** -- help session know where to look -5. **Own the decisions** -- decide granularity and priorities, don't defer -6. **Update spec status** -- mark as implementing -7. **One backend only** -- Linear-native creates Linear issues and no local tasks; local-tasks mode creates local tasks and no Linear calls -8. **Summary not copy** -- the Linear parent description summarizes + links; it does not re-host the spec - ---- - -## Suggests Next - -After breakdown completes, suggest implement to start working on the tasks. - -## Related Skills - -- **shape** -- Create specs that get broken down -- **implement** -- Start session for a task or coordinate multiple tasks - -## Topics - -| Topic | Reference | Use When | -|-------|-----------|----------| -| Linear Integration | `orchestration/references/linear.md` | Working out Linear issue structure, labels, parent/child | -| Local Task Model | `orchestration/references/local-tasks.md` | Local-tasks mode details and CLI flags | diff --git a/dist/cursor/skills/breakdown/templates/task.md b/dist/cursor/skills/breakdown/templates/task.md deleted file mode 100644 index fe74a794f..000000000 --- a/dist/cursor/skills/breakdown/templates/task.md +++ /dev/null @@ -1,28 +0,0 @@ -# Task Template - -**Create with:** `loaf task create --title "..." --spec SPEC-XXX` - -```yaml ---- -id: TASK-XXX -title: [Clear action] -spec: SPEC-001 -status: todo -priority: P2 -files: - - [likely file 1] - - [likely file 2] -verify: [command to verify] -done: [observable outcome] ---- - -## Description -[What needs to be done] - -## Acceptance Criteria -- [ ] [Criterion 1] -- [ ] [Criterion 2] - -## Context -See SPEC-001 for full context. -``` diff --git a/dist/cursor/skills/council/SKILL.md b/dist/cursor/skills/council/SKILL.md index 14d370f53..fa2cc2b4d 100644 --- a/dist/cursor/skills/council/SKILL.md +++ b/dist/cursor/skills/council/SKILL.md @@ -77,13 +77,13 @@ Councils stay **local**. Even when the workspace uses Linear-native mode, council files live in `.agents/councils/` — they are deliberation artifacts, not executable work, and belong with specs in git. -When a council resolves a spec's open questions: +When a council resolves an issue's open questions: -- Include the spec ID in council frontmatter (e.g., `spec: SPEC-024`). This +- Include the issue ID in council frontmatter (e.g., `issue: LOAF-42`). This is already the common pattern. -- If the spec's `linear_parent` has been populated by breakdown, also - include `linear_parent: ENG-198` in council frontmatter so a reader on - Linear can trace back to the deliberation. +- If the issue is tracked in Linear (tracker authority), also include the + tracker key (e.g., `linear_parent: ENG-198`) in council frontmatter so a + reader on Linear can trace back to the deliberation. - Do not post council content to the Linear parent issue. A brief one-line reference ("Resolved via council 2026-04-21 — see .agents/councils/…") in a sub-issue comment is sufficient if the council drove a specific task diff --git a/dist/cursor/skills/documentation-standards/SKILL.md b/dist/cursor/skills/documentation-standards/SKILL.md index c4aed9a36..da018c76f 100644 --- a/dist/cursor/skills/documentation-standards/SKILL.md +++ b/dist/cursor/skills/documentation-standards/SKILL.md @@ -49,7 +49,7 @@ Standards for ADRs, API docs, changelogs, and diagrams. - Internal spec/task IDs - Verbatim commit or PR-title dumps - **Good examples:** - - "Add `loaf release --post-merge` guardrails for tagged GitHub releases" + - "Add `loaf release suggest` and `loaf release cut` for retroactive releases" - "Fix journal context routing when hook payloads are empty" - "Document worktree-aware `.agents/` storage for linked checkouts" - **Version protection:** diff --git a/dist/cursor/skills/explore/SKILL.md b/dist/cursor/skills/explore/SKILL.md index 829912f8c..4586c741f 100644 --- a/dist/cursor/skills/explore/SKILL.md +++ b/dist/cursor/skills/explore/SKILL.md @@ -2,16 +2,16 @@ name: explore description: >- Conducts divergent inquiry as a durable Exploration with portable checkpoints, - conversation provenance, and Intent capture that survive compaction and - harness changes. Agent technique — not a user entry point: route "explore - this" and similar user asks to pitch; use this technique from inside pitch or - other agent work when the direction is genuinely undecided, or when resuming a - named Exploration. Produces Exploration records, portable checkpoints, and - tracked or deferred Intents; Exploration machinery and the four-field - checkpoint contract stay intact. Not for evidence gathering on a known - question (use research), continuing implementation (use implement), processing - the intake queue (use triage), shaping a bounded Change (use shape), problem - discovery (use pitch), or quick capture (use idea). + conversation provenance, and backlog-issue dispositions that survive + compaction and harness changes. Agent technique — not a user entry point: + route "explore this" and similar user asks to pitch; use this technique from + inside pitch or other agent work when the direction is genuinely undecided, or + when resuming a named Exploration. Produces Exploration records, portable + checkpoints, and backlog issues for crystallized directions; Exploration + machinery and the four-field checkpoint contract stay intact. Not for evidence + gathering on a known question (use research), continuing implementation (use + implement), processing the intake queue (use triage), shaping a bounded issue + (use shape), problem discovery (use pitch), or quick capture (use idea). version: 0.2.21 --- @@ -30,6 +30,7 @@ Divergent inquiry with durable continuity. An Exploration is a relational identi - Process - Checkpoint Discipline - Resumption +- Parking a direction - Techniques - Related Skills @@ -39,37 +40,39 @@ Divergent inquiry with durable continuity. An Exploration is a relational identi - You choose what an Exploration means and when to checkpoint; the CLI validates and performs the operation you request. Never expect the CLI to classify or decide for you. - Checkpoint before the context window gets hostile: every checkpoint must carry all four portable fields — purpose, conclusions, unresolved, next action — each self-sufficient without this conversation. - A conversation handle or log path is provenance, never context. Presence of handles does not make an Exploration resumable; only a portable checkpoint does. -- Capture crystallized directions as Intent (`loaf intent create`), deferred bodies with `--disposition deferred`; never leave a substantial direction only in prose. -- Never create Git artifacts, branches, worktrees, or Changes from Explore; when a direction is ready for problem discovery hand it to pitch, and when it is ready for bounded delivery hand it to shape. +- Capture crystallized directions as backlog issues (`loaf issue new "<title>" --status backlog`); park remaining unsharp questions on that issue with `--fog`. Never leave a substantial direction only in prose. +- Never create Git artifacts, branches, or worktrees from Explore; when a direction is ready for problem discovery hand it to pitch, and when it is ready for bounded delivery hand it to shape (issue preparation). - Never store transcripts, prompts, or tool output in checkpoints or items; curate semantic context instead. -- Not a user slash front door — human "explore this" / "where do I start" intent routes to pitch. +- Not a user slash front door — human "explore this" / "where do I start" routes to pitch. ## Verification - The Exploration exists with `portable_context_present: true` after the first checkpoint (`loaf exploration list`). - `loaf exploration context <ref> --json` returns the four-field core whole, and a fresh reader could identify the next action from it alone. -- Crystallized directions exist as Intents with derived dispositions (`loaf intent list`). +- Crystallized directions exist as backlog issues (`loaf issue list --status backlog`); issue aliases named in the checkpoint match those rows. - Conversation provenance, when recorded, carries harness and locality facts without any transcript content. ## Quick Reference | Operation | Command | |-----------|---------| -| Start an inquiry | `loaf exploration create --title <title> [--from <intent-or-source>]...` | +| Start an inquiry | `loaf exploration create --title <title> [--from <source>]...` | | Checkpoint | `loaf exploration checkpoint <ref> --purpose <p> --conclusions <c> --unresolved <u> --next <n> [--item candidate:<text>]... [--operation-id <key>]` | | Resume elsewhere | `loaf exploration context <ref> --json` | -| Track a direction | `loaf intent create --title <t> --body <b> --from <source>...` | -| Defer a direction | `loaf intent create --title <t> --body <b> --disposition deferred --why <w> --boundary <bd> --trigger <tr> --operation-id <key> [--from <source>]` | +| File a direction | `loaf issue new "<title>" --status backlog [--parent <ref>] [--kind delivery\|decision] [--fog <text>] [--body <text>]` | +| Optional bucket | `loaf issue bucket <ref> now\|next\|later\|none` | | Record provenance | `loaf conversation create --title <label>` then `loaf conversation handle add <id> --harness <h> --handle <opaque-id> [--locality <scope>] [--log-ref <path>]` | | Associate conversation | `loaf exploration conversation add <exploration> <conversation-id>` | +`--from` on create accepts journal entries, handoffs, reports, and findings. It does not accept issue, spark, or idea refs — name those in the checkpoint and in the issue body instead. Buckets are labels only and are never read as a constraint. `fog` is writeable only at create. + ## Process 1. **Orient.** If the input names an existing Exploration, run `loaf exploration context <ref>` and continue from its recommended next action. Otherwise check `loaf exploration list` before creating a duplicate inquiry. -2. **Create or resume.** New inquiries get `loaf exploration create` with `--from` links to the Intents, journal entries, reports, or findings that motivated them. +2. **Create or resume.** New inquiries get `loaf exploration create` with `--from` links to the journal entries, reports, findings, or handoffs that motivated them. 3. **Diverge.** Expand the option space before judging it. Use the brainstorm stance (below), research, scouting, prototypes, or spikes as the question demands. -4. **Capture as you go.** Incidental thoughts become sparks; explicit propositions become ideas; deliberately tracked directions become Intents with their sources linked. -5. **Checkpoint.** At every meaningful plateau — and always before ending a session — append a checkpoint with the four portable fields and optional `candidate:`/`evidence:` items. +4. **Capture as you go.** Incidental thoughts become sparks (`loaf spark capture --scope <scope> --text <text>`); explicit propositions become ideas (`loaf idea capture --title "..."`); directions worth keeping become backlog issues. Resolve the capture against the issue so the direction appears once: `loaf spark resolve <ref> --by <issue-ref>` or `loaf idea resolve <ref> --by <issue-ref>`. +5. **Checkpoint.** At every meaningful plateau — and always before ending a session — append a checkpoint with the four portable fields and optional `candidate:`/`evidence:` items. Name any filed issue aliases in conclusions or next. 6. **Record provenance when useful.** Machine-local conversation handles and log locators help forensic navigation later; add them explicitly, and never infer identity from the current session. ## Checkpoint Discipline @@ -81,17 +84,17 @@ The four fields are the portable contract; each is capped at 4096 UTF-8 bytes an - **unresolved** — the open question or decision the inquiry currently turns on. - **next** — the recommended next action, concrete enough for a fresh agent to execute without this conversation. -Larger detail belongs in ordered `--item candidate:` and `--item evidence:` entries or in related reports, not crammed into the core fields. +Larger detail belongs in ordered `--item candidate:` and `--item evidence:` entries or in related reports, not crammed into the core fields. When filing an issue, copy still-unsharp questions into `--fog`; they will not be editable on the issue after create. ## Resumption -A new conversation, harness, or machine resumes with `loaf exploration context <ref> --json`: the portable core returns whole, and each optional layer (items, intents, evidence, conversations) reports counts, truncation, and its exact expansion command. Source handles appear with their last observed availability; treat unavailable ones as lost without ceremony — the checkpoint is the context. If `portable_context_present` is false, the Exploration was never checkpointed: rebuild understanding from linked sources, then write the missing checkpoint first. +A new conversation, harness, or machine resumes with `loaf exploration context <ref> --json`: the portable core returns whole, and each optional layer reports counts, truncation, and its exact expansion command. Source handles appear with their last observed availability; treat unavailable ones as lost without ceremony — the checkpoint is the context. If `portable_context_present` is false, the Exploration was never checkpointed: rebuild understanding from linked sources, then write the missing checkpoint first. -Before continuing, inspect the linked Intents in the context. If an Intent this inquiry was developing has since been resolved, do not silently reopen it: acknowledge the resolution, and if the checkpoint's next action still matters, create a successor Intent, record why in its body, and relate the lineage with `loaf link create --from <new-intent-ref> --to <resolved-intent-ref> --type derived-from`. Continued evidence gathering that serves no unresolved Intent should say so in its next checkpoint. +Before continuing, inspect issue aliases named in the checkpoint. If an issue this inquiry was developing is now done, cancelled, or duplicate, do not silently reopen it: acknowledge the resolution, and if the checkpoint's next action still matters, file a successor backlog issue and record why in its body. Continued evidence gathering that serves no open issue should say so in its next checkpoint. -## Deferring +## Parking a direction -An Exploration is never deferred, paused, or closed — it has no lifecycle to transition. "Defer this exploration" means two concrete acts: checkpoint the current state honestly, then defer the direction it was developing as an Intent — `loaf intent defer` on the linked Intent, or `loaf intent create --disposition deferred` for a new one followed by `loaf link create --from <exploration-ref> --to <intent-ref> --type explores`. The deferred Intent carries the revisit trigger; the Exploration simply waits, resumable from its checkpoint whenever the Intent is resumed. +An Exploration is never paused or closed — it has no lifecycle to transition. When the user wants to park or set aside the inquiry, do two concrete acts: checkpoint the current state honestly, then file the direction it was developing as a backlog issue (`loaf issue new "<title>" --status backlog`, optional `--parent`, `--fog` for remaining unsharp questions, optional `loaf issue bucket <ref> later`). The issue is the revisit surface; the Exploration simply waits, resumable from its checkpoint. ## Techniques @@ -99,8 +102,8 @@ Brainstorm's full divergent stance lives inside Explore: generate options before ## Related Skills -- **pitch** — human problem-discovery front door; route user entry intent here; reach for explore from inside pitch when still undecided -- **triage** — processes the intake queue and may disposition items toward pitch, shape, or agent-side explore -- **shape** — narrows one well-understood direction into a bounded Change +- **pitch** — human problem-discovery front door; route user entry here; reach for explore from inside pitch when still undecided +- **triage** — processes the intake queue and may disposition items toward a backlog issue, pitch, shape, or agent-side explore +- **shape** — prepares a well-understood direction as a bounded issue - **research** — evidence gathering for a known question, usable inside an Exploration - **idea** — quick capture without inquiry diff --git a/dist/cursor/skills/foundations/references/code-review.md b/dist/cursor/skills/foundations/references/code-review.md index 2c37d2f0b..37c8a2e10 100644 --- a/dist/cursor/skills/foundations/references/code-review.md +++ b/dist/cursor/skills/foundations/references/code-review.md @@ -86,5 +86,5 @@ Project code review conventions and workflow. | Command | Code Review Role | |---------|-----------------| | implement | Self-review before marking complete | -| breakdown | Review task scope and approach | +| shape | Review issue scope and DoD before implementation | | reflect | Note review feedback patterns | diff --git a/dist/cursor/skills/foundations/references/tdd.md b/dist/cursor/skills/foundations/references/tdd.md index 28c883709..1ea6fe7c9 100644 --- a/dist/cursor/skills/foundations/references/tdd.md +++ b/dist/cursor/skills/foundations/references/tdd.md @@ -54,6 +54,6 @@ If the failing test points to a non-obvious root cause, or if your first fix att | Phase | TDD Role | |-------|----------| | shape | Test conditions become TDD test cases | -| breakdown | Each task should have clear test targets | +| shape | Each promoted issue should have clear test targets in its DoD | | implement | Follow TDD cycle for each task | | reflect | Note TDD friction points for improvement | diff --git a/dist/cursor/skills/foundations/references/verification.md b/dist/cursor/skills/foundations/references/verification.md index d3cf84e1f..142aa7dfc 100644 --- a/dist/cursor/skills/foundations/references/verification.md +++ b/dist/cursor/skills/foundations/references/verification.md @@ -123,7 +123,7 @@ npm run lint # Check: No errors or warnings | Command | Verification Point | |---------|-------------------| | implement | Before marking session complete | -| breakdown | Each task has verification criteria | +| shape | Each issue has verification criteria (V-tier DoD) | | shape | Test conditions define verification | | reflect | Note verification gaps discovered | diff --git a/dist/cursor/skills/git-workflow/SKILL.md b/dist/cursor/skills/git-workflow/SKILL.md index 798f55dfc..80a2ce2e1 100644 --- a/dist/cursor/skills/git-workflow/SKILL.md +++ b/dist/cursor/skills/git-workflow/SKILL.md @@ -24,7 +24,7 @@ Git conventions for branching, commits, PRs, and merge workflow. - Use Conventional Commits format for all commit messages - Commit complete units of work -- don't commit partial or in-progress changes - Squash merge feature branches -- never merge commits directly -- One branch per spec/feature; branch name format: `feat/{slug}` +- One branch per issue; `loaf issue start` creates `issue/<alias-or-id>` (or use `feat/{slug}` / `fix/{slug}` when not starting from an issue) - Never force-push to `main` or shared branches - Never push without explicit user confirmation @@ -38,7 +38,7 @@ Git conventions for branching, commits, PRs, and merge workflow. | Action | Command/Pattern | |--------|----------------| -| Branch naming | `feat/{slug}`, `fix/{slug}`, `chore/{slug}` | +| Branch naming | `issue/<alias-or-id>` from `loaf issue start`; else `feat/{slug}`, `fix/{slug}`, `chore/{slug}` | | Commit format | `type: description` | | Squash merge | `gh pr merge --squash` | | PR creation | `gh pr create --title "..." --body "..."` | diff --git a/dist/cursor/skills/git-workflow/references/commits.md b/dist/cursor/skills/git-workflow/references/commits.md index a879b8709..3e9e3c00d 100644 --- a/dist/cursor/skills/git-workflow/references/commits.md +++ b/dist/cursor/skills/git-workflow/references/commits.md @@ -110,13 +110,14 @@ Closes BACK-123 ## Branch Naming ``` +issue/<alias-or-id> <type>/<description> -<type>/TASK-123-description ``` ### Types -- `feat/` - New features (e.g., `feat/spec-010-task-management-cli`) +- `issue/` - Started from `loaf issue start` (`issue/loaf-42`) +- `feat/` - New features (e.g., `feat/thermal-rating-cli`) - `fix/` - Bug fixes - `hotfix/` - Critical production fixes - `release/` - Release preparation @@ -126,7 +127,7 @@ Closes BACK-123 - Lowercase with hyphens (kebab-case) - Short but descriptive (max 50 chars) -- Include spec or task slug when applicable (e.g., `feat/spec-010-task-management-cli`) +- Prefer the started worktree branch from `loaf issue start` when implementing an issue ## Pull Request Format @@ -140,26 +141,10 @@ feat: add thermal rating calculation ### Description -Focus on **review context** — what changed, why, and how to test. Do not include squash merge commit text in the PR body. +The PR body is `loaf issue render <ref>` output — paste-ready, no manual editing. Definition-of-done criteria in the render are the review checklist. Do not include squash merge commit text in the PR body. -```markdown -## Summary - -Brief description of what this PR adds/changes and why. - -- Bullet points covering key changes -- Focus on what a reviewer needs to know - -## Test plan - -- [ ] Unit tests added/updated -- [ ] Integration tests pass -- [ ] Manual testing performed - -## Related Issues - -Closes BACK-123 -Refs BACK-124 +``` +gh pr create --title "type: summary" --body "$(loaf issue render <ref>)" ``` ### Merge Strategy @@ -182,10 +167,10 @@ published release notes read as user-facing prose, not an internal worklog. Internal terms that have no meaning outside the team's working context: -- Spec IDs and task IDs (`SPEC-024`, `TASK-042`) +- Internal work-unit numbering that is not the issue ID (issue IDs like `LOAF-42` belong in commits — release attribution reads them) - Session, sprint, or branch references - Internal terminology from skills/docs that isn't part of the user's mental model — e.g. `Q1`/`Q2`/`Q3` question numbers from a Triage Gate, internal gate-logic notation like `(Q1 OR Q2) AND Q3`, hook IDs that aren't user-facing -- "How the work got done" framing — interview steps, breakdown steps, review gates +- "How the work got done" framing — interview steps, decomposition steps, review gates ### Keep @@ -204,7 +189,7 @@ Internal terms that have no meaning outside the team's working context: ### Auto-generated Entries -When `loaf release` auto-generates the `[Unreleased]` section from commit history, those entries inherit any internal terms present in the commit messages. Treat the generated output as a draft: rewrite it under the curated path before bumping. The release skill preserves curated content when it's already in `[Unreleased]` — curate first, bump second. +`loaf release suggest` drafts notes from landed issues; `loaf release cut` prepends them into `CHANGELOG.md`. Treat drafted notes as a draft: rewrite internal terms before cutting. Curate `[Unreleased]` as PRs land so the later cut reads as user-facing prose. Before approving a release bump, compare `[Unreleased]` against the actual release range and remove scaffolding language introduced by specs, reviews, tasks, or session triage. If an entry only explains why the work was discovered or how the work was organized, it does not belong in the changelog. @@ -228,14 +213,15 @@ Before approving a release bump, compare `[Unreleased]` against the actual relea - Add agent attribution - Mix unrelated changes - Commit secrets or sensitive data -- Put SPEC or TASK IDs in commit subject (use human-readable names) +- Put work-unit IDs in the commit subject (use human-readable names). Issue aliases belong in the body so `loaf release suggest` can attribute the commit. ### ID References - **IDs belong in footer, not subject line** - - Bad: `feat: implement SPEC-002 invisible sessions` - - Good: `feat: implement invisible sessions and task board` + - Bad: `feat: implement LOAF-42 invisible sessions` + - Good: `feat: implement invisible sessions` - Use descriptive names that are understandable without looking up IDs +- Issue aliases (`LOAF-42`) go in the body so release attribution can find them - Linear issue IDs go in footer only (e.g., `Closes BACK-123`) ## Semantic Versioning @@ -277,6 +263,6 @@ BREAKING CHANGE: Description of breaking change. **Convention:** - Use standard SemVer pre-release identifiers (`alpha`, `beta`, or `rc`) when publishing pre-release versions. -- `loaf release` handles all bump types: `prerelease`, `release`, `major`, `minor`, `patch` +- `loaf release cut --bump` handles all bump types: `prerelease`, `release`, `major`, `minor`, `patch` **Not required** — projects using simple `MAJOR.MINOR.PATCH` versioning can ignore pre-release suffixes entirely. This convention is for projects publishing staged pre-releases before stable releases. diff --git a/dist/cursor/skills/housekeeping/SKILL.md b/dist/cursor/skills/housekeeping/SKILL.md index e438c9632..d71c79537 100644 --- a/dist/cursor/skills/housekeeping/SKILL.md +++ b/dist/cursor/skills/housekeeping/SKILL.md @@ -1,12 +1,12 @@ --- name: housekeeping description: >- - Reviews and maintains agent artifacts in .agents/ — specs, plans, drafts, - handoffs, councils, and reports. Use when the user asks "housekeeping," "clean - up," or "tidy up .agents/." Provides hygiene recommendations, archives - completed work, and ensures extracted knowledge is preserved. Not for - strategic reflection (use reflect) or knowledge management (use - knowledge-base). + Reviews and maintains agent artifacts in .agents/ plus issue hygiene — + reports, handoffs, councils, archived issues, and stale started worktrees. Use + when the user asks "housekeeping," "clean up," or "tidy up .agents/." Provides + hygiene recommendations, archives completed work, and ensures extracted + knowledge is preserved. Not for strategic reflection (use reflect) or + knowledge management (use knowledge-base). version: 0.2.21 --- @@ -17,40 +17,43 @@ version: 0.2.21 - Verification - Quick Reference - Mode-Aware Checks -- Process -- Guardrails -- Related Skills +- Suggests Next +- Topics +- Artifact Naming -Systematic review and archival of all `.agents/` artifacts with Linear-aware checks. +Systematic review of `.agents/` artifacts and issue workspaces. ## Critical Rules **Always** - Log invocation as the first action: `loaf journal log "skill(housekeeping): <scope or trigger>"` - Review EVERY file individually — never sample or average -- Check Linear issue status before archiving linked specs +- Check Loaf issue status (and Linear overlay, if enabled) before archiving linked artifacts - Extract lessons learned and decisions before archiving -- Use CLI (`loaf housekeeping`, `loaf task archive`, `loaf spec archive`) — never raw `mv` +- Use CLI (`loaf housekeeping`, `loaf report archive`, `loaf issue status` / `loaf issue stop`) — never raw `mv` - Treat `.agents/handoffs/` as first-class but disposable: keep active/final handoffs, delete only after confirmed deprecated status -- Check report `status` is `processed` before archiving reports (see [templates/report.md](templates/report.md)) -- In SQLite-backed projects, verify lifecycle changes through `loaf task list --json`, `loaf spec list --json`, and `loaf report list --json`; use `loaf task sync` only for Markdown compatibility repair +- Check report `status` is `done` (or `final`) before archiving reports (see [templates/report.md](templates/report.md)) +- In SQLite-backed projects, verify lifecycle through `loaf issue list --json`, `loaf issue list --started`, `loaf issue list --archived`, and `loaf report list --json` - When delegated subagents are available, use the `librarian` profile for - `.agents/`-scoped durable artifact tending: report/spec/handoff hygiene, + `.agents/`-scoped durable artifact tending: report/handoff hygiene, staleness notes, and lifecycle-safe cleanup recommendations. Housekeeping still owns user confirmation and final archive decisions. -- Log outcome to the project journal: `loaf journal log "decision(housekeeping): archived N specs, M reports"` +- Log outcome to the project journal: `loaf journal log "decision(housekeeping): archived N reports; stopped M stale worktrees"` **Never** - Auto-archive without user confirmation for each artifact - Skip spark extraction before deleting brainstorm drafts - Leave `archived_at` or `archived_by` fields empty in archived files +- Run `loaf issue stop` from inside the started worktree +- Dispatch cleanup agents into a live started worktree another agent occupies ## Verification After work completes, verify: -- Tasks archived via `loaf task archive` -- Specs archived via `loaf spec archive` -- SQLite-backed task/spec/report state reflects lifecycle changes when initialized +- Reports archived via `loaf report archive` after processing +- Archived issues reviewed via `loaf issue list --archived` (`cancelled` / `duplicate` archive through `loaf issue status`) +- Stale started worktrees reviewed via `loaf issue list --started` (a `(missing)` marker means the recorded path is gone) +- SQLite-backed report/issue state reflects lifecycle changes when initialized - Drafts checked for unprocessed sparks before deletion - Handoffs deleted only after explicit deprecation is confirmed - Summary table presented showing all actions taken @@ -62,11 +65,18 @@ After work completes, verify: ```bash loaf housekeeping --dry-run # Preview recommendations loaf housekeeping # Run artifact scanner -loaf task archive TASK-XXX # Archive single task -loaf spec archive SPEC-XXX # Archive single spec -loaf task sync # Compatibility diagnostic in SQLite-backed projects +loaf issue list --started # Started worktrees (alias, title, branch, path) +loaf issue list --archived # cancelled / duplicate rows +loaf issue stop <ref> # Remove worktree; keeps branch; does not change status +loaf issue status <ref> cancelled # Archive an abandoned issue +loaf issue status <ref> duplicate --duplicate-of <surviving> +loaf report archive <report> # Archive a processed report ``` +`loaf housekeeping` still prints leftover `specs` / `tasks` sections when those +SQLite tables have rows — compatibility scan only. Do not create new records +there. The `loaf task` / `loaf spec` CLI is legacy. + The project journal is append-only and never archived — it is not a housekeeping target. It is the canonical record housekeeping reads when extracting decisions before archiving other artifacts. @@ -75,19 +85,12 @@ before archiving other artifacts. | Artifact | Active Location | Archive | Action | |----------|-----------------|---------|--------| -| Tasks (local mode only) | SQLite state | SQLite archived status | `loaf task archive` | -| Specs | SQLite state + `.agents/specs/` authored prose | `archive/` | `loaf spec archive` | +| Issues | SQLite (`loaf issue list`) | `cancelled` / `duplicate` via `loaf issue status` | Confirm, then status; `done` is ship, not housekeeping | +| Started worktrees | `loaf issue list --started` | `loaf issue stop <ref>` | Stop stale or `(missing)` trees after confirmation | | Drafts / brainstorms | SQLite state | SQLite resolved/archived status | User decision (spark extraction first) | | Handoffs | `.agents/handoffs/` | delete | Delete after status is confirmed `deprecated` | | Reports | SQLite state + generated/authored report Markdown | `archive/` | `loaf report archive` after processing | -**Linear-native mode** (when `integrations.linear.enabled` is `true` in -`.agents/loaf.json`): local `TASK-NNN.md` files do not exist for new specs — -Linear issues are the task record. The "Tasks" row above is inert unless the -project has pre-Linear local tasks lingering (see [Mode-Aware Checks](#mode-aware-checks)). -Specs still archive locally — they are the canonical deliberation artifact in -every mode. - ## Cross-Branch Reconciliation If a stale branch reintroduces `.agents/{tasks,ideas,sparks,sessions,brainstorms,drafts}/` @@ -97,35 +100,30 @@ or `.agents/TASKS.json`, keep the deletion from the cutover branch and rerun ## Mode-Aware Checks -When `integrations.linear.enabled` is `true` in `.agents/loaf.json`, apply -these additional checks: +### Started worktrees -### Spec / Linear parent reconciliation +For each row from `loaf issue list --started`: -For each spec file (active and archive) with a `linear_parent:` frontmatter key: +1. If `(missing)`, flag as **stale started workspace** — the row still records a path that is gone. Offer `loaf issue stop <ref>` after confirmation. Stop does not mark the issue `done`. +2. If the path exists but the issue is `done` / `cancelled` / `duplicate`, flag as **worktree outlived the issue** — same offer. +3. If the path exists and status is `active`, leave it unless the user asks to stop. -1. Call `get_issue` with the issue identifier. If it 404s or returns - archived/deleted, flag as **orphaned linear_parent** — the local spec - references a Linear issue that no longer exists. -2. If the spec's local status is `done` (or legacy `complete`) or `archived`, - verify the Linear parent issue is in a `completed`-type state. If not - (e.g., still "In Progress"), flag as **status mismatch** — "Spec marked - complete locally but Linear parent ENG-198 is still 'In Progress'." -3. If the spec's local status is `in_progress` and the Linear parent is - already `completed`, flag the inverse — spec likely needs to be moved to - `done` and archived. +Treat these as **warnings**, not auto-fixes. -Treat all three as **warnings**, not auto-fixes. The user decides resolution. +### Linear overlay -### Pre-Linear local task detection +When `integrations.linear.enabled` is `true` in `.agents/loaf.json`, the tracker +adapter is not shipped. If a report or journal entry names a Linear id next to +a Loaf alias, you may `get_issue` and flag an obvious mismatch (Linear Done vs +Loaf still `active`, or the reverse). Warnings only. Do not drive Loaf status +from Linear. -If Linear is enabled but local task records exist in SQLite, -surface them with context: "Pre-Linear local tasks detected. These aren't -auto-migrated. Either continue using them, run a manual migration, or -archive if superseded by Linear issues." +### Leftover board rows -Do NOT auto-migrate. Migration is user-initiated and out of scope for -housekeeping. +If `loaf housekeeping --dry-run` still reports `tasks` or `specs` cleanup +candidates, surface them: "Legacy board rows are still in SQLite. They are not +the work unit. Archive only if the user confirms they are superseded by Loaf +issues." Do NOT auto-migrate. ## Suggests Next @@ -136,9 +134,9 @@ After housekeeping, suggest reflect if the session produced key decisions or lea | Topic | Reference | Use When | |-------|-----------|----------| | Report Template | [templates/report.md](templates/report.md) | Creating cleanup reports | -| Linear Integration | `orchestration/references/linear.md` | Checking external issue status | +| Linear Integration | `orchestration/references/linear.md` | Checking external tracker overlay | | Journal Continuity | `orchestration/references/journal.md` | Understanding the project journal model | ## Artifact Naming -Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or Change already records that provenance. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. +Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or the issue already records that provenance. Put the source in a front-matter field (`source: LOAF-42`), not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. diff --git a/dist/cursor/skills/housekeeping/templates/journal.md b/dist/cursor/skills/housekeeping/templates/journal.md index caf7409fd..9a0a61f8f 100644 --- a/dist/cursor/skills/housekeeping/templates/journal.md +++ b/dist/cursor/skills/housekeeping/templates/journal.md @@ -13,7 +13,7 @@ not create or edit journal markdown as the source of truth — use ## Entries -[YYYY-MM-DD HH:MM] skill(implement): implementing TASK-042 +[YYYY-MM-DD HH:MM] skill(implement): implementing LOAF-42 [YYYY-MM-DD HH:MM] decision(scope): description of decision [YYYY-MM-DD HH:MM] discover(scope): something learned [YYYY-MM-DD HH:MM] block(scope): what is blocked diff --git a/dist/cursor/skills/housekeeping/templates/report.md b/dist/cursor/skills/housekeeping/templates/report.md index d0894e593..ca201e869 100644 --- a/dist/cursor/skills/housekeeping/templates/report.md +++ b/dist/cursor/skills/housekeeping/templates/report.md @@ -8,7 +8,7 @@ title: "Report: [Topic]" type: research | audit | analysis | council created: YYYY-MM-DDTHH:MM:SSZ status: draft | done | archived -source: SPEC-XXX | TASK-XXX | ad-hoc +source: LOAF-42 | ad-hoc finalized_at: YYYY-MM-DDTHH:MM:SSZ # Set when status → done archived_at: YYYY-MM-DDTHH:MM:SSZ # Set when status → archived archived_by: orchestrator diff --git a/dist/cursor/skills/idea/SKILL.md b/dist/cursor/skills/idea/SKILL.md index f1c268823..c5ba62f48 100644 --- a/dist/cursor/skills/idea/SKILL.md +++ b/dist/cursor/skills/idea/SKILL.md @@ -3,11 +3,12 @@ name: idea description: >- Captures ideas into structured nuggets for later evaluation. Use when the user says "I have an idea" or "note this down." Also activate when a specific - actionable concept crystallizes during conversation. Ideas and sparks are - capture primitives routed through triage, which chooses dispositions such as - tracking an Intent or handing to pitch. Not for problem discovery (use pitch), - processing the intake queue (use triage), shaping (use shape), or agent-side - divergent inquiry when direction is undecided (use explore as a technique). + actionable concept crystallizes during conversation. Ideas and sparks stay + capture primitives routed through triage, which files worth-keeping items as + backlog issues or hands them to pitch or shape. Not for problem discovery (use + pitch), processing the intake queue (use triage), shaping a bounded issue (use + shape), or agent-side divergent inquiry when direction is undecided (use + explore as a technique). version: 0.2.21 --- @@ -25,7 +26,6 @@ Capture ideas quickly with minimal friction. - Quick Reference - Purpose - Process -- Idea Lifecycle - Guardrails - Related Skills @@ -35,7 +35,7 @@ Capture ideas quickly with minimal friction. - 2-3 questions maximum -- don't turn capture into an interview - Infer metadata automatically -- don't ask for tags, title, or links - One idea per captured row/artifact -- keep them atomic -- No shaping or pitching here -- problem discovery is pitch; bounding is shape +- No shaping, pitching, or filing issues here -- problem discovery is pitch; bounding is shape; dispositions are triage - Capture through `loaf idea capture --title ...` when SQLite state is initialized; log notable context with `loaf journal log` @@ -57,7 +57,7 @@ Capture ideas quickly with minimal friction. ## Purpose -Ideas are raw nuggets — unprocessed, unshaped, but worth remembering. The goal is **speed of capture**, not thoroughness. An idea is retained material, nothing more: tracking it as an Intent, handing it to pitch for problem discovery, handing it to shape when already well-understood, or archiving it are triage dispositions chosen later by the user. +Ideas are raw nuggets — unprocessed, unshaped, but worth remembering. The goal is **speed of capture**, not thoroughness. An idea is retained material, nothing more: filing it as a backlog issue, handing it to pitch for problem discovery, handing it to shape when already well-understood, or archiving it are triage dispositions chosen later by the user. --- @@ -81,7 +81,7 @@ Ideas are raw nuggets — unprocessed, unshaped, but worth remembering. The goal ## Related Skills -- **triage** — process the intake queue and choose dispositions (including hand to pitch or shape) +- **triage** — process the intake queue and choose dispositions (file as backlog issue, hand to pitch, or hand to shape) - **pitch** — problem-discovery ceremony when a captured idea needs a brief before shaping -- **shape** — develop a well-understood direction into a bounded Change +- **shape** — develop a well-understood direction into a bounded issue - **explore** — agent technique when direction is genuinely undecided (not a user front door) diff --git a/dist/cursor/skills/idea/templates/idea.md b/dist/cursor/skills/idea/templates/idea.md index b070494d3..b631ca832 100644 --- a/dist/cursor/skills/idea/templates/idea.md +++ b/dist/cursor/skills/idea/templates/idea.md @@ -11,7 +11,7 @@ title: "[Idea Title]" captured: YYYY-MM-DDTHH:MM:SSZ status: raw tags: [] -related: [] # Optional: spec IDs, idea filenames, or other references +related: [] # Optional: issue aliases, idea filenames, or other references origin: # Optional: draft filename this spark came from (e.g. drafts/YYYYMMDD-brainstorm-slug.md) --- @@ -31,5 +31,5 @@ origin: # Optional: draft filename this spark came from (e.g. dra --- -*Captured via idea -- shape with shape when ready* +*Captured via idea — triage later (backlog issue, pitch, or shape)* ``` diff --git a/dist/cursor/skills/implement/SKILL.md b/dist/cursor/skills/implement/SKILL.md index 775c75488..6baaedb80 100644 --- a/dist/cursor/skills/implement/SKILL.md +++ b/dist/cursor/skills/implement/SKILL.md @@ -1,18 +1,19 @@ --- name: implement description: >- - Orchestrates implementation work through agent delegation and batch execution. - Use for all implementation work — features, bug fixes, refactors, and code - changes. Picks Change task files when present and flips checkboxes in - delivering commits. Logs to the project journal and produces agent spawn plans - and progress tracking. Not for shaping (use shape), breakdown (use breakdown), - research, or review. + Orchestrates implementation work through agent delegation and batch execution + against Loaf issues. Use for all implementation work — features, bug fixes, + refactors, and code changes. Picks the next issue from loaf issue frontier, + delegates one agent per started worktree, and treats definition-of-done + criteria as the completion contract. Logs to the project journal and produces + agent spawn plans and progress tracking. Not for shaping or decomposition (use + shape), research, or review. version: 0.2.21 --- # Implement -You are the coordinator. Start by understanding the task: +You are the coordinator. Work units are issues. ## Contents - Critical Rules @@ -20,7 +21,7 @@ You are the coordinator. Start by understanding the task: - Quick Reference - Step 0: Context Check - Input Detection -- Linear-Native Routing +- Pick-up and Dispatch - Agent Spawning - Journal First - Guardrails @@ -38,27 +39,32 @@ You are the coordinator. Start by understanding the task: **You are the ORCHESTRATOR, not the implementer.** -- Log `loaf journal log "skill(implement): <task/spec/context>"` as the first action. -- **Change-first task packets:** prefer `docs/changes/<folder>/tasks/TASK-NNN-*.md` as the delegation brief. Flip checkboxes `- [ ]`→`- [x]` in the same commit that delivers the work (outside `docs/changes/` paths must land with the flip for provenance). Use `loaf change tasks --json` for the index. -- Commit task packets unchecked before executing them — a packet that first lands already-checked induces no flip transition, and the evidence trail never exists. -- Compatibility: existing `TASK-XXX` / `SPEC-XXX` SQLite records remain supported until converted; they are not the default for new work. +- Log `loaf journal log "skill(implement): LOAF-42 — <what>"` as the first action. Substitute the real alias (or opaque id) and a short intent. +- **Pick-up-next is `loaf issue frontier`.** That view is open (`triage` / `backlog` / `todo`), unblocked, and unclaimed (not `active`, no started worktree). Derived at read time. +- **The delegation brief is the issue row** — `loaf issue show <ref>` / `loaf issue render <ref>`: body, definition-of-done criteria, children. There is no other packet. +- **One agent, one worktree.** `loaf issue start <ref>` creates the branch and worktree and moves status to `active`. Before dispatch, run `loaf issue list --started`. Never send two agents into the same worktree. +- **Definition of done is the completion contract.** `loaf issue verify <ref>` runs V-tier criteria from the repository root and writes nothing. H-tier is reviewed by a human or this orchestrator. Completion is the work landing plus `loaf issue status <ref> done`. Do not flip checkboxes. Provenance is the delivering commits and the PR whose body is `loaf issue render <ref>`. +- Shape prepares issues. If `loaf issue check <ref>` does not report the delivery issue shaped (or the decision issue ready), stop and send the work to shape. Do not mint a new issue from this skill. ### Orchestrator Can Do Directly - Log journal entries, read journal context, create council files -- Use your harness's task/todo tracking surface; **if `integrations.linear.enabled` is `true` in `.agents/loaf.json`**, use Linear MCP tools when helpful +- Use your harness's todo tracking surface; **if `integrations.linear.enabled` is `true` in `.agents/loaf.json`**, Linear MCP is an overlay only — Loaf issues remain the work unit and Linear never drives Loaf status - Read any file for context - Ask clarifying questions +- Run `loaf issue` read commands, `loaf issue start` / `stop`, `loaf issue status`, and open a PR whose body is `loaf issue render` output ### Orchestrator MUST Delegate (via agent spawn) -**ALL code changes, documentation edits, and implementation work** to specialized agents. **No exceptions**, even for "trivial" 1-line fixes. +**ALL code changes, documentation edits, and implementation work** to specialized agents. **No exceptions**, even for "trivial" 1-line fixes. Spawn each agent into that issue's started worktree. ## Verification - The invocation is logged to the project journal before implementation work begins — no session start step, no "active session" precondition - All code changes delegated via your harness's agent-spawn mechanism -- no direct edits by orchestrator - The journal is continuously updated with spawns, progress, and decisions as work happens -- Spec artifacts closed out on branch before PR creation -- **Linear-native mode:** `blockedBy` of the target sub-issue is fully `completed` before work begins; starting a sub-issue also promotes an unstarted parent rollup to active; parent rollup is auto-closed only when all sub-issues are `completed` +- Each in-flight issue has exactly one started worktree; `loaf issue list --started` was checked before every spawn +- V-tier criteria pass `loaf issue verify <ref>` (writes nothing); H-tier criteria were reviewed by a human or this orchestrator +- The PR body is `loaf issue render <ref>` with no manual editing; checkboxes stay unchecked until status is `done` +- Completion is landing plus `loaf issue status <ref> done` (usually via ship) ## Quick Reference @@ -73,6 +79,15 @@ You are the coordinator. Start by understanding the task: | Code review/audit | reviewer | relevant domain skills | | Research/comparison | researcher | relevant domain skills | +| Moment | Command | +|--------|---------| +| Pick next | `loaf issue frontier` | +| Brief | `loaf issue show <ref>` / `loaf issue render <ref>` | +| Claim workspace | `loaf issue start <ref>` | +| Occupied trees | `loaf issue list --started` | +| V-tier gate | `loaf issue verify <ref>` | +| Landed | `loaf issue status <ref> done` | + --- ## Step 0: Context Check @@ -83,152 +98,51 @@ Before starting, evaluate context suitability. |---------|--------| | New command/skill added this conversation | **Restart required** (skills loaded at start) | | Conversation > 30 exchanges | Suggest restart | -| Just completed a different task/spec | Suggest clear | +| Just completed a different issue | Suggest clear | | About to start multi-file implementation | Check depth | If restart needed: log current state with `loaf journal log`, then ask the user to restart. A supported startup adapter may reconstruct continuity from the journal in the next conversation; when the exact current target mode is candidate or unsupported, explicitly run `loaf journal context` after restarting. ## Input Detection -Parse `$ARGUMENTS` to determine the work type: +Parse `$ARGUMENTS` to determine the work: | Input Pattern | Type | Action | |---------------|------|--------| -| `TASK-XXX` | Local task | Load via `loaf task show`, log the task coupling | -| `SPEC-XXX` | Spec orchestration | If spec frontmatter has `linear_parent`, resolve to that Linear parent and follow Linear-Native Routing. Otherwise resolve local tasks and build dependency-ready rounds | -| `TASK-XXX..YYY` | Task range | Expand range, build dependency-ready rounds | -| `TASK-XXX,YYY,ZZZ` | Task list | Parse list, build dependency-ready rounds | -| `PLT-123`, `ENG-198`, `PROJ-123` | Linear issue | **If `integrations.linear.enabled` is `true`:** fetch via `get_issue`, then branch on parent vs sub-issue — see [Linear-Native Routing](#linear-native-routing). **Otherwise:** treat as label text or create local task | -| Description text | Ad-hoc | Auto-create local task from description, then fall through to task-coupled flow | - -### Task-Coupled Work - -When starting from `TASK-XXX`: - -1. Load task metadata via `loaf task show TASK-XXX --json`; do not recreate `.agents/TASKS.json` after the SQLite cutover -2. Log the task coupling: `loaf journal log "decision(implement): implementing TASK-XXX"` -3. Load parent spec if task has `spec:` field - -### Ad-hoc Task Auto-Creation - -When input is free-text description (not matching any known pattern): - -1. **Parse the description:** - - Single sentence → use entire text as task title - - Multi-sentence → first sentence = title, remainder = acceptance criteria - - Split on `. ` followed by uppercase letter only (conservative — avoids false positives from URLs, abbreviations) -2. **Create the task:** `loaf task create --title "<parsed title>"` -3. **Write criteria** (if multi-sentence): edit the task `.md` file body to add the remaining sentences as acceptance criteria -4. **Fall through** to the task-coupled flow above — the result is a `TASK-XXX` ID that enters the existing planning pipeline unchanged - -**No user interaction required.** The description IS the task; invoking implement already expressed intent. +| `LOAF-42` or opaque id | Single issue | Load via `loaf issue show <ref>`; fall through to Pick-up and Dispatch | +| Parent ref with children | Tree | `loaf issue tree <ref>`; build rounds from children and `blocks` / `blocked_by` edges (see [batch-orchestration.md](references/batch-orchestration.md)) | +| Multiple refs | Batch | Same round construction across the named set | +| Empty / "next" | Frontier | `loaf issue frontier`; if one row, pick it; if several, ask (structured question tool if the harness has one); if none, stop | +| Description text | Ad-hoc | Match frontier by title. Do not mint. If nothing matches, stop and send to shape | +| Decision kind | Question | Not implementation. Surface the question; do not `loaf issue start` unless the user points at a delivery issue that records the decided answer | -### Non-Existent Task ID Error +### Missing ref -If input matches `TASK-XXX` pattern but `loaf task show` cannot resolve it: +If input looks like an issue ref but `loaf issue show` cannot resolve it: -1. Show error: `"TASK-XXX not found in local task state"` -2. Ask the user: `"Did you mean to create a new task? You can re-run with the description as free text."` -3. **Do not silently create** — the user likely has a typo +1. Show error: `"<ref> not found"` +2. Ask whether they meant a different alias, or to shape a new issue +3. **Do not silently create** --- -## Linear-Native Routing - -Applies when `integrations.linear.enabled` is `true` AND `$ARGUMENTS` -resolves to a Linear issue (direct Linear ID, or a `SPEC-XXX` whose -frontmatter has `linear_parent`). - -Fetch the issue once via `get_issue` and branch on its shape: - -### Parent rollup issue (has `spec` label) - -The issue represents a spec. Do **not** implement it directly — spec-level -"work" is always done via sub-issues. - -1. List sub-issues via `list_issues` with `parent: <parent-id>`. -2. Classify each by state: - - `in_progress` — active work - - `unstarted` + no open `blockedBy` — ready to start - - `unstarted` + open `blockedBy` — blocked - - `completed` — done, skip -3. Select the next work item: - - If one or more sub-issues are `in_progress`, pick the **lowest-ID** - in-progress sub-issue. Resume that. - - Else, if one unblocked `unstarted` sub-issue exists, pick it. - - Else, if multiple unblocked `unstarted` sub-issues exist, use - your harness's structured question tool (if it has one) to let the user choose: pick one, or delegate N in - parallel via parallel agents. List each sub-issue's title + ID. - - Else (all remaining sub-issues are blocked), refuse with a summary: - "All remaining sub-issues under <parent-id> are blocked. Blockers: - <list>." -4. Once a sub-issue is selected, recurse into the sub-issue flow below - with that ID. The parent itself is never the implementation target. - -### Sub-issue (has `parentId`, no `spec` label) - -The issue is an actual task. Implement it directly — with a pre-flight gate. - -1. **Pre-flight: verify `blockedBy` is clear.** For each issue in the - sub-issue's `blockedBy` field, call `get_issue` and confirm its state is - `completed`-type. If any blocker is not Done: - - **Refuse to start.** Do not begin work. Do not move the issue. - - Show the blockers: `"Cannot start <sub-issue-id>. Blocked by: <list - with IDs, titles, and current states>."` - - Suggest: `"Complete the blocker(s) first, or ask to override if the - blockedBy link is stale."` -2. If blockers are clear: - - Start the sub-issue as one logical Linear operation. This moves - the sub-issue to the team's `started`/In Progress state and, when the - parent rollup is still `backlog` or `unstarted`, promotes the parent to - the same `started`/In Progress state. - - If the parent is already active, leave it unchanged. If the parent is - `completed`, `canceled`, or archived, refuse to start unless the user - explicitly asks to override the protected parent state. - - If the child update succeeds but parent promotion fails, report a - reconciliation error naming the parent issue before continuing. - - Resolve branch name from the sub-issue's `branchName` field (Linear - auto-generates one) — see - [branch-and-completion.md](references/branch-and-completion.md). - - Log the task coupling, then continue with the standard Startup Checklist. - -### Completion (after implementer + reviewer finish cleanly) - -When the sub-issue's implementation passes review and tests: - -1. Move the sub-issue to the team's `completed`-type state via - `update_issue` (look up via `list_issue_statuses`, filter - `type: "completed"`). -2. Query the parent's sub-issues again: - - If **all** sub-issues are now `completed`-type, move the parent - rollup to `completed` as well. Also mark the local spec as - `complete` (see [Then Execute → AFTER](#then-execute)). - - If **some** remain, list them as "next available" for the user, - applying the same classification as step 2 of the parent flow above. - Offer to continue with the next one in this session, or stop here. -3. **Do not** close the parent while any sub-issue is open — not even if - only `blocked` ones remain. Blocked sub-issues are still in-flight - work from the spec's perspective. - -### Status flow summary - -| Moment | Sub-issue state | Parent state | -|--------|----------------|--------------| -| Implementation starts | `started` / In Progress | promoted to `started` / In Progress if still `backlog` or `unstarted` | -| Implementation + review pass | `completed` | check: close only if all sibs completed | -| Blocker discovered mid-work | `in_progress` + blocker comment | unchanged | - -### What Linear-native routing does NOT do - -- Does not pull down the full spec text. The parent's description already - links to `.agents/specs/SPEC-NNN-*.md`. Read the local file for shape, - rabbit holes, and strategic tensions. -- Does not create or rewrite sub-issues. That's breakdown's job. If - implementation reveals a missing task, surface it to the user; they - decide whether to run breakdown again or add an ad-hoc sub-issue. -- Does not sync in-progress state bidirectionally. Source of truth at any - moment: Linear for issue state, local files for spec content, the project - journal for current handoff. +## Pick-up and Dispatch + +1. **Confirm the issue is implementable.** `loaf issue check <ref>` must report a delivery issue shaped (or, if the user explicitly asked to resolve a decision issue, that it is ready). Unshaped work goes to shape. +2. **Honor the frontier.** An issue that is blocked does not appear on `loaf issue frontier`. `loaf issue link A blocks B` means A blocks B; B waits until A is `done`, `cancelled`, or `duplicate`. Do not start a blocked successor. Parent/child structure from `loaf issue tree` is not a sequencing edge — only `blocks` / `blocked_by` are. Use the tree to know who belongs in the batch; use the edges to order rounds. +3. **Parents with children are not the implementation target.** Dispatch leaf delivery children that are on the frontier. A parent executes through claimed child criteria, not by starting the parent worktree. +4. **Inspect occupied worktrees:** + ```bash + loaf issue list --started + ``` + Columns: alias, title, `started_branch`, `started_worktree`, optional `(missing)`. If this ref is already started, resume in that worktree with one agent. If the path is occupied by another issue, refuse. A `(missing)` marker means the recorded path is gone — `loaf issue stop <ref>` (not from inside the tree) before starting again. +5. **Start the workspace** (skip if already started and the path exists): + ```bash + loaf issue start <ref> + ``` + Creates branch `issue/<alias-or-id>` in lowercase (`issue/loaf-42`, id suffix when that name is claimed), a sibling worktree, and sets status to `active`. Base is the nearest started ancestor's branch, else the repository default branch. Start refuses archived rows and terminal statuses (`done`, `cancelled`, `duplicate`). +6. **Hand the agent the brief** from `loaf issue show <ref>` (body, criteria, children) and, when opening a PR, `loaf issue render <ref>`. Tell the agent to work only in `started_worktree`. +7. **Batch rounds.** When input is a parent or a set of refs, group unblocked delivery children into dependency-ready rounds from `blocked_by` edges and parent/child structure. Parallel only within a round, max 3, and only when each agent has its own worktree. See [batch-orchestration.md](references/batch-orchestration.md) for the round loop, `--dry-run` / `--parallel` / `--continue` / `--skip <ref>` / `--abort`, and blocked-state recovery. --- @@ -247,7 +161,7 @@ Spawn specialized agents with the appropriate profile: | Code review/audit | reviewer | relevant domain skills | | Research/comparison | researcher | relevant domain skills | -**Rules:** Be specific in prompts. One concern per agent. Include context. Parallel when independent, sequential when dependent. +**Rules:** Be specific in prompts. One concern per agent. Include the issue ref, `started_worktree`, body, and definition of done. Parallel when independent (separate worktrees), sequential when a `blocks` edge says so. --- @@ -256,15 +170,14 @@ Spawn specialized agents with the appropriate profile: There is no session to start — journaling is continuous. Your first action is to log the invocation: ```bash -loaf journal log "skill(implement): <task/spec/context>" +loaf journal log "skill(implement): LOAF-42 — <what>" ``` Entries are project-scoped and tagged with this conversation's harness id automatically. Continuity from prior conversations may arrive through a supported startup adapter; when the exact current target mode is candidate or unsupported, pull it explicitly with `loaf journal context`. Use `loaf journal recent` when you need a narrower timeline. -Suggest renaming the harness conversation with a meaningful name derived from context (use your harness's rename surface if it has one): -- From spec: `SPEC-027-session-stability` -- From task: `TASK-042-login-fix` -- From ad-hoc: `{short-slug-from-description}` +Suggest renaming the harness conversation with a meaningful name derived from the issue (use your harness's rename surface if it has one): +- From issue: `LOAF-42-login-fix` +- From ad-hoc match: `{alias}-{short-slug}` --- @@ -278,12 +191,14 @@ Suggest renaming the harness conversation with a meaningful name derived from co 6. **Journal continuously** -- log spawns, progress, blockers, and decisions with `loaf journal log` as they happen 7. **Clean up** -- no ephemeral files; write an optional `wrap` entry only when there's synthesis worth saving 8. **When in doubt, ask the user** +9. **Never `loaf issue stop` from inside the started worktree** -- stop does not change status; `--force` removes a dirty tree +10. **Do not tick definition-of-done boxes** -- `loaf issue verify` writes nothing; render checks a box only when status is already `done` ## Decision Tree ``` Is this a code/config/doc change? -+-- YES -> Spawn appropriate agent ++-- YES -> Spawn appropriate agent into the issue worktree +-- NO -> Is this a planning/coordination decision? +-- YES with clear path -> Proceed, log the decision +-- YES but ambiguous -> Ask user @@ -296,18 +211,16 @@ When multiple valid approaches exist: spawn council (5-7 agents, odd), present r ## Startup Checklist -1. [ ] Log the invocation: `loaf journal log "skill(implement): <context>"` -2. [ ] Parse input (task, Linear ID, or description) -3. [ ] If TASK-XXX: load task via `loaf task show TASK-XXX`, log task coupling, load parent spec -4. [ ] If Linear ID (or `SPEC-XXX` with `linear_parent`): follow [Linear-Native Routing](#linear-native-routing). Parent → walk sub-issues and select next. Sub-issue → verify `blockedBy` is clear, then start it as one logical Linear operation so the parent is promoted when needed -5. [ ] If description: auto-create task (see Ad-hoc Task Auto-Creation above) -6. [ ] Create dedicated branch (see [branch-and-completion.md](references/branch-and-completion.md)) -7. [ ] Suggest team based on task context -8. [ ] Log initial context and references with `loaf journal log` -9. [ ] Break down work using your harness's task/todo tracking surface -10. [ ] Identify needed specialized agents -11. [ ] Log next steps before spawning -12. [ ] **Get user approval** before spawning +1. [ ] Log the invocation: `loaf journal log "skill(implement): LOAF-42 — <what>"` +2. [ ] Parse input (issue ref, parent, set, frontier, or description) +3. [ ] Load `loaf issue show <ref>`; if children, `loaf issue tree <ref>` +4. [ ] `loaf issue check <ref>` — shaped/ready, or stop and send to shape +5. [ ] Confirm the ref is on `loaf issue frontier` (or already started for resume) +6. [ ] `loaf issue list --started` — one agent per worktree +7. [ ] `loaf issue start <ref>` unless already started +8. [ ] Suggest conversation rename (`LOAF-42-login-fix`) +9. [ ] Identify specialized agents; log next steps +10. [ ] **Get user approval** before spawning --- @@ -315,32 +228,28 @@ When multiple valid approaches exist: spawn council (5-7 agents, odd), present r ### BEFORE (Planning) 1. Log the invocation with `loaf journal log` -2. Set task status: `loaf task update TASK-XXX --status in_progress` -3. Break down work into agent-sized tasks -4. Identify spawn order (respect dependencies) +2. `loaf issue start <ref>` (status becomes `active` through start) +3. Slice work into agent-sized units that still belong to this one issue +4. Identify spawn order (respect `blocked_by` edges and parent/child rounds) 5. Get user approval ### DURING (Execution) -1. Spawn specialized agents via your harness's agent-spawn mechanism -2. Log each spawn with `loaf journal log "todo(agent): spawned <agent> for <task>"` -3. Update Linear with progress (no emoji, no file paths) -4. Keep journal entries handoff-ready -5. After each agent completes: log outcome, spawn next +1. Spawn specialized agents into `started_worktree` via your harness's agent-spawn mechanism +2. Log each spawn with `loaf journal log "todo(agent): spawned <agent> for <ref>"` +3. Keep journal entries handoff-ready +4. After each agent completes: log outcome, spawn next +5. If Linear overlay is enabled, you may comment there — Loaf status stays on `loaf issue` ### AFTER (Completion) 1. Code review pass (spawn `reviewer` agent) 2. Spawn implementer (with foundations + language skill) for final testing -3. **Close out spec artifacts on the branch** (included in the squash merge): - - **Local-tasks mode:** `loaf task update TASK-XXX --status done` (per task), then `loaf task archive --spec SPEC-XXX` - - **Linear-native mode:** `update_issue` the sub-issue to `completed`-type state. Then query the parent's sub-issues; if all are `completed`, also close the parent. If some remain, list them for the user (see [Linear-Native Routing → Completion](#completion-after-implementer--reviewer-finish-cleanly)) - - Mark spec complete and archive: `loaf spec archive SPEC-XXX` (both modes) - - Write a `wrap(scope)` journal entry if the work produced synthesis worth saving (next steps, abandoned paths); otherwise skip it - - Commit: `chore: close SPEC-XXX — archive tasks and spec` -4. If on a feature branch: push and create PR (`gh pr create`). Follow PR format and squash merge conventions in [commits reference](../git-workflow/references/commits.md). -5. After PR is created and approved, use ship to review, verify, and land the PR. Use release later when a coherent batch of landed work is ready to publish. -6. **Suggest reflection:** Check the journal for extractable learnings before closing out: +3. Run `loaf issue verify <ref>` (V-tier, writes nothing). Review every H-tier row yourself or with the user — a skip from verify is not a pass +4. Open or update the PR with body `loaf issue render <ref>` — no manual editing. Follow PR format and squash merge conventions in [commits reference](../git-workflow/references/commits.md) +5. After the PR is created, use ship to review, verify, land, mark `loaf issue status <ref> done`, and `loaf issue stop <ref>`. Use release later when a coherent batch of landed work is ready to publish +6. Write a `wrap(scope)` journal entry if the work produced synthesis worth saving; otherwise skip it +7. **Suggest reflection:** Check the journal for extractable learnings before closing out: - `decision(...)` entries are present - - ADRs, report verdicts, or spec changelog entries were recorded + - ADRs or report verdicts were recorded If any signal is present, suggest: *"This produced key decisions. Consider running reflect to update strategic docs."* If none are present, stay silent. --- @@ -349,18 +258,18 @@ When multiple valid approaches exist: spawn council (5-7 agents, odd), present r | Topic | Reference | Use When | |-------|-----------|----------| -| Batch Orchestration | [batch-orchestration.md](references/batch-orchestration.md) | Running specs, task ranges, or task lists with dependency-ready rounds | -| Branch and Completion | [branch-and-completion.md](references/branch-and-completion.md) | Branch management, team routing, diagrams, Linear sync, journaling, task completion | +| Batch Orchestration | [batch-orchestration.md](references/batch-orchestration.md) | Running a parent or a set of issue refs with dependency-ready rounds | +| Branch and Completion | [branch-and-completion.md](references/branch-and-completion.md) | Team routing, diagrams, exploration, journaling alongside `loaf issue start` / `stop` | +| Working issues locally | [../orchestration/references/local-tasks.md](../orchestration/references/local-tasks.md) | Frontier, started worktrees, status vocabulary, definition of done | --- ## Suggests Next -After all tasks are complete, suggest ship to land the PR. Suggest release only when the landed work forms a coherent release batch. +After the PR exists, suggest ship to land it. Suggest release only when the landed work forms a coherent release batch. ## Related Skills -- **shape** - Spec format and lifecycle -- **breakdown** - Turning specs into tasks -- **orchestration/local-tasks** - Task file format and lifecycle -- **orchestration/journal** - Project journal continuity model +- **shape** — Issue preparation and decomposition +- **orchestration/journal** — Project journal continuity model +- **orchestration/local-tasks** — Frontier, started worktrees, status, definition of done diff --git a/dist/cursor/skills/implement/references/batch-orchestration.md b/dist/cursor/skills/implement/references/batch-orchestration.md index 7ab48f755..7902f28f1 100644 --- a/dist/cursor/skills/implement/references/batch-orchestration.md +++ b/dist/cursor/skills/implement/references/batch-orchestration.md @@ -7,63 +7,64 @@ - Batch Execution Model - Blocked-State Recovery -Detailed reference for running specs, task ranges, or task lists with dependency-ready scheduling. +Detailed reference for running a parent issue or a set of issue refs with dependency-ready scheduling. ## Orchestration Options | Option | Behavior | |--------|----------| | `--dry-run` | Show dependency-ready execution plan, do not run agents | -| `--parallel` | Run tasks in the same dependency-ready group concurrently (max 3 at once) | -| `--continue` | Resume a blocked orchestration from the recorded task/group | -| `--skip TASK-XXX` | Mark one blocked task as skipped and continue | +| `--parallel` | Run issues in the same dependency-ready group concurrently (max 3 at once) | +| `--continue` | Resume a blocked orchestration from the recorded issue/group | +| `--skip <ref>` | Skip one blocked issue and continue | | `--abort` | Mark orchestration as aborted and stop remaining work | ## Batch Resolution and Dependency-Ready Scheduling -For `SPEC-XXX`, `TASK-XXX..YYY`, and `TASK-XXX,YYY,ZZZ`: +For a parent ref (`loaf issue tree <ref>`) or a named set of refs: -1. Resolve selected tasks and validate each task file exists. -2. Extract `depends_on` from each task and build a dependency graph. -3. Group tasks into dependency-ready rounds: - - First round: tasks with no unresolved dependencies - - Each subsequent round: tasks whose dependencies are completed in earlier rounds -4. If `--parallel` is set, allow parallel execution only within a dependency-ready round and only for non-conflicting tasks. -5. Present execution plan (tasks, dependency-ready rounds, mode, total count) and ask for confirmation unless `--dry-run`. -6. Track progress in the journal and in task statuses: log round boundaries and the current task with `loaf journal log`, and drive each task's status with `loaf task update`. The journal plus task statuses are the durable record of where the batch is. +1. Resolve the selected refs and validate each issue exists (`loaf issue show <ref>`). +2. Read `blocks` / `blocked_by` edges and parent/child structure. Parent/child is not a sequencing edge — only `blocks` / `blocked_by` are. +3. Group unblocked delivery children into dependency-ready rounds: + - First round: issues with no unresolved predecessors + - Each subsequent round: issues whose predecessors are `done`, `cancelled`, or `duplicate` +4. If `--parallel` is set, allow parallel execution only within a dependency-ready round, max 3, and only when each agent has its own started worktree. +5. Present execution plan (issues, dependency-ready rounds, mode, total count) and ask for confirmation unless `--dry-run`. +6. Track progress in the journal: log round boundaries and the current ref with `loaf journal log`. Status moves through `loaf issue start` (to `active`) and, after landing, `loaf issue status <ref> done`. The journal plus issue statuses are the durable record of where the batch is. + +Parents with children are not the implementation target. Dispatch leaf delivery children that are on `loaf issue frontier`. ## Option Handling (`--continue`, `--skip`, `--abort`) -1. Recover batch progress from the journal: `loaf journal recent --since-last-wrap` (or `loaf journal context`) plus `loaf task list --json` to see which tasks are still open. -2. If `--continue`: resume from the last logged dependency-ready round and task. -3. If `--skip TASK-XXX`: mark that task `skipped` via `loaf task update`, log the reason with `loaf journal log`, continue the same dependency-ready round. +1. Recover batch progress from the journal: `loaf journal recent --since-last-wrap` (or `loaf journal context`) plus `loaf issue list --json` and `loaf issue list --started` to see which issues are still open or claimed. +2. If `--continue`: resume from the last logged dependency-ready round and issue. +3. If `--skip <ref>`: log the reason with `loaf journal log`, continue the same dependency-ready round. Do not mark the skipped issue `done`. 4. If `--abort`: log `block(orchestration): aborted`, print a summary, and stop. 5. If no in-flight batch is evident from the journal, report that and ask for fresh selection input. ## Batch Execution Model -When input resolves to multiple tasks, run a dependency-ready round loop: +When input resolves to multiple issues, run a dependency-ready round loop: 1. Set orchestration mode (`sequential` by default, `parallel` only with `--parallel`). 2. For each dependency-ready round: - Log the round start with `loaf journal log` - - Run each task (sequentially, or concurrently within safety limits) - - For each task: set `in_progress` -> spawn agent -> run task verification -> mark `done`/`failed` via `loaf task update` -3. If any task fails verification, stop immediately and log `block(orchestration): <task> failed <reason>`. -4. Consider a round complete only when all its tasks are `done` or skipped. + - For each issue: `loaf issue list --started`, then `loaf issue start <ref>` unless already started, spawn one agent into `started_worktree`, run `loaf issue verify <ref>` (V-tier; writes nothing) +3. If any issue fails verification, stop immediately and log `block(orchestration): <ref> failed <reason>`. +4. Consider a round complete only when all its issues have landed (`loaf issue status <ref> done` via ship) or were skipped. 5. Continue until all rounds complete, then log a closing entry summarizing the batch. ## Blocked-State Recovery When blocked, always print: -- Failed task ID and title +- Failed issue ref and title - Dependency-ready round and current progress - Failure reason + key error output - Recovery options: Re-invoke the implement workflow with: -- `--continue` — after fixes are applied, retry from the blocked task -- `--skip TASK-XXX` — skip only the specified task and continue remaining tasks in the current dependency-ready round +- `--continue` — after fixes are applied, retry from the blocked issue +- `--skip <ref>` — skip only the specified issue and continue remaining issues in the current dependency-ready round - `--abort` — finalize the orchestration as aborted with no further execution diff --git a/dist/cursor/skills/implement/references/branch-and-completion.md b/dist/cursor/skills/implement/references/branch-and-completion.md index e4bea8b5d..5c89cdf02 100644 --- a/dist/cursor/skills/implement/references/branch-and-completion.md +++ b/dist/cursor/skills/implement/references/branch-and-completion.md @@ -8,7 +8,7 @@ - Linear Status Management - Handoff Readiness - Timestamps for User Context -- Task Completion +- Issue Completion Detailed reference for branch setup, Linear routing, and completion during implementation. @@ -18,28 +18,18 @@ Detailed reference for branch setup, Linear routing, and completion during imple ### Getting Branch Name -1. **If Linear issue exists**: Use the `branchName` field from `get_issue` response - - Linear auto-generates branch names like `username/plt-123-issue-title` - - These are pre-formatted and consistent with team conventions +`loaf issue start <ref>` is the claim. It creates `issue/<alias-or-id>` in lowercase (`issue/loaf-42`, id suffix when that name is claimed), a sibling worktree, and moves status to `active`. -2. **If no Linear issue**: Create branch name from the work description - - Format: `feature/<description>` or `fix/<description>` - - Use kebab-case, keep it concise +Do not `git checkout -b` as a substitute for start. Check `loaf issue list --started` first. Never send two agents into the same worktree. Do not run `loaf issue stop` from inside that worktree. ### Branch Workflow ```bash -# 1. Check current branch status -git status - -# 2. Create and checkout the branch (use Linear's branchName if available) -git checkout -b <branch-name> - -# 3. Confirm branch creation -git branch --show-current +loaf issue list --started +loaf issue start <ref> ``` -**Important:** All implementation agents will work on this branch. The branch should be ready for PR when work completes. Journal entries are tagged with the observed branch automatically, so continuity stays branch-scoped. +Work only in `started_worktree`. The branch should be ready for PR when work completes. Journal entries are tagged with the observed branch automatically. --- @@ -53,7 +43,7 @@ When creating Linear issues, suggest the appropriate team: > "This task seems best suited for the **Security** team (matched: 'auth', 'vulnerability'). > Security hasn't been used in this project yet. Add this team?" 4. **If user confirms**, add team to `known_teams` in config -5. **Create issue** with suggested team +5. **Create via `loaf issue new`** so identity can be delegated; do not create in Linear MCP and forget `loaf issue pull` ### Team Suggestion Example @@ -75,7 +65,7 @@ Use Linear MCP's `list_teams` (if configured) to get all workspace teams for val ## Diagram Consideration -For multi-file or multi-service changes, consider adding architecture diagrams to the linked spec, report, ADR, or implementation notes. +For multi-file or multi-service changes, consider adding architecture diagrams to the issue, a report, ADR, or implementation notes. ### When to Create Diagrams @@ -94,7 +84,7 @@ Ask yourself: 2. Is there a data flow that needs to be understood? 3. Would a visual help communicate the approach? -If yes to any, capture the diagram in a durable artifact such as a spec, report, ADR, or implementation note, and log the reference with `loaf journal log`. +If yes to any, capture the diagram in a durable artifact such as a report, ADR, or implementation note, and log the reference with `loaf journal log`. ### Diagram Template @@ -146,36 +136,23 @@ For complex tasks, explore before implementing: ## Linear Status Management -**Keep Linear status synchronized with actual work state:** - -| Work State | Linear Status (sub-issue) | -|------------|---------------------------| -| Work begun | In Progress | -| Blocked/waiting for user | In Progress (add blocker comment) | -| Work completed | Done (or In Review if PR pending) | +**Keep Loaf status synchronized with actual work state.** Linear is an overlay (`loaf issue pull` / `push` / `reconcile`); never drive Loaf status from Linear MCP tools. -### Parent rollup auto-close +| Work State | Loaf status | +|------------|-------------| +| Work begun | `active` via `loaf issue start` | +| Blocked/waiting | Stay `active`; log `block(scope)` and leave a Linear comment if the overlay is on | +| Work landed | `done` via `loaf issue status <ref> done` (usually ship), then `loaf issue stop <ref>` | -In Linear-native mode, the **parent** rollup issue (labeled `spec`) is not -moved manually during sub-issue work. It flips to Done automatically when -the last sub-issue flips to Done, and only then. Procedure: +### Parent vs children -1. After moving a sub-issue to a `completed`-type state, call - `list_issues` with `parent: <parent-id>`. -2. If every sub-issue is in a `completed`-type state, move the parent to - `completed` via `update_issue`. -3. If any sub-issue is still in an open state (including `blocked`), the - parent stays where it is — the spec is not done. +Parents with children are not the implementation target. Dispatch leaf delivery children on `loaf issue frontier`. A parent is not marked `done` because a child landed. -Never set the parent to In Progress manually — a parent in Linear-native -mode reflects a rollup of its sub-issues, not its own work. +`loaf issue link A blocks B` is the sequencing edge. An issue with an open predecessor does not appear on the frontier. Do not start a blocked successor. -### BlockedBy pre-flight +### Blocked-by pre-flight -Before moving a sub-issue to In Progress, confirm every issue in its -`blockedBy` field is in a `completed`-type state. If not, refuse to start -and report the blockers. This is a hard gate in Linear-native mode — -never implement through open `blockedBy`. +Before `loaf issue start`, confirm the ref is on `loaf issue frontier`. If it is blocked, refuse and report the predecessors. Never implement through an open `blocks` edge. --- @@ -184,7 +161,7 @@ never implement through open `blockedBy`. **The journal must ALWAYS be handoff-ready.** After every significant action: 1. Log what just happened with `loaf journal log` -2. Reference task/spec/report/commit IDs rather than duplicating long prose +2. Reference issue/report/commit IDs rather than duplicating long prose 3. Log completed agent work with outcomes 4. Ensure anyone could pick up the work immediately from `loaf journal recent` @@ -205,32 +182,18 @@ Generate with: `date -u +"%Y-%m-%d %H:%M UTC"` --- -## Task Completion +## Issue Completion -When a task-coupled unit of work completes: +When an issue-coupled unit of work completes: -1. **Update task status** (local file or Linear sub-issue) -2. **Check spec progress:** - - Local-tasks mode: list all tasks for the spec; if all done → mark - spec `complete`, else spec stays `implementing` - - Linear-native mode: query the parent rollup's sub-issues via - `list_issues` with `parent: <parent-id>`; if all are `completed`-type, - close the parent and mark the local spec `complete`, else both stay - in flight -3. **Write a `wrap` journal entry** if the conversation holds synthesis worth - saving (next steps, abandoned paths); skip it otherwise — nothing is - "closed," a conversation that ends without a wrap leaves a valid journal - -### Spec Completion Check +1. **Open or update the PR** with body `loaf issue render <ref>` — no manual editing +2. **Land via ship** — review definition of done, `loaf issue verify <ref>`, squash merge, then `loaf issue status <ref> done` and `loaf issue stop <ref>` +3. **Write a `wrap` journal entry** if the conversation holds synthesis worth saving (next steps, abandoned paths); skip it otherwise — nothing is "closed," a conversation that ends without a wrap leaves a valid journal ```bash -# Local-tasks mode: any open tasks for this spec? -loaf task list --spec SPEC-001 --status open --json - -# Linear-native mode: query the Linear parent's sub-issues -# (via get_issue + list_issues with parent filter) -# The parent itself only flips to Done when every sub-issue is Done. +loaf issue show <ref> +loaf issue tree <ref> +loaf issue list --started ``` -Never mark the local spec `complete` while its Linear parent still has -open sub-issues — the two sources of truth should agree on "done." +Do not mark a parent `done` while delivery children are still open. Do not flip Loaf status from Linear MCP tools; use `loaf issue reconcile` if the overlay has drifted. diff --git a/dist/cursor/skills/implement/templates/journal.md b/dist/cursor/skills/implement/templates/journal.md index caf7409fd..9a0a61f8f 100644 --- a/dist/cursor/skills/implement/templates/journal.md +++ b/dist/cursor/skills/implement/templates/journal.md @@ -13,7 +13,7 @@ not create or edit journal markdown as the source of truth — use ## Entries -[YYYY-MM-DD HH:MM] skill(implement): implementing TASK-042 +[YYYY-MM-DD HH:MM] skill(implement): implementing LOAF-42 [YYYY-MM-DD HH:MM] decision(scope): description of decision [YYYY-MM-DD HH:MM] discover(scope): something learned [YYYY-MM-DD HH:MM] block(scope): what is blocked diff --git a/dist/cursor/skills/loaf-reference/SKILL.md b/dist/cursor/skills/loaf-reference/SKILL.md index 06baf7b87..8f58f82f2 100644 --- a/dist/cursor/skills/loaf-reference/SKILL.md +++ b/dist/cursor/skills/loaf-reference/SKILL.md @@ -25,7 +25,7 @@ The Loaf operating manual for agents: how to discover commands, diagnose project ## Operating Rules - Get exact, current syntax live: `loaf --help` lists every command, `loaf <command> --help` details one. This index is a map, not the contract. -- Prefer `--json` surfaces when diagnosing: `loaf config check --json`, `loaf state doctor --json`, `loaf change check --json`. Parse the structured output instead of scraping human-readable text. +- Prefer `--json` surfaces when diagnosing: `loaf config check --json`, `loaf state doctor --json`. Parse the structured output instead of scraping human-readable text. - Run the deterministic CLI command before hand-editing anything it manages; the command owns its files. - Use `--fix` only for safe, mechanical repairs, and review what it changed. - Ask the user for project-owned choices — GitHub account, tracker or integration election, which harnesses to install — never guess them. @@ -64,17 +64,16 @@ Names and one-line purposes only. Run `loaf <command> --help` for options, argum | `loaf config` | Validate and refresh project Loaf config | check | | `loaf hooks` | Inspect and set which Loaf hooks project into an installed harness's hooks file | list, enable, disable | | `loaf init` | Initialize a project with Loaf structure | — | -| `loaf release` | Create a new release with changelog, version bump, and tag | — | +| `loaf release` | Cut a retroactive release from already-landed work | suggest, cut | | `loaf search` | Search SQLite artifact bodies, journal entries, and indexed docs | — | | `loaf docs` | Manage docs/ indexing | index | -| `loaf change` | Shape-first Change artifacts: git-canonical work context under docs/changes/ | init, check, list | | `loaf render` | Maintain committed durable Markdown renders | sweep | | `loaf state` | Manage native SQLite state | path, status, init, doctor, repair legacy-project-database, repair relationship-origin, repair journal-search, migrate markdown, migrate storage-home, migrate schema, migrate deferrals, migrate lifecycle-statuses, migrate alias-orphans, migrate journal-duplicates, migrate journal-first, backup, backup verify, backup restore, restore-ephemerals, verify-ephemerals, export, export all, export triage, export spec, export release-readiness | | `loaf journal` | Record and read the project-scoped journal (the durable record across all conversations) | log, recent, search, show, context, export, defer | | `loaf project` | Manage durable project identity | list, show, identity, rename, move, delete | | `loaf migrate` | Run native migration workflows | markdown, storage-home, schema, lifecycle-statuses, journal-first, worktree-storage | -| `loaf task` | Manage project tasks | list, show, status, create, update, archive, refresh, sync | -| `loaf spec` | Manage project specs | new, edit, list, show, status, render, finalize, archive, delete | +| `loaf task` | Manage project tasks; superseded by loaf issue for new work | list, show, status, create, update, archive, refresh, sync | +| `loaf issue` | Manage issues in native SQLite state | new, show, list, tree, frontier, start, stop, edit, status, dod, dod add, dod list, dod remove, dod claim, dod unclaim, promote, check, verify, bucket, link, render, export, pull, push, reconcile | | `loaf report` | Manage durable reports (research, audits, investigations) | list, show, render, generate, create, edit, finalize, archive | | `loaf finding` | Manage report findings and verdicts in native SQLite state | list, show, create, verdict, import-json | | `loaf run` | Manage provenance runs for generated findings and reports | list, show, create, complete | @@ -88,7 +87,7 @@ Names and one-line purposes only. Run `loaf <command> --help` for options, argum | `loaf trace` | Trace relationships for one state entity | — | | `loaf brainstorm` | Manage brainstorms in native SQLite state | capture, list, show, promote, archive | | `loaf idea` | Manage ideas in native SQLite state | list, show, capture, promote, resolve, archive | -| `loaf intent` | Manage tracked Intent in native SQLite state; disposition is derived from append-only facts | create, defer, resume, resolve, show, list | +| `loaf intent` | Manage tracked Intent in native SQLite state; disposition is derived from append-only facts; superseded by loaf issue for new work | create, defer, resume, resolve, show, list | | `loaf intake` | Read the deterministic local intake projection; triage judgment stays with humans and Skills | list | | `loaf exploration` | Manage relational Exploration continuity: immutable portable checkpoints, no lifecycle status, no current pointer | create, checkpoint, list, context, conversation | | `loaf conversation` | Manage logical conversations and machine-local provenance handles; handles never imply portable context | create, show, list, handle, observe | diff --git a/dist/cursor/skills/loaf-reference/references/command-routing.md b/dist/cursor/skills/loaf-reference/references/command-routing.md index e6856fd65..5f9d30bbb 100644 --- a/dist/cursor/skills/loaf-reference/references/command-routing.md +++ b/dist/cursor/skills/loaf-reference/references/command-routing.md @@ -6,15 +6,15 @@ Which command a task needs. For exact flags, run `loaf <command> --help`. | Intent | Route | |--------|-------| -| Shape new bounded work | `loaf change init <slug>`, then `loaf change check` | -| Start implementing new bounded work | the implement workflow after shaping and validating its Change | -| Continue an existing task or spec record | `loaf task` and `loaf spec` remain supported for existing records | +| Shape new bounded work | `loaf issue new <title>`, then `loaf issue dod add` and `loaf issue check <ref>` | +| Start implementing new bounded work | the implement workflow: pick from `loaf issue frontier`, then `loaf issue start <ref>` | +| Continue an existing task or spec record | `loaf task` and `loaf spec` remain readable for legacy records; new work is issues | | Continue after a restart | `loaf journal context` | | Skills or content changed | `loaf build && loaf install --to <target>` | -| See what is in progress | `loaf task list --active` | -| Archive completed work | `loaf task archive` | +| See what is in progress | `loaf issue list --status active` and `loaf issue list --started` | +| Remove finished-with work | `loaf issue status <ref> cancelled` or `duplicate --duplicate-of <ref>` (archives; record survives) | | Check knowledge freshness | `loaf kb check` | -| Validate a Change is structurally executable, not implementation-complete | `loaf change check --require-executable` | +| Validate an issue is shaped, covered, and contained | `loaf issue check <ref>` (non-zero exit names each failure) | | Import legacy `.agents` Markdown into SQLite | `loaf migrate markdown --dry-run` then `--apply` (see markdown-migration reference) | ## JSON diagnosis surfaces @@ -24,10 +24,10 @@ scraping human-readable text: - `loaf config check --json` — config file and installed hook config validity - `loaf state doctor --json` / `loaf state status --json` — SQLite health and readiness -- `loaf change check --json` — Change violations and derived executability +- `loaf issue check <ref> --json` — derived readiness, coverage, and containment - `loaf check --hook <id> --json` — one enforcement hook's result - `loaf kb check --json` — knowledge staleness against git history -- `loaf task list --json` / `loaf journal recent --json` — current work and timeline +- `loaf issue list --json` / `loaf journal recent --json` — current work and timeline - `loaf migrate markdown --dry-run --json` — `mode` (`simulation`/`inventory`) plus `import_report` when simulated Choosing between the `doctor` commands and `LOAF_DB` isolation are covered in diff --git a/dist/cursor/skills/orchestration/SKILL.md b/dist/cursor/skills/orchestration/SKILL.md index 2013e84f9..37fd15126 100644 --- a/dist/cursor/skills/orchestration/SKILL.md +++ b/dist/cursor/skills/orchestration/SKILL.md @@ -42,9 +42,9 @@ Comprehensive patterns for orchestration: coordinating multi-agent work, keeping - Outcome-focused, self-contained, no local file references - Magic words in commit body, not subject -**If `integrations.linear.enabled` is `true` in `.agents/loaf.json`:** use Linear MCP workflows and [references/linear.md](references/linear.md) for issue updates and status. +**If `integrations.linear.enabled` is `true` in `.agents/loaf.json`:** Linear is an identity adapter — `loaf issue pull` / `push` / `reconcile`, not a second work unit. See [references/linear.md](references/linear.md). Linear MCP is an overlay; Loaf issues remain the work unit and Linear never drives Loaf status. -**Otherwise:** coordinate with the project journal and `loaf task` / file-based tracking only; do not assume Linear MCP tools are available. +**Otherwise:** coordinate with the project journal and `loaf issue` only; do not assume Linear MCP tools or identity delegation are available. ### Planning (Shape Up) - Complexity-based sizing (small / medium / large) @@ -71,15 +71,15 @@ Comprehensive patterns for orchestration: coordinating multi-agent work, keeping | Pre-compaction | On an exact target mode with supported compaction delivery, hooks may nudge a journal flush and emit the digest afterward; otherwise flush manually and run `loaf journal context` after compaction | | Durable artifact handling | Delegate `.agents/`-scoped report/spec/handoff/knowledge tending to `librarian` | | Low-priority work | Spawn background-runner (see Background Agents) | -| New feature workflow | Research -> Architecture -> Shape -> Breakdown -> Implement | +| New feature workflow | Pitch -> Shape -> Implement -> Ship -> Release | ## Topics | Topic | Reference | Use When | |-------|-----------|----------| -| Shaping Specs | [../shape/SKILL.md](../shape/SKILL.md) | Creating specs, shaping work, defining test conditions | -| Breaking Work Into Tasks | [../breakdown/SKILL.md](../breakdown/SKILL.md) | Turning shaped specs into implementation tasks | -| Local Tasks | [references/local-tasks.md](references/local-tasks.md) | Managing tasks locally or with Linear backend | +| Shaping Issues | [../shape/SKILL.md](../shape/SKILL.md) | Preparing issues: body, definition of done, out of scope | +| Decomposition | [../shape/SKILL.md](../shape/SKILL.md) | Promoting a criterion that earns its own DoD (`loaf issue promote`) | +| Working Issues | [references/local-tasks.md](references/local-tasks.md) | Frontier, started worktrees, status, definition of done | | Agent Delegation | [references/delegation.md](references/delegation.md) | Choosing agents, spawning subagents, decision trees | | Parallel Agents | [references/parallel-agents.md](references/parallel-agents.md) | Dispatching independent work concurrently | | Subagent Development | [references/subagent-development.md](references/subagent-development.md) | Delegating to specialized agents | @@ -96,7 +96,7 @@ Comprehensive patterns for orchestration: coordinating multi-agent work, keeping The orchestrator: 1. Creates issues and logs the orchestration intent for tracking -2. Breaks down work into delegable tasks +2. Picks from `loaf issue frontier` and starts one worktree per issue 3. Spawns specialized agents for implementation 4. Coordinates outcomes and updates external systems 5. Never implements code, tests, or documentation directly @@ -126,16 +126,16 @@ This skill uses paths from `.agents/loaf.json`: | Councils | `.agents/councils/` | `.agents/councils/archive/` | `YYYYMMDD-HHMMSS-topic.md` | | Handoffs | `.agents/handoffs/` | delete after deprecated | Created by handoff | | Reports | `.agents/reports/` | N/A | `YYYYMMDD-HHMMSS-subject.md` | -| Tasks | SQLite (`loaf task show/list`) | N/A | Per task manager conventions | +| Issues | SQLite (`loaf issue show/list`) | `cancelled` / `duplicate` via `loaf issue status` | Alias or opaque id | **Rule:** Agents write artifacts to disk, orchestrator reasons over artifacts, users retrieve from disk. ## Workflow by Lifecycle ### BEFORE (Planning) -- Create/check external issue (Linear, GitHub) +- Shape prepares issues; implement works the frontier. Decomposition is `loaf issue promote` inside shape. - Log the orchestration intent with `loaf journal log` -- Break down into tasks, identify agents, get user approval +- `loaf issue check <ref>` must report shaped (delivery) or ready (decision); identify agents; get user approval ### DURING (Execution) - Spawn specialized agents (never implement directly) @@ -144,6 +144,6 @@ This skill uses paths from `.agents/loaf.json`: ### AFTER (Completion) - Code review + QA testing -- Update external issue to Done +- Land via ship: `loaf issue status <ref> done`, then `loaf issue stop <ref>` - Ensure knowledge captured in permanent locations - Write an optional `wrap` journal entry if the conversation holds synthesis worth saving diff --git a/dist/cursor/skills/orchestration/references/background-agents.md b/dist/cursor/skills/orchestration/references/background-agents.md index 6aa2b3c61..95b4b3b54 100644 --- a/dist/cursor/skills/orchestration/references/background-agents.md +++ b/dist/cursor/skills/orchestration/references/background-agents.md @@ -43,7 +43,7 @@ Task( - src/services/ Write report to: .agents/reports/YYYYMMDD-HHMMSS-security-audit.md - Reference: TASK-123, SPEC-045 if relevant + Reference: LOAF-123 if relevant """, run_in_background=True ) @@ -51,12 +51,12 @@ Task( ### Cursor -Background agents are configured via the `is_background: true` YAML property. When spawning, specify the report destination and any task/spec IDs: +Background agents are configured via the `is_background: true` YAML property. When spawning, specify the report destination and any issue refs: ``` @background-runner Run security audit on backend codebase. Write report to .agents/reports/. -Reference TASK-123 if relevant. +Reference LOAF-123 if relevant. ``` The background agent's journal entries are tagged with its own harness id automatically — there is no session alias to pass. @@ -72,7 +72,7 @@ Track background work with durable references: 1. Log the spawn with `loaf journal log "todo(background): started <id> for <task>"`. 2. Ask the background agent to write a report under `.agents/reports/`. 3. When complete, log `discover(background): <id> wrote <report>`. -4. Process findings into tasks, specs, ADRs, or report verdicts as appropriate. +4. Process findings into issues, ADRs, or report verdicts as appropriate. Use a stable ID such as `bg-YYYYMMDD-HHMMSS-description` in the prompt and journal entries. @@ -85,7 +85,7 @@ Background agents write results to `.agents/reports/` with enough metadata to id 1. Orchestrator identifies non-blocking security audit work. 2. Orchestrator logs the background spawn to the journal. 3. Background agent writes `.agents/reports/YYYYMMDD-HHMMSS-auth-security.md`. -4. Orchestrator reviews the report, creates follow-up tasks, and logs the outcome. +4. Orchestrator reviews the report, creates follow-up issues, and logs the outcome. 5. Report state is finalized or archived through the report lifecycle. ## Anti-Patterns @@ -94,7 +94,7 @@ Background agents write results to `.agents/reports/` with enough metadata to id |-------|------------| | Use for blocking work | Keep blocking work in foreground | | Spawn without tracking | Log the spawn and require a report path | -| Ignore completed results | Process reports into tasks, findings, or decisions | +| Ignore completed results | Process reports into issues, findings, or decisions | | Use for interactive tasks | Reserve for autonomous work | | Spawn many concurrent background agents | Limit concurrency to avoid resource contention | | Skip result location in prompt | Always specify where output belongs | diff --git a/dist/cursor/skills/orchestration/references/context-management.md b/dist/cursor/skills/orchestration/references/context-management.md index 11ce34ac7..49151a167 100644 --- a/dist/cursor/skills/orchestration/references/context-management.md +++ b/dist/cursor/skills/orchestration/references/context-management.md @@ -19,28 +19,28 @@ Patterns for keeping long work resumable while using the project journal as exte Compaction is normal in long workflows. Design work that spans many exchanges so important state is already outside chat context. 1. **The journal is external memory.** Record decisions, discoveries, blockers, and next actions with `loaf journal log`. -2. **Artifacts carry detail.** Changes, task-board records, reports, ADRs, and commits hold rich detail; journal entries point to them. +2. **Artifacts carry detail.** Issues, reports, ADRs, and commits hold rich detail; journal entries point to them. 3. **Delegated work absorbs exploration.** Use delegated agents for broad investigation and return concise findings to the main context. -4. **`wrap` captures synthesis.** When meaningful work holds intentions or abandoned paths worth saving, write an optional `wrap` journal entry. +4. **`wrap` captures synthesis.** When meaningful work holds abandoned paths worth saving, write an optional `wrap` journal entry. ## Continuity Digest (contract v2) -`loaf journal context` is the contract-v2 active-truth digest and supersedes the retired three-part summary. Read its named layers and diagnostics; an absent item and an unavailable source are different states. +`loaf journal context` is the contract-v2 active-truth digest. Read its named layers and diagnostics; an absent item and an unavailable source are different states. Layer *names* below are the live CLI identifiers. | Layer | Truth and precedence | |-------|----------------------| | `project-synthesis` | The latest `wrap(project)` synthesis. This is the only wrap that represents project-wide synthesis. | | `scoped-checkpoint` | The latest non-project wrap, only when `project-synthesis` has no item. It is labeled as a fallback, not promoted to project synthesis. | -| `active-lineage` | Journal evidence associated with the active Change lineage. | +| `active-lineage` | Journal evidence associated with the active work lineage. | | `unresolved-blockers` | Blocks without a later exact-scope unblock. | -| `deferred-intent` | Open deferred-intent decision and spark pairs. | -| `active-changes` | Git-derived active Change evidence and worktree state. | +| `deferred-intent` | CLI layer for open deferred decision and spark pairs. | +| `active-changes` | Git-derived active worktree evidence. | | `branch-recency` | Recent branch entries after entries already surfaced as active truth are removed. | -| `transitional-tasks` | Open task-board records retained for compatibility. | +| `transitional-tasks` | Leftover board records retained for compatibility — not the work unit. Prefer `loaf issue frontier` / `loaf issue list --started`. | -Each returned layer includes `source_available`, `available_count`, `shown_count`, `truncated`, and `expand_command`; paginated layers also include a cursor. Treat `source_available: false` as an explicit unavailable source, never as “nothing is active.” If Change discovery is unavailable, `active-changes` and `active-lineage` are unavailable and the digest carries a diagnostic. +Each returned layer includes `source_available`, `available_count`, `shown_count`, `truncated`, and `expand_command`; paginated layers also include a cursor. Treat `source_available: false` as an explicit unavailable source, never as “nothing is active.” If git-derived discovery is unavailable, `active-changes` and `active-lineage` are unavailable and the digest carries a diagnostic. -Use `--branch` to select `branch-recency` scope and bind state cursors. It does not override active Change provenance or reasons, which always use the actual Git branch. Use `loaf journal context --layer <name>` to inspect one layer. `--limit` accepts 1 through 100 and requires `--layer`; `--cursor` also requires `--layer` and cannot be used with the intrinsic one-item `project-synthesis` or `scoped-checkpoint` layers. Follow the returned `expand_command` exactly: a cursor is bound to its layer, project, branch, snapshot, and limit. Use `--json` for automation; the human view preserves availability, counts, truncation, diagnostics, and expansion commands. +Use `--branch` to select `branch-recency` scope and bind state cursors. It does not override git provenance or reasons, which always use the actual Git branch. Use `loaf journal context --layer <name>` to inspect one layer. `--limit` accepts 1 through 100 and requires `--layer`; `--cursor` also requires `--layer` and cannot be used with the intrinsic one-item `project-synthesis` or `scoped-checkpoint` layers. Follow the returned `expand_command` exactly: a cursor is bound to its layer, project, branch, snapshot, and limit. Use `--json` for automation; the human view preserves availability, counts, truncation, diagnostics, and expansion commands. ## Context Commands @@ -53,21 +53,21 @@ Use `--branch` to select `branch-recency` scope and bind state cursors. It does ## When to Clear Context -Clear the conversation when starting a completely new task, after the previous task is complete, when debugging noise crowds out the current objective, or when switching between unrelated codebases. Avoid clearing mid-task until enough state is logged for recovery. +Clear the conversation when starting a completely new issue, after the previous issue is complete, when debugging noise crowds out the current objective, or when switching between unrelated codebases. Avoid clearing mid-issue until enough state is logged for recovery. ## Compaction Lifecycle PreCompact: 1. Flush unrecorded decisions, discoveries, blockers, and next actions with `loaf journal log`. -2. Reference Changes, task-board records, reports, commits, and files by stable ID or path. +2. Reference issues, reports, commits, and files by stable ID or path. 3. On an exact target mode with supported PreCompact delivery, let the hook nudge the flush; otherwise flush manually before compacting. PostCompact: 1. On an exact target mode with supported resumption delivery, read the continuity digest emitted by the hook; otherwise run `loaf journal context` explicitly. 2. Expand the named layer that needs more detail, or use `loaf journal recent` and `loaf journal search` for a different query. -3. Continue from the journal and linked artifacts. +3. Continue from the journal and linked artifacts (`loaf issue show <ref>`). This makes compaction survivable without relying on hand-maintained Markdown state. State not logged or captured in a durable artifact can be lost. @@ -79,10 +79,10 @@ Use delegated agents to investigate without filling the main context. |-----------|----------| | Quick file lookup | Direct read or search tool | | Multi-file exploration | Explorer or research agent | -| Implementation work | Implementer or task-focused agent | +| Implementation work | Implementer in the issue's started worktree | | Long audit | Background agent with report output | -Pass stable references to delegated agents: Change IDs, task IDs, branch names, and report paths. The harness ID is attached to journal entries automatically; there is no session alias to pass. +Pass stable references to delegated agents: issue IDs, branch names, worktree paths, and report paths. The harness ID is attached to journal entries automatically; there is no session alias to pass. Never send two agents to the same started worktree. ## Context Budget Guidelines @@ -109,12 +109,12 @@ No special management is usually needed. | Repeating same mistakes | Context pollution | Log current facts, then clear or compact | | Forgetting recent decisions | Overcrowded context | Read `loaf journal context` and expand the relevant layer | | Slow responses | Large context | Delegate exploration | -| Confusion about task | Too many pivots | Re-anchor on Change or task IDs | +| Confusion about the work | Too many pivots | Re-anchor on issue IDs (`loaf issue show`) | ## Best Practices 1. Log durable facts early with `loaf journal log`. 2. Use delegated agents for exploration-heavy work. -3. Clear between unrelated tasks. -4. Compact mid-task when the journal and artifacts are current. +3. Clear between unrelated issues. +4. Compact mid-issue when the journal and artifacts are current. 5. Scope tool calls so context stays focused. diff --git a/dist/cursor/skills/orchestration/references/delegation.md b/dist/cursor/skills/orchestration/references/delegation.md index b710244b6..a620c0ba7 100644 --- a/dist/cursor/skills/orchestration/references/delegation.md +++ b/dist/cursor/skills/orchestration/references/delegation.md @@ -131,8 +131,8 @@ Use when work is truly independent. Spawn multiple agents in the same turn when 1. **Be specific in prompts** - Include file paths, requirements, constraints 2. **One concern per agent** - Don't ask a backend implementer to also write tests -3. **Include context** - Task/spec IDs, issue ID, previous outcomes -4. **Reference durable artifacts** - Task, spec, and report IDs; the subagent's journal entries are harness-id tagged automatically +3. **Include context** - Issue refs (`LOAF-42` or opaque id), previous outcomes +4. **Reference durable artifacts** - Issue aliases and report IDs; the subagent's journal entries are harness-id tagged automatically 5. **Include skill hints** - Name the skills that should guide the agent's work ### Skill Hints @@ -171,8 +171,7 @@ Files: - src/api/users.py - src/models/user.py -Task: TASK-042 -Linear: BACK-123 +Issue: LOAF-42 ``` ## Anti-Patterns @@ -183,7 +182,7 @@ Linear: BACK-123 | Asking backend implementer for React | Spawn implementer with frontend skills | | Single agent for database + backend + tests | Sequential: implementer (database-design), implementer (language skill), implementer (foundations) | | Parallel spawns with hidden dependencies | Make dependencies explicit, spawn sequentially | -| Spawning without context | Reference task/spec/report IDs in prompts | +| Spawning without context | Reference issue aliases and report IDs in prompts | | Council for simple decisions | Single agent or orchestrator judgment | ## Agent Access Hierarchy diff --git a/dist/cursor/skills/orchestration/references/journal.md b/dist/cursor/skills/orchestration/references/journal.md index de2a9de3a..442f2b216 100644 --- a/dist/cursor/skills/orchestration/references/journal.md +++ b/dist/cursor/skills/orchestration/references/journal.md @@ -42,20 +42,20 @@ loaf journal log "spark(scope): possible follow-up idea" loaf journal log "todo(scope): concrete follow-up action" ``` -Log durable facts, not thoughts. Reference task IDs, spec IDs, report IDs, and +Log durable facts, not thoughts. Reference issue IDs, report IDs, and commit refs rather than pasting long prose. The journal should let another agent resume without reading the whole conversation. ## Codex Auto Mode -When the user has explicitly enabled Loaf's managed Codex Auto-journal capability, use the exact path-pinned command shown in the Loaf-managed block of `CODEX_HOME/AGENTS.md`; its form is `'<canonical-loaf-path>' journal log --execpolicy-safe "decision(scope): chose X because Y"`. Do not substitute a bare `loaf`, alternate executable, or shell/environment wrapper. The installed Codex rule authorizes only explicitly classified basic Loaf command leaves outside the workspace sandbox, including this hardened journal writer and approved readers; ordinary `journal log`, body/file-consuming leaves, and path-taking `change check` remain operator-gated, and the policy does not authorize a general Loaf data-directory writable root. Other harness adapters are not implied and continue to use their own ordinary surfaces. +When the user has explicitly enabled Loaf's managed Codex Auto-journal capability, use the exact path-pinned command shown in the Loaf-managed block of `CODEX_HOME/AGENTS.md`; its form is `'<canonical-loaf-path>' journal log --execpolicy-safe "decision(scope): chose X because Y"`. Do not substitute a bare `loaf`, alternate executable, or shell/environment wrapper. The installed Codex rule authorizes only explicitly classified basic Loaf command leaves outside the workspace sandbox, including this hardened journal writer and approved readers; ordinary `journal log`, body/file-consuming leaves, and other path-taking operator-gated leaves remain operator-gated, and the policy does not authorize a general Loaf data-directory writable root. Other harness adapters are not implied and continue to use their own ordinary surfaces. Enable the capability once with `loaf install --to codex --codex-basic-commands`. Installation is an explicit trust decision. If the rules are absent, stale, locally modified, or conflict with user-owned `loaf.rules`, Loaf reports the condition instead of overwriting it or asking for full system access. ## Wrap: Optional Checkpoint A `wrap` entry is a voluntary checkpoint, not a lifecycle transition. Write one -only when the conversation holds synthesis worth saving — intentions, abandoned +only when the conversation holds synthesis worth saving — abandoned paths, next steps — the connective narrative that evaporates with the context window. Almost everything else is derivable from raw entries. @@ -70,11 +70,12 @@ perfectly valid journal. A wrap reviews its own conversation's entries first: loaf journal recent --since-last-wrap ``` -See the `wrap` skill for the full checkpoint flow. +See the `wrap` skill for the full checkpoint flow. Loose ends name issues +(`loaf issue frontier`, `loaf issue list --started`), not board leftovers. ## Derived Continuity -Continuity is computed at read time and never persisted. On an exact target mode whose startup delivery capability is supported, the startup adapter may emit a layered digest with the latest project-level wrap, recent entries scoped to the current branch/worktree, and open (`in_progress`/`pending`) tasks. When startup delivery is candidate or unsupported for the current mode, explicitly run `loaf journal context` at conversation start. On demand, reproduce or extend the digest: +Continuity is computed at read time and never persisted. On an exact target mode whose startup delivery capability is supported, the startup adapter may emit a layered digest with the latest project-level wrap, recent entries scoped to the current branch/worktree, and open work. Bind that open work to `loaf issue frontier` and `loaf issue list --started`. When startup delivery is candidate or unsupported for the current mode, explicitly run `loaf journal context` at conversation start. On demand, reproduce or extend the digest: ```bash loaf journal context # the layered continuity digest @@ -82,7 +83,7 @@ loaf journal recent --branch <b> # recent entries for one branch loaf journal search <query> # find prior decisions by topic ``` -Pass task/spec/report references to background and delegated agents. The harness +Pass issue IDs and report references to background and delegated agents. The harness id is attached automatically — there is no session alias to pass along. ## Recovery @@ -91,7 +92,7 @@ After compaction, a branch switch, or a long gap: 1. Read `loaf journal context`; on an exact target mode with supported resumption delivery, the digest emitted by the adapter is equivalent continuity context. 2. Widen with `loaf journal recent` / `loaf journal search` when more is needed. -3. Compare against `git status`, `git log`, and the relevant specs/tasks. +3. Compare against `git status`, `git log`, `loaf issue frontier`, and `loaf issue list --started`. 4. If code and journal have drifted, log the reconciliation: `loaf journal log "decision(recovery): rewound to <commit>; replaying tests"`. @@ -108,7 +109,7 @@ After compaction, a branch switch, or a long gap: | Don't | Do Instead | |-------|------------| | Wait to log everything at the end | Log significant facts as they happen | -| Store decisions only in chat context | Log them and promote durable ones to ADR/spec/report/docs | +| Store decisions only in chat context | Log them and promote durable ones to ADR/issue body/report/docs | | Write a placeholder wrap out of ceremony | Wrap only when there's synthesis worth saving | | Treat a missing wrap as an open loop | A conversation without a wrap is complete and valid | | Pass a session alias to delegated agents | Nothing to pass — the harness id is automatic | diff --git a/dist/cursor/skills/orchestration/references/linear.md b/dist/cursor/skills/orchestration/references/linear.md index f488726cf..f00960ebc 100644 --- a/dist/cursor/skills/orchestration/references/linear.md +++ b/dist/cursor/skills/orchestration/references/linear.md @@ -7,8 +7,7 @@ Guidelines for writing Linear issue updates, comments, and commit messages with - Configuration - MCP Server Naming - Multi-Workspace Guidance -- Linear-Native Mode (Parent + Sub-Issues) -- The `spec` Label Convention +- Identity Adapter - Progress Update Format - Issue Description Format - Status Conventions @@ -97,96 +96,44 @@ Match the `linear.mcp_server_name` in each project's `.agents/loaf.json` to the name used in that project's `.mcp.json`. That way the Loaf skills invoke the right workspace automatically. -## Linear-Native Mode (Parent + Sub-Issues) +## Identity Adapter -In Linear-native mode (`integrations.linear.enabled: true`), each spec -produces one parent **rollup issue** and N sub-issues under it. +When `issue_identity.authority = linear`, Linear owns identity, title, status, +and assignment. Loaf owns shaping state: body, definition-of-done criteria, +claims, and the started worktree. The Loaf issue is the work unit. Linear MCP +is an overlay — never drive Loaf status from MCP tools. -``` -Agent framework alignment ← parent, label: `change` -├── Split reviewer profile into reviewer/auditor ← sub-issue, label: type/refactor -├── Harden MCP fallback path ← sub-issue, label: type/feature -└── Migrate legacy task references ← sub-issue, label: type/refactor -``` - -### Parent issue — what it is and isn't - -The parent issue is a **dashboard anchor**, not a re-hosting of the spec. - -- **Is:** a short summary (1–3 paragraphs) of the problem and solution - direction + a link to the canonical spec file in the repo. -- **Is not:** a copy of the spec's Scope / Rabbit Holes / Open Questions / - Risks sections. Those live in the local spec file and evolve there. - -### Sample parent description +`loaf issue new` delegates identity: Linear mints the identifier, and that +key becomes the local alias. The local counter is not advanced. If Linear is +offline, refuse — capture via `loaf spark` or `loaf idea`. Do not mint a +local alias as a fallback. -```markdown -## Summary -Align Loaf's agent profiles with the three-role model (implementer, reviewer, -researcher). Consolidate historical profile variants and add tool-boundary -tests so profiles can't drift without a test failing. - -## Context -See the canonical change file in the repository for full text, council -references, rabbit holes, and strategic tensions. +If Linear created an issue but the local bind failed, adopt it: -## Progress -Sub-issues track execution. +```text +loaf issue pull <linear-key> +loaf issue pull <linear-key> --tree ``` -### Sub-issues - -- Each sub-issue has `parentId` set to the parent issue ID. -- Cross-task dependencies use Linear's `blockedBy` field referencing sibling - sub-issue IDs. -- Sub-issue labels describe the task itself (type, team, area), not the - parent — don't label sub-issues with `spec`. -- Starting a sub-issue promotes the parent rollup from `backlog`/`unstarted` - to the team's `started`/In Progress state. Parent promotion is a state - invariant of the start operation, not a separate manual reminder. -- Do not silently reopen protected parents. If the parent is `completed`, - `canceled`, or archived, stop and ask for an explicit override before - starting the child. - -### Spec file remains canonical - -Even with the parent in Linear, the local spec file is the source of truth -for: +`--tree` also adopts the sub-issue tree with parent edges intact. -- Problem statement and solution direction -- Scope / in-scope / out-of-scope / rabbit holes / no-gos -- Risks and open questions -- Council references and strategic tensions +### Commands -When the spec evolves, edit the file and let git track it. The parent -issue's summary is a frozen entry point; only refresh it if the summary -itself (not the rabbit holes or risks) changes meaningfully. - -## The `spec` Label Convention - -Every spec-parent rollup issue carries a Linear label named `spec`. This lets -anyone in Linear filter for "all spec roots" across projects without having to -know which issues happen to be parents. - -| Field | Value | -|-------|-------| -| Name | `spec` | -| Color | `#5e6ad2` (suggested; implementer may adjust) | -| Description | `Parent rollup issue representing a design spec tracked in the repo at .agents/specs/` | -| Scope | Workspace-scoped preferred; fall back to team-scoped if the MCP requires it | - -### Who creates it - -breakdown creates the `spec` label on first Linear-native breakdown in a -workspace that doesn't already have it. Subsequent breakdowns reuse the -existing label. Log whether the label was created this run or already -existed — this matters for first-time setup. +```text +loaf issue pull <linear-key> [--tree] [--json] +loaf issue push <ref> [--json] +loaf issue reconcile [<ref>] [--take-local|--take-tracker] [--json] +``` -### Sub-issues never carry `spec` +| Command | Writes? | What it does | +|---------|---------|----------------| +| `loaf issue pull` | Yes | Adopt an existing Linear issue as a local row. The Linear key becomes the alias | +| `loaf issue push` | Yes | Write `loaf issue render` as the Linear description. Status is written only when the local status event is newer than the tracker. Never renames the Linear issue | +| `loaf issue reconcile` | Yes with a take flag | Compare local and Linear. Title drift updates the local title (tracker wins). Status drift is reported; `--take-local` or `--take-tracker` resolves it. Description drift is reported only | -`spec` applies only to parents. A sub-issue describing a task uses its own -labels (type groups like `feature`/`bug`/`refactor`, team labels, area -labels) — never `spec`. This keeps the "filter for spec roots" query clean. +Do not create records with `loaf task` or `loaf spec`. Parent/child structure +is `loaf issue promote` (or `loaf issue new --parent`), not a `spec`-labeled +Linear rollup. ## Progress Update Format @@ -222,18 +169,9 @@ None currently. ## Issue Description Format -```markdown -## Summary -Brief description of the work and its purpose. - -## Acceptance Criteria -- [ ] Criterion 1 -- [ ] Criterion 2 -- [ ] Criterion 3 +The Linear description is `loaf issue push` output — `loaf issue render`, not a hand-authored summary. Do not paste a competing description over the render. -## Notes -Any relevant context (keep brief). -``` +Comments (not the description) still follow the progress-update format above. **Rules:** - Concise and actionable @@ -243,6 +181,8 @@ Any relevant context (keep brief). ## Status Conventions +Loaf status is `loaf issue status`. Linear status is the tracker's. Resolve drift with `loaf issue reconcile` (`--take-local` or `--take-tracker`). Do not flip Loaf status from Linear MCP tools. + | State | When to Use | |-------|-------------| | **Backlog** | Issue created, not started | @@ -341,6 +281,8 @@ Use `scripts/suggest-team.py "task desc"` to get suggestions. ## When to Create Issues +Create through `loaf issue new` so identity can be delegated. Do not create in Linear MCP and then forget to `loaf issue pull`. + | Action | Create Issue? | |--------|---------------| | Features, bugs, refactoring | Yes | diff --git a/dist/cursor/skills/orchestration/references/local-tasks.md b/dist/cursor/skills/orchestration/references/local-tasks.md index 69c89df64..30d617f47 100644 --- a/dist/cursor/skills/orchestration/references/local-tasks.md +++ b/dist/cursor/skills/orchestration/references/local-tasks.md @@ -1,259 +1,173 @@ -# Local Task Management +# Working Issues Locally -Break specs into atomic tasks using SQLite-backed Loaf task records when Linear -isn't available. +Orchestration-facing reference for the Loaf issue CLI: pick-up-next, started +worktrees, status, definition of done, and advisory labels. Issue commands +require initialized SQLite state. ## Contents -- Task Abstraction Layer -- Local Task Records -- Task Lifecycle -- Creating Tasks from Specs -- Cutover Reconciliation -- Task ID Generation -- Archiving Tasks -- Journal Integration -- Task Sizing -- Priority Levels -- Listing Tasks -- Work Log Updates -- Verification -- Local vs Linear Comparison - -## Task Abstraction Layer - -Tasks work identically whether backed by Linear or local SQLite state. - -### Configuration - -```yaml -# .agents/loaf.yaml -task_management: - backend: linear # or "local" - - linear: - team: ProjectName - default_labels: [] - - local: - archive_completed: true -``` - -### Abstracted Operations - -| Operation | Linear | Local | -|-----------|--------|-------| -| Create task | Create issue | `loaf task create --spec SPEC-XXX --title "..." --priority P1` | -| Fetch task | Get issue | `loaf task show TASK-XXX` or `loaf task show TASK-XXX --json` | -| Update status | Update issue | `loaf task update TASK-XXX --status in_progress` | -| List tasks | List issues | `loaf task list` (or `loaf task list --json` for machine parsing) | -| Complete | Move to Done | `loaf task update TASK-XXX --status done` | - -## Local Task Records +- Frontier +- Started worktree +- Status vocabulary +- Relationships +- Definition of done +- Buckets +- Command cheat sheet +- LEGACY -SQLite is the operational source of truth for task metadata, status, priority, -dependencies, dates, and relationships. Use `loaf task` CLI commands for all -task mutations. `.agents/tasks/` and `.agents/TASKS.json` were removed by the -SPEC-045 cutover and are rollback material only. - -**Create with:** `loaf task create --title "..." --spec SPEC-XXX` - -## Task Lifecycle +## Frontier +```text +loaf issue frontier [--json] ``` -todo → in_progress → review → done - │ │ │ │ - └────────┴───────────┴────────┘ - can return to earlier states -``` - -| Status | Meaning | -|--------|---------| -| `todo` | Ready to work, not started | -| `in_progress` | Actively being worked | -| `review` | Implementation complete, needs verification | -| `done` | Verified complete, ready for archive | - -## Creating Tasks from Specs - -### Input -- Spec ID (e.g., `SPEC-001`) -- Optional: priority override +Pick-up-next. Derived at read time, never stored. Lists non-archived issues in +`triage`, `backlog`, or `todo` that are not blocked. -### Task Breakdown Rules +| Qualifier | Meaning | +|-----------|---------| +| Open | Status is `triage`, `backlog`, or `todo` — not `active`, `done`, `cancelled`, or `duplicate` | +| Unblocked | No open predecessor via `blocks` / `blocked_by`. A predecessor that is `done`, `cancelled`, or `duplicate` does not block | +| Unclaimed | Not `active` and no started worktree. `loaf issue start` is the claim | -1. **One concern per task** - Don't mix backend + tests + frontend -2. **Clear done condition** - Observable, verifiable outcome -3. **Verification command** - How to prove it works -4. **File hints** - Which files will likely be modified +Archived rows are excluded. Kind is not filtered: a `--kind decision` question +can appear; it is not delivery work. Buckets are not read. Prefer `--json` +when diagnosing rather than scraping the human-readable text. -### Example Breakdown +## Started worktree +```text +loaf issue start <ref> [--json] +loaf issue stop <ref> [--force] [--json] +loaf issue list --started [--json] ``` -SPEC-001: User Authentication with OAuth - ↓ -TASK-001: OAuth Provider Integration - - Google OAuth client setup - - GitHub OAuth client setup - - Token exchange logic - verify: pytest tests/auth/test_oauth.py - -TASK-002: Session Management - - Session cookie handling - - Session storage (Redis/DB) - - Session expiry logic - verify: pytest tests/auth/test_session.py - -TASK-003: Login UI Components - - Login page layout - - Provider buttons - - Error states - verify: npm run test:e2e -- auth -``` - -## Cutover Reconciliation - -If a stale branch reintroduces `.agents/tasks/`, `.agents/sessions/`, other -ephemeral roots, or `.agents/TASKS.json`, keep the deletion side from the -cutover branch and rerun `loaf check --hook ephemeral-provenance`. Use -`loaf state restore-ephemerals <backup-id>` only for an intentional rollback, -then re-import forward. - -## Task ID Generation -Format: `TASK-{number}-{slug}` +**Invariant:** one agent, one worktree. Check `loaf issue list --started` +before dispatch. Never send two agents into the same path. -Task IDs are auto-generated by `loaf task create`. In SQLite-backed projects, -the allocation is recorded in state. +`start` creates branch `issue/<alias-or-id>` in lowercase (`issue/loaf-42`, +disambiguated with an id suffix when that name is already claimed), adds a +sibling worktree, records `started_branch` / `started_worktree` on the row, and +moves status to `active` through the events path. Base is the nearest started +ancestor's branch, else the repository default branch. Start refuses an already +started row, an archived row, and terminal statuses (`done`, `cancelled`, +`duplicate`). Requires a git repository. -## Archiving Tasks +`list --started` prints alias, title, `started_branch`, `started_worktree`, and +`(missing)` when the recorded path is gone. -When a task is done: +`stop` removes the worktree and clears the started workspace on the row. It +keeps the branch and does not change status. `--force` removes a dirty +worktree. Do not run `stop` from inside the started worktree. -1. Mark complete via CLI: `loaf task update TASK-XXX --status done` -2. Archive: `loaf task archive TASK-XXX` +## Status vocabulary -## Journal Integration +Write statuses that update in place: `triage`, `backlog`, `todo`, `active`, +`done`. `cancelled` and `duplicate` archive through the remove path +(`loaf issue status <ref> duplicate --duplicate-of <surviving>`). -When the implement workflow starts on `TASK-001`: - -1. Load task metadata via `loaf task show TASK-001` for context -2. Read linked spec for full picture -3. Log the task coupling as the first action: +```text +loaf issue status <ref> <status> [--duplicate-of <ref>] [--json] +``` -```bash -loaf journal log "decision(implement): implementing TASK-001" +| Status | Meaning | +|--------|---------| +| `triage` | Default at create. Shaped is derived (`loaf issue check`), not a status | +| `backlog` | Filed, worth keeping | +| `todo` | Explicitly ready to work | +| `active` | Started. **Review is a display name for `active`** — there is no `review` write status | +| `done` | Work landed | +| `cancelled` | Archived; abandoned | +| `duplicate` | Archived; `--duplicate-of` required | + +There is **no `blocked` status**. Blocked is a relationship. Title and body stay +mutable at every status. + +```text +loaf issue list [--status <status>] [--kind delivery|decision] [--archived] [--started] [--json] ``` -There is no session to create or couple to. Task progress is read through -`loaf task show/list`; the surrounding decisions and blockers live in the -project journal (`loaf journal recent`, `loaf journal search`). +Archived rows are hidden unless `--archived`. `--status` accepts every value in +the table above. -## Task Sizing +## Relationships -### Separation of Concerns +```text +loaf issue link <from> blocks|relates-to <to> [--json] +loaf issue link <from> remove <type> <to> [--json] +``` -**The primary principle for task breakdown is separation of concerns.** +Stored types are `blocks` and `relates_to`. `loaf issue link A blocks B` means +A blocks B: B is absent from the frontier until A is `done`, `cancelled`, or +`duplicate`. `relates-to` is not a sequencing constraint. -| Rule | Guideline | -|------|-----------| -| **One agent type** | Task completable by ONE subagent (implementer, reviewer, researcher) | -| **One concern** | Task touches one layer, one service, or one component | -| **Context-appropriate** | Fits in model context with room for exploration | -| **Not over-fragmented** | Don't split what naturally belongs together | +Do not encode order in `loaf issue tree`. Parent/child is structure; `blocks` +is the dependency. `loaf issue export [--json]` dumps relationships (and +claims) when you need the graph. -### Right Size Test +## Definition of done -1. Can a single specialized agent complete this? → If no, split by agent type -2. Does it touch multiple unrelated concerns? → If yes, split by concern -3. Will the agent need too much context? → If yes, split into smaller coherent units -4. Am I splitting just to have more tasks? → If yes, merge back +Criteria live on the issue row. `loaf issue show <ref>` prints each as +`position. [V|H] text` with `command=` / `expect=` when present. -### Agent Scope +```text +loaf issue dod add <ref> <text> [--command <cmd>] [--expect <expect>] [--tier V|H] [--serves <parent-position>] [--json] +loaf issue dod list <ref> [--json] +loaf issue dod remove <ref> <position> [--json] +loaf issue dod claim <child> <child-position> <parent-position> [--json] +loaf issue dod unclaim <child> <child-position> <parent-position> [--json] +loaf issue promote <ref> <position> [--json] +loaf issue check <ref> [--json] [--human <reason>] +loaf issue verify <ref> [--json] +``` -| Agent | Typical Task Scope | -|-------|-------------------| -| implementer (backend) | One service/module, its tests, its docs | -| implementer (frontend) | One component/page, its tests, its styles | -| implementer (database) | One migration, related schema changes | -| implementer (testing) | Test suite for one feature/area | -| implementer (infra) | One infrastructure concern (CI, deploy, config) | +| Tier | When | Who checks | +|------|------|------------| +| V | `--command` present, unless `--tier` overrides | `loaf issue verify <ref>` from the repository root. Honors `exit <N>` and `` contains `text` ``. Writes nothing. Non-zero on failure | +| H | No `--command`, unless `--tier` overrides | Human or orchestrator. Verify skips H-tier; that skip is not a pass | -## Priority Levels +Claims: a child criterion serves a parent criterion. `promote` copies the +parent criterion onto a new delivery child and records the claim. +`--serves` claims a newly added child criterion. `claim` / `unclaim` retarget +an existing pair. Positions are 1-based. -| Priority | Meaning | Response | -|----------|---------|----------| -| P0 | Urgent/blocking | Drop everything | -| P1 | High | Work next | -| P2 | Normal | Scheduled work | -| P3 | Low | When time permits | +`check` is readiness (shape's gate): delivery is shaped with a nonempty body, +at least one criterion, and an out-of-scope statement; decision is ready on a +sharp `?`. Children add coverage (every parent criterion claimed — failure) +and containment (every child criterion claims a parent — report). `verify` is +implement's preflight and writes nothing — it does not set status and does not +tick boxes. -## Listing Tasks +`loaf issue render <ref>` emits the paste-ready PR body: title, body, +definition-of-done checkboxes (checked only when status is already `done`), +and children. No manual editing. -### All Active Tasks +## Buckets -```bash -loaf task list +```text +loaf issue bucket <ref> now|next|later|none [--json] ``` -### Tasks for a Spec - -```bash -loaf spec list # Show specs with task counts -loaf task list --json # Machine-parseable output, filter by spec -``` +Advisory Now/Next/Later labels. Never read as a constraint. Frontier, start, +and verify ignore them. `none` clears the label. -## Work Log Updates +## Command cheat sheet -As work progresses, append to the Work Log section: - -```markdown -## Work Log - -### 2026-01-23 14:30 UTC -Started OAuth integration. Set up Google OAuth client credentials. - -### 2026-01-23 15:45 UTC -Google OAuth working. Moving to GitHub integration. - -### 2026-01-23 17:00 UTC -Both providers working. Tests pass. Moving to review. +```text +loaf issue new <title> [--body <text>|--body -|--body-file <path>|--message <text>] + [--kind delivery|decision] [--parent <ref>] [--fog <text>] [--status <status>] [--json] +loaf issue show <ref> [--json] +loaf issue tree [<ref>] [--archived] [--json] +loaf issue edit <ref> [--body-file <path>|--body -|--message <text>] [--json] +loaf issue export [--json] ``` -## Verification +`new` default kind is `delivery`; default status is `triage`. `--status` on +create still records the initial triage event, then writes the requested +write-status. `--fog` exists only on create. `edit` replaces the body; there +is no patch form. -Before marking `done`: - -1. Run the `verify` command from frontmatter -2. Check all acceptance criteria are checked -3. Ensure no regressions in related tests - -```bash -# Run task verification -verify_cmd=$(grep '^verify:' TASK-001-*.md | cut -d: -f2-) -eval "$verify_cmd" -``` +## LEGACY -## Local vs Linear Comparison - -| Feature | Local | Linear | -|---------|-------|--------| -| No external dependency | yes | no | -| Rich UI | no | yes | -| Team collaboration | git-based | native | -| Notifications | none | email/slack | -| Reporting | manual | built-in | -| Offline work | yes | limited | - -**Use local when:** -- Solo project -- No Linear access -- Offline development -- Simple task tracking - -**Use Linear when:** -- Team collaboration needed -- Rich workflow automation -- Integration with other tools -- Reporting requirements +`loaf task` and `loaf spec` remain readable against leftover SQLite rows. They +mint nothing new. Do not create records there. Issues are the work unit. diff --git a/dist/cursor/skills/orchestration/references/parallel-agents.md b/dist/cursor/skills/orchestration/references/parallel-agents.md index f4ef48ab2..35a07a7d6 100644 --- a/dist/cursor/skills/orchestration/references/parallel-agents.md +++ b/dist/cursor/skills/orchestration/references/parallel-agents.md @@ -117,7 +117,7 @@ When streams complete: | Command | Parallel Opportunity | |---------|---------------------| -| breakdown | Identify parallelizable tasks during decomposition | +| shape | Identify parallelizable child issues during decomposition | | implement | Single task, usually sequential | | implement | Runs dependency-aware orchestration, including parallel-safe tasks | diff --git a/dist/cursor/skills/orchestration/references/script-surface.md b/dist/cursor/skills/orchestration/references/script-surface.md index 70206ab63..474b76354 100644 --- a/dist/cursor/skills/orchestration/references/script-surface.md +++ b/dist/cursor/skills/orchestration/references/script-surface.md @@ -20,7 +20,7 @@ script surface: - The source currently has 10 orchestration scripts out of 22 skill-local scripts overall. -- Several scripts overlap existing `loaf journal`, `loaf task`, `loaf check`, +- Several scripts overlap existing `loaf journal`, `loaf issue`, `loaf check`, and Linear-aware behavior. - Shell/Python helpers are harder to discover than `loaf <noun> <verb>` and are not consistently covered by CLI tests. diff --git a/dist/cursor/skills/orchestration/references/subagent-development.md b/dist/cursor/skills/orchestration/references/subagent-development.md index 2d36b6175..d2c46e2a1 100644 --- a/dist/cursor/skills/orchestration/references/subagent-development.md +++ b/dist/cursor/skills/orchestration/references/subagent-development.md @@ -211,7 +211,7 @@ After subagent completes: | Command | Subagent Role | |---------|---------------| -| breakdown | Tasks become subagent assignments | +| shape | Promoted child issues become subagent assignments | | implement | May dispatch subagents for specialized work | | implement | Automatically coordinates single-task and multi-task subagent work | diff --git a/dist/cursor/skills/orchestration/templates/journal.md b/dist/cursor/skills/orchestration/templates/journal.md index caf7409fd..9a0a61f8f 100644 --- a/dist/cursor/skills/orchestration/templates/journal.md +++ b/dist/cursor/skills/orchestration/templates/journal.md @@ -13,7 +13,7 @@ not create or edit journal markdown as the source of truth — use ## Entries -[YYYY-MM-DD HH:MM] skill(implement): implementing TASK-042 +[YYYY-MM-DD HH:MM] skill(implement): implementing LOAF-42 [YYYY-MM-DD HH:MM] decision(scope): description of decision [YYYY-MM-DD HH:MM] discover(scope): something learned [YYYY-MM-DD HH:MM] block(scope): what is blocked diff --git a/dist/cursor/skills/pitch/SKILL.md b/dist/cursor/skills/pitch/SKILL.md index f34f451ad..635c0c2cd 100644 --- a/dist/cursor/skills/pitch/SKILL.md +++ b/dist/cursor/skills/pitch/SKILL.md @@ -1,21 +1,21 @@ --- name: pitch description: >- - Runs the human problem-discovery ceremony at change or project scale: grills - problem, who has it, current alternatives, value proposition, and constraints, - then authors a brief (change brief.md via loaf change init --brief, or project - docs/BRIEF.md with source: pitch). Use when the user invokes pitch, starts - work on a raw concept, or triage dispositions an item as pitch. Produces an - authored problem-space brief and a shape-now or park offer — never shape.md, - tasks, or PRs. Not for solution shaping (use shape), queue processing (use - triage), quick capture (use idea), or open-ended divergent inquiry (use - explore as an agent technique when pitch reveals the direction is undecided). + Runs the human problem-discovery ceremony: grills problem, who has it, current + alternatives, value proposition, and constraints, then hands a sharpened + problem narrative to shape or authors project docs/BRIEF.md. Use when the user + invokes pitch, starts work on a raw concept, or triage dispositions a spark or + idea as pitch. Produces a problem-space narrative and a shape-now or park + offer — never a bounded issue, criteria, or PRs. Not for quick capture (use + idea), solution bounding (use shape), queue processing (use triage), or + open-ended divergent inquiry (use explore as an agent technique when pitch + reveals the direction is undecided). version: 0.2.21 --- # Pitch -Human problem-discovery ceremony. Authors a brief at the matching scale so shape starts from a framed problem and bootstrap can consume a pitched project BRIEF. +Human problem-discovery ceremony. Narrows sparks and ideas into a framed problem so shape can mint an issue, and bootstrap can consume a pitched project BRIEF. ## Contents - Critical Rules @@ -32,61 +32,90 @@ Human problem-discovery ceremony. Authors a brief at the matching scale so shape ## Critical Rules 1. **Agents never initiate a pitch.** This ceremony is human-invoked only. On Claude Code the sidecar sets `disable-model-invocation: true`; on every target this rule binds behaviorally. Agent legwork *inside* a human-opened pitch (competitive scans, file writes the skill directs) is fine — opening one is not. -2. **Log invocation first** — `loaf journal log "skill(pitch): <idea, problem, or intake item>"` before interviewing. -3. **Problem-space only** — grill what, who, and why-valuable. Approach, architecture, decomposition, and verification design belong to shape. A brief that reads like a pseudo-shape is a failure; rewrite before landing. -4. **One question at a time, recommendation-first** — using your harness's structured question tool if it has one (otherwise one inline question per message). Never a multi-field form. Order by impact on the brief. Full mechanics: [references/interview-guide.md](references/interview-guide.md). -5. **Never write `shape.md`, seed `tasks/`, push, or open PRs** — pitch prepares commits and hands off; push and PR stay human. Never auto-run shape or bootstrap. -6. **Landing is validated, then committed once** — every capture landing runs explicit-path `loaf change check <folder> --json` (zero violations, expected captured state) and a direct read-back of that folder's `change.json` confirming intended `target_release` presence or absence, then one docs-only commit per capture. Never batch captures into one commit. -7. **Slug identity is local** — propose a slug that names the concept, never another work unit (no `spec-042`, no task ids). Provenance lives in frontmatter and the change folder. -8. **Log the outcome** — `loaf journal log "decision(pitch): <slug or project> — <shape-now|park-targeted|park-untargeted|handed-to-bootstrap>"`. +2. **Log invocation first** — `loaf journal log "skill(pitch): <idea, problem, spark, or intake item>"` before interviewing. +3. **Problem-space only** — grill what, who, and why-valuable. Approach, architecture, decomposition, and verification design belong to shape. A narrative that reads like a pseudo-shape is a failure; rewrite before landing. +4. **One question at a time, recommendation-first** — using your harness's structured question tool if it has one (otherwise one inline question per message). Never a multi-field form. Order by impact on the narrative. Full mechanics: [references/interview-guide.md](references/interview-guide.md). +5. **Never bound, never ship** — do not add definition-of-done criteria, do not write an out-of-scope statement, do not run `loaf issue check` or `loaf issue promote`, do not push, do not open PRs. Never auto-run shape or bootstrap. +6. **Shape mints on the happy path** — same-session shape-now hands the authored narrative; shape runs `loaf issue new` with that body. Pitch writes an issue body only when parking an unshaped row or when `$ARGUMENTS` already names an issue (`loaf issue edit` replaces the body). +7. **Titles name the concept** — propose a working title, never another work unit's alias. Provenance lives in the issue row, the spark/idea resolution, and frontmatter on `docs/BRIEF.md`. +8. **Log the outcome** — `loaf journal log "decision(pitch): <title, ref, or project> — <shape-now|park-issue|park-idea|handed-to-bootstrap>"`. --- ## Verification -- Change scale: `docs/changes/YYYYMMDD-slug/` holds `change.json` + authored `brief.md`; `loaf change check <folder> --json` reports zero violations and captured state; `change.json` read-back matches the intended target binding -- Project scale: `docs/BRIEF.md` exists with `source: pitch` and the shared problem-space skeleton -- Cold-read: problem, who, alternative, and value nameable in one pass; zero solution-space content -- No `shape.md` or `tasks/` written by this skill; no push; no PR +- Issue-scale: a problem narrative exists against the shared skeleton; it was handed to shape, written into an existing issue body, or minted as an unshaped triage row with that body and no criteria +- Project-scale: `docs/BRIEF.md` exists with `source: pitch` and the shared problem-space skeleton +- Cold-read: problem, who, alternative, and value nameable in one pass; zero solution-space content; no out-of-scope statement and no criteria added by this skill +- Named sparks were promoted to an idea when pitching them; ideas and sparks were resolved against the issue only after a row exists +- No push; no PR; shape and bootstrap were not auto-run - Journal shows skill invocation and outcome entries --- ## Quick Reference +| Harness | Invoke skill | +|---------|----------------| +| Claude Code (plugin) | `/loaf:pitch` | +| OpenCode, Cursor, Codex, Amp | `/pitch` | + ### Scale detection | Signal | Scale | Output | |--------|-------|--------| -| Existing project (git history, source, or Loaf state) + a concept | **Change** | `loaf change init <slug> --brief` → authored `brief.md` | -| Empty or minimal directory / greenfield intent | **Project** | `docs/BRIEF.md` with `source: pitch` → hand to bootstrap | +| Existing project (git history, source, or Loaf state) + a concept | **Issue** | Problem narrative → shape (`loaf issue new --body`) or an unshaped triage row | +| Empty or minimal directory / greenfield product pitch | **Project** | `docs/BRIEF.md` with `source: pitch` → hand to bootstrap | Detect and confirm briefly; let the human correct. When both could apply (repo exists but they want a new product pitch), ask once. -### Landing matrix (Decision 11) +### Landing offers -| Intent | Branch | Commit | Target | -|--------|--------|--------|--------| -| **Shape now** | Create the slug branch (`git switch -c <slug>`), stay there | Hand to shape for in-place promotion — do not park-commit first | Stamp `target_release` when known | -| **Park targeted** | Default branch | One docs-only commit on default (promise-carrier exception) | `target_release` present and confirmed by read-back | -| **Park untargeted** | Slug branch **or** remain intake (Intent/spark) | Docs-only commit on the slug branch if becoming a Change; else no Change folder | No `target_release`; untargeted captures never land on main | +| Offer | When to recommend | What pitch does | +|-------|-------------------|-----------------| +| **Shape now** | Framing is solid; they want to bound next | Hand the narrative; do not mint; do not auto-run shape | +| **Park as issue** | Framed, durable, not bounding yet | `loaf issue new "<title>" --body -` with the narrative only; status stays `triage` | +| **Park as idea** | Too thin to keep as a row, or might discard | `loaf idea capture --title "..."`; journal the gist | +| **Hand to bootstrap** | Project-scale BRIEF authored | Point at bootstrap; do not auto-run it | -Pitch prepares the commit; never pushes; never opens PRs. +Pitch never pushes; never opens PRs. There is nothing to commit at issue scale — the row lives in SQLite. Project-scale may commit `docs/BRIEF.md` if the human wants it durable. -### Pre-landing guard (every capture) +### Spark and idea promotion -```bash -loaf change check <folder> --json # zero violations; state is captured -# then read <folder>/change.json and confirm target_release presence/absence matches intent +| Input | Read | Then | +|-------|------|------| +| Spark | `loaf spark show <ref>` | `loaf idea capture --title "..."` then `loaf spark promote <spark> --to-idea <idea>`; grill from the idea | +| Idea | `loaf idea show <ref>` | Grill; after a row exists, `loaf idea resolve <idea> --by <ref>` | +| Existing issue | `loaf issue show <ref>` | Grill; `loaf issue edit <ref> --body -` writes the narrative (replaces the whole body) | +| Free text | — | Grill; shape-now hands text; park captures an idea or mints an unshaped row | + +Do not invent a pitch from the queue without human selection. When they name an intake item, read it (`loaf intake list` / the item's read command). + +`loaf idea promote --to-spec` is not this path. Resolve ideas against the minted issue. + +### Problem-narrative skeleton + +Author against these sections, problem-space sentences only. This text is what shape puts in `--body` (or what a park-as-issue row stores): + +```markdown +## Problem Statement +## Who Has It +## Current Alternatives +## Value Proposition +## Constraints +## Sequencing and Relationships +## Sources and Research Links +## Open Questions ``` -Bare `loaf change check` resolves by branch and can miss a capture landing elsewhere — always pass the explicit folder path. +Do not add an out-of-scope statement. Shape bounds; pitch frames. ### Defined terms -- **Brief** — the pitch output (problem-space). Superseded by `shape.md` when shaping starts; may accrete parked problem-space sentences until then; freezes when `shape.md` exists. -- **Accretion** — adding problem-space concepts to a parked brief is legal; solution prose is not. -- **Shape now** — slug branch + hand to shape, which promotes the capture in place via ordinary `loaf change init <slug>` (no `--brief`). +- **Problem narrative** — pitch's issue-scale output. Superseded as the working surface once shape mints and bounds the issue; may accrete parked problem-space sentences until then. +- **BRIEF** — project-scale `docs/BRIEF.md`. A project document, not a work container. +- **Accretion** — adding problem-space concepts to a parked narrative is legal; solution prose is not. +- **Shape now** — hand the narrative to shape, which mints via `loaf issue new` and owns bounding. --- @@ -95,81 +124,82 @@ Bare `loaf change check` resolves by branch and can miss a capture landing elsew ### Step 1: Log and parse input ```bash -loaf journal log "skill(pitch): <idea, problem, or intake item>" +loaf journal log "skill(pitch): <idea, problem, spark, or intake item>" ``` -Parse `$ARGUMENTS`: free text, an intake ref the human already chose, or empty (ask what to pitch). Read the named intake item when provided (`loaf intake list` / the item's read command). Do not invent a pitch from the queue without human selection. +Parse `$ARGUMENTS`: free text, a spark, an idea, an issue ref, an intake ref the human already chose, or empty (ask what to pitch). Read the named item when provided. Do not invent a pitch from the queue without human selection. ### Step 2: Detect scale -Apply the Quick Reference table. Confirm: "I'll treat this as a **change-scale** pitch on this repo" or "…as a **project-scale** pitch for a new BRIEF." Adjust if corrected. +Apply the Quick Reference table. Confirm: "I'll treat this as an **issue-scale** pitch on this repo" or "…as a **project-scale** pitch for a new BRIEF." Adjust if corrected. -### Step 3: Problem-discovery interview +### Step 3: Promote sparks; read ideas + +When the named input is a spark, promote it to an idea before grilling so the capture trail is one idea, not a dangling spark: + +```bash +loaf idea capture --title "<working title>" +loaf spark promote <spark> --to-idea <idea> +``` + +When the named input is already an idea, `loaf idea show` and grill. Leave resolution until an issue row exists. + +### Step 4: Problem-discovery interview Run the interview per [references/interview-guide.md](references/interview-guide.md): -- Pin a one-or-two-line **destination** before dimension grilling (fixes brief scope; project scale feeds VISION success criteria, change scale sharpens the eventual Hypothesis) +- Pin a one-or-two-line **destination** before dimension grilling (fixes narrative scope; project scale feeds VISION success criteria; issue scale sharpens what good looks like for the row) - Dimensions: problem, who has it, current alternatives / competitive landscape, value proposition, constraints (plus sequencing and open questions when needed) - Depth: scenario stress-testing, challenge stance, glossary-term hygiene; open questions must pass the specifiability test and carry HITL/AFK tags when precise - Applicability judgment: skip formal competitive analysis and deep personas when the pitch kind does not warrant them (bug fixes, internal chores) -- One question at a time, recommendation-first, ordered by brief impact +- One question at a time, recommendation-first, ordered by narrative impact - Stop on exit criteria or when answers stop changing the framing -If the direction is genuinely undecided mid-interview, offer the **explore** technique (agent-side; Explorations and checkpoints) rather than forcing a false brief. Pitch remains the human front door; explore is not re-opened as a slash alternative by this skill. +If the direction is genuinely undecided mid-interview, offer the **explore** technique (agent-side; Explorations and checkpoints) rather than forcing a false narrative. Pitch remains the human front door; explore is not re-opened as a slash alternative by this skill. -### Step 4: Evidence delegation (when warranted) +### Step 5: Evidence delegation (when warranted) -When competitive or landscape facts would change the brief and are not already known: +When competitive or landscape facts would change the narrative and are not already known: 1. Spawn a **researcher** subagent with a bounded question (competitors, substitutes, prior art — not solution design). 2. Land evidence: - - **Change scale:** files under the change folder's `research/` (create the folder with the change); link from Sources and Research Links - - **Project scale:** inline source links in `docs/BRIEF.md` Sources and Research Links (no change `research/` yet) -3. Resume the interview or brief draft with recommendations informed by the scan. + - **Issue scale:** source links in the narrative's Sources and Research Links. If a longer scan lands on disk, name it for the landscape, never for the work unit, and cite it from Sources. + - **Project scale:** inline source links in `docs/BRIEF.md` Sources and Research Links. +3. Resume the interview or draft with recommendations informed by the scan. Never fabricate competitive claims. If a scan is skipped, say so in Sources ("no external scan; alternative is internal workaround X"). -### Step 5a: Change-scale ceremony - -1. **Propose a slug** — lowercase, digits, single hyphens; names the concept locally. Confirm with the human. -2. **Initialize capture:** - - ```bash - loaf change init <slug> --brief - ``` - - Creates `docs/changes/YYYYMMDD-<slug>/` with `change.json` + `brief.md` scaffold only. -3. **Author `brief.md`** against the shared problem-space skeleton (shape's brief template / the scaffold just written): Problem Statement, Who Has It, Current Alternatives, Value Proposition, Constraints, Sequencing and Relationships, Sources and Research Links, Open Questions. Problem-space sentences only. -4. **Stamp `target_release` when known** — edit `change.json` with canonical `MAJOR.MINOR.PATCH` (no `v`, no prerelease). Omit the field when untargeted. Confirm with the human before stamping. -5. **Accretion note** — tell the human: parked problem-space concepts may accrete into this brief until shaping starts; once `shape.md` exists the brief freezes. -6. **Cold-read** the brief (interview guide test); revise with the human until it passes. -7. **Offer landing** (recommendation-first): +### Step 6a: Issue-scale ceremony - | Offer | When to recommend | - |-------|-------------------| - | **Shape now** | Framing is solid and they want to bound implementation next | - | **Park targeted** | Bound to a release cohort but not shaping yet — docs-only on default branch | - | **Park untargeted** | Worth capturing off-main, or not ready as a Change (stay intake) | +1. **Propose a working title** — names the concept locally. Confirm with the human. This becomes shape's `loaf issue new` title (or the park-as-issue title). +2. **Author the problem narrative** against the skeleton above. Problem-space sentences only. +3. **Accretion note** — tell the human: parked problem-space concepts may accrete until shaping starts; once the issue is minted, the body is the home. +4. **Cold-read** the narrative (interview guide test); revise with the human until it passes. +5. **Offer landing** (recommendation-first) using the Landing offers table. +6. **Execute the chosen landing:** -8. **Execute the chosen landing:** + - **Shape now:** hand the full narrative and any spark/idea refs. Shape runs `loaf issue new "<title>" --body -` (or `--body-file`) with that text. Do not mint, do not add criteria, do not open a PR. After shape mints, resolve intake: `loaf idea resolve <idea> --by <ref>` (and `loaf spark resolve <spark> --by <ref>` only if the spark was never promoted). + - **Park as issue:** mint the unshaped row yourself, then resolve intake against it: - - **Shape now:** `git switch -c <slug>` (from default unless already on a working branch the human prefers), ensure pre-landing guard would pass if they later park, hand to shape with the folder path — shape promotes in place. Do not open a PR. - - **Park targeted:** on the **default branch**, run pre-landing guard on the explicit folder, confirm `target_release` present in `change.json`, then one docs-only commit of the change folder (and any `research/` under it). - - **Park untargeted as Change:** `git switch -c <slug>`, pre-landing guard, confirm `target_release` **absent**, one docs-only commit on the slug branch. - - **Park as intake:** do not leave a half-written change folder; prefer Intent/spark retention and delete or never create the capture if the human backs out. + ```bash + loaf issue new "<title>" --body - + loaf idea resolve <idea> --by <ref> + ``` -9. **Commit message** (when parking): conventional, e.g. `docs(change): capture <slug> brief` — one commit per capture. + Paste the narrative on stdin. Do not add criteria. Do not write out-of-scope. Default status is `triage`. Read back with `loaf issue show <ref>`. + - **Park as idea:** if no idea exists yet, `loaf idea capture --title "<title>"`. Journal the gist (`loaf journal log "discover(pitch): <one-line problem>"`). Do not mint an issue. + - **Existing issue:** `loaf issue edit <ref> --body -` with the full narrative. Edit replaces the body; do not strip a row that is already bounded — if criteria already exist, hand the narrative to the human and let shape merge. -10. **Closing ceremony (required — never trail off).** After the landing is executed (or intake retained), announce completion with a full closing block: +7. **Closing ceremony (required — never trail off).** After the landing is executed, announce completion with a full closing block: - - **Recap the brief** — section-by-section gist (Problem, Who, Alternatives, Value, Constraints, Sequencing, Open Questions). Name the change folder path (`docs/changes/YYYYMMDD-<slug>/`) and what it holds (`change.json` + `brief.md`, plus any `research/`). + - **Recap the narrative** — section-by-section gist (Problem, Who, Alternatives, Value, Constraints, Sequencing, Open Questions). Name where it lives (handed to shape, unshaped issue `<ref>`, idea `<ref>`, or the conversation plus journal gist). - **Restate the landing actually taken** and what it means next: - - **Shape now** — you are on the slug branch; run shape next to promote the capture in place and bound implementation. No park-commit was made. - - **Park targeted** — the capture is a docs-only commit on the default branch with `target_release` stamped; it sits as a promise carrier for that cohort until shape is invoked later. - - **Park untargeted** — the capture lives on the slug branch (or remains intake) without `target_release`; it is off-main until retargeted or shaped. If intake-only, name the Intent/spark and that no change folder was left half-written. + - **Shape now** — run shape next to mint the issue from this narrative and bound implementation. No row was minted here. + - **Park as issue** — `<ref>` holds the problem in its body and is unshaped; run shape later on that ref. + - **Park as idea** — the idea remains open; re-invoke pitch or shape when ready. Name the idea ref. - **Announce completion** in plain language: "Pitch is complete." Do not end on a dangling offer or an unfinished sentence. -### Step 5b: Project-scale ceremony +### Step 6b: Project-scale ceremony 1. **Author `docs/BRIEF.md`** using bootstrap's brief skeleton with frontmatter: @@ -181,31 +211,31 @@ Never fabricate competitive claims. If a scan is skipped, say so in Sources ("no --- ``` - Same problem-space sections as change scale, at project altitude (Sequencing describes the initial arc as prose). + Same problem-space sections as issue scale, at project altitude (Sequencing describes the initial arc as prose). 2. **Cold-read** and revise with the human. 3. Optionally commit `docs/BRIEF.md` if the human wants it durable before bootstrap; still no push unless they ask outside this skill's duties — pitch itself never pushes. 4. **Closing ceremony (required — never trail off).** Announce completion with a full closing block — do not hand off in a half-sentence: - **Recap what was authored** — section-by-section gist of the BRIEF (Problem Statement, Who Has It, Current Alternatives, Value Proposition, Constraints, Sequencing and Relationships, Sources and Research Links, Open Questions). One or two sentences per section is enough; the human should hear what landed without reopening the file. - **Artifact path** — name `docs/BRIEF.md` explicitly, including that frontmatter carries `source: pitch`. - - **Explicit handoff** — state, verbatim in spirit: next, run bootstrap: it reads this BRIEF as discovery-done (`source: pitch`), interviews only on gaps, populates the operating documents (VISION, STRATEGY, ARCHITECTURE, AGENTS), and closes by proposing your initial arc of captured changes. Do not auto-run bootstrap. + - **Explicit handoff** — state, verbatim in spirit: next, run bootstrap: it reads this BRIEF as discovery-done (`source: pitch`), interviews only on gaps, and populates the operating documents (VISION, STRATEGY, ARCHITECTURE, AGENTS). Do not auto-run bootstrap. - **Announce completion** in plain language: "Pitch is complete." The ceremony ends with a period, never a trail-off. -### Step 6: Log the outcome +### Step 7: Log the outcome ```bash -loaf journal log "decision(pitch): <slug or project> — <shape-now|park-targeted|park-untargeted|handed-to-bootstrap>" +loaf journal log "decision(pitch): <title, ref, or project> — <shape-now|park-issue|park-idea|handed-to-bootstrap>" ``` -The journal line is mechanical; the human-facing close is the closing ceremony in Step 5a/5b. Never log-and-stop without that recap and next-step restatement. +The journal line is mechanical; the human-facing close is the closing ceremony in Step 6a/6b. Never log-and-stop without that recap and next-step restatement. --- ## Related Skills -- **shape** — solution-space narrowing from an existing brief (or full narrowing when no brief); promotes capture folders in place -- **bootstrap** — consumes `docs/BRIEF.md` (`source: pitch` → gap interview) and series-preps captured changes -- **triage** — queue dispositions; may hand an item to pitch when problem discovery is needed +- **shape** — solution-space bounding; mints the issue from the problem narrative (`loaf issue new`) and owns criteria, out-of-scope, and decomposition +- **bootstrap** — consumes `docs/BRIEF.md` (`source: pitch` → gap interview) and populates operating documents +- **triage** — queue dispositions; may hand a spark or idea to pitch when problem discovery is needed - **explore** — agent-side technique when pitch finds the direction still undecided - **idea** — quick capture without ceremony; not a substitute for pitch - **research** — patterns the researcher subagent follows for landscape scans @@ -218,4 +248,4 @@ The journal line is mechanical; the human-facing close is the closing ceremony i ## Artifact Naming -Name every artifact for what it is, never for the work unit that produced it. The change folder already records provenance. Put source in frontmatter, not the filename. See the `foundations` skill; `loaf check --hook artifact-names` enforces it at commit. +Name every on-disk artifact for what it is, never for the work unit that produced it. The issue row or `docs/BRIEF.md` already records provenance. Put source in frontmatter, not the filename. See the `foundations` skill; `loaf check --hook artifact-names` enforces it at commit. diff --git a/dist/cursor/skills/pitch/references/interview-guide.md b/dist/cursor/skills/pitch/references/interview-guide.md index 8014082fe..65780fb10 100644 --- a/dist/cursor/skills/pitch/references/interview-guide.md +++ b/dist/cursor/skills/pitch/references/interview-guide.md @@ -11,7 +11,7 @@ Problem-discovery interview for pitch. Borrows shape's grilling mechanics (one q - Open Questions: Specifiability and HITL/AFK - Exit Criteria - Anti-Patterns -- Brief Cold-Read +- Problem Cold-Read ## How This Guide Works @@ -23,22 +23,22 @@ The interview is adaptive, not a form. Strong answers skip dimensions; weak answ ## Destination Pinning -Before dimension grilling, converge on a **destination**: one or two lines naming the end state this pitch is aiming at — what becomes true in the world if the work succeeds. Pin it early; it fixes the brief's scope for the rest of the interview. +Before dimension grilling, converge on a **destination**: one or two lines naming the end state this pitch is aiming at — what becomes true in the world if the work succeeds. Pin it early; it fixes the narrative's scope for the rest of the interview. | Scale | Destination feeds | |-------|-------------------| | **Project** | VISION success criteria (bootstrap extracts it; pitch keeps it as the project's north star in the BRIEF) | -| **Change** | The eventual Hypothesis when shape promotes the capture — a sharper "what good looks like" than a feature list | +| **Issue** | What good looks like for the work — the problem statement shape will put in the issue body | **How to pin:** offer a recommendation-first draft from the human's opening words ("Destination: operators can ship a release without a manual config audit"). Confirm, tighten, or rewrite until both parties can restate it. Do not start deep dimension probes until the destination is on the table. -If the destination keeps shifting mid-interview, pause and re-pin — a moving destination means the brief's scope is still open. +If the destination keeps shifting mid-interview, pause and re-pin — a moving destination means the narrative's scope is still open. --- ## Problem-Discovery Dimensions -Grill these five dimensions. Order by what would change the brief most — a wrong "who" invalidates value and alternatives; cosmetic naming goes last. +Grill these five dimensions. Order by what would change the narrative most — a wrong "who" invalidates value and alternatives; cosmetic naming goes last. ### 1. Problem @@ -62,17 +62,17 @@ What do they do today? Existing tools, manual workarounds, cobbled scripts, or " Why is solving this worth it? What becomes true for the people who have the problem if this lands? One line: different AND better relative to the alternative — not a feature list, not an architecture sketch. -**Applicability:** always name value, but depth scales. A product pitch needs a crisp UVP; a small change can be "removes the weekly fire-drill so release day is boring." +**Applicability:** always name value, but depth scales. A product pitch needs a crisp UVP; a small internal pitch can be "removes the weekly fire-drill so release day is boring." ### 5. Constraints -Non-negotiable bounds: technical, legal, organizational, philosophical, sequencing. Things that limit the solution space before design begins. Capture as problem-space limits ("must not break the promise-carrier exception"), never as chosen designs ("use Postgres"). +Non-negotiable bounds: technical, legal, organizational, philosophical, sequencing. Things that limit the solution space before design begins. Capture as problem-space limits ("must not add a new human ceremony to the weekly path"), never as chosen designs ("use Postgres"). **Always ask lightly:** at least one real constraint or an explicit "none known yet." ### Secondary (only when signal demands) -- **Sequencing and relationships** — how this hangs with other work, release cohort as prose, series order. No machine relation fields. +- **Sequencing and relationships** — how this hangs with other work, series order. No machine relation fields. - **Open questions** — unresolved problem-space items that pass the [specifiability test](#open-questions-specifiability-and-hitlafk); each tagged HITL or AFK. - **Evidence of pain** — money, time, workarounds (Mom Test lens). When absent and the claim is large, challenge gently. @@ -105,13 +105,13 @@ Every question includes a recommended answer and a short rationale. The human ov Example shape: > **Who has this problem most often?** -> Recommendation: mid-size platform engineers who already run multi-service deploys — they feel config drift weekly. Rationale: your examples all came from that world; broader "all developers" would dilute the brief. +> Recommendation: mid-size platform engineers who already run multi-service deploys — they feel config drift weekly. Rationale: your examples all came from that world; broader "all developers" would dilute the narrative. ### Ordering -Prioritize answers that would rewrite the brief. Typical order: **destination pin** → problem → who → alternatives → value → constraints → sequencing/open questions. Reorder when the human's first utterance already settles an early dimension. +Prioritize answers that would rewrite the narrative. Typical order: **destination pin** → problem → who → alternatives → value → constraints → sequencing/open questions. Reorder when the human's first utterance already settles an early dimension. -Before asking, check whether reading resolves it — journal, prior Change, intake item body, BRIEF. Only ask what reading could not answer. +Before asking, check whether reading resolves it — journal, prior issue, intake item, BRIEF. Only ask what reading could not answer. ### Adaptive depth @@ -120,12 +120,12 @@ Before asking, check whether reading resolves it — journal, prior Change, inta | Crisp, specific answers | Confirm, move on; skip expand-if-needed probes | | Category answers ("developers need better tools") | Ask for a concrete story or last painful moment | | Solution-first ("I want a CLI that…") | Pause; reframe to problem and who | -| Energy dropping | Cut to synthesis; a brief with named gaps beats an exhausted interrogation | -| Direction genuinely undecided | Offer the explore technique from inside pitch; do not force a false brief | +| Energy dropping | Cut to synthesis; a narrative with named gaps beats an exhausted interrogation | +| Direction genuinely undecided | Offer the explore technique from inside pitch; do not force a false narrative | ### Scenario stress-testing -Probe the problem with **concrete lived scenarios**, not abstract categories. Prefer "walk me through the last time this bit you" over "how often does this happen?" Invent edge cases deliberately to force boundary precision: "What if the operator is on-call at 2am with half the logs missing — is that still this problem, or a different one?" Scenarios that collapse the framing expose missing constraints or a second concept that should stay out of this brief. +Probe the problem with **concrete lived scenarios**, not abstract categories. Prefer "walk me through the last time this bit you" over "how often does this happen?" Invent edge cases deliberately to force boundary precision: "What if the operator is on-call at 2am with half the logs missing — is that still this problem, or a different one?" Scenarios that collapse the framing expose missing constraints or a second concept that should stay out of this narrative. ### Challenge stance @@ -133,12 +133,12 @@ Demand specificity over generalization. Probe the rationale behind claims ("why Also enforce **canonical language** during the interview: -- Flag terms that conflict with `docs/knowledge/glossary.md` usage (e.g. calling a Change a "spec," a release cohort a "milestone," a skill a "module"). Offer the glossary term and get the human to accept the swap before it lands in the brief. +- Flag terms that conflict with `docs/knowledge/glossary.md` usage (e.g. calling an issue a "ticket," a skill a "module"). Offer the glossary term and get the human to accept the swap before it lands in the narrative. - Sharpen fuzzy project-local terms to a single canonical choice mid-interview ("you said both 'capture' and 'ticket' — pick one and stick to it"). Ambiguous vocabulary becomes solution fog later. ### Mid-interview evidence -When competitive landscape or external facts would change the brief and the human lacks them, pause the grill, delegate a researcher subagent, land evidence (change-scale: `research/` in the change folder; project-scale: links in Sources), then resume with a recommendation informed by the scan. Evidence supports framing; it does not become solution design. +When competitive landscape or external facts would change the narrative and the human lacks them, pause the grill, delegate a researcher subagent, land evidence (issue-scale: source links in the narrative; project-scale: links in Sources), then resume with a recommendation informed by the scan. Evidence supports framing; it does not become solution design. --- @@ -146,11 +146,11 @@ When competitive landscape or external facts would change the brief and the huma ### Specifiability test -An open question earns a **precise entry** in the brief only if it can be **stated precisely now** — not answered now. The bar is: a later reader (human or agent) could work the question without inventing what was meant. +An open question earns a **precise entry** in the narrative only if it can be **stated precisely now** — not answered now. The bar is: a later reader (human or agent) could work the question without inventing what was meant. | Passes (precise entry) | Fails (coarse note only) | |------------------------|--------------------------| -| "Does the operator need multi-region failover in v1, or is single-region acceptable for the first cohort?" | "Figure out reliability stuff" | +| "Does the operator need multi-region failover in v1, or is single-region acceptable until the first cut?" | "Figure out reliability stuff" | | "Which existing CLI command is the migration source of truth for config paths?" | "TBD on integration" | Everything vaguer stays a **coarse note** in Open Questions or Sequencing prose — never pre-sliced into fake precision. Do not invent enumerated options the human did not surface. @@ -164,7 +164,7 @@ Mark each precise open question with one of: | **HITL** | Needs the human live — judgment, taste, organizational call, or access only they hold | "Will legal accept the data-retention tradeoff?" | | **AFK** | Runnable by an agent without the human in the loop — research, codebase scan, competitive lookup | "What do the top three substitutes charge for the free tier?" | -Briefs carry the tag inline in the Open Questions section, e.g. `- [HITL, blocking] …` or `- [AFK, deferrable] …`. Blocking vs deferrable remains orthogonal: a HITL question can be deferrable; an AFK question can still block shaping if the answer rewrites scope. +Narratives carry the tag inline in the Open Questions section, e.g. `- [HITL, blocking] …` or `- [AFK, deferrable] …`. Blocking vs deferrable remains orthogonal: a HITL question can be deferrable; an AFK question can still block shaping if the answer rewrites scope. --- @@ -180,13 +180,13 @@ Stop interviewing when all of the following hold (or the human explicitly wants 6. **Constraints** are listed or explicitly empty. 7. Answers have stopped changing the framing — the last questions confirmed rather than rewrote. 8. Open questions that remain pass the specifiability test (or are coarse notes) and carry HITL/AFK tags when precise. -9. A cold reader could pass the brief cold-read test below. +9. A cold reader could pass the problem cold-read test below. -Open questions may remain; mark each blocking or deferrable, and HITL or AFK when precise. Blocking problem-space unknowns belong in the brief's Open Questions, not as invented answers. +Open questions may remain; mark each blocking or deferrable, and HITL or AFK when precise. Blocking problem-space unknowns belong in the narrative's Open Questions, not as invented answers. ### The pivot -Do not announce "the interview is over." Shift: "I think I have enough to draft the brief — tell me what I got wrong." Author the brief against the shared skeleton, then section-review with the human before any init or landing step. The **closing ceremony** (recap, path, next step) is owned by SKILL.md after landing — this guide owns interview depth only. +Do not announce "the interview is over." Shift: "I think I have enough to draft the problem narrative — tell me what I got wrong." Author the narrative against the shared skeleton, then section-review with the human before any mint or landing step. The **closing ceremony** (recap, path, next step) is owned by SKILL.md after landing — this guide owns interview depth only. --- @@ -196,7 +196,7 @@ Adopted from bootstrap's interview guide; binding on pitch. **The Form.** Running dimensions mechanically like a survey. If answer 2 covers dimension 4, confirm and skip. -**The 45-Minute Interrogation.** If energy drops, cut to draft. Gaps in the brief are honest; drained enthusiasm is not recoverable in the same session. +**The 45-Minute Interrogation.** If energy drops, cut to draft. Gaps in the narrative are honest; drained enthusiasm is not recoverable in the same session. **The Therapist.** Do not explore the builder's feelings about the product. User emotions (switching forces, pain) matter; builder therapy does not. @@ -212,7 +212,7 @@ Adopted from bootstrap's interview guide; binding on pitch. **Third Interview Idiom.** Do not invent pitch-specific interview machinery. Destination pinning, scenario stress-testing, and challenge stance deepen the same grilling mechanics — they are not a parallel framework. -**Pseudo-Shape in the Brief.** Approach, architecture, task breakdown, or verification design must not enter `brief.md` / `docs/BRIEF.md`. If it appears while drafting, move it out and note it for shape. +**Pseudo-Shape in the Narrative.** Approach, architecture, decomposition, or verification design must not enter the problem narrative or `docs/BRIEF.md`. If it appears while drafting, move it out and note it for shape. **Fake Precision.** Pre-slicing vague unknowns into numbered open questions that cannot yet be stated precisely. Coarse notes beat counterfeit clarity. @@ -220,9 +220,9 @@ Adopted from bootstrap's interview guide; binding on pitch. --- -## Brief Cold-Read +## Problem Cold-Read -Before offering shape-now or park, cold-read the authored brief. A stranger should name, in one pass: +Before offering shape-now or park, cold-read the authored narrative (issue-scale) or `docs/BRIEF.md` (project-scale). A stranger should name, in one pass: 1. The **destination** (or success end-state) 2. The **problem** @@ -230,4 +230,4 @@ Before offering shape-now or park, cold-read the authored brief. A stranger shou 4. The **current alternative** 5. The **value** of solving it -…and find **zero solution-space content** (no approach, stack, API shape, or task list). Precise open questions should carry HITL/AFK tags. If any of the five is missing or solution prose creeps in, revise before landing. +…and find **zero solution-space content** (no approach, stack, API shape, or implementation slices). Precise open questions should carry HITL/AFK tags. If any of the five is missing or solution prose creeps in, revise before landing. diff --git a/dist/cursor/skills/refactor-deepen/SKILL.md b/dist/cursor/skills/refactor-deepen/SKILL.md index 4e0974049..06a4998e5 100644 --- a/dist/cursor/skills/refactor-deepen/SKILL.md +++ b/dist/cursor/skills/refactor-deepen/SKILL.md @@ -173,7 +173,7 @@ skill terminates by writing a PLAN file using [templates/plan.md](templates/plan > Plan saved to `.agents/plans/<filename>.md`. Workflow handoff is pending > the SPEC/PLAN/TASKS artifact taxonomy spec — for now, decide manually. -Do **not** recommend breakdown or implement as the next step. The +Do **not** recommend implement as the next step. The handoff design is downstream of a deferred taxonomy spec. ### Codex Review (Opt-In, Plugin-Gated) diff --git a/dist/cursor/skills/refactor-deepen/templates/plan.md b/dist/cursor/skills/refactor-deepen/templates/plan.md index 8be684590..0fe537186 100644 --- a/dist/cursor/skills/refactor-deepen/templates/plan.md +++ b/dist/cursor/skills/refactor-deepen/templates/plan.md @@ -28,8 +28,8 @@ write a new file rather than updating an existing one. | `title` | Yes | One-line description of the deepening, not the candidate name | | `created` | Yes | ISO 8601 UTC, e.g. `2026-05-02T01:30:00Z` (must match the filename timestamp) | | `status` | Yes | `drafting` on first write; this template does not define additional lifecycle states | -| `spec` | Yes | `SPEC-NNN` if the plan is scoped under a spec; `null` otherwise (do not omit the key) | -| `related` | No | List of related artifact IDs (`ADR-*`, `SPEC-*`, idea filenames, other plan filenames) | +| `issue` | Yes | `LOAF-NNN` if the plan is scoped under an issue; `null` otherwise (do not omit the key) | +| `related` | No | List of related artifact IDs (`ADR-*`, `LOAF-*`, idea filenames, other plan filenames) | PLAN files do **not** carry an `id` frontmatter field. The filename is the identity, mirroring councils and ideas. @@ -131,7 +131,7 @@ Filename: `.agents/plans/20260502-013000-deepen-journal-append.md` title: "Deepen journal append into a self-managing module" created: "2026-05-02T01:30:00Z" status: drafting -spec: SPEC-034 +issue: LOAF-34 related: - 20260501-231922-plan-lifecycle-cli-doctor-housekeeping --- diff --git a/dist/cursor/skills/reflect/SKILL.md b/dist/cursor/skills/reflect/SKILL.md index 44322c585..47153e788 100644 --- a/dist/cursor/skills/reflect/SKILL.md +++ b/dist/cursor/skills/reflect/SKILL.md @@ -81,12 +81,12 @@ After completing work, reflect extracts learnings and proposes updates to strate ### Step 1: Parse Input -`$ARGUMENTS` can be: a spec ID (`SPEC-001`), a topic ("authentication learnings"), or empty (general reflection on recent work). +`$ARGUMENTS` can be: an issue ref (`LOAF-42`), a topic ("authentication learnings"), or empty (general reflection on recent work). ### Step 2: Gather Evidence Sources: -1. **Completed specs** (`.agents/specs/SPEC-*.md` with status `done`; legacy files may still read `complete`) -- look for "Lessons Learned" +1. **Completed issues** (`loaf issue list` / `loaf issue show <ref>` with status `done`) -- look for lessons in the issue body 2. **Project journal** (`loaf journal recent --json`, `loaf journal search <topic>`) -- insights, surprises, pivots 3. **Recent commits** (`git log --oneline -30`) 4. **Implementation reality** -- what was harder/easier than expected? What assumptions were wrong? diff --git a/dist/cursor/skills/release/SKILL.md b/dist/cursor/skills/release/SKILL.md index 510f4ee1d..9dbab87b1 100644 --- a/dist/cursor/skills/release/SKILL.md +++ b/dist/cursor/skills/release/SKILL.md @@ -1,33 +1,27 @@ --- name: release description: >- - Orchestrates standalone releases from already-landed work: release readiness, - version selection, changelog curation, release commit, tag, GitHub Release, - install verification, and post-release follow-up. Use when the user says "cut - a release," "publish a version," "release from main," or asks whether enough - landed work should become a release. Not for reviewing or merging a PR (use - ship). + Cuts a retroactive release from already-landed issues: loaf release suggest + reports the range, loaf release cut records the version. Use when the user + says "cut a release," "publish a version," "release from main," or asks what + landed since the last tag. Produces a recorded release row and members as + facts. Not for reviewing or merging a PR (use ship). version: 0.2.21 --- # Release -Publish a coherent version from work that has already landed. +Cut a version from work that has already landed. ## Contents - Critical Rules - Verification - Quick Reference - Topics -- Context Detection -- Step 1: Release Readiness -- Step 2: Change Collection -- Step 3: Version + Changelog -- Step 4: Release Execution -- Step 5: Release-PR Flow -- Step 6: Publication Verification -- Step 7: Post-Release Follow-Up -- Hook Interaction +- Process +- Attribution +- Bump derivation +- Must-contain convention - Related Skills **Input:** $ARGUMENTS @@ -36,259 +30,227 @@ Publish a coherent version from work that has already landed. ## Critical Rules -- **Release is not merge** -- do not use release to review, approve, or land a feature PR. Use ship for PR correctness and landing. -- **Release from landed work** -- collect changes from the release base branch, normally the repo default branch, since the last release tag. -- **Release-PR flow is the default** -- prepare on a release branch with `loaf release --pre-merge`, squash-merge the release PR, then finalize with `loaf release --post-merge` on the base branch. Direct `--bump` on the base branch is a named exception used only on explicit user request. -- **Batch by intent** -- group release notes by user-facing outcome, `CR-*` change bundle, spec, or related PRs; do not mirror individual commits mechanically. -- **Keep landed and released distinct** -- a PR may be landed without being released; a release may contain multiple landed PRs. -- **Block on release-readiness failure** -- do not publish if build, tests, version files, changelog, tag, or GitHub release state is inconsistent. -- **Never push, tag, or publish without confirmation** -- present the exact actions first. -- **Use your harness's structured question tool (if it has one) for release decisions** -- version bump type, release PR handoff, push/tag/GitHub Release confirmation. -- **Log release** -- after publication, run `loaf journal log "decision(release): vX.Y.Z published from <base> with <summary>"`. +1. **Log invocation first** — `loaf journal log "skill(release): <what is being cut or suggested>"` before doing anything else. +2. **Release is not merge** — do not review, approve, or land a PR here. Verification authority is the ship workflow (PR review and CI at merge). If the user is asking to merge, stop and route to ship. +3. **A release is cut from what landed** — the surface is `loaf release suggest` and `loaf release cut`. Do not run unsubcommmanded `loaf release`, `--pre-merge`, or `--post-merge`; this skill does not own that path. +4. **Suggest writes nothing** — it reads `baseline-tag..HEAD` (or `--base <ref>..HEAD`), attributes commits to issues, rolls up through parents, reports partially-landed parents and unattributed commits as information, derives the bump, reports the advisory bucket delta, and drafts notes. +5. **Cut records facts** — it applies the version, prepends the drafted notes into `CHANGELOG.md`, tags, records the release row plus members, then attempts a GitHub Release draft. A `gh` failure degrades to a warning with a paste-ready retry command; the recorded row stays. +6. **No forward version stamp** — do not bind an issue to a future version. Members are what already landed. Buckets (`loaf issue bucket`) are advisory labels; planned-vs-landed is information only. +7. **No suite, no re-record, no publication stop in this skill** — ship already verified the merged work. Cut's operational refusals (dirty worktree, disagreeing version files, missing version, `--no-tag` without an existing tag) are command errors, not a substitute for ship. +8. **Confirm before cut** — present the suggest report (or `cut --dry-run`) first. Ask one question at a time, with a recommendation, using your harness's structured question tool if it has one. `--dry-run` previews everything and writes nothing. +9. **Log the outcome** — after a successful cut, `loaf journal log "decision(release): vX.Y.Z recorded from <base> with <summary>"`. + +--- ## Verification -- Release base branch is clean, current, and contains the intended landed PRs -- Pre-flight checks pass before versioning or publication -- Changelog entries are curated user-facing prose, not commit or PR-title dumps -- Version files, changelog heading, git tag, and GitHub Release all agree -- Tag points at the released base-branch commit or release commit, not an abandoned feature branch -- Downstream install path is verified when applicable, especially Homebrew for Loaf releases +- Journal contains the `skill(release)` invocation (and a `decision(release)` entry after a real cut) +- The work in the range already landed through ship (PR review and CI at merge); this skill did not re-verify or re-merge it +- `loaf release suggest` (or `cut --dry-run`) was shown: landed issues, partially-landed parents, unattributed commits, advisory buckets, derived bump, drafted notes +- Partially-landed parents, unattributed commits, and bucket drift were reported as information — not treated as a cut refusal +- Mutating `loaf release cut` updated version files, wrote the notes into `CHANGELOG.md`, created or reused tag `v<version>`, and recorded the release row with issue members (plus `--includes` release members when given) +- `cut --dry-run` left version files, changelog, tags, HEAD, and release rows untouched +- GitHub Release is a draft, was skipped with `--no-gh`, or failed with a warning plus a paste-ready `gh release create …` retry — never a silent rollback of the recorded row +- No issue was stamped with a future version + +--- ## Quick Reference -| Step | Gate | Blocking? | -|------|------|-----------| -| Readiness | clean/current base branch, no unresolved release collisions | Yes | -| Change Collection | landed work since last tag grouped into release themes | Yes | -| Version + Changelog | bump selected, notes curated, files updated | Yes | -| Execution | release commit prepared via `--pre-merge`, release PR landed, `--post-merge` finalizes | Yes | -| Verification | release and install paths checked | Yes | -| Follow-Up | reflect/housekeeping suggested when useful | No | +| Harness | Invoke skill | +|---------|----------------| +| Claude Code (plugin) | `/loaf:release` | +| OpenCode, Cursor, Codex, Amp | `/release` | + +### Commands + +```text +loaf release suggest [--base <ref>] [--json] +loaf release cut [--base <ref>] [--bump <type>] [--includes <version|tag>] [--no-tag] [--no-gh] [--dry-run] +loaf issue bucket <ref> now|next|later|none [--json] +loaf issue link <from> blocks|relates-to <to> [--json] +``` + +Both commands need initialized SQLite state (`loaf state init`, or `loaf state migrate markdown --apply`). They are not a git repository's optional extra — without SQLite they refuse. + +| Command | Writes? | What it does | +|---------|---------|----------------| +| `loaf release suggest` | No | Report landed work since the last version tag | +| `loaf release cut` | Yes (unless `--dry-run`) | Cut the retroactive release and record members as facts | +| `loaf release cut --dry-run` | No | Print the plan, including `--includes` rows, and write nothing | + +### `suggest` flags + +| Flag | Meaning | +|------|---------| +| `--base <ref>` | Commits since `<ref>` instead of the last tag | +| `--json` | Machine-readable suggestion | +| `-h`, `--help` | Help | + +`suggest` rejects `--dry-run` (it is already read-only) and rejects cut-only flags (`--bump`, `--includes`, `--no-tag`, `--no-gh`). + +### `cut` flags + +| Flag | Meaning | +|------|---------| +| `--base <ref>` | Commits since `<ref>` instead of the last tag | +| `--bump <type>` | Override the derived bump: `major`, `minor`, `patch`, `prerelease`, `release` | +| `--includes <version\|tag>` | Record a prior release as a member (repeatable). Use this to hang prerelease references on a stable | +| `--no-tag` | Do not create a git tag; tag `v<version>` must already exist | +| `--no-gh` | Skip the GitHub Release draft | +| `--dry-run` | Print the plan and write nothing | +| `-h`, `--help` | Help | + +`cut` rejects `--json`. `--bump prerelease` and `--bump release` only produce a version when the current version already has a prerelease suffix; otherwise cut fails with `could not compute a version to cut`. `--no-tag` is checked before `--dry-run`: the tag must already exist even for a preview. + +### Cut sequence (mutating) + +1. Recompute the same suggestion as `suggest` (then apply `--bump` if given, and redraft notes) +2. Resolve each `--includes` ref to an existing release +3. Require a clean worktree +4. Apply the version to detected version files (they must exist and agree) +5. Prepend drafted notes into `CHANGELOG.md` (after `[Unreleased]`, ahead of prior versions; creates the file if missing) +6. Commit `chore: release vX.Y.Z` +7. Unless `--no-tag`: create annotated tag `vX.Y.Z` (`git tag -a`). Signing follows git config (`tag.gpgSign`); cut never passes `-s` or `--no-sign` +8. Record the release row, issue members, and `--includes` members as facts +9. Unless `--no-gh`: `gh release create <tag> --draft --title <tag> --notes <notes>` (adds `--prerelease` when the version is a prerelease). Switches to the configured GitHub account first. On `gh` missing, account failure, or create failure: print `warning:` plus a POSIX-quoted `retry:` command; do not fail the cut + +Cut does not push the commit or the tag. + +--- ## Topics | Topic | Use When | |-------|----------| -| [Context Detection](#context-detection) | Determining release base, last tag, and current branch | -| [Release-PR Flow](#step-5-release-pr-flow) | Preparing, landing, and finalizing every release | -| [Hook Interaction](#hook-interaction) | Understanding coexistence with git hooks | +| [Process](#process) | Running suggest then cut in this conversation | +| [Attribution](#attribution) | How commits become issue members | +| [Bump derivation](#bump-derivation) | Why suggest picked major, minor, or patch | +| [Must-contain convention](#must-contain-convention) | The rare promise that named issues must land first | --- -## Context Detection - -Before anything, establish the release surface: - -1. Get current branch and repo default branch: - ```bash - git branch --show-current - gh repo view --json defaultBranchRef -q .defaultBranchRef.name - ``` -2. Parse `$ARGUMENTS` for an explicit base, tag, or version. If omitted, use the repo default branch as the release base. -3. Verify the current branch: - - If already on the release base, continue; the release-PR flow in Step 5 branches from here. - - If on a dedicated release branch, resume the release-PR flow at the matching step. - - If on a feature branch, stop and explain that release publishes from landed work. Offer ship if the active PR needs landing first. -4. Find the previous release tag: - ```bash - git describe --tags --abbrev=0 - ``` -5. Gather the candidate release range: - ```bash - git log --oneline <last-tag>..HEAD - git diff --stat <last-tag>..HEAD - ``` +## Process ---- +Parse `$ARGUMENTS` for a base, bump, version, `--includes`, `--no-tag`, `--no-gh`, or `--dry-run`. Default baseline is the last version tag; `--base` overrides. With neither a last tag nor `--base`, the range is all of `HEAD`. -## Step 1: Release Readiness - -Run release pre-flight checks before editing release files: - -1. Ensure worktree is clean: - ```bash - git status --short - ``` -2. Ensure the release base is current: - ```bash - git fetch --tags origin - git status --branch --short - ``` -3. Check for existing tag or GitHub Release collisions for the target version once known: - ```bash - git tag --list vX.Y.Z - gh release view vX.Y.Z - ``` -4. Run project checks: - - Node: `npm run typecheck`, `npm run test`, `npm run build` when scripts exist - - Go: `go vet ./...`, `go test ./...` when `go.mod` exists - - Python: `pytest`, `mypy .`, `ruff check .` when configured - - Rust: `cargo check`, `cargo test` when `Cargo.toml` exists - -If no checks are detected, warn explicitly. If a check fails, stop and fix before release. +### Step 1: Log and route ---- +```bash +loaf journal log "skill(release): <what is being cut or suggested>" +``` -## Step 2: Change Collection - -Collect landed work since the last release and group it for release notes. - -1. Inspect commits: - ```bash - git log --first-parent --oneline <last-tag>..HEAD - git log --oneline <last-tag>..HEAD - ``` -2. Inspect merged PRs when GitHub is available: - ```bash - gh pr list --state merged --base <base> --json number,title,mergedAt,url - ``` -3. Group changes by user-facing outcome: - - `CR-*` change bundle, when referenced - - spec or task family, when public enough to be useful - - feature/fix/documentation/build themes - - operational release work, when it affects users or maintainers -4. Drop noise: - - purely internal task labels - - reverted work that is not present in `HEAD` - - individual commit mechanics that collapse into one user-facing change - -Present the grouped release contents before choosing the bump. +If the user wants a PR reviewed or merged, stop and use ship. If the work is still on a feature branch, explain that a release is cut from landed `HEAD` since the baseline, and offer ship. ---- +### Step 2: Suggest -## Step 3: Version + Changelog - -Choose the bump and curate the changelog from the grouped landed work. - -1. Run a dry run: - ```bash - loaf release --dry-run - ``` - Use `--base <ref>` when the project expects a non-default release base. -2. Present: - - current version - - proposed next version - - detected version files - - release actions the CLI would perform - - draft changelog entries -3. Curate `CHANGELOG.md` before publishing: - - write from the upgrading user's perspective - - group under Common Changelog categories: `Changed`, `Added`, `Removed`, `Fixed` - - use one self-describing line per meaningful change - - include public PR, issue, ADR, release, or commit links when helpful - - avoid dumping commit subjects, task IDs, session mechanics, or internal gate language -4. Confirm the bump type: `prerelease`, `release`, `major`, `minor`, or `patch`. +```bash +loaf release suggest +# or +loaf release suggest --base <ref> +loaf release suggest --json +``` ---- +Present the report as-is: base, suggested bump and version, bump evidence, landed issues with commits, partially-landed parents (missing children), unattributed commits, advisory buckets (planned landed / planned not landed / unplanned landed), drafted notes. -## Step 4: Release Execution +Do not hide partial parents or unattributed commits, and do not refuse the cut because of them unless the operator is using the [must-contain convention](#must-contain-convention) and wants to wait. -Every release routes through the release-PR flow in Step 5: prepare the release commit on a release branch with `loaf release --pre-merge`, land the release PR, then finalize with `loaf release --post-merge` on the base branch. +### Step 3: Confirm -Release preparation should: +Show the exact `loaf release cut …` you would run. Recommend cutting the derived version when the landed set matches what the operator asked for. Use `--bump` only when they override. Use `--dry-run` when they want a preview: -1. Update version files -2. Convert `[Unreleased]` into `## [X.Y.Z] - YYYY-MM-DD` -3. Reinsert a fresh empty `[Unreleased]` section -4. Run configured release artifact commands -5. Create the release commit +```bash +loaf release cut --dry-run +loaf release cut --dry-run --no-gh +loaf release cut --dry-run --includes <version|tag> +``` -After preparation, verify generated artifacts are current: +### Step 4: Cut ```bash -npm run build -git diff --exit-code -- dist plugins content/skills/loaf-reference/SKILL.md +loaf release cut +loaf release cut --bump minor +loaf release cut --includes v1.1.0-alpha.1 +loaf release cut --no-tag --no-gh ``` -Adjust the path list to the project. For Loaf itself, tracked generated outputs under `dist/`, `plugins/`, and native binaries must match the source changes. +On success, report version files updated, changelog written, tag created or reused, release recorded (member count), and GitHub draft created / skipped / warned. If stderr has `retry:`, paste that command; the row is already recorded. -Capability receipts pin artifact SHA-256s, and the release rebuild version-stamps generated artifacts (`dist/opencode/plugins/hooks.ts` embeds `@version`, so every version bump stales the OpenCode receipt; Go changes additionally stale all binary-pinned receipts via `bin/native`). Therefore re-recording runs AFTER `loaf release --pre-merge` completes its artifact rebuild, on the release branch, before pushing the release PR — never before the bump. Verify with `go test ./internal/cli -run TestTargetCapabilityEvidence`. `loaf release` now enforces this mechanically on every mutating path (post-rebuild refusal in apply, guardrail 9 in `--post-merge`) — the rule explains WHY the gate fires; the gate makes skipping it impossible. +```bash +loaf journal log "decision(release): vX.Y.Z recorded from <base> with <summary>" +``` -### Direct Release (Named Exception) +### Step 5: After -`loaf release --bump <type> --yes` on the base branch prepares, commits, tags, and publishes in a single shot. Use it only when the user explicitly requests a direct release; never select it by default. Skipping the release PR means nothing runs the suite against the prepared tree before the tag exists — the v0.2.16 cut took this door and a capability-evidence canary surfaced only in tag CI, after publication. The same day, v0.2.17 re-recorded evidence minutes before the version bump; the release commit staled it, and the tag again published zero assets — ordering, not diligence, is the failure mode. The CLI prints a flow advisory when a mutating release starts on the default branch; treat it as a routing signal, not noise. +Suggest reflect when the cut produced durable product or workflow learnings, and housekeeping when temporary artifacts need cleanup. Capture leftover discoveries as issues or sparks — not as extra changelog lines. --- -## Step 5: Release-PR Flow +## Attribution -The default for every release: PR CI runs the full suite against the prepared tree, so evidence canaries surface before any tag or GitHub Release exists. This holds regardless of repository settings — where branch protection is enabled it is satisfied as a side effect, not the reason for the flow. +`suggest` (and `cut`, which recomputes the same suggestion) attributes each commit in the range to zero or more issues, then rolls up through parents. -1. Create a dedicated release branch from the release base. -2. Run `loaf release --pre-merge` on it: this creates the version/changelog/artifact release commit but no tag and no GitHub Release. -3. Open a release PR with a concise release-focused body. -4. Hand the PR to ship for review and landing; squash-merge it into one `chore: release vX.Y.Z (#PR)` commit carrying the curated changelog. -5. After the release PR lands, run `loaf release --post-merge` on the base branch to tag, publish the GitHub Release, and verify installability. +**Commit → issue**, first match wins: -If guardrail 9 fires on `--post-merge`, the merged tree itself carries stale evidence; recovery is to re-record against the merged tree, land the receipts as a single evidence-only commit on the base branch (the repair commit must not modify the capability registry), and rerun `loaf release --post-merge`. +1. Issue alias (`PREFIX-N`, e.g. `LOAF-42`) in the subject or body (prefix case-sensitive). URLs and code spans are stripped first. +2. Else the merge/branch rung: aliases in a `Merge …` subject, plus any alias anywhere in the body (case-insensitive). Squash subjects like `feat: add auth (#42)` often carry the alias only in the body. No network, no `gh`. An alias that lived only on a deleted branch name is unattributable. +3. Else a unique journal `commit(<hash>)` row whose message contains an alias (scope uniquely matching that commit). -Do not hide this handoff inside release: ship remains the PR correctness and merge gate. +Resolved aliases become **landed** issues (with the commits that named them). Commits that match nothing are **unattributed** — listed, and included under drafted notes as `### Unattributed`. They do not block the cut. ---- +**Parent rollup** (information): -## Step 6: Publication Verification +- For each landed issue that has a parent, if any sibling child is not `done`, the parent is **partially landed** and the missing children are listed. +- A parent is not auto-added to landed unless a commit attributed to it. -After publishing, verify the public release state: +Drafted notes are `## [version] - YYYY-MM-DD`, then one `### ALIAS — title` section per landed issue with commit subjects, then unattributed. -1. Confirm tag location: - ```bash - git show --stat vX.Y.Z - ``` -2. Confirm GitHub Release: - ```bash - gh release view vX.Y.Z - ``` -3. Confirm package or installer availability when applicable: - - npm: `npm view <package> version` - - Homebrew: `brew update && brew info <tap>/<formula>` - - project-specific deploy or artifact registry checks -4. For Loaf/Homebrew, report readiness only after the GitHub release exists, assets are uploaded, the tap formula is updated, and tap CI has passed. +--- -If publication partially completes, do not retag casually. Name the exact state and continue with the smallest repair or patch release path. +## Bump derivation ---- +Derived from the range, in order: -## Step 7: Post-Release Follow-Up +| Condition | Bump | +|-----------|------| +| Breaking marker (`type!:` in the subject, or `BREAKING CHANGE:` / `BREAKING-CHANGE:` in the body) | `major` | +| A **done** parent with **two or more** children, every child `done` and landed, and the parent's done timestamp **after** the baseline tag's committer time | `minor` (closed multi-child parent fully landed) | +| Else a conventional `feat` commit | `minor` | +| Else | `patch` (`fix` / other) | -After verification: +`--bump` on `cut` replaces the derived bump and redrafts notes; the evidence string becomes `overridden by --bump <type>`. -1. Log the release decision to the project journal: - ```bash - loaf journal log "decision(release): vX.Y.Z published from <base> with <summary>" - ``` -2. Suggest reflect when the release produced durable product or workflow learnings. -3. Suggest housekeeping when release branches or temporary reports need cleanup. -4. Keep future-work discoveries out of the release notes; capture them as tasks, ideas, or sparks instead. +Current version comes from agreeing version files, else from a semver last tag. Cut still requires version files on the mutating path. --- -## Hook Interaction +## Must-contain convention -This skill coexists with existing hooks. Git workflow hooks are advisory unless -configured otherwise; security and secret-scanning hooks remain blocking. +Rare. When the operator needs a named set of issues to land before a cut, create a **release-prep** issue and express `blocked_by` edges with issue mechanics: + +```bash +loaf issue new "Release prep for vX.Y.Z" --body "Must contain LOAF-12 and LOAF-15. Out of scope: the cut itself." +loaf issue link LOAF-12 blocks LOAF-99 +loaf issue link LOAF-15 blocks LOAF-99 +``` -| Hook | Type | When release Runs | -|------|------|---------------------| -| `github-account` | Force-switch | Switches to the configured GitHub account before `gh` release operations; blocks only if the switch fails | -| `validate-push` | Advisory | Cross-checks version bump, changelog, and build on push | -| `workflow-pre-pr` | Advisory | Fires when the release PR is opened | -| `workflow-pre-merge` | Advisory | Belongs to ship when a release PR must land | -| `workflow-post-merge` | Advisory | Belongs to ship after PR landing | -| `check-secrets` | Blocking | Always respected before writes or shell actions | +Stored write types are `blocks` and `relates_to`. `loaf issue link <must-land> blocks <release-prep>` is how you record that the prep issue is blocked by those that must land. `loaf issue frontier` and implement honor `blocks`. **`loaf release suggest` and `cut` do not read these edges** — convention, not schema. If the operator wants to wait, wait; if they cut anyway, cut records whatever actually landed. -Do not disable hooks to force a release through. +Buckets stay labels: ---- +```bash +loaf issue bucket LOAF-12 now +loaf issue bucket LOAF-12 none +``` -## Suggests Next +`suggest` prints `bucket:<name> ALIAS — title (landed|not landed)` and `unplanned ALIAS — title (landed)`. Never treat that delta as a constraint. -After a successful release, suggest reflect for durable learnings and housekeeping if temporary release artifacts need attention. +--- ## Related Skills -- **ship** -- Reviews, verifies, and lands a PR before it becomes release input -- **git-workflow** -- Branching, PR, commit, and squash merge conventions -- **documentation-standards** -- Changelog and release-note quality -- **reflect** -- Updates strategy from shipped/released learnings -- **housekeeping** -- Cleans up completed spec, report, and handoff artifacts +- **ship** — Reviews, verifies, and lands a PR. That merge is the verification authority for what this skill may later cut +- **git-workflow** — Branching, PRs, and any later push of the local tag (cut does not push) +- **documentation-standards** — Changelog prose if a human edits notes after the cut +- **reflect** — Durable learnings after a cut +- **housekeeping** — Cleanup of temporary artifacts diff --git a/dist/cursor/skills/research/SKILL.md b/dist/cursor/skills/research/SKILL.md index af98d9acd..f99be1eca 100644 --- a/dist/cursor/skills/research/SKILL.md +++ b/dist/cursor/skills/research/SKILL.md @@ -94,7 +94,7 @@ Always check project context first. Rate findings: **High** (official/verified), **Trigger:** Empty input, "project state", "catch me up" 1. Read project documents: VISION.md, STRATEGY.md, ARCHITECTURE.md -2. Check ideas with `loaf idea list --json` and specs with `loaf spec list --json` +2. Check ideas with `loaf idea list --json` and issues with `loaf issue list --json` (or `loaf issue export` for the full graph) 3. Review recent journal activity with `loaf journal recent --json` and `loaf journal context` 4. Check recent commits: `git log --oneline -20` 5. Synthesize following [state-assessment template](templates/state-assessment.md) @@ -144,4 +144,4 @@ User-facing entry for a new concept is pitch (problem-discovery brief). Do not t ## Artifact Naming -Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or Change already records that provenance. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. +Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or the issue already records that provenance. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. diff --git a/dist/cursor/skills/research/templates/report.md b/dist/cursor/skills/research/templates/report.md index 2ff4c059c..f7cc5d7dd 100644 --- a/dist/cursor/skills/research/templates/report.md +++ b/dist/cursor/skills/research/templates/report.md @@ -14,7 +14,7 @@ title: "Report: [Topic]" type: research | audit | analysis | council created: YYYY-MM-DDTHH:MM:SSZ status: draft | done | archived -source: SPEC-XXX | TASK-XXX | ad-hoc +source: LOAF-42 | ad-hoc tags: [] --- diff --git a/dist/cursor/skills/research/templates/state-assessment.md b/dist/cursor/skills/research/templates/state-assessment.md index 65e426f3b..359392033 100644 --- a/dist/cursor/skills/research/templates/state-assessment.md +++ b/dist/cursor/skills/research/templates/state-assessment.md @@ -24,7 +24,7 @@ tags: [] - **Vision:** [Brief summary] - **Key personas:** [Who we're building for] -- **Current focus:** [Active specs/work] +- **Current focus:** [Active issues] ## Recent Progress @@ -32,10 +32,10 @@ tags: [] ## In Flight -| Spec/Task | Status | Notes | -|-----------|--------|-------| -| SPEC-001 | implementing | [progress] | -| SPEC-002 | approved | [next up] | +| Issue | Status | Notes | +|-------|--------|-------| +| LOAF-1 | active | [progress] | +| LOAF-2 | todo | [next up] | ## Ideas Pipeline diff --git a/dist/cursor/skills/shape/SKILL.md b/dist/cursor/skills/shape/SKILL.md index 42fce86f5..c4fa4a21d 100644 --- a/dist/cursor/skills/shape/SKILL.md +++ b/dist/cursor/skills/shape/SKILL.md @@ -1,25 +1,21 @@ --- name: shape description: >- - Shapes messy input into a bounded, reviewable Change under - docs/changes/YYYYMMDD-slug/ (change.json + shape.md + tasks/), validated by - loaf change check. Runs a fog-routed narrowing protocol — gather context, - optional blindspot pass, grilling, reaction artifacts — seeds task-file - vertical slices, runs a critique gate, and offers an opt-in draft PR. Use when - the user asks "shape this," "turn this into a Change," or an idea has enough - constraints to bound. Produces role-named narrative (shape.md required; - brief/plan/design optional) plus task packets — never a numbered spec. - Teaches the problem-boundary test (same problem → another task; different - problem → Intent) and vertical-slice discipline. Not for quick capture (use - idea), problem discovery that should author a brief first (use pitch), or - open-ended divergent thinking (agent technique: explore / brainstorm — user - entry intent routes to pitch). + Shapes messy input into a bounded issue — problem body, definition-of-done + criteria, out-of-scope statement, and children when a criterion earns its own + DoD — validated by loaf issue check. Use when the user asks "shape this," + "turn this into an issue," or a diagnosed fix needs a row. Produces a shaped + issue — never a folder or a plan document. Teaches fog graduation (park, + then a decision child) and one-criterion sizing (one fresh context window, + verifiable alone). Not for quick capture (use idea), problem discovery that + should author a brief first (use pitch), or open-ended divergent thinking + (agent technique: explore / brainstorm — user entry routes to pitch). version: 0.2.21 --- # Shape -Turn messy input into a bounded, reviewable Change. +Prepare a bounded, reviewable issue. ## Contents - Critical Rules @@ -35,29 +31,30 @@ Turn messy input into a bounded, reviewable Change. ## Critical Rules -1. **Log invocation first** — `loaf journal log "skill(shape): <input being shaped>"` before doing anything else. -2. **Produces a Change, never a spec** — `change.json` + `shape.md` (+ optional `brief.md`/`plan.md`/`design.md`) and `tasks/TASK-NNN-slug.md`. No sequentially-numbered spec file, no status-like fields anywhere. +1. **Log invocation first** — `loaf journal log "skill(shape): shaping <topic> into LOAF-42"` before doing anything else. If no issue exists yet, log `skill(shape): shaping <topic>` and add the alias in the outcome entry. +2. **Produces an issue, never a folder** — the deliverable is the issue row: problem in the body, definition of done as `loaf issue dod` criteria, an explicit out-of-scope statement in the body, children via `loaf issue promote` when a criterion earns its own DoD. No plan document is committed. The PR body, if a PR is opened, is `loaf issue render` output. 3. **The fog register routes, you don't guess** — every named unknown carries a quadrant tag that dispatches it to exactly one technique (see Quick Reference). Technique-by-vibes is the failure mode this replaces. -4. **Blindspot pass precedes grilling, offered not imposed** — run it when the territory is unfamiliar; skip it when the shaper is the domain expert. Never impose it as a mandatory step. -5. **Grill one question at a time, with a recommendation** — using your harness's structured question tool if it has one (otherwise one inline question per message — same semantics). Never a form to fill in one pass. Order by architectural impact: questions whose answer would change the architecture go first, cosmetic questions last. -6. **Techniques return fog entries, not decisions** — blindspot passes, grilling, and reaction artifacts hand back named unknowns and evidence for the human to adjudicate. Reconnaissance, not orders. -7. **Own the decomposition** — decide Implementation Unit boundaries and granularity autonomously (absorbed from the retired breakdown step); ask only when two orderings carry genuinely different trade-offs. -8. **Order units by likelihood-of-change** — data models, interfaces, and user-facing flows lead; mechanical work collapses at the bottom, so review attention lands on what's most likely to need changing. -9. **Surface misalignment, never silently adjust** — when the idea conflicts with strategic docs, prior Changes, or the journal, tell the user and let them decide. Don't quietly reshape their idea. -10. **Critique before finalizing** — run the Critique Gate as the last shaping step, before `loaf change check` and the PR offer. -11. **Get approval before `loaf change init`** — don't scaffold the folder without explicit confirmation of scope. -12. **Log the outcome** — `loaf journal log "decision(shape): <slug> shaped — N units, N open fog entries"`. +4. **Fog graduates instead of evaporating** — a question not yet sharp enough is parked in the issue's `fog` field (`loaf issue new --fog`). When it sharpens it becomes a `--kind decision` child, which is ready when it poses a sharp question (a `?` in the title or body). No plan required. +5. **Blindspot pass precedes grilling, offered not imposed** — run it when the territory is unfamiliar; skip it when the shaper is the domain expert. Never impose it as a mandatory step. +6. **Grill one question at a time, with a recommendation** — using your harness's structured question tool if it has one (otherwise one inline question per message — same semantics). Never a form to fill in one pass. Order by architectural impact: questions whose answer would change the architecture go first, cosmetic questions last. +7. **Techniques return fog entries, not decisions** — blindspot passes, grilling, and reaction artifacts hand back named unknowns and evidence for the human to adjudicate. Reconnaissance, not orders. +8. **Decomposition is the tail** — a parent gets children only when its DoD needs more than one coherent slice. A criterion becomes a child the moment it earns its own DoD, via `loaf issue promote`. Own those boundaries autonomously; ask only when two orderings carry genuinely different trade-offs. +9. **One sizing criterion** — a slice is right-sized when it fits one fresh context window and is verifiable alone. Expand–contract is the named exception for wide mechanical refactors. See [references/decomposition.md](references/decomposition.md). +10. **Surface misalignment, never silently adjust** — when the idea conflicts with strategic docs, prior issues, or the journal, tell the user and let them decide. Don't quietly reshape their idea. +11. **Critique before finalizing** — run the Critique Gate as the last shaping step, before `loaf issue check`. +12. **A diagnosed one-line fix is two commands** — `loaf issue new` with a body that states the problem and `Out of scope: …`, then one `loaf issue dod add`. No problem-space ceremony. Confirm scope with the user before `loaf issue new` on anything larger. +13. **Log the outcome** — `loaf journal log "decision(shape): LOAF-42 shaped — N children, N open fog entries"`. --- ## Verification -- `docs/changes/YYYYMMDD-slug/` has `change.json` + `shape.md` with Product Contract sections non-empty; task packets seeded under `tasks/` when decomposition is known -- Every Open Questions entry carries a quadrant tag (`[KU]`, `[UK]`, or `[UU]`) and a route -- `loaf change check` reports zero violations (no legacy deprecation on new layout); executability gaps were read, not ignored -- Problem-boundary test applied: discovered different problems become Intents, not TASK-007 -- The Critique Gate ran, and its answers changed the documents where they applied -- No status-like fields in `change.json` or task frontmatter +- The issue body states the problem and contains an explicit out-of-scope statement (`out of scope`, case-insensitive — that substring is what `loaf issue check` reads) +- At least one definition-of-done criterion exists; V-tier criteria carry `--command` (and `--expect` when the check is more than exit 0); H-tier otherwise +- Every open unknown is either parked in create-time `fog`, held in the session register until it sharpens, graduated to a `--kind decision` child (or sibling) with a sharp question, or written into the body as a decided answer +- `loaf issue check <ref>` reports the issue shaped (delivery) or ready (decision). When children exist, coverage failures were fixed and containment orphans were filed as sibling backlog issues using the printed remedy +- Problem-boundary test applied: a discovered different problem becomes a new backlog issue, not another criterion on this one +- The Critique Gate ran, and its answers changed the issue where they applied --- @@ -65,34 +62,46 @@ Turn messy input into a bounded, reviewable Change. ### Fog register format -Open Questions entries take one of three forms: +Open unknowns take one of three forms. Keep the register in the session. Park what is still unsharp in `--fog` at create; after create, unsharp entries stay in the session register (edit cannot mutate `fog`). Graduate what is sharp to a decision child or sibling, and write decided answers into the body. ```text -- [KU] <the unknown> → <route: grilling | research spike | owner section> -- [UK] <the recognize-it-when-seen criterion> → reaction artifact in research/ +- [KU] <the unknown> → <route: grilling | research spike | owner> +- [UK] <the recognize-it-when-seen criterion> → reaction artifact - [UU] <the suspected blind area> → blindspot pass over <territory> ``` -An entry resolves by becoming a Decision, a Planning Contract subsection, or a named follow-up — visible in the diff, never silently deleted. A `[UU]` that gets named becomes a `[KU]` or `[UK]` and re-routes through the table below. +An entry resolves by becoming a decision child, a body paragraph, a criterion, or remaining parked in `fog` — visible on `loaf issue show`, never silently deleted. A `[UU]` that gets named becomes a `[KU]` or `[UK]` and re-routes through the table below. ### Quadrant routing | Tag | Meaning | Routes to | |-----|---------|-----------| | `[KU]` known unknown | A question you can state precisely | [Grilling](references/grilling.md) (architecture-changing answers first) or a research spike | -| `[UK]` unknown known | You'd recognize the right answer if you saw it, but can't state it yet | [Reaction artifact](references/reaction-artifact.md) — a variant or mock in `research/`, react and pick | +| `[UK]` unknown known | You'd recognize the right answer if you saw it, but can't state it yet | [Reaction artifact](references/reaction-artifact.md) — a variant or mock, react and pick | | `[UU]` suspected blind spot | Unfamiliar territory; you don't yet know what you don't know | [Blindspot pass](references/blindspot-pass.md) | -No route names a skill invocation. Research re-interviews an already-scoped question and writes to `.agents/reports/`; brainstorm forces a strategic frame onto a Change-local question and sends resolutions to intake. Shape runs all three techniques itself, in-session, and writes evidence into the Change's own `research/` — never `.agents/reports/`. +No route names a skill invocation. Research re-interviews an already-scoped question; brainstorm forces a strategic frame onto an issue-local question and sends resolutions to intake. Shape runs all three techniques itself, in-session, and writes the captured answer onto the issue — never into `.agents/reports/`. ### Defined terms -- **Rabbit holes** — tempting expansions of scope that would consume disproportionate effort for marginal value; name them so nobody wanders in unknowingly. -- **No-gos** — approaches explicitly forbidden for this Change, stated so they aren't silently reconsidered mid-implementation. +- **Rabbit holes** — tempting expansions of scope that would consume disproportionate effort for marginal value; name them in the out-of-scope statement so nobody wanders in unknowingly. +- **No-gos** — approaches explicitly forbidden for this issue, stated so they aren't silently reconsidered mid-implementation. ### Source inputs recognized -Step 1 reads whatever `$ARGUMENTS` names, or asks. Recognized sources: a change `brief.md` (from pitch or capture), a journal entry (cite by ID), a spark, an idea, a brainstorm document, a Linear issue, a PR conversation, a prior Change, or plain conversation with no artifact behind it yet. +Step 1 reads whatever `$ARGUMENTS` names, or asks. Recognized sources: a brief from pitch, a journal entry (cite by ID), a spark, an idea, a brainstorm document, a Linear issue, a PR conversation, a prior issue, or plain conversation with no artifact behind it yet. + +### One-line entry + +A diagnosed fix that already has a problem and a done-check: + +```bash +loaf issue new "Fix missing --json in list help" --body "issue list --help omits --json. Out of scope: rewriting other help pages." +loaf issue dod add LOAF-42 "issue list help names --json" --command "loaf issue list --help" --expect "contains \`--json\`" +loaf issue check LOAF-42 +``` + +Two writes, then the readiness verdict. No grilling, no children, no files. --- @@ -100,53 +109,86 @@ Step 1 reads whatever `$ARGUMENTS` names, or asks. Recognized sources: a change ### Step 1: Gather Context -Parse `$ARGUMENTS` against the source inputs above. When the input names a Change folder that already has `brief.md` (or you find one for this work), treat the brief as primary: restate the problem from it, confirm with the user rather than re-discovering, and keep later grilling on solution-space (how, boundaries, verification) — pitch already framed the problem. When no brief exists, run full narrowing as today; pitch is the recommended front door for raw concepts, never a gate. Read the journal (`loaf journal recent` / `search`) for related history, and check for a prior Change touching the same area. When VISION.md, STRATEGY.md, and ARCHITECTURE.md exist, read them for strategic fit. Most consumer projects don't have them yet — when absent, shape against the journal, recent Changes, and the conversation instead, and say so in the Change's Source Inputs. +Parse `$ARGUMENTS` against the source inputs above. When a brief from pitch already frames the problem, restate it, confirm with the user rather than re-discovering, and keep later grilling on solution-space (how, boundaries, verification). When no brief exists, run full narrowing; pitch is the recommended front door for raw concepts, never a gate. Read the journal (`loaf journal recent` / `search`) for related history, and check for a prior issue touching the same area (`loaf issue list`, `loaf issue tree`). When VISION.md, STRATEGY.md, and ARCHITECTURE.md exist, read them for strategic fit. Most consumer projects don't have them yet — when absent, shape against the journal, recent issues, and the conversation instead, and say so in the issue body. ### Step 2: Evaluate Strategic Fit -When strategic docs exist, check: does this advance the vision, serve the target personas, fit technical constraints, avoid conflicting with in-flight Changes? On misalignment, **surface it to the user — never silently adjust the idea**. The user decides whether to proceed, narrow scope, or defer to reflect after this ships. +When strategic docs exist, check: does this advance the vision, serve the target personas, fit technical constraints, avoid conflicting with in-flight issues? On misalignment, **surface it to the user — never silently adjust the idea**. The user decides whether to proceed, narrow scope, or file the conflicting concern as its own backlog issue. -### Step 3: Name the Change and Initialize +### Step 3: Name the Issue and Write the Row -Once the shape of the work is nameable, confirm scope with the user, then: +Once the work is nameable, confirm scope with the user (skip this confirmation on the one-line path), then create the row. Prefer creating after the first narrowing pass so `--fog` can carry remaining unsharp questions — the CLI writes `fog` only at create. ```bash -loaf change init <slug> +loaf issue new "Rotate auth tokens on a sliding window" \ + --body "Sessions never expire while the tab stays open, so a stolen cookie is valid indefinitely. + +Out of scope: migrating existing sessions; third-party IdP support." \ + --fog "[KU] sliding-window length → grill; [UU] existing session-store conventions → blindspot pass" ``` -On a fresh slug this scaffolds `change.json` + `shape.md` + seeded `tasks/` from the embedded templates (see `templates/shape.md`, `templates/task.md`). On a capture-only folder that already has `change.json` + `brief.md` (from pitch or `init --brief`), the same command promotes in place — preserving brief and metadata verbatim while materializing `shape.md` and `tasks/` — never hand-copy templates into the folder; rely on that promotion path. Use `loaf change init <slug> --brief` only for capture-before-shape (emits `change.json` + `brief.md`). It does not switch branches — `git switch -c <slug>` yourself. Fill `shape.md` Product Contract sections as understanding solidifies; seed `tasks/TASK-NNN-slug.md` as vertical slices (a task is a commit, not a PR). Optional `plan.md`/`design.md` accrete when the how needs prose. See [references/cli-boundary.md](references/cli-boundary.md). +Default kind is `delivery`; default status is `triage`. `--status` accepts `triage`, `backlog`, `todo`, `active`, or `done`. Use `--body -` or `--body-file <path>` for a longer body; `loaf issue edit <ref>` later **replaces** the body, it does not patch it. + +A delivery issue is shaped when the body is nonempty (the problem), at least one criterion exists, and the body contains an explicit out-of-scope statement. Fill those as understanding solidifies — create can carry the first body; criteria come next. + +A discovered different problem is a new backlog issue, not a child of this one: + +```bash +loaf issue new --status backlog "Rewrite the session store" +``` ### Step 4: Narrow the Unknowns -Offer the blindspot pass when the territory is unfamiliar (a new domain, an unfamiliar subsystem, a first collaboration) — skip it when the shaper is the expert. Its fog entries, and any others surfaced in interview, route by quadrant (Quick Reference above). Loop: grill `[KU]` entries, react to `[UK]` entries, run blindspot reconnaissance on `[UU]` entries until each gets a name and re-routes. Stop grilling when no unrouted `[KU]` entries remain or answers stop changing the contract. Entries still open at the end of the session are fine — each names its owner (a section, a spike, a follow-up). +Offer the blindspot pass when the territory is unfamiliar (a new domain, an unfamiliar subsystem, a first collaboration) — skip it when the shaper is the expert. Its fog entries, and any others surfaced in interview, route by quadrant (Quick Reference above). Loop: grill `[KU]` entries, react to `[UK]` entries, run blindspot reconnaissance on `[UU]` entries until each gets a name and re-routes. Stop grilling when no unrouted `[KU]` entries remain or answers stop changing the issue. -### Step 5: Decompose into Implementation Units +When a parked question sharpens, graduate it — after the parent's DoD is written (Step 5). Attaching **any** child, including a decision, turns coverage on. -Absorbed from the retired breakdown step — see [references/decomposition.md](references/decomposition.md) for the Right Size Test and per-unit verification discipline, including the V-tier `Command:` / `Expect:` forms `loaf change verify` parses (commands run from the repository root; H-tier is never gate input). Order units by likelihood-of-change; state real sequencing constraints in prose, never by list order alone. +```bash +loaf issue new --kind decision --parent LOAF-42 "Should tokens live in httpOnly cookies?" +``` + +A decision issue is ready when the title or body contains `?`. It needs no criteria and no out-of-scope statement. A decision child does not claim a parent criterion, so promote (or otherwise claim) the parent's DoD before adding children, or keep the decision as a sibling (`loaf issue new --kind decision --status backlog`, no `--parent`) if the parent stays a leaf. Unsharp questions discovered after create stay in the session register until they graduate — there is no `--fog` on edit. See [references/decomposition.md](references/decomposition.md). + +### Step 5: Write Definition of Done (decomposition tail) + +Add criteria as the interrogation produces observable done-checks. V-tier when a command can disagree with the implementation; H-tier when only a human can tell. + +```bash +loaf issue dod add LOAF-42 "Sliding-window expiry is covered by tests" --command "go test ./internal/auth/..." --expect "exit 0" +loaf issue dod add LOAF-42 "Stolen-cookie writeup is reviewable" --tier H +``` + +`--command` implies V unless `--tier` overrides. `--expect` uses the verify grammar (`exit <N>`, `` contains `text` ``, joined by ` and `). Commands run from the repository root. See [references/cli-boundary.md](references/cli-boundary.md) and [references/decomposition.md](references/decomposition.md). + +A parent gets children only when its DoD needs more than one coherent slice. The moment a criterion earns its own DoD, promote it — the parent criterion stays, the child starts with a copy, and the claim is recorded so coverage holds for that position: -### Step 6: Fill the Planning Contract +```bash +loaf issue promote LOAF-42 1 +``` -Write the free-form `###` subsections the work actually needs (approach, placement, risks, sequencing) inside the Planning Contract container. Its subsection names are yours; the container itself, plus Implementation Units, Verification Contract, and Definition of Done, is what `loaf change check` looks for. Durable Outputs stays forward-looking here — name what a final spec, ADR, or knowledge doc will need to capture, but don't write it now. Durable artifacts get created after implementation proves what's true, not during shaping. +Then shape the child the same way (body, out-of-scope, its own criteria). Order children by likelihood-of-change when presenting them; state real sequencing with `loaf issue link <from> blocks <to>`, never by tree order alone. -### Step 7: Run the Critique Gate +### Step 6: Run the Critique Gate -Before finalizing, challenge the draft — see [references/critique-gate.md](references/critique-gate.md). Is scope still bounded, does every new command or state name its ceremony, is a status field creeping back in under another name, is the CLI/skill boundary drawn correctly, and could this be smaller and still deliver the Hypothesis? +Before finalizing, challenge the draft — see [references/critique-gate.md](references/critique-gate.md). Is scope still bounded, does every new command or state name its ceremony, is a second progress flag creeping into the body, is the CLI/skill boundary drawn correctly, and could this be smaller and still be verifiable in one fresh context window? -### Step 8: Validate +### Step 7: Validate ```bash -loaf change check +loaf issue check LOAF-42 ``` -Read violations (always block — fix them) separately from the executability report (derived, informational unless `--require-executable` is passed — that flag is implement's preflight and CI's non-draft gate, not shape's business). See [references/cli-boundary.md](references/cli-boundary.md). +A delivery issue that passes prints `issue LOAF-42 is shaped`; a decision issue prints `issue LOAF-42 is ready`. Failures always block (missing body, missing criterion, missing out-of-scope, no sharp question, uncovered parent criterion). Containment orphans are reported, not failed: each line includes a ready-to-paste remedy that files the orphan as a sibling backlog issue — run that command, do not invent a different disposition. + +`loaf issue verify <ref>` runs V-tier commands from the repository root and writes nothing. That is implement's preflight, not shape's gate. See [references/cli-boundary.md](references/cli-boundary.md). -### Step 9: Offer the Draft PR +### Step 8: Offer the Review Surface -Offer to push the branch and open a draft PR, using [the PR template](templates/pr.md) — opt-in, never automatic. `loaf change check` (with no `--require-executable`) plus `gh pr list` is the cross-branch index either way. +The issue lives in SQLite. There is no folder to commit and nothing plan-shaped to land. Offer `loaf issue show <ref>` and `loaf issue tree <ref>` as the review surface. If a PR is being opened for the work, its body is `loaf issue render <ref>` — paste-ready, no manual editing. Opt-in, never automatic. -### Step 10: Log the Outcome +### Step 9: Log the Outcome -`loaf journal log "decision(shape): <slug> shaped — N units, N open fog entries"`. +`loaf journal log "decision(shape): LOAF-42 shaped — N children, N open fog entries"`. --- @@ -154,8 +196,8 @@ Offer to push the branch and open a draft PR, using [the PR template](templates/ - **pitch** — Problem-discovery ceremony that authors a brief; preferred front door when the problem is not yet framed - **idea** — Quick capture; feeds into pitch or shape once a concept has enough weight -- **brainstorm** — Agent technique for divergent thinking (route user entry intent to pitch) -- **implement** — Starts execution once a Change is structurally executable; this does not prove implementation completion +- **brainstorm** — Agent technique for divergent thinking (route user entry to pitch) +- **implement** — Starts execution once `loaf issue check` reports the issue shaped; this does not prove implementation completion - **reflect** — Updates strategic docs after the shipped work proves what changed ## Topics @@ -165,10 +207,10 @@ Offer to push the branch and open a draft PR, using [the PR template](templates/ | Blindspot pass | [references/blindspot-pass.md](references/blindspot-pass.md) | Deciding whether to offer reconnaissance, and how to prompt it | | Grilling | [references/grilling.md](references/grilling.md) | Running the one-question-at-a-time interview for `[KU]` entries | | Reaction artifacts | [references/reaction-artifact.md](references/reaction-artifact.md) | Resolving `[UK]` entries with a variant, mock, or prototype | -| Decomposition | [references/decomposition.md](references/decomposition.md) | Sizing and ordering Implementation Units | -| CLI boundary | [references/cli-boundary.md](references/cli-boundary.md) | Reading `loaf change init`/`check`/`verify` output, or explaining `--require-executable` | +| Decomposition | [references/decomposition.md](references/decomposition.md) | Sizing slices, promoting criteria, reading coverage and containment | +| CLI boundary | [references/cli-boundary.md](references/cli-boundary.md) | Reading `loaf issue` output, authoring `--command`/`--expect`, or explaining `loaf issue check` | | Critique Gate | [references/critique-gate.md](references/critique-gate.md) | Self-challenging scope and boundaries before finalizing | ## Artifact Naming -Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or Change already records that provenance. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. +Shape's deliverable is the issue row. If a reaction artifact or spike note lands on disk, name it for what it is, never for the issue that produced it. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. diff --git a/dist/cursor/skills/shape/references/blindspot-pass.md b/dist/cursor/skills/shape/references/blindspot-pass.md index 7bbd329a4..616f544d5 100644 --- a/dist/cursor/skills/shape/references/blindspot-pass.md +++ b/dist/cursor/skills/shape/references/blindspot-pass.md @@ -12,7 +12,7 @@ Ask the user whether to run it; skip when they're the domain expert. Do not auto ## Prompt Shape -Ask, against the specific territory named by the Change: +Ask, against the specific territory named by the issue: > What would I not know to ask here — codebase history, domain conventions, prior art, potholes? @@ -29,6 +29,8 @@ A `[UU]` that gets named through this pass becomes: and re-routes through the quadrant table in the main skill body. +Park what is still unsharp in the issue's `fog` field at create (`--fog`). After create, keep unsharp entries in the session register — edit cannot mutate `fog`. When an entry sharpens, graduate it to a `--kind decision` child or sibling. Do not drop a named unknown on the floor. + ## Stopping The pass ends when the shaper (or the user) can name the territory's remaining risks as entries, not vague unease. A pass that keeps surfacing "something might be wrong here" without a nameable entry has run past its useful length — stop and proceed with what's been named. diff --git a/dist/cursor/skills/shape/references/cli-boundary.md b/dist/cursor/skills/shape/references/cli-boundary.md index b7ade652b..6ed241493 100644 --- a/dist/cursor/skills/shape/references/cli-boundary.md +++ b/dist/cursor/skills/shape/references/cli-boundary.md @@ -1,56 +1,118 @@ # CLI Boundary -Reading `loaf change init` and `loaf change check` — the skill teaches reading the CLI, not wrapping it. For the rest of the Loaf CLI surface, see the `loaf-reference` skill. +Reading `loaf issue` — the skill teaches reading the CLI, not wrapping it. For the rest of the Loaf CLI surface, see the `loaf-reference` skill. Issue commands require initialized SQLite state (`loaf state init`, or `loaf state migrate markdown --apply`). + +## Contents +- `loaf issue new` +- `loaf issue show` / `list` / `tree` / `frontier` +- `loaf issue edit` / `status` +- `loaf issue dod` +- `loaf issue promote` +- `loaf issue check` +- `loaf issue verify` +- `loaf issue link` / `bucket` +- `loaf issue render` +- `loaf release suggest` / `cut` +- What shape does not run + +## `loaf issue new <title> [options]` + +```text +loaf issue new <title> [--body <text>|--body -|--body-file <path>|--message <text>] + [--kind delivery|decision] [--parent <ref>] [--fog <text>] [--status <status>] [--json] +``` + +Creates the issue row. Default kind is `delivery`; default status is `triage`. `--status` accepts the write statuses `triage`, `backlog`, `todo`, `active`, `done` (it still records the initial triage event). `--fog` parks questions not yet sharp enough to be issues; this flag exists only on create — `loaf issue edit` replaces the body and does not mutate `fog`. + +`--body -` reads stdin; `--body-file` reads a UTF-8 file; `--message` is inline body at lower precedence than `--body-file` and `--body -`. A hyphen-leading title is positional after `--`: + +```bash +loaf issue new --parent LOAF-42 --status backlog -- "--help is missing from the man page" +``` -## `loaf change init <slug> [--brief]` +A delivery body must state the problem and, before `loaf issue check` will pass, contain the substring `out of scope` (case-insensitive). A decision issue needs a sharp question (`?` in the title or body), not a body contract. -Scaffolds `docs/changes/<YYYYMMDD>-<slug>/` from the Change template, where `<YYYYMMDD>` is the creation day (not a target date) and the branch is named by the bare slug — no date prefix on the branch. Ordinary init writes `change.json + shape.md + tasks/`; `--brief` is capture mode (`change.json + brief.md` only). The slug uses lowercase letters, digits, and single hyphens. +## `loaf issue show` / `list` / `tree` / `frontier` -**Capture promotion.** Re-running ordinary `loaf change init <slug>` (no `--brief`) against a structurally valid capture-only folder completes it in place: `brief.md` and every `change.json` value are preserved verbatim, and missing `shape.md` plus the seeded `tasks/` are published atomically (temp-write then rename; existing destinations are never overwritten; `shape.md` is the last rename and the promotion marker). A partial promotion that already holds the byte-identical seed task resumes by filling only the gaps. Everything else fails clearly and leaves the folder untouched — repeated `--brief`, `change.json`-only (missing brief), hybrid `change.md` + `change.json`, diverged `tasks/` content, malformed metadata, and fully-materialized folders (duplicate rejection unchanged). +```text +loaf issue show <ref> [--json] +loaf issue list [--status <status>] [--kind delivery|decision] [--archived] [--started] [--json] +loaf issue tree [<ref>] [--archived] [--json] +loaf issue frontier [--json] +``` -## `loaf change check [folder] [--require-executable] [--json]` +`show` prints identity, parent, fog, body, definition of done, and children. `list` hides archived issues unless `--archived`. `--status` filters by `triage`, `backlog`, `todo`, `active`, `done`, `cancelled`, `duplicate`. `tree` prints from a ref, or the whole project when omitted. `frontier` lists non-archived `triage`/`backlog`/`todo` issues that are not blocked — derived at read time, useful when checking whether this work is already covered. -Folder resolution: an explicit `[folder]` argument always wins; otherwise the current git branch is matched against the `branch:` frontmatter across every `docs/changes/*/change.json + shape.md`. Zero or multiple matches is an error naming the candidates found. +Prefer `--json` when diagnosing rather than scraping the human-readable text. -Output splits into two tiers: +## `loaf issue edit` / `status` -- **Violations** — always fail (exit code 2), regardless of flags: status-like frontmatter keys (`readiness`, `status`, `state`) or values matching the canonical change-state vocabulary; frontmatter not opening the file at byte one; malformed `YYYYMMDD-slug` folder naming; identity mismatch between `change:`/`created:` and the folder name; missing Product Contract sections (Problem, Hypothesis, Scope, Observable Workflow, Rabbit Holes and No-Gos). -- **Derived executability** — reported, never gating by default. A Change is executable when Planning Contract, Implementation Units, Verification Contract, and Definition of Done are all present and non-empty (bracket placeholders and HTML comments don't count as content). A Change with open gaps is incomplete and non-executable; the report just says what's still missing. +```text +loaf issue edit <ref> [--body-file <path>|--body -|--message <text>] [--json] +loaf issue status <ref> <status> [--duplicate-of <ref>] [--json] +``` -`--require-executable` turns structural executability into a gate (exit code 1 if not structurally executable); it does not prove implementation completion. This is implement's preflight and CI's non-draft-PR check, not something shape itself passes during shaping. +`edit` **replaces** the body. Rewrite the full problem-plus-out-of-scope text; there is no patch form. `status` write-statuses (`triage`, `backlog`, `todo`, `active`, `done`) update in place; `cancelled` and `duplicate` archive through the remove path (`--duplicate-of` is required when status is `duplicate`). Shape leaves status at `triage` unless the user asks otherwise — shaped is derived, not a status. -A branch/Change mismatch (current branch doesn't match the Change's `branch:` field) is a warning, never a violation. +## `loaf issue dod` -`--json` emits `{command, folder, passed, state, executable, exitCode, findings, warnings, gaps}` (plus optional `layout`, `captured`, `notices`) for scripted reads; prefer it when diagnosing rather than scraping the human-readable text. The landing guard reads `state` from this envelope — e.g. `"captured"` for a brief-only folder and `"shaped"` (or higher) once `shape.md` exists — and must not invent a second state surface. +```text +loaf issue dod add <ref> <text> [--command <cmd>] [--expect <expect>] [--tier V|H] [--serves <parent-position>] [--json] +loaf issue dod list <ref> [--json] +loaf issue dod remove <ref> <position> [--json] +loaf issue dod claim <child> <child-position> <parent-position> [--json] +loaf issue dod unclaim <child> <child-position> <parent-position> [--json] +``` +V-tier is used when `--command` is present, otherwise H, unless `--tier` overrides. `--serves` records that the new child criterion claims that parent position. Positions are 1-based and compact after `remove`. Authoring guidance and the expect grammar live in the Decomposition topic. -## `loaf change report new <slug> --kind <kind>` +## `loaf issue promote <ref> <position> [--json]` -Stamps `reports/YYYYMMDD-HHMMSS-<kind>-<slug>.html` with charset, provenance, and token skeleton; prints design-language guidance. Closed kinds: approval, review, visual, audit, note. +Promotes the criterion at the 1-based position into a child **delivery** issue. The parent criterion stays in place. The child is minted in `triage` with a copy of the criterion and a claim already recorded, so coverage for that parent position holds by construction. -## `loaf change verify [folder]` +## `loaf issue check <ref> [--json] [--human <reason>]` -Runs executable V-tier criteria declared in `shape.md` and writes `receipts/verify.json` (criteria digest, verified commit, cwd, per-criterion evidence). New-layout-only. +Derives readiness from the issue row, not from markdown headings. -Criteria forms (both parse): +- **Delivery** — shaped when the body is nonempty (the problem), at least one criterion exists, and the body contains an explicit out-of-scope statement. Prints `issue <ref> is shaped` when ready. +- **Decision** — ready when the title or body contains `?`. Prints `issue <ref> is ready`. +- **Children present** — coverage is a failure (every parent criterion must be claimed). Containment is a report (every child criterion must claim a parent criterion); each orphan prints a ready-to-paste `loaf issue new --parent … --status backlog -- …` remedy. -```markdown -- **V1.** Prose. Command: `exact command`. Expect: exit 0. -- **V1.** Prose. - - Command: `exact command` - - Expect: exit 0 -``` +`--human <reason>` publishes ready-for-human instead of ready-for-agent when a tracker authority is configured. Shape's own gate is the derived verdict, not the publication. + +`--json` emits `{issue, kind, shaped, covered, ready, failures, orphans, …}`. Exit code 1 when not ready. -`Expect` is enforced, with a deliberately minimal grammar: atoms join with ` and ` — `exit <N>` is the required exit code (omit the atom, or `Expect` entirely, and `exit 0` is enforced) and `` contains `text` `` requires the command's combined stdout+stderr to contain that backtick-delimited text (repeatable). A criterion passes when the command ran, the exit code matched, and every `contains` matched; the receipt records each atom and its outcome. +## `loaf issue verify <ref> [--json]` -```markdown -- **V1.** Prose. Command: `go test ./...`. Expect: exit 0 and contains `ok github.com/acme/pkg`. +Runs the issue's V-tier criteria (`--command` plus `--expect`) from the repository root. Honors `exit <N>` and `` contains `text` ``. Writes nothing; exits non-zero on any failure. H-tier rows are skipped. This is implement's preflight, not shape's gate. + +A criterion passes when the command ran, the exit code matched, and every `contains` matched. Unenforceable expect clauses are warned and recorded as advisory — never quietly decorative. + +## `loaf issue link` / `bucket` + +```text +loaf issue link <from> blocks|relates-to <to> [--json] +loaf issue link <from> remove <type> <to> [--json] +loaf issue bucket <ref> now|next|later|none [--json] ``` -Any other clause is unenforceable: verify prints a warning naming the criterion and the clause, records it on the criterion as advisory, and never lets it affect the result — an expectation is either checked or loudly not. +Stored relationship types are `blocks` and `relates_to`. Use `blocks` for a real sequencing constraint; do not encode order in `loaf issue tree`. Buckets are labels only and are never read as a constraint. + +## `loaf issue render <ref> [--json]` + +Emits markdown suitable to paste as a PR body with no manual editing: title, body, definition-of-done checkboxes (checked only when status is `done`), and children. Nothing plan-shaped is committed; if a PR is opened, this output *is* the body. + +## `loaf release suggest` / `cut` + +Releases are retroactive. Shape does not bind an issue to a version. + +```text +loaf release suggest [--base <ref>] [--json] +loaf release cut [--base <ref>] [--bump <type>] [--includes <version|tag>] [--no-tag] [--no-gh] [--dry-run] +``` -Commands run from the repository root; the receipt records that cwd. H-tier entries (`**H1.** …`) are never gate input. See [decomposition.md](decomposition.md) for authoring guidance. +`suggest` reports landed work since the last version tag and writes nothing. `cut` records a release from landed work. Neither is a shaping step. -## `loaf change tasks` / `show` +## What shape does not run -On-demand projections. See `loaf change --help`. +`loaf issue start` / `stop` create and remove the issue worktree — implement's job, after the issue is shaped. `loaf issue export` dumps the project snapshot. Do not call them from this skill. diff --git a/dist/cursor/skills/shape/references/critique-gate.md b/dist/cursor/skills/shape/references/critique-gate.md index 4e131243c..f01109327 100644 --- a/dist/cursor/skills/shape/references/critique-gate.md +++ b/dist/cursor/skills/shape/references/critique-gate.md @@ -1,14 +1,15 @@ # Critique Gate -The last shaping step, before `loaf change check` and the PR offer. An agent won't know to interrogate its own scope, boundary placement, or smuggled-in status words unless something makes it stop and ask. Instantiated from the shape-first pilot's own Critique Gate, generalized for any Change rather than that pilot's specific CLI-surface question. +The last shaping step, before `loaf issue check` and any review offer. An agent won't know to interrogate its own scope, boundary placement, or smuggled-in progress words unless something makes it stop and ask. Run through these before finalizing: -- **Is scope still bounded?** Has the draft crept beyond what the Problem and Hypothesis justify? Could this Change be smaller and still deliver the Hypothesis? +- **Is scope still bounded?** Has the draft crept beyond what the problem statement justifies? Could this issue be smaller and still be verifiable in one fresh context window? - **Does every new command, state, or lifecycle verb name its ceremony?** If a command or state can't name the ceremony that exercises it, cut it — don't build it now and hope a use appears. -- **Is a status field creeping back in under another name?** `readiness`, `phase`, `stage`, or anything else that reintroduces a declared progress flag `loaf change check` doesn't already ban by pattern. +- **Is a second progress flag creeping into the body?** `readiness`, `phase`, `stage`, or anything else that reintroduces a declared progress flag. Status lives on the issue row (`loaf issue status`). Shaped, covered, and ready are derived by `loaf issue check`. `loaf issue bucket` is a label only and is never read as a constraint. - **Is the CLI/skill boundary drawn correctly?** Is the skill doing deterministic work that belongs in the CLI, or is the CLI claiming judgment that belongs in the skill? -- **Which Verification Contract criteria are genuinely executable gates, and which are human review dressed up as automatable?** A criterion that can't disagree with the implementation isn't a gate. -- **Are the Rabbit Holes and No-Gos sections doing real work?** Or are they restating the Scope's Out list in different words? +- **Which criteria are genuinely executable gates, and which are human review dressed up as automatable?** A V-tier criterion needs `--command` (and `--expect` when exit 0 is not enough). A criterion that can't disagree with the implementation isn't a gate — make it H, or rewrite it against an independent source of truth. +- **Is the out-of-scope statement doing real work?** Rabbit holes and no-gos belong there as named exclusions. Restating the problem in different words is not an out-of-scope statement. +- **Did fog graduate, or evaporate?** Every named unknown is parked in create-time `fog`, held in the session register, sitting as a decision child (or sibling) with a sharp question, filed as a new backlog issue (a different problem), or written into the body as a decided answer. Silent deletion is the failure. -Answers that change the document go back into it — the Decisions log, the Planning Contract, or the relevant Product Contract section — before moving to `loaf change check`. An answer spoken but not written doesn't count. +Answers that change the issue go back into it — `loaf issue edit` for the body, `loaf issue dod add` / `remove` for criteria, `loaf issue promote` or `loaf issue new` for children — before moving to `loaf issue check`. An answer spoken but not written doesn't count. diff --git a/dist/cursor/skills/shape/references/decomposition.md b/dist/cursor/skills/shape/references/decomposition.md index 7deef4b13..b6839482b 100644 --- a/dist/cursor/skills/shape/references/decomposition.md +++ b/dist/cursor/skills/shape/references/decomposition.md @@ -1,53 +1,119 @@ # Decomposition -Shaping step absorbed from the retired breakdown skill: dependency awareness, granularity judgment, and acceptance-criteria thinking, now expressed as the Change's Implementation Units and Verification Contract instead of a separate task-minting pass. +Shaping's tail, not a separate ceremony: dependency awareness, granularity judgment, and acceptance-criteria thinking, expressed as definition-of-done criteria and — only when a criterion earns its own DoD — child issues created by `loaf issue promote`. -## What Survives +## Contents +- When to split +- The sizing rule +- Expand–contract +- Promote, don't mint +- Coverage and containment +- Authoring criteria +- Order by likelihood-of-change +- Own the decisions -- **The Right Size Test** — before finalizing a unit boundary, check: Can a single implementer complete this? If no, split by concern. Does it touch multiple unrelated concerns? If yes, split by concern. Will the agent need too much context? If yes, split into smaller coherent units. Are you splitting just to have more units? If yes, merge back. -- **Right-sizing rules** — one agent type per unit (completable by a single implementer), one concern per unit (one layer, service, or component), context-appropriate (fits in model context with room for exploration), not over-fragmented (don't split what naturally belongs together). -- **Per-unit verification discipline** — every unit includes its own observable done condition. Never a separate "verify" unit; keep tests with the code they test. -- **Own the decisions** — decide granularity and unit boundaries autonomously. Ask the user only when two orderings are genuinely equally valid with different trade-offs; otherwise decide and move on. +## When to split -## What Dies +A parent gets children only when its DoD needs more than one coherent slice. One criterion that is already right-sized stays on the parent. A checkbox becomes a sub-issue the moment it earns its own DoD — its own problem statement, its own out-of-scope, its own criteria. -Task-file minting, ID allocation, estimate fields, and ordering-for-execution as the default presentation. Implementation Units are in-document work packets — commit-boundary guides and review anchors — never tracked entities with IDs, statuses, or a persistence layer of their own. +Same problem, another slice → another criterion on this issue, or a promoted child if that slice now has its own DoD. A different problem discovered mid-shaping → a new backlog issue (`loaf issue new --status backlog`), not a child of this one. -## The New Principle: Order by Likelihood-of-Change +## The sizing rule -Breakdown ordered units for execution (dependency graph, then priority). Shaping orders units for review: data models, interfaces, and user-facing flows lead; mechanical refactors and boilerplate collapse at the bottom. The reviewer's attention at the draft-to-ready flip should land on what's most likely to need changing, not on whatever happens to run first. +One test, replacing the old four-question checklist: **a slice is right-sized when it fits one fresh context window and is verifiable alone.** -Sequencing constraints that genuinely exist — this unit must land before that one — are stated in prose within the unit description or a Planning Contract subsection. Never rely on list order alone to imply a dependency; a reviewer skimming units by likelihood-of-change won't read sequencing into position. +- If an implementer cannot pick the issue up in a new conversation and finish it without reading a sibling, split. +- If the done-check cannot run (or be reviewed) without another slice landing first, either split and `loaf issue link <predecessor> blocks <successor>`, or merge — do not leave a criterion that is only true in combination. +- If you are splitting just to have more rows, merge back. -## Authoring the Verification Contract +Per-slice verification stays with the slice. Never a separate "verify" child; keep tests with the code they test. -Split criteria into two groups, mirroring the Change template: +## Expand–contract -- **Executable (V-tier)** — bound to a command and an expected result; machine-checkable by `loaf change verify`. Two equivalent forms: +The named exception for wide mechanical refactors (a rename, a schema migration, an expand-then-remove of an old path). The slice may be wide in files touched and still be one issue, because it is one coherent mechanical motion and one verification: the suite still passes after the motion. - Inline (what the scaffold writes): +Do not use this exception to smuggle a second problem into the parent. A mechanical rename plus a behavior change is two slices. - ```markdown - - **V1.** What must be true. Command: `go test ./...`. Expect: exit 0. - - **V2.** Output-bound. Command: `loaf change check`. Expect: exit 0 and contains `executable`. - ``` +## Promote, don't mint - Or with an authoring checkbox still open: +```bash +loaf issue promote <ref> <position> +``` - ```markdown - - [**V1.** What must be true. Command: `go test ./...`. Expect: exit 0.] - ``` +The criterion at the 1-based position stays on the parent. A child **delivery** issue is created in `triage`, titled from the criterion text, with a copy of that criterion as its first DoD line and a claim already recorded from the copy to the parent. Coverage for that parent position holds by construction. + +Then shape the child: give it a problem body and an out-of-scope statement (`loaf issue edit` replaces the body), add the criteria that make *its* DoD complete, promote again if one of those earns its own DoD. + +`loaf issue promote` always mints a delivery child. Decision children are created separately: + +```bash +loaf issue new --kind decision --parent <ref> "Should the store be append-only?" +``` + +A decision child is ready when the title or body contains `?`. It does not claim a parent criterion. + +Once **any** child exists — delivery or decision — `loaf issue check` requires every parent criterion to be claimed. A leaf parent that grows a decision child without promoted (or otherwise claimed) criteria will fail coverage. Sequence the tail as: write DoD → promote every slice that will not execute on the parent → then add decision children. If the parent stays a leaf, leave remaining unsharp questions in create-time `fog` (there is no `--fog` on edit) or file sharpened ones as sibling decision issues (`loaf issue new --kind decision --status backlog`, no `--parent`). - Sub-bullet: +Manual claims, when a child criterion was added rather than promoted: - ```markdown - - **V1.** What must be true. - - Command: `go test ./...` - - Expect: exit 0 +```bash +loaf issue dod add <child> "Child done-check" --serves 1 +loaf issue dod claim <child> <child-position> <parent-position> +loaf issue dod unclaim <child> <child-position> <parent-position> +``` + +`--serves` claims the newly added child criterion against that parent position. `claim` / `unclaim` retarget an existing pair. + +## Coverage and containment + +`loaf issue check <ref>` runs these only when the issue has children. + +- **Coverage** (failure) — every parent criterion must be claimed by at least one child criterion. An uncovered position is named in the failure list; fix it by promoting that criterion or adding a claiming child criterion. +- **Containment** (report, not a failure) — every child criterion must claim a parent criterion. An orphan is printed with a ready-to-paste remedy that files it as a **sibling** backlog issue: + + ```bash + loaf issue new --parent '<parent>' --status backlog -- '<orphan text>' ``` - `Expect` is optional and enforced when present, with a deliberately minimal grammar: atoms join with ` and ` — `exit <N>` is the required exit code (an absent `Expect`, or an `Expect` with no exit atom, means `exit 0`) and `` contains `text` `` requires the combined stdout+stderr to contain that backtick-delimited text (repeatable). Any other clause is unenforceable: `loaf change verify` warns naming the criterion and the clause and records it as advisory, so an expectation is either checked or loudly not — never quietly decorative. Commands run from the **repository root** (never the change folder). Only V-entries that declare a fenced `Command:` value are gate input. + Run the printed remedy. Do not fold the orphan back onto the parent, and do not treat it as in-scope work that somehow escaped the DoD — it is a new backlog row under the same parent. + +A different problem (not an orphan criterion) is not a sibling of this decomposition. File it as a new backlog issue with no `--parent`. -- **Human review (H-tier)** — what a reviewer confirms that no command can. H-entries are review material and are **never** gate input; `loaf change verify` ignores them. +## Authoring criteria + +```bash +loaf issue dod add <ref> <text> [--command <cmd>] [--expect <expect>] [--tier V|H] [--serves <parent-position>] +``` + +- **V-tier** — used when `--command` is present, unless `--tier` overrides. Machine-checkable by `loaf issue verify <ref>`. Commands run from the **repository root**. +- **H-tier** — default when `--command` is absent. Human review; never gate input. `loaf issue verify` skips H-tier rows. + +`--expect` is optional and enforced when present, with a deliberately minimal grammar: atoms join with ` and ` — `exit <N>` is the required exit code (an absent `--expect`, or one with no exit atom, means `exit 0`) and `` contains `text` `` requires the combined stdout+stderr to contain that backtick-delimited text (repeatable). Any other clause is unenforceable: verify warns naming the criterion and the clause and records it as advisory, so an expectation is either checked or loudly not — never quietly decorative. + +```bash +loaf issue dod add LOAF-42 "Package tests pass" --command "go test ./..." --expect "exit 0" +loaf issue dod add LOAF-42 "Check names the uncovered criterion" --command "loaf issue check LOAF-42" --expect "exit 0 and contains \`uncovered\`" +loaf issue dod add LOAF-42 "The writeup is readable by someone new to the area" --tier H +``` A criterion whose check only restates the implementation (recomputing the expected value the way the code does) is vacuous — it can never disagree with the code under test. Prefer criteria with an independent source of truth. + +`loaf issue dod list <ref>` prints the current lines. `loaf issue dod remove <ref> <position>` removes one (positions then compact). + +## Order by likelihood-of-change + +Present children for review, not for execution order: data models, interfaces, and user-facing flows lead; mechanical refactors and boilerplate collapse at the bottom. The reviewer's attention should land on what's most likely to need changing, not on whatever happens to run first. + +Sequencing constraints that genuinely exist — this child must land before that one — are recorded as relationships: + +```bash +loaf issue link <from> blocks <to> +loaf issue link <from> relates-to <to> +loaf issue link <from> remove blocks <to> +``` + +Never rely on `loaf issue tree` order to imply a dependency; a reviewer skimming by likelihood-of-change won't read sequencing into position. `loaf issue bucket <ref> now|next|later|none` is an advisory label only and is never read as a constraint. + +## Own the decisions + +Decide granularity and slice boundaries autonomously. Ask the user only when two orderings are genuinely equally valid with different trade-offs; otherwise decide and move on. diff --git a/dist/cursor/skills/shape/references/grilling.md b/dist/cursor/skills/shape/references/grilling.md index cd4d6a575..4cf6437dd 100644 --- a/dist/cursor/skills/shape/references/grilling.md +++ b/dist/cursor/skills/shape/references/grilling.md @@ -1,8 +1,8 @@ # Grilling -The interview technique for `[KU]` entries — known unknowns precise enough to phrase as a question. Imported from the reviewed `grill-me` / `grill-with-docs` pattern (`docs/changes/20260704-shape-first-change-workflow/research/mattpocock-review/`), sharpened with the Field Guide's architectural-impact ordering. +The interview technique for `[KU]` entries — known unknowns precise enough to phrase as a question. Imported from the reviewed `grill-me` / `grill-with-docs` pattern, sharpened with architectural-impact ordering. -This is a distinct, shape-specific technique — not the shared `templates/grilling.md` used by `architecture` and `refactor-deepen`. That template's glossary-mutation duty doesn't fit shape's ambiguity-resolving step (see the deferral rationale it documents); shape's grilling stays scoped to fog entries and never writes to the domain glossary. +This is a distinct, shape-specific technique — not the shared `templates/grilling.md` used by `architecture` and `refactor-deepen`. That template's glossary-mutation duty doesn't fit shape's ambiguity-resolving step (see the rationale it documents); shape's grilling stays scoped to fog entries and never writes to the domain glossary. ## The Mechanic @@ -14,17 +14,21 @@ Every question carries a recommended answer with rationale — never "what do yo Prioritize questions whose answer would change the architecture. Cosmetic questions — naming, ordering, presentation — go last, even when they're easier to answer. An architecture-changing answer received late can invalidate everything decided in between; asking it first avoids that rework. -Before asking, check whether reading resolves the question — an existing ADR, a prior Change, a journal entry. Only ask what reading couldn't answer. +Before asking, check whether reading resolves the question — an existing ADR, a prior issue, a journal entry. Only ask what reading couldn't answer. ## Stop Condition Stop when either holds: - No unrouted `[KU]` entries remain. -- Answers stop changing the contract — the last several questions confirmed direction rather than altering it. +- Answers stop changing the issue — the last several questions confirmed direction rather than altering the body, the criteria, or the children. -Grilling never opens the session. Context gathering and the blindspot-pass offer come first; an interview without that groundwork asks questions the codebase or journal could have answered for free. +Write each accepted answer into the issue as it lands: `loaf issue edit` for the body, `loaf issue dod add` for a new done-check, `loaf issue new --kind decision --parent <ref>` when the answer is itself a sharp question that still needs a later call. Do not leave a resolved `[KU]` only in the conversation. ## Mid-Interview Reroute If a question turns out to need domain fluency the shaper doesn't have — the follow-up can't even be phrased — stop grilling it and route the entry to the blindspot pass instead of guessing at an answer. + +## Opening + +Grilling never opens the session. Context gathering and the blindspot-pass offer come first; an interview without that groundwork asks questions the codebase or journal could have answered for free. diff --git a/dist/cursor/skills/shape/references/reaction-artifact.md b/dist/cursor/skills/shape/references/reaction-artifact.md index d3254bed0..ca30f7485 100644 --- a/dist/cursor/skills/shape/references/reaction-artifact.md +++ b/dist/cursor/skills/shape/references/reaction-artifact.md @@ -8,11 +8,11 @@ Before building anything, state precisely what this artifact must let the user d ## Build the Smallest Thing That Lets the User React -Construct the minimum artifact that resolves the named unknown — competing layouts, a comparison of approaches, a rendered mock. Write it into the Change's own `research/` folder, never `.agents/reports/`; this is shape's own technique, executed in-session, not a handoff to the research skill. +Construct the minimum artifact that resolves the named unknown — competing layouts, a comparison of approaches, a rendered mock. Keep it in the session or in a throwaway file you will discard. This is shape's own technique, executed in-session, not a handoff to the research skill and not a committed plan document. Do not write it into `.agents/reports/`. ## Capture the Answer, Discard the Shell -Once the user reacts and picks, write the decision — with rationale — back into the Change: the Decisions log, or the relevant Planning Contract subsection. The artifact itself is not the deliverable; the choice it produced is. Discard or archive the shell once the answer is captured. If a piece of logic proved out along the way is worth keeping (a validated approach, not just a throwaway shell), note that explicitly — but don't let scaffolding survive under the pretense that it's production code. +Once the user reacts and picks, write the decision — with rationale — back onto the issue: the body via `loaf issue edit`, or a `--kind decision` child when the pick is itself a sharp question that still needs a later call. The artifact itself is not the deliverable; the choice it produced is. Discard the shell once the answer is captured. If a piece of logic proved out along the way is worth keeping (a validated approach, not just a throwaway shell), note that explicitly in the issue body — but don't let scaffolding survive under the pretense that it's production code. ## Never a Deliverable diff --git a/dist/cursor/skills/shape/templates/brief.md b/dist/cursor/skills/shape/templates/brief.md deleted file mode 100644 index 05016c8c8..000000000 --- a/dist/cursor/skills/shape/templates/brief.md +++ /dev/null @@ -1,42 +0,0 @@ -<!-- brief.md is the optional archeological kickstart — the original unshaped ask. - May accrete parked problem-space concepts while the change is captured; freezes when shape.md exists. - Superseded by shape.md; never mechanically load-bearing. - A brief-only folder is legal and non-executable (captured, not shaped). --> - -# [Brief title] - -## Problem Statement - -[What friction, gap, or unmet need exists? Be specific about the pain — vague problems produce vague solutions. Problem-space only; do not design the approach here.] - -## Who Has It - -[Who experiences this problem? Role, context, and how often the pain shows up. Avoid unqualified "users" or "developers."] - -## Current Alternatives - -[What do they do today? Existing tools, manual workarounds, or "nothing" are all valid. Understanding the status quo clarifies what better means.] - -## Value Proposition - -[Why is solving this worth it? What becomes true for the people who have the problem if this lands? Describe value, not features or architecture.] - -## Constraints - -[Non-negotiable bounds: technical, legal, organizational, or philosophical. Things that limit the solution space before design begins.] - -- [Constraint 1] - -## Sequencing and Relationships - -[How this relates to other work — series order, release cohort, dependencies stated as prose. No machine relation fields; narrative order only.] - -## Sources and Research Links - -[Evidence that informed this framing — competitive scans, research notes, issue links, prior art. Link rather than paste.] - -## Open Questions - -[Unresolved problem-space items. Mark urgency: blocking (must resolve before shaping) or deferrable.] - -- [ ] [Question] — blocking | deferrable diff --git a/dist/cursor/skills/shape/templates/change.md b/dist/cursor/skills/shape/templates/change.md deleted file mode 100644 index 6ce5b9725..000000000 --- a/dist/cursor/skills/shape/templates/change.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -change: [slug] -created: [YYYY-MM-DD] -branch: [slug] ---- - -<!-- Frontmatter must open the file at byte one — parsers depend on it. No status-like frontmatter (readiness/status/state): readiness is derived — a draft PR is shaping; `loaf change check` derives structural executability from the sections below. --> - -# [Change Title] - -## Problem - -[Why this work exists — the friction, gap, or rot being addressed.] - -## Hypothesis - -[The bet: what becomes true if this ships, and why it is worth making.] - -## Scope - -**In** - -- [What this Change delivers.] - -**Out** (deferred, not rejected) - -- [What is explicitly postponed, and where it went.] - -**Cut** (explicitly rejected) - -- [What this Change will not do, ever.] - -## Observable Workflow - -[What someone sees or does once this ships — commands, flows, UX. Concrete over abstract.] - -## Rabbit Holes and No-Gos - -[Boundaries: the ways this work could quietly grow into something it must not become.] - -## Decisions - -Provenance: [how each decision was accepted — interview, review, dogfooding.] - -1. **[Decision.]** [Rationale, and what it forecloses.] - -## Planning Contract - -[The HOW. Free-form `###` subsections named by the work — the container is the contract; the subsection names are yours.] - -### [Approach / Placement / Risks / Sequencing / Spike findings …] - -[...] - -## Implementation Units - -<!-- In-document work packets — commit-boundary guides and review anchors, not tracked entities. --> - -- [**U1 — Unit name.** What it delivers.] - -## Verification Contract - -<!-- Executable (machine-checkable): --> - -- [**V1.** Criterion bound to a command and an expected result.] - -<!-- Human review: --> - -- [**H1.** What a reviewer confirms that no command can.] - -## Definition of Done - -- [Derived from gates and review — never a status flag.] - -## Durable Outputs - -[Specs, ADRs, knowledge docs, or schema updates to create after implementation proves what is now true. A final spec describes reality, not a plan.] - -## Open Questions - -<!-- Fog register: tag entries [KU]/[UK]/[UU] with a route — see the shape skill's quadrant table. Tags are convention, never parsed by check. --> - -- [Known unknowns, each owned by a section, a spike, or a follow-up.] - -## Source Inputs - -- [Where this Change came from: journal entries (cite by ID), sparks, ideas, brainstorms, issues, conversations, prior Changes.] - -<!-- Optional sections, added when they earn their place: Background, Success Metrics (when validation matters), Follow-ups, Critique Gate. --> diff --git a/dist/cursor/skills/shape/templates/design.md b/dist/cursor/skills/shape/templates/design.md deleted file mode 100644 index 2244ccdb6..000000000 --- a/dist/cursor/skills/shape/templates/design.md +++ /dev/null @@ -1,20 +0,0 @@ -<!-- design.md is the optional design surface for UI, protocol, or schema detail - that would crowd shape.md or plan.md. Accretive; not load-bearing for the gate. --> - -# Design — [Change Title] - -## Intent - -[What this design clarifies that shape.md does not.] - -## Surfaces - -[Screens, APIs, schemas, or protocols under design.] - -## Decisions - -1. **[Decision.]** [Rationale.] - -## Open questions - -- [What remains undecided.] diff --git a/dist/cursor/skills/shape/templates/plan.md b/dist/cursor/skills/shape/templates/plan.md deleted file mode 100644 index 6d17a7179..000000000 --- a/dist/cursor/skills/shape/templates/plan.md +++ /dev/null @@ -1,21 +0,0 @@ -<!-- plan.md is the optional technical route (the corpus's plan sense). - Accretive during shaping; approach churn here never expires a cohort receipt. - Criteria live in shape.md — do not relocate Verification Contract here. --> - -# Plan — [Change Title] - -## Approach - -[How the work lands — architecture, sequencing, compatibility.] - -## Placement - -[Where code and docs live; what stays out of scope for this route.] - -## Risks - -[Failure modes and the safe failure direction.] - -## Sequencing - -[Ordered slices that leave main coherent at each landing.] diff --git a/dist/cursor/skills/shape/templates/pr.md b/dist/cursor/skills/shape/templates/pr.md deleted file mode 100644 index 91b4b7401..000000000 --- a/dist/cursor/skills/shape/templates/pr.md +++ /dev/null @@ -1,25 +0,0 @@ -<!-- Draft = still shaping. Ready for review = structurally executable, not proof of implementation completion. --> - -## Change - -<!-- Link the Change folder this PR advances: docs/changes/YYYYMMDD-slug/ The Change artifact lives in this PR's diff — the draft PR is its shaping surface. During coexistence, legacy PRs implementing a numbered spec link the .agents/specs/ path here instead. Delete this section for PRs tied to neither. --> - -## What & Why - -<!-- What this PR does and the problem it solves. For a shaping (draft) PR, summarize the direction; for an implementation PR, summarize the delta. --> - -## Review focus - -<!-- Where reviewer attention pays off: decisions to challenge, boundaries to verify, criteria to confirm. --> - -## Verification - -<!-- What proves this works: gates run (`loaf change check`, `loaf check`), tests, commands a reviewer should re-run locally. On a draft, state what remains open instead. --> - -## Migration / breaking changes - -<!-- If this PR changes user-facing behavior, document the migration step. Otherwise: "None." --> - -## Deferred - -<!-- Intentionally out of scope, and where it went (follow-up Change, open question, issue). Delete if nothing was deferred. --> diff --git a/dist/cursor/skills/shape/templates/shape.md b/dist/cursor/skills/shape/templates/shape.md deleted file mode 100644 index 59aa9b89a..000000000 --- a/dist/cursor/skills/shape/templates/shape.md +++ /dev/null @@ -1,77 +0,0 @@ -<!-- shape.md is the change contract. Identity lives in change.json — no status-like frontmatter. Readiness is derived: a draft PR is shaping; `loaf change check` derives structural executability from the sections below. --> - -# [Change Title] - -## Problem - -[Why this work exists — the friction, gap, or rot being addressed.] - -## Hypothesis - -[The bet: what becomes true if this ships, and why it is worth making.] - -## Scope - -**In** - -- [What this Change delivers.] - -**Out** (deferred, not rejected) - -- [What is explicitly postponed, and where it went.] - -**Cut** (explicitly rejected) - -- [What this Change will not do, ever.] - -## Observable Workflow - -[What someone sees or does once this ships — commands, flows, UX. Concrete over abstract.] - -## Rabbit Holes and No-Gos - -[Boundaries: the ways this work could quietly grow into something it must not become.] - -## Decisions - -Provenance: [how each decision was accepted — interview, review, dogfooding.] - -1. **[Decision.]** [Rationale, and what it forecloses.] - -## Planning Contract - -<!-- The HOW. Prefer plan.md/design.md when the route needs its own file; keep this container. Free-form ### subsections named by the work. --> - -### [Approach / Placement / Risks / Sequencing …] - -[…] - -## Implementation Units - -<!-- Task packets live in tasks/TASK-NNN-slug.md; this section may summarize the decomposition. --> - -- [**TASK-001 — Unit name.** What it delivers.] - -## Verification Contract - -<!-- Executable (machine-checkable): each V-entry declares Command and Expect for loaf change verify. Expect is a grammar, not prose: atoms join with " and " — `exit <N>` is the required exit code (omit the atom, or Expect entirely, for exit 0; a second exit atom is a contradiction and fails the criterion) and contains `text` requires the combined stdout+stderr to contain that backtick-delimited text (repeatable). Example: Expect: exit 0 and contains `all green`. Any other clause is unenforceable: verify warns naming the criterion and clause, records it as advisory, and never checks it. --> - -- [**V1.** What must be true. Command: `exact command`. Expect: exit 0.] - -<!-- Human review (H-tier): review material, never gate input. --> - -- [**H1.** What a reviewer confirms that no command can.] - -## Definition of Done - -- [Derived from gates and review — never a status flag.] - -## Durable Outputs - -[Specs, ADRs, knowledge docs, or schema updates to create after implementation proves what is now true.] - -## Open Questions - -<!-- Fog register: tag entries [KU]/[UK]/[UU] with a route. Tags are convention, never parsed by check. --> - -- [KU] [Known unknown → route to a task or later change] diff --git a/dist/cursor/skills/shape/templates/task.md b/dist/cursor/skills/shape/templates/task.md deleted file mode 100644 index 05de15307..000000000 --- a/dist/cursor/skills/shape/templates/task.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -change: [slug] -id: TASK-NNN -title: [short title] -# Relations (closed set; targets are TASK-NNN within this change only): -# parent: TASK-NNN -# blocks: -# - TASK-NNN -# blocked-by: -# - TASK-NNN -# relates-to: -# - TASK-NNN ---- - -# TASK-NNN — [Title] - -## Objective - -[What this task delivers when its checkboxes are flipped.] - -## Scope boundaries - -**In:** [What this task may touch.] - -**Out:** [What this task must not touch — other tasks, other changes, deferred work.] - -## Context pointers - -- Contract: `shape.md` — [relevant sections] -- Research: [paths under research/ when cited] - -## Acquisition - -```bash -loaf journal log "skill(implement): TASK-NNN — [short intent]" -# [commands or files to load before editing] -``` - -## Steps - -- [ ] [Atomic step — one commit's worth when flipped with the delivering work] -- [ ] [Next step] - -## Verification - -- [Commands or checks that prove this task alone] -- The slug never cites other work units (`TASK-…`, `SPEC-…`, issue keys) — identity is local; provenance is in frontmatter and the change folder. diff --git a/dist/cursor/skills/ship/SKILL.md b/dist/cursor/skills/ship/SKILL.md index 3b645f200..16c112267 100644 --- a/dist/cursor/skills/ship/SKILL.md +++ b/dist/cursor/skills/ship/SKILL.md @@ -1,17 +1,20 @@ --- name: ship description: >- - Reviews, verifies, and lands one pull request. Use when the user says "ship - it," "merge this PR," "ready to merge," "land this branch," or asks for a - final merge gate. Produces a reviewed, squash-merged PR and post-merge - cleanup. Not for version bumps, tags, GitHub Releases, or install verification - (use release). + Reviews, verifies, and lands one pull request — the sole quality gate before + work can appear in a later release cut. Use when the user says "ship it," + "merge this PR," "ready to merge," "land this branch," or asks for a final + merge gate. Binds the PR to an issue: the body is `loaf issue render` output, + definition-of-done criteria are the review checklist, and landing marks the + issue done and stops its worktree. Produces a reviewed, squash-merged PR and + post-merge cleanup. Not for version bumps, tags, GitHub Releases, or install + verification (use release). version: 0.2.21 --- # Ship -Review, verify, and land one PR. Shipping is the PR gate; releasing is the version-publication gate. +Review, verify, and land one PR. Ship's review is the quality gate for everything that will ever ship. Releases are retroactive — they cut a version from already-landed work. Nothing between merge and the next release cut re-checks the diff, the tests, or the issue. A rubber-stamped PR ships in the next cut with no second net. ## Contents - Critical Rules @@ -21,7 +24,7 @@ Review, verify, and land one PR. Shipping is the PR gate; releasing is the versi - Context Detection - Step 1: PR Readiness - Step 1b: Stacked PR Detection -- Step 2: Evidence Review +- Step 2: Definition-of-Done Review - Step 3: Local Verification - Step 4: Squash Merge - Step 5: Post-Merge Cleanup @@ -35,64 +38,94 @@ Review, verify, and land one PR. Shipping is the PR gate; releasing is the versi ## Critical Rules -- **Ship is not release** -- do not bump versions, create tags, publish GitHub Releases, or verify package installation here. -- **Keep PR quality local** -- smaller PRs are welcome, but ship must still verify correctness before merge. -- **Detect-first** -- auto-detect the PR from the current branch before asking for a PR number. -- **Review before merge** -- inspect code, docs, tests, changelog, PR body, and CI state before approval. -- **Never merge without explicit confirmation** -- present the PR, checks, findings, and squash body first. -- **Detect the stack before merging** -- another open PR may use this PR's head branch as its base. Find out before merge, never after, and never delete a head branch while a child PR still points at it. -- **Clean squash body** -- write an intentional squash commit body; never accept the automatic commit dump. -- **Keep landed and released distinct** -- after merge, describe the PR as landed or shipped, not necessarily released. -- **Log shipping** -- after merge, run `loaf journal log "decision(ship): PR #N landed via squash merge"`. +1. **Log invocation first** — `loaf journal log "skill(ship): shipping <ref or PR or current branch>"` before doing anything else. After merge, log `loaf journal log "decision(ship): PR #N landed via squash merge; <ref> done"`. +2. **Rigor is load-bearing** — this review is the only quality gate. Releases cut from landed work; they do not re-check. If the review is thin, the next cut still publishes it. +3. **Ship is not release** — do not bump versions, create tags, publish GitHub Releases, or verify package installation here. Use the release skill for that. +4. **Bind the PR to an issue** — the PR body is `loaf issue render <ref>` (paste-ready, no manual editing). The issue's definition-of-done criteria are the review checklist. `loaf issue verify <ref>` runs the executable (V-tier) rows and writes nothing. Landing means `loaf issue status <ref> done`. Then `loaf issue stop <ref>` removes the started worktree. +5. **Detect-first** — auto-detect the PR from the current branch, and the issue from `$ARGUMENTS` or the started workspace, before asking for a PR number or issue ref. +6. **Review before merge** — inspect code, docs, tests, changelog, the rendered issue body, definition of done, and CI state before approval. +7. **Never merge without explicit confirmation** — present the PR, checks, review notes, and squash body first, using your harness's structured question tool if it has one. +8. **Detect the stack before merging** — another open PR may use this PR's head branch as its base. Find out before merge, never after, and never delete a head branch while a child PR still points at it. +9. **Clean squash body** — write an intentional squash commit body; never accept the automatic commit dump. +10. **Keep landed and released distinct** — after merge, describe the PR as landed or shipped. It is not released until release publishes a version. + +--- ## Verification +- Invocation is logged to the project journal before review work begins - PR identity, base branch, and head branch are confirmed +- The PR is bound to one issue; `loaf issue show <ref>` is the issue surface +- PR body matches `loaf issue render <ref>` with no manual editing +- Every definition-of-done criterion was reviewed against the diff; H-tier by reading, V-tier by `loaf issue verify <ref>` (writes nothing; exit non-zero blocks) - CI status is passing or the user explicitly accepts named non-blocking checks - Relevant local checks pass or failures are fixed before merge - PR body and durable docs do not overclaim relative to the diff - Squash commit title/body are clean, conventional, and user-facing - Child PRs stacked on this PR's head branch are enumerated before merge, and each is retargeted, rebased, and re-verified after it -- Base branch is updated after merge and the feature branch cleanup state is known +- After merge: `loaf issue status <ref> done`, then `loaf issue stop <ref>` if a worktree was started, base branch updated, feature-branch cleanup state known +- A `decision(ship)` journal entry records the landing ## Quick Reference | Step | Gate | Blocking? | |------|------|-----------| +| Context Detection | PR and issue bound | Yes | | PR Readiness | PR exists, target base known, CI state reviewed | Yes | | Stacked PR Detection | child PRs on this head branch are enumerated | Yes | -| Evidence Review | findings resolved or explicitly accepted | Yes | -| Local Verification | relevant checks pass | Yes | +| Definition-of-Done Review | every criterion reviewed; `loaf issue verify` passes or reports no V-tier rows | Yes | +| Local Verification | relevant project checks pass | Yes | | Squash Merge | user approves body text | Yes | -| Cleanup | base pulled, children retargeted and rebased, branch deletion handled | Yes when a child exists | -| Release Suggestion | enough landed work may justify release | No | +| Cleanup | issue marked done, started worktree stopped, base pulled, children retargeted and rebased, branch deletion handled | Yes when a child PR exists; done + stop always | +| Release Suggestion | enough landed work may justify a later cut | No | ## Topics | Topic | Use When | |-------|----------| -| [Context Detection](#context-detection) | Determining current branch and PR state | +| [Context Detection](#context-detection) | Binding the current branch to a PR and an issue | +| [Definition-of-Done Review](#step-2-definition-of-done-review) | Using issue criteria as the merge checklist | | [Hook Interaction](#hook-interaction) | Understanding coexistence with git hooks | --- ## Context Detection -Before anything, detect the PR surface: +Log the invocation, then detect the PR and the issue. + +### PR 1. Get current branch and repo default branch: ```bash git branch --show-current gh repo view --json defaultBranchRef -q .defaultBranchRef.name ``` -2. Parse `$ARGUMENTS`: may be a PR number, PR URL, branch name, or empty. -3. If `$ARGUMENTS` is empty, auto-detect from the current branch: +2. Parse `$ARGUMENTS`: may be an issue ref (`LOAF-42`), a PR number, a PR URL, a branch name, or empty. +3. If `$ARGUMENTS` is empty or is not a PR identity, auto-detect from the current branch: ```bash gh pr view --json number,title,url,headRefName,baseRefName,state,mergeStateStatus,isDraft ``` 4. If no PR exists for the current branch, stop and offer to create one via `git-workflow` rather than silently merging a branch. 5. If already on the default branch, stop. There is no PR to ship from the current branch. -6. Confirm PR identity with the user before merge actions. + +### Issue + +Issue commands require initialized SQLite state. Bind exactly one issue: + +1. If `$ARGUMENTS` (or a remaining token) is an issue ref, load it: + ```bash + loaf issue show <ref> + ``` +2. Otherwise match the PR's `headRefName` to a started workspace: + ```bash + loaf issue list --started + ``` + Columns are alias, title, `started_branch`, `started_worktree`. The started branch from `loaf issue start` is `issue/<alias-or-id>` in lowercase (`issue/loaf-42`), disambiguated with an id suffix when that name is already claimed. +3. Confirm with `loaf issue show <ref>` — `started_branch` / `started_worktree` should match this PR when the issue was started. + +If nothing binds, stop and ask for the issue ref. Do not invent a row during ship. If `loaf issue show` reports the issue archived (`cancelled` or `duplicate`), stop. + +Confirm PR identity and the bound issue with the user before merge actions. --- @@ -104,6 +137,14 @@ Inspect the PR's declared state: gh pr view <N> --json number,title,body,url,headRefName,baseRefName,state,isDraft,mergeStateStatus,reviewDecision,statusCheckRollup ``` +The body must be `loaf issue render <ref>` output — title, issue body, definition-of-done checkboxes (checked only when status is already `done`), and children. No project headers, no hand-edited summary. If the live PR body differs, replace it: + +```bash +gh pr edit <N> --body "$(loaf issue render <ref>)" +``` + +Do not rewrite the markdown by hand. Checkboxes are unchecked until `loaf issue status <ref> done`; do not tick them in the PR body to fake completion. + Block or pause when: - PR is draft @@ -124,20 +165,27 @@ Before merging anything, find out whether another open PR uses this PR's head br gh pr list --state open --base <headRefName> --json number,title,headRefName ``` -Any result is a **child PR**, and this PR is the base of a stack. Record the list now, because after the merge the relationship is harder to see and easier to break. +Any result is a **child PR**, and this PR is the base of a stack. Record the list now, because after the merge the relationship is harder to see and easier to break. Child PRs are GitHub stacking, not child issues. -When a child exists: +When a child PR exists: - **Do not pass `--delete-branch` to the merge.** Removing the head branch while a child still points at it can close the child outright. Delete it only after every child has been retargeted, in Step 5. - Tell the user the stack exists and name the children before asking for merge confirmation. A stack changes what "merge this" means. -When no child exists, say so, and `--delete-branch` is safe. +When no child PR exists, say so, and `--delete-branch` is safe. --- -## Step 2: Evidence Review +## Step 2: Definition-of-Done Review + +The issue's definition of done is the merge checklist. Load it from the issue, not from memory: + +```bash +loaf issue dod list <ref> +loaf issue show <ref> +``` -Review the landing diff and durable prose together: +`show` prints each criterion as `position. [V|H] text` with `command=` / `expect=` when present. Walk every row against the landing diff. 1. Gather diff context: ```bash @@ -145,13 +193,19 @@ Review the landing diff and durable prose together: git diff --stat origin/<baseRefName>...HEAD git diff --name-only origin/<baseRefName>...HEAD ``` -2. Read the PR title/body and changed docs that make behavior claims. -3. Check for drift: - - PR body claims features that are not in the diff - - changelog entries mention unreleased or unrelated behavior +2. For each **H-tier** criterion, read the diff and durable prose and decide whether the text is met. `loaf issue verify` skips H-tier rows — that skip is not a pass; you are the check. +3. Run the **V-tier** rows: + ```bash + loaf issue verify <ref> + ``` + Commands run from the **repository root**. The command honors `exit <N>` and `` contains `text` ``. It writes nothing — it does not tick checkboxes, does not set status, and does not record a run. Non-zero exit blocks merge. `no executable V-tier criteria on <ref>` is not a failure; H-tier review still is. Unenforceable expect clauses print as advisory warnings and are never quietly decorative. +4. Check for drift: + - Rendered issue body claims features that are not in the diff + - changelog entries mention unrelated behavior - docs describe future work as already shipped - - comments or runbooks use stale internal vocabulary -4. Fix blocking drift before merge. For non-blocking polish, name it and let the user decide. + - comments or runbooks use stale vocabulary +5. If `loaf issue show` lists child issues that are not `done`, name them before asking to merge. Do not mark those children done unless this PR is theirs. +6. Fix blocking drift and unmet criteria before merge. For non-blocking polish, name it and let the user decide. For high-risk PRs, use the project's review skill or read-only review flow before proceeding. @@ -159,7 +213,7 @@ For high-risk PRs, use the project's review skill or read-only review flow befor ## Step 3: Local Verification -Run the checks the project supports. Examples: +Run the checks the project supports, in addition to `loaf issue verify`. Examples: - Node: `npm run typecheck`, `npm run test`, `npm run build` - Go: `go vet ./...`, `go test ./...` @@ -178,7 +232,7 @@ Use the repo's documented pre-commit or pre-PR checklist when present. Stop on f ## Step 4: Squash Merge -Draft a clean squash body from the reviewed diff and PR body: +Draft a clean squash body from the reviewed diff and the rendered issue: - One-line summary, then bullet points grouped by feature area - Plain text; use backticks only for code identifiers @@ -201,31 +255,41 @@ Let GitHub default the title from the PR title so the squash subject remains `ty ## Step 5: Post-Merge Cleanup -After a successful merge: +After a successful merge, leave the started worktree before removing it. Do not run `loaf issue stop` from inside that worktree. -1. Switch to the PR base branch: +1. Switch to the PR base branch in the repository checkout: ```bash git checkout <baseRefName> git pull --ff-only origin <baseRefName> ``` -2. Delete the local feature branch when safe: +2. Mark the bound issue done — this is what "done" means; `loaf issue stop` does not change status: + ```bash + loaf issue status <ref> done + ``` + Write statuses (`triage`, `backlog`, `todo`, `active`, `done`) update in place. Do not use `cancelled` or `duplicate` here. +3. Stop the started worktree if one exists. `loaf issue stop` removes the worktree, clears `started_branch` / `started_worktree` on the row, and **keeps the branch**: + ```bash + loaf issue stop <ref> + ``` + If the issue was never started, the command errors with `issue <ref> is not started` — treat that as already clean and continue. If the worktree is dirty, do not pass `--force` without user confirmation. +4. Delete the local feature branch when safe: ```bash git branch -d <headRefName> ``` -3. Confirm the remote branch deletion state from GitHub output or run: +5. Confirm the remote branch deletion state from GitHub output or run: ```bash gh pr view <N> --json headRefName,state ``` -4. Log the landing to the project journal: +6. Log the landing: ```bash - loaf journal log "decision(ship): PR #N landed via squash merge" + loaf journal log "decision(ship): PR #N landed via squash merge; <ref> done" ``` -If cleanup fails, report the exact residual state. Do not force-delete without user confirmation. +If cleanup fails, report the exact residual state (issue status, whether the worktree is still started, which branches remain). Do not force-delete without user confirmation. ### Stacked child PRs -Every child recorded in Step 1b needs three repairs before it can ship, and none of them happen on their own. +Every child PR recorded in Step 1b needs three repairs before it can ship, and none of them happen on their own. **Retarget the base.** GitHub does not reliably move a child's base when its base branch merges. Check, and move it explicitly: @@ -247,7 +311,7 @@ git diff --stat <baseRefName>...HEAD | tail -1 gh pr view <child> --json changedFiles -q .changedFiles ``` -Then force-push with a lease, wait for CI to finish on the **rebased head** rather than trusting the previous run, and only then ship the child. Delete the parent's head branch once no child points at it. +Then force-push with a lease, wait for CI to finish on the **rebased head** rather than trusting the previous run, and only then ship the child. Delete the parent's head branch once no child PR points at it. Report the numbers rather than asserting success: the before and after file counts, the commits the rebase skipped, and the CI conclusion on the new head. @@ -255,11 +319,11 @@ Report the numbers rather than asserting success: the before and after file coun ## Step 6: Release Suggestion -After landing, decide whether to suggest release: +After landing, decide whether to suggest the release skill. That cut will not re-run this review. -- Suggest release when the landed PR completes a coherent batch, user-facing feature, fix train, or release branch. +- Suggest release when the landed PR completes a coherent batch, user-facing feature, or fix train. - Do not suggest release for every small PR by default. -- If multiple related PRs are expected, say the PR is landed and can wait for a later batched release. +- If more related PRs are expected, say this PR is landed and can wait for a later cut. Use language carefully: the PR is **landed** or **shipped**; it is not **released** until release publishes a version. @@ -283,12 +347,12 @@ Do not disable hooks to force a PR through. ## Suggests Next -After a successful ship, suggest release only when the landed work forms a coherent release batch or the user asks to publish. +After a successful ship, suggest release only when the landed work forms a coherent batch or the user asks to publish. Release will not re-check the landed PR. ## Related Skills -- **release** -- Publishes a version from already-landed work -- **git-workflow** -- Branching, PR, commit, and squash merge conventions -- **foundations** -- Verification, code review, and production readiness -- **documentation-standards** -- Changelog, docs, and durable prose quality -- **reflect** -- Updates strategy from significant shipped work +- **release** — Publishes a version from already-landed work; does not re-review those PRs +- **git-workflow** — Branching, PR, commit, and squash merge conventions +- **foundations** — Verification, code review, and production readiness +- **documentation-standards** — Changelog, docs, and durable prose quality +- **reflect** — Updates strategy from significant shipped work diff --git a/dist/cursor/skills/triage/SKILL.md b/dist/cursor/skills/triage/SKILL.md index 0778654a2..b8ad99622 100644 --- a/dist/cursor/skills/triage/SKILL.md +++ b/dist/cursor/skills/triage/SKILL.md @@ -2,13 +2,13 @@ name: triage description: >- Processes the local intake queue from loaf intake list: unresolved sparks, - ideas, brainstorms, tracked and deferred Intents, and unmigrated legacy - deferrals. Use when the user asks "triage", "process my backlog", or wants - dispositions chosen across intake items. Produces explicit dispositions: - discard, retain, track as Intent, defer, resume, resolve, explore, hand to - pitch, or hand to shape. Not for reading a single known item (use loaf intent - show or journal directly), capturing new ideas (use idea), problem discovery - (use pitch), or bounding one chosen direction (use shape). + ideas, and brainstorms. Use when the user asks "triage", "process my backlog", + or wants dispositions chosen across intake items. Produces explicit + dispositions: discard, retain as spark/idea, file as backlog issue, resume + exploration, resolve, hand to pitch, or hand to shape (issue preparation). Not + for reading a single known item (use loaf issue show, loaf spark show, loaf + idea show, or journal directly), capturing new ideas (use idea), problem + discovery (use pitch), or bounding one chosen direction (use shape). version: 0.2.21 --- @@ -26,7 +26,7 @@ Process the intake queue. Triage is the public funnel where captured material me - Quick Reference - Process - Dispositions -- Legacy Deferrals +- Leftover kinds - Guardrails - Related Skills @@ -36,62 +36,67 @@ Process the intake queue. Triage is the public funnel where captured material me - Read the queue with `loaf intake list --json`; it projects every unresolved logical item exactly once with its provenance and exact read command. - Present everything before acting — the user decides each disposition; never auto-promote, auto-discard, or auto-convert. - The CLI never classifies: you and the user interpret each item; commands perform the chosen operation deterministically. -- Capture, Intent, and Exploration are different claims: a spark or idea is retained material, a tracked Intent is deliberately tracked work, a deferral is an Intent disposition with an immutable payload, an Exploration is an inquiry. Do not conflate them to save a step. +- Capture, issue, and Exploration are different claims: a spark or idea is retained material, a backlog issue is deliberately tracked work, an Exploration is an inquiry. Do not conflate them to save a step. - One pass through the queue — don't loop or re-present items. -- **Two doors into a Change:** items needing problem discovery hand to pitch, which owns `loaf change init <slug> --brief` and brief authoring; well-understood directions hand to shape. When capture should precede shaping without a full pitch, run `loaf change init <slug> --brief` and seed `brief.md` with the original ask, then hand to shape. +- **Two doors into issue work:** items needing problem discovery hand to pitch; well-understood directions hand to shape (issue preparation). Worth keeping but not ready for either door files as a backlog issue (`loaf issue new "<title>" --status backlog`, optional `--parent`, optional `loaf issue bucket`). Triage never runs `loaf issue start`, never opens PRs, and never invents Git artifacts. ## Verification - Every presented item has a recorded disposition or an explicit "leave for next triage". -- Tracked and deferred choices exist as Intents with the expected derived disposition (`loaf intent list`). +- Filed directions exist as backlog issues (`loaf issue list --status backlog`) and no longer appear in `loaf intake list` once their captures are resolved or archived. - Discards are resolved or archived through their own commands and no longer appear in `loaf intake list`. -- No Linear or tracker operation was attempted; publication is a later concern outside this Change. +- No Linear or tracker operation was attempted; publication is a later concern outside triage. ## Quick Reference | Item kind | Comes from | Typical dispositions | |-----------|-----------|----------------------| -| spark | `loaf spark capture` moments | discard, promote to idea, track as Intent | -| idea | idea capture | archive, explore, track as Intent, hand to pitch, hand to shape | -| brainstorm | archived divergent sessions | archive, explore, promote, hand to pitch | -| intent (tracked) | `loaf intent create` | keep tracking, defer, resolve, explore, hand to pitch, hand to shape | -| intent (deferred) | `loaf intent defer` or adapter | resume, resolve, leave deferred | -| legacy_deferral | pre-conversion `journal defer` | read, then optionally convert (see Legacy Deferrals) | +| spark | `loaf spark capture --scope <scope> --text <text>` | discard, retain, promote to idea, file as backlog issue, resume exploration, resolve, hand to pitch, hand to shape | +| idea | `loaf idea capture --title "<title>"` | archive, retain, file as backlog issue, resume exploration, resolve, hand to pitch, hand to shape | +| brainstorm | `loaf brainstorm capture` | archive, retain, promote to idea, file as backlog issue, resume exploration, hand to pitch, hand to shape | ## Process 1. **Scan.** Run `loaf intake list --json`. Summarize counts by kind, then list each item with its title, disposition or status, and read command. 2. **Read on demand.** Use each item's `read_command` verbatim when the user wants detail before deciding. If a read command fails, record the exact command and error in the summary as `unreadable`, make no semantic disposition for that item, continue the pass, and offer a factual diagnostic step (`loaf state doctor --json`) afterward. Never persist unreadable as a status. 3. **Decide per item.** Present the applicable dispositions and perform exactly the chosen one. -4. **Summarize.** Report what was discarded, retained, tracked, deferred, resumed, resolved, or handed onward, and journal notable decisions. +4. **Summarize.** Report what was discarded, retained, filed as backlog issues, resumed as explorations, resolved, or handed to pitch or shape, and journal notable decisions. ## Dispositions - **Discard** — ideas and brainstorms: `loaf idea archive <ref> --reason <r>` or `loaf brainstorm archive <ref> --reason <r>`. A spark is resolved against the entity that addressed it (`loaf spark resolve <ref> --by <entity> --reason <r>`); a pure dead-end spark currently has no deterministic discard operation — leave it retained, journal the judgment, and never invent a resolving entity. -- **Retain as capture** — do nothing; open captures resurface next triage. -- **Track as Intent** — two steps: create the Intent with the capture as its source, then close the capture against it so the direction appears once. `loaf intent create --title <t> --body <self-sufficient body> --from <capture-ref>`, then `loaf spark resolve <capture-ref> --by <intent-ref>` or `loaf idea resolve <capture-ref> --by <intent-ref>` (brainstorms: `loaf brainstorm archive <ref> --reason "tracked as <intent-ref>"`). -- **Defer** — an existing Intent: `loaf intent defer <ref> --why <w> --boundary <b> --trigger <t> --operation-id <key>`; a new deferred direction needs the full skeleton: `loaf intent create --title <t> --body <b> --disposition deferred --why <w> --boundary <bd> --trigger <tr> --operation-id <key> [--from <source-ref>]`. -- **Resume** — `loaf intent resume <ref> --reason <why now>`; appends a tracked disposition linked to the deferral it supersedes. -- **Resolve** — `loaf intent resolve <ref> --reason <outcome>`; history is never rewritten. -- **Explore** — agent technique for genuinely undecided directions (Explorations and checkpoints); not a user slash entry — prefer pitch when the human needs problem discovery first, then reach for explore from inside that work if still undecided. -- **Pitch** — items needing problem discovery hand to pitch, which owns init and brief authoring; resolve the promoted item against the created change (`loaf spark resolve` / `loaf idea resolve` / archive brainstorm with the change as the reason). -- **Shape** — when a direction is already well-understood and ready for bounded delivery, hand it to shape; triage never creates fully-materialized Changes, branches, or worktrees (capture-only brief seeding is the Critical Rules exception above). +- **Retain as spark/idea** — do nothing to leave the capture open, or promote into the other capture primitive: capture the idea first (`loaf idea capture --title "..."`), then `loaf spark promote <spark> --to-idea <idea>` or `loaf brainstorm promote <brainstorm> --to-idea <idea>`. Open captures resurface next triage. +- **File as backlog issue** — two steps so the direction appears once. Create the issue, then close the capture against it: -## Legacy Deferrals + ```bash + loaf issue new "<title>" --status backlog [--parent <ref>] [--kind delivery|decision] [--fog <text>] [--body <text>] + loaf issue bucket <issue-ref> now|next|later # optional; labels only, never a constraint + loaf spark resolve <capture-ref> --by <issue-ref> + # or: loaf idea resolve <capture-ref> --by <issue-ref> + # brainstorms: loaf brainstorm archive <ref> --reason "filed as <issue-ref>" + ``` -Items of kind `legacy_deferral` are pre-conversion `journal defer` records. They stay visible and readable until the explicit, backup-first conversion is run; nothing disappears while migration is pending. When the user wants them converged, offer `loaf state migrate deferrals --dry-run` to preview the project-specific manifest and `--apply` only with explicit consent — apply verifies a whole-database backup first and preserves every legacy row. + Use `--kind decision` when filing a sharp question. Copy still-unsharp questions into `--fog` (create-time only). `--parent` nests under an existing issue; omit it for a different problem. +- **Resume exploration** — agent technique for genuinely undecided directions (Explorations and checkpoints); not a user slash entry. Prefer pitch when the human needs problem discovery first, then reach for explore from inside that work if still undecided. Resume with `loaf exploration context <ref>` when a named Exploration already exists. +- **Resolve** — the capture is already represented elsewhere. `loaf spark resolve <ref> --by <entity> --reason <r>` or `loaf idea resolve <ref> --by <entity>`. History is never rewritten. +- **Hand to pitch** — items needing problem discovery hand to pitch. Resolve the capture against the issue once one exists (`loaf spark resolve` / `loaf idea resolve --by <issue-ref>` / archive the brainstorm with that issue as the reason). +- **Hand to shape** — when a direction is already well-understood and ready for bounded delivery, hand it to shape for issue preparation. Triage never writes definition-of-done criteria, never runs `loaf issue check`, and never creates branches or worktrees. + +## Leftover kinds + +`loaf intake list` may still include `intent` and `legacy_deferral` items. Do not create new `intent` rows. Treat leftover directions like any other capture: file a backlog issue if worth keeping, or leave them for a later pass. Do not offer conversion commands that recreate the old tracked/deferred row. ## Guardrails 1. **User decides every disposition** — present, don't decide. 2. **Batch presentation, individual decisions** — show the full queue, then process one item at a time. 3. **Log everything** — no silent discards, promotions, or conversions. -4. **Deferred is not forgotten** — deferred Intents remain active truth in `loaf journal context` until resumed or resolved. +4. **Filed is not forgotten** — backlog issues remain on `loaf issue list` and may appear on `loaf issue frontier` until their status changes. Buckets are labels only. ## Related Skills - **idea** — capture a new idea (fast, minimal friction) - **pitch** — problem-discovery ceremony for items that need framing before shape - **explore** — agent technique for divergent inquiry with portable checkpoints -- **shape** — develop a well-understood direction into a bounded Change +- **shape** — develop a well-understood direction into a bounded issue - **housekeeping** — flags stale artifacts; does not choose dispositions diff --git a/dist/cursor/skills/wrap/SKILL.md b/dist/cursor/skills/wrap/SKILL.md index ab64cc64d..52777e1bf 100644 --- a/dist/cursor/skills/wrap/SKILL.md +++ b/dist/cursor/skills/wrap/SKILL.md @@ -136,7 +136,7 @@ After the wrap-up report, suggest housekeeping if it wasn't run this session and ## Report Format -Use backtick formatting for code identifiers, file paths, spec/task IDs, version numbers, status values, and CLI commands. Use uppercase for spec and task IDs (`SPEC-029`, not `spec-029`). +Use backtick formatting for code identifiers, file paths, issue IDs, version numbers, status values, and CLI commands. Use uppercase for issue IDs (`LOAF-29`, not `loaf-29`). ```markdown ## Session Wrap-Up diff --git a/dist/opencode/.loaf-target-manifest.json b/dist/opencode/.loaf-target-manifest.json index d8bfc72af..f1231bea3 100644 --- a/dist/opencode/.loaf-target-manifest.json +++ b/dist/opencode/.loaf-target-manifest.json @@ -12,7 +12,7 @@ "kind": "hook-file", "source_path": "plugins/hooks/instructions/post-merge.md", "destination": "plugins/hooks/instructions/post-merge.md", - "sha256": "f728a0a9a004ea1ea76b70ca3292996c798baa2838633806e2fb4250118203b6", + "sha256": "4f712c30a821a1b5d971f9fd8bf17dfc8888634b6bb8f307339f9e5a47c05551", "mode": 420 }, { @@ -28,7 +28,7 @@ "kind": "hook-file", "source_path": "plugins/hooks/instructions/pre-pr-checklist.md", "destination": "plugins/hooks/instructions/pre-pr-checklist.md", - "sha256": "234b5e37846adf226ae501ac65a62139fed61c42dc62bbf9c78fc8b885debbad", + "sha256": "64a647e40d2d7f52224a60b978012265f80414c8eb2c893e61a97faa74375dd3", "mode": 420 }, { @@ -107,7 +107,7 @@ "id": "managed-instructions", "kind": "instruction", "destination": "project-instructions", - "sha256": "ac6debb93fcd1b2d7806681c446f3b7d9691a43a872831a969c82a7470b0b30d" + "sha256": "21e91a6226ead7de1ef1d3d61c4e2060dc9763e8485192f6efc0060a09bbe66e" }, { "id": "plugin:plugins/hooks.ts", diff --git a/dist/opencode/agents/background-runner.md b/dist/opencode/agents/background-runner.md index 258d59dfc..47a7ef0f5 100644 --- a/dist/opencode/agents/background-runner.md +++ b/dist/opencode/agents/background-runner.md @@ -35,7 +35,7 @@ The spawning agent provides: - Specific task to execute - Files or scope to analyze - Output location (`.agents/reports/YYYYMMDD-HHMMSS-<name>.md`) -- Task/spec reference when available +- Issue reference when available ## Execution Process @@ -45,7 +45,7 @@ Extract from prompt: - What to do (audit, analyze, review) - Scope (files, directories) - Output location -- Task ID or spec ID when provided +- Issue ID when provided ### 2. Execute Work @@ -67,7 +67,7 @@ report: status: unprocessed created: "2026-01-23T14:30:00Z" background_agent_id: "bg-YYYYMMDD-HHMMSS-description" - task_reference: "task or spec reference when provided" + issue_reference: "issue reference when provided" --- # Report Title diff --git a/dist/opencode/commands/bootstrap.md b/dist/opencode/commands/bootstrap.md index 1a5459477..f39db5a73 100644 --- a/dist/opencode/commands/bootstrap.md +++ b/dist/opencode/commands/bootstrap.md @@ -29,7 +29,7 @@ First-contact project setup: detect state, interview the builder, populate proje - Guardrails - Related Skills -Series-prep lives under Finalization (phase between Knowledge Base Scaffolding and Next Steps). +Series-prep lives under Finalization (phase between Knowledge Base Scaffolding and Next Steps): the initial arc becomes backlog issues, not folders. **Input:** $ARGUMENTS @@ -42,8 +42,8 @@ Series-prep lives under Finalization (phase between Knowledge Base Scaffolding a - **Always interview** -- even with a rich brief, confirm understanding through structured questions — one at a time, with a recommendation, using your harness's structured question tool if it has one - **Pitched BRIEF is discovery-already-done** -- when `docs/BRIEF.md` has `source: pitch`, do not re-excavate the problem space; quote-back and gap-fill only for operating-document population - **BRIEF is input, not output** -- the BRIEF is raw intake. Extract every useful fact into VISION/STRATEGY/ARCHITECTURE/AGENTS during bootstrap. -- **BRIEF is archeological after bootstrap** -- once extraction completes (including series-prep reading scoped concepts from it), the BRIEF is a frozen historical snapshot. No skill, agent, command, or template should reference `docs/BRIEF.md` post-bootstrap. Operating documents and minted change briefs must stand on their own. -- **Series-prep never auto-shapes and never creates branches** -- every mint is user-confirmed; no priority, date, or dependency fields; concepts without a coarse `target_release` stay BRIEF lines, sparks, or Intents +- **BRIEF is archeological after bootstrap** -- once extraction completes (including series-prep reading scoped concepts from it), the BRIEF is a frozen historical snapshot. No skill, agent, command, or template should reference `docs/BRIEF.md` post-bootstrap. Operating documents and minted issue bodies must stand on their own. +- **Series-prep never auto-shapes and never creates branches** -- every mint is user-confirmed; no priority, date, or dependency fields; buckets are labels, never bindings; concepts that fail granularity stay BRIEF lines or sparks - **Suggest, don't execute** -- recommend next skills at the end, never auto-run them - **Log first** -- log invocation before interviewing: `loaf journal log "skill(bootstrap): <project or intake>"` - **Log outcome** -- log bootstrap completion to the project journal: `loaf journal log "decision(bootstrap): project bootstrapped, mode detected"` @@ -55,7 +55,7 @@ Series-prep lives under Finalization (phase between Knowledge Base Scaffolding a - All expected operating documents (`docs/VISION.md`, `AGENTS.md` at minimum) exist and contain populated content - Useful BRIEF content has been extracted into operating documents (no future reader should need to open the BRIEF) - When `source: pitch`, the interview was gap-only (no re-excavation of already-specific problem sections) -- When series-prep ran: each minted folder has `change.json` with stamped `target_release`, a standalone problem-space `brief.md`, zero-violation captured state via explicit-path `loaf change check <folder> --json`, and its own docs-only commit (never a batch); no branches created for the series; no auto-shape +- When series-prep ran: each minted row is a backlog issue (`loaf issue new "<title>" --body "<problem narrative>" --status backlog`) with a standalone problem-space body; an advisory bucket (`loaf issue bucket <ref> now|next|later`) may be set — buckets are labels, never bindings; `loaf issue check <ref>` only when a capture is shaped enough to check, otherwise nothing (a backlog issue with a problem body needs no ceremony); no folders, no docs-only commits; no branches created for the series; no auto-shape - Root `AGENTS.md` is a real file; on Claude Code, the compatibility symlink `.claude/CLAUDE.md -> ../AGENTS.md` exists (see Finalization) - Key decisions and interview outcomes were logged with `loaf journal log` and are readable with `loaf journal recent` @@ -236,7 +236,7 @@ Pitch owned the problem-space grill. Bootstrap does not re-excavate. The pitch 1. **Acknowledge the pitch** — name that `docs/BRIEF.md` carries `source: pitch` and that problem discovery is already done. 2. **Summarize what pitch captured** — short section-by-section gist (problem, who, alternatives, value, constraints, sequencing, open questions). The builder should hear continuity with the pitch closing ceremony, not a cold restart. -3. **State what bootstrap will do now** — interview only on gaps for operating-document population (VISION, STRATEGY, ARCHITECTURE, AGENTS), then series-prep the initial arc of captured changes. Do not re-grill the problem space. +3. **State what bootstrap will do now** — interview only on gaps for operating-document population (VISION, STRATEGY, ARCHITECTURE, AGENTS), then series-prep the initial arc as backlog issues. Do not re-grill the problem space. Then continue: @@ -423,58 +423,62 @@ The journal should capture: Use [templates/journal.md](templates/journal.md) only as the rendered entry format reference; do not hand-author journal markdown as the source of truth. -### 4. Series-Prep (initial arc as captured changes) +### 4. Series-Prep (initial arc as backlog issues) -After operating documents are populated (and Knowledge Base Scaffolding above has run), close bootstrap by minting the BRIEF's initial arc as **captured promise carriers** — brief-only change folders bound to a coarse `target_release`, each landed as its own docs-only commit. Series-prep is not roadmap planning: no milestone entities, no dates, no priorities, no dependency fields. Sequencing is prose in each brief; cohort membership is the shared `target_release`. +After operating documents are populated (and Knowledge Base Scaffolding above has run), close bootstrap by minting the BRIEF's initial arc as **backlog issues** — SQLite rows with a problem-space body and an optional advisory bucket label (`loaf issue bucket <ref> now|next|later`). Buckets are labels, never bindings. Series-prep is not roadmap planning: no milestone entities, no dates, no priorities, no dependency fields. Sequencing is prose in each issue body. No folders, no docs-only commits per capture — rows, not files. **When to run** - Always offer series-prep when a project BRIEF exists and names more than one scoped concept (typical after a pitched BRIEF; also after a rich non-pitch brief). -- If the BRIEF is a single atomic concept with no series, say so and skip to Next Steps — one future shape or a single capture later is enough. -- Series-prep **reads** the BRIEF during this phase only. After bootstrap ends, nothing references `docs/BRIEF.md` again; minted change briefs and operating docs stand alone. +- If the BRIEF is a single atomic concept with no series, say so and skip to Next Steps — one future shape or a single issue later is enough. +- Series-prep **reads** the BRIEF during this phase only. After bootstrap ends, nothing references `docs/BRIEF.md` again; minted issue bodies and operating docs stand alone. **Procedure** 1. **Enumerate concepts** with the builder from the BRIEF's scoped problem space (Sequencing and Relationships, Open Questions, and distinct problem threads in Problem Statement). List candidates as recommendation-first options using your harness's structured question tool if it has one. -2. **Apply granularity** per [references/interview-guide.md](references/interview-guide.md) (Series-Prep Granularity): a concept earns its own captured change when it is independently shippable **and** its problem can be **stated precisely now** (the mint-time specifiability test — not answered now, stated now) without the others; otherwise it stays a BRIEF line, becomes a spark, or an Intent — never a half-minted folder. +2. **Apply granularity** per [references/interview-guide.md](references/interview-guide.md) (Series-Prep Granularity): a concept earns its own backlog issue when it is independently shippable **and** its problem can be **stated precisely now** (the mint-time specifiability test — not answered now, stated now) without the others; otherwise it stays a BRIEF line or becomes a spark — never a half-minted row. 3. **Per confirmed concept (one at a time — never batch):** - 1. Confirm mint with the builder (slug, coarse `target_release`, one-line problem restatement). If the builder will not bind even a coarse target, do not mint — park as spark/Intent/BRIEF line. - 2. Propose a **local slug** that names the concept, never another work unit (`spec-042`, task ids, change folder names). Confirm the slug. - 3. Run capture init: + 1. Confirm mint with the builder (title, optional advisory bucket, one-line problem restatement). Buckets are labels, never bindings — a missing bucket does not block mint. If the concept fails granularity, do not mint — park as spark or BRIEF line. + 2. Propose a **working title** that names the concept, never another work unit (issue aliases, task ids). Confirm the title. + 3. **Seed a problem-space-only narrative** from the BRIEF's content for that concept (Problem Statement, Who Has It, Current Alternatives, Value Proposition, Constraints, Sequencing and Relationships as prose order relative to the arc, Sources if any, Open Questions). Do not copy solution design. The seeded body must stand alone as intent for later shape — cold-read without the project BRIEF or this session. + 4. Mint the backlog issue: ```bash - loaf change init <slug> --brief + loaf issue new "<title>" --body "<problem narrative>" --status backlog ``` - Creates `docs/changes/YYYYMMDD-<slug>/` with `change.json` + `brief.md` only. - 4. **Seed `brief.md` problem-space-only** from the BRIEF's content for that concept (Problem Statement, Who Has It, Current Alternatives, Value Proposition, Constraints, Sequencing and Relationships as prose order relative to the arc, Sources if any, Open Questions). Do not copy solution design. The seeded brief must stand alone as intent for later shape — cold-read without the project BRIEF or this session. - 5. **Stamp `target_release`** on that folder's `change.json` with the builder's coarse binding (canonical `MAJOR.MINOR.PATCH`, no `v`, no prerelease). Series-prep mints only targeted captures (promise-carrier path). - 6. **Pre-landing guard** (required before every commit): + Creates a SQLite row, not a folder. Use `--body -` or `--body-file <path>` when the narrative is long (see `loaf issue new --help`). + 5. **Optionally set an advisory bucket** (a label, never a binding): ```bash - loaf change check <folder> --json + loaf issue bucket <ref> now|next|later ``` + 6. **Validate** only when the capture is shaped enough to check: - Must report zero violations and captured state. Then **read `<folder>/change.json` directly** and confirm the stamped `target_release` matches what the builder bound. Bare `loaf change check` resolves by branch and can miss a capture elsewhere — always pass the explicit folder path. - 7. **Land as its own docs-only commit on the default branch** (one commit per capture, never a batch). Example subject: `docs(change): capture <slug> brief`. Bootstrap prepares the commit; never push; never open a PR. + ```bash + loaf issue check <ref> + ``` + + A backlog issue with a problem body is capture-only and needs no ceremony — skip the check. Do not add criteria or an out-of-scope statement during series-prep (that is shape). + 7. **Do not land a docs-only commit.** The row is the artifact. Never push; never open a PR; never create a branch. 4. **Guards (hard):** - Every mint is user-confirmed — never auto-mint the whole list - - Never auto-run shape and never create slug branches during series-prep - - No priority, date, estimate, or dependency fields on captures - - No batching multiple captures into one commit - - Concepts without a coarse target stay BRIEF lines, sparks, or Intents + - Never auto-run shape and never create branches during series-prep + - No priority, date, estimate, or dependency fields on issues + - No folders, no docs-only commits per capture — rows, not files + - Concepts that fail granularity stay BRIEF lines or sparks; buckets are labels, never bindings **After the series** -Log the arc: `loaf journal log "decision(bootstrap): series-prep minted <n> captures for <cohort or targets>"`. Hand off by naming the first capture folder for shape when the builder is ready. +Log the arc: `loaf journal log "decision(bootstrap): series-prep minted <n> backlog issues"`. Hand off by naming the first issue ref for shape when the builder is ready. ### 5. Next Steps Suggest relevant next steps based on what was learned: -- shape -- on a series-prep capture (or any ready concept) to promote the folder and bound implementation +- shape -- on a series-prep backlog issue (or any ready concept) to bound implementation - pitch -- if a new concept still needs problem discovery (not for re-grilling the BRIEF) -- idea -- if specific feature ideas emerged during the interview and should not become captures yet +- idea -- if specific feature ideas emerged during the interview and should not become issues yet - research -- if there are open questions that need investigation - `loaf doctor` -- to verify the setup is healthy @@ -501,18 +505,18 @@ When the interactive interview path is unavailable, bootstrap the operating docu 2. **Always interview** -- even with a rich brief, confirm understanding; when `source: pitch`, gap-fill only 3. **Never overwrite** -- existing documents require explicit confirmation 4. **Draft, then review** -- present documents section-by-section -5. **Extract, don't preserve** -- pull every useful fact from the BRIEF into operating docs (and series-prep seeds change briefs from it once). The BRIEF is archeological after bootstrap; nothing should reference it again. +5. **Extract, don't preserve** -- pull every useful fact from the BRIEF into operating docs (and series-prep seeds issue bodies from it once). The BRIEF is archeological after bootstrap; nothing should reference it again. 6. **Record the session** -- decisions and rationale are preserved 7. **Suggest, don't execute** -- recommend next skills, don't auto-run them; series-prep never auto-shapes or creates branches 8. **Interview structured** -- one question at a time, with a recommendation, using your harness's structured question tool if it has one -9. **Series-prep is not roadmap planning** -- coarse `target_release` + prose sequencing only; no dates, priorities, or dependency fields +9. **Series-prep is not roadmap planning** -- advisory bucket labels (never bindings) + prose sequencing only; no dates, priorities, or dependency fields --- ## Related Skills -- **pitch** -- Authors a project-scale `docs/BRIEF.md` with `source: pitch` (or a change-scale brief); bootstrap consumes the pitched BRIEF with gap-only interview and series-prep -- **shape** -- Bound a captured change into a contract (promotes brief-only folders; often follows series-prep) +- **pitch** -- Authors a project-scale `docs/BRIEF.md` with `source: pitch` (or an issue-scale problem narrative); bootstrap consumes the pitched BRIEF with gap-only interview and series-prep +- **shape** -- Bound a backlog issue into a contract (often follows series-prep) - **explore** -- Agent technique when a concept that emerges during bootstrap is still undecided (not a user front door; prefer pitch for human problem discovery) - **research** -- Investigate topics and open questions - **idea** -- Quick-capture feature ideas that emerge during bootstrap diff --git a/dist/opencode/commands/breakdown.md b/dist/opencode/commands/breakdown.md deleted file mode 100644 index 8f442854b..000000000 --- a/dist/opencode/commands/breakdown.md +++ /dev/null @@ -1,330 +0,0 @@ ---- -description: >- - Decomposes specifications into atomic tasks with dependencies and priorities. - Use when the user asks "break this down" or "create tasks for this spec." - Produces task files with estimates, dependencies, and acceptance criteria. Not - for shaping ideas (use shape) or implementation work (use implement). -subtask: false -version: 0.2.21 ---- - -# Breakdown - -Decompose specifications into atomic, implementable tasks. - -## Contents -- Critical Rules -- Verification -- Quick Reference -- Task Breakdown Philosophy -- Task Backend Detection -- Process -- Linear-Native Mode -- Local-Tasks Mode -- Priority Levels -- Guardrails -- Related Skills - -**Input:** $ARGUMENTS - ---- - -## Critical Rules - -- **One concern per task** -- never mix unrelated layers (backend + frontend) in a single task -- **Every task includes its own verification** -- no separate "verify" tasks; each task must have an observable done condition -- **Own the decisions** -- decide granularity and priorities autonomously; only ask the user when two equally valid orderings have genuinely different trade-offs -- **Keep tests with the code they test** -- never split implementation and tests into separate tasks -- **Update spec status** -- mark the spec as `implementing` after tasks are created -- **One backend only** -- in Linear-native mode create Linear issues and NO local `TASK-NNN.md`; in local mode create local tasks and make NO Linear calls -- **Spec file is always local** -- in both modes, the spec stays in `.agents/specs/`. The Linear parent issue, when present, is a rollup pointing to the spec, not a re-host of it -- **Log outcome** -- log breakdown to the project journal: `loaf journal log "decision(breakdown): SPEC-NNN → N tasks created"` - ---- - -## Verification - -- Each created task has a clear title, priority, file hints, verification command, and observable done condition -- The dependency graph has no cycles and reflects actual implementation order -- Spec status has been updated to `implementing` -- **Linear-native mode only:** parent issue exists, labeled `spec`, with description pointing to the local spec file; N sub-issues have `parentId` set; zero local task rows or `TASK-NNN.md` files were created; spec frontmatter has `linear_parent` and `linear_parent_url` populated -- **Local-tasks mode only:** N local tasks exist in `loaf task list` with compatibility `.md` files when configured; no Linear calls were made - ---- - -## Quick Reference - -### Priority Levels - -| Priority | Loaf | Linear Priority | -|----------|------|-----------------| -| P0 | Urgent/blocking -- drop everything | Urgent (1) | -| P1 | High -- work next | High (2) | -| P2 | Normal -- scheduled work (default) | Normal (3) | -| P3 | Low -- when time permits | Low (4) | - -### Right-Sizing Rules - -| Rule | Guideline | -|------|-----------| -| **One agent type** | Completable by a single implementer (after skills narrowing) | -| **One concern** | Touches one layer, service, or component | -| **Context-appropriate** | Fits in model context with room for exploration | -| **Not over-fragmented** | Don't split what naturally belongs together | - -### Mode Selection - -| `integrations.linear.enabled` in `.agents/loaf.json` | Mode | See | -|------------------------------------------------------|------|-----| -| `true` | Linear-native | [Linear-Native Mode](#linear-native-mode) | -| `false` or absent | Local-tasks | [Local-Tasks Mode](#local-tasks-mode) | - ---- - -## Task Breakdown Philosophy - -**Primary principle: separation of concerns.** - -### The Right Size Test - -1. Can a single implementer complete this? If no, split by concern -2. Does it touch multiple unrelated concerns? If yes, split by concern -3. Will the agent need too much context? If yes, split into smaller coherent units -4. Am I splitting just to have more tasks? If yes, merge back - -### Anti-Patterns - -| Don't | Do Instead | -|-------|------------| -| Split backend + tests into separate tasks | Keep tests with the code they test | -| Create a task per file | Group files by concern | -| Separate "implement" and "verify" tasks | Every task includes its own verification | -| Copy the full spec text into the Linear parent issue | Summarize + link to the local spec file | -| Create both local `TASK-NNN.md` and Linear sub-issues | Pick one backend; never mix | - ---- - -## Task Backend Detection - -Read `.agents/loaf.json`: - -```json -{ - "integrations": { - "linear": { "enabled": true } - } -} -``` - -If `integrations.linear.enabled` is `true`, proceed in **Linear-native mode**. -Otherwise, proceed in **Local-tasks mode**. - -If `.agents/loaf.json` is missing, default to local-tasks and note the -assumption in the project journal. - ---- - -## Process - -### Step 1: Parse Input - -`$ARGUMENTS` should reference a spec (e.g., "SPEC-001"). If unclear, list available specs. - -### Step 2: Read the Spec - -Extract: test conditions, scope, implementation notes, priority ordering, complexity size. - -### Step 3: Identify Task Boundaries - -Break down by concern (data layer, backend, frontend, infrastructure, etc.). One concern per task. Explicit dependencies for sequential tasks. - -### Step 4: Decide Priorities and Granularity - -Own the granularity and priority decisions. Apply the Right Size Test, assign priorities -based on dependencies, priority order, and go/no-go gates, and do a self-review pass. Do not -defer these decisions to the user — they trust agent judgment here. - -If genuinely uncertain (e.g., two equally valid orderings with different trade-offs), -ask. Otherwise, decide and move on. - -### Step 5: Draft Task List - -Draft tasks following [task template](templates/task.md). Each task needs: clear title, priority, file hints, verification command, observable done condition, labels (if routing by team). - -### Step 6: Present the Plan - -Show the dependency graph and task summary for awareness before creating anything. -Present it as "here's what I'm creating" not "which option do you prefer?" The user -can still adjust after creation, but the default is to proceed. - -### Step 7: Create Tasks (mode-specific) - -Detect the mode (see [Task Backend Detection](#task-backend-detection)) and follow the -matching section below. Do NOT mix modes. - -- Linear enabled → [Linear-Native Mode](#linear-native-mode) -- Linear disabled or missing → [Local-Tasks Mode](#local-tasks-mode) - -### Step 8: Update Spec and Announce - -Set spec status to `implementing`. In Linear-native mode, also write -`linear_parent` and `linear_parent_url` into the spec's frontmatter. Announce -created tasks and next steps. - ---- - -## Linear-Native Mode - -Spec files stay local and canonical in `.agents/specs/`. Tasks live in Linear -as sub-issues of a parent rollup issue representing the spec. No local -task rows or `TASK-NNN.md` files are created. - -### 7a. Ensure the `spec` label exists - -The `spec` label groups all spec-parent rollup issues so Linear users can -filter for them. - -1. Call `list_issue_labels` to check whether a label named `spec` exists. -2. If missing, create it via `create_issue_label`: - - `name`: `spec` - - `color`: `#5e6ad2` (Linear-ish indigo; implementer may adjust) - - `description`: `Parent rollup issue representing a design spec tracked in the repo at .agents/specs/` - - Prefer workspace-scoped so all teams can filter uniformly. If the MCP - only supports team-scoped labels, create on the default team. -3. Log whether the label was created this run or already existed. This - matters for first-time Loaf setup on a Linear workspace. - -### 7b. Resolve team, project, and state - -Read from `.agents/loaf.json`: - -- **Team:** `linear.default_team` (name) — resolve to team ID via - `list_teams` if not already cached in `known_teams`. -- **Project:** `linear.project.id`. -- **State:** call `list_issue_statuses` for the team, pick the - `unstarted`-type state (typically "Backlog" or "To-Do"). States are - **team-scoped**, not workspace-scoped — always pass the team. - -### 7c. Create the parent issue - -Use `create_issue` with: - -| Field | Value | -|-------|-------| -| `title` | `[SPEC-NNN] <spec title>` | -| `teamId` | from 7b | -| `projectId` | from 7b | -| `stateId` | unstarted state from 7b | -| `priority` | mapped from spec (default High = 2 if unspecified) | -| `labels` | `["spec"]` | -| `description` | Summary synthesized from the spec's Problem Statement + Solution Direction (1–3 paragraphs), ending with: `See .agents/specs/SPEC-NNN-<slug>.md for full text, council references, and strategic tensions.` | - -**Do NOT** copy the full spec body into the description. The local file is canonical. - -### 7d. Check label-group conflicts (pre-flight per sub-issue) - -Linear labels can belong to exclusive groups (e.g., a `type` group where -`feature`, `testing`, `docs`, `bug`, `refactor` are mutually exclusive). -Before creating each sub-issue: - -1. Inspect proposed labels against known group membership (from - `list_issue_labels` group metadata). -2. If a task has more than one label from the same exclusive group, pick the - most appropriate and drop the others. Warn the user about the drop. -3. Log the resolution so the user can override if desired. - -### 7e. Create sub-issues - -For each task, use `create_issue` with: - -| Field | Value | -|-------|-------| -| `parentId` | parent issue ID from 7c | -| `title` | task title | -| `description` | task description + acceptance criteria | -| `teamId` | routed from `team_keywords` or falling back to `default_team` | -| `projectId` | same as parent unless task explicitly belongs elsewhere | -| `stateId` | unstarted state for the target team | -| `priority` | mapped from task priority (see Priority Levels table) | -| `labels` | task labels after conflict resolution (7d) | - -Express dependencies from the spec's Priority Order / dependency graph via -`blockedBy` referencing sibling sub-issue IDs. Create in dependency order so -predecessors exist when referenced. - -### 7f. Do NOT create local task files - -Skip `loaf task create` entirely. Linear issue IDs are the task record. No -local task rows or `TASK-NNN.md` files for this spec's tasks. - -### 7g. Update spec frontmatter - -Add to the spec file's YAML frontmatter: - -```yaml -linear_parent: ENG-198 -linear_parent_url: https://linear.app/<workspace>/issue/ENG-198 -``` - -Use the actual parent issue identifier and URL returned from 7c. - ---- - -## Local-Tasks Mode - -Spec files and task files both live locally. No Linear calls. - -Use `loaf task create --spec SPEC-XXX --title "Task title" --priority P1` -for each task. In SQLite-backed projects, the CLI creates the operational state -row and any compatibility Markdown/index artifacts needed by the current -project. Then edit the `.md` body content (description, acceptance criteria) -only when an authored task prose artifact exists. - -Dependencies are expressed through CLI flags such as `--depends-on`, not by -hand-editing the compatibility index. Priority Order from the spec maps directly -to task `priority` fields. - -See [local-tasks reference](../orchestration/references/local-tasks.md) for -the full local-task model. - ---- - -## Priority Mapping (reference) - -| Loaf | Linear API value | Linear label | -|------|------------------|--------------| -| P0 | `1` | Urgent | -| P1 | `2` | High | -| P2 | `3` | Normal | -| P3 | `4` | Low | - ---- - -## Guardrails - -1. **One concern per task** -- don't mix backend + frontend -2. **Clear verification** -- how to prove it works -3. **Observable done condition** -- not subjective -4. **File hints** -- help session know where to look -5. **Own the decisions** -- decide granularity and priorities, don't defer -6. **Update spec status** -- mark as implementing -7. **One backend only** -- Linear-native creates Linear issues and no local tasks; local-tasks mode creates local tasks and no Linear calls -8. **Summary not copy** -- the Linear parent description summarizes + links; it does not re-host the spec - ---- - -## Suggests Next - -After breakdown completes, suggest implement to start working on the tasks. - -## Related Skills - -- **shape** -- Create specs that get broken down -- **implement** -- Start session for a task or coordinate multiple tasks - -## Topics - -| Topic | Reference | Use When | -|-------|-----------|----------| -| Linear Integration | `orchestration/references/linear.md` | Working out Linear issue structure, labels, parent/child | -| Local Task Model | `orchestration/references/local-tasks.md` | Local-tasks mode details and CLI flags | diff --git a/dist/opencode/commands/council.md b/dist/opencode/commands/council.md index f7342b16b..23374cd98 100644 --- a/dist/opencode/commands/council.md +++ b/dist/opencode/commands/council.md @@ -77,13 +77,13 @@ Councils stay **local**. Even when the workspace uses Linear-native mode, council files live in `.agents/councils/` — they are deliberation artifacts, not executable work, and belong with specs in git. -When a council resolves a spec's open questions: +When a council resolves an issue's open questions: -- Include the spec ID in council frontmatter (e.g., `spec: SPEC-024`). This +- Include the issue ID in council frontmatter (e.g., `issue: LOAF-42`). This is already the common pattern. -- If the spec's `linear_parent` has been populated by breakdown, also - include `linear_parent: ENG-198` in council frontmatter so a reader on - Linear can trace back to the deliberation. +- If the issue is tracked in Linear (tracker authority), also include the + tracker key (e.g., `linear_parent: ENG-198`) in council frontmatter so a + reader on Linear can trace back to the deliberation. - Do not post council content to the Linear parent issue. A brief one-line reference ("Resolved via council 2026-04-21 — see .agents/councils/…") in a sub-issue comment is sufficient if the council drove a specific task diff --git a/dist/opencode/commands/housekeeping.md b/dist/opencode/commands/housekeeping.md index e40f2ae0a..a05e64bcd 100644 --- a/dist/opencode/commands/housekeeping.md +++ b/dist/opencode/commands/housekeeping.md @@ -1,11 +1,11 @@ --- description: >- - Reviews and maintains agent artifacts in .agents/ — specs, plans, drafts, - handoffs, councils, and reports. Use when the user asks "housekeeping," "clean - up," or "tidy up .agents/." Provides hygiene recommendations, archives - completed work, and ensures extracted knowledge is preserved. Not for - strategic reflection (use reflect) or knowledge management (use - knowledge-base). + Reviews and maintains agent artifacts in .agents/ plus issue hygiene — + reports, handoffs, councils, archived issues, and stale started worktrees. Use + when the user asks "housekeeping," "clean up," or "tidy up .agents/." Provides + hygiene recommendations, archives completed work, and ensures extracted + knowledge is preserved. Not for strategic reflection (use reflect) or + knowledge management (use knowledge-base). subtask: false version: 0.2.21 --- @@ -17,40 +17,43 @@ version: 0.2.21 - Verification - Quick Reference - Mode-Aware Checks -- Process -- Guardrails -- Related Skills +- Suggests Next +- Topics +- Artifact Naming -Systematic review and archival of all `.agents/` artifacts with Linear-aware checks. +Systematic review of `.agents/` artifacts and issue workspaces. ## Critical Rules **Always** - Log invocation as the first action: `loaf journal log "skill(housekeeping): <scope or trigger>"` - Review EVERY file individually — never sample or average -- Check Linear issue status before archiving linked specs +- Check Loaf issue status (and Linear overlay, if enabled) before archiving linked artifacts - Extract lessons learned and decisions before archiving -- Use CLI (`loaf housekeeping`, `loaf task archive`, `loaf spec archive`) — never raw `mv` +- Use CLI (`loaf housekeeping`, `loaf report archive`, `loaf issue status` / `loaf issue stop`) — never raw `mv` - Treat `.agents/handoffs/` as first-class but disposable: keep active/final handoffs, delete only after confirmed deprecated status -- Check report `status` is `processed` before archiving reports (see [templates/report.md](templates/report.md)) -- In SQLite-backed projects, verify lifecycle changes through `loaf task list --json`, `loaf spec list --json`, and `loaf report list --json`; use `loaf task sync` only for Markdown compatibility repair +- Check report `status` is `done` (or `final`) before archiving reports (see [templates/report.md](templates/report.md)) +- In SQLite-backed projects, verify lifecycle through `loaf issue list --json`, `loaf issue list --started`, `loaf issue list --archived`, and `loaf report list --json` - When delegated subagents are available, use the `librarian` profile for - `.agents/`-scoped durable artifact tending: report/spec/handoff hygiene, + `.agents/`-scoped durable artifact tending: report/handoff hygiene, staleness notes, and lifecycle-safe cleanup recommendations. Housekeeping still owns user confirmation and final archive decisions. -- Log outcome to the project journal: `loaf journal log "decision(housekeeping): archived N specs, M reports"` +- Log outcome to the project journal: `loaf journal log "decision(housekeeping): archived N reports; stopped M stale worktrees"` **Never** - Auto-archive without user confirmation for each artifact - Skip spark extraction before deleting brainstorm drafts - Leave `archived_at` or `archived_by` fields empty in archived files +- Run `loaf issue stop` from inside the started worktree +- Dispatch cleanup agents into a live started worktree another agent occupies ## Verification After work completes, verify: -- Tasks archived via `loaf task archive` -- Specs archived via `loaf spec archive` -- SQLite-backed task/spec/report state reflects lifecycle changes when initialized +- Reports archived via `loaf report archive` after processing +- Archived issues reviewed via `loaf issue list --archived` (`cancelled` / `duplicate` archive through `loaf issue status`) +- Stale started worktrees reviewed via `loaf issue list --started` (a `(missing)` marker means the recorded path is gone) +- SQLite-backed report/issue state reflects lifecycle changes when initialized - Drafts checked for unprocessed sparks before deletion - Handoffs deleted only after explicit deprecation is confirmed - Summary table presented showing all actions taken @@ -62,11 +65,18 @@ After work completes, verify: ```bash loaf housekeeping --dry-run # Preview recommendations loaf housekeeping # Run artifact scanner -loaf task archive TASK-XXX # Archive single task -loaf spec archive SPEC-XXX # Archive single spec -loaf task sync # Compatibility diagnostic in SQLite-backed projects +loaf issue list --started # Started worktrees (alias, title, branch, path) +loaf issue list --archived # cancelled / duplicate rows +loaf issue stop <ref> # Remove worktree; keeps branch; does not change status +loaf issue status <ref> cancelled # Archive an abandoned issue +loaf issue status <ref> duplicate --duplicate-of <surviving> +loaf report archive <report> # Archive a processed report ``` +`loaf housekeeping` still prints leftover `specs` / `tasks` sections when those +SQLite tables have rows — compatibility scan only. Do not create new records +there. The `loaf task` / `loaf spec` CLI is legacy. + The project journal is append-only and never archived — it is not a housekeeping target. It is the canonical record housekeeping reads when extracting decisions before archiving other artifacts. @@ -75,19 +85,12 @@ before archiving other artifacts. | Artifact | Active Location | Archive | Action | |----------|-----------------|---------|--------| -| Tasks (local mode only) | SQLite state | SQLite archived status | `loaf task archive` | -| Specs | SQLite state + `.agents/specs/` authored prose | `archive/` | `loaf spec archive` | +| Issues | SQLite (`loaf issue list`) | `cancelled` / `duplicate` via `loaf issue status` | Confirm, then status; `done` is ship, not housekeeping | +| Started worktrees | `loaf issue list --started` | `loaf issue stop <ref>` | Stop stale or `(missing)` trees after confirmation | | Drafts / brainstorms | SQLite state | SQLite resolved/archived status | User decision (spark extraction first) | | Handoffs | `.agents/handoffs/` | delete | Delete after status is confirmed `deprecated` | | Reports | SQLite state + generated/authored report Markdown | `archive/` | `loaf report archive` after processing | -**Linear-native mode** (when `integrations.linear.enabled` is `true` in -`.agents/loaf.json`): local `TASK-NNN.md` files do not exist for new specs — -Linear issues are the task record. The "Tasks" row above is inert unless the -project has pre-Linear local tasks lingering (see [Mode-Aware Checks](#mode-aware-checks)). -Specs still archive locally — they are the canonical deliberation artifact in -every mode. - ## Cross-Branch Reconciliation If a stale branch reintroduces `.agents/{tasks,ideas,sparks,sessions,brainstorms,drafts}/` @@ -97,35 +100,30 @@ or `.agents/TASKS.json`, keep the deletion from the cutover branch and rerun ## Mode-Aware Checks -When `integrations.linear.enabled` is `true` in `.agents/loaf.json`, apply -these additional checks: +### Started worktrees -### Spec / Linear parent reconciliation +For each row from `loaf issue list --started`: -For each spec file (active and archive) with a `linear_parent:` frontmatter key: +1. If `(missing)`, flag as **stale started workspace** — the row still records a path that is gone. Offer `loaf issue stop <ref>` after confirmation. Stop does not mark the issue `done`. +2. If the path exists but the issue is `done` / `cancelled` / `duplicate`, flag as **worktree outlived the issue** — same offer. +3. If the path exists and status is `active`, leave it unless the user asks to stop. -1. Call `get_issue` with the issue identifier. If it 404s or returns - archived/deleted, flag as **orphaned linear_parent** — the local spec - references a Linear issue that no longer exists. -2. If the spec's local status is `done` (or legacy `complete`) or `archived`, - verify the Linear parent issue is in a `completed`-type state. If not - (e.g., still "In Progress"), flag as **status mismatch** — "Spec marked - complete locally but Linear parent ENG-198 is still 'In Progress'." -3. If the spec's local status is `in_progress` and the Linear parent is - already `completed`, flag the inverse — spec likely needs to be moved to - `done` and archived. +Treat these as **warnings**, not auto-fixes. -Treat all three as **warnings**, not auto-fixes. The user decides resolution. +### Linear overlay -### Pre-Linear local task detection +When `integrations.linear.enabled` is `true` in `.agents/loaf.json`, the tracker +adapter is not shipped. If a report or journal entry names a Linear id next to +a Loaf alias, you may `get_issue` and flag an obvious mismatch (Linear Done vs +Loaf still `active`, or the reverse). Warnings only. Do not drive Loaf status +from Linear. -If Linear is enabled but local task records exist in SQLite, -surface them with context: "Pre-Linear local tasks detected. These aren't -auto-migrated. Either continue using them, run a manual migration, or -archive if superseded by Linear issues." +### Leftover board rows -Do NOT auto-migrate. Migration is user-initiated and out of scope for -housekeeping. +If `loaf housekeeping --dry-run` still reports `tasks` or `specs` cleanup +candidates, surface them: "Legacy board rows are still in SQLite. They are not +the work unit. Archive only if the user confirms they are superseded by Loaf +issues." Do NOT auto-migrate. ## Suggests Next @@ -136,9 +134,9 @@ After housekeeping, suggest reflect if the session produced key decisions or lea | Topic | Reference | Use When | |-------|-----------|----------| | Report Template | [templates/report.md](templates/report.md) | Creating cleanup reports | -| Linear Integration | `orchestration/references/linear.md` | Checking external issue status | +| Linear Integration | `orchestration/references/linear.md` | Checking external tracker overlay | | Journal Continuity | `orchestration/references/journal.md` | Understanding the project journal model | ## Artifact Naming -Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or Change already records that provenance. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. +Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or the issue already records that provenance. Put the source in a front-matter field (`source: LOAF-42`), not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. diff --git a/dist/opencode/commands/idea.md b/dist/opencode/commands/idea.md index 1016b5bd0..d82fbdaaa 100644 --- a/dist/opencode/commands/idea.md +++ b/dist/opencode/commands/idea.md @@ -2,11 +2,12 @@ description: >- Captures ideas into structured nuggets for later evaluation. Use when the user says "I have an idea" or "note this down." Also activate when a specific - actionable concept crystallizes during conversation. Ideas and sparks are - capture primitives routed through triage, which chooses dispositions such as - tracking an Intent or handing to pitch. Not for problem discovery (use pitch), - processing the intake queue (use triage), shaping (use shape), or agent-side - divergent inquiry when direction is undecided (use explore as a technique). + actionable concept crystallizes during conversation. Ideas and sparks stay + capture primitives routed through triage, which files worth-keeping items as + backlog issues or hands them to pitch or shape. Not for problem discovery (use + pitch), processing the intake queue (use triage), shaping a bounded issue (use + shape), or agent-side divergent inquiry when direction is undecided (use + explore as a technique). subtask: false version: 0.2.21 --- @@ -25,7 +26,6 @@ Capture ideas quickly with minimal friction. - Quick Reference - Purpose - Process -- Idea Lifecycle - Guardrails - Related Skills @@ -35,7 +35,7 @@ Capture ideas quickly with minimal friction. - 2-3 questions maximum -- don't turn capture into an interview - Infer metadata automatically -- don't ask for tags, title, or links - One idea per captured row/artifact -- keep them atomic -- No shaping or pitching here -- problem discovery is pitch; bounding is shape +- No shaping, pitching, or filing issues here -- problem discovery is pitch; bounding is shape; dispositions are triage - Capture through `loaf idea capture --title ...` when SQLite state is initialized; log notable context with `loaf journal log` @@ -57,7 +57,7 @@ Capture ideas quickly with minimal friction. ## Purpose -Ideas are raw nuggets — unprocessed, unshaped, but worth remembering. The goal is **speed of capture**, not thoroughness. An idea is retained material, nothing more: tracking it as an Intent, handing it to pitch for problem discovery, handing it to shape when already well-understood, or archiving it are triage dispositions chosen later by the user. +Ideas are raw nuggets — unprocessed, unshaped, but worth remembering. The goal is **speed of capture**, not thoroughness. An idea is retained material, nothing more: filing it as a backlog issue, handing it to pitch for problem discovery, handing it to shape when already well-understood, or archiving it are triage dispositions chosen later by the user. --- @@ -81,7 +81,7 @@ Ideas are raw nuggets — unprocessed, unshaped, but worth remembering. The goal ## Related Skills -- **triage** — process the intake queue and choose dispositions (including hand to pitch or shape) +- **triage** — process the intake queue and choose dispositions (file as backlog issue, hand to pitch, or hand to shape) - **pitch** — problem-discovery ceremony when a captured idea needs a brief before shaping -- **shape** — develop a well-understood direction into a bounded Change +- **shape** — develop a well-understood direction into a bounded issue - **explore** — agent technique when direction is genuinely undecided (not a user front door) diff --git a/dist/opencode/commands/implement.md b/dist/opencode/commands/implement.md index 9823b74ec..291c9f02a 100644 --- a/dist/opencode/commands/implement.md +++ b/dist/opencode/commands/implement.md @@ -1,18 +1,19 @@ --- description: >- - Orchestrates implementation work through agent delegation and batch execution. - Use for all implementation work — features, bug fixes, refactors, and code - changes. Picks Change task files when present and flips checkboxes in - delivering commits. Logs to the project journal and produces agent spawn plans - and progress tracking. Not for shaping (use shape), breakdown (use breakdown), - research, or review. + Orchestrates implementation work through agent delegation and batch execution + against Loaf issues. Use for all implementation work — features, bug fixes, + refactors, and code changes. Picks the next issue from loaf issue frontier, + delegates one agent per started worktree, and treats definition-of-done + criteria as the completion contract. Logs to the project journal and produces + agent spawn plans and progress tracking. Not for shaping or decomposition (use + shape), research, or review. subtask: false version: 0.2.21 --- # Implement -You are the coordinator. Start by understanding the task: +You are the coordinator. Work units are issues. ## Contents - Critical Rules @@ -20,7 +21,7 @@ You are the coordinator. Start by understanding the task: - Quick Reference - Step 0: Context Check - Input Detection -- Linear-Native Routing +- Pick-up and Dispatch - Agent Spawning - Journal First - Guardrails @@ -38,27 +39,32 @@ You are the coordinator. Start by understanding the task: **You are the ORCHESTRATOR, not the implementer.** -- Log `loaf journal log "skill(implement): <task/spec/context>"` as the first action. -- **Change-first task packets:** prefer `docs/changes/<folder>/tasks/TASK-NNN-*.md` as the delegation brief. Flip checkboxes `- [ ]`→`- [x]` in the same commit that delivers the work (outside `docs/changes/` paths must land with the flip for provenance). Use `loaf change tasks --json` for the index. -- Commit task packets unchecked before executing them — a packet that first lands already-checked induces no flip transition, and the evidence trail never exists. -- Compatibility: existing `TASK-XXX` / `SPEC-XXX` SQLite records remain supported until converted; they are not the default for new work. +- Log `loaf journal log "skill(implement): LOAF-42 — <what>"` as the first action. Substitute the real alias (or opaque id) and a short intent. +- **Pick-up-next is `loaf issue frontier`.** That view is open (`triage` / `backlog` / `todo`), unblocked, and unclaimed (not `active`, no started worktree). Derived at read time. +- **The delegation brief is the issue row** — `loaf issue show <ref>` / `loaf issue render <ref>`: body, definition-of-done criteria, children. There is no other packet. +- **One agent, one worktree.** `loaf issue start <ref>` creates the branch and worktree and moves status to `active`. Before dispatch, run `loaf issue list --started`. Never send two agents into the same worktree. +- **Definition of done is the completion contract.** `loaf issue verify <ref>` runs V-tier criteria from the repository root and writes nothing. H-tier is reviewed by a human or this orchestrator. Completion is the work landing plus `loaf issue status <ref> done`. Do not flip checkboxes. Provenance is the delivering commits and the PR whose body is `loaf issue render <ref>`. +- Shape prepares issues. If `loaf issue check <ref>` does not report the delivery issue shaped (or the decision issue ready), stop and send the work to shape. Do not mint a new issue from this skill. ### Orchestrator Can Do Directly - Log journal entries, read journal context, create council files -- Use your harness's task/todo tracking surface; **if `integrations.linear.enabled` is `true` in `.agents/loaf.json`**, use Linear MCP tools when helpful +- Use your harness's todo tracking surface; **if `integrations.linear.enabled` is `true` in `.agents/loaf.json`**, Linear MCP is an overlay only — Loaf issues remain the work unit and Linear never drives Loaf status - Read any file for context - Ask clarifying questions +- Run `loaf issue` read commands, `loaf issue start` / `stop`, `loaf issue status`, and open a PR whose body is `loaf issue render` output ### Orchestrator MUST Delegate (via agent spawn) -**ALL code changes, documentation edits, and implementation work** to specialized agents. **No exceptions**, even for "trivial" 1-line fixes. +**ALL code changes, documentation edits, and implementation work** to specialized agents. **No exceptions**, even for "trivial" 1-line fixes. Spawn each agent into that issue's started worktree. ## Verification - The invocation is logged to the project journal before implementation work begins — no session start step, no "active session" precondition - All code changes delegated via your harness's agent-spawn mechanism -- no direct edits by orchestrator - The journal is continuously updated with spawns, progress, and decisions as work happens -- Spec artifacts closed out on branch before PR creation -- **Linear-native mode:** `blockedBy` of the target sub-issue is fully `completed` before work begins; starting a sub-issue also promotes an unstarted parent rollup to active; parent rollup is auto-closed only when all sub-issues are `completed` +- Each in-flight issue has exactly one started worktree; `loaf issue list --started` was checked before every spawn +- V-tier criteria pass `loaf issue verify <ref>` (writes nothing); H-tier criteria were reviewed by a human or this orchestrator +- The PR body is `loaf issue render <ref>` with no manual editing; checkboxes stay unchecked until status is `done` +- Completion is landing plus `loaf issue status <ref> done` (usually via ship) ## Quick Reference @@ -73,6 +79,15 @@ You are the coordinator. Start by understanding the task: | Code review/audit | reviewer | relevant domain skills | | Research/comparison | researcher | relevant domain skills | +| Moment | Command | +|--------|---------| +| Pick next | `loaf issue frontier` | +| Brief | `loaf issue show <ref>` / `loaf issue render <ref>` | +| Claim workspace | `loaf issue start <ref>` | +| Occupied trees | `loaf issue list --started` | +| V-tier gate | `loaf issue verify <ref>` | +| Landed | `loaf issue status <ref> done` | + --- ## Step 0: Context Check @@ -83,152 +98,51 @@ Before starting, evaluate context suitability. |---------|--------| | New command/skill added this conversation | **Restart required** (skills loaded at start) | | Conversation > 30 exchanges | Suggest restart | -| Just completed a different task/spec | Suggest clear | +| Just completed a different issue | Suggest clear | | About to start multi-file implementation | Check depth | If restart needed: log current state with `loaf journal log`, then ask the user to restart. A supported startup adapter may reconstruct continuity from the journal in the next conversation; when the exact current target mode is candidate or unsupported, explicitly run `loaf journal context` after restarting. ## Input Detection -Parse `$ARGUMENTS` to determine the work type: +Parse `$ARGUMENTS` to determine the work: | Input Pattern | Type | Action | |---------------|------|--------| -| `TASK-XXX` | Local task | Load via `loaf task show`, log the task coupling | -| `SPEC-XXX` | Spec orchestration | If spec frontmatter has `linear_parent`, resolve to that Linear parent and follow Linear-Native Routing. Otherwise resolve local tasks and build dependency-ready rounds | -| `TASK-XXX..YYY` | Task range | Expand range, build dependency-ready rounds | -| `TASK-XXX,YYY,ZZZ` | Task list | Parse list, build dependency-ready rounds | -| `PLT-123`, `ENG-198`, `PROJ-123` | Linear issue | **If `integrations.linear.enabled` is `true`:** fetch via `get_issue`, then branch on parent vs sub-issue — see [Linear-Native Routing](#linear-native-routing). **Otherwise:** treat as label text or create local task | -| Description text | Ad-hoc | Auto-create local task from description, then fall through to task-coupled flow | - -### Task-Coupled Work - -When starting from `TASK-XXX`: - -1. Load task metadata via `loaf task show TASK-XXX --json`; do not recreate `.agents/TASKS.json` after the SQLite cutover -2. Log the task coupling: `loaf journal log "decision(implement): implementing TASK-XXX"` -3. Load parent spec if task has `spec:` field - -### Ad-hoc Task Auto-Creation - -When input is free-text description (not matching any known pattern): - -1. **Parse the description:** - - Single sentence → use entire text as task title - - Multi-sentence → first sentence = title, remainder = acceptance criteria - - Split on `. ` followed by uppercase letter only (conservative — avoids false positives from URLs, abbreviations) -2. **Create the task:** `loaf task create --title "<parsed title>"` -3. **Write criteria** (if multi-sentence): edit the task `.md` file body to add the remaining sentences as acceptance criteria -4. **Fall through** to the task-coupled flow above — the result is a `TASK-XXX` ID that enters the existing planning pipeline unchanged - -**No user interaction required.** The description IS the task; invoking implement already expressed intent. +| `LOAF-42` or opaque id | Single issue | Load via `loaf issue show <ref>`; fall through to Pick-up and Dispatch | +| Parent ref with children | Tree | `loaf issue tree <ref>`; build rounds from children and `blocks` / `blocked_by` edges (see [batch-orchestration.md](references/batch-orchestration.md)) | +| Multiple refs | Batch | Same round construction across the named set | +| Empty / "next" | Frontier | `loaf issue frontier`; if one row, pick it; if several, ask (structured question tool if the harness has one); if none, stop | +| Description text | Ad-hoc | Match frontier by title. Do not mint. If nothing matches, stop and send to shape | +| Decision kind | Question | Not implementation. Surface the question; do not `loaf issue start` unless the user points at a delivery issue that records the decided answer | -### Non-Existent Task ID Error +### Missing ref -If input matches `TASK-XXX` pattern but `loaf task show` cannot resolve it: +If input looks like an issue ref but `loaf issue show` cannot resolve it: -1. Show error: `"TASK-XXX not found in local task state"` -2. Ask the user: `"Did you mean to create a new task? You can re-run with the description as free text."` -3. **Do not silently create** — the user likely has a typo +1. Show error: `"<ref> not found"` +2. Ask whether they meant a different alias, or to shape a new issue +3. **Do not silently create** --- -## Linear-Native Routing - -Applies when `integrations.linear.enabled` is `true` AND `$ARGUMENTS` -resolves to a Linear issue (direct Linear ID, or a `SPEC-XXX` whose -frontmatter has `linear_parent`). - -Fetch the issue once via `get_issue` and branch on its shape: - -### Parent rollup issue (has `spec` label) - -The issue represents a spec. Do **not** implement it directly — spec-level -"work" is always done via sub-issues. - -1. List sub-issues via `list_issues` with `parent: <parent-id>`. -2. Classify each by state: - - `in_progress` — active work - - `unstarted` + no open `blockedBy` — ready to start - - `unstarted` + open `blockedBy` — blocked - - `completed` — done, skip -3. Select the next work item: - - If one or more sub-issues are `in_progress`, pick the **lowest-ID** - in-progress sub-issue. Resume that. - - Else, if one unblocked `unstarted` sub-issue exists, pick it. - - Else, if multiple unblocked `unstarted` sub-issues exist, use - your harness's structured question tool (if it has one) to let the user choose: pick one, or delegate N in - parallel via parallel agents. List each sub-issue's title + ID. - - Else (all remaining sub-issues are blocked), refuse with a summary: - "All remaining sub-issues under <parent-id> are blocked. Blockers: - <list>." -4. Once a sub-issue is selected, recurse into the sub-issue flow below - with that ID. The parent itself is never the implementation target. - -### Sub-issue (has `parentId`, no `spec` label) - -The issue is an actual task. Implement it directly — with a pre-flight gate. - -1. **Pre-flight: verify `blockedBy` is clear.** For each issue in the - sub-issue's `blockedBy` field, call `get_issue` and confirm its state is - `completed`-type. If any blocker is not Done: - - **Refuse to start.** Do not begin work. Do not move the issue. - - Show the blockers: `"Cannot start <sub-issue-id>. Blocked by: <list - with IDs, titles, and current states>."` - - Suggest: `"Complete the blocker(s) first, or ask to override if the - blockedBy link is stale."` -2. If blockers are clear: - - Start the sub-issue as one logical Linear operation. This moves - the sub-issue to the team's `started`/In Progress state and, when the - parent rollup is still `backlog` or `unstarted`, promotes the parent to - the same `started`/In Progress state. - - If the parent is already active, leave it unchanged. If the parent is - `completed`, `canceled`, or archived, refuse to start unless the user - explicitly asks to override the protected parent state. - - If the child update succeeds but parent promotion fails, report a - reconciliation error naming the parent issue before continuing. - - Resolve branch name from the sub-issue's `branchName` field (Linear - auto-generates one) — see - [branch-and-completion.md](references/branch-and-completion.md). - - Log the task coupling, then continue with the standard Startup Checklist. - -### Completion (after implementer + reviewer finish cleanly) - -When the sub-issue's implementation passes review and tests: - -1. Move the sub-issue to the team's `completed`-type state via - `update_issue` (look up via `list_issue_statuses`, filter - `type: "completed"`). -2. Query the parent's sub-issues again: - - If **all** sub-issues are now `completed`-type, move the parent - rollup to `completed` as well. Also mark the local spec as - `complete` (see [Then Execute → AFTER](#then-execute)). - - If **some** remain, list them as "next available" for the user, - applying the same classification as step 2 of the parent flow above. - Offer to continue with the next one in this session, or stop here. -3. **Do not** close the parent while any sub-issue is open — not even if - only `blocked` ones remain. Blocked sub-issues are still in-flight - work from the spec's perspective. - -### Status flow summary - -| Moment | Sub-issue state | Parent state | -|--------|----------------|--------------| -| Implementation starts | `started` / In Progress | promoted to `started` / In Progress if still `backlog` or `unstarted` | -| Implementation + review pass | `completed` | check: close only if all sibs completed | -| Blocker discovered mid-work | `in_progress` + blocker comment | unchanged | - -### What Linear-native routing does NOT do - -- Does not pull down the full spec text. The parent's description already - links to `.agents/specs/SPEC-NNN-*.md`. Read the local file for shape, - rabbit holes, and strategic tensions. -- Does not create or rewrite sub-issues. That's breakdown's job. If - implementation reveals a missing task, surface it to the user; they - decide whether to run breakdown again or add an ad-hoc sub-issue. -- Does not sync in-progress state bidirectionally. Source of truth at any - moment: Linear for issue state, local files for spec content, the project - journal for current handoff. +## Pick-up and Dispatch + +1. **Confirm the issue is implementable.** `loaf issue check <ref>` must report a delivery issue shaped (or, if the user explicitly asked to resolve a decision issue, that it is ready). Unshaped work goes to shape. +2. **Honor the frontier.** An issue that is blocked does not appear on `loaf issue frontier`. `loaf issue link A blocks B` means A blocks B; B waits until A is `done`, `cancelled`, or `duplicate`. Do not start a blocked successor. Parent/child structure from `loaf issue tree` is not a sequencing edge — only `blocks` / `blocked_by` are. Use the tree to know who belongs in the batch; use the edges to order rounds. +3. **Parents with children are not the implementation target.** Dispatch leaf delivery children that are on the frontier. A parent executes through claimed child criteria, not by starting the parent worktree. +4. **Inspect occupied worktrees:** + ```bash + loaf issue list --started + ``` + Columns: alias, title, `started_branch`, `started_worktree`, optional `(missing)`. If this ref is already started, resume in that worktree with one agent. If the path is occupied by another issue, refuse. A `(missing)` marker means the recorded path is gone — `loaf issue stop <ref>` (not from inside the tree) before starting again. +5. **Start the workspace** (skip if already started and the path exists): + ```bash + loaf issue start <ref> + ``` + Creates branch `issue/<alias-or-id>` in lowercase (`issue/loaf-42`, id suffix when that name is claimed), a sibling worktree, and sets status to `active`. Base is the nearest started ancestor's branch, else the repository default branch. Start refuses archived rows and terminal statuses (`done`, `cancelled`, `duplicate`). +6. **Hand the agent the brief** from `loaf issue show <ref>` (body, criteria, children) and, when opening a PR, `loaf issue render <ref>`. Tell the agent to work only in `started_worktree`. +7. **Batch rounds.** When input is a parent or a set of refs, group unblocked delivery children into dependency-ready rounds from `blocked_by` edges and parent/child structure. Parallel only within a round, max 3, and only when each agent has its own worktree. See [batch-orchestration.md](references/batch-orchestration.md) for the round loop, `--dry-run` / `--parallel` / `--continue` / `--skip <ref>` / `--abort`, and blocked-state recovery. --- @@ -247,7 +161,7 @@ Spawn specialized agents with the appropriate profile: | Code review/audit | reviewer | relevant domain skills | | Research/comparison | researcher | relevant domain skills | -**Rules:** Be specific in prompts. One concern per agent. Include context. Parallel when independent, sequential when dependent. +**Rules:** Be specific in prompts. One concern per agent. Include the issue ref, `started_worktree`, body, and definition of done. Parallel when independent (separate worktrees), sequential when a `blocks` edge says so. --- @@ -256,15 +170,14 @@ Spawn specialized agents with the appropriate profile: There is no session to start — journaling is continuous. Your first action is to log the invocation: ```bash -loaf journal log "skill(implement): <task/spec/context>" +loaf journal log "skill(implement): LOAF-42 — <what>" ``` Entries are project-scoped and tagged with this conversation's harness id automatically. Continuity from prior conversations may arrive through a supported startup adapter; when the exact current target mode is candidate or unsupported, pull it explicitly with `loaf journal context`. Use `loaf journal recent` when you need a narrower timeline. -Suggest renaming the harness conversation with a meaningful name derived from context (use your harness's rename surface if it has one): -- From spec: `SPEC-027-session-stability` -- From task: `TASK-042-login-fix` -- From ad-hoc: `{short-slug-from-description}` +Suggest renaming the harness conversation with a meaningful name derived from the issue (use your harness's rename surface if it has one): +- From issue: `LOAF-42-login-fix` +- From ad-hoc match: `{alias}-{short-slug}` --- @@ -278,12 +191,14 @@ Suggest renaming the harness conversation with a meaningful name derived from co 6. **Journal continuously** -- log spawns, progress, blockers, and decisions with `loaf journal log` as they happen 7. **Clean up** -- no ephemeral files; write an optional `wrap` entry only when there's synthesis worth saving 8. **When in doubt, ask the user** +9. **Never `loaf issue stop` from inside the started worktree** -- stop does not change status; `--force` removes a dirty tree +10. **Do not tick definition-of-done boxes** -- `loaf issue verify` writes nothing; render checks a box only when status is already `done` ## Decision Tree ``` Is this a code/config/doc change? -+-- YES -> Spawn appropriate agent ++-- YES -> Spawn appropriate agent into the issue worktree +-- NO -> Is this a planning/coordination decision? +-- YES with clear path -> Proceed, log the decision +-- YES but ambiguous -> Ask user @@ -296,18 +211,16 @@ When multiple valid approaches exist: spawn council (5-7 agents, odd), present r ## Startup Checklist -1. [ ] Log the invocation: `loaf journal log "skill(implement): <context>"` -2. [ ] Parse input (task, Linear ID, or description) -3. [ ] If TASK-XXX: load task via `loaf task show TASK-XXX`, log task coupling, load parent spec -4. [ ] If Linear ID (or `SPEC-XXX` with `linear_parent`): follow [Linear-Native Routing](#linear-native-routing). Parent → walk sub-issues and select next. Sub-issue → verify `blockedBy` is clear, then start it as one logical Linear operation so the parent is promoted when needed -5. [ ] If description: auto-create task (see Ad-hoc Task Auto-Creation above) -6. [ ] Create dedicated branch (see [branch-and-completion.md](references/branch-and-completion.md)) -7. [ ] Suggest team based on task context -8. [ ] Log initial context and references with `loaf journal log` -9. [ ] Break down work using your harness's task/todo tracking surface -10. [ ] Identify needed specialized agents -11. [ ] Log next steps before spawning -12. [ ] **Get user approval** before spawning +1. [ ] Log the invocation: `loaf journal log "skill(implement): LOAF-42 — <what>"` +2. [ ] Parse input (issue ref, parent, set, frontier, or description) +3. [ ] Load `loaf issue show <ref>`; if children, `loaf issue tree <ref>` +4. [ ] `loaf issue check <ref>` — shaped/ready, or stop and send to shape +5. [ ] Confirm the ref is on `loaf issue frontier` (or already started for resume) +6. [ ] `loaf issue list --started` — one agent per worktree +7. [ ] `loaf issue start <ref>` unless already started +8. [ ] Suggest conversation rename (`LOAF-42-login-fix`) +9. [ ] Identify specialized agents; log next steps +10. [ ] **Get user approval** before spawning --- @@ -315,32 +228,28 @@ When multiple valid approaches exist: spawn council (5-7 agents, odd), present r ### BEFORE (Planning) 1. Log the invocation with `loaf journal log` -2. Set task status: `loaf task update TASK-XXX --status in_progress` -3. Break down work into agent-sized tasks -4. Identify spawn order (respect dependencies) +2. `loaf issue start <ref>` (status becomes `active` through start) +3. Slice work into agent-sized units that still belong to this one issue +4. Identify spawn order (respect `blocked_by` edges and parent/child rounds) 5. Get user approval ### DURING (Execution) -1. Spawn specialized agents via your harness's agent-spawn mechanism -2. Log each spawn with `loaf journal log "todo(agent): spawned <agent> for <task>"` -3. Update Linear with progress (no emoji, no file paths) -4. Keep journal entries handoff-ready -5. After each agent completes: log outcome, spawn next +1. Spawn specialized agents into `started_worktree` via your harness's agent-spawn mechanism +2. Log each spawn with `loaf journal log "todo(agent): spawned <agent> for <ref>"` +3. Keep journal entries handoff-ready +4. After each agent completes: log outcome, spawn next +5. If Linear overlay is enabled, you may comment there — Loaf status stays on `loaf issue` ### AFTER (Completion) 1. Code review pass (spawn `reviewer` agent) 2. Spawn implementer (with foundations + language skill) for final testing -3. **Close out spec artifacts on the branch** (included in the squash merge): - - **Local-tasks mode:** `loaf task update TASK-XXX --status done` (per task), then `loaf task archive --spec SPEC-XXX` - - **Linear-native mode:** `update_issue` the sub-issue to `completed`-type state. Then query the parent's sub-issues; if all are `completed`, also close the parent. If some remain, list them for the user (see [Linear-Native Routing → Completion](#completion-after-implementer--reviewer-finish-cleanly)) - - Mark spec complete and archive: `loaf spec archive SPEC-XXX` (both modes) - - Write a `wrap(scope)` journal entry if the work produced synthesis worth saving (next steps, abandoned paths); otherwise skip it - - Commit: `chore: close SPEC-XXX — archive tasks and spec` -4. If on a feature branch: push and create PR (`gh pr create`). Follow PR format and squash merge conventions in [commits reference](../git-workflow/references/commits.md). -5. After PR is created and approved, use ship to review, verify, and land the PR. Use release later when a coherent batch of landed work is ready to publish. -6. **Suggest reflection:** Check the journal for extractable learnings before closing out: +3. Run `loaf issue verify <ref>` (V-tier, writes nothing). Review every H-tier row yourself or with the user — a skip from verify is not a pass +4. Open or update the PR with body `loaf issue render <ref>` — no manual editing. Follow PR format and squash merge conventions in [commits reference](../git-workflow/references/commits.md) +5. After the PR is created, use ship to review, verify, land, mark `loaf issue status <ref> done`, and `loaf issue stop <ref>`. Use release later when a coherent batch of landed work is ready to publish +6. Write a `wrap(scope)` journal entry if the work produced synthesis worth saving; otherwise skip it +7. **Suggest reflection:** Check the journal for extractable learnings before closing out: - `decision(...)` entries are present - - ADRs, report verdicts, or spec changelog entries were recorded + - ADRs or report verdicts were recorded If any signal is present, suggest: *"This produced key decisions. Consider running reflect to update strategic docs."* If none are present, stay silent. --- @@ -349,18 +258,18 @@ When multiple valid approaches exist: spawn council (5-7 agents, odd), present r | Topic | Reference | Use When | |-------|-----------|----------| -| Batch Orchestration | [batch-orchestration.md](references/batch-orchestration.md) | Running specs, task ranges, or task lists with dependency-ready rounds | -| Branch and Completion | [branch-and-completion.md](references/branch-and-completion.md) | Branch management, team routing, diagrams, Linear sync, journaling, task completion | +| Batch Orchestration | [batch-orchestration.md](references/batch-orchestration.md) | Running a parent or a set of issue refs with dependency-ready rounds | +| Branch and Completion | [branch-and-completion.md](references/branch-and-completion.md) | Team routing, diagrams, exploration, journaling alongside `loaf issue start` / `stop` | +| Working issues locally | [../orchestration/references/local-tasks.md](../orchestration/references/local-tasks.md) | Frontier, started worktrees, status vocabulary, definition of done | --- ## Suggests Next -After all tasks are complete, suggest ship to land the PR. Suggest release only when the landed work forms a coherent release batch. +After the PR exists, suggest ship to land it. Suggest release only when the landed work forms a coherent release batch. ## Related Skills -- **shape** - Spec format and lifecycle -- **breakdown** - Turning specs into tasks -- **orchestration/local-tasks** - Task file format and lifecycle -- **orchestration/journal** - Project journal continuity model +- **shape** — Issue preparation and decomposition +- **orchestration/journal** — Project journal continuity model +- **orchestration/local-tasks** — Frontier, started worktrees, status, definition of done diff --git a/dist/opencode/commands/pitch.md b/dist/opencode/commands/pitch.md index 88be85de1..6cb023112 100644 --- a/dist/opencode/commands/pitch.md +++ b/dist/opencode/commands/pitch.md @@ -1,20 +1,20 @@ --- description: >- - Runs the human problem-discovery ceremony at change or project scale: grills - problem, who has it, current alternatives, value proposition, and constraints, - then authors a brief (change brief.md via loaf change init --brief, or project - docs/BRIEF.md with source: pitch). Use when the user invokes pitch, starts - work on a raw concept, or triage dispositions an item as pitch. Produces an - authored problem-space brief and a shape-now or park offer — never shape.md, - tasks, or PRs. Not for solution shaping (use shape), queue processing (use - triage), quick capture (use idea), or open-ended divergent inquiry (use - explore as an agent technique when pitch reveals the direction is undecided). + Runs the human problem-discovery ceremony: grills problem, who has it, current + alternatives, value proposition, and constraints, then hands a sharpened + problem narrative to shape or authors project docs/BRIEF.md. Use when the user + invokes pitch, starts work on a raw concept, or triage dispositions a spark or + idea as pitch. Produces a problem-space narrative and a shape-now or park + offer — never a bounded issue, criteria, or PRs. Not for quick capture (use + idea), solution bounding (use shape), queue processing (use triage), or + open-ended divergent inquiry (use explore as an agent technique when pitch + reveals the direction is undecided). version: 0.2.21 --- # Pitch -Human problem-discovery ceremony. Authors a brief at the matching scale so shape starts from a framed problem and bootstrap can consume a pitched project BRIEF. +Human problem-discovery ceremony. Narrows sparks and ideas into a framed problem so shape can mint an issue, and bootstrap can consume a pitched project BRIEF. ## Contents - Critical Rules @@ -31,61 +31,90 @@ Human problem-discovery ceremony. Authors a brief at the matching scale so shape ## Critical Rules 1. **Agents never initiate a pitch.** This ceremony is human-invoked only. On Claude Code the sidecar sets `disable-model-invocation: true`; on every target this rule binds behaviorally. Agent legwork *inside* a human-opened pitch (competitive scans, file writes the skill directs) is fine — opening one is not. -2. **Log invocation first** — `loaf journal log "skill(pitch): <idea, problem, or intake item>"` before interviewing. -3. **Problem-space only** — grill what, who, and why-valuable. Approach, architecture, decomposition, and verification design belong to shape. A brief that reads like a pseudo-shape is a failure; rewrite before landing. -4. **One question at a time, recommendation-first** — using your harness's structured question tool if it has one (otherwise one inline question per message). Never a multi-field form. Order by impact on the brief. Full mechanics: [references/interview-guide.md](references/interview-guide.md). -5. **Never write `shape.md`, seed `tasks/`, push, or open PRs** — pitch prepares commits and hands off; push and PR stay human. Never auto-run shape or bootstrap. -6. **Landing is validated, then committed once** — every capture landing runs explicit-path `loaf change check <folder> --json` (zero violations, expected captured state) and a direct read-back of that folder's `change.json` confirming intended `target_release` presence or absence, then one docs-only commit per capture. Never batch captures into one commit. -7. **Slug identity is local** — propose a slug that names the concept, never another work unit (no `spec-042`, no task ids). Provenance lives in frontmatter and the change folder. -8. **Log the outcome** — `loaf journal log "decision(pitch): <slug or project> — <shape-now|park-targeted|park-untargeted|handed-to-bootstrap>"`. +2. **Log invocation first** — `loaf journal log "skill(pitch): <idea, problem, spark, or intake item>"` before interviewing. +3. **Problem-space only** — grill what, who, and why-valuable. Approach, architecture, decomposition, and verification design belong to shape. A narrative that reads like a pseudo-shape is a failure; rewrite before landing. +4. **One question at a time, recommendation-first** — using your harness's structured question tool if it has one (otherwise one inline question per message). Never a multi-field form. Order by impact on the narrative. Full mechanics: [references/interview-guide.md](references/interview-guide.md). +5. **Never bound, never ship** — do not add definition-of-done criteria, do not write an out-of-scope statement, do not run `loaf issue check` or `loaf issue promote`, do not push, do not open PRs. Never auto-run shape or bootstrap. +6. **Shape mints on the happy path** — same-session shape-now hands the authored narrative; shape runs `loaf issue new` with that body. Pitch writes an issue body only when parking an unshaped row or when `$ARGUMENTS` already names an issue (`loaf issue edit` replaces the body). +7. **Titles name the concept** — propose a working title, never another work unit's alias. Provenance lives in the issue row, the spark/idea resolution, and frontmatter on `docs/BRIEF.md`. +8. **Log the outcome** — `loaf journal log "decision(pitch): <title, ref, or project> — <shape-now|park-issue|park-idea|handed-to-bootstrap>"`. --- ## Verification -- Change scale: `docs/changes/YYYYMMDD-slug/` holds `change.json` + authored `brief.md`; `loaf change check <folder> --json` reports zero violations and captured state; `change.json` read-back matches the intended target binding -- Project scale: `docs/BRIEF.md` exists with `source: pitch` and the shared problem-space skeleton -- Cold-read: problem, who, alternative, and value nameable in one pass; zero solution-space content -- No `shape.md` or `tasks/` written by this skill; no push; no PR +- Issue-scale: a problem narrative exists against the shared skeleton; it was handed to shape, written into an existing issue body, or minted as an unshaped triage row with that body and no criteria +- Project-scale: `docs/BRIEF.md` exists with `source: pitch` and the shared problem-space skeleton +- Cold-read: problem, who, alternative, and value nameable in one pass; zero solution-space content; no out-of-scope statement and no criteria added by this skill +- Named sparks were promoted to an idea when pitching them; ideas and sparks were resolved against the issue only after a row exists +- No push; no PR; shape and bootstrap were not auto-run - Journal shows skill invocation and outcome entries --- ## Quick Reference +| Harness | Invoke skill | +|---------|----------------| +| Claude Code (plugin) | `/loaf:pitch` | +| OpenCode, Cursor, Codex, Amp | `/pitch` | + ### Scale detection | Signal | Scale | Output | |--------|-------|--------| -| Existing project (git history, source, or Loaf state) + a concept | **Change** | `loaf change init <slug> --brief` → authored `brief.md` | -| Empty or minimal directory / greenfield intent | **Project** | `docs/BRIEF.md` with `source: pitch` → hand to bootstrap | +| Existing project (git history, source, or Loaf state) + a concept | **Issue** | Problem narrative → shape (`loaf issue new --body`) or an unshaped triage row | +| Empty or minimal directory / greenfield product pitch | **Project** | `docs/BRIEF.md` with `source: pitch` → hand to bootstrap | Detect and confirm briefly; let the human correct. When both could apply (repo exists but they want a new product pitch), ask once. -### Landing matrix (Decision 11) +### Landing offers -| Intent | Branch | Commit | Target | -|--------|--------|--------|--------| -| **Shape now** | Create the slug branch (`git switch -c <slug>`), stay there | Hand to shape for in-place promotion — do not park-commit first | Stamp `target_release` when known | -| **Park targeted** | Default branch | One docs-only commit on default (promise-carrier exception) | `target_release` present and confirmed by read-back | -| **Park untargeted** | Slug branch **or** remain intake (Intent/spark) | Docs-only commit on the slug branch if becoming a Change; else no Change folder | No `target_release`; untargeted captures never land on main | +| Offer | When to recommend | What pitch does | +|-------|-------------------|-----------------| +| **Shape now** | Framing is solid; they want to bound next | Hand the narrative; do not mint; do not auto-run shape | +| **Park as issue** | Framed, durable, not bounding yet | `loaf issue new "<title>" --body -` with the narrative only; status stays `triage` | +| **Park as idea** | Too thin to keep as a row, or might discard | `loaf idea capture --title "..."`; journal the gist | +| **Hand to bootstrap** | Project-scale BRIEF authored | Point at bootstrap; do not auto-run it | -Pitch prepares the commit; never pushes; never opens PRs. +Pitch never pushes; never opens PRs. There is nothing to commit at issue scale — the row lives in SQLite. Project-scale may commit `docs/BRIEF.md` if the human wants it durable. -### Pre-landing guard (every capture) +### Spark and idea promotion -```bash -loaf change check <folder> --json # zero violations; state is captured -# then read <folder>/change.json and confirm target_release presence/absence matches intent +| Input | Read | Then | +|-------|------|------| +| Spark | `loaf spark show <ref>` | `loaf idea capture --title "..."` then `loaf spark promote <spark> --to-idea <idea>`; grill from the idea | +| Idea | `loaf idea show <ref>` | Grill; after a row exists, `loaf idea resolve <idea> --by <ref>` | +| Existing issue | `loaf issue show <ref>` | Grill; `loaf issue edit <ref> --body -` writes the narrative (replaces the whole body) | +| Free text | — | Grill; shape-now hands text; park captures an idea or mints an unshaped row | + +Do not invent a pitch from the queue without human selection. When they name an intake item, read it (`loaf intake list` / the item's read command). + +`loaf idea promote --to-spec` is not this path. Resolve ideas against the minted issue. + +### Problem-narrative skeleton + +Author against these sections, problem-space sentences only. This text is what shape puts in `--body` (or what a park-as-issue row stores): + +```markdown +## Problem Statement +## Who Has It +## Current Alternatives +## Value Proposition +## Constraints +## Sequencing and Relationships +## Sources and Research Links +## Open Questions ``` -Bare `loaf change check` resolves by branch and can miss a capture landing elsewhere — always pass the explicit folder path. +Do not add an out-of-scope statement. Shape bounds; pitch frames. ### Defined terms -- **Brief** — the pitch output (problem-space). Superseded by `shape.md` when shaping starts; may accrete parked problem-space sentences until then; freezes when `shape.md` exists. -- **Accretion** — adding problem-space concepts to a parked brief is legal; solution prose is not. -- **Shape now** — slug branch + hand to shape, which promotes the capture in place via ordinary `loaf change init <slug>` (no `--brief`). +- **Problem narrative** — pitch's issue-scale output. Superseded as the working surface once shape mints and bounds the issue; may accrete parked problem-space sentences until then. +- **BRIEF** — project-scale `docs/BRIEF.md`. A project document, not a work container. +- **Accretion** — adding problem-space concepts to a parked narrative is legal; solution prose is not. +- **Shape now** — hand the narrative to shape, which mints via `loaf issue new` and owns bounding. --- @@ -94,81 +123,82 @@ Bare `loaf change check` resolves by branch and can miss a capture landing elsew ### Step 1: Log and parse input ```bash -loaf journal log "skill(pitch): <idea, problem, or intake item>" +loaf journal log "skill(pitch): <idea, problem, spark, or intake item>" ``` -Parse `$ARGUMENTS`: free text, an intake ref the human already chose, or empty (ask what to pitch). Read the named intake item when provided (`loaf intake list` / the item's read command). Do not invent a pitch from the queue without human selection. +Parse `$ARGUMENTS`: free text, a spark, an idea, an issue ref, an intake ref the human already chose, or empty (ask what to pitch). Read the named item when provided. Do not invent a pitch from the queue without human selection. ### Step 2: Detect scale -Apply the Quick Reference table. Confirm: "I'll treat this as a **change-scale** pitch on this repo" or "…as a **project-scale** pitch for a new BRIEF." Adjust if corrected. +Apply the Quick Reference table. Confirm: "I'll treat this as an **issue-scale** pitch on this repo" or "…as a **project-scale** pitch for a new BRIEF." Adjust if corrected. -### Step 3: Problem-discovery interview +### Step 3: Promote sparks; read ideas + +When the named input is a spark, promote it to an idea before grilling so the capture trail is one idea, not a dangling spark: + +```bash +loaf idea capture --title "<working title>" +loaf spark promote <spark> --to-idea <idea> +``` + +When the named input is already an idea, `loaf idea show` and grill. Leave resolution until an issue row exists. + +### Step 4: Problem-discovery interview Run the interview per [references/interview-guide.md](references/interview-guide.md): -- Pin a one-or-two-line **destination** before dimension grilling (fixes brief scope; project scale feeds VISION success criteria, change scale sharpens the eventual Hypothesis) +- Pin a one-or-two-line **destination** before dimension grilling (fixes narrative scope; project scale feeds VISION success criteria; issue scale sharpens what good looks like for the row) - Dimensions: problem, who has it, current alternatives / competitive landscape, value proposition, constraints (plus sequencing and open questions when needed) - Depth: scenario stress-testing, challenge stance, glossary-term hygiene; open questions must pass the specifiability test and carry HITL/AFK tags when precise - Applicability judgment: skip formal competitive analysis and deep personas when the pitch kind does not warrant them (bug fixes, internal chores) -- One question at a time, recommendation-first, ordered by brief impact +- One question at a time, recommendation-first, ordered by narrative impact - Stop on exit criteria or when answers stop changing the framing -If the direction is genuinely undecided mid-interview, offer the **explore** technique (agent-side; Explorations and checkpoints) rather than forcing a false brief. Pitch remains the human front door; explore is not re-opened as a slash alternative by this skill. +If the direction is genuinely undecided mid-interview, offer the **explore** technique (agent-side; Explorations and checkpoints) rather than forcing a false narrative. Pitch remains the human front door; explore is not re-opened as a slash alternative by this skill. -### Step 4: Evidence delegation (when warranted) +### Step 5: Evidence delegation (when warranted) -When competitive or landscape facts would change the brief and are not already known: +When competitive or landscape facts would change the narrative and are not already known: 1. Spawn a **researcher** subagent with a bounded question (competitors, substitutes, prior art — not solution design). 2. Land evidence: - - **Change scale:** files under the change folder's `research/` (create the folder with the change); link from Sources and Research Links - - **Project scale:** inline source links in `docs/BRIEF.md` Sources and Research Links (no change `research/` yet) -3. Resume the interview or brief draft with recommendations informed by the scan. + - **Issue scale:** source links in the narrative's Sources and Research Links. If a longer scan lands on disk, name it for the landscape, never for the work unit, and cite it from Sources. + - **Project scale:** inline source links in `docs/BRIEF.md` Sources and Research Links. +3. Resume the interview or draft with recommendations informed by the scan. Never fabricate competitive claims. If a scan is skipped, say so in Sources ("no external scan; alternative is internal workaround X"). -### Step 5a: Change-scale ceremony - -1. **Propose a slug** — lowercase, digits, single hyphens; names the concept locally. Confirm with the human. -2. **Initialize capture:** - - ```bash - loaf change init <slug> --brief - ``` - - Creates `docs/changes/YYYYMMDD-<slug>/` with `change.json` + `brief.md` scaffold only. -3. **Author `brief.md`** against the shared problem-space skeleton (shape's brief template / the scaffold just written): Problem Statement, Who Has It, Current Alternatives, Value Proposition, Constraints, Sequencing and Relationships, Sources and Research Links, Open Questions. Problem-space sentences only. -4. **Stamp `target_release` when known** — edit `change.json` with canonical `MAJOR.MINOR.PATCH` (no `v`, no prerelease). Omit the field when untargeted. Confirm with the human before stamping. -5. **Accretion note** — tell the human: parked problem-space concepts may accrete into this brief until shaping starts; once `shape.md` exists the brief freezes. -6. **Cold-read** the brief (interview guide test); revise with the human until it passes. -7. **Offer landing** (recommendation-first): +### Step 6a: Issue-scale ceremony - | Offer | When to recommend | - |-------|-------------------| - | **Shape now** | Framing is solid and they want to bound implementation next | - | **Park targeted** | Bound to a release cohort but not shaping yet — docs-only on default branch | - | **Park untargeted** | Worth capturing off-main, or not ready as a Change (stay intake) | +1. **Propose a working title** — names the concept locally. Confirm with the human. This becomes shape's `loaf issue new` title (or the park-as-issue title). +2. **Author the problem narrative** against the skeleton above. Problem-space sentences only. +3. **Accretion note** — tell the human: parked problem-space concepts may accrete until shaping starts; once the issue is minted, the body is the home. +4. **Cold-read** the narrative (interview guide test); revise with the human until it passes. +5. **Offer landing** (recommendation-first) using the Landing offers table. +6. **Execute the chosen landing:** -8. **Execute the chosen landing:** + - **Shape now:** hand the full narrative and any spark/idea refs. Shape runs `loaf issue new "<title>" --body -` (or `--body-file`) with that text. Do not mint, do not add criteria, do not open a PR. After shape mints, resolve intake: `loaf idea resolve <idea> --by <ref>` (and `loaf spark resolve <spark> --by <ref>` only if the spark was never promoted). + - **Park as issue:** mint the unshaped row yourself, then resolve intake against it: - - **Shape now:** `git switch -c <slug>` (from default unless already on a working branch the human prefers), ensure pre-landing guard would pass if they later park, hand to shape with the folder path — shape promotes in place. Do not open a PR. - - **Park targeted:** on the **default branch**, run pre-landing guard on the explicit folder, confirm `target_release` present in `change.json`, then one docs-only commit of the change folder (and any `research/` under it). - - **Park untargeted as Change:** `git switch -c <slug>`, pre-landing guard, confirm `target_release` **absent**, one docs-only commit on the slug branch. - - **Park as intake:** do not leave a half-written change folder; prefer Intent/spark retention and delete or never create the capture if the human backs out. + ```bash + loaf issue new "<title>" --body - + loaf idea resolve <idea> --by <ref> + ``` -9. **Commit message** (when parking): conventional, e.g. `docs(change): capture <slug> brief` — one commit per capture. + Paste the narrative on stdin. Do not add criteria. Do not write out-of-scope. Default status is `triage`. Read back with `loaf issue show <ref>`. + - **Park as idea:** if no idea exists yet, `loaf idea capture --title "<title>"`. Journal the gist (`loaf journal log "discover(pitch): <one-line problem>"`). Do not mint an issue. + - **Existing issue:** `loaf issue edit <ref> --body -` with the full narrative. Edit replaces the body; do not strip a row that is already bounded — if criteria already exist, hand the narrative to the human and let shape merge. -10. **Closing ceremony (required — never trail off).** After the landing is executed (or intake retained), announce completion with a full closing block: +7. **Closing ceremony (required — never trail off).** After the landing is executed, announce completion with a full closing block: - - **Recap the brief** — section-by-section gist (Problem, Who, Alternatives, Value, Constraints, Sequencing, Open Questions). Name the change folder path (`docs/changes/YYYYMMDD-<slug>/`) and what it holds (`change.json` + `brief.md`, plus any `research/`). + - **Recap the narrative** — section-by-section gist (Problem, Who, Alternatives, Value, Constraints, Sequencing, Open Questions). Name where it lives (handed to shape, unshaped issue `<ref>`, idea `<ref>`, or the conversation plus journal gist). - **Restate the landing actually taken** and what it means next: - - **Shape now** — you are on the slug branch; run shape next to promote the capture in place and bound implementation. No park-commit was made. - - **Park targeted** — the capture is a docs-only commit on the default branch with `target_release` stamped; it sits as a promise carrier for that cohort until shape is invoked later. - - **Park untargeted** — the capture lives on the slug branch (or remains intake) without `target_release`; it is off-main until retargeted or shaped. If intake-only, name the Intent/spark and that no change folder was left half-written. + - **Shape now** — run shape next to mint the issue from this narrative and bound implementation. No row was minted here. + - **Park as issue** — `<ref>` holds the problem in its body and is unshaped; run shape later on that ref. + - **Park as idea** — the idea remains open; re-invoke pitch or shape when ready. Name the idea ref. - **Announce completion** in plain language: "Pitch is complete." Do not end on a dangling offer or an unfinished sentence. -### Step 5b: Project-scale ceremony +### Step 6b: Project-scale ceremony 1. **Author `docs/BRIEF.md`** using bootstrap's brief skeleton with frontmatter: @@ -180,31 +210,31 @@ Never fabricate competitive claims. If a scan is skipped, say so in Sources ("no --- ``` - Same problem-space sections as change scale, at project altitude (Sequencing describes the initial arc as prose). + Same problem-space sections as issue scale, at project altitude (Sequencing describes the initial arc as prose). 2. **Cold-read** and revise with the human. 3. Optionally commit `docs/BRIEF.md` if the human wants it durable before bootstrap; still no push unless they ask outside this skill's duties — pitch itself never pushes. 4. **Closing ceremony (required — never trail off).** Announce completion with a full closing block — do not hand off in a half-sentence: - **Recap what was authored** — section-by-section gist of the BRIEF (Problem Statement, Who Has It, Current Alternatives, Value Proposition, Constraints, Sequencing and Relationships, Sources and Research Links, Open Questions). One or two sentences per section is enough; the human should hear what landed without reopening the file. - **Artifact path** — name `docs/BRIEF.md` explicitly, including that frontmatter carries `source: pitch`. - - **Explicit handoff** — state, verbatim in spirit: next, run bootstrap: it reads this BRIEF as discovery-done (`source: pitch`), interviews only on gaps, populates the operating documents (VISION, STRATEGY, ARCHITECTURE, AGENTS), and closes by proposing your initial arc of captured changes. Do not auto-run bootstrap. + - **Explicit handoff** — state, verbatim in spirit: next, run bootstrap: it reads this BRIEF as discovery-done (`source: pitch`), interviews only on gaps, and populates the operating documents (VISION, STRATEGY, ARCHITECTURE, AGENTS). Do not auto-run bootstrap. - **Announce completion** in plain language: "Pitch is complete." The ceremony ends with a period, never a trail-off. -### Step 6: Log the outcome +### Step 7: Log the outcome ```bash -loaf journal log "decision(pitch): <slug or project> — <shape-now|park-targeted|park-untargeted|handed-to-bootstrap>" +loaf journal log "decision(pitch): <title, ref, or project> — <shape-now|park-issue|park-idea|handed-to-bootstrap>" ``` -The journal line is mechanical; the human-facing close is the closing ceremony in Step 5a/5b. Never log-and-stop without that recap and next-step restatement. +The journal line is mechanical; the human-facing close is the closing ceremony in Step 6a/6b. Never log-and-stop without that recap and next-step restatement. --- ## Related Skills -- **shape** — solution-space narrowing from an existing brief (or full narrowing when no brief); promotes capture folders in place -- **bootstrap** — consumes `docs/BRIEF.md` (`source: pitch` → gap interview) and series-preps captured changes -- **triage** — queue dispositions; may hand an item to pitch when problem discovery is needed +- **shape** — solution-space bounding; mints the issue from the problem narrative (`loaf issue new`) and owns criteria, out-of-scope, and decomposition +- **bootstrap** — consumes `docs/BRIEF.md` (`source: pitch` → gap interview) and populates operating documents +- **triage** — queue dispositions; may hand a spark or idea to pitch when problem discovery is needed - **explore** — agent-side technique when pitch finds the direction still undecided - **idea** — quick capture without ceremony; not a substitute for pitch - **research** — patterns the researcher subagent follows for landscape scans @@ -217,4 +247,4 @@ The journal line is mechanical; the human-facing close is the closing ceremony i ## Artifact Naming -Name every artifact for what it is, never for the work unit that produced it. The change folder already records provenance. Put source in frontmatter, not the filename. See the `foundations` skill; `loaf check --hook artifact-names` enforces it at commit. +Name every on-disk artifact for what it is, never for the work unit that produced it. The issue row or `docs/BRIEF.md` already records provenance. Put source in frontmatter, not the filename. See the `foundations` skill; `loaf check --hook artifact-names` enforces it at commit. diff --git a/dist/opencode/commands/refactor-deepen.md b/dist/opencode/commands/refactor-deepen.md index fc5bc3fca..38133e92c 100644 --- a/dist/opencode/commands/refactor-deepen.md +++ b/dist/opencode/commands/refactor-deepen.md @@ -172,7 +172,7 @@ skill terminates by writing a PLAN file using [templates/plan.md](templates/plan > Plan saved to `.agents/plans/<filename>.md`. Workflow handoff is pending > the SPEC/PLAN/TASKS artifact taxonomy spec — for now, decide manually. -Do **not** recommend breakdown or implement as the next step. The +Do **not** recommend implement as the next step. The handoff design is downstream of a deferred taxonomy spec. ### Codex Review (Opt-In, Plugin-Gated) diff --git a/dist/opencode/commands/reflect.md b/dist/opencode/commands/reflect.md index 515ce3b9c..23cab1707 100644 --- a/dist/opencode/commands/reflect.md +++ b/dist/opencode/commands/reflect.md @@ -81,12 +81,12 @@ After completing work, reflect extracts learnings and proposes updates to strate ### Step 1: Parse Input -`$ARGUMENTS` can be: a spec ID (`SPEC-001`), a topic ("authentication learnings"), or empty (general reflection on recent work). +`$ARGUMENTS` can be: an issue ref (`LOAF-42`), a topic ("authentication learnings"), or empty (general reflection on recent work). ### Step 2: Gather Evidence Sources: -1. **Completed specs** (`.agents/specs/SPEC-*.md` with status `done`; legacy files may still read `complete`) -- look for "Lessons Learned" +1. **Completed issues** (`loaf issue list` / `loaf issue show <ref>` with status `done`) -- look for lessons in the issue body 2. **Project journal** (`loaf journal recent --json`, `loaf journal search <topic>`) -- insights, surprises, pivots 3. **Recent commits** (`git log --oneline -30`) 4. **Implementation reality** -- what was harder/easier than expected? What assumptions were wrong? diff --git a/dist/opencode/commands/release.md b/dist/opencode/commands/release.md index 2579a68f1..fe22d19d1 100644 --- a/dist/opencode/commands/release.md +++ b/dist/opencode/commands/release.md @@ -1,32 +1,26 @@ --- description: >- - Orchestrates standalone releases from already-landed work: release readiness, - version selection, changelog curation, release commit, tag, GitHub Release, - install verification, and post-release follow-up. Use when the user says "cut - a release," "publish a version," "release from main," or asks whether enough - landed work should become a release. Not for reviewing or merging a PR (use - ship). + Cuts a retroactive release from already-landed issues: loaf release suggest + reports the range, loaf release cut records the version. Use when the user + says "cut a release," "publish a version," "release from main," or asks what + landed since the last tag. Produces a recorded release row and members as + facts. Not for reviewing or merging a PR (use ship). version: 0.2.21 --- # Release -Publish a coherent version from work that has already landed. +Cut a version from work that has already landed. ## Contents - Critical Rules - Verification - Quick Reference - Topics -- Context Detection -- Step 1: Release Readiness -- Step 2: Change Collection -- Step 3: Version + Changelog -- Step 4: Release Execution -- Step 5: Release-PR Flow -- Step 6: Publication Verification -- Step 7: Post-Release Follow-Up -- Hook Interaction +- Process +- Attribution +- Bump derivation +- Must-contain convention - Related Skills **Input:** $ARGUMENTS @@ -35,259 +29,227 @@ Publish a coherent version from work that has already landed. ## Critical Rules -- **Release is not merge** -- do not use release to review, approve, or land a feature PR. Use ship for PR correctness and landing. -- **Release from landed work** -- collect changes from the release base branch, normally the repo default branch, since the last release tag. -- **Release-PR flow is the default** -- prepare on a release branch with `loaf release --pre-merge`, squash-merge the release PR, then finalize with `loaf release --post-merge` on the base branch. Direct `--bump` on the base branch is a named exception used only on explicit user request. -- **Batch by intent** -- group release notes by user-facing outcome, `CR-*` change bundle, spec, or related PRs; do not mirror individual commits mechanically. -- **Keep landed and released distinct** -- a PR may be landed without being released; a release may contain multiple landed PRs. -- **Block on release-readiness failure** -- do not publish if build, tests, version files, changelog, tag, or GitHub release state is inconsistent. -- **Never push, tag, or publish without confirmation** -- present the exact actions first. -- **Use your harness's structured question tool (if it has one) for release decisions** -- version bump type, release PR handoff, push/tag/GitHub Release confirmation. -- **Log release** -- after publication, run `loaf journal log "decision(release): vX.Y.Z published from <base> with <summary>"`. +1. **Log invocation first** — `loaf journal log "skill(release): <what is being cut or suggested>"` before doing anything else. +2. **Release is not merge** — do not review, approve, or land a PR here. Verification authority is the ship workflow (PR review and CI at merge). If the user is asking to merge, stop and route to ship. +3. **A release is cut from what landed** — the surface is `loaf release suggest` and `loaf release cut`. Do not run unsubcommmanded `loaf release`, `--pre-merge`, or `--post-merge`; this skill does not own that path. +4. **Suggest writes nothing** — it reads `baseline-tag..HEAD` (or `--base <ref>..HEAD`), attributes commits to issues, rolls up through parents, reports partially-landed parents and unattributed commits as information, derives the bump, reports the advisory bucket delta, and drafts notes. +5. **Cut records facts** — it applies the version, prepends the drafted notes into `CHANGELOG.md`, tags, records the release row plus members, then attempts a GitHub Release draft. A `gh` failure degrades to a warning with a paste-ready retry command; the recorded row stays. +6. **No forward version stamp** — do not bind an issue to a future version. Members are what already landed. Buckets (`loaf issue bucket`) are advisory labels; planned-vs-landed is information only. +7. **No suite, no re-record, no publication stop in this skill** — ship already verified the merged work. Cut's operational refusals (dirty worktree, disagreeing version files, missing version, `--no-tag` without an existing tag) are command errors, not a substitute for ship. +8. **Confirm before cut** — present the suggest report (or `cut --dry-run`) first. Ask one question at a time, with a recommendation, using your harness's structured question tool if it has one. `--dry-run` previews everything and writes nothing. +9. **Log the outcome** — after a successful cut, `loaf journal log "decision(release): vX.Y.Z recorded from <base> with <summary>"`. + +--- ## Verification -- Release base branch is clean, current, and contains the intended landed PRs -- Pre-flight checks pass before versioning or publication -- Changelog entries are curated user-facing prose, not commit or PR-title dumps -- Version files, changelog heading, git tag, and GitHub Release all agree -- Tag points at the released base-branch commit or release commit, not an abandoned feature branch -- Downstream install path is verified when applicable, especially Homebrew for Loaf releases +- Journal contains the `skill(release)` invocation (and a `decision(release)` entry after a real cut) +- The work in the range already landed through ship (PR review and CI at merge); this skill did not re-verify or re-merge it +- `loaf release suggest` (or `cut --dry-run`) was shown: landed issues, partially-landed parents, unattributed commits, advisory buckets, derived bump, drafted notes +- Partially-landed parents, unattributed commits, and bucket drift were reported as information — not treated as a cut refusal +- Mutating `loaf release cut` updated version files, wrote the notes into `CHANGELOG.md`, created or reused tag `v<version>`, and recorded the release row with issue members (plus `--includes` release members when given) +- `cut --dry-run` left version files, changelog, tags, HEAD, and release rows untouched +- GitHub Release is a draft, was skipped with `--no-gh`, or failed with a warning plus a paste-ready `gh release create …` retry — never a silent rollback of the recorded row +- No issue was stamped with a future version + +--- ## Quick Reference -| Step | Gate | Blocking? | -|------|------|-----------| -| Readiness | clean/current base branch, no unresolved release collisions | Yes | -| Change Collection | landed work since last tag grouped into release themes | Yes | -| Version + Changelog | bump selected, notes curated, files updated | Yes | -| Execution | release commit prepared via `--pre-merge`, release PR landed, `--post-merge` finalizes | Yes | -| Verification | release and install paths checked | Yes | -| Follow-Up | reflect/housekeeping suggested when useful | No | +| Harness | Invoke skill | +|---------|----------------| +| Claude Code (plugin) | `/loaf:release` | +| OpenCode, Cursor, Codex, Amp | `/release` | + +### Commands + +```text +loaf release suggest [--base <ref>] [--json] +loaf release cut [--base <ref>] [--bump <type>] [--includes <version|tag>] [--no-tag] [--no-gh] [--dry-run] +loaf issue bucket <ref> now|next|later|none [--json] +loaf issue link <from> blocks|relates-to <to> [--json] +``` + +Both commands need initialized SQLite state (`loaf state init`, or `loaf state migrate markdown --apply`). They are not a git repository's optional extra — without SQLite they refuse. + +| Command | Writes? | What it does | +|---------|---------|----------------| +| `loaf release suggest` | No | Report landed work since the last version tag | +| `loaf release cut` | Yes (unless `--dry-run`) | Cut the retroactive release and record members as facts | +| `loaf release cut --dry-run` | No | Print the plan, including `--includes` rows, and write nothing | + +### `suggest` flags + +| Flag | Meaning | +|------|---------| +| `--base <ref>` | Commits since `<ref>` instead of the last tag | +| `--json` | Machine-readable suggestion | +| `-h`, `--help` | Help | + +`suggest` rejects `--dry-run` (it is already read-only) and rejects cut-only flags (`--bump`, `--includes`, `--no-tag`, `--no-gh`). + +### `cut` flags + +| Flag | Meaning | +|------|---------| +| `--base <ref>` | Commits since `<ref>` instead of the last tag | +| `--bump <type>` | Override the derived bump: `major`, `minor`, `patch`, `prerelease`, `release` | +| `--includes <version\|tag>` | Record a prior release as a member (repeatable). Use this to hang prerelease references on a stable | +| `--no-tag` | Do not create a git tag; tag `v<version>` must already exist | +| `--no-gh` | Skip the GitHub Release draft | +| `--dry-run` | Print the plan and write nothing | +| `-h`, `--help` | Help | + +`cut` rejects `--json`. `--bump prerelease` and `--bump release` only produce a version when the current version already has a prerelease suffix; otherwise cut fails with `could not compute a version to cut`. `--no-tag` is checked before `--dry-run`: the tag must already exist even for a preview. + +### Cut sequence (mutating) + +1. Recompute the same suggestion as `suggest` (then apply `--bump` if given, and redraft notes) +2. Resolve each `--includes` ref to an existing release +3. Require a clean worktree +4. Apply the version to detected version files (they must exist and agree) +5. Prepend drafted notes into `CHANGELOG.md` (after `[Unreleased]`, ahead of prior versions; creates the file if missing) +6. Commit `chore: release vX.Y.Z` +7. Unless `--no-tag`: create annotated tag `vX.Y.Z` (`git tag -a`). Signing follows git config (`tag.gpgSign`); cut never passes `-s` or `--no-sign` +8. Record the release row, issue members, and `--includes` members as facts +9. Unless `--no-gh`: `gh release create <tag> --draft --title <tag> --notes <notes>` (adds `--prerelease` when the version is a prerelease). Switches to the configured GitHub account first. On `gh` missing, account failure, or create failure: print `warning:` plus a POSIX-quoted `retry:` command; do not fail the cut + +Cut does not push the commit or the tag. + +--- ## Topics | Topic | Use When | |-------|----------| -| [Context Detection](#context-detection) | Determining release base, last tag, and current branch | -| [Release-PR Flow](#step-5-release-pr-flow) | Preparing, landing, and finalizing every release | -| [Hook Interaction](#hook-interaction) | Understanding coexistence with git hooks | +| [Process](#process) | Running suggest then cut in this conversation | +| [Attribution](#attribution) | How commits become issue members | +| [Bump derivation](#bump-derivation) | Why suggest picked major, minor, or patch | +| [Must-contain convention](#must-contain-convention) | The rare promise that named issues must land first | --- -## Context Detection - -Before anything, establish the release surface: - -1. Get current branch and repo default branch: - ```bash - git branch --show-current - gh repo view --json defaultBranchRef -q .defaultBranchRef.name - ``` -2. Parse `$ARGUMENTS` for an explicit base, tag, or version. If omitted, use the repo default branch as the release base. -3. Verify the current branch: - - If already on the release base, continue; the release-PR flow in Step 5 branches from here. - - If on a dedicated release branch, resume the release-PR flow at the matching step. - - If on a feature branch, stop and explain that release publishes from landed work. Offer ship if the active PR needs landing first. -4. Find the previous release tag: - ```bash - git describe --tags --abbrev=0 - ``` -5. Gather the candidate release range: - ```bash - git log --oneline <last-tag>..HEAD - git diff --stat <last-tag>..HEAD - ``` +## Process ---- +Parse `$ARGUMENTS` for a base, bump, version, `--includes`, `--no-tag`, `--no-gh`, or `--dry-run`. Default baseline is the last version tag; `--base` overrides. With neither a last tag nor `--base`, the range is all of `HEAD`. -## Step 1: Release Readiness - -Run release pre-flight checks before editing release files: - -1. Ensure worktree is clean: - ```bash - git status --short - ``` -2. Ensure the release base is current: - ```bash - git fetch --tags origin - git status --branch --short - ``` -3. Check for existing tag or GitHub Release collisions for the target version once known: - ```bash - git tag --list vX.Y.Z - gh release view vX.Y.Z - ``` -4. Run project checks: - - Node: `npm run typecheck`, `npm run test`, `npm run build` when scripts exist - - Go: `go vet ./...`, `go test ./...` when `go.mod` exists - - Python: `pytest`, `mypy .`, `ruff check .` when configured - - Rust: `cargo check`, `cargo test` when `Cargo.toml` exists - -If no checks are detected, warn explicitly. If a check fails, stop and fix before release. +### Step 1: Log and route ---- +```bash +loaf journal log "skill(release): <what is being cut or suggested>" +``` -## Step 2: Change Collection - -Collect landed work since the last release and group it for release notes. - -1. Inspect commits: - ```bash - git log --first-parent --oneline <last-tag>..HEAD - git log --oneline <last-tag>..HEAD - ``` -2. Inspect merged PRs when GitHub is available: - ```bash - gh pr list --state merged --base <base> --json number,title,mergedAt,url - ``` -3. Group changes by user-facing outcome: - - `CR-*` change bundle, when referenced - - spec or task family, when public enough to be useful - - feature/fix/documentation/build themes - - operational release work, when it affects users or maintainers -4. Drop noise: - - purely internal task labels - - reverted work that is not present in `HEAD` - - individual commit mechanics that collapse into one user-facing change - -Present the grouped release contents before choosing the bump. +If the user wants a PR reviewed or merged, stop and use ship. If the work is still on a feature branch, explain that a release is cut from landed `HEAD` since the baseline, and offer ship. ---- +### Step 2: Suggest -## Step 3: Version + Changelog - -Choose the bump and curate the changelog from the grouped landed work. - -1. Run a dry run: - ```bash - loaf release --dry-run - ``` - Use `--base <ref>` when the project expects a non-default release base. -2. Present: - - current version - - proposed next version - - detected version files - - release actions the CLI would perform - - draft changelog entries -3. Curate `CHANGELOG.md` before publishing: - - write from the upgrading user's perspective - - group under Common Changelog categories: `Changed`, `Added`, `Removed`, `Fixed` - - use one self-describing line per meaningful change - - include public PR, issue, ADR, release, or commit links when helpful - - avoid dumping commit subjects, task IDs, session mechanics, or internal gate language -4. Confirm the bump type: `prerelease`, `release`, `major`, `minor`, or `patch`. +```bash +loaf release suggest +# or +loaf release suggest --base <ref> +loaf release suggest --json +``` ---- +Present the report as-is: base, suggested bump and version, bump evidence, landed issues with commits, partially-landed parents (missing children), unattributed commits, advisory buckets (planned landed / planned not landed / unplanned landed), drafted notes. -## Step 4: Release Execution +Do not hide partial parents or unattributed commits, and do not refuse the cut because of them unless the operator is using the [must-contain convention](#must-contain-convention) and wants to wait. -Every release routes through the release-PR flow in Step 5: prepare the release commit on a release branch with `loaf release --pre-merge`, land the release PR, then finalize with `loaf release --post-merge` on the base branch. +### Step 3: Confirm -Release preparation should: +Show the exact `loaf release cut …` you would run. Recommend cutting the derived version when the landed set matches what the operator asked for. Use `--bump` only when they override. Use `--dry-run` when they want a preview: -1. Update version files -2. Convert `[Unreleased]` into `## [X.Y.Z] - YYYY-MM-DD` -3. Reinsert a fresh empty `[Unreleased]` section -4. Run configured release artifact commands -5. Create the release commit +```bash +loaf release cut --dry-run +loaf release cut --dry-run --no-gh +loaf release cut --dry-run --includes <version|tag> +``` -After preparation, verify generated artifacts are current: +### Step 4: Cut ```bash -npm run build -git diff --exit-code -- dist plugins content/skills/loaf-reference/SKILL.md +loaf release cut +loaf release cut --bump minor +loaf release cut --includes v1.1.0-alpha.1 +loaf release cut --no-tag --no-gh ``` -Adjust the path list to the project. For Loaf itself, tracked generated outputs under `dist/`, `plugins/`, and native binaries must match the source changes. +On success, report version files updated, changelog written, tag created or reused, release recorded (member count), and GitHub draft created / skipped / warned. If stderr has `retry:`, paste that command; the row is already recorded. -Capability receipts pin artifact SHA-256s, and the release rebuild version-stamps generated artifacts (`dist/opencode/plugins/hooks.ts` embeds `@version`, so every version bump stales the OpenCode receipt; Go changes additionally stale all binary-pinned receipts via `bin/native`). Therefore re-recording runs AFTER `loaf release --pre-merge` completes its artifact rebuild, on the release branch, before pushing the release PR — never before the bump. Verify with `go test ./internal/cli -run TestTargetCapabilityEvidence`. `loaf release` now enforces this mechanically on every mutating path (post-rebuild refusal in apply, guardrail 9 in `--post-merge`) — the rule explains WHY the gate fires; the gate makes skipping it impossible. +```bash +loaf journal log "decision(release): vX.Y.Z recorded from <base> with <summary>" +``` -### Direct Release (Named Exception) +### Step 5: After -`loaf release --bump <type> --yes` on the base branch prepares, commits, tags, and publishes in a single shot. Use it only when the user explicitly requests a direct release; never select it by default. Skipping the release PR means nothing runs the suite against the prepared tree before the tag exists — the v0.2.16 cut took this door and a capability-evidence canary surfaced only in tag CI, after publication. The same day, v0.2.17 re-recorded evidence minutes before the version bump; the release commit staled it, and the tag again published zero assets — ordering, not diligence, is the failure mode. The CLI prints a flow advisory when a mutating release starts on the default branch; treat it as a routing signal, not noise. +Suggest reflect when the cut produced durable product or workflow learnings, and housekeeping when temporary artifacts need cleanup. Capture leftover discoveries as issues or sparks — not as extra changelog lines. --- -## Step 5: Release-PR Flow +## Attribution -The default for every release: PR CI runs the full suite against the prepared tree, so evidence canaries surface before any tag or GitHub Release exists. This holds regardless of repository settings — where branch protection is enabled it is satisfied as a side effect, not the reason for the flow. +`suggest` (and `cut`, which recomputes the same suggestion) attributes each commit in the range to zero or more issues, then rolls up through parents. -1. Create a dedicated release branch from the release base. -2. Run `loaf release --pre-merge` on it: this creates the version/changelog/artifact release commit but no tag and no GitHub Release. -3. Open a release PR with a concise release-focused body. -4. Hand the PR to ship for review and landing; squash-merge it into one `chore: release vX.Y.Z (#PR)` commit carrying the curated changelog. -5. After the release PR lands, run `loaf release --post-merge` on the base branch to tag, publish the GitHub Release, and verify installability. +**Commit → issue**, first match wins: -If guardrail 9 fires on `--post-merge`, the merged tree itself carries stale evidence; recovery is to re-record against the merged tree, land the receipts as a single evidence-only commit on the base branch (the repair commit must not modify the capability registry), and rerun `loaf release --post-merge`. +1. Issue alias (`PREFIX-N`, e.g. `LOAF-42`) in the subject or body (prefix case-sensitive). URLs and code spans are stripped first. +2. Else the merge/branch rung: aliases in a `Merge …` subject, plus any alias anywhere in the body (case-insensitive). Squash subjects like `feat: add auth (#42)` often carry the alias only in the body. No network, no `gh`. An alias that lived only on a deleted branch name is unattributable. +3. Else a unique journal `commit(<hash>)` row whose message contains an alias (scope uniquely matching that commit). -Do not hide this handoff inside release: ship remains the PR correctness and merge gate. +Resolved aliases become **landed** issues (with the commits that named them). Commits that match nothing are **unattributed** — listed, and included under drafted notes as `### Unattributed`. They do not block the cut. ---- +**Parent rollup** (information): -## Step 6: Publication Verification +- For each landed issue that has a parent, if any sibling child is not `done`, the parent is **partially landed** and the missing children are listed. +- A parent is not auto-added to landed unless a commit attributed to it. -After publishing, verify the public release state: +Drafted notes are `## [version] - YYYY-MM-DD`, then one `### ALIAS — title` section per landed issue with commit subjects, then unattributed. -1. Confirm tag location: - ```bash - git show --stat vX.Y.Z - ``` -2. Confirm GitHub Release: - ```bash - gh release view vX.Y.Z - ``` -3. Confirm package or installer availability when applicable: - - npm: `npm view <package> version` - - Homebrew: `brew update && brew info <tap>/<formula>` - - project-specific deploy or artifact registry checks -4. For Loaf/Homebrew, report readiness only after the GitHub release exists, assets are uploaded, the tap formula is updated, and tap CI has passed. +--- -If publication partially completes, do not retag casually. Name the exact state and continue with the smallest repair or patch release path. +## Bump derivation ---- +Derived from the range, in order: -## Step 7: Post-Release Follow-Up +| Condition | Bump | +|-----------|------| +| Breaking marker (`type!:` in the subject, or `BREAKING CHANGE:` / `BREAKING-CHANGE:` in the body) | `major` | +| A **done** parent with **two or more** children, every child `done` and landed, and the parent's done timestamp **after** the baseline tag's committer time | `minor` (closed multi-child parent fully landed) | +| Else a conventional `feat` commit | `minor` | +| Else | `patch` (`fix` / other) | -After verification: +`--bump` on `cut` replaces the derived bump and redrafts notes; the evidence string becomes `overridden by --bump <type>`. -1. Log the release decision to the project journal: - ```bash - loaf journal log "decision(release): vX.Y.Z published from <base> with <summary>" - ``` -2. Suggest reflect when the release produced durable product or workflow learnings. -3. Suggest housekeeping when release branches or temporary reports need cleanup. -4. Keep future-work discoveries out of the release notes; capture them as tasks, ideas, or sparks instead. +Current version comes from agreeing version files, else from a semver last tag. Cut still requires version files on the mutating path. --- -## Hook Interaction +## Must-contain convention -This skill coexists with existing hooks. Git workflow hooks are advisory unless -configured otherwise; security and secret-scanning hooks remain blocking. +Rare. When the operator needs a named set of issues to land before a cut, create a **release-prep** issue and express `blocked_by` edges with issue mechanics: + +```bash +loaf issue new "Release prep for vX.Y.Z" --body "Must contain LOAF-12 and LOAF-15. Out of scope: the cut itself." +loaf issue link LOAF-12 blocks LOAF-99 +loaf issue link LOAF-15 blocks LOAF-99 +``` -| Hook | Type | When release Runs | -|------|------|---------------------| -| `github-account` | Force-switch | Switches to the configured GitHub account before `gh` release operations; blocks only if the switch fails | -| `validate-push` | Advisory | Cross-checks version bump, changelog, and build on push | -| `workflow-pre-pr` | Advisory | Fires when the release PR is opened | -| `workflow-pre-merge` | Advisory | Belongs to ship when a release PR must land | -| `workflow-post-merge` | Advisory | Belongs to ship after PR landing | -| `check-secrets` | Blocking | Always respected before writes or shell actions | +Stored write types are `blocks` and `relates_to`. `loaf issue link <must-land> blocks <release-prep>` is how you record that the prep issue is blocked by those that must land. `loaf issue frontier` and implement honor `blocks`. **`loaf release suggest` and `cut` do not read these edges** — convention, not schema. If the operator wants to wait, wait; if they cut anyway, cut records whatever actually landed. -Do not disable hooks to force a release through. +Buckets stay labels: ---- +```bash +loaf issue bucket LOAF-12 now +loaf issue bucket LOAF-12 none +``` -## Suggests Next +`suggest` prints `bucket:<name> ALIAS — title (landed|not landed)` and `unplanned ALIAS — title (landed)`. Never treat that delta as a constraint. -After a successful release, suggest reflect for durable learnings and housekeeping if temporary release artifacts need attention. +--- ## Related Skills -- **ship** -- Reviews, verifies, and lands a PR before it becomes release input -- **git-workflow** -- Branching, PR, commit, and squash merge conventions -- **documentation-standards** -- Changelog and release-note quality -- **reflect** -- Updates strategy from shipped/released learnings -- **housekeeping** -- Cleans up completed spec, report, and handoff artifacts +- **ship** — Reviews, verifies, and lands a PR. That merge is the verification authority for what this skill may later cut +- **git-workflow** — Branching, PRs, and any later push of the local tag (cut does not push) +- **documentation-standards** — Changelog prose if a human edits notes after the cut +- **reflect** — Durable learnings after a cut +- **housekeeping** — Cleanup of temporary artifacts diff --git a/dist/opencode/commands/research.md b/dist/opencode/commands/research.md index 6164f078e..7264344b0 100644 --- a/dist/opencode/commands/research.md +++ b/dist/opencode/commands/research.md @@ -94,7 +94,7 @@ Always check project context first. Rate findings: **High** (official/verified), **Trigger:** Empty input, "project state", "catch me up" 1. Read project documents: VISION.md, STRATEGY.md, ARCHITECTURE.md -2. Check ideas with `loaf idea list --json` and specs with `loaf spec list --json` +2. Check ideas with `loaf idea list --json` and issues with `loaf issue list --json` (or `loaf issue export` for the full graph) 3. Review recent journal activity with `loaf journal recent --json` and `loaf journal context` 4. Check recent commits: `git log --oneline -20` 5. Synthesize following [state-assessment template](templates/state-assessment.md) @@ -144,4 +144,4 @@ User-facing entry for a new concept is pitch (problem-discovery brief). Do not t ## Artifact Naming -Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or Change already records that provenance. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. +Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or the issue already records that provenance. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. diff --git a/dist/opencode/commands/shape.md b/dist/opencode/commands/shape.md index fdef168ca..59e1c73bc 100644 --- a/dist/opencode/commands/shape.md +++ b/dist/opencode/commands/shape.md @@ -1,25 +1,21 @@ --- description: >- - Shapes messy input into a bounded, reviewable Change under - docs/changes/YYYYMMDD-slug/ (change.json + shape.md + tasks/), validated by - loaf change check. Runs a fog-routed narrowing protocol — gather context, - optional blindspot pass, grilling, reaction artifacts — seeds task-file - vertical slices, runs a critique gate, and offers an opt-in draft PR. Use when - the user asks "shape this," "turn this into a Change," or an idea has enough - constraints to bound. Produces role-named narrative (shape.md required; - brief/plan/design optional) plus task packets — never a numbered spec. - Teaches the problem-boundary test (same problem → another task; different - problem → Intent) and vertical-slice discipline. Not for quick capture (use - idea), problem discovery that should author a brief first (use pitch), or - open-ended divergent thinking (agent technique: explore / brainstorm — user - entry intent routes to pitch). + Shapes messy input into a bounded issue — problem body, definition-of-done + criteria, out-of-scope statement, and children when a criterion earns its own + DoD — validated by loaf issue check. Use when the user asks "shape this," + "turn this into an issue," or a diagnosed fix needs a row. Produces a shaped + issue — never a folder or a plan document. Teaches fog graduation (park, + then a decision child) and one-criterion sizing (one fresh context window, + verifiable alone). Not for quick capture (use idea), problem discovery that + should author a brief first (use pitch), or open-ended divergent thinking + (agent technique: explore / brainstorm — user entry routes to pitch). subtask: false version: 0.2.21 --- # Shape -Turn messy input into a bounded, reviewable Change. +Prepare a bounded, reviewable issue. ## Contents - Critical Rules @@ -35,29 +31,30 @@ Turn messy input into a bounded, reviewable Change. ## Critical Rules -1. **Log invocation first** — `loaf journal log "skill(shape): <input being shaped>"` before doing anything else. -2. **Produces a Change, never a spec** — `change.json` + `shape.md` (+ optional `brief.md`/`plan.md`/`design.md`) and `tasks/TASK-NNN-slug.md`. No sequentially-numbered spec file, no status-like fields anywhere. +1. **Log invocation first** — `loaf journal log "skill(shape): shaping <topic> into LOAF-42"` before doing anything else. If no issue exists yet, log `skill(shape): shaping <topic>` and add the alias in the outcome entry. +2. **Produces an issue, never a folder** — the deliverable is the issue row: problem in the body, definition of done as `loaf issue dod` criteria, an explicit out-of-scope statement in the body, children via `loaf issue promote` when a criterion earns its own DoD. No plan document is committed. The PR body, if a PR is opened, is `loaf issue render` output. 3. **The fog register routes, you don't guess** — every named unknown carries a quadrant tag that dispatches it to exactly one technique (see Quick Reference). Technique-by-vibes is the failure mode this replaces. -4. **Blindspot pass precedes grilling, offered not imposed** — run it when the territory is unfamiliar; skip it when the shaper is the domain expert. Never impose it as a mandatory step. -5. **Grill one question at a time, with a recommendation** — using your harness's structured question tool if it has one (otherwise one inline question per message — same semantics). Never a form to fill in one pass. Order by architectural impact: questions whose answer would change the architecture go first, cosmetic questions last. -6. **Techniques return fog entries, not decisions** — blindspot passes, grilling, and reaction artifacts hand back named unknowns and evidence for the human to adjudicate. Reconnaissance, not orders. -7. **Own the decomposition** — decide Implementation Unit boundaries and granularity autonomously (absorbed from the retired breakdown step); ask only when two orderings carry genuinely different trade-offs. -8. **Order units by likelihood-of-change** — data models, interfaces, and user-facing flows lead; mechanical work collapses at the bottom, so review attention lands on what's most likely to need changing. -9. **Surface misalignment, never silently adjust** — when the idea conflicts with strategic docs, prior Changes, or the journal, tell the user and let them decide. Don't quietly reshape their idea. -10. **Critique before finalizing** — run the Critique Gate as the last shaping step, before `loaf change check` and the PR offer. -11. **Get approval before `loaf change init`** — don't scaffold the folder without explicit confirmation of scope. -12. **Log the outcome** — `loaf journal log "decision(shape): <slug> shaped — N units, N open fog entries"`. +4. **Fog graduates instead of evaporating** — a question not yet sharp enough is parked in the issue's `fog` field (`loaf issue new --fog`). When it sharpens it becomes a `--kind decision` child, which is ready when it poses a sharp question (a `?` in the title or body). No plan required. +5. **Blindspot pass precedes grilling, offered not imposed** — run it when the territory is unfamiliar; skip it when the shaper is the domain expert. Never impose it as a mandatory step. +6. **Grill one question at a time, with a recommendation** — using your harness's structured question tool if it has one (otherwise one inline question per message — same semantics). Never a form to fill in one pass. Order by architectural impact: questions whose answer would change the architecture go first, cosmetic questions last. +7. **Techniques return fog entries, not decisions** — blindspot passes, grilling, and reaction artifacts hand back named unknowns and evidence for the human to adjudicate. Reconnaissance, not orders. +8. **Decomposition is the tail** — a parent gets children only when its DoD needs more than one coherent slice. A criterion becomes a child the moment it earns its own DoD, via `loaf issue promote`. Own those boundaries autonomously; ask only when two orderings carry genuinely different trade-offs. +9. **One sizing criterion** — a slice is right-sized when it fits one fresh context window and is verifiable alone. Expand–contract is the named exception for wide mechanical refactors. See [references/decomposition.md](references/decomposition.md). +10. **Surface misalignment, never silently adjust** — when the idea conflicts with strategic docs, prior issues, or the journal, tell the user and let them decide. Don't quietly reshape their idea. +11. **Critique before finalizing** — run the Critique Gate as the last shaping step, before `loaf issue check`. +12. **A diagnosed one-line fix is two commands** — `loaf issue new` with a body that states the problem and `Out of scope: …`, then one `loaf issue dod add`. No problem-space ceremony. Confirm scope with the user before `loaf issue new` on anything larger. +13. **Log the outcome** — `loaf journal log "decision(shape): LOAF-42 shaped — N children, N open fog entries"`. --- ## Verification -- `docs/changes/YYYYMMDD-slug/` has `change.json` + `shape.md` with Product Contract sections non-empty; task packets seeded under `tasks/` when decomposition is known -- Every Open Questions entry carries a quadrant tag (`[KU]`, `[UK]`, or `[UU]`) and a route -- `loaf change check` reports zero violations (no legacy deprecation on new layout); executability gaps were read, not ignored -- Problem-boundary test applied: discovered different problems become Intents, not TASK-007 -- The Critique Gate ran, and its answers changed the documents where they applied -- No status-like fields in `change.json` or task frontmatter +- The issue body states the problem and contains an explicit out-of-scope statement (`out of scope`, case-insensitive — that substring is what `loaf issue check` reads) +- At least one definition-of-done criterion exists; V-tier criteria carry `--command` (and `--expect` when the check is more than exit 0); H-tier otherwise +- Every open unknown is either parked in create-time `fog`, held in the session register until it sharpens, graduated to a `--kind decision` child (or sibling) with a sharp question, or written into the body as a decided answer +- `loaf issue check <ref>` reports the issue shaped (delivery) or ready (decision). When children exist, coverage failures were fixed and containment orphans were filed as sibling backlog issues using the printed remedy +- Problem-boundary test applied: a discovered different problem becomes a new backlog issue, not another criterion on this one +- The Critique Gate ran, and its answers changed the issue where they applied --- @@ -65,34 +62,46 @@ Turn messy input into a bounded, reviewable Change. ### Fog register format -Open Questions entries take one of three forms: +Open unknowns take one of three forms. Keep the register in the session. Park what is still unsharp in `--fog` at create; after create, unsharp entries stay in the session register (edit cannot mutate `fog`). Graduate what is sharp to a decision child or sibling, and write decided answers into the body. ```text -- [KU] <the unknown> → <route: grilling | research spike | owner section> -- [UK] <the recognize-it-when-seen criterion> → reaction artifact in research/ +- [KU] <the unknown> → <route: grilling | research spike | owner> +- [UK] <the recognize-it-when-seen criterion> → reaction artifact - [UU] <the suspected blind area> → blindspot pass over <territory> ``` -An entry resolves by becoming a Decision, a Planning Contract subsection, or a named follow-up — visible in the diff, never silently deleted. A `[UU]` that gets named becomes a `[KU]` or `[UK]` and re-routes through the table below. +An entry resolves by becoming a decision child, a body paragraph, a criterion, or remaining parked in `fog` — visible on `loaf issue show`, never silently deleted. A `[UU]` that gets named becomes a `[KU]` or `[UK]` and re-routes through the table below. ### Quadrant routing | Tag | Meaning | Routes to | |-----|---------|-----------| | `[KU]` known unknown | A question you can state precisely | [Grilling](references/grilling.md) (architecture-changing answers first) or a research spike | -| `[UK]` unknown known | You'd recognize the right answer if you saw it, but can't state it yet | [Reaction artifact](references/reaction-artifact.md) — a variant or mock in `research/`, react and pick | +| `[UK]` unknown known | You'd recognize the right answer if you saw it, but can't state it yet | [Reaction artifact](references/reaction-artifact.md) — a variant or mock, react and pick | | `[UU]` suspected blind spot | Unfamiliar territory; you don't yet know what you don't know | [Blindspot pass](references/blindspot-pass.md) | -No route names a skill invocation. Research re-interviews an already-scoped question and writes to `.agents/reports/`; brainstorm forces a strategic frame onto a Change-local question and sends resolutions to intake. Shape runs all three techniques itself, in-session, and writes evidence into the Change's own `research/` — never `.agents/reports/`. +No route names a skill invocation. Research re-interviews an already-scoped question; brainstorm forces a strategic frame onto an issue-local question and sends resolutions to intake. Shape runs all three techniques itself, in-session, and writes the captured answer onto the issue — never into `.agents/reports/`. ### Defined terms -- **Rabbit holes** — tempting expansions of scope that would consume disproportionate effort for marginal value; name them so nobody wanders in unknowingly. -- **No-gos** — approaches explicitly forbidden for this Change, stated so they aren't silently reconsidered mid-implementation. +- **Rabbit holes** — tempting expansions of scope that would consume disproportionate effort for marginal value; name them in the out-of-scope statement so nobody wanders in unknowingly. +- **No-gos** — approaches explicitly forbidden for this issue, stated so they aren't silently reconsidered mid-implementation. ### Source inputs recognized -Step 1 reads whatever `$ARGUMENTS` names, or asks. Recognized sources: a change `brief.md` (from pitch or capture), a journal entry (cite by ID), a spark, an idea, a brainstorm document, a Linear issue, a PR conversation, a prior Change, or plain conversation with no artifact behind it yet. +Step 1 reads whatever `$ARGUMENTS` names, or asks. Recognized sources: a brief from pitch, a journal entry (cite by ID), a spark, an idea, a brainstorm document, a Linear issue, a PR conversation, a prior issue, or plain conversation with no artifact behind it yet. + +### One-line entry + +A diagnosed fix that already has a problem and a done-check: + +```bash +loaf issue new "Fix missing --json in list help" --body "issue list --help omits --json. Out of scope: rewriting other help pages." +loaf issue dod add LOAF-42 "issue list help names --json" --command "loaf issue list --help" --expect "contains \`--json\`" +loaf issue check LOAF-42 +``` + +Two writes, then the readiness verdict. No grilling, no children, no files. --- @@ -100,53 +109,86 @@ Step 1 reads whatever `$ARGUMENTS` names, or asks. Recognized sources: a change ### Step 1: Gather Context -Parse `$ARGUMENTS` against the source inputs above. When the input names a Change folder that already has `brief.md` (or you find one for this work), treat the brief as primary: restate the problem from it, confirm with the user rather than re-discovering, and keep later grilling on solution-space (how, boundaries, verification) — pitch already framed the problem. When no brief exists, run full narrowing as today; pitch is the recommended front door for raw concepts, never a gate. Read the journal (`loaf journal recent` / `search`) for related history, and check for a prior Change touching the same area. When VISION.md, STRATEGY.md, and ARCHITECTURE.md exist, read them for strategic fit. Most consumer projects don't have them yet — when absent, shape against the journal, recent Changes, and the conversation instead, and say so in the Change's Source Inputs. +Parse `$ARGUMENTS` against the source inputs above. When a brief from pitch already frames the problem, restate it, confirm with the user rather than re-discovering, and keep later grilling on solution-space (how, boundaries, verification). When no brief exists, run full narrowing; pitch is the recommended front door for raw concepts, never a gate. Read the journal (`loaf journal recent` / `search`) for related history, and check for a prior issue touching the same area (`loaf issue list`, `loaf issue tree`). When VISION.md, STRATEGY.md, and ARCHITECTURE.md exist, read them for strategic fit. Most consumer projects don't have them yet — when absent, shape against the journal, recent issues, and the conversation instead, and say so in the issue body. ### Step 2: Evaluate Strategic Fit -When strategic docs exist, check: does this advance the vision, serve the target personas, fit technical constraints, avoid conflicting with in-flight Changes? On misalignment, **surface it to the user — never silently adjust the idea**. The user decides whether to proceed, narrow scope, or defer to reflect after this ships. +When strategic docs exist, check: does this advance the vision, serve the target personas, fit technical constraints, avoid conflicting with in-flight issues? On misalignment, **surface it to the user — never silently adjust the idea**. The user decides whether to proceed, narrow scope, or file the conflicting concern as its own backlog issue. -### Step 3: Name the Change and Initialize +### Step 3: Name the Issue and Write the Row -Once the shape of the work is nameable, confirm scope with the user, then: +Once the work is nameable, confirm scope with the user (skip this confirmation on the one-line path), then create the row. Prefer creating after the first narrowing pass so `--fog` can carry remaining unsharp questions — the CLI writes `fog` only at create. ```bash -loaf change init <slug> +loaf issue new "Rotate auth tokens on a sliding window" \ + --body "Sessions never expire while the tab stays open, so a stolen cookie is valid indefinitely. + +Out of scope: migrating existing sessions; third-party IdP support." \ + --fog "[KU] sliding-window length → grill; [UU] existing session-store conventions → blindspot pass" ``` -On a fresh slug this scaffolds `change.json` + `shape.md` + seeded `tasks/` from the embedded templates (see `templates/shape.md`, `templates/task.md`). On a capture-only folder that already has `change.json` + `brief.md` (from pitch or `init --brief`), the same command promotes in place — preserving brief and metadata verbatim while materializing `shape.md` and `tasks/` — never hand-copy templates into the folder; rely on that promotion path. Use `loaf change init <slug> --brief` only for capture-before-shape (emits `change.json` + `brief.md`). It does not switch branches — `git switch -c <slug>` yourself. Fill `shape.md` Product Contract sections as understanding solidifies; seed `tasks/TASK-NNN-slug.md` as vertical slices (a task is a commit, not a PR). Optional `plan.md`/`design.md` accrete when the how needs prose. See [references/cli-boundary.md](references/cli-boundary.md). +Default kind is `delivery`; default status is `triage`. `--status` accepts `triage`, `backlog`, `todo`, `active`, or `done`. Use `--body -` or `--body-file <path>` for a longer body; `loaf issue edit <ref>` later **replaces** the body, it does not patch it. + +A delivery issue is shaped when the body is nonempty (the problem), at least one criterion exists, and the body contains an explicit out-of-scope statement. Fill those as understanding solidifies — create can carry the first body; criteria come next. + +A discovered different problem is a new backlog issue, not a child of this one: + +```bash +loaf issue new --status backlog "Rewrite the session store" +``` ### Step 4: Narrow the Unknowns -Offer the blindspot pass when the territory is unfamiliar (a new domain, an unfamiliar subsystem, a first collaboration) — skip it when the shaper is the expert. Its fog entries, and any others surfaced in interview, route by quadrant (Quick Reference above). Loop: grill `[KU]` entries, react to `[UK]` entries, run blindspot reconnaissance on `[UU]` entries until each gets a name and re-routes. Stop grilling when no unrouted `[KU]` entries remain or answers stop changing the contract. Entries still open at the end of the session are fine — each names its owner (a section, a spike, a follow-up). +Offer the blindspot pass when the territory is unfamiliar (a new domain, an unfamiliar subsystem, a first collaboration) — skip it when the shaper is the expert. Its fog entries, and any others surfaced in interview, route by quadrant (Quick Reference above). Loop: grill `[KU]` entries, react to `[UK]` entries, run blindspot reconnaissance on `[UU]` entries until each gets a name and re-routes. Stop grilling when no unrouted `[KU]` entries remain or answers stop changing the issue. -### Step 5: Decompose into Implementation Units +When a parked question sharpens, graduate it — after the parent's DoD is written (Step 5). Attaching **any** child, including a decision, turns coverage on. -Absorbed from the retired breakdown step — see [references/decomposition.md](references/decomposition.md) for the Right Size Test and per-unit verification discipline, including the V-tier `Command:` / `Expect:` forms `loaf change verify` parses (commands run from the repository root; H-tier is never gate input). Order units by likelihood-of-change; state real sequencing constraints in prose, never by list order alone. +```bash +loaf issue new --kind decision --parent LOAF-42 "Should tokens live in httpOnly cookies?" +``` + +A decision issue is ready when the title or body contains `?`. It needs no criteria and no out-of-scope statement. A decision child does not claim a parent criterion, so promote (or otherwise claim) the parent's DoD before adding children, or keep the decision as a sibling (`loaf issue new --kind decision --status backlog`, no `--parent`) if the parent stays a leaf. Unsharp questions discovered after create stay in the session register until they graduate — there is no `--fog` on edit. See [references/decomposition.md](references/decomposition.md). + +### Step 5: Write Definition of Done (decomposition tail) + +Add criteria as the interrogation produces observable done-checks. V-tier when a command can disagree with the implementation; H-tier when only a human can tell. + +```bash +loaf issue dod add LOAF-42 "Sliding-window expiry is covered by tests" --command "go test ./internal/auth/..." --expect "exit 0" +loaf issue dod add LOAF-42 "Stolen-cookie writeup is reviewable" --tier H +``` + +`--command` implies V unless `--tier` overrides. `--expect` uses the verify grammar (`exit <N>`, `` contains `text` ``, joined by ` and `). Commands run from the repository root. See [references/cli-boundary.md](references/cli-boundary.md) and [references/decomposition.md](references/decomposition.md). + +A parent gets children only when its DoD needs more than one coherent slice. The moment a criterion earns its own DoD, promote it — the parent criterion stays, the child starts with a copy, and the claim is recorded so coverage holds for that position: -### Step 6: Fill the Planning Contract +```bash +loaf issue promote LOAF-42 1 +``` -Write the free-form `###` subsections the work actually needs (approach, placement, risks, sequencing) inside the Planning Contract container. Its subsection names are yours; the container itself, plus Implementation Units, Verification Contract, and Definition of Done, is what `loaf change check` looks for. Durable Outputs stays forward-looking here — name what a final spec, ADR, or knowledge doc will need to capture, but don't write it now. Durable artifacts get created after implementation proves what's true, not during shaping. +Then shape the child the same way (body, out-of-scope, its own criteria). Order children by likelihood-of-change when presenting them; state real sequencing with `loaf issue link <from> blocks <to>`, never by tree order alone. -### Step 7: Run the Critique Gate +### Step 6: Run the Critique Gate -Before finalizing, challenge the draft — see [references/critique-gate.md](references/critique-gate.md). Is scope still bounded, does every new command or state name its ceremony, is a status field creeping back in under another name, is the CLI/skill boundary drawn correctly, and could this be smaller and still deliver the Hypothesis? +Before finalizing, challenge the draft — see [references/critique-gate.md](references/critique-gate.md). Is scope still bounded, does every new command or state name its ceremony, is a second progress flag creeping into the body, is the CLI/skill boundary drawn correctly, and could this be smaller and still be verifiable in one fresh context window? -### Step 8: Validate +### Step 7: Validate ```bash -loaf change check +loaf issue check LOAF-42 ``` -Read violations (always block — fix them) separately from the executability report (derived, informational unless `--require-executable` is passed — that flag is implement's preflight and CI's non-draft gate, not shape's business). See [references/cli-boundary.md](references/cli-boundary.md). +A delivery issue that passes prints `issue LOAF-42 is shaped`; a decision issue prints `issue LOAF-42 is ready`. Failures always block (missing body, missing criterion, missing out-of-scope, no sharp question, uncovered parent criterion). Containment orphans are reported, not failed: each line includes a ready-to-paste remedy that files the orphan as a sibling backlog issue — run that command, do not invent a different disposition. + +`loaf issue verify <ref>` runs V-tier commands from the repository root and writes nothing. That is implement's preflight, not shape's gate. See [references/cli-boundary.md](references/cli-boundary.md). -### Step 9: Offer the Draft PR +### Step 8: Offer the Review Surface -Offer to push the branch and open a draft PR, using [the PR template](templates/pr.md) — opt-in, never automatic. `loaf change check` (with no `--require-executable`) plus `gh pr list` is the cross-branch index either way. +The issue lives in SQLite. There is no folder to commit and nothing plan-shaped to land. Offer `loaf issue show <ref>` and `loaf issue tree <ref>` as the review surface. If a PR is being opened for the work, its body is `loaf issue render <ref>` — paste-ready, no manual editing. Opt-in, never automatic. -### Step 10: Log the Outcome +### Step 9: Log the Outcome -`loaf journal log "decision(shape): <slug> shaped — N units, N open fog entries"`. +`loaf journal log "decision(shape): LOAF-42 shaped — N children, N open fog entries"`. --- @@ -154,8 +196,8 @@ Offer to push the branch and open a draft PR, using [the PR template](templates/ - **pitch** — Problem-discovery ceremony that authors a brief; preferred front door when the problem is not yet framed - **idea** — Quick capture; feeds into pitch or shape once a concept has enough weight -- **brainstorm** — Agent technique for divergent thinking (route user entry intent to pitch) -- **implement** — Starts execution once a Change is structurally executable; this does not prove implementation completion +- **brainstorm** — Agent technique for divergent thinking (route user entry to pitch) +- **implement** — Starts execution once `loaf issue check` reports the issue shaped; this does not prove implementation completion - **reflect** — Updates strategic docs after the shipped work proves what changed ## Topics @@ -165,10 +207,10 @@ Offer to push the branch and open a draft PR, using [the PR template](templates/ | Blindspot pass | [references/blindspot-pass.md](references/blindspot-pass.md) | Deciding whether to offer reconnaissance, and how to prompt it | | Grilling | [references/grilling.md](references/grilling.md) | Running the one-question-at-a-time interview for `[KU]` entries | | Reaction artifacts | [references/reaction-artifact.md](references/reaction-artifact.md) | Resolving `[UK]` entries with a variant, mock, or prototype | -| Decomposition | [references/decomposition.md](references/decomposition.md) | Sizing and ordering Implementation Units | -| CLI boundary | [references/cli-boundary.md](references/cli-boundary.md) | Reading `loaf change init`/`check`/`verify` output, or explaining `--require-executable` | +| Decomposition | [references/decomposition.md](references/decomposition.md) | Sizing slices, promoting criteria, reading coverage and containment | +| CLI boundary | [references/cli-boundary.md](references/cli-boundary.md) | Reading `loaf issue` output, authoring `--command`/`--expect`, or explaining `loaf issue check` | | Critique Gate | [references/critique-gate.md](references/critique-gate.md) | Self-challenging scope and boundaries before finalizing | ## Artifact Naming -Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or Change already records that provenance. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. +Shape's deliverable is the issue row. If a reaction artifact or spike note lands on disk, name it for what it is, never for the issue that produced it. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. diff --git a/dist/opencode/commands/ship.md b/dist/opencode/commands/ship.md index 22a19a0ad..842f6566e 100644 --- a/dist/opencode/commands/ship.md +++ b/dist/opencode/commands/ship.md @@ -1,16 +1,19 @@ --- description: >- - Reviews, verifies, and lands one pull request. Use when the user says "ship - it," "merge this PR," "ready to merge," "land this branch," or asks for a - final merge gate. Produces a reviewed, squash-merged PR and post-merge - cleanup. Not for version bumps, tags, GitHub Releases, or install verification - (use release). + Reviews, verifies, and lands one pull request — the sole quality gate before + work can appear in a later release cut. Use when the user says "ship it," + "merge this PR," "ready to merge," "land this branch," or asks for a final + merge gate. Binds the PR to an issue: the body is `loaf issue render` output, + definition-of-done criteria are the review checklist, and landing marks the + issue done and stops its worktree. Produces a reviewed, squash-merged PR and + post-merge cleanup. Not for version bumps, tags, GitHub Releases, or install + verification (use release). version: 0.2.21 --- # Ship -Review, verify, and land one PR. Shipping is the PR gate; releasing is the version-publication gate. +Review, verify, and land one PR. Ship's review is the quality gate for everything that will ever ship. Releases are retroactive — they cut a version from already-landed work. Nothing between merge and the next release cut re-checks the diff, the tests, or the issue. A rubber-stamped PR ships in the next cut with no second net. ## Contents - Critical Rules @@ -20,7 +23,7 @@ Review, verify, and land one PR. Shipping is the PR gate; releasing is the versi - Context Detection - Step 1: PR Readiness - Step 1b: Stacked PR Detection -- Step 2: Evidence Review +- Step 2: Definition-of-Done Review - Step 3: Local Verification - Step 4: Squash Merge - Step 5: Post-Merge Cleanup @@ -34,64 +37,94 @@ Review, verify, and land one PR. Shipping is the PR gate; releasing is the versi ## Critical Rules -- **Ship is not release** -- do not bump versions, create tags, publish GitHub Releases, or verify package installation here. -- **Keep PR quality local** -- smaller PRs are welcome, but ship must still verify correctness before merge. -- **Detect-first** -- auto-detect the PR from the current branch before asking for a PR number. -- **Review before merge** -- inspect code, docs, tests, changelog, PR body, and CI state before approval. -- **Never merge without explicit confirmation** -- present the PR, checks, findings, and squash body first. -- **Detect the stack before merging** -- another open PR may use this PR's head branch as its base. Find out before merge, never after, and never delete a head branch while a child PR still points at it. -- **Clean squash body** -- write an intentional squash commit body; never accept the automatic commit dump. -- **Keep landed and released distinct** -- after merge, describe the PR as landed or shipped, not necessarily released. -- **Log shipping** -- after merge, run `loaf journal log "decision(ship): PR #N landed via squash merge"`. +1. **Log invocation first** — `loaf journal log "skill(ship): shipping <ref or PR or current branch>"` before doing anything else. After merge, log `loaf journal log "decision(ship): PR #N landed via squash merge; <ref> done"`. +2. **Rigor is load-bearing** — this review is the only quality gate. Releases cut from landed work; they do not re-check. If the review is thin, the next cut still publishes it. +3. **Ship is not release** — do not bump versions, create tags, publish GitHub Releases, or verify package installation here. Use the release skill for that. +4. **Bind the PR to an issue** — the PR body is `loaf issue render <ref>` (paste-ready, no manual editing). The issue's definition-of-done criteria are the review checklist. `loaf issue verify <ref>` runs the executable (V-tier) rows and writes nothing. Landing means `loaf issue status <ref> done`. Then `loaf issue stop <ref>` removes the started worktree. +5. **Detect-first** — auto-detect the PR from the current branch, and the issue from `$ARGUMENTS` or the started workspace, before asking for a PR number or issue ref. +6. **Review before merge** — inspect code, docs, tests, changelog, the rendered issue body, definition of done, and CI state before approval. +7. **Never merge without explicit confirmation** — present the PR, checks, review notes, and squash body first, using your harness's structured question tool if it has one. +8. **Detect the stack before merging** — another open PR may use this PR's head branch as its base. Find out before merge, never after, and never delete a head branch while a child PR still points at it. +9. **Clean squash body** — write an intentional squash commit body; never accept the automatic commit dump. +10. **Keep landed and released distinct** — after merge, describe the PR as landed or shipped. It is not released until release publishes a version. + +--- ## Verification +- Invocation is logged to the project journal before review work begins - PR identity, base branch, and head branch are confirmed +- The PR is bound to one issue; `loaf issue show <ref>` is the issue surface +- PR body matches `loaf issue render <ref>` with no manual editing +- Every definition-of-done criterion was reviewed against the diff; H-tier by reading, V-tier by `loaf issue verify <ref>` (writes nothing; exit non-zero blocks) - CI status is passing or the user explicitly accepts named non-blocking checks - Relevant local checks pass or failures are fixed before merge - PR body and durable docs do not overclaim relative to the diff - Squash commit title/body are clean, conventional, and user-facing - Child PRs stacked on this PR's head branch are enumerated before merge, and each is retargeted, rebased, and re-verified after it -- Base branch is updated after merge and the feature branch cleanup state is known +- After merge: `loaf issue status <ref> done`, then `loaf issue stop <ref>` if a worktree was started, base branch updated, feature-branch cleanup state known +- A `decision(ship)` journal entry records the landing ## Quick Reference | Step | Gate | Blocking? | |------|------|-----------| +| Context Detection | PR and issue bound | Yes | | PR Readiness | PR exists, target base known, CI state reviewed | Yes | | Stacked PR Detection | child PRs on this head branch are enumerated | Yes | -| Evidence Review | findings resolved or explicitly accepted | Yes | -| Local Verification | relevant checks pass | Yes | +| Definition-of-Done Review | every criterion reviewed; `loaf issue verify` passes or reports no V-tier rows | Yes | +| Local Verification | relevant project checks pass | Yes | | Squash Merge | user approves body text | Yes | -| Cleanup | base pulled, children retargeted and rebased, branch deletion handled | Yes when a child exists | -| Release Suggestion | enough landed work may justify release | No | +| Cleanup | issue marked done, started worktree stopped, base pulled, children retargeted and rebased, branch deletion handled | Yes when a child PR exists; done + stop always | +| Release Suggestion | enough landed work may justify a later cut | No | ## Topics | Topic | Use When | |-------|----------| -| [Context Detection](#context-detection) | Determining current branch and PR state | +| [Context Detection](#context-detection) | Binding the current branch to a PR and an issue | +| [Definition-of-Done Review](#step-2-definition-of-done-review) | Using issue criteria as the merge checklist | | [Hook Interaction](#hook-interaction) | Understanding coexistence with git hooks | --- ## Context Detection -Before anything, detect the PR surface: +Log the invocation, then detect the PR and the issue. + +### PR 1. Get current branch and repo default branch: ```bash git branch --show-current gh repo view --json defaultBranchRef -q .defaultBranchRef.name ``` -2. Parse `$ARGUMENTS`: may be a PR number, PR URL, branch name, or empty. -3. If `$ARGUMENTS` is empty, auto-detect from the current branch: +2. Parse `$ARGUMENTS`: may be an issue ref (`LOAF-42`), a PR number, a PR URL, a branch name, or empty. +3. If `$ARGUMENTS` is empty or is not a PR identity, auto-detect from the current branch: ```bash gh pr view --json number,title,url,headRefName,baseRefName,state,mergeStateStatus,isDraft ``` 4. If no PR exists for the current branch, stop and offer to create one via `git-workflow` rather than silently merging a branch. 5. If already on the default branch, stop. There is no PR to ship from the current branch. -6. Confirm PR identity with the user before merge actions. + +### Issue + +Issue commands require initialized SQLite state. Bind exactly one issue: + +1. If `$ARGUMENTS` (or a remaining token) is an issue ref, load it: + ```bash + loaf issue show <ref> + ``` +2. Otherwise match the PR's `headRefName` to a started workspace: + ```bash + loaf issue list --started + ``` + Columns are alias, title, `started_branch`, `started_worktree`. The started branch from `loaf issue start` is `issue/<alias-or-id>` in lowercase (`issue/loaf-42`), disambiguated with an id suffix when that name is already claimed. +3. Confirm with `loaf issue show <ref>` — `started_branch` / `started_worktree` should match this PR when the issue was started. + +If nothing binds, stop and ask for the issue ref. Do not invent a row during ship. If `loaf issue show` reports the issue archived (`cancelled` or `duplicate`), stop. + +Confirm PR identity and the bound issue with the user before merge actions. --- @@ -103,6 +136,14 @@ Inspect the PR's declared state: gh pr view <N> --json number,title,body,url,headRefName,baseRefName,state,isDraft,mergeStateStatus,reviewDecision,statusCheckRollup ``` +The body must be `loaf issue render <ref>` output — title, issue body, definition-of-done checkboxes (checked only when status is already `done`), and children. No project headers, no hand-edited summary. If the live PR body differs, replace it: + +```bash +gh pr edit <N> --body "$(loaf issue render <ref>)" +``` + +Do not rewrite the markdown by hand. Checkboxes are unchecked until `loaf issue status <ref> done`; do not tick them in the PR body to fake completion. + Block or pause when: - PR is draft @@ -123,20 +164,27 @@ Before merging anything, find out whether another open PR uses this PR's head br gh pr list --state open --base <headRefName> --json number,title,headRefName ``` -Any result is a **child PR**, and this PR is the base of a stack. Record the list now, because after the merge the relationship is harder to see and easier to break. +Any result is a **child PR**, and this PR is the base of a stack. Record the list now, because after the merge the relationship is harder to see and easier to break. Child PRs are GitHub stacking, not child issues. -When a child exists: +When a child PR exists: - **Do not pass `--delete-branch` to the merge.** Removing the head branch while a child still points at it can close the child outright. Delete it only after every child has been retargeted, in Step 5. - Tell the user the stack exists and name the children before asking for merge confirmation. A stack changes what "merge this" means. -When no child exists, say so, and `--delete-branch` is safe. +When no child PR exists, say so, and `--delete-branch` is safe. --- -## Step 2: Evidence Review +## Step 2: Definition-of-Done Review + +The issue's definition of done is the merge checklist. Load it from the issue, not from memory: + +```bash +loaf issue dod list <ref> +loaf issue show <ref> +``` -Review the landing diff and durable prose together: +`show` prints each criterion as `position. [V|H] text` with `command=` / `expect=` when present. Walk every row against the landing diff. 1. Gather diff context: ```bash @@ -144,13 +192,19 @@ Review the landing diff and durable prose together: git diff --stat origin/<baseRefName>...HEAD git diff --name-only origin/<baseRefName>...HEAD ``` -2. Read the PR title/body and changed docs that make behavior claims. -3. Check for drift: - - PR body claims features that are not in the diff - - changelog entries mention unreleased or unrelated behavior +2. For each **H-tier** criterion, read the diff and durable prose and decide whether the text is met. `loaf issue verify` skips H-tier rows — that skip is not a pass; you are the check. +3. Run the **V-tier** rows: + ```bash + loaf issue verify <ref> + ``` + Commands run from the **repository root**. The command honors `exit <N>` and `` contains `text` ``. It writes nothing — it does not tick checkboxes, does not set status, and does not record a run. Non-zero exit blocks merge. `no executable V-tier criteria on <ref>` is not a failure; H-tier review still is. Unenforceable expect clauses print as advisory warnings and are never quietly decorative. +4. Check for drift: + - Rendered issue body claims features that are not in the diff + - changelog entries mention unrelated behavior - docs describe future work as already shipped - - comments or runbooks use stale internal vocabulary -4. Fix blocking drift before merge. For non-blocking polish, name it and let the user decide. + - comments or runbooks use stale vocabulary +5. If `loaf issue show` lists child issues that are not `done`, name them before asking to merge. Do not mark those children done unless this PR is theirs. +6. Fix blocking drift and unmet criteria before merge. For non-blocking polish, name it and let the user decide. For high-risk PRs, use the project's review skill or read-only review flow before proceeding. @@ -158,7 +212,7 @@ For high-risk PRs, use the project's review skill or read-only review flow befor ## Step 3: Local Verification -Run the checks the project supports. Examples: +Run the checks the project supports, in addition to `loaf issue verify`. Examples: - Node: `npm run typecheck`, `npm run test`, `npm run build` - Go: `go vet ./...`, `go test ./...` @@ -177,7 +231,7 @@ Use the repo's documented pre-commit or pre-PR checklist when present. Stop on f ## Step 4: Squash Merge -Draft a clean squash body from the reviewed diff and PR body: +Draft a clean squash body from the reviewed diff and the rendered issue: - One-line summary, then bullet points grouped by feature area - Plain text; use backticks only for code identifiers @@ -200,31 +254,41 @@ Let GitHub default the title from the PR title so the squash subject remains `ty ## Step 5: Post-Merge Cleanup -After a successful merge: +After a successful merge, leave the started worktree before removing it. Do not run `loaf issue stop` from inside that worktree. -1. Switch to the PR base branch: +1. Switch to the PR base branch in the repository checkout: ```bash git checkout <baseRefName> git pull --ff-only origin <baseRefName> ``` -2. Delete the local feature branch when safe: +2. Mark the bound issue done — this is what "done" means; `loaf issue stop` does not change status: + ```bash + loaf issue status <ref> done + ``` + Write statuses (`triage`, `backlog`, `todo`, `active`, `done`) update in place. Do not use `cancelled` or `duplicate` here. +3. Stop the started worktree if one exists. `loaf issue stop` removes the worktree, clears `started_branch` / `started_worktree` on the row, and **keeps the branch**: + ```bash + loaf issue stop <ref> + ``` + If the issue was never started, the command errors with `issue <ref> is not started` — treat that as already clean and continue. If the worktree is dirty, do not pass `--force` without user confirmation. +4. Delete the local feature branch when safe: ```bash git branch -d <headRefName> ``` -3. Confirm the remote branch deletion state from GitHub output or run: +5. Confirm the remote branch deletion state from GitHub output or run: ```bash gh pr view <N> --json headRefName,state ``` -4. Log the landing to the project journal: +6. Log the landing: ```bash - loaf journal log "decision(ship): PR #N landed via squash merge" + loaf journal log "decision(ship): PR #N landed via squash merge; <ref> done" ``` -If cleanup fails, report the exact residual state. Do not force-delete without user confirmation. +If cleanup fails, report the exact residual state (issue status, whether the worktree is still started, which branches remain). Do not force-delete without user confirmation. ### Stacked child PRs -Every child recorded in Step 1b needs three repairs before it can ship, and none of them happen on their own. +Every child PR recorded in Step 1b needs three repairs before it can ship, and none of them happen on their own. **Retarget the base.** GitHub does not reliably move a child's base when its base branch merges. Check, and move it explicitly: @@ -246,7 +310,7 @@ git diff --stat <baseRefName>...HEAD | tail -1 gh pr view <child> --json changedFiles -q .changedFiles ``` -Then force-push with a lease, wait for CI to finish on the **rebased head** rather than trusting the previous run, and only then ship the child. Delete the parent's head branch once no child points at it. +Then force-push with a lease, wait for CI to finish on the **rebased head** rather than trusting the previous run, and only then ship the child. Delete the parent's head branch once no child PR points at it. Report the numbers rather than asserting success: the before and after file counts, the commits the rebase skipped, and the CI conclusion on the new head. @@ -254,11 +318,11 @@ Report the numbers rather than asserting success: the before and after file coun ## Step 6: Release Suggestion -After landing, decide whether to suggest release: +After landing, decide whether to suggest the release skill. That cut will not re-run this review. -- Suggest release when the landed PR completes a coherent batch, user-facing feature, fix train, or release branch. +- Suggest release when the landed PR completes a coherent batch, user-facing feature, or fix train. - Do not suggest release for every small PR by default. -- If multiple related PRs are expected, say the PR is landed and can wait for a later batched release. +- If more related PRs are expected, say this PR is landed and can wait for a later cut. Use language carefully: the PR is **landed** or **shipped**; it is not **released** until release publishes a version. @@ -282,12 +346,12 @@ Do not disable hooks to force a PR through. ## Suggests Next -After a successful ship, suggest release only when the landed work forms a coherent release batch or the user asks to publish. +After a successful ship, suggest release only when the landed work forms a coherent batch or the user asks to publish. Release will not re-check the landed PR. ## Related Skills -- **release** -- Publishes a version from already-landed work -- **git-workflow** -- Branching, PR, commit, and squash merge conventions -- **foundations** -- Verification, code review, and production readiness -- **documentation-standards** -- Changelog, docs, and durable prose quality -- **reflect** -- Updates strategy from significant shipped work +- **release** — Publishes a version from already-landed work; does not re-review those PRs +- **git-workflow** — Branching, PR, commit, and squash merge conventions +- **foundations** — Verification, code review, and production readiness +- **documentation-standards** — Changelog, docs, and durable prose quality +- **reflect** — Updates strategy from significant shipped work diff --git a/dist/opencode/commands/triage.md b/dist/opencode/commands/triage.md index 10223d761..19a336a27 100644 --- a/dist/opencode/commands/triage.md +++ b/dist/opencode/commands/triage.md @@ -1,13 +1,13 @@ --- description: >- Processes the local intake queue from loaf intake list: unresolved sparks, - ideas, brainstorms, tracked and deferred Intents, and unmigrated legacy - deferrals. Use when the user asks "triage", "process my backlog", or wants - dispositions chosen across intake items. Produces explicit dispositions: - discard, retain, track as Intent, defer, resume, resolve, explore, hand to - pitch, or hand to shape. Not for reading a single known item (use loaf intent - show or journal directly), capturing new ideas (use idea), problem discovery - (use pitch), or bounding one chosen direction (use shape). + ideas, and brainstorms. Use when the user asks "triage", "process my backlog", + or wants dispositions chosen across intake items. Produces explicit + dispositions: discard, retain as spark/idea, file as backlog issue, resume + exploration, resolve, hand to pitch, or hand to shape (issue preparation). Not + for reading a single known item (use loaf issue show, loaf spark show, loaf + idea show, or journal directly), capturing new ideas (use idea), problem + discovery (use pitch), or bounding one chosen direction (use shape). user-invocable: true version: 0.2.21 --- @@ -26,7 +26,7 @@ Process the intake queue. Triage is the public funnel where captured material me - Quick Reference - Process - Dispositions -- Legacy Deferrals +- Leftover kinds - Guardrails - Related Skills @@ -36,62 +36,67 @@ Process the intake queue. Triage is the public funnel where captured material me - Read the queue with `loaf intake list --json`; it projects every unresolved logical item exactly once with its provenance and exact read command. - Present everything before acting — the user decides each disposition; never auto-promote, auto-discard, or auto-convert. - The CLI never classifies: you and the user interpret each item; commands perform the chosen operation deterministically. -- Capture, Intent, and Exploration are different claims: a spark or idea is retained material, a tracked Intent is deliberately tracked work, a deferral is an Intent disposition with an immutable payload, an Exploration is an inquiry. Do not conflate them to save a step. +- Capture, issue, and Exploration are different claims: a spark or idea is retained material, a backlog issue is deliberately tracked work, an Exploration is an inquiry. Do not conflate them to save a step. - One pass through the queue — don't loop or re-present items. -- **Two doors into a Change:** items needing problem discovery hand to pitch, which owns `loaf change init <slug> --brief` and brief authoring; well-understood directions hand to shape. When capture should precede shaping without a full pitch, run `loaf change init <slug> --brief` and seed `brief.md` with the original ask, then hand to shape. +- **Two doors into issue work:** items needing problem discovery hand to pitch; well-understood directions hand to shape (issue preparation). Worth keeping but not ready for either door files as a backlog issue (`loaf issue new "<title>" --status backlog`, optional `--parent`, optional `loaf issue bucket`). Triage never runs `loaf issue start`, never opens PRs, and never invents Git artifacts. ## Verification - Every presented item has a recorded disposition or an explicit "leave for next triage". -- Tracked and deferred choices exist as Intents with the expected derived disposition (`loaf intent list`). +- Filed directions exist as backlog issues (`loaf issue list --status backlog`) and no longer appear in `loaf intake list` once their captures are resolved or archived. - Discards are resolved or archived through their own commands and no longer appear in `loaf intake list`. -- No Linear or tracker operation was attempted; publication is a later concern outside this Change. +- No Linear or tracker operation was attempted; publication is a later concern outside triage. ## Quick Reference | Item kind | Comes from | Typical dispositions | |-----------|-----------|----------------------| -| spark | `loaf spark capture` moments | discard, promote to idea, track as Intent | -| idea | idea capture | archive, explore, track as Intent, hand to pitch, hand to shape | -| brainstorm | archived divergent sessions | archive, explore, promote, hand to pitch | -| intent (tracked) | `loaf intent create` | keep tracking, defer, resolve, explore, hand to pitch, hand to shape | -| intent (deferred) | `loaf intent defer` or adapter | resume, resolve, leave deferred | -| legacy_deferral | pre-conversion `journal defer` | read, then optionally convert (see Legacy Deferrals) | +| spark | `loaf spark capture --scope <scope> --text <text>` | discard, retain, promote to idea, file as backlog issue, resume exploration, resolve, hand to pitch, hand to shape | +| idea | `loaf idea capture --title "<title>"` | archive, retain, file as backlog issue, resume exploration, resolve, hand to pitch, hand to shape | +| brainstorm | `loaf brainstorm capture` | archive, retain, promote to idea, file as backlog issue, resume exploration, hand to pitch, hand to shape | ## Process 1. **Scan.** Run `loaf intake list --json`. Summarize counts by kind, then list each item with its title, disposition or status, and read command. 2. **Read on demand.** Use each item's `read_command` verbatim when the user wants detail before deciding. If a read command fails, record the exact command and error in the summary as `unreadable`, make no semantic disposition for that item, continue the pass, and offer a factual diagnostic step (`loaf state doctor --json`) afterward. Never persist unreadable as a status. 3. **Decide per item.** Present the applicable dispositions and perform exactly the chosen one. -4. **Summarize.** Report what was discarded, retained, tracked, deferred, resumed, resolved, or handed onward, and journal notable decisions. +4. **Summarize.** Report what was discarded, retained, filed as backlog issues, resumed as explorations, resolved, or handed to pitch or shape, and journal notable decisions. ## Dispositions - **Discard** — ideas and brainstorms: `loaf idea archive <ref> --reason <r>` or `loaf brainstorm archive <ref> --reason <r>`. A spark is resolved against the entity that addressed it (`loaf spark resolve <ref> --by <entity> --reason <r>`); a pure dead-end spark currently has no deterministic discard operation — leave it retained, journal the judgment, and never invent a resolving entity. -- **Retain as capture** — do nothing; open captures resurface next triage. -- **Track as Intent** — two steps: create the Intent with the capture as its source, then close the capture against it so the direction appears once. `loaf intent create --title <t> --body <self-sufficient body> --from <capture-ref>`, then `loaf spark resolve <capture-ref> --by <intent-ref>` or `loaf idea resolve <capture-ref> --by <intent-ref>` (brainstorms: `loaf brainstorm archive <ref> --reason "tracked as <intent-ref>"`). -- **Defer** — an existing Intent: `loaf intent defer <ref> --why <w> --boundary <b> --trigger <t> --operation-id <key>`; a new deferred direction needs the full skeleton: `loaf intent create --title <t> --body <b> --disposition deferred --why <w> --boundary <bd> --trigger <tr> --operation-id <key> [--from <source-ref>]`. -- **Resume** — `loaf intent resume <ref> --reason <why now>`; appends a tracked disposition linked to the deferral it supersedes. -- **Resolve** — `loaf intent resolve <ref> --reason <outcome>`; history is never rewritten. -- **Explore** — agent technique for genuinely undecided directions (Explorations and checkpoints); not a user slash entry — prefer pitch when the human needs problem discovery first, then reach for explore from inside that work if still undecided. -- **Pitch** — items needing problem discovery hand to pitch, which owns init and brief authoring; resolve the promoted item against the created change (`loaf spark resolve` / `loaf idea resolve` / archive brainstorm with the change as the reason). -- **Shape** — when a direction is already well-understood and ready for bounded delivery, hand it to shape; triage never creates fully-materialized Changes, branches, or worktrees (capture-only brief seeding is the Critical Rules exception above). +- **Retain as spark/idea** — do nothing to leave the capture open, or promote into the other capture primitive: capture the idea first (`loaf idea capture --title "..."`), then `loaf spark promote <spark> --to-idea <idea>` or `loaf brainstorm promote <brainstorm> --to-idea <idea>`. Open captures resurface next triage. +- **File as backlog issue** — two steps so the direction appears once. Create the issue, then close the capture against it: -## Legacy Deferrals + ```bash + loaf issue new "<title>" --status backlog [--parent <ref>] [--kind delivery|decision] [--fog <text>] [--body <text>] + loaf issue bucket <issue-ref> now|next|later # optional; labels only, never a constraint + loaf spark resolve <capture-ref> --by <issue-ref> + # or: loaf idea resolve <capture-ref> --by <issue-ref> + # brainstorms: loaf brainstorm archive <ref> --reason "filed as <issue-ref>" + ``` -Items of kind `legacy_deferral` are pre-conversion `journal defer` records. They stay visible and readable until the explicit, backup-first conversion is run; nothing disappears while migration is pending. When the user wants them converged, offer `loaf state migrate deferrals --dry-run` to preview the project-specific manifest and `--apply` only with explicit consent — apply verifies a whole-database backup first and preserves every legacy row. + Use `--kind decision` when filing a sharp question. Copy still-unsharp questions into `--fog` (create-time only). `--parent` nests under an existing issue; omit it for a different problem. +- **Resume exploration** — agent technique for genuinely undecided directions (Explorations and checkpoints); not a user slash entry. Prefer pitch when the human needs problem discovery first, then reach for explore from inside that work if still undecided. Resume with `loaf exploration context <ref>` when a named Exploration already exists. +- **Resolve** — the capture is already represented elsewhere. `loaf spark resolve <ref> --by <entity> --reason <r>` or `loaf idea resolve <ref> --by <entity>`. History is never rewritten. +- **Hand to pitch** — items needing problem discovery hand to pitch. Resolve the capture against the issue once one exists (`loaf spark resolve` / `loaf idea resolve --by <issue-ref>` / archive the brainstorm with that issue as the reason). +- **Hand to shape** — when a direction is already well-understood and ready for bounded delivery, hand it to shape for issue preparation. Triage never writes definition-of-done criteria, never runs `loaf issue check`, and never creates branches or worktrees. + +## Leftover kinds + +`loaf intake list` may still include `intent` and `legacy_deferral` items. Do not create new `intent` rows. Treat leftover directions like any other capture: file a backlog issue if worth keeping, or leave them for a later pass. Do not offer conversion commands that recreate the old tracked/deferred row. ## Guardrails 1. **User decides every disposition** — present, don't decide. 2. **Batch presentation, individual decisions** — show the full queue, then process one item at a time. 3. **Log everything** — no silent discards, promotions, or conversions. -4. **Deferred is not forgotten** — deferred Intents remain active truth in `loaf journal context` until resumed or resolved. +4. **Filed is not forgotten** — backlog issues remain on `loaf issue list` and may appear on `loaf issue frontier` until their status changes. Buckets are labels only. ## Related Skills - **idea** — capture a new idea (fast, minimal friction) - **pitch** — problem-discovery ceremony for items that need framing before shape - **explore** — agent technique for divergent inquiry with portable checkpoints -- **shape** — develop a well-understood direction into a bounded Change +- **shape** — develop a well-understood direction into a bounded issue - **housekeeping** — flags stale artifacts; does not choose dispositions diff --git a/dist/opencode/commands/wrap.md b/dist/opencode/commands/wrap.md index fd8335767..d0ab4efa5 100644 --- a/dist/opencode/commands/wrap.md +++ b/dist/opencode/commands/wrap.md @@ -136,7 +136,7 @@ After the wrap-up report, suggest housekeeping if it wasn't run this session and ## Report Format -Use backtick formatting for code identifiers, file paths, spec/task IDs, version numbers, status values, and CLI commands. Use uppercase for spec and task IDs (`SPEC-029`, not `spec-029`). +Use backtick formatting for code identifiers, file paths, issue IDs, version numbers, status values, and CLI commands. Use uppercase for issue IDs (`LOAF-29`, not `loaf-29`). ```markdown ## Session Wrap-Up diff --git a/dist/opencode/plugins/hooks/instructions/post-merge.md b/dist/opencode/plugins/hooks/instructions/post-merge.md index 35d3b90d3..9c8f183e1 100644 --- a/dist/opencode/plugins/hooks/instructions/post-merge.md +++ b/dist/opencode/plugins/hooks/instructions/post-merge.md @@ -1,51 +1,37 @@ **Note:** If you used the ship workflow, these steps were already handled by the skill. This checklist is for manual merges. -# Pre-Merge Checklist +# Post-Merge Housekeeping -Complete these steps on the feature branch before creating the PR. +Complete these steps after a successful squash merge. Leave the started worktree before removing it — do not run `loaf issue stop` from inside that worktree. -1. **Close out spec artifacts** (so they're included in the squash merge): +1. **Switch to the PR base and pull:** ``` - loaf task update TASK-XXX --status done - loaf task archive --spec SPEC-XXX - loaf spec archive SPEC-XXX + git checkout <baseRefName> + git pull --ff-only origin <baseRefName> ``` - Write an optional `wrap(scope)` journal entry with `loaf journal log` if the work produced synthesis worth saving. - -2. **Update CHANGELOG.md when the PR has release-facing impact:** - Add curated entries under `[Unreleased]` describing what the PR lands. Do not move entries to a versioned section here; the release workflow publishes the batch later. -3. **Rebuild all targets:** +2. **Mark the bound issue done** — this is what "done" means; `loaf issue stop` does not change status: ``` - npx loaf build + loaf issue status <ref> done ``` -4. **Commit and push** the changelog and generated artifacts to the PR branch. - -5. **Create PR** with `gh pr create` — title + summary + test plan. - -6. **Squash merge** with a clean commit body: - - Let GitHub default the title: `PR title (#N)` - - Write a concise 2-4 sentence summary as `--body` (use a HEREDOC) - - **Never** use the automatic squash description that dumps all individual commit messages - ---- - -# Post-Merge Housekeeping - -Complete these steps on main after merging. +3. **Stop the started worktree** if one exists (`loaf issue list --started`). `loaf issue stop` removes the worktree, clears `started_branch` / `started_worktree`, and keeps the branch: + ``` + loaf issue stop <ref> + ``` + If the issue was never started, the command errors with `issue <ref> is not started` — treat that as already clean and continue. Do not pass `--force` without user confirmation. -1. **Switch to main and pull:** +4. **Delete the local feature branch** when safe: ``` - git checkout main && git pull --rebase + git branch -d <headRefName> ``` -2. **Delete merged feature branch:** +5. **Log the landing:** ``` - git branch -d feat/xxx - git push origin --delete feat/xxx + loaf journal log "decision(ship): PR #N landed via squash merge; <ref> done" + loaf journal log "commit(<hash>): <squash subject>" ``` -3. **Suggest reflection** if the session had key decisions or learnings. +6. **Suggest reflection** if the work produced key decisions or learnings. -4. **Suggest release only when appropriate** — if this PR completes a coherent batch or release branch, publish from the base branch after the landed work is present there. +7. **Suggest release only when appropriate** — if this PR completes a coherent batch, publish later with `loaf release suggest` / `loaf release cut`. The PR is landed, not released, until that cut. diff --git a/dist/opencode/plugins/hooks/instructions/pre-pr-checklist.md b/dist/opencode/plugins/hooks/instructions/pre-pr-checklist.md index d92539828..75129d985 100644 --- a/dist/opencode/plugins/hooks/instructions/pre-pr-checklist.md +++ b/dist/opencode/plugins/hooks/instructions/pre-pr-checklist.md @@ -50,13 +50,10 @@ No scope prefixes. No SPEC/TASK IDs in the title. ### 3. PR body -```markdown -## Summary -- Key changes (2-4 bullets) +The body is `loaf issue render <ref>` output. No project headers, no hand-edited summary. Checkboxes stay unchecked until `loaf issue status <ref> done`. -## Test plan -- [ ] Tests added/updated -- [ ] Manual testing performed +``` +gh pr create --title "type: summary" --body "$(loaf issue render <ref>)" ``` ### 4. Merge strategy diff --git a/dist/opencode/skills/bootstrap/SKILL.md b/dist/opencode/skills/bootstrap/SKILL.md index 33ceb4e3f..e9ca2819a 100644 --- a/dist/opencode/skills/bootstrap/SKILL.md +++ b/dist/opencode/skills/bootstrap/SKILL.md @@ -30,7 +30,7 @@ First-contact project setup: detect state, interview the builder, populate proje - Guardrails - Related Skills -Series-prep lives under Finalization (phase between Knowledge Base Scaffolding and Next Steps). +Series-prep lives under Finalization (phase between Knowledge Base Scaffolding and Next Steps): the initial arc becomes backlog issues, not folders. **Input:** $ARGUMENTS @@ -43,8 +43,8 @@ Series-prep lives under Finalization (phase between Knowledge Base Scaffolding a - **Always interview** -- even with a rich brief, confirm understanding through structured questions — one at a time, with a recommendation, using your harness's structured question tool if it has one - **Pitched BRIEF is discovery-already-done** -- when `docs/BRIEF.md` has `source: pitch`, do not re-excavate the problem space; quote-back and gap-fill only for operating-document population - **BRIEF is input, not output** -- the BRIEF is raw intake. Extract every useful fact into VISION/STRATEGY/ARCHITECTURE/AGENTS during bootstrap. -- **BRIEF is archeological after bootstrap** -- once extraction completes (including series-prep reading scoped concepts from it), the BRIEF is a frozen historical snapshot. No skill, agent, command, or template should reference `docs/BRIEF.md` post-bootstrap. Operating documents and minted change briefs must stand on their own. -- **Series-prep never auto-shapes and never creates branches** -- every mint is user-confirmed; no priority, date, or dependency fields; concepts without a coarse `target_release` stay BRIEF lines, sparks, or Intents +- **BRIEF is archeological after bootstrap** -- once extraction completes (including series-prep reading scoped concepts from it), the BRIEF is a frozen historical snapshot. No skill, agent, command, or template should reference `docs/BRIEF.md` post-bootstrap. Operating documents and minted issue bodies must stand on their own. +- **Series-prep never auto-shapes and never creates branches** -- every mint is user-confirmed; no priority, date, or dependency fields; buckets are labels, never bindings; concepts that fail granularity stay BRIEF lines or sparks - **Suggest, don't execute** -- recommend next skills at the end, never auto-run them - **Log first** -- log invocation before interviewing: `loaf journal log "skill(bootstrap): <project or intake>"` - **Log outcome** -- log bootstrap completion to the project journal: `loaf journal log "decision(bootstrap): project bootstrapped, mode detected"` @@ -56,7 +56,7 @@ Series-prep lives under Finalization (phase between Knowledge Base Scaffolding a - All expected operating documents (`docs/VISION.md`, `AGENTS.md` at minimum) exist and contain populated content - Useful BRIEF content has been extracted into operating documents (no future reader should need to open the BRIEF) - When `source: pitch`, the interview was gap-only (no re-excavation of already-specific problem sections) -- When series-prep ran: each minted folder has `change.json` with stamped `target_release`, a standalone problem-space `brief.md`, zero-violation captured state via explicit-path `loaf change check <folder> --json`, and its own docs-only commit (never a batch); no branches created for the series; no auto-shape +- When series-prep ran: each minted row is a backlog issue (`loaf issue new "<title>" --body "<problem narrative>" --status backlog`) with a standalone problem-space body; an advisory bucket (`loaf issue bucket <ref> now|next|later`) may be set — buckets are labels, never bindings; `loaf issue check <ref>` only when a capture is shaped enough to check, otherwise nothing (a backlog issue with a problem body needs no ceremony); no folders, no docs-only commits; no branches created for the series; no auto-shape - Root `AGENTS.md` is a real file; on Claude Code, the compatibility symlink `.claude/CLAUDE.md -> ../AGENTS.md` exists (see Finalization) - Key decisions and interview outcomes were logged with `loaf journal log` and are readable with `loaf journal recent` @@ -237,7 +237,7 @@ Pitch owned the problem-space grill. Bootstrap does not re-excavate. The pitch 1. **Acknowledge the pitch** — name that `docs/BRIEF.md` carries `source: pitch` and that problem discovery is already done. 2. **Summarize what pitch captured** — short section-by-section gist (problem, who, alternatives, value, constraints, sequencing, open questions). The builder should hear continuity with the pitch closing ceremony, not a cold restart. -3. **State what bootstrap will do now** — interview only on gaps for operating-document population (VISION, STRATEGY, ARCHITECTURE, AGENTS), then series-prep the initial arc of captured changes. Do not re-grill the problem space. +3. **State what bootstrap will do now** — interview only on gaps for operating-document population (VISION, STRATEGY, ARCHITECTURE, AGENTS), then series-prep the initial arc as backlog issues. Do not re-grill the problem space. Then continue: @@ -424,58 +424,62 @@ The journal should capture: Use [templates/journal.md](templates/journal.md) only as the rendered entry format reference; do not hand-author journal markdown as the source of truth. -### 4. Series-Prep (initial arc as captured changes) +### 4. Series-Prep (initial arc as backlog issues) -After operating documents are populated (and Knowledge Base Scaffolding above has run), close bootstrap by minting the BRIEF's initial arc as **captured promise carriers** — brief-only change folders bound to a coarse `target_release`, each landed as its own docs-only commit. Series-prep is not roadmap planning: no milestone entities, no dates, no priorities, no dependency fields. Sequencing is prose in each brief; cohort membership is the shared `target_release`. +After operating documents are populated (and Knowledge Base Scaffolding above has run), close bootstrap by minting the BRIEF's initial arc as **backlog issues** — SQLite rows with a problem-space body and an optional advisory bucket label (`loaf issue bucket <ref> now|next|later`). Buckets are labels, never bindings. Series-prep is not roadmap planning: no milestone entities, no dates, no priorities, no dependency fields. Sequencing is prose in each issue body. No folders, no docs-only commits per capture — rows, not files. **When to run** - Always offer series-prep when a project BRIEF exists and names more than one scoped concept (typical after a pitched BRIEF; also after a rich non-pitch brief). -- If the BRIEF is a single atomic concept with no series, say so and skip to Next Steps — one future shape or a single capture later is enough. -- Series-prep **reads** the BRIEF during this phase only. After bootstrap ends, nothing references `docs/BRIEF.md` again; minted change briefs and operating docs stand alone. +- If the BRIEF is a single atomic concept with no series, say so and skip to Next Steps — one future shape or a single issue later is enough. +- Series-prep **reads** the BRIEF during this phase only. After bootstrap ends, nothing references `docs/BRIEF.md` again; minted issue bodies and operating docs stand alone. **Procedure** 1. **Enumerate concepts** with the builder from the BRIEF's scoped problem space (Sequencing and Relationships, Open Questions, and distinct problem threads in Problem Statement). List candidates as recommendation-first options using your harness's structured question tool if it has one. -2. **Apply granularity** per [references/interview-guide.md](references/interview-guide.md) (Series-Prep Granularity): a concept earns its own captured change when it is independently shippable **and** its problem can be **stated precisely now** (the mint-time specifiability test — not answered now, stated now) without the others; otherwise it stays a BRIEF line, becomes a spark, or an Intent — never a half-minted folder. +2. **Apply granularity** per [references/interview-guide.md](references/interview-guide.md) (Series-Prep Granularity): a concept earns its own backlog issue when it is independently shippable **and** its problem can be **stated precisely now** (the mint-time specifiability test — not answered now, stated now) without the others; otherwise it stays a BRIEF line or becomes a spark — never a half-minted row. 3. **Per confirmed concept (one at a time — never batch):** - 1. Confirm mint with the builder (slug, coarse `target_release`, one-line problem restatement). If the builder will not bind even a coarse target, do not mint — park as spark/Intent/BRIEF line. - 2. Propose a **local slug** that names the concept, never another work unit (`spec-042`, task ids, change folder names). Confirm the slug. - 3. Run capture init: + 1. Confirm mint with the builder (title, optional advisory bucket, one-line problem restatement). Buckets are labels, never bindings — a missing bucket does not block mint. If the concept fails granularity, do not mint — park as spark or BRIEF line. + 2. Propose a **working title** that names the concept, never another work unit (issue aliases, task ids). Confirm the title. + 3. **Seed a problem-space-only narrative** from the BRIEF's content for that concept (Problem Statement, Who Has It, Current Alternatives, Value Proposition, Constraints, Sequencing and Relationships as prose order relative to the arc, Sources if any, Open Questions). Do not copy solution design. The seeded body must stand alone as intent for later shape — cold-read without the project BRIEF or this session. + 4. Mint the backlog issue: ```bash - loaf change init <slug> --brief + loaf issue new "<title>" --body "<problem narrative>" --status backlog ``` - Creates `docs/changes/YYYYMMDD-<slug>/` with `change.json` + `brief.md` only. - 4. **Seed `brief.md` problem-space-only** from the BRIEF's content for that concept (Problem Statement, Who Has It, Current Alternatives, Value Proposition, Constraints, Sequencing and Relationships as prose order relative to the arc, Sources if any, Open Questions). Do not copy solution design. The seeded brief must stand alone as intent for later shape — cold-read without the project BRIEF or this session. - 5. **Stamp `target_release`** on that folder's `change.json` with the builder's coarse binding (canonical `MAJOR.MINOR.PATCH`, no `v`, no prerelease). Series-prep mints only targeted captures (promise-carrier path). - 6. **Pre-landing guard** (required before every commit): + Creates a SQLite row, not a folder. Use `--body -` or `--body-file <path>` when the narrative is long (see `loaf issue new --help`). + 5. **Optionally set an advisory bucket** (a label, never a binding): ```bash - loaf change check <folder> --json + loaf issue bucket <ref> now|next|later ``` + 6. **Validate** only when the capture is shaped enough to check: - Must report zero violations and captured state. Then **read `<folder>/change.json` directly** and confirm the stamped `target_release` matches what the builder bound. Bare `loaf change check` resolves by branch and can miss a capture elsewhere — always pass the explicit folder path. - 7. **Land as its own docs-only commit on the default branch** (one commit per capture, never a batch). Example subject: `docs(change): capture <slug> brief`. Bootstrap prepares the commit; never push; never open a PR. + ```bash + loaf issue check <ref> + ``` + + A backlog issue with a problem body is capture-only and needs no ceremony — skip the check. Do not add criteria or an out-of-scope statement during series-prep (that is shape). + 7. **Do not land a docs-only commit.** The row is the artifact. Never push; never open a PR; never create a branch. 4. **Guards (hard):** - Every mint is user-confirmed — never auto-mint the whole list - - Never auto-run shape and never create slug branches during series-prep - - No priority, date, estimate, or dependency fields on captures - - No batching multiple captures into one commit - - Concepts without a coarse target stay BRIEF lines, sparks, or Intents + - Never auto-run shape and never create branches during series-prep + - No priority, date, estimate, or dependency fields on issues + - No folders, no docs-only commits per capture — rows, not files + - Concepts that fail granularity stay BRIEF lines or sparks; buckets are labels, never bindings **After the series** -Log the arc: `loaf journal log "decision(bootstrap): series-prep minted <n> captures for <cohort or targets>"`. Hand off by naming the first capture folder for shape when the builder is ready. +Log the arc: `loaf journal log "decision(bootstrap): series-prep minted <n> backlog issues"`. Hand off by naming the first issue ref for shape when the builder is ready. ### 5. Next Steps Suggest relevant next steps based on what was learned: -- shape -- on a series-prep capture (or any ready concept) to promote the folder and bound implementation +- shape -- on a series-prep backlog issue (or any ready concept) to bound implementation - pitch -- if a new concept still needs problem discovery (not for re-grilling the BRIEF) -- idea -- if specific feature ideas emerged during the interview and should not become captures yet +- idea -- if specific feature ideas emerged during the interview and should not become issues yet - research -- if there are open questions that need investigation - `loaf doctor` -- to verify the setup is healthy @@ -502,18 +506,18 @@ When the interactive interview path is unavailable, bootstrap the operating docu 2. **Always interview** -- even with a rich brief, confirm understanding; when `source: pitch`, gap-fill only 3. **Never overwrite** -- existing documents require explicit confirmation 4. **Draft, then review** -- present documents section-by-section -5. **Extract, don't preserve** -- pull every useful fact from the BRIEF into operating docs (and series-prep seeds change briefs from it once). The BRIEF is archeological after bootstrap; nothing should reference it again. +5. **Extract, don't preserve** -- pull every useful fact from the BRIEF into operating docs (and series-prep seeds issue bodies from it once). The BRIEF is archeological after bootstrap; nothing should reference it again. 6. **Record the session** -- decisions and rationale are preserved 7. **Suggest, don't execute** -- recommend next skills, don't auto-run them; series-prep never auto-shapes or creates branches 8. **Interview structured** -- one question at a time, with a recommendation, using your harness's structured question tool if it has one -9. **Series-prep is not roadmap planning** -- coarse `target_release` + prose sequencing only; no dates, priorities, or dependency fields +9. **Series-prep is not roadmap planning** -- advisory bucket labels (never bindings) + prose sequencing only; no dates, priorities, or dependency fields --- ## Related Skills -- **pitch** -- Authors a project-scale `docs/BRIEF.md` with `source: pitch` (or a change-scale brief); bootstrap consumes the pitched BRIEF with gap-only interview and series-prep -- **shape** -- Bound a captured change into a contract (promotes brief-only folders; often follows series-prep) +- **pitch** -- Authors a project-scale `docs/BRIEF.md` with `source: pitch` (or an issue-scale problem narrative); bootstrap consumes the pitched BRIEF with gap-only interview and series-prep +- **shape** -- Bound a backlog issue into a contract (often follows series-prep) - **explore** -- Agent technique when a concept that emerges during bootstrap is still undecided (not a user front door; prefer pitch for human problem discovery) - **research** -- Investigate topics and open questions - **idea** -- Quick-capture feature ideas that emerge during bootstrap diff --git a/dist/opencode/skills/bootstrap/references/interview-guide.md b/dist/opencode/skills/bootstrap/references/interview-guide.md index f3fc0c1ef..83f583396 100644 --- a/dist/opencode/skills/bootstrap/references/interview-guide.md +++ b/dist/opencode/skills/bootstrap/references/interview-guide.md @@ -358,33 +358,32 @@ Expect 6-10 questions total, mostly in Excavation and Sharpening. Grounding is l ## Series-Prep Granularity -Series-prep (bootstrap Finalization) turns the project BRIEF's scoped concepts into captured change folders. This section resolves when a concept earns a folder versus staying a BRIEF line, spark, or Intent. Full procedure: bootstrap SKILL.md Finalization → Series-Prep. +Series-prep (bootstrap Finalization) turns the project BRIEF's scoped concepts into backlog issues. This section resolves when a concept earns a row versus staying a BRIEF line or spark. Full procedure: bootstrap SKILL.md Finalization → Series-Prep. -### Earns its own captured change when both are true +### Earns its own backlog issue when both are true -1. **Independently shippable** — the concept could land as a meaningful release unit without waiting on the other scoped concepts to ship in the same commit or PR. Sharing a coarse `target_release` cohort is fine; hard entanglement is not. -2. **Specifiability (mint test)** — a concept mints only if its problem can be **stated precisely now** (not answered now): a cold reader can name the friction, who has it, the current alternative, and the value for *this* concept alone, without needing sibling folders or inventing what was meant. Everything vaguer stays a BRIEF line, spark, or Intent — never pre-sliced into a fake capture. +1. **Independently shippable** — the concept could land as a meaningful release unit without waiting on the other scoped concepts to ship in the same commit or PR. Sharing an advisory bucket is fine (buckets are labels, never bindings); hard entanglement is not. +2. **Specifiability (mint test)** — a concept mints only if its problem can be **stated precisely now** (not answered now): a cold reader can name the friction, who has it, the current alternative, and the value for *this* concept alone, without needing sibling issues or inventing what was meant. Everything vaguer stays a BRIEF line or spark — never pre-sliced into a fake row. -When both hold and the builder will bind a coarse `target_release`, mint: `loaf change init <slug> --brief`, seed problem-space-only, stamp the target, pre-landing check + `change.json` read-back, one docs-only commit on the default branch. +When both hold, mint: `loaf issue new "<title>" --body "<problem narrative>" --status backlog`, seed problem-space-only, optionally `loaf issue bucket <ref> now|next|later` (buckets are labels, never bindings). Run `loaf issue check <ref>` only when the capture is shaped enough to check; a backlog issue with a problem body needs no ceremony. No folders, no docs-only commits — rows, not files. -### Stays a BRIEF line, spark, or Intent when any is true +### Stays a BRIEF line or spark when any is true - The concept only makes sense as a clause of a larger problem (it cannot be stated alone). - Its problem cannot yet be stated precisely — coarse intent only; minting would invent precision. - It is a solution-space detail, implementation task, or "nice-to-have" flavor — not a shippable problem boundary. -- The builder will not bind even a coarse `target_release` (untargeted series members are not promise carriers on main; keep them as sparks/Intents or leave them as prose in the BRIEF until a target exists). -- Splitting would create two folders that always ship as one unit — keep one folder and name the sub-concerns in Sequencing prose. +- Splitting would create two issues that always ship as one unit — keep one issue and name the sub-concerns in Sequencing prose. ### Interview moves during series-prep -- Recommend a decomposition first (list candidate folders + what stays in the BRIEF), then confirm each mint one at a time. +- Recommend a decomposition first (list candidate issues + what stays in the BRIEF), then confirm each mint one at a time. - Challenge over-splitting: "Can this problem be stated precisely now without that one?" If no, merge candidates or leave as BRIEF lines. -- Challenge under-splitting: "Is this independently shippable, or are we smuggling two products into one capture?" -- Never invent priority ranks, dates, or machine dependency fields — sequencing is prose; cohort is `target_release`. +- Challenge under-splitting: "Is this independently shippable, or are we smuggling two products into one issue?" +- Never invent priority ranks, dates, or machine dependency fields — sequencing is prose; buckets are labels, never bindings. ### Standalone cold-read test (H3) -Each minted brief must name its problem without reference to the bootstrap session or to `docs/BRIEF.md`. If a draft says "as discussed" or "per the project brief," rewrite until the problem stands alone. +Each minted issue body must name its problem without reference to the bootstrap session or to `docs/BRIEF.md`. If a draft says "as discussed" or "per the project brief," rewrite until the problem stands alone. --- diff --git a/dist/opencode/skills/bootstrap/templates/brief.md b/dist/opencode/skills/bootstrap/templates/brief.md index d7b58d0db..5b550a307 100644 --- a/dist/opencode/skills/bootstrap/templates/brief.md +++ b/dist/opencode/skills/bootstrap/templates/brief.md @@ -39,7 +39,7 @@ archived: true # Always true -- BRIEF is a historical snapshot, not a w ## Sequencing and Relationships -[How the initial arc hangs together — which concepts belong as early changes, what depends on what, release cohort stated as prose. No machine relation fields; narrative order only.] +[How the initial arc hangs together — which problems become early backlog issues, what depends on what, sequencing stated as prose. No machine relation fields; narrative order only.] ## Sources and Research Links diff --git a/dist/opencode/skills/bootstrap/templates/journal.md b/dist/opencode/skills/bootstrap/templates/journal.md index caf7409fd..9a0a61f8f 100644 --- a/dist/opencode/skills/bootstrap/templates/journal.md +++ b/dist/opencode/skills/bootstrap/templates/journal.md @@ -13,7 +13,7 @@ not create or edit journal markdown as the source of truth — use ## Entries -[YYYY-MM-DD HH:MM] skill(implement): implementing TASK-042 +[YYYY-MM-DD HH:MM] skill(implement): implementing LOAF-42 [YYYY-MM-DD HH:MM] decision(scope): description of decision [YYYY-MM-DD HH:MM] discover(scope): something learned [YYYY-MM-DD HH:MM] block(scope): what is blocked diff --git a/dist/opencode/skills/breakdown/SKILL.md b/dist/opencode/skills/breakdown/SKILL.md deleted file mode 100644 index d69c71428..000000000 --- a/dist/opencode/skills/breakdown/SKILL.md +++ /dev/null @@ -1,331 +0,0 @@ ---- -name: breakdown -description: >- - Decomposes specifications into atomic tasks with dependencies and priorities. - Use when the user asks "break this down" or "create tasks for this spec." - Produces task files with estimates, dependencies, and acceptance criteria. Not - for shaping ideas (use shape) or implementation work (use implement). -subtask: false -version: 0.2.21 ---- - -# Breakdown - -Decompose specifications into atomic, implementable tasks. - -## Contents -- Critical Rules -- Verification -- Quick Reference -- Task Breakdown Philosophy -- Task Backend Detection -- Process -- Linear-Native Mode -- Local-Tasks Mode -- Priority Levels -- Guardrails -- Related Skills - -**Input:** $ARGUMENTS - ---- - -## Critical Rules - -- **One concern per task** -- never mix unrelated layers (backend + frontend) in a single task -- **Every task includes its own verification** -- no separate "verify" tasks; each task must have an observable done condition -- **Own the decisions** -- decide granularity and priorities autonomously; only ask the user when two equally valid orderings have genuinely different trade-offs -- **Keep tests with the code they test** -- never split implementation and tests into separate tasks -- **Update spec status** -- mark the spec as `implementing` after tasks are created -- **One backend only** -- in Linear-native mode create Linear issues and NO local `TASK-NNN.md`; in local mode create local tasks and make NO Linear calls -- **Spec file is always local** -- in both modes, the spec stays in `.agents/specs/`. The Linear parent issue, when present, is a rollup pointing to the spec, not a re-host of it -- **Log outcome** -- log breakdown to the project journal: `loaf journal log "decision(breakdown): SPEC-NNN → N tasks created"` - ---- - -## Verification - -- Each created task has a clear title, priority, file hints, verification command, and observable done condition -- The dependency graph has no cycles and reflects actual implementation order -- Spec status has been updated to `implementing` -- **Linear-native mode only:** parent issue exists, labeled `spec`, with description pointing to the local spec file; N sub-issues have `parentId` set; zero local task rows or `TASK-NNN.md` files were created; spec frontmatter has `linear_parent` and `linear_parent_url` populated -- **Local-tasks mode only:** N local tasks exist in `loaf task list` with compatibility `.md` files when configured; no Linear calls were made - ---- - -## Quick Reference - -### Priority Levels - -| Priority | Loaf | Linear Priority | -|----------|------|-----------------| -| P0 | Urgent/blocking -- drop everything | Urgent (1) | -| P1 | High -- work next | High (2) | -| P2 | Normal -- scheduled work (default) | Normal (3) | -| P3 | Low -- when time permits | Low (4) | - -### Right-Sizing Rules - -| Rule | Guideline | -|------|-----------| -| **One agent type** | Completable by a single implementer (after skills narrowing) | -| **One concern** | Touches one layer, service, or component | -| **Context-appropriate** | Fits in model context with room for exploration | -| **Not over-fragmented** | Don't split what naturally belongs together | - -### Mode Selection - -| `integrations.linear.enabled` in `.agents/loaf.json` | Mode | See | -|------------------------------------------------------|------|-----| -| `true` | Linear-native | [Linear-Native Mode](#linear-native-mode) | -| `false` or absent | Local-tasks | [Local-Tasks Mode](#local-tasks-mode) | - ---- - -## Task Breakdown Philosophy - -**Primary principle: separation of concerns.** - -### The Right Size Test - -1. Can a single implementer complete this? If no, split by concern -2. Does it touch multiple unrelated concerns? If yes, split by concern -3. Will the agent need too much context? If yes, split into smaller coherent units -4. Am I splitting just to have more tasks? If yes, merge back - -### Anti-Patterns - -| Don't | Do Instead | -|-------|------------| -| Split backend + tests into separate tasks | Keep tests with the code they test | -| Create a task per file | Group files by concern | -| Separate "implement" and "verify" tasks | Every task includes its own verification | -| Copy the full spec text into the Linear parent issue | Summarize + link to the local spec file | -| Create both local `TASK-NNN.md` and Linear sub-issues | Pick one backend; never mix | - ---- - -## Task Backend Detection - -Read `.agents/loaf.json`: - -```json -{ - "integrations": { - "linear": { "enabled": true } - } -} -``` - -If `integrations.linear.enabled` is `true`, proceed in **Linear-native mode**. -Otherwise, proceed in **Local-tasks mode**. - -If `.agents/loaf.json` is missing, default to local-tasks and note the -assumption in the project journal. - ---- - -## Process - -### Step 1: Parse Input - -`$ARGUMENTS` should reference a spec (e.g., "SPEC-001"). If unclear, list available specs. - -### Step 2: Read the Spec - -Extract: test conditions, scope, implementation notes, priority ordering, complexity size. - -### Step 3: Identify Task Boundaries - -Break down by concern (data layer, backend, frontend, infrastructure, etc.). One concern per task. Explicit dependencies for sequential tasks. - -### Step 4: Decide Priorities and Granularity - -Own the granularity and priority decisions. Apply the Right Size Test, assign priorities -based on dependencies, priority order, and go/no-go gates, and do a self-review pass. Do not -defer these decisions to the user — they trust agent judgment here. - -If genuinely uncertain (e.g., two equally valid orderings with different trade-offs), -ask. Otherwise, decide and move on. - -### Step 5: Draft Task List - -Draft tasks following [task template](templates/task.md). Each task needs: clear title, priority, file hints, verification command, observable done condition, labels (if routing by team). - -### Step 6: Present the Plan - -Show the dependency graph and task summary for awareness before creating anything. -Present it as "here's what I'm creating" not "which option do you prefer?" The user -can still adjust after creation, but the default is to proceed. - -### Step 7: Create Tasks (mode-specific) - -Detect the mode (see [Task Backend Detection](#task-backend-detection)) and follow the -matching section below. Do NOT mix modes. - -- Linear enabled → [Linear-Native Mode](#linear-native-mode) -- Linear disabled or missing → [Local-Tasks Mode](#local-tasks-mode) - -### Step 8: Update Spec and Announce - -Set spec status to `implementing`. In Linear-native mode, also write -`linear_parent` and `linear_parent_url` into the spec's frontmatter. Announce -created tasks and next steps. - ---- - -## Linear-Native Mode - -Spec files stay local and canonical in `.agents/specs/`. Tasks live in Linear -as sub-issues of a parent rollup issue representing the spec. No local -task rows or `TASK-NNN.md` files are created. - -### 7a. Ensure the `spec` label exists - -The `spec` label groups all spec-parent rollup issues so Linear users can -filter for them. - -1. Call `list_issue_labels` to check whether a label named `spec` exists. -2. If missing, create it via `create_issue_label`: - - `name`: `spec` - - `color`: `#5e6ad2` (Linear-ish indigo; implementer may adjust) - - `description`: `Parent rollup issue representing a design spec tracked in the repo at .agents/specs/` - - Prefer workspace-scoped so all teams can filter uniformly. If the MCP - only supports team-scoped labels, create on the default team. -3. Log whether the label was created this run or already existed. This - matters for first-time Loaf setup on a Linear workspace. - -### 7b. Resolve team, project, and state - -Read from `.agents/loaf.json`: - -- **Team:** `linear.default_team` (name) — resolve to team ID via - `list_teams` if not already cached in `known_teams`. -- **Project:** `linear.project.id`. -- **State:** call `list_issue_statuses` for the team, pick the - `unstarted`-type state (typically "Backlog" or "To-Do"). States are - **team-scoped**, not workspace-scoped — always pass the team. - -### 7c. Create the parent issue - -Use `create_issue` with: - -| Field | Value | -|-------|-------| -| `title` | `[SPEC-NNN] <spec title>` | -| `teamId` | from 7b | -| `projectId` | from 7b | -| `stateId` | unstarted state from 7b | -| `priority` | mapped from spec (default High = 2 if unspecified) | -| `labels` | `["spec"]` | -| `description` | Summary synthesized from the spec's Problem Statement + Solution Direction (1–3 paragraphs), ending with: `See .agents/specs/SPEC-NNN-<slug>.md for full text, council references, and strategic tensions.` | - -**Do NOT** copy the full spec body into the description. The local file is canonical. - -### 7d. Check label-group conflicts (pre-flight per sub-issue) - -Linear labels can belong to exclusive groups (e.g., a `type` group where -`feature`, `testing`, `docs`, `bug`, `refactor` are mutually exclusive). -Before creating each sub-issue: - -1. Inspect proposed labels against known group membership (from - `list_issue_labels` group metadata). -2. If a task has more than one label from the same exclusive group, pick the - most appropriate and drop the others. Warn the user about the drop. -3. Log the resolution so the user can override if desired. - -### 7e. Create sub-issues - -For each task, use `create_issue` with: - -| Field | Value | -|-------|-------| -| `parentId` | parent issue ID from 7c | -| `title` | task title | -| `description` | task description + acceptance criteria | -| `teamId` | routed from `team_keywords` or falling back to `default_team` | -| `projectId` | same as parent unless task explicitly belongs elsewhere | -| `stateId` | unstarted state for the target team | -| `priority` | mapped from task priority (see Priority Levels table) | -| `labels` | task labels after conflict resolution (7d) | - -Express dependencies from the spec's Priority Order / dependency graph via -`blockedBy` referencing sibling sub-issue IDs. Create in dependency order so -predecessors exist when referenced. - -### 7f. Do NOT create local task files - -Skip `loaf task create` entirely. Linear issue IDs are the task record. No -local task rows or `TASK-NNN.md` files for this spec's tasks. - -### 7g. Update spec frontmatter - -Add to the spec file's YAML frontmatter: - -```yaml -linear_parent: ENG-198 -linear_parent_url: https://linear.app/<workspace>/issue/ENG-198 -``` - -Use the actual parent issue identifier and URL returned from 7c. - ---- - -## Local-Tasks Mode - -Spec files and task files both live locally. No Linear calls. - -Use `loaf task create --spec SPEC-XXX --title "Task title" --priority P1` -for each task. In SQLite-backed projects, the CLI creates the operational state -row and any compatibility Markdown/index artifacts needed by the current -project. Then edit the `.md` body content (description, acceptance criteria) -only when an authored task prose artifact exists. - -Dependencies are expressed through CLI flags such as `--depends-on`, not by -hand-editing the compatibility index. Priority Order from the spec maps directly -to task `priority` fields. - -See [local-tasks reference](../orchestration/references/local-tasks.md) for -the full local-task model. - ---- - -## Priority Mapping (reference) - -| Loaf | Linear API value | Linear label | -|------|------------------|--------------| -| P0 | `1` | Urgent | -| P1 | `2` | High | -| P2 | `3` | Normal | -| P3 | `4` | Low | - ---- - -## Guardrails - -1. **One concern per task** -- don't mix backend + frontend -2. **Clear verification** -- how to prove it works -3. **Observable done condition** -- not subjective -4. **File hints** -- help session know where to look -5. **Own the decisions** -- decide granularity and priorities, don't defer -6. **Update spec status** -- mark as implementing -7. **One backend only** -- Linear-native creates Linear issues and no local tasks; local-tasks mode creates local tasks and no Linear calls -8. **Summary not copy** -- the Linear parent description summarizes + links; it does not re-host the spec - ---- - -## Suggests Next - -After breakdown completes, suggest implement to start working on the tasks. - -## Related Skills - -- **shape** -- Create specs that get broken down -- **implement** -- Start session for a task or coordinate multiple tasks - -## Topics - -| Topic | Reference | Use When | -|-------|-----------|----------| -| Linear Integration | `orchestration/references/linear.md` | Working out Linear issue structure, labels, parent/child | -| Local Task Model | `orchestration/references/local-tasks.md` | Local-tasks mode details and CLI flags | diff --git a/dist/opencode/skills/breakdown/templates/task.md b/dist/opencode/skills/breakdown/templates/task.md deleted file mode 100644 index fe74a794f..000000000 --- a/dist/opencode/skills/breakdown/templates/task.md +++ /dev/null @@ -1,28 +0,0 @@ -# Task Template - -**Create with:** `loaf task create --title "..." --spec SPEC-XXX` - -```yaml ---- -id: TASK-XXX -title: [Clear action] -spec: SPEC-001 -status: todo -priority: P2 -files: - - [likely file 1] - - [likely file 2] -verify: [command to verify] -done: [observable outcome] ---- - -## Description -[What needs to be done] - -## Acceptance Criteria -- [ ] [Criterion 1] -- [ ] [Criterion 2] - -## Context -See SPEC-001 for full context. -``` diff --git a/dist/opencode/skills/council/SKILL.md b/dist/opencode/skills/council/SKILL.md index dd4069989..53f230c3f 100644 --- a/dist/opencode/skills/council/SKILL.md +++ b/dist/opencode/skills/council/SKILL.md @@ -78,13 +78,13 @@ Councils stay **local**. Even when the workspace uses Linear-native mode, council files live in `.agents/councils/` — they are deliberation artifacts, not executable work, and belong with specs in git. -When a council resolves a spec's open questions: +When a council resolves an issue's open questions: -- Include the spec ID in council frontmatter (e.g., `spec: SPEC-024`). This +- Include the issue ID in council frontmatter (e.g., `issue: LOAF-42`). This is already the common pattern. -- If the spec's `linear_parent` has been populated by breakdown, also - include `linear_parent: ENG-198` in council frontmatter so a reader on - Linear can trace back to the deliberation. +- If the issue is tracked in Linear (tracker authority), also include the + tracker key (e.g., `linear_parent: ENG-198`) in council frontmatter so a + reader on Linear can trace back to the deliberation. - Do not post council content to the Linear parent issue. A brief one-line reference ("Resolved via council 2026-04-21 — see .agents/councils/…") in a sub-issue comment is sufficient if the council drove a specific task diff --git a/dist/opencode/skills/documentation-standards/SKILL.md b/dist/opencode/skills/documentation-standards/SKILL.md index c4aed9a36..da018c76f 100644 --- a/dist/opencode/skills/documentation-standards/SKILL.md +++ b/dist/opencode/skills/documentation-standards/SKILL.md @@ -49,7 +49,7 @@ Standards for ADRs, API docs, changelogs, and diagrams. - Internal spec/task IDs - Verbatim commit or PR-title dumps - **Good examples:** - - "Add `loaf release --post-merge` guardrails for tagged GitHub releases" + - "Add `loaf release suggest` and `loaf release cut` for retroactive releases" - "Fix journal context routing when hook payloads are empty" - "Document worktree-aware `.agents/` storage for linked checkouts" - **Version protection:** diff --git a/dist/opencode/skills/explore/SKILL.md b/dist/opencode/skills/explore/SKILL.md index 829912f8c..4586c741f 100644 --- a/dist/opencode/skills/explore/SKILL.md +++ b/dist/opencode/skills/explore/SKILL.md @@ -2,16 +2,16 @@ name: explore description: >- Conducts divergent inquiry as a durable Exploration with portable checkpoints, - conversation provenance, and Intent capture that survive compaction and - harness changes. Agent technique — not a user entry point: route "explore - this" and similar user asks to pitch; use this technique from inside pitch or - other agent work when the direction is genuinely undecided, or when resuming a - named Exploration. Produces Exploration records, portable checkpoints, and - tracked or deferred Intents; Exploration machinery and the four-field - checkpoint contract stay intact. Not for evidence gathering on a known - question (use research), continuing implementation (use implement), processing - the intake queue (use triage), shaping a bounded Change (use shape), problem - discovery (use pitch), or quick capture (use idea). + conversation provenance, and backlog-issue dispositions that survive + compaction and harness changes. Agent technique — not a user entry point: + route "explore this" and similar user asks to pitch; use this technique from + inside pitch or other agent work when the direction is genuinely undecided, or + when resuming a named Exploration. Produces Exploration records, portable + checkpoints, and backlog issues for crystallized directions; Exploration + machinery and the four-field checkpoint contract stay intact. Not for evidence + gathering on a known question (use research), continuing implementation (use + implement), processing the intake queue (use triage), shaping a bounded issue + (use shape), problem discovery (use pitch), or quick capture (use idea). version: 0.2.21 --- @@ -30,6 +30,7 @@ Divergent inquiry with durable continuity. An Exploration is a relational identi - Process - Checkpoint Discipline - Resumption +- Parking a direction - Techniques - Related Skills @@ -39,37 +40,39 @@ Divergent inquiry with durable continuity. An Exploration is a relational identi - You choose what an Exploration means and when to checkpoint; the CLI validates and performs the operation you request. Never expect the CLI to classify or decide for you. - Checkpoint before the context window gets hostile: every checkpoint must carry all four portable fields — purpose, conclusions, unresolved, next action — each self-sufficient without this conversation. - A conversation handle or log path is provenance, never context. Presence of handles does not make an Exploration resumable; only a portable checkpoint does. -- Capture crystallized directions as Intent (`loaf intent create`), deferred bodies with `--disposition deferred`; never leave a substantial direction only in prose. -- Never create Git artifacts, branches, worktrees, or Changes from Explore; when a direction is ready for problem discovery hand it to pitch, and when it is ready for bounded delivery hand it to shape. +- Capture crystallized directions as backlog issues (`loaf issue new "<title>" --status backlog`); park remaining unsharp questions on that issue with `--fog`. Never leave a substantial direction only in prose. +- Never create Git artifacts, branches, or worktrees from Explore; when a direction is ready for problem discovery hand it to pitch, and when it is ready for bounded delivery hand it to shape (issue preparation). - Never store transcripts, prompts, or tool output in checkpoints or items; curate semantic context instead. -- Not a user slash front door — human "explore this" / "where do I start" intent routes to pitch. +- Not a user slash front door — human "explore this" / "where do I start" routes to pitch. ## Verification - The Exploration exists with `portable_context_present: true` after the first checkpoint (`loaf exploration list`). - `loaf exploration context <ref> --json` returns the four-field core whole, and a fresh reader could identify the next action from it alone. -- Crystallized directions exist as Intents with derived dispositions (`loaf intent list`). +- Crystallized directions exist as backlog issues (`loaf issue list --status backlog`); issue aliases named in the checkpoint match those rows. - Conversation provenance, when recorded, carries harness and locality facts without any transcript content. ## Quick Reference | Operation | Command | |-----------|---------| -| Start an inquiry | `loaf exploration create --title <title> [--from <intent-or-source>]...` | +| Start an inquiry | `loaf exploration create --title <title> [--from <source>]...` | | Checkpoint | `loaf exploration checkpoint <ref> --purpose <p> --conclusions <c> --unresolved <u> --next <n> [--item candidate:<text>]... [--operation-id <key>]` | | Resume elsewhere | `loaf exploration context <ref> --json` | -| Track a direction | `loaf intent create --title <t> --body <b> --from <source>...` | -| Defer a direction | `loaf intent create --title <t> --body <b> --disposition deferred --why <w> --boundary <bd> --trigger <tr> --operation-id <key> [--from <source>]` | +| File a direction | `loaf issue new "<title>" --status backlog [--parent <ref>] [--kind delivery\|decision] [--fog <text>] [--body <text>]` | +| Optional bucket | `loaf issue bucket <ref> now\|next\|later\|none` | | Record provenance | `loaf conversation create --title <label>` then `loaf conversation handle add <id> --harness <h> --handle <opaque-id> [--locality <scope>] [--log-ref <path>]` | | Associate conversation | `loaf exploration conversation add <exploration> <conversation-id>` | +`--from` on create accepts journal entries, handoffs, reports, and findings. It does not accept issue, spark, or idea refs — name those in the checkpoint and in the issue body instead. Buckets are labels only and are never read as a constraint. `fog` is writeable only at create. + ## Process 1. **Orient.** If the input names an existing Exploration, run `loaf exploration context <ref>` and continue from its recommended next action. Otherwise check `loaf exploration list` before creating a duplicate inquiry. -2. **Create or resume.** New inquiries get `loaf exploration create` with `--from` links to the Intents, journal entries, reports, or findings that motivated them. +2. **Create or resume.** New inquiries get `loaf exploration create` with `--from` links to the journal entries, reports, findings, or handoffs that motivated them. 3. **Diverge.** Expand the option space before judging it. Use the brainstorm stance (below), research, scouting, prototypes, or spikes as the question demands. -4. **Capture as you go.** Incidental thoughts become sparks; explicit propositions become ideas; deliberately tracked directions become Intents with their sources linked. -5. **Checkpoint.** At every meaningful plateau — and always before ending a session — append a checkpoint with the four portable fields and optional `candidate:`/`evidence:` items. +4. **Capture as you go.** Incidental thoughts become sparks (`loaf spark capture --scope <scope> --text <text>`); explicit propositions become ideas (`loaf idea capture --title "..."`); directions worth keeping become backlog issues. Resolve the capture against the issue so the direction appears once: `loaf spark resolve <ref> --by <issue-ref>` or `loaf idea resolve <ref> --by <issue-ref>`. +5. **Checkpoint.** At every meaningful plateau — and always before ending a session — append a checkpoint with the four portable fields and optional `candidate:`/`evidence:` items. Name any filed issue aliases in conclusions or next. 6. **Record provenance when useful.** Machine-local conversation handles and log locators help forensic navigation later; add them explicitly, and never infer identity from the current session. ## Checkpoint Discipline @@ -81,17 +84,17 @@ The four fields are the portable contract; each is capped at 4096 UTF-8 bytes an - **unresolved** — the open question or decision the inquiry currently turns on. - **next** — the recommended next action, concrete enough for a fresh agent to execute without this conversation. -Larger detail belongs in ordered `--item candidate:` and `--item evidence:` entries or in related reports, not crammed into the core fields. +Larger detail belongs in ordered `--item candidate:` and `--item evidence:` entries or in related reports, not crammed into the core fields. When filing an issue, copy still-unsharp questions into `--fog`; they will not be editable on the issue after create. ## Resumption -A new conversation, harness, or machine resumes with `loaf exploration context <ref> --json`: the portable core returns whole, and each optional layer (items, intents, evidence, conversations) reports counts, truncation, and its exact expansion command. Source handles appear with their last observed availability; treat unavailable ones as lost without ceremony — the checkpoint is the context. If `portable_context_present` is false, the Exploration was never checkpointed: rebuild understanding from linked sources, then write the missing checkpoint first. +A new conversation, harness, or machine resumes with `loaf exploration context <ref> --json`: the portable core returns whole, and each optional layer reports counts, truncation, and its exact expansion command. Source handles appear with their last observed availability; treat unavailable ones as lost without ceremony — the checkpoint is the context. If `portable_context_present` is false, the Exploration was never checkpointed: rebuild understanding from linked sources, then write the missing checkpoint first. -Before continuing, inspect the linked Intents in the context. If an Intent this inquiry was developing has since been resolved, do not silently reopen it: acknowledge the resolution, and if the checkpoint's next action still matters, create a successor Intent, record why in its body, and relate the lineage with `loaf link create --from <new-intent-ref> --to <resolved-intent-ref> --type derived-from`. Continued evidence gathering that serves no unresolved Intent should say so in its next checkpoint. +Before continuing, inspect issue aliases named in the checkpoint. If an issue this inquiry was developing is now done, cancelled, or duplicate, do not silently reopen it: acknowledge the resolution, and if the checkpoint's next action still matters, file a successor backlog issue and record why in its body. Continued evidence gathering that serves no open issue should say so in its next checkpoint. -## Deferring +## Parking a direction -An Exploration is never deferred, paused, or closed — it has no lifecycle to transition. "Defer this exploration" means two concrete acts: checkpoint the current state honestly, then defer the direction it was developing as an Intent — `loaf intent defer` on the linked Intent, or `loaf intent create --disposition deferred` for a new one followed by `loaf link create --from <exploration-ref> --to <intent-ref> --type explores`. The deferred Intent carries the revisit trigger; the Exploration simply waits, resumable from its checkpoint whenever the Intent is resumed. +An Exploration is never paused or closed — it has no lifecycle to transition. When the user wants to park or set aside the inquiry, do two concrete acts: checkpoint the current state honestly, then file the direction it was developing as a backlog issue (`loaf issue new "<title>" --status backlog`, optional `--parent`, `--fog` for remaining unsharp questions, optional `loaf issue bucket <ref> later`). The issue is the revisit surface; the Exploration simply waits, resumable from its checkpoint. ## Techniques @@ -99,8 +102,8 @@ Brainstorm's full divergent stance lives inside Explore: generate options before ## Related Skills -- **pitch** — human problem-discovery front door; route user entry intent here; reach for explore from inside pitch when still undecided -- **triage** — processes the intake queue and may disposition items toward pitch, shape, or agent-side explore -- **shape** — narrows one well-understood direction into a bounded Change +- **pitch** — human problem-discovery front door; route user entry here; reach for explore from inside pitch when still undecided +- **triage** — processes the intake queue and may disposition items toward a backlog issue, pitch, shape, or agent-side explore +- **shape** — prepares a well-understood direction as a bounded issue - **research** — evidence gathering for a known question, usable inside an Exploration - **idea** — quick capture without inquiry diff --git a/dist/opencode/skills/foundations/references/code-review.md b/dist/opencode/skills/foundations/references/code-review.md index 2c37d2f0b..37c8a2e10 100644 --- a/dist/opencode/skills/foundations/references/code-review.md +++ b/dist/opencode/skills/foundations/references/code-review.md @@ -86,5 +86,5 @@ Project code review conventions and workflow. | Command | Code Review Role | |---------|-----------------| | implement | Self-review before marking complete | -| breakdown | Review task scope and approach | +| shape | Review issue scope and DoD before implementation | | reflect | Note review feedback patterns | diff --git a/dist/opencode/skills/foundations/references/tdd.md b/dist/opencode/skills/foundations/references/tdd.md index 28c883709..1ea6fe7c9 100644 --- a/dist/opencode/skills/foundations/references/tdd.md +++ b/dist/opencode/skills/foundations/references/tdd.md @@ -54,6 +54,6 @@ If the failing test points to a non-obvious root cause, or if your first fix att | Phase | TDD Role | |-------|----------| | shape | Test conditions become TDD test cases | -| breakdown | Each task should have clear test targets | +| shape | Each promoted issue should have clear test targets in its DoD | | implement | Follow TDD cycle for each task | | reflect | Note TDD friction points for improvement | diff --git a/dist/opencode/skills/foundations/references/verification.md b/dist/opencode/skills/foundations/references/verification.md index d3cf84e1f..142aa7dfc 100644 --- a/dist/opencode/skills/foundations/references/verification.md +++ b/dist/opencode/skills/foundations/references/verification.md @@ -123,7 +123,7 @@ npm run lint # Check: No errors or warnings | Command | Verification Point | |---------|-------------------| | implement | Before marking session complete | -| breakdown | Each task has verification criteria | +| shape | Each issue has verification criteria (V-tier DoD) | | shape | Test conditions define verification | | reflect | Note verification gaps discovered | diff --git a/dist/opencode/skills/git-workflow/SKILL.md b/dist/opencode/skills/git-workflow/SKILL.md index 798f55dfc..80a2ce2e1 100644 --- a/dist/opencode/skills/git-workflow/SKILL.md +++ b/dist/opencode/skills/git-workflow/SKILL.md @@ -24,7 +24,7 @@ Git conventions for branching, commits, PRs, and merge workflow. - Use Conventional Commits format for all commit messages - Commit complete units of work -- don't commit partial or in-progress changes - Squash merge feature branches -- never merge commits directly -- One branch per spec/feature; branch name format: `feat/{slug}` +- One branch per issue; `loaf issue start` creates `issue/<alias-or-id>` (or use `feat/{slug}` / `fix/{slug}` when not starting from an issue) - Never force-push to `main` or shared branches - Never push without explicit user confirmation @@ -38,7 +38,7 @@ Git conventions for branching, commits, PRs, and merge workflow. | Action | Command/Pattern | |--------|----------------| -| Branch naming | `feat/{slug}`, `fix/{slug}`, `chore/{slug}` | +| Branch naming | `issue/<alias-or-id>` from `loaf issue start`; else `feat/{slug}`, `fix/{slug}`, `chore/{slug}` | | Commit format | `type: description` | | Squash merge | `gh pr merge --squash` | | PR creation | `gh pr create --title "..." --body "..."` | diff --git a/dist/opencode/skills/git-workflow/references/commits.md b/dist/opencode/skills/git-workflow/references/commits.md index a879b8709..3e9e3c00d 100644 --- a/dist/opencode/skills/git-workflow/references/commits.md +++ b/dist/opencode/skills/git-workflow/references/commits.md @@ -110,13 +110,14 @@ Closes BACK-123 ## Branch Naming ``` +issue/<alias-or-id> <type>/<description> -<type>/TASK-123-description ``` ### Types -- `feat/` - New features (e.g., `feat/spec-010-task-management-cli`) +- `issue/` - Started from `loaf issue start` (`issue/loaf-42`) +- `feat/` - New features (e.g., `feat/thermal-rating-cli`) - `fix/` - Bug fixes - `hotfix/` - Critical production fixes - `release/` - Release preparation @@ -126,7 +127,7 @@ Closes BACK-123 - Lowercase with hyphens (kebab-case) - Short but descriptive (max 50 chars) -- Include spec or task slug when applicable (e.g., `feat/spec-010-task-management-cli`) +- Prefer the started worktree branch from `loaf issue start` when implementing an issue ## Pull Request Format @@ -140,26 +141,10 @@ feat: add thermal rating calculation ### Description -Focus on **review context** — what changed, why, and how to test. Do not include squash merge commit text in the PR body. +The PR body is `loaf issue render <ref>` output — paste-ready, no manual editing. Definition-of-done criteria in the render are the review checklist. Do not include squash merge commit text in the PR body. -```markdown -## Summary - -Brief description of what this PR adds/changes and why. - -- Bullet points covering key changes -- Focus on what a reviewer needs to know - -## Test plan - -- [ ] Unit tests added/updated -- [ ] Integration tests pass -- [ ] Manual testing performed - -## Related Issues - -Closes BACK-123 -Refs BACK-124 +``` +gh pr create --title "type: summary" --body "$(loaf issue render <ref>)" ``` ### Merge Strategy @@ -182,10 +167,10 @@ published release notes read as user-facing prose, not an internal worklog. Internal terms that have no meaning outside the team's working context: -- Spec IDs and task IDs (`SPEC-024`, `TASK-042`) +- Internal work-unit numbering that is not the issue ID (issue IDs like `LOAF-42` belong in commits — release attribution reads them) - Session, sprint, or branch references - Internal terminology from skills/docs that isn't part of the user's mental model — e.g. `Q1`/`Q2`/`Q3` question numbers from a Triage Gate, internal gate-logic notation like `(Q1 OR Q2) AND Q3`, hook IDs that aren't user-facing -- "How the work got done" framing — interview steps, breakdown steps, review gates +- "How the work got done" framing — interview steps, decomposition steps, review gates ### Keep @@ -204,7 +189,7 @@ Internal terms that have no meaning outside the team's working context: ### Auto-generated Entries -When `loaf release` auto-generates the `[Unreleased]` section from commit history, those entries inherit any internal terms present in the commit messages. Treat the generated output as a draft: rewrite it under the curated path before bumping. The release skill preserves curated content when it's already in `[Unreleased]` — curate first, bump second. +`loaf release suggest` drafts notes from landed issues; `loaf release cut` prepends them into `CHANGELOG.md`. Treat drafted notes as a draft: rewrite internal terms before cutting. Curate `[Unreleased]` as PRs land so the later cut reads as user-facing prose. Before approving a release bump, compare `[Unreleased]` against the actual release range and remove scaffolding language introduced by specs, reviews, tasks, or session triage. If an entry only explains why the work was discovered or how the work was organized, it does not belong in the changelog. @@ -228,14 +213,15 @@ Before approving a release bump, compare `[Unreleased]` against the actual relea - Add agent attribution - Mix unrelated changes - Commit secrets or sensitive data -- Put SPEC or TASK IDs in commit subject (use human-readable names) +- Put work-unit IDs in the commit subject (use human-readable names). Issue aliases belong in the body so `loaf release suggest` can attribute the commit. ### ID References - **IDs belong in footer, not subject line** - - Bad: `feat: implement SPEC-002 invisible sessions` - - Good: `feat: implement invisible sessions and task board` + - Bad: `feat: implement LOAF-42 invisible sessions` + - Good: `feat: implement invisible sessions` - Use descriptive names that are understandable without looking up IDs +- Issue aliases (`LOAF-42`) go in the body so release attribution can find them - Linear issue IDs go in footer only (e.g., `Closes BACK-123`) ## Semantic Versioning @@ -277,6 +263,6 @@ BREAKING CHANGE: Description of breaking change. **Convention:** - Use standard SemVer pre-release identifiers (`alpha`, `beta`, or `rc`) when publishing pre-release versions. -- `loaf release` handles all bump types: `prerelease`, `release`, `major`, `minor`, `patch` +- `loaf release cut --bump` handles all bump types: `prerelease`, `release`, `major`, `minor`, `patch` **Not required** — projects using simple `MAJOR.MINOR.PATCH` versioning can ignore pre-release suffixes entirely. This convention is for projects publishing staged pre-releases before stable releases. diff --git a/dist/opencode/skills/housekeeping/SKILL.md b/dist/opencode/skills/housekeeping/SKILL.md index 14d051f59..060a5ceee 100644 --- a/dist/opencode/skills/housekeeping/SKILL.md +++ b/dist/opencode/skills/housekeeping/SKILL.md @@ -1,12 +1,12 @@ --- name: housekeeping description: >- - Reviews and maintains agent artifacts in .agents/ — specs, plans, drafts, - handoffs, councils, and reports. Use when the user asks "housekeeping," "clean - up," or "tidy up .agents/." Provides hygiene recommendations, archives - completed work, and ensures extracted knowledge is preserved. Not for - strategic reflection (use reflect) or knowledge management (use - knowledge-base). + Reviews and maintains agent artifacts in .agents/ plus issue hygiene — + reports, handoffs, councils, archived issues, and stale started worktrees. Use + when the user asks "housekeeping," "clean up," or "tidy up .agents/." Provides + hygiene recommendations, archives completed work, and ensures extracted + knowledge is preserved. Not for strategic reflection (use reflect) or + knowledge management (use knowledge-base). subtask: false version: 0.2.21 --- @@ -18,40 +18,43 @@ version: 0.2.21 - Verification - Quick Reference - Mode-Aware Checks -- Process -- Guardrails -- Related Skills +- Suggests Next +- Topics +- Artifact Naming -Systematic review and archival of all `.agents/` artifacts with Linear-aware checks. +Systematic review of `.agents/` artifacts and issue workspaces. ## Critical Rules **Always** - Log invocation as the first action: `loaf journal log "skill(housekeeping): <scope or trigger>"` - Review EVERY file individually — never sample or average -- Check Linear issue status before archiving linked specs +- Check Loaf issue status (and Linear overlay, if enabled) before archiving linked artifacts - Extract lessons learned and decisions before archiving -- Use CLI (`loaf housekeeping`, `loaf task archive`, `loaf spec archive`) — never raw `mv` +- Use CLI (`loaf housekeeping`, `loaf report archive`, `loaf issue status` / `loaf issue stop`) — never raw `mv` - Treat `.agents/handoffs/` as first-class but disposable: keep active/final handoffs, delete only after confirmed deprecated status -- Check report `status` is `processed` before archiving reports (see [templates/report.md](templates/report.md)) -- In SQLite-backed projects, verify lifecycle changes through `loaf task list --json`, `loaf spec list --json`, and `loaf report list --json`; use `loaf task sync` only for Markdown compatibility repair +- Check report `status` is `done` (or `final`) before archiving reports (see [templates/report.md](templates/report.md)) +- In SQLite-backed projects, verify lifecycle through `loaf issue list --json`, `loaf issue list --started`, `loaf issue list --archived`, and `loaf report list --json` - When delegated subagents are available, use the `librarian` profile for - `.agents/`-scoped durable artifact tending: report/spec/handoff hygiene, + `.agents/`-scoped durable artifact tending: report/handoff hygiene, staleness notes, and lifecycle-safe cleanup recommendations. Housekeeping still owns user confirmation and final archive decisions. -- Log outcome to the project journal: `loaf journal log "decision(housekeeping): archived N specs, M reports"` +- Log outcome to the project journal: `loaf journal log "decision(housekeeping): archived N reports; stopped M stale worktrees"` **Never** - Auto-archive without user confirmation for each artifact - Skip spark extraction before deleting brainstorm drafts - Leave `archived_at` or `archived_by` fields empty in archived files +- Run `loaf issue stop` from inside the started worktree +- Dispatch cleanup agents into a live started worktree another agent occupies ## Verification After work completes, verify: -- Tasks archived via `loaf task archive` -- Specs archived via `loaf spec archive` -- SQLite-backed task/spec/report state reflects lifecycle changes when initialized +- Reports archived via `loaf report archive` after processing +- Archived issues reviewed via `loaf issue list --archived` (`cancelled` / `duplicate` archive through `loaf issue status`) +- Stale started worktrees reviewed via `loaf issue list --started` (a `(missing)` marker means the recorded path is gone) +- SQLite-backed report/issue state reflects lifecycle changes when initialized - Drafts checked for unprocessed sparks before deletion - Handoffs deleted only after explicit deprecation is confirmed - Summary table presented showing all actions taken @@ -63,11 +66,18 @@ After work completes, verify: ```bash loaf housekeeping --dry-run # Preview recommendations loaf housekeeping # Run artifact scanner -loaf task archive TASK-XXX # Archive single task -loaf spec archive SPEC-XXX # Archive single spec -loaf task sync # Compatibility diagnostic in SQLite-backed projects +loaf issue list --started # Started worktrees (alias, title, branch, path) +loaf issue list --archived # cancelled / duplicate rows +loaf issue stop <ref> # Remove worktree; keeps branch; does not change status +loaf issue status <ref> cancelled # Archive an abandoned issue +loaf issue status <ref> duplicate --duplicate-of <surviving> +loaf report archive <report> # Archive a processed report ``` +`loaf housekeeping` still prints leftover `specs` / `tasks` sections when those +SQLite tables have rows — compatibility scan only. Do not create new records +there. The `loaf task` / `loaf spec` CLI is legacy. + The project journal is append-only and never archived — it is not a housekeeping target. It is the canonical record housekeeping reads when extracting decisions before archiving other artifacts. @@ -76,19 +86,12 @@ before archiving other artifacts. | Artifact | Active Location | Archive | Action | |----------|-----------------|---------|--------| -| Tasks (local mode only) | SQLite state | SQLite archived status | `loaf task archive` | -| Specs | SQLite state + `.agents/specs/` authored prose | `archive/` | `loaf spec archive` | +| Issues | SQLite (`loaf issue list`) | `cancelled` / `duplicate` via `loaf issue status` | Confirm, then status; `done` is ship, not housekeeping | +| Started worktrees | `loaf issue list --started` | `loaf issue stop <ref>` | Stop stale or `(missing)` trees after confirmation | | Drafts / brainstorms | SQLite state | SQLite resolved/archived status | User decision (spark extraction first) | | Handoffs | `.agents/handoffs/` | delete | Delete after status is confirmed `deprecated` | | Reports | SQLite state + generated/authored report Markdown | `archive/` | `loaf report archive` after processing | -**Linear-native mode** (when `integrations.linear.enabled` is `true` in -`.agents/loaf.json`): local `TASK-NNN.md` files do not exist for new specs — -Linear issues are the task record. The "Tasks" row above is inert unless the -project has pre-Linear local tasks lingering (see [Mode-Aware Checks](#mode-aware-checks)). -Specs still archive locally — they are the canonical deliberation artifact in -every mode. - ## Cross-Branch Reconciliation If a stale branch reintroduces `.agents/{tasks,ideas,sparks,sessions,brainstorms,drafts}/` @@ -98,35 +101,30 @@ or `.agents/TASKS.json`, keep the deletion from the cutover branch and rerun ## Mode-Aware Checks -When `integrations.linear.enabled` is `true` in `.agents/loaf.json`, apply -these additional checks: +### Started worktrees -### Spec / Linear parent reconciliation +For each row from `loaf issue list --started`: -For each spec file (active and archive) with a `linear_parent:` frontmatter key: +1. If `(missing)`, flag as **stale started workspace** — the row still records a path that is gone. Offer `loaf issue stop <ref>` after confirmation. Stop does not mark the issue `done`. +2. If the path exists but the issue is `done` / `cancelled` / `duplicate`, flag as **worktree outlived the issue** — same offer. +3. If the path exists and status is `active`, leave it unless the user asks to stop. -1. Call `get_issue` with the issue identifier. If it 404s or returns - archived/deleted, flag as **orphaned linear_parent** — the local spec - references a Linear issue that no longer exists. -2. If the spec's local status is `done` (or legacy `complete`) or `archived`, - verify the Linear parent issue is in a `completed`-type state. If not - (e.g., still "In Progress"), flag as **status mismatch** — "Spec marked - complete locally but Linear parent ENG-198 is still 'In Progress'." -3. If the spec's local status is `in_progress` and the Linear parent is - already `completed`, flag the inverse — spec likely needs to be moved to - `done` and archived. +Treat these as **warnings**, not auto-fixes. -Treat all three as **warnings**, not auto-fixes. The user decides resolution. +### Linear overlay -### Pre-Linear local task detection +When `integrations.linear.enabled` is `true` in `.agents/loaf.json`, the tracker +adapter is not shipped. If a report or journal entry names a Linear id next to +a Loaf alias, you may `get_issue` and flag an obvious mismatch (Linear Done vs +Loaf still `active`, or the reverse). Warnings only. Do not drive Loaf status +from Linear. -If Linear is enabled but local task records exist in SQLite, -surface them with context: "Pre-Linear local tasks detected. These aren't -auto-migrated. Either continue using them, run a manual migration, or -archive if superseded by Linear issues." +### Leftover board rows -Do NOT auto-migrate. Migration is user-initiated and out of scope for -housekeeping. +If `loaf housekeeping --dry-run` still reports `tasks` or `specs` cleanup +candidates, surface them: "Legacy board rows are still in SQLite. They are not +the work unit. Archive only if the user confirms they are superseded by Loaf +issues." Do NOT auto-migrate. ## Suggests Next @@ -137,9 +135,9 @@ After housekeeping, suggest reflect if the session produced key decisions or lea | Topic | Reference | Use When | |-------|-----------|----------| | Report Template | [templates/report.md](templates/report.md) | Creating cleanup reports | -| Linear Integration | `orchestration/references/linear.md` | Checking external issue status | +| Linear Integration | `orchestration/references/linear.md` | Checking external tracker overlay | | Journal Continuity | `orchestration/references/journal.md` | Understanding the project journal model | ## Artifact Naming -Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or Change already records that provenance. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. +Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or the issue already records that provenance. Put the source in a front-matter field (`source: LOAF-42`), not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. diff --git a/dist/opencode/skills/housekeeping/templates/journal.md b/dist/opencode/skills/housekeeping/templates/journal.md index caf7409fd..9a0a61f8f 100644 --- a/dist/opencode/skills/housekeeping/templates/journal.md +++ b/dist/opencode/skills/housekeeping/templates/journal.md @@ -13,7 +13,7 @@ not create or edit journal markdown as the source of truth — use ## Entries -[YYYY-MM-DD HH:MM] skill(implement): implementing TASK-042 +[YYYY-MM-DD HH:MM] skill(implement): implementing LOAF-42 [YYYY-MM-DD HH:MM] decision(scope): description of decision [YYYY-MM-DD HH:MM] discover(scope): something learned [YYYY-MM-DD HH:MM] block(scope): what is blocked diff --git a/dist/opencode/skills/housekeeping/templates/report.md b/dist/opencode/skills/housekeeping/templates/report.md index d0894e593..ca201e869 100644 --- a/dist/opencode/skills/housekeeping/templates/report.md +++ b/dist/opencode/skills/housekeeping/templates/report.md @@ -8,7 +8,7 @@ title: "Report: [Topic]" type: research | audit | analysis | council created: YYYY-MM-DDTHH:MM:SSZ status: draft | done | archived -source: SPEC-XXX | TASK-XXX | ad-hoc +source: LOAF-42 | ad-hoc finalized_at: YYYY-MM-DDTHH:MM:SSZ # Set when status → done archived_at: YYYY-MM-DDTHH:MM:SSZ # Set when status → archived archived_by: orchestrator diff --git a/dist/opencode/skills/idea/SKILL.md b/dist/opencode/skills/idea/SKILL.md index 2a4784f1b..4382b914e 100644 --- a/dist/opencode/skills/idea/SKILL.md +++ b/dist/opencode/skills/idea/SKILL.md @@ -3,11 +3,12 @@ name: idea description: >- Captures ideas into structured nuggets for later evaluation. Use when the user says "I have an idea" or "note this down." Also activate when a specific - actionable concept crystallizes during conversation. Ideas and sparks are - capture primitives routed through triage, which chooses dispositions such as - tracking an Intent or handing to pitch. Not for problem discovery (use pitch), - processing the intake queue (use triage), shaping (use shape), or agent-side - divergent inquiry when direction is undecided (use explore as a technique). + actionable concept crystallizes during conversation. Ideas and sparks stay + capture primitives routed through triage, which files worth-keeping items as + backlog issues or hands them to pitch or shape. Not for problem discovery (use + pitch), processing the intake queue (use triage), shaping a bounded issue (use + shape), or agent-side divergent inquiry when direction is undecided (use + explore as a technique). subtask: false version: 0.2.21 --- @@ -26,7 +27,6 @@ Capture ideas quickly with minimal friction. - Quick Reference - Purpose - Process -- Idea Lifecycle - Guardrails - Related Skills @@ -36,7 +36,7 @@ Capture ideas quickly with minimal friction. - 2-3 questions maximum -- don't turn capture into an interview - Infer metadata automatically -- don't ask for tags, title, or links - One idea per captured row/artifact -- keep them atomic -- No shaping or pitching here -- problem discovery is pitch; bounding is shape +- No shaping, pitching, or filing issues here -- problem discovery is pitch; bounding is shape; dispositions are triage - Capture through `loaf idea capture --title ...` when SQLite state is initialized; log notable context with `loaf journal log` @@ -58,7 +58,7 @@ Capture ideas quickly with minimal friction. ## Purpose -Ideas are raw nuggets — unprocessed, unshaped, but worth remembering. The goal is **speed of capture**, not thoroughness. An idea is retained material, nothing more: tracking it as an Intent, handing it to pitch for problem discovery, handing it to shape when already well-understood, or archiving it are triage dispositions chosen later by the user. +Ideas are raw nuggets — unprocessed, unshaped, but worth remembering. The goal is **speed of capture**, not thoroughness. An idea is retained material, nothing more: filing it as a backlog issue, handing it to pitch for problem discovery, handing it to shape when already well-understood, or archiving it are triage dispositions chosen later by the user. --- @@ -82,7 +82,7 @@ Ideas are raw nuggets — unprocessed, unshaped, but worth remembering. The goal ## Related Skills -- **triage** — process the intake queue and choose dispositions (including hand to pitch or shape) +- **triage** — process the intake queue and choose dispositions (file as backlog issue, hand to pitch, or hand to shape) - **pitch** — problem-discovery ceremony when a captured idea needs a brief before shaping -- **shape** — develop a well-understood direction into a bounded Change +- **shape** — develop a well-understood direction into a bounded issue - **explore** — agent technique when direction is genuinely undecided (not a user front door) diff --git a/dist/opencode/skills/idea/templates/idea.md b/dist/opencode/skills/idea/templates/idea.md index b070494d3..b631ca832 100644 --- a/dist/opencode/skills/idea/templates/idea.md +++ b/dist/opencode/skills/idea/templates/idea.md @@ -11,7 +11,7 @@ title: "[Idea Title]" captured: YYYY-MM-DDTHH:MM:SSZ status: raw tags: [] -related: [] # Optional: spec IDs, idea filenames, or other references +related: [] # Optional: issue aliases, idea filenames, or other references origin: # Optional: draft filename this spark came from (e.g. drafts/YYYYMMDD-brainstorm-slug.md) --- @@ -31,5 +31,5 @@ origin: # Optional: draft filename this spark came from (e.g. dra --- -*Captured via idea -- shape with shape when ready* +*Captured via idea — triage later (backlog issue, pitch, or shape)* ``` diff --git a/dist/opencode/skills/implement/SKILL.md b/dist/opencode/skills/implement/SKILL.md index ece60cd65..7d3fc109e 100644 --- a/dist/opencode/skills/implement/SKILL.md +++ b/dist/opencode/skills/implement/SKILL.md @@ -1,19 +1,20 @@ --- name: implement description: >- - Orchestrates implementation work through agent delegation and batch execution. - Use for all implementation work — features, bug fixes, refactors, and code - changes. Picks Change task files when present and flips checkboxes in - delivering commits. Logs to the project journal and produces agent spawn plans - and progress tracking. Not for shaping (use shape), breakdown (use breakdown), - research, or review. + Orchestrates implementation work through agent delegation and batch execution + against Loaf issues. Use for all implementation work — features, bug fixes, + refactors, and code changes. Picks the next issue from loaf issue frontier, + delegates one agent per started worktree, and treats definition-of-done + criteria as the completion contract. Logs to the project journal and produces + agent spawn plans and progress tracking. Not for shaping or decomposition (use + shape), research, or review. subtask: false version: 0.2.21 --- # Implement -You are the coordinator. Start by understanding the task: +You are the coordinator. Work units are issues. ## Contents - Critical Rules @@ -21,7 +22,7 @@ You are the coordinator. Start by understanding the task: - Quick Reference - Step 0: Context Check - Input Detection -- Linear-Native Routing +- Pick-up and Dispatch - Agent Spawning - Journal First - Guardrails @@ -39,27 +40,32 @@ You are the coordinator. Start by understanding the task: **You are the ORCHESTRATOR, not the implementer.** -- Log `loaf journal log "skill(implement): <task/spec/context>"` as the first action. -- **Change-first task packets:** prefer `docs/changes/<folder>/tasks/TASK-NNN-*.md` as the delegation brief. Flip checkboxes `- [ ]`→`- [x]` in the same commit that delivers the work (outside `docs/changes/` paths must land with the flip for provenance). Use `loaf change tasks --json` for the index. -- Commit task packets unchecked before executing them — a packet that first lands already-checked induces no flip transition, and the evidence trail never exists. -- Compatibility: existing `TASK-XXX` / `SPEC-XXX` SQLite records remain supported until converted; they are not the default for new work. +- Log `loaf journal log "skill(implement): LOAF-42 — <what>"` as the first action. Substitute the real alias (or opaque id) and a short intent. +- **Pick-up-next is `loaf issue frontier`.** That view is open (`triage` / `backlog` / `todo`), unblocked, and unclaimed (not `active`, no started worktree). Derived at read time. +- **The delegation brief is the issue row** — `loaf issue show <ref>` / `loaf issue render <ref>`: body, definition-of-done criteria, children. There is no other packet. +- **One agent, one worktree.** `loaf issue start <ref>` creates the branch and worktree and moves status to `active`. Before dispatch, run `loaf issue list --started`. Never send two agents into the same worktree. +- **Definition of done is the completion contract.** `loaf issue verify <ref>` runs V-tier criteria from the repository root and writes nothing. H-tier is reviewed by a human or this orchestrator. Completion is the work landing plus `loaf issue status <ref> done`. Do not flip checkboxes. Provenance is the delivering commits and the PR whose body is `loaf issue render <ref>`. +- Shape prepares issues. If `loaf issue check <ref>` does not report the delivery issue shaped (or the decision issue ready), stop and send the work to shape. Do not mint a new issue from this skill. ### Orchestrator Can Do Directly - Log journal entries, read journal context, create council files -- Use your harness's task/todo tracking surface; **if `integrations.linear.enabled` is `true` in `.agents/loaf.json`**, use Linear MCP tools when helpful +- Use your harness's todo tracking surface; **if `integrations.linear.enabled` is `true` in `.agents/loaf.json`**, Linear MCP is an overlay only — Loaf issues remain the work unit and Linear never drives Loaf status - Read any file for context - Ask clarifying questions +- Run `loaf issue` read commands, `loaf issue start` / `stop`, `loaf issue status`, and open a PR whose body is `loaf issue render` output ### Orchestrator MUST Delegate (via agent spawn) -**ALL code changes, documentation edits, and implementation work** to specialized agents. **No exceptions**, even for "trivial" 1-line fixes. +**ALL code changes, documentation edits, and implementation work** to specialized agents. **No exceptions**, even for "trivial" 1-line fixes. Spawn each agent into that issue's started worktree. ## Verification - The invocation is logged to the project journal before implementation work begins — no session start step, no "active session" precondition - All code changes delegated via your harness's agent-spawn mechanism -- no direct edits by orchestrator - The journal is continuously updated with spawns, progress, and decisions as work happens -- Spec artifacts closed out on branch before PR creation -- **Linear-native mode:** `blockedBy` of the target sub-issue is fully `completed` before work begins; starting a sub-issue also promotes an unstarted parent rollup to active; parent rollup is auto-closed only when all sub-issues are `completed` +- Each in-flight issue has exactly one started worktree; `loaf issue list --started` was checked before every spawn +- V-tier criteria pass `loaf issue verify <ref>` (writes nothing); H-tier criteria were reviewed by a human or this orchestrator +- The PR body is `loaf issue render <ref>` with no manual editing; checkboxes stay unchecked until status is `done` +- Completion is landing plus `loaf issue status <ref> done` (usually via ship) ## Quick Reference @@ -74,6 +80,15 @@ You are the coordinator. Start by understanding the task: | Code review/audit | reviewer | relevant domain skills | | Research/comparison | researcher | relevant domain skills | +| Moment | Command | +|--------|---------| +| Pick next | `loaf issue frontier` | +| Brief | `loaf issue show <ref>` / `loaf issue render <ref>` | +| Claim workspace | `loaf issue start <ref>` | +| Occupied trees | `loaf issue list --started` | +| V-tier gate | `loaf issue verify <ref>` | +| Landed | `loaf issue status <ref> done` | + --- ## Step 0: Context Check @@ -84,152 +99,51 @@ Before starting, evaluate context suitability. |---------|--------| | New command/skill added this conversation | **Restart required** (skills loaded at start) | | Conversation > 30 exchanges | Suggest restart | -| Just completed a different task/spec | Suggest clear | +| Just completed a different issue | Suggest clear | | About to start multi-file implementation | Check depth | If restart needed: log current state with `loaf journal log`, then ask the user to restart. A supported startup adapter may reconstruct continuity from the journal in the next conversation; when the exact current target mode is candidate or unsupported, explicitly run `loaf journal context` after restarting. ## Input Detection -Parse `$ARGUMENTS` to determine the work type: +Parse `$ARGUMENTS` to determine the work: | Input Pattern | Type | Action | |---------------|------|--------| -| `TASK-XXX` | Local task | Load via `loaf task show`, log the task coupling | -| `SPEC-XXX` | Spec orchestration | If spec frontmatter has `linear_parent`, resolve to that Linear parent and follow Linear-Native Routing. Otherwise resolve local tasks and build dependency-ready rounds | -| `TASK-XXX..YYY` | Task range | Expand range, build dependency-ready rounds | -| `TASK-XXX,YYY,ZZZ` | Task list | Parse list, build dependency-ready rounds | -| `PLT-123`, `ENG-198`, `PROJ-123` | Linear issue | **If `integrations.linear.enabled` is `true`:** fetch via `get_issue`, then branch on parent vs sub-issue — see [Linear-Native Routing](#linear-native-routing). **Otherwise:** treat as label text or create local task | -| Description text | Ad-hoc | Auto-create local task from description, then fall through to task-coupled flow | - -### Task-Coupled Work - -When starting from `TASK-XXX`: - -1. Load task metadata via `loaf task show TASK-XXX --json`; do not recreate `.agents/TASKS.json` after the SQLite cutover -2. Log the task coupling: `loaf journal log "decision(implement): implementing TASK-XXX"` -3. Load parent spec if task has `spec:` field - -### Ad-hoc Task Auto-Creation - -When input is free-text description (not matching any known pattern): - -1. **Parse the description:** - - Single sentence → use entire text as task title - - Multi-sentence → first sentence = title, remainder = acceptance criteria - - Split on `. ` followed by uppercase letter only (conservative — avoids false positives from URLs, abbreviations) -2. **Create the task:** `loaf task create --title "<parsed title>"` -3. **Write criteria** (if multi-sentence): edit the task `.md` file body to add the remaining sentences as acceptance criteria -4. **Fall through** to the task-coupled flow above — the result is a `TASK-XXX` ID that enters the existing planning pipeline unchanged - -**No user interaction required.** The description IS the task; invoking implement already expressed intent. +| `LOAF-42` or opaque id | Single issue | Load via `loaf issue show <ref>`; fall through to Pick-up and Dispatch | +| Parent ref with children | Tree | `loaf issue tree <ref>`; build rounds from children and `blocks` / `blocked_by` edges (see [batch-orchestration.md](references/batch-orchestration.md)) | +| Multiple refs | Batch | Same round construction across the named set | +| Empty / "next" | Frontier | `loaf issue frontier`; if one row, pick it; if several, ask (structured question tool if the harness has one); if none, stop | +| Description text | Ad-hoc | Match frontier by title. Do not mint. If nothing matches, stop and send to shape | +| Decision kind | Question | Not implementation. Surface the question; do not `loaf issue start` unless the user points at a delivery issue that records the decided answer | -### Non-Existent Task ID Error +### Missing ref -If input matches `TASK-XXX` pattern but `loaf task show` cannot resolve it: +If input looks like an issue ref but `loaf issue show` cannot resolve it: -1. Show error: `"TASK-XXX not found in local task state"` -2. Ask the user: `"Did you mean to create a new task? You can re-run with the description as free text."` -3. **Do not silently create** — the user likely has a typo +1. Show error: `"<ref> not found"` +2. Ask whether they meant a different alias, or to shape a new issue +3. **Do not silently create** --- -## Linear-Native Routing - -Applies when `integrations.linear.enabled` is `true` AND `$ARGUMENTS` -resolves to a Linear issue (direct Linear ID, or a `SPEC-XXX` whose -frontmatter has `linear_parent`). - -Fetch the issue once via `get_issue` and branch on its shape: - -### Parent rollup issue (has `spec` label) - -The issue represents a spec. Do **not** implement it directly — spec-level -"work" is always done via sub-issues. - -1. List sub-issues via `list_issues` with `parent: <parent-id>`. -2. Classify each by state: - - `in_progress` — active work - - `unstarted` + no open `blockedBy` — ready to start - - `unstarted` + open `blockedBy` — blocked - - `completed` — done, skip -3. Select the next work item: - - If one or more sub-issues are `in_progress`, pick the **lowest-ID** - in-progress sub-issue. Resume that. - - Else, if one unblocked `unstarted` sub-issue exists, pick it. - - Else, if multiple unblocked `unstarted` sub-issues exist, use - your harness's structured question tool (if it has one) to let the user choose: pick one, or delegate N in - parallel via parallel agents. List each sub-issue's title + ID. - - Else (all remaining sub-issues are blocked), refuse with a summary: - "All remaining sub-issues under <parent-id> are blocked. Blockers: - <list>." -4. Once a sub-issue is selected, recurse into the sub-issue flow below - with that ID. The parent itself is never the implementation target. - -### Sub-issue (has `parentId`, no `spec` label) - -The issue is an actual task. Implement it directly — with a pre-flight gate. - -1. **Pre-flight: verify `blockedBy` is clear.** For each issue in the - sub-issue's `blockedBy` field, call `get_issue` and confirm its state is - `completed`-type. If any blocker is not Done: - - **Refuse to start.** Do not begin work. Do not move the issue. - - Show the blockers: `"Cannot start <sub-issue-id>. Blocked by: <list - with IDs, titles, and current states>."` - - Suggest: `"Complete the blocker(s) first, or ask to override if the - blockedBy link is stale."` -2. If blockers are clear: - - Start the sub-issue as one logical Linear operation. This moves - the sub-issue to the team's `started`/In Progress state and, when the - parent rollup is still `backlog` or `unstarted`, promotes the parent to - the same `started`/In Progress state. - - If the parent is already active, leave it unchanged. If the parent is - `completed`, `canceled`, or archived, refuse to start unless the user - explicitly asks to override the protected parent state. - - If the child update succeeds but parent promotion fails, report a - reconciliation error naming the parent issue before continuing. - - Resolve branch name from the sub-issue's `branchName` field (Linear - auto-generates one) — see - [branch-and-completion.md](references/branch-and-completion.md). - - Log the task coupling, then continue with the standard Startup Checklist. - -### Completion (after implementer + reviewer finish cleanly) - -When the sub-issue's implementation passes review and tests: - -1. Move the sub-issue to the team's `completed`-type state via - `update_issue` (look up via `list_issue_statuses`, filter - `type: "completed"`). -2. Query the parent's sub-issues again: - - If **all** sub-issues are now `completed`-type, move the parent - rollup to `completed` as well. Also mark the local spec as - `complete` (see [Then Execute → AFTER](#then-execute)). - - If **some** remain, list them as "next available" for the user, - applying the same classification as step 2 of the parent flow above. - Offer to continue with the next one in this session, or stop here. -3. **Do not** close the parent while any sub-issue is open — not even if - only `blocked` ones remain. Blocked sub-issues are still in-flight - work from the spec's perspective. - -### Status flow summary - -| Moment | Sub-issue state | Parent state | -|--------|----------------|--------------| -| Implementation starts | `started` / In Progress | promoted to `started` / In Progress if still `backlog` or `unstarted` | -| Implementation + review pass | `completed` | check: close only if all sibs completed | -| Blocker discovered mid-work | `in_progress` + blocker comment | unchanged | - -### What Linear-native routing does NOT do - -- Does not pull down the full spec text. The parent's description already - links to `.agents/specs/SPEC-NNN-*.md`. Read the local file for shape, - rabbit holes, and strategic tensions. -- Does not create or rewrite sub-issues. That's breakdown's job. If - implementation reveals a missing task, surface it to the user; they - decide whether to run breakdown again or add an ad-hoc sub-issue. -- Does not sync in-progress state bidirectionally. Source of truth at any - moment: Linear for issue state, local files for spec content, the project - journal for current handoff. +## Pick-up and Dispatch + +1. **Confirm the issue is implementable.** `loaf issue check <ref>` must report a delivery issue shaped (or, if the user explicitly asked to resolve a decision issue, that it is ready). Unshaped work goes to shape. +2. **Honor the frontier.** An issue that is blocked does not appear on `loaf issue frontier`. `loaf issue link A blocks B` means A blocks B; B waits until A is `done`, `cancelled`, or `duplicate`. Do not start a blocked successor. Parent/child structure from `loaf issue tree` is not a sequencing edge — only `blocks` / `blocked_by` are. Use the tree to know who belongs in the batch; use the edges to order rounds. +3. **Parents with children are not the implementation target.** Dispatch leaf delivery children that are on the frontier. A parent executes through claimed child criteria, not by starting the parent worktree. +4. **Inspect occupied worktrees:** + ```bash + loaf issue list --started + ``` + Columns: alias, title, `started_branch`, `started_worktree`, optional `(missing)`. If this ref is already started, resume in that worktree with one agent. If the path is occupied by another issue, refuse. A `(missing)` marker means the recorded path is gone — `loaf issue stop <ref>` (not from inside the tree) before starting again. +5. **Start the workspace** (skip if already started and the path exists): + ```bash + loaf issue start <ref> + ``` + Creates branch `issue/<alias-or-id>` in lowercase (`issue/loaf-42`, id suffix when that name is claimed), a sibling worktree, and sets status to `active`. Base is the nearest started ancestor's branch, else the repository default branch. Start refuses archived rows and terminal statuses (`done`, `cancelled`, `duplicate`). +6. **Hand the agent the brief** from `loaf issue show <ref>` (body, criteria, children) and, when opening a PR, `loaf issue render <ref>`. Tell the agent to work only in `started_worktree`. +7. **Batch rounds.** When input is a parent or a set of refs, group unblocked delivery children into dependency-ready rounds from `blocked_by` edges and parent/child structure. Parallel only within a round, max 3, and only when each agent has its own worktree. See [batch-orchestration.md](references/batch-orchestration.md) for the round loop, `--dry-run` / `--parallel` / `--continue` / `--skip <ref>` / `--abort`, and blocked-state recovery. --- @@ -248,7 +162,7 @@ Spawn specialized agents with the appropriate profile: | Code review/audit | reviewer | relevant domain skills | | Research/comparison | researcher | relevant domain skills | -**Rules:** Be specific in prompts. One concern per agent. Include context. Parallel when independent, sequential when dependent. +**Rules:** Be specific in prompts. One concern per agent. Include the issue ref, `started_worktree`, body, and definition of done. Parallel when independent (separate worktrees), sequential when a `blocks` edge says so. --- @@ -257,15 +171,14 @@ Spawn specialized agents with the appropriate profile: There is no session to start — journaling is continuous. Your first action is to log the invocation: ```bash -loaf journal log "skill(implement): <task/spec/context>" +loaf journal log "skill(implement): LOAF-42 — <what>" ``` Entries are project-scoped and tagged with this conversation's harness id automatically. Continuity from prior conversations may arrive through a supported startup adapter; when the exact current target mode is candidate or unsupported, pull it explicitly with `loaf journal context`. Use `loaf journal recent` when you need a narrower timeline. -Suggest renaming the harness conversation with a meaningful name derived from context (use your harness's rename surface if it has one): -- From spec: `SPEC-027-session-stability` -- From task: `TASK-042-login-fix` -- From ad-hoc: `{short-slug-from-description}` +Suggest renaming the harness conversation with a meaningful name derived from the issue (use your harness's rename surface if it has one): +- From issue: `LOAF-42-login-fix` +- From ad-hoc match: `{alias}-{short-slug}` --- @@ -279,12 +192,14 @@ Suggest renaming the harness conversation with a meaningful name derived from co 6. **Journal continuously** -- log spawns, progress, blockers, and decisions with `loaf journal log` as they happen 7. **Clean up** -- no ephemeral files; write an optional `wrap` entry only when there's synthesis worth saving 8. **When in doubt, ask the user** +9. **Never `loaf issue stop` from inside the started worktree** -- stop does not change status; `--force` removes a dirty tree +10. **Do not tick definition-of-done boxes** -- `loaf issue verify` writes nothing; render checks a box only when status is already `done` ## Decision Tree ``` Is this a code/config/doc change? -+-- YES -> Spawn appropriate agent ++-- YES -> Spawn appropriate agent into the issue worktree +-- NO -> Is this a planning/coordination decision? +-- YES with clear path -> Proceed, log the decision +-- YES but ambiguous -> Ask user @@ -297,18 +212,16 @@ When multiple valid approaches exist: spawn council (5-7 agents, odd), present r ## Startup Checklist -1. [ ] Log the invocation: `loaf journal log "skill(implement): <context>"` -2. [ ] Parse input (task, Linear ID, or description) -3. [ ] If TASK-XXX: load task via `loaf task show TASK-XXX`, log task coupling, load parent spec -4. [ ] If Linear ID (or `SPEC-XXX` with `linear_parent`): follow [Linear-Native Routing](#linear-native-routing). Parent → walk sub-issues and select next. Sub-issue → verify `blockedBy` is clear, then start it as one logical Linear operation so the parent is promoted when needed -5. [ ] If description: auto-create task (see Ad-hoc Task Auto-Creation above) -6. [ ] Create dedicated branch (see [branch-and-completion.md](references/branch-and-completion.md)) -7. [ ] Suggest team based on task context -8. [ ] Log initial context and references with `loaf journal log` -9. [ ] Break down work using your harness's task/todo tracking surface -10. [ ] Identify needed specialized agents -11. [ ] Log next steps before spawning -12. [ ] **Get user approval** before spawning +1. [ ] Log the invocation: `loaf journal log "skill(implement): LOAF-42 — <what>"` +2. [ ] Parse input (issue ref, parent, set, frontier, or description) +3. [ ] Load `loaf issue show <ref>`; if children, `loaf issue tree <ref>` +4. [ ] `loaf issue check <ref>` — shaped/ready, or stop and send to shape +5. [ ] Confirm the ref is on `loaf issue frontier` (or already started for resume) +6. [ ] `loaf issue list --started` — one agent per worktree +7. [ ] `loaf issue start <ref>` unless already started +8. [ ] Suggest conversation rename (`LOAF-42-login-fix`) +9. [ ] Identify specialized agents; log next steps +10. [ ] **Get user approval** before spawning --- @@ -316,32 +229,28 @@ When multiple valid approaches exist: spawn council (5-7 agents, odd), present r ### BEFORE (Planning) 1. Log the invocation with `loaf journal log` -2. Set task status: `loaf task update TASK-XXX --status in_progress` -3. Break down work into agent-sized tasks -4. Identify spawn order (respect dependencies) +2. `loaf issue start <ref>` (status becomes `active` through start) +3. Slice work into agent-sized units that still belong to this one issue +4. Identify spawn order (respect `blocked_by` edges and parent/child rounds) 5. Get user approval ### DURING (Execution) -1. Spawn specialized agents via your harness's agent-spawn mechanism -2. Log each spawn with `loaf journal log "todo(agent): spawned <agent> for <task>"` -3. Update Linear with progress (no emoji, no file paths) -4. Keep journal entries handoff-ready -5. After each agent completes: log outcome, spawn next +1. Spawn specialized agents into `started_worktree` via your harness's agent-spawn mechanism +2. Log each spawn with `loaf journal log "todo(agent): spawned <agent> for <ref>"` +3. Keep journal entries handoff-ready +4. After each agent completes: log outcome, spawn next +5. If Linear overlay is enabled, you may comment there — Loaf status stays on `loaf issue` ### AFTER (Completion) 1. Code review pass (spawn `reviewer` agent) 2. Spawn implementer (with foundations + language skill) for final testing -3. **Close out spec artifacts on the branch** (included in the squash merge): - - **Local-tasks mode:** `loaf task update TASK-XXX --status done` (per task), then `loaf task archive --spec SPEC-XXX` - - **Linear-native mode:** `update_issue` the sub-issue to `completed`-type state. Then query the parent's sub-issues; if all are `completed`, also close the parent. If some remain, list them for the user (see [Linear-Native Routing → Completion](#completion-after-implementer--reviewer-finish-cleanly)) - - Mark spec complete and archive: `loaf spec archive SPEC-XXX` (both modes) - - Write a `wrap(scope)` journal entry if the work produced synthesis worth saving (next steps, abandoned paths); otherwise skip it - - Commit: `chore: close SPEC-XXX — archive tasks and spec` -4. If on a feature branch: push and create PR (`gh pr create`). Follow PR format and squash merge conventions in [commits reference](../git-workflow/references/commits.md). -5. After PR is created and approved, use ship to review, verify, and land the PR. Use release later when a coherent batch of landed work is ready to publish. -6. **Suggest reflection:** Check the journal for extractable learnings before closing out: +3. Run `loaf issue verify <ref>` (V-tier, writes nothing). Review every H-tier row yourself or with the user — a skip from verify is not a pass +4. Open or update the PR with body `loaf issue render <ref>` — no manual editing. Follow PR format and squash merge conventions in [commits reference](../git-workflow/references/commits.md) +5. After the PR is created, use ship to review, verify, land, mark `loaf issue status <ref> done`, and `loaf issue stop <ref>`. Use release later when a coherent batch of landed work is ready to publish +6. Write a `wrap(scope)` journal entry if the work produced synthesis worth saving; otherwise skip it +7. **Suggest reflection:** Check the journal for extractable learnings before closing out: - `decision(...)` entries are present - - ADRs, report verdicts, or spec changelog entries were recorded + - ADRs or report verdicts were recorded If any signal is present, suggest: *"This produced key decisions. Consider running reflect to update strategic docs."* If none are present, stay silent. --- @@ -350,18 +259,18 @@ When multiple valid approaches exist: spawn council (5-7 agents, odd), present r | Topic | Reference | Use When | |-------|-----------|----------| -| Batch Orchestration | [batch-orchestration.md](references/batch-orchestration.md) | Running specs, task ranges, or task lists with dependency-ready rounds | -| Branch and Completion | [branch-and-completion.md](references/branch-and-completion.md) | Branch management, team routing, diagrams, Linear sync, journaling, task completion | +| Batch Orchestration | [batch-orchestration.md](references/batch-orchestration.md) | Running a parent or a set of issue refs with dependency-ready rounds | +| Branch and Completion | [branch-and-completion.md](references/branch-and-completion.md) | Team routing, diagrams, exploration, journaling alongside `loaf issue start` / `stop` | +| Working issues locally | [../orchestration/references/local-tasks.md](../orchestration/references/local-tasks.md) | Frontier, started worktrees, status vocabulary, definition of done | --- ## Suggests Next -After all tasks are complete, suggest ship to land the PR. Suggest release only when the landed work forms a coherent release batch. +After the PR exists, suggest ship to land it. Suggest release only when the landed work forms a coherent release batch. ## Related Skills -- **shape** - Spec format and lifecycle -- **breakdown** - Turning specs into tasks -- **orchestration/local-tasks** - Task file format and lifecycle -- **orchestration/journal** - Project journal continuity model +- **shape** — Issue preparation and decomposition +- **orchestration/journal** — Project journal continuity model +- **orchestration/local-tasks** — Frontier, started worktrees, status, definition of done diff --git a/dist/opencode/skills/implement/references/batch-orchestration.md b/dist/opencode/skills/implement/references/batch-orchestration.md index 7ab48f755..7902f28f1 100644 --- a/dist/opencode/skills/implement/references/batch-orchestration.md +++ b/dist/opencode/skills/implement/references/batch-orchestration.md @@ -7,63 +7,64 @@ - Batch Execution Model - Blocked-State Recovery -Detailed reference for running specs, task ranges, or task lists with dependency-ready scheduling. +Detailed reference for running a parent issue or a set of issue refs with dependency-ready scheduling. ## Orchestration Options | Option | Behavior | |--------|----------| | `--dry-run` | Show dependency-ready execution plan, do not run agents | -| `--parallel` | Run tasks in the same dependency-ready group concurrently (max 3 at once) | -| `--continue` | Resume a blocked orchestration from the recorded task/group | -| `--skip TASK-XXX` | Mark one blocked task as skipped and continue | +| `--parallel` | Run issues in the same dependency-ready group concurrently (max 3 at once) | +| `--continue` | Resume a blocked orchestration from the recorded issue/group | +| `--skip <ref>` | Skip one blocked issue and continue | | `--abort` | Mark orchestration as aborted and stop remaining work | ## Batch Resolution and Dependency-Ready Scheduling -For `SPEC-XXX`, `TASK-XXX..YYY`, and `TASK-XXX,YYY,ZZZ`: +For a parent ref (`loaf issue tree <ref>`) or a named set of refs: -1. Resolve selected tasks and validate each task file exists. -2. Extract `depends_on` from each task and build a dependency graph. -3. Group tasks into dependency-ready rounds: - - First round: tasks with no unresolved dependencies - - Each subsequent round: tasks whose dependencies are completed in earlier rounds -4. If `--parallel` is set, allow parallel execution only within a dependency-ready round and only for non-conflicting tasks. -5. Present execution plan (tasks, dependency-ready rounds, mode, total count) and ask for confirmation unless `--dry-run`. -6. Track progress in the journal and in task statuses: log round boundaries and the current task with `loaf journal log`, and drive each task's status with `loaf task update`. The journal plus task statuses are the durable record of where the batch is. +1. Resolve the selected refs and validate each issue exists (`loaf issue show <ref>`). +2. Read `blocks` / `blocked_by` edges and parent/child structure. Parent/child is not a sequencing edge — only `blocks` / `blocked_by` are. +3. Group unblocked delivery children into dependency-ready rounds: + - First round: issues with no unresolved predecessors + - Each subsequent round: issues whose predecessors are `done`, `cancelled`, or `duplicate` +4. If `--parallel` is set, allow parallel execution only within a dependency-ready round, max 3, and only when each agent has its own started worktree. +5. Present execution plan (issues, dependency-ready rounds, mode, total count) and ask for confirmation unless `--dry-run`. +6. Track progress in the journal: log round boundaries and the current ref with `loaf journal log`. Status moves through `loaf issue start` (to `active`) and, after landing, `loaf issue status <ref> done`. The journal plus issue statuses are the durable record of where the batch is. + +Parents with children are not the implementation target. Dispatch leaf delivery children that are on `loaf issue frontier`. ## Option Handling (`--continue`, `--skip`, `--abort`) -1. Recover batch progress from the journal: `loaf journal recent --since-last-wrap` (or `loaf journal context`) plus `loaf task list --json` to see which tasks are still open. -2. If `--continue`: resume from the last logged dependency-ready round and task. -3. If `--skip TASK-XXX`: mark that task `skipped` via `loaf task update`, log the reason with `loaf journal log`, continue the same dependency-ready round. +1. Recover batch progress from the journal: `loaf journal recent --since-last-wrap` (or `loaf journal context`) plus `loaf issue list --json` and `loaf issue list --started` to see which issues are still open or claimed. +2. If `--continue`: resume from the last logged dependency-ready round and issue. +3. If `--skip <ref>`: log the reason with `loaf journal log`, continue the same dependency-ready round. Do not mark the skipped issue `done`. 4. If `--abort`: log `block(orchestration): aborted`, print a summary, and stop. 5. If no in-flight batch is evident from the journal, report that and ask for fresh selection input. ## Batch Execution Model -When input resolves to multiple tasks, run a dependency-ready round loop: +When input resolves to multiple issues, run a dependency-ready round loop: 1. Set orchestration mode (`sequential` by default, `parallel` only with `--parallel`). 2. For each dependency-ready round: - Log the round start with `loaf journal log` - - Run each task (sequentially, or concurrently within safety limits) - - For each task: set `in_progress` -> spawn agent -> run task verification -> mark `done`/`failed` via `loaf task update` -3. If any task fails verification, stop immediately and log `block(orchestration): <task> failed <reason>`. -4. Consider a round complete only when all its tasks are `done` or skipped. + - For each issue: `loaf issue list --started`, then `loaf issue start <ref>` unless already started, spawn one agent into `started_worktree`, run `loaf issue verify <ref>` (V-tier; writes nothing) +3. If any issue fails verification, stop immediately and log `block(orchestration): <ref> failed <reason>`. +4. Consider a round complete only when all its issues have landed (`loaf issue status <ref> done` via ship) or were skipped. 5. Continue until all rounds complete, then log a closing entry summarizing the batch. ## Blocked-State Recovery When blocked, always print: -- Failed task ID and title +- Failed issue ref and title - Dependency-ready round and current progress - Failure reason + key error output - Recovery options: Re-invoke the implement workflow with: -- `--continue` — after fixes are applied, retry from the blocked task -- `--skip TASK-XXX` — skip only the specified task and continue remaining tasks in the current dependency-ready round +- `--continue` — after fixes are applied, retry from the blocked issue +- `--skip <ref>` — skip only the specified issue and continue remaining issues in the current dependency-ready round - `--abort` — finalize the orchestration as aborted with no further execution diff --git a/dist/opencode/skills/implement/references/branch-and-completion.md b/dist/opencode/skills/implement/references/branch-and-completion.md index e4bea8b5d..5c89cdf02 100644 --- a/dist/opencode/skills/implement/references/branch-and-completion.md +++ b/dist/opencode/skills/implement/references/branch-and-completion.md @@ -8,7 +8,7 @@ - Linear Status Management - Handoff Readiness - Timestamps for User Context -- Task Completion +- Issue Completion Detailed reference for branch setup, Linear routing, and completion during implementation. @@ -18,28 +18,18 @@ Detailed reference for branch setup, Linear routing, and completion during imple ### Getting Branch Name -1. **If Linear issue exists**: Use the `branchName` field from `get_issue` response - - Linear auto-generates branch names like `username/plt-123-issue-title` - - These are pre-formatted and consistent with team conventions +`loaf issue start <ref>` is the claim. It creates `issue/<alias-or-id>` in lowercase (`issue/loaf-42`, id suffix when that name is claimed), a sibling worktree, and moves status to `active`. -2. **If no Linear issue**: Create branch name from the work description - - Format: `feature/<description>` or `fix/<description>` - - Use kebab-case, keep it concise +Do not `git checkout -b` as a substitute for start. Check `loaf issue list --started` first. Never send two agents into the same worktree. Do not run `loaf issue stop` from inside that worktree. ### Branch Workflow ```bash -# 1. Check current branch status -git status - -# 2. Create and checkout the branch (use Linear's branchName if available) -git checkout -b <branch-name> - -# 3. Confirm branch creation -git branch --show-current +loaf issue list --started +loaf issue start <ref> ``` -**Important:** All implementation agents will work on this branch. The branch should be ready for PR when work completes. Journal entries are tagged with the observed branch automatically, so continuity stays branch-scoped. +Work only in `started_worktree`. The branch should be ready for PR when work completes. Journal entries are tagged with the observed branch automatically. --- @@ -53,7 +43,7 @@ When creating Linear issues, suggest the appropriate team: > "This task seems best suited for the **Security** team (matched: 'auth', 'vulnerability'). > Security hasn't been used in this project yet. Add this team?" 4. **If user confirms**, add team to `known_teams` in config -5. **Create issue** with suggested team +5. **Create via `loaf issue new`** so identity can be delegated; do not create in Linear MCP and forget `loaf issue pull` ### Team Suggestion Example @@ -75,7 +65,7 @@ Use Linear MCP's `list_teams` (if configured) to get all workspace teams for val ## Diagram Consideration -For multi-file or multi-service changes, consider adding architecture diagrams to the linked spec, report, ADR, or implementation notes. +For multi-file or multi-service changes, consider adding architecture diagrams to the issue, a report, ADR, or implementation notes. ### When to Create Diagrams @@ -94,7 +84,7 @@ Ask yourself: 2. Is there a data flow that needs to be understood? 3. Would a visual help communicate the approach? -If yes to any, capture the diagram in a durable artifact such as a spec, report, ADR, or implementation note, and log the reference with `loaf journal log`. +If yes to any, capture the diagram in a durable artifact such as a report, ADR, or implementation note, and log the reference with `loaf journal log`. ### Diagram Template @@ -146,36 +136,23 @@ For complex tasks, explore before implementing: ## Linear Status Management -**Keep Linear status synchronized with actual work state:** - -| Work State | Linear Status (sub-issue) | -|------------|---------------------------| -| Work begun | In Progress | -| Blocked/waiting for user | In Progress (add blocker comment) | -| Work completed | Done (or In Review if PR pending) | +**Keep Loaf status synchronized with actual work state.** Linear is an overlay (`loaf issue pull` / `push` / `reconcile`); never drive Loaf status from Linear MCP tools. -### Parent rollup auto-close +| Work State | Loaf status | +|------------|-------------| +| Work begun | `active` via `loaf issue start` | +| Blocked/waiting | Stay `active`; log `block(scope)` and leave a Linear comment if the overlay is on | +| Work landed | `done` via `loaf issue status <ref> done` (usually ship), then `loaf issue stop <ref>` | -In Linear-native mode, the **parent** rollup issue (labeled `spec`) is not -moved manually during sub-issue work. It flips to Done automatically when -the last sub-issue flips to Done, and only then. Procedure: +### Parent vs children -1. After moving a sub-issue to a `completed`-type state, call - `list_issues` with `parent: <parent-id>`. -2. If every sub-issue is in a `completed`-type state, move the parent to - `completed` via `update_issue`. -3. If any sub-issue is still in an open state (including `blocked`), the - parent stays where it is — the spec is not done. +Parents with children are not the implementation target. Dispatch leaf delivery children on `loaf issue frontier`. A parent is not marked `done` because a child landed. -Never set the parent to In Progress manually — a parent in Linear-native -mode reflects a rollup of its sub-issues, not its own work. +`loaf issue link A blocks B` is the sequencing edge. An issue with an open predecessor does not appear on the frontier. Do not start a blocked successor. -### BlockedBy pre-flight +### Blocked-by pre-flight -Before moving a sub-issue to In Progress, confirm every issue in its -`blockedBy` field is in a `completed`-type state. If not, refuse to start -and report the blockers. This is a hard gate in Linear-native mode — -never implement through open `blockedBy`. +Before `loaf issue start`, confirm the ref is on `loaf issue frontier`. If it is blocked, refuse and report the predecessors. Never implement through an open `blocks` edge. --- @@ -184,7 +161,7 @@ never implement through open `blockedBy`. **The journal must ALWAYS be handoff-ready.** After every significant action: 1. Log what just happened with `loaf journal log` -2. Reference task/spec/report/commit IDs rather than duplicating long prose +2. Reference issue/report/commit IDs rather than duplicating long prose 3. Log completed agent work with outcomes 4. Ensure anyone could pick up the work immediately from `loaf journal recent` @@ -205,32 +182,18 @@ Generate with: `date -u +"%Y-%m-%d %H:%M UTC"` --- -## Task Completion +## Issue Completion -When a task-coupled unit of work completes: +When an issue-coupled unit of work completes: -1. **Update task status** (local file or Linear sub-issue) -2. **Check spec progress:** - - Local-tasks mode: list all tasks for the spec; if all done → mark - spec `complete`, else spec stays `implementing` - - Linear-native mode: query the parent rollup's sub-issues via - `list_issues` with `parent: <parent-id>`; if all are `completed`-type, - close the parent and mark the local spec `complete`, else both stay - in flight -3. **Write a `wrap` journal entry** if the conversation holds synthesis worth - saving (next steps, abandoned paths); skip it otherwise — nothing is - "closed," a conversation that ends without a wrap leaves a valid journal - -### Spec Completion Check +1. **Open or update the PR** with body `loaf issue render <ref>` — no manual editing +2. **Land via ship** — review definition of done, `loaf issue verify <ref>`, squash merge, then `loaf issue status <ref> done` and `loaf issue stop <ref>` +3. **Write a `wrap` journal entry** if the conversation holds synthesis worth saving (next steps, abandoned paths); skip it otherwise — nothing is "closed," a conversation that ends without a wrap leaves a valid journal ```bash -# Local-tasks mode: any open tasks for this spec? -loaf task list --spec SPEC-001 --status open --json - -# Linear-native mode: query the Linear parent's sub-issues -# (via get_issue + list_issues with parent filter) -# The parent itself only flips to Done when every sub-issue is Done. +loaf issue show <ref> +loaf issue tree <ref> +loaf issue list --started ``` -Never mark the local spec `complete` while its Linear parent still has -open sub-issues — the two sources of truth should agree on "done." +Do not mark a parent `done` while delivery children are still open. Do not flip Loaf status from Linear MCP tools; use `loaf issue reconcile` if the overlay has drifted. diff --git a/dist/opencode/skills/implement/templates/journal.md b/dist/opencode/skills/implement/templates/journal.md index caf7409fd..9a0a61f8f 100644 --- a/dist/opencode/skills/implement/templates/journal.md +++ b/dist/opencode/skills/implement/templates/journal.md @@ -13,7 +13,7 @@ not create or edit journal markdown as the source of truth — use ## Entries -[YYYY-MM-DD HH:MM] skill(implement): implementing TASK-042 +[YYYY-MM-DD HH:MM] skill(implement): implementing LOAF-42 [YYYY-MM-DD HH:MM] decision(scope): description of decision [YYYY-MM-DD HH:MM] discover(scope): something learned [YYYY-MM-DD HH:MM] block(scope): what is blocked diff --git a/dist/opencode/skills/loaf-reference/SKILL.md b/dist/opencode/skills/loaf-reference/SKILL.md index 06baf7b87..8f58f82f2 100644 --- a/dist/opencode/skills/loaf-reference/SKILL.md +++ b/dist/opencode/skills/loaf-reference/SKILL.md @@ -25,7 +25,7 @@ The Loaf operating manual for agents: how to discover commands, diagnose project ## Operating Rules - Get exact, current syntax live: `loaf --help` lists every command, `loaf <command> --help` details one. This index is a map, not the contract. -- Prefer `--json` surfaces when diagnosing: `loaf config check --json`, `loaf state doctor --json`, `loaf change check --json`. Parse the structured output instead of scraping human-readable text. +- Prefer `--json` surfaces when diagnosing: `loaf config check --json`, `loaf state doctor --json`. Parse the structured output instead of scraping human-readable text. - Run the deterministic CLI command before hand-editing anything it manages; the command owns its files. - Use `--fix` only for safe, mechanical repairs, and review what it changed. - Ask the user for project-owned choices — GitHub account, tracker or integration election, which harnesses to install — never guess them. @@ -64,17 +64,16 @@ Names and one-line purposes only. Run `loaf <command> --help` for options, argum | `loaf config` | Validate and refresh project Loaf config | check | | `loaf hooks` | Inspect and set which Loaf hooks project into an installed harness's hooks file | list, enable, disable | | `loaf init` | Initialize a project with Loaf structure | — | -| `loaf release` | Create a new release with changelog, version bump, and tag | — | +| `loaf release` | Cut a retroactive release from already-landed work | suggest, cut | | `loaf search` | Search SQLite artifact bodies, journal entries, and indexed docs | — | | `loaf docs` | Manage docs/ indexing | index | -| `loaf change` | Shape-first Change artifacts: git-canonical work context under docs/changes/ | init, check, list | | `loaf render` | Maintain committed durable Markdown renders | sweep | | `loaf state` | Manage native SQLite state | path, status, init, doctor, repair legacy-project-database, repair relationship-origin, repair journal-search, migrate markdown, migrate storage-home, migrate schema, migrate deferrals, migrate lifecycle-statuses, migrate alias-orphans, migrate journal-duplicates, migrate journal-first, backup, backup verify, backup restore, restore-ephemerals, verify-ephemerals, export, export all, export triage, export spec, export release-readiness | | `loaf journal` | Record and read the project-scoped journal (the durable record across all conversations) | log, recent, search, show, context, export, defer | | `loaf project` | Manage durable project identity | list, show, identity, rename, move, delete | | `loaf migrate` | Run native migration workflows | markdown, storage-home, schema, lifecycle-statuses, journal-first, worktree-storage | -| `loaf task` | Manage project tasks | list, show, status, create, update, archive, refresh, sync | -| `loaf spec` | Manage project specs | new, edit, list, show, status, render, finalize, archive, delete | +| `loaf task` | Manage project tasks; superseded by loaf issue for new work | list, show, status, create, update, archive, refresh, sync | +| `loaf issue` | Manage issues in native SQLite state | new, show, list, tree, frontier, start, stop, edit, status, dod, dod add, dod list, dod remove, dod claim, dod unclaim, promote, check, verify, bucket, link, render, export, pull, push, reconcile | | `loaf report` | Manage durable reports (research, audits, investigations) | list, show, render, generate, create, edit, finalize, archive | | `loaf finding` | Manage report findings and verdicts in native SQLite state | list, show, create, verdict, import-json | | `loaf run` | Manage provenance runs for generated findings and reports | list, show, create, complete | @@ -88,7 +87,7 @@ Names and one-line purposes only. Run `loaf <command> --help` for options, argum | `loaf trace` | Trace relationships for one state entity | — | | `loaf brainstorm` | Manage brainstorms in native SQLite state | capture, list, show, promote, archive | | `loaf idea` | Manage ideas in native SQLite state | list, show, capture, promote, resolve, archive | -| `loaf intent` | Manage tracked Intent in native SQLite state; disposition is derived from append-only facts | create, defer, resume, resolve, show, list | +| `loaf intent` | Manage tracked Intent in native SQLite state; disposition is derived from append-only facts; superseded by loaf issue for new work | create, defer, resume, resolve, show, list | | `loaf intake` | Read the deterministic local intake projection; triage judgment stays with humans and Skills | list | | `loaf exploration` | Manage relational Exploration continuity: immutable portable checkpoints, no lifecycle status, no current pointer | create, checkpoint, list, context, conversation | | `loaf conversation` | Manage logical conversations and machine-local provenance handles; handles never imply portable context | create, show, list, handle, observe | diff --git a/dist/opencode/skills/loaf-reference/references/command-routing.md b/dist/opencode/skills/loaf-reference/references/command-routing.md index e6856fd65..5f9d30bbb 100644 --- a/dist/opencode/skills/loaf-reference/references/command-routing.md +++ b/dist/opencode/skills/loaf-reference/references/command-routing.md @@ -6,15 +6,15 @@ Which command a task needs. For exact flags, run `loaf <command> --help`. | Intent | Route | |--------|-------| -| Shape new bounded work | `loaf change init <slug>`, then `loaf change check` | -| Start implementing new bounded work | the implement workflow after shaping and validating its Change | -| Continue an existing task or spec record | `loaf task` and `loaf spec` remain supported for existing records | +| Shape new bounded work | `loaf issue new <title>`, then `loaf issue dod add` and `loaf issue check <ref>` | +| Start implementing new bounded work | the implement workflow: pick from `loaf issue frontier`, then `loaf issue start <ref>` | +| Continue an existing task or spec record | `loaf task` and `loaf spec` remain readable for legacy records; new work is issues | | Continue after a restart | `loaf journal context` | | Skills or content changed | `loaf build && loaf install --to <target>` | -| See what is in progress | `loaf task list --active` | -| Archive completed work | `loaf task archive` | +| See what is in progress | `loaf issue list --status active` and `loaf issue list --started` | +| Remove finished-with work | `loaf issue status <ref> cancelled` or `duplicate --duplicate-of <ref>` (archives; record survives) | | Check knowledge freshness | `loaf kb check` | -| Validate a Change is structurally executable, not implementation-complete | `loaf change check --require-executable` | +| Validate an issue is shaped, covered, and contained | `loaf issue check <ref>` (non-zero exit names each failure) | | Import legacy `.agents` Markdown into SQLite | `loaf migrate markdown --dry-run` then `--apply` (see markdown-migration reference) | ## JSON diagnosis surfaces @@ -24,10 +24,10 @@ scraping human-readable text: - `loaf config check --json` — config file and installed hook config validity - `loaf state doctor --json` / `loaf state status --json` — SQLite health and readiness -- `loaf change check --json` — Change violations and derived executability +- `loaf issue check <ref> --json` — derived readiness, coverage, and containment - `loaf check --hook <id> --json` — one enforcement hook's result - `loaf kb check --json` — knowledge staleness against git history -- `loaf task list --json` / `loaf journal recent --json` — current work and timeline +- `loaf issue list --json` / `loaf journal recent --json` — current work and timeline - `loaf migrate markdown --dry-run --json` — `mode` (`simulation`/`inventory`) plus `import_report` when simulated Choosing between the `doctor` commands and `LOAF_DB` isolation are covered in diff --git a/dist/opencode/skills/orchestration/SKILL.md b/dist/opencode/skills/orchestration/SKILL.md index 2013e84f9..37fd15126 100644 --- a/dist/opencode/skills/orchestration/SKILL.md +++ b/dist/opencode/skills/orchestration/SKILL.md @@ -42,9 +42,9 @@ Comprehensive patterns for orchestration: coordinating multi-agent work, keeping - Outcome-focused, self-contained, no local file references - Magic words in commit body, not subject -**If `integrations.linear.enabled` is `true` in `.agents/loaf.json`:** use Linear MCP workflows and [references/linear.md](references/linear.md) for issue updates and status. +**If `integrations.linear.enabled` is `true` in `.agents/loaf.json`:** Linear is an identity adapter — `loaf issue pull` / `push` / `reconcile`, not a second work unit. See [references/linear.md](references/linear.md). Linear MCP is an overlay; Loaf issues remain the work unit and Linear never drives Loaf status. -**Otherwise:** coordinate with the project journal and `loaf task` / file-based tracking only; do not assume Linear MCP tools are available. +**Otherwise:** coordinate with the project journal and `loaf issue` only; do not assume Linear MCP tools or identity delegation are available. ### Planning (Shape Up) - Complexity-based sizing (small / medium / large) @@ -71,15 +71,15 @@ Comprehensive patterns for orchestration: coordinating multi-agent work, keeping | Pre-compaction | On an exact target mode with supported compaction delivery, hooks may nudge a journal flush and emit the digest afterward; otherwise flush manually and run `loaf journal context` after compaction | | Durable artifact handling | Delegate `.agents/`-scoped report/spec/handoff/knowledge tending to `librarian` | | Low-priority work | Spawn background-runner (see Background Agents) | -| New feature workflow | Research -> Architecture -> Shape -> Breakdown -> Implement | +| New feature workflow | Pitch -> Shape -> Implement -> Ship -> Release | ## Topics | Topic | Reference | Use When | |-------|-----------|----------| -| Shaping Specs | [../shape/SKILL.md](../shape/SKILL.md) | Creating specs, shaping work, defining test conditions | -| Breaking Work Into Tasks | [../breakdown/SKILL.md](../breakdown/SKILL.md) | Turning shaped specs into implementation tasks | -| Local Tasks | [references/local-tasks.md](references/local-tasks.md) | Managing tasks locally or with Linear backend | +| Shaping Issues | [../shape/SKILL.md](../shape/SKILL.md) | Preparing issues: body, definition of done, out of scope | +| Decomposition | [../shape/SKILL.md](../shape/SKILL.md) | Promoting a criterion that earns its own DoD (`loaf issue promote`) | +| Working Issues | [references/local-tasks.md](references/local-tasks.md) | Frontier, started worktrees, status, definition of done | | Agent Delegation | [references/delegation.md](references/delegation.md) | Choosing agents, spawning subagents, decision trees | | Parallel Agents | [references/parallel-agents.md](references/parallel-agents.md) | Dispatching independent work concurrently | | Subagent Development | [references/subagent-development.md](references/subagent-development.md) | Delegating to specialized agents | @@ -96,7 +96,7 @@ Comprehensive patterns for orchestration: coordinating multi-agent work, keeping The orchestrator: 1. Creates issues and logs the orchestration intent for tracking -2. Breaks down work into delegable tasks +2. Picks from `loaf issue frontier` and starts one worktree per issue 3. Spawns specialized agents for implementation 4. Coordinates outcomes and updates external systems 5. Never implements code, tests, or documentation directly @@ -126,16 +126,16 @@ This skill uses paths from `.agents/loaf.json`: | Councils | `.agents/councils/` | `.agents/councils/archive/` | `YYYYMMDD-HHMMSS-topic.md` | | Handoffs | `.agents/handoffs/` | delete after deprecated | Created by handoff | | Reports | `.agents/reports/` | N/A | `YYYYMMDD-HHMMSS-subject.md` | -| Tasks | SQLite (`loaf task show/list`) | N/A | Per task manager conventions | +| Issues | SQLite (`loaf issue show/list`) | `cancelled` / `duplicate` via `loaf issue status` | Alias or opaque id | **Rule:** Agents write artifacts to disk, orchestrator reasons over artifacts, users retrieve from disk. ## Workflow by Lifecycle ### BEFORE (Planning) -- Create/check external issue (Linear, GitHub) +- Shape prepares issues; implement works the frontier. Decomposition is `loaf issue promote` inside shape. - Log the orchestration intent with `loaf journal log` -- Break down into tasks, identify agents, get user approval +- `loaf issue check <ref>` must report shaped (delivery) or ready (decision); identify agents; get user approval ### DURING (Execution) - Spawn specialized agents (never implement directly) @@ -144,6 +144,6 @@ This skill uses paths from `.agents/loaf.json`: ### AFTER (Completion) - Code review + QA testing -- Update external issue to Done +- Land via ship: `loaf issue status <ref> done`, then `loaf issue stop <ref>` - Ensure knowledge captured in permanent locations - Write an optional `wrap` journal entry if the conversation holds synthesis worth saving diff --git a/dist/opencode/skills/orchestration/references/background-agents.md b/dist/opencode/skills/orchestration/references/background-agents.md index 6aa2b3c61..95b4b3b54 100644 --- a/dist/opencode/skills/orchestration/references/background-agents.md +++ b/dist/opencode/skills/orchestration/references/background-agents.md @@ -43,7 +43,7 @@ Task( - src/services/ Write report to: .agents/reports/YYYYMMDD-HHMMSS-security-audit.md - Reference: TASK-123, SPEC-045 if relevant + Reference: LOAF-123 if relevant """, run_in_background=True ) @@ -51,12 +51,12 @@ Task( ### Cursor -Background agents are configured via the `is_background: true` YAML property. When spawning, specify the report destination and any task/spec IDs: +Background agents are configured via the `is_background: true` YAML property. When spawning, specify the report destination and any issue refs: ``` @background-runner Run security audit on backend codebase. Write report to .agents/reports/. -Reference TASK-123 if relevant. +Reference LOAF-123 if relevant. ``` The background agent's journal entries are tagged with its own harness id automatically — there is no session alias to pass. @@ -72,7 +72,7 @@ Track background work with durable references: 1. Log the spawn with `loaf journal log "todo(background): started <id> for <task>"`. 2. Ask the background agent to write a report under `.agents/reports/`. 3. When complete, log `discover(background): <id> wrote <report>`. -4. Process findings into tasks, specs, ADRs, or report verdicts as appropriate. +4. Process findings into issues, ADRs, or report verdicts as appropriate. Use a stable ID such as `bg-YYYYMMDD-HHMMSS-description` in the prompt and journal entries. @@ -85,7 +85,7 @@ Background agents write results to `.agents/reports/` with enough metadata to id 1. Orchestrator identifies non-blocking security audit work. 2. Orchestrator logs the background spawn to the journal. 3. Background agent writes `.agents/reports/YYYYMMDD-HHMMSS-auth-security.md`. -4. Orchestrator reviews the report, creates follow-up tasks, and logs the outcome. +4. Orchestrator reviews the report, creates follow-up issues, and logs the outcome. 5. Report state is finalized or archived through the report lifecycle. ## Anti-Patterns @@ -94,7 +94,7 @@ Background agents write results to `.agents/reports/` with enough metadata to id |-------|------------| | Use for blocking work | Keep blocking work in foreground | | Spawn without tracking | Log the spawn and require a report path | -| Ignore completed results | Process reports into tasks, findings, or decisions | +| Ignore completed results | Process reports into issues, findings, or decisions | | Use for interactive tasks | Reserve for autonomous work | | Spawn many concurrent background agents | Limit concurrency to avoid resource contention | | Skip result location in prompt | Always specify where output belongs | diff --git a/dist/opencode/skills/orchestration/references/context-management.md b/dist/opencode/skills/orchestration/references/context-management.md index 11ce34ac7..49151a167 100644 --- a/dist/opencode/skills/orchestration/references/context-management.md +++ b/dist/opencode/skills/orchestration/references/context-management.md @@ -19,28 +19,28 @@ Patterns for keeping long work resumable while using the project journal as exte Compaction is normal in long workflows. Design work that spans many exchanges so important state is already outside chat context. 1. **The journal is external memory.** Record decisions, discoveries, blockers, and next actions with `loaf journal log`. -2. **Artifacts carry detail.** Changes, task-board records, reports, ADRs, and commits hold rich detail; journal entries point to them. +2. **Artifacts carry detail.** Issues, reports, ADRs, and commits hold rich detail; journal entries point to them. 3. **Delegated work absorbs exploration.** Use delegated agents for broad investigation and return concise findings to the main context. -4. **`wrap` captures synthesis.** When meaningful work holds intentions or abandoned paths worth saving, write an optional `wrap` journal entry. +4. **`wrap` captures synthesis.** When meaningful work holds abandoned paths worth saving, write an optional `wrap` journal entry. ## Continuity Digest (contract v2) -`loaf journal context` is the contract-v2 active-truth digest and supersedes the retired three-part summary. Read its named layers and diagnostics; an absent item and an unavailable source are different states. +`loaf journal context` is the contract-v2 active-truth digest. Read its named layers and diagnostics; an absent item and an unavailable source are different states. Layer *names* below are the live CLI identifiers. | Layer | Truth and precedence | |-------|----------------------| | `project-synthesis` | The latest `wrap(project)` synthesis. This is the only wrap that represents project-wide synthesis. | | `scoped-checkpoint` | The latest non-project wrap, only when `project-synthesis` has no item. It is labeled as a fallback, not promoted to project synthesis. | -| `active-lineage` | Journal evidence associated with the active Change lineage. | +| `active-lineage` | Journal evidence associated with the active work lineage. | | `unresolved-blockers` | Blocks without a later exact-scope unblock. | -| `deferred-intent` | Open deferred-intent decision and spark pairs. | -| `active-changes` | Git-derived active Change evidence and worktree state. | +| `deferred-intent` | CLI layer for open deferred decision and spark pairs. | +| `active-changes` | Git-derived active worktree evidence. | | `branch-recency` | Recent branch entries after entries already surfaced as active truth are removed. | -| `transitional-tasks` | Open task-board records retained for compatibility. | +| `transitional-tasks` | Leftover board records retained for compatibility — not the work unit. Prefer `loaf issue frontier` / `loaf issue list --started`. | -Each returned layer includes `source_available`, `available_count`, `shown_count`, `truncated`, and `expand_command`; paginated layers also include a cursor. Treat `source_available: false` as an explicit unavailable source, never as “nothing is active.” If Change discovery is unavailable, `active-changes` and `active-lineage` are unavailable and the digest carries a diagnostic. +Each returned layer includes `source_available`, `available_count`, `shown_count`, `truncated`, and `expand_command`; paginated layers also include a cursor. Treat `source_available: false` as an explicit unavailable source, never as “nothing is active.” If git-derived discovery is unavailable, `active-changes` and `active-lineage` are unavailable and the digest carries a diagnostic. -Use `--branch` to select `branch-recency` scope and bind state cursors. It does not override active Change provenance or reasons, which always use the actual Git branch. Use `loaf journal context --layer <name>` to inspect one layer. `--limit` accepts 1 through 100 and requires `--layer`; `--cursor` also requires `--layer` and cannot be used with the intrinsic one-item `project-synthesis` or `scoped-checkpoint` layers. Follow the returned `expand_command` exactly: a cursor is bound to its layer, project, branch, snapshot, and limit. Use `--json` for automation; the human view preserves availability, counts, truncation, diagnostics, and expansion commands. +Use `--branch` to select `branch-recency` scope and bind state cursors. It does not override git provenance or reasons, which always use the actual Git branch. Use `loaf journal context --layer <name>` to inspect one layer. `--limit` accepts 1 through 100 and requires `--layer`; `--cursor` also requires `--layer` and cannot be used with the intrinsic one-item `project-synthesis` or `scoped-checkpoint` layers. Follow the returned `expand_command` exactly: a cursor is bound to its layer, project, branch, snapshot, and limit. Use `--json` for automation; the human view preserves availability, counts, truncation, diagnostics, and expansion commands. ## Context Commands @@ -53,21 +53,21 @@ Use `--branch` to select `branch-recency` scope and bind state cursors. It does ## When to Clear Context -Clear the conversation when starting a completely new task, after the previous task is complete, when debugging noise crowds out the current objective, or when switching between unrelated codebases. Avoid clearing mid-task until enough state is logged for recovery. +Clear the conversation when starting a completely new issue, after the previous issue is complete, when debugging noise crowds out the current objective, or when switching between unrelated codebases. Avoid clearing mid-issue until enough state is logged for recovery. ## Compaction Lifecycle PreCompact: 1. Flush unrecorded decisions, discoveries, blockers, and next actions with `loaf journal log`. -2. Reference Changes, task-board records, reports, commits, and files by stable ID or path. +2. Reference issues, reports, commits, and files by stable ID or path. 3. On an exact target mode with supported PreCompact delivery, let the hook nudge the flush; otherwise flush manually before compacting. PostCompact: 1. On an exact target mode with supported resumption delivery, read the continuity digest emitted by the hook; otherwise run `loaf journal context` explicitly. 2. Expand the named layer that needs more detail, or use `loaf journal recent` and `loaf journal search` for a different query. -3. Continue from the journal and linked artifacts. +3. Continue from the journal and linked artifacts (`loaf issue show <ref>`). This makes compaction survivable without relying on hand-maintained Markdown state. State not logged or captured in a durable artifact can be lost. @@ -79,10 +79,10 @@ Use delegated agents to investigate without filling the main context. |-----------|----------| | Quick file lookup | Direct read or search tool | | Multi-file exploration | Explorer or research agent | -| Implementation work | Implementer or task-focused agent | +| Implementation work | Implementer in the issue's started worktree | | Long audit | Background agent with report output | -Pass stable references to delegated agents: Change IDs, task IDs, branch names, and report paths. The harness ID is attached to journal entries automatically; there is no session alias to pass. +Pass stable references to delegated agents: issue IDs, branch names, worktree paths, and report paths. The harness ID is attached to journal entries automatically; there is no session alias to pass. Never send two agents to the same started worktree. ## Context Budget Guidelines @@ -109,12 +109,12 @@ No special management is usually needed. | Repeating same mistakes | Context pollution | Log current facts, then clear or compact | | Forgetting recent decisions | Overcrowded context | Read `loaf journal context` and expand the relevant layer | | Slow responses | Large context | Delegate exploration | -| Confusion about task | Too many pivots | Re-anchor on Change or task IDs | +| Confusion about the work | Too many pivots | Re-anchor on issue IDs (`loaf issue show`) | ## Best Practices 1. Log durable facts early with `loaf journal log`. 2. Use delegated agents for exploration-heavy work. -3. Clear between unrelated tasks. -4. Compact mid-task when the journal and artifacts are current. +3. Clear between unrelated issues. +4. Compact mid-issue when the journal and artifacts are current. 5. Scope tool calls so context stays focused. diff --git a/dist/opencode/skills/orchestration/references/delegation.md b/dist/opencode/skills/orchestration/references/delegation.md index b710244b6..a620c0ba7 100644 --- a/dist/opencode/skills/orchestration/references/delegation.md +++ b/dist/opencode/skills/orchestration/references/delegation.md @@ -131,8 +131,8 @@ Use when work is truly independent. Spawn multiple agents in the same turn when 1. **Be specific in prompts** - Include file paths, requirements, constraints 2. **One concern per agent** - Don't ask a backend implementer to also write tests -3. **Include context** - Task/spec IDs, issue ID, previous outcomes -4. **Reference durable artifacts** - Task, spec, and report IDs; the subagent's journal entries are harness-id tagged automatically +3. **Include context** - Issue refs (`LOAF-42` or opaque id), previous outcomes +4. **Reference durable artifacts** - Issue aliases and report IDs; the subagent's journal entries are harness-id tagged automatically 5. **Include skill hints** - Name the skills that should guide the agent's work ### Skill Hints @@ -171,8 +171,7 @@ Files: - src/api/users.py - src/models/user.py -Task: TASK-042 -Linear: BACK-123 +Issue: LOAF-42 ``` ## Anti-Patterns @@ -183,7 +182,7 @@ Linear: BACK-123 | Asking backend implementer for React | Spawn implementer with frontend skills | | Single agent for database + backend + tests | Sequential: implementer (database-design), implementer (language skill), implementer (foundations) | | Parallel spawns with hidden dependencies | Make dependencies explicit, spawn sequentially | -| Spawning without context | Reference task/spec/report IDs in prompts | +| Spawning without context | Reference issue aliases and report IDs in prompts | | Council for simple decisions | Single agent or orchestrator judgment | ## Agent Access Hierarchy diff --git a/dist/opencode/skills/orchestration/references/journal.md b/dist/opencode/skills/orchestration/references/journal.md index de2a9de3a..442f2b216 100644 --- a/dist/opencode/skills/orchestration/references/journal.md +++ b/dist/opencode/skills/orchestration/references/journal.md @@ -42,20 +42,20 @@ loaf journal log "spark(scope): possible follow-up idea" loaf journal log "todo(scope): concrete follow-up action" ``` -Log durable facts, not thoughts. Reference task IDs, spec IDs, report IDs, and +Log durable facts, not thoughts. Reference issue IDs, report IDs, and commit refs rather than pasting long prose. The journal should let another agent resume without reading the whole conversation. ## Codex Auto Mode -When the user has explicitly enabled Loaf's managed Codex Auto-journal capability, use the exact path-pinned command shown in the Loaf-managed block of `CODEX_HOME/AGENTS.md`; its form is `'<canonical-loaf-path>' journal log --execpolicy-safe "decision(scope): chose X because Y"`. Do not substitute a bare `loaf`, alternate executable, or shell/environment wrapper. The installed Codex rule authorizes only explicitly classified basic Loaf command leaves outside the workspace sandbox, including this hardened journal writer and approved readers; ordinary `journal log`, body/file-consuming leaves, and path-taking `change check` remain operator-gated, and the policy does not authorize a general Loaf data-directory writable root. Other harness adapters are not implied and continue to use their own ordinary surfaces. +When the user has explicitly enabled Loaf's managed Codex Auto-journal capability, use the exact path-pinned command shown in the Loaf-managed block of `CODEX_HOME/AGENTS.md`; its form is `'<canonical-loaf-path>' journal log --execpolicy-safe "decision(scope): chose X because Y"`. Do not substitute a bare `loaf`, alternate executable, or shell/environment wrapper. The installed Codex rule authorizes only explicitly classified basic Loaf command leaves outside the workspace sandbox, including this hardened journal writer and approved readers; ordinary `journal log`, body/file-consuming leaves, and other path-taking operator-gated leaves remain operator-gated, and the policy does not authorize a general Loaf data-directory writable root. Other harness adapters are not implied and continue to use their own ordinary surfaces. Enable the capability once with `loaf install --to codex --codex-basic-commands`. Installation is an explicit trust decision. If the rules are absent, stale, locally modified, or conflict with user-owned `loaf.rules`, Loaf reports the condition instead of overwriting it or asking for full system access. ## Wrap: Optional Checkpoint A `wrap` entry is a voluntary checkpoint, not a lifecycle transition. Write one -only when the conversation holds synthesis worth saving — intentions, abandoned +only when the conversation holds synthesis worth saving — abandoned paths, next steps — the connective narrative that evaporates with the context window. Almost everything else is derivable from raw entries. @@ -70,11 +70,12 @@ perfectly valid journal. A wrap reviews its own conversation's entries first: loaf journal recent --since-last-wrap ``` -See the `wrap` skill for the full checkpoint flow. +See the `wrap` skill for the full checkpoint flow. Loose ends name issues +(`loaf issue frontier`, `loaf issue list --started`), not board leftovers. ## Derived Continuity -Continuity is computed at read time and never persisted. On an exact target mode whose startup delivery capability is supported, the startup adapter may emit a layered digest with the latest project-level wrap, recent entries scoped to the current branch/worktree, and open (`in_progress`/`pending`) tasks. When startup delivery is candidate or unsupported for the current mode, explicitly run `loaf journal context` at conversation start. On demand, reproduce or extend the digest: +Continuity is computed at read time and never persisted. On an exact target mode whose startup delivery capability is supported, the startup adapter may emit a layered digest with the latest project-level wrap, recent entries scoped to the current branch/worktree, and open work. Bind that open work to `loaf issue frontier` and `loaf issue list --started`. When startup delivery is candidate or unsupported for the current mode, explicitly run `loaf journal context` at conversation start. On demand, reproduce or extend the digest: ```bash loaf journal context # the layered continuity digest @@ -82,7 +83,7 @@ loaf journal recent --branch <b> # recent entries for one branch loaf journal search <query> # find prior decisions by topic ``` -Pass task/spec/report references to background and delegated agents. The harness +Pass issue IDs and report references to background and delegated agents. The harness id is attached automatically — there is no session alias to pass along. ## Recovery @@ -91,7 +92,7 @@ After compaction, a branch switch, or a long gap: 1. Read `loaf journal context`; on an exact target mode with supported resumption delivery, the digest emitted by the adapter is equivalent continuity context. 2. Widen with `loaf journal recent` / `loaf journal search` when more is needed. -3. Compare against `git status`, `git log`, and the relevant specs/tasks. +3. Compare against `git status`, `git log`, `loaf issue frontier`, and `loaf issue list --started`. 4. If code and journal have drifted, log the reconciliation: `loaf journal log "decision(recovery): rewound to <commit>; replaying tests"`. @@ -108,7 +109,7 @@ After compaction, a branch switch, or a long gap: | Don't | Do Instead | |-------|------------| | Wait to log everything at the end | Log significant facts as they happen | -| Store decisions only in chat context | Log them and promote durable ones to ADR/spec/report/docs | +| Store decisions only in chat context | Log them and promote durable ones to ADR/issue body/report/docs | | Write a placeholder wrap out of ceremony | Wrap only when there's synthesis worth saving | | Treat a missing wrap as an open loop | A conversation without a wrap is complete and valid | | Pass a session alias to delegated agents | Nothing to pass — the harness id is automatic | diff --git a/dist/opencode/skills/orchestration/references/linear.md b/dist/opencode/skills/orchestration/references/linear.md index f488726cf..f00960ebc 100644 --- a/dist/opencode/skills/orchestration/references/linear.md +++ b/dist/opencode/skills/orchestration/references/linear.md @@ -7,8 +7,7 @@ Guidelines for writing Linear issue updates, comments, and commit messages with - Configuration - MCP Server Naming - Multi-Workspace Guidance -- Linear-Native Mode (Parent + Sub-Issues) -- The `spec` Label Convention +- Identity Adapter - Progress Update Format - Issue Description Format - Status Conventions @@ -97,96 +96,44 @@ Match the `linear.mcp_server_name` in each project's `.agents/loaf.json` to the name used in that project's `.mcp.json`. That way the Loaf skills invoke the right workspace automatically. -## Linear-Native Mode (Parent + Sub-Issues) +## Identity Adapter -In Linear-native mode (`integrations.linear.enabled: true`), each spec -produces one parent **rollup issue** and N sub-issues under it. +When `issue_identity.authority = linear`, Linear owns identity, title, status, +and assignment. Loaf owns shaping state: body, definition-of-done criteria, +claims, and the started worktree. The Loaf issue is the work unit. Linear MCP +is an overlay — never drive Loaf status from MCP tools. -``` -Agent framework alignment ← parent, label: `change` -├── Split reviewer profile into reviewer/auditor ← sub-issue, label: type/refactor -├── Harden MCP fallback path ← sub-issue, label: type/feature -└── Migrate legacy task references ← sub-issue, label: type/refactor -``` - -### Parent issue — what it is and isn't - -The parent issue is a **dashboard anchor**, not a re-hosting of the spec. - -- **Is:** a short summary (1–3 paragraphs) of the problem and solution - direction + a link to the canonical spec file in the repo. -- **Is not:** a copy of the spec's Scope / Rabbit Holes / Open Questions / - Risks sections. Those live in the local spec file and evolve there. - -### Sample parent description +`loaf issue new` delegates identity: Linear mints the identifier, and that +key becomes the local alias. The local counter is not advanced. If Linear is +offline, refuse — capture via `loaf spark` or `loaf idea`. Do not mint a +local alias as a fallback. -```markdown -## Summary -Align Loaf's agent profiles with the three-role model (implementer, reviewer, -researcher). Consolidate historical profile variants and add tool-boundary -tests so profiles can't drift without a test failing. - -## Context -See the canonical change file in the repository for full text, council -references, rabbit holes, and strategic tensions. +If Linear created an issue but the local bind failed, adopt it: -## Progress -Sub-issues track execution. +```text +loaf issue pull <linear-key> +loaf issue pull <linear-key> --tree ``` -### Sub-issues - -- Each sub-issue has `parentId` set to the parent issue ID. -- Cross-task dependencies use Linear's `blockedBy` field referencing sibling - sub-issue IDs. -- Sub-issue labels describe the task itself (type, team, area), not the - parent — don't label sub-issues with `spec`. -- Starting a sub-issue promotes the parent rollup from `backlog`/`unstarted` - to the team's `started`/In Progress state. Parent promotion is a state - invariant of the start operation, not a separate manual reminder. -- Do not silently reopen protected parents. If the parent is `completed`, - `canceled`, or archived, stop and ask for an explicit override before - starting the child. - -### Spec file remains canonical - -Even with the parent in Linear, the local spec file is the source of truth -for: +`--tree` also adopts the sub-issue tree with parent edges intact. -- Problem statement and solution direction -- Scope / in-scope / out-of-scope / rabbit holes / no-gos -- Risks and open questions -- Council references and strategic tensions +### Commands -When the spec evolves, edit the file and let git track it. The parent -issue's summary is a frozen entry point; only refresh it if the summary -itself (not the rabbit holes or risks) changes meaningfully. - -## The `spec` Label Convention - -Every spec-parent rollup issue carries a Linear label named `spec`. This lets -anyone in Linear filter for "all spec roots" across projects without having to -know which issues happen to be parents. - -| Field | Value | -|-------|-------| -| Name | `spec` | -| Color | `#5e6ad2` (suggested; implementer may adjust) | -| Description | `Parent rollup issue representing a design spec tracked in the repo at .agents/specs/` | -| Scope | Workspace-scoped preferred; fall back to team-scoped if the MCP requires it | - -### Who creates it - -breakdown creates the `spec` label on first Linear-native breakdown in a -workspace that doesn't already have it. Subsequent breakdowns reuse the -existing label. Log whether the label was created this run or already -existed — this matters for first-time setup. +```text +loaf issue pull <linear-key> [--tree] [--json] +loaf issue push <ref> [--json] +loaf issue reconcile [<ref>] [--take-local|--take-tracker] [--json] +``` -### Sub-issues never carry `spec` +| Command | Writes? | What it does | +|---------|---------|----------------| +| `loaf issue pull` | Yes | Adopt an existing Linear issue as a local row. The Linear key becomes the alias | +| `loaf issue push` | Yes | Write `loaf issue render` as the Linear description. Status is written only when the local status event is newer than the tracker. Never renames the Linear issue | +| `loaf issue reconcile` | Yes with a take flag | Compare local and Linear. Title drift updates the local title (tracker wins). Status drift is reported; `--take-local` or `--take-tracker` resolves it. Description drift is reported only | -`spec` applies only to parents. A sub-issue describing a task uses its own -labels (type groups like `feature`/`bug`/`refactor`, team labels, area -labels) — never `spec`. This keeps the "filter for spec roots" query clean. +Do not create records with `loaf task` or `loaf spec`. Parent/child structure +is `loaf issue promote` (or `loaf issue new --parent`), not a `spec`-labeled +Linear rollup. ## Progress Update Format @@ -222,18 +169,9 @@ None currently. ## Issue Description Format -```markdown -## Summary -Brief description of the work and its purpose. - -## Acceptance Criteria -- [ ] Criterion 1 -- [ ] Criterion 2 -- [ ] Criterion 3 +The Linear description is `loaf issue push` output — `loaf issue render`, not a hand-authored summary. Do not paste a competing description over the render. -## Notes -Any relevant context (keep brief). -``` +Comments (not the description) still follow the progress-update format above. **Rules:** - Concise and actionable @@ -243,6 +181,8 @@ Any relevant context (keep brief). ## Status Conventions +Loaf status is `loaf issue status`. Linear status is the tracker's. Resolve drift with `loaf issue reconcile` (`--take-local` or `--take-tracker`). Do not flip Loaf status from Linear MCP tools. + | State | When to Use | |-------|-------------| | **Backlog** | Issue created, not started | @@ -341,6 +281,8 @@ Use `scripts/suggest-team.py "task desc"` to get suggestions. ## When to Create Issues +Create through `loaf issue new` so identity can be delegated. Do not create in Linear MCP and then forget to `loaf issue pull`. + | Action | Create Issue? | |--------|---------------| | Features, bugs, refactoring | Yes | diff --git a/dist/opencode/skills/orchestration/references/local-tasks.md b/dist/opencode/skills/orchestration/references/local-tasks.md index 69c89df64..30d617f47 100644 --- a/dist/opencode/skills/orchestration/references/local-tasks.md +++ b/dist/opencode/skills/orchestration/references/local-tasks.md @@ -1,259 +1,173 @@ -# Local Task Management +# Working Issues Locally -Break specs into atomic tasks using SQLite-backed Loaf task records when Linear -isn't available. +Orchestration-facing reference for the Loaf issue CLI: pick-up-next, started +worktrees, status, definition of done, and advisory labels. Issue commands +require initialized SQLite state. ## Contents -- Task Abstraction Layer -- Local Task Records -- Task Lifecycle -- Creating Tasks from Specs -- Cutover Reconciliation -- Task ID Generation -- Archiving Tasks -- Journal Integration -- Task Sizing -- Priority Levels -- Listing Tasks -- Work Log Updates -- Verification -- Local vs Linear Comparison - -## Task Abstraction Layer - -Tasks work identically whether backed by Linear or local SQLite state. - -### Configuration - -```yaml -# .agents/loaf.yaml -task_management: - backend: linear # or "local" - - linear: - team: ProjectName - default_labels: [] - - local: - archive_completed: true -``` - -### Abstracted Operations - -| Operation | Linear | Local | -|-----------|--------|-------| -| Create task | Create issue | `loaf task create --spec SPEC-XXX --title "..." --priority P1` | -| Fetch task | Get issue | `loaf task show TASK-XXX` or `loaf task show TASK-XXX --json` | -| Update status | Update issue | `loaf task update TASK-XXX --status in_progress` | -| List tasks | List issues | `loaf task list` (or `loaf task list --json` for machine parsing) | -| Complete | Move to Done | `loaf task update TASK-XXX --status done` | - -## Local Task Records +- Frontier +- Started worktree +- Status vocabulary +- Relationships +- Definition of done +- Buckets +- Command cheat sheet +- LEGACY -SQLite is the operational source of truth for task metadata, status, priority, -dependencies, dates, and relationships. Use `loaf task` CLI commands for all -task mutations. `.agents/tasks/` and `.agents/TASKS.json` were removed by the -SPEC-045 cutover and are rollback material only. - -**Create with:** `loaf task create --title "..." --spec SPEC-XXX` - -## Task Lifecycle +## Frontier +```text +loaf issue frontier [--json] ``` -todo → in_progress → review → done - │ │ │ │ - └────────┴───────────┴────────┘ - can return to earlier states -``` - -| Status | Meaning | -|--------|---------| -| `todo` | Ready to work, not started | -| `in_progress` | Actively being worked | -| `review` | Implementation complete, needs verification | -| `done` | Verified complete, ready for archive | - -## Creating Tasks from Specs - -### Input -- Spec ID (e.g., `SPEC-001`) -- Optional: priority override +Pick-up-next. Derived at read time, never stored. Lists non-archived issues in +`triage`, `backlog`, or `todo` that are not blocked. -### Task Breakdown Rules +| Qualifier | Meaning | +|-----------|---------| +| Open | Status is `triage`, `backlog`, or `todo` — not `active`, `done`, `cancelled`, or `duplicate` | +| Unblocked | No open predecessor via `blocks` / `blocked_by`. A predecessor that is `done`, `cancelled`, or `duplicate` does not block | +| Unclaimed | Not `active` and no started worktree. `loaf issue start` is the claim | -1. **One concern per task** - Don't mix backend + tests + frontend -2. **Clear done condition** - Observable, verifiable outcome -3. **Verification command** - How to prove it works -4. **File hints** - Which files will likely be modified +Archived rows are excluded. Kind is not filtered: a `--kind decision` question +can appear; it is not delivery work. Buckets are not read. Prefer `--json` +when diagnosing rather than scraping the human-readable text. -### Example Breakdown +## Started worktree +```text +loaf issue start <ref> [--json] +loaf issue stop <ref> [--force] [--json] +loaf issue list --started [--json] ``` -SPEC-001: User Authentication with OAuth - ↓ -TASK-001: OAuth Provider Integration - - Google OAuth client setup - - GitHub OAuth client setup - - Token exchange logic - verify: pytest tests/auth/test_oauth.py - -TASK-002: Session Management - - Session cookie handling - - Session storage (Redis/DB) - - Session expiry logic - verify: pytest tests/auth/test_session.py - -TASK-003: Login UI Components - - Login page layout - - Provider buttons - - Error states - verify: npm run test:e2e -- auth -``` - -## Cutover Reconciliation - -If a stale branch reintroduces `.agents/tasks/`, `.agents/sessions/`, other -ephemeral roots, or `.agents/TASKS.json`, keep the deletion side from the -cutover branch and rerun `loaf check --hook ephemeral-provenance`. Use -`loaf state restore-ephemerals <backup-id>` only for an intentional rollback, -then re-import forward. - -## Task ID Generation -Format: `TASK-{number}-{slug}` +**Invariant:** one agent, one worktree. Check `loaf issue list --started` +before dispatch. Never send two agents into the same path. -Task IDs are auto-generated by `loaf task create`. In SQLite-backed projects, -the allocation is recorded in state. +`start` creates branch `issue/<alias-or-id>` in lowercase (`issue/loaf-42`, +disambiguated with an id suffix when that name is already claimed), adds a +sibling worktree, records `started_branch` / `started_worktree` on the row, and +moves status to `active` through the events path. Base is the nearest started +ancestor's branch, else the repository default branch. Start refuses an already +started row, an archived row, and terminal statuses (`done`, `cancelled`, +`duplicate`). Requires a git repository. -## Archiving Tasks +`list --started` prints alias, title, `started_branch`, `started_worktree`, and +`(missing)` when the recorded path is gone. -When a task is done: +`stop` removes the worktree and clears the started workspace on the row. It +keeps the branch and does not change status. `--force` removes a dirty +worktree. Do not run `stop` from inside the started worktree. -1. Mark complete via CLI: `loaf task update TASK-XXX --status done` -2. Archive: `loaf task archive TASK-XXX` +## Status vocabulary -## Journal Integration +Write statuses that update in place: `triage`, `backlog`, `todo`, `active`, +`done`. `cancelled` and `duplicate` archive through the remove path +(`loaf issue status <ref> duplicate --duplicate-of <surviving>`). -When the implement workflow starts on `TASK-001`: - -1. Load task metadata via `loaf task show TASK-001` for context -2. Read linked spec for full picture -3. Log the task coupling as the first action: +```text +loaf issue status <ref> <status> [--duplicate-of <ref>] [--json] +``` -```bash -loaf journal log "decision(implement): implementing TASK-001" +| Status | Meaning | +|--------|---------| +| `triage` | Default at create. Shaped is derived (`loaf issue check`), not a status | +| `backlog` | Filed, worth keeping | +| `todo` | Explicitly ready to work | +| `active` | Started. **Review is a display name for `active`** — there is no `review` write status | +| `done` | Work landed | +| `cancelled` | Archived; abandoned | +| `duplicate` | Archived; `--duplicate-of` required | + +There is **no `blocked` status**. Blocked is a relationship. Title and body stay +mutable at every status. + +```text +loaf issue list [--status <status>] [--kind delivery|decision] [--archived] [--started] [--json] ``` -There is no session to create or couple to. Task progress is read through -`loaf task show/list`; the surrounding decisions and blockers live in the -project journal (`loaf journal recent`, `loaf journal search`). +Archived rows are hidden unless `--archived`. `--status` accepts every value in +the table above. -## Task Sizing +## Relationships -### Separation of Concerns +```text +loaf issue link <from> blocks|relates-to <to> [--json] +loaf issue link <from> remove <type> <to> [--json] +``` -**The primary principle for task breakdown is separation of concerns.** +Stored types are `blocks` and `relates_to`. `loaf issue link A blocks B` means +A blocks B: B is absent from the frontier until A is `done`, `cancelled`, or +`duplicate`. `relates-to` is not a sequencing constraint. -| Rule | Guideline | -|------|-----------| -| **One agent type** | Task completable by ONE subagent (implementer, reviewer, researcher) | -| **One concern** | Task touches one layer, one service, or one component | -| **Context-appropriate** | Fits in model context with room for exploration | -| **Not over-fragmented** | Don't split what naturally belongs together | +Do not encode order in `loaf issue tree`. Parent/child is structure; `blocks` +is the dependency. `loaf issue export [--json]` dumps relationships (and +claims) when you need the graph. -### Right Size Test +## Definition of done -1. Can a single specialized agent complete this? → If no, split by agent type -2. Does it touch multiple unrelated concerns? → If yes, split by concern -3. Will the agent need too much context? → If yes, split into smaller coherent units -4. Am I splitting just to have more tasks? → If yes, merge back +Criteria live on the issue row. `loaf issue show <ref>` prints each as +`position. [V|H] text` with `command=` / `expect=` when present. -### Agent Scope +```text +loaf issue dod add <ref> <text> [--command <cmd>] [--expect <expect>] [--tier V|H] [--serves <parent-position>] [--json] +loaf issue dod list <ref> [--json] +loaf issue dod remove <ref> <position> [--json] +loaf issue dod claim <child> <child-position> <parent-position> [--json] +loaf issue dod unclaim <child> <child-position> <parent-position> [--json] +loaf issue promote <ref> <position> [--json] +loaf issue check <ref> [--json] [--human <reason>] +loaf issue verify <ref> [--json] +``` -| Agent | Typical Task Scope | -|-------|-------------------| -| implementer (backend) | One service/module, its tests, its docs | -| implementer (frontend) | One component/page, its tests, its styles | -| implementer (database) | One migration, related schema changes | -| implementer (testing) | Test suite for one feature/area | -| implementer (infra) | One infrastructure concern (CI, deploy, config) | +| Tier | When | Who checks | +|------|------|------------| +| V | `--command` present, unless `--tier` overrides | `loaf issue verify <ref>` from the repository root. Honors `exit <N>` and `` contains `text` ``. Writes nothing. Non-zero on failure | +| H | No `--command`, unless `--tier` overrides | Human or orchestrator. Verify skips H-tier; that skip is not a pass | -## Priority Levels +Claims: a child criterion serves a parent criterion. `promote` copies the +parent criterion onto a new delivery child and records the claim. +`--serves` claims a newly added child criterion. `claim` / `unclaim` retarget +an existing pair. Positions are 1-based. -| Priority | Meaning | Response | -|----------|---------|----------| -| P0 | Urgent/blocking | Drop everything | -| P1 | High | Work next | -| P2 | Normal | Scheduled work | -| P3 | Low | When time permits | +`check` is readiness (shape's gate): delivery is shaped with a nonempty body, +at least one criterion, and an out-of-scope statement; decision is ready on a +sharp `?`. Children add coverage (every parent criterion claimed — failure) +and containment (every child criterion claims a parent — report). `verify` is +implement's preflight and writes nothing — it does not set status and does not +tick boxes. -## Listing Tasks +`loaf issue render <ref>` emits the paste-ready PR body: title, body, +definition-of-done checkboxes (checked only when status is already `done`), +and children. No manual editing. -### All Active Tasks +## Buckets -```bash -loaf task list +```text +loaf issue bucket <ref> now|next|later|none [--json] ``` -### Tasks for a Spec - -```bash -loaf spec list # Show specs with task counts -loaf task list --json # Machine-parseable output, filter by spec -``` +Advisory Now/Next/Later labels. Never read as a constraint. Frontier, start, +and verify ignore them. `none` clears the label. -## Work Log Updates +## Command cheat sheet -As work progresses, append to the Work Log section: - -```markdown -## Work Log - -### 2026-01-23 14:30 UTC -Started OAuth integration. Set up Google OAuth client credentials. - -### 2026-01-23 15:45 UTC -Google OAuth working. Moving to GitHub integration. - -### 2026-01-23 17:00 UTC -Both providers working. Tests pass. Moving to review. +```text +loaf issue new <title> [--body <text>|--body -|--body-file <path>|--message <text>] + [--kind delivery|decision] [--parent <ref>] [--fog <text>] [--status <status>] [--json] +loaf issue show <ref> [--json] +loaf issue tree [<ref>] [--archived] [--json] +loaf issue edit <ref> [--body-file <path>|--body -|--message <text>] [--json] +loaf issue export [--json] ``` -## Verification +`new` default kind is `delivery`; default status is `triage`. `--status` on +create still records the initial triage event, then writes the requested +write-status. `--fog` exists only on create. `edit` replaces the body; there +is no patch form. -Before marking `done`: - -1. Run the `verify` command from frontmatter -2. Check all acceptance criteria are checked -3. Ensure no regressions in related tests - -```bash -# Run task verification -verify_cmd=$(grep '^verify:' TASK-001-*.md | cut -d: -f2-) -eval "$verify_cmd" -``` +## LEGACY -## Local vs Linear Comparison - -| Feature | Local | Linear | -|---------|-------|--------| -| No external dependency | yes | no | -| Rich UI | no | yes | -| Team collaboration | git-based | native | -| Notifications | none | email/slack | -| Reporting | manual | built-in | -| Offline work | yes | limited | - -**Use local when:** -- Solo project -- No Linear access -- Offline development -- Simple task tracking - -**Use Linear when:** -- Team collaboration needed -- Rich workflow automation -- Integration with other tools -- Reporting requirements +`loaf task` and `loaf spec` remain readable against leftover SQLite rows. They +mint nothing new. Do not create records there. Issues are the work unit. diff --git a/dist/opencode/skills/orchestration/references/parallel-agents.md b/dist/opencode/skills/orchestration/references/parallel-agents.md index f4ef48ab2..35a07a7d6 100644 --- a/dist/opencode/skills/orchestration/references/parallel-agents.md +++ b/dist/opencode/skills/orchestration/references/parallel-agents.md @@ -117,7 +117,7 @@ When streams complete: | Command | Parallel Opportunity | |---------|---------------------| -| breakdown | Identify parallelizable tasks during decomposition | +| shape | Identify parallelizable child issues during decomposition | | implement | Single task, usually sequential | | implement | Runs dependency-aware orchestration, including parallel-safe tasks | diff --git a/dist/opencode/skills/orchestration/references/script-surface.md b/dist/opencode/skills/orchestration/references/script-surface.md index 70206ab63..474b76354 100644 --- a/dist/opencode/skills/orchestration/references/script-surface.md +++ b/dist/opencode/skills/orchestration/references/script-surface.md @@ -20,7 +20,7 @@ script surface: - The source currently has 10 orchestration scripts out of 22 skill-local scripts overall. -- Several scripts overlap existing `loaf journal`, `loaf task`, `loaf check`, +- Several scripts overlap existing `loaf journal`, `loaf issue`, `loaf check`, and Linear-aware behavior. - Shell/Python helpers are harder to discover than `loaf <noun> <verb>` and are not consistently covered by CLI tests. diff --git a/dist/opencode/skills/orchestration/references/subagent-development.md b/dist/opencode/skills/orchestration/references/subagent-development.md index 2d36b6175..d2c46e2a1 100644 --- a/dist/opencode/skills/orchestration/references/subagent-development.md +++ b/dist/opencode/skills/orchestration/references/subagent-development.md @@ -211,7 +211,7 @@ After subagent completes: | Command | Subagent Role | |---------|---------------| -| breakdown | Tasks become subagent assignments | +| shape | Promoted child issues become subagent assignments | | implement | May dispatch subagents for specialized work | | implement | Automatically coordinates single-task and multi-task subagent work | diff --git a/dist/opencode/skills/orchestration/templates/journal.md b/dist/opencode/skills/orchestration/templates/journal.md index caf7409fd..9a0a61f8f 100644 --- a/dist/opencode/skills/orchestration/templates/journal.md +++ b/dist/opencode/skills/orchestration/templates/journal.md @@ -13,7 +13,7 @@ not create or edit journal markdown as the source of truth — use ## Entries -[YYYY-MM-DD HH:MM] skill(implement): implementing TASK-042 +[YYYY-MM-DD HH:MM] skill(implement): implementing LOAF-42 [YYYY-MM-DD HH:MM] decision(scope): description of decision [YYYY-MM-DD HH:MM] discover(scope): something learned [YYYY-MM-DD HH:MM] block(scope): what is blocked diff --git a/dist/opencode/skills/pitch/SKILL.md b/dist/opencode/skills/pitch/SKILL.md index f34f451ad..635c0c2cd 100644 --- a/dist/opencode/skills/pitch/SKILL.md +++ b/dist/opencode/skills/pitch/SKILL.md @@ -1,21 +1,21 @@ --- name: pitch description: >- - Runs the human problem-discovery ceremony at change or project scale: grills - problem, who has it, current alternatives, value proposition, and constraints, - then authors a brief (change brief.md via loaf change init --brief, or project - docs/BRIEF.md with source: pitch). Use when the user invokes pitch, starts - work on a raw concept, or triage dispositions an item as pitch. Produces an - authored problem-space brief and a shape-now or park offer — never shape.md, - tasks, or PRs. Not for solution shaping (use shape), queue processing (use - triage), quick capture (use idea), or open-ended divergent inquiry (use - explore as an agent technique when pitch reveals the direction is undecided). + Runs the human problem-discovery ceremony: grills problem, who has it, current + alternatives, value proposition, and constraints, then hands a sharpened + problem narrative to shape or authors project docs/BRIEF.md. Use when the user + invokes pitch, starts work on a raw concept, or triage dispositions a spark or + idea as pitch. Produces a problem-space narrative and a shape-now or park + offer — never a bounded issue, criteria, or PRs. Not for quick capture (use + idea), solution bounding (use shape), queue processing (use triage), or + open-ended divergent inquiry (use explore as an agent technique when pitch + reveals the direction is undecided). version: 0.2.21 --- # Pitch -Human problem-discovery ceremony. Authors a brief at the matching scale so shape starts from a framed problem and bootstrap can consume a pitched project BRIEF. +Human problem-discovery ceremony. Narrows sparks and ideas into a framed problem so shape can mint an issue, and bootstrap can consume a pitched project BRIEF. ## Contents - Critical Rules @@ -32,61 +32,90 @@ Human problem-discovery ceremony. Authors a brief at the matching scale so shape ## Critical Rules 1. **Agents never initiate a pitch.** This ceremony is human-invoked only. On Claude Code the sidecar sets `disable-model-invocation: true`; on every target this rule binds behaviorally. Agent legwork *inside* a human-opened pitch (competitive scans, file writes the skill directs) is fine — opening one is not. -2. **Log invocation first** — `loaf journal log "skill(pitch): <idea, problem, or intake item>"` before interviewing. -3. **Problem-space only** — grill what, who, and why-valuable. Approach, architecture, decomposition, and verification design belong to shape. A brief that reads like a pseudo-shape is a failure; rewrite before landing. -4. **One question at a time, recommendation-first** — using your harness's structured question tool if it has one (otherwise one inline question per message). Never a multi-field form. Order by impact on the brief. Full mechanics: [references/interview-guide.md](references/interview-guide.md). -5. **Never write `shape.md`, seed `tasks/`, push, or open PRs** — pitch prepares commits and hands off; push and PR stay human. Never auto-run shape or bootstrap. -6. **Landing is validated, then committed once** — every capture landing runs explicit-path `loaf change check <folder> --json` (zero violations, expected captured state) and a direct read-back of that folder's `change.json` confirming intended `target_release` presence or absence, then one docs-only commit per capture. Never batch captures into one commit. -7. **Slug identity is local** — propose a slug that names the concept, never another work unit (no `spec-042`, no task ids). Provenance lives in frontmatter and the change folder. -8. **Log the outcome** — `loaf journal log "decision(pitch): <slug or project> — <shape-now|park-targeted|park-untargeted|handed-to-bootstrap>"`. +2. **Log invocation first** — `loaf journal log "skill(pitch): <idea, problem, spark, or intake item>"` before interviewing. +3. **Problem-space only** — grill what, who, and why-valuable. Approach, architecture, decomposition, and verification design belong to shape. A narrative that reads like a pseudo-shape is a failure; rewrite before landing. +4. **One question at a time, recommendation-first** — using your harness's structured question tool if it has one (otherwise one inline question per message). Never a multi-field form. Order by impact on the narrative. Full mechanics: [references/interview-guide.md](references/interview-guide.md). +5. **Never bound, never ship** — do not add definition-of-done criteria, do not write an out-of-scope statement, do not run `loaf issue check` or `loaf issue promote`, do not push, do not open PRs. Never auto-run shape or bootstrap. +6. **Shape mints on the happy path** — same-session shape-now hands the authored narrative; shape runs `loaf issue new` with that body. Pitch writes an issue body only when parking an unshaped row or when `$ARGUMENTS` already names an issue (`loaf issue edit` replaces the body). +7. **Titles name the concept** — propose a working title, never another work unit's alias. Provenance lives in the issue row, the spark/idea resolution, and frontmatter on `docs/BRIEF.md`. +8. **Log the outcome** — `loaf journal log "decision(pitch): <title, ref, or project> — <shape-now|park-issue|park-idea|handed-to-bootstrap>"`. --- ## Verification -- Change scale: `docs/changes/YYYYMMDD-slug/` holds `change.json` + authored `brief.md`; `loaf change check <folder> --json` reports zero violations and captured state; `change.json` read-back matches the intended target binding -- Project scale: `docs/BRIEF.md` exists with `source: pitch` and the shared problem-space skeleton -- Cold-read: problem, who, alternative, and value nameable in one pass; zero solution-space content -- No `shape.md` or `tasks/` written by this skill; no push; no PR +- Issue-scale: a problem narrative exists against the shared skeleton; it was handed to shape, written into an existing issue body, or minted as an unshaped triage row with that body and no criteria +- Project-scale: `docs/BRIEF.md` exists with `source: pitch` and the shared problem-space skeleton +- Cold-read: problem, who, alternative, and value nameable in one pass; zero solution-space content; no out-of-scope statement and no criteria added by this skill +- Named sparks were promoted to an idea when pitching them; ideas and sparks were resolved against the issue only after a row exists +- No push; no PR; shape and bootstrap were not auto-run - Journal shows skill invocation and outcome entries --- ## Quick Reference +| Harness | Invoke skill | +|---------|----------------| +| Claude Code (plugin) | `/loaf:pitch` | +| OpenCode, Cursor, Codex, Amp | `/pitch` | + ### Scale detection | Signal | Scale | Output | |--------|-------|--------| -| Existing project (git history, source, or Loaf state) + a concept | **Change** | `loaf change init <slug> --brief` → authored `brief.md` | -| Empty or minimal directory / greenfield intent | **Project** | `docs/BRIEF.md` with `source: pitch` → hand to bootstrap | +| Existing project (git history, source, or Loaf state) + a concept | **Issue** | Problem narrative → shape (`loaf issue new --body`) or an unshaped triage row | +| Empty or minimal directory / greenfield product pitch | **Project** | `docs/BRIEF.md` with `source: pitch` → hand to bootstrap | Detect and confirm briefly; let the human correct. When both could apply (repo exists but they want a new product pitch), ask once. -### Landing matrix (Decision 11) +### Landing offers -| Intent | Branch | Commit | Target | -|--------|--------|--------|--------| -| **Shape now** | Create the slug branch (`git switch -c <slug>`), stay there | Hand to shape for in-place promotion — do not park-commit first | Stamp `target_release` when known | -| **Park targeted** | Default branch | One docs-only commit on default (promise-carrier exception) | `target_release` present and confirmed by read-back | -| **Park untargeted** | Slug branch **or** remain intake (Intent/spark) | Docs-only commit on the slug branch if becoming a Change; else no Change folder | No `target_release`; untargeted captures never land on main | +| Offer | When to recommend | What pitch does | +|-------|-------------------|-----------------| +| **Shape now** | Framing is solid; they want to bound next | Hand the narrative; do not mint; do not auto-run shape | +| **Park as issue** | Framed, durable, not bounding yet | `loaf issue new "<title>" --body -` with the narrative only; status stays `triage` | +| **Park as idea** | Too thin to keep as a row, or might discard | `loaf idea capture --title "..."`; journal the gist | +| **Hand to bootstrap** | Project-scale BRIEF authored | Point at bootstrap; do not auto-run it | -Pitch prepares the commit; never pushes; never opens PRs. +Pitch never pushes; never opens PRs. There is nothing to commit at issue scale — the row lives in SQLite. Project-scale may commit `docs/BRIEF.md` if the human wants it durable. -### Pre-landing guard (every capture) +### Spark and idea promotion -```bash -loaf change check <folder> --json # zero violations; state is captured -# then read <folder>/change.json and confirm target_release presence/absence matches intent +| Input | Read | Then | +|-------|------|------| +| Spark | `loaf spark show <ref>` | `loaf idea capture --title "..."` then `loaf spark promote <spark> --to-idea <idea>`; grill from the idea | +| Idea | `loaf idea show <ref>` | Grill; after a row exists, `loaf idea resolve <idea> --by <ref>` | +| Existing issue | `loaf issue show <ref>` | Grill; `loaf issue edit <ref> --body -` writes the narrative (replaces the whole body) | +| Free text | — | Grill; shape-now hands text; park captures an idea or mints an unshaped row | + +Do not invent a pitch from the queue without human selection. When they name an intake item, read it (`loaf intake list` / the item's read command). + +`loaf idea promote --to-spec` is not this path. Resolve ideas against the minted issue. + +### Problem-narrative skeleton + +Author against these sections, problem-space sentences only. This text is what shape puts in `--body` (or what a park-as-issue row stores): + +```markdown +## Problem Statement +## Who Has It +## Current Alternatives +## Value Proposition +## Constraints +## Sequencing and Relationships +## Sources and Research Links +## Open Questions ``` -Bare `loaf change check` resolves by branch and can miss a capture landing elsewhere — always pass the explicit folder path. +Do not add an out-of-scope statement. Shape bounds; pitch frames. ### Defined terms -- **Brief** — the pitch output (problem-space). Superseded by `shape.md` when shaping starts; may accrete parked problem-space sentences until then; freezes when `shape.md` exists. -- **Accretion** — adding problem-space concepts to a parked brief is legal; solution prose is not. -- **Shape now** — slug branch + hand to shape, which promotes the capture in place via ordinary `loaf change init <slug>` (no `--brief`). +- **Problem narrative** — pitch's issue-scale output. Superseded as the working surface once shape mints and bounds the issue; may accrete parked problem-space sentences until then. +- **BRIEF** — project-scale `docs/BRIEF.md`. A project document, not a work container. +- **Accretion** — adding problem-space concepts to a parked narrative is legal; solution prose is not. +- **Shape now** — hand the narrative to shape, which mints via `loaf issue new` and owns bounding. --- @@ -95,81 +124,82 @@ Bare `loaf change check` resolves by branch and can miss a capture landing elsew ### Step 1: Log and parse input ```bash -loaf journal log "skill(pitch): <idea, problem, or intake item>" +loaf journal log "skill(pitch): <idea, problem, spark, or intake item>" ``` -Parse `$ARGUMENTS`: free text, an intake ref the human already chose, or empty (ask what to pitch). Read the named intake item when provided (`loaf intake list` / the item's read command). Do not invent a pitch from the queue without human selection. +Parse `$ARGUMENTS`: free text, a spark, an idea, an issue ref, an intake ref the human already chose, or empty (ask what to pitch). Read the named item when provided. Do not invent a pitch from the queue without human selection. ### Step 2: Detect scale -Apply the Quick Reference table. Confirm: "I'll treat this as a **change-scale** pitch on this repo" or "…as a **project-scale** pitch for a new BRIEF." Adjust if corrected. +Apply the Quick Reference table. Confirm: "I'll treat this as an **issue-scale** pitch on this repo" or "…as a **project-scale** pitch for a new BRIEF." Adjust if corrected. -### Step 3: Problem-discovery interview +### Step 3: Promote sparks; read ideas + +When the named input is a spark, promote it to an idea before grilling so the capture trail is one idea, not a dangling spark: + +```bash +loaf idea capture --title "<working title>" +loaf spark promote <spark> --to-idea <idea> +``` + +When the named input is already an idea, `loaf idea show` and grill. Leave resolution until an issue row exists. + +### Step 4: Problem-discovery interview Run the interview per [references/interview-guide.md](references/interview-guide.md): -- Pin a one-or-two-line **destination** before dimension grilling (fixes brief scope; project scale feeds VISION success criteria, change scale sharpens the eventual Hypothesis) +- Pin a one-or-two-line **destination** before dimension grilling (fixes narrative scope; project scale feeds VISION success criteria; issue scale sharpens what good looks like for the row) - Dimensions: problem, who has it, current alternatives / competitive landscape, value proposition, constraints (plus sequencing and open questions when needed) - Depth: scenario stress-testing, challenge stance, glossary-term hygiene; open questions must pass the specifiability test and carry HITL/AFK tags when precise - Applicability judgment: skip formal competitive analysis and deep personas when the pitch kind does not warrant them (bug fixes, internal chores) -- One question at a time, recommendation-first, ordered by brief impact +- One question at a time, recommendation-first, ordered by narrative impact - Stop on exit criteria or when answers stop changing the framing -If the direction is genuinely undecided mid-interview, offer the **explore** technique (agent-side; Explorations and checkpoints) rather than forcing a false brief. Pitch remains the human front door; explore is not re-opened as a slash alternative by this skill. +If the direction is genuinely undecided mid-interview, offer the **explore** technique (agent-side; Explorations and checkpoints) rather than forcing a false narrative. Pitch remains the human front door; explore is not re-opened as a slash alternative by this skill. -### Step 4: Evidence delegation (when warranted) +### Step 5: Evidence delegation (when warranted) -When competitive or landscape facts would change the brief and are not already known: +When competitive or landscape facts would change the narrative and are not already known: 1. Spawn a **researcher** subagent with a bounded question (competitors, substitutes, prior art — not solution design). 2. Land evidence: - - **Change scale:** files under the change folder's `research/` (create the folder with the change); link from Sources and Research Links - - **Project scale:** inline source links in `docs/BRIEF.md` Sources and Research Links (no change `research/` yet) -3. Resume the interview or brief draft with recommendations informed by the scan. + - **Issue scale:** source links in the narrative's Sources and Research Links. If a longer scan lands on disk, name it for the landscape, never for the work unit, and cite it from Sources. + - **Project scale:** inline source links in `docs/BRIEF.md` Sources and Research Links. +3. Resume the interview or draft with recommendations informed by the scan. Never fabricate competitive claims. If a scan is skipped, say so in Sources ("no external scan; alternative is internal workaround X"). -### Step 5a: Change-scale ceremony - -1. **Propose a slug** — lowercase, digits, single hyphens; names the concept locally. Confirm with the human. -2. **Initialize capture:** - - ```bash - loaf change init <slug> --brief - ``` - - Creates `docs/changes/YYYYMMDD-<slug>/` with `change.json` + `brief.md` scaffold only. -3. **Author `brief.md`** against the shared problem-space skeleton (shape's brief template / the scaffold just written): Problem Statement, Who Has It, Current Alternatives, Value Proposition, Constraints, Sequencing and Relationships, Sources and Research Links, Open Questions. Problem-space sentences only. -4. **Stamp `target_release` when known** — edit `change.json` with canonical `MAJOR.MINOR.PATCH` (no `v`, no prerelease). Omit the field when untargeted. Confirm with the human before stamping. -5. **Accretion note** — tell the human: parked problem-space concepts may accrete into this brief until shaping starts; once `shape.md` exists the brief freezes. -6. **Cold-read** the brief (interview guide test); revise with the human until it passes. -7. **Offer landing** (recommendation-first): +### Step 6a: Issue-scale ceremony - | Offer | When to recommend | - |-------|-------------------| - | **Shape now** | Framing is solid and they want to bound implementation next | - | **Park targeted** | Bound to a release cohort but not shaping yet — docs-only on default branch | - | **Park untargeted** | Worth capturing off-main, or not ready as a Change (stay intake) | +1. **Propose a working title** — names the concept locally. Confirm with the human. This becomes shape's `loaf issue new` title (or the park-as-issue title). +2. **Author the problem narrative** against the skeleton above. Problem-space sentences only. +3. **Accretion note** — tell the human: parked problem-space concepts may accrete until shaping starts; once the issue is minted, the body is the home. +4. **Cold-read** the narrative (interview guide test); revise with the human until it passes. +5. **Offer landing** (recommendation-first) using the Landing offers table. +6. **Execute the chosen landing:** -8. **Execute the chosen landing:** + - **Shape now:** hand the full narrative and any spark/idea refs. Shape runs `loaf issue new "<title>" --body -` (or `--body-file`) with that text. Do not mint, do not add criteria, do not open a PR. After shape mints, resolve intake: `loaf idea resolve <idea> --by <ref>` (and `loaf spark resolve <spark> --by <ref>` only if the spark was never promoted). + - **Park as issue:** mint the unshaped row yourself, then resolve intake against it: - - **Shape now:** `git switch -c <slug>` (from default unless already on a working branch the human prefers), ensure pre-landing guard would pass if they later park, hand to shape with the folder path — shape promotes in place. Do not open a PR. - - **Park targeted:** on the **default branch**, run pre-landing guard on the explicit folder, confirm `target_release` present in `change.json`, then one docs-only commit of the change folder (and any `research/` under it). - - **Park untargeted as Change:** `git switch -c <slug>`, pre-landing guard, confirm `target_release` **absent**, one docs-only commit on the slug branch. - - **Park as intake:** do not leave a half-written change folder; prefer Intent/spark retention and delete or never create the capture if the human backs out. + ```bash + loaf issue new "<title>" --body - + loaf idea resolve <idea> --by <ref> + ``` -9. **Commit message** (when parking): conventional, e.g. `docs(change): capture <slug> brief` — one commit per capture. + Paste the narrative on stdin. Do not add criteria. Do not write out-of-scope. Default status is `triage`. Read back with `loaf issue show <ref>`. + - **Park as idea:** if no idea exists yet, `loaf idea capture --title "<title>"`. Journal the gist (`loaf journal log "discover(pitch): <one-line problem>"`). Do not mint an issue. + - **Existing issue:** `loaf issue edit <ref> --body -` with the full narrative. Edit replaces the body; do not strip a row that is already bounded — if criteria already exist, hand the narrative to the human and let shape merge. -10. **Closing ceremony (required — never trail off).** After the landing is executed (or intake retained), announce completion with a full closing block: +7. **Closing ceremony (required — never trail off).** After the landing is executed, announce completion with a full closing block: - - **Recap the brief** — section-by-section gist (Problem, Who, Alternatives, Value, Constraints, Sequencing, Open Questions). Name the change folder path (`docs/changes/YYYYMMDD-<slug>/`) and what it holds (`change.json` + `brief.md`, plus any `research/`). + - **Recap the narrative** — section-by-section gist (Problem, Who, Alternatives, Value, Constraints, Sequencing, Open Questions). Name where it lives (handed to shape, unshaped issue `<ref>`, idea `<ref>`, or the conversation plus journal gist). - **Restate the landing actually taken** and what it means next: - - **Shape now** — you are on the slug branch; run shape next to promote the capture in place and bound implementation. No park-commit was made. - - **Park targeted** — the capture is a docs-only commit on the default branch with `target_release` stamped; it sits as a promise carrier for that cohort until shape is invoked later. - - **Park untargeted** — the capture lives on the slug branch (or remains intake) without `target_release`; it is off-main until retargeted or shaped. If intake-only, name the Intent/spark and that no change folder was left half-written. + - **Shape now** — run shape next to mint the issue from this narrative and bound implementation. No row was minted here. + - **Park as issue** — `<ref>` holds the problem in its body and is unshaped; run shape later on that ref. + - **Park as idea** — the idea remains open; re-invoke pitch or shape when ready. Name the idea ref. - **Announce completion** in plain language: "Pitch is complete." Do not end on a dangling offer or an unfinished sentence. -### Step 5b: Project-scale ceremony +### Step 6b: Project-scale ceremony 1. **Author `docs/BRIEF.md`** using bootstrap's brief skeleton with frontmatter: @@ -181,31 +211,31 @@ Never fabricate competitive claims. If a scan is skipped, say so in Sources ("no --- ``` - Same problem-space sections as change scale, at project altitude (Sequencing describes the initial arc as prose). + Same problem-space sections as issue scale, at project altitude (Sequencing describes the initial arc as prose). 2. **Cold-read** and revise with the human. 3. Optionally commit `docs/BRIEF.md` if the human wants it durable before bootstrap; still no push unless they ask outside this skill's duties — pitch itself never pushes. 4. **Closing ceremony (required — never trail off).** Announce completion with a full closing block — do not hand off in a half-sentence: - **Recap what was authored** — section-by-section gist of the BRIEF (Problem Statement, Who Has It, Current Alternatives, Value Proposition, Constraints, Sequencing and Relationships, Sources and Research Links, Open Questions). One or two sentences per section is enough; the human should hear what landed without reopening the file. - **Artifact path** — name `docs/BRIEF.md` explicitly, including that frontmatter carries `source: pitch`. - - **Explicit handoff** — state, verbatim in spirit: next, run bootstrap: it reads this BRIEF as discovery-done (`source: pitch`), interviews only on gaps, populates the operating documents (VISION, STRATEGY, ARCHITECTURE, AGENTS), and closes by proposing your initial arc of captured changes. Do not auto-run bootstrap. + - **Explicit handoff** — state, verbatim in spirit: next, run bootstrap: it reads this BRIEF as discovery-done (`source: pitch`), interviews only on gaps, and populates the operating documents (VISION, STRATEGY, ARCHITECTURE, AGENTS). Do not auto-run bootstrap. - **Announce completion** in plain language: "Pitch is complete." The ceremony ends with a period, never a trail-off. -### Step 6: Log the outcome +### Step 7: Log the outcome ```bash -loaf journal log "decision(pitch): <slug or project> — <shape-now|park-targeted|park-untargeted|handed-to-bootstrap>" +loaf journal log "decision(pitch): <title, ref, or project> — <shape-now|park-issue|park-idea|handed-to-bootstrap>" ``` -The journal line is mechanical; the human-facing close is the closing ceremony in Step 5a/5b. Never log-and-stop without that recap and next-step restatement. +The journal line is mechanical; the human-facing close is the closing ceremony in Step 6a/6b. Never log-and-stop without that recap and next-step restatement. --- ## Related Skills -- **shape** — solution-space narrowing from an existing brief (or full narrowing when no brief); promotes capture folders in place -- **bootstrap** — consumes `docs/BRIEF.md` (`source: pitch` → gap interview) and series-preps captured changes -- **triage** — queue dispositions; may hand an item to pitch when problem discovery is needed +- **shape** — solution-space bounding; mints the issue from the problem narrative (`loaf issue new`) and owns criteria, out-of-scope, and decomposition +- **bootstrap** — consumes `docs/BRIEF.md` (`source: pitch` → gap interview) and populates operating documents +- **triage** — queue dispositions; may hand a spark or idea to pitch when problem discovery is needed - **explore** — agent-side technique when pitch finds the direction still undecided - **idea** — quick capture without ceremony; not a substitute for pitch - **research** — patterns the researcher subagent follows for landscape scans @@ -218,4 +248,4 @@ The journal line is mechanical; the human-facing close is the closing ceremony i ## Artifact Naming -Name every artifact for what it is, never for the work unit that produced it. The change folder already records provenance. Put source in frontmatter, not the filename. See the `foundations` skill; `loaf check --hook artifact-names` enforces it at commit. +Name every on-disk artifact for what it is, never for the work unit that produced it. The issue row or `docs/BRIEF.md` already records provenance. Put source in frontmatter, not the filename. See the `foundations` skill; `loaf check --hook artifact-names` enforces it at commit. diff --git a/dist/opencode/skills/pitch/references/interview-guide.md b/dist/opencode/skills/pitch/references/interview-guide.md index 8014082fe..65780fb10 100644 --- a/dist/opencode/skills/pitch/references/interview-guide.md +++ b/dist/opencode/skills/pitch/references/interview-guide.md @@ -11,7 +11,7 @@ Problem-discovery interview for pitch. Borrows shape's grilling mechanics (one q - Open Questions: Specifiability and HITL/AFK - Exit Criteria - Anti-Patterns -- Brief Cold-Read +- Problem Cold-Read ## How This Guide Works @@ -23,22 +23,22 @@ The interview is adaptive, not a form. Strong answers skip dimensions; weak answ ## Destination Pinning -Before dimension grilling, converge on a **destination**: one or two lines naming the end state this pitch is aiming at — what becomes true in the world if the work succeeds. Pin it early; it fixes the brief's scope for the rest of the interview. +Before dimension grilling, converge on a **destination**: one or two lines naming the end state this pitch is aiming at — what becomes true in the world if the work succeeds. Pin it early; it fixes the narrative's scope for the rest of the interview. | Scale | Destination feeds | |-------|-------------------| | **Project** | VISION success criteria (bootstrap extracts it; pitch keeps it as the project's north star in the BRIEF) | -| **Change** | The eventual Hypothesis when shape promotes the capture — a sharper "what good looks like" than a feature list | +| **Issue** | What good looks like for the work — the problem statement shape will put in the issue body | **How to pin:** offer a recommendation-first draft from the human's opening words ("Destination: operators can ship a release without a manual config audit"). Confirm, tighten, or rewrite until both parties can restate it. Do not start deep dimension probes until the destination is on the table. -If the destination keeps shifting mid-interview, pause and re-pin — a moving destination means the brief's scope is still open. +If the destination keeps shifting mid-interview, pause and re-pin — a moving destination means the narrative's scope is still open. --- ## Problem-Discovery Dimensions -Grill these five dimensions. Order by what would change the brief most — a wrong "who" invalidates value and alternatives; cosmetic naming goes last. +Grill these five dimensions. Order by what would change the narrative most — a wrong "who" invalidates value and alternatives; cosmetic naming goes last. ### 1. Problem @@ -62,17 +62,17 @@ What do they do today? Existing tools, manual workarounds, cobbled scripts, or " Why is solving this worth it? What becomes true for the people who have the problem if this lands? One line: different AND better relative to the alternative — not a feature list, not an architecture sketch. -**Applicability:** always name value, but depth scales. A product pitch needs a crisp UVP; a small change can be "removes the weekly fire-drill so release day is boring." +**Applicability:** always name value, but depth scales. A product pitch needs a crisp UVP; a small internal pitch can be "removes the weekly fire-drill so release day is boring." ### 5. Constraints -Non-negotiable bounds: technical, legal, organizational, philosophical, sequencing. Things that limit the solution space before design begins. Capture as problem-space limits ("must not break the promise-carrier exception"), never as chosen designs ("use Postgres"). +Non-negotiable bounds: technical, legal, organizational, philosophical, sequencing. Things that limit the solution space before design begins. Capture as problem-space limits ("must not add a new human ceremony to the weekly path"), never as chosen designs ("use Postgres"). **Always ask lightly:** at least one real constraint or an explicit "none known yet." ### Secondary (only when signal demands) -- **Sequencing and relationships** — how this hangs with other work, release cohort as prose, series order. No machine relation fields. +- **Sequencing and relationships** — how this hangs with other work, series order. No machine relation fields. - **Open questions** — unresolved problem-space items that pass the [specifiability test](#open-questions-specifiability-and-hitlafk); each tagged HITL or AFK. - **Evidence of pain** — money, time, workarounds (Mom Test lens). When absent and the claim is large, challenge gently. @@ -105,13 +105,13 @@ Every question includes a recommended answer and a short rationale. The human ov Example shape: > **Who has this problem most often?** -> Recommendation: mid-size platform engineers who already run multi-service deploys — they feel config drift weekly. Rationale: your examples all came from that world; broader "all developers" would dilute the brief. +> Recommendation: mid-size platform engineers who already run multi-service deploys — they feel config drift weekly. Rationale: your examples all came from that world; broader "all developers" would dilute the narrative. ### Ordering -Prioritize answers that would rewrite the brief. Typical order: **destination pin** → problem → who → alternatives → value → constraints → sequencing/open questions. Reorder when the human's first utterance already settles an early dimension. +Prioritize answers that would rewrite the narrative. Typical order: **destination pin** → problem → who → alternatives → value → constraints → sequencing/open questions. Reorder when the human's first utterance already settles an early dimension. -Before asking, check whether reading resolves it — journal, prior Change, intake item body, BRIEF. Only ask what reading could not answer. +Before asking, check whether reading resolves it — journal, prior issue, intake item, BRIEF. Only ask what reading could not answer. ### Adaptive depth @@ -120,12 +120,12 @@ Before asking, check whether reading resolves it — journal, prior Change, inta | Crisp, specific answers | Confirm, move on; skip expand-if-needed probes | | Category answers ("developers need better tools") | Ask for a concrete story or last painful moment | | Solution-first ("I want a CLI that…") | Pause; reframe to problem and who | -| Energy dropping | Cut to synthesis; a brief with named gaps beats an exhausted interrogation | -| Direction genuinely undecided | Offer the explore technique from inside pitch; do not force a false brief | +| Energy dropping | Cut to synthesis; a narrative with named gaps beats an exhausted interrogation | +| Direction genuinely undecided | Offer the explore technique from inside pitch; do not force a false narrative | ### Scenario stress-testing -Probe the problem with **concrete lived scenarios**, not abstract categories. Prefer "walk me through the last time this bit you" over "how often does this happen?" Invent edge cases deliberately to force boundary precision: "What if the operator is on-call at 2am with half the logs missing — is that still this problem, or a different one?" Scenarios that collapse the framing expose missing constraints or a second concept that should stay out of this brief. +Probe the problem with **concrete lived scenarios**, not abstract categories. Prefer "walk me through the last time this bit you" over "how often does this happen?" Invent edge cases deliberately to force boundary precision: "What if the operator is on-call at 2am with half the logs missing — is that still this problem, or a different one?" Scenarios that collapse the framing expose missing constraints or a second concept that should stay out of this narrative. ### Challenge stance @@ -133,12 +133,12 @@ Demand specificity over generalization. Probe the rationale behind claims ("why Also enforce **canonical language** during the interview: -- Flag terms that conflict with `docs/knowledge/glossary.md` usage (e.g. calling a Change a "spec," a release cohort a "milestone," a skill a "module"). Offer the glossary term and get the human to accept the swap before it lands in the brief. +- Flag terms that conflict with `docs/knowledge/glossary.md` usage (e.g. calling an issue a "ticket," a skill a "module"). Offer the glossary term and get the human to accept the swap before it lands in the narrative. - Sharpen fuzzy project-local terms to a single canonical choice mid-interview ("you said both 'capture' and 'ticket' — pick one and stick to it"). Ambiguous vocabulary becomes solution fog later. ### Mid-interview evidence -When competitive landscape or external facts would change the brief and the human lacks them, pause the grill, delegate a researcher subagent, land evidence (change-scale: `research/` in the change folder; project-scale: links in Sources), then resume with a recommendation informed by the scan. Evidence supports framing; it does not become solution design. +When competitive landscape or external facts would change the narrative and the human lacks them, pause the grill, delegate a researcher subagent, land evidence (issue-scale: source links in the narrative; project-scale: links in Sources), then resume with a recommendation informed by the scan. Evidence supports framing; it does not become solution design. --- @@ -146,11 +146,11 @@ When competitive landscape or external facts would change the brief and the huma ### Specifiability test -An open question earns a **precise entry** in the brief only if it can be **stated precisely now** — not answered now. The bar is: a later reader (human or agent) could work the question without inventing what was meant. +An open question earns a **precise entry** in the narrative only if it can be **stated precisely now** — not answered now. The bar is: a later reader (human or agent) could work the question without inventing what was meant. | Passes (precise entry) | Fails (coarse note only) | |------------------------|--------------------------| -| "Does the operator need multi-region failover in v1, or is single-region acceptable for the first cohort?" | "Figure out reliability stuff" | +| "Does the operator need multi-region failover in v1, or is single-region acceptable until the first cut?" | "Figure out reliability stuff" | | "Which existing CLI command is the migration source of truth for config paths?" | "TBD on integration" | Everything vaguer stays a **coarse note** in Open Questions or Sequencing prose — never pre-sliced into fake precision. Do not invent enumerated options the human did not surface. @@ -164,7 +164,7 @@ Mark each precise open question with one of: | **HITL** | Needs the human live — judgment, taste, organizational call, or access only they hold | "Will legal accept the data-retention tradeoff?" | | **AFK** | Runnable by an agent without the human in the loop — research, codebase scan, competitive lookup | "What do the top three substitutes charge for the free tier?" | -Briefs carry the tag inline in the Open Questions section, e.g. `- [HITL, blocking] …` or `- [AFK, deferrable] …`. Blocking vs deferrable remains orthogonal: a HITL question can be deferrable; an AFK question can still block shaping if the answer rewrites scope. +Narratives carry the tag inline in the Open Questions section, e.g. `- [HITL, blocking] …` or `- [AFK, deferrable] …`. Blocking vs deferrable remains orthogonal: a HITL question can be deferrable; an AFK question can still block shaping if the answer rewrites scope. --- @@ -180,13 +180,13 @@ Stop interviewing when all of the following hold (or the human explicitly wants 6. **Constraints** are listed or explicitly empty. 7. Answers have stopped changing the framing — the last questions confirmed rather than rewrote. 8. Open questions that remain pass the specifiability test (or are coarse notes) and carry HITL/AFK tags when precise. -9. A cold reader could pass the brief cold-read test below. +9. A cold reader could pass the problem cold-read test below. -Open questions may remain; mark each blocking or deferrable, and HITL or AFK when precise. Blocking problem-space unknowns belong in the brief's Open Questions, not as invented answers. +Open questions may remain; mark each blocking or deferrable, and HITL or AFK when precise. Blocking problem-space unknowns belong in the narrative's Open Questions, not as invented answers. ### The pivot -Do not announce "the interview is over." Shift: "I think I have enough to draft the brief — tell me what I got wrong." Author the brief against the shared skeleton, then section-review with the human before any init or landing step. The **closing ceremony** (recap, path, next step) is owned by SKILL.md after landing — this guide owns interview depth only. +Do not announce "the interview is over." Shift: "I think I have enough to draft the problem narrative — tell me what I got wrong." Author the narrative against the shared skeleton, then section-review with the human before any mint or landing step. The **closing ceremony** (recap, path, next step) is owned by SKILL.md after landing — this guide owns interview depth only. --- @@ -196,7 +196,7 @@ Adopted from bootstrap's interview guide; binding on pitch. **The Form.** Running dimensions mechanically like a survey. If answer 2 covers dimension 4, confirm and skip. -**The 45-Minute Interrogation.** If energy drops, cut to draft. Gaps in the brief are honest; drained enthusiasm is not recoverable in the same session. +**The 45-Minute Interrogation.** If energy drops, cut to draft. Gaps in the narrative are honest; drained enthusiasm is not recoverable in the same session. **The Therapist.** Do not explore the builder's feelings about the product. User emotions (switching forces, pain) matter; builder therapy does not. @@ -212,7 +212,7 @@ Adopted from bootstrap's interview guide; binding on pitch. **Third Interview Idiom.** Do not invent pitch-specific interview machinery. Destination pinning, scenario stress-testing, and challenge stance deepen the same grilling mechanics — they are not a parallel framework. -**Pseudo-Shape in the Brief.** Approach, architecture, task breakdown, or verification design must not enter `brief.md` / `docs/BRIEF.md`. If it appears while drafting, move it out and note it for shape. +**Pseudo-Shape in the Narrative.** Approach, architecture, decomposition, or verification design must not enter the problem narrative or `docs/BRIEF.md`. If it appears while drafting, move it out and note it for shape. **Fake Precision.** Pre-slicing vague unknowns into numbered open questions that cannot yet be stated precisely. Coarse notes beat counterfeit clarity. @@ -220,9 +220,9 @@ Adopted from bootstrap's interview guide; binding on pitch. --- -## Brief Cold-Read +## Problem Cold-Read -Before offering shape-now or park, cold-read the authored brief. A stranger should name, in one pass: +Before offering shape-now or park, cold-read the authored narrative (issue-scale) or `docs/BRIEF.md` (project-scale). A stranger should name, in one pass: 1. The **destination** (or success end-state) 2. The **problem** @@ -230,4 +230,4 @@ Before offering shape-now or park, cold-read the authored brief. A stranger shou 4. The **current alternative** 5. The **value** of solving it -…and find **zero solution-space content** (no approach, stack, API shape, or task list). Precise open questions should carry HITL/AFK tags. If any of the five is missing or solution prose creeps in, revise before landing. +…and find **zero solution-space content** (no approach, stack, API shape, or implementation slices). Precise open questions should carry HITL/AFK tags. If any of the five is missing or solution prose creeps in, revise before landing. diff --git a/dist/opencode/skills/refactor-deepen/SKILL.md b/dist/opencode/skills/refactor-deepen/SKILL.md index 4e0974049..06a4998e5 100644 --- a/dist/opencode/skills/refactor-deepen/SKILL.md +++ b/dist/opencode/skills/refactor-deepen/SKILL.md @@ -173,7 +173,7 @@ skill terminates by writing a PLAN file using [templates/plan.md](templates/plan > Plan saved to `.agents/plans/<filename>.md`. Workflow handoff is pending > the SPEC/PLAN/TASKS artifact taxonomy spec — for now, decide manually. -Do **not** recommend breakdown or implement as the next step. The +Do **not** recommend implement as the next step. The handoff design is downstream of a deferred taxonomy spec. ### Codex Review (Opt-In, Plugin-Gated) diff --git a/dist/opencode/skills/refactor-deepen/templates/plan.md b/dist/opencode/skills/refactor-deepen/templates/plan.md index 8be684590..0fe537186 100644 --- a/dist/opencode/skills/refactor-deepen/templates/plan.md +++ b/dist/opencode/skills/refactor-deepen/templates/plan.md @@ -28,8 +28,8 @@ write a new file rather than updating an existing one. | `title` | Yes | One-line description of the deepening, not the candidate name | | `created` | Yes | ISO 8601 UTC, e.g. `2026-05-02T01:30:00Z` (must match the filename timestamp) | | `status` | Yes | `drafting` on first write; this template does not define additional lifecycle states | -| `spec` | Yes | `SPEC-NNN` if the plan is scoped under a spec; `null` otherwise (do not omit the key) | -| `related` | No | List of related artifact IDs (`ADR-*`, `SPEC-*`, idea filenames, other plan filenames) | +| `issue` | Yes | `LOAF-NNN` if the plan is scoped under an issue; `null` otherwise (do not omit the key) | +| `related` | No | List of related artifact IDs (`ADR-*`, `LOAF-*`, idea filenames, other plan filenames) | PLAN files do **not** carry an `id` frontmatter field. The filename is the identity, mirroring councils and ideas. @@ -131,7 +131,7 @@ Filename: `.agents/plans/20260502-013000-deepen-journal-append.md` title: "Deepen journal append into a self-managing module" created: "2026-05-02T01:30:00Z" status: drafting -spec: SPEC-034 +issue: LOAF-34 related: - 20260501-231922-plan-lifecycle-cli-doctor-housekeeping --- diff --git a/dist/opencode/skills/reflect/SKILL.md b/dist/opencode/skills/reflect/SKILL.md index 9cffd72b3..783926498 100644 --- a/dist/opencode/skills/reflect/SKILL.md +++ b/dist/opencode/skills/reflect/SKILL.md @@ -82,12 +82,12 @@ After completing work, reflect extracts learnings and proposes updates to strate ### Step 1: Parse Input -`$ARGUMENTS` can be: a spec ID (`SPEC-001`), a topic ("authentication learnings"), or empty (general reflection on recent work). +`$ARGUMENTS` can be: an issue ref (`LOAF-42`), a topic ("authentication learnings"), or empty (general reflection on recent work). ### Step 2: Gather Evidence Sources: -1. **Completed specs** (`.agents/specs/SPEC-*.md` with status `done`; legacy files may still read `complete`) -- look for "Lessons Learned" +1. **Completed issues** (`loaf issue list` / `loaf issue show <ref>` with status `done`) -- look for lessons in the issue body 2. **Project journal** (`loaf journal recent --json`, `loaf journal search <topic>`) -- insights, surprises, pivots 3. **Recent commits** (`git log --oneline -30`) 4. **Implementation reality** -- what was harder/easier than expected? What assumptions were wrong? diff --git a/dist/opencode/skills/release/SKILL.md b/dist/opencode/skills/release/SKILL.md index 510f4ee1d..9dbab87b1 100644 --- a/dist/opencode/skills/release/SKILL.md +++ b/dist/opencode/skills/release/SKILL.md @@ -1,33 +1,27 @@ --- name: release description: >- - Orchestrates standalone releases from already-landed work: release readiness, - version selection, changelog curation, release commit, tag, GitHub Release, - install verification, and post-release follow-up. Use when the user says "cut - a release," "publish a version," "release from main," or asks whether enough - landed work should become a release. Not for reviewing or merging a PR (use - ship). + Cuts a retroactive release from already-landed issues: loaf release suggest + reports the range, loaf release cut records the version. Use when the user + says "cut a release," "publish a version," "release from main," or asks what + landed since the last tag. Produces a recorded release row and members as + facts. Not for reviewing or merging a PR (use ship). version: 0.2.21 --- # Release -Publish a coherent version from work that has already landed. +Cut a version from work that has already landed. ## Contents - Critical Rules - Verification - Quick Reference - Topics -- Context Detection -- Step 1: Release Readiness -- Step 2: Change Collection -- Step 3: Version + Changelog -- Step 4: Release Execution -- Step 5: Release-PR Flow -- Step 6: Publication Verification -- Step 7: Post-Release Follow-Up -- Hook Interaction +- Process +- Attribution +- Bump derivation +- Must-contain convention - Related Skills **Input:** $ARGUMENTS @@ -36,259 +30,227 @@ Publish a coherent version from work that has already landed. ## Critical Rules -- **Release is not merge** -- do not use release to review, approve, or land a feature PR. Use ship for PR correctness and landing. -- **Release from landed work** -- collect changes from the release base branch, normally the repo default branch, since the last release tag. -- **Release-PR flow is the default** -- prepare on a release branch with `loaf release --pre-merge`, squash-merge the release PR, then finalize with `loaf release --post-merge` on the base branch. Direct `--bump` on the base branch is a named exception used only on explicit user request. -- **Batch by intent** -- group release notes by user-facing outcome, `CR-*` change bundle, spec, or related PRs; do not mirror individual commits mechanically. -- **Keep landed and released distinct** -- a PR may be landed without being released; a release may contain multiple landed PRs. -- **Block on release-readiness failure** -- do not publish if build, tests, version files, changelog, tag, or GitHub release state is inconsistent. -- **Never push, tag, or publish without confirmation** -- present the exact actions first. -- **Use your harness's structured question tool (if it has one) for release decisions** -- version bump type, release PR handoff, push/tag/GitHub Release confirmation. -- **Log release** -- after publication, run `loaf journal log "decision(release): vX.Y.Z published from <base> with <summary>"`. +1. **Log invocation first** — `loaf journal log "skill(release): <what is being cut or suggested>"` before doing anything else. +2. **Release is not merge** — do not review, approve, or land a PR here. Verification authority is the ship workflow (PR review and CI at merge). If the user is asking to merge, stop and route to ship. +3. **A release is cut from what landed** — the surface is `loaf release suggest` and `loaf release cut`. Do not run unsubcommmanded `loaf release`, `--pre-merge`, or `--post-merge`; this skill does not own that path. +4. **Suggest writes nothing** — it reads `baseline-tag..HEAD` (or `--base <ref>..HEAD`), attributes commits to issues, rolls up through parents, reports partially-landed parents and unattributed commits as information, derives the bump, reports the advisory bucket delta, and drafts notes. +5. **Cut records facts** — it applies the version, prepends the drafted notes into `CHANGELOG.md`, tags, records the release row plus members, then attempts a GitHub Release draft. A `gh` failure degrades to a warning with a paste-ready retry command; the recorded row stays. +6. **No forward version stamp** — do not bind an issue to a future version. Members are what already landed. Buckets (`loaf issue bucket`) are advisory labels; planned-vs-landed is information only. +7. **No suite, no re-record, no publication stop in this skill** — ship already verified the merged work. Cut's operational refusals (dirty worktree, disagreeing version files, missing version, `--no-tag` without an existing tag) are command errors, not a substitute for ship. +8. **Confirm before cut** — present the suggest report (or `cut --dry-run`) first. Ask one question at a time, with a recommendation, using your harness's structured question tool if it has one. `--dry-run` previews everything and writes nothing. +9. **Log the outcome** — after a successful cut, `loaf journal log "decision(release): vX.Y.Z recorded from <base> with <summary>"`. + +--- ## Verification -- Release base branch is clean, current, and contains the intended landed PRs -- Pre-flight checks pass before versioning or publication -- Changelog entries are curated user-facing prose, not commit or PR-title dumps -- Version files, changelog heading, git tag, and GitHub Release all agree -- Tag points at the released base-branch commit or release commit, not an abandoned feature branch -- Downstream install path is verified when applicable, especially Homebrew for Loaf releases +- Journal contains the `skill(release)` invocation (and a `decision(release)` entry after a real cut) +- The work in the range already landed through ship (PR review and CI at merge); this skill did not re-verify or re-merge it +- `loaf release suggest` (or `cut --dry-run`) was shown: landed issues, partially-landed parents, unattributed commits, advisory buckets, derived bump, drafted notes +- Partially-landed parents, unattributed commits, and bucket drift were reported as information — not treated as a cut refusal +- Mutating `loaf release cut` updated version files, wrote the notes into `CHANGELOG.md`, created or reused tag `v<version>`, and recorded the release row with issue members (plus `--includes` release members when given) +- `cut --dry-run` left version files, changelog, tags, HEAD, and release rows untouched +- GitHub Release is a draft, was skipped with `--no-gh`, or failed with a warning plus a paste-ready `gh release create …` retry — never a silent rollback of the recorded row +- No issue was stamped with a future version + +--- ## Quick Reference -| Step | Gate | Blocking? | -|------|------|-----------| -| Readiness | clean/current base branch, no unresolved release collisions | Yes | -| Change Collection | landed work since last tag grouped into release themes | Yes | -| Version + Changelog | bump selected, notes curated, files updated | Yes | -| Execution | release commit prepared via `--pre-merge`, release PR landed, `--post-merge` finalizes | Yes | -| Verification | release and install paths checked | Yes | -| Follow-Up | reflect/housekeeping suggested when useful | No | +| Harness | Invoke skill | +|---------|----------------| +| Claude Code (plugin) | `/loaf:release` | +| OpenCode, Cursor, Codex, Amp | `/release` | + +### Commands + +```text +loaf release suggest [--base <ref>] [--json] +loaf release cut [--base <ref>] [--bump <type>] [--includes <version|tag>] [--no-tag] [--no-gh] [--dry-run] +loaf issue bucket <ref> now|next|later|none [--json] +loaf issue link <from> blocks|relates-to <to> [--json] +``` + +Both commands need initialized SQLite state (`loaf state init`, or `loaf state migrate markdown --apply`). They are not a git repository's optional extra — without SQLite they refuse. + +| Command | Writes? | What it does | +|---------|---------|----------------| +| `loaf release suggest` | No | Report landed work since the last version tag | +| `loaf release cut` | Yes (unless `--dry-run`) | Cut the retroactive release and record members as facts | +| `loaf release cut --dry-run` | No | Print the plan, including `--includes` rows, and write nothing | + +### `suggest` flags + +| Flag | Meaning | +|------|---------| +| `--base <ref>` | Commits since `<ref>` instead of the last tag | +| `--json` | Machine-readable suggestion | +| `-h`, `--help` | Help | + +`suggest` rejects `--dry-run` (it is already read-only) and rejects cut-only flags (`--bump`, `--includes`, `--no-tag`, `--no-gh`). + +### `cut` flags + +| Flag | Meaning | +|------|---------| +| `--base <ref>` | Commits since `<ref>` instead of the last tag | +| `--bump <type>` | Override the derived bump: `major`, `minor`, `patch`, `prerelease`, `release` | +| `--includes <version\|tag>` | Record a prior release as a member (repeatable). Use this to hang prerelease references on a stable | +| `--no-tag` | Do not create a git tag; tag `v<version>` must already exist | +| `--no-gh` | Skip the GitHub Release draft | +| `--dry-run` | Print the plan and write nothing | +| `-h`, `--help` | Help | + +`cut` rejects `--json`. `--bump prerelease` and `--bump release` only produce a version when the current version already has a prerelease suffix; otherwise cut fails with `could not compute a version to cut`. `--no-tag` is checked before `--dry-run`: the tag must already exist even for a preview. + +### Cut sequence (mutating) + +1. Recompute the same suggestion as `suggest` (then apply `--bump` if given, and redraft notes) +2. Resolve each `--includes` ref to an existing release +3. Require a clean worktree +4. Apply the version to detected version files (they must exist and agree) +5. Prepend drafted notes into `CHANGELOG.md` (after `[Unreleased]`, ahead of prior versions; creates the file if missing) +6. Commit `chore: release vX.Y.Z` +7. Unless `--no-tag`: create annotated tag `vX.Y.Z` (`git tag -a`). Signing follows git config (`tag.gpgSign`); cut never passes `-s` or `--no-sign` +8. Record the release row, issue members, and `--includes` members as facts +9. Unless `--no-gh`: `gh release create <tag> --draft --title <tag> --notes <notes>` (adds `--prerelease` when the version is a prerelease). Switches to the configured GitHub account first. On `gh` missing, account failure, or create failure: print `warning:` plus a POSIX-quoted `retry:` command; do not fail the cut + +Cut does not push the commit or the tag. + +--- ## Topics | Topic | Use When | |-------|----------| -| [Context Detection](#context-detection) | Determining release base, last tag, and current branch | -| [Release-PR Flow](#step-5-release-pr-flow) | Preparing, landing, and finalizing every release | -| [Hook Interaction](#hook-interaction) | Understanding coexistence with git hooks | +| [Process](#process) | Running suggest then cut in this conversation | +| [Attribution](#attribution) | How commits become issue members | +| [Bump derivation](#bump-derivation) | Why suggest picked major, minor, or patch | +| [Must-contain convention](#must-contain-convention) | The rare promise that named issues must land first | --- -## Context Detection - -Before anything, establish the release surface: - -1. Get current branch and repo default branch: - ```bash - git branch --show-current - gh repo view --json defaultBranchRef -q .defaultBranchRef.name - ``` -2. Parse `$ARGUMENTS` for an explicit base, tag, or version. If omitted, use the repo default branch as the release base. -3. Verify the current branch: - - If already on the release base, continue; the release-PR flow in Step 5 branches from here. - - If on a dedicated release branch, resume the release-PR flow at the matching step. - - If on a feature branch, stop and explain that release publishes from landed work. Offer ship if the active PR needs landing first. -4. Find the previous release tag: - ```bash - git describe --tags --abbrev=0 - ``` -5. Gather the candidate release range: - ```bash - git log --oneline <last-tag>..HEAD - git diff --stat <last-tag>..HEAD - ``` +## Process ---- +Parse `$ARGUMENTS` for a base, bump, version, `--includes`, `--no-tag`, `--no-gh`, or `--dry-run`. Default baseline is the last version tag; `--base` overrides. With neither a last tag nor `--base`, the range is all of `HEAD`. -## Step 1: Release Readiness - -Run release pre-flight checks before editing release files: - -1. Ensure worktree is clean: - ```bash - git status --short - ``` -2. Ensure the release base is current: - ```bash - git fetch --tags origin - git status --branch --short - ``` -3. Check for existing tag or GitHub Release collisions for the target version once known: - ```bash - git tag --list vX.Y.Z - gh release view vX.Y.Z - ``` -4. Run project checks: - - Node: `npm run typecheck`, `npm run test`, `npm run build` when scripts exist - - Go: `go vet ./...`, `go test ./...` when `go.mod` exists - - Python: `pytest`, `mypy .`, `ruff check .` when configured - - Rust: `cargo check`, `cargo test` when `Cargo.toml` exists - -If no checks are detected, warn explicitly. If a check fails, stop and fix before release. +### Step 1: Log and route ---- +```bash +loaf journal log "skill(release): <what is being cut or suggested>" +``` -## Step 2: Change Collection - -Collect landed work since the last release and group it for release notes. - -1. Inspect commits: - ```bash - git log --first-parent --oneline <last-tag>..HEAD - git log --oneline <last-tag>..HEAD - ``` -2. Inspect merged PRs when GitHub is available: - ```bash - gh pr list --state merged --base <base> --json number,title,mergedAt,url - ``` -3. Group changes by user-facing outcome: - - `CR-*` change bundle, when referenced - - spec or task family, when public enough to be useful - - feature/fix/documentation/build themes - - operational release work, when it affects users or maintainers -4. Drop noise: - - purely internal task labels - - reverted work that is not present in `HEAD` - - individual commit mechanics that collapse into one user-facing change - -Present the grouped release contents before choosing the bump. +If the user wants a PR reviewed or merged, stop and use ship. If the work is still on a feature branch, explain that a release is cut from landed `HEAD` since the baseline, and offer ship. ---- +### Step 2: Suggest -## Step 3: Version + Changelog - -Choose the bump and curate the changelog from the grouped landed work. - -1. Run a dry run: - ```bash - loaf release --dry-run - ``` - Use `--base <ref>` when the project expects a non-default release base. -2. Present: - - current version - - proposed next version - - detected version files - - release actions the CLI would perform - - draft changelog entries -3. Curate `CHANGELOG.md` before publishing: - - write from the upgrading user's perspective - - group under Common Changelog categories: `Changed`, `Added`, `Removed`, `Fixed` - - use one self-describing line per meaningful change - - include public PR, issue, ADR, release, or commit links when helpful - - avoid dumping commit subjects, task IDs, session mechanics, or internal gate language -4. Confirm the bump type: `prerelease`, `release`, `major`, `minor`, or `patch`. +```bash +loaf release suggest +# or +loaf release suggest --base <ref> +loaf release suggest --json +``` ---- +Present the report as-is: base, suggested bump and version, bump evidence, landed issues with commits, partially-landed parents (missing children), unattributed commits, advisory buckets (planned landed / planned not landed / unplanned landed), drafted notes. -## Step 4: Release Execution +Do not hide partial parents or unattributed commits, and do not refuse the cut because of them unless the operator is using the [must-contain convention](#must-contain-convention) and wants to wait. -Every release routes through the release-PR flow in Step 5: prepare the release commit on a release branch with `loaf release --pre-merge`, land the release PR, then finalize with `loaf release --post-merge` on the base branch. +### Step 3: Confirm -Release preparation should: +Show the exact `loaf release cut …` you would run. Recommend cutting the derived version when the landed set matches what the operator asked for. Use `--bump` only when they override. Use `--dry-run` when they want a preview: -1. Update version files -2. Convert `[Unreleased]` into `## [X.Y.Z] - YYYY-MM-DD` -3. Reinsert a fresh empty `[Unreleased]` section -4. Run configured release artifact commands -5. Create the release commit +```bash +loaf release cut --dry-run +loaf release cut --dry-run --no-gh +loaf release cut --dry-run --includes <version|tag> +``` -After preparation, verify generated artifacts are current: +### Step 4: Cut ```bash -npm run build -git diff --exit-code -- dist plugins content/skills/loaf-reference/SKILL.md +loaf release cut +loaf release cut --bump minor +loaf release cut --includes v1.1.0-alpha.1 +loaf release cut --no-tag --no-gh ``` -Adjust the path list to the project. For Loaf itself, tracked generated outputs under `dist/`, `plugins/`, and native binaries must match the source changes. +On success, report version files updated, changelog written, tag created or reused, release recorded (member count), and GitHub draft created / skipped / warned. If stderr has `retry:`, paste that command; the row is already recorded. -Capability receipts pin artifact SHA-256s, and the release rebuild version-stamps generated artifacts (`dist/opencode/plugins/hooks.ts` embeds `@version`, so every version bump stales the OpenCode receipt; Go changes additionally stale all binary-pinned receipts via `bin/native`). Therefore re-recording runs AFTER `loaf release --pre-merge` completes its artifact rebuild, on the release branch, before pushing the release PR — never before the bump. Verify with `go test ./internal/cli -run TestTargetCapabilityEvidence`. `loaf release` now enforces this mechanically on every mutating path (post-rebuild refusal in apply, guardrail 9 in `--post-merge`) — the rule explains WHY the gate fires; the gate makes skipping it impossible. +```bash +loaf journal log "decision(release): vX.Y.Z recorded from <base> with <summary>" +``` -### Direct Release (Named Exception) +### Step 5: After -`loaf release --bump <type> --yes` on the base branch prepares, commits, tags, and publishes in a single shot. Use it only when the user explicitly requests a direct release; never select it by default. Skipping the release PR means nothing runs the suite against the prepared tree before the tag exists — the v0.2.16 cut took this door and a capability-evidence canary surfaced only in tag CI, after publication. The same day, v0.2.17 re-recorded evidence minutes before the version bump; the release commit staled it, and the tag again published zero assets — ordering, not diligence, is the failure mode. The CLI prints a flow advisory when a mutating release starts on the default branch; treat it as a routing signal, not noise. +Suggest reflect when the cut produced durable product or workflow learnings, and housekeeping when temporary artifacts need cleanup. Capture leftover discoveries as issues or sparks — not as extra changelog lines. --- -## Step 5: Release-PR Flow +## Attribution -The default for every release: PR CI runs the full suite against the prepared tree, so evidence canaries surface before any tag or GitHub Release exists. This holds regardless of repository settings — where branch protection is enabled it is satisfied as a side effect, not the reason for the flow. +`suggest` (and `cut`, which recomputes the same suggestion) attributes each commit in the range to zero or more issues, then rolls up through parents. -1. Create a dedicated release branch from the release base. -2. Run `loaf release --pre-merge` on it: this creates the version/changelog/artifact release commit but no tag and no GitHub Release. -3. Open a release PR with a concise release-focused body. -4. Hand the PR to ship for review and landing; squash-merge it into one `chore: release vX.Y.Z (#PR)` commit carrying the curated changelog. -5. After the release PR lands, run `loaf release --post-merge` on the base branch to tag, publish the GitHub Release, and verify installability. +**Commit → issue**, first match wins: -If guardrail 9 fires on `--post-merge`, the merged tree itself carries stale evidence; recovery is to re-record against the merged tree, land the receipts as a single evidence-only commit on the base branch (the repair commit must not modify the capability registry), and rerun `loaf release --post-merge`. +1. Issue alias (`PREFIX-N`, e.g. `LOAF-42`) in the subject or body (prefix case-sensitive). URLs and code spans are stripped first. +2. Else the merge/branch rung: aliases in a `Merge …` subject, plus any alias anywhere in the body (case-insensitive). Squash subjects like `feat: add auth (#42)` often carry the alias only in the body. No network, no `gh`. An alias that lived only on a deleted branch name is unattributable. +3. Else a unique journal `commit(<hash>)` row whose message contains an alias (scope uniquely matching that commit). -Do not hide this handoff inside release: ship remains the PR correctness and merge gate. +Resolved aliases become **landed** issues (with the commits that named them). Commits that match nothing are **unattributed** — listed, and included under drafted notes as `### Unattributed`. They do not block the cut. ---- +**Parent rollup** (information): -## Step 6: Publication Verification +- For each landed issue that has a parent, if any sibling child is not `done`, the parent is **partially landed** and the missing children are listed. +- A parent is not auto-added to landed unless a commit attributed to it. -After publishing, verify the public release state: +Drafted notes are `## [version] - YYYY-MM-DD`, then one `### ALIAS — title` section per landed issue with commit subjects, then unattributed. -1. Confirm tag location: - ```bash - git show --stat vX.Y.Z - ``` -2. Confirm GitHub Release: - ```bash - gh release view vX.Y.Z - ``` -3. Confirm package or installer availability when applicable: - - npm: `npm view <package> version` - - Homebrew: `brew update && brew info <tap>/<formula>` - - project-specific deploy or artifact registry checks -4. For Loaf/Homebrew, report readiness only after the GitHub release exists, assets are uploaded, the tap formula is updated, and tap CI has passed. +--- -If publication partially completes, do not retag casually. Name the exact state and continue with the smallest repair or patch release path. +## Bump derivation ---- +Derived from the range, in order: -## Step 7: Post-Release Follow-Up +| Condition | Bump | +|-----------|------| +| Breaking marker (`type!:` in the subject, or `BREAKING CHANGE:` / `BREAKING-CHANGE:` in the body) | `major` | +| A **done** parent with **two or more** children, every child `done` and landed, and the parent's done timestamp **after** the baseline tag's committer time | `minor` (closed multi-child parent fully landed) | +| Else a conventional `feat` commit | `minor` | +| Else | `patch` (`fix` / other) | -After verification: +`--bump` on `cut` replaces the derived bump and redrafts notes; the evidence string becomes `overridden by --bump <type>`. -1. Log the release decision to the project journal: - ```bash - loaf journal log "decision(release): vX.Y.Z published from <base> with <summary>" - ``` -2. Suggest reflect when the release produced durable product or workflow learnings. -3. Suggest housekeeping when release branches or temporary reports need cleanup. -4. Keep future-work discoveries out of the release notes; capture them as tasks, ideas, or sparks instead. +Current version comes from agreeing version files, else from a semver last tag. Cut still requires version files on the mutating path. --- -## Hook Interaction +## Must-contain convention -This skill coexists with existing hooks. Git workflow hooks are advisory unless -configured otherwise; security and secret-scanning hooks remain blocking. +Rare. When the operator needs a named set of issues to land before a cut, create a **release-prep** issue and express `blocked_by` edges with issue mechanics: + +```bash +loaf issue new "Release prep for vX.Y.Z" --body "Must contain LOAF-12 and LOAF-15. Out of scope: the cut itself." +loaf issue link LOAF-12 blocks LOAF-99 +loaf issue link LOAF-15 blocks LOAF-99 +``` -| Hook | Type | When release Runs | -|------|------|---------------------| -| `github-account` | Force-switch | Switches to the configured GitHub account before `gh` release operations; blocks only if the switch fails | -| `validate-push` | Advisory | Cross-checks version bump, changelog, and build on push | -| `workflow-pre-pr` | Advisory | Fires when the release PR is opened | -| `workflow-pre-merge` | Advisory | Belongs to ship when a release PR must land | -| `workflow-post-merge` | Advisory | Belongs to ship after PR landing | -| `check-secrets` | Blocking | Always respected before writes or shell actions | +Stored write types are `blocks` and `relates_to`. `loaf issue link <must-land> blocks <release-prep>` is how you record that the prep issue is blocked by those that must land. `loaf issue frontier` and implement honor `blocks`. **`loaf release suggest` and `cut` do not read these edges** — convention, not schema. If the operator wants to wait, wait; if they cut anyway, cut records whatever actually landed. -Do not disable hooks to force a release through. +Buckets stay labels: ---- +```bash +loaf issue bucket LOAF-12 now +loaf issue bucket LOAF-12 none +``` -## Suggests Next +`suggest` prints `bucket:<name> ALIAS — title (landed|not landed)` and `unplanned ALIAS — title (landed)`. Never treat that delta as a constraint. -After a successful release, suggest reflect for durable learnings and housekeeping if temporary release artifacts need attention. +--- ## Related Skills -- **ship** -- Reviews, verifies, and lands a PR before it becomes release input -- **git-workflow** -- Branching, PR, commit, and squash merge conventions -- **documentation-standards** -- Changelog and release-note quality -- **reflect** -- Updates strategy from shipped/released learnings -- **housekeeping** -- Cleans up completed spec, report, and handoff artifacts +- **ship** — Reviews, verifies, and lands a PR. That merge is the verification authority for what this skill may later cut +- **git-workflow** — Branching, PRs, and any later push of the local tag (cut does not push) +- **documentation-standards** — Changelog prose if a human edits notes after the cut +- **reflect** — Durable learnings after a cut +- **housekeeping** — Cleanup of temporary artifacts diff --git a/dist/opencode/skills/research/SKILL.md b/dist/opencode/skills/research/SKILL.md index e3e949a32..326e908bf 100644 --- a/dist/opencode/skills/research/SKILL.md +++ b/dist/opencode/skills/research/SKILL.md @@ -95,7 +95,7 @@ Always check project context first. Rate findings: **High** (official/verified), **Trigger:** Empty input, "project state", "catch me up" 1. Read project documents: VISION.md, STRATEGY.md, ARCHITECTURE.md -2. Check ideas with `loaf idea list --json` and specs with `loaf spec list --json` +2. Check ideas with `loaf idea list --json` and issues with `loaf issue list --json` (or `loaf issue export` for the full graph) 3. Review recent journal activity with `loaf journal recent --json` and `loaf journal context` 4. Check recent commits: `git log --oneline -20` 5. Synthesize following [state-assessment template](templates/state-assessment.md) @@ -145,4 +145,4 @@ User-facing entry for a new concept is pitch (problem-discovery brief). Do not t ## Artifact Naming -Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or Change already records that provenance. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. +Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or the issue already records that provenance. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. diff --git a/dist/opencode/skills/research/templates/report.md b/dist/opencode/skills/research/templates/report.md index 2ff4c059c..f7cc5d7dd 100644 --- a/dist/opencode/skills/research/templates/report.md +++ b/dist/opencode/skills/research/templates/report.md @@ -14,7 +14,7 @@ title: "Report: [Topic]" type: research | audit | analysis | council created: YYYY-MM-DDTHH:MM:SSZ status: draft | done | archived -source: SPEC-XXX | TASK-XXX | ad-hoc +source: LOAF-42 | ad-hoc tags: [] --- diff --git a/dist/opencode/skills/research/templates/state-assessment.md b/dist/opencode/skills/research/templates/state-assessment.md index 65e426f3b..359392033 100644 --- a/dist/opencode/skills/research/templates/state-assessment.md +++ b/dist/opencode/skills/research/templates/state-assessment.md @@ -24,7 +24,7 @@ tags: [] - **Vision:** [Brief summary] - **Key personas:** [Who we're building for] -- **Current focus:** [Active specs/work] +- **Current focus:** [Active issues] ## Recent Progress @@ -32,10 +32,10 @@ tags: [] ## In Flight -| Spec/Task | Status | Notes | -|-----------|--------|-------| -| SPEC-001 | implementing | [progress] | -| SPEC-002 | approved | [next up] | +| Issue | Status | Notes | +|-------|--------|-------| +| LOAF-1 | active | [progress] | +| LOAF-2 | todo | [next up] | ## Ideas Pipeline diff --git a/dist/opencode/skills/shape/SKILL.md b/dist/opencode/skills/shape/SKILL.md index 88bc4c6cd..14bcdb3a7 100644 --- a/dist/opencode/skills/shape/SKILL.md +++ b/dist/opencode/skills/shape/SKILL.md @@ -1,26 +1,22 @@ --- name: shape description: >- - Shapes messy input into a bounded, reviewable Change under - docs/changes/YYYYMMDD-slug/ (change.json + shape.md + tasks/), validated by - loaf change check. Runs a fog-routed narrowing protocol — gather context, - optional blindspot pass, grilling, reaction artifacts — seeds task-file - vertical slices, runs a critique gate, and offers an opt-in draft PR. Use when - the user asks "shape this," "turn this into a Change," or an idea has enough - constraints to bound. Produces role-named narrative (shape.md required; - brief/plan/design optional) plus task packets — never a numbered spec. - Teaches the problem-boundary test (same problem → another task; different - problem → Intent) and vertical-slice discipline. Not for quick capture (use - idea), problem discovery that should author a brief first (use pitch), or - open-ended divergent thinking (agent technique: explore / brainstorm — user - entry intent routes to pitch). + Shapes messy input into a bounded issue — problem body, definition-of-done + criteria, out-of-scope statement, and children when a criterion earns its own + DoD — validated by loaf issue check. Use when the user asks "shape this," + "turn this into an issue," or a diagnosed fix needs a row. Produces a shaped + issue — never a folder or a plan document. Teaches fog graduation (park, + then a decision child) and one-criterion sizing (one fresh context window, + verifiable alone). Not for quick capture (use idea), problem discovery that + should author a brief first (use pitch), or open-ended divergent thinking + (agent technique: explore / brainstorm — user entry routes to pitch). subtask: false version: 0.2.21 --- # Shape -Turn messy input into a bounded, reviewable Change. +Prepare a bounded, reviewable issue. ## Contents - Critical Rules @@ -36,29 +32,30 @@ Turn messy input into a bounded, reviewable Change. ## Critical Rules -1. **Log invocation first** — `loaf journal log "skill(shape): <input being shaped>"` before doing anything else. -2. **Produces a Change, never a spec** — `change.json` + `shape.md` (+ optional `brief.md`/`plan.md`/`design.md`) and `tasks/TASK-NNN-slug.md`. No sequentially-numbered spec file, no status-like fields anywhere. +1. **Log invocation first** — `loaf journal log "skill(shape): shaping <topic> into LOAF-42"` before doing anything else. If no issue exists yet, log `skill(shape): shaping <topic>` and add the alias in the outcome entry. +2. **Produces an issue, never a folder** — the deliverable is the issue row: problem in the body, definition of done as `loaf issue dod` criteria, an explicit out-of-scope statement in the body, children via `loaf issue promote` when a criterion earns its own DoD. No plan document is committed. The PR body, if a PR is opened, is `loaf issue render` output. 3. **The fog register routes, you don't guess** — every named unknown carries a quadrant tag that dispatches it to exactly one technique (see Quick Reference). Technique-by-vibes is the failure mode this replaces. -4. **Blindspot pass precedes grilling, offered not imposed** — run it when the territory is unfamiliar; skip it when the shaper is the domain expert. Never impose it as a mandatory step. -5. **Grill one question at a time, with a recommendation** — using your harness's structured question tool if it has one (otherwise one inline question per message — same semantics). Never a form to fill in one pass. Order by architectural impact: questions whose answer would change the architecture go first, cosmetic questions last. -6. **Techniques return fog entries, not decisions** — blindspot passes, grilling, and reaction artifacts hand back named unknowns and evidence for the human to adjudicate. Reconnaissance, not orders. -7. **Own the decomposition** — decide Implementation Unit boundaries and granularity autonomously (absorbed from the retired breakdown step); ask only when two orderings carry genuinely different trade-offs. -8. **Order units by likelihood-of-change** — data models, interfaces, and user-facing flows lead; mechanical work collapses at the bottom, so review attention lands on what's most likely to need changing. -9. **Surface misalignment, never silently adjust** — when the idea conflicts with strategic docs, prior Changes, or the journal, tell the user and let them decide. Don't quietly reshape their idea. -10. **Critique before finalizing** — run the Critique Gate as the last shaping step, before `loaf change check` and the PR offer. -11. **Get approval before `loaf change init`** — don't scaffold the folder without explicit confirmation of scope. -12. **Log the outcome** — `loaf journal log "decision(shape): <slug> shaped — N units, N open fog entries"`. +4. **Fog graduates instead of evaporating** — a question not yet sharp enough is parked in the issue's `fog` field (`loaf issue new --fog`). When it sharpens it becomes a `--kind decision` child, which is ready when it poses a sharp question (a `?` in the title or body). No plan required. +5. **Blindspot pass precedes grilling, offered not imposed** — run it when the territory is unfamiliar; skip it when the shaper is the domain expert. Never impose it as a mandatory step. +6. **Grill one question at a time, with a recommendation** — using your harness's structured question tool if it has one (otherwise one inline question per message — same semantics). Never a form to fill in one pass. Order by architectural impact: questions whose answer would change the architecture go first, cosmetic questions last. +7. **Techniques return fog entries, not decisions** — blindspot passes, grilling, and reaction artifacts hand back named unknowns and evidence for the human to adjudicate. Reconnaissance, not orders. +8. **Decomposition is the tail** — a parent gets children only when its DoD needs more than one coherent slice. A criterion becomes a child the moment it earns its own DoD, via `loaf issue promote`. Own those boundaries autonomously; ask only when two orderings carry genuinely different trade-offs. +9. **One sizing criterion** — a slice is right-sized when it fits one fresh context window and is verifiable alone. Expand–contract is the named exception for wide mechanical refactors. See [references/decomposition.md](references/decomposition.md). +10. **Surface misalignment, never silently adjust** — when the idea conflicts with strategic docs, prior issues, or the journal, tell the user and let them decide. Don't quietly reshape their idea. +11. **Critique before finalizing** — run the Critique Gate as the last shaping step, before `loaf issue check`. +12. **A diagnosed one-line fix is two commands** — `loaf issue new` with a body that states the problem and `Out of scope: …`, then one `loaf issue dod add`. No problem-space ceremony. Confirm scope with the user before `loaf issue new` on anything larger. +13. **Log the outcome** — `loaf journal log "decision(shape): LOAF-42 shaped — N children, N open fog entries"`. --- ## Verification -- `docs/changes/YYYYMMDD-slug/` has `change.json` + `shape.md` with Product Contract sections non-empty; task packets seeded under `tasks/` when decomposition is known -- Every Open Questions entry carries a quadrant tag (`[KU]`, `[UK]`, or `[UU]`) and a route -- `loaf change check` reports zero violations (no legacy deprecation on new layout); executability gaps were read, not ignored -- Problem-boundary test applied: discovered different problems become Intents, not TASK-007 -- The Critique Gate ran, and its answers changed the documents where they applied -- No status-like fields in `change.json` or task frontmatter +- The issue body states the problem and contains an explicit out-of-scope statement (`out of scope`, case-insensitive — that substring is what `loaf issue check` reads) +- At least one definition-of-done criterion exists; V-tier criteria carry `--command` (and `--expect` when the check is more than exit 0); H-tier otherwise +- Every open unknown is either parked in create-time `fog`, held in the session register until it sharpens, graduated to a `--kind decision` child (or sibling) with a sharp question, or written into the body as a decided answer +- `loaf issue check <ref>` reports the issue shaped (delivery) or ready (decision). When children exist, coverage failures were fixed and containment orphans were filed as sibling backlog issues using the printed remedy +- Problem-boundary test applied: a discovered different problem becomes a new backlog issue, not another criterion on this one +- The Critique Gate ran, and its answers changed the issue where they applied --- @@ -66,34 +63,46 @@ Turn messy input into a bounded, reviewable Change. ### Fog register format -Open Questions entries take one of three forms: +Open unknowns take one of three forms. Keep the register in the session. Park what is still unsharp in `--fog` at create; after create, unsharp entries stay in the session register (edit cannot mutate `fog`). Graduate what is sharp to a decision child or sibling, and write decided answers into the body. ```text -- [KU] <the unknown> → <route: grilling | research spike | owner section> -- [UK] <the recognize-it-when-seen criterion> → reaction artifact in research/ +- [KU] <the unknown> → <route: grilling | research spike | owner> +- [UK] <the recognize-it-when-seen criterion> → reaction artifact - [UU] <the suspected blind area> → blindspot pass over <territory> ``` -An entry resolves by becoming a Decision, a Planning Contract subsection, or a named follow-up — visible in the diff, never silently deleted. A `[UU]` that gets named becomes a `[KU]` or `[UK]` and re-routes through the table below. +An entry resolves by becoming a decision child, a body paragraph, a criterion, or remaining parked in `fog` — visible on `loaf issue show`, never silently deleted. A `[UU]` that gets named becomes a `[KU]` or `[UK]` and re-routes through the table below. ### Quadrant routing | Tag | Meaning | Routes to | |-----|---------|-----------| | `[KU]` known unknown | A question you can state precisely | [Grilling](references/grilling.md) (architecture-changing answers first) or a research spike | -| `[UK]` unknown known | You'd recognize the right answer if you saw it, but can't state it yet | [Reaction artifact](references/reaction-artifact.md) — a variant or mock in `research/`, react and pick | +| `[UK]` unknown known | You'd recognize the right answer if you saw it, but can't state it yet | [Reaction artifact](references/reaction-artifact.md) — a variant or mock, react and pick | | `[UU]` suspected blind spot | Unfamiliar territory; you don't yet know what you don't know | [Blindspot pass](references/blindspot-pass.md) | -No route names a skill invocation. Research re-interviews an already-scoped question and writes to `.agents/reports/`; brainstorm forces a strategic frame onto a Change-local question and sends resolutions to intake. Shape runs all three techniques itself, in-session, and writes evidence into the Change's own `research/` — never `.agents/reports/`. +No route names a skill invocation. Research re-interviews an already-scoped question; brainstorm forces a strategic frame onto an issue-local question and sends resolutions to intake. Shape runs all three techniques itself, in-session, and writes the captured answer onto the issue — never into `.agents/reports/`. ### Defined terms -- **Rabbit holes** — tempting expansions of scope that would consume disproportionate effort for marginal value; name them so nobody wanders in unknowingly. -- **No-gos** — approaches explicitly forbidden for this Change, stated so they aren't silently reconsidered mid-implementation. +- **Rabbit holes** — tempting expansions of scope that would consume disproportionate effort for marginal value; name them in the out-of-scope statement so nobody wanders in unknowingly. +- **No-gos** — approaches explicitly forbidden for this issue, stated so they aren't silently reconsidered mid-implementation. ### Source inputs recognized -Step 1 reads whatever `$ARGUMENTS` names, or asks. Recognized sources: a change `brief.md` (from pitch or capture), a journal entry (cite by ID), a spark, an idea, a brainstorm document, a Linear issue, a PR conversation, a prior Change, or plain conversation with no artifact behind it yet. +Step 1 reads whatever `$ARGUMENTS` names, or asks. Recognized sources: a brief from pitch, a journal entry (cite by ID), a spark, an idea, a brainstorm document, a Linear issue, a PR conversation, a prior issue, or plain conversation with no artifact behind it yet. + +### One-line entry + +A diagnosed fix that already has a problem and a done-check: + +```bash +loaf issue new "Fix missing --json in list help" --body "issue list --help omits --json. Out of scope: rewriting other help pages." +loaf issue dod add LOAF-42 "issue list help names --json" --command "loaf issue list --help" --expect "contains \`--json\`" +loaf issue check LOAF-42 +``` + +Two writes, then the readiness verdict. No grilling, no children, no files. --- @@ -101,53 +110,86 @@ Step 1 reads whatever `$ARGUMENTS` names, or asks. Recognized sources: a change ### Step 1: Gather Context -Parse `$ARGUMENTS` against the source inputs above. When the input names a Change folder that already has `brief.md` (or you find one for this work), treat the brief as primary: restate the problem from it, confirm with the user rather than re-discovering, and keep later grilling on solution-space (how, boundaries, verification) — pitch already framed the problem. When no brief exists, run full narrowing as today; pitch is the recommended front door for raw concepts, never a gate. Read the journal (`loaf journal recent` / `search`) for related history, and check for a prior Change touching the same area. When VISION.md, STRATEGY.md, and ARCHITECTURE.md exist, read them for strategic fit. Most consumer projects don't have them yet — when absent, shape against the journal, recent Changes, and the conversation instead, and say so in the Change's Source Inputs. +Parse `$ARGUMENTS` against the source inputs above. When a brief from pitch already frames the problem, restate it, confirm with the user rather than re-discovering, and keep later grilling on solution-space (how, boundaries, verification). When no brief exists, run full narrowing; pitch is the recommended front door for raw concepts, never a gate. Read the journal (`loaf journal recent` / `search`) for related history, and check for a prior issue touching the same area (`loaf issue list`, `loaf issue tree`). When VISION.md, STRATEGY.md, and ARCHITECTURE.md exist, read them for strategic fit. Most consumer projects don't have them yet — when absent, shape against the journal, recent issues, and the conversation instead, and say so in the issue body. ### Step 2: Evaluate Strategic Fit -When strategic docs exist, check: does this advance the vision, serve the target personas, fit technical constraints, avoid conflicting with in-flight Changes? On misalignment, **surface it to the user — never silently adjust the idea**. The user decides whether to proceed, narrow scope, or defer to reflect after this ships. +When strategic docs exist, check: does this advance the vision, serve the target personas, fit technical constraints, avoid conflicting with in-flight issues? On misalignment, **surface it to the user — never silently adjust the idea**. The user decides whether to proceed, narrow scope, or file the conflicting concern as its own backlog issue. -### Step 3: Name the Change and Initialize +### Step 3: Name the Issue and Write the Row -Once the shape of the work is nameable, confirm scope with the user, then: +Once the work is nameable, confirm scope with the user (skip this confirmation on the one-line path), then create the row. Prefer creating after the first narrowing pass so `--fog` can carry remaining unsharp questions — the CLI writes `fog` only at create. ```bash -loaf change init <slug> +loaf issue new "Rotate auth tokens on a sliding window" \ + --body "Sessions never expire while the tab stays open, so a stolen cookie is valid indefinitely. + +Out of scope: migrating existing sessions; third-party IdP support." \ + --fog "[KU] sliding-window length → grill; [UU] existing session-store conventions → blindspot pass" ``` -On a fresh slug this scaffolds `change.json` + `shape.md` + seeded `tasks/` from the embedded templates (see `templates/shape.md`, `templates/task.md`). On a capture-only folder that already has `change.json` + `brief.md` (from pitch or `init --brief`), the same command promotes in place — preserving brief and metadata verbatim while materializing `shape.md` and `tasks/` — never hand-copy templates into the folder; rely on that promotion path. Use `loaf change init <slug> --brief` only for capture-before-shape (emits `change.json` + `brief.md`). It does not switch branches — `git switch -c <slug>` yourself. Fill `shape.md` Product Contract sections as understanding solidifies; seed `tasks/TASK-NNN-slug.md` as vertical slices (a task is a commit, not a PR). Optional `plan.md`/`design.md` accrete when the how needs prose. See [references/cli-boundary.md](references/cli-boundary.md). +Default kind is `delivery`; default status is `triage`. `--status` accepts `triage`, `backlog`, `todo`, `active`, or `done`. Use `--body -` or `--body-file <path>` for a longer body; `loaf issue edit <ref>` later **replaces** the body, it does not patch it. + +A delivery issue is shaped when the body is nonempty (the problem), at least one criterion exists, and the body contains an explicit out-of-scope statement. Fill those as understanding solidifies — create can carry the first body; criteria come next. + +A discovered different problem is a new backlog issue, not a child of this one: + +```bash +loaf issue new --status backlog "Rewrite the session store" +``` ### Step 4: Narrow the Unknowns -Offer the blindspot pass when the territory is unfamiliar (a new domain, an unfamiliar subsystem, a first collaboration) — skip it when the shaper is the expert. Its fog entries, and any others surfaced in interview, route by quadrant (Quick Reference above). Loop: grill `[KU]` entries, react to `[UK]` entries, run blindspot reconnaissance on `[UU]` entries until each gets a name and re-routes. Stop grilling when no unrouted `[KU]` entries remain or answers stop changing the contract. Entries still open at the end of the session are fine — each names its owner (a section, a spike, a follow-up). +Offer the blindspot pass when the territory is unfamiliar (a new domain, an unfamiliar subsystem, a first collaboration) — skip it when the shaper is the expert. Its fog entries, and any others surfaced in interview, route by quadrant (Quick Reference above). Loop: grill `[KU]` entries, react to `[UK]` entries, run blindspot reconnaissance on `[UU]` entries until each gets a name and re-routes. Stop grilling when no unrouted `[KU]` entries remain or answers stop changing the issue. -### Step 5: Decompose into Implementation Units +When a parked question sharpens, graduate it — after the parent's DoD is written (Step 5). Attaching **any** child, including a decision, turns coverage on. -Absorbed from the retired breakdown step — see [references/decomposition.md](references/decomposition.md) for the Right Size Test and per-unit verification discipline, including the V-tier `Command:` / `Expect:` forms `loaf change verify` parses (commands run from the repository root; H-tier is never gate input). Order units by likelihood-of-change; state real sequencing constraints in prose, never by list order alone. +```bash +loaf issue new --kind decision --parent LOAF-42 "Should tokens live in httpOnly cookies?" +``` + +A decision issue is ready when the title or body contains `?`. It needs no criteria and no out-of-scope statement. A decision child does not claim a parent criterion, so promote (or otherwise claim) the parent's DoD before adding children, or keep the decision as a sibling (`loaf issue new --kind decision --status backlog`, no `--parent`) if the parent stays a leaf. Unsharp questions discovered after create stay in the session register until they graduate — there is no `--fog` on edit. See [references/decomposition.md](references/decomposition.md). + +### Step 5: Write Definition of Done (decomposition tail) + +Add criteria as the interrogation produces observable done-checks. V-tier when a command can disagree with the implementation; H-tier when only a human can tell. + +```bash +loaf issue dod add LOAF-42 "Sliding-window expiry is covered by tests" --command "go test ./internal/auth/..." --expect "exit 0" +loaf issue dod add LOAF-42 "Stolen-cookie writeup is reviewable" --tier H +``` + +`--command` implies V unless `--tier` overrides. `--expect` uses the verify grammar (`exit <N>`, `` contains `text` ``, joined by ` and `). Commands run from the repository root. See [references/cli-boundary.md](references/cli-boundary.md) and [references/decomposition.md](references/decomposition.md). + +A parent gets children only when its DoD needs more than one coherent slice. The moment a criterion earns its own DoD, promote it — the parent criterion stays, the child starts with a copy, and the claim is recorded so coverage holds for that position: -### Step 6: Fill the Planning Contract +```bash +loaf issue promote LOAF-42 1 +``` -Write the free-form `###` subsections the work actually needs (approach, placement, risks, sequencing) inside the Planning Contract container. Its subsection names are yours; the container itself, plus Implementation Units, Verification Contract, and Definition of Done, is what `loaf change check` looks for. Durable Outputs stays forward-looking here — name what a final spec, ADR, or knowledge doc will need to capture, but don't write it now. Durable artifacts get created after implementation proves what's true, not during shaping. +Then shape the child the same way (body, out-of-scope, its own criteria). Order children by likelihood-of-change when presenting them; state real sequencing with `loaf issue link <from> blocks <to>`, never by tree order alone. -### Step 7: Run the Critique Gate +### Step 6: Run the Critique Gate -Before finalizing, challenge the draft — see [references/critique-gate.md](references/critique-gate.md). Is scope still bounded, does every new command or state name its ceremony, is a status field creeping back in under another name, is the CLI/skill boundary drawn correctly, and could this be smaller and still deliver the Hypothesis? +Before finalizing, challenge the draft — see [references/critique-gate.md](references/critique-gate.md). Is scope still bounded, does every new command or state name its ceremony, is a second progress flag creeping into the body, is the CLI/skill boundary drawn correctly, and could this be smaller and still be verifiable in one fresh context window? -### Step 8: Validate +### Step 7: Validate ```bash -loaf change check +loaf issue check LOAF-42 ``` -Read violations (always block — fix them) separately from the executability report (derived, informational unless `--require-executable` is passed — that flag is implement's preflight and CI's non-draft gate, not shape's business). See [references/cli-boundary.md](references/cli-boundary.md). +A delivery issue that passes prints `issue LOAF-42 is shaped`; a decision issue prints `issue LOAF-42 is ready`. Failures always block (missing body, missing criterion, missing out-of-scope, no sharp question, uncovered parent criterion). Containment orphans are reported, not failed: each line includes a ready-to-paste remedy that files the orphan as a sibling backlog issue — run that command, do not invent a different disposition. + +`loaf issue verify <ref>` runs V-tier commands from the repository root and writes nothing. That is implement's preflight, not shape's gate. See [references/cli-boundary.md](references/cli-boundary.md). -### Step 9: Offer the Draft PR +### Step 8: Offer the Review Surface -Offer to push the branch and open a draft PR, using [the PR template](templates/pr.md) — opt-in, never automatic. `loaf change check` (with no `--require-executable`) plus `gh pr list` is the cross-branch index either way. +The issue lives in SQLite. There is no folder to commit and nothing plan-shaped to land. Offer `loaf issue show <ref>` and `loaf issue tree <ref>` as the review surface. If a PR is being opened for the work, its body is `loaf issue render <ref>` — paste-ready, no manual editing. Opt-in, never automatic. -### Step 10: Log the Outcome +### Step 9: Log the Outcome -`loaf journal log "decision(shape): <slug> shaped — N units, N open fog entries"`. +`loaf journal log "decision(shape): LOAF-42 shaped — N children, N open fog entries"`. --- @@ -155,8 +197,8 @@ Offer to push the branch and open a draft PR, using [the PR template](templates/ - **pitch** — Problem-discovery ceremony that authors a brief; preferred front door when the problem is not yet framed - **idea** — Quick capture; feeds into pitch or shape once a concept has enough weight -- **brainstorm** — Agent technique for divergent thinking (route user entry intent to pitch) -- **implement** — Starts execution once a Change is structurally executable; this does not prove implementation completion +- **brainstorm** — Agent technique for divergent thinking (route user entry to pitch) +- **implement** — Starts execution once `loaf issue check` reports the issue shaped; this does not prove implementation completion - **reflect** — Updates strategic docs after the shipped work proves what changed ## Topics @@ -166,10 +208,10 @@ Offer to push the branch and open a draft PR, using [the PR template](templates/ | Blindspot pass | [references/blindspot-pass.md](references/blindspot-pass.md) | Deciding whether to offer reconnaissance, and how to prompt it | | Grilling | [references/grilling.md](references/grilling.md) | Running the one-question-at-a-time interview for `[KU]` entries | | Reaction artifacts | [references/reaction-artifact.md](references/reaction-artifact.md) | Resolving `[UK]` entries with a variant, mock, or prototype | -| Decomposition | [references/decomposition.md](references/decomposition.md) | Sizing and ordering Implementation Units | -| CLI boundary | [references/cli-boundary.md](references/cli-boundary.md) | Reading `loaf change init`/`check`/`verify` output, or explaining `--require-executable` | +| Decomposition | [references/decomposition.md](references/decomposition.md) | Sizing slices, promoting criteria, reading coverage and containment | +| CLI boundary | [references/cli-boundary.md](references/cli-boundary.md) | Reading `loaf issue` output, authoring `--command`/`--expect`, or explaining `loaf issue check` | | Critique Gate | [references/critique-gate.md](references/critique-gate.md) | Self-challenging scope and boundaries before finalizing | ## Artifact Naming -Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or Change already records that provenance. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. +Shape's deliverable is the issue row. If a reaction artifact or spike note lands on disk, name it for what it is, never for the issue that produced it. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. diff --git a/dist/opencode/skills/shape/references/blindspot-pass.md b/dist/opencode/skills/shape/references/blindspot-pass.md index 7bbd329a4..616f544d5 100644 --- a/dist/opencode/skills/shape/references/blindspot-pass.md +++ b/dist/opencode/skills/shape/references/blindspot-pass.md @@ -12,7 +12,7 @@ Ask the user whether to run it; skip when they're the domain expert. Do not auto ## Prompt Shape -Ask, against the specific territory named by the Change: +Ask, against the specific territory named by the issue: > What would I not know to ask here — codebase history, domain conventions, prior art, potholes? @@ -29,6 +29,8 @@ A `[UU]` that gets named through this pass becomes: and re-routes through the quadrant table in the main skill body. +Park what is still unsharp in the issue's `fog` field at create (`--fog`). After create, keep unsharp entries in the session register — edit cannot mutate `fog`. When an entry sharpens, graduate it to a `--kind decision` child or sibling. Do not drop a named unknown on the floor. + ## Stopping The pass ends when the shaper (or the user) can name the territory's remaining risks as entries, not vague unease. A pass that keeps surfacing "something might be wrong here" without a nameable entry has run past its useful length — stop and proceed with what's been named. diff --git a/dist/opencode/skills/shape/references/cli-boundary.md b/dist/opencode/skills/shape/references/cli-boundary.md index b7ade652b..6ed241493 100644 --- a/dist/opencode/skills/shape/references/cli-boundary.md +++ b/dist/opencode/skills/shape/references/cli-boundary.md @@ -1,56 +1,118 @@ # CLI Boundary -Reading `loaf change init` and `loaf change check` — the skill teaches reading the CLI, not wrapping it. For the rest of the Loaf CLI surface, see the `loaf-reference` skill. +Reading `loaf issue` — the skill teaches reading the CLI, not wrapping it. For the rest of the Loaf CLI surface, see the `loaf-reference` skill. Issue commands require initialized SQLite state (`loaf state init`, or `loaf state migrate markdown --apply`). + +## Contents +- `loaf issue new` +- `loaf issue show` / `list` / `tree` / `frontier` +- `loaf issue edit` / `status` +- `loaf issue dod` +- `loaf issue promote` +- `loaf issue check` +- `loaf issue verify` +- `loaf issue link` / `bucket` +- `loaf issue render` +- `loaf release suggest` / `cut` +- What shape does not run + +## `loaf issue new <title> [options]` + +```text +loaf issue new <title> [--body <text>|--body -|--body-file <path>|--message <text>] + [--kind delivery|decision] [--parent <ref>] [--fog <text>] [--status <status>] [--json] +``` + +Creates the issue row. Default kind is `delivery`; default status is `triage`. `--status` accepts the write statuses `triage`, `backlog`, `todo`, `active`, `done` (it still records the initial triage event). `--fog` parks questions not yet sharp enough to be issues; this flag exists only on create — `loaf issue edit` replaces the body and does not mutate `fog`. + +`--body -` reads stdin; `--body-file` reads a UTF-8 file; `--message` is inline body at lower precedence than `--body-file` and `--body -`. A hyphen-leading title is positional after `--`: + +```bash +loaf issue new --parent LOAF-42 --status backlog -- "--help is missing from the man page" +``` -## `loaf change init <slug> [--brief]` +A delivery body must state the problem and, before `loaf issue check` will pass, contain the substring `out of scope` (case-insensitive). A decision issue needs a sharp question (`?` in the title or body), not a body contract. -Scaffolds `docs/changes/<YYYYMMDD>-<slug>/` from the Change template, where `<YYYYMMDD>` is the creation day (not a target date) and the branch is named by the bare slug — no date prefix on the branch. Ordinary init writes `change.json + shape.md + tasks/`; `--brief` is capture mode (`change.json + brief.md` only). The slug uses lowercase letters, digits, and single hyphens. +## `loaf issue show` / `list` / `tree` / `frontier` -**Capture promotion.** Re-running ordinary `loaf change init <slug>` (no `--brief`) against a structurally valid capture-only folder completes it in place: `brief.md` and every `change.json` value are preserved verbatim, and missing `shape.md` plus the seeded `tasks/` are published atomically (temp-write then rename; existing destinations are never overwritten; `shape.md` is the last rename and the promotion marker). A partial promotion that already holds the byte-identical seed task resumes by filling only the gaps. Everything else fails clearly and leaves the folder untouched — repeated `--brief`, `change.json`-only (missing brief), hybrid `change.md` + `change.json`, diverged `tasks/` content, malformed metadata, and fully-materialized folders (duplicate rejection unchanged). +```text +loaf issue show <ref> [--json] +loaf issue list [--status <status>] [--kind delivery|decision] [--archived] [--started] [--json] +loaf issue tree [<ref>] [--archived] [--json] +loaf issue frontier [--json] +``` -## `loaf change check [folder] [--require-executable] [--json]` +`show` prints identity, parent, fog, body, definition of done, and children. `list` hides archived issues unless `--archived`. `--status` filters by `triage`, `backlog`, `todo`, `active`, `done`, `cancelled`, `duplicate`. `tree` prints from a ref, or the whole project when omitted. `frontier` lists non-archived `triage`/`backlog`/`todo` issues that are not blocked — derived at read time, useful when checking whether this work is already covered. -Folder resolution: an explicit `[folder]` argument always wins; otherwise the current git branch is matched against the `branch:` frontmatter across every `docs/changes/*/change.json + shape.md`. Zero or multiple matches is an error naming the candidates found. +Prefer `--json` when diagnosing rather than scraping the human-readable text. -Output splits into two tiers: +## `loaf issue edit` / `status` -- **Violations** — always fail (exit code 2), regardless of flags: status-like frontmatter keys (`readiness`, `status`, `state`) or values matching the canonical change-state vocabulary; frontmatter not opening the file at byte one; malformed `YYYYMMDD-slug` folder naming; identity mismatch between `change:`/`created:` and the folder name; missing Product Contract sections (Problem, Hypothesis, Scope, Observable Workflow, Rabbit Holes and No-Gos). -- **Derived executability** — reported, never gating by default. A Change is executable when Planning Contract, Implementation Units, Verification Contract, and Definition of Done are all present and non-empty (bracket placeholders and HTML comments don't count as content). A Change with open gaps is incomplete and non-executable; the report just says what's still missing. +```text +loaf issue edit <ref> [--body-file <path>|--body -|--message <text>] [--json] +loaf issue status <ref> <status> [--duplicate-of <ref>] [--json] +``` -`--require-executable` turns structural executability into a gate (exit code 1 if not structurally executable); it does not prove implementation completion. This is implement's preflight and CI's non-draft-PR check, not something shape itself passes during shaping. +`edit` **replaces** the body. Rewrite the full problem-plus-out-of-scope text; there is no patch form. `status` write-statuses (`triage`, `backlog`, `todo`, `active`, `done`) update in place; `cancelled` and `duplicate` archive through the remove path (`--duplicate-of` is required when status is `duplicate`). Shape leaves status at `triage` unless the user asks otherwise — shaped is derived, not a status. -A branch/Change mismatch (current branch doesn't match the Change's `branch:` field) is a warning, never a violation. +## `loaf issue dod` -`--json` emits `{command, folder, passed, state, executable, exitCode, findings, warnings, gaps}` (plus optional `layout`, `captured`, `notices`) for scripted reads; prefer it when diagnosing rather than scraping the human-readable text. The landing guard reads `state` from this envelope — e.g. `"captured"` for a brief-only folder and `"shaped"` (or higher) once `shape.md` exists — and must not invent a second state surface. +```text +loaf issue dod add <ref> <text> [--command <cmd>] [--expect <expect>] [--tier V|H] [--serves <parent-position>] [--json] +loaf issue dod list <ref> [--json] +loaf issue dod remove <ref> <position> [--json] +loaf issue dod claim <child> <child-position> <parent-position> [--json] +loaf issue dod unclaim <child> <child-position> <parent-position> [--json] +``` +V-tier is used when `--command` is present, otherwise H, unless `--tier` overrides. `--serves` records that the new child criterion claims that parent position. Positions are 1-based and compact after `remove`. Authoring guidance and the expect grammar live in the Decomposition topic. -## `loaf change report new <slug> --kind <kind>` +## `loaf issue promote <ref> <position> [--json]` -Stamps `reports/YYYYMMDD-HHMMSS-<kind>-<slug>.html` with charset, provenance, and token skeleton; prints design-language guidance. Closed kinds: approval, review, visual, audit, note. +Promotes the criterion at the 1-based position into a child **delivery** issue. The parent criterion stays in place. The child is minted in `triage` with a copy of the criterion and a claim already recorded, so coverage for that parent position holds by construction. -## `loaf change verify [folder]` +## `loaf issue check <ref> [--json] [--human <reason>]` -Runs executable V-tier criteria declared in `shape.md` and writes `receipts/verify.json` (criteria digest, verified commit, cwd, per-criterion evidence). New-layout-only. +Derives readiness from the issue row, not from markdown headings. -Criteria forms (both parse): +- **Delivery** — shaped when the body is nonempty (the problem), at least one criterion exists, and the body contains an explicit out-of-scope statement. Prints `issue <ref> is shaped` when ready. +- **Decision** — ready when the title or body contains `?`. Prints `issue <ref> is ready`. +- **Children present** — coverage is a failure (every parent criterion must be claimed). Containment is a report (every child criterion must claim a parent criterion); each orphan prints a ready-to-paste `loaf issue new --parent … --status backlog -- …` remedy. -```markdown -- **V1.** Prose. Command: `exact command`. Expect: exit 0. -- **V1.** Prose. - - Command: `exact command` - - Expect: exit 0 -``` +`--human <reason>` publishes ready-for-human instead of ready-for-agent when a tracker authority is configured. Shape's own gate is the derived verdict, not the publication. + +`--json` emits `{issue, kind, shaped, covered, ready, failures, orphans, …}`. Exit code 1 when not ready. -`Expect` is enforced, with a deliberately minimal grammar: atoms join with ` and ` — `exit <N>` is the required exit code (omit the atom, or `Expect` entirely, and `exit 0` is enforced) and `` contains `text` `` requires the command's combined stdout+stderr to contain that backtick-delimited text (repeatable). A criterion passes when the command ran, the exit code matched, and every `contains` matched; the receipt records each atom and its outcome. +## `loaf issue verify <ref> [--json]` -```markdown -- **V1.** Prose. Command: `go test ./...`. Expect: exit 0 and contains `ok github.com/acme/pkg`. +Runs the issue's V-tier criteria (`--command` plus `--expect`) from the repository root. Honors `exit <N>` and `` contains `text` ``. Writes nothing; exits non-zero on any failure. H-tier rows are skipped. This is implement's preflight, not shape's gate. + +A criterion passes when the command ran, the exit code matched, and every `contains` matched. Unenforceable expect clauses are warned and recorded as advisory — never quietly decorative. + +## `loaf issue link` / `bucket` + +```text +loaf issue link <from> blocks|relates-to <to> [--json] +loaf issue link <from> remove <type> <to> [--json] +loaf issue bucket <ref> now|next|later|none [--json] ``` -Any other clause is unenforceable: verify prints a warning naming the criterion and the clause, records it on the criterion as advisory, and never lets it affect the result — an expectation is either checked or loudly not. +Stored relationship types are `blocks` and `relates_to`. Use `blocks` for a real sequencing constraint; do not encode order in `loaf issue tree`. Buckets are labels only and are never read as a constraint. + +## `loaf issue render <ref> [--json]` + +Emits markdown suitable to paste as a PR body with no manual editing: title, body, definition-of-done checkboxes (checked only when status is `done`), and children. Nothing plan-shaped is committed; if a PR is opened, this output *is* the body. + +## `loaf release suggest` / `cut` + +Releases are retroactive. Shape does not bind an issue to a version. + +```text +loaf release suggest [--base <ref>] [--json] +loaf release cut [--base <ref>] [--bump <type>] [--includes <version|tag>] [--no-tag] [--no-gh] [--dry-run] +``` -Commands run from the repository root; the receipt records that cwd. H-tier entries (`**H1.** …`) are never gate input. See [decomposition.md](decomposition.md) for authoring guidance. +`suggest` reports landed work since the last version tag and writes nothing. `cut` records a release from landed work. Neither is a shaping step. -## `loaf change tasks` / `show` +## What shape does not run -On-demand projections. See `loaf change --help`. +`loaf issue start` / `stop` create and remove the issue worktree — implement's job, after the issue is shaped. `loaf issue export` dumps the project snapshot. Do not call them from this skill. diff --git a/dist/opencode/skills/shape/references/critique-gate.md b/dist/opencode/skills/shape/references/critique-gate.md index 4e131243c..f01109327 100644 --- a/dist/opencode/skills/shape/references/critique-gate.md +++ b/dist/opencode/skills/shape/references/critique-gate.md @@ -1,14 +1,15 @@ # Critique Gate -The last shaping step, before `loaf change check` and the PR offer. An agent won't know to interrogate its own scope, boundary placement, or smuggled-in status words unless something makes it stop and ask. Instantiated from the shape-first pilot's own Critique Gate, generalized for any Change rather than that pilot's specific CLI-surface question. +The last shaping step, before `loaf issue check` and any review offer. An agent won't know to interrogate its own scope, boundary placement, or smuggled-in progress words unless something makes it stop and ask. Run through these before finalizing: -- **Is scope still bounded?** Has the draft crept beyond what the Problem and Hypothesis justify? Could this Change be smaller and still deliver the Hypothesis? +- **Is scope still bounded?** Has the draft crept beyond what the problem statement justifies? Could this issue be smaller and still be verifiable in one fresh context window? - **Does every new command, state, or lifecycle verb name its ceremony?** If a command or state can't name the ceremony that exercises it, cut it — don't build it now and hope a use appears. -- **Is a status field creeping back in under another name?** `readiness`, `phase`, `stage`, or anything else that reintroduces a declared progress flag `loaf change check` doesn't already ban by pattern. +- **Is a second progress flag creeping into the body?** `readiness`, `phase`, `stage`, or anything else that reintroduces a declared progress flag. Status lives on the issue row (`loaf issue status`). Shaped, covered, and ready are derived by `loaf issue check`. `loaf issue bucket` is a label only and is never read as a constraint. - **Is the CLI/skill boundary drawn correctly?** Is the skill doing deterministic work that belongs in the CLI, or is the CLI claiming judgment that belongs in the skill? -- **Which Verification Contract criteria are genuinely executable gates, and which are human review dressed up as automatable?** A criterion that can't disagree with the implementation isn't a gate. -- **Are the Rabbit Holes and No-Gos sections doing real work?** Or are they restating the Scope's Out list in different words? +- **Which criteria are genuinely executable gates, and which are human review dressed up as automatable?** A V-tier criterion needs `--command` (and `--expect` when exit 0 is not enough). A criterion that can't disagree with the implementation isn't a gate — make it H, or rewrite it against an independent source of truth. +- **Is the out-of-scope statement doing real work?** Rabbit holes and no-gos belong there as named exclusions. Restating the problem in different words is not an out-of-scope statement. +- **Did fog graduate, or evaporate?** Every named unknown is parked in create-time `fog`, held in the session register, sitting as a decision child (or sibling) with a sharp question, filed as a new backlog issue (a different problem), or written into the body as a decided answer. Silent deletion is the failure. -Answers that change the document go back into it — the Decisions log, the Planning Contract, or the relevant Product Contract section — before moving to `loaf change check`. An answer spoken but not written doesn't count. +Answers that change the issue go back into it — `loaf issue edit` for the body, `loaf issue dod add` / `remove` for criteria, `loaf issue promote` or `loaf issue new` for children — before moving to `loaf issue check`. An answer spoken but not written doesn't count. diff --git a/dist/opencode/skills/shape/references/decomposition.md b/dist/opencode/skills/shape/references/decomposition.md index 7deef4b13..b6839482b 100644 --- a/dist/opencode/skills/shape/references/decomposition.md +++ b/dist/opencode/skills/shape/references/decomposition.md @@ -1,53 +1,119 @@ # Decomposition -Shaping step absorbed from the retired breakdown skill: dependency awareness, granularity judgment, and acceptance-criteria thinking, now expressed as the Change's Implementation Units and Verification Contract instead of a separate task-minting pass. +Shaping's tail, not a separate ceremony: dependency awareness, granularity judgment, and acceptance-criteria thinking, expressed as definition-of-done criteria and — only when a criterion earns its own DoD — child issues created by `loaf issue promote`. -## What Survives +## Contents +- When to split +- The sizing rule +- Expand–contract +- Promote, don't mint +- Coverage and containment +- Authoring criteria +- Order by likelihood-of-change +- Own the decisions -- **The Right Size Test** — before finalizing a unit boundary, check: Can a single implementer complete this? If no, split by concern. Does it touch multiple unrelated concerns? If yes, split by concern. Will the agent need too much context? If yes, split into smaller coherent units. Are you splitting just to have more units? If yes, merge back. -- **Right-sizing rules** — one agent type per unit (completable by a single implementer), one concern per unit (one layer, service, or component), context-appropriate (fits in model context with room for exploration), not over-fragmented (don't split what naturally belongs together). -- **Per-unit verification discipline** — every unit includes its own observable done condition. Never a separate "verify" unit; keep tests with the code they test. -- **Own the decisions** — decide granularity and unit boundaries autonomously. Ask the user only when two orderings are genuinely equally valid with different trade-offs; otherwise decide and move on. +## When to split -## What Dies +A parent gets children only when its DoD needs more than one coherent slice. One criterion that is already right-sized stays on the parent. A checkbox becomes a sub-issue the moment it earns its own DoD — its own problem statement, its own out-of-scope, its own criteria. -Task-file minting, ID allocation, estimate fields, and ordering-for-execution as the default presentation. Implementation Units are in-document work packets — commit-boundary guides and review anchors — never tracked entities with IDs, statuses, or a persistence layer of their own. +Same problem, another slice → another criterion on this issue, or a promoted child if that slice now has its own DoD. A different problem discovered mid-shaping → a new backlog issue (`loaf issue new --status backlog`), not a child of this one. -## The New Principle: Order by Likelihood-of-Change +## The sizing rule -Breakdown ordered units for execution (dependency graph, then priority). Shaping orders units for review: data models, interfaces, and user-facing flows lead; mechanical refactors and boilerplate collapse at the bottom. The reviewer's attention at the draft-to-ready flip should land on what's most likely to need changing, not on whatever happens to run first. +One test, replacing the old four-question checklist: **a slice is right-sized when it fits one fresh context window and is verifiable alone.** -Sequencing constraints that genuinely exist — this unit must land before that one — are stated in prose within the unit description or a Planning Contract subsection. Never rely on list order alone to imply a dependency; a reviewer skimming units by likelihood-of-change won't read sequencing into position. +- If an implementer cannot pick the issue up in a new conversation and finish it without reading a sibling, split. +- If the done-check cannot run (or be reviewed) without another slice landing first, either split and `loaf issue link <predecessor> blocks <successor>`, or merge — do not leave a criterion that is only true in combination. +- If you are splitting just to have more rows, merge back. -## Authoring the Verification Contract +Per-slice verification stays with the slice. Never a separate "verify" child; keep tests with the code they test. -Split criteria into two groups, mirroring the Change template: +## Expand–contract -- **Executable (V-tier)** — bound to a command and an expected result; machine-checkable by `loaf change verify`. Two equivalent forms: +The named exception for wide mechanical refactors (a rename, a schema migration, an expand-then-remove of an old path). The slice may be wide in files touched and still be one issue, because it is one coherent mechanical motion and one verification: the suite still passes after the motion. - Inline (what the scaffold writes): +Do not use this exception to smuggle a second problem into the parent. A mechanical rename plus a behavior change is two slices. - ```markdown - - **V1.** What must be true. Command: `go test ./...`. Expect: exit 0. - - **V2.** Output-bound. Command: `loaf change check`. Expect: exit 0 and contains `executable`. - ``` +## Promote, don't mint - Or with an authoring checkbox still open: +```bash +loaf issue promote <ref> <position> +``` - ```markdown - - [**V1.** What must be true. Command: `go test ./...`. Expect: exit 0.] - ``` +The criterion at the 1-based position stays on the parent. A child **delivery** issue is created in `triage`, titled from the criterion text, with a copy of that criterion as its first DoD line and a claim already recorded from the copy to the parent. Coverage for that parent position holds by construction. + +Then shape the child: give it a problem body and an out-of-scope statement (`loaf issue edit` replaces the body), add the criteria that make *its* DoD complete, promote again if one of those earns its own DoD. + +`loaf issue promote` always mints a delivery child. Decision children are created separately: + +```bash +loaf issue new --kind decision --parent <ref> "Should the store be append-only?" +``` + +A decision child is ready when the title or body contains `?`. It does not claim a parent criterion. + +Once **any** child exists — delivery or decision — `loaf issue check` requires every parent criterion to be claimed. A leaf parent that grows a decision child without promoted (or otherwise claimed) criteria will fail coverage. Sequence the tail as: write DoD → promote every slice that will not execute on the parent → then add decision children. If the parent stays a leaf, leave remaining unsharp questions in create-time `fog` (there is no `--fog` on edit) or file sharpened ones as sibling decision issues (`loaf issue new --kind decision --status backlog`, no `--parent`). - Sub-bullet: +Manual claims, when a child criterion was added rather than promoted: - ```markdown - - **V1.** What must be true. - - Command: `go test ./...` - - Expect: exit 0 +```bash +loaf issue dod add <child> "Child done-check" --serves 1 +loaf issue dod claim <child> <child-position> <parent-position> +loaf issue dod unclaim <child> <child-position> <parent-position> +``` + +`--serves` claims the newly added child criterion against that parent position. `claim` / `unclaim` retarget an existing pair. + +## Coverage and containment + +`loaf issue check <ref>` runs these only when the issue has children. + +- **Coverage** (failure) — every parent criterion must be claimed by at least one child criterion. An uncovered position is named in the failure list; fix it by promoting that criterion or adding a claiming child criterion. +- **Containment** (report, not a failure) — every child criterion must claim a parent criterion. An orphan is printed with a ready-to-paste remedy that files it as a **sibling** backlog issue: + + ```bash + loaf issue new --parent '<parent>' --status backlog -- '<orphan text>' ``` - `Expect` is optional and enforced when present, with a deliberately minimal grammar: atoms join with ` and ` — `exit <N>` is the required exit code (an absent `Expect`, or an `Expect` with no exit atom, means `exit 0`) and `` contains `text` `` requires the combined stdout+stderr to contain that backtick-delimited text (repeatable). Any other clause is unenforceable: `loaf change verify` warns naming the criterion and the clause and records it as advisory, so an expectation is either checked or loudly not — never quietly decorative. Commands run from the **repository root** (never the change folder). Only V-entries that declare a fenced `Command:` value are gate input. + Run the printed remedy. Do not fold the orphan back onto the parent, and do not treat it as in-scope work that somehow escaped the DoD — it is a new backlog row under the same parent. + +A different problem (not an orphan criterion) is not a sibling of this decomposition. File it as a new backlog issue with no `--parent`. -- **Human review (H-tier)** — what a reviewer confirms that no command can. H-entries are review material and are **never** gate input; `loaf change verify` ignores them. +## Authoring criteria + +```bash +loaf issue dod add <ref> <text> [--command <cmd>] [--expect <expect>] [--tier V|H] [--serves <parent-position>] +``` + +- **V-tier** — used when `--command` is present, unless `--tier` overrides. Machine-checkable by `loaf issue verify <ref>`. Commands run from the **repository root**. +- **H-tier** — default when `--command` is absent. Human review; never gate input. `loaf issue verify` skips H-tier rows. + +`--expect` is optional and enforced when present, with a deliberately minimal grammar: atoms join with ` and ` — `exit <N>` is the required exit code (an absent `--expect`, or one with no exit atom, means `exit 0`) and `` contains `text` `` requires the combined stdout+stderr to contain that backtick-delimited text (repeatable). Any other clause is unenforceable: verify warns naming the criterion and the clause and records it as advisory, so an expectation is either checked or loudly not — never quietly decorative. + +```bash +loaf issue dod add LOAF-42 "Package tests pass" --command "go test ./..." --expect "exit 0" +loaf issue dod add LOAF-42 "Check names the uncovered criterion" --command "loaf issue check LOAF-42" --expect "exit 0 and contains \`uncovered\`" +loaf issue dod add LOAF-42 "The writeup is readable by someone new to the area" --tier H +``` A criterion whose check only restates the implementation (recomputing the expected value the way the code does) is vacuous — it can never disagree with the code under test. Prefer criteria with an independent source of truth. + +`loaf issue dod list <ref>` prints the current lines. `loaf issue dod remove <ref> <position>` removes one (positions then compact). + +## Order by likelihood-of-change + +Present children for review, not for execution order: data models, interfaces, and user-facing flows lead; mechanical refactors and boilerplate collapse at the bottom. The reviewer's attention should land on what's most likely to need changing, not on whatever happens to run first. + +Sequencing constraints that genuinely exist — this child must land before that one — are recorded as relationships: + +```bash +loaf issue link <from> blocks <to> +loaf issue link <from> relates-to <to> +loaf issue link <from> remove blocks <to> +``` + +Never rely on `loaf issue tree` order to imply a dependency; a reviewer skimming by likelihood-of-change won't read sequencing into position. `loaf issue bucket <ref> now|next|later|none` is an advisory label only and is never read as a constraint. + +## Own the decisions + +Decide granularity and slice boundaries autonomously. Ask the user only when two orderings are genuinely equally valid with different trade-offs; otherwise decide and move on. diff --git a/dist/opencode/skills/shape/references/grilling.md b/dist/opencode/skills/shape/references/grilling.md index cd4d6a575..4cf6437dd 100644 --- a/dist/opencode/skills/shape/references/grilling.md +++ b/dist/opencode/skills/shape/references/grilling.md @@ -1,8 +1,8 @@ # Grilling -The interview technique for `[KU]` entries — known unknowns precise enough to phrase as a question. Imported from the reviewed `grill-me` / `grill-with-docs` pattern (`docs/changes/20260704-shape-first-change-workflow/research/mattpocock-review/`), sharpened with the Field Guide's architectural-impact ordering. +The interview technique for `[KU]` entries — known unknowns precise enough to phrase as a question. Imported from the reviewed `grill-me` / `grill-with-docs` pattern, sharpened with architectural-impact ordering. -This is a distinct, shape-specific technique — not the shared `templates/grilling.md` used by `architecture` and `refactor-deepen`. That template's glossary-mutation duty doesn't fit shape's ambiguity-resolving step (see the deferral rationale it documents); shape's grilling stays scoped to fog entries and never writes to the domain glossary. +This is a distinct, shape-specific technique — not the shared `templates/grilling.md` used by `architecture` and `refactor-deepen`. That template's glossary-mutation duty doesn't fit shape's ambiguity-resolving step (see the rationale it documents); shape's grilling stays scoped to fog entries and never writes to the domain glossary. ## The Mechanic @@ -14,17 +14,21 @@ Every question carries a recommended answer with rationale — never "what do yo Prioritize questions whose answer would change the architecture. Cosmetic questions — naming, ordering, presentation — go last, even when they're easier to answer. An architecture-changing answer received late can invalidate everything decided in between; asking it first avoids that rework. -Before asking, check whether reading resolves the question — an existing ADR, a prior Change, a journal entry. Only ask what reading couldn't answer. +Before asking, check whether reading resolves the question — an existing ADR, a prior issue, a journal entry. Only ask what reading couldn't answer. ## Stop Condition Stop when either holds: - No unrouted `[KU]` entries remain. -- Answers stop changing the contract — the last several questions confirmed direction rather than altering it. +- Answers stop changing the issue — the last several questions confirmed direction rather than altering the body, the criteria, or the children. -Grilling never opens the session. Context gathering and the blindspot-pass offer come first; an interview without that groundwork asks questions the codebase or journal could have answered for free. +Write each accepted answer into the issue as it lands: `loaf issue edit` for the body, `loaf issue dod add` for a new done-check, `loaf issue new --kind decision --parent <ref>` when the answer is itself a sharp question that still needs a later call. Do not leave a resolved `[KU]` only in the conversation. ## Mid-Interview Reroute If a question turns out to need domain fluency the shaper doesn't have — the follow-up can't even be phrased — stop grilling it and route the entry to the blindspot pass instead of guessing at an answer. + +## Opening + +Grilling never opens the session. Context gathering and the blindspot-pass offer come first; an interview without that groundwork asks questions the codebase or journal could have answered for free. diff --git a/dist/opencode/skills/shape/references/reaction-artifact.md b/dist/opencode/skills/shape/references/reaction-artifact.md index d3254bed0..ca30f7485 100644 --- a/dist/opencode/skills/shape/references/reaction-artifact.md +++ b/dist/opencode/skills/shape/references/reaction-artifact.md @@ -8,11 +8,11 @@ Before building anything, state precisely what this artifact must let the user d ## Build the Smallest Thing That Lets the User React -Construct the minimum artifact that resolves the named unknown — competing layouts, a comparison of approaches, a rendered mock. Write it into the Change's own `research/` folder, never `.agents/reports/`; this is shape's own technique, executed in-session, not a handoff to the research skill. +Construct the minimum artifact that resolves the named unknown — competing layouts, a comparison of approaches, a rendered mock. Keep it in the session or in a throwaway file you will discard. This is shape's own technique, executed in-session, not a handoff to the research skill and not a committed plan document. Do not write it into `.agents/reports/`. ## Capture the Answer, Discard the Shell -Once the user reacts and picks, write the decision — with rationale — back into the Change: the Decisions log, or the relevant Planning Contract subsection. The artifact itself is not the deliverable; the choice it produced is. Discard or archive the shell once the answer is captured. If a piece of logic proved out along the way is worth keeping (a validated approach, not just a throwaway shell), note that explicitly — but don't let scaffolding survive under the pretense that it's production code. +Once the user reacts and picks, write the decision — with rationale — back onto the issue: the body via `loaf issue edit`, or a `--kind decision` child when the pick is itself a sharp question that still needs a later call. The artifact itself is not the deliverable; the choice it produced is. Discard the shell once the answer is captured. If a piece of logic proved out along the way is worth keeping (a validated approach, not just a throwaway shell), note that explicitly in the issue body — but don't let scaffolding survive under the pretense that it's production code. ## Never a Deliverable diff --git a/dist/opencode/skills/shape/templates/brief.md b/dist/opencode/skills/shape/templates/brief.md deleted file mode 100644 index 05016c8c8..000000000 --- a/dist/opencode/skills/shape/templates/brief.md +++ /dev/null @@ -1,42 +0,0 @@ -<!-- brief.md is the optional archeological kickstart — the original unshaped ask. - May accrete parked problem-space concepts while the change is captured; freezes when shape.md exists. - Superseded by shape.md; never mechanically load-bearing. - A brief-only folder is legal and non-executable (captured, not shaped). --> - -# [Brief title] - -## Problem Statement - -[What friction, gap, or unmet need exists? Be specific about the pain — vague problems produce vague solutions. Problem-space only; do not design the approach here.] - -## Who Has It - -[Who experiences this problem? Role, context, and how often the pain shows up. Avoid unqualified "users" or "developers."] - -## Current Alternatives - -[What do they do today? Existing tools, manual workarounds, or "nothing" are all valid. Understanding the status quo clarifies what better means.] - -## Value Proposition - -[Why is solving this worth it? What becomes true for the people who have the problem if this lands? Describe value, not features or architecture.] - -## Constraints - -[Non-negotiable bounds: technical, legal, organizational, or philosophical. Things that limit the solution space before design begins.] - -- [Constraint 1] - -## Sequencing and Relationships - -[How this relates to other work — series order, release cohort, dependencies stated as prose. No machine relation fields; narrative order only.] - -## Sources and Research Links - -[Evidence that informed this framing — competitive scans, research notes, issue links, prior art. Link rather than paste.] - -## Open Questions - -[Unresolved problem-space items. Mark urgency: blocking (must resolve before shaping) or deferrable.] - -- [ ] [Question] — blocking | deferrable diff --git a/dist/opencode/skills/shape/templates/change.md b/dist/opencode/skills/shape/templates/change.md deleted file mode 100644 index 6ce5b9725..000000000 --- a/dist/opencode/skills/shape/templates/change.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -change: [slug] -created: [YYYY-MM-DD] -branch: [slug] ---- - -<!-- Frontmatter must open the file at byte one — parsers depend on it. No status-like frontmatter (readiness/status/state): readiness is derived — a draft PR is shaping; `loaf change check` derives structural executability from the sections below. --> - -# [Change Title] - -## Problem - -[Why this work exists — the friction, gap, or rot being addressed.] - -## Hypothesis - -[The bet: what becomes true if this ships, and why it is worth making.] - -## Scope - -**In** - -- [What this Change delivers.] - -**Out** (deferred, not rejected) - -- [What is explicitly postponed, and where it went.] - -**Cut** (explicitly rejected) - -- [What this Change will not do, ever.] - -## Observable Workflow - -[What someone sees or does once this ships — commands, flows, UX. Concrete over abstract.] - -## Rabbit Holes and No-Gos - -[Boundaries: the ways this work could quietly grow into something it must not become.] - -## Decisions - -Provenance: [how each decision was accepted — interview, review, dogfooding.] - -1. **[Decision.]** [Rationale, and what it forecloses.] - -## Planning Contract - -[The HOW. Free-form `###` subsections named by the work — the container is the contract; the subsection names are yours.] - -### [Approach / Placement / Risks / Sequencing / Spike findings …] - -[...] - -## Implementation Units - -<!-- In-document work packets — commit-boundary guides and review anchors, not tracked entities. --> - -- [**U1 — Unit name.** What it delivers.] - -## Verification Contract - -<!-- Executable (machine-checkable): --> - -- [**V1.** Criterion bound to a command and an expected result.] - -<!-- Human review: --> - -- [**H1.** What a reviewer confirms that no command can.] - -## Definition of Done - -- [Derived from gates and review — never a status flag.] - -## Durable Outputs - -[Specs, ADRs, knowledge docs, or schema updates to create after implementation proves what is now true. A final spec describes reality, not a plan.] - -## Open Questions - -<!-- Fog register: tag entries [KU]/[UK]/[UU] with a route — see the shape skill's quadrant table. Tags are convention, never parsed by check. --> - -- [Known unknowns, each owned by a section, a spike, or a follow-up.] - -## Source Inputs - -- [Where this Change came from: journal entries (cite by ID), sparks, ideas, brainstorms, issues, conversations, prior Changes.] - -<!-- Optional sections, added when they earn their place: Background, Success Metrics (when validation matters), Follow-ups, Critique Gate. --> diff --git a/dist/opencode/skills/shape/templates/design.md b/dist/opencode/skills/shape/templates/design.md deleted file mode 100644 index 2244ccdb6..000000000 --- a/dist/opencode/skills/shape/templates/design.md +++ /dev/null @@ -1,20 +0,0 @@ -<!-- design.md is the optional design surface for UI, protocol, or schema detail - that would crowd shape.md or plan.md. Accretive; not load-bearing for the gate. --> - -# Design — [Change Title] - -## Intent - -[What this design clarifies that shape.md does not.] - -## Surfaces - -[Screens, APIs, schemas, or protocols under design.] - -## Decisions - -1. **[Decision.]** [Rationale.] - -## Open questions - -- [What remains undecided.] diff --git a/dist/opencode/skills/shape/templates/plan.md b/dist/opencode/skills/shape/templates/plan.md deleted file mode 100644 index 6d17a7179..000000000 --- a/dist/opencode/skills/shape/templates/plan.md +++ /dev/null @@ -1,21 +0,0 @@ -<!-- plan.md is the optional technical route (the corpus's plan sense). - Accretive during shaping; approach churn here never expires a cohort receipt. - Criteria live in shape.md — do not relocate Verification Contract here. --> - -# Plan — [Change Title] - -## Approach - -[How the work lands — architecture, sequencing, compatibility.] - -## Placement - -[Where code and docs live; what stays out of scope for this route.] - -## Risks - -[Failure modes and the safe failure direction.] - -## Sequencing - -[Ordered slices that leave main coherent at each landing.] diff --git a/dist/opencode/skills/shape/templates/pr.md b/dist/opencode/skills/shape/templates/pr.md deleted file mode 100644 index 91b4b7401..000000000 --- a/dist/opencode/skills/shape/templates/pr.md +++ /dev/null @@ -1,25 +0,0 @@ -<!-- Draft = still shaping. Ready for review = structurally executable, not proof of implementation completion. --> - -## Change - -<!-- Link the Change folder this PR advances: docs/changes/YYYYMMDD-slug/ The Change artifact lives in this PR's diff — the draft PR is its shaping surface. During coexistence, legacy PRs implementing a numbered spec link the .agents/specs/ path here instead. Delete this section for PRs tied to neither. --> - -## What & Why - -<!-- What this PR does and the problem it solves. For a shaping (draft) PR, summarize the direction; for an implementation PR, summarize the delta. --> - -## Review focus - -<!-- Where reviewer attention pays off: decisions to challenge, boundaries to verify, criteria to confirm. --> - -## Verification - -<!-- What proves this works: gates run (`loaf change check`, `loaf check`), tests, commands a reviewer should re-run locally. On a draft, state what remains open instead. --> - -## Migration / breaking changes - -<!-- If this PR changes user-facing behavior, document the migration step. Otherwise: "None." --> - -## Deferred - -<!-- Intentionally out of scope, and where it went (follow-up Change, open question, issue). Delete if nothing was deferred. --> diff --git a/dist/opencode/skills/shape/templates/shape.md b/dist/opencode/skills/shape/templates/shape.md deleted file mode 100644 index 59aa9b89a..000000000 --- a/dist/opencode/skills/shape/templates/shape.md +++ /dev/null @@ -1,77 +0,0 @@ -<!-- shape.md is the change contract. Identity lives in change.json — no status-like frontmatter. Readiness is derived: a draft PR is shaping; `loaf change check` derives structural executability from the sections below. --> - -# [Change Title] - -## Problem - -[Why this work exists — the friction, gap, or rot being addressed.] - -## Hypothesis - -[The bet: what becomes true if this ships, and why it is worth making.] - -## Scope - -**In** - -- [What this Change delivers.] - -**Out** (deferred, not rejected) - -- [What is explicitly postponed, and where it went.] - -**Cut** (explicitly rejected) - -- [What this Change will not do, ever.] - -## Observable Workflow - -[What someone sees or does once this ships — commands, flows, UX. Concrete over abstract.] - -## Rabbit Holes and No-Gos - -[Boundaries: the ways this work could quietly grow into something it must not become.] - -## Decisions - -Provenance: [how each decision was accepted — interview, review, dogfooding.] - -1. **[Decision.]** [Rationale, and what it forecloses.] - -## Planning Contract - -<!-- The HOW. Prefer plan.md/design.md when the route needs its own file; keep this container. Free-form ### subsections named by the work. --> - -### [Approach / Placement / Risks / Sequencing …] - -[…] - -## Implementation Units - -<!-- Task packets live in tasks/TASK-NNN-slug.md; this section may summarize the decomposition. --> - -- [**TASK-001 — Unit name.** What it delivers.] - -## Verification Contract - -<!-- Executable (machine-checkable): each V-entry declares Command and Expect for loaf change verify. Expect is a grammar, not prose: atoms join with " and " — `exit <N>` is the required exit code (omit the atom, or Expect entirely, for exit 0; a second exit atom is a contradiction and fails the criterion) and contains `text` requires the combined stdout+stderr to contain that backtick-delimited text (repeatable). Example: Expect: exit 0 and contains `all green`. Any other clause is unenforceable: verify warns naming the criterion and clause, records it as advisory, and never checks it. --> - -- [**V1.** What must be true. Command: `exact command`. Expect: exit 0.] - -<!-- Human review (H-tier): review material, never gate input. --> - -- [**H1.** What a reviewer confirms that no command can.] - -## Definition of Done - -- [Derived from gates and review — never a status flag.] - -## Durable Outputs - -[Specs, ADRs, knowledge docs, or schema updates to create after implementation proves what is now true.] - -## Open Questions - -<!-- Fog register: tag entries [KU]/[UK]/[UU] with a route. Tags are convention, never parsed by check. --> - -- [KU] [Known unknown → route to a task or later change] diff --git a/dist/opencode/skills/shape/templates/task.md b/dist/opencode/skills/shape/templates/task.md deleted file mode 100644 index 05de15307..000000000 --- a/dist/opencode/skills/shape/templates/task.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -change: [slug] -id: TASK-NNN -title: [short title] -# Relations (closed set; targets are TASK-NNN within this change only): -# parent: TASK-NNN -# blocks: -# - TASK-NNN -# blocked-by: -# - TASK-NNN -# relates-to: -# - TASK-NNN ---- - -# TASK-NNN — [Title] - -## Objective - -[What this task delivers when its checkboxes are flipped.] - -## Scope boundaries - -**In:** [What this task may touch.] - -**Out:** [What this task must not touch — other tasks, other changes, deferred work.] - -## Context pointers - -- Contract: `shape.md` — [relevant sections] -- Research: [paths under research/ when cited] - -## Acquisition - -```bash -loaf journal log "skill(implement): TASK-NNN — [short intent]" -# [commands or files to load before editing] -``` - -## Steps - -- [ ] [Atomic step — one commit's worth when flipped with the delivering work] -- [ ] [Next step] - -## Verification - -- [Commands or checks that prove this task alone] -- The slug never cites other work units (`TASK-…`, `SPEC-…`, issue keys) — identity is local; provenance is in frontmatter and the change folder. diff --git a/dist/opencode/skills/ship/SKILL.md b/dist/opencode/skills/ship/SKILL.md index 3b645f200..16c112267 100644 --- a/dist/opencode/skills/ship/SKILL.md +++ b/dist/opencode/skills/ship/SKILL.md @@ -1,17 +1,20 @@ --- name: ship description: >- - Reviews, verifies, and lands one pull request. Use when the user says "ship - it," "merge this PR," "ready to merge," "land this branch," or asks for a - final merge gate. Produces a reviewed, squash-merged PR and post-merge - cleanup. Not for version bumps, tags, GitHub Releases, or install verification - (use release). + Reviews, verifies, and lands one pull request — the sole quality gate before + work can appear in a later release cut. Use when the user says "ship it," + "merge this PR," "ready to merge," "land this branch," or asks for a final + merge gate. Binds the PR to an issue: the body is `loaf issue render` output, + definition-of-done criteria are the review checklist, and landing marks the + issue done and stops its worktree. Produces a reviewed, squash-merged PR and + post-merge cleanup. Not for version bumps, tags, GitHub Releases, or install + verification (use release). version: 0.2.21 --- # Ship -Review, verify, and land one PR. Shipping is the PR gate; releasing is the version-publication gate. +Review, verify, and land one PR. Ship's review is the quality gate for everything that will ever ship. Releases are retroactive — they cut a version from already-landed work. Nothing between merge and the next release cut re-checks the diff, the tests, or the issue. A rubber-stamped PR ships in the next cut with no second net. ## Contents - Critical Rules @@ -21,7 +24,7 @@ Review, verify, and land one PR. Shipping is the PR gate; releasing is the versi - Context Detection - Step 1: PR Readiness - Step 1b: Stacked PR Detection -- Step 2: Evidence Review +- Step 2: Definition-of-Done Review - Step 3: Local Verification - Step 4: Squash Merge - Step 5: Post-Merge Cleanup @@ -35,64 +38,94 @@ Review, verify, and land one PR. Shipping is the PR gate; releasing is the versi ## Critical Rules -- **Ship is not release** -- do not bump versions, create tags, publish GitHub Releases, or verify package installation here. -- **Keep PR quality local** -- smaller PRs are welcome, but ship must still verify correctness before merge. -- **Detect-first** -- auto-detect the PR from the current branch before asking for a PR number. -- **Review before merge** -- inspect code, docs, tests, changelog, PR body, and CI state before approval. -- **Never merge without explicit confirmation** -- present the PR, checks, findings, and squash body first. -- **Detect the stack before merging** -- another open PR may use this PR's head branch as its base. Find out before merge, never after, and never delete a head branch while a child PR still points at it. -- **Clean squash body** -- write an intentional squash commit body; never accept the automatic commit dump. -- **Keep landed and released distinct** -- after merge, describe the PR as landed or shipped, not necessarily released. -- **Log shipping** -- after merge, run `loaf journal log "decision(ship): PR #N landed via squash merge"`. +1. **Log invocation first** — `loaf journal log "skill(ship): shipping <ref or PR or current branch>"` before doing anything else. After merge, log `loaf journal log "decision(ship): PR #N landed via squash merge; <ref> done"`. +2. **Rigor is load-bearing** — this review is the only quality gate. Releases cut from landed work; they do not re-check. If the review is thin, the next cut still publishes it. +3. **Ship is not release** — do not bump versions, create tags, publish GitHub Releases, or verify package installation here. Use the release skill for that. +4. **Bind the PR to an issue** — the PR body is `loaf issue render <ref>` (paste-ready, no manual editing). The issue's definition-of-done criteria are the review checklist. `loaf issue verify <ref>` runs the executable (V-tier) rows and writes nothing. Landing means `loaf issue status <ref> done`. Then `loaf issue stop <ref>` removes the started worktree. +5. **Detect-first** — auto-detect the PR from the current branch, and the issue from `$ARGUMENTS` or the started workspace, before asking for a PR number or issue ref. +6. **Review before merge** — inspect code, docs, tests, changelog, the rendered issue body, definition of done, and CI state before approval. +7. **Never merge without explicit confirmation** — present the PR, checks, review notes, and squash body first, using your harness's structured question tool if it has one. +8. **Detect the stack before merging** — another open PR may use this PR's head branch as its base. Find out before merge, never after, and never delete a head branch while a child PR still points at it. +9. **Clean squash body** — write an intentional squash commit body; never accept the automatic commit dump. +10. **Keep landed and released distinct** — after merge, describe the PR as landed or shipped. It is not released until release publishes a version. + +--- ## Verification +- Invocation is logged to the project journal before review work begins - PR identity, base branch, and head branch are confirmed +- The PR is bound to one issue; `loaf issue show <ref>` is the issue surface +- PR body matches `loaf issue render <ref>` with no manual editing +- Every definition-of-done criterion was reviewed against the diff; H-tier by reading, V-tier by `loaf issue verify <ref>` (writes nothing; exit non-zero blocks) - CI status is passing or the user explicitly accepts named non-blocking checks - Relevant local checks pass or failures are fixed before merge - PR body and durable docs do not overclaim relative to the diff - Squash commit title/body are clean, conventional, and user-facing - Child PRs stacked on this PR's head branch are enumerated before merge, and each is retargeted, rebased, and re-verified after it -- Base branch is updated after merge and the feature branch cleanup state is known +- After merge: `loaf issue status <ref> done`, then `loaf issue stop <ref>` if a worktree was started, base branch updated, feature-branch cleanup state known +- A `decision(ship)` journal entry records the landing ## Quick Reference | Step | Gate | Blocking? | |------|------|-----------| +| Context Detection | PR and issue bound | Yes | | PR Readiness | PR exists, target base known, CI state reviewed | Yes | | Stacked PR Detection | child PRs on this head branch are enumerated | Yes | -| Evidence Review | findings resolved or explicitly accepted | Yes | -| Local Verification | relevant checks pass | Yes | +| Definition-of-Done Review | every criterion reviewed; `loaf issue verify` passes or reports no V-tier rows | Yes | +| Local Verification | relevant project checks pass | Yes | | Squash Merge | user approves body text | Yes | -| Cleanup | base pulled, children retargeted and rebased, branch deletion handled | Yes when a child exists | -| Release Suggestion | enough landed work may justify release | No | +| Cleanup | issue marked done, started worktree stopped, base pulled, children retargeted and rebased, branch deletion handled | Yes when a child PR exists; done + stop always | +| Release Suggestion | enough landed work may justify a later cut | No | ## Topics | Topic | Use When | |-------|----------| -| [Context Detection](#context-detection) | Determining current branch and PR state | +| [Context Detection](#context-detection) | Binding the current branch to a PR and an issue | +| [Definition-of-Done Review](#step-2-definition-of-done-review) | Using issue criteria as the merge checklist | | [Hook Interaction](#hook-interaction) | Understanding coexistence with git hooks | --- ## Context Detection -Before anything, detect the PR surface: +Log the invocation, then detect the PR and the issue. + +### PR 1. Get current branch and repo default branch: ```bash git branch --show-current gh repo view --json defaultBranchRef -q .defaultBranchRef.name ``` -2. Parse `$ARGUMENTS`: may be a PR number, PR URL, branch name, or empty. -3. If `$ARGUMENTS` is empty, auto-detect from the current branch: +2. Parse `$ARGUMENTS`: may be an issue ref (`LOAF-42`), a PR number, a PR URL, a branch name, or empty. +3. If `$ARGUMENTS` is empty or is not a PR identity, auto-detect from the current branch: ```bash gh pr view --json number,title,url,headRefName,baseRefName,state,mergeStateStatus,isDraft ``` 4. If no PR exists for the current branch, stop and offer to create one via `git-workflow` rather than silently merging a branch. 5. If already on the default branch, stop. There is no PR to ship from the current branch. -6. Confirm PR identity with the user before merge actions. + +### Issue + +Issue commands require initialized SQLite state. Bind exactly one issue: + +1. If `$ARGUMENTS` (or a remaining token) is an issue ref, load it: + ```bash + loaf issue show <ref> + ``` +2. Otherwise match the PR's `headRefName` to a started workspace: + ```bash + loaf issue list --started + ``` + Columns are alias, title, `started_branch`, `started_worktree`. The started branch from `loaf issue start` is `issue/<alias-or-id>` in lowercase (`issue/loaf-42`), disambiguated with an id suffix when that name is already claimed. +3. Confirm with `loaf issue show <ref>` — `started_branch` / `started_worktree` should match this PR when the issue was started. + +If nothing binds, stop and ask for the issue ref. Do not invent a row during ship. If `loaf issue show` reports the issue archived (`cancelled` or `duplicate`), stop. + +Confirm PR identity and the bound issue with the user before merge actions. --- @@ -104,6 +137,14 @@ Inspect the PR's declared state: gh pr view <N> --json number,title,body,url,headRefName,baseRefName,state,isDraft,mergeStateStatus,reviewDecision,statusCheckRollup ``` +The body must be `loaf issue render <ref>` output — title, issue body, definition-of-done checkboxes (checked only when status is already `done`), and children. No project headers, no hand-edited summary. If the live PR body differs, replace it: + +```bash +gh pr edit <N> --body "$(loaf issue render <ref>)" +``` + +Do not rewrite the markdown by hand. Checkboxes are unchecked until `loaf issue status <ref> done`; do not tick them in the PR body to fake completion. + Block or pause when: - PR is draft @@ -124,20 +165,27 @@ Before merging anything, find out whether another open PR uses this PR's head br gh pr list --state open --base <headRefName> --json number,title,headRefName ``` -Any result is a **child PR**, and this PR is the base of a stack. Record the list now, because after the merge the relationship is harder to see and easier to break. +Any result is a **child PR**, and this PR is the base of a stack. Record the list now, because after the merge the relationship is harder to see and easier to break. Child PRs are GitHub stacking, not child issues. -When a child exists: +When a child PR exists: - **Do not pass `--delete-branch` to the merge.** Removing the head branch while a child still points at it can close the child outright. Delete it only after every child has been retargeted, in Step 5. - Tell the user the stack exists and name the children before asking for merge confirmation. A stack changes what "merge this" means. -When no child exists, say so, and `--delete-branch` is safe. +When no child PR exists, say so, and `--delete-branch` is safe. --- -## Step 2: Evidence Review +## Step 2: Definition-of-Done Review + +The issue's definition of done is the merge checklist. Load it from the issue, not from memory: + +```bash +loaf issue dod list <ref> +loaf issue show <ref> +``` -Review the landing diff and durable prose together: +`show` prints each criterion as `position. [V|H] text` with `command=` / `expect=` when present. Walk every row against the landing diff. 1. Gather diff context: ```bash @@ -145,13 +193,19 @@ Review the landing diff and durable prose together: git diff --stat origin/<baseRefName>...HEAD git diff --name-only origin/<baseRefName>...HEAD ``` -2. Read the PR title/body and changed docs that make behavior claims. -3. Check for drift: - - PR body claims features that are not in the diff - - changelog entries mention unreleased or unrelated behavior +2. For each **H-tier** criterion, read the diff and durable prose and decide whether the text is met. `loaf issue verify` skips H-tier rows — that skip is not a pass; you are the check. +3. Run the **V-tier** rows: + ```bash + loaf issue verify <ref> + ``` + Commands run from the **repository root**. The command honors `exit <N>` and `` contains `text` ``. It writes nothing — it does not tick checkboxes, does not set status, and does not record a run. Non-zero exit blocks merge. `no executable V-tier criteria on <ref>` is not a failure; H-tier review still is. Unenforceable expect clauses print as advisory warnings and are never quietly decorative. +4. Check for drift: + - Rendered issue body claims features that are not in the diff + - changelog entries mention unrelated behavior - docs describe future work as already shipped - - comments or runbooks use stale internal vocabulary -4. Fix blocking drift before merge. For non-blocking polish, name it and let the user decide. + - comments or runbooks use stale vocabulary +5. If `loaf issue show` lists child issues that are not `done`, name them before asking to merge. Do not mark those children done unless this PR is theirs. +6. Fix blocking drift and unmet criteria before merge. For non-blocking polish, name it and let the user decide. For high-risk PRs, use the project's review skill or read-only review flow before proceeding. @@ -159,7 +213,7 @@ For high-risk PRs, use the project's review skill or read-only review flow befor ## Step 3: Local Verification -Run the checks the project supports. Examples: +Run the checks the project supports, in addition to `loaf issue verify`. Examples: - Node: `npm run typecheck`, `npm run test`, `npm run build` - Go: `go vet ./...`, `go test ./...` @@ -178,7 +232,7 @@ Use the repo's documented pre-commit or pre-PR checklist when present. Stop on f ## Step 4: Squash Merge -Draft a clean squash body from the reviewed diff and PR body: +Draft a clean squash body from the reviewed diff and the rendered issue: - One-line summary, then bullet points grouped by feature area - Plain text; use backticks only for code identifiers @@ -201,31 +255,41 @@ Let GitHub default the title from the PR title so the squash subject remains `ty ## Step 5: Post-Merge Cleanup -After a successful merge: +After a successful merge, leave the started worktree before removing it. Do not run `loaf issue stop` from inside that worktree. -1. Switch to the PR base branch: +1. Switch to the PR base branch in the repository checkout: ```bash git checkout <baseRefName> git pull --ff-only origin <baseRefName> ``` -2. Delete the local feature branch when safe: +2. Mark the bound issue done — this is what "done" means; `loaf issue stop` does not change status: + ```bash + loaf issue status <ref> done + ``` + Write statuses (`triage`, `backlog`, `todo`, `active`, `done`) update in place. Do not use `cancelled` or `duplicate` here. +3. Stop the started worktree if one exists. `loaf issue stop` removes the worktree, clears `started_branch` / `started_worktree` on the row, and **keeps the branch**: + ```bash + loaf issue stop <ref> + ``` + If the issue was never started, the command errors with `issue <ref> is not started` — treat that as already clean and continue. If the worktree is dirty, do not pass `--force` without user confirmation. +4. Delete the local feature branch when safe: ```bash git branch -d <headRefName> ``` -3. Confirm the remote branch deletion state from GitHub output or run: +5. Confirm the remote branch deletion state from GitHub output or run: ```bash gh pr view <N> --json headRefName,state ``` -4. Log the landing to the project journal: +6. Log the landing: ```bash - loaf journal log "decision(ship): PR #N landed via squash merge" + loaf journal log "decision(ship): PR #N landed via squash merge; <ref> done" ``` -If cleanup fails, report the exact residual state. Do not force-delete without user confirmation. +If cleanup fails, report the exact residual state (issue status, whether the worktree is still started, which branches remain). Do not force-delete without user confirmation. ### Stacked child PRs -Every child recorded in Step 1b needs three repairs before it can ship, and none of them happen on their own. +Every child PR recorded in Step 1b needs three repairs before it can ship, and none of them happen on their own. **Retarget the base.** GitHub does not reliably move a child's base when its base branch merges. Check, and move it explicitly: @@ -247,7 +311,7 @@ git diff --stat <baseRefName>...HEAD | tail -1 gh pr view <child> --json changedFiles -q .changedFiles ``` -Then force-push with a lease, wait for CI to finish on the **rebased head** rather than trusting the previous run, and only then ship the child. Delete the parent's head branch once no child points at it. +Then force-push with a lease, wait for CI to finish on the **rebased head** rather than trusting the previous run, and only then ship the child. Delete the parent's head branch once no child PR points at it. Report the numbers rather than asserting success: the before and after file counts, the commits the rebase skipped, and the CI conclusion on the new head. @@ -255,11 +319,11 @@ Report the numbers rather than asserting success: the before and after file coun ## Step 6: Release Suggestion -After landing, decide whether to suggest release: +After landing, decide whether to suggest the release skill. That cut will not re-run this review. -- Suggest release when the landed PR completes a coherent batch, user-facing feature, fix train, or release branch. +- Suggest release when the landed PR completes a coherent batch, user-facing feature, or fix train. - Do not suggest release for every small PR by default. -- If multiple related PRs are expected, say the PR is landed and can wait for a later batched release. +- If more related PRs are expected, say this PR is landed and can wait for a later cut. Use language carefully: the PR is **landed** or **shipped**; it is not **released** until release publishes a version. @@ -283,12 +347,12 @@ Do not disable hooks to force a PR through. ## Suggests Next -After a successful ship, suggest release only when the landed work forms a coherent release batch or the user asks to publish. +After a successful ship, suggest release only when the landed work forms a coherent batch or the user asks to publish. Release will not re-check the landed PR. ## Related Skills -- **release** -- Publishes a version from already-landed work -- **git-workflow** -- Branching, PR, commit, and squash merge conventions -- **foundations** -- Verification, code review, and production readiness -- **documentation-standards** -- Changelog, docs, and durable prose quality -- **reflect** -- Updates strategy from significant shipped work +- **release** — Publishes a version from already-landed work; does not re-review those PRs +- **git-workflow** — Branching, PR, commit, and squash merge conventions +- **foundations** — Verification, code review, and production readiness +- **documentation-standards** — Changelog, docs, and durable prose quality +- **reflect** — Updates strategy from significant shipped work diff --git a/dist/opencode/skills/triage/SKILL.md b/dist/opencode/skills/triage/SKILL.md index 25bcdec6f..4a6f7ff91 100644 --- a/dist/opencode/skills/triage/SKILL.md +++ b/dist/opencode/skills/triage/SKILL.md @@ -2,13 +2,13 @@ name: triage description: >- Processes the local intake queue from loaf intake list: unresolved sparks, - ideas, brainstorms, tracked and deferred Intents, and unmigrated legacy - deferrals. Use when the user asks "triage", "process my backlog", or wants - dispositions chosen across intake items. Produces explicit dispositions: - discard, retain, track as Intent, defer, resume, resolve, explore, hand to - pitch, or hand to shape. Not for reading a single known item (use loaf intent - show or journal directly), capturing new ideas (use idea), problem discovery - (use pitch), or bounding one chosen direction (use shape). + ideas, and brainstorms. Use when the user asks "triage", "process my backlog", + or wants dispositions chosen across intake items. Produces explicit + dispositions: discard, retain as spark/idea, file as backlog issue, resume + exploration, resolve, hand to pitch, or hand to shape (issue preparation). Not + for reading a single known item (use loaf issue show, loaf spark show, loaf + idea show, or journal directly), capturing new ideas (use idea), problem + discovery (use pitch), or bounding one chosen direction (use shape). user-invocable: true version: 0.2.21 --- @@ -27,7 +27,7 @@ Process the intake queue. Triage is the public funnel where captured material me - Quick Reference - Process - Dispositions -- Legacy Deferrals +- Leftover kinds - Guardrails - Related Skills @@ -37,62 +37,67 @@ Process the intake queue. Triage is the public funnel where captured material me - Read the queue with `loaf intake list --json`; it projects every unresolved logical item exactly once with its provenance and exact read command. - Present everything before acting — the user decides each disposition; never auto-promote, auto-discard, or auto-convert. - The CLI never classifies: you and the user interpret each item; commands perform the chosen operation deterministically. -- Capture, Intent, and Exploration are different claims: a spark or idea is retained material, a tracked Intent is deliberately tracked work, a deferral is an Intent disposition with an immutable payload, an Exploration is an inquiry. Do not conflate them to save a step. +- Capture, issue, and Exploration are different claims: a spark or idea is retained material, a backlog issue is deliberately tracked work, an Exploration is an inquiry. Do not conflate them to save a step. - One pass through the queue — don't loop or re-present items. -- **Two doors into a Change:** items needing problem discovery hand to pitch, which owns `loaf change init <slug> --brief` and brief authoring; well-understood directions hand to shape. When capture should precede shaping without a full pitch, run `loaf change init <slug> --brief` and seed `brief.md` with the original ask, then hand to shape. +- **Two doors into issue work:** items needing problem discovery hand to pitch; well-understood directions hand to shape (issue preparation). Worth keeping but not ready for either door files as a backlog issue (`loaf issue new "<title>" --status backlog`, optional `--parent`, optional `loaf issue bucket`). Triage never runs `loaf issue start`, never opens PRs, and never invents Git artifacts. ## Verification - Every presented item has a recorded disposition or an explicit "leave for next triage". -- Tracked and deferred choices exist as Intents with the expected derived disposition (`loaf intent list`). +- Filed directions exist as backlog issues (`loaf issue list --status backlog`) and no longer appear in `loaf intake list` once their captures are resolved or archived. - Discards are resolved or archived through their own commands and no longer appear in `loaf intake list`. -- No Linear or tracker operation was attempted; publication is a later concern outside this Change. +- No Linear or tracker operation was attempted; publication is a later concern outside triage. ## Quick Reference | Item kind | Comes from | Typical dispositions | |-----------|-----------|----------------------| -| spark | `loaf spark capture` moments | discard, promote to idea, track as Intent | -| idea | idea capture | archive, explore, track as Intent, hand to pitch, hand to shape | -| brainstorm | archived divergent sessions | archive, explore, promote, hand to pitch | -| intent (tracked) | `loaf intent create` | keep tracking, defer, resolve, explore, hand to pitch, hand to shape | -| intent (deferred) | `loaf intent defer` or adapter | resume, resolve, leave deferred | -| legacy_deferral | pre-conversion `journal defer` | read, then optionally convert (see Legacy Deferrals) | +| spark | `loaf spark capture --scope <scope> --text <text>` | discard, retain, promote to idea, file as backlog issue, resume exploration, resolve, hand to pitch, hand to shape | +| idea | `loaf idea capture --title "<title>"` | archive, retain, file as backlog issue, resume exploration, resolve, hand to pitch, hand to shape | +| brainstorm | `loaf brainstorm capture` | archive, retain, promote to idea, file as backlog issue, resume exploration, hand to pitch, hand to shape | ## Process 1. **Scan.** Run `loaf intake list --json`. Summarize counts by kind, then list each item with its title, disposition or status, and read command. 2. **Read on demand.** Use each item's `read_command` verbatim when the user wants detail before deciding. If a read command fails, record the exact command and error in the summary as `unreadable`, make no semantic disposition for that item, continue the pass, and offer a factual diagnostic step (`loaf state doctor --json`) afterward. Never persist unreadable as a status. 3. **Decide per item.** Present the applicable dispositions and perform exactly the chosen one. -4. **Summarize.** Report what was discarded, retained, tracked, deferred, resumed, resolved, or handed onward, and journal notable decisions. +4. **Summarize.** Report what was discarded, retained, filed as backlog issues, resumed as explorations, resolved, or handed to pitch or shape, and journal notable decisions. ## Dispositions - **Discard** — ideas and brainstorms: `loaf idea archive <ref> --reason <r>` or `loaf brainstorm archive <ref> --reason <r>`. A spark is resolved against the entity that addressed it (`loaf spark resolve <ref> --by <entity> --reason <r>`); a pure dead-end spark currently has no deterministic discard operation — leave it retained, journal the judgment, and never invent a resolving entity. -- **Retain as capture** — do nothing; open captures resurface next triage. -- **Track as Intent** — two steps: create the Intent with the capture as its source, then close the capture against it so the direction appears once. `loaf intent create --title <t> --body <self-sufficient body> --from <capture-ref>`, then `loaf spark resolve <capture-ref> --by <intent-ref>` or `loaf idea resolve <capture-ref> --by <intent-ref>` (brainstorms: `loaf brainstorm archive <ref> --reason "tracked as <intent-ref>"`). -- **Defer** — an existing Intent: `loaf intent defer <ref> --why <w> --boundary <b> --trigger <t> --operation-id <key>`; a new deferred direction needs the full skeleton: `loaf intent create --title <t> --body <b> --disposition deferred --why <w> --boundary <bd> --trigger <tr> --operation-id <key> [--from <source-ref>]`. -- **Resume** — `loaf intent resume <ref> --reason <why now>`; appends a tracked disposition linked to the deferral it supersedes. -- **Resolve** — `loaf intent resolve <ref> --reason <outcome>`; history is never rewritten. -- **Explore** — agent technique for genuinely undecided directions (Explorations and checkpoints); not a user slash entry — prefer pitch when the human needs problem discovery first, then reach for explore from inside that work if still undecided. -- **Pitch** — items needing problem discovery hand to pitch, which owns init and brief authoring; resolve the promoted item against the created change (`loaf spark resolve` / `loaf idea resolve` / archive brainstorm with the change as the reason). -- **Shape** — when a direction is already well-understood and ready for bounded delivery, hand it to shape; triage never creates fully-materialized Changes, branches, or worktrees (capture-only brief seeding is the Critical Rules exception above). +- **Retain as spark/idea** — do nothing to leave the capture open, or promote into the other capture primitive: capture the idea first (`loaf idea capture --title "..."`), then `loaf spark promote <spark> --to-idea <idea>` or `loaf brainstorm promote <brainstorm> --to-idea <idea>`. Open captures resurface next triage. +- **File as backlog issue** — two steps so the direction appears once. Create the issue, then close the capture against it: -## Legacy Deferrals + ```bash + loaf issue new "<title>" --status backlog [--parent <ref>] [--kind delivery|decision] [--fog <text>] [--body <text>] + loaf issue bucket <issue-ref> now|next|later # optional; labels only, never a constraint + loaf spark resolve <capture-ref> --by <issue-ref> + # or: loaf idea resolve <capture-ref> --by <issue-ref> + # brainstorms: loaf brainstorm archive <ref> --reason "filed as <issue-ref>" + ``` -Items of kind `legacy_deferral` are pre-conversion `journal defer` records. They stay visible and readable until the explicit, backup-first conversion is run; nothing disappears while migration is pending. When the user wants them converged, offer `loaf state migrate deferrals --dry-run` to preview the project-specific manifest and `--apply` only with explicit consent — apply verifies a whole-database backup first and preserves every legacy row. + Use `--kind decision` when filing a sharp question. Copy still-unsharp questions into `--fog` (create-time only). `--parent` nests under an existing issue; omit it for a different problem. +- **Resume exploration** — agent technique for genuinely undecided directions (Explorations and checkpoints); not a user slash entry. Prefer pitch when the human needs problem discovery first, then reach for explore from inside that work if still undecided. Resume with `loaf exploration context <ref>` when a named Exploration already exists. +- **Resolve** — the capture is already represented elsewhere. `loaf spark resolve <ref> --by <entity> --reason <r>` or `loaf idea resolve <ref> --by <entity>`. History is never rewritten. +- **Hand to pitch** — items needing problem discovery hand to pitch. Resolve the capture against the issue once one exists (`loaf spark resolve` / `loaf idea resolve --by <issue-ref>` / archive the brainstorm with that issue as the reason). +- **Hand to shape** — when a direction is already well-understood and ready for bounded delivery, hand it to shape for issue preparation. Triage never writes definition-of-done criteria, never runs `loaf issue check`, and never creates branches or worktrees. + +## Leftover kinds + +`loaf intake list` may still include `intent` and `legacy_deferral` items. Do not create new `intent` rows. Treat leftover directions like any other capture: file a backlog issue if worth keeping, or leave them for a later pass. Do not offer conversion commands that recreate the old tracked/deferred row. ## Guardrails 1. **User decides every disposition** — present, don't decide. 2. **Batch presentation, individual decisions** — show the full queue, then process one item at a time. 3. **Log everything** — no silent discards, promotions, or conversions. -4. **Deferred is not forgotten** — deferred Intents remain active truth in `loaf journal context` until resumed or resolved. +4. **Filed is not forgotten** — backlog issues remain on `loaf issue list` and may appear on `loaf issue frontier` until their status changes. Buckets are labels only. ## Related Skills - **idea** — capture a new idea (fast, minimal friction) - **pitch** — problem-discovery ceremony for items that need framing before shape - **explore** — agent technique for divergent inquiry with portable checkpoints -- **shape** — develop a well-understood direction into a bounded Change +- **shape** — develop a well-understood direction into a bounded issue - **housekeeping** — flags stale artifacts; does not choose dispositions diff --git a/dist/opencode/skills/wrap/SKILL.md b/dist/opencode/skills/wrap/SKILL.md index 9e119f625..7246817b2 100644 --- a/dist/opencode/skills/wrap/SKILL.md +++ b/dist/opencode/skills/wrap/SKILL.md @@ -137,7 +137,7 @@ After the wrap-up report, suggest housekeeping if it wasn't run this session and ## Report Format -Use backtick formatting for code identifiers, file paths, spec/task IDs, version numbers, status values, and CLI commands. Use uppercase for spec and task IDs (`SPEC-029`, not `spec-029`). +Use backtick formatting for code identifiers, file paths, issue IDs, version numbers, status values, and CLI commands. Use uppercase for issue IDs (`LOAF-29`, not `loaf-29`). ```markdown ## Session Wrap-Up diff --git a/dist/skills/bootstrap/SKILL.md b/dist/skills/bootstrap/SKILL.md index 8e8b8a2bf..9a4fe65c9 100644 --- a/dist/skills/bootstrap/SKILL.md +++ b/dist/skills/bootstrap/SKILL.md @@ -29,7 +29,7 @@ First-contact project setup: detect state, interview the builder, populate proje - Guardrails - Related Skills -Series-prep lives under Finalization (phase between Knowledge Base Scaffolding and Next Steps). +Series-prep lives under Finalization (phase between Knowledge Base Scaffolding and Next Steps): the initial arc becomes backlog issues, not folders. **Input:** $ARGUMENTS @@ -42,8 +42,8 @@ Series-prep lives under Finalization (phase between Knowledge Base Scaffolding a - **Always interview** -- even with a rich brief, confirm understanding through structured questions — one at a time, with a recommendation, using your harness's structured question tool if it has one - **Pitched BRIEF is discovery-already-done** -- when `docs/BRIEF.md` has `source: pitch`, do not re-excavate the problem space; quote-back and gap-fill only for operating-document population - **BRIEF is input, not output** -- the BRIEF is raw intake. Extract every useful fact into VISION/STRATEGY/ARCHITECTURE/AGENTS during bootstrap. -- **BRIEF is archeological after bootstrap** -- once extraction completes (including series-prep reading scoped concepts from it), the BRIEF is a frozen historical snapshot. No skill, agent, command, or template should reference `docs/BRIEF.md` post-bootstrap. Operating documents and minted change briefs must stand on their own. -- **Series-prep never auto-shapes and never creates branches** -- every mint is user-confirmed; no priority, date, or dependency fields; concepts without a coarse `target_release` stay BRIEF lines, sparks, or Intents +- **BRIEF is archeological after bootstrap** -- once extraction completes (including series-prep reading scoped concepts from it), the BRIEF is a frozen historical snapshot. No skill, agent, command, or template should reference `docs/BRIEF.md` post-bootstrap. Operating documents and minted issue bodies must stand on their own. +- **Series-prep never auto-shapes and never creates branches** -- every mint is user-confirmed; no priority, date, or dependency fields; buckets are labels, never bindings; concepts that fail granularity stay BRIEF lines or sparks - **Suggest, don't execute** -- recommend next skills at the end, never auto-run them - **Log first** -- log invocation before interviewing: `loaf journal log "skill(bootstrap): <project or intake>"` - **Log outcome** -- log bootstrap completion to the project journal: `loaf journal log "decision(bootstrap): project bootstrapped, mode detected"` @@ -55,7 +55,7 @@ Series-prep lives under Finalization (phase between Knowledge Base Scaffolding a - All expected operating documents (`docs/VISION.md`, `AGENTS.md` at minimum) exist and contain populated content - Useful BRIEF content has been extracted into operating documents (no future reader should need to open the BRIEF) - When `source: pitch`, the interview was gap-only (no re-excavation of already-specific problem sections) -- When series-prep ran: each minted folder has `change.json` with stamped `target_release`, a standalone problem-space `brief.md`, zero-violation captured state via explicit-path `loaf change check <folder> --json`, and its own docs-only commit (never a batch); no branches created for the series; no auto-shape +- When series-prep ran: each minted row is a backlog issue (`loaf issue new "<title>" --body "<problem narrative>" --status backlog`) with a standalone problem-space body; an advisory bucket (`loaf issue bucket <ref> now|next|later`) may be set — buckets are labels, never bindings; `loaf issue check <ref>` only when a capture is shaped enough to check, otherwise nothing (a backlog issue with a problem body needs no ceremony); no folders, no docs-only commits; no branches created for the series; no auto-shape - Root `AGENTS.md` is a real file; on Claude Code, the compatibility symlink `.claude/CLAUDE.md -> ../AGENTS.md` exists (see Finalization) - Key decisions and interview outcomes were logged with `loaf journal log` and are readable with `loaf journal recent` @@ -236,7 +236,7 @@ Pitch owned the problem-space grill. Bootstrap does not re-excavate. The pitch 1. **Acknowledge the pitch** — name that `docs/BRIEF.md` carries `source: pitch` and that problem discovery is already done. 2. **Summarize what pitch captured** — short section-by-section gist (problem, who, alternatives, value, constraints, sequencing, open questions). The builder should hear continuity with the pitch closing ceremony, not a cold restart. -3. **State what bootstrap will do now** — interview only on gaps for operating-document population (VISION, STRATEGY, ARCHITECTURE, AGENTS), then series-prep the initial arc of captured changes. Do not re-grill the problem space. +3. **State what bootstrap will do now** — interview only on gaps for operating-document population (VISION, STRATEGY, ARCHITECTURE, AGENTS), then series-prep the initial arc as backlog issues. Do not re-grill the problem space. Then continue: @@ -423,58 +423,62 @@ The journal should capture: Use [templates/journal.md](templates/journal.md) only as the rendered entry format reference; do not hand-author journal markdown as the source of truth. -### 4. Series-Prep (initial arc as captured changes) +### 4. Series-Prep (initial arc as backlog issues) -After operating documents are populated (and Knowledge Base Scaffolding above has run), close bootstrap by minting the BRIEF's initial arc as **captured promise carriers** — brief-only change folders bound to a coarse `target_release`, each landed as its own docs-only commit. Series-prep is not roadmap planning: no milestone entities, no dates, no priorities, no dependency fields. Sequencing is prose in each brief; cohort membership is the shared `target_release`. +After operating documents are populated (and Knowledge Base Scaffolding above has run), close bootstrap by minting the BRIEF's initial arc as **backlog issues** — SQLite rows with a problem-space body and an optional advisory bucket label (`loaf issue bucket <ref> now|next|later`). Buckets are labels, never bindings. Series-prep is not roadmap planning: no milestone entities, no dates, no priorities, no dependency fields. Sequencing is prose in each issue body. No folders, no docs-only commits per capture — rows, not files. **When to run** - Always offer series-prep when a project BRIEF exists and names more than one scoped concept (typical after a pitched BRIEF; also after a rich non-pitch brief). -- If the BRIEF is a single atomic concept with no series, say so and skip to Next Steps — one future shape or a single capture later is enough. -- Series-prep **reads** the BRIEF during this phase only. After bootstrap ends, nothing references `docs/BRIEF.md` again; minted change briefs and operating docs stand alone. +- If the BRIEF is a single atomic concept with no series, say so and skip to Next Steps — one future shape or a single issue later is enough. +- Series-prep **reads** the BRIEF during this phase only. After bootstrap ends, nothing references `docs/BRIEF.md` again; minted issue bodies and operating docs stand alone. **Procedure** 1. **Enumerate concepts** with the builder from the BRIEF's scoped problem space (Sequencing and Relationships, Open Questions, and distinct problem threads in Problem Statement). List candidates as recommendation-first options using your harness's structured question tool if it has one. -2. **Apply granularity** per [references/interview-guide.md](references/interview-guide.md) (Series-Prep Granularity): a concept earns its own captured change when it is independently shippable **and** its problem can be **stated precisely now** (the mint-time specifiability test — not answered now, stated now) without the others; otherwise it stays a BRIEF line, becomes a spark, or an Intent — never a half-minted folder. +2. **Apply granularity** per [references/interview-guide.md](references/interview-guide.md) (Series-Prep Granularity): a concept earns its own backlog issue when it is independently shippable **and** its problem can be **stated precisely now** (the mint-time specifiability test — not answered now, stated now) without the others; otherwise it stays a BRIEF line or becomes a spark — never a half-minted row. 3. **Per confirmed concept (one at a time — never batch):** - 1. Confirm mint with the builder (slug, coarse `target_release`, one-line problem restatement). If the builder will not bind even a coarse target, do not mint — park as spark/Intent/BRIEF line. - 2. Propose a **local slug** that names the concept, never another work unit (`spec-042`, task ids, change folder names). Confirm the slug. - 3. Run capture init: + 1. Confirm mint with the builder (title, optional advisory bucket, one-line problem restatement). Buckets are labels, never bindings — a missing bucket does not block mint. If the concept fails granularity, do not mint — park as spark or BRIEF line. + 2. Propose a **working title** that names the concept, never another work unit (issue aliases, task ids). Confirm the title. + 3. **Seed a problem-space-only narrative** from the BRIEF's content for that concept (Problem Statement, Who Has It, Current Alternatives, Value Proposition, Constraints, Sequencing and Relationships as prose order relative to the arc, Sources if any, Open Questions). Do not copy solution design. The seeded body must stand alone as intent for later shape — cold-read without the project BRIEF or this session. + 4. Mint the backlog issue: ```bash - loaf change init <slug> --brief + loaf issue new "<title>" --body "<problem narrative>" --status backlog ``` - Creates `docs/changes/YYYYMMDD-<slug>/` with `change.json` + `brief.md` only. - 4. **Seed `brief.md` problem-space-only** from the BRIEF's content for that concept (Problem Statement, Who Has It, Current Alternatives, Value Proposition, Constraints, Sequencing and Relationships as prose order relative to the arc, Sources if any, Open Questions). Do not copy solution design. The seeded brief must stand alone as intent for later shape — cold-read without the project BRIEF or this session. - 5. **Stamp `target_release`** on that folder's `change.json` with the builder's coarse binding (canonical `MAJOR.MINOR.PATCH`, no `v`, no prerelease). Series-prep mints only targeted captures (promise-carrier path). - 6. **Pre-landing guard** (required before every commit): + Creates a SQLite row, not a folder. Use `--body -` or `--body-file <path>` when the narrative is long (see `loaf issue new --help`). + 5. **Optionally set an advisory bucket** (a label, never a binding): ```bash - loaf change check <folder> --json + loaf issue bucket <ref> now|next|later ``` + 6. **Validate** only when the capture is shaped enough to check: - Must report zero violations and captured state. Then **read `<folder>/change.json` directly** and confirm the stamped `target_release` matches what the builder bound. Bare `loaf change check` resolves by branch and can miss a capture elsewhere — always pass the explicit folder path. - 7. **Land as its own docs-only commit on the default branch** (one commit per capture, never a batch). Example subject: `docs(change): capture <slug> brief`. Bootstrap prepares the commit; never push; never open a PR. + ```bash + loaf issue check <ref> + ``` + + A backlog issue with a problem body is capture-only and needs no ceremony — skip the check. Do not add criteria or an out-of-scope statement during series-prep (that is shape). + 7. **Do not land a docs-only commit.** The row is the artifact. Never push; never open a PR; never create a branch. 4. **Guards (hard):** - Every mint is user-confirmed — never auto-mint the whole list - - Never auto-run shape and never create slug branches during series-prep - - No priority, date, estimate, or dependency fields on captures - - No batching multiple captures into one commit - - Concepts without a coarse target stay BRIEF lines, sparks, or Intents + - Never auto-run shape and never create branches during series-prep + - No priority, date, estimate, or dependency fields on issues + - No folders, no docs-only commits per capture — rows, not files + - Concepts that fail granularity stay BRIEF lines or sparks; buckets are labels, never bindings **After the series** -Log the arc: `loaf journal log "decision(bootstrap): series-prep minted <n> captures for <cohort or targets>"`. Hand off by naming the first capture folder for shape when the builder is ready. +Log the arc: `loaf journal log "decision(bootstrap): series-prep minted <n> backlog issues"`. Hand off by naming the first issue ref for shape when the builder is ready. ### 5. Next Steps Suggest relevant next steps based on what was learned: -- shape -- on a series-prep capture (or any ready concept) to promote the folder and bound implementation +- shape -- on a series-prep backlog issue (or any ready concept) to bound implementation - pitch -- if a new concept still needs problem discovery (not for re-grilling the BRIEF) -- idea -- if specific feature ideas emerged during the interview and should not become captures yet +- idea -- if specific feature ideas emerged during the interview and should not become issues yet - research -- if there are open questions that need investigation - `loaf doctor` -- to verify the setup is healthy @@ -501,18 +505,18 @@ When the interactive interview path is unavailable, bootstrap the operating docu 2. **Always interview** -- even with a rich brief, confirm understanding; when `source: pitch`, gap-fill only 3. **Never overwrite** -- existing documents require explicit confirmation 4. **Draft, then review** -- present documents section-by-section -5. **Extract, don't preserve** -- pull every useful fact from the BRIEF into operating docs (and series-prep seeds change briefs from it once). The BRIEF is archeological after bootstrap; nothing should reference it again. +5. **Extract, don't preserve** -- pull every useful fact from the BRIEF into operating docs (and series-prep seeds issue bodies from it once). The BRIEF is archeological after bootstrap; nothing should reference it again. 6. **Record the session** -- decisions and rationale are preserved 7. **Suggest, don't execute** -- recommend next skills, don't auto-run them; series-prep never auto-shapes or creates branches 8. **Interview structured** -- one question at a time, with a recommendation, using your harness's structured question tool if it has one -9. **Series-prep is not roadmap planning** -- coarse `target_release` + prose sequencing only; no dates, priorities, or dependency fields +9. **Series-prep is not roadmap planning** -- advisory bucket labels (never bindings) + prose sequencing only; no dates, priorities, or dependency fields --- ## Related Skills -- **pitch** -- Authors a project-scale `docs/BRIEF.md` with `source: pitch` (or a change-scale brief); bootstrap consumes the pitched BRIEF with gap-only interview and series-prep -- **shape** -- Bound a captured change into a contract (promotes brief-only folders; often follows series-prep) +- **pitch** -- Authors a project-scale `docs/BRIEF.md` with `source: pitch` (or an issue-scale problem narrative); bootstrap consumes the pitched BRIEF with gap-only interview and series-prep +- **shape** -- Bound a backlog issue into a contract (often follows series-prep) - **explore** -- Agent technique when a concept that emerges during bootstrap is still undecided (not a user front door; prefer pitch for human problem discovery) - **research** -- Investigate topics and open questions - **idea** -- Quick-capture feature ideas that emerge during bootstrap diff --git a/dist/skills/bootstrap/references/interview-guide.md b/dist/skills/bootstrap/references/interview-guide.md index f3fc0c1ef..83f583396 100644 --- a/dist/skills/bootstrap/references/interview-guide.md +++ b/dist/skills/bootstrap/references/interview-guide.md @@ -358,33 +358,32 @@ Expect 6-10 questions total, mostly in Excavation and Sharpening. Grounding is l ## Series-Prep Granularity -Series-prep (bootstrap Finalization) turns the project BRIEF's scoped concepts into captured change folders. This section resolves when a concept earns a folder versus staying a BRIEF line, spark, or Intent. Full procedure: bootstrap SKILL.md Finalization → Series-Prep. +Series-prep (bootstrap Finalization) turns the project BRIEF's scoped concepts into backlog issues. This section resolves when a concept earns a row versus staying a BRIEF line or spark. Full procedure: bootstrap SKILL.md Finalization → Series-Prep. -### Earns its own captured change when both are true +### Earns its own backlog issue when both are true -1. **Independently shippable** — the concept could land as a meaningful release unit without waiting on the other scoped concepts to ship in the same commit or PR. Sharing a coarse `target_release` cohort is fine; hard entanglement is not. -2. **Specifiability (mint test)** — a concept mints only if its problem can be **stated precisely now** (not answered now): a cold reader can name the friction, who has it, the current alternative, and the value for *this* concept alone, without needing sibling folders or inventing what was meant. Everything vaguer stays a BRIEF line, spark, or Intent — never pre-sliced into a fake capture. +1. **Independently shippable** — the concept could land as a meaningful release unit without waiting on the other scoped concepts to ship in the same commit or PR. Sharing an advisory bucket is fine (buckets are labels, never bindings); hard entanglement is not. +2. **Specifiability (mint test)** — a concept mints only if its problem can be **stated precisely now** (not answered now): a cold reader can name the friction, who has it, the current alternative, and the value for *this* concept alone, without needing sibling issues or inventing what was meant. Everything vaguer stays a BRIEF line or spark — never pre-sliced into a fake row. -When both hold and the builder will bind a coarse `target_release`, mint: `loaf change init <slug> --brief`, seed problem-space-only, stamp the target, pre-landing check + `change.json` read-back, one docs-only commit on the default branch. +When both hold, mint: `loaf issue new "<title>" --body "<problem narrative>" --status backlog`, seed problem-space-only, optionally `loaf issue bucket <ref> now|next|later` (buckets are labels, never bindings). Run `loaf issue check <ref>` only when the capture is shaped enough to check; a backlog issue with a problem body needs no ceremony. No folders, no docs-only commits — rows, not files. -### Stays a BRIEF line, spark, or Intent when any is true +### Stays a BRIEF line or spark when any is true - The concept only makes sense as a clause of a larger problem (it cannot be stated alone). - Its problem cannot yet be stated precisely — coarse intent only; minting would invent precision. - It is a solution-space detail, implementation task, or "nice-to-have" flavor — not a shippable problem boundary. -- The builder will not bind even a coarse `target_release` (untargeted series members are not promise carriers on main; keep them as sparks/Intents or leave them as prose in the BRIEF until a target exists). -- Splitting would create two folders that always ship as one unit — keep one folder and name the sub-concerns in Sequencing prose. +- Splitting would create two issues that always ship as one unit — keep one issue and name the sub-concerns in Sequencing prose. ### Interview moves during series-prep -- Recommend a decomposition first (list candidate folders + what stays in the BRIEF), then confirm each mint one at a time. +- Recommend a decomposition first (list candidate issues + what stays in the BRIEF), then confirm each mint one at a time. - Challenge over-splitting: "Can this problem be stated precisely now without that one?" If no, merge candidates or leave as BRIEF lines. -- Challenge under-splitting: "Is this independently shippable, or are we smuggling two products into one capture?" -- Never invent priority ranks, dates, or machine dependency fields — sequencing is prose; cohort is `target_release`. +- Challenge under-splitting: "Is this independently shippable, or are we smuggling two products into one issue?" +- Never invent priority ranks, dates, or machine dependency fields — sequencing is prose; buckets are labels, never bindings. ### Standalone cold-read test (H3) -Each minted brief must name its problem without reference to the bootstrap session or to `docs/BRIEF.md`. If a draft says "as discussed" or "per the project brief," rewrite until the problem stands alone. +Each minted issue body must name its problem without reference to the bootstrap session or to `docs/BRIEF.md`. If a draft says "as discussed" or "per the project brief," rewrite until the problem stands alone. --- diff --git a/dist/skills/bootstrap/templates/brief.md b/dist/skills/bootstrap/templates/brief.md index d7b58d0db..5b550a307 100644 --- a/dist/skills/bootstrap/templates/brief.md +++ b/dist/skills/bootstrap/templates/brief.md @@ -39,7 +39,7 @@ archived: true # Always true -- BRIEF is a historical snapshot, not a w ## Sequencing and Relationships -[How the initial arc hangs together — which concepts belong as early changes, what depends on what, release cohort stated as prose. No machine relation fields; narrative order only.] +[How the initial arc hangs together — which problems become early backlog issues, what depends on what, sequencing stated as prose. No machine relation fields; narrative order only.] ## Sources and Research Links diff --git a/dist/skills/bootstrap/templates/journal.md b/dist/skills/bootstrap/templates/journal.md index caf7409fd..9a0a61f8f 100644 --- a/dist/skills/bootstrap/templates/journal.md +++ b/dist/skills/bootstrap/templates/journal.md @@ -13,7 +13,7 @@ not create or edit journal markdown as the source of truth — use ## Entries -[YYYY-MM-DD HH:MM] skill(implement): implementing TASK-042 +[YYYY-MM-DD HH:MM] skill(implement): implementing LOAF-42 [YYYY-MM-DD HH:MM] decision(scope): description of decision [YYYY-MM-DD HH:MM] discover(scope): something learned [YYYY-MM-DD HH:MM] block(scope): what is blocked diff --git a/dist/skills/breakdown/SKILL.md b/dist/skills/breakdown/SKILL.md deleted file mode 100644 index 1dc47fd2a..000000000 --- a/dist/skills/breakdown/SKILL.md +++ /dev/null @@ -1,329 +0,0 @@ ---- -name: breakdown -description: >- - Decomposes specifications into atomic tasks with dependencies and priorities. - Use when the user asks "break this down" or "create tasks for this spec." - Produces task files with estimates, dependencies, and acceptance criteria. Not - for shaping ideas (use shape) or implementation work (use implement). ---- - -# Breakdown - -Decompose specifications into atomic, implementable tasks. - -## Contents -- Critical Rules -- Verification -- Quick Reference -- Task Breakdown Philosophy -- Task Backend Detection -- Process -- Linear-Native Mode -- Local-Tasks Mode -- Priority Levels -- Guardrails -- Related Skills - -**Input:** $ARGUMENTS - ---- - -## Critical Rules - -- **One concern per task** -- never mix unrelated layers (backend + frontend) in a single task -- **Every task includes its own verification** -- no separate "verify" tasks; each task must have an observable done condition -- **Own the decisions** -- decide granularity and priorities autonomously; only ask the user when two equally valid orderings have genuinely different trade-offs -- **Keep tests with the code they test** -- never split implementation and tests into separate tasks -- **Update spec status** -- mark the spec as `implementing` after tasks are created -- **One backend only** -- in Linear-native mode create Linear issues and NO local `TASK-NNN.md`; in local mode create local tasks and make NO Linear calls -- **Spec file is always local** -- in both modes, the spec stays in `.agents/specs/`. The Linear parent issue, when present, is a rollup pointing to the spec, not a re-host of it -- **Log outcome** -- log breakdown to the project journal: `loaf journal log "decision(breakdown): SPEC-NNN → N tasks created"` - ---- - -## Verification - -- Each created task has a clear title, priority, file hints, verification command, and observable done condition -- The dependency graph has no cycles and reflects actual implementation order -- Spec status has been updated to `implementing` -- **Linear-native mode only:** parent issue exists, labeled `spec`, with description pointing to the local spec file; N sub-issues have `parentId` set; zero local task rows or `TASK-NNN.md` files were created; spec frontmatter has `linear_parent` and `linear_parent_url` populated -- **Local-tasks mode only:** N local tasks exist in `loaf task list` with compatibility `.md` files when configured; no Linear calls were made - ---- - -## Quick Reference - -### Priority Levels - -| Priority | Loaf | Linear Priority | -|----------|------|-----------------| -| P0 | Urgent/blocking -- drop everything | Urgent (1) | -| P1 | High -- work next | High (2) | -| P2 | Normal -- scheduled work (default) | Normal (3) | -| P3 | Low -- when time permits | Low (4) | - -### Right-Sizing Rules - -| Rule | Guideline | -|------|-----------| -| **One agent type** | Completable by a single implementer (after skills narrowing) | -| **One concern** | Touches one layer, service, or component | -| **Context-appropriate** | Fits in model context with room for exploration | -| **Not over-fragmented** | Don't split what naturally belongs together | - -### Mode Selection - -| `integrations.linear.enabled` in `.agents/loaf.json` | Mode | See | -|------------------------------------------------------|------|-----| -| `true` | Linear-native | [Linear-Native Mode](#linear-native-mode) | -| `false` or absent | Local-tasks | [Local-Tasks Mode](#local-tasks-mode) | - ---- - -## Task Breakdown Philosophy - -**Primary principle: separation of concerns.** - -### The Right Size Test - -1. Can a single implementer complete this? If no, split by concern -2. Does it touch multiple unrelated concerns? If yes, split by concern -3. Will the agent need too much context? If yes, split into smaller coherent units -4. Am I splitting just to have more tasks? If yes, merge back - -### Anti-Patterns - -| Don't | Do Instead | -|-------|------------| -| Split backend + tests into separate tasks | Keep tests with the code they test | -| Create a task per file | Group files by concern | -| Separate "implement" and "verify" tasks | Every task includes its own verification | -| Copy the full spec text into the Linear parent issue | Summarize + link to the local spec file | -| Create both local `TASK-NNN.md` and Linear sub-issues | Pick one backend; never mix | - ---- - -## Task Backend Detection - -Read `.agents/loaf.json`: - -```json -{ - "integrations": { - "linear": { "enabled": true } - } -} -``` - -If `integrations.linear.enabled` is `true`, proceed in **Linear-native mode**. -Otherwise, proceed in **Local-tasks mode**. - -If `.agents/loaf.json` is missing, default to local-tasks and note the -assumption in the project journal. - ---- - -## Process - -### Step 1: Parse Input - -`$ARGUMENTS` should reference a spec (e.g., "SPEC-001"). If unclear, list available specs. - -### Step 2: Read the Spec - -Extract: test conditions, scope, implementation notes, priority ordering, complexity size. - -### Step 3: Identify Task Boundaries - -Break down by concern (data layer, backend, frontend, infrastructure, etc.). One concern per task. Explicit dependencies for sequential tasks. - -### Step 4: Decide Priorities and Granularity - -Own the granularity and priority decisions. Apply the Right Size Test, assign priorities -based on dependencies, priority order, and go/no-go gates, and do a self-review pass. Do not -defer these decisions to the user — they trust agent judgment here. - -If genuinely uncertain (e.g., two equally valid orderings with different trade-offs), -ask. Otherwise, decide and move on. - -### Step 5: Draft Task List - -Draft tasks following [task template](templates/task.md). Each task needs: clear title, priority, file hints, verification command, observable done condition, labels (if routing by team). - -### Step 6: Present the Plan - -Show the dependency graph and task summary for awareness before creating anything. -Present it as "here's what I'm creating" not "which option do you prefer?" The user -can still adjust after creation, but the default is to proceed. - -### Step 7: Create Tasks (mode-specific) - -Detect the mode (see [Task Backend Detection](#task-backend-detection)) and follow the -matching section below. Do NOT mix modes. - -- Linear enabled → [Linear-Native Mode](#linear-native-mode) -- Linear disabled or missing → [Local-Tasks Mode](#local-tasks-mode) - -### Step 8: Update Spec and Announce - -Set spec status to `implementing`. In Linear-native mode, also write -`linear_parent` and `linear_parent_url` into the spec's frontmatter. Announce -created tasks and next steps. - ---- - -## Linear-Native Mode - -Spec files stay local and canonical in `.agents/specs/`. Tasks live in Linear -as sub-issues of a parent rollup issue representing the spec. No local -task rows or `TASK-NNN.md` files are created. - -### 7a. Ensure the `spec` label exists - -The `spec` label groups all spec-parent rollup issues so Linear users can -filter for them. - -1. Call `list_issue_labels` to check whether a label named `spec` exists. -2. If missing, create it via `create_issue_label`: - - `name`: `spec` - - `color`: `#5e6ad2` (Linear-ish indigo; implementer may adjust) - - `description`: `Parent rollup issue representing a design spec tracked in the repo at .agents/specs/` - - Prefer workspace-scoped so all teams can filter uniformly. If the MCP - only supports team-scoped labels, create on the default team. -3. Log whether the label was created this run or already existed. This - matters for first-time Loaf setup on a Linear workspace. - -### 7b. Resolve team, project, and state - -Read from `.agents/loaf.json`: - -- **Team:** `linear.default_team` (name) — resolve to team ID via - `list_teams` if not already cached in `known_teams`. -- **Project:** `linear.project.id`. -- **State:** call `list_issue_statuses` for the team, pick the - `unstarted`-type state (typically "Backlog" or "To-Do"). States are - **team-scoped**, not workspace-scoped — always pass the team. - -### 7c. Create the parent issue - -Use `create_issue` with: - -| Field | Value | -|-------|-------| -| `title` | `[SPEC-NNN] <spec title>` | -| `teamId` | from 7b | -| `projectId` | from 7b | -| `stateId` | unstarted state from 7b | -| `priority` | mapped from spec (default High = 2 if unspecified) | -| `labels` | `["spec"]` | -| `description` | Summary synthesized from the spec's Problem Statement + Solution Direction (1–3 paragraphs), ending with: `See .agents/specs/SPEC-NNN-<slug>.md for full text, council references, and strategic tensions.` | - -**Do NOT** copy the full spec body into the description. The local file is canonical. - -### 7d. Check label-group conflicts (pre-flight per sub-issue) - -Linear labels can belong to exclusive groups (e.g., a `type` group where -`feature`, `testing`, `docs`, `bug`, `refactor` are mutually exclusive). -Before creating each sub-issue: - -1. Inspect proposed labels against known group membership (from - `list_issue_labels` group metadata). -2. If a task has more than one label from the same exclusive group, pick the - most appropriate and drop the others. Warn the user about the drop. -3. Log the resolution so the user can override if desired. - -### 7e. Create sub-issues - -For each task, use `create_issue` with: - -| Field | Value | -|-------|-------| -| `parentId` | parent issue ID from 7c | -| `title` | task title | -| `description` | task description + acceptance criteria | -| `teamId` | routed from `team_keywords` or falling back to `default_team` | -| `projectId` | same as parent unless task explicitly belongs elsewhere | -| `stateId` | unstarted state for the target team | -| `priority` | mapped from task priority (see Priority Levels table) | -| `labels` | task labels after conflict resolution (7d) | - -Express dependencies from the spec's Priority Order / dependency graph via -`blockedBy` referencing sibling sub-issue IDs. Create in dependency order so -predecessors exist when referenced. - -### 7f. Do NOT create local task files - -Skip `loaf task create` entirely. Linear issue IDs are the task record. No -local task rows or `TASK-NNN.md` files for this spec's tasks. - -### 7g. Update spec frontmatter - -Add to the spec file's YAML frontmatter: - -```yaml -linear_parent: ENG-198 -linear_parent_url: https://linear.app/<workspace>/issue/ENG-198 -``` - -Use the actual parent issue identifier and URL returned from 7c. - ---- - -## Local-Tasks Mode - -Spec files and task files both live locally. No Linear calls. - -Use `loaf task create --spec SPEC-XXX --title "Task title" --priority P1` -for each task. In SQLite-backed projects, the CLI creates the operational state -row and any compatibility Markdown/index artifacts needed by the current -project. Then edit the `.md` body content (description, acceptance criteria) -only when an authored task prose artifact exists. - -Dependencies are expressed through CLI flags such as `--depends-on`, not by -hand-editing the compatibility index. Priority Order from the spec maps directly -to task `priority` fields. - -See [local-tasks reference](../orchestration/references/local-tasks.md) for -the full local-task model. - ---- - -## Priority Mapping (reference) - -| Loaf | Linear API value | Linear label | -|------|------------------|--------------| -| P0 | `1` | Urgent | -| P1 | `2` | High | -| P2 | `3` | Normal | -| P3 | `4` | Low | - ---- - -## Guardrails - -1. **One concern per task** -- don't mix backend + frontend -2. **Clear verification** -- how to prove it works -3. **Observable done condition** -- not subjective -4. **File hints** -- help session know where to look -5. **Own the decisions** -- decide granularity and priorities, don't defer -6. **Update spec status** -- mark as implementing -7. **One backend only** -- Linear-native creates Linear issues and no local tasks; local-tasks mode creates local tasks and no Linear calls -8. **Summary not copy** -- the Linear parent description summarizes + links; it does not re-host the spec - ---- - -## Suggests Next - -After breakdown completes, suggest implement to start working on the tasks. - -## Related Skills - -- **shape** -- Create specs that get broken down -- **implement** -- Start session for a task or coordinate multiple tasks - -## Topics - -| Topic | Reference | Use When | -|-------|-----------|----------| -| Linear Integration | `orchestration/references/linear.md` | Working out Linear issue structure, labels, parent/child | -| Local Task Model | `orchestration/references/local-tasks.md` | Local-tasks mode details and CLI flags | diff --git a/dist/skills/breakdown/templates/task.md b/dist/skills/breakdown/templates/task.md deleted file mode 100644 index fe74a794f..000000000 --- a/dist/skills/breakdown/templates/task.md +++ /dev/null @@ -1,28 +0,0 @@ -# Task Template - -**Create with:** `loaf task create --title "..." --spec SPEC-XXX` - -```yaml ---- -id: TASK-XXX -title: [Clear action] -spec: SPEC-001 -status: todo -priority: P2 -files: - - [likely file 1] - - [likely file 2] -verify: [command to verify] -done: [observable outcome] ---- - -## Description -[What needs to be done] - -## Acceptance Criteria -- [ ] [Criterion 1] -- [ ] [Criterion 2] - -## Context -See SPEC-001 for full context. -``` diff --git a/dist/skills/council/SKILL.md b/dist/skills/council/SKILL.md index f4e93198b..f76c6348f 100644 --- a/dist/skills/council/SKILL.md +++ b/dist/skills/council/SKILL.md @@ -76,13 +76,13 @@ Councils stay **local**. Even when the workspace uses Linear-native mode, council files live in `.agents/councils/` — they are deliberation artifacts, not executable work, and belong with specs in git. -When a council resolves a spec's open questions: +When a council resolves an issue's open questions: -- Include the spec ID in council frontmatter (e.g., `spec: SPEC-024`). This +- Include the issue ID in council frontmatter (e.g., `issue: LOAF-42`). This is already the common pattern. -- If the spec's `linear_parent` has been populated by breakdown, also - include `linear_parent: ENG-198` in council frontmatter so a reader on - Linear can trace back to the deliberation. +- If the issue is tracked in Linear (tracker authority), also include the + tracker key (e.g., `linear_parent: ENG-198`) in council frontmatter so a + reader on Linear can trace back to the deliberation. - Do not post council content to the Linear parent issue. A brief one-line reference ("Resolved via council 2026-04-21 — see .agents/councils/…") in a sub-issue comment is sufficient if the council drove a specific task diff --git a/dist/skills/documentation-standards/SKILL.md b/dist/skills/documentation-standards/SKILL.md index e35e8ad74..db1fa4326 100644 --- a/dist/skills/documentation-standards/SKILL.md +++ b/dist/skills/documentation-standards/SKILL.md @@ -48,7 +48,7 @@ Standards for ADRs, API docs, changelogs, and diagrams. - Internal spec/task IDs - Verbatim commit or PR-title dumps - **Good examples:** - - "Add `loaf release --post-merge` guardrails for tagged GitHub releases" + - "Add `loaf release suggest` and `loaf release cut` for retroactive releases" - "Fix journal context routing when hook payloads are empty" - "Document worktree-aware `.agents/` storage for linked checkouts" - **Version protection:** diff --git a/dist/skills/explore/SKILL.md b/dist/skills/explore/SKILL.md index 2100181c7..0172ff5d3 100644 --- a/dist/skills/explore/SKILL.md +++ b/dist/skills/explore/SKILL.md @@ -2,16 +2,16 @@ name: explore description: >- Conducts divergent inquiry as a durable Exploration with portable checkpoints, - conversation provenance, and Intent capture that survive compaction and - harness changes. Agent technique — not a user entry point: route "explore - this" and similar user asks to pitch; use this technique from inside pitch or - other agent work when the direction is genuinely undecided, or when resuming a - named Exploration. Produces Exploration records, portable checkpoints, and - tracked or deferred Intents; Exploration machinery and the four-field - checkpoint contract stay intact. Not for evidence gathering on a known - question (use research), continuing implementation (use implement), processing - the intake queue (use triage), shaping a bounded Change (use shape), problem - discovery (use pitch), or quick capture (use idea). + conversation provenance, and backlog-issue dispositions that survive + compaction and harness changes. Agent technique — not a user entry point: + route "explore this" and similar user asks to pitch; use this technique from + inside pitch or other agent work when the direction is genuinely undecided, or + when resuming a named Exploration. Produces Exploration records, portable + checkpoints, and backlog issues for crystallized directions; Exploration + machinery and the four-field checkpoint contract stay intact. Not for evidence + gathering on a known question (use research), continuing implementation (use + implement), processing the intake queue (use triage), shaping a bounded issue + (use shape), problem discovery (use pitch), or quick capture (use idea). --- # Explore @@ -29,6 +29,7 @@ Divergent inquiry with durable continuity. An Exploration is a relational identi - Process - Checkpoint Discipline - Resumption +- Parking a direction - Techniques - Related Skills @@ -38,37 +39,39 @@ Divergent inquiry with durable continuity. An Exploration is a relational identi - You choose what an Exploration means and when to checkpoint; the CLI validates and performs the operation you request. Never expect the CLI to classify or decide for you. - Checkpoint before the context window gets hostile: every checkpoint must carry all four portable fields — purpose, conclusions, unresolved, next action — each self-sufficient without this conversation. - A conversation handle or log path is provenance, never context. Presence of handles does not make an Exploration resumable; only a portable checkpoint does. -- Capture crystallized directions as Intent (`loaf intent create`), deferred bodies with `--disposition deferred`; never leave a substantial direction only in prose. -- Never create Git artifacts, branches, worktrees, or Changes from Explore; when a direction is ready for problem discovery hand it to pitch, and when it is ready for bounded delivery hand it to shape. +- Capture crystallized directions as backlog issues (`loaf issue new "<title>" --status backlog`); park remaining unsharp questions on that issue with `--fog`. Never leave a substantial direction only in prose. +- Never create Git artifacts, branches, or worktrees from Explore; when a direction is ready for problem discovery hand it to pitch, and when it is ready for bounded delivery hand it to shape (issue preparation). - Never store transcripts, prompts, or tool output in checkpoints or items; curate semantic context instead. -- Not a user slash front door — human "explore this" / "where do I start" intent routes to pitch. +- Not a user slash front door — human "explore this" / "where do I start" routes to pitch. ## Verification - The Exploration exists with `portable_context_present: true` after the first checkpoint (`loaf exploration list`). - `loaf exploration context <ref> --json` returns the four-field core whole, and a fresh reader could identify the next action from it alone. -- Crystallized directions exist as Intents with derived dispositions (`loaf intent list`). +- Crystallized directions exist as backlog issues (`loaf issue list --status backlog`); issue aliases named in the checkpoint match those rows. - Conversation provenance, when recorded, carries harness and locality facts without any transcript content. ## Quick Reference | Operation | Command | |-----------|---------| -| Start an inquiry | `loaf exploration create --title <title> [--from <intent-or-source>]...` | +| Start an inquiry | `loaf exploration create --title <title> [--from <source>]...` | | Checkpoint | `loaf exploration checkpoint <ref> --purpose <p> --conclusions <c> --unresolved <u> --next <n> [--item candidate:<text>]... [--operation-id <key>]` | | Resume elsewhere | `loaf exploration context <ref> --json` | -| Track a direction | `loaf intent create --title <t> --body <b> --from <source>...` | -| Defer a direction | `loaf intent create --title <t> --body <b> --disposition deferred --why <w> --boundary <bd> --trigger <tr> --operation-id <key> [--from <source>]` | +| File a direction | `loaf issue new "<title>" --status backlog [--parent <ref>] [--kind delivery\|decision] [--fog <text>] [--body <text>]` | +| Optional bucket | `loaf issue bucket <ref> now\|next\|later\|none` | | Record provenance | `loaf conversation create --title <label>` then `loaf conversation handle add <id> --harness <h> --handle <opaque-id> [--locality <scope>] [--log-ref <path>]` | | Associate conversation | `loaf exploration conversation add <exploration> <conversation-id>` | +`--from` on create accepts journal entries, handoffs, reports, and findings. It does not accept issue, spark, or idea refs — name those in the checkpoint and in the issue body instead. Buckets are labels only and are never read as a constraint. `fog` is writeable only at create. + ## Process 1. **Orient.** If the input names an existing Exploration, run `loaf exploration context <ref>` and continue from its recommended next action. Otherwise check `loaf exploration list` before creating a duplicate inquiry. -2. **Create or resume.** New inquiries get `loaf exploration create` with `--from` links to the Intents, journal entries, reports, or findings that motivated them. +2. **Create or resume.** New inquiries get `loaf exploration create` with `--from` links to the journal entries, reports, findings, or handoffs that motivated them. 3. **Diverge.** Expand the option space before judging it. Use the brainstorm stance (below), research, scouting, prototypes, or spikes as the question demands. -4. **Capture as you go.** Incidental thoughts become sparks; explicit propositions become ideas; deliberately tracked directions become Intents with their sources linked. -5. **Checkpoint.** At every meaningful plateau — and always before ending a session — append a checkpoint with the four portable fields and optional `candidate:`/`evidence:` items. +4. **Capture as you go.** Incidental thoughts become sparks (`loaf spark capture --scope <scope> --text <text>`); explicit propositions become ideas (`loaf idea capture --title "..."`); directions worth keeping become backlog issues. Resolve the capture against the issue so the direction appears once: `loaf spark resolve <ref> --by <issue-ref>` or `loaf idea resolve <ref> --by <issue-ref>`. +5. **Checkpoint.** At every meaningful plateau — and always before ending a session — append a checkpoint with the four portable fields and optional `candidate:`/`evidence:` items. Name any filed issue aliases in conclusions or next. 6. **Record provenance when useful.** Machine-local conversation handles and log locators help forensic navigation later; add them explicitly, and never infer identity from the current session. ## Checkpoint Discipline @@ -80,17 +83,17 @@ The four fields are the portable contract; each is capped at 4096 UTF-8 bytes an - **unresolved** — the open question or decision the inquiry currently turns on. - **next** — the recommended next action, concrete enough for a fresh agent to execute without this conversation. -Larger detail belongs in ordered `--item candidate:` and `--item evidence:` entries or in related reports, not crammed into the core fields. +Larger detail belongs in ordered `--item candidate:` and `--item evidence:` entries or in related reports, not crammed into the core fields. When filing an issue, copy still-unsharp questions into `--fog`; they will not be editable on the issue after create. ## Resumption -A new conversation, harness, or machine resumes with `loaf exploration context <ref> --json`: the portable core returns whole, and each optional layer (items, intents, evidence, conversations) reports counts, truncation, and its exact expansion command. Source handles appear with their last observed availability; treat unavailable ones as lost without ceremony — the checkpoint is the context. If `portable_context_present` is false, the Exploration was never checkpointed: rebuild understanding from linked sources, then write the missing checkpoint first. +A new conversation, harness, or machine resumes with `loaf exploration context <ref> --json`: the portable core returns whole, and each optional layer reports counts, truncation, and its exact expansion command. Source handles appear with their last observed availability; treat unavailable ones as lost without ceremony — the checkpoint is the context. If `portable_context_present` is false, the Exploration was never checkpointed: rebuild understanding from linked sources, then write the missing checkpoint first. -Before continuing, inspect the linked Intents in the context. If an Intent this inquiry was developing has since been resolved, do not silently reopen it: acknowledge the resolution, and if the checkpoint's next action still matters, create a successor Intent, record why in its body, and relate the lineage with `loaf link create --from <new-intent-ref> --to <resolved-intent-ref> --type derived-from`. Continued evidence gathering that serves no unresolved Intent should say so in its next checkpoint. +Before continuing, inspect issue aliases named in the checkpoint. If an issue this inquiry was developing is now done, cancelled, or duplicate, do not silently reopen it: acknowledge the resolution, and if the checkpoint's next action still matters, file a successor backlog issue and record why in its body. Continued evidence gathering that serves no open issue should say so in its next checkpoint. -## Deferring +## Parking a direction -An Exploration is never deferred, paused, or closed — it has no lifecycle to transition. "Defer this exploration" means two concrete acts: checkpoint the current state honestly, then defer the direction it was developing as an Intent — `loaf intent defer` on the linked Intent, or `loaf intent create --disposition deferred` for a new one followed by `loaf link create --from <exploration-ref> --to <intent-ref> --type explores`. The deferred Intent carries the revisit trigger; the Exploration simply waits, resumable from its checkpoint whenever the Intent is resumed. +An Exploration is never paused or closed — it has no lifecycle to transition. When the user wants to park or set aside the inquiry, do two concrete acts: checkpoint the current state honestly, then file the direction it was developing as a backlog issue (`loaf issue new "<title>" --status backlog`, optional `--parent`, `--fog` for remaining unsharp questions, optional `loaf issue bucket <ref> later`). The issue is the revisit surface; the Exploration simply waits, resumable from its checkpoint. ## Techniques @@ -98,8 +101,8 @@ Brainstorm's full divergent stance lives inside Explore: generate options before ## Related Skills -- **pitch** — human problem-discovery front door; route user entry intent here; reach for explore from inside pitch when still undecided -- **triage** — processes the intake queue and may disposition items toward pitch, shape, or agent-side explore -- **shape** — narrows one well-understood direction into a bounded Change +- **pitch** — human problem-discovery front door; route user entry here; reach for explore from inside pitch when still undecided +- **triage** — processes the intake queue and may disposition items toward a backlog issue, pitch, shape, or agent-side explore +- **shape** — prepares a well-understood direction as a bounded issue - **research** — evidence gathering for a known question, usable inside an Exploration - **idea** — quick capture without inquiry diff --git a/dist/skills/foundations/references/code-review.md b/dist/skills/foundations/references/code-review.md index 2c37d2f0b..37c8a2e10 100644 --- a/dist/skills/foundations/references/code-review.md +++ b/dist/skills/foundations/references/code-review.md @@ -86,5 +86,5 @@ Project code review conventions and workflow. | Command | Code Review Role | |---------|-----------------| | implement | Self-review before marking complete | -| breakdown | Review task scope and approach | +| shape | Review issue scope and DoD before implementation | | reflect | Note review feedback patterns | diff --git a/dist/skills/foundations/references/tdd.md b/dist/skills/foundations/references/tdd.md index 28c883709..1ea6fe7c9 100644 --- a/dist/skills/foundations/references/tdd.md +++ b/dist/skills/foundations/references/tdd.md @@ -54,6 +54,6 @@ If the failing test points to a non-obvious root cause, or if your first fix att | Phase | TDD Role | |-------|----------| | shape | Test conditions become TDD test cases | -| breakdown | Each task should have clear test targets | +| shape | Each promoted issue should have clear test targets in its DoD | | implement | Follow TDD cycle for each task | | reflect | Note TDD friction points for improvement | diff --git a/dist/skills/foundations/references/verification.md b/dist/skills/foundations/references/verification.md index d3cf84e1f..142aa7dfc 100644 --- a/dist/skills/foundations/references/verification.md +++ b/dist/skills/foundations/references/verification.md @@ -123,7 +123,7 @@ npm run lint # Check: No errors or warnings | Command | Verification Point | |---------|-------------------| | implement | Before marking session complete | -| breakdown | Each task has verification criteria | +| shape | Each issue has verification criteria (V-tier DoD) | | shape | Test conditions define verification | | reflect | Note verification gaps discovered | diff --git a/dist/skills/git-workflow/SKILL.md b/dist/skills/git-workflow/SKILL.md index 96fd9a0c8..16018d3c0 100644 --- a/dist/skills/git-workflow/SKILL.md +++ b/dist/skills/git-workflow/SKILL.md @@ -23,7 +23,7 @@ Git conventions for branching, commits, PRs, and merge workflow. - Use Conventional Commits format for all commit messages - Commit complete units of work -- don't commit partial or in-progress changes - Squash merge feature branches -- never merge commits directly -- One branch per spec/feature; branch name format: `feat/{slug}` +- One branch per issue; `loaf issue start` creates `issue/<alias-or-id>` (or use `feat/{slug}` / `fix/{slug}` when not starting from an issue) - Never force-push to `main` or shared branches - Never push without explicit user confirmation @@ -37,7 +37,7 @@ Git conventions for branching, commits, PRs, and merge workflow. | Action | Command/Pattern | |--------|----------------| -| Branch naming | `feat/{slug}`, `fix/{slug}`, `chore/{slug}` | +| Branch naming | `issue/<alias-or-id>` from `loaf issue start`; else `feat/{slug}`, `fix/{slug}`, `chore/{slug}` | | Commit format | `type: description` | | Squash merge | `gh pr merge --squash` | | PR creation | `gh pr create --title "..." --body "..."` | diff --git a/dist/skills/git-workflow/references/commits.md b/dist/skills/git-workflow/references/commits.md index a879b8709..3e9e3c00d 100644 --- a/dist/skills/git-workflow/references/commits.md +++ b/dist/skills/git-workflow/references/commits.md @@ -110,13 +110,14 @@ Closes BACK-123 ## Branch Naming ``` +issue/<alias-or-id> <type>/<description> -<type>/TASK-123-description ``` ### Types -- `feat/` - New features (e.g., `feat/spec-010-task-management-cli`) +- `issue/` - Started from `loaf issue start` (`issue/loaf-42`) +- `feat/` - New features (e.g., `feat/thermal-rating-cli`) - `fix/` - Bug fixes - `hotfix/` - Critical production fixes - `release/` - Release preparation @@ -126,7 +127,7 @@ Closes BACK-123 - Lowercase with hyphens (kebab-case) - Short but descriptive (max 50 chars) -- Include spec or task slug when applicable (e.g., `feat/spec-010-task-management-cli`) +- Prefer the started worktree branch from `loaf issue start` when implementing an issue ## Pull Request Format @@ -140,26 +141,10 @@ feat: add thermal rating calculation ### Description -Focus on **review context** — what changed, why, and how to test. Do not include squash merge commit text in the PR body. +The PR body is `loaf issue render <ref>` output — paste-ready, no manual editing. Definition-of-done criteria in the render are the review checklist. Do not include squash merge commit text in the PR body. -```markdown -## Summary - -Brief description of what this PR adds/changes and why. - -- Bullet points covering key changes -- Focus on what a reviewer needs to know - -## Test plan - -- [ ] Unit tests added/updated -- [ ] Integration tests pass -- [ ] Manual testing performed - -## Related Issues - -Closes BACK-123 -Refs BACK-124 +``` +gh pr create --title "type: summary" --body "$(loaf issue render <ref>)" ``` ### Merge Strategy @@ -182,10 +167,10 @@ published release notes read as user-facing prose, not an internal worklog. Internal terms that have no meaning outside the team's working context: -- Spec IDs and task IDs (`SPEC-024`, `TASK-042`) +- Internal work-unit numbering that is not the issue ID (issue IDs like `LOAF-42` belong in commits — release attribution reads them) - Session, sprint, or branch references - Internal terminology from skills/docs that isn't part of the user's mental model — e.g. `Q1`/`Q2`/`Q3` question numbers from a Triage Gate, internal gate-logic notation like `(Q1 OR Q2) AND Q3`, hook IDs that aren't user-facing -- "How the work got done" framing — interview steps, breakdown steps, review gates +- "How the work got done" framing — interview steps, decomposition steps, review gates ### Keep @@ -204,7 +189,7 @@ Internal terms that have no meaning outside the team's working context: ### Auto-generated Entries -When `loaf release` auto-generates the `[Unreleased]` section from commit history, those entries inherit any internal terms present in the commit messages. Treat the generated output as a draft: rewrite it under the curated path before bumping. The release skill preserves curated content when it's already in `[Unreleased]` — curate first, bump second. +`loaf release suggest` drafts notes from landed issues; `loaf release cut` prepends them into `CHANGELOG.md`. Treat drafted notes as a draft: rewrite internal terms before cutting. Curate `[Unreleased]` as PRs land so the later cut reads as user-facing prose. Before approving a release bump, compare `[Unreleased]` against the actual release range and remove scaffolding language introduced by specs, reviews, tasks, or session triage. If an entry only explains why the work was discovered or how the work was organized, it does not belong in the changelog. @@ -228,14 +213,15 @@ Before approving a release bump, compare `[Unreleased]` against the actual relea - Add agent attribution - Mix unrelated changes - Commit secrets or sensitive data -- Put SPEC or TASK IDs in commit subject (use human-readable names) +- Put work-unit IDs in the commit subject (use human-readable names). Issue aliases belong in the body so `loaf release suggest` can attribute the commit. ### ID References - **IDs belong in footer, not subject line** - - Bad: `feat: implement SPEC-002 invisible sessions` - - Good: `feat: implement invisible sessions and task board` + - Bad: `feat: implement LOAF-42 invisible sessions` + - Good: `feat: implement invisible sessions` - Use descriptive names that are understandable without looking up IDs +- Issue aliases (`LOAF-42`) go in the body so release attribution can find them - Linear issue IDs go in footer only (e.g., `Closes BACK-123`) ## Semantic Versioning @@ -277,6 +263,6 @@ BREAKING CHANGE: Description of breaking change. **Convention:** - Use standard SemVer pre-release identifiers (`alpha`, `beta`, or `rc`) when publishing pre-release versions. -- `loaf release` handles all bump types: `prerelease`, `release`, `major`, `minor`, `patch` +- `loaf release cut --bump` handles all bump types: `prerelease`, `release`, `major`, `minor`, `patch` **Not required** — projects using simple `MAJOR.MINOR.PATCH` versioning can ignore pre-release suffixes entirely. This convention is for projects publishing staged pre-releases before stable releases. diff --git a/dist/skills/housekeeping/SKILL.md b/dist/skills/housekeeping/SKILL.md index 6b81b5207..816dc89ac 100644 --- a/dist/skills/housekeeping/SKILL.md +++ b/dist/skills/housekeeping/SKILL.md @@ -1,12 +1,12 @@ --- name: housekeeping description: >- - Reviews and maintains agent artifacts in .agents/ — specs, plans, drafts, - handoffs, councils, and reports. Use when the user asks "housekeeping," "clean - up," or "tidy up .agents/." Provides hygiene recommendations, archives - completed work, and ensures extracted knowledge is preserved. Not for - strategic reflection (use reflect) or knowledge management (use - knowledge-base). + Reviews and maintains agent artifacts in .agents/ plus issue hygiene — + reports, handoffs, councils, archived issues, and stale started worktrees. Use + when the user asks "housekeeping," "clean up," or "tidy up .agents/." Provides + hygiene recommendations, archives completed work, and ensures extracted + knowledge is preserved. Not for strategic reflection (use reflect) or + knowledge management (use knowledge-base). --- # Housekeeping @@ -16,40 +16,43 @@ description: >- - Verification - Quick Reference - Mode-Aware Checks -- Process -- Guardrails -- Related Skills +- Suggests Next +- Topics +- Artifact Naming -Systematic review and archival of all `.agents/` artifacts with Linear-aware checks. +Systematic review of `.agents/` artifacts and issue workspaces. ## Critical Rules **Always** - Log invocation as the first action: `loaf journal log "skill(housekeeping): <scope or trigger>"` - Review EVERY file individually — never sample or average -- Check Linear issue status before archiving linked specs +- Check Loaf issue status (and Linear overlay, if enabled) before archiving linked artifacts - Extract lessons learned and decisions before archiving -- Use CLI (`loaf housekeeping`, `loaf task archive`, `loaf spec archive`) — never raw `mv` +- Use CLI (`loaf housekeeping`, `loaf report archive`, `loaf issue status` / `loaf issue stop`) — never raw `mv` - Treat `.agents/handoffs/` as first-class but disposable: keep active/final handoffs, delete only after confirmed deprecated status -- Check report `status` is `processed` before archiving reports (see [templates/report.md](templates/report.md)) -- In SQLite-backed projects, verify lifecycle changes through `loaf task list --json`, `loaf spec list --json`, and `loaf report list --json`; use `loaf task sync` only for Markdown compatibility repair +- Check report `status` is `done` (or `final`) before archiving reports (see [templates/report.md](templates/report.md)) +- In SQLite-backed projects, verify lifecycle through `loaf issue list --json`, `loaf issue list --started`, `loaf issue list --archived`, and `loaf report list --json` - When delegated subagents are available, use the `librarian` profile for - `.agents/`-scoped durable artifact tending: report/spec/handoff hygiene, + `.agents/`-scoped durable artifact tending: report/handoff hygiene, staleness notes, and lifecycle-safe cleanup recommendations. Housekeeping still owns user confirmation and final archive decisions. -- Log outcome to the project journal: `loaf journal log "decision(housekeeping): archived N specs, M reports"` +- Log outcome to the project journal: `loaf journal log "decision(housekeeping): archived N reports; stopped M stale worktrees"` **Never** - Auto-archive without user confirmation for each artifact - Skip spark extraction before deleting brainstorm drafts - Leave `archived_at` or `archived_by` fields empty in archived files +- Run `loaf issue stop` from inside the started worktree +- Dispatch cleanup agents into a live started worktree another agent occupies ## Verification After work completes, verify: -- Tasks archived via `loaf task archive` -- Specs archived via `loaf spec archive` -- SQLite-backed task/spec/report state reflects lifecycle changes when initialized +- Reports archived via `loaf report archive` after processing +- Archived issues reviewed via `loaf issue list --archived` (`cancelled` / `duplicate` archive through `loaf issue status`) +- Stale started worktrees reviewed via `loaf issue list --started` (a `(missing)` marker means the recorded path is gone) +- SQLite-backed report/issue state reflects lifecycle changes when initialized - Drafts checked for unprocessed sparks before deletion - Handoffs deleted only after explicit deprecation is confirmed - Summary table presented showing all actions taken @@ -61,11 +64,18 @@ After work completes, verify: ```bash loaf housekeeping --dry-run # Preview recommendations loaf housekeeping # Run artifact scanner -loaf task archive TASK-XXX # Archive single task -loaf spec archive SPEC-XXX # Archive single spec -loaf task sync # Compatibility diagnostic in SQLite-backed projects +loaf issue list --started # Started worktrees (alias, title, branch, path) +loaf issue list --archived # cancelled / duplicate rows +loaf issue stop <ref> # Remove worktree; keeps branch; does not change status +loaf issue status <ref> cancelled # Archive an abandoned issue +loaf issue status <ref> duplicate --duplicate-of <surviving> +loaf report archive <report> # Archive a processed report ``` +`loaf housekeeping` still prints leftover `specs` / `tasks` sections when those +SQLite tables have rows — compatibility scan only. Do not create new records +there. The `loaf task` / `loaf spec` CLI is legacy. + The project journal is append-only and never archived — it is not a housekeeping target. It is the canonical record housekeeping reads when extracting decisions before archiving other artifacts. @@ -74,19 +84,12 @@ before archiving other artifacts. | Artifact | Active Location | Archive | Action | |----------|-----------------|---------|--------| -| Tasks (local mode only) | SQLite state | SQLite archived status | `loaf task archive` | -| Specs | SQLite state + `.agents/specs/` authored prose | `archive/` | `loaf spec archive` | +| Issues | SQLite (`loaf issue list`) | `cancelled` / `duplicate` via `loaf issue status` | Confirm, then status; `done` is ship, not housekeeping | +| Started worktrees | `loaf issue list --started` | `loaf issue stop <ref>` | Stop stale or `(missing)` trees after confirmation | | Drafts / brainstorms | SQLite state | SQLite resolved/archived status | User decision (spark extraction first) | | Handoffs | `.agents/handoffs/` | delete | Delete after status is confirmed `deprecated` | | Reports | SQLite state + generated/authored report Markdown | `archive/` | `loaf report archive` after processing | -**Linear-native mode** (when `integrations.linear.enabled` is `true` in -`.agents/loaf.json`): local `TASK-NNN.md` files do not exist for new specs — -Linear issues are the task record. The "Tasks" row above is inert unless the -project has pre-Linear local tasks lingering (see [Mode-Aware Checks](#mode-aware-checks)). -Specs still archive locally — they are the canonical deliberation artifact in -every mode. - ## Cross-Branch Reconciliation If a stale branch reintroduces `.agents/{tasks,ideas,sparks,sessions,brainstorms,drafts}/` @@ -96,35 +99,30 @@ or `.agents/TASKS.json`, keep the deletion from the cutover branch and rerun ## Mode-Aware Checks -When `integrations.linear.enabled` is `true` in `.agents/loaf.json`, apply -these additional checks: +### Started worktrees -### Spec / Linear parent reconciliation +For each row from `loaf issue list --started`: -For each spec file (active and archive) with a `linear_parent:` frontmatter key: +1. If `(missing)`, flag as **stale started workspace** — the row still records a path that is gone. Offer `loaf issue stop <ref>` after confirmation. Stop does not mark the issue `done`. +2. If the path exists but the issue is `done` / `cancelled` / `duplicate`, flag as **worktree outlived the issue** — same offer. +3. If the path exists and status is `active`, leave it unless the user asks to stop. -1. Call `get_issue` with the issue identifier. If it 404s or returns - archived/deleted, flag as **orphaned linear_parent** — the local spec - references a Linear issue that no longer exists. -2. If the spec's local status is `done` (or legacy `complete`) or `archived`, - verify the Linear parent issue is in a `completed`-type state. If not - (e.g., still "In Progress"), flag as **status mismatch** — "Spec marked - complete locally but Linear parent ENG-198 is still 'In Progress'." -3. If the spec's local status is `in_progress` and the Linear parent is - already `completed`, flag the inverse — spec likely needs to be moved to - `done` and archived. +Treat these as **warnings**, not auto-fixes. -Treat all three as **warnings**, not auto-fixes. The user decides resolution. +### Linear overlay -### Pre-Linear local task detection +When `integrations.linear.enabled` is `true` in `.agents/loaf.json`, the tracker +adapter is not shipped. If a report or journal entry names a Linear id next to +a Loaf alias, you may `get_issue` and flag an obvious mismatch (Linear Done vs +Loaf still `active`, or the reverse). Warnings only. Do not drive Loaf status +from Linear. -If Linear is enabled but local task records exist in SQLite, -surface them with context: "Pre-Linear local tasks detected. These aren't -auto-migrated. Either continue using them, run a manual migration, or -archive if superseded by Linear issues." +### Leftover board rows -Do NOT auto-migrate. Migration is user-initiated and out of scope for -housekeeping. +If `loaf housekeeping --dry-run` still reports `tasks` or `specs` cleanup +candidates, surface them: "Legacy board rows are still in SQLite. They are not +the work unit. Archive only if the user confirms they are superseded by Loaf +issues." Do NOT auto-migrate. ## Suggests Next @@ -135,9 +133,9 @@ After housekeeping, suggest reflect if the session produced key decisions or lea | Topic | Reference | Use When | |-------|-----------|----------| | Report Template | [templates/report.md](templates/report.md) | Creating cleanup reports | -| Linear Integration | `orchestration/references/linear.md` | Checking external issue status | +| Linear Integration | `orchestration/references/linear.md` | Checking external tracker overlay | | Journal Continuity | `orchestration/references/journal.md` | Understanding the project journal model | ## Artifact Naming -Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or Change already records that provenance. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. +Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or the issue already records that provenance. Put the source in a front-matter field (`source: LOAF-42`), not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. diff --git a/dist/skills/housekeeping/templates/journal.md b/dist/skills/housekeeping/templates/journal.md index caf7409fd..9a0a61f8f 100644 --- a/dist/skills/housekeeping/templates/journal.md +++ b/dist/skills/housekeeping/templates/journal.md @@ -13,7 +13,7 @@ not create or edit journal markdown as the source of truth — use ## Entries -[YYYY-MM-DD HH:MM] skill(implement): implementing TASK-042 +[YYYY-MM-DD HH:MM] skill(implement): implementing LOAF-42 [YYYY-MM-DD HH:MM] decision(scope): description of decision [YYYY-MM-DD HH:MM] discover(scope): something learned [YYYY-MM-DD HH:MM] block(scope): what is blocked diff --git a/dist/skills/housekeeping/templates/report.md b/dist/skills/housekeeping/templates/report.md index d0894e593..ca201e869 100644 --- a/dist/skills/housekeeping/templates/report.md +++ b/dist/skills/housekeeping/templates/report.md @@ -8,7 +8,7 @@ title: "Report: [Topic]" type: research | audit | analysis | council created: YYYY-MM-DDTHH:MM:SSZ status: draft | done | archived -source: SPEC-XXX | TASK-XXX | ad-hoc +source: LOAF-42 | ad-hoc finalized_at: YYYY-MM-DDTHH:MM:SSZ # Set when status → done archived_at: YYYY-MM-DDTHH:MM:SSZ # Set when status → archived archived_by: orchestrator diff --git a/dist/skills/idea/SKILL.md b/dist/skills/idea/SKILL.md index 63d41d1cb..f0a564252 100644 --- a/dist/skills/idea/SKILL.md +++ b/dist/skills/idea/SKILL.md @@ -3,11 +3,12 @@ name: idea description: >- Captures ideas into structured nuggets for later evaluation. Use when the user says "I have an idea" or "note this down." Also activate when a specific - actionable concept crystallizes during conversation. Ideas and sparks are - capture primitives routed through triage, which chooses dispositions such as - tracking an Intent or handing to pitch. Not for problem discovery (use pitch), - processing the intake queue (use triage), shaping (use shape), or agent-side - divergent inquiry when direction is undecided (use explore as a technique). + actionable concept crystallizes during conversation. Ideas and sparks stay + capture primitives routed through triage, which files worth-keeping items as + backlog issues or hands them to pitch or shape. Not for problem discovery (use + pitch), processing the intake queue (use triage), shaping a bounded issue (use + shape), or agent-side divergent inquiry when direction is undecided (use + explore as a technique). --- # Idea @@ -24,7 +25,6 @@ Capture ideas quickly with minimal friction. - Quick Reference - Purpose - Process -- Idea Lifecycle - Guardrails - Related Skills @@ -34,7 +34,7 @@ Capture ideas quickly with minimal friction. - 2-3 questions maximum -- don't turn capture into an interview - Infer metadata automatically -- don't ask for tags, title, or links - One idea per captured row/artifact -- keep them atomic -- No shaping or pitching here -- problem discovery is pitch; bounding is shape +- No shaping, pitching, or filing issues here -- problem discovery is pitch; bounding is shape; dispositions are triage - Capture through `loaf idea capture --title ...` when SQLite state is initialized; log notable context with `loaf journal log` @@ -56,7 +56,7 @@ Capture ideas quickly with minimal friction. ## Purpose -Ideas are raw nuggets — unprocessed, unshaped, but worth remembering. The goal is **speed of capture**, not thoroughness. An idea is retained material, nothing more: tracking it as an Intent, handing it to pitch for problem discovery, handing it to shape when already well-understood, or archiving it are triage dispositions chosen later by the user. +Ideas are raw nuggets — unprocessed, unshaped, but worth remembering. The goal is **speed of capture**, not thoroughness. An idea is retained material, nothing more: filing it as a backlog issue, handing it to pitch for problem discovery, handing it to shape when already well-understood, or archiving it are triage dispositions chosen later by the user. --- @@ -80,7 +80,7 @@ Ideas are raw nuggets — unprocessed, unshaped, but worth remembering. The goal ## Related Skills -- **triage** — process the intake queue and choose dispositions (including hand to pitch or shape) +- **triage** — process the intake queue and choose dispositions (file as backlog issue, hand to pitch, or hand to shape) - **pitch** — problem-discovery ceremony when a captured idea needs a brief before shaping -- **shape** — develop a well-understood direction into a bounded Change +- **shape** — develop a well-understood direction into a bounded issue - **explore** — agent technique when direction is genuinely undecided (not a user front door) diff --git a/dist/skills/idea/templates/idea.md b/dist/skills/idea/templates/idea.md index b070494d3..b631ca832 100644 --- a/dist/skills/idea/templates/idea.md +++ b/dist/skills/idea/templates/idea.md @@ -11,7 +11,7 @@ title: "[Idea Title]" captured: YYYY-MM-DDTHH:MM:SSZ status: raw tags: [] -related: [] # Optional: spec IDs, idea filenames, or other references +related: [] # Optional: issue aliases, idea filenames, or other references origin: # Optional: draft filename this spark came from (e.g. drafts/YYYYMMDD-brainstorm-slug.md) --- @@ -31,5 +31,5 @@ origin: # Optional: draft filename this spark came from (e.g. dra --- -*Captured via idea -- shape with shape when ready* +*Captured via idea — triage later (backlog issue, pitch, or shape)* ``` diff --git a/dist/skills/implement/SKILL.md b/dist/skills/implement/SKILL.md index d7bd0d6aa..7c3620907 100644 --- a/dist/skills/implement/SKILL.md +++ b/dist/skills/implement/SKILL.md @@ -1,17 +1,18 @@ --- name: implement description: >- - Orchestrates implementation work through agent delegation and batch execution. - Use for all implementation work — features, bug fixes, refactors, and code - changes. Picks Change task files when present and flips checkboxes in - delivering commits. Logs to the project journal and produces agent spawn plans - and progress tracking. Not for shaping (use shape), breakdown (use breakdown), - research, or review. + Orchestrates implementation work through agent delegation and batch execution + against Loaf issues. Use for all implementation work — features, bug fixes, + refactors, and code changes. Picks the next issue from loaf issue frontier, + delegates one agent per started worktree, and treats definition-of-done + criteria as the completion contract. Logs to the project journal and produces + agent spawn plans and progress tracking. Not for shaping or decomposition (use + shape), research, or review. --- # Implement -You are the coordinator. Start by understanding the task: +You are the coordinator. Work units are issues. ## Contents - Critical Rules @@ -19,7 +20,7 @@ You are the coordinator. Start by understanding the task: - Quick Reference - Step 0: Context Check - Input Detection -- Linear-Native Routing +- Pick-up and Dispatch - Agent Spawning - Journal First - Guardrails @@ -37,27 +38,32 @@ You are the coordinator. Start by understanding the task: **You are the ORCHESTRATOR, not the implementer.** -- Log `loaf journal log "skill(implement): <task/spec/context>"` as the first action. -- **Change-first task packets:** prefer `docs/changes/<folder>/tasks/TASK-NNN-*.md` as the delegation brief. Flip checkboxes `- [ ]`→`- [x]` in the same commit that delivers the work (outside `docs/changes/` paths must land with the flip for provenance). Use `loaf change tasks --json` for the index. -- Commit task packets unchecked before executing them — a packet that first lands already-checked induces no flip transition, and the evidence trail never exists. -- Compatibility: existing `TASK-XXX` / `SPEC-XXX` SQLite records remain supported until converted; they are not the default for new work. +- Log `loaf journal log "skill(implement): LOAF-42 — <what>"` as the first action. Substitute the real alias (or opaque id) and a short intent. +- **Pick-up-next is `loaf issue frontier`.** That view is open (`triage` / `backlog` / `todo`), unblocked, and unclaimed (not `active`, no started worktree). Derived at read time. +- **The delegation brief is the issue row** — `loaf issue show <ref>` / `loaf issue render <ref>`: body, definition-of-done criteria, children. There is no other packet. +- **One agent, one worktree.** `loaf issue start <ref>` creates the branch and worktree and moves status to `active`. Before dispatch, run `loaf issue list --started`. Never send two agents into the same worktree. +- **Definition of done is the completion contract.** `loaf issue verify <ref>` runs V-tier criteria from the repository root and writes nothing. H-tier is reviewed by a human or this orchestrator. Completion is the work landing plus `loaf issue status <ref> done`. Do not flip checkboxes. Provenance is the delivering commits and the PR whose body is `loaf issue render <ref>`. +- Shape prepares issues. If `loaf issue check <ref>` does not report the delivery issue shaped (or the decision issue ready), stop and send the work to shape. Do not mint a new issue from this skill. ### Orchestrator Can Do Directly - Log journal entries, read journal context, create council files -- Use your harness's task/todo tracking surface; **if `integrations.linear.enabled` is `true` in `.agents/loaf.json`**, use Linear MCP tools when helpful +- Use your harness's todo tracking surface; **if `integrations.linear.enabled` is `true` in `.agents/loaf.json`**, Linear MCP is an overlay only — Loaf issues remain the work unit and Linear never drives Loaf status - Read any file for context - Ask clarifying questions +- Run `loaf issue` read commands, `loaf issue start` / `stop`, `loaf issue status`, and open a PR whose body is `loaf issue render` output ### Orchestrator MUST Delegate (via agent spawn) -**ALL code changes, documentation edits, and implementation work** to specialized agents. **No exceptions**, even for "trivial" 1-line fixes. +**ALL code changes, documentation edits, and implementation work** to specialized agents. **No exceptions**, even for "trivial" 1-line fixes. Spawn each agent into that issue's started worktree. ## Verification - The invocation is logged to the project journal before implementation work begins — no session start step, no "active session" precondition - All code changes delegated via your harness's agent-spawn mechanism -- no direct edits by orchestrator - The journal is continuously updated with spawns, progress, and decisions as work happens -- Spec artifacts closed out on branch before PR creation -- **Linear-native mode:** `blockedBy` of the target sub-issue is fully `completed` before work begins; starting a sub-issue also promotes an unstarted parent rollup to active; parent rollup is auto-closed only when all sub-issues are `completed` +- Each in-flight issue has exactly one started worktree; `loaf issue list --started` was checked before every spawn +- V-tier criteria pass `loaf issue verify <ref>` (writes nothing); H-tier criteria were reviewed by a human or this orchestrator +- The PR body is `loaf issue render <ref>` with no manual editing; checkboxes stay unchecked until status is `done` +- Completion is landing plus `loaf issue status <ref> done` (usually via ship) ## Quick Reference @@ -72,6 +78,15 @@ You are the coordinator. Start by understanding the task: | Code review/audit | reviewer | relevant domain skills | | Research/comparison | researcher | relevant domain skills | +| Moment | Command | +|--------|---------| +| Pick next | `loaf issue frontier` | +| Brief | `loaf issue show <ref>` / `loaf issue render <ref>` | +| Claim workspace | `loaf issue start <ref>` | +| Occupied trees | `loaf issue list --started` | +| V-tier gate | `loaf issue verify <ref>` | +| Landed | `loaf issue status <ref> done` | + --- ## Step 0: Context Check @@ -82,152 +97,51 @@ Before starting, evaluate context suitability. |---------|--------| | New command/skill added this conversation | **Restart required** (skills loaded at start) | | Conversation > 30 exchanges | Suggest restart | -| Just completed a different task/spec | Suggest clear | +| Just completed a different issue | Suggest clear | | About to start multi-file implementation | Check depth | If restart needed: log current state with `loaf journal log`, then ask the user to restart. A supported startup adapter may reconstruct continuity from the journal in the next conversation; when the exact current target mode is candidate or unsupported, explicitly run `loaf journal context` after restarting. ## Input Detection -Parse `$ARGUMENTS` to determine the work type: +Parse `$ARGUMENTS` to determine the work: | Input Pattern | Type | Action | |---------------|------|--------| -| `TASK-XXX` | Local task | Load via `loaf task show`, log the task coupling | -| `SPEC-XXX` | Spec orchestration | If spec frontmatter has `linear_parent`, resolve to that Linear parent and follow Linear-Native Routing. Otherwise resolve local tasks and build dependency-ready rounds | -| `TASK-XXX..YYY` | Task range | Expand range, build dependency-ready rounds | -| `TASK-XXX,YYY,ZZZ` | Task list | Parse list, build dependency-ready rounds | -| `PLT-123`, `ENG-198`, `PROJ-123` | Linear issue | **If `integrations.linear.enabled` is `true`:** fetch via `get_issue`, then branch on parent vs sub-issue — see [Linear-Native Routing](#linear-native-routing). **Otherwise:** treat as label text or create local task | -| Description text | Ad-hoc | Auto-create local task from description, then fall through to task-coupled flow | - -### Task-Coupled Work - -When starting from `TASK-XXX`: - -1. Load task metadata via `loaf task show TASK-XXX --json`; do not recreate `.agents/TASKS.json` after the SQLite cutover -2. Log the task coupling: `loaf journal log "decision(implement): implementing TASK-XXX"` -3. Load parent spec if task has `spec:` field - -### Ad-hoc Task Auto-Creation - -When input is free-text description (not matching any known pattern): - -1. **Parse the description:** - - Single sentence → use entire text as task title - - Multi-sentence → first sentence = title, remainder = acceptance criteria - - Split on `. ` followed by uppercase letter only (conservative — avoids false positives from URLs, abbreviations) -2. **Create the task:** `loaf task create --title "<parsed title>"` -3. **Write criteria** (if multi-sentence): edit the task `.md` file body to add the remaining sentences as acceptance criteria -4. **Fall through** to the task-coupled flow above — the result is a `TASK-XXX` ID that enters the existing planning pipeline unchanged - -**No user interaction required.** The description IS the task; invoking implement already expressed intent. +| `LOAF-42` or opaque id | Single issue | Load via `loaf issue show <ref>`; fall through to Pick-up and Dispatch | +| Parent ref with children | Tree | `loaf issue tree <ref>`; build rounds from children and `blocks` / `blocked_by` edges (see [batch-orchestration.md](references/batch-orchestration.md)) | +| Multiple refs | Batch | Same round construction across the named set | +| Empty / "next" | Frontier | `loaf issue frontier`; if one row, pick it; if several, ask (structured question tool if the harness has one); if none, stop | +| Description text | Ad-hoc | Match frontier by title. Do not mint. If nothing matches, stop and send to shape | +| Decision kind | Question | Not implementation. Surface the question; do not `loaf issue start` unless the user points at a delivery issue that records the decided answer | -### Non-Existent Task ID Error +### Missing ref -If input matches `TASK-XXX` pattern but `loaf task show` cannot resolve it: +If input looks like an issue ref but `loaf issue show` cannot resolve it: -1. Show error: `"TASK-XXX not found in local task state"` -2. Ask the user: `"Did you mean to create a new task? You can re-run with the description as free text."` -3. **Do not silently create** — the user likely has a typo +1. Show error: `"<ref> not found"` +2. Ask whether they meant a different alias, or to shape a new issue +3. **Do not silently create** --- -## Linear-Native Routing - -Applies when `integrations.linear.enabled` is `true` AND `$ARGUMENTS` -resolves to a Linear issue (direct Linear ID, or a `SPEC-XXX` whose -frontmatter has `linear_parent`). - -Fetch the issue once via `get_issue` and branch on its shape: - -### Parent rollup issue (has `spec` label) - -The issue represents a spec. Do **not** implement it directly — spec-level -"work" is always done via sub-issues. - -1. List sub-issues via `list_issues` with `parent: <parent-id>`. -2. Classify each by state: - - `in_progress` — active work - - `unstarted` + no open `blockedBy` — ready to start - - `unstarted` + open `blockedBy` — blocked - - `completed` — done, skip -3. Select the next work item: - - If one or more sub-issues are `in_progress`, pick the **lowest-ID** - in-progress sub-issue. Resume that. - - Else, if one unblocked `unstarted` sub-issue exists, pick it. - - Else, if multiple unblocked `unstarted` sub-issues exist, use - your harness's structured question tool (if it has one) to let the user choose: pick one, or delegate N in - parallel via parallel agents. List each sub-issue's title + ID. - - Else (all remaining sub-issues are blocked), refuse with a summary: - "All remaining sub-issues under <parent-id> are blocked. Blockers: - <list>." -4. Once a sub-issue is selected, recurse into the sub-issue flow below - with that ID. The parent itself is never the implementation target. - -### Sub-issue (has `parentId`, no `spec` label) - -The issue is an actual task. Implement it directly — with a pre-flight gate. - -1. **Pre-flight: verify `blockedBy` is clear.** For each issue in the - sub-issue's `blockedBy` field, call `get_issue` and confirm its state is - `completed`-type. If any blocker is not Done: - - **Refuse to start.** Do not begin work. Do not move the issue. - - Show the blockers: `"Cannot start <sub-issue-id>. Blocked by: <list - with IDs, titles, and current states>."` - - Suggest: `"Complete the blocker(s) first, or ask to override if the - blockedBy link is stale."` -2. If blockers are clear: - - Start the sub-issue as one logical Linear operation. This moves - the sub-issue to the team's `started`/In Progress state and, when the - parent rollup is still `backlog` or `unstarted`, promotes the parent to - the same `started`/In Progress state. - - If the parent is already active, leave it unchanged. If the parent is - `completed`, `canceled`, or archived, refuse to start unless the user - explicitly asks to override the protected parent state. - - If the child update succeeds but parent promotion fails, report a - reconciliation error naming the parent issue before continuing. - - Resolve branch name from the sub-issue's `branchName` field (Linear - auto-generates one) — see - [branch-and-completion.md](references/branch-and-completion.md). - - Log the task coupling, then continue with the standard Startup Checklist. - -### Completion (after implementer + reviewer finish cleanly) - -When the sub-issue's implementation passes review and tests: - -1. Move the sub-issue to the team's `completed`-type state via - `update_issue` (look up via `list_issue_statuses`, filter - `type: "completed"`). -2. Query the parent's sub-issues again: - - If **all** sub-issues are now `completed`-type, move the parent - rollup to `completed` as well. Also mark the local spec as - `complete` (see [Then Execute → AFTER](#then-execute)). - - If **some** remain, list them as "next available" for the user, - applying the same classification as step 2 of the parent flow above. - Offer to continue with the next one in this session, or stop here. -3. **Do not** close the parent while any sub-issue is open — not even if - only `blocked` ones remain. Blocked sub-issues are still in-flight - work from the spec's perspective. - -### Status flow summary - -| Moment | Sub-issue state | Parent state | -|--------|----------------|--------------| -| Implementation starts | `started` / In Progress | promoted to `started` / In Progress if still `backlog` or `unstarted` | -| Implementation + review pass | `completed` | check: close only if all sibs completed | -| Blocker discovered mid-work | `in_progress` + blocker comment | unchanged | - -### What Linear-native routing does NOT do - -- Does not pull down the full spec text. The parent's description already - links to `.agents/specs/SPEC-NNN-*.md`. Read the local file for shape, - rabbit holes, and strategic tensions. -- Does not create or rewrite sub-issues. That's breakdown's job. If - implementation reveals a missing task, surface it to the user; they - decide whether to run breakdown again or add an ad-hoc sub-issue. -- Does not sync in-progress state bidirectionally. Source of truth at any - moment: Linear for issue state, local files for spec content, the project - journal for current handoff. +## Pick-up and Dispatch + +1. **Confirm the issue is implementable.** `loaf issue check <ref>` must report a delivery issue shaped (or, if the user explicitly asked to resolve a decision issue, that it is ready). Unshaped work goes to shape. +2. **Honor the frontier.** An issue that is blocked does not appear on `loaf issue frontier`. `loaf issue link A blocks B` means A blocks B; B waits until A is `done`, `cancelled`, or `duplicate`. Do not start a blocked successor. Parent/child structure from `loaf issue tree` is not a sequencing edge — only `blocks` / `blocked_by` are. Use the tree to know who belongs in the batch; use the edges to order rounds. +3. **Parents with children are not the implementation target.** Dispatch leaf delivery children that are on the frontier. A parent executes through claimed child criteria, not by starting the parent worktree. +4. **Inspect occupied worktrees:** + ```bash + loaf issue list --started + ``` + Columns: alias, title, `started_branch`, `started_worktree`, optional `(missing)`. If this ref is already started, resume in that worktree with one agent. If the path is occupied by another issue, refuse. A `(missing)` marker means the recorded path is gone — `loaf issue stop <ref>` (not from inside the tree) before starting again. +5. **Start the workspace** (skip if already started and the path exists): + ```bash + loaf issue start <ref> + ``` + Creates branch `issue/<alias-or-id>` in lowercase (`issue/loaf-42`, id suffix when that name is claimed), a sibling worktree, and sets status to `active`. Base is the nearest started ancestor's branch, else the repository default branch. Start refuses archived rows and terminal statuses (`done`, `cancelled`, `duplicate`). +6. **Hand the agent the brief** from `loaf issue show <ref>` (body, criteria, children) and, when opening a PR, `loaf issue render <ref>`. Tell the agent to work only in `started_worktree`. +7. **Batch rounds.** When input is a parent or a set of refs, group unblocked delivery children into dependency-ready rounds from `blocked_by` edges and parent/child structure. Parallel only within a round, max 3, and only when each agent has its own worktree. See [batch-orchestration.md](references/batch-orchestration.md) for the round loop, `--dry-run` / `--parallel` / `--continue` / `--skip <ref>` / `--abort`, and blocked-state recovery. --- @@ -246,7 +160,7 @@ Spawn specialized agents with the appropriate profile: | Code review/audit | reviewer | relevant domain skills | | Research/comparison | researcher | relevant domain skills | -**Rules:** Be specific in prompts. One concern per agent. Include context. Parallel when independent, sequential when dependent. +**Rules:** Be specific in prompts. One concern per agent. Include the issue ref, `started_worktree`, body, and definition of done. Parallel when independent (separate worktrees), sequential when a `blocks` edge says so. --- @@ -255,15 +169,14 @@ Spawn specialized agents with the appropriate profile: There is no session to start — journaling is continuous. Your first action is to log the invocation: ```bash -loaf journal log "skill(implement): <task/spec/context>" +loaf journal log "skill(implement): LOAF-42 — <what>" ``` Entries are project-scoped and tagged with this conversation's harness id automatically. Continuity from prior conversations may arrive through a supported startup adapter; when the exact current target mode is candidate or unsupported, pull it explicitly with `loaf journal context`. Use `loaf journal recent` when you need a narrower timeline. -Suggest renaming the harness conversation with a meaningful name derived from context (use your harness's rename surface if it has one): -- From spec: `SPEC-027-session-stability` -- From task: `TASK-042-login-fix` -- From ad-hoc: `{short-slug-from-description}` +Suggest renaming the harness conversation with a meaningful name derived from the issue (use your harness's rename surface if it has one): +- From issue: `LOAF-42-login-fix` +- From ad-hoc match: `{alias}-{short-slug}` --- @@ -277,12 +190,14 @@ Suggest renaming the harness conversation with a meaningful name derived from co 6. **Journal continuously** -- log spawns, progress, blockers, and decisions with `loaf journal log` as they happen 7. **Clean up** -- no ephemeral files; write an optional `wrap` entry only when there's synthesis worth saving 8. **When in doubt, ask the user** +9. **Never `loaf issue stop` from inside the started worktree** -- stop does not change status; `--force` removes a dirty tree +10. **Do not tick definition-of-done boxes** -- `loaf issue verify` writes nothing; render checks a box only when status is already `done` ## Decision Tree ``` Is this a code/config/doc change? -+-- YES -> Spawn appropriate agent ++-- YES -> Spawn appropriate agent into the issue worktree +-- NO -> Is this a planning/coordination decision? +-- YES with clear path -> Proceed, log the decision +-- YES but ambiguous -> Ask user @@ -295,18 +210,16 @@ When multiple valid approaches exist: spawn council (5-7 agents, odd), present r ## Startup Checklist -1. [ ] Log the invocation: `loaf journal log "skill(implement): <context>"` -2. [ ] Parse input (task, Linear ID, or description) -3. [ ] If TASK-XXX: load task via `loaf task show TASK-XXX`, log task coupling, load parent spec -4. [ ] If Linear ID (or `SPEC-XXX` with `linear_parent`): follow [Linear-Native Routing](#linear-native-routing). Parent → walk sub-issues and select next. Sub-issue → verify `blockedBy` is clear, then start it as one logical Linear operation so the parent is promoted when needed -5. [ ] If description: auto-create task (see Ad-hoc Task Auto-Creation above) -6. [ ] Create dedicated branch (see [branch-and-completion.md](references/branch-and-completion.md)) -7. [ ] Suggest team based on task context -8. [ ] Log initial context and references with `loaf journal log` -9. [ ] Break down work using your harness's task/todo tracking surface -10. [ ] Identify needed specialized agents -11. [ ] Log next steps before spawning -12. [ ] **Get user approval** before spawning +1. [ ] Log the invocation: `loaf journal log "skill(implement): LOAF-42 — <what>"` +2. [ ] Parse input (issue ref, parent, set, frontier, or description) +3. [ ] Load `loaf issue show <ref>`; if children, `loaf issue tree <ref>` +4. [ ] `loaf issue check <ref>` — shaped/ready, or stop and send to shape +5. [ ] Confirm the ref is on `loaf issue frontier` (or already started for resume) +6. [ ] `loaf issue list --started` — one agent per worktree +7. [ ] `loaf issue start <ref>` unless already started +8. [ ] Suggest conversation rename (`LOAF-42-login-fix`) +9. [ ] Identify specialized agents; log next steps +10. [ ] **Get user approval** before spawning --- @@ -314,32 +227,28 @@ When multiple valid approaches exist: spawn council (5-7 agents, odd), present r ### BEFORE (Planning) 1. Log the invocation with `loaf journal log` -2. Set task status: `loaf task update TASK-XXX --status in_progress` -3. Break down work into agent-sized tasks -4. Identify spawn order (respect dependencies) +2. `loaf issue start <ref>` (status becomes `active` through start) +3. Slice work into agent-sized units that still belong to this one issue +4. Identify spawn order (respect `blocked_by` edges and parent/child rounds) 5. Get user approval ### DURING (Execution) -1. Spawn specialized agents via your harness's agent-spawn mechanism -2. Log each spawn with `loaf journal log "todo(agent): spawned <agent> for <task>"` -3. Update Linear with progress (no emoji, no file paths) -4. Keep journal entries handoff-ready -5. After each agent completes: log outcome, spawn next +1. Spawn specialized agents into `started_worktree` via your harness's agent-spawn mechanism +2. Log each spawn with `loaf journal log "todo(agent): spawned <agent> for <ref>"` +3. Keep journal entries handoff-ready +4. After each agent completes: log outcome, spawn next +5. If Linear overlay is enabled, you may comment there — Loaf status stays on `loaf issue` ### AFTER (Completion) 1. Code review pass (spawn `reviewer` agent) 2. Spawn implementer (with foundations + language skill) for final testing -3. **Close out spec artifacts on the branch** (included in the squash merge): - - **Local-tasks mode:** `loaf task update TASK-XXX --status done` (per task), then `loaf task archive --spec SPEC-XXX` - - **Linear-native mode:** `update_issue` the sub-issue to `completed`-type state. Then query the parent's sub-issues; if all are `completed`, also close the parent. If some remain, list them for the user (see [Linear-Native Routing → Completion](#completion-after-implementer--reviewer-finish-cleanly)) - - Mark spec complete and archive: `loaf spec archive SPEC-XXX` (both modes) - - Write a `wrap(scope)` journal entry if the work produced synthesis worth saving (next steps, abandoned paths); otherwise skip it - - Commit: `chore: close SPEC-XXX — archive tasks and spec` -4. If on a feature branch: push and create PR (`gh pr create`). Follow PR format and squash merge conventions in [commits reference](../git-workflow/references/commits.md). -5. After PR is created and approved, use ship to review, verify, and land the PR. Use release later when a coherent batch of landed work is ready to publish. -6. **Suggest reflection:** Check the journal for extractable learnings before closing out: +3. Run `loaf issue verify <ref>` (V-tier, writes nothing). Review every H-tier row yourself or with the user — a skip from verify is not a pass +4. Open or update the PR with body `loaf issue render <ref>` — no manual editing. Follow PR format and squash merge conventions in [commits reference](../git-workflow/references/commits.md) +5. After the PR is created, use ship to review, verify, land, mark `loaf issue status <ref> done`, and `loaf issue stop <ref>`. Use release later when a coherent batch of landed work is ready to publish +6. Write a `wrap(scope)` journal entry if the work produced synthesis worth saving; otherwise skip it +7. **Suggest reflection:** Check the journal for extractable learnings before closing out: - `decision(...)` entries are present - - ADRs, report verdicts, or spec changelog entries were recorded + - ADRs or report verdicts were recorded If any signal is present, suggest: *"This produced key decisions. Consider running reflect to update strategic docs."* If none are present, stay silent. --- @@ -348,18 +257,18 @@ When multiple valid approaches exist: spawn council (5-7 agents, odd), present r | Topic | Reference | Use When | |-------|-----------|----------| -| Batch Orchestration | [batch-orchestration.md](references/batch-orchestration.md) | Running specs, task ranges, or task lists with dependency-ready rounds | -| Branch and Completion | [branch-and-completion.md](references/branch-and-completion.md) | Branch management, team routing, diagrams, Linear sync, journaling, task completion | +| Batch Orchestration | [batch-orchestration.md](references/batch-orchestration.md) | Running a parent or a set of issue refs with dependency-ready rounds | +| Branch and Completion | [branch-and-completion.md](references/branch-and-completion.md) | Team routing, diagrams, exploration, journaling alongside `loaf issue start` / `stop` | +| Working issues locally | [../orchestration/references/local-tasks.md](../orchestration/references/local-tasks.md) | Frontier, started worktrees, status vocabulary, definition of done | --- ## Suggests Next -After all tasks are complete, suggest ship to land the PR. Suggest release only when the landed work forms a coherent release batch. +After the PR exists, suggest ship to land it. Suggest release only when the landed work forms a coherent release batch. ## Related Skills -- **shape** - Spec format and lifecycle -- **breakdown** - Turning specs into tasks -- **orchestration/local-tasks** - Task file format and lifecycle -- **orchestration/journal** - Project journal continuity model +- **shape** — Issue preparation and decomposition +- **orchestration/journal** — Project journal continuity model +- **orchestration/local-tasks** — Frontier, started worktrees, status, definition of done diff --git a/dist/skills/implement/references/batch-orchestration.md b/dist/skills/implement/references/batch-orchestration.md index 7ab48f755..7902f28f1 100644 --- a/dist/skills/implement/references/batch-orchestration.md +++ b/dist/skills/implement/references/batch-orchestration.md @@ -7,63 +7,64 @@ - Batch Execution Model - Blocked-State Recovery -Detailed reference for running specs, task ranges, or task lists with dependency-ready scheduling. +Detailed reference for running a parent issue or a set of issue refs with dependency-ready scheduling. ## Orchestration Options | Option | Behavior | |--------|----------| | `--dry-run` | Show dependency-ready execution plan, do not run agents | -| `--parallel` | Run tasks in the same dependency-ready group concurrently (max 3 at once) | -| `--continue` | Resume a blocked orchestration from the recorded task/group | -| `--skip TASK-XXX` | Mark one blocked task as skipped and continue | +| `--parallel` | Run issues in the same dependency-ready group concurrently (max 3 at once) | +| `--continue` | Resume a blocked orchestration from the recorded issue/group | +| `--skip <ref>` | Skip one blocked issue and continue | | `--abort` | Mark orchestration as aborted and stop remaining work | ## Batch Resolution and Dependency-Ready Scheduling -For `SPEC-XXX`, `TASK-XXX..YYY`, and `TASK-XXX,YYY,ZZZ`: +For a parent ref (`loaf issue tree <ref>`) or a named set of refs: -1. Resolve selected tasks and validate each task file exists. -2. Extract `depends_on` from each task and build a dependency graph. -3. Group tasks into dependency-ready rounds: - - First round: tasks with no unresolved dependencies - - Each subsequent round: tasks whose dependencies are completed in earlier rounds -4. If `--parallel` is set, allow parallel execution only within a dependency-ready round and only for non-conflicting tasks. -5. Present execution plan (tasks, dependency-ready rounds, mode, total count) and ask for confirmation unless `--dry-run`. -6. Track progress in the journal and in task statuses: log round boundaries and the current task with `loaf journal log`, and drive each task's status with `loaf task update`. The journal plus task statuses are the durable record of where the batch is. +1. Resolve the selected refs and validate each issue exists (`loaf issue show <ref>`). +2. Read `blocks` / `blocked_by` edges and parent/child structure. Parent/child is not a sequencing edge — only `blocks` / `blocked_by` are. +3. Group unblocked delivery children into dependency-ready rounds: + - First round: issues with no unresolved predecessors + - Each subsequent round: issues whose predecessors are `done`, `cancelled`, or `duplicate` +4. If `--parallel` is set, allow parallel execution only within a dependency-ready round, max 3, and only when each agent has its own started worktree. +5. Present execution plan (issues, dependency-ready rounds, mode, total count) and ask for confirmation unless `--dry-run`. +6. Track progress in the journal: log round boundaries and the current ref with `loaf journal log`. Status moves through `loaf issue start` (to `active`) and, after landing, `loaf issue status <ref> done`. The journal plus issue statuses are the durable record of where the batch is. + +Parents with children are not the implementation target. Dispatch leaf delivery children that are on `loaf issue frontier`. ## Option Handling (`--continue`, `--skip`, `--abort`) -1. Recover batch progress from the journal: `loaf journal recent --since-last-wrap` (or `loaf journal context`) plus `loaf task list --json` to see which tasks are still open. -2. If `--continue`: resume from the last logged dependency-ready round and task. -3. If `--skip TASK-XXX`: mark that task `skipped` via `loaf task update`, log the reason with `loaf journal log`, continue the same dependency-ready round. +1. Recover batch progress from the journal: `loaf journal recent --since-last-wrap` (or `loaf journal context`) plus `loaf issue list --json` and `loaf issue list --started` to see which issues are still open or claimed. +2. If `--continue`: resume from the last logged dependency-ready round and issue. +3. If `--skip <ref>`: log the reason with `loaf journal log`, continue the same dependency-ready round. Do not mark the skipped issue `done`. 4. If `--abort`: log `block(orchestration): aborted`, print a summary, and stop. 5. If no in-flight batch is evident from the journal, report that and ask for fresh selection input. ## Batch Execution Model -When input resolves to multiple tasks, run a dependency-ready round loop: +When input resolves to multiple issues, run a dependency-ready round loop: 1. Set orchestration mode (`sequential` by default, `parallel` only with `--parallel`). 2. For each dependency-ready round: - Log the round start with `loaf journal log` - - Run each task (sequentially, or concurrently within safety limits) - - For each task: set `in_progress` -> spawn agent -> run task verification -> mark `done`/`failed` via `loaf task update` -3. If any task fails verification, stop immediately and log `block(orchestration): <task> failed <reason>`. -4. Consider a round complete only when all its tasks are `done` or skipped. + - For each issue: `loaf issue list --started`, then `loaf issue start <ref>` unless already started, spawn one agent into `started_worktree`, run `loaf issue verify <ref>` (V-tier; writes nothing) +3. If any issue fails verification, stop immediately and log `block(orchestration): <ref> failed <reason>`. +4. Consider a round complete only when all its issues have landed (`loaf issue status <ref> done` via ship) or were skipped. 5. Continue until all rounds complete, then log a closing entry summarizing the batch. ## Blocked-State Recovery When blocked, always print: -- Failed task ID and title +- Failed issue ref and title - Dependency-ready round and current progress - Failure reason + key error output - Recovery options: Re-invoke the implement workflow with: -- `--continue` — after fixes are applied, retry from the blocked task -- `--skip TASK-XXX` — skip only the specified task and continue remaining tasks in the current dependency-ready round +- `--continue` — after fixes are applied, retry from the blocked issue +- `--skip <ref>` — skip only the specified issue and continue remaining issues in the current dependency-ready round - `--abort` — finalize the orchestration as aborted with no further execution diff --git a/dist/skills/implement/references/branch-and-completion.md b/dist/skills/implement/references/branch-and-completion.md index e4bea8b5d..5c89cdf02 100644 --- a/dist/skills/implement/references/branch-and-completion.md +++ b/dist/skills/implement/references/branch-and-completion.md @@ -8,7 +8,7 @@ - Linear Status Management - Handoff Readiness - Timestamps for User Context -- Task Completion +- Issue Completion Detailed reference for branch setup, Linear routing, and completion during implementation. @@ -18,28 +18,18 @@ Detailed reference for branch setup, Linear routing, and completion during imple ### Getting Branch Name -1. **If Linear issue exists**: Use the `branchName` field from `get_issue` response - - Linear auto-generates branch names like `username/plt-123-issue-title` - - These are pre-formatted and consistent with team conventions +`loaf issue start <ref>` is the claim. It creates `issue/<alias-or-id>` in lowercase (`issue/loaf-42`, id suffix when that name is claimed), a sibling worktree, and moves status to `active`. -2. **If no Linear issue**: Create branch name from the work description - - Format: `feature/<description>` or `fix/<description>` - - Use kebab-case, keep it concise +Do not `git checkout -b` as a substitute for start. Check `loaf issue list --started` first. Never send two agents into the same worktree. Do not run `loaf issue stop` from inside that worktree. ### Branch Workflow ```bash -# 1. Check current branch status -git status - -# 2. Create and checkout the branch (use Linear's branchName if available) -git checkout -b <branch-name> - -# 3. Confirm branch creation -git branch --show-current +loaf issue list --started +loaf issue start <ref> ``` -**Important:** All implementation agents will work on this branch. The branch should be ready for PR when work completes. Journal entries are tagged with the observed branch automatically, so continuity stays branch-scoped. +Work only in `started_worktree`. The branch should be ready for PR when work completes. Journal entries are tagged with the observed branch automatically. --- @@ -53,7 +43,7 @@ When creating Linear issues, suggest the appropriate team: > "This task seems best suited for the **Security** team (matched: 'auth', 'vulnerability'). > Security hasn't been used in this project yet. Add this team?" 4. **If user confirms**, add team to `known_teams` in config -5. **Create issue** with suggested team +5. **Create via `loaf issue new`** so identity can be delegated; do not create in Linear MCP and forget `loaf issue pull` ### Team Suggestion Example @@ -75,7 +65,7 @@ Use Linear MCP's `list_teams` (if configured) to get all workspace teams for val ## Diagram Consideration -For multi-file or multi-service changes, consider adding architecture diagrams to the linked spec, report, ADR, or implementation notes. +For multi-file or multi-service changes, consider adding architecture diagrams to the issue, a report, ADR, or implementation notes. ### When to Create Diagrams @@ -94,7 +84,7 @@ Ask yourself: 2. Is there a data flow that needs to be understood? 3. Would a visual help communicate the approach? -If yes to any, capture the diagram in a durable artifact such as a spec, report, ADR, or implementation note, and log the reference with `loaf journal log`. +If yes to any, capture the diagram in a durable artifact such as a report, ADR, or implementation note, and log the reference with `loaf journal log`. ### Diagram Template @@ -146,36 +136,23 @@ For complex tasks, explore before implementing: ## Linear Status Management -**Keep Linear status synchronized with actual work state:** - -| Work State | Linear Status (sub-issue) | -|------------|---------------------------| -| Work begun | In Progress | -| Blocked/waiting for user | In Progress (add blocker comment) | -| Work completed | Done (or In Review if PR pending) | +**Keep Loaf status synchronized with actual work state.** Linear is an overlay (`loaf issue pull` / `push` / `reconcile`); never drive Loaf status from Linear MCP tools. -### Parent rollup auto-close +| Work State | Loaf status | +|------------|-------------| +| Work begun | `active` via `loaf issue start` | +| Blocked/waiting | Stay `active`; log `block(scope)` and leave a Linear comment if the overlay is on | +| Work landed | `done` via `loaf issue status <ref> done` (usually ship), then `loaf issue stop <ref>` | -In Linear-native mode, the **parent** rollup issue (labeled `spec`) is not -moved manually during sub-issue work. It flips to Done automatically when -the last sub-issue flips to Done, and only then. Procedure: +### Parent vs children -1. After moving a sub-issue to a `completed`-type state, call - `list_issues` with `parent: <parent-id>`. -2. If every sub-issue is in a `completed`-type state, move the parent to - `completed` via `update_issue`. -3. If any sub-issue is still in an open state (including `blocked`), the - parent stays where it is — the spec is not done. +Parents with children are not the implementation target. Dispatch leaf delivery children on `loaf issue frontier`. A parent is not marked `done` because a child landed. -Never set the parent to In Progress manually — a parent in Linear-native -mode reflects a rollup of its sub-issues, not its own work. +`loaf issue link A blocks B` is the sequencing edge. An issue with an open predecessor does not appear on the frontier. Do not start a blocked successor. -### BlockedBy pre-flight +### Blocked-by pre-flight -Before moving a sub-issue to In Progress, confirm every issue in its -`blockedBy` field is in a `completed`-type state. If not, refuse to start -and report the blockers. This is a hard gate in Linear-native mode — -never implement through open `blockedBy`. +Before `loaf issue start`, confirm the ref is on `loaf issue frontier`. If it is blocked, refuse and report the predecessors. Never implement through an open `blocks` edge. --- @@ -184,7 +161,7 @@ never implement through open `blockedBy`. **The journal must ALWAYS be handoff-ready.** After every significant action: 1. Log what just happened with `loaf journal log` -2. Reference task/spec/report/commit IDs rather than duplicating long prose +2. Reference issue/report/commit IDs rather than duplicating long prose 3. Log completed agent work with outcomes 4. Ensure anyone could pick up the work immediately from `loaf journal recent` @@ -205,32 +182,18 @@ Generate with: `date -u +"%Y-%m-%d %H:%M UTC"` --- -## Task Completion +## Issue Completion -When a task-coupled unit of work completes: +When an issue-coupled unit of work completes: -1. **Update task status** (local file or Linear sub-issue) -2. **Check spec progress:** - - Local-tasks mode: list all tasks for the spec; if all done → mark - spec `complete`, else spec stays `implementing` - - Linear-native mode: query the parent rollup's sub-issues via - `list_issues` with `parent: <parent-id>`; if all are `completed`-type, - close the parent and mark the local spec `complete`, else both stay - in flight -3. **Write a `wrap` journal entry** if the conversation holds synthesis worth - saving (next steps, abandoned paths); skip it otherwise — nothing is - "closed," a conversation that ends without a wrap leaves a valid journal - -### Spec Completion Check +1. **Open or update the PR** with body `loaf issue render <ref>` — no manual editing +2. **Land via ship** — review definition of done, `loaf issue verify <ref>`, squash merge, then `loaf issue status <ref> done` and `loaf issue stop <ref>` +3. **Write a `wrap` journal entry** if the conversation holds synthesis worth saving (next steps, abandoned paths); skip it otherwise — nothing is "closed," a conversation that ends without a wrap leaves a valid journal ```bash -# Local-tasks mode: any open tasks for this spec? -loaf task list --spec SPEC-001 --status open --json - -# Linear-native mode: query the Linear parent's sub-issues -# (via get_issue + list_issues with parent filter) -# The parent itself only flips to Done when every sub-issue is Done. +loaf issue show <ref> +loaf issue tree <ref> +loaf issue list --started ``` -Never mark the local spec `complete` while its Linear parent still has -open sub-issues — the two sources of truth should agree on "done." +Do not mark a parent `done` while delivery children are still open. Do not flip Loaf status from Linear MCP tools; use `loaf issue reconcile` if the overlay has drifted. diff --git a/dist/skills/implement/templates/journal.md b/dist/skills/implement/templates/journal.md index caf7409fd..9a0a61f8f 100644 --- a/dist/skills/implement/templates/journal.md +++ b/dist/skills/implement/templates/journal.md @@ -13,7 +13,7 @@ not create or edit journal markdown as the source of truth — use ## Entries -[YYYY-MM-DD HH:MM] skill(implement): implementing TASK-042 +[YYYY-MM-DD HH:MM] skill(implement): implementing LOAF-42 [YYYY-MM-DD HH:MM] decision(scope): description of decision [YYYY-MM-DD HH:MM] discover(scope): something learned [YYYY-MM-DD HH:MM] block(scope): what is blocked diff --git a/dist/skills/loaf-reference/SKILL.md b/dist/skills/loaf-reference/SKILL.md index 31169f792..adef3b43b 100644 --- a/dist/skills/loaf-reference/SKILL.md +++ b/dist/skills/loaf-reference/SKILL.md @@ -24,7 +24,7 @@ The Loaf operating manual for agents: how to discover commands, diagnose project ## Operating Rules - Get exact, current syntax live: `loaf --help` lists every command, `loaf <command> --help` details one. This index is a map, not the contract. -- Prefer `--json` surfaces when diagnosing: `loaf config check --json`, `loaf state doctor --json`, `loaf change check --json`. Parse the structured output instead of scraping human-readable text. +- Prefer `--json` surfaces when diagnosing: `loaf config check --json`, `loaf state doctor --json`. Parse the structured output instead of scraping human-readable text. - Run the deterministic CLI command before hand-editing anything it manages; the command owns its files. - Use `--fix` only for safe, mechanical repairs, and review what it changed. - Ask the user for project-owned choices — GitHub account, tracker or integration election, which harnesses to install — never guess them. @@ -63,17 +63,16 @@ Names and one-line purposes only. Run `loaf <command> --help` for options, argum | `loaf config` | Validate and refresh project Loaf config | check | | `loaf hooks` | Inspect and set which Loaf hooks project into an installed harness's hooks file | list, enable, disable | | `loaf init` | Initialize a project with Loaf structure | — | -| `loaf release` | Create a new release with changelog, version bump, and tag | — | +| `loaf release` | Cut a retroactive release from already-landed work | suggest, cut | | `loaf search` | Search SQLite artifact bodies, journal entries, and indexed docs | — | | `loaf docs` | Manage docs/ indexing | index | -| `loaf change` | Shape-first Change artifacts: git-canonical work context under docs/changes/ | init, check, list | | `loaf render` | Maintain committed durable Markdown renders | sweep | | `loaf state` | Manage native SQLite state | path, status, init, doctor, repair legacy-project-database, repair relationship-origin, repair journal-search, migrate markdown, migrate storage-home, migrate schema, migrate deferrals, migrate lifecycle-statuses, migrate alias-orphans, migrate journal-duplicates, migrate journal-first, backup, backup verify, backup restore, restore-ephemerals, verify-ephemerals, export, export all, export triage, export spec, export release-readiness | | `loaf journal` | Record and read the project-scoped journal (the durable record across all conversations) | log, recent, search, show, context, export, defer | | `loaf project` | Manage durable project identity | list, show, identity, rename, move, delete | | `loaf migrate` | Run native migration workflows | markdown, storage-home, schema, lifecycle-statuses, journal-first, worktree-storage | -| `loaf task` | Manage project tasks | list, show, status, create, update, archive, refresh, sync | -| `loaf spec` | Manage project specs | new, edit, list, show, status, render, finalize, archive, delete | +| `loaf task` | Manage project tasks; superseded by loaf issue for new work | list, show, status, create, update, archive, refresh, sync | +| `loaf issue` | Manage issues in native SQLite state | new, show, list, tree, frontier, start, stop, edit, status, dod, dod add, dod list, dod remove, dod claim, dod unclaim, promote, check, verify, bucket, link, render, export, pull, push, reconcile | | `loaf report` | Manage durable reports (research, audits, investigations) | list, show, render, generate, create, edit, finalize, archive | | `loaf finding` | Manage report findings and verdicts in native SQLite state | list, show, create, verdict, import-json | | `loaf run` | Manage provenance runs for generated findings and reports | list, show, create, complete | @@ -87,7 +86,7 @@ Names and one-line purposes only. Run `loaf <command> --help` for options, argum | `loaf trace` | Trace relationships for one state entity | — | | `loaf brainstorm` | Manage brainstorms in native SQLite state | capture, list, show, promote, archive | | `loaf idea` | Manage ideas in native SQLite state | list, show, capture, promote, resolve, archive | -| `loaf intent` | Manage tracked Intent in native SQLite state; disposition is derived from append-only facts | create, defer, resume, resolve, show, list | +| `loaf intent` | Manage tracked Intent in native SQLite state; disposition is derived from append-only facts; superseded by loaf issue for new work | create, defer, resume, resolve, show, list | | `loaf intake` | Read the deterministic local intake projection; triage judgment stays with humans and Skills | list | | `loaf exploration` | Manage relational Exploration continuity: immutable portable checkpoints, no lifecycle status, no current pointer | create, checkpoint, list, context, conversation | | `loaf conversation` | Manage logical conversations and machine-local provenance handles; handles never imply portable context | create, show, list, handle, observe | diff --git a/dist/skills/loaf-reference/references/command-routing.md b/dist/skills/loaf-reference/references/command-routing.md index e6856fd65..5f9d30bbb 100644 --- a/dist/skills/loaf-reference/references/command-routing.md +++ b/dist/skills/loaf-reference/references/command-routing.md @@ -6,15 +6,15 @@ Which command a task needs. For exact flags, run `loaf <command> --help`. | Intent | Route | |--------|-------| -| Shape new bounded work | `loaf change init <slug>`, then `loaf change check` | -| Start implementing new bounded work | the implement workflow after shaping and validating its Change | -| Continue an existing task or spec record | `loaf task` and `loaf spec` remain supported for existing records | +| Shape new bounded work | `loaf issue new <title>`, then `loaf issue dod add` and `loaf issue check <ref>` | +| Start implementing new bounded work | the implement workflow: pick from `loaf issue frontier`, then `loaf issue start <ref>` | +| Continue an existing task or spec record | `loaf task` and `loaf spec` remain readable for legacy records; new work is issues | | Continue after a restart | `loaf journal context` | | Skills or content changed | `loaf build && loaf install --to <target>` | -| See what is in progress | `loaf task list --active` | -| Archive completed work | `loaf task archive` | +| See what is in progress | `loaf issue list --status active` and `loaf issue list --started` | +| Remove finished-with work | `loaf issue status <ref> cancelled` or `duplicate --duplicate-of <ref>` (archives; record survives) | | Check knowledge freshness | `loaf kb check` | -| Validate a Change is structurally executable, not implementation-complete | `loaf change check --require-executable` | +| Validate an issue is shaped, covered, and contained | `loaf issue check <ref>` (non-zero exit names each failure) | | Import legacy `.agents` Markdown into SQLite | `loaf migrate markdown --dry-run` then `--apply` (see markdown-migration reference) | ## JSON diagnosis surfaces @@ -24,10 +24,10 @@ scraping human-readable text: - `loaf config check --json` — config file and installed hook config validity - `loaf state doctor --json` / `loaf state status --json` — SQLite health and readiness -- `loaf change check --json` — Change violations and derived executability +- `loaf issue check <ref> --json` — derived readiness, coverage, and containment - `loaf check --hook <id> --json` — one enforcement hook's result - `loaf kb check --json` — knowledge staleness against git history -- `loaf task list --json` / `loaf journal recent --json` — current work and timeline +- `loaf issue list --json` / `loaf journal recent --json` — current work and timeline - `loaf migrate markdown --dry-run --json` — `mode` (`simulation`/`inventory`) plus `import_report` when simulated Choosing between the `doctor` commands and `LOAF_DB` isolation are covered in diff --git a/dist/skills/orchestration/SKILL.md b/dist/skills/orchestration/SKILL.md index a1fc38b52..895f55568 100644 --- a/dist/skills/orchestration/SKILL.md +++ b/dist/skills/orchestration/SKILL.md @@ -41,9 +41,9 @@ Comprehensive patterns for orchestration: coordinating multi-agent work, keeping - Outcome-focused, self-contained, no local file references - Magic words in commit body, not subject -**If `integrations.linear.enabled` is `true` in `.agents/loaf.json`:** use Linear MCP workflows and [references/linear.md](references/linear.md) for issue updates and status. +**If `integrations.linear.enabled` is `true` in `.agents/loaf.json`:** Linear is an identity adapter — `loaf issue pull` / `push` / `reconcile`, not a second work unit. See [references/linear.md](references/linear.md). Linear MCP is an overlay; Loaf issues remain the work unit and Linear never drives Loaf status. -**Otherwise:** coordinate with the project journal and `loaf task` / file-based tracking only; do not assume Linear MCP tools are available. +**Otherwise:** coordinate with the project journal and `loaf issue` only; do not assume Linear MCP tools or identity delegation are available. ### Planning (Shape Up) - Complexity-based sizing (small / medium / large) @@ -70,15 +70,15 @@ Comprehensive patterns for orchestration: coordinating multi-agent work, keeping | Pre-compaction | On an exact target mode with supported compaction delivery, hooks may nudge a journal flush and emit the digest afterward; otherwise flush manually and run `loaf journal context` after compaction | | Durable artifact handling | Delegate `.agents/`-scoped report/spec/handoff/knowledge tending to `librarian` | | Low-priority work | Spawn background-runner (see Background Agents) | -| New feature workflow | Research -> Architecture -> Shape -> Breakdown -> Implement | +| New feature workflow | Pitch -> Shape -> Implement -> Ship -> Release | ## Topics | Topic | Reference | Use When | |-------|-----------|----------| -| Shaping Specs | [../shape/SKILL.md](../shape/SKILL.md) | Creating specs, shaping work, defining test conditions | -| Breaking Work Into Tasks | [../breakdown/SKILL.md](../breakdown/SKILL.md) | Turning shaped specs into implementation tasks | -| Local Tasks | [references/local-tasks.md](references/local-tasks.md) | Managing tasks locally or with Linear backend | +| Shaping Issues | [../shape/SKILL.md](../shape/SKILL.md) | Preparing issues: body, definition of done, out of scope | +| Decomposition | [../shape/SKILL.md](../shape/SKILL.md) | Promoting a criterion that earns its own DoD (`loaf issue promote`) | +| Working Issues | [references/local-tasks.md](references/local-tasks.md) | Frontier, started worktrees, status, definition of done | | Agent Delegation | [references/delegation.md](references/delegation.md) | Choosing agents, spawning subagents, decision trees | | Parallel Agents | [references/parallel-agents.md](references/parallel-agents.md) | Dispatching independent work concurrently | | Subagent Development | [references/subagent-development.md](references/subagent-development.md) | Delegating to specialized agents | @@ -95,7 +95,7 @@ Comprehensive patterns for orchestration: coordinating multi-agent work, keeping The orchestrator: 1. Creates issues and logs the orchestration intent for tracking -2. Breaks down work into delegable tasks +2. Picks from `loaf issue frontier` and starts one worktree per issue 3. Spawns specialized agents for implementation 4. Coordinates outcomes and updates external systems 5. Never implements code, tests, or documentation directly @@ -125,16 +125,16 @@ This skill uses paths from `.agents/loaf.json`: | Councils | `.agents/councils/` | `.agents/councils/archive/` | `YYYYMMDD-HHMMSS-topic.md` | | Handoffs | `.agents/handoffs/` | delete after deprecated | Created by handoff | | Reports | `.agents/reports/` | N/A | `YYYYMMDD-HHMMSS-subject.md` | -| Tasks | SQLite (`loaf task show/list`) | N/A | Per task manager conventions | +| Issues | SQLite (`loaf issue show/list`) | `cancelled` / `duplicate` via `loaf issue status` | Alias or opaque id | **Rule:** Agents write artifacts to disk, orchestrator reasons over artifacts, users retrieve from disk. ## Workflow by Lifecycle ### BEFORE (Planning) -- Create/check external issue (Linear, GitHub) +- Shape prepares issues; implement works the frontier. Decomposition is `loaf issue promote` inside shape. - Log the orchestration intent with `loaf journal log` -- Break down into tasks, identify agents, get user approval +- `loaf issue check <ref>` must report shaped (delivery) or ready (decision); identify agents; get user approval ### DURING (Execution) - Spawn specialized agents (never implement directly) @@ -143,6 +143,6 @@ This skill uses paths from `.agents/loaf.json`: ### AFTER (Completion) - Code review + QA testing -- Update external issue to Done +- Land via ship: `loaf issue status <ref> done`, then `loaf issue stop <ref>` - Ensure knowledge captured in permanent locations - Write an optional `wrap` journal entry if the conversation holds synthesis worth saving diff --git a/dist/skills/orchestration/references/background-agents.md b/dist/skills/orchestration/references/background-agents.md index 6aa2b3c61..95b4b3b54 100644 --- a/dist/skills/orchestration/references/background-agents.md +++ b/dist/skills/orchestration/references/background-agents.md @@ -43,7 +43,7 @@ Task( - src/services/ Write report to: .agents/reports/YYYYMMDD-HHMMSS-security-audit.md - Reference: TASK-123, SPEC-045 if relevant + Reference: LOAF-123 if relevant """, run_in_background=True ) @@ -51,12 +51,12 @@ Task( ### Cursor -Background agents are configured via the `is_background: true` YAML property. When spawning, specify the report destination and any task/spec IDs: +Background agents are configured via the `is_background: true` YAML property. When spawning, specify the report destination and any issue refs: ``` @background-runner Run security audit on backend codebase. Write report to .agents/reports/. -Reference TASK-123 if relevant. +Reference LOAF-123 if relevant. ``` The background agent's journal entries are tagged with its own harness id automatically — there is no session alias to pass. @@ -72,7 +72,7 @@ Track background work with durable references: 1. Log the spawn with `loaf journal log "todo(background): started <id> for <task>"`. 2. Ask the background agent to write a report under `.agents/reports/`. 3. When complete, log `discover(background): <id> wrote <report>`. -4. Process findings into tasks, specs, ADRs, or report verdicts as appropriate. +4. Process findings into issues, ADRs, or report verdicts as appropriate. Use a stable ID such as `bg-YYYYMMDD-HHMMSS-description` in the prompt and journal entries. @@ -85,7 +85,7 @@ Background agents write results to `.agents/reports/` with enough metadata to id 1. Orchestrator identifies non-blocking security audit work. 2. Orchestrator logs the background spawn to the journal. 3. Background agent writes `.agents/reports/YYYYMMDD-HHMMSS-auth-security.md`. -4. Orchestrator reviews the report, creates follow-up tasks, and logs the outcome. +4. Orchestrator reviews the report, creates follow-up issues, and logs the outcome. 5. Report state is finalized or archived through the report lifecycle. ## Anti-Patterns @@ -94,7 +94,7 @@ Background agents write results to `.agents/reports/` with enough metadata to id |-------|------------| | Use for blocking work | Keep blocking work in foreground | | Spawn without tracking | Log the spawn and require a report path | -| Ignore completed results | Process reports into tasks, findings, or decisions | +| Ignore completed results | Process reports into issues, findings, or decisions | | Use for interactive tasks | Reserve for autonomous work | | Spawn many concurrent background agents | Limit concurrency to avoid resource contention | | Skip result location in prompt | Always specify where output belongs | diff --git a/dist/skills/orchestration/references/context-management.md b/dist/skills/orchestration/references/context-management.md index 11ce34ac7..49151a167 100644 --- a/dist/skills/orchestration/references/context-management.md +++ b/dist/skills/orchestration/references/context-management.md @@ -19,28 +19,28 @@ Patterns for keeping long work resumable while using the project journal as exte Compaction is normal in long workflows. Design work that spans many exchanges so important state is already outside chat context. 1. **The journal is external memory.** Record decisions, discoveries, blockers, and next actions with `loaf journal log`. -2. **Artifacts carry detail.** Changes, task-board records, reports, ADRs, and commits hold rich detail; journal entries point to them. +2. **Artifacts carry detail.** Issues, reports, ADRs, and commits hold rich detail; journal entries point to them. 3. **Delegated work absorbs exploration.** Use delegated agents for broad investigation and return concise findings to the main context. -4. **`wrap` captures synthesis.** When meaningful work holds intentions or abandoned paths worth saving, write an optional `wrap` journal entry. +4. **`wrap` captures synthesis.** When meaningful work holds abandoned paths worth saving, write an optional `wrap` journal entry. ## Continuity Digest (contract v2) -`loaf journal context` is the contract-v2 active-truth digest and supersedes the retired three-part summary. Read its named layers and diagnostics; an absent item and an unavailable source are different states. +`loaf journal context` is the contract-v2 active-truth digest. Read its named layers and diagnostics; an absent item and an unavailable source are different states. Layer *names* below are the live CLI identifiers. | Layer | Truth and precedence | |-------|----------------------| | `project-synthesis` | The latest `wrap(project)` synthesis. This is the only wrap that represents project-wide synthesis. | | `scoped-checkpoint` | The latest non-project wrap, only when `project-synthesis` has no item. It is labeled as a fallback, not promoted to project synthesis. | -| `active-lineage` | Journal evidence associated with the active Change lineage. | +| `active-lineage` | Journal evidence associated with the active work lineage. | | `unresolved-blockers` | Blocks without a later exact-scope unblock. | -| `deferred-intent` | Open deferred-intent decision and spark pairs. | -| `active-changes` | Git-derived active Change evidence and worktree state. | +| `deferred-intent` | CLI layer for open deferred decision and spark pairs. | +| `active-changes` | Git-derived active worktree evidence. | | `branch-recency` | Recent branch entries after entries already surfaced as active truth are removed. | -| `transitional-tasks` | Open task-board records retained for compatibility. | +| `transitional-tasks` | Leftover board records retained for compatibility — not the work unit. Prefer `loaf issue frontier` / `loaf issue list --started`. | -Each returned layer includes `source_available`, `available_count`, `shown_count`, `truncated`, and `expand_command`; paginated layers also include a cursor. Treat `source_available: false` as an explicit unavailable source, never as “nothing is active.” If Change discovery is unavailable, `active-changes` and `active-lineage` are unavailable and the digest carries a diagnostic. +Each returned layer includes `source_available`, `available_count`, `shown_count`, `truncated`, and `expand_command`; paginated layers also include a cursor. Treat `source_available: false` as an explicit unavailable source, never as “nothing is active.” If git-derived discovery is unavailable, `active-changes` and `active-lineage` are unavailable and the digest carries a diagnostic. -Use `--branch` to select `branch-recency` scope and bind state cursors. It does not override active Change provenance or reasons, which always use the actual Git branch. Use `loaf journal context --layer <name>` to inspect one layer. `--limit` accepts 1 through 100 and requires `--layer`; `--cursor` also requires `--layer` and cannot be used with the intrinsic one-item `project-synthesis` or `scoped-checkpoint` layers. Follow the returned `expand_command` exactly: a cursor is bound to its layer, project, branch, snapshot, and limit. Use `--json` for automation; the human view preserves availability, counts, truncation, diagnostics, and expansion commands. +Use `--branch` to select `branch-recency` scope and bind state cursors. It does not override git provenance or reasons, which always use the actual Git branch. Use `loaf journal context --layer <name>` to inspect one layer. `--limit` accepts 1 through 100 and requires `--layer`; `--cursor` also requires `--layer` and cannot be used with the intrinsic one-item `project-synthesis` or `scoped-checkpoint` layers. Follow the returned `expand_command` exactly: a cursor is bound to its layer, project, branch, snapshot, and limit. Use `--json` for automation; the human view preserves availability, counts, truncation, diagnostics, and expansion commands. ## Context Commands @@ -53,21 +53,21 @@ Use `--branch` to select `branch-recency` scope and bind state cursors. It does ## When to Clear Context -Clear the conversation when starting a completely new task, after the previous task is complete, when debugging noise crowds out the current objective, or when switching between unrelated codebases. Avoid clearing mid-task until enough state is logged for recovery. +Clear the conversation when starting a completely new issue, after the previous issue is complete, when debugging noise crowds out the current objective, or when switching between unrelated codebases. Avoid clearing mid-issue until enough state is logged for recovery. ## Compaction Lifecycle PreCompact: 1. Flush unrecorded decisions, discoveries, blockers, and next actions with `loaf journal log`. -2. Reference Changes, task-board records, reports, commits, and files by stable ID or path. +2. Reference issues, reports, commits, and files by stable ID or path. 3. On an exact target mode with supported PreCompact delivery, let the hook nudge the flush; otherwise flush manually before compacting. PostCompact: 1. On an exact target mode with supported resumption delivery, read the continuity digest emitted by the hook; otherwise run `loaf journal context` explicitly. 2. Expand the named layer that needs more detail, or use `loaf journal recent` and `loaf journal search` for a different query. -3. Continue from the journal and linked artifacts. +3. Continue from the journal and linked artifacts (`loaf issue show <ref>`). This makes compaction survivable without relying on hand-maintained Markdown state. State not logged or captured in a durable artifact can be lost. @@ -79,10 +79,10 @@ Use delegated agents to investigate without filling the main context. |-----------|----------| | Quick file lookup | Direct read or search tool | | Multi-file exploration | Explorer or research agent | -| Implementation work | Implementer or task-focused agent | +| Implementation work | Implementer in the issue's started worktree | | Long audit | Background agent with report output | -Pass stable references to delegated agents: Change IDs, task IDs, branch names, and report paths. The harness ID is attached to journal entries automatically; there is no session alias to pass. +Pass stable references to delegated agents: issue IDs, branch names, worktree paths, and report paths. The harness ID is attached to journal entries automatically; there is no session alias to pass. Never send two agents to the same started worktree. ## Context Budget Guidelines @@ -109,12 +109,12 @@ No special management is usually needed. | Repeating same mistakes | Context pollution | Log current facts, then clear or compact | | Forgetting recent decisions | Overcrowded context | Read `loaf journal context` and expand the relevant layer | | Slow responses | Large context | Delegate exploration | -| Confusion about task | Too many pivots | Re-anchor on Change or task IDs | +| Confusion about the work | Too many pivots | Re-anchor on issue IDs (`loaf issue show`) | ## Best Practices 1. Log durable facts early with `loaf journal log`. 2. Use delegated agents for exploration-heavy work. -3. Clear between unrelated tasks. -4. Compact mid-task when the journal and artifacts are current. +3. Clear between unrelated issues. +4. Compact mid-issue when the journal and artifacts are current. 5. Scope tool calls so context stays focused. diff --git a/dist/skills/orchestration/references/delegation.md b/dist/skills/orchestration/references/delegation.md index b710244b6..a620c0ba7 100644 --- a/dist/skills/orchestration/references/delegation.md +++ b/dist/skills/orchestration/references/delegation.md @@ -131,8 +131,8 @@ Use when work is truly independent. Spawn multiple agents in the same turn when 1. **Be specific in prompts** - Include file paths, requirements, constraints 2. **One concern per agent** - Don't ask a backend implementer to also write tests -3. **Include context** - Task/spec IDs, issue ID, previous outcomes -4. **Reference durable artifacts** - Task, spec, and report IDs; the subagent's journal entries are harness-id tagged automatically +3. **Include context** - Issue refs (`LOAF-42` or opaque id), previous outcomes +4. **Reference durable artifacts** - Issue aliases and report IDs; the subagent's journal entries are harness-id tagged automatically 5. **Include skill hints** - Name the skills that should guide the agent's work ### Skill Hints @@ -171,8 +171,7 @@ Files: - src/api/users.py - src/models/user.py -Task: TASK-042 -Linear: BACK-123 +Issue: LOAF-42 ``` ## Anti-Patterns @@ -183,7 +182,7 @@ Linear: BACK-123 | Asking backend implementer for React | Spawn implementer with frontend skills | | Single agent for database + backend + tests | Sequential: implementer (database-design), implementer (language skill), implementer (foundations) | | Parallel spawns with hidden dependencies | Make dependencies explicit, spawn sequentially | -| Spawning without context | Reference task/spec/report IDs in prompts | +| Spawning without context | Reference issue aliases and report IDs in prompts | | Council for simple decisions | Single agent or orchestrator judgment | ## Agent Access Hierarchy diff --git a/dist/skills/orchestration/references/journal.md b/dist/skills/orchestration/references/journal.md index de2a9de3a..442f2b216 100644 --- a/dist/skills/orchestration/references/journal.md +++ b/dist/skills/orchestration/references/journal.md @@ -42,20 +42,20 @@ loaf journal log "spark(scope): possible follow-up idea" loaf journal log "todo(scope): concrete follow-up action" ``` -Log durable facts, not thoughts. Reference task IDs, spec IDs, report IDs, and +Log durable facts, not thoughts. Reference issue IDs, report IDs, and commit refs rather than pasting long prose. The journal should let another agent resume without reading the whole conversation. ## Codex Auto Mode -When the user has explicitly enabled Loaf's managed Codex Auto-journal capability, use the exact path-pinned command shown in the Loaf-managed block of `CODEX_HOME/AGENTS.md`; its form is `'<canonical-loaf-path>' journal log --execpolicy-safe "decision(scope): chose X because Y"`. Do not substitute a bare `loaf`, alternate executable, or shell/environment wrapper. The installed Codex rule authorizes only explicitly classified basic Loaf command leaves outside the workspace sandbox, including this hardened journal writer and approved readers; ordinary `journal log`, body/file-consuming leaves, and path-taking `change check` remain operator-gated, and the policy does not authorize a general Loaf data-directory writable root. Other harness adapters are not implied and continue to use their own ordinary surfaces. +When the user has explicitly enabled Loaf's managed Codex Auto-journal capability, use the exact path-pinned command shown in the Loaf-managed block of `CODEX_HOME/AGENTS.md`; its form is `'<canonical-loaf-path>' journal log --execpolicy-safe "decision(scope): chose X because Y"`. Do not substitute a bare `loaf`, alternate executable, or shell/environment wrapper. The installed Codex rule authorizes only explicitly classified basic Loaf command leaves outside the workspace sandbox, including this hardened journal writer and approved readers; ordinary `journal log`, body/file-consuming leaves, and other path-taking operator-gated leaves remain operator-gated, and the policy does not authorize a general Loaf data-directory writable root. Other harness adapters are not implied and continue to use their own ordinary surfaces. Enable the capability once with `loaf install --to codex --codex-basic-commands`. Installation is an explicit trust decision. If the rules are absent, stale, locally modified, or conflict with user-owned `loaf.rules`, Loaf reports the condition instead of overwriting it or asking for full system access. ## Wrap: Optional Checkpoint A `wrap` entry is a voluntary checkpoint, not a lifecycle transition. Write one -only when the conversation holds synthesis worth saving — intentions, abandoned +only when the conversation holds synthesis worth saving — abandoned paths, next steps — the connective narrative that evaporates with the context window. Almost everything else is derivable from raw entries. @@ -70,11 +70,12 @@ perfectly valid journal. A wrap reviews its own conversation's entries first: loaf journal recent --since-last-wrap ``` -See the `wrap` skill for the full checkpoint flow. +See the `wrap` skill for the full checkpoint flow. Loose ends name issues +(`loaf issue frontier`, `loaf issue list --started`), not board leftovers. ## Derived Continuity -Continuity is computed at read time and never persisted. On an exact target mode whose startup delivery capability is supported, the startup adapter may emit a layered digest with the latest project-level wrap, recent entries scoped to the current branch/worktree, and open (`in_progress`/`pending`) tasks. When startup delivery is candidate or unsupported for the current mode, explicitly run `loaf journal context` at conversation start. On demand, reproduce or extend the digest: +Continuity is computed at read time and never persisted. On an exact target mode whose startup delivery capability is supported, the startup adapter may emit a layered digest with the latest project-level wrap, recent entries scoped to the current branch/worktree, and open work. Bind that open work to `loaf issue frontier` and `loaf issue list --started`. When startup delivery is candidate or unsupported for the current mode, explicitly run `loaf journal context` at conversation start. On demand, reproduce or extend the digest: ```bash loaf journal context # the layered continuity digest @@ -82,7 +83,7 @@ loaf journal recent --branch <b> # recent entries for one branch loaf journal search <query> # find prior decisions by topic ``` -Pass task/spec/report references to background and delegated agents. The harness +Pass issue IDs and report references to background and delegated agents. The harness id is attached automatically — there is no session alias to pass along. ## Recovery @@ -91,7 +92,7 @@ After compaction, a branch switch, or a long gap: 1. Read `loaf journal context`; on an exact target mode with supported resumption delivery, the digest emitted by the adapter is equivalent continuity context. 2. Widen with `loaf journal recent` / `loaf journal search` when more is needed. -3. Compare against `git status`, `git log`, and the relevant specs/tasks. +3. Compare against `git status`, `git log`, `loaf issue frontier`, and `loaf issue list --started`. 4. If code and journal have drifted, log the reconciliation: `loaf journal log "decision(recovery): rewound to <commit>; replaying tests"`. @@ -108,7 +109,7 @@ After compaction, a branch switch, or a long gap: | Don't | Do Instead | |-------|------------| | Wait to log everything at the end | Log significant facts as they happen | -| Store decisions only in chat context | Log them and promote durable ones to ADR/spec/report/docs | +| Store decisions only in chat context | Log them and promote durable ones to ADR/issue body/report/docs | | Write a placeholder wrap out of ceremony | Wrap only when there's synthesis worth saving | | Treat a missing wrap as an open loop | A conversation without a wrap is complete and valid | | Pass a session alias to delegated agents | Nothing to pass — the harness id is automatic | diff --git a/dist/skills/orchestration/references/linear.md b/dist/skills/orchestration/references/linear.md index f488726cf..f00960ebc 100644 --- a/dist/skills/orchestration/references/linear.md +++ b/dist/skills/orchestration/references/linear.md @@ -7,8 +7,7 @@ Guidelines for writing Linear issue updates, comments, and commit messages with - Configuration - MCP Server Naming - Multi-Workspace Guidance -- Linear-Native Mode (Parent + Sub-Issues) -- The `spec` Label Convention +- Identity Adapter - Progress Update Format - Issue Description Format - Status Conventions @@ -97,96 +96,44 @@ Match the `linear.mcp_server_name` in each project's `.agents/loaf.json` to the name used in that project's `.mcp.json`. That way the Loaf skills invoke the right workspace automatically. -## Linear-Native Mode (Parent + Sub-Issues) +## Identity Adapter -In Linear-native mode (`integrations.linear.enabled: true`), each spec -produces one parent **rollup issue** and N sub-issues under it. +When `issue_identity.authority = linear`, Linear owns identity, title, status, +and assignment. Loaf owns shaping state: body, definition-of-done criteria, +claims, and the started worktree. The Loaf issue is the work unit. Linear MCP +is an overlay — never drive Loaf status from MCP tools. -``` -Agent framework alignment ← parent, label: `change` -├── Split reviewer profile into reviewer/auditor ← sub-issue, label: type/refactor -├── Harden MCP fallback path ← sub-issue, label: type/feature -└── Migrate legacy task references ← sub-issue, label: type/refactor -``` - -### Parent issue — what it is and isn't - -The parent issue is a **dashboard anchor**, not a re-hosting of the spec. - -- **Is:** a short summary (1–3 paragraphs) of the problem and solution - direction + a link to the canonical spec file in the repo. -- **Is not:** a copy of the spec's Scope / Rabbit Holes / Open Questions / - Risks sections. Those live in the local spec file and evolve there. - -### Sample parent description +`loaf issue new` delegates identity: Linear mints the identifier, and that +key becomes the local alias. The local counter is not advanced. If Linear is +offline, refuse — capture via `loaf spark` or `loaf idea`. Do not mint a +local alias as a fallback. -```markdown -## Summary -Align Loaf's agent profiles with the three-role model (implementer, reviewer, -researcher). Consolidate historical profile variants and add tool-boundary -tests so profiles can't drift without a test failing. - -## Context -See the canonical change file in the repository for full text, council -references, rabbit holes, and strategic tensions. +If Linear created an issue but the local bind failed, adopt it: -## Progress -Sub-issues track execution. +```text +loaf issue pull <linear-key> +loaf issue pull <linear-key> --tree ``` -### Sub-issues - -- Each sub-issue has `parentId` set to the parent issue ID. -- Cross-task dependencies use Linear's `blockedBy` field referencing sibling - sub-issue IDs. -- Sub-issue labels describe the task itself (type, team, area), not the - parent — don't label sub-issues with `spec`. -- Starting a sub-issue promotes the parent rollup from `backlog`/`unstarted` - to the team's `started`/In Progress state. Parent promotion is a state - invariant of the start operation, not a separate manual reminder. -- Do not silently reopen protected parents. If the parent is `completed`, - `canceled`, or archived, stop and ask for an explicit override before - starting the child. - -### Spec file remains canonical - -Even with the parent in Linear, the local spec file is the source of truth -for: +`--tree` also adopts the sub-issue tree with parent edges intact. -- Problem statement and solution direction -- Scope / in-scope / out-of-scope / rabbit holes / no-gos -- Risks and open questions -- Council references and strategic tensions +### Commands -When the spec evolves, edit the file and let git track it. The parent -issue's summary is a frozen entry point; only refresh it if the summary -itself (not the rabbit holes or risks) changes meaningfully. - -## The `spec` Label Convention - -Every spec-parent rollup issue carries a Linear label named `spec`. This lets -anyone in Linear filter for "all spec roots" across projects without having to -know which issues happen to be parents. - -| Field | Value | -|-------|-------| -| Name | `spec` | -| Color | `#5e6ad2` (suggested; implementer may adjust) | -| Description | `Parent rollup issue representing a design spec tracked in the repo at .agents/specs/` | -| Scope | Workspace-scoped preferred; fall back to team-scoped if the MCP requires it | - -### Who creates it - -breakdown creates the `spec` label on first Linear-native breakdown in a -workspace that doesn't already have it. Subsequent breakdowns reuse the -existing label. Log whether the label was created this run or already -existed — this matters for first-time setup. +```text +loaf issue pull <linear-key> [--tree] [--json] +loaf issue push <ref> [--json] +loaf issue reconcile [<ref>] [--take-local|--take-tracker] [--json] +``` -### Sub-issues never carry `spec` +| Command | Writes? | What it does | +|---------|---------|----------------| +| `loaf issue pull` | Yes | Adopt an existing Linear issue as a local row. The Linear key becomes the alias | +| `loaf issue push` | Yes | Write `loaf issue render` as the Linear description. Status is written only when the local status event is newer than the tracker. Never renames the Linear issue | +| `loaf issue reconcile` | Yes with a take flag | Compare local and Linear. Title drift updates the local title (tracker wins). Status drift is reported; `--take-local` or `--take-tracker` resolves it. Description drift is reported only | -`spec` applies only to parents. A sub-issue describing a task uses its own -labels (type groups like `feature`/`bug`/`refactor`, team labels, area -labels) — never `spec`. This keeps the "filter for spec roots" query clean. +Do not create records with `loaf task` or `loaf spec`. Parent/child structure +is `loaf issue promote` (or `loaf issue new --parent`), not a `spec`-labeled +Linear rollup. ## Progress Update Format @@ -222,18 +169,9 @@ None currently. ## Issue Description Format -```markdown -## Summary -Brief description of the work and its purpose. - -## Acceptance Criteria -- [ ] Criterion 1 -- [ ] Criterion 2 -- [ ] Criterion 3 +The Linear description is `loaf issue push` output — `loaf issue render`, not a hand-authored summary. Do not paste a competing description over the render. -## Notes -Any relevant context (keep brief). -``` +Comments (not the description) still follow the progress-update format above. **Rules:** - Concise and actionable @@ -243,6 +181,8 @@ Any relevant context (keep brief). ## Status Conventions +Loaf status is `loaf issue status`. Linear status is the tracker's. Resolve drift with `loaf issue reconcile` (`--take-local` or `--take-tracker`). Do not flip Loaf status from Linear MCP tools. + | State | When to Use | |-------|-------------| | **Backlog** | Issue created, not started | @@ -341,6 +281,8 @@ Use `scripts/suggest-team.py "task desc"` to get suggestions. ## When to Create Issues +Create through `loaf issue new` so identity can be delegated. Do not create in Linear MCP and then forget to `loaf issue pull`. + | Action | Create Issue? | |--------|---------------| | Features, bugs, refactoring | Yes | diff --git a/dist/skills/orchestration/references/local-tasks.md b/dist/skills/orchestration/references/local-tasks.md index 69c89df64..30d617f47 100644 --- a/dist/skills/orchestration/references/local-tasks.md +++ b/dist/skills/orchestration/references/local-tasks.md @@ -1,259 +1,173 @@ -# Local Task Management +# Working Issues Locally -Break specs into atomic tasks using SQLite-backed Loaf task records when Linear -isn't available. +Orchestration-facing reference for the Loaf issue CLI: pick-up-next, started +worktrees, status, definition of done, and advisory labels. Issue commands +require initialized SQLite state. ## Contents -- Task Abstraction Layer -- Local Task Records -- Task Lifecycle -- Creating Tasks from Specs -- Cutover Reconciliation -- Task ID Generation -- Archiving Tasks -- Journal Integration -- Task Sizing -- Priority Levels -- Listing Tasks -- Work Log Updates -- Verification -- Local vs Linear Comparison - -## Task Abstraction Layer - -Tasks work identically whether backed by Linear or local SQLite state. - -### Configuration - -```yaml -# .agents/loaf.yaml -task_management: - backend: linear # or "local" - - linear: - team: ProjectName - default_labels: [] - - local: - archive_completed: true -``` - -### Abstracted Operations - -| Operation | Linear | Local | -|-----------|--------|-------| -| Create task | Create issue | `loaf task create --spec SPEC-XXX --title "..." --priority P1` | -| Fetch task | Get issue | `loaf task show TASK-XXX` or `loaf task show TASK-XXX --json` | -| Update status | Update issue | `loaf task update TASK-XXX --status in_progress` | -| List tasks | List issues | `loaf task list` (or `loaf task list --json` for machine parsing) | -| Complete | Move to Done | `loaf task update TASK-XXX --status done` | - -## Local Task Records +- Frontier +- Started worktree +- Status vocabulary +- Relationships +- Definition of done +- Buckets +- Command cheat sheet +- LEGACY -SQLite is the operational source of truth for task metadata, status, priority, -dependencies, dates, and relationships. Use `loaf task` CLI commands for all -task mutations. `.agents/tasks/` and `.agents/TASKS.json` were removed by the -SPEC-045 cutover and are rollback material only. - -**Create with:** `loaf task create --title "..." --spec SPEC-XXX` - -## Task Lifecycle +## Frontier +```text +loaf issue frontier [--json] ``` -todo → in_progress → review → done - │ │ │ │ - └────────┴───────────┴────────┘ - can return to earlier states -``` - -| Status | Meaning | -|--------|---------| -| `todo` | Ready to work, not started | -| `in_progress` | Actively being worked | -| `review` | Implementation complete, needs verification | -| `done` | Verified complete, ready for archive | - -## Creating Tasks from Specs - -### Input -- Spec ID (e.g., `SPEC-001`) -- Optional: priority override +Pick-up-next. Derived at read time, never stored. Lists non-archived issues in +`triage`, `backlog`, or `todo` that are not blocked. -### Task Breakdown Rules +| Qualifier | Meaning | +|-----------|---------| +| Open | Status is `triage`, `backlog`, or `todo` — not `active`, `done`, `cancelled`, or `duplicate` | +| Unblocked | No open predecessor via `blocks` / `blocked_by`. A predecessor that is `done`, `cancelled`, or `duplicate` does not block | +| Unclaimed | Not `active` and no started worktree. `loaf issue start` is the claim | -1. **One concern per task** - Don't mix backend + tests + frontend -2. **Clear done condition** - Observable, verifiable outcome -3. **Verification command** - How to prove it works -4. **File hints** - Which files will likely be modified +Archived rows are excluded. Kind is not filtered: a `--kind decision` question +can appear; it is not delivery work. Buckets are not read. Prefer `--json` +when diagnosing rather than scraping the human-readable text. -### Example Breakdown +## Started worktree +```text +loaf issue start <ref> [--json] +loaf issue stop <ref> [--force] [--json] +loaf issue list --started [--json] ``` -SPEC-001: User Authentication with OAuth - ↓ -TASK-001: OAuth Provider Integration - - Google OAuth client setup - - GitHub OAuth client setup - - Token exchange logic - verify: pytest tests/auth/test_oauth.py - -TASK-002: Session Management - - Session cookie handling - - Session storage (Redis/DB) - - Session expiry logic - verify: pytest tests/auth/test_session.py - -TASK-003: Login UI Components - - Login page layout - - Provider buttons - - Error states - verify: npm run test:e2e -- auth -``` - -## Cutover Reconciliation - -If a stale branch reintroduces `.agents/tasks/`, `.agents/sessions/`, other -ephemeral roots, or `.agents/TASKS.json`, keep the deletion side from the -cutover branch and rerun `loaf check --hook ephemeral-provenance`. Use -`loaf state restore-ephemerals <backup-id>` only for an intentional rollback, -then re-import forward. - -## Task ID Generation -Format: `TASK-{number}-{slug}` +**Invariant:** one agent, one worktree. Check `loaf issue list --started` +before dispatch. Never send two agents into the same path. -Task IDs are auto-generated by `loaf task create`. In SQLite-backed projects, -the allocation is recorded in state. +`start` creates branch `issue/<alias-or-id>` in lowercase (`issue/loaf-42`, +disambiguated with an id suffix when that name is already claimed), adds a +sibling worktree, records `started_branch` / `started_worktree` on the row, and +moves status to `active` through the events path. Base is the nearest started +ancestor's branch, else the repository default branch. Start refuses an already +started row, an archived row, and terminal statuses (`done`, `cancelled`, +`duplicate`). Requires a git repository. -## Archiving Tasks +`list --started` prints alias, title, `started_branch`, `started_worktree`, and +`(missing)` when the recorded path is gone. -When a task is done: +`stop` removes the worktree and clears the started workspace on the row. It +keeps the branch and does not change status. `--force` removes a dirty +worktree. Do not run `stop` from inside the started worktree. -1. Mark complete via CLI: `loaf task update TASK-XXX --status done` -2. Archive: `loaf task archive TASK-XXX` +## Status vocabulary -## Journal Integration +Write statuses that update in place: `triage`, `backlog`, `todo`, `active`, +`done`. `cancelled` and `duplicate` archive through the remove path +(`loaf issue status <ref> duplicate --duplicate-of <surviving>`). -When the implement workflow starts on `TASK-001`: - -1. Load task metadata via `loaf task show TASK-001` for context -2. Read linked spec for full picture -3. Log the task coupling as the first action: +```text +loaf issue status <ref> <status> [--duplicate-of <ref>] [--json] +``` -```bash -loaf journal log "decision(implement): implementing TASK-001" +| Status | Meaning | +|--------|---------| +| `triage` | Default at create. Shaped is derived (`loaf issue check`), not a status | +| `backlog` | Filed, worth keeping | +| `todo` | Explicitly ready to work | +| `active` | Started. **Review is a display name for `active`** — there is no `review` write status | +| `done` | Work landed | +| `cancelled` | Archived; abandoned | +| `duplicate` | Archived; `--duplicate-of` required | + +There is **no `blocked` status**. Blocked is a relationship. Title and body stay +mutable at every status. + +```text +loaf issue list [--status <status>] [--kind delivery|decision] [--archived] [--started] [--json] ``` -There is no session to create or couple to. Task progress is read through -`loaf task show/list`; the surrounding decisions and blockers live in the -project journal (`loaf journal recent`, `loaf journal search`). +Archived rows are hidden unless `--archived`. `--status` accepts every value in +the table above. -## Task Sizing +## Relationships -### Separation of Concerns +```text +loaf issue link <from> blocks|relates-to <to> [--json] +loaf issue link <from> remove <type> <to> [--json] +``` -**The primary principle for task breakdown is separation of concerns.** +Stored types are `blocks` and `relates_to`. `loaf issue link A blocks B` means +A blocks B: B is absent from the frontier until A is `done`, `cancelled`, or +`duplicate`. `relates-to` is not a sequencing constraint. -| Rule | Guideline | -|------|-----------| -| **One agent type** | Task completable by ONE subagent (implementer, reviewer, researcher) | -| **One concern** | Task touches one layer, one service, or one component | -| **Context-appropriate** | Fits in model context with room for exploration | -| **Not over-fragmented** | Don't split what naturally belongs together | +Do not encode order in `loaf issue tree`. Parent/child is structure; `blocks` +is the dependency. `loaf issue export [--json]` dumps relationships (and +claims) when you need the graph. -### Right Size Test +## Definition of done -1. Can a single specialized agent complete this? → If no, split by agent type -2. Does it touch multiple unrelated concerns? → If yes, split by concern -3. Will the agent need too much context? → If yes, split into smaller coherent units -4. Am I splitting just to have more tasks? → If yes, merge back +Criteria live on the issue row. `loaf issue show <ref>` prints each as +`position. [V|H] text` with `command=` / `expect=` when present. -### Agent Scope +```text +loaf issue dod add <ref> <text> [--command <cmd>] [--expect <expect>] [--tier V|H] [--serves <parent-position>] [--json] +loaf issue dod list <ref> [--json] +loaf issue dod remove <ref> <position> [--json] +loaf issue dod claim <child> <child-position> <parent-position> [--json] +loaf issue dod unclaim <child> <child-position> <parent-position> [--json] +loaf issue promote <ref> <position> [--json] +loaf issue check <ref> [--json] [--human <reason>] +loaf issue verify <ref> [--json] +``` -| Agent | Typical Task Scope | -|-------|-------------------| -| implementer (backend) | One service/module, its tests, its docs | -| implementer (frontend) | One component/page, its tests, its styles | -| implementer (database) | One migration, related schema changes | -| implementer (testing) | Test suite for one feature/area | -| implementer (infra) | One infrastructure concern (CI, deploy, config) | +| Tier | When | Who checks | +|------|------|------------| +| V | `--command` present, unless `--tier` overrides | `loaf issue verify <ref>` from the repository root. Honors `exit <N>` and `` contains `text` ``. Writes nothing. Non-zero on failure | +| H | No `--command`, unless `--tier` overrides | Human or orchestrator. Verify skips H-tier; that skip is not a pass | -## Priority Levels +Claims: a child criterion serves a parent criterion. `promote` copies the +parent criterion onto a new delivery child and records the claim. +`--serves` claims a newly added child criterion. `claim` / `unclaim` retarget +an existing pair. Positions are 1-based. -| Priority | Meaning | Response | -|----------|---------|----------| -| P0 | Urgent/blocking | Drop everything | -| P1 | High | Work next | -| P2 | Normal | Scheduled work | -| P3 | Low | When time permits | +`check` is readiness (shape's gate): delivery is shaped with a nonempty body, +at least one criterion, and an out-of-scope statement; decision is ready on a +sharp `?`. Children add coverage (every parent criterion claimed — failure) +and containment (every child criterion claims a parent — report). `verify` is +implement's preflight and writes nothing — it does not set status and does not +tick boxes. -## Listing Tasks +`loaf issue render <ref>` emits the paste-ready PR body: title, body, +definition-of-done checkboxes (checked only when status is already `done`), +and children. No manual editing. -### All Active Tasks +## Buckets -```bash -loaf task list +```text +loaf issue bucket <ref> now|next|later|none [--json] ``` -### Tasks for a Spec - -```bash -loaf spec list # Show specs with task counts -loaf task list --json # Machine-parseable output, filter by spec -``` +Advisory Now/Next/Later labels. Never read as a constraint. Frontier, start, +and verify ignore them. `none` clears the label. -## Work Log Updates +## Command cheat sheet -As work progresses, append to the Work Log section: - -```markdown -## Work Log - -### 2026-01-23 14:30 UTC -Started OAuth integration. Set up Google OAuth client credentials. - -### 2026-01-23 15:45 UTC -Google OAuth working. Moving to GitHub integration. - -### 2026-01-23 17:00 UTC -Both providers working. Tests pass. Moving to review. +```text +loaf issue new <title> [--body <text>|--body -|--body-file <path>|--message <text>] + [--kind delivery|decision] [--parent <ref>] [--fog <text>] [--status <status>] [--json] +loaf issue show <ref> [--json] +loaf issue tree [<ref>] [--archived] [--json] +loaf issue edit <ref> [--body-file <path>|--body -|--message <text>] [--json] +loaf issue export [--json] ``` -## Verification +`new` default kind is `delivery`; default status is `triage`. `--status` on +create still records the initial triage event, then writes the requested +write-status. `--fog` exists only on create. `edit` replaces the body; there +is no patch form. -Before marking `done`: - -1. Run the `verify` command from frontmatter -2. Check all acceptance criteria are checked -3. Ensure no regressions in related tests - -```bash -# Run task verification -verify_cmd=$(grep '^verify:' TASK-001-*.md | cut -d: -f2-) -eval "$verify_cmd" -``` +## LEGACY -## Local vs Linear Comparison - -| Feature | Local | Linear | -|---------|-------|--------| -| No external dependency | yes | no | -| Rich UI | no | yes | -| Team collaboration | git-based | native | -| Notifications | none | email/slack | -| Reporting | manual | built-in | -| Offline work | yes | limited | - -**Use local when:** -- Solo project -- No Linear access -- Offline development -- Simple task tracking - -**Use Linear when:** -- Team collaboration needed -- Rich workflow automation -- Integration with other tools -- Reporting requirements +`loaf task` and `loaf spec` remain readable against leftover SQLite rows. They +mint nothing new. Do not create records there. Issues are the work unit. diff --git a/dist/skills/orchestration/references/parallel-agents.md b/dist/skills/orchestration/references/parallel-agents.md index f4ef48ab2..35a07a7d6 100644 --- a/dist/skills/orchestration/references/parallel-agents.md +++ b/dist/skills/orchestration/references/parallel-agents.md @@ -117,7 +117,7 @@ When streams complete: | Command | Parallel Opportunity | |---------|---------------------| -| breakdown | Identify parallelizable tasks during decomposition | +| shape | Identify parallelizable child issues during decomposition | | implement | Single task, usually sequential | | implement | Runs dependency-aware orchestration, including parallel-safe tasks | diff --git a/dist/skills/orchestration/references/script-surface.md b/dist/skills/orchestration/references/script-surface.md index 70206ab63..474b76354 100644 --- a/dist/skills/orchestration/references/script-surface.md +++ b/dist/skills/orchestration/references/script-surface.md @@ -20,7 +20,7 @@ script surface: - The source currently has 10 orchestration scripts out of 22 skill-local scripts overall. -- Several scripts overlap existing `loaf journal`, `loaf task`, `loaf check`, +- Several scripts overlap existing `loaf journal`, `loaf issue`, `loaf check`, and Linear-aware behavior. - Shell/Python helpers are harder to discover than `loaf <noun> <verb>` and are not consistently covered by CLI tests. diff --git a/dist/skills/orchestration/references/subagent-development.md b/dist/skills/orchestration/references/subagent-development.md index 2d36b6175..d2c46e2a1 100644 --- a/dist/skills/orchestration/references/subagent-development.md +++ b/dist/skills/orchestration/references/subagent-development.md @@ -211,7 +211,7 @@ After subagent completes: | Command | Subagent Role | |---------|---------------| -| breakdown | Tasks become subagent assignments | +| shape | Promoted child issues become subagent assignments | | implement | May dispatch subagents for specialized work | | implement | Automatically coordinates single-task and multi-task subagent work | diff --git a/dist/skills/orchestration/templates/journal.md b/dist/skills/orchestration/templates/journal.md index caf7409fd..9a0a61f8f 100644 --- a/dist/skills/orchestration/templates/journal.md +++ b/dist/skills/orchestration/templates/journal.md @@ -13,7 +13,7 @@ not create or edit journal markdown as the source of truth — use ## Entries -[YYYY-MM-DD HH:MM] skill(implement): implementing TASK-042 +[YYYY-MM-DD HH:MM] skill(implement): implementing LOAF-42 [YYYY-MM-DD HH:MM] decision(scope): description of decision [YYYY-MM-DD HH:MM] discover(scope): something learned [YYYY-MM-DD HH:MM] block(scope): what is blocked diff --git a/dist/skills/pitch/SKILL.md b/dist/skills/pitch/SKILL.md index f6a1a7d39..03f74ef79 100644 --- a/dist/skills/pitch/SKILL.md +++ b/dist/skills/pitch/SKILL.md @@ -1,20 +1,20 @@ --- name: pitch description: >- - Runs the human problem-discovery ceremony at change or project scale: grills - problem, who has it, current alternatives, value proposition, and constraints, - then authors a brief (change brief.md via loaf change init --brief, or project - docs/BRIEF.md with source: pitch). Use when the user invokes pitch, starts - work on a raw concept, or triage dispositions an item as pitch. Produces an - authored problem-space brief and a shape-now or park offer — never shape.md, - tasks, or PRs. Not for solution shaping (use shape), queue processing (use - triage), quick capture (use idea), or open-ended divergent inquiry (use - explore as an agent technique when pitch reveals the direction is undecided). + Runs the human problem-discovery ceremony: grills problem, who has it, current + alternatives, value proposition, and constraints, then hands a sharpened + problem narrative to shape or authors project docs/BRIEF.md. Use when the user + invokes pitch, starts work on a raw concept, or triage dispositions a spark or + idea as pitch. Produces a problem-space narrative and a shape-now or park + offer — never a bounded issue, criteria, or PRs. Not for quick capture (use + idea), solution bounding (use shape), queue processing (use triage), or + open-ended divergent inquiry (use explore as an agent technique when pitch + reveals the direction is undecided). --- # Pitch -Human problem-discovery ceremony. Authors a brief at the matching scale so shape starts from a framed problem and bootstrap can consume a pitched project BRIEF. +Human problem-discovery ceremony. Narrows sparks and ideas into a framed problem so shape can mint an issue, and bootstrap can consume a pitched project BRIEF. ## Contents - Critical Rules @@ -31,61 +31,90 @@ Human problem-discovery ceremony. Authors a brief at the matching scale so shape ## Critical Rules 1. **Agents never initiate a pitch.** This ceremony is human-invoked only. On Claude Code the sidecar sets `disable-model-invocation: true`; on every target this rule binds behaviorally. Agent legwork *inside* a human-opened pitch (competitive scans, file writes the skill directs) is fine — opening one is not. -2. **Log invocation first** — `loaf journal log "skill(pitch): <idea, problem, or intake item>"` before interviewing. -3. **Problem-space only** — grill what, who, and why-valuable. Approach, architecture, decomposition, and verification design belong to shape. A brief that reads like a pseudo-shape is a failure; rewrite before landing. -4. **One question at a time, recommendation-first** — using your harness's structured question tool if it has one (otherwise one inline question per message). Never a multi-field form. Order by impact on the brief. Full mechanics: [references/interview-guide.md](references/interview-guide.md). -5. **Never write `shape.md`, seed `tasks/`, push, or open PRs** — pitch prepares commits and hands off; push and PR stay human. Never auto-run shape or bootstrap. -6. **Landing is validated, then committed once** — every capture landing runs explicit-path `loaf change check <folder> --json` (zero violations, expected captured state) and a direct read-back of that folder's `change.json` confirming intended `target_release` presence or absence, then one docs-only commit per capture. Never batch captures into one commit. -7. **Slug identity is local** — propose a slug that names the concept, never another work unit (no `spec-042`, no task ids). Provenance lives in frontmatter and the change folder. -8. **Log the outcome** — `loaf journal log "decision(pitch): <slug or project> — <shape-now|park-targeted|park-untargeted|handed-to-bootstrap>"`. +2. **Log invocation first** — `loaf journal log "skill(pitch): <idea, problem, spark, or intake item>"` before interviewing. +3. **Problem-space only** — grill what, who, and why-valuable. Approach, architecture, decomposition, and verification design belong to shape. A narrative that reads like a pseudo-shape is a failure; rewrite before landing. +4. **One question at a time, recommendation-first** — using your harness's structured question tool if it has one (otherwise one inline question per message). Never a multi-field form. Order by impact on the narrative. Full mechanics: [references/interview-guide.md](references/interview-guide.md). +5. **Never bound, never ship** — do not add definition-of-done criteria, do not write an out-of-scope statement, do not run `loaf issue check` or `loaf issue promote`, do not push, do not open PRs. Never auto-run shape or bootstrap. +6. **Shape mints on the happy path** — same-session shape-now hands the authored narrative; shape runs `loaf issue new` with that body. Pitch writes an issue body only when parking an unshaped row or when `$ARGUMENTS` already names an issue (`loaf issue edit` replaces the body). +7. **Titles name the concept** — propose a working title, never another work unit's alias. Provenance lives in the issue row, the spark/idea resolution, and frontmatter on `docs/BRIEF.md`. +8. **Log the outcome** — `loaf journal log "decision(pitch): <title, ref, or project> — <shape-now|park-issue|park-idea|handed-to-bootstrap>"`. --- ## Verification -- Change scale: `docs/changes/YYYYMMDD-slug/` holds `change.json` + authored `brief.md`; `loaf change check <folder> --json` reports zero violations and captured state; `change.json` read-back matches the intended target binding -- Project scale: `docs/BRIEF.md` exists with `source: pitch` and the shared problem-space skeleton -- Cold-read: problem, who, alternative, and value nameable in one pass; zero solution-space content -- No `shape.md` or `tasks/` written by this skill; no push; no PR +- Issue-scale: a problem narrative exists against the shared skeleton; it was handed to shape, written into an existing issue body, or minted as an unshaped triage row with that body and no criteria +- Project-scale: `docs/BRIEF.md` exists with `source: pitch` and the shared problem-space skeleton +- Cold-read: problem, who, alternative, and value nameable in one pass; zero solution-space content; no out-of-scope statement and no criteria added by this skill +- Named sparks were promoted to an idea when pitching them; ideas and sparks were resolved against the issue only after a row exists +- No push; no PR; shape and bootstrap were not auto-run - Journal shows skill invocation and outcome entries --- ## Quick Reference +| Harness | Invoke skill | +|---------|----------------| +| Claude Code (plugin) | `/loaf:pitch` | +| OpenCode, Cursor, Codex, Amp | `/pitch` | + ### Scale detection | Signal | Scale | Output | |--------|-------|--------| -| Existing project (git history, source, or Loaf state) + a concept | **Change** | `loaf change init <slug> --brief` → authored `brief.md` | -| Empty or minimal directory / greenfield intent | **Project** | `docs/BRIEF.md` with `source: pitch` → hand to bootstrap | +| Existing project (git history, source, or Loaf state) + a concept | **Issue** | Problem narrative → shape (`loaf issue new --body`) or an unshaped triage row | +| Empty or minimal directory / greenfield product pitch | **Project** | `docs/BRIEF.md` with `source: pitch` → hand to bootstrap | Detect and confirm briefly; let the human correct. When both could apply (repo exists but they want a new product pitch), ask once. -### Landing matrix (Decision 11) +### Landing offers -| Intent | Branch | Commit | Target | -|--------|--------|--------|--------| -| **Shape now** | Create the slug branch (`git switch -c <slug>`), stay there | Hand to shape for in-place promotion — do not park-commit first | Stamp `target_release` when known | -| **Park targeted** | Default branch | One docs-only commit on default (promise-carrier exception) | `target_release` present and confirmed by read-back | -| **Park untargeted** | Slug branch **or** remain intake (Intent/spark) | Docs-only commit on the slug branch if becoming a Change; else no Change folder | No `target_release`; untargeted captures never land on main | +| Offer | When to recommend | What pitch does | +|-------|-------------------|-----------------| +| **Shape now** | Framing is solid; they want to bound next | Hand the narrative; do not mint; do not auto-run shape | +| **Park as issue** | Framed, durable, not bounding yet | `loaf issue new "<title>" --body -` with the narrative only; status stays `triage` | +| **Park as idea** | Too thin to keep as a row, or might discard | `loaf idea capture --title "..."`; journal the gist | +| **Hand to bootstrap** | Project-scale BRIEF authored | Point at bootstrap; do not auto-run it | -Pitch prepares the commit; never pushes; never opens PRs. +Pitch never pushes; never opens PRs. There is nothing to commit at issue scale — the row lives in SQLite. Project-scale may commit `docs/BRIEF.md` if the human wants it durable. -### Pre-landing guard (every capture) +### Spark and idea promotion -```bash -loaf change check <folder> --json # zero violations; state is captured -# then read <folder>/change.json and confirm target_release presence/absence matches intent +| Input | Read | Then | +|-------|------|------| +| Spark | `loaf spark show <ref>` | `loaf idea capture --title "..."` then `loaf spark promote <spark> --to-idea <idea>`; grill from the idea | +| Idea | `loaf idea show <ref>` | Grill; after a row exists, `loaf idea resolve <idea> --by <ref>` | +| Existing issue | `loaf issue show <ref>` | Grill; `loaf issue edit <ref> --body -` writes the narrative (replaces the whole body) | +| Free text | — | Grill; shape-now hands text; park captures an idea or mints an unshaped row | + +Do not invent a pitch from the queue without human selection. When they name an intake item, read it (`loaf intake list` / the item's read command). + +`loaf idea promote --to-spec` is not this path. Resolve ideas against the minted issue. + +### Problem-narrative skeleton + +Author against these sections, problem-space sentences only. This text is what shape puts in `--body` (or what a park-as-issue row stores): + +```markdown +## Problem Statement +## Who Has It +## Current Alternatives +## Value Proposition +## Constraints +## Sequencing and Relationships +## Sources and Research Links +## Open Questions ``` -Bare `loaf change check` resolves by branch and can miss a capture landing elsewhere — always pass the explicit folder path. +Do not add an out-of-scope statement. Shape bounds; pitch frames. ### Defined terms -- **Brief** — the pitch output (problem-space). Superseded by `shape.md` when shaping starts; may accrete parked problem-space sentences until then; freezes when `shape.md` exists. -- **Accretion** — adding problem-space concepts to a parked brief is legal; solution prose is not. -- **Shape now** — slug branch + hand to shape, which promotes the capture in place via ordinary `loaf change init <slug>` (no `--brief`). +- **Problem narrative** — pitch's issue-scale output. Superseded as the working surface once shape mints and bounds the issue; may accrete parked problem-space sentences until then. +- **BRIEF** — project-scale `docs/BRIEF.md`. A project document, not a work container. +- **Accretion** — adding problem-space concepts to a parked narrative is legal; solution prose is not. +- **Shape now** — hand the narrative to shape, which mints via `loaf issue new` and owns bounding. --- @@ -94,81 +123,82 @@ Bare `loaf change check` resolves by branch and can miss a capture landing elsew ### Step 1: Log and parse input ```bash -loaf journal log "skill(pitch): <idea, problem, or intake item>" +loaf journal log "skill(pitch): <idea, problem, spark, or intake item>" ``` -Parse `$ARGUMENTS`: free text, an intake ref the human already chose, or empty (ask what to pitch). Read the named intake item when provided (`loaf intake list` / the item's read command). Do not invent a pitch from the queue without human selection. +Parse `$ARGUMENTS`: free text, a spark, an idea, an issue ref, an intake ref the human already chose, or empty (ask what to pitch). Read the named item when provided. Do not invent a pitch from the queue without human selection. ### Step 2: Detect scale -Apply the Quick Reference table. Confirm: "I'll treat this as a **change-scale** pitch on this repo" or "…as a **project-scale** pitch for a new BRIEF." Adjust if corrected. +Apply the Quick Reference table. Confirm: "I'll treat this as an **issue-scale** pitch on this repo" or "…as a **project-scale** pitch for a new BRIEF." Adjust if corrected. -### Step 3: Problem-discovery interview +### Step 3: Promote sparks; read ideas + +When the named input is a spark, promote it to an idea before grilling so the capture trail is one idea, not a dangling spark: + +```bash +loaf idea capture --title "<working title>" +loaf spark promote <spark> --to-idea <idea> +``` + +When the named input is already an idea, `loaf idea show` and grill. Leave resolution until an issue row exists. + +### Step 4: Problem-discovery interview Run the interview per [references/interview-guide.md](references/interview-guide.md): -- Pin a one-or-two-line **destination** before dimension grilling (fixes brief scope; project scale feeds VISION success criteria, change scale sharpens the eventual Hypothesis) +- Pin a one-or-two-line **destination** before dimension grilling (fixes narrative scope; project scale feeds VISION success criteria; issue scale sharpens what good looks like for the row) - Dimensions: problem, who has it, current alternatives / competitive landscape, value proposition, constraints (plus sequencing and open questions when needed) - Depth: scenario stress-testing, challenge stance, glossary-term hygiene; open questions must pass the specifiability test and carry HITL/AFK tags when precise - Applicability judgment: skip formal competitive analysis and deep personas when the pitch kind does not warrant them (bug fixes, internal chores) -- One question at a time, recommendation-first, ordered by brief impact +- One question at a time, recommendation-first, ordered by narrative impact - Stop on exit criteria or when answers stop changing the framing -If the direction is genuinely undecided mid-interview, offer the **explore** technique (agent-side; Explorations and checkpoints) rather than forcing a false brief. Pitch remains the human front door; explore is not re-opened as a slash alternative by this skill. +If the direction is genuinely undecided mid-interview, offer the **explore** technique (agent-side; Explorations and checkpoints) rather than forcing a false narrative. Pitch remains the human front door; explore is not re-opened as a slash alternative by this skill. -### Step 4: Evidence delegation (when warranted) +### Step 5: Evidence delegation (when warranted) -When competitive or landscape facts would change the brief and are not already known: +When competitive or landscape facts would change the narrative and are not already known: 1. Spawn a **researcher** subagent with a bounded question (competitors, substitutes, prior art — not solution design). 2. Land evidence: - - **Change scale:** files under the change folder's `research/` (create the folder with the change); link from Sources and Research Links - - **Project scale:** inline source links in `docs/BRIEF.md` Sources and Research Links (no change `research/` yet) -3. Resume the interview or brief draft with recommendations informed by the scan. + - **Issue scale:** source links in the narrative's Sources and Research Links. If a longer scan lands on disk, name it for the landscape, never for the work unit, and cite it from Sources. + - **Project scale:** inline source links in `docs/BRIEF.md` Sources and Research Links. +3. Resume the interview or draft with recommendations informed by the scan. Never fabricate competitive claims. If a scan is skipped, say so in Sources ("no external scan; alternative is internal workaround X"). -### Step 5a: Change-scale ceremony - -1. **Propose a slug** — lowercase, digits, single hyphens; names the concept locally. Confirm with the human. -2. **Initialize capture:** - - ```bash - loaf change init <slug> --brief - ``` - - Creates `docs/changes/YYYYMMDD-<slug>/` with `change.json` + `brief.md` scaffold only. -3. **Author `brief.md`** against the shared problem-space skeleton (shape's brief template / the scaffold just written): Problem Statement, Who Has It, Current Alternatives, Value Proposition, Constraints, Sequencing and Relationships, Sources and Research Links, Open Questions. Problem-space sentences only. -4. **Stamp `target_release` when known** — edit `change.json` with canonical `MAJOR.MINOR.PATCH` (no `v`, no prerelease). Omit the field when untargeted. Confirm with the human before stamping. -5. **Accretion note** — tell the human: parked problem-space concepts may accrete into this brief until shaping starts; once `shape.md` exists the brief freezes. -6. **Cold-read** the brief (interview guide test); revise with the human until it passes. -7. **Offer landing** (recommendation-first): +### Step 6a: Issue-scale ceremony - | Offer | When to recommend | - |-------|-------------------| - | **Shape now** | Framing is solid and they want to bound implementation next | - | **Park targeted** | Bound to a release cohort but not shaping yet — docs-only on default branch | - | **Park untargeted** | Worth capturing off-main, or not ready as a Change (stay intake) | +1. **Propose a working title** — names the concept locally. Confirm with the human. This becomes shape's `loaf issue new` title (or the park-as-issue title). +2. **Author the problem narrative** against the skeleton above. Problem-space sentences only. +3. **Accretion note** — tell the human: parked problem-space concepts may accrete until shaping starts; once the issue is minted, the body is the home. +4. **Cold-read** the narrative (interview guide test); revise with the human until it passes. +5. **Offer landing** (recommendation-first) using the Landing offers table. +6. **Execute the chosen landing:** -8. **Execute the chosen landing:** + - **Shape now:** hand the full narrative and any spark/idea refs. Shape runs `loaf issue new "<title>" --body -` (or `--body-file`) with that text. Do not mint, do not add criteria, do not open a PR. After shape mints, resolve intake: `loaf idea resolve <idea> --by <ref>` (and `loaf spark resolve <spark> --by <ref>` only if the spark was never promoted). + - **Park as issue:** mint the unshaped row yourself, then resolve intake against it: - - **Shape now:** `git switch -c <slug>` (from default unless already on a working branch the human prefers), ensure pre-landing guard would pass if they later park, hand to shape with the folder path — shape promotes in place. Do not open a PR. - - **Park targeted:** on the **default branch**, run pre-landing guard on the explicit folder, confirm `target_release` present in `change.json`, then one docs-only commit of the change folder (and any `research/` under it). - - **Park untargeted as Change:** `git switch -c <slug>`, pre-landing guard, confirm `target_release` **absent**, one docs-only commit on the slug branch. - - **Park as intake:** do not leave a half-written change folder; prefer Intent/spark retention and delete or never create the capture if the human backs out. + ```bash + loaf issue new "<title>" --body - + loaf idea resolve <idea> --by <ref> + ``` -9. **Commit message** (when parking): conventional, e.g. `docs(change): capture <slug> brief` — one commit per capture. + Paste the narrative on stdin. Do not add criteria. Do not write out-of-scope. Default status is `triage`. Read back with `loaf issue show <ref>`. + - **Park as idea:** if no idea exists yet, `loaf idea capture --title "<title>"`. Journal the gist (`loaf journal log "discover(pitch): <one-line problem>"`). Do not mint an issue. + - **Existing issue:** `loaf issue edit <ref> --body -` with the full narrative. Edit replaces the body; do not strip a row that is already bounded — if criteria already exist, hand the narrative to the human and let shape merge. -10. **Closing ceremony (required — never trail off).** After the landing is executed (or intake retained), announce completion with a full closing block: +7. **Closing ceremony (required — never trail off).** After the landing is executed, announce completion with a full closing block: - - **Recap the brief** — section-by-section gist (Problem, Who, Alternatives, Value, Constraints, Sequencing, Open Questions). Name the change folder path (`docs/changes/YYYYMMDD-<slug>/`) and what it holds (`change.json` + `brief.md`, plus any `research/`). + - **Recap the narrative** — section-by-section gist (Problem, Who, Alternatives, Value, Constraints, Sequencing, Open Questions). Name where it lives (handed to shape, unshaped issue `<ref>`, idea `<ref>`, or the conversation plus journal gist). - **Restate the landing actually taken** and what it means next: - - **Shape now** — you are on the slug branch; run shape next to promote the capture in place and bound implementation. No park-commit was made. - - **Park targeted** — the capture is a docs-only commit on the default branch with `target_release` stamped; it sits as a promise carrier for that cohort until shape is invoked later. - - **Park untargeted** — the capture lives on the slug branch (or remains intake) without `target_release`; it is off-main until retargeted or shaped. If intake-only, name the Intent/spark and that no change folder was left half-written. + - **Shape now** — run shape next to mint the issue from this narrative and bound implementation. No row was minted here. + - **Park as issue** — `<ref>` holds the problem in its body and is unshaped; run shape later on that ref. + - **Park as idea** — the idea remains open; re-invoke pitch or shape when ready. Name the idea ref. - **Announce completion** in plain language: "Pitch is complete." Do not end on a dangling offer or an unfinished sentence. -### Step 5b: Project-scale ceremony +### Step 6b: Project-scale ceremony 1. **Author `docs/BRIEF.md`** using bootstrap's brief skeleton with frontmatter: @@ -180,31 +210,31 @@ Never fabricate competitive claims. If a scan is skipped, say so in Sources ("no --- ``` - Same problem-space sections as change scale, at project altitude (Sequencing describes the initial arc as prose). + Same problem-space sections as issue scale, at project altitude (Sequencing describes the initial arc as prose). 2. **Cold-read** and revise with the human. 3. Optionally commit `docs/BRIEF.md` if the human wants it durable before bootstrap; still no push unless they ask outside this skill's duties — pitch itself never pushes. 4. **Closing ceremony (required — never trail off).** Announce completion with a full closing block — do not hand off in a half-sentence: - **Recap what was authored** — section-by-section gist of the BRIEF (Problem Statement, Who Has It, Current Alternatives, Value Proposition, Constraints, Sequencing and Relationships, Sources and Research Links, Open Questions). One or two sentences per section is enough; the human should hear what landed without reopening the file. - **Artifact path** — name `docs/BRIEF.md` explicitly, including that frontmatter carries `source: pitch`. - - **Explicit handoff** — state, verbatim in spirit: next, run bootstrap: it reads this BRIEF as discovery-done (`source: pitch`), interviews only on gaps, populates the operating documents (VISION, STRATEGY, ARCHITECTURE, AGENTS), and closes by proposing your initial arc of captured changes. Do not auto-run bootstrap. + - **Explicit handoff** — state, verbatim in spirit: next, run bootstrap: it reads this BRIEF as discovery-done (`source: pitch`), interviews only on gaps, and populates the operating documents (VISION, STRATEGY, ARCHITECTURE, AGENTS). Do not auto-run bootstrap. - **Announce completion** in plain language: "Pitch is complete." The ceremony ends with a period, never a trail-off. -### Step 6: Log the outcome +### Step 7: Log the outcome ```bash -loaf journal log "decision(pitch): <slug or project> — <shape-now|park-targeted|park-untargeted|handed-to-bootstrap>" +loaf journal log "decision(pitch): <title, ref, or project> — <shape-now|park-issue|park-idea|handed-to-bootstrap>" ``` -The journal line is mechanical; the human-facing close is the closing ceremony in Step 5a/5b. Never log-and-stop without that recap and next-step restatement. +The journal line is mechanical; the human-facing close is the closing ceremony in Step 6a/6b. Never log-and-stop without that recap and next-step restatement. --- ## Related Skills -- **shape** — solution-space narrowing from an existing brief (or full narrowing when no brief); promotes capture folders in place -- **bootstrap** — consumes `docs/BRIEF.md` (`source: pitch` → gap interview) and series-preps captured changes -- **triage** — queue dispositions; may hand an item to pitch when problem discovery is needed +- **shape** — solution-space bounding; mints the issue from the problem narrative (`loaf issue new`) and owns criteria, out-of-scope, and decomposition +- **bootstrap** — consumes `docs/BRIEF.md` (`source: pitch` → gap interview) and populates operating documents +- **triage** — queue dispositions; may hand a spark or idea to pitch when problem discovery is needed - **explore** — agent-side technique when pitch finds the direction still undecided - **idea** — quick capture without ceremony; not a substitute for pitch - **research** — patterns the researcher subagent follows for landscape scans @@ -217,4 +247,4 @@ The journal line is mechanical; the human-facing close is the closing ceremony i ## Artifact Naming -Name every artifact for what it is, never for the work unit that produced it. The change folder already records provenance. Put source in frontmatter, not the filename. See the `foundations` skill; `loaf check --hook artifact-names` enforces it at commit. +Name every on-disk artifact for what it is, never for the work unit that produced it. The issue row or `docs/BRIEF.md` already records provenance. Put source in frontmatter, not the filename. See the `foundations` skill; `loaf check --hook artifact-names` enforces it at commit. diff --git a/dist/skills/pitch/references/interview-guide.md b/dist/skills/pitch/references/interview-guide.md index 8014082fe..65780fb10 100644 --- a/dist/skills/pitch/references/interview-guide.md +++ b/dist/skills/pitch/references/interview-guide.md @@ -11,7 +11,7 @@ Problem-discovery interview for pitch. Borrows shape's grilling mechanics (one q - Open Questions: Specifiability and HITL/AFK - Exit Criteria - Anti-Patterns -- Brief Cold-Read +- Problem Cold-Read ## How This Guide Works @@ -23,22 +23,22 @@ The interview is adaptive, not a form. Strong answers skip dimensions; weak answ ## Destination Pinning -Before dimension grilling, converge on a **destination**: one or two lines naming the end state this pitch is aiming at — what becomes true in the world if the work succeeds. Pin it early; it fixes the brief's scope for the rest of the interview. +Before dimension grilling, converge on a **destination**: one or two lines naming the end state this pitch is aiming at — what becomes true in the world if the work succeeds. Pin it early; it fixes the narrative's scope for the rest of the interview. | Scale | Destination feeds | |-------|-------------------| | **Project** | VISION success criteria (bootstrap extracts it; pitch keeps it as the project's north star in the BRIEF) | -| **Change** | The eventual Hypothesis when shape promotes the capture — a sharper "what good looks like" than a feature list | +| **Issue** | What good looks like for the work — the problem statement shape will put in the issue body | **How to pin:** offer a recommendation-first draft from the human's opening words ("Destination: operators can ship a release without a manual config audit"). Confirm, tighten, or rewrite until both parties can restate it. Do not start deep dimension probes until the destination is on the table. -If the destination keeps shifting mid-interview, pause and re-pin — a moving destination means the brief's scope is still open. +If the destination keeps shifting mid-interview, pause and re-pin — a moving destination means the narrative's scope is still open. --- ## Problem-Discovery Dimensions -Grill these five dimensions. Order by what would change the brief most — a wrong "who" invalidates value and alternatives; cosmetic naming goes last. +Grill these five dimensions. Order by what would change the narrative most — a wrong "who" invalidates value and alternatives; cosmetic naming goes last. ### 1. Problem @@ -62,17 +62,17 @@ What do they do today? Existing tools, manual workarounds, cobbled scripts, or " Why is solving this worth it? What becomes true for the people who have the problem if this lands? One line: different AND better relative to the alternative — not a feature list, not an architecture sketch. -**Applicability:** always name value, but depth scales. A product pitch needs a crisp UVP; a small change can be "removes the weekly fire-drill so release day is boring." +**Applicability:** always name value, but depth scales. A product pitch needs a crisp UVP; a small internal pitch can be "removes the weekly fire-drill so release day is boring." ### 5. Constraints -Non-negotiable bounds: technical, legal, organizational, philosophical, sequencing. Things that limit the solution space before design begins. Capture as problem-space limits ("must not break the promise-carrier exception"), never as chosen designs ("use Postgres"). +Non-negotiable bounds: technical, legal, organizational, philosophical, sequencing. Things that limit the solution space before design begins. Capture as problem-space limits ("must not add a new human ceremony to the weekly path"), never as chosen designs ("use Postgres"). **Always ask lightly:** at least one real constraint or an explicit "none known yet." ### Secondary (only when signal demands) -- **Sequencing and relationships** — how this hangs with other work, release cohort as prose, series order. No machine relation fields. +- **Sequencing and relationships** — how this hangs with other work, series order. No machine relation fields. - **Open questions** — unresolved problem-space items that pass the [specifiability test](#open-questions-specifiability-and-hitlafk); each tagged HITL or AFK. - **Evidence of pain** — money, time, workarounds (Mom Test lens). When absent and the claim is large, challenge gently. @@ -105,13 +105,13 @@ Every question includes a recommended answer and a short rationale. The human ov Example shape: > **Who has this problem most often?** -> Recommendation: mid-size platform engineers who already run multi-service deploys — they feel config drift weekly. Rationale: your examples all came from that world; broader "all developers" would dilute the brief. +> Recommendation: mid-size platform engineers who already run multi-service deploys — they feel config drift weekly. Rationale: your examples all came from that world; broader "all developers" would dilute the narrative. ### Ordering -Prioritize answers that would rewrite the brief. Typical order: **destination pin** → problem → who → alternatives → value → constraints → sequencing/open questions. Reorder when the human's first utterance already settles an early dimension. +Prioritize answers that would rewrite the narrative. Typical order: **destination pin** → problem → who → alternatives → value → constraints → sequencing/open questions. Reorder when the human's first utterance already settles an early dimension. -Before asking, check whether reading resolves it — journal, prior Change, intake item body, BRIEF. Only ask what reading could not answer. +Before asking, check whether reading resolves it — journal, prior issue, intake item, BRIEF. Only ask what reading could not answer. ### Adaptive depth @@ -120,12 +120,12 @@ Before asking, check whether reading resolves it — journal, prior Change, inta | Crisp, specific answers | Confirm, move on; skip expand-if-needed probes | | Category answers ("developers need better tools") | Ask for a concrete story or last painful moment | | Solution-first ("I want a CLI that…") | Pause; reframe to problem and who | -| Energy dropping | Cut to synthesis; a brief with named gaps beats an exhausted interrogation | -| Direction genuinely undecided | Offer the explore technique from inside pitch; do not force a false brief | +| Energy dropping | Cut to synthesis; a narrative with named gaps beats an exhausted interrogation | +| Direction genuinely undecided | Offer the explore technique from inside pitch; do not force a false narrative | ### Scenario stress-testing -Probe the problem with **concrete lived scenarios**, not abstract categories. Prefer "walk me through the last time this bit you" over "how often does this happen?" Invent edge cases deliberately to force boundary precision: "What if the operator is on-call at 2am with half the logs missing — is that still this problem, or a different one?" Scenarios that collapse the framing expose missing constraints or a second concept that should stay out of this brief. +Probe the problem with **concrete lived scenarios**, not abstract categories. Prefer "walk me through the last time this bit you" over "how often does this happen?" Invent edge cases deliberately to force boundary precision: "What if the operator is on-call at 2am with half the logs missing — is that still this problem, or a different one?" Scenarios that collapse the framing expose missing constraints or a second concept that should stay out of this narrative. ### Challenge stance @@ -133,12 +133,12 @@ Demand specificity over generalization. Probe the rationale behind claims ("why Also enforce **canonical language** during the interview: -- Flag terms that conflict with `docs/knowledge/glossary.md` usage (e.g. calling a Change a "spec," a release cohort a "milestone," a skill a "module"). Offer the glossary term and get the human to accept the swap before it lands in the brief. +- Flag terms that conflict with `docs/knowledge/glossary.md` usage (e.g. calling an issue a "ticket," a skill a "module"). Offer the glossary term and get the human to accept the swap before it lands in the narrative. - Sharpen fuzzy project-local terms to a single canonical choice mid-interview ("you said both 'capture' and 'ticket' — pick one and stick to it"). Ambiguous vocabulary becomes solution fog later. ### Mid-interview evidence -When competitive landscape or external facts would change the brief and the human lacks them, pause the grill, delegate a researcher subagent, land evidence (change-scale: `research/` in the change folder; project-scale: links in Sources), then resume with a recommendation informed by the scan. Evidence supports framing; it does not become solution design. +When competitive landscape or external facts would change the narrative and the human lacks them, pause the grill, delegate a researcher subagent, land evidence (issue-scale: source links in the narrative; project-scale: links in Sources), then resume with a recommendation informed by the scan. Evidence supports framing; it does not become solution design. --- @@ -146,11 +146,11 @@ When competitive landscape or external facts would change the brief and the huma ### Specifiability test -An open question earns a **precise entry** in the brief only if it can be **stated precisely now** — not answered now. The bar is: a later reader (human or agent) could work the question without inventing what was meant. +An open question earns a **precise entry** in the narrative only if it can be **stated precisely now** — not answered now. The bar is: a later reader (human or agent) could work the question without inventing what was meant. | Passes (precise entry) | Fails (coarse note only) | |------------------------|--------------------------| -| "Does the operator need multi-region failover in v1, or is single-region acceptable for the first cohort?" | "Figure out reliability stuff" | +| "Does the operator need multi-region failover in v1, or is single-region acceptable until the first cut?" | "Figure out reliability stuff" | | "Which existing CLI command is the migration source of truth for config paths?" | "TBD on integration" | Everything vaguer stays a **coarse note** in Open Questions or Sequencing prose — never pre-sliced into fake precision. Do not invent enumerated options the human did not surface. @@ -164,7 +164,7 @@ Mark each precise open question with one of: | **HITL** | Needs the human live — judgment, taste, organizational call, or access only they hold | "Will legal accept the data-retention tradeoff?" | | **AFK** | Runnable by an agent without the human in the loop — research, codebase scan, competitive lookup | "What do the top three substitutes charge for the free tier?" | -Briefs carry the tag inline in the Open Questions section, e.g. `- [HITL, blocking] …` or `- [AFK, deferrable] …`. Blocking vs deferrable remains orthogonal: a HITL question can be deferrable; an AFK question can still block shaping if the answer rewrites scope. +Narratives carry the tag inline in the Open Questions section, e.g. `- [HITL, blocking] …` or `- [AFK, deferrable] …`. Blocking vs deferrable remains orthogonal: a HITL question can be deferrable; an AFK question can still block shaping if the answer rewrites scope. --- @@ -180,13 +180,13 @@ Stop interviewing when all of the following hold (or the human explicitly wants 6. **Constraints** are listed or explicitly empty. 7. Answers have stopped changing the framing — the last questions confirmed rather than rewrote. 8. Open questions that remain pass the specifiability test (or are coarse notes) and carry HITL/AFK tags when precise. -9. A cold reader could pass the brief cold-read test below. +9. A cold reader could pass the problem cold-read test below. -Open questions may remain; mark each blocking or deferrable, and HITL or AFK when precise. Blocking problem-space unknowns belong in the brief's Open Questions, not as invented answers. +Open questions may remain; mark each blocking or deferrable, and HITL or AFK when precise. Blocking problem-space unknowns belong in the narrative's Open Questions, not as invented answers. ### The pivot -Do not announce "the interview is over." Shift: "I think I have enough to draft the brief — tell me what I got wrong." Author the brief against the shared skeleton, then section-review with the human before any init or landing step. The **closing ceremony** (recap, path, next step) is owned by SKILL.md after landing — this guide owns interview depth only. +Do not announce "the interview is over." Shift: "I think I have enough to draft the problem narrative — tell me what I got wrong." Author the narrative against the shared skeleton, then section-review with the human before any mint or landing step. The **closing ceremony** (recap, path, next step) is owned by SKILL.md after landing — this guide owns interview depth only. --- @@ -196,7 +196,7 @@ Adopted from bootstrap's interview guide; binding on pitch. **The Form.** Running dimensions mechanically like a survey. If answer 2 covers dimension 4, confirm and skip. -**The 45-Minute Interrogation.** If energy drops, cut to draft. Gaps in the brief are honest; drained enthusiasm is not recoverable in the same session. +**The 45-Minute Interrogation.** If energy drops, cut to draft. Gaps in the narrative are honest; drained enthusiasm is not recoverable in the same session. **The Therapist.** Do not explore the builder's feelings about the product. User emotions (switching forces, pain) matter; builder therapy does not. @@ -212,7 +212,7 @@ Adopted from bootstrap's interview guide; binding on pitch. **Third Interview Idiom.** Do not invent pitch-specific interview machinery. Destination pinning, scenario stress-testing, and challenge stance deepen the same grilling mechanics — they are not a parallel framework. -**Pseudo-Shape in the Brief.** Approach, architecture, task breakdown, or verification design must not enter `brief.md` / `docs/BRIEF.md`. If it appears while drafting, move it out and note it for shape. +**Pseudo-Shape in the Narrative.** Approach, architecture, decomposition, or verification design must not enter the problem narrative or `docs/BRIEF.md`. If it appears while drafting, move it out and note it for shape. **Fake Precision.** Pre-slicing vague unknowns into numbered open questions that cannot yet be stated precisely. Coarse notes beat counterfeit clarity. @@ -220,9 +220,9 @@ Adopted from bootstrap's interview guide; binding on pitch. --- -## Brief Cold-Read +## Problem Cold-Read -Before offering shape-now or park, cold-read the authored brief. A stranger should name, in one pass: +Before offering shape-now or park, cold-read the authored narrative (issue-scale) or `docs/BRIEF.md` (project-scale). A stranger should name, in one pass: 1. The **destination** (or success end-state) 2. The **problem** @@ -230,4 +230,4 @@ Before offering shape-now or park, cold-read the authored brief. A stranger shou 4. The **current alternative** 5. The **value** of solving it -…and find **zero solution-space content** (no approach, stack, API shape, or task list). Precise open questions should carry HITL/AFK tags. If any of the five is missing or solution prose creeps in, revise before landing. +…and find **zero solution-space content** (no approach, stack, API shape, or implementation slices). Precise open questions should carry HITL/AFK tags. If any of the five is missing or solution prose creeps in, revise before landing. diff --git a/dist/skills/refactor-deepen/SKILL.md b/dist/skills/refactor-deepen/SKILL.md index 298a5b152..6eb0857d2 100644 --- a/dist/skills/refactor-deepen/SKILL.md +++ b/dist/skills/refactor-deepen/SKILL.md @@ -172,7 +172,7 @@ skill terminates by writing a PLAN file using [templates/plan.md](templates/plan > Plan saved to `.agents/plans/<filename>.md`. Workflow handoff is pending > the SPEC/PLAN/TASKS artifact taxonomy spec — for now, decide manually. -Do **not** recommend breakdown or implement as the next step. The +Do **not** recommend implement as the next step. The handoff design is downstream of a deferred taxonomy spec. ### Codex Review (Opt-In, Plugin-Gated) diff --git a/dist/skills/refactor-deepen/templates/plan.md b/dist/skills/refactor-deepen/templates/plan.md index 8be684590..0fe537186 100644 --- a/dist/skills/refactor-deepen/templates/plan.md +++ b/dist/skills/refactor-deepen/templates/plan.md @@ -28,8 +28,8 @@ write a new file rather than updating an existing one. | `title` | Yes | One-line description of the deepening, not the candidate name | | `created` | Yes | ISO 8601 UTC, e.g. `2026-05-02T01:30:00Z` (must match the filename timestamp) | | `status` | Yes | `drafting` on first write; this template does not define additional lifecycle states | -| `spec` | Yes | `SPEC-NNN` if the plan is scoped under a spec; `null` otherwise (do not omit the key) | -| `related` | No | List of related artifact IDs (`ADR-*`, `SPEC-*`, idea filenames, other plan filenames) | +| `issue` | Yes | `LOAF-NNN` if the plan is scoped under an issue; `null` otherwise (do not omit the key) | +| `related` | No | List of related artifact IDs (`ADR-*`, `LOAF-*`, idea filenames, other plan filenames) | PLAN files do **not** carry an `id` frontmatter field. The filename is the identity, mirroring councils and ideas. @@ -131,7 +131,7 @@ Filename: `.agents/plans/20260502-013000-deepen-journal-append.md` title: "Deepen journal append into a self-managing module" created: "2026-05-02T01:30:00Z" status: drafting -spec: SPEC-034 +issue: LOAF-34 related: - 20260501-231922-plan-lifecycle-cli-doctor-housekeeping --- diff --git a/dist/skills/reflect/SKILL.md b/dist/skills/reflect/SKILL.md index c333656ea..4fb0f80bd 100644 --- a/dist/skills/reflect/SKILL.md +++ b/dist/skills/reflect/SKILL.md @@ -80,12 +80,12 @@ After completing work, reflect extracts learnings and proposes updates to strate ### Step 1: Parse Input -`$ARGUMENTS` can be: a spec ID (`SPEC-001`), a topic ("authentication learnings"), or empty (general reflection on recent work). +`$ARGUMENTS` can be: an issue ref (`LOAF-42`), a topic ("authentication learnings"), or empty (general reflection on recent work). ### Step 2: Gather Evidence Sources: -1. **Completed specs** (`.agents/specs/SPEC-*.md` with status `done`; legacy files may still read `complete`) -- look for "Lessons Learned" +1. **Completed issues** (`loaf issue list` / `loaf issue show <ref>` with status `done`) -- look for lessons in the issue body 2. **Project journal** (`loaf journal recent --json`, `loaf journal search <topic>`) -- insights, surprises, pivots 3. **Recent commits** (`git log --oneline -30`) 4. **Implementation reality** -- what was harder/easier than expected? What assumptions were wrong? diff --git a/dist/skills/release/SKILL.md b/dist/skills/release/SKILL.md index a896624d0..120a37cc2 100644 --- a/dist/skills/release/SKILL.md +++ b/dist/skills/release/SKILL.md @@ -1,32 +1,26 @@ --- name: release description: >- - Orchestrates standalone releases from already-landed work: release readiness, - version selection, changelog curation, release commit, tag, GitHub Release, - install verification, and post-release follow-up. Use when the user says "cut - a release," "publish a version," "release from main," or asks whether enough - landed work should become a release. Not for reviewing or merging a PR (use - ship). + Cuts a retroactive release from already-landed issues: loaf release suggest + reports the range, loaf release cut records the version. Use when the user + says "cut a release," "publish a version," "release from main," or asks what + landed since the last tag. Produces a recorded release row and members as + facts. Not for reviewing or merging a PR (use ship). --- # Release -Publish a coherent version from work that has already landed. +Cut a version from work that has already landed. ## Contents - Critical Rules - Verification - Quick Reference - Topics -- Context Detection -- Step 1: Release Readiness -- Step 2: Change Collection -- Step 3: Version + Changelog -- Step 4: Release Execution -- Step 5: Release-PR Flow -- Step 6: Publication Verification -- Step 7: Post-Release Follow-Up -- Hook Interaction +- Process +- Attribution +- Bump derivation +- Must-contain convention - Related Skills **Input:** $ARGUMENTS @@ -35,259 +29,227 @@ Publish a coherent version from work that has already landed. ## Critical Rules -- **Release is not merge** -- do not use release to review, approve, or land a feature PR. Use ship for PR correctness and landing. -- **Release from landed work** -- collect changes from the release base branch, normally the repo default branch, since the last release tag. -- **Release-PR flow is the default** -- prepare on a release branch with `loaf release --pre-merge`, squash-merge the release PR, then finalize with `loaf release --post-merge` on the base branch. Direct `--bump` on the base branch is a named exception used only on explicit user request. -- **Batch by intent** -- group release notes by user-facing outcome, `CR-*` change bundle, spec, or related PRs; do not mirror individual commits mechanically. -- **Keep landed and released distinct** -- a PR may be landed without being released; a release may contain multiple landed PRs. -- **Block on release-readiness failure** -- do not publish if build, tests, version files, changelog, tag, or GitHub release state is inconsistent. -- **Never push, tag, or publish without confirmation** -- present the exact actions first. -- **Use your harness's structured question tool (if it has one) for release decisions** -- version bump type, release PR handoff, push/tag/GitHub Release confirmation. -- **Log release** -- after publication, run `loaf journal log "decision(release): vX.Y.Z published from <base> with <summary>"`. +1. **Log invocation first** — `loaf journal log "skill(release): <what is being cut or suggested>"` before doing anything else. +2. **Release is not merge** — do not review, approve, or land a PR here. Verification authority is the ship workflow (PR review and CI at merge). If the user is asking to merge, stop and route to ship. +3. **A release is cut from what landed** — the surface is `loaf release suggest` and `loaf release cut`. Do not run unsubcommmanded `loaf release`, `--pre-merge`, or `--post-merge`; this skill does not own that path. +4. **Suggest writes nothing** — it reads `baseline-tag..HEAD` (or `--base <ref>..HEAD`), attributes commits to issues, rolls up through parents, reports partially-landed parents and unattributed commits as information, derives the bump, reports the advisory bucket delta, and drafts notes. +5. **Cut records facts** — it applies the version, prepends the drafted notes into `CHANGELOG.md`, tags, records the release row plus members, then attempts a GitHub Release draft. A `gh` failure degrades to a warning with a paste-ready retry command; the recorded row stays. +6. **No forward version stamp** — do not bind an issue to a future version. Members are what already landed. Buckets (`loaf issue bucket`) are advisory labels; planned-vs-landed is information only. +7. **No suite, no re-record, no publication stop in this skill** — ship already verified the merged work. Cut's operational refusals (dirty worktree, disagreeing version files, missing version, `--no-tag` without an existing tag) are command errors, not a substitute for ship. +8. **Confirm before cut** — present the suggest report (or `cut --dry-run`) first. Ask one question at a time, with a recommendation, using your harness's structured question tool if it has one. `--dry-run` previews everything and writes nothing. +9. **Log the outcome** — after a successful cut, `loaf journal log "decision(release): vX.Y.Z recorded from <base> with <summary>"`. + +--- ## Verification -- Release base branch is clean, current, and contains the intended landed PRs -- Pre-flight checks pass before versioning or publication -- Changelog entries are curated user-facing prose, not commit or PR-title dumps -- Version files, changelog heading, git tag, and GitHub Release all agree -- Tag points at the released base-branch commit or release commit, not an abandoned feature branch -- Downstream install path is verified when applicable, especially Homebrew for Loaf releases +- Journal contains the `skill(release)` invocation (and a `decision(release)` entry after a real cut) +- The work in the range already landed through ship (PR review and CI at merge); this skill did not re-verify or re-merge it +- `loaf release suggest` (or `cut --dry-run`) was shown: landed issues, partially-landed parents, unattributed commits, advisory buckets, derived bump, drafted notes +- Partially-landed parents, unattributed commits, and bucket drift were reported as information — not treated as a cut refusal +- Mutating `loaf release cut` updated version files, wrote the notes into `CHANGELOG.md`, created or reused tag `v<version>`, and recorded the release row with issue members (plus `--includes` release members when given) +- `cut --dry-run` left version files, changelog, tags, HEAD, and release rows untouched +- GitHub Release is a draft, was skipped with `--no-gh`, or failed with a warning plus a paste-ready `gh release create …` retry — never a silent rollback of the recorded row +- No issue was stamped with a future version + +--- ## Quick Reference -| Step | Gate | Blocking? | -|------|------|-----------| -| Readiness | clean/current base branch, no unresolved release collisions | Yes | -| Change Collection | landed work since last tag grouped into release themes | Yes | -| Version + Changelog | bump selected, notes curated, files updated | Yes | -| Execution | release commit prepared via `--pre-merge`, release PR landed, `--post-merge` finalizes | Yes | -| Verification | release and install paths checked | Yes | -| Follow-Up | reflect/housekeeping suggested when useful | No | +| Harness | Invoke skill | +|---------|----------------| +| Claude Code (plugin) | `/loaf:release` | +| OpenCode, Cursor, Codex, Amp | `/release` | + +### Commands + +```text +loaf release suggest [--base <ref>] [--json] +loaf release cut [--base <ref>] [--bump <type>] [--includes <version|tag>] [--no-tag] [--no-gh] [--dry-run] +loaf issue bucket <ref> now|next|later|none [--json] +loaf issue link <from> blocks|relates-to <to> [--json] +``` + +Both commands need initialized SQLite state (`loaf state init`, or `loaf state migrate markdown --apply`). They are not a git repository's optional extra — without SQLite they refuse. + +| Command | Writes? | What it does | +|---------|---------|----------------| +| `loaf release suggest` | No | Report landed work since the last version tag | +| `loaf release cut` | Yes (unless `--dry-run`) | Cut the retroactive release and record members as facts | +| `loaf release cut --dry-run` | No | Print the plan, including `--includes` rows, and write nothing | + +### `suggest` flags + +| Flag | Meaning | +|------|---------| +| `--base <ref>` | Commits since `<ref>` instead of the last tag | +| `--json` | Machine-readable suggestion | +| `-h`, `--help` | Help | + +`suggest` rejects `--dry-run` (it is already read-only) and rejects cut-only flags (`--bump`, `--includes`, `--no-tag`, `--no-gh`). + +### `cut` flags + +| Flag | Meaning | +|------|---------| +| `--base <ref>` | Commits since `<ref>` instead of the last tag | +| `--bump <type>` | Override the derived bump: `major`, `minor`, `patch`, `prerelease`, `release` | +| `--includes <version\|tag>` | Record a prior release as a member (repeatable). Use this to hang prerelease references on a stable | +| `--no-tag` | Do not create a git tag; tag `v<version>` must already exist | +| `--no-gh` | Skip the GitHub Release draft | +| `--dry-run` | Print the plan and write nothing | +| `-h`, `--help` | Help | + +`cut` rejects `--json`. `--bump prerelease` and `--bump release` only produce a version when the current version already has a prerelease suffix; otherwise cut fails with `could not compute a version to cut`. `--no-tag` is checked before `--dry-run`: the tag must already exist even for a preview. + +### Cut sequence (mutating) + +1. Recompute the same suggestion as `suggest` (then apply `--bump` if given, and redraft notes) +2. Resolve each `--includes` ref to an existing release +3. Require a clean worktree +4. Apply the version to detected version files (they must exist and agree) +5. Prepend drafted notes into `CHANGELOG.md` (after `[Unreleased]`, ahead of prior versions; creates the file if missing) +6. Commit `chore: release vX.Y.Z` +7. Unless `--no-tag`: create annotated tag `vX.Y.Z` (`git tag -a`). Signing follows git config (`tag.gpgSign`); cut never passes `-s` or `--no-sign` +8. Record the release row, issue members, and `--includes` members as facts +9. Unless `--no-gh`: `gh release create <tag> --draft --title <tag> --notes <notes>` (adds `--prerelease` when the version is a prerelease). Switches to the configured GitHub account first. On `gh` missing, account failure, or create failure: print `warning:` plus a POSIX-quoted `retry:` command; do not fail the cut + +Cut does not push the commit or the tag. + +--- ## Topics | Topic | Use When | |-------|----------| -| [Context Detection](#context-detection) | Determining release base, last tag, and current branch | -| [Release-PR Flow](#step-5-release-pr-flow) | Preparing, landing, and finalizing every release | -| [Hook Interaction](#hook-interaction) | Understanding coexistence with git hooks | +| [Process](#process) | Running suggest then cut in this conversation | +| [Attribution](#attribution) | How commits become issue members | +| [Bump derivation](#bump-derivation) | Why suggest picked major, minor, or patch | +| [Must-contain convention](#must-contain-convention) | The rare promise that named issues must land first | --- -## Context Detection - -Before anything, establish the release surface: - -1. Get current branch and repo default branch: - ```bash - git branch --show-current - gh repo view --json defaultBranchRef -q .defaultBranchRef.name - ``` -2. Parse `$ARGUMENTS` for an explicit base, tag, or version. If omitted, use the repo default branch as the release base. -3. Verify the current branch: - - If already on the release base, continue; the release-PR flow in Step 5 branches from here. - - If on a dedicated release branch, resume the release-PR flow at the matching step. - - If on a feature branch, stop and explain that release publishes from landed work. Offer ship if the active PR needs landing first. -4. Find the previous release tag: - ```bash - git describe --tags --abbrev=0 - ``` -5. Gather the candidate release range: - ```bash - git log --oneline <last-tag>..HEAD - git diff --stat <last-tag>..HEAD - ``` +## Process ---- +Parse `$ARGUMENTS` for a base, bump, version, `--includes`, `--no-tag`, `--no-gh`, or `--dry-run`. Default baseline is the last version tag; `--base` overrides. With neither a last tag nor `--base`, the range is all of `HEAD`. -## Step 1: Release Readiness - -Run release pre-flight checks before editing release files: - -1. Ensure worktree is clean: - ```bash - git status --short - ``` -2. Ensure the release base is current: - ```bash - git fetch --tags origin - git status --branch --short - ``` -3. Check for existing tag or GitHub Release collisions for the target version once known: - ```bash - git tag --list vX.Y.Z - gh release view vX.Y.Z - ``` -4. Run project checks: - - Node: `npm run typecheck`, `npm run test`, `npm run build` when scripts exist - - Go: `go vet ./...`, `go test ./...` when `go.mod` exists - - Python: `pytest`, `mypy .`, `ruff check .` when configured - - Rust: `cargo check`, `cargo test` when `Cargo.toml` exists - -If no checks are detected, warn explicitly. If a check fails, stop and fix before release. +### Step 1: Log and route ---- +```bash +loaf journal log "skill(release): <what is being cut or suggested>" +``` -## Step 2: Change Collection - -Collect landed work since the last release and group it for release notes. - -1. Inspect commits: - ```bash - git log --first-parent --oneline <last-tag>..HEAD - git log --oneline <last-tag>..HEAD - ``` -2. Inspect merged PRs when GitHub is available: - ```bash - gh pr list --state merged --base <base> --json number,title,mergedAt,url - ``` -3. Group changes by user-facing outcome: - - `CR-*` change bundle, when referenced - - spec or task family, when public enough to be useful - - feature/fix/documentation/build themes - - operational release work, when it affects users or maintainers -4. Drop noise: - - purely internal task labels - - reverted work that is not present in `HEAD` - - individual commit mechanics that collapse into one user-facing change - -Present the grouped release contents before choosing the bump. +If the user wants a PR reviewed or merged, stop and use ship. If the work is still on a feature branch, explain that a release is cut from landed `HEAD` since the baseline, and offer ship. ---- +### Step 2: Suggest -## Step 3: Version + Changelog - -Choose the bump and curate the changelog from the grouped landed work. - -1. Run a dry run: - ```bash - loaf release --dry-run - ``` - Use `--base <ref>` when the project expects a non-default release base. -2. Present: - - current version - - proposed next version - - detected version files - - release actions the CLI would perform - - draft changelog entries -3. Curate `CHANGELOG.md` before publishing: - - write from the upgrading user's perspective - - group under Common Changelog categories: `Changed`, `Added`, `Removed`, `Fixed` - - use one self-describing line per meaningful change - - include public PR, issue, ADR, release, or commit links when helpful - - avoid dumping commit subjects, task IDs, session mechanics, or internal gate language -4. Confirm the bump type: `prerelease`, `release`, `major`, `minor`, or `patch`. +```bash +loaf release suggest +# or +loaf release suggest --base <ref> +loaf release suggest --json +``` ---- +Present the report as-is: base, suggested bump and version, bump evidence, landed issues with commits, partially-landed parents (missing children), unattributed commits, advisory buckets (planned landed / planned not landed / unplanned landed), drafted notes. -## Step 4: Release Execution +Do not hide partial parents or unattributed commits, and do not refuse the cut because of them unless the operator is using the [must-contain convention](#must-contain-convention) and wants to wait. -Every release routes through the release-PR flow in Step 5: prepare the release commit on a release branch with `loaf release --pre-merge`, land the release PR, then finalize with `loaf release --post-merge` on the base branch. +### Step 3: Confirm -Release preparation should: +Show the exact `loaf release cut …` you would run. Recommend cutting the derived version when the landed set matches what the operator asked for. Use `--bump` only when they override. Use `--dry-run` when they want a preview: -1. Update version files -2. Convert `[Unreleased]` into `## [X.Y.Z] - YYYY-MM-DD` -3. Reinsert a fresh empty `[Unreleased]` section -4. Run configured release artifact commands -5. Create the release commit +```bash +loaf release cut --dry-run +loaf release cut --dry-run --no-gh +loaf release cut --dry-run --includes <version|tag> +``` -After preparation, verify generated artifacts are current: +### Step 4: Cut ```bash -npm run build -git diff --exit-code -- dist plugins content/skills/loaf-reference/SKILL.md +loaf release cut +loaf release cut --bump minor +loaf release cut --includes v1.1.0-alpha.1 +loaf release cut --no-tag --no-gh ``` -Adjust the path list to the project. For Loaf itself, tracked generated outputs under `dist/`, `plugins/`, and native binaries must match the source changes. +On success, report version files updated, changelog written, tag created or reused, release recorded (member count), and GitHub draft created / skipped / warned. If stderr has `retry:`, paste that command; the row is already recorded. -Capability receipts pin artifact SHA-256s, and the release rebuild version-stamps generated artifacts (`dist/opencode/plugins/hooks.ts` embeds `@version`, so every version bump stales the OpenCode receipt; Go changes additionally stale all binary-pinned receipts via `bin/native`). Therefore re-recording runs AFTER `loaf release --pre-merge` completes its artifact rebuild, on the release branch, before pushing the release PR — never before the bump. Verify with `go test ./internal/cli -run TestTargetCapabilityEvidence`. `loaf release` now enforces this mechanically on every mutating path (post-rebuild refusal in apply, guardrail 9 in `--post-merge`) — the rule explains WHY the gate fires; the gate makes skipping it impossible. +```bash +loaf journal log "decision(release): vX.Y.Z recorded from <base> with <summary>" +``` -### Direct Release (Named Exception) +### Step 5: After -`loaf release --bump <type> --yes` on the base branch prepares, commits, tags, and publishes in a single shot. Use it only when the user explicitly requests a direct release; never select it by default. Skipping the release PR means nothing runs the suite against the prepared tree before the tag exists — the v0.2.16 cut took this door and a capability-evidence canary surfaced only in tag CI, after publication. The same day, v0.2.17 re-recorded evidence minutes before the version bump; the release commit staled it, and the tag again published zero assets — ordering, not diligence, is the failure mode. The CLI prints a flow advisory when a mutating release starts on the default branch; treat it as a routing signal, not noise. +Suggest reflect when the cut produced durable product or workflow learnings, and housekeeping when temporary artifacts need cleanup. Capture leftover discoveries as issues or sparks — not as extra changelog lines. --- -## Step 5: Release-PR Flow +## Attribution -The default for every release: PR CI runs the full suite against the prepared tree, so evidence canaries surface before any tag or GitHub Release exists. This holds regardless of repository settings — where branch protection is enabled it is satisfied as a side effect, not the reason for the flow. +`suggest` (and `cut`, which recomputes the same suggestion) attributes each commit in the range to zero or more issues, then rolls up through parents. -1. Create a dedicated release branch from the release base. -2. Run `loaf release --pre-merge` on it: this creates the version/changelog/artifact release commit but no tag and no GitHub Release. -3. Open a release PR with a concise release-focused body. -4. Hand the PR to ship for review and landing; squash-merge it into one `chore: release vX.Y.Z (#PR)` commit carrying the curated changelog. -5. After the release PR lands, run `loaf release --post-merge` on the base branch to tag, publish the GitHub Release, and verify installability. +**Commit → issue**, first match wins: -If guardrail 9 fires on `--post-merge`, the merged tree itself carries stale evidence; recovery is to re-record against the merged tree, land the receipts as a single evidence-only commit on the base branch (the repair commit must not modify the capability registry), and rerun `loaf release --post-merge`. +1. Issue alias (`PREFIX-N`, e.g. `LOAF-42`) in the subject or body (prefix case-sensitive). URLs and code spans are stripped first. +2. Else the merge/branch rung: aliases in a `Merge …` subject, plus any alias anywhere in the body (case-insensitive). Squash subjects like `feat: add auth (#42)` often carry the alias only in the body. No network, no `gh`. An alias that lived only on a deleted branch name is unattributable. +3. Else a unique journal `commit(<hash>)` row whose message contains an alias (scope uniquely matching that commit). -Do not hide this handoff inside release: ship remains the PR correctness and merge gate. +Resolved aliases become **landed** issues (with the commits that named them). Commits that match nothing are **unattributed** — listed, and included under drafted notes as `### Unattributed`. They do not block the cut. ---- +**Parent rollup** (information): -## Step 6: Publication Verification +- For each landed issue that has a parent, if any sibling child is not `done`, the parent is **partially landed** and the missing children are listed. +- A parent is not auto-added to landed unless a commit attributed to it. -After publishing, verify the public release state: +Drafted notes are `## [version] - YYYY-MM-DD`, then one `### ALIAS — title` section per landed issue with commit subjects, then unattributed. -1. Confirm tag location: - ```bash - git show --stat vX.Y.Z - ``` -2. Confirm GitHub Release: - ```bash - gh release view vX.Y.Z - ``` -3. Confirm package or installer availability when applicable: - - npm: `npm view <package> version` - - Homebrew: `brew update && brew info <tap>/<formula>` - - project-specific deploy or artifact registry checks -4. For Loaf/Homebrew, report readiness only after the GitHub release exists, assets are uploaded, the tap formula is updated, and tap CI has passed. +--- -If publication partially completes, do not retag casually. Name the exact state and continue with the smallest repair or patch release path. +## Bump derivation ---- +Derived from the range, in order: -## Step 7: Post-Release Follow-Up +| Condition | Bump | +|-----------|------| +| Breaking marker (`type!:` in the subject, or `BREAKING CHANGE:` / `BREAKING-CHANGE:` in the body) | `major` | +| A **done** parent with **two or more** children, every child `done` and landed, and the parent's done timestamp **after** the baseline tag's committer time | `minor` (closed multi-child parent fully landed) | +| Else a conventional `feat` commit | `minor` | +| Else | `patch` (`fix` / other) | -After verification: +`--bump` on `cut` replaces the derived bump and redrafts notes; the evidence string becomes `overridden by --bump <type>`. -1. Log the release decision to the project journal: - ```bash - loaf journal log "decision(release): vX.Y.Z published from <base> with <summary>" - ``` -2. Suggest reflect when the release produced durable product or workflow learnings. -3. Suggest housekeeping when release branches or temporary reports need cleanup. -4. Keep future-work discoveries out of the release notes; capture them as tasks, ideas, or sparks instead. +Current version comes from agreeing version files, else from a semver last tag. Cut still requires version files on the mutating path. --- -## Hook Interaction +## Must-contain convention -This skill coexists with existing hooks. Git workflow hooks are advisory unless -configured otherwise; security and secret-scanning hooks remain blocking. +Rare. When the operator needs a named set of issues to land before a cut, create a **release-prep** issue and express `blocked_by` edges with issue mechanics: + +```bash +loaf issue new "Release prep for vX.Y.Z" --body "Must contain LOAF-12 and LOAF-15. Out of scope: the cut itself." +loaf issue link LOAF-12 blocks LOAF-99 +loaf issue link LOAF-15 blocks LOAF-99 +``` -| Hook | Type | When release Runs | -|------|------|---------------------| -| `github-account` | Force-switch | Switches to the configured GitHub account before `gh` release operations; blocks only if the switch fails | -| `validate-push` | Advisory | Cross-checks version bump, changelog, and build on push | -| `workflow-pre-pr` | Advisory | Fires when the release PR is opened | -| `workflow-pre-merge` | Advisory | Belongs to ship when a release PR must land | -| `workflow-post-merge` | Advisory | Belongs to ship after PR landing | -| `check-secrets` | Blocking | Always respected before writes or shell actions | +Stored write types are `blocks` and `relates_to`. `loaf issue link <must-land> blocks <release-prep>` is how you record that the prep issue is blocked by those that must land. `loaf issue frontier` and implement honor `blocks`. **`loaf release suggest` and `cut` do not read these edges** — convention, not schema. If the operator wants to wait, wait; if they cut anyway, cut records whatever actually landed. -Do not disable hooks to force a release through. +Buckets stay labels: ---- +```bash +loaf issue bucket LOAF-12 now +loaf issue bucket LOAF-12 none +``` -## Suggests Next +`suggest` prints `bucket:<name> ALIAS — title (landed|not landed)` and `unplanned ALIAS — title (landed)`. Never treat that delta as a constraint. -After a successful release, suggest reflect for durable learnings and housekeeping if temporary release artifacts need attention. +--- ## Related Skills -- **ship** -- Reviews, verifies, and lands a PR before it becomes release input -- **git-workflow** -- Branching, PR, commit, and squash merge conventions -- **documentation-standards** -- Changelog and release-note quality -- **reflect** -- Updates strategy from shipped/released learnings -- **housekeeping** -- Cleans up completed spec, report, and handoff artifacts +- **ship** — Reviews, verifies, and lands a PR. That merge is the verification authority for what this skill may later cut +- **git-workflow** — Branching, PRs, and any later push of the local tag (cut does not push) +- **documentation-standards** — Changelog prose if a human edits notes after the cut +- **reflect** — Durable learnings after a cut +- **housekeeping** — Cleanup of temporary artifacts diff --git a/dist/skills/research/SKILL.md b/dist/skills/research/SKILL.md index f389cc5e9..6bcdef3b8 100644 --- a/dist/skills/research/SKILL.md +++ b/dist/skills/research/SKILL.md @@ -93,7 +93,7 @@ Always check project context first. Rate findings: **High** (official/verified), **Trigger:** Empty input, "project state", "catch me up" 1. Read project documents: VISION.md, STRATEGY.md, ARCHITECTURE.md -2. Check ideas with `loaf idea list --json` and specs with `loaf spec list --json` +2. Check ideas with `loaf idea list --json` and issues with `loaf issue list --json` (or `loaf issue export` for the full graph) 3. Review recent journal activity with `loaf journal recent --json` and `loaf journal context` 4. Check recent commits: `git log --oneline -20` 5. Synthesize following [state-assessment template](templates/state-assessment.md) @@ -143,4 +143,4 @@ User-facing entry for a new concept is pitch (problem-discovery brief). Do not t ## Artifact Naming -Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or Change already records that provenance. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. +Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or the issue already records that provenance. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. diff --git a/dist/skills/research/templates/report.md b/dist/skills/research/templates/report.md index 2ff4c059c..f7cc5d7dd 100644 --- a/dist/skills/research/templates/report.md +++ b/dist/skills/research/templates/report.md @@ -14,7 +14,7 @@ title: "Report: [Topic]" type: research | audit | analysis | council created: YYYY-MM-DDTHH:MM:SSZ status: draft | done | archived -source: SPEC-XXX | TASK-XXX | ad-hoc +source: LOAF-42 | ad-hoc tags: [] --- diff --git a/dist/skills/research/templates/state-assessment.md b/dist/skills/research/templates/state-assessment.md index 65e426f3b..359392033 100644 --- a/dist/skills/research/templates/state-assessment.md +++ b/dist/skills/research/templates/state-assessment.md @@ -24,7 +24,7 @@ tags: [] - **Vision:** [Brief summary] - **Key personas:** [Who we're building for] -- **Current focus:** [Active specs/work] +- **Current focus:** [Active issues] ## Recent Progress @@ -32,10 +32,10 @@ tags: [] ## In Flight -| Spec/Task | Status | Notes | -|-----------|--------|-------| -| SPEC-001 | implementing | [progress] | -| SPEC-002 | approved | [next up] | +| Issue | Status | Notes | +|-------|--------|-------| +| LOAF-1 | active | [progress] | +| LOAF-2 | todo | [next up] | ## Ideas Pipeline diff --git a/dist/skills/shape/SKILL.md b/dist/skills/shape/SKILL.md index ed2587217..6f27f9ded 100644 --- a/dist/skills/shape/SKILL.md +++ b/dist/skills/shape/SKILL.md @@ -1,24 +1,20 @@ --- name: shape description: >- - Shapes messy input into a bounded, reviewable Change under - docs/changes/YYYYMMDD-slug/ (change.json + shape.md + tasks/), validated by - loaf change check. Runs a fog-routed narrowing protocol — gather context, - optional blindspot pass, grilling, reaction artifacts — seeds task-file - vertical slices, runs a critique gate, and offers an opt-in draft PR. Use when - the user asks "shape this," "turn this into a Change," or an idea has enough - constraints to bound. Produces role-named narrative (shape.md required; - brief/plan/design optional) plus task packets — never a numbered spec. - Teaches the problem-boundary test (same problem → another task; different - problem → Intent) and vertical-slice discipline. Not for quick capture (use - idea), problem discovery that should author a brief first (use pitch), or - open-ended divergent thinking (agent technique: explore / brainstorm — user - entry intent routes to pitch). + Shapes messy input into a bounded issue — problem body, definition-of-done + criteria, out-of-scope statement, and children when a criterion earns its own + DoD — validated by loaf issue check. Use when the user asks "shape this," + "turn this into an issue," or a diagnosed fix needs a row. Produces a shaped + issue — never a folder or a plan document. Teaches fog graduation (park, + then a decision child) and one-criterion sizing (one fresh context window, + verifiable alone). Not for quick capture (use idea), problem discovery that + should author a brief first (use pitch), or open-ended divergent thinking + (agent technique: explore / brainstorm — user entry routes to pitch). --- # Shape -Turn messy input into a bounded, reviewable Change. +Prepare a bounded, reviewable issue. ## Contents - Critical Rules @@ -34,29 +30,30 @@ Turn messy input into a bounded, reviewable Change. ## Critical Rules -1. **Log invocation first** — `loaf journal log "skill(shape): <input being shaped>"` before doing anything else. -2. **Produces a Change, never a spec** — `change.json` + `shape.md` (+ optional `brief.md`/`plan.md`/`design.md`) and `tasks/TASK-NNN-slug.md`. No sequentially-numbered spec file, no status-like fields anywhere. +1. **Log invocation first** — `loaf journal log "skill(shape): shaping <topic> into LOAF-42"` before doing anything else. If no issue exists yet, log `skill(shape): shaping <topic>` and add the alias in the outcome entry. +2. **Produces an issue, never a folder** — the deliverable is the issue row: problem in the body, definition of done as `loaf issue dod` criteria, an explicit out-of-scope statement in the body, children via `loaf issue promote` when a criterion earns its own DoD. No plan document is committed. The PR body, if a PR is opened, is `loaf issue render` output. 3. **The fog register routes, you don't guess** — every named unknown carries a quadrant tag that dispatches it to exactly one technique (see Quick Reference). Technique-by-vibes is the failure mode this replaces. -4. **Blindspot pass precedes grilling, offered not imposed** — run it when the territory is unfamiliar; skip it when the shaper is the domain expert. Never impose it as a mandatory step. -5. **Grill one question at a time, with a recommendation** — using your harness's structured question tool if it has one (otherwise one inline question per message — same semantics). Never a form to fill in one pass. Order by architectural impact: questions whose answer would change the architecture go first, cosmetic questions last. -6. **Techniques return fog entries, not decisions** — blindspot passes, grilling, and reaction artifacts hand back named unknowns and evidence for the human to adjudicate. Reconnaissance, not orders. -7. **Own the decomposition** — decide Implementation Unit boundaries and granularity autonomously (absorbed from the retired breakdown step); ask only when two orderings carry genuinely different trade-offs. -8. **Order units by likelihood-of-change** — data models, interfaces, and user-facing flows lead; mechanical work collapses at the bottom, so review attention lands on what's most likely to need changing. -9. **Surface misalignment, never silently adjust** — when the idea conflicts with strategic docs, prior Changes, or the journal, tell the user and let them decide. Don't quietly reshape their idea. -10. **Critique before finalizing** — run the Critique Gate as the last shaping step, before `loaf change check` and the PR offer. -11. **Get approval before `loaf change init`** — don't scaffold the folder without explicit confirmation of scope. -12. **Log the outcome** — `loaf journal log "decision(shape): <slug> shaped — N units, N open fog entries"`. +4. **Fog graduates instead of evaporating** — a question not yet sharp enough is parked in the issue's `fog` field (`loaf issue new --fog`). When it sharpens it becomes a `--kind decision` child, which is ready when it poses a sharp question (a `?` in the title or body). No plan required. +5. **Blindspot pass precedes grilling, offered not imposed** — run it when the territory is unfamiliar; skip it when the shaper is the domain expert. Never impose it as a mandatory step. +6. **Grill one question at a time, with a recommendation** — using your harness's structured question tool if it has one (otherwise one inline question per message — same semantics). Never a form to fill in one pass. Order by architectural impact: questions whose answer would change the architecture go first, cosmetic questions last. +7. **Techniques return fog entries, not decisions** — blindspot passes, grilling, and reaction artifacts hand back named unknowns and evidence for the human to adjudicate. Reconnaissance, not orders. +8. **Decomposition is the tail** — a parent gets children only when its DoD needs more than one coherent slice. A criterion becomes a child the moment it earns its own DoD, via `loaf issue promote`. Own those boundaries autonomously; ask only when two orderings carry genuinely different trade-offs. +9. **One sizing criterion** — a slice is right-sized when it fits one fresh context window and is verifiable alone. Expand–contract is the named exception for wide mechanical refactors. See [references/decomposition.md](references/decomposition.md). +10. **Surface misalignment, never silently adjust** — when the idea conflicts with strategic docs, prior issues, or the journal, tell the user and let them decide. Don't quietly reshape their idea. +11. **Critique before finalizing** — run the Critique Gate as the last shaping step, before `loaf issue check`. +12. **A diagnosed one-line fix is two commands** — `loaf issue new` with a body that states the problem and `Out of scope: …`, then one `loaf issue dod add`. No problem-space ceremony. Confirm scope with the user before `loaf issue new` on anything larger. +13. **Log the outcome** — `loaf journal log "decision(shape): LOAF-42 shaped — N children, N open fog entries"`. --- ## Verification -- `docs/changes/YYYYMMDD-slug/` has `change.json` + `shape.md` with Product Contract sections non-empty; task packets seeded under `tasks/` when decomposition is known -- Every Open Questions entry carries a quadrant tag (`[KU]`, `[UK]`, or `[UU]`) and a route -- `loaf change check` reports zero violations (no legacy deprecation on new layout); executability gaps were read, not ignored -- Problem-boundary test applied: discovered different problems become Intents, not TASK-007 -- The Critique Gate ran, and its answers changed the documents where they applied -- No status-like fields in `change.json` or task frontmatter +- The issue body states the problem and contains an explicit out-of-scope statement (`out of scope`, case-insensitive — that substring is what `loaf issue check` reads) +- At least one definition-of-done criterion exists; V-tier criteria carry `--command` (and `--expect` when the check is more than exit 0); H-tier otherwise +- Every open unknown is either parked in create-time `fog`, held in the session register until it sharpens, graduated to a `--kind decision` child (or sibling) with a sharp question, or written into the body as a decided answer +- `loaf issue check <ref>` reports the issue shaped (delivery) or ready (decision). When children exist, coverage failures were fixed and containment orphans were filed as sibling backlog issues using the printed remedy +- Problem-boundary test applied: a discovered different problem becomes a new backlog issue, not another criterion on this one +- The Critique Gate ran, and its answers changed the issue where they applied --- @@ -64,34 +61,46 @@ Turn messy input into a bounded, reviewable Change. ### Fog register format -Open Questions entries take one of three forms: +Open unknowns take one of three forms. Keep the register in the session. Park what is still unsharp in `--fog` at create; after create, unsharp entries stay in the session register (edit cannot mutate `fog`). Graduate what is sharp to a decision child or sibling, and write decided answers into the body. ```text -- [KU] <the unknown> → <route: grilling | research spike | owner section> -- [UK] <the recognize-it-when-seen criterion> → reaction artifact in research/ +- [KU] <the unknown> → <route: grilling | research spike | owner> +- [UK] <the recognize-it-when-seen criterion> → reaction artifact - [UU] <the suspected blind area> → blindspot pass over <territory> ``` -An entry resolves by becoming a Decision, a Planning Contract subsection, or a named follow-up — visible in the diff, never silently deleted. A `[UU]` that gets named becomes a `[KU]` or `[UK]` and re-routes through the table below. +An entry resolves by becoming a decision child, a body paragraph, a criterion, or remaining parked in `fog` — visible on `loaf issue show`, never silently deleted. A `[UU]` that gets named becomes a `[KU]` or `[UK]` and re-routes through the table below. ### Quadrant routing | Tag | Meaning | Routes to | |-----|---------|-----------| | `[KU]` known unknown | A question you can state precisely | [Grilling](references/grilling.md) (architecture-changing answers first) or a research spike | -| `[UK]` unknown known | You'd recognize the right answer if you saw it, but can't state it yet | [Reaction artifact](references/reaction-artifact.md) — a variant or mock in `research/`, react and pick | +| `[UK]` unknown known | You'd recognize the right answer if you saw it, but can't state it yet | [Reaction artifact](references/reaction-artifact.md) — a variant or mock, react and pick | | `[UU]` suspected blind spot | Unfamiliar territory; you don't yet know what you don't know | [Blindspot pass](references/blindspot-pass.md) | -No route names a skill invocation. Research re-interviews an already-scoped question and writes to `.agents/reports/`; brainstorm forces a strategic frame onto a Change-local question and sends resolutions to intake. Shape runs all three techniques itself, in-session, and writes evidence into the Change's own `research/` — never `.agents/reports/`. +No route names a skill invocation. Research re-interviews an already-scoped question; brainstorm forces a strategic frame onto an issue-local question and sends resolutions to intake. Shape runs all three techniques itself, in-session, and writes the captured answer onto the issue — never into `.agents/reports/`. ### Defined terms -- **Rabbit holes** — tempting expansions of scope that would consume disproportionate effort for marginal value; name them so nobody wanders in unknowingly. -- **No-gos** — approaches explicitly forbidden for this Change, stated so they aren't silently reconsidered mid-implementation. +- **Rabbit holes** — tempting expansions of scope that would consume disproportionate effort for marginal value; name them in the out-of-scope statement so nobody wanders in unknowingly. +- **No-gos** — approaches explicitly forbidden for this issue, stated so they aren't silently reconsidered mid-implementation. ### Source inputs recognized -Step 1 reads whatever `$ARGUMENTS` names, or asks. Recognized sources: a change `brief.md` (from pitch or capture), a journal entry (cite by ID), a spark, an idea, a brainstorm document, a Linear issue, a PR conversation, a prior Change, or plain conversation with no artifact behind it yet. +Step 1 reads whatever `$ARGUMENTS` names, or asks. Recognized sources: a brief from pitch, a journal entry (cite by ID), a spark, an idea, a brainstorm document, a Linear issue, a PR conversation, a prior issue, or plain conversation with no artifact behind it yet. + +### One-line entry + +A diagnosed fix that already has a problem and a done-check: + +```bash +loaf issue new "Fix missing --json in list help" --body "issue list --help omits --json. Out of scope: rewriting other help pages." +loaf issue dod add LOAF-42 "issue list help names --json" --command "loaf issue list --help" --expect "contains \`--json\`" +loaf issue check LOAF-42 +``` + +Two writes, then the readiness verdict. No grilling, no children, no files. --- @@ -99,53 +108,86 @@ Step 1 reads whatever `$ARGUMENTS` names, or asks. Recognized sources: a change ### Step 1: Gather Context -Parse `$ARGUMENTS` against the source inputs above. When the input names a Change folder that already has `brief.md` (or you find one for this work), treat the brief as primary: restate the problem from it, confirm with the user rather than re-discovering, and keep later grilling on solution-space (how, boundaries, verification) — pitch already framed the problem. When no brief exists, run full narrowing as today; pitch is the recommended front door for raw concepts, never a gate. Read the journal (`loaf journal recent` / `search`) for related history, and check for a prior Change touching the same area. When VISION.md, STRATEGY.md, and ARCHITECTURE.md exist, read them for strategic fit. Most consumer projects don't have them yet — when absent, shape against the journal, recent Changes, and the conversation instead, and say so in the Change's Source Inputs. +Parse `$ARGUMENTS` against the source inputs above. When a brief from pitch already frames the problem, restate it, confirm with the user rather than re-discovering, and keep later grilling on solution-space (how, boundaries, verification). When no brief exists, run full narrowing; pitch is the recommended front door for raw concepts, never a gate. Read the journal (`loaf journal recent` / `search`) for related history, and check for a prior issue touching the same area (`loaf issue list`, `loaf issue tree`). When VISION.md, STRATEGY.md, and ARCHITECTURE.md exist, read them for strategic fit. Most consumer projects don't have them yet — when absent, shape against the journal, recent issues, and the conversation instead, and say so in the issue body. ### Step 2: Evaluate Strategic Fit -When strategic docs exist, check: does this advance the vision, serve the target personas, fit technical constraints, avoid conflicting with in-flight Changes? On misalignment, **surface it to the user — never silently adjust the idea**. The user decides whether to proceed, narrow scope, or defer to reflect after this ships. +When strategic docs exist, check: does this advance the vision, serve the target personas, fit technical constraints, avoid conflicting with in-flight issues? On misalignment, **surface it to the user — never silently adjust the idea**. The user decides whether to proceed, narrow scope, or file the conflicting concern as its own backlog issue. -### Step 3: Name the Change and Initialize +### Step 3: Name the Issue and Write the Row -Once the shape of the work is nameable, confirm scope with the user, then: +Once the work is nameable, confirm scope with the user (skip this confirmation on the one-line path), then create the row. Prefer creating after the first narrowing pass so `--fog` can carry remaining unsharp questions — the CLI writes `fog` only at create. ```bash -loaf change init <slug> +loaf issue new "Rotate auth tokens on a sliding window" \ + --body "Sessions never expire while the tab stays open, so a stolen cookie is valid indefinitely. + +Out of scope: migrating existing sessions; third-party IdP support." \ + --fog "[KU] sliding-window length → grill; [UU] existing session-store conventions → blindspot pass" ``` -On a fresh slug this scaffolds `change.json` + `shape.md` + seeded `tasks/` from the embedded templates (see `templates/shape.md`, `templates/task.md`). On a capture-only folder that already has `change.json` + `brief.md` (from pitch or `init --brief`), the same command promotes in place — preserving brief and metadata verbatim while materializing `shape.md` and `tasks/` — never hand-copy templates into the folder; rely on that promotion path. Use `loaf change init <slug> --brief` only for capture-before-shape (emits `change.json` + `brief.md`). It does not switch branches — `git switch -c <slug>` yourself. Fill `shape.md` Product Contract sections as understanding solidifies; seed `tasks/TASK-NNN-slug.md` as vertical slices (a task is a commit, not a PR). Optional `plan.md`/`design.md` accrete when the how needs prose. See [references/cli-boundary.md](references/cli-boundary.md). +Default kind is `delivery`; default status is `triage`. `--status` accepts `triage`, `backlog`, `todo`, `active`, or `done`. Use `--body -` or `--body-file <path>` for a longer body; `loaf issue edit <ref>` later **replaces** the body, it does not patch it. + +A delivery issue is shaped when the body is nonempty (the problem), at least one criterion exists, and the body contains an explicit out-of-scope statement. Fill those as understanding solidifies — create can carry the first body; criteria come next. + +A discovered different problem is a new backlog issue, not a child of this one: + +```bash +loaf issue new --status backlog "Rewrite the session store" +``` ### Step 4: Narrow the Unknowns -Offer the blindspot pass when the territory is unfamiliar (a new domain, an unfamiliar subsystem, a first collaboration) — skip it when the shaper is the expert. Its fog entries, and any others surfaced in interview, route by quadrant (Quick Reference above). Loop: grill `[KU]` entries, react to `[UK]` entries, run blindspot reconnaissance on `[UU]` entries until each gets a name and re-routes. Stop grilling when no unrouted `[KU]` entries remain or answers stop changing the contract. Entries still open at the end of the session are fine — each names its owner (a section, a spike, a follow-up). +Offer the blindspot pass when the territory is unfamiliar (a new domain, an unfamiliar subsystem, a first collaboration) — skip it when the shaper is the expert. Its fog entries, and any others surfaced in interview, route by quadrant (Quick Reference above). Loop: grill `[KU]` entries, react to `[UK]` entries, run blindspot reconnaissance on `[UU]` entries until each gets a name and re-routes. Stop grilling when no unrouted `[KU]` entries remain or answers stop changing the issue. -### Step 5: Decompose into Implementation Units +When a parked question sharpens, graduate it — after the parent's DoD is written (Step 5). Attaching **any** child, including a decision, turns coverage on. -Absorbed from the retired breakdown step — see [references/decomposition.md](references/decomposition.md) for the Right Size Test and per-unit verification discipline, including the V-tier `Command:` / `Expect:` forms `loaf change verify` parses (commands run from the repository root; H-tier is never gate input). Order units by likelihood-of-change; state real sequencing constraints in prose, never by list order alone. +```bash +loaf issue new --kind decision --parent LOAF-42 "Should tokens live in httpOnly cookies?" +``` + +A decision issue is ready when the title or body contains `?`. It needs no criteria and no out-of-scope statement. A decision child does not claim a parent criterion, so promote (or otherwise claim) the parent's DoD before adding children, or keep the decision as a sibling (`loaf issue new --kind decision --status backlog`, no `--parent`) if the parent stays a leaf. Unsharp questions discovered after create stay in the session register until they graduate — there is no `--fog` on edit. See [references/decomposition.md](references/decomposition.md). + +### Step 5: Write Definition of Done (decomposition tail) + +Add criteria as the interrogation produces observable done-checks. V-tier when a command can disagree with the implementation; H-tier when only a human can tell. + +```bash +loaf issue dod add LOAF-42 "Sliding-window expiry is covered by tests" --command "go test ./internal/auth/..." --expect "exit 0" +loaf issue dod add LOAF-42 "Stolen-cookie writeup is reviewable" --tier H +``` + +`--command` implies V unless `--tier` overrides. `--expect` uses the verify grammar (`exit <N>`, `` contains `text` ``, joined by ` and `). Commands run from the repository root. See [references/cli-boundary.md](references/cli-boundary.md) and [references/decomposition.md](references/decomposition.md). + +A parent gets children only when its DoD needs more than one coherent slice. The moment a criterion earns its own DoD, promote it — the parent criterion stays, the child starts with a copy, and the claim is recorded so coverage holds for that position: -### Step 6: Fill the Planning Contract +```bash +loaf issue promote LOAF-42 1 +``` -Write the free-form `###` subsections the work actually needs (approach, placement, risks, sequencing) inside the Planning Contract container. Its subsection names are yours; the container itself, plus Implementation Units, Verification Contract, and Definition of Done, is what `loaf change check` looks for. Durable Outputs stays forward-looking here — name what a final spec, ADR, or knowledge doc will need to capture, but don't write it now. Durable artifacts get created after implementation proves what's true, not during shaping. +Then shape the child the same way (body, out-of-scope, its own criteria). Order children by likelihood-of-change when presenting them; state real sequencing with `loaf issue link <from> blocks <to>`, never by tree order alone. -### Step 7: Run the Critique Gate +### Step 6: Run the Critique Gate -Before finalizing, challenge the draft — see [references/critique-gate.md](references/critique-gate.md). Is scope still bounded, does every new command or state name its ceremony, is a status field creeping back in under another name, is the CLI/skill boundary drawn correctly, and could this be smaller and still deliver the Hypothesis? +Before finalizing, challenge the draft — see [references/critique-gate.md](references/critique-gate.md). Is scope still bounded, does every new command or state name its ceremony, is a second progress flag creeping into the body, is the CLI/skill boundary drawn correctly, and could this be smaller and still be verifiable in one fresh context window? -### Step 8: Validate +### Step 7: Validate ```bash -loaf change check +loaf issue check LOAF-42 ``` -Read violations (always block — fix them) separately from the executability report (derived, informational unless `--require-executable` is passed — that flag is implement's preflight and CI's non-draft gate, not shape's business). See [references/cli-boundary.md](references/cli-boundary.md). +A delivery issue that passes prints `issue LOAF-42 is shaped`; a decision issue prints `issue LOAF-42 is ready`. Failures always block (missing body, missing criterion, missing out-of-scope, no sharp question, uncovered parent criterion). Containment orphans are reported, not failed: each line includes a ready-to-paste remedy that files the orphan as a sibling backlog issue — run that command, do not invent a different disposition. + +`loaf issue verify <ref>` runs V-tier commands from the repository root and writes nothing. That is implement's preflight, not shape's gate. See [references/cli-boundary.md](references/cli-boundary.md). -### Step 9: Offer the Draft PR +### Step 8: Offer the Review Surface -Offer to push the branch and open a draft PR, using [the PR template](templates/pr.md) — opt-in, never automatic. `loaf change check` (with no `--require-executable`) plus `gh pr list` is the cross-branch index either way. +The issue lives in SQLite. There is no folder to commit and nothing plan-shaped to land. Offer `loaf issue show <ref>` and `loaf issue tree <ref>` as the review surface. If a PR is being opened for the work, its body is `loaf issue render <ref>` — paste-ready, no manual editing. Opt-in, never automatic. -### Step 10: Log the Outcome +### Step 9: Log the Outcome -`loaf journal log "decision(shape): <slug> shaped — N units, N open fog entries"`. +`loaf journal log "decision(shape): LOAF-42 shaped — N children, N open fog entries"`. --- @@ -153,8 +195,8 @@ Offer to push the branch and open a draft PR, using [the PR template](templates/ - **pitch** — Problem-discovery ceremony that authors a brief; preferred front door when the problem is not yet framed - **idea** — Quick capture; feeds into pitch or shape once a concept has enough weight -- **brainstorm** — Agent technique for divergent thinking (route user entry intent to pitch) -- **implement** — Starts execution once a Change is structurally executable; this does not prove implementation completion +- **brainstorm** — Agent technique for divergent thinking (route user entry to pitch) +- **implement** — Starts execution once `loaf issue check` reports the issue shaped; this does not prove implementation completion - **reflect** — Updates strategic docs after the shipped work proves what changed ## Topics @@ -164,10 +206,10 @@ Offer to push the branch and open a draft PR, using [the PR template](templates/ | Blindspot pass | [references/blindspot-pass.md](references/blindspot-pass.md) | Deciding whether to offer reconnaissance, and how to prompt it | | Grilling | [references/grilling.md](references/grilling.md) | Running the one-question-at-a-time interview for `[KU]` entries | | Reaction artifacts | [references/reaction-artifact.md](references/reaction-artifact.md) | Resolving `[UK]` entries with a variant, mock, or prototype | -| Decomposition | [references/decomposition.md](references/decomposition.md) | Sizing and ordering Implementation Units | -| CLI boundary | [references/cli-boundary.md](references/cli-boundary.md) | Reading `loaf change init`/`check`/`verify` output, or explaining `--require-executable` | +| Decomposition | [references/decomposition.md](references/decomposition.md) | Sizing slices, promoting criteria, reading coverage and containment | +| CLI boundary | [references/cli-boundary.md](references/cli-boundary.md) | Reading `loaf issue` output, authoring `--command`/`--expect`, or explaining `loaf issue check` | | Critique Gate | [references/critique-gate.md](references/critique-gate.md) | Self-challenging scope and boundaries before finalizing | ## Artifact Naming -Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or Change already records that provenance. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. +Shape's deliverable is the issue row. If a reaction artifact or spike note lands on disk, name it for what it is, never for the issue that produced it. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. diff --git a/dist/skills/shape/references/blindspot-pass.md b/dist/skills/shape/references/blindspot-pass.md index 7bbd329a4..616f544d5 100644 --- a/dist/skills/shape/references/blindspot-pass.md +++ b/dist/skills/shape/references/blindspot-pass.md @@ -12,7 +12,7 @@ Ask the user whether to run it; skip when they're the domain expert. Do not auto ## Prompt Shape -Ask, against the specific territory named by the Change: +Ask, against the specific territory named by the issue: > What would I not know to ask here — codebase history, domain conventions, prior art, potholes? @@ -29,6 +29,8 @@ A `[UU]` that gets named through this pass becomes: and re-routes through the quadrant table in the main skill body. +Park what is still unsharp in the issue's `fog` field at create (`--fog`). After create, keep unsharp entries in the session register — edit cannot mutate `fog`. When an entry sharpens, graduate it to a `--kind decision` child or sibling. Do not drop a named unknown on the floor. + ## Stopping The pass ends when the shaper (or the user) can name the territory's remaining risks as entries, not vague unease. A pass that keeps surfacing "something might be wrong here" without a nameable entry has run past its useful length — stop and proceed with what's been named. diff --git a/dist/skills/shape/references/cli-boundary.md b/dist/skills/shape/references/cli-boundary.md index b7ade652b..6ed241493 100644 --- a/dist/skills/shape/references/cli-boundary.md +++ b/dist/skills/shape/references/cli-boundary.md @@ -1,56 +1,118 @@ # CLI Boundary -Reading `loaf change init` and `loaf change check` — the skill teaches reading the CLI, not wrapping it. For the rest of the Loaf CLI surface, see the `loaf-reference` skill. +Reading `loaf issue` — the skill teaches reading the CLI, not wrapping it. For the rest of the Loaf CLI surface, see the `loaf-reference` skill. Issue commands require initialized SQLite state (`loaf state init`, or `loaf state migrate markdown --apply`). + +## Contents +- `loaf issue new` +- `loaf issue show` / `list` / `tree` / `frontier` +- `loaf issue edit` / `status` +- `loaf issue dod` +- `loaf issue promote` +- `loaf issue check` +- `loaf issue verify` +- `loaf issue link` / `bucket` +- `loaf issue render` +- `loaf release suggest` / `cut` +- What shape does not run + +## `loaf issue new <title> [options]` + +```text +loaf issue new <title> [--body <text>|--body -|--body-file <path>|--message <text>] + [--kind delivery|decision] [--parent <ref>] [--fog <text>] [--status <status>] [--json] +``` + +Creates the issue row. Default kind is `delivery`; default status is `triage`. `--status` accepts the write statuses `triage`, `backlog`, `todo`, `active`, `done` (it still records the initial triage event). `--fog` parks questions not yet sharp enough to be issues; this flag exists only on create — `loaf issue edit` replaces the body and does not mutate `fog`. + +`--body -` reads stdin; `--body-file` reads a UTF-8 file; `--message` is inline body at lower precedence than `--body-file` and `--body -`. A hyphen-leading title is positional after `--`: + +```bash +loaf issue new --parent LOAF-42 --status backlog -- "--help is missing from the man page" +``` -## `loaf change init <slug> [--brief]` +A delivery body must state the problem and, before `loaf issue check` will pass, contain the substring `out of scope` (case-insensitive). A decision issue needs a sharp question (`?` in the title or body), not a body contract. -Scaffolds `docs/changes/<YYYYMMDD>-<slug>/` from the Change template, where `<YYYYMMDD>` is the creation day (not a target date) and the branch is named by the bare slug — no date prefix on the branch. Ordinary init writes `change.json + shape.md + tasks/`; `--brief` is capture mode (`change.json + brief.md` only). The slug uses lowercase letters, digits, and single hyphens. +## `loaf issue show` / `list` / `tree` / `frontier` -**Capture promotion.** Re-running ordinary `loaf change init <slug>` (no `--brief`) against a structurally valid capture-only folder completes it in place: `brief.md` and every `change.json` value are preserved verbatim, and missing `shape.md` plus the seeded `tasks/` are published atomically (temp-write then rename; existing destinations are never overwritten; `shape.md` is the last rename and the promotion marker). A partial promotion that already holds the byte-identical seed task resumes by filling only the gaps. Everything else fails clearly and leaves the folder untouched — repeated `--brief`, `change.json`-only (missing brief), hybrid `change.md` + `change.json`, diverged `tasks/` content, malformed metadata, and fully-materialized folders (duplicate rejection unchanged). +```text +loaf issue show <ref> [--json] +loaf issue list [--status <status>] [--kind delivery|decision] [--archived] [--started] [--json] +loaf issue tree [<ref>] [--archived] [--json] +loaf issue frontier [--json] +``` -## `loaf change check [folder] [--require-executable] [--json]` +`show` prints identity, parent, fog, body, definition of done, and children. `list` hides archived issues unless `--archived`. `--status` filters by `triage`, `backlog`, `todo`, `active`, `done`, `cancelled`, `duplicate`. `tree` prints from a ref, or the whole project when omitted. `frontier` lists non-archived `triage`/`backlog`/`todo` issues that are not blocked — derived at read time, useful when checking whether this work is already covered. -Folder resolution: an explicit `[folder]` argument always wins; otherwise the current git branch is matched against the `branch:` frontmatter across every `docs/changes/*/change.json + shape.md`. Zero or multiple matches is an error naming the candidates found. +Prefer `--json` when diagnosing rather than scraping the human-readable text. -Output splits into two tiers: +## `loaf issue edit` / `status` -- **Violations** — always fail (exit code 2), regardless of flags: status-like frontmatter keys (`readiness`, `status`, `state`) or values matching the canonical change-state vocabulary; frontmatter not opening the file at byte one; malformed `YYYYMMDD-slug` folder naming; identity mismatch between `change:`/`created:` and the folder name; missing Product Contract sections (Problem, Hypothesis, Scope, Observable Workflow, Rabbit Holes and No-Gos). -- **Derived executability** — reported, never gating by default. A Change is executable when Planning Contract, Implementation Units, Verification Contract, and Definition of Done are all present and non-empty (bracket placeholders and HTML comments don't count as content). A Change with open gaps is incomplete and non-executable; the report just says what's still missing. +```text +loaf issue edit <ref> [--body-file <path>|--body -|--message <text>] [--json] +loaf issue status <ref> <status> [--duplicate-of <ref>] [--json] +``` -`--require-executable` turns structural executability into a gate (exit code 1 if not structurally executable); it does not prove implementation completion. This is implement's preflight and CI's non-draft-PR check, not something shape itself passes during shaping. +`edit` **replaces** the body. Rewrite the full problem-plus-out-of-scope text; there is no patch form. `status` write-statuses (`triage`, `backlog`, `todo`, `active`, `done`) update in place; `cancelled` and `duplicate` archive through the remove path (`--duplicate-of` is required when status is `duplicate`). Shape leaves status at `triage` unless the user asks otherwise — shaped is derived, not a status. -A branch/Change mismatch (current branch doesn't match the Change's `branch:` field) is a warning, never a violation. +## `loaf issue dod` -`--json` emits `{command, folder, passed, state, executable, exitCode, findings, warnings, gaps}` (plus optional `layout`, `captured`, `notices`) for scripted reads; prefer it when diagnosing rather than scraping the human-readable text. The landing guard reads `state` from this envelope — e.g. `"captured"` for a brief-only folder and `"shaped"` (or higher) once `shape.md` exists — and must not invent a second state surface. +```text +loaf issue dod add <ref> <text> [--command <cmd>] [--expect <expect>] [--tier V|H] [--serves <parent-position>] [--json] +loaf issue dod list <ref> [--json] +loaf issue dod remove <ref> <position> [--json] +loaf issue dod claim <child> <child-position> <parent-position> [--json] +loaf issue dod unclaim <child> <child-position> <parent-position> [--json] +``` +V-tier is used when `--command` is present, otherwise H, unless `--tier` overrides. `--serves` records that the new child criterion claims that parent position. Positions are 1-based and compact after `remove`. Authoring guidance and the expect grammar live in the Decomposition topic. -## `loaf change report new <slug> --kind <kind>` +## `loaf issue promote <ref> <position> [--json]` -Stamps `reports/YYYYMMDD-HHMMSS-<kind>-<slug>.html` with charset, provenance, and token skeleton; prints design-language guidance. Closed kinds: approval, review, visual, audit, note. +Promotes the criterion at the 1-based position into a child **delivery** issue. The parent criterion stays in place. The child is minted in `triage` with a copy of the criterion and a claim already recorded, so coverage for that parent position holds by construction. -## `loaf change verify [folder]` +## `loaf issue check <ref> [--json] [--human <reason>]` -Runs executable V-tier criteria declared in `shape.md` and writes `receipts/verify.json` (criteria digest, verified commit, cwd, per-criterion evidence). New-layout-only. +Derives readiness from the issue row, not from markdown headings. -Criteria forms (both parse): +- **Delivery** — shaped when the body is nonempty (the problem), at least one criterion exists, and the body contains an explicit out-of-scope statement. Prints `issue <ref> is shaped` when ready. +- **Decision** — ready when the title or body contains `?`. Prints `issue <ref> is ready`. +- **Children present** — coverage is a failure (every parent criterion must be claimed). Containment is a report (every child criterion must claim a parent criterion); each orphan prints a ready-to-paste `loaf issue new --parent … --status backlog -- …` remedy. -```markdown -- **V1.** Prose. Command: `exact command`. Expect: exit 0. -- **V1.** Prose. - - Command: `exact command` - - Expect: exit 0 -``` +`--human <reason>` publishes ready-for-human instead of ready-for-agent when a tracker authority is configured. Shape's own gate is the derived verdict, not the publication. + +`--json` emits `{issue, kind, shaped, covered, ready, failures, orphans, …}`. Exit code 1 when not ready. -`Expect` is enforced, with a deliberately minimal grammar: atoms join with ` and ` — `exit <N>` is the required exit code (omit the atom, or `Expect` entirely, and `exit 0` is enforced) and `` contains `text` `` requires the command's combined stdout+stderr to contain that backtick-delimited text (repeatable). A criterion passes when the command ran, the exit code matched, and every `contains` matched; the receipt records each atom and its outcome. +## `loaf issue verify <ref> [--json]` -```markdown -- **V1.** Prose. Command: `go test ./...`. Expect: exit 0 and contains `ok github.com/acme/pkg`. +Runs the issue's V-tier criteria (`--command` plus `--expect`) from the repository root. Honors `exit <N>` and `` contains `text` ``. Writes nothing; exits non-zero on any failure. H-tier rows are skipped. This is implement's preflight, not shape's gate. + +A criterion passes when the command ran, the exit code matched, and every `contains` matched. Unenforceable expect clauses are warned and recorded as advisory — never quietly decorative. + +## `loaf issue link` / `bucket` + +```text +loaf issue link <from> blocks|relates-to <to> [--json] +loaf issue link <from> remove <type> <to> [--json] +loaf issue bucket <ref> now|next|later|none [--json] ``` -Any other clause is unenforceable: verify prints a warning naming the criterion and the clause, records it on the criterion as advisory, and never lets it affect the result — an expectation is either checked or loudly not. +Stored relationship types are `blocks` and `relates_to`. Use `blocks` for a real sequencing constraint; do not encode order in `loaf issue tree`. Buckets are labels only and are never read as a constraint. + +## `loaf issue render <ref> [--json]` + +Emits markdown suitable to paste as a PR body with no manual editing: title, body, definition-of-done checkboxes (checked only when status is `done`), and children. Nothing plan-shaped is committed; if a PR is opened, this output *is* the body. + +## `loaf release suggest` / `cut` + +Releases are retroactive. Shape does not bind an issue to a version. + +```text +loaf release suggest [--base <ref>] [--json] +loaf release cut [--base <ref>] [--bump <type>] [--includes <version|tag>] [--no-tag] [--no-gh] [--dry-run] +``` -Commands run from the repository root; the receipt records that cwd. H-tier entries (`**H1.** …`) are never gate input. See [decomposition.md](decomposition.md) for authoring guidance. +`suggest` reports landed work since the last version tag and writes nothing. `cut` records a release from landed work. Neither is a shaping step. -## `loaf change tasks` / `show` +## What shape does not run -On-demand projections. See `loaf change --help`. +`loaf issue start` / `stop` create and remove the issue worktree — implement's job, after the issue is shaped. `loaf issue export` dumps the project snapshot. Do not call them from this skill. diff --git a/dist/skills/shape/references/critique-gate.md b/dist/skills/shape/references/critique-gate.md index 4e131243c..f01109327 100644 --- a/dist/skills/shape/references/critique-gate.md +++ b/dist/skills/shape/references/critique-gate.md @@ -1,14 +1,15 @@ # Critique Gate -The last shaping step, before `loaf change check` and the PR offer. An agent won't know to interrogate its own scope, boundary placement, or smuggled-in status words unless something makes it stop and ask. Instantiated from the shape-first pilot's own Critique Gate, generalized for any Change rather than that pilot's specific CLI-surface question. +The last shaping step, before `loaf issue check` and any review offer. An agent won't know to interrogate its own scope, boundary placement, or smuggled-in progress words unless something makes it stop and ask. Run through these before finalizing: -- **Is scope still bounded?** Has the draft crept beyond what the Problem and Hypothesis justify? Could this Change be smaller and still deliver the Hypothesis? +- **Is scope still bounded?** Has the draft crept beyond what the problem statement justifies? Could this issue be smaller and still be verifiable in one fresh context window? - **Does every new command, state, or lifecycle verb name its ceremony?** If a command or state can't name the ceremony that exercises it, cut it — don't build it now and hope a use appears. -- **Is a status field creeping back in under another name?** `readiness`, `phase`, `stage`, or anything else that reintroduces a declared progress flag `loaf change check` doesn't already ban by pattern. +- **Is a second progress flag creeping into the body?** `readiness`, `phase`, `stage`, or anything else that reintroduces a declared progress flag. Status lives on the issue row (`loaf issue status`). Shaped, covered, and ready are derived by `loaf issue check`. `loaf issue bucket` is a label only and is never read as a constraint. - **Is the CLI/skill boundary drawn correctly?** Is the skill doing deterministic work that belongs in the CLI, or is the CLI claiming judgment that belongs in the skill? -- **Which Verification Contract criteria are genuinely executable gates, and which are human review dressed up as automatable?** A criterion that can't disagree with the implementation isn't a gate. -- **Are the Rabbit Holes and No-Gos sections doing real work?** Or are they restating the Scope's Out list in different words? +- **Which criteria are genuinely executable gates, and which are human review dressed up as automatable?** A V-tier criterion needs `--command` (and `--expect` when exit 0 is not enough). A criterion that can't disagree with the implementation isn't a gate — make it H, or rewrite it against an independent source of truth. +- **Is the out-of-scope statement doing real work?** Rabbit holes and no-gos belong there as named exclusions. Restating the problem in different words is not an out-of-scope statement. +- **Did fog graduate, or evaporate?** Every named unknown is parked in create-time `fog`, held in the session register, sitting as a decision child (or sibling) with a sharp question, filed as a new backlog issue (a different problem), or written into the body as a decided answer. Silent deletion is the failure. -Answers that change the document go back into it — the Decisions log, the Planning Contract, or the relevant Product Contract section — before moving to `loaf change check`. An answer spoken but not written doesn't count. +Answers that change the issue go back into it — `loaf issue edit` for the body, `loaf issue dod add` / `remove` for criteria, `loaf issue promote` or `loaf issue new` for children — before moving to `loaf issue check`. An answer spoken but not written doesn't count. diff --git a/dist/skills/shape/references/decomposition.md b/dist/skills/shape/references/decomposition.md index 7deef4b13..b6839482b 100644 --- a/dist/skills/shape/references/decomposition.md +++ b/dist/skills/shape/references/decomposition.md @@ -1,53 +1,119 @@ # Decomposition -Shaping step absorbed from the retired breakdown skill: dependency awareness, granularity judgment, and acceptance-criteria thinking, now expressed as the Change's Implementation Units and Verification Contract instead of a separate task-minting pass. +Shaping's tail, not a separate ceremony: dependency awareness, granularity judgment, and acceptance-criteria thinking, expressed as definition-of-done criteria and — only when a criterion earns its own DoD — child issues created by `loaf issue promote`. -## What Survives +## Contents +- When to split +- The sizing rule +- Expand–contract +- Promote, don't mint +- Coverage and containment +- Authoring criteria +- Order by likelihood-of-change +- Own the decisions -- **The Right Size Test** — before finalizing a unit boundary, check: Can a single implementer complete this? If no, split by concern. Does it touch multiple unrelated concerns? If yes, split by concern. Will the agent need too much context? If yes, split into smaller coherent units. Are you splitting just to have more units? If yes, merge back. -- **Right-sizing rules** — one agent type per unit (completable by a single implementer), one concern per unit (one layer, service, or component), context-appropriate (fits in model context with room for exploration), not over-fragmented (don't split what naturally belongs together). -- **Per-unit verification discipline** — every unit includes its own observable done condition. Never a separate "verify" unit; keep tests with the code they test. -- **Own the decisions** — decide granularity and unit boundaries autonomously. Ask the user only when two orderings are genuinely equally valid with different trade-offs; otherwise decide and move on. +## When to split -## What Dies +A parent gets children only when its DoD needs more than one coherent slice. One criterion that is already right-sized stays on the parent. A checkbox becomes a sub-issue the moment it earns its own DoD — its own problem statement, its own out-of-scope, its own criteria. -Task-file minting, ID allocation, estimate fields, and ordering-for-execution as the default presentation. Implementation Units are in-document work packets — commit-boundary guides and review anchors — never tracked entities with IDs, statuses, or a persistence layer of their own. +Same problem, another slice → another criterion on this issue, or a promoted child if that slice now has its own DoD. A different problem discovered mid-shaping → a new backlog issue (`loaf issue new --status backlog`), not a child of this one. -## The New Principle: Order by Likelihood-of-Change +## The sizing rule -Breakdown ordered units for execution (dependency graph, then priority). Shaping orders units for review: data models, interfaces, and user-facing flows lead; mechanical refactors and boilerplate collapse at the bottom. The reviewer's attention at the draft-to-ready flip should land on what's most likely to need changing, not on whatever happens to run first. +One test, replacing the old four-question checklist: **a slice is right-sized when it fits one fresh context window and is verifiable alone.** -Sequencing constraints that genuinely exist — this unit must land before that one — are stated in prose within the unit description or a Planning Contract subsection. Never rely on list order alone to imply a dependency; a reviewer skimming units by likelihood-of-change won't read sequencing into position. +- If an implementer cannot pick the issue up in a new conversation and finish it without reading a sibling, split. +- If the done-check cannot run (or be reviewed) without another slice landing first, either split and `loaf issue link <predecessor> blocks <successor>`, or merge — do not leave a criterion that is only true in combination. +- If you are splitting just to have more rows, merge back. -## Authoring the Verification Contract +Per-slice verification stays with the slice. Never a separate "verify" child; keep tests with the code they test. -Split criteria into two groups, mirroring the Change template: +## Expand–contract -- **Executable (V-tier)** — bound to a command and an expected result; machine-checkable by `loaf change verify`. Two equivalent forms: +The named exception for wide mechanical refactors (a rename, a schema migration, an expand-then-remove of an old path). The slice may be wide in files touched and still be one issue, because it is one coherent mechanical motion and one verification: the suite still passes after the motion. - Inline (what the scaffold writes): +Do not use this exception to smuggle a second problem into the parent. A mechanical rename plus a behavior change is two slices. - ```markdown - - **V1.** What must be true. Command: `go test ./...`. Expect: exit 0. - - **V2.** Output-bound. Command: `loaf change check`. Expect: exit 0 and contains `executable`. - ``` +## Promote, don't mint - Or with an authoring checkbox still open: +```bash +loaf issue promote <ref> <position> +``` - ```markdown - - [**V1.** What must be true. Command: `go test ./...`. Expect: exit 0.] - ``` +The criterion at the 1-based position stays on the parent. A child **delivery** issue is created in `triage`, titled from the criterion text, with a copy of that criterion as its first DoD line and a claim already recorded from the copy to the parent. Coverage for that parent position holds by construction. + +Then shape the child: give it a problem body and an out-of-scope statement (`loaf issue edit` replaces the body), add the criteria that make *its* DoD complete, promote again if one of those earns its own DoD. + +`loaf issue promote` always mints a delivery child. Decision children are created separately: + +```bash +loaf issue new --kind decision --parent <ref> "Should the store be append-only?" +``` + +A decision child is ready when the title or body contains `?`. It does not claim a parent criterion. + +Once **any** child exists — delivery or decision — `loaf issue check` requires every parent criterion to be claimed. A leaf parent that grows a decision child without promoted (or otherwise claimed) criteria will fail coverage. Sequence the tail as: write DoD → promote every slice that will not execute on the parent → then add decision children. If the parent stays a leaf, leave remaining unsharp questions in create-time `fog` (there is no `--fog` on edit) or file sharpened ones as sibling decision issues (`loaf issue new --kind decision --status backlog`, no `--parent`). - Sub-bullet: +Manual claims, when a child criterion was added rather than promoted: - ```markdown - - **V1.** What must be true. - - Command: `go test ./...` - - Expect: exit 0 +```bash +loaf issue dod add <child> "Child done-check" --serves 1 +loaf issue dod claim <child> <child-position> <parent-position> +loaf issue dod unclaim <child> <child-position> <parent-position> +``` + +`--serves` claims the newly added child criterion against that parent position. `claim` / `unclaim` retarget an existing pair. + +## Coverage and containment + +`loaf issue check <ref>` runs these only when the issue has children. + +- **Coverage** (failure) — every parent criterion must be claimed by at least one child criterion. An uncovered position is named in the failure list; fix it by promoting that criterion or adding a claiming child criterion. +- **Containment** (report, not a failure) — every child criterion must claim a parent criterion. An orphan is printed with a ready-to-paste remedy that files it as a **sibling** backlog issue: + + ```bash + loaf issue new --parent '<parent>' --status backlog -- '<orphan text>' ``` - `Expect` is optional and enforced when present, with a deliberately minimal grammar: atoms join with ` and ` — `exit <N>` is the required exit code (an absent `Expect`, or an `Expect` with no exit atom, means `exit 0`) and `` contains `text` `` requires the combined stdout+stderr to contain that backtick-delimited text (repeatable). Any other clause is unenforceable: `loaf change verify` warns naming the criterion and the clause and records it as advisory, so an expectation is either checked or loudly not — never quietly decorative. Commands run from the **repository root** (never the change folder). Only V-entries that declare a fenced `Command:` value are gate input. + Run the printed remedy. Do not fold the orphan back onto the parent, and do not treat it as in-scope work that somehow escaped the DoD — it is a new backlog row under the same parent. + +A different problem (not an orphan criterion) is not a sibling of this decomposition. File it as a new backlog issue with no `--parent`. -- **Human review (H-tier)** — what a reviewer confirms that no command can. H-entries are review material and are **never** gate input; `loaf change verify` ignores them. +## Authoring criteria + +```bash +loaf issue dod add <ref> <text> [--command <cmd>] [--expect <expect>] [--tier V|H] [--serves <parent-position>] +``` + +- **V-tier** — used when `--command` is present, unless `--tier` overrides. Machine-checkable by `loaf issue verify <ref>`. Commands run from the **repository root**. +- **H-tier** — default when `--command` is absent. Human review; never gate input. `loaf issue verify` skips H-tier rows. + +`--expect` is optional and enforced when present, with a deliberately minimal grammar: atoms join with ` and ` — `exit <N>` is the required exit code (an absent `--expect`, or one with no exit atom, means `exit 0`) and `` contains `text` `` requires the combined stdout+stderr to contain that backtick-delimited text (repeatable). Any other clause is unenforceable: verify warns naming the criterion and the clause and records it as advisory, so an expectation is either checked or loudly not — never quietly decorative. + +```bash +loaf issue dod add LOAF-42 "Package tests pass" --command "go test ./..." --expect "exit 0" +loaf issue dod add LOAF-42 "Check names the uncovered criterion" --command "loaf issue check LOAF-42" --expect "exit 0 and contains \`uncovered\`" +loaf issue dod add LOAF-42 "The writeup is readable by someone new to the area" --tier H +``` A criterion whose check only restates the implementation (recomputing the expected value the way the code does) is vacuous — it can never disagree with the code under test. Prefer criteria with an independent source of truth. + +`loaf issue dod list <ref>` prints the current lines. `loaf issue dod remove <ref> <position>` removes one (positions then compact). + +## Order by likelihood-of-change + +Present children for review, not for execution order: data models, interfaces, and user-facing flows lead; mechanical refactors and boilerplate collapse at the bottom. The reviewer's attention should land on what's most likely to need changing, not on whatever happens to run first. + +Sequencing constraints that genuinely exist — this child must land before that one — are recorded as relationships: + +```bash +loaf issue link <from> blocks <to> +loaf issue link <from> relates-to <to> +loaf issue link <from> remove blocks <to> +``` + +Never rely on `loaf issue tree` order to imply a dependency; a reviewer skimming by likelihood-of-change won't read sequencing into position. `loaf issue bucket <ref> now|next|later|none` is an advisory label only and is never read as a constraint. + +## Own the decisions + +Decide granularity and slice boundaries autonomously. Ask the user only when two orderings are genuinely equally valid with different trade-offs; otherwise decide and move on. diff --git a/dist/skills/shape/references/grilling.md b/dist/skills/shape/references/grilling.md index cd4d6a575..4cf6437dd 100644 --- a/dist/skills/shape/references/grilling.md +++ b/dist/skills/shape/references/grilling.md @@ -1,8 +1,8 @@ # Grilling -The interview technique for `[KU]` entries — known unknowns precise enough to phrase as a question. Imported from the reviewed `grill-me` / `grill-with-docs` pattern (`docs/changes/20260704-shape-first-change-workflow/research/mattpocock-review/`), sharpened with the Field Guide's architectural-impact ordering. +The interview technique for `[KU]` entries — known unknowns precise enough to phrase as a question. Imported from the reviewed `grill-me` / `grill-with-docs` pattern, sharpened with architectural-impact ordering. -This is a distinct, shape-specific technique — not the shared `templates/grilling.md` used by `architecture` and `refactor-deepen`. That template's glossary-mutation duty doesn't fit shape's ambiguity-resolving step (see the deferral rationale it documents); shape's grilling stays scoped to fog entries and never writes to the domain glossary. +This is a distinct, shape-specific technique — not the shared `templates/grilling.md` used by `architecture` and `refactor-deepen`. That template's glossary-mutation duty doesn't fit shape's ambiguity-resolving step (see the rationale it documents); shape's grilling stays scoped to fog entries and never writes to the domain glossary. ## The Mechanic @@ -14,17 +14,21 @@ Every question carries a recommended answer with rationale — never "what do yo Prioritize questions whose answer would change the architecture. Cosmetic questions — naming, ordering, presentation — go last, even when they're easier to answer. An architecture-changing answer received late can invalidate everything decided in between; asking it first avoids that rework. -Before asking, check whether reading resolves the question — an existing ADR, a prior Change, a journal entry. Only ask what reading couldn't answer. +Before asking, check whether reading resolves the question — an existing ADR, a prior issue, a journal entry. Only ask what reading couldn't answer. ## Stop Condition Stop when either holds: - No unrouted `[KU]` entries remain. -- Answers stop changing the contract — the last several questions confirmed direction rather than altering it. +- Answers stop changing the issue — the last several questions confirmed direction rather than altering the body, the criteria, or the children. -Grilling never opens the session. Context gathering and the blindspot-pass offer come first; an interview without that groundwork asks questions the codebase or journal could have answered for free. +Write each accepted answer into the issue as it lands: `loaf issue edit` for the body, `loaf issue dod add` for a new done-check, `loaf issue new --kind decision --parent <ref>` when the answer is itself a sharp question that still needs a later call. Do not leave a resolved `[KU]` only in the conversation. ## Mid-Interview Reroute If a question turns out to need domain fluency the shaper doesn't have — the follow-up can't even be phrased — stop grilling it and route the entry to the blindspot pass instead of guessing at an answer. + +## Opening + +Grilling never opens the session. Context gathering and the blindspot-pass offer come first; an interview without that groundwork asks questions the codebase or journal could have answered for free. diff --git a/dist/skills/shape/references/reaction-artifact.md b/dist/skills/shape/references/reaction-artifact.md index d3254bed0..ca30f7485 100644 --- a/dist/skills/shape/references/reaction-artifact.md +++ b/dist/skills/shape/references/reaction-artifact.md @@ -8,11 +8,11 @@ Before building anything, state precisely what this artifact must let the user d ## Build the Smallest Thing That Lets the User React -Construct the minimum artifact that resolves the named unknown — competing layouts, a comparison of approaches, a rendered mock. Write it into the Change's own `research/` folder, never `.agents/reports/`; this is shape's own technique, executed in-session, not a handoff to the research skill. +Construct the minimum artifact that resolves the named unknown — competing layouts, a comparison of approaches, a rendered mock. Keep it in the session or in a throwaway file you will discard. This is shape's own technique, executed in-session, not a handoff to the research skill and not a committed plan document. Do not write it into `.agents/reports/`. ## Capture the Answer, Discard the Shell -Once the user reacts and picks, write the decision — with rationale — back into the Change: the Decisions log, or the relevant Planning Contract subsection. The artifact itself is not the deliverable; the choice it produced is. Discard or archive the shell once the answer is captured. If a piece of logic proved out along the way is worth keeping (a validated approach, not just a throwaway shell), note that explicitly — but don't let scaffolding survive under the pretense that it's production code. +Once the user reacts and picks, write the decision — with rationale — back onto the issue: the body via `loaf issue edit`, or a `--kind decision` child when the pick is itself a sharp question that still needs a later call. The artifact itself is not the deliverable; the choice it produced is. Discard the shell once the answer is captured. If a piece of logic proved out along the way is worth keeping (a validated approach, not just a throwaway shell), note that explicitly in the issue body — but don't let scaffolding survive under the pretense that it's production code. ## Never a Deliverable diff --git a/dist/skills/shape/templates/brief.md b/dist/skills/shape/templates/brief.md deleted file mode 100644 index 05016c8c8..000000000 --- a/dist/skills/shape/templates/brief.md +++ /dev/null @@ -1,42 +0,0 @@ -<!-- brief.md is the optional archeological kickstart — the original unshaped ask. - May accrete parked problem-space concepts while the change is captured; freezes when shape.md exists. - Superseded by shape.md; never mechanically load-bearing. - A brief-only folder is legal and non-executable (captured, not shaped). --> - -# [Brief title] - -## Problem Statement - -[What friction, gap, or unmet need exists? Be specific about the pain — vague problems produce vague solutions. Problem-space only; do not design the approach here.] - -## Who Has It - -[Who experiences this problem? Role, context, and how often the pain shows up. Avoid unqualified "users" or "developers."] - -## Current Alternatives - -[What do they do today? Existing tools, manual workarounds, or "nothing" are all valid. Understanding the status quo clarifies what better means.] - -## Value Proposition - -[Why is solving this worth it? What becomes true for the people who have the problem if this lands? Describe value, not features or architecture.] - -## Constraints - -[Non-negotiable bounds: technical, legal, organizational, or philosophical. Things that limit the solution space before design begins.] - -- [Constraint 1] - -## Sequencing and Relationships - -[How this relates to other work — series order, release cohort, dependencies stated as prose. No machine relation fields; narrative order only.] - -## Sources and Research Links - -[Evidence that informed this framing — competitive scans, research notes, issue links, prior art. Link rather than paste.] - -## Open Questions - -[Unresolved problem-space items. Mark urgency: blocking (must resolve before shaping) or deferrable.] - -- [ ] [Question] — blocking | deferrable diff --git a/dist/skills/shape/templates/change.md b/dist/skills/shape/templates/change.md deleted file mode 100644 index 6ce5b9725..000000000 --- a/dist/skills/shape/templates/change.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -change: [slug] -created: [YYYY-MM-DD] -branch: [slug] ---- - -<!-- Frontmatter must open the file at byte one — parsers depend on it. No status-like frontmatter (readiness/status/state): readiness is derived — a draft PR is shaping; `loaf change check` derives structural executability from the sections below. --> - -# [Change Title] - -## Problem - -[Why this work exists — the friction, gap, or rot being addressed.] - -## Hypothesis - -[The bet: what becomes true if this ships, and why it is worth making.] - -## Scope - -**In** - -- [What this Change delivers.] - -**Out** (deferred, not rejected) - -- [What is explicitly postponed, and where it went.] - -**Cut** (explicitly rejected) - -- [What this Change will not do, ever.] - -## Observable Workflow - -[What someone sees or does once this ships — commands, flows, UX. Concrete over abstract.] - -## Rabbit Holes and No-Gos - -[Boundaries: the ways this work could quietly grow into something it must not become.] - -## Decisions - -Provenance: [how each decision was accepted — interview, review, dogfooding.] - -1. **[Decision.]** [Rationale, and what it forecloses.] - -## Planning Contract - -[The HOW. Free-form `###` subsections named by the work — the container is the contract; the subsection names are yours.] - -### [Approach / Placement / Risks / Sequencing / Spike findings …] - -[...] - -## Implementation Units - -<!-- In-document work packets — commit-boundary guides and review anchors, not tracked entities. --> - -- [**U1 — Unit name.** What it delivers.] - -## Verification Contract - -<!-- Executable (machine-checkable): --> - -- [**V1.** Criterion bound to a command and an expected result.] - -<!-- Human review: --> - -- [**H1.** What a reviewer confirms that no command can.] - -## Definition of Done - -- [Derived from gates and review — never a status flag.] - -## Durable Outputs - -[Specs, ADRs, knowledge docs, or schema updates to create after implementation proves what is now true. A final spec describes reality, not a plan.] - -## Open Questions - -<!-- Fog register: tag entries [KU]/[UK]/[UU] with a route — see the shape skill's quadrant table. Tags are convention, never parsed by check. --> - -- [Known unknowns, each owned by a section, a spike, or a follow-up.] - -## Source Inputs - -- [Where this Change came from: journal entries (cite by ID), sparks, ideas, brainstorms, issues, conversations, prior Changes.] - -<!-- Optional sections, added when they earn their place: Background, Success Metrics (when validation matters), Follow-ups, Critique Gate. --> diff --git a/dist/skills/shape/templates/design.md b/dist/skills/shape/templates/design.md deleted file mode 100644 index 2244ccdb6..000000000 --- a/dist/skills/shape/templates/design.md +++ /dev/null @@ -1,20 +0,0 @@ -<!-- design.md is the optional design surface for UI, protocol, or schema detail - that would crowd shape.md or plan.md. Accretive; not load-bearing for the gate. --> - -# Design — [Change Title] - -## Intent - -[What this design clarifies that shape.md does not.] - -## Surfaces - -[Screens, APIs, schemas, or protocols under design.] - -## Decisions - -1. **[Decision.]** [Rationale.] - -## Open questions - -- [What remains undecided.] diff --git a/dist/skills/shape/templates/plan.md b/dist/skills/shape/templates/plan.md deleted file mode 100644 index 6d17a7179..000000000 --- a/dist/skills/shape/templates/plan.md +++ /dev/null @@ -1,21 +0,0 @@ -<!-- plan.md is the optional technical route (the corpus's plan sense). - Accretive during shaping; approach churn here never expires a cohort receipt. - Criteria live in shape.md — do not relocate Verification Contract here. --> - -# Plan — [Change Title] - -## Approach - -[How the work lands — architecture, sequencing, compatibility.] - -## Placement - -[Where code and docs live; what stays out of scope for this route.] - -## Risks - -[Failure modes and the safe failure direction.] - -## Sequencing - -[Ordered slices that leave main coherent at each landing.] diff --git a/dist/skills/shape/templates/pr.md b/dist/skills/shape/templates/pr.md deleted file mode 100644 index 91b4b7401..000000000 --- a/dist/skills/shape/templates/pr.md +++ /dev/null @@ -1,25 +0,0 @@ -<!-- Draft = still shaping. Ready for review = structurally executable, not proof of implementation completion. --> - -## Change - -<!-- Link the Change folder this PR advances: docs/changes/YYYYMMDD-slug/ The Change artifact lives in this PR's diff — the draft PR is its shaping surface. During coexistence, legacy PRs implementing a numbered spec link the .agents/specs/ path here instead. Delete this section for PRs tied to neither. --> - -## What & Why - -<!-- What this PR does and the problem it solves. For a shaping (draft) PR, summarize the direction; for an implementation PR, summarize the delta. --> - -## Review focus - -<!-- Where reviewer attention pays off: decisions to challenge, boundaries to verify, criteria to confirm. --> - -## Verification - -<!-- What proves this works: gates run (`loaf change check`, `loaf check`), tests, commands a reviewer should re-run locally. On a draft, state what remains open instead. --> - -## Migration / breaking changes - -<!-- If this PR changes user-facing behavior, document the migration step. Otherwise: "None." --> - -## Deferred - -<!-- Intentionally out of scope, and where it went (follow-up Change, open question, issue). Delete if nothing was deferred. --> diff --git a/dist/skills/shape/templates/shape.md b/dist/skills/shape/templates/shape.md deleted file mode 100644 index 59aa9b89a..000000000 --- a/dist/skills/shape/templates/shape.md +++ /dev/null @@ -1,77 +0,0 @@ -<!-- shape.md is the change contract. Identity lives in change.json — no status-like frontmatter. Readiness is derived: a draft PR is shaping; `loaf change check` derives structural executability from the sections below. --> - -# [Change Title] - -## Problem - -[Why this work exists — the friction, gap, or rot being addressed.] - -## Hypothesis - -[The bet: what becomes true if this ships, and why it is worth making.] - -## Scope - -**In** - -- [What this Change delivers.] - -**Out** (deferred, not rejected) - -- [What is explicitly postponed, and where it went.] - -**Cut** (explicitly rejected) - -- [What this Change will not do, ever.] - -## Observable Workflow - -[What someone sees or does once this ships — commands, flows, UX. Concrete over abstract.] - -## Rabbit Holes and No-Gos - -[Boundaries: the ways this work could quietly grow into something it must not become.] - -## Decisions - -Provenance: [how each decision was accepted — interview, review, dogfooding.] - -1. **[Decision.]** [Rationale, and what it forecloses.] - -## Planning Contract - -<!-- The HOW. Prefer plan.md/design.md when the route needs its own file; keep this container. Free-form ### subsections named by the work. --> - -### [Approach / Placement / Risks / Sequencing …] - -[…] - -## Implementation Units - -<!-- Task packets live in tasks/TASK-NNN-slug.md; this section may summarize the decomposition. --> - -- [**TASK-001 — Unit name.** What it delivers.] - -## Verification Contract - -<!-- Executable (machine-checkable): each V-entry declares Command and Expect for loaf change verify. Expect is a grammar, not prose: atoms join with " and " — `exit <N>` is the required exit code (omit the atom, or Expect entirely, for exit 0; a second exit atom is a contradiction and fails the criterion) and contains `text` requires the combined stdout+stderr to contain that backtick-delimited text (repeatable). Example: Expect: exit 0 and contains `all green`. Any other clause is unenforceable: verify warns naming the criterion and clause, records it as advisory, and never checks it. --> - -- [**V1.** What must be true. Command: `exact command`. Expect: exit 0.] - -<!-- Human review (H-tier): review material, never gate input. --> - -- [**H1.** What a reviewer confirms that no command can.] - -## Definition of Done - -- [Derived from gates and review — never a status flag.] - -## Durable Outputs - -[Specs, ADRs, knowledge docs, or schema updates to create after implementation proves what is now true.] - -## Open Questions - -<!-- Fog register: tag entries [KU]/[UK]/[UU] with a route. Tags are convention, never parsed by check. --> - -- [KU] [Known unknown → route to a task or later change] diff --git a/dist/skills/shape/templates/task.md b/dist/skills/shape/templates/task.md deleted file mode 100644 index 05de15307..000000000 --- a/dist/skills/shape/templates/task.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -change: [slug] -id: TASK-NNN -title: [short title] -# Relations (closed set; targets are TASK-NNN within this change only): -# parent: TASK-NNN -# blocks: -# - TASK-NNN -# blocked-by: -# - TASK-NNN -# relates-to: -# - TASK-NNN ---- - -# TASK-NNN — [Title] - -## Objective - -[What this task delivers when its checkboxes are flipped.] - -## Scope boundaries - -**In:** [What this task may touch.] - -**Out:** [What this task must not touch — other tasks, other changes, deferred work.] - -## Context pointers - -- Contract: `shape.md` — [relevant sections] -- Research: [paths under research/ when cited] - -## Acquisition - -```bash -loaf journal log "skill(implement): TASK-NNN — [short intent]" -# [commands or files to load before editing] -``` - -## Steps - -- [ ] [Atomic step — one commit's worth when flipped with the delivering work] -- [ ] [Next step] - -## Verification - -- [Commands or checks that prove this task alone] -- The slug never cites other work units (`TASK-…`, `SPEC-…`, issue keys) — identity is local; provenance is in frontmatter and the change folder. diff --git a/dist/skills/ship/SKILL.md b/dist/skills/ship/SKILL.md index 2e1207ac2..b298dd0c2 100644 --- a/dist/skills/ship/SKILL.md +++ b/dist/skills/ship/SKILL.md @@ -1,16 +1,19 @@ --- name: ship description: >- - Reviews, verifies, and lands one pull request. Use when the user says "ship - it," "merge this PR," "ready to merge," "land this branch," or asks for a - final merge gate. Produces a reviewed, squash-merged PR and post-merge - cleanup. Not for version bumps, tags, GitHub Releases, or install verification - (use release). + Reviews, verifies, and lands one pull request — the sole quality gate before + work can appear in a later release cut. Use when the user says "ship it," + "merge this PR," "ready to merge," "land this branch," or asks for a final + merge gate. Binds the PR to an issue: the body is `loaf issue render` output, + definition-of-done criteria are the review checklist, and landing marks the + issue done and stops its worktree. Produces a reviewed, squash-merged PR and + post-merge cleanup. Not for version bumps, tags, GitHub Releases, or install + verification (use release). --- # Ship -Review, verify, and land one PR. Shipping is the PR gate; releasing is the version-publication gate. +Review, verify, and land one PR. Ship's review is the quality gate for everything that will ever ship. Releases are retroactive — they cut a version from already-landed work. Nothing between merge and the next release cut re-checks the diff, the tests, or the issue. A rubber-stamped PR ships in the next cut with no second net. ## Contents - Critical Rules @@ -20,7 +23,7 @@ Review, verify, and land one PR. Shipping is the PR gate; releasing is the versi - Context Detection - Step 1: PR Readiness - Step 1b: Stacked PR Detection -- Step 2: Evidence Review +- Step 2: Definition-of-Done Review - Step 3: Local Verification - Step 4: Squash Merge - Step 5: Post-Merge Cleanup @@ -34,64 +37,94 @@ Review, verify, and land one PR. Shipping is the PR gate; releasing is the versi ## Critical Rules -- **Ship is not release** -- do not bump versions, create tags, publish GitHub Releases, or verify package installation here. -- **Keep PR quality local** -- smaller PRs are welcome, but ship must still verify correctness before merge. -- **Detect-first** -- auto-detect the PR from the current branch before asking for a PR number. -- **Review before merge** -- inspect code, docs, tests, changelog, PR body, and CI state before approval. -- **Never merge without explicit confirmation** -- present the PR, checks, findings, and squash body first. -- **Detect the stack before merging** -- another open PR may use this PR's head branch as its base. Find out before merge, never after, and never delete a head branch while a child PR still points at it. -- **Clean squash body** -- write an intentional squash commit body; never accept the automatic commit dump. -- **Keep landed and released distinct** -- after merge, describe the PR as landed or shipped, not necessarily released. -- **Log shipping** -- after merge, run `loaf journal log "decision(ship): PR #N landed via squash merge"`. +1. **Log invocation first** — `loaf journal log "skill(ship): shipping <ref or PR or current branch>"` before doing anything else. After merge, log `loaf journal log "decision(ship): PR #N landed via squash merge; <ref> done"`. +2. **Rigor is load-bearing** — this review is the only quality gate. Releases cut from landed work; they do not re-check. If the review is thin, the next cut still publishes it. +3. **Ship is not release** — do not bump versions, create tags, publish GitHub Releases, or verify package installation here. Use the release skill for that. +4. **Bind the PR to an issue** — the PR body is `loaf issue render <ref>` (paste-ready, no manual editing). The issue's definition-of-done criteria are the review checklist. `loaf issue verify <ref>` runs the executable (V-tier) rows and writes nothing. Landing means `loaf issue status <ref> done`. Then `loaf issue stop <ref>` removes the started worktree. +5. **Detect-first** — auto-detect the PR from the current branch, and the issue from `$ARGUMENTS` or the started workspace, before asking for a PR number or issue ref. +6. **Review before merge** — inspect code, docs, tests, changelog, the rendered issue body, definition of done, and CI state before approval. +7. **Never merge without explicit confirmation** — present the PR, checks, review notes, and squash body first, using your harness's structured question tool if it has one. +8. **Detect the stack before merging** — another open PR may use this PR's head branch as its base. Find out before merge, never after, and never delete a head branch while a child PR still points at it. +9. **Clean squash body** — write an intentional squash commit body; never accept the automatic commit dump. +10. **Keep landed and released distinct** — after merge, describe the PR as landed or shipped. It is not released until release publishes a version. + +--- ## Verification +- Invocation is logged to the project journal before review work begins - PR identity, base branch, and head branch are confirmed +- The PR is bound to one issue; `loaf issue show <ref>` is the issue surface +- PR body matches `loaf issue render <ref>` with no manual editing +- Every definition-of-done criterion was reviewed against the diff; H-tier by reading, V-tier by `loaf issue verify <ref>` (writes nothing; exit non-zero blocks) - CI status is passing or the user explicitly accepts named non-blocking checks - Relevant local checks pass or failures are fixed before merge - PR body and durable docs do not overclaim relative to the diff - Squash commit title/body are clean, conventional, and user-facing - Child PRs stacked on this PR's head branch are enumerated before merge, and each is retargeted, rebased, and re-verified after it -- Base branch is updated after merge and the feature branch cleanup state is known +- After merge: `loaf issue status <ref> done`, then `loaf issue stop <ref>` if a worktree was started, base branch updated, feature-branch cleanup state known +- A `decision(ship)` journal entry records the landing ## Quick Reference | Step | Gate | Blocking? | |------|------|-----------| +| Context Detection | PR and issue bound | Yes | | PR Readiness | PR exists, target base known, CI state reviewed | Yes | | Stacked PR Detection | child PRs on this head branch are enumerated | Yes | -| Evidence Review | findings resolved or explicitly accepted | Yes | -| Local Verification | relevant checks pass | Yes | +| Definition-of-Done Review | every criterion reviewed; `loaf issue verify` passes or reports no V-tier rows | Yes | +| Local Verification | relevant project checks pass | Yes | | Squash Merge | user approves body text | Yes | -| Cleanup | base pulled, children retargeted and rebased, branch deletion handled | Yes when a child exists | -| Release Suggestion | enough landed work may justify release | No | +| Cleanup | issue marked done, started worktree stopped, base pulled, children retargeted and rebased, branch deletion handled | Yes when a child PR exists; done + stop always | +| Release Suggestion | enough landed work may justify a later cut | No | ## Topics | Topic | Use When | |-------|----------| -| [Context Detection](#context-detection) | Determining current branch and PR state | +| [Context Detection](#context-detection) | Binding the current branch to a PR and an issue | +| [Definition-of-Done Review](#step-2-definition-of-done-review) | Using issue criteria as the merge checklist | | [Hook Interaction](#hook-interaction) | Understanding coexistence with git hooks | --- ## Context Detection -Before anything, detect the PR surface: +Log the invocation, then detect the PR and the issue. + +### PR 1. Get current branch and repo default branch: ```bash git branch --show-current gh repo view --json defaultBranchRef -q .defaultBranchRef.name ``` -2. Parse `$ARGUMENTS`: may be a PR number, PR URL, branch name, or empty. -3. If `$ARGUMENTS` is empty, auto-detect from the current branch: +2. Parse `$ARGUMENTS`: may be an issue ref (`LOAF-42`), a PR number, a PR URL, a branch name, or empty. +3. If `$ARGUMENTS` is empty or is not a PR identity, auto-detect from the current branch: ```bash gh pr view --json number,title,url,headRefName,baseRefName,state,mergeStateStatus,isDraft ``` 4. If no PR exists for the current branch, stop and offer to create one via `git-workflow` rather than silently merging a branch. 5. If already on the default branch, stop. There is no PR to ship from the current branch. -6. Confirm PR identity with the user before merge actions. + +### Issue + +Issue commands require initialized SQLite state. Bind exactly one issue: + +1. If `$ARGUMENTS` (or a remaining token) is an issue ref, load it: + ```bash + loaf issue show <ref> + ``` +2. Otherwise match the PR's `headRefName` to a started workspace: + ```bash + loaf issue list --started + ``` + Columns are alias, title, `started_branch`, `started_worktree`. The started branch from `loaf issue start` is `issue/<alias-or-id>` in lowercase (`issue/loaf-42`), disambiguated with an id suffix when that name is already claimed. +3. Confirm with `loaf issue show <ref>` — `started_branch` / `started_worktree` should match this PR when the issue was started. + +If nothing binds, stop and ask for the issue ref. Do not invent a row during ship. If `loaf issue show` reports the issue archived (`cancelled` or `duplicate`), stop. + +Confirm PR identity and the bound issue with the user before merge actions. --- @@ -103,6 +136,14 @@ Inspect the PR's declared state: gh pr view <N> --json number,title,body,url,headRefName,baseRefName,state,isDraft,mergeStateStatus,reviewDecision,statusCheckRollup ``` +The body must be `loaf issue render <ref>` output — title, issue body, definition-of-done checkboxes (checked only when status is already `done`), and children. No project headers, no hand-edited summary. If the live PR body differs, replace it: + +```bash +gh pr edit <N> --body "$(loaf issue render <ref>)" +``` + +Do not rewrite the markdown by hand. Checkboxes are unchecked until `loaf issue status <ref> done`; do not tick them in the PR body to fake completion. + Block or pause when: - PR is draft @@ -123,20 +164,27 @@ Before merging anything, find out whether another open PR uses this PR's head br gh pr list --state open --base <headRefName> --json number,title,headRefName ``` -Any result is a **child PR**, and this PR is the base of a stack. Record the list now, because after the merge the relationship is harder to see and easier to break. +Any result is a **child PR**, and this PR is the base of a stack. Record the list now, because after the merge the relationship is harder to see and easier to break. Child PRs are GitHub stacking, not child issues. -When a child exists: +When a child PR exists: - **Do not pass `--delete-branch` to the merge.** Removing the head branch while a child still points at it can close the child outright. Delete it only after every child has been retargeted, in Step 5. - Tell the user the stack exists and name the children before asking for merge confirmation. A stack changes what "merge this" means. -When no child exists, say so, and `--delete-branch` is safe. +When no child PR exists, say so, and `--delete-branch` is safe. --- -## Step 2: Evidence Review +## Step 2: Definition-of-Done Review + +The issue's definition of done is the merge checklist. Load it from the issue, not from memory: + +```bash +loaf issue dod list <ref> +loaf issue show <ref> +``` -Review the landing diff and durable prose together: +`show` prints each criterion as `position. [V|H] text` with `command=` / `expect=` when present. Walk every row against the landing diff. 1. Gather diff context: ```bash @@ -144,13 +192,19 @@ Review the landing diff and durable prose together: git diff --stat origin/<baseRefName>...HEAD git diff --name-only origin/<baseRefName>...HEAD ``` -2. Read the PR title/body and changed docs that make behavior claims. -3. Check for drift: - - PR body claims features that are not in the diff - - changelog entries mention unreleased or unrelated behavior +2. For each **H-tier** criterion, read the diff and durable prose and decide whether the text is met. `loaf issue verify` skips H-tier rows — that skip is not a pass; you are the check. +3. Run the **V-tier** rows: + ```bash + loaf issue verify <ref> + ``` + Commands run from the **repository root**. The command honors `exit <N>` and `` contains `text` ``. It writes nothing — it does not tick checkboxes, does not set status, and does not record a run. Non-zero exit blocks merge. `no executable V-tier criteria on <ref>` is not a failure; H-tier review still is. Unenforceable expect clauses print as advisory warnings and are never quietly decorative. +4. Check for drift: + - Rendered issue body claims features that are not in the diff + - changelog entries mention unrelated behavior - docs describe future work as already shipped - - comments or runbooks use stale internal vocabulary -4. Fix blocking drift before merge. For non-blocking polish, name it and let the user decide. + - comments or runbooks use stale vocabulary +5. If `loaf issue show` lists child issues that are not `done`, name them before asking to merge. Do not mark those children done unless this PR is theirs. +6. Fix blocking drift and unmet criteria before merge. For non-blocking polish, name it and let the user decide. For high-risk PRs, use the project's review skill or read-only review flow before proceeding. @@ -158,7 +212,7 @@ For high-risk PRs, use the project's review skill or read-only review flow befor ## Step 3: Local Verification -Run the checks the project supports. Examples: +Run the checks the project supports, in addition to `loaf issue verify`. Examples: - Node: `npm run typecheck`, `npm run test`, `npm run build` - Go: `go vet ./...`, `go test ./...` @@ -177,7 +231,7 @@ Use the repo's documented pre-commit or pre-PR checklist when present. Stop on f ## Step 4: Squash Merge -Draft a clean squash body from the reviewed diff and PR body: +Draft a clean squash body from the reviewed diff and the rendered issue: - One-line summary, then bullet points grouped by feature area - Plain text; use backticks only for code identifiers @@ -200,31 +254,41 @@ Let GitHub default the title from the PR title so the squash subject remains `ty ## Step 5: Post-Merge Cleanup -After a successful merge: +After a successful merge, leave the started worktree before removing it. Do not run `loaf issue stop` from inside that worktree. -1. Switch to the PR base branch: +1. Switch to the PR base branch in the repository checkout: ```bash git checkout <baseRefName> git pull --ff-only origin <baseRefName> ``` -2. Delete the local feature branch when safe: +2. Mark the bound issue done — this is what "done" means; `loaf issue stop` does not change status: + ```bash + loaf issue status <ref> done + ``` + Write statuses (`triage`, `backlog`, `todo`, `active`, `done`) update in place. Do not use `cancelled` or `duplicate` here. +3. Stop the started worktree if one exists. `loaf issue stop` removes the worktree, clears `started_branch` / `started_worktree` on the row, and **keeps the branch**: + ```bash + loaf issue stop <ref> + ``` + If the issue was never started, the command errors with `issue <ref> is not started` — treat that as already clean and continue. If the worktree is dirty, do not pass `--force` without user confirmation. +4. Delete the local feature branch when safe: ```bash git branch -d <headRefName> ``` -3. Confirm the remote branch deletion state from GitHub output or run: +5. Confirm the remote branch deletion state from GitHub output or run: ```bash gh pr view <N> --json headRefName,state ``` -4. Log the landing to the project journal: +6. Log the landing: ```bash - loaf journal log "decision(ship): PR #N landed via squash merge" + loaf journal log "decision(ship): PR #N landed via squash merge; <ref> done" ``` -If cleanup fails, report the exact residual state. Do not force-delete without user confirmation. +If cleanup fails, report the exact residual state (issue status, whether the worktree is still started, which branches remain). Do not force-delete without user confirmation. ### Stacked child PRs -Every child recorded in Step 1b needs three repairs before it can ship, and none of them happen on their own. +Every child PR recorded in Step 1b needs three repairs before it can ship, and none of them happen on their own. **Retarget the base.** GitHub does not reliably move a child's base when its base branch merges. Check, and move it explicitly: @@ -246,7 +310,7 @@ git diff --stat <baseRefName>...HEAD | tail -1 gh pr view <child> --json changedFiles -q .changedFiles ``` -Then force-push with a lease, wait for CI to finish on the **rebased head** rather than trusting the previous run, and only then ship the child. Delete the parent's head branch once no child points at it. +Then force-push with a lease, wait for CI to finish on the **rebased head** rather than trusting the previous run, and only then ship the child. Delete the parent's head branch once no child PR points at it. Report the numbers rather than asserting success: the before and after file counts, the commits the rebase skipped, and the CI conclusion on the new head. @@ -254,11 +318,11 @@ Report the numbers rather than asserting success: the before and after file coun ## Step 6: Release Suggestion -After landing, decide whether to suggest release: +After landing, decide whether to suggest the release skill. That cut will not re-run this review. -- Suggest release when the landed PR completes a coherent batch, user-facing feature, fix train, or release branch. +- Suggest release when the landed PR completes a coherent batch, user-facing feature, or fix train. - Do not suggest release for every small PR by default. -- If multiple related PRs are expected, say the PR is landed and can wait for a later batched release. +- If more related PRs are expected, say this PR is landed and can wait for a later cut. Use language carefully: the PR is **landed** or **shipped**; it is not **released** until release publishes a version. @@ -282,12 +346,12 @@ Do not disable hooks to force a PR through. ## Suggests Next -After a successful ship, suggest release only when the landed work forms a coherent release batch or the user asks to publish. +After a successful ship, suggest release only when the landed work forms a coherent batch or the user asks to publish. Release will not re-check the landed PR. ## Related Skills -- **release** -- Publishes a version from already-landed work -- **git-workflow** -- Branching, PR, commit, and squash merge conventions -- **foundations** -- Verification, code review, and production readiness -- **documentation-standards** -- Changelog, docs, and durable prose quality -- **reflect** -- Updates strategy from significant shipped work +- **release** — Publishes a version from already-landed work; does not re-review those PRs +- **git-workflow** — Branching, PR, commit, and squash merge conventions +- **foundations** — Verification, code review, and production readiness +- **documentation-standards** — Changelog, docs, and durable prose quality +- **reflect** — Updates strategy from significant shipped work diff --git a/dist/skills/triage/SKILL.md b/dist/skills/triage/SKILL.md index 7ef69a0c2..d3f24d92b 100644 --- a/dist/skills/triage/SKILL.md +++ b/dist/skills/triage/SKILL.md @@ -2,13 +2,13 @@ name: triage description: >- Processes the local intake queue from loaf intake list: unresolved sparks, - ideas, brainstorms, tracked and deferred Intents, and unmigrated legacy - deferrals. Use when the user asks "triage", "process my backlog", or wants - dispositions chosen across intake items. Produces explicit dispositions: - discard, retain, track as Intent, defer, resume, resolve, explore, hand to - pitch, or hand to shape. Not for reading a single known item (use loaf intent - show or journal directly), capturing new ideas (use idea), problem discovery - (use pitch), or bounding one chosen direction (use shape). + ideas, and brainstorms. Use when the user asks "triage", "process my backlog", + or wants dispositions chosen across intake items. Produces explicit + dispositions: discard, retain as spark/idea, file as backlog issue, resume + exploration, resolve, hand to pitch, or hand to shape (issue preparation). Not + for reading a single known item (use loaf issue show, loaf spark show, loaf + idea show, or journal directly), capturing new ideas (use idea), problem + discovery (use pitch), or bounding one chosen direction (use shape). --- # Triage @@ -25,7 +25,7 @@ Process the intake queue. Triage is the public funnel where captured material me - Quick Reference - Process - Dispositions -- Legacy Deferrals +- Leftover kinds - Guardrails - Related Skills @@ -35,62 +35,67 @@ Process the intake queue. Triage is the public funnel where captured material me - Read the queue with `loaf intake list --json`; it projects every unresolved logical item exactly once with its provenance and exact read command. - Present everything before acting — the user decides each disposition; never auto-promote, auto-discard, or auto-convert. - The CLI never classifies: you and the user interpret each item; commands perform the chosen operation deterministically. -- Capture, Intent, and Exploration are different claims: a spark or idea is retained material, a tracked Intent is deliberately tracked work, a deferral is an Intent disposition with an immutable payload, an Exploration is an inquiry. Do not conflate them to save a step. +- Capture, issue, and Exploration are different claims: a spark or idea is retained material, a backlog issue is deliberately tracked work, an Exploration is an inquiry. Do not conflate them to save a step. - One pass through the queue — don't loop or re-present items. -- **Two doors into a Change:** items needing problem discovery hand to pitch, which owns `loaf change init <slug> --brief` and brief authoring; well-understood directions hand to shape. When capture should precede shaping without a full pitch, run `loaf change init <slug> --brief` and seed `brief.md` with the original ask, then hand to shape. +- **Two doors into issue work:** items needing problem discovery hand to pitch; well-understood directions hand to shape (issue preparation). Worth keeping but not ready for either door files as a backlog issue (`loaf issue new "<title>" --status backlog`, optional `--parent`, optional `loaf issue bucket`). Triage never runs `loaf issue start`, never opens PRs, and never invents Git artifacts. ## Verification - Every presented item has a recorded disposition or an explicit "leave for next triage". -- Tracked and deferred choices exist as Intents with the expected derived disposition (`loaf intent list`). +- Filed directions exist as backlog issues (`loaf issue list --status backlog`) and no longer appear in `loaf intake list` once their captures are resolved or archived. - Discards are resolved or archived through their own commands and no longer appear in `loaf intake list`. -- No Linear or tracker operation was attempted; publication is a later concern outside this Change. +- No Linear or tracker operation was attempted; publication is a later concern outside triage. ## Quick Reference | Item kind | Comes from | Typical dispositions | |-----------|-----------|----------------------| -| spark | `loaf spark capture` moments | discard, promote to idea, track as Intent | -| idea | idea capture | archive, explore, track as Intent, hand to pitch, hand to shape | -| brainstorm | archived divergent sessions | archive, explore, promote, hand to pitch | -| intent (tracked) | `loaf intent create` | keep tracking, defer, resolve, explore, hand to pitch, hand to shape | -| intent (deferred) | `loaf intent defer` or adapter | resume, resolve, leave deferred | -| legacy_deferral | pre-conversion `journal defer` | read, then optionally convert (see Legacy Deferrals) | +| spark | `loaf spark capture --scope <scope> --text <text>` | discard, retain, promote to idea, file as backlog issue, resume exploration, resolve, hand to pitch, hand to shape | +| idea | `loaf idea capture --title "<title>"` | archive, retain, file as backlog issue, resume exploration, resolve, hand to pitch, hand to shape | +| brainstorm | `loaf brainstorm capture` | archive, retain, promote to idea, file as backlog issue, resume exploration, hand to pitch, hand to shape | ## Process 1. **Scan.** Run `loaf intake list --json`. Summarize counts by kind, then list each item with its title, disposition or status, and read command. 2. **Read on demand.** Use each item's `read_command` verbatim when the user wants detail before deciding. If a read command fails, record the exact command and error in the summary as `unreadable`, make no semantic disposition for that item, continue the pass, and offer a factual diagnostic step (`loaf state doctor --json`) afterward. Never persist unreadable as a status. 3. **Decide per item.** Present the applicable dispositions and perform exactly the chosen one. -4. **Summarize.** Report what was discarded, retained, tracked, deferred, resumed, resolved, or handed onward, and journal notable decisions. +4. **Summarize.** Report what was discarded, retained, filed as backlog issues, resumed as explorations, resolved, or handed to pitch or shape, and journal notable decisions. ## Dispositions - **Discard** — ideas and brainstorms: `loaf idea archive <ref> --reason <r>` or `loaf brainstorm archive <ref> --reason <r>`. A spark is resolved against the entity that addressed it (`loaf spark resolve <ref> --by <entity> --reason <r>`); a pure dead-end spark currently has no deterministic discard operation — leave it retained, journal the judgment, and never invent a resolving entity. -- **Retain as capture** — do nothing; open captures resurface next triage. -- **Track as Intent** — two steps: create the Intent with the capture as its source, then close the capture against it so the direction appears once. `loaf intent create --title <t> --body <self-sufficient body> --from <capture-ref>`, then `loaf spark resolve <capture-ref> --by <intent-ref>` or `loaf idea resolve <capture-ref> --by <intent-ref>` (brainstorms: `loaf brainstorm archive <ref> --reason "tracked as <intent-ref>"`). -- **Defer** — an existing Intent: `loaf intent defer <ref> --why <w> --boundary <b> --trigger <t> --operation-id <key>`; a new deferred direction needs the full skeleton: `loaf intent create --title <t> --body <b> --disposition deferred --why <w> --boundary <bd> --trigger <tr> --operation-id <key> [--from <source-ref>]`. -- **Resume** — `loaf intent resume <ref> --reason <why now>`; appends a tracked disposition linked to the deferral it supersedes. -- **Resolve** — `loaf intent resolve <ref> --reason <outcome>`; history is never rewritten. -- **Explore** — agent technique for genuinely undecided directions (Explorations and checkpoints); not a user slash entry — prefer pitch when the human needs problem discovery first, then reach for explore from inside that work if still undecided. -- **Pitch** — items needing problem discovery hand to pitch, which owns init and brief authoring; resolve the promoted item against the created change (`loaf spark resolve` / `loaf idea resolve` / archive brainstorm with the change as the reason). -- **Shape** — when a direction is already well-understood and ready for bounded delivery, hand it to shape; triage never creates fully-materialized Changes, branches, or worktrees (capture-only brief seeding is the Critical Rules exception above). +- **Retain as spark/idea** — do nothing to leave the capture open, or promote into the other capture primitive: capture the idea first (`loaf idea capture --title "..."`), then `loaf spark promote <spark> --to-idea <idea>` or `loaf brainstorm promote <brainstorm> --to-idea <idea>`. Open captures resurface next triage. +- **File as backlog issue** — two steps so the direction appears once. Create the issue, then close the capture against it: -## Legacy Deferrals + ```bash + loaf issue new "<title>" --status backlog [--parent <ref>] [--kind delivery|decision] [--fog <text>] [--body <text>] + loaf issue bucket <issue-ref> now|next|later # optional; labels only, never a constraint + loaf spark resolve <capture-ref> --by <issue-ref> + # or: loaf idea resolve <capture-ref> --by <issue-ref> + # brainstorms: loaf brainstorm archive <ref> --reason "filed as <issue-ref>" + ``` -Items of kind `legacy_deferral` are pre-conversion `journal defer` records. They stay visible and readable until the explicit, backup-first conversion is run; nothing disappears while migration is pending. When the user wants them converged, offer `loaf state migrate deferrals --dry-run` to preview the project-specific manifest and `--apply` only with explicit consent — apply verifies a whole-database backup first and preserves every legacy row. + Use `--kind decision` when filing a sharp question. Copy still-unsharp questions into `--fog` (create-time only). `--parent` nests under an existing issue; omit it for a different problem. +- **Resume exploration** — agent technique for genuinely undecided directions (Explorations and checkpoints); not a user slash entry. Prefer pitch when the human needs problem discovery first, then reach for explore from inside that work if still undecided. Resume with `loaf exploration context <ref>` when a named Exploration already exists. +- **Resolve** — the capture is already represented elsewhere. `loaf spark resolve <ref> --by <entity> --reason <r>` or `loaf idea resolve <ref> --by <entity>`. History is never rewritten. +- **Hand to pitch** — items needing problem discovery hand to pitch. Resolve the capture against the issue once one exists (`loaf spark resolve` / `loaf idea resolve --by <issue-ref>` / archive the brainstorm with that issue as the reason). +- **Hand to shape** — when a direction is already well-understood and ready for bounded delivery, hand it to shape for issue preparation. Triage never writes definition-of-done criteria, never runs `loaf issue check`, and never creates branches or worktrees. + +## Leftover kinds + +`loaf intake list` may still include `intent` and `legacy_deferral` items. Do not create new `intent` rows. Treat leftover directions like any other capture: file a backlog issue if worth keeping, or leave them for a later pass. Do not offer conversion commands that recreate the old tracked/deferred row. ## Guardrails 1. **User decides every disposition** — present, don't decide. 2. **Batch presentation, individual decisions** — show the full queue, then process one item at a time. 3. **Log everything** — no silent discards, promotions, or conversions. -4. **Deferred is not forgotten** — deferred Intents remain active truth in `loaf journal context` until resumed or resolved. +4. **Filed is not forgotten** — backlog issues remain on `loaf issue list` and may appear on `loaf issue frontier` until their status changes. Buckets are labels only. ## Related Skills - **idea** — capture a new idea (fast, minimal friction) - **pitch** — problem-discovery ceremony for items that need framing before shape - **explore** — agent technique for divergent inquiry with portable checkpoints -- **shape** — develop a well-understood direction into a bounded Change +- **shape** — develop a well-understood direction into a bounded issue - **housekeeping** — flags stale artifacts; does not choose dispositions diff --git a/dist/skills/wrap/SKILL.md b/dist/skills/wrap/SKILL.md index 1bf6a7533..f04160956 100644 --- a/dist/skills/wrap/SKILL.md +++ b/dist/skills/wrap/SKILL.md @@ -135,7 +135,7 @@ After the wrap-up report, suggest housekeeping if it wasn't run this session and ## Report Format -Use backtick formatting for code identifiers, file paths, spec/task IDs, version numbers, status values, and CLI commands. Use uppercase for spec and task IDs (`SPEC-029`, not `spec-029`). +Use backtick formatting for code identifiers, file paths, issue IDs, version numbers, status values, and CLI commands. Use uppercase for issue IDs (`LOAF-29`, not `loaf-29`). ```markdown ## Session Wrap-Up diff --git a/docs/decisions/ADR-011-linear-native-mode.md b/docs/decisions/ADR-011-linear-native-mode.md index 90e86e3cd..cfe27d7f3 100644 --- a/docs/decisions/ADR-011-linear-native-mode.md +++ b/docs/decisions/ADR-011-linear-native-mode.md @@ -1,7 +1,8 @@ --- id: ADR-011 title: Linear-Native Mode — Deliberation vs Execution Split -status: Accepted +status: Superseded +superseded_by: Linear identity adapter (loaf issue mint/pull/push/reconcile) date: 2026-04-22 --- @@ -65,6 +66,10 @@ ADR-010 established the consolidation pattern at the overlay-file layer (`.agent - SPEC-023 (backend abstraction) narrows: most of the work becomes extracting Linear-specific calls into a `tracker` CLI subcommand and adding a second backend (GitHub Issues would be next), not rewriting skills. - No migration shim. Projects that toggle Linear on mid-flight keep existing local tasks; only new breakdowns go Linear-native. Migration is user-initiated. +## Revision + +2026-08-15 — Superseded by the Linear identity adapter (`loaf issue` mint, pull, push, reconcile). Issue identity is now `issue_identity.authority = linear` with the tracker owning identity, title, status, and assignment; Loaf owns shaping state (body, DoD criteria, claims, worktree). The `integrations.linear.enabled` skill-mode split remains historical inventory, not the execution contract. + ## Shipped - PR #34 merged 2026-04-22 as `v2.0.0-dev.29`. diff --git a/docs/decisions/ADR-016-artifact-storage-trichotomy.md b/docs/decisions/ADR-016-artifact-storage-trichotomy.md index f386a0f47..19eb5d0f7 100644 --- a/docs/decisions/ADR-016-artifact-storage-trichotomy.md +++ b/docs/decisions/ADR-016-artifact-storage-trichotomy.md @@ -6,10 +6,13 @@ date: 2026-06-24 supersedes: null superseded_by: null amended_by: ADR-019 +revised: 2026-08-15 --- # ADR-016: Artifact Storage Trichotomy — Nouns in SQLite, Verbs in Git, Markdown is a Render +2026-08-15: revisited under the issue model. + ## Context SPEC-040 made one global SQLite database the canonical store for operational **metadata**, with diff --git a/docs/decisions/ADR-022-change-anatomy-and-release-cohorts.md b/docs/decisions/ADR-022-change-anatomy-and-release-cohorts.md index 69486934b..6c4829094 100644 --- a/docs/decisions/ADR-022-change-anatomy-and-release-cohorts.md +++ b/docs/decisions/ADR-022-change-anatomy-and-release-cohorts.md @@ -1,8 +1,9 @@ --- id: ADR-022 title: "Change anatomy — role-named narrative, task-file state, and release cohorts via target_release" -status: Accepted +status: Deprecated date: 2026-07-28 +deprecated_date: 2026-08-15 supersedes: null superseded_by: null amended_by: @@ -11,6 +12,8 @@ amended_by: # ADR-022: Change anatomy and release cohorts +2026-08-15: retired with the release gate — releases are retroactive; verification authority is ship. + ## Context The bounded-work unit was one `docs/changes/YYYYMMDD-slug/change.md` serving three lives at once — the pitch, the plan, and the machine surface (frontmatter carrying `lineage`, `predecessor`, `release-after`). Four failures followed, one of them proven mechanically: the release gate's satisfaction test was node presence in HEAD plus structural executability, which is precisely what shaping produces, so on 2026-07-25 an isolated worktree took `loaf release --dry-run --bump minor` from blocked to a complete stable plan by merging a single shaping commit with zero lines of the promised work implemented. Shaping edits and execution edits were indistinguishable in history; the document conflated roles the agentic corpus keeps separate (spec, plan, brief); and nothing supported tracker parity or stable task reference, because the retired `SPEC-XXX` counter could not return — a global sequence needs a mint authority, and a branch-native, offline-capable, multi-worktree model deliberately has none. diff --git a/docs/decisions/ADR-023-execution-provenance-and-cohort-receipts.md b/docs/decisions/ADR-023-execution-provenance-and-cohort-receipts.md index 49b1bae62..9b69f1fce 100644 --- a/docs/decisions/ADR-023-execution-provenance-and-cohort-receipts.md +++ b/docs/decisions/ADR-023-execution-provenance-and-cohort-receipts.md @@ -1,14 +1,17 @@ --- id: ADR-023 title: "Execution provenance and cohort receipts — git as the witness, verify as the only runner" -status: Accepted +status: Deprecated date: 2026-07-28 +deprecated_date: 2026-08-15 supersedes: null superseded_by: ADR-024 # freshness + receipt schema claims annotated below; see ADR-024 --- # ADR-023: Execution provenance and cohort receipts +2026-08-15: retired with the release gate — releases are retroactive; verification authority is ship. + ## Context ADR-022's release cohorts need an evidence model: what proves a change was executed and verified, readable on any machine that can clone the repo. SQLite facts were rejected because the gate must hold on CI and other machines; slug-grep heuristics were rejected as trivially satisfiable. The receipt design superseded itself four times before settling — a `plan.md` binding (expired on approach churn, wrong surface), a criteria-digest-only form (would have survived a revert of the verified work), a legacy-prose digest patch (digesting prose does not make it executable), and shaped auto-re-run-at-preflight semantics, superseded at implementation review round 2 when the shipped block-with-reason behavior proved the better design. Two implementation incidents shaped the final rules: a receipt written by a failing `loaf change verify` satisfied the gate end-to-end (a fixture cut stable 1.0.0 against `exit_code: 1, ok: false`), and the committed binary went three code commits stale while the suite stayed green because tests compile source and the capability receipts pin the committed binary's hash. diff --git a/docs/decisions/ADR-024-receipt-content-digest-freshness.md b/docs/decisions/ADR-024-receipt-content-digest-freshness.md index 70dbabe43..549d6ec13 100644 --- a/docs/decisions/ADR-024-receipt-content-digest-freshness.md +++ b/docs/decisions/ADR-024-receipt-content-digest-freshness.md @@ -1,8 +1,9 @@ --- id: ADR-024 title: "Receipt validity binds to a masked root-tree content digest" -status: Accepted +status: Deprecated date: 2026-07-29 +deprecated_date: 2026-08-15 supersedes: null superseded_by: null related: @@ -11,6 +12,8 @@ related: # ADR-024: Receipt validity binds to a masked root-tree content digest +2026-08-15: retired with the release gate — releases are retroactive; verification authority is ship. + ## Context ADR-023's receipt freshness walked `verified_commit..HEAD` and exempted only the change's own receipt path. Merge strategies that destroy commit identity (squash, rebase) make that walk machine-dependent: the author's object store may still hold the commit while a protocol clone exits 128 into `cannot inspect receipt`. The walk also made N≥2 cohorts unsatisfiable — member B's receipt stales member A — and left touch-then-revert as a load-bearing property that squash merges already erase on main. diff --git a/docs/decisions/ADR-026-major-zero-versioning.md b/docs/decisions/ADR-026-major-zero-versioning.md index 3be745185..84a0fdebf 100644 --- a/docs/decisions/ADR-026-major-zero-versioning.md +++ b/docs/decisions/ADR-026-major-zero-versioning.md @@ -3,7 +3,7 @@ id: ADR-026 title: "Major-zero versioning — arc-boundary releases, liberal X epochs, and timestamp dev identity" status: Accepted date: 2026-08-06 -revised: 2026-08-10 +revised: 2026-08-15 supersedes: null superseded_by: null related: @@ -14,6 +14,8 @@ related: # ADR-026: Major-zero versioning +2026-08-15: bump semantics restate as `loaf release suggest` evidence. + ## Context Loaf spent two months on a `2.0.0-alpha.N` treadmill — nineteen alphas, each implying 2.0 was imminent while the work was foundational churn. The number made a promise the software was not close to keeping, and it distorted release behaviour: cutting a release read as a statement about 2.0 rather than a routine act of shipping fixes, so releases stopped happening and known field bugs sat unfixed on the daily-driver machine. No ADR ever blessed the alpha line — it arrived in a chore commit — so this record has no predecessor. diff --git a/docs/decisions/ADR-027-content-bound-release-evidence.md b/docs/decisions/ADR-027-content-bound-release-evidence.md index 79dcfafcf..eae81ce5e 100644 --- a/docs/decisions/ADR-027-content-bound-release-evidence.md +++ b/docs/decisions/ADR-027-content-bound-release-evidence.md @@ -1,8 +1,9 @@ --- id: ADR-027 title: "Content-bound release evidence — receipts vouch where history shape cannot" -status: Accepted +status: Deprecated date: 2026-08-07 +deprecated_date: 2026-08-15 supersedes: null superseded_by: null related: @@ -14,6 +15,8 @@ related: # ADR-027: Content-bound release evidence +2026-08-15: retired with the release gate — releases are retroactive; verification authority is ship. + ## Context Cutting `0.2.20` — the first *stable* candidate ever evaluated by the release cohort gate — surfaced a latent contradiction between two evidence systems that had never been tested together. Verify receipts bind content: they digest the scope tree they verified (ADR-024), which is why they survived the squash merge that landed the versioning reset. Execution provenance bound history shape: `changeFolderExecuted` scanned `git log HEAD -- <folder>` for a commit whose diff flips `- [ ]` → `- [x]` while touching code outside `docs/changes/` (ADR-023). The squash merge preserved every byte of the tree and rewrote every commit of the history — the receipt sailed through, the flip evidence ceased to exist, and a fully implemented, receipt-verified Change was refused as `targets 0.2.20 but is not executed`. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 83b13c39c..307f3e61b 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -25,7 +25,7 @@ Immutable records of significant architectural decisions. | [ADR-019](ADR-019-journal-first-session-model.md) | Journal-first: the project journal replaces the session entity | Accepted | 2026-07-04 | | [ADR-020](ADR-020-root-agents-md-canonical.md) | Root AGENTS.md as the Canonical Project Instruction File | Accepted | 2026-07-15 | | [ADR-021](ADR-021-markdown-reimport-authority.md) | Markdown re-import authority — fingerprint reclaim, insert-only status, snapshot simulation | Accepted | 2026-07-24 | -| [ADR-022](ADR-022-change-anatomy-and-release-cohorts.md) | Change anatomy — role-named narrative, task-file state, and release cohorts via target_release | Accepted | 2026-07-28 | -| [ADR-023](ADR-023-execution-provenance-and-cohort-receipts.md) | Execution provenance and cohort receipts — git as the witness, verify as the only runner | Accepted | 2026-07-28 | +| [ADR-022](ADR-022-change-anatomy-and-release-cohorts.md) | Change anatomy — role-named narrative, task-file state, and release cohorts via target_release | Deprecated | 2026-07-28 | +| [ADR-023](ADR-023-execution-provenance-and-cohort-receipts.md) | Execution provenance and cohort receipts — git as the witness, verify as the only runner | Deprecated | 2026-07-28 | See [../knowledge/](../knowledge/) for domain knowledge files. diff --git a/docs/reports/2026-06-10-native-go-cutover-test-map.md b/docs/reports/2026-06-10-native-go-cutover-test-map.md index 021417fbc..3670b675b 100644 --- a/docs/reports/2026-06-10-native-go-cutover-test-map.md +++ b/docs/reports/2026-06-10-native-go-cutover-test-map.md @@ -46,6 +46,7 @@ This audit is generated from the Go-native dispatch in `internal/cli/cli.go`, wi | `idea` | Native Go | Go dispatcher only | SQLite-only knowledge-object lifecycle. | | `intake` | Native Go | Go dispatcher only | Deterministic local intake projection over unresolved sparks, ideas, brainstorms, intents, and unmigrated legacy deferrals; read-only with exact follow-up commands. | | `intent` | Native Go | Go dispatcher only | SQLite-backed tracked Intent: immutable snapshots, append-only dispositions derived from per-intent sequences, immutable deferral payloads, and the shared `intent_operations` retry mapping used by the `journal defer` compatibility adapter. | +| `issue` | Native Go | Go dispatcher only | SQLite-backed issue lifecycle: create, show, list, tree, frontier, edit, status, definition-of-done, promote, bucket, link, render, and export. | | `init` | Native Go | Go dispatcher only | Project scaffolding is native, including `--no-symlinks`, help, project detection, idempotency, and existing-file preservation. The obsolete TypeScript init command source has been removed; CLI reference generation now uses native Go reference metadata for this command. | | `install` | Native Go | Go dispatcher only | Target installs, upgrade-only installs, interactive target selection, project-file enforcement, PATH-binary self-install, and MCP recommendation/prerequisite config writes are native. The obsolete TypeScript command source has been removed; CLI reference generation now uses native Go reference metadata. | | `kb` | Native Go | Go dispatcher only | KB subcommands are native: `status`, `validate`, `check`, `review`, `init`, `import`, and `glossary`; top-level help plus unknown-subcommand handling are native. The obsolete TypeScript KB command source and helper library have been removed; CLI reference generation now uses native Go reference metadata. | diff --git a/docs/schema/0014_issues_and_identity.sql b/docs/schema/0014_issues_and_identity.sql new file mode 100644 index 000000000..c3b0fb468 --- /dev/null +++ b/docs/schema/0014_issues_and_identity.sql @@ -0,0 +1,79 @@ +-- Issue schema and identity foundation. +-- +-- Issues are the recursive work entity. This migration is additive: the +-- existing tasks and specs tables are left untouched and go inert. There is +-- no data migration and no compatibility shim. +-- +-- Status on issues.status is a projection of the append-only events table +-- (entity_kind 'issue'). Writes go through events; a parity check proves the +-- column equals the latest event. Default status at creation is triage. +-- There is no blocked status (that is a relationship) and no review status +-- (a display name for active). +-- +-- Title and body are fully mutable at every status. Body is a plain TEXT +-- column; the sources/body_source_id indirection is not reused. Fog holds +-- questions not yet sharp enough to be issues. +-- +-- Human-readable IDs for local authority are minted from +-- issue_identity.next_number and stored in aliases (entity_kind and +-- namespace 'issue'). The counter is a stored value, never derived from +-- MAX() over aliases or issues. Minted numbers are permanent: removing or +-- hard-deleting an issue does not free its number. Tracker authorities +-- (linear, github) mint no local alias. +-- +-- parent_id is a same-project self-reference. Cycle prevention is a +-- write-time guard in the Go API, not a schema trigger. +-- +-- issue_criteria.command / expect use the same grammar loaf change verify +-- parses today (exit N, contains <text>). This migration only stores them. + +CREATE TABLE IF NOT EXISTS issues ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + parent_id TEXT, + kind TEXT NOT NULL CHECK (kind IN ('delivery', 'decision')), + title TEXT NOT NULL CHECK (length(trim(title)) > 0), + body TEXT NOT NULL DEFAULT '', + fog TEXT, + status TEXT NOT NULL CHECK (status IN ('triage', 'backlog', 'todo', 'active', 'done', 'cancelled', 'duplicate')), + archived_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (project_id, parent_id) REFERENCES issues(project_id, id), + UNIQUE (project_id, id) +); +CREATE INDEX IF NOT EXISTS idx_issues_project ON issues (project_id, created_at); +CREATE INDEX IF NOT EXISTS idx_issues_parent ON issues (project_id, parent_id); +CREATE INDEX IF NOT EXISTS idx_issues_status ON issues (project_id, status); + +CREATE TABLE IF NOT EXISTS issue_criteria ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + issue_id TEXT NOT NULL, + position INTEGER NOT NULL CHECK (position >= 1), + text TEXT NOT NULL CHECK (length(trim(text)) > 0), + command TEXT, + expect TEXT, + tier TEXT NOT NULL CHECK (tier IN ('V', 'H')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (project_id, issue_id) REFERENCES issues(project_id, id) ON DELETE CASCADE, + UNIQUE (issue_id, position) +); +CREATE INDEX IF NOT EXISTS idx_issue_criteria_issue ON issue_criteria (project_id, issue_id, position); + +-- One authority row per project. next_number is the next local alias to mint +-- and is never recomputed from existing rows. +CREATE TABLE IF NOT EXISTS issue_identity ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + authority TEXT NOT NULL CHECK (authority IN ('local', 'linear', 'github')), + prefix TEXT NOT NULL CHECK (prefix GLOB '[A-Za-z]*' AND prefix NOT GLOB '*[^A-Za-z0-9]*' AND length(prefix) = length(CAST(prefix AS BLOB))), + next_number INTEGER NOT NULL CHECK (next_number >= 1), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + UNIQUE (project_id) +); diff --git a/docs/schema/0015_issue_criterion_claims.sql b/docs/schema/0015_issue_criterion_claims.sql new file mode 100644 index 000000000..2c7e5dedf --- /dev/null +++ b/docs/schema/0015_issue_criterion_claims.sql @@ -0,0 +1,33 @@ +-- Criterion-grain claims for derived issue readiness. +-- +-- 0014 is the issue model. This migration adds the one child table that +-- makes decomposition honesty mechanical: a child criterion claims a parent +-- criterion by opaque id, never by position. Positions renumber; claims +-- must not. +-- +-- Coverage (every parent criterion is claimed) and containment (every child +-- criterion claims some parent criterion) are derived at read time from +-- these rows. Promote writes a claim by construction. There is no data +-- backfill: existing issues have no claims until an operator records them. +-- +-- Claim FKs are project-scoped: a row cannot satisfy coverage by pointing +-- at a criterion that belongs to another project. The unique index on +-- issue_criteria (project_id, id) is the parent key those FKs require. + +CREATE UNIQUE INDEX IF NOT EXISTS idx_issue_criteria_project_id ON issue_criteria (project_id, id); + +CREATE TABLE IF NOT EXISTS issue_criterion_claims ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + child_criterion_id TEXT NOT NULL, + parent_criterion_id TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (project_id, child_criterion_id) REFERENCES issue_criteria(project_id, id) ON DELETE CASCADE, + FOREIGN KEY (project_id, parent_criterion_id) REFERENCES issue_criteria(project_id, id) ON DELETE CASCADE, + UNIQUE (child_criterion_id, parent_criterion_id), + CHECK (child_criterion_id != parent_criterion_id) +); +CREATE INDEX IF NOT EXISTS idx_issue_criterion_claims_parent ON issue_criterion_claims (project_id, parent_criterion_id); +CREATE INDEX IF NOT EXISTS idx_issue_criterion_claims_child ON issue_criterion_claims (project_id, child_criterion_id); diff --git a/docs/schema/0016_releases.sql b/docs/schema/0016_releases.sql new file mode 100644 index 000000000..ae18e7507 --- /dev/null +++ b/docs/schema/0016_releases.sql @@ -0,0 +1,41 @@ +-- Retroactive releases: facts about what landed, never a plan. +-- +-- A release is recorded after a tag exists. Members are the attributed +-- issues observed in the baseline..HEAD range, plus optional prerelease +-- references when a stable is cut with --includes. There is no +-- target_release, no cohort, and no forward binding of issues to versions. +-- +-- member_kind 'release' is a narrative reference, not a union: cutting a +-- stable does not re-include the prerelease's issue members. member_id is +-- polymorphic (issue or release) and cannot carry a hard FK; kind and +-- existence are validated on the write path. The composite FK on +-- (project_id, release_id) keeps membership inside the same project. + +CREATE TABLE IF NOT EXISTS releases ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + version TEXT NOT NULL, + tag TEXT NOT NULL, + tagged_commit TEXT NOT NULL, + notes TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + UNIQUE (project_id, version), + UNIQUE (project_id, tag) +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_releases_project_id ON releases (project_id, id); + +CREATE TABLE IF NOT EXISTS release_members ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + release_id TEXT NOT NULL, + member_kind TEXT NOT NULL, + member_id TEXT NOT NULL, + recorded_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (project_id, release_id) REFERENCES releases(project_id, id) ON DELETE CASCADE, + UNIQUE (release_id, member_kind, member_id), + CHECK (member_kind IN ('issue', 'release')) +); +CREATE INDEX IF NOT EXISTS idx_release_members_release ON release_members (project_id, release_id); diff --git a/docs/schema/0017_issue_started_workspace.sql b/docs/schema/0017_issue_started_workspace.sql new file mode 100644 index 000000000..5d5092e27 --- /dev/null +++ b/docs/schema/0017_issue_started_workspace.sql @@ -0,0 +1,17 @@ +-- Started workspace: branch and worktree recorded on the issue row. +-- +-- Worktrees were observed (journal, project identity, storage migration) +-- but never managed. This migration adds the two columns that bind an +-- issue to the workspace `loaf issue start` creates. started_branch and +-- started_worktree are nullable: an unstarted issue has neither. They are +-- written together when start records the workspace, and cleared together +-- when stop tears it down. +-- +-- Status remains the events projection; these columns are workspace facts, +-- not a status. Stopping does not change status. There is no data +-- backfill: existing issues stay unstarted (NULL). +-- +-- No new tables. ALTER TABLE ADD COLUMN is SQLite-safe. + +ALTER TABLE issues ADD COLUMN started_branch TEXT; +ALTER TABLE issues ADD COLUMN started_worktree TEXT; diff --git a/go.mod b/go.mod index 2523542e9..f158f6fc9 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/levifig/loaf go 1.25.0 -toolchain go1.26.5 +toolchain go1.26.6 require github.com/ncruces/go-sqlite3 v0.34.3 diff --git a/internal/cli/agent_help.go b/internal/cli/agent_help.go index 7063e21fe..62e7a5a61 100644 --- a/internal/cli/agent_help.go +++ b/internal/cli/agent_help.go @@ -145,15 +145,6 @@ func agentHelpCommands() []agentHelpCommand { {Name: "index", Description: "Index docs/ Markdown into SQLite FTS", Options: []agentHelpOption{{Flags: "--rebuild", Description: "Rebuild current worktree docs index before scanning"}, {Flags: "--json", Description: "Output indexed docs, counts, global database scope, and project identity as JSON"}}}, }, }, - { - Name: "change", - Description: "Manage shape-first Change artifacts under docs/changes/", - Subcommands: []agentHelpSubcommand{ - {Name: "init", Description: "Scaffold a new Change folder, or promote a capture-only folder in place; fully-materialized folders still reject as duplicates", Options: []agentHelpOption{{Flags: "<slug>", Description: "Change slug: lowercase letters, digits, and single hyphens"}, {Flags: "--brief", Description: "Capture mode: change.json + brief.md only; refuses when the slug already exists"}}}, - {Name: "check", Description: "Validate a Change and report derived executability", Options: []agentHelpOption{{Flags: "[folder]", Description: "Change folder path; an explicit path wins, otherwise resolves from the current branch"}, {Flags: "--require-executable", Description: "Exit non-zero unless the Change is structurally executable; this does not prove implementation completion"}, {Flags: "--json", Description: "Output folder, passed, state, executable, findings, warnings, and gaps as JSON"}}}, - {Name: "list", Description: "List a retained lineage after merge or branch deletion", Options: []agentHelpOption{{Flags: "--lineage <key>", Description: "Required lineage key"}, {Flags: "--json", Description: "Output derived nodes, gaps, and optional journal enrichment"}}}, - }, - }, { Name: "migrate", Description: "Run migration workflows", @@ -174,7 +165,7 @@ func agentHelpCommands() []agentHelpCommand { {Name: "recent", Description: "Show the recent project journal timeline", Options: []agentHelpOption{{Flags: "--branch <branch>", Description: "Restrict to entries observed on one branch"}, {Flags: "--since-last-wrap", Description: "Trim to entries logged after the most recent wrap"}, {Flags: "--limit <n>", Description: "Maximum entries to return"}, {Flags: "--json", Description: "Output the timeline and project identity as JSON"}}}, {Name: "search", Description: "Full-text search journal entries", Options: []agentHelpOption{{Flags: "--all", Description: "Search across all projects"}, {Flags: "--limit <n>", Description: "Maximum hits to return"}, {Flags: "--json", Description: "Output hits and project identity as JSON"}}}, {Name: "show", Description: "Show one journal entry by id", Options: []agentHelpOption{{Flags: "--json", Description: "Output the entry and project identity as JSON"}}}, - {Name: "defer", Description: "Capture a self-sufficient deferred intent as a decision and open spark pair; stable operation IDs make first writes idempotent and reworded retries visible", Options: []agentHelpOption{{Flags: "--why <text>", Description: "Why this intent was deferred"}, {Flags: "--boundary <text>", Description: "What remains outside this packet"}, {Flags: "--trigger <text>", Description: "What should cause revisit"}, {Flags: "--operation-id <id>", Description: "Stable retry/idempotency key"}, {Flags: "--change <slug|path>", Description: "Optional retained Change local evidence"}, {Flags: "--json", Description: "Output the state result as JSON"}}}, + {Name: "defer", Description: "Capture a self-sufficient deferred intent as a decision and open spark pair; stable operation IDs make first writes idempotent and reworded retries visible", Options: []agentHelpOption{{Flags: "--why <text>", Description: "Why this intent was deferred"}, {Flags: "--boundary <text>", Description: "What remains outside this packet"}, {Flags: "--trigger <text>", Description: "What should cause revisit"}, {Flags: "--operation-id <id>", Description: "Stable retry/idempotency key"}, {Flags: "--json", Description: "Output the state result as JSON"}}}, {Name: "context", Description: "Emit the contract-v2 active-truth continuity digest", Options: []agentHelpOption{{Flags: "--branch <branch>", Description: "Select branch-recency scope and bind state cursors; active Change provenance remains derived from the actual Git branch"}, {Flags: "--layer <name>", Description: "Select one canonical layer: project-synthesis, scoped-checkpoint, active-lineage, unresolved-blockers, deferred-intent, active-changes, branch-recency, or transitional-tasks"}, {Flags: "--limit <n>", Description: "Maximum 1..100 items for the selected layer; requires --layer"}, {Flags: "--cursor <token>", Description: "Continue the selected layer; requires --layer and is unavailable for intrinsic one-item project-synthesis and scoped-checkpoint"}, {Flags: "--from-hook", Description: "Read the harness hook payload on stdin; exits silently for subagent invocations"}, {Flags: "--cursor-hook", Description: "Read Cursor sessionStart JSON and emit its additional_context envelope"}, {Flags: "--claude-code", Description: "Read Claude Code SessionStart JSON and emit its native hook envelope"}, {Flags: "--codex-hook", Description: "Read Codex SessionStart JSON and emit its native hook envelope"}, {Flags: "--opencode-hook", Description: "Read the OpenCode session lifecycle payload and emit the digest as plain-text system context"}, {Flags: "--json", Description: "Output the contract-v2 continuity digest with project identity, named layers with availability/counts/truncation/expansion, and diagnostics as JSON"}, {Flags: "for-prompt|for-compact|for-resumption", Description: "Hook subcommands: inject implementation principles, journal-flush guidance, or the resumption digest"}}}, {Name: "export", Description: "Export the project journal to markdown or JSONL", Options: []agentHelpOption{{Flags: "--format <format>", Description: "Output format: markdown (default) or jsonl"}}}, }, @@ -194,13 +185,27 @@ func agentHelpCommands() []agentHelpCommand { }, }, { - Name: "spec", - Description: "Manage project specs", + Name: "issue", + Description: "Manage issues in native SQLite state", Subcommands: []agentHelpSubcommand{ - {Name: "list", Description: "Show specs with status and task counts", Options: []agentHelpOption{{Flags: "--json", Description: "Output specs, diagnostics, task counts, global database scope, and project identity as JSON"}}}, - {Name: "show", Description: "Show spec details", Options: []agentHelpOption{{Flags: "--json", Description: "Output spec details, task counts, relationships, global database scope, and project identity as JSON"}}}, - {Name: "edit", Description: "Replace a spec's SQLite body; run spec finalize to update the tracked render", Options: []agentHelpOption{{Flags: "--body-file <path>", Description: "Read Markdown body from a UTF-8 file"}, {Flags: "--body -", Description: "Read Markdown body from stdin"}, {Flags: "--message <text>", Description: "Use inline Markdown body text"}, {Flags: "--force", Description: "Proceed when the legacy source file diverges from the SQLite body"}, {Flags: "--json", Description: "Output the edited spec, imported flag, content hash, event, global database scope, and project identity as JSON"}}}, - {Name: "archive", Description: "Archive a completed spec", Options: []agentHelpOption{{Flags: "--json", Description: "Output archive result, archived specs, global database scope, and project identity as JSON"}}}, + {Name: "new", Description: "Create an issue", Options: []agentHelpOption{{Flags: "--body <text>", Description: "Inline issue body, or '-' to read from stdin"}, {Flags: "--body-file <path>", Description: "Read Markdown body from a UTF-8 file"}, {Flags: "--message <text>", Description: "Use inline Markdown body text"}, {Flags: "--kind <kind>", Description: "Issue kind: delivery or decision"}, {Flags: "--parent <ref>", Description: "Parent issue ref"}, {Flags: "--fog <text>", Description: "Questions not yet sharp enough to be issues"}, {Flags: "--status <status>", Description: "Write status after create: " + strings.Join(state.IssueWriteStatuses(), ", ") + "; still records the initial triage event"}, {Flags: "--json", Description: "Output the created issue, global database scope, and project identity as JSON"}}}, + {Name: "show", Description: "Show one issue", Options: []agentHelpOption{{Flags: "--json", Description: "Output issue details, parent, children, bucket, global database scope, and project identity as JSON"}}}, + {Name: "list", Description: "List project issues", Options: []agentHelpOption{{Flags: "--status <status>", Description: "Filter by status"}, {Flags: "--kind <kind>", Description: "Filter by kind"}, {Flags: "--archived", Description: "Include archived issues"}, {Flags: "--started", Description: "List issues with a recorded started worktree"}, {Flags: "--json", Description: "Output issues, global database scope, and project identity as JSON"}}}, + {Name: "tree", Description: "Print a recursive issue tree", Options: []agentHelpOption{{Flags: "--archived", Description: "Include archived issues"}, {Flags: "--json", Description: "Output the tree, global database scope, and project identity as JSON"}}}, + {Name: "frontier", Description: "List unblocked pick-up-next issues", Options: []agentHelpOption{{Flags: "--json", Description: "Output frontier issues, global database scope, and project identity as JSON"}}}, + {Name: "start", Description: "Create a branch and worktree for an issue", Options: []agentHelpOption{{Flags: "--json", Description: "Output the started issue, branch, worktree, base, global database scope, and project identity as JSON"}}}, + {Name: "stop", Description: "Remove an issue worktree and clear the started workspace", Options: []agentHelpOption{{Flags: "--force", Description: "Remove a dirty worktree"}, {Flags: "--json", Description: "Output the stopped issue, branch, worktree, already-gone flag, global database scope, and project identity as JSON"}}}, + {Name: "edit", Description: "Replace an issue body through the shared body-edit path", Options: []agentHelpOption{{Flags: "--body-file <path>", Description: "Read Markdown body from a UTF-8 file"}, {Flags: "--body -", Description: "Read Markdown body from stdin"}, {Flags: "--message <text>", Description: "Use inline Markdown body text"}, {Flags: "--json", Description: "Output the edited issue, global database scope, and project identity as JSON"}}}, + {Name: "status", Description: "Set an issue status", Options: []agentHelpOption{{Flags: "--duplicate-of <ref>", Description: "Surviving issue required when status is duplicate"}, {Flags: "--json", Description: "Output the updated issue, global database scope, and project identity as JSON"}}}, + {Name: "dod", Description: "Manage definition-of-done criteria"}, + {Name: "promote", Description: "Promote a criterion into a child issue", Options: []agentHelpOption{{Flags: "--json", Description: "Output the new child issue, global database scope, and project identity as JSON"}}}, + {Name: "bucket", Description: "Set an advisory Now/Next/Later label", Options: []agentHelpOption{{Flags: "--json", Description: "Output the issue and bucket, global database scope, and project identity as JSON"}}}, + {Name: "link", Description: "Create or remove an issue relationship", Options: []agentHelpOption{{Flags: "--json", Description: "Output the relationship mutation, global database scope, and project identity as JSON"}}}, + {Name: "render", Description: "Emit a paste-ready PR body", Options: []agentHelpOption{{Flags: "--json", Description: "Output the markdown, issue, global database scope, and project identity as JSON"}}}, + {Name: "export", Description: "Export issues, identity, criteria, claims, and relationships as JSON", Options: []agentHelpOption{{Flags: "--json", Description: "Output the export snapshot"}}}, + {Name: "pull", Description: "Adopt an existing Linear issue", Options: []agentHelpOption{{Flags: "--tree", Description: "Also adopt the sub-issue tree with parent edges intact"}, {Flags: "--json", Description: "Output the adopted issue and tree as JSON"}}}, + {Name: "push", Description: "Write the local render and status to Linear", Options: []agentHelpOption{{Flags: "--json", Description: "Output the push result as JSON"}}}, + {Name: "reconcile", Description: "Compare local and Linear and surface conflicts", Options: []agentHelpOption{{Flags: "--take-local", Description: "Write the local status to Linear"}, {Flags: "--take-tracker", Description: "Write the Linear status to local through the events path"}, {Flags: "--json", Description: "Output the reconcile result as JSON"}}}, }, }, { @@ -264,19 +269,20 @@ func agentHelpCommands() []agentHelpCommand { {Name: "doctor", Description: "Diagnose project alignment", Options: []agentHelpOption{{Flags: "--fix", Description: "Offer safe repairs with y/N confirmation"}, {Flags: "--force", Description: "With --fix, accept every repair without prompting"}, {Flags: "--verbose", Description: "Show details"}, {Flags: "--json", Description: "Output the identical check set as read-only JSON; never prompts or repairs"}}}, { Name: "release", - Description: "Create a new release with changelog, version bump, and tag", - Options: []agentHelpOption{ - {Flags: "--dry-run", Description: "Preview release without making changes"}, - {Flags: "--bump <type>", Description: "Skip interactive bump choice"}, - {Flags: "--base <ref>", Description: "Use commits since ref"}, - {Flags: "--no-tag", Description: "Skip git tag creation"}, - {Flags: "--tag", Description: "Force git tag creation"}, - {Flags: "--no-gh", Description: "Skip GitHub release draft"}, - {Flags: "--gh", Description: "Force GitHub release draft"}, - {Flags: "--version-file <path>", Description: "Override version file path"}, - {Flags: "--pre-merge", Description: "Prepare release artifacts before squash-merge"}, - {Flags: "--post-merge", Description: "Finalize release after squash-merge"}, - {Flags: "-y, --yes", Description: "Skip confirmation prompt"}, + Description: "Cut a retroactive release from already-landed work", + Subcommands: []agentHelpSubcommand{ + {Name: "suggest", Description: "Report landed work since the last version tag. Writes nothing.", Options: []agentHelpOption{ + {Flags: "--base <ref>", Description: "Use commits since <ref> instead of last tag"}, + {Flags: "--json", Description: "Output the suggestion as JSON"}, + }}, + {Name: "cut", Description: "Cut a retroactive release from landed work. Records members as facts.", Options: []agentHelpOption{ + {Flags: "--base <ref>", Description: "Use commits since <ref> instead of last tag"}, + {Flags: "--bump <type>", Description: "Override the suggested bump"}, + {Flags: "--includes <version|tag>", Description: "Reference a prior release (repeatable)"}, + {Flags: "--no-tag", Description: "Skip git tag creation (tag v<version> must already exist)"}, + {Flags: "--no-gh", Description: "Skip GitHub release draft"}, + {Flags: "--dry-run", Description: "Print the plan and write nothing"}, + }}, }, }, {Name: "version", Description: "Show version and content counts"}, diff --git a/internal/cli/authority.go b/internal/cli/authority.go index 61f4d7cc8..322e52e3a 100644 --- a/internal/cli/authority.go +++ b/internal/cli/authority.go @@ -40,14 +40,11 @@ var basicCommandAuthorityPrefixes = []commandAuthorityPrefix{ {tokens: []string{"journal", "recent"}}, {tokens: []string{"journal", "search"}}, {tokens: []string{"journal", "show"}}, - {tokens: []string{"task", "archive"}}, - {tokens: []string{"task", "create"}}, {tokens: []string{"task", "list"}}, {tokens: []string{"task", "refresh"}}, {tokens: []string{"task", "show"}}, {tokens: []string{"task", "status"}}, {tokens: []string{"task", "sync"}}, - {tokens: []string{"task", "update"}}, {tokens: []string{"report", "archive"}}, {tokens: []string{"report", "finalize"}}, {tokens: []string{"report", "generate", "release-readiness"}}, @@ -107,9 +104,9 @@ var basicCommandAuthorityPrefixes = []commandAuthorityPrefix{ // Explicitly approved readers and diagnostics. `state doctor` is omitted // because --fix mutates state; the whole leaf therefore stays operator. - // Body/file-consuming creation/import leaves and path-taking `change check` - // are also omitted so a prefix rule cannot authorize caller-selected input - // outside the centrally reviewed command contracts. + // Body/file-consuming creation/import leaves are omitted so a prefix rule + // cannot authorize caller-selected input outside the centrally reviewed + // command contracts. {tokens: []string{"docs", "index"}}, {tokens: []string{"state", "backup", "verify"}}, {tokens: []string{"state", "export", "all"}}, @@ -121,7 +118,6 @@ var basicCommandAuthorityPrefixes = []commandAuthorityPrefix{ {tokens: []string{"project", "identity"}}, {tokens: []string{"project", "list"}}, {tokens: []string{"project", "show"}}, - {tokens: []string{"change", "list"}}, {tokens: []string{"kb", "check"}}, {tokens: []string{"kb", "glossary", "check"}}, {tokens: []string{"kb", "glossary", "list"}}, @@ -129,11 +125,14 @@ var basicCommandAuthorityPrefixes = []commandAuthorityPrefix{ {tokens: []string{"kb", "validate"}}, {tokens: []string{"check"}}, {tokens: []string{"housekeeping"}}, - {tokens: []string{"spec", "archive"}}, - {tokens: []string{"spec", "list"}}, - {tokens: []string{"spec", "render"}}, - {tokens: []string{"spec", "show"}}, - {tokens: []string{"spec", "status"}}, + {tokens: []string{"issue", "check"}}, + {tokens: []string{"issue", "dod", "list"}}, + {tokens: []string{"issue", "export"}}, + {tokens: []string{"issue", "frontier"}}, + {tokens: []string{"issue", "list"}}, + {tokens: []string{"issue", "render"}}, + {tokens: []string{"issue", "show"}}, + {tokens: []string{"issue", "tree"}}, {tokens: []string{"version"}}, } diff --git a/internal/cli/authority_test.go b/internal/cli/authority_test.go index b7a8539ca..42a194ad9 100644 --- a/internal/cli/authority_test.go +++ b/internal/cli/authority_test.go @@ -9,7 +9,7 @@ import ( func TestCommandAuthorityForUsesExplicitBasicLeafPrefixes(t *testing.T) { for _, args := range [][]string{ {"journal", "log", "--execpolicy-safe", "decision(scope): message"}, - {"task", "create", "--title", "task"}, + {"task", "list"}, {"docs", "index", "--rebuild"}, {"state", "backup", "verify", "/tmp/backup.sqlite"}, {"state", "export", "all", "--format", "json"}, @@ -19,7 +19,14 @@ func TestCommandAuthorityForUsesExplicitBasicLeafPrefixes(t *testing.T) { {"report", "generate", "triage", "--format", "markdown"}, {"report", "generate", "release-readiness", "--format", "markdown"}, {"project", "identity"}, - {"spec", "status", "SPEC-001", "done"}, + {"issue", "list"}, + {"issue", "show", "LOAF-1"}, + {"issue", "tree"}, + {"issue", "frontier"}, + {"issue", "render", "LOAF-1"}, + {"issue", "export"}, + {"issue", "dod", "list", "LOAF-1"}, + {"issue", "check", "LOAF-1"}, {"kb", "glossary", "list", "--all"}, {"check", "--hook", "check-secrets"}, {"trace", "task:TASK-001"}, @@ -35,6 +42,10 @@ func TestCommandAuthorityForDefaultsToOperatorForParentsAndUnsafeLeaves(t *testi {"journal"}, {"journal", "log", "decision(scope): message"}, {"task", "unknown"}, + {"task", "create", "--title", "task"}, + {"task", "update", "TASK-001", "--status", "done"}, + {"task", "archive", "TASK-001"}, + {"intent", "create", "--title", "intent", "--body", "body"}, {"state", "doctor"}, // --fix means the whole leaf is operator. {"state", "init"}, {"state", "repair", "journal-search"}, @@ -51,13 +62,13 @@ func TestCommandAuthorityForDefaultsToOperatorForParentsAndUnsafeLeaves(t *testi {"brainstorm", "capture", "--title", "brainstorm"}, {"idea", "capture", "--title", "idea"}, {"project", "rename", "new-name"}, - {"spec", "finalize", "SPEC-001"}, - {"spec", "new", "slug", "--body-file", "body.md"}, - {"spec", "delete", "SPEC-001", "--yes"}, + {"issue", "new", "title", "--body", "body"}, + {"issue", "edit", "LOAF-1", "--message", "body"}, + {"issue", "status", "LOAF-1", "cancelled"}, + {"issue", "start", "LOAF-1"}, + {"issue", "stop", "LOAF-1"}, {"kb", "review", "docs/knowledge/example.md"}, {"kb", "glossary", "upsert", "term"}, - {"change", "init", "new-change"}, - {"change", "check"}, {"release"}, {"not-a-command"}, } { diff --git a/internal/cli/change.go b/internal/cli/change.go index 450bb9eec..fcbe7a37f 100644 --- a/internal/cli/change.go +++ b/internal/cli/change.go @@ -1,146 +1,16 @@ package cli import ( - "context" - _ "embed" "fmt" - "io" - "os" "path/filepath" "regexp" "sort" "strings" - "time" - "unicode" - - "github.com/levifig/loaf/internal/project" - "github.com/levifig/loaf/internal/state" ) -// changeTemplate is the canonical Change artifact template, embedded so -// `loaf change init` never depends on installed content. It must stay -// byte-identical to content/skills/shape/templates/change.md; the drift is -// gated by TestChangeTemplateMatchesCanonicalContent. -// -//go:embed change_template.md -var changeTemplate string - -// changeSlugRE bounds a Change slug: lowercase letters and digits in -// hyphen-separated groups. No leading/trailing/doubled hyphens. -var changeSlugRE = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) - // changeFolderRE bounds a Change folder name: YYYYMMDD-slug. var changeFolderRE = regexp.MustCompile(`^(\d{8})-([a-z0-9]+(?:-[a-z0-9]+)*)$`) -// changeHTMLCommentRE matches an HTML comment, including multi-line blocks. -var changeHTMLCommentRE = regexp.MustCompile(`(?s)<!--.*?-->`) - -// changeBracketPlaceholderRE matches a bracket placeholder span (`[...]`). The -// class excludes brackets but not newlines, so a placeholder wrapping several -// lines is matched as one span. -var changeBracketPlaceholderRE = regexp.MustCompile(`\[[^\[\]]*\]`) - -// changeProductSections are the required Product Contract H2s (V1e). -var changeProductSections = []string{ - "Problem", - "Hypothesis", - "Scope", - "Observable Workflow", - "Rabbit Holes and No-Gos", -} - -// changeExecutableSections drive derived structural executability (V2). -var changeExecutableSections = []string{ - "Planning Contract", - "Implementation Units", - "Verification Contract", - "Definition of Done", -} - -// changeStatusKeys are the banned status-like frontmatter keys (V1a): readiness -// is derived from PR state and document structure, never declared. -var changeStatusKeys = map[string]bool{ - "readiness": true, - "status": true, - "state": true, -} - -// changeBannedStateValues are banned as frontmatter values under any key (V1a): -// the full canonical change-state vocabulary (Decision 22) plus released and the -// legacy progress words. Change state is derived (loaf change state), never -// stored, so none of these words may live in a stored frontmatter value. -// Matching is on the normalized value (see normalizeChangeStateValue). -var changeBannedStateValues = map[string]bool{ - // Canonical change-state vocabulary (Decision 22). - "backlog": true, - "shaping": true, - "todo": true, - "in-progress": true, - "review": true, - "merged": true, - // released is a project-level event, never a change state, but is equally - // banned as a stored value (Decision 22, Verification Contract V1). - "released": true, - // Legacy progress words, kept as regression insurance. - "active": true, - "done": true, - "archived": true, -} - -// changeIdentityKeys are the frontmatter fields whose values carry identity, not -// state, and are therefore exempt from the state-vocabulary ban. change: and -// created: are already checked against the folder name; branch: names a git -// branch that may legitimately equal a state word (a branch named "review"). -// The status-key ban (readiness/status/state) still applies to every key. -var changeIdentityKeys = map[string]bool{ - "change": true, - "created": true, - "branch": true, -} - -// changeStateSeparatorRE collapses underscores and whitespace runs to a single -// hyphen so "In Progress" and "in_progress" both normalize to "in-progress". -var changeStateSeparatorRE = regexp.MustCompile(`[_\s]+`) - -type changeCheckOptions struct { - path string - requireExecutable bool - jsonOutput bool -} - -type changeCheckJSON struct { - Command string `json:"command"` - Folder string `json:"folder"` - Layout string `json:"layout,omitempty"` - Passed bool `json:"passed"` - State string `json:"state"` - Executable bool `json:"executable"` - Captured bool `json:"captured,omitempty"` - ExitCode int `json:"exitCode"` - Findings []string `json:"findings"` - Warnings []string `json:"warnings"` - Gaps []string `json:"gaps"` - Notices []string `json:"notices,omitempty"` -} - -type changeFrontmatterField struct { - Key string - Value string -} - -type changeFrontmatterParse struct { - Fields []changeFrontmatterField - AtByteOne bool - Findings []string -} - -type changeCheckReport struct { - Violations []string - Warnings []string - Gaps []string - Executable bool -} - // changeNode is the git-canonical portion of a materialized Change. It is // deliberately derived from retained files; no lineage state is persisted. // Layout is "new" when change.json is present, else "legacy" (change.md). @@ -162,386 +32,15 @@ type changeNode struct { CapturedOnly bool `json:"-"` } -type changeListOptions struct { - lineage string - jsonOutput bool -} - -type changeListJSON struct { - Command string `json:"command"` - Lineage string `json:"lineage"` - Nodes []changeNode `json:"nodes"` - Root string `json:"root,omitempty"` - ReleaseAfter string `json:"releaseAfter,omitempty"` - Findings []string `json:"findings"` - Warnings []string `json:"warnings"` - Gaps []string `json:"gaps"` - JournalAvailable bool `json:"journalAvailable"` - LineageDecision string `json:"lineageDecision,omitempty"` -} - -const ( - changeListProjectResolutionWarning = "journal-enrichment-project-resolution-failed: run change list from a resolvable project root" - changeListJournalReadWarning = "journal-enrichment-read-failed: inspect native state with `loaf state status`" - // Removal boundary for the legacy single-file layout (H2 / TASK-003): the first - // stable release after the new layout has shipped one minor. - changeLegacyDeprecationNotice = "legacy layout (change.md): prefer change.json + shape.md + tasks/. Removal boundary: the first stable release after the new layout has shipped one minor." -) - -func (r Runner) runChange(args []string, out io.Writer, runtime state.Runtime) error { - if len(args) == 0 || isHelpArg(args) { - writeChangeHelp(out) - return nil - } - if writeNestedHelp(out, args, map[string]func(io.Writer){ - "init": writeChangeInitHelp, - "check": writeChangeCheckHelp, - "list": writeChangeListHelp, - "report": writeChangeReportHelp, - "tasks": writeChangeTasksHelp, - "show": writeChangeShowHelp, - "verify": writeChangeVerifyHelp, - }) { - return nil - } - switch args[0] { - case "init": - return r.runChangeInit(args[1:], out, runtime.RootPath()) - case "check": - return r.runChangeCheck(args[1:], out, runtime.RootPath()) - case "list": - return r.runChangeListUnits(args[1:], out, runtime.RootPath()) - case "report": - return r.runChangeReport(args[1:], out, runtime.RootPath()) - case "tasks": - return r.runChangeTasks(args[1:], out, runtime.RootPath()) - case "show": - return r.runChangeShow(args[1:], out, runtime.RootPath()) - case "verify": - return r.runChangeVerify(args[1:], out, runtime.RootPath()) - default: - return unknownSubcommandError("change", args[0]) - } -} - -func writeChangeHelp(out io.Writer) { - writeCommandGroupHelp(out, "loaf change <subcommand> [options]", - "Shape-first Change artifacts: git-canonical work context under docs/changes/.", - []subcommandHelpItem{ - {Name: "init", Summary: "Scaffold a new Change folder (change.json + shape.md + tasks/)"}, - {Name: "check", Summary: "Validate a Change and report derived executability"}, - {Name: "list", Summary: "List Changes as units/cohort projection"}, - {Name: "tasks", Summary: "Project the stable-ID task index as JSON"}, - {Name: "show", Summary: "Show layout, target, state, and derived PR set"}, - {Name: "verify", Summary: "Run executable criteria and write a cohort receipt"}, - {Name: "report", Summary: "Stamp authored HTML reports under reports/"}, - }) -} - -func writeChangeListHelp(out io.Writer) { - writeUsageHelp(out, "loaf change list [--target <version>] [--json]", - "List Changes as a units/cohort projection: layout, target_release, and derived state. --target filters one release cohort.", - "--target Filter to changes with this target_release (MAJOR.MINOR.PATCH)", - "--json Output units as JSON") -} - -func writeChangeInitHelp(out io.Writer) { - writeUsageHelp(out, "loaf change init <slug> [--brief]", - "Create docs/changes/<YYYYMMDD>-<slug>/ with change.json + shape.md + seeded tasks/. --brief is capture mode (change.json + brief.md only). Re-running ordinary init on a structurally valid capture-only folder promotes it in place (preserves brief.md and change.json, instantiates shape.md + tasks/); fully-materialized folders still reject as duplicates. The slug uses lowercase letters, digits, and single hyphens.", - "--brief Capture mode: emit change.json + brief.md only (non-executable until shaped); refuses when the slug already exists") -} - -func writeChangeCheckHelp(out io.Writer) { - writeUsageHelp(out, "loaf change check [folder] [--require-executable] [--json]", - "Validate a Change and report derived structural executability, not implementation completion. Folder resolution: an "+ - "explicit [folder] path always wins; otherwise the current git branch is "+ - "matched against declared branch identity across docs/changes/*/ (change.json or change.md).", - "[folder] Change folder (or change.json/change.md) path; resolves from the current branch when omitted", - "--require-executable Exit non-zero unless the Change is structurally executable (CI gate for non-draft PRs)", - "--json Output folder, passed, state, executable, findings, warnings, and gaps as JSON") -} - -func (r Runner) runChangeInit(args []string, out io.Writer, rootPath string) error { - if isHelpArg(args) { - writeChangeInitHelp(out) - return nil - } - options, err := parseChangeInitArgs(args) - if err != nil { - return err - } - slug := options.slug - if existing, err := findChangeSlug(rootPath, slug); err != nil { - return err - } else if existing != "" { - folderAbs := filepath.Join(rootPath, filepath.FromSlash(existing)) - decision := classifyChangePromotion(folderAbs, existing, slug, options.brief) - if decision.outcome == changePromotionReject { - return fmt.Errorf("%s", decision.reason) - } - if err := completeCapturedChangeFolder(folderAbs, slug, decision); err != nil { - return err - } - writeChangePromotionSuccess(out, rootPath, folderAbs, slug, decision) - return nil - } - - now := time.Now() - folderName := now.Format("20060102") + "-" + slug - folder := filepath.Join(rootPath, "docs", "changes", folderName) - if info, err := os.Stat(folder); err == nil { - _ = info - return fmt.Errorf("change folder already exists: %s", relFromRoot(rootPath, folder)) - } else if !os.IsNotExist(err) { - return fmt.Errorf("stat change folder: %w", err) - } - - if err := scaffoldChangeFolder(folder, slug, options.brief, now); err != nil { - return err - } - folderRel := relFromRoot(rootPath, folder) - primary := changeContractFileShape - if options.brief { - primary = changeBriefFile - } - fmt.Fprintf(out, "Created change: %s\n", filepath.ToSlash(filepath.Join(folderRel, primary))) - if options.brief { - fmt.Fprintf(out, " Capture mode: change.json + brief.md (shape later to make executable)\n") - } else { - fmt.Fprintf(out, " Layout: change.json + shape.md + tasks/\n") - } - fmt.Fprintf(out, "\nNext: work on this change happens on branch %q.\n", slug) - fmt.Fprintf(out, " Create or switch to it: git switch -c %s\n", slug) - fmt.Fprintf(out, " Then validate the change: loaf change check\n") - fmt.Fprintf(out, " Or check it from any branch by passing the folder: loaf change check %s\n", folderRel) - return nil -} - -// Legacy change.md template remains embedded for coexistence and the -// TestChangeTemplateMatchesCanonicalContent drift gate. New scaffolds use -// change_scaffold.go embeds (shape/brief/plan/design/task). - -func (r Runner) runChangeCheck(args []string, out io.Writer, rootPath string) error { - if isHelpArg(args) { - writeChangeCheckHelp(out) - return nil - } - options, err := parseChangeCheckArgs(args) - if err != nil { - return err - } - - folder, changeFile, err := resolveChangeFolder(rootPath, options.path) - if err != nil { - return err - } - node, err := assembleChangeNodeFromFolder(rootPath, folder) - if err != nil { - return err - } - _ = changeFile - - report := evaluateChangeNode(node, currentChangeBranch(rootPath)) - nodes, indexErr := loadChangeNodes(rootPath) - if indexErr != nil { - return indexErr - } - report, composeErr := composeChangeCheckReport(report, rootPath, folder, node, nodes, commandOutput, options.requireExecutable, changeTaskContentWorkingTree) - if composeErr != nil { - return composeErr - } - - var notices []string - if node.Layout == changeLayoutLegacy { - notices = append(notices, changeLegacyDeprecationNotice) - } - if node.CapturedOnly { - report.Warnings = append(report.Warnings, "captured, not shaped (brief-only folder)") - } - - requireFail := options.requireExecutable && !report.Executable - findings := append([]string{}, report.Violations...) - if requireFail { - findings = append(findings, "not structurally executable (--require-executable; implementation completion is not implied): missing "+strings.Join(report.Gaps, ", ")) - } - exitCode := 0 - switch { - case len(report.Violations) > 0: - exitCode = 2 - case requireFail: - exitCode = 1 - } - passed := exitCode == 0 - - state, stateWarnings := deriveChangeStateDetailed(rootPath, node, changeEvidenceGitOutput) - result := changeCheckJSON{ - Command: "change check", - Folder: relFromRoot(rootPath, folder), - Layout: node.Layout, - Passed: passed, - State: state, - Executable: report.Executable, - Captured: node.CapturedOnly, - ExitCode: exitCode, - Findings: findings, - Warnings: sortedUnique(append(append([]string{}, report.Warnings...), stateWarnings...)), - Gaps: report.Gaps, - Notices: notices, - } - - if options.jsonOutput { - if err := writeJSON(out, result); err != nil { - return err - } - } else { - writeChangeCheckText(out, result) - } - if exitCode != 0 { - return ExitError{Code: exitCode} - } - return nil -} - -func (r Runner) runChangeList(args []string, out io.Writer, runtime state.Runtime) error { - options, err := parseChangeListArgs(args) - if err != nil { - return err - } - nodes, err := loadChangeNodes(runtime.RootPath()) - if err != nil { - return err - } - graph := deriveChangeGraph(nodes) - result := changeListJSON{Command: "change list", Lineage: options.lineage, Nodes: []changeNode{}, Findings: graph.findingsForLineage(options.lineage), Warnings: []string{}, Gaps: graph.gapsForLineage(options.lineage)} - for _, node := range nodes { - if node.Lineage == options.lineage { - result.Nodes = append(result.Nodes, node) - } - } - if len(result.Nodes) == 0 { - return fmt.Errorf("no retained Changes found for lineage %q", options.lineage) - } - sort.Slice(result.Nodes, func(i, j int) bool { return result.Nodes[i].Folder < result.Nodes[j].Folder }) - for _, node := range result.Nodes { - if node.Predecessor == "" { - if result.Root == "" { - result.Root = node.Slug - } - } - if node.ReleaseAfter != "" { - if result.ReleaseAfter == "" { - result.ReleaseAfter = node.ReleaseAfter - } - } - } - // Journal intent enriches this derived view when available. State is never a - // prerequisite: missing/uninitialized state simply leaves it unavailable. - if root, rootErr := project.ResolveRoot(runtime.RootPath()); rootErr != nil { - result.Warnings = append(result.Warnings, changeListProjectResolutionWarning) - } else { - entry, found, available, recentErr := state.LatestJournalEntryForScope(context.Background(), root, state.PathResolver{StateHome: r.StateHome}, "decision", "lineage/"+options.lineage) - if recentErr != nil { - result.Warnings = append(result.Warnings, changeListJournalReadWarning) - } else { - result.JournalAvailable = available - if found { - result.LineageDecision = entry.Message - } - } - } - result.Warnings = sortedUnique(result.Warnings) - if options.jsonOutput { - return writeJSON(out, result) - } - fmt.Fprintf(out, "\n%s %s\n", ansiBold("change lineage"), result.Lineage) - for _, node := range result.Nodes { - fmt.Fprintf(out, " %s %s\n", node.Slug, filepath.ToSlash(node.Folder)) - if node.Predecessor != "" { - fmt.Fprintf(out, " predecessor: %s\n", node.Predecessor) - } - } - if result.Root != "" { - fmt.Fprintf(out, "root: %s\n", result.Root) - } - if result.ReleaseAfter != "" { - fmt.Fprintf(out, "release after: %s\n", result.ReleaseAfter) - } - for _, gap := range result.Gaps { - fmt.Fprintf(out, "gap: %s\n", gap) - } - for _, finding := range result.Findings { - fmt.Fprintf(out, "finding: %s\n", finding) - } - for _, warning := range result.Warnings { - fmt.Fprintf(out, "warning: %s\n", warning) - } - if result.JournalAvailable && result.LineageDecision != "" { - fmt.Fprintf(out, "latest lineage decision: %s\n", result.LineageDecision) - } else if !result.JournalAvailable { - fmt.Fprintln(out, "lineage decision: unavailable (native state is not required)") - } - return nil -} - -func parseChangeListArgs(args []string) (changeListOptions, error) { - var options changeListOptions - for i := 0; i < len(args); i++ { - switch args[i] { - case "--json": - options.jsonOutput = true - case "--lineage": - if i+1 >= len(args) { - return options, fmt.Errorf("--lineage requires a value") - } - i++ - options.lineage = args[i] - default: - if strings.HasPrefix(args[i], "--lineage=") { - options.lineage = strings.TrimPrefix(args[i], "--lineage=") - } else { - return options, fmt.Errorf("unknown change list option %q", args[i]) - } - } - } - if options.lineage == "" { - return options, fmt.Errorf("change list requires --lineage <key>") - } - return options, nil -} - -func parseChangeCheckArgs(args []string) (changeCheckOptions, error) { - var options changeCheckOptions - for _, arg := range args { - switch arg { - case "--require-executable": - options.requireExecutable = true - case "--json": - options.jsonOutput = true - default: - if strings.HasPrefix(arg, "-") { - return changeCheckOptions{}, fmt.Errorf("unknown change check option %q", arg) - } - if options.path != "" { - return changeCheckOptions{}, fmt.Errorf("change check accepts a single [folder] argument") - } - options.path = arg - } - } - return options, nil +type changeFrontmatterField struct { + Key string + Value string } -func findChangeSlug(rootPath, slug string) (string, error) { - folders, err := listChangeFolderNames(rootPath) - if err != nil { - return "", err - } - for _, name := range folders { - match := changeFolderRE.FindStringSubmatch(name) - if match != nil && match[2] == slug { - return filepath.ToSlash(filepath.Join("docs", "changes", name)), nil - } - } - return "", nil +type changeFrontmatterParse struct { + Fields []changeFrontmatterField + AtByteOne bool + Findings []string } func loadChangeNodes(rootPath string) ([]changeNode, error) { @@ -567,312 +66,6 @@ func loadChangeNodes(rootPath string) ([]changeNode, error) { return nodes, nil } -// resolveChangeFolder returns the Change folder and its primary machine file -// (change.json when present, otherwise change.md). An explicit path wins; -// otherwise the folder is resolved by matching the current git branch against -// declared branch identity across both layouts. -func resolveChangeFolder(rootPath string, path string) (string, string, error) { - if path != "" { - abs := path - if !filepath.IsAbs(abs) { - abs = filepath.Join(rootPath, path) - } - info, err := os.Stat(abs) - if err != nil { - return "", "", fmt.Errorf("change path not found: %s", path) - } - folder := abs - if !info.IsDir() { - base := filepath.Base(abs) - if base == changeMachineFileJSON || base == changeMachineFileLegacy || base == changeContractFileShape || base == changeBriefFile { - folder = filepath.Dir(abs) - } else { - return "", "", fmt.Errorf("change path not found: %s", path) - } - } - node, err := assembleChangeNodeFromFolder(rootPath, folder) - if err != nil { - return "", "", err - } - return folder, filepath.Join(rootPath, filepath.FromSlash(node.ChangeFile)), nil - } - return resolveChangeFolderByBranch(rootPath) -} - -func resolveChangeFolderByBranch(rootPath string) (string, string, error) { - branch := currentChangeBranch(rootPath) - if branch == "" { - return "", "", fmt.Errorf("could not determine the current git branch; pass a change folder path") - } - nodes, err := loadChangeNodes(rootPath) - if err != nil { - return "", "", err - } - var folders []string - var available []changeBranchEntry - for _, node := range nodes { - available = append(available, changeBranchEntry{ - folder: node.Folder, - branch: node.Branch, - }) - if node.Branch == branch { - folders = append(folders, filepath.Join(rootPath, filepath.FromSlash(node.Folder))) - } - } - switch len(folders) { - case 1: - node, err := assembleChangeNodeFromFolder(rootPath, folders[0]) - if err != nil { - return "", "", err - } - return folders[0], filepath.Join(rootPath, filepath.FromSlash(node.ChangeFile)), nil - case 0: - return "", "", fmt.Errorf("no change folder matches branch %q; pass a change folder path.%s", branch, formatAvailableChanges(available)) - default: - return "", "", fmt.Errorf("multiple change folders match branch %q; pass a change folder path.%s", branch, formatAvailableChanges(available)) - } -} - -// changeBranchEntry pairs a Change folder with the branch declared in its -// frontmatter, for listing candidates when branch resolution is unambiguous. -type changeBranchEntry struct { - folder string - branch string -} - -// formatAvailableChanges renders the discovered Change folders and their branch: -// values so a failed branch resolution tells the user exactly what they can pass. -func formatAvailableChanges(entries []changeBranchEntry) string { - if len(entries) == 0 { - return " (no change folders found under docs/changes/)" - } - sort.Slice(entries, func(i, j int) bool { return entries[i].folder < entries[j].folder }) - var b strings.Builder - b.WriteString("\navailable change folders:") - for _, entry := range entries { - branch := entry.branch - if branch == "" { - branch = "(no branch: field)" - } - fmt.Fprintf(&b, "\n %s branch: %s", entry.folder, branch) - } - return b.String() -} - -// evaluateChangeNode runs the Verification Contract against a layout-agnostic -// Change node: machine-surface findings first, then per-layout contract body. -func evaluateChangeNode(node changeNode, currentBranch string) changeCheckReport { - report := changeCheckReport{Violations: []string{}, Warnings: []string{}, Gaps: []string{}} - for _, finding := range node.ParseFindings { - report.Violations = append(report.Violations, prefixChangeFinding(node.ChangeFile, finding)) - } - - folderBase := filepath.Base(node.Folder) - folderMatch := changeFolderRE.FindStringSubmatch(folderBase) - if folderMatch == nil { - report.Violations = append(report.Violations, - fmt.Sprintf("malformed change folder name %q (want YYYYMMDD-slug)", folderBase)) - } else { - folderDate, folderSlug := folderMatch[1], folderMatch[2] - if node.Slug != "" && node.Slug != folderSlug { - report.Violations = append(report.Violations, - fmt.Sprintf("identity mismatch: change: %q does not match folder slug %q", node.Slug, folderSlug)) - } - if node.Created != "" && strings.ReplaceAll(node.Created, "-", "") != folderDate { - report.Violations = append(report.Violations, - fmt.Sprintf("identity mismatch: created: %q does not match folder date %q", node.Created, folderDate)) - } - } - - if node.Layout == changeLayoutNew { - if node.CapturedOnly || node.ContractFile == "" || strings.HasSuffix(node.ContractFile, "/"+changeBriefFile) { - report.Gaps = append(report.Gaps, "shape.md (missing)") - } else { - report = applyChangeContractSections(report, node.Content) - } - if currentBranch != "" && node.Branch != "" && node.Branch != currentBranch { - report.Warnings = append(report.Warnings, - fmt.Sprintf("current branch %q does not match change branch %q", currentBranch, node.Branch)) - } - report.Executable = len(report.Gaps) == 0 && len(report.Violations) == 0 - report.Violations = sortedUnique(report.Violations) - report.Warnings = sortedUnique(report.Warnings) - report.Gaps = sortedUnique(report.Gaps) - return report - } - - legacy := evaluateChangeDocAtPath(node.Content, folderBase, currentBranch, node.ChangeFile) - legacy.Violations = append(append([]string{}, report.Violations...), legacy.Violations...) - legacy.Violations = sortedUnique(legacy.Violations) - return legacy -} - -// composeChangeCheckReport is the structural composite shared by `loaf change -// check`, the release cohort gate, and the verified-state guard: lineage -// validation over the loaded node set, then task-hygiene and conversion -// findings. One helper; the task-content source distinguishes author feedback -// (working tree for check) from evidence (committed HEAD for gate/state). -func composeChangeCheckReport(report changeCheckReport, rootPath, folderAbs string, node changeNode, nodes []changeNode, outputCommand changeGitOutput, requireExecutable bool, taskSource changeTaskContentSource) (changeCheckReport, error) { - report = applyLineageValidation(report, nodes, node.ChangeFile, rootPath, requireExecutable) - return applyChangeStructuralFindings(report, rootPath, folderAbs, node, outputCommand, taskSource) -} - -// applyChangeStructuralFindings folds the structural surface that lives outside -// evaluateChangeNode into a report: task-hygiene findings from tasks/ and -// pre-checked conversion findings from history, both blocking, plus task -// warnings, which never block. Executability is downgraded when either fires. -// `loaf change check` and the release cohort gate share this composite so -// "structurally valid" means the same thing at both surfaces — a gate that -// judged violations alone let contract gaps and banned task frontmatter release. -func applyChangeStructuralFindings(report changeCheckReport, rootPath, folderAbs string, node changeNode, outputCommand changeGitOutput, taskSource changeTaskContentSource) (changeCheckReport, error) { - if node.Layout != changeLayoutNew { - return report, nil - } - _, taskFindings, taskWarnings := loadChangeTasks(rootPath, folderAbs, node, taskSource, outputCommand) - report.Violations = append(report.Violations, taskFindings...) - report.Warnings = append(report.Warnings, taskWarnings...) - conversionFindings, err := conversionPreCheckedFindings(rootPath, relFromRoot(rootPath, folderAbs), outputCommand) - if err != nil { - return report, err - } - report.Violations = append(report.Violations, conversionFindings...) - report.Violations = sortedUnique(report.Violations) - report.Warnings = sortedUnique(report.Warnings) - if len(taskFindings) > 0 || len(conversionFindings) > 0 { - report.Executable = false - } - return report, nil -} - -// applyChangeContractSections checks Product + executable section presence/authorship -// on a narrative contract body (shape.md or legacy change.md body). -func applyChangeContractSections(report changeCheckReport, content string) changeCheckReport { - sections := changeSections(content) - for _, name := range changeProductSections { - if _, ok := sections[name]; !ok { - report.Violations = append(report.Violations, - fmt.Sprintf("missing Product Contract section: %s", name)) - } - } - for _, name := range changeExecutableSections { - body, ok := sections[name] - if !ok { - report.Gaps = append(report.Gaps, fmt.Sprintf("%s (missing)", name)) - continue - } - if !changeSectionAuthored(body) { - report.Gaps = append(report.Gaps, fmt.Sprintf("%s (empty)", name)) - } - } - return report -} - -// evaluateChangeDoc runs the Verification Contract against one change.md. -func evaluateChangeDoc(content string, folderBase string, currentBranch string) changeCheckReport { - return evaluateChangeDocAtPath(content, folderBase, currentBranch, "") -} - -func evaluateChangeDocAtPath(content string, folderBase string, currentBranch string, changePath string) changeCheckReport { - report := changeCheckReport{ - Violations: []string{}, - Warnings: []string{}, - Gaps: []string{}, - } - - parsed := parseChangeFrontmatter(content) - fields, atByteOne := parsed.Fields, parsed.AtByteOne - if !atByteOne { - report.Violations = append(report.Violations, prefixChangeFinding(changePath, "frontmatter must open the file at byte one")) - } - for _, finding := range parsed.Findings { - report.Violations = append(report.Violations, prefixChangeFinding(changePath, finding)) - } - for _, key := range []string{"change", "created", "lineage", "predecessor", "release-after", "target_release"} { - if countChangeFields(fields, key) > 1 { - report.Violations = append(report.Violations, prefixChangeFinding(changePath, fmt.Sprintf("duplicate frontmatter field %q", key))) - } - } - if target := changeFieldValue(fields, "target_release"); target != "" && !isCanonicalChangeTargetRelease(target) { - report.Violations = append(report.Violations, prefixChangeFinding(changePath, - fmt.Sprintf("target_release %q must be canonical MAJOR.MINOR.PATCH (no v, leading zeros, prerelease, or build)", target))) - } - - // V1a: status-like keys and the canonical change-state vocabulary as values. - for _, field := range fields { - if changeStatusKeys[strings.ToLower(field.Key)] { - report.Violations = append(report.Violations, - fmt.Sprintf("status-like frontmatter key %q is banned; readiness is derived", field.Key)) - continue - } - if changeIdentityKeys[strings.ToLower(field.Key)] { - continue - } - if changeBannedStateValues[normalizeChangeStateValue(field.Value)] { - report.Violations = append(report.Violations, - fmt.Sprintf("change-state vocabulary %q in frontmatter field %q is banned; state is derived", field.Value, field.Key)) - } - } - - // V1c + V1d: folder-name shape and identity. - folderMatch := changeFolderRE.FindStringSubmatch(folderBase) - if folderMatch == nil { - report.Violations = append(report.Violations, - fmt.Sprintf("malformed change folder name %q (want YYYYMMDD-slug)", folderBase)) - } else if atByteOne { - folderDate, folderSlug := folderMatch[1], folderMatch[2] - if change := changeFieldValue(fields, "change"); change != folderSlug { - report.Violations = append(report.Violations, - fmt.Sprintf("identity mismatch: change: %q does not match folder slug %q", change, folderSlug)) - } - created := changeFieldValue(fields, "created") - if strings.ReplaceAll(created, "-", "") != folderDate { - report.Violations = append(report.Violations, - fmt.Sprintf("identity mismatch: created: %q does not match folder date %q", created, folderDate)) - } - } - - // V1e: required Product Contract sections present. - sections := changeSections(content) - for _, name := range changeProductSections { - if _, ok := sections[name]; !ok { - report.Violations = append(report.Violations, - fmt.Sprintf("missing Product Contract section: %s", name)) - } - } - - // V2: derived executability — required tail sections present and non-empty. - // Non-empty means authored content: bracket placeholders and comments are - // scaffolding, not content, so a freshly-templated Change is not executable. - for _, name := range changeExecutableSections { - body, ok := sections[name] - if !ok { - report.Gaps = append(report.Gaps, fmt.Sprintf("%s (missing)", name)) - continue - } - if !changeSectionAuthored(body) { - report.Gaps = append(report.Gaps, fmt.Sprintf("%s (empty)", name)) - } - } - report.Executable = len(report.Gaps) == 0 - - // Branch mismatch is a warning, never a violation. - if atByteOne && currentBranch != "" { - if branch := changeFieldValue(fields, "branch"); branch != "" && branch != currentBranch { - report.Warnings = append(report.Warnings, - fmt.Sprintf("current branch %q does not match change branch %q", currentBranch, branch)) - } - } - - return report -} - -func prefixChangeFinding(changePath, finding string) string { - if changePath == "" { - return finding - } - return filepath.ToSlash(changePath) + ": " + finding -} - // changeFrontmatterFields parses the leading YAML frontmatter into ordered // key/value fields. The second return reports whether frontmatter opens the // file at byte one — parsers depend on it, so this is checkable on its own. @@ -922,13 +115,6 @@ func parseChangeFrontmatter(content string) changeFrontmatterParse { return result } -// normalizeChangeStateValue lowercases, trims, and collapses underscore/space -// runs to hyphens so state words are matched regardless of casing or separator -// style ("In Progress", "in_progress", "in-progress" all match "in-progress"). -func normalizeChangeStateValue(value string) string { - return changeStateSeparatorRE.ReplaceAllString(strings.ToLower(strings.TrimSpace(value)), "-") -} - func changeFieldValue(fields []changeFrontmatterField, key string) string { for _, field := range fields { if strings.EqualFold(field.Key, key) { @@ -947,60 +133,11 @@ func cleanChangeScalar(value string) string { return value } -// changeSections maps each H2 heading to its trimmed body text (H3 subsections -// included), so section presence and non-emptiness are both derivable. -func changeSections(content string) map[string]string { - sections := map[string]string{} - current := "" - var body []string - flush := func() { - if current != "" { - sections[current] = strings.TrimSpace(strings.Join(body, "\n")) - } - } - for _, line := range strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") { - switch { - case strings.HasPrefix(line, "## "): - flush() - current = strings.TrimSpace(strings.TrimPrefix(line, "## ")) - body = nil - case strings.HasPrefix(line, "# "): - flush() - current = "" - body = nil - default: - if current != "" { - body = append(body, line) - } - } - } - flush() - return sections -} - -// changeSectionAuthored reports whether a section body carries authored content -// once scaffolding is discounted (V2). HTML comments and bracket placeholder -// spans (`[...]`, including multi-line spans) are removed; if any letter or -// digit survives, the section is authored. Bare structural labels (e.g. a **U1** -// bullet left unfilled) survive discounting and therefore count as authored — -// the rule strips placeholders and comments, never labels. -func changeSectionAuthored(body string) bool { - stripped := changeHTMLCommentRE.ReplaceAllString(body, "") - stripped = changeBracketPlaceholderRE.ReplaceAllString(stripped, "") - for _, r := range stripped { - if unicode.IsLetter(r) || unicode.IsDigit(r) { - return true - } - } - return false -} - -func currentChangeBranch(root string) string { - output, err := commandOutput(root, "git", "branch", "--show-current") - if err != nil { - return "" +func prefixChangeFinding(changePath, finding string) string { + if changePath == "" { + return finding } - return strings.TrimSpace(output) + return filepath.ToSlash(changePath) + ": " + finding } func relFromRoot(root string, path string) string { @@ -1011,38 +148,12 @@ func relFromRoot(root string, path string) string { return filepath.ToSlash(rel) } -func writeChangeCheckText(out io.Writer, result changeCheckJSON) { - fmt.Fprintf(out, "\n%s %s\n", ansiBold("change check"), result.Folder) - if result.Layout != "" { - fmt.Fprintf(out, "layout: %s\n", result.Layout) - } - for _, notice := range result.Notices { - fmt.Fprintf(out, "%s %s\n", ansiYellow("notice:"), notice) - } - if result.State != "" { - state := result.State - if result.State == "captured" { - state = ansiYellow("captured") - } - fmt.Fprintf(out, "state: %s\n", state) - } - if len(result.Findings) > 0 { - fmt.Fprintf(out, "\n%s %d violation(s)\n", ansiRed("x"), len(result.Findings)) - for _, finding := range result.Findings { - fmt.Fprintf(out, " %s %s\n", ansiRed("-"), finding) - } - } else { - fmt.Fprintf(out, "%s no violations\n", ansiGreen("ok")) - } - if result.Executable { - fmt.Fprintf(out, "executable: %s\n", ansiGreen("yes")) - } else { - fmt.Fprintf(out, "executable: %s\n", ansiYellow("no")) - for _, gap := range result.Gaps { - fmt.Fprintf(out, " %s %s\n", ansiGray("gap:"), gap) +func countChangeFields(fields []changeFrontmatterField, key string) int { + count := 0 + for _, field := range fields { + if strings.EqualFold(field.Key, key) { + count++ } } - for _, warning := range result.Warnings { - fmt.Fprintf(out, " %s %s\n", ansiYellow("warn:"), warning) - } + return count } diff --git a/internal/cli/change_brief_template.md b/internal/cli/change_brief_template.md deleted file mode 100644 index 05016c8c8..000000000 --- a/internal/cli/change_brief_template.md +++ /dev/null @@ -1,42 +0,0 @@ -<!-- brief.md is the optional archeological kickstart — the original unshaped ask. - May accrete parked problem-space concepts while the change is captured; freezes when shape.md exists. - Superseded by shape.md; never mechanically load-bearing. - A brief-only folder is legal and non-executable (captured, not shaped). --> - -# [Brief title] - -## Problem Statement - -[What friction, gap, or unmet need exists? Be specific about the pain — vague problems produce vague solutions. Problem-space only; do not design the approach here.] - -## Who Has It - -[Who experiences this problem? Role, context, and how often the pain shows up. Avoid unqualified "users" or "developers."] - -## Current Alternatives - -[What do they do today? Existing tools, manual workarounds, or "nothing" are all valid. Understanding the status quo clarifies what better means.] - -## Value Proposition - -[Why is solving this worth it? What becomes true for the people who have the problem if this lands? Describe value, not features or architecture.] - -## Constraints - -[Non-negotiable bounds: technical, legal, organizational, or philosophical. Things that limit the solution space before design begins.] - -- [Constraint 1] - -## Sequencing and Relationships - -[How this relates to other work — series order, release cohort, dependencies stated as prose. No machine relation fields; narrative order only.] - -## Sources and Research Links - -[Evidence that informed this framing — competitive scans, research notes, issue links, prior art. Link rather than paste.] - -## Open Questions - -[Unresolved problem-space items. Mark urgency: blocking (must resolve before shaping) or deferrable.] - -- [ ] [Question] — blocking | deferrable diff --git a/internal/cli/change_context_test.go b/internal/cli/change_context_test.go index 5874a0147..42c7ef966 100644 --- a/internal/cli/change_context_test.go +++ b/internal/cli/change_context_test.go @@ -54,8 +54,8 @@ func TestJournalContextDiscoversActiveChangesFromNestedDirectory(t *testing.T) { if !reflect.DeepEqual(fromNested, fromRoot) { t.Fatalf("nested active Changes = %#v, want root result %#v", fromNested, fromRoot) } - if got := fromNested.Items[0].ActiveReasons; !reflect.DeepEqual(got, []string{"working_tree_change", "lineage_unresolved"}) { - t.Fatalf("nested active reasons = %#v, want dirty and unresolved evidence", got) + if got := fromNested.Items[0].ActiveReasons; !reflect.DeepEqual(got, []string{"working_tree_change"}) { + t.Fatalf("nested active reasons = %#v, want dirty working-tree evidence", got) } } diff --git a/internal/cli/change_conversion.go b/internal/cli/change_conversion.go deleted file mode 100644 index 8cb8224d6..000000000 --- a/internal/cli/change_conversion.go +++ /dev/null @@ -1,115 +0,0 @@ -package cli - -import ( - "fmt" - "path/filepath" - "strings" -) - -// conversionPreCheckedGrandfathers lists conversion commits that predate the -// unchecked-at-conversion rule and are tracked for remediation rather than -// blocking check forever. See INTENT-20260727-dogfood-conversion-manufactured-task-003-execution. -var conversionPreCheckedGrandfathers = map[string]bool{ - "acbea95001f9187b154d095f4579225b7744fe1d": true, -} - -// conversionPreCheckedFindings reports sanctioned atomic conversions whose -// resulting tasks/ tree carried any checked checkbox. Flip-grade execution must -// come from later delivering commits, never be manufactured by conversion. -// Findings surface on loaf change check (not only release preflight): a rule -// that fires only at release arrives too late to be useful. -func conversionPreCheckedFindings(rootPath, folderRel string, outputCommand changeGitOutput) ([]string, error) { - if outputCommand == nil { - outputCommand = commandOutput - } - folderRel = filepath.ToSlash(strings.TrimSpace(folderRel)) - if folderRel == "" { - return nil, nil - } - mdPath := filepath.ToSlash(filepath.Join(folderRel, changeMachineFileLegacy)) - jsonPath := filepath.ToSlash(filepath.Join(folderRel, changeMachineFileJSON)) - output, err := outputCommand(rootPath, "git", "rev-list", "--full-history", "--topo-order", "HEAD", "--", mdPath, jsonPath) - if err != nil { - return nil, fmt.Errorf("enumerate conversion history for %s: %w", folderRel, err) - } - var findings []string - for _, commit := range strings.Fields(output) { - if conversionPreCheckedGrandfathers[commit] { - continue - } - parentsOutput, err := outputCommand(rootPath, "git", "rev-list", "--parents", "-n", "1", commit) - if err != nil { - return nil, fmt.Errorf("read parents for %s: %w", shortChangeCommit(commit), err) - } - ancestry := strings.Fields(parentsOutput) - if len(ancestry) == 0 || ancestry[0] != commit { - return nil, fmt.Errorf("read parents for %s: unexpected git response %q", shortChangeCommit(commit), strings.TrimSpace(parentsOutput)) - } - for _, parent := range ancestry[1:] { - diffOutput, err := outputCommand(rootPath, "git", "diff-tree", "--no-commit-id", "--name-status", "--no-renames", "-r", parent, commit, "--", folderRel) - if err != nil { - return nil, fmt.Errorf("compare %s with parent %s: %w", shortChangeCommit(commit), shortChangeCommit(parent), err) - } - deletedMD := false - addedJSON := false - deletedJSON := false - for _, line := range strings.Split(diffOutput, "\n") { - status, path, ok := strings.Cut(strings.TrimSpace(line), "\t") - path = filepath.ToSlash(strings.TrimSpace(path)) - if !ok || path == "" { - continue - } - base := filepath.Base(path) - switch { - case strings.HasPrefix(status, "D") && base == changeMachineFileLegacy && path == mdPath: - deletedMD = true - case strings.HasPrefix(status, "A") && base == changeMachineFileJSON && path == jsonPath: - addedJSON = true - case strings.HasPrefix(status, "D") && base == changeMachineFileJSON && path == jsonPath: - deletedJSON = true - } - } - if !(deletedMD && addedJSON && !deletedJSON) { - continue - } - offending, err := conversionCommitCheckedTaskFiles(rootPath, commit, folderRel, outputCommand) - if err != nil { - return nil, err - } - for _, path := range offending { - findings = append(findings, fmt.Sprintf("%s: conversion commit %s carries checked task checkbox(es); sanctioned conversion must land with all boxes unchecked", path, shortChangeCommit(commit))) - } - } - } - return sortedUnique(findings), nil -} - -func conversionCommitCheckedTaskFiles(rootPath, commit, folderRel string, outputCommand changeGitOutput) ([]string, error) { - tasksPrefix := filepath.ToSlash(filepath.Join(folderRel, "tasks")) + "/" - listOutput, err := outputCommand(rootPath, "git", "ls-tree", "-r", "--name-only", commit, "--", filepath.ToSlash(filepath.Join(folderRel, "tasks"))) - if err != nil { - return nil, fmt.Errorf("list tasks at %s: %w", shortChangeCommit(commit), err) - } - var offending []string - for _, path := range strings.Split(listOutput, "\n") { - path = filepath.ToSlash(strings.TrimSpace(path)) - if path == "" || !strings.HasPrefix(path, tasksPrefix) { - continue - } - if !changeTaskFileRE.MatchString(filepath.Base(path)) { - continue - } - content, err := outputCommand(rootPath, "git", "show", commit+":"+path) - if err != nil { - return nil, fmt.Errorf("read %s at %s: %w", path, shortChangeCommit(commit), err) - } - body := stripMarkdownCodeFences(content) - for _, m := range changeTaskCheckbox.FindAllStringSubmatch(body, -1) { - if strings.EqualFold(m[1], "x") { - offending = append(offending, path) - break - } - } - } - return offending, nil -} diff --git a/internal/cli/change_design_template.md b/internal/cli/change_design_template.md deleted file mode 100644 index 2244ccdb6..000000000 --- a/internal/cli/change_design_template.md +++ /dev/null @@ -1,20 +0,0 @@ -<!-- design.md is the optional design surface for UI, protocol, or schema detail - that would crowd shape.md or plan.md. Accretive; not load-bearing for the gate. --> - -# Design — [Change Title] - -## Intent - -[What this design clarifies that shape.md does not.] - -## Surfaces - -[Screens, APIs, schemas, or protocols under design.] - -## Decisions - -1. **[Decision.]** [Rationale.] - -## Open questions - -- [What remains undecided.] diff --git a/internal/cli/change_evidence.go b/internal/cli/change_evidence.go deleted file mode 100644 index fa8237108..000000000 --- a/internal/cli/change_evidence.go +++ /dev/null @@ -1,224 +0,0 @@ -package cli - -import ( - "crypto/sha256" - "encoding/hex" - "fmt" - "sort" - "strings" -) - -// ChangeEvidenceDigestSpec is the domain-separation / serialization version -// recorded on every v2 receipt as digest_spec. Bumping it expires all receipts. -const ChangeEvidenceDigestSpec = "v1" - -const changeEvidenceDigestDomain = "loaf/change-evidence-digest\nv1\n" - -// ChangeEvidenceReceiptMasks exclude every change's receipt surface from the -// scope digest so cohort members' receipts never stale each other. -var ChangeEvidenceReceiptMasks = []string{ - "docs/changes/*/receipts/**", -} - -// ChangeEvidenceReportMasks exclude authored report boards from the scope digest. -var ChangeEvidenceReportMasks = []string{ - "docs/changes/*/reports/**", -} - -// ReleaseMetadataAllowlist names paths the release/promotion ceremony may rewrite -// without changing receipt-bound content. The promotion Change imports this -// constant for its designation diff check so designation-legal ≡ receipt-neutral. -// -// Composition obligation: masking regenerated outputs (dist/**, plugins/**, bin/**) -// means the promotion designation check must independently prove they are the -// deterministic rebuild of source. -var ReleaseMetadataAllowlist = []string{ - "package.json", - ".claude-plugin/marketplace.json", - "CHANGELOG.md", - "dist/**", - "plugins/**", - "bin/**", -} - -// ChangeEvidenceExclusions is the full exclusion set for scopeDigest: -// receipts ∪ reports ∪ ReleaseMetadataAllowlist. Exported so the promotion -// Change can import the same boundary without redefining it. -func ChangeEvidenceExclusions() []string { - out := make([]string, 0, len(ChangeEvidenceReceiptMasks)+len(ChangeEvidenceReportMasks)+len(ReleaseMetadataAllowlist)) - out = append(out, ChangeEvidenceReceiptMasks...) - out = append(out, ChangeEvidenceReportMasks...) - out = append(out, ReleaseMetadataAllowlist...) - return out -} - -// changeTreeEntry is one ls-tree blob/commit entry used for digest construction. -type changeTreeEntry struct { - Mode string - OID string - Path string -} - -// changeScopeDigestResult holds the masked root digest and per-top-level-directory -// sub-digests derived from the same filtered, sorted entry stream. -type changeScopeDigestResult struct { - Digest string - Sections map[string]string -} - -// scopeDigest computes the content digest for treeish under exclusions. -// Serialization (pinned): over every git ls-tree -r -z --full-tree entry whose -// path matches no glob in exclusions, emit path\0mode\0oid\n, byte-sort -// ascending, prefix the domain header, SHA-256 hex. -// -// Glob grammar (component-anchored, byte-exact, case-sensitive): -// - literal segments match exactly -// - * matches exactly one path segment -// - ** as a trailing segment matches zero or more remaining segments -// -// Paths come from ls-tree only — never the filesystem — so quotePath, autocrlf, -// and case-folding cannot change the digest. -func scopeDigest(rootPath, treeish string, exclusions []string, outputCommand changeGitOutput) (changeScopeDigestResult, error) { - if outputCommand == nil { - outputCommand = commandOutput - } - raw, err := outputCommand(rootPath, "git", "ls-tree", "-r", "-z", "--full-tree", treeish) - if err != nil { - return changeScopeDigestResult{}, fmt.Errorf("ls-tree %s: %w", treeish, err) - } - entries, err := parseLSTreeNUL(raw) - if err != nil { - return changeScopeDigestResult{}, err - } - filtered := make([]changeTreeEntry, 0, len(entries)) - for _, e := range entries { - if evidencePathExcluded(e.Path, exclusions) { - continue - } - filtered = append(filtered, e) - } - sort.Slice(filtered, func(i, j int) bool { - return filtered[i].Path < filtered[j].Path - }) - digest := hashEvidenceEntries(filtered) - sections := map[string]string{} - bySection := map[string][]changeTreeEntry{} - for _, e := range filtered { - section := evidenceTopLevelSection(e.Path) - bySection[section] = append(bySection[section], e) - } - for section, sectionEntries := range bySection { - sections[section] = hashEvidenceEntries(sectionEntries) - } - return changeScopeDigestResult{Digest: digest, Sections: sections}, nil -} - -func hashEvidenceEntries(entries []changeTreeEntry) string { - var b strings.Builder - b.WriteString(changeEvidenceDigestDomain) - for _, e := range entries { - b.WriteString(e.Path) - b.WriteByte(0) - b.WriteString(e.Mode) - b.WriteByte(0) - b.WriteString(e.OID) - b.WriteByte('\n') - } - sum := sha256.Sum256([]byte(b.String())) - return hex.EncodeToString(sum[:]) -} - -func evidenceTopLevelSection(path string) string { - if i := strings.IndexByte(path, '/'); i >= 0 { - return path[:i] - } - return path -} - -// evidencePathExcluded reports whether path matches any exclusion glob. -func evidencePathExcluded(path string, exclusions []string) bool { - for _, pattern := range exclusions { - if matchEvidenceGlob(path, pattern) { - return true - } - } - return false -} - -// matchEvidenceGlob matches a git tree path against a component-anchored glob. -// Matching is byte-exact and case-sensitive; * is one segment; trailing ** is -// zero-or-more remaining segments. -func matchEvidenceGlob(path, pattern string) bool { - pathParts := splitPathSegments(path) - patternParts := splitPathSegments(pattern) - return matchEvidenceParts(pathParts, patternParts) -} - -func splitPathSegments(path string) []string { - if path == "" { - return nil - } - return strings.Split(path, "/") -} - -func matchEvidenceParts(pathParts, patternParts []string) bool { - pi, pti := 0, 0 - for pti < len(patternParts) { - pat := patternParts[pti] - if pat == "**" { - if pti == len(patternParts)-1 { - return true - } - // Non-trailing ** is not used by the exclusion set; treat as "match - // any prefix then resume" for completeness. - rest := patternParts[pti+1:] - for skip := 0; skip <= len(pathParts)-pi; skip++ { - if matchEvidenceParts(pathParts[pi+skip:], rest) { - return true - } - } - return false - } - if pi >= len(pathParts) { - return false - } - if pat != "*" && pat != pathParts[pi] { - return false - } - pi++ - pti++ - } - return pi == len(pathParts) -} - -// parseLSTreeNUL parses `git ls-tree -z` output into entries. -// Each record is: <mode> SP <type> SP <object> TAB <file> NUL -func parseLSTreeNUL(raw string) ([]changeTreeEntry, error) { - if raw == "" { - return nil, nil - } - records := strings.Split(raw, "\x00") - entries := make([]changeTreeEntry, 0, len(records)) - for _, rec := range records { - if rec == "" { - continue - } - tab := strings.IndexByte(rec, '\t') - if tab < 0 { - return nil, fmt.Errorf("ls-tree record missing tab: %q", rec) - } - meta := rec[:tab] - path := rec[tab+1:] - parts := strings.SplitN(meta, " ", 3) - if len(parts) != 3 { - return nil, fmt.Errorf("ls-tree record malformed meta: %q", rec) - } - mode, typ, oid := parts[0], parts[1], parts[2] - if typ != "blob" && typ != "commit" { - // -r lists blobs (and submodule commits); skip unexpected types. - continue - } - entries = append(entries, changeTreeEntry{Mode: mode, OID: oid, Path: path}) - } - return entries, nil -} diff --git a/internal/cli/change_evidence_test.go b/internal/cli/change_evidence_test.go deleted file mode 100644 index 8db9ac61f..000000000 --- a/internal/cli/change_evidence_test.go +++ /dev/null @@ -1,251 +0,0 @@ -package cli - -import ( - "os" - "path/filepath" - "testing" -) - -func TestChangeScopeDigest(t *testing.T) { - t.Run("identical-trees-digest-identically-regardless-of-quotePath", func(t *testing.T) { - repo := initCLIGitRepo(t) - writeEvidenceFixtureTree(t, repo) - commitAllChangeTest(t, repo, "chore: seed evidence tree") - - gitCLI(t, repo, "config", "core.quotePath", "true") - a, err := scopeDigest(repo, "HEAD", ChangeEvidenceExclusions(), nil) - if err != nil { - t.Fatalf("digest quotePath=true: %v", err) - } - gitCLI(t, repo, "config", "core.quotePath", "false") - b, err := scopeDigest(repo, "HEAD", ChangeEvidenceExclusions(), nil) - if err != nil { - t.Fatalf("digest quotePath=false: %v", err) - } - if a.Digest == "" || a.Digest != b.Digest { - t.Fatalf("digest mismatch under quotePath: %s vs %s", a.Digest, b.Digest) - } - if len(a.Sections) == 0 { - t.Fatal("expected scope_sections") - } - }) - - t.Run("sort-independent-of-traversal-order", func(t *testing.T) { - // Two trees with the same entries must digest identically even if we - // feed unsorted ls-tree output through the parser path — scopeDigest - // byte-sorts before hashing. - entries := []changeTreeEntry{ - {Mode: "100644", OID: "aaa", Path: "z.txt"}, - {Mode: "100644", OID: "bbb", Path: "a.txt"}, - {Mode: "100755", OID: "ccc", Path: "m/bin"}, - } - reversed := []changeTreeEntry{entries[2], entries[1], entries[0]} - sortedCopy := append([]changeTreeEntry(nil), entries...) - // Mimic scopeDigest's sort. - sortEvidenceEntries(sortedCopy) - sortEvidenceEntries(reversed) - if hashEvidenceEntries(sortedCopy) != hashEvidenceEntries(reversed) { - t.Fatal("byte-sort must make digest traversal-order independent") - } - }) - - t.Run("mode-change-changes-digest", func(t *testing.T) { - repo := initCLIGitRepo(t) - path := filepath.Join(repo, "script.sh") - if err := os.WriteFile(path, []byte("#!/bin/sh\necho hi\n"), 0o644); err != nil { - t.Fatalf("WriteFile: %v", err) - } - commitAllChangeTest(t, repo, "chore: add script non-executable") - before, err := scopeDigest(repo, "HEAD", ChangeEvidenceExclusions(), nil) - if err != nil { - t.Fatalf("digest before: %v", err) - } - gitCLI(t, repo, "update-index", "--chmod=+x", "script.sh") - gitCLI(t, repo, "-c", "user.name=Loaf Test", "-c", "user.email=loaf@example.test", "-c", "commit.gpgsign=false", "commit", "-m", "chore: mark executable") - after, err := scopeDigest(repo, "HEAD", ChangeEvidenceExclusions(), nil) - if err != nil { - t.Fatalf("digest after: %v", err) - } - if before.Digest == after.Digest { - t.Fatal("100644→100755 must change the digest") - } - }) - - t.Run("excluded-paths-never-participate", func(t *testing.T) { - repo := initCLIGitRepo(t) - writeEvidenceFixtureTree(t, repo) - commitAllChangeTest(t, repo, "chore: seed") - - baseline, err := scopeDigest(repo, "HEAD", ChangeEvidenceExclusions(), nil) - if err != nil { - t.Fatalf("baseline: %v", err) - } - - // Receipts-only commit. - receipt := filepath.Join(repo, "docs", "changes", "20260728-demo", "receipts", "verify.json") - if err := os.MkdirAll(filepath.Dir(receipt), 0o755); err != nil { - t.Fatalf("mkdir: %v", err) - } - if err := os.WriteFile(receipt, []byte(`{"schema_version":2}`+"\n"), 0o644); err != nil { - t.Fatalf("WriteFile receipt: %v", err) - } - commitAllChangeTest(t, repo, "chore: receipts only") - afterReceipt, err := scopeDigest(repo, "HEAD", ChangeEvidenceExclusions(), nil) - if err != nil { - t.Fatalf("after receipt: %v", err) - } - if afterReceipt.Digest != baseline.Digest { - t.Fatalf("receipts-only commit must leave digest unchanged: %s → %s", baseline.Digest, afterReceipt.Digest) - } - - // Reports-only commit. - report := filepath.Join(repo, "docs", "changes", "20260728-demo", "reports", "board.html") - if err := os.MkdirAll(filepath.Dir(report), 0o755); err != nil { - t.Fatalf("mkdir: %v", err) - } - if err := os.WriteFile(report, []byte("<html></html>\n"), 0o644); err != nil { - t.Fatalf("WriteFile report: %v", err) - } - commitAllChangeTest(t, repo, "chore: reports only") - afterReport, err := scopeDigest(repo, "HEAD", ChangeEvidenceExclusions(), nil) - if err != nil { - t.Fatalf("after report: %v", err) - } - if afterReport.Digest != baseline.Digest { - t.Fatalf("reports-only commit must leave digest unchanged: %s → %s", baseline.Digest, afterReport.Digest) - } - - // Allowlist paths. - if err := os.WriteFile(filepath.Join(repo, "package.json"), []byte(`{"version":"9.9.9"}`+"\n"), 0o644); err != nil { - t.Fatalf("WriteFile package.json: %v", err) - } - if err := os.MkdirAll(filepath.Join(repo, "dist"), 0o755); err != nil { - t.Fatalf("mkdir dist: %v", err) - } - if err := os.WriteFile(filepath.Join(repo, "dist", "out.js"), []byte("x\n"), 0o644); err != nil { - t.Fatalf("WriteFile dist: %v", err) - } - commitAllChangeTest(t, repo, "chore: release metadata only") - afterMeta, err := scopeDigest(repo, "HEAD", ChangeEvidenceExclusions(), nil) - if err != nil { - t.Fatalf("after meta: %v", err) - } - if afterMeta.Digest != baseline.Digest { - t.Fatalf("allowlist-only commit must leave digest unchanged") - } - - // A real code path must change it. - if err := os.WriteFile(filepath.Join(repo, "internal", "cli", "x.go"), []byte("package cli\n"), 0o644); err != nil { - t.Fatalf("WriteFile code: %v", err) - } - commitAllChangeTest(t, repo, "feat: real code") - afterCode, err := scopeDigest(repo, "HEAD", ChangeEvidenceExclusions(), nil) - if err != nil { - t.Fatalf("after code: %v", err) - } - if afterCode.Digest == baseline.Digest { - t.Fatal("non-excluded path must change digest") - } - if afterCode.Sections["internal"] == "" || afterCode.Sections["internal"] == baseline.Sections["internal"] { - t.Fatalf("internal section must drift: %#v vs %#v", baseline.Sections, afterCode.Sections) - } - }) - - t.Run("case-sensitive-mask-matching", func(t *testing.T) { - if matchEvidenceGlob("Docs/changes/x/receipts/verify.json", "docs/changes/*/receipts/**") { - t.Fatal("mask must be case-sensitive") - } - if !matchEvidenceGlob("docs/changes/x/receipts/verify.json", "docs/changes/*/receipts/**") { - t.Fatal("expected match for exact-case receipts path") - } - if matchEvidenceGlob("Package.json", "package.json") { - t.Fatal("package.json mask must be case-sensitive") - } - if !matchEvidenceGlob("dist/foo/bar.js", "dist/**") { - t.Fatal("dist/** must match nested paths") - } - if matchEvidenceGlob("distributor/x", "dist/**") { - t.Fatal("dist/** must not prefix-match unrelated paths") - } - }) - - t.Run("exclusions-exported-boundary", func(t *testing.T) { - got := ChangeEvidenceExclusions() - wantParts := []string{ - "docs/changes/*/receipts/**", - "docs/changes/*/reports/**", - "package.json", - ".claude-plugin/marketplace.json", - "CHANGELOG.md", - "dist/**", - "plugins/**", - "bin/**", - } - if len(got) != len(wantParts) { - t.Fatalf("exclusions = %#v, want %#v", got, wantParts) - } - for i, want := range wantParts { - if got[i] != want { - t.Fatalf("exclusions[%d] = %q, want %q", i, got[i], want) - } - } - if ChangeEvidenceDigestSpec != "v1" { - t.Fatalf("digest spec = %q, want v1", ChangeEvidenceDigestSpec) - } - if len(ReleaseMetadataAllowlist) == 0 { - t.Fatal("ReleaseMetadataAllowlist must be exported for promotion Change") - } - }) -} - -func writeEvidenceFixtureTree(t *testing.T, repo string) { - t.Helper() - files := map[string]string{ - "internal/cli/main.go": "package cli\n", - "content/skills/x.md": "# skill\n", - "weird name.txt": "space\n", - } - for rel, body := range files { - path := filepath.Join(repo, filepath.FromSlash(rel)) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatalf("mkdir %s: %v", rel, err) - } - if err := os.WriteFile(path, []byte(body), 0o644); err != nil { - t.Fatalf("WriteFile %s: %v", rel, err) - } - } -} - -func sortEvidenceEntries(entries []changeTreeEntry) { - // Local helper mirroring scopeDigest's byte-sort without importing sort in - // every call site of the test — keep deterministic for the fixture. - for i := 0; i < len(entries); i++ { - for j := i + 1; j < len(entries); j++ { - if entries[j].Path < entries[i].Path { - entries[i], entries[j] = entries[j], entries[i] - } - } - } -} - -func TestMatchEvidenceGlob(t *testing.T) { - cases := []struct { - path, pattern string - want bool - }{ - {"docs/changes/foo/receipts/verify.json", "docs/changes/*/receipts/**", true}, - {"docs/changes/foo/receipts", "docs/changes/*/receipts/**", true}, - {"docs/changes/foo/shape.md", "docs/changes/*/receipts/**", false}, - {"docs/changes/foo/bar/receipts/x", "docs/changes/*/receipts/**", false}, - {"bin/loaf", "bin/**", true}, - {"bin", "bin/**", true}, - {"CHANGELOG.md", "CHANGELOG.md", true}, - {"docs/CHANGELOG.md", "CHANGELOG.md", false}, - } - for _, tc := range cases { - got := matchEvidenceGlob(tc.path, tc.pattern) - if got != tc.want { - t.Fatalf("match(%q, %q) = %v, want %v", tc.path, tc.pattern, got, tc.want) - } - } -} diff --git a/internal/cli/change_json_test.go b/internal/cli/change_json_test.go index 2e9571d9f..d8a951f42 100644 --- a/internal/cli/change_json_test.go +++ b/internal/cli/change_json_test.go @@ -108,240 +108,3 @@ func TestAssembleLegacyLoadsTargetRelease(t *testing.T) { t.Fatalf("node = %+v", node) } } - -func TestDeriveChangeCohorts(t *testing.T) { - nodes := []changeNode{ - {Slug: "a", TargetRelease: "2.0.0", Folder: "docs/changes/20260710-a"}, - {Slug: "b", TargetRelease: "2.0.0", Folder: "docs/changes/20260711-b"}, - {Slug: "c", TargetRelease: "2.1.0", Folder: "docs/changes/20260712-c"}, - {Slug: "d", Folder: "docs/changes/20260713-d"}, - } - cohorts := deriveChangeCohorts(nodes) - if len(cohorts["2.0.0"]) != 2 || len(cohorts["2.1.0"]) != 1 || len(cohorts[""]) != 0 { - t.Fatalf("cohorts = %#v", cohorts) - } -} - -func TestChangeCheckFailsClosedOnMalformedJSONBesideMarkdown(t *testing.T) { - repo := initCLIGitRepo(t) - folder := writeChangeFolder(t, repo, "20260727-example", changeDoc(changeFrontmatter("example", "2026-07-27", "example"), append(productSections(), executableSections()...)...)) - if err := os.WriteFile(filepath.Join(folder, "change.json"), []byte(`{bad`), 0o644); err != nil { - t.Fatal(err) - } - out, err := runChangeCheckJSON(t, repo, "docs/changes/20260727-example") - if err == nil || !findingsContain(out.Findings, "malformed change.json") { - t.Fatalf("err=%v out=%+v", err, out) - } -} - -func TestDeriveChangeRetargetEventsUnionsSurfaces(t *testing.T) { - repo := initCLIGitRepo(t) - folder := writeChangeFolder(t, repo, "20260727-example", changeDoc( - "---\nchange: example\ncreated: 2026-07-27\nbranch: example\ntarget_release: 2.0.0\n---\n", - append(productSections(), executableSections()...)..., - )) - commitAllChangeTest(t, repo, "docs: add targeted change") - - jsonPath := filepath.Join(folder, "change.json") - if err := os.WriteFile(jsonPath, []byte(`{"change":"example","created":"2026-07-27","branch":"example","target_release":"2.1.0"}`), 0o644); err != nil { - t.Fatal(err) - } - if err := os.Remove(filepath.Join(folder, "change.md")); err != nil { - t.Fatal(err) - } - commitAllChangeTest(t, repo, "docs: convert and retarget") - - events, err := deriveChangeRetargetEvents(repo, commandOutput) - if err != nil { - t.Fatal(err) - } - found := false - for _, event := range events { - if event.Folder == "docs/changes/20260727-example" && event.From == "2.0.0" && event.To == "2.1.0" { - found = true - } - } - if !found { - t.Fatalf("events = %#v, want 2.0.0 -> 2.1.0", events) - } -} - -func TestRetentionAllowsAtomicConversionAndBlocksJSONTargetDeletion(t *testing.T) { - repo := initCLIGitRepo(t) - folder := writeChangeFolder(t, repo, "20260727-example", changeDoc( - "---\nchange: example\ncreated: 2026-07-27\nbranch: example\ntarget_release: 2.0.0\n---\n", - append(productSections(), executableSections()...)..., - )) - commitAllChangeTest(t, repo, "docs: add targeted change") - - if err := os.WriteFile(filepath.Join(folder, "change.json"), []byte(`{"change":"example","created":"2026-07-27","branch":"example","target_release":"2.0.0"}`), 0o644); err != nil { - t.Fatal(err) - } - if err := os.Remove(filepath.Join(folder, "change.md")); err != nil { - t.Fatal(err) - } - commitAllChangeTest(t, repo, "docs: atomic convert") - - deleted, err := deletedLineageChangesWithOutput(repo, commandOutput) - if err != nil { - t.Fatal(err) - } - if len(deleted) != 0 { - t.Fatalf("atomic conversion treated as deletion: %v", deleted) - } - - if err := os.Remove(filepath.Join(folder, "change.json")); err != nil { - t.Fatal(err) - } - commitAllChangeTest(t, repo, "docs: delete target-declaring json") - deleted, err = deletedLineageChangesWithOutput(repo, commandOutput) - if err != nil { - t.Fatal(err) - } - if len(deleted) == 0 { - t.Fatal("expected retention finding for deleted target-declaring change.json") - } -} - -func TestResolveChangeFolderFindsJSONLayout(t *testing.T) { - repo := initCLIGitRepo(t) - folder := filepath.Join(repo, "docs", "changes", "20260727-example") - if err := os.MkdirAll(folder, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(folder, "change.json"), []byte(`{"change":"example","created":"2026-07-27","branch":"example"}`), 0o644); err != nil { - t.Fatal(err) - } - gotFolder, gotFile, err := resolveChangeFolder(repo, "docs/changes/20260727-example") - if err != nil { - t.Fatal(err) - } - if filepath.Base(gotFolder) != "20260727-example" || filepath.Base(gotFile) != "change.json" { - t.Fatalf("folder=%q file=%q", gotFolder, gotFile) - } -} - -func taskPacketBody(change, id, title string, checked bool) string { - mark := " " - if checked { - mark = "x" - } - return "---\nchange: " + change + "\nid: " + id + "\ntitle: " + title + "\n---\n\n# " + id + " — " + title + "\n\n## Steps\n\n- [" + mark + "] Do the work\n" -} - -func atomicConvertFolder(t *testing.T, repo, folder, slug string, checked bool) { - t.Helper() - dir := filepath.Join(repo, "docs", "changes", folder) - if err := os.MkdirAll(filepath.Join(dir, "tasks"), 0o755); err != nil { - t.Fatalf("MkdirAll tasks: %v", err) - } - meta := "{\n \"change\": \"" + slug + "\",\n \"created\": \"2026-07-27\",\n \"branch\": \"" + slug + "\",\n \"target_release\": \"2.0.0\"\n}\n" - if err := os.WriteFile(filepath.Join(dir, "change.json"), []byte(meta), 0o644); err != nil { - t.Fatalf("WriteFile change.json: %v", err) - } - if err := os.WriteFile(filepath.Join(dir, "shape.md"), []byte(authoredShapeBody()), 0o644); err != nil { - t.Fatalf("WriteFile shape.md: %v", err) - } - taskName := "TASK-001-do-work.md" - if err := os.WriteFile(filepath.Join(dir, "tasks", taskName), []byte(taskPacketBody(slug, "TASK-001", "Do work", checked)), 0o644); err != nil { - t.Fatalf("WriteFile task: %v", err) - } - if err := os.Remove(filepath.Join(dir, "change.md")); err != nil { - t.Fatalf("Remove change.md: %v", err) - } -} - -func TestConversionPreCheckedBoxesAreCheckViolation(t *testing.T) { - repo := initCLIGitRepo(t) - folder := writeChangeFolder(t, repo, "20260727-prechecked", changeDoc( - "---\nchange: prechecked\ncreated: 2026-07-27\nbranch: prechecked\ntarget_release: 2.0.0\n---\n", - append(productSections(), executableSections()...)..., - )) - commitAllChangeTest(t, repo, "docs: add legacy targeted change") - atomicConvertFolder(t, repo, "20260727-prechecked", "prechecked", true) - commitAllChangeTest(t, repo, "docs: convert with pre-checked box") - - out, err := runChangeCheckJSON(t, repo, folder) - if err == nil || !findingsContain(out.Findings, "TASK-001-do-work.md") || !findingsContain(out.Findings, "checked task checkbox") { - t.Fatalf("err=%v findings=%v, want conversion violation naming TASK-001-do-work.md", err, out.Findings) - } -} - -func TestConversionAllUncheckedPassesAndRealDogfoodIsCovered(t *testing.T) { - repo := initCLIGitRepo(t) - folder := writeChangeFolder(t, repo, "20260727-clean-convert", changeDoc( - "---\nchange: clean-convert\ncreated: 2026-07-27\nbranch: clean-convert\ntarget_release: 2.0.0\n---\n", - append(productSections(), executableSections()...)..., - )) - commitAllChangeTest(t, repo, "docs: add legacy targeted change") - atomicConvertFolder(t, repo, "20260727-clean-convert", "clean-convert", false) - commitAllChangeTest(t, repo, "docs: atomic convert all unchecked") - - out, err := runChangeCheckJSON(t, repo, folder) - if err != nil { - t.Fatalf("clean conversion check err=%v findings=%v", err, out.Findings) - } - if findingsContain(out.Findings, "checked task checkbox") { - t.Fatalf("findings=%v, want no conversion checkbox violation", out.Findings) - } - - // This change's real dogfood conversion (acbea950) is the positive coverage - // target: grandfathered while INTENT-20260727-dogfood-conversion-manufactured-task-003-execution - // tracks remediation, check stays green and retention still treats it as replace. - repoRoot := filepath.Join("..", "..") - pilot := "docs/changes/20260726-change-work-model" - if _, err := os.Stat(filepath.Join(repoRoot, pilot, "change.json")); err != nil { - t.Skipf("change-work-model folder not present: %v", err) - } - findings, err := conversionPreCheckedFindings(repoRoot, pilot, commandOutput) - if err != nil { - t.Fatalf("real conversion findings err=%v", err) - } - if len(findings) != 0 { - t.Fatalf("real dogfood conversion findings=%v, want none (grandfathered acbea950)", findings) - } - // The dogfood commit lived on the change-work-model branch; the squash - // merge of PR #141 replaced it and branch deletion made it unreachable, so - // clones and CI checkouts legitimately do not have the object. The scanner - // leg below is history-dependent extra coverage, not the load-bearing - // assertion (the synthetic fixtures above are); skip it when the commit is - // absent rather than failing on a correct checkout. - const dogfoodConversion = "acbea95001f9187b154d095f4579225b7744fe1d" - if _, err := commandOutput(repoRoot, "git", "cat-file", "-e", dogfoodConversion+"^{commit}"); err != nil { - t.Skipf("dogfood conversion commit %s unreachable after squash merge: %v", dogfoodConversion[:8], err) - } - offending, err := conversionCommitCheckedTaskFiles(repoRoot, dogfoodConversion, pilot, commandOutput) - if err != nil { - t.Fatalf("inspect acbea950 tasks: %v", err) - } - if !findingsContain(offending, "TASK-003-check-and-projections.md") { - t.Fatalf("acbea950 offending=%v, want TASK-003 detected by the scanner (grandfather only suppresses the finding)", offending) - } -} - -func TestTwoCommitConversionStillBlocksWithRetentionFinding(t *testing.T) { - repo := initCLIGitRepo(t) - folder := writeChangeFolder(t, repo, "20260727-two-step", changeDoc( - "---\nchange: two-step\ncreated: 2026-07-27\nbranch: two-step\ntarget_release: 2.0.0\n---\n", - append(productSections(), executableSections()...)..., - )) - commitAllChangeTest(t, repo, "docs: add targeted legacy change") - - if err := os.WriteFile(filepath.Join(folder, "change.json"), []byte(`{"change":"two-step","created":"2026-07-27","branch":"two-step","target_release":"2.0.0"}`), 0o644); err != nil { - t.Fatal(err) - } - commitAllChangeTest(t, repo, "docs: add change.json keep-both") - - if err := os.Remove(filepath.Join(folder, "change.md")); err != nil { - t.Fatal(err) - } - commitAllChangeTest(t, repo, "docs: retire change.md later") - - deleted, err := deletedLineageChangesWithOutput(repo, commandOutput) - if err != nil { - t.Fatal(err) - } - if !findingsContain(deleted, "docs/changes/20260727-two-step/change.md") { - t.Fatalf("deleted=%v, want two-commit conversion retention finding for change.md", deleted) - } -} diff --git a/internal/cli/change_layout.go b/internal/cli/change_layout.go index 2dead52ff..ca6245f91 100644 --- a/internal/cli/change_layout.go +++ b/internal/cli/change_layout.go @@ -20,17 +20,6 @@ const ( changeBriefFile = "brief.md" ) -// changeRetargetEvent records a target_release mutation derived from unioned -// history across change.json and change.md surfaces. -type changeRetargetEvent struct { - Folder string - Slug string - From string - To string - Commit string - Surface string -} - // assembleChangeNodeFromFolder builds a layout-agnostic node from a working-tree // Change folder. change.json presence selects the new layout; its absence falls // back to legacy change.md. A present but malformed change.json fails closed. @@ -186,147 +175,3 @@ func changeFolderHasMachineSurface(folderAbs string) bool { } return false } - -func deriveChangeCohorts(nodes []changeNode) map[string][]changeNode { - cohorts := map[string][]changeNode{} - for _, node := range nodes { - if node.TargetRelease == "" { - continue - } - cohorts[node.TargetRelease] = append(cohorts[node.TargetRelease], node) - } - for target, members := range cohorts { - sort.Slice(members, func(i, j int) bool { return members[i].Folder < members[j].Folder }) - cohorts[target] = members - } - return cohorts -} - -func changeFolderRelFromMachinePath(path string) string { - path = filepath.ToSlash(path) - if strings.HasSuffix(path, "/"+changeMachineFileJSON) || strings.HasSuffix(path, "/"+changeMachineFileLegacy) { - return filepath.ToSlash(filepath.Dir(path)) - } - return path -} - -// deriveChangeRetargetEvents unions change.json and change.md histories per -// folder and returns target_release mutations (including removal-to-none). -// Retargets are surfaced, never blocked. -func deriveChangeRetargetEvents(rootPath string, outputCommand changeGitOutput) ([]changeRetargetEvent, error) { - output, err := outputCommand(rootPath, "git", "ls-tree", "-r", "--name-only", "HEAD", "--", "docs/changes") - if err != nil { - return nil, fmt.Errorf("inspect Change paths at HEAD: %w", err) - } - folders := map[string]bool{} - for _, path := range strings.Split(strings.TrimSpace(output), "\n") { - path = filepath.ToSlash(strings.TrimSpace(path)) - base := filepath.Base(path) - if base == changeMachineFileJSON || base == changeMachineFileLegacy { - folders[filepath.ToSlash(filepath.Dir(path))] = true - } - } - // Also include folders that only exist in history (deleted) — retarget - // surfacing for current HEAD nodes is enough for TASK-001 consumers. - folderList := sortedKeys(folders) - var events []changeRetargetEvent - for _, folder := range folderList { - folderEvents, err := deriveFolderRetargetEvents(rootPath, folder, outputCommand) - if err != nil { - return nil, err - } - events = append(events, folderEvents...) - } - return events, nil -} - -func deriveFolderRetargetEvents(rootPath, folder string, outputCommand changeGitOutput) ([]changeRetargetEvent, error) { - mdPath := filepath.ToSlash(filepath.Join(folder, changeMachineFileLegacy)) - jsonPath := filepath.ToSlash(filepath.Join(folder, changeMachineFileJSON)) - versions, err := loadUnionTargetHistory(rootPath, folder, mdPath, jsonPath, outputCommand) - if err != nil { - return nil, err - } - var events []changeRetargetEvent - prev := "" - havePrev := false - for _, version := range versions { - if !havePrev { - prev = version.Target - havePrev = true - continue - } - if version.Target == prev { - continue - } - events = append(events, changeRetargetEvent{ - Folder: folder, - Slug: version.Slug, - From: prev, - To: version.Target, - Commit: version.Commit, - Surface: version.Surface, - }) - prev = version.Target - } - return events, nil -} - -type changeTargetVersion struct { - Commit string - Target string - Slug string - Surface string -} - -func loadUnionTargetHistory(rootPath, folder, mdPath, jsonPath string, outputCommand changeGitOutput) ([]changeTargetVersion, error) { - commitsOutput, err := outputCommand(rootPath, "git", "rev-list", "--full-history", "--topo-order", "--reverse", "HEAD", "--", mdPath, jsonPath) - if err != nil { - return nil, fmt.Errorf("read %s target history: %w", folder, err) - } - commits := strings.Fields(commitsOutput) - var versions []changeTargetVersion - for _, commit := range commits { - jsonContent, jsonOK, err := readCommittedOptional(rootPath, commit, jsonPath, outputCommand) - if err != nil { - return nil, err - } - mdContent, mdOK, err := readCommittedOptional(rootPath, commit, mdPath, outputCommand) - if err != nil { - return nil, err - } - if !jsonOK && !mdOK { - continue - } - version := changeTargetVersion{Commit: commit} - if jsonOK { - meta := parseChangeJSON(jsonContent) - version.Target = meta.TargetRelease - version.Slug = meta.Change - version.Surface = changeMachineFileJSON - } else if mdOK { - fields, _ := changeFrontmatterFields(mdContent) - version.Target = changeFieldValue(fields, "target_release") - version.Slug = changeFieldValue(fields, "change") - version.Surface = changeMachineFileLegacy - } - versions = append(versions, version) - } - return versions, nil -} - -func readCommittedOptional(rootPath, commit, path string, outputCommand changeGitOutput) (string, bool, error) { - treePath, err := outputCommand(rootPath, "git", "ls-tree", "--name-only", commit, "--", path) - if err != nil { - return "", false, fmt.Errorf("inspect %s at %s: %w", path, shortChangeCommit(commit), err) - } - treePath = filepath.ToSlash(strings.TrimSpace(treePath)) - if treePath == "" { - return "", false, nil - } - content, err := outputCommand(rootPath, "git", "show", commit+":"+path) - if err != nil { - return "", false, fmt.Errorf("read %s at %s: %w", path, shortChangeCommit(commit), err) - } - return content, true, nil -} diff --git a/internal/cli/change_lineage.go b/internal/cli/change_lineage.go index f2f8542b8..ab72edbc4 100644 --- a/internal/cli/change_lineage.go +++ b/internal/cli/change_lineage.go @@ -195,9 +195,6 @@ func deriveChangeGraph(nodes []changeNode) changeGraph { g.addFinding(lineage, fmt.Sprintf("lineage %q has multiple roots: %s", lineage, strings.Join(roots, ", "))) } else if len(roots) == 1 { root := lineageBySlug[roots[0]] - if root.ReleaseAfter == "" { - g.addGap(lineage, fmt.Sprintf("lineage %q root %q must declare release-after", lineage, root.Slug)) - } for _, node := range lineageNodes { if node.Slug != root.Slug && node.ReleaseAfter != "" { g.addFinding(lineage, fmt.Sprintf("Change %q declares release-after; lineage %q root %q must own the declaration", node.Slug, lineage, root.Slug)) @@ -233,9 +230,7 @@ func deriveChangeGraph(nodes []changeNode) changeGraph { g.addFinding(lineage, fmt.Sprintf("lineage %q has conflicting release-after terminals: %s", lineage, strings.Join(terminalNames, ", "))) } else if len(terminalNames) == 1 { terminal, ok := lineageBySlug[terminalNames[0]] - if !ok { - g.addGap(lineage, fmt.Sprintf("release-after terminal %q is not materialized", terminalNames[0])) - } else if len(children[terminal.Slug]) != 0 { + if ok && len(children[terminal.Slug]) != 0 { g.addFinding(lineage, fmt.Sprintf("release-after %q is not the lineage terminal", terminal.Slug)) } } @@ -273,403 +268,29 @@ func changeLineageHasCycle(nodes map[string]changeNode) bool { return false } -func applyLineageValidation(report changeCheckReport, nodes []changeNode, targetPath, rootPath string, requireExecutable bool) changeCheckReport { - graph := deriveChangeGraph(nodes) - target, ok := graph.nodeByPath(targetPath) - if !ok { - report.Violations = append(report.Violations, fmt.Sprintf("checked Change %s is absent from the derived graph", targetPath)) - return report - } - report.Violations = append(report.Violations, graph.findingsForChange(target)...) - lineageGaps := graph.gapsForLineage(target.Lineage) - executionGaps := executionRelevantLineageGaps(lineageGaps) - report.Gaps = append(report.Gaps, executionGaps...) - if requireExecutable { - report.Gaps = append(report.Gaps, committedPredecessorGaps(rootPath, target)...) - } - for _, gap := range lineageGaps { - if strings.HasPrefix(gap, "release-after terminal ") { - report.Warnings = append(report.Warnings, gap) - } - } - report.Violations = sortedUnique(report.Violations) - report.Warnings = sortedUnique(report.Warnings) - report.Gaps = sortedUnique(report.Gaps) - report.Executable = report.Executable && len(report.Violations) == 0 && len(report.Gaps) == 0 - return report -} - -func committedPredecessorGaps(rootPath string, target changeNode) []string { - if target.Predecessor == "" { - return nil - } - nodes, err := loadChangeNodesAtHEAD(rootPath) - if err != nil { - return []string{fmt.Sprintf("cannot inspect committed HEAD Change graph: %v", err)} - } - graph := deriveChangeGraph(nodes) - bySlug := map[string]changeNode{} - for _, node := range nodes { - bySlug[node.Slug] = node - } - var gaps []string - seen := map[string]bool{} - for slug := target.Predecessor; slug != ""; { - if seen[slug] { - break - } - seen[slug] = true - node, ok := bySlug[slug] - if !ok || node.Lineage != target.Lineage { - gaps = append(gaps, fmt.Sprintf("predecessor %q is not committed and retained in HEAD", slug)) - break - } - doc := evaluateChangeDocAtPath(node.Content, filepath.Base(node.Folder), "", node.ChangeFile) - lineageGaps := executionRelevantLineageGaps(graph.gapsForLineage(node.Lineage)) - if len(doc.Violations) != 0 || !doc.Executable || len(graph.findingsForLineage(node.Lineage)) != 0 || len(lineageGaps) != 0 { - gaps = append(gaps, fmt.Sprintf("committed predecessor %q is not structurally executable", slug)) - } - slug = node.Predecessor - } - sort.Strings(gaps) - return gaps -} - -func releaseLineagePreflight(rootPath string) error { - return releaseLineagePreflightWithOutputAndOptions(rootPath, commandOutput, false) -} - -func releaseLineagePreflightWithOutput(rootPath string, outputCommand changeGitOutput) error { - return releaseLineagePreflightWithOutputAndOptions(rootPath, outputCommand, false) -} - -func releaseLineagePreflightWithOptions(rootPath string, allowPrerelease bool) error { - return releaseLineagePreflightWithOutputAndOptions(rootPath, commandOutput, allowPrerelease) -} - -func releaseLineagePreflightWithOutputAndOptions(rootPath string, outputCommand changeGitOutput, allowPrerelease bool) error { - _ = allowPrerelease - configOverrides, err := releaseConfigVersionFiles(rootPath) - if err != nil { - return fmt.Errorf("release blocked: %w", err) - } - versionFiles, err := detectReleaseVersionFiles(rootPath, configOverrides) - if err != nil { - return fmt.Errorf("release blocked: %w", err) - } - candidate := "0.0.0-dev" - if len(versionFiles) > 0 { - candidate = versionFiles[0].CurrentVersion - } - return releaseCohortPreflightWithOutput(rootPath, candidate, outputCommand, nil) -} - -func removeChangeGraphGap(gaps []string, ignored string) []string { - filtered := make([]string, 0, len(gaps)) - for _, gap := range gaps { - if gap != ignored { - filtered = append(filtered, gap) - } - } - return filtered -} - -func requireCompleteChangeHistory(rootPath string, outputCommand changeGitOutput) error { - output, err := outputCommand(rootPath, "git", "rev-parse", "--is-shallow-repository") - if err != nil { - return fmt.Errorf("inspect repository history depth: %w", err) - } - switch strings.TrimSpace(output) { - case "false": - return nil - case "true": - return fmt.Errorf("repository is shallow; fetch complete history with `git fetch --unshallow` before releasing") - default: - return fmt.Errorf("inspect repository history depth: unexpected git response %q", strings.TrimSpace(output)) - } -} - -func deletedLineageChangesWithOutput(rootPath string, outputCommand changeGitOutput) ([]string, error) { - output, err := outputCommand(rootPath, "git", "rev-list", "--full-history", "--topo-order", "HEAD", "--", "docs/changes") - if err != nil { - return nil, fmt.Errorf("enumerate Change history: %w", err) - } - var deleted []string - for _, commit := range strings.Fields(output) { - parentsOutput, err := outputCommand(rootPath, "git", "rev-list", "--parents", "-n", "1", commit) - if err != nil { - return nil, fmt.Errorf("read parents for %s: %w", shortChangeCommit(commit), err) - } - ancestry := strings.Fields(parentsOutput) - if len(ancestry) == 0 || ancestry[0] != commit { - return nil, fmt.Errorf("read parents for %s: unexpected git response %q", shortChangeCommit(commit), strings.TrimSpace(parentsOutput)) - } - for _, parent := range ancestry[1:] { - diffOutput, err := outputCommand(rootPath, "git", "diff-tree", "--no-commit-id", "--name-status", "--no-renames", "-r", parent, commit, "--", "docs/changes") - if err != nil { - return nil, fmt.Errorf("compare %s with parent %s: %w", shortChangeCommit(commit), shortChangeCommit(parent), err) - } - type folderDiff struct { - deletedMD bool - deletedJSON bool - addedJSON bool - mdPath string - jsonPath string - } - byFolder := map[string]*folderDiff{} - for _, line := range strings.Split(diffOutput, "\n") { - status, path, ok := strings.Cut(strings.TrimSpace(line), "\t") - path = filepath.ToSlash(strings.TrimSpace(path)) - if !ok || path == "" { - continue - } - base := filepath.Base(path) - if base != changeMachineFileLegacy && base != changeMachineFileJSON { - continue - } - folder := filepath.ToSlash(filepath.Dir(path)) - entry := byFolder[folder] - if entry == nil { - entry = &folderDiff{} - byFolder[folder] = entry - } - switch { - case strings.HasPrefix(status, "D") && base == changeMachineFileLegacy: - entry.deletedMD = true - entry.mdPath = path - case strings.HasPrefix(status, "D") && base == changeMachineFileJSON: - entry.deletedJSON = true - entry.jsonPath = path - case strings.HasPrefix(status, "A") && base == changeMachineFileJSON: - entry.addedJSON = true - entry.jsonPath = path - } - } - for folder, entry := range byFolder { - // Sanctioned atomic conversion: retire change.md and add change.json - // in the same commit. That is replacement, not retention loss. - if entry.deletedMD && entry.addedJSON && !entry.deletedJSON { - continue - } - if entry.deletedMD { - retained, err := changePathHadRetentionSignalInHistory(rootPath, parent, entry.mdPath, outputCommand) - if err != nil { - return nil, err - } - if retained { - deleted = append(deleted, entry.mdPath) - } - } - if entry.deletedJSON { - jsonPath := entry.jsonPath - if jsonPath == "" { - jsonPath = filepath.ToSlash(filepath.Join(folder, changeMachineFileJSON)) - } - retained, err := changePathHadRetentionSignalInHistory(rootPath, parent, jsonPath, outputCommand) - if err != nil { - return nil, err - } - if retained { - deleted = append(deleted, jsonPath) - } - } - } - } - } - return sortedUnique(deleted), nil -} - -func changePathHadLineageInHistory(rootPath string, ref string, path string, outputCommand changeGitOutput) (bool, error) { - return changePathHadRetentionSignalInHistory(rootPath, ref, path, outputCommand) -} - -func changePathHadRetentionSignalInHistory(rootPath string, ref string, path string, outputCommand changeGitOutput) (bool, error) { - output, err := outputCommand(rootPath, "git", "rev-list", "--full-history", "--topo-order", ref, "--", path) - if err != nil { - return false, fmt.Errorf("enumerate %s history from %s: %w", path, shortChangeCommit(ref), err) - } - commits := strings.Fields(output) - if len(commits) == 0 { - return false, fmt.Errorf("enumerate %s history from %s: no commits found for deleted path", path, shortChangeCommit(ref)) - } - for _, commit := range commits { - treePath, err := outputCommand(rootPath, "git", "ls-tree", "--name-only", commit, "--", path) - if err != nil { - return false, fmt.Errorf("inspect %s at %s: %w", path, shortChangeCommit(commit), err) - } - treePath = filepath.ToSlash(strings.TrimSpace(treePath)) - if treePath == "" { - continue - } - if treePath != path { - return false, fmt.Errorf("inspect %s at %s: unexpected git path %q", path, shortChangeCommit(commit), treePath) - } - content, err := outputCommand(rootPath, "git", "show", commit+":"+path) - if err != nil { - return false, fmt.Errorf("read %s at %s: %w", path, shortChangeCommit(commit), err) - } - if strings.HasSuffix(path, "/"+changeMachineFileJSON) { - if changeJSONDeclaresTarget(content) { - return true, nil - } - continue - } - parsed := parseChangeFrontmatter(content) - if hasNonEmptyChangeField(parsed.Fields, "lineage") || hasNonEmptyChangeField(parsed.Fields, "release-after") || hasNonEmptyChangeField(parsed.Fields, "target_release") { - return true, nil - } - } - return false, nil -} - -func changeJSONDeclaresTarget(content string) bool { - meta := parseChangeJSON(content) - if meta.TargetRelease != "" { - return true - } - // Malformed historical versions that still named the field count as a - // retention signal so delete/re-add cannot launder a declared target away. - trimmed := strings.TrimSpace(content) - return strings.Contains(trimmed, `"target_release"`) -} - -type dependencyMetadataVersion struct { - Commit string - Lineage string - ReleaseAfter string - Problems []string - Duplicate []string -} - -func dependencyMetadataHistoryFindings(rootPath string, nodes []changeNode, outputCommand changeGitOutput) ([]string, error) { - var findings []string - for _, node := range nodes { - // Lineage/release-after freeze still keys on the markdown surface. New - // layout nodes without a historical change.md simply have no frozen - // dependency metadata; target_release mutability is handled separately. - historyPath := filepath.ToSlash(filepath.Join(node.Folder, changeMachineFileLegacy)) - if node.Layout == changeLayoutLegacy { - historyPath = node.ChangeFile - } - commitsOutput, err := outputCommand(rootPath, "git", "rev-list", "--full-history", "--topo-order", "--reverse", "HEAD", "--", historyPath) - if err != nil { - return nil, fmt.Errorf("read %s history: %w", historyPath, err) - } - commits := strings.Fields(commitsOutput) - if len(commits) == 0 { - continue - } - versions := make([]dependencyMetadataVersion, 0, len(commits)) - hasDependencyMetadata := false - for _, commit := range commits { - content, ok, err := readCommittedOptional(rootPath, commit, historyPath, outputCommand) - if err != nil { - return nil, err - } - if !ok { - continue - } - parsed := parseChangeFrontmatter(content) - version := dependencyMetadataVersion{ - Commit: commit, - Lineage: changeFieldValue(parsed.Fields, "lineage"), - ReleaseAfter: changeFieldValue(parsed.Fields, "release-after"), - Problems: changeFrontmatterInspectionProblems(parsed), - } - if countChangeFields(parsed.Fields, "lineage") > 1 { - version.Duplicate = append(version.Duplicate, "lineage") - } - if countChangeFields(parsed.Fields, "release-after") > 1 { - version.Duplicate = append(version.Duplicate, "release-after") - } - if version.Lineage != "" || version.ReleaseAfter != "" { - hasDependencyMetadata = true - } - versions = append(versions, version) - } - if !hasDependencyMetadata { - continue - } - for _, version := range versions { - if len(version.Problems) != 0 { - return nil, fmt.Errorf("parse %s at %s: %s", historyPath, shortChangeCommit(version.Commit), strings.Join(version.Problems, "; ")) - } - if len(version.Duplicate) != 0 { - return nil, fmt.Errorf("parse %s at %s: duplicate %s field", historyPath, shortChangeCommit(version.Commit), strings.Join(version.Duplicate, " and ")) - } - } - findings = append(findings, immutableDependencyFieldFindings(historyPath, "lineage", versions, func(version dependencyMetadataVersion) string { return version.Lineage })...) - findings = append(findings, immutableDependencyFieldFindings(historyPath, "release-after", versions, func(version dependencyMetadataVersion) string { return version.ReleaseAfter })...) - } - return sortedUnique(findings), nil -} - -func immutableDependencyFieldFindings(path string, field string, versions []dependencyMetadataVersion, valueOf func(dependencyMetadataVersion) string) []string { - frozenValue := "" - frozenCommit := "" - var findings []string - for _, version := range versions { - value := valueOf(version) - if frozenValue == "" { - if value != "" { - frozenValue = value - frozenCommit = version.Commit - } - continue - } - if value != frozenValue { - findings = append(findings, fmt.Sprintf("%s changed %s from %q (set at %s) to %q at %s", path, field, frozenValue, shortChangeCommit(frozenCommit), value, shortChangeCommit(version.Commit))) - } - } - return findings -} - -func changeFrontmatterInspectionProblems(parsed changeFrontmatterParse) []string { - var problems []string - if !parsed.AtByteOne { - problems = append(problems, "frontmatter must open at byte one") - } - problems = append(problems, parsed.Findings...) - return sortedUnique(problems) -} - -func shortChangeCommit(commit string) string { - if len(commit) > 12 { - return commit[:12] - } - return commit -} - -func (g changeGraph) nodeByPath(path string) (changeNode, bool) { - path = filepath.ToSlash(path) - folder := changeFolderRelFromMachinePath(path) - for _, node := range g.Nodes { - if node.ChangeFile == path || node.Folder == path || node.Folder == folder || node.ContractFile == path { - return node, true - } - } - return changeNode{}, false -} - func (g changeGraph) findingsForLineage(lineage string) []string { findings := append([]string{}, g.GlobalFindings...) findings = append(findings, g.findingsByLineage[lineage]...) findings = append(findings, g.localFindingsByLineage[lineage]...) return sortedUnique(findings) } + func (g changeGraph) findingsForChange(node changeNode) []string { if node.Lineage != "" { return g.findingsForLineage(node.Lineage) } return sortedUnique(append(append([]string{}, g.GlobalFindings...), g.localFindingsByChange[node.ChangeFile]...)) } + func (g changeGraph) gapsForLineage(lineage string) []string { return sortedUnique(g.gapsByLineage[lineage]) } + func (g *changeGraph) addFinding(lineage, finding string) { g.Findings = append(g.Findings, finding) g.findingsByLineage[lineage] = append(g.findingsByLineage[lineage], finding) } + func (g *changeGraph) addLocalFinding(node changeNode, finding string) { g.Findings = append(g.Findings, finding) g.localFindingsByChange[node.ChangeFile] = append(g.localFindingsByChange[node.ChangeFile], finding) @@ -677,14 +298,17 @@ func (g *changeGraph) addLocalFinding(node changeNode, finding string) { g.localFindingsByLineage[node.Lineage] = append(g.localFindingsByLineage[node.Lineage], finding) } } + func (g *changeGraph) addGlobalFinding(finding string) { g.Findings = append(g.Findings, finding) g.GlobalFindings = append(g.GlobalFindings, finding) } + func (g *changeGraph) addGap(lineage, gap string) { g.Gaps = append(g.Gaps, gap) g.gapsByLineage[lineage] = append(g.gapsByLineage[lineage], gap) } + func (g *changeGraph) sort() { g.Findings = sortedUnique(g.Findings) g.Gaps = sortedUnique(g.Gaps) @@ -692,23 +316,6 @@ func (g *changeGraph) sort() { sort.Slice(g.Nodes, func(i, j int) bool { return g.Nodes[i].ChangeFile < g.Nodes[j].ChangeFile }) } -func countChangeFields(fields []changeFrontmatterField, key string) int { - count := 0 - for _, field := range fields { - if strings.EqualFold(field.Key, key) { - count++ - } - } - return count -} -func hasNonEmptyChangeField(fields []changeFrontmatterField, key string) bool { - for _, field := range fields { - if strings.EqualFold(field.Key, key) && field.Value != "" { - return true - } - } - return false -} func joinChangePaths(nodes []changeNode) string { paths := make([]string, 0, len(nodes)) for _, node := range nodes { @@ -717,6 +324,7 @@ func joinChangePaths(nodes []changeNode) string { sort.Strings(paths) return strings.Join(paths, ", ") } + func sortedUnique(values []string) []string { seen := map[string]bool{} out := []string{} @@ -729,6 +337,7 @@ func sortedUnique(values []string) []string { sort.Strings(out) return out } + func sortedKeys[V any](values map[string]V) []string { keys := make([]string, 0, len(values)) for key := range values { @@ -737,13 +346,3 @@ func sortedKeys[V any](values map[string]V) []string { sort.Strings(keys) return keys } - -func executionRelevantLineageGaps(gaps []string) []string { - var relevant []string - for _, gap := range gaps { - if !strings.HasPrefix(gap, "release-after terminal ") { - relevant = append(relevant, gap) - } - } - return sortedUnique(relevant) -} diff --git a/internal/cli/change_list.go b/internal/cli/change_list.go deleted file mode 100644 index 3cfc4907b..000000000 --- a/internal/cli/change_list.go +++ /dev/null @@ -1,122 +0,0 @@ -package cli - -import ( - "fmt" - "io" - "sort" - "strings" -) - -type changeListUnitJSON struct { - Command string `json:"command"` - Target string `json:"target,omitempty"` - Units []changeListUnit `json:"units"` - Warnings []string `json:"warnings,omitempty"` -} - -type changeListUnit struct { - Slug string `json:"slug"` - Folder string `json:"folder"` - Layout string `json:"layout"` - Branch string `json:"branch,omitempty"` - TargetRelease string `json:"targetRelease,omitempty"` - State string `json:"state"` - PathExecuted bool `json:"pathExecuted,omitempty"` - FlipExecuted bool `json:"flipExecuted,omitempty"` - Warnings []string `json:"warnings,omitempty"` -} - -type changeListOptionsV2 struct { - target string - jsonOutput bool -} - -func parseChangeListArgsV2(args []string) (changeListOptionsV2, error) { - options := changeListOptionsV2{} - for i := 0; i < len(args); i++ { - arg := args[i] - switch { - case arg == "--json": - options.jsonOutput = true - case arg == "--target": - if i+1 >= len(args) { - return options, fmt.Errorf("--target requires a value") - } - i++ - options.target = args[i] - case strings.HasPrefix(arg, "--target="): - options.target = strings.TrimPrefix(arg, "--target=") - case arg == "--lineage" || strings.HasPrefix(arg, "--lineage="): - return options, fmt.Errorf("--lineage retired; use loaf change list [--target <version>] for the units/cohort projection") - case strings.HasPrefix(arg, "-"): - return options, fmt.Errorf("unknown change list option %q", arg) - default: - return options, fmt.Errorf("change list accepts no positional arguments") - } - } - if options.target != "" && !isCanonicalChangeTargetRelease(options.target) { - return options, fmt.Errorf("target %q must be canonical MAJOR.MINOR.PATCH", options.target) - } - return options, nil -} - -func (r Runner) runChangeListUnits(args []string, out io.Writer, rootPath string) error { - options, err := parseChangeListArgsV2(args) - if err != nil { - return err - } - nodes, err := loadChangeNodes(rootPath) - if err != nil { - return err - } - units := []changeListUnit{} - var listWarnings []string - for _, node := range nodes { - if options.target != "" && node.TargetRelease != options.target { - continue - } - status, statusErr := changeFolderExecuted(rootPath, node.Folder, node.Layout, commandOutput) - state, stateWarnings := deriveChangeStateDetailed(rootPath, node, changeEvidenceGitOutput) - if statusErr != nil { - stateWarnings = append(stateWarnings, "execution provenance failed: "+statusErr.Error()) - } - units = append(units, changeListUnit{ - Slug: node.Slug, - Folder: node.Folder, - Layout: node.Layout, - Branch: node.Branch, - TargetRelease: node.TargetRelease, - State: state, - PathExecuted: status.PathExecuted, - FlipExecuted: status.FlipExecuted, - Warnings: append([]string{}, stateWarnings...), - }) - for _, w := range stateWarnings { - listWarnings = append(listWarnings, fmt.Sprintf("%s: %s", node.Slug, w)) - } - } - sort.Slice(units, func(i, j int) bool { return units[i].Folder < units[j].Folder }) - result := changeListUnitJSON{Command: "change list", Target: options.target, Units: units, Warnings: sortedUnique(listWarnings)} - if options.jsonOutput { - return writeJSON(out, result) - } - fmt.Fprintf(out, "\n%s\n", ansiBold("change list")) - if options.target != "" { - fmt.Fprintf(out, "target: %s\n", options.target) - } - for _, unit := range units { - target := unit.TargetRelease - if target == "" { - target = "-" - } - fmt.Fprintf(out, " %s %s layout=%s target=%s state=%s\n", - unit.Slug, unit.Folder, unit.Layout, target, unit.State) - for _, w := range unit.Warnings { - fmt.Fprintf(out, " %s %s\n", ansiYellow("warn:"), w) - } - } - if len(units) == 0 { - fmt.Fprintf(out, " (no changes)\n") - } - return nil -} diff --git a/internal/cli/change_list_test.go b/internal/cli/change_list_test.go deleted file mode 100644 index 9b8ea206f..000000000 --- a/internal/cli/change_list_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package cli - -import ( - "bytes" - "encoding/json" - "testing" -) - -func TestChangeListUnitsProjection(t *testing.T) { - repo := initCLIGitRepo(t) - writeNewLayoutChange(t, repo, "20260727-listed", "listed", "2.0.0", "") - writeNewLayoutChange(t, repo, "20260727-other", "other", "2.1.0", "") - var stdout bytes.Buffer - if err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "list", "--target", "2.0.0", "--json"}); err != nil { - t.Fatalf("list: %v\n%s", err, stdout.String()) - } - var result changeListUnitJSON - if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { - t.Fatalf("Unmarshal: %v", err) - } - if result.Target != "2.0.0" || len(result.Units) != 1 || result.Units[0].Slug != "listed" { - t.Fatalf("result = %+v", result) - } -} diff --git a/internal/cli/change_origin.go b/internal/cli/change_origin.go deleted file mode 100644 index 40d8a9bed..000000000 --- a/internal/cli/change_origin.go +++ /dev/null @@ -1,549 +0,0 @@ -package cli - -import ( - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "sort" - "strings" - - "github.com/levifig/loaf/internal/state" -) - -const ( - ChangeOriginCodeNotFound = "change-not-found" - ChangeOriginCodeAmbiguous = "change-ambiguous" - ChangeOriginCodeOutsideCanonicalDirectory = "change-outside-canonical-directory" - ChangeOriginCodeIdentityMismatch = "change-identity-mismatch" - ChangeOriginCodeEvidenceUnavailable = "change-evidence-unavailable" -) - -// ChangeOriginError is a stable, typed failure from local Change evidence -// resolution. Ref and Path retain the caller's selector and the best-known -// filesystem path without making the error depend on mutable state later. -type ChangeOriginError struct { - Code string - Ref string - Path string - Err error -} - -// changeOriginOps is a per-call seam for deterministic local race tests. It -// deliberately has no package-global hooks: production calls use the real Git -// and filesystem operations, while tests can advance HEAD or swap a path at -// the exact evidence-capture boundaries. -type changeOriginOps struct { - gitOutputBytes func(string, ...string) ([]byte, error) - afterHeadCapture func() - afterOpenBeforeRevalidate func() -} - -func normalizeChangeOriginOps(ops changeOriginOps) changeOriginOps { - if ops.gitOutputBytes == nil { - ops.gitOutputBytes = originGitOutputBytes - } - if ops.afterHeadCapture == nil { - ops.afterHeadCapture = func() {} - } - if ops.afterOpenBeforeRevalidate == nil { - ops.afterOpenBeforeRevalidate = func() {} - } - return ops -} - -func (e *ChangeOriginError) Error() string { - if e == nil { - return "" - } - parts := []string{e.Code} - if e.Ref != "" { - parts = append(parts, fmt.Sprintf("ref %q", e.Ref)) - } - if e.Path != "" { - parts = append(parts, fmt.Sprintf("path %q", e.Path)) - } - if e.Err != nil { - parts = append(parts, e.Err.Error()) - } - return strings.Join(parts, ": ") -} - -func (e *ChangeOriginError) Unwrap() error { - if e == nil { - return nil - } - return e.Err -} - -// ResolveChangeOrigin captures a self-contained local Change origin envelope. -// The selector is either a retained Change slug or a path to its canonical -// folder/change.md. No network or harness metadata is consulted. -func ResolveChangeOrigin(rootPath, ref string) (state.JournalOriginInput, error) { - return resolveChangeOriginWithOps(rootPath, ref, changeOriginOps{}) -} - -// ResolveManualJournalOrigin captures the local Git context that is available -// for a manual journal write. Git is contextual rather than required here: -// journal logging remains useful outside a repository and in repositories -// without a commit, so unavailable fields stay empty instead of being guessed. -func ResolveManualJournalOrigin(rootPath, sourceEvent string) state.JournalOriginInput { - origin := state.JournalOriginInput{ - EnvelopeVersion: state.JournalOriginEnvelopeVersion, - CaptureMechanism: state.JournalOriginMechanismManual, - SourceEvent: sourceEvent, - } - if strings.TrimSpace(rootPath) == "" { - rootPath = "." - } - worktreeBytes, err := originGitOutputBytes(rootPath, "rev-parse", "--show-toplevel") - if err != nil { - return origin - } - worktree := strings.TrimSpace(string(worktreeBytes)) - if worktree == "" { - return origin - } - if absolute, absErr := filepath.Abs(worktree); absErr == nil { - worktree = absolute - } - if evaluated, evalErr := filepath.EvalSymlinks(worktree); evalErr == nil { - worktree = evaluated - } - origin.Worktree = worktree - - if headBytes, headErr := originGitOutputBytes(worktree, "rev-parse", "--verify", "HEAD"); headErr == nil { - origin.Head = strings.TrimSpace(string(headBytes)) - } - if branchBytes, branchErr := originGitOutputBytes(worktree, "symbolic-ref", "--quiet", "--short", "HEAD"); branchErr == nil { - origin.Branch = strings.TrimSpace(string(branchBytes)) - } - return origin -} - -func resolveChangeOriginWithOps(rootPath, ref string, rawOps changeOriginOps) (state.JournalOriginInput, error) { - ops := normalizeChangeOriginOps(rawOps) - gitRoot, head, branch, err := resolveChangeGitContextWithOps(rootPath, ref, ops) - if err != nil { - return state.JournalOriginInput{}, err - } - - changeFile, relPath, err := resolveCanonicalChangePath(gitRoot, ref) - if err != nil { - return state.JournalOriginInput{}, err - } - content, err := readValidatedChange(gitRoot, ref, changeFile, relPath, ops) - if err != nil { - return state.JournalOriginInput{}, err - } - if err := validateChangeOriginIdentity(content, filepath.Base(filepath.Dir(changeFile)), ref, relPath); err != nil { - return state.JournalOriginInput{}, err - } - - digest := sha256.Sum256(content) - dirty := true - reconstructable := false - if headContent, showErr := ops.gitOutputBytes(gitRoot, "show", head+":"+filepath.ToSlash(relPath)); showErr == nil { - dirty = !equalBytes(headContent, content) - if !dirty && head != "" { - reconstructable = true - } - } - - return state.JournalOriginInput{ - EnvelopeVersion: state.JournalOriginEnvelopeVersion, - CaptureMechanism: state.JournalOriginMechanismManual, - SourceEvent: "journal.defer", - Branch: branch, - Worktree: gitRoot, - Head: head, - ChangePath: filepath.ToSlash(relPath), - ChangeSHA256: hex.EncodeToString(digest[:]), - Dirty: boolPointer(dirty), - Reconstructable: boolPointer(reconstructable), - }, nil -} - -// resolveChangeOrigin is kept package-local for the journal command, while -// ResolveChangeOrigin is available to other internal CLI wiring and tests. -func resolveChangeOrigin(rootPath, ref string) (state.JournalOriginInput, error) { - return ResolveChangeOrigin(rootPath, ref) -} - -func resolveChangeGitContext(rootPath, ref string) (string, string, string, error) { - return resolveChangeGitContextWithOps(rootPath, ref, normalizeChangeOriginOps(changeOriginOps{})) -} - -func resolveChangeGitContextWithOps(rootPath, ref string, ops changeOriginOps) (string, string, string, error) { - ops = normalizeChangeOriginOps(ops) - if strings.TrimSpace(rootPath) == "" { - rootPath = "." - } - outputBytes, err := ops.gitOutputBytes(rootPath, "rev-parse", "--show-toplevel") - if err != nil { - return "", "", "", changeOriginFailure(ChangeOriginCodeEvidenceUnavailable, ref, rootPath, "resolve git worktree", err) - } - gitRoot := strings.TrimSpace(string(outputBytes)) - if gitRoot == "" { - return "", "", "", changeOriginFailure(ChangeOriginCodeEvidenceUnavailable, ref, rootPath, "resolve git worktree", errors.New("git returned an empty worktree")) - } - gitRoot, err = filepath.Abs(gitRoot) - if err != nil { - return "", "", "", changeOriginFailure(ChangeOriginCodeEvidenceUnavailable, ref, rootPath, "make git worktree absolute", err) - } - if evaluated, evalErr := filepath.EvalSymlinks(gitRoot); evalErr == nil { - gitRoot = evaluated - } - - headBytes, err := ops.gitOutputBytes(gitRoot, "rev-parse", "--verify", "HEAD") - head := strings.TrimSpace(string(headBytes)) - if err != nil || head == "" { - if err == nil { - err = errors.New("git returned an empty HEAD") - } - return "", "", "", changeOriginFailure(ChangeOriginCodeEvidenceUnavailable, ref, gitRoot, "resolve HEAD", err) - } - ops.afterHeadCapture() - branchBytes, branchErr := ops.gitOutputBytes(gitRoot, "symbolic-ref", "--quiet", "--short", "HEAD") - if branchErr != nil { - branchBytes = nil - } - branch := strings.TrimSpace(string(branchBytes)) - return gitRoot, head, branch, nil -} - -func resolveCanonicalChangePath(gitRoot, ref string) (string, string, error) { - base := filepath.Join(gitRoot, "docs", "changes") - baseReal, baseErr := filepath.EvalSymlinks(base) - if baseErr != nil { - baseReal = base - } else if !pathWithin(gitRoot, baseReal) { - return "", "", changeOriginFailure(ChangeOriginCodeOutsideCanonicalDirectory, ref, base, "docs/changes resolves outside the git worktree", nil) - } - - if isChangeSlugSelector(ref) { - return resolveChangeSlug(gitRoot, base, baseReal, ref) - } - return resolveExplicitChangePath(gitRoot, base, baseReal, ref) -} - -func isChangeSlugSelector(ref string) bool { - return changeSlugRE.MatchString(ref) && changeFolderRE.FindStringSubmatch(ref) == nil -} - -func resolveChangeSlug(gitRoot, base, baseReal, slug string) (string, string, error) { - entries, err := os.ReadDir(base) - if err != nil { - if os.IsNotExist(err) { - return "", "", changeOriginFailure(ChangeOriginCodeNotFound, slug, filepath.Join("docs", "changes"), "no retained Change matches slug", nil) - } - return "", "", changeOriginFailure(ChangeOriginCodeEvidenceUnavailable, slug, base, "list retained Changes", err) - } - var matches []canonicalChangePath - for _, entry := range entries { - folderMatch := changeFolderRE.FindStringSubmatch(entry.Name()) - if folderMatch == nil || folderMatch[2] != slug { - continue - } - folderPath := filepath.Join(base, entry.Name()) - if folderTarget, folderErr := filepath.EvalSymlinks(folderPath); folderErr == nil && (!pathWithin(gitRoot, folderTarget) || !pathWithin(baseReal, folderTarget)) { - return "", "", changeOriginFailure(ChangeOriginCodeOutsideCanonicalDirectory, slug, folderTarget, "Change folder resolves outside docs/changes", nil) - } - candidate, candidateErr := resolveCanonicalChangeFile(gitRoot, base, baseReal, filepath.Join(folderPath, "change.md")) - if candidateErr != nil { - var typed *ChangeOriginError - if errors.As(candidateErr, &typed) && typed.Code == ChangeOriginCodeNotFound { - continue - } - return "", "", candidateErr - } - resolvedFolder := changeFolderRE.FindStringSubmatch(filepath.Base(filepath.Dir(candidate.absolute))) - if resolvedFolder == nil || resolvedFolder[2] != slug { - resolvedSlug := "" - if resolvedFolder != nil { - resolvedSlug = resolvedFolder[2] - } - return "", "", changeOriginFailure(ChangeOriginCodeIdentityMismatch, slug, candidate.relative, fmt.Sprintf("resolved folder slug %q does not match requested slug %q", resolvedSlug, slug), nil) - } - matches = append(matches, candidate) - } - if len(matches) == 0 { - return "", "", changeOriginFailure(ChangeOriginCodeNotFound, slug, filepath.Join("docs", "changes"), "no retained Change matches slug", nil) - } - sort.Slice(matches, func(i, j int) bool { return matches[i].relative < matches[j].relative }) - if len(matches) > 1 { - paths := make([]string, len(matches)) - for i, match := range matches { - paths[i] = match.relative - } - return "", "", changeOriginFailure(ChangeOriginCodeAmbiguous, slug, filepath.Join("docs", "changes"), "matches "+strings.Join(paths, ", "), nil) - } - return matches[0].absolute, matches[0].relative, nil -} - -func resolveExplicitChangePath(gitRoot, base, baseReal, ref string) (string, string, error) { - path := ref - if !filepath.IsAbs(path) { - if changeFolderRE.FindStringSubmatch(path) != nil { - path = filepath.Join(gitRoot, "docs", "changes", path) - } else { - path = filepath.Join(gitRoot, path) - } - } - path, err := filepath.Abs(filepath.Clean(path)) - if err != nil { - return "", "", changeOriginFailure(ChangeOriginCodeOutsideCanonicalDirectory, ref, path, "clean Change path", err) - } - - if info, statErr := os.Stat(path); statErr == nil { - if info.IsDir() { - path = filepath.Join(path, "change.md") - } - } else if !os.IsNotExist(statErr) { - return "", "", changeOriginFailure(ChangeOriginCodeEvidenceUnavailable, ref, path, "inspect Change path", statErr) - } else if filepath.Base(path) != "change.md" { - path = filepath.Join(path, "change.md") - } - - resolved, err := resolveCanonicalChangeFile(gitRoot, base, baseReal, path) - if err != nil { - return "", "", err - } - return resolved.absolute, resolved.relative, nil -} - -type canonicalChangePath struct { - absolute string - relative string -} - -func resolveCanonicalChangeFile(gitRoot, base, baseReal, candidate string) (canonicalChangePath, error) { - lexical, err := filepath.Abs(filepath.Clean(candidate)) - if err != nil { - return canonicalChangePath{}, changeOriginFailure(ChangeOriginCodeOutsideCanonicalDirectory, candidate, candidate, "clean Change path", err) - } - resolved, err := filepath.EvalSymlinks(candidate) - if err != nil { - if !pathWithinExistingAncestor(gitRoot, lexical) { - return canonicalChangePath{}, changeOriginFailure(ChangeOriginCodeOutsideCanonicalDirectory, candidate, lexical, "Change path is outside the git worktree", nil) - } - if os.IsNotExist(err) { - if !canonicalChangeLexicalShape(lexical) { - return canonicalChangePath{}, changeOriginFailure(ChangeOriginCodeOutsideCanonicalDirectory, candidate, lexical, "Change path must be docs/changes/YYYYMMDD-slug/change.md", nil) - } - return canonicalChangePath{}, changeOriginFailure(ChangeOriginCodeNotFound, candidate, candidate, "Change file does not exist", err) - } - return canonicalChangePath{}, changeOriginFailure(ChangeOriginCodeEvidenceUnavailable, candidate, candidate, "resolve Change path", err) - } - resolved, err = filepath.Abs(resolved) - if err != nil { - return canonicalChangePath{}, changeOriginFailure(ChangeOriginCodeEvidenceUnavailable, candidate, candidate, "make Change path absolute", err) - } - info, err := os.Stat(resolved) - if err != nil { - return canonicalChangePath{}, changeOriginFailure(ChangeOriginCodeNotFound, candidate, candidate, "stat Change file", err) - } - if info.IsDir() { - return canonicalChangePath{}, changeOriginFailure(ChangeOriginCodeOutsideCanonicalDirectory, candidate, resolved, "Change path is a directory", nil) - } - if !info.Mode().IsRegular() { - return canonicalChangePath{}, changeOriginFailure(ChangeOriginCodeEvidenceUnavailable, candidate, resolved, "Change path is not a regular file", errNotRegularFile) - } - - rel, err := filepath.Rel(baseReal, resolved) - if err != nil || !pathWithin(baseReal, resolved) { - return canonicalChangePath{}, changeOriginFailure(ChangeOriginCodeOutsideCanonicalDirectory, candidate, resolved, "Change path is outside docs/changes", err) - } - parts := splitPath(rel) - if len(parts) != 2 || parts[1] != "change.md" || changeFolderRE.FindStringSubmatch(parts[0]) == nil { - return canonicalChangePath{}, changeOriginFailure(ChangeOriginCodeOutsideCanonicalDirectory, candidate, resolved, "Change path must be docs/changes/YYYYMMDD-slug/change.md", nil) - } - if !pathWithin(gitRoot, resolved) { - return canonicalChangePath{}, changeOriginFailure(ChangeOriginCodeOutsideCanonicalDirectory, candidate, resolved, "Change path resolves outside the git worktree", nil) - } - // base is intentionally retained in the signature: it documents and checks - // the lexical canonical root for missing-path diagnostics and future callers. - _ = base - return canonicalChangePath{absolute: resolved, relative: filepath.ToSlash(relFromRoot(gitRoot, resolved))}, nil -} - -func canonicalChangeLexicalShape(path string) bool { - parts := strings.Split(filepath.ToSlash(filepath.Clean(path)), "/") - for i := 0; i+2 < len(parts); i++ { - if parts[i] != "docs" || parts[i+1] != "changes" { - continue - } - return len(parts[i+2:]) == 2 && parts[len(parts)-1] == "change.md" && changeFolderRE.FindStringSubmatch(parts[i+2]) != nil - } - return false -} - -func readValidatedChange(gitRoot, ref, changeFile, relPath string, ops changeOriginOps) ([]byte, error) { - ops = normalizeChangeOriginOps(ops) - // Open through the descriptor-hardened non-blocking path so a FIFO or - // device at change.md cannot hang the reader, then bound the read to the - // project-file ceiling used everywhere else for change-scale documents. - opened, err := openRegularFile(changeFile) - if err != nil { - if os.IsNotExist(err) { - return nil, changeOriginFailure(ChangeOriginCodeNotFound, ref, relPath, "open working Change", err) - } - if isProjectFileRefusal(err) { - return nil, changeOriginFailure(ChangeOriginCodeEvidenceUnavailable, ref, relPath, "open working Change", err) - } - return nil, changeOriginFailure(ChangeOriginCodeEvidenceUnavailable, ref, relPath, "open working Change", err) - } - defer opened.Close() - - openedInfo, err := opened.Stat() - if err != nil { - return nil, changeOriginFailure(ChangeOriginCodeEvidenceUnavailable, ref, relPath, "stat opened Change", err) - } - ops.afterOpenBeforeRevalidate() - - revalidatedFile, revalidatedPath, revalidateErr := resolveCanonicalChangePath(gitRoot, ref) - if revalidateErr != nil { - var typed *ChangeOriginError - if errors.As(revalidateErr, &typed) && (typed.Code == ChangeOriginCodeOutsideCanonicalDirectory || typed.Code == ChangeOriginCodeIdentityMismatch) { - return nil, revalidateErr - } - return nil, changeOriginFailure(ChangeOriginCodeEvidenceUnavailable, ref, relPath, "revalidate opened Change path", revalidateErr) - } - requestedSlug := "" - if isChangeSlugSelector(ref) { - requestedSlug = ref - } - if requestedSlug != "" { - resolvedFolder := changeFolderRE.FindStringSubmatch(filepath.Base(filepath.Dir(revalidatedFile))) - if resolvedFolder == nil || resolvedFolder[2] != requestedSlug { - return nil, changeOriginFailure(ChangeOriginCodeIdentityMismatch, ref, revalidatedPath, "revalidated folder slug does not match requested slug", nil) - } - } - - revalidatedInfo, err := os.Stat(revalidatedFile) - if err != nil { - return nil, changeOriginFailure(ChangeOriginCodeEvidenceUnavailable, ref, relPath, "stat revalidated Change path", err) - } - if !os.SameFile(openedInfo, revalidatedInfo) { - return nil, changeOriginFailure(ChangeOriginCodeEvidenceUnavailable, ref, relPath, "opened Change inode changed during revalidation", nil) - } - - content, err := io.ReadAll(io.LimitReader(opened, projectFileReadLimit+1)) - if err != nil { - return nil, changeOriginFailure(ChangeOriginCodeEvidenceUnavailable, ref, relPath, "read opened Change", err) - } - if int64(len(content)) > projectFileReadLimit { - return nil, changeOriginFailure(ChangeOriginCodeEvidenceUnavailable, ref, relPath, "read opened Change", errFileTooLarge) - } - readInfo, err := opened.Stat() - if err != nil { - return nil, changeOriginFailure(ChangeOriginCodeEvidenceUnavailable, ref, relPath, "restat opened Change", err) - } - pathInfo, err := os.Stat(revalidatedFile) - if err != nil { - return nil, changeOriginFailure(ChangeOriginCodeEvidenceUnavailable, ref, relPath, "restat revalidated Change path", err) - } - if !os.SameFile(readInfo, pathInfo) { - return nil, changeOriginFailure(ChangeOriginCodeEvidenceUnavailable, ref, relPath, "opened Change inode changed after read", nil) - } - return content, nil -} - -func pathWithinExistingAncestor(root, path string) bool { - for current := path; ; current = filepath.Dir(current) { - if evaluated, err := filepath.EvalSymlinks(current); err == nil { - return pathWithin(root, evaluated) - } - parent := filepath.Dir(current) - if parent == current { - return false - } - } -} - -func validateChangeOriginIdentity(content []byte, folderName, ref, relPath string) error { - folderMatch := changeFolderRE.FindStringSubmatch(folderName) - if folderMatch == nil { - return changeOriginFailure(ChangeOriginCodeOutsideCanonicalDirectory, ref, relPath, "malformed Change folder", nil) - } - parsed := parseChangeFrontmatter(string(content)) - if !parsed.AtByteOne { - return changeOriginFailure(ChangeOriginCodeIdentityMismatch, ref, relPath, "Change frontmatter is missing", nil) - } - found := false - for _, field := range parsed.Fields { - if !strings.EqualFold(field.Key, "slug") && !strings.EqualFold(field.Key, "change") { - continue - } - found = true - if field.Value != folderMatch[2] { - return changeOriginFailure(ChangeOriginCodeIdentityMismatch, ref, relPath, fmt.Sprintf("frontmatter %s %q does not match folder slug %q", field.Key, field.Value, folderMatch[2]), nil) - } - } - if !found { - return changeOriginFailure(ChangeOriginCodeIdentityMismatch, ref, relPath, "frontmatter has no slug identity", nil) - } - return nil -} - -func pathWithin(root, path string) bool { - rel, err := filepath.Rel(root, path) - if err != nil { - return false - } - return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) -} - -func splitPath(path string) []string { - path = filepath.Clean(path) - if path == "." || path == string(filepath.Separator) { - return nil - } - return strings.Split(filepath.ToSlash(path), "/") -} - -func originGitOutput(cwd string, args ...string) (string, error) { - output, err := originGitOutputBytes(cwd, args...) - return string(output), err -} - -func originGitOutputBytes(cwd string, args ...string) ([]byte, error) { - cmd := exec.Command("git", args...) - cmd.Dir = cwd - return cmd.Output() -} - -func equalBytes(left, right []byte) bool { - if len(left) != len(right) { - return false - } - for i := range left { - if left[i] != right[i] { - return false - } - } - return true -} - -func boolPointer(value bool) *bool { - return &value -} - -func changeOriginFailure(code, ref, path, message string, cause error) *ChangeOriginError { - var err error - if message != "" { - err = errors.New(message) - } - if cause != nil { - if err != nil { - err = fmt.Errorf("%w: %v", err, cause) - } else { - err = cause - } - } - return &ChangeOriginError{Code: code, Ref: ref, Path: path, Err: err} -} diff --git a/internal/cli/change_origin_test.go b/internal/cli/change_origin_test.go deleted file mode 100644 index b8f7c141e..000000000 --- a/internal/cli/change_origin_test.go +++ /dev/null @@ -1,340 +0,0 @@ -package cli - -import ( - "crypto/sha256" - "encoding/hex" - "errors" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - "github.com/levifig/loaf/internal/state" -) - -func TestResolveChangeOriginCommittedAndExplicitSelectors(t *testing.T) { - repo, changeFile, content := committedOriginFixture(t, "auth-token-rotation", "20260711") - - bySlug, err := ResolveChangeOrigin(repo, "auth-token-rotation") - if err != nil { - t.Fatalf("resolve by slug: %v", err) - } - byFolder, err := ResolveChangeOrigin(repo, filepath.Join("docs", "changes", "20260711-auth-token-rotation")) - if err != nil { - t.Fatalf("resolve by folder: %v", err) - } - byFile, err := ResolveChangeOrigin(repo, changeFile) - if err != nil { - t.Fatalf("resolve by file: %v", err) - } - - for name, origin := range map[string]state.JournalOriginInput{"slug": bySlug, "folder": byFolder, "file": byFile} { - if origin.EnvelopeVersion != state.JournalOriginEnvelopeVersion || origin.CaptureMechanism != state.JournalOriginMechanismManual || origin.SourceEvent != "journal.defer" { - t.Errorf("%s envelope = %#v", name, origin) - } - if origin.ChangePath != "docs/changes/20260711-auth-token-rotation/change.md" { - t.Errorf("%s ChangePath = %q", name, origin.ChangePath) - } - if origin.Worktree != evalPath(t, repo) { - t.Errorf("%s Worktree = %q, want %q", name, origin.Worktree, evalPath(t, repo)) - } - if *origin.Dirty || !*origin.Reconstructable { - t.Errorf("%s dirty/reconstructable = %v/%v", name, *origin.Dirty, *origin.Reconstructable) - } - if origin.Branch != "main" || origin.Head == "" { - t.Errorf("%s branch/head = %q/%q", name, origin.Branch, origin.Head) - } - digest := sha256.Sum256(content) - if origin.ChangeSHA256 != hex.EncodeToString(digest[:]) { - t.Errorf("%s digest = %q", name, origin.ChangeSHA256) - } - } -} - -func TestResolveChangeOriginDirtyAndUnrelatedWorkingTreeChanges(t *testing.T) { - repo, changeFile, committed := committedOriginFixture(t, "dirty-change", "20260711") - - if err := os.WriteFile(changeFile, []byte("---\nslug: dirty-change\n---\nchanged working bytes\n"), 0o644); err != nil { - t.Fatal(err) - } - dirty, err := ResolveChangeOrigin(repo, "dirty-change") - if err != nil { - t.Fatalf("resolve dirty Change: %v", err) - } - if !*dirty.Dirty || *dirty.Reconstructable { - t.Fatalf("dirty/reconstructable = %v/%v", *dirty.Dirty, *dirty.Reconstructable) - } - - if err := os.WriteFile(changeFile, committed, 0o644); err != nil { - t.Fatal(err) - } - unrelatedFile := filepath.Join(repo, "unrelated.txt") - if err := os.WriteFile(unrelatedFile, []byte("unrelated dirty bytes\n"), 0o644); err != nil { - t.Fatal(err) - } - clean, err := ResolveChangeOrigin(repo, "dirty-change") - if err != nil { - t.Fatalf("resolve dirty Change with unrelated file: %v", err) - } - if *clean.Dirty || !*clean.Reconstructable { - t.Fatalf("unrelated dirty file changed evidence = %v/%v", *clean.Dirty, *clean.Reconstructable) - } -} - -func TestResolveChangeOriginUntrackedDetachedAndSelfContained(t *testing.T) { - repo, _, _ := committedOriginFixture(t, "tracked-change", "20260711") - untrackedFolder := filepath.Join(repo, "docs", "changes", "20260712-untracked-change") - untrackedFile := filepath.Join(untrackedFolder, "change.md") - writeOriginChange(t, untrackedFile, "untracked-change") - untracked, err := ResolveChangeOrigin(repo, "untracked-change") - if err != nil { - t.Fatalf("resolve untracked Change: %v", err) - } - if !*untracked.Dirty || *untracked.Reconstructable { - t.Fatalf("untracked dirty/reconstructable = %v/%v", *untracked.Dirty, *untracked.Reconstructable) - } - - if err := originGitCLI(repo, "checkout", "--detach"); err != nil { - t.Fatal(err) - } - detached, err := ResolveChangeOrigin(repo, "tracked-change") - if err != nil { - t.Fatalf("resolve detached Change: %v", err) - } - if detached.Branch != "" { - t.Fatalf("detached branch = %q, want empty", detached.Branch) - } - - snapshot := detached - if err := os.RemoveAll(repo); err != nil { - t.Fatal(err) - } - if snapshot.ChangePath != detached.ChangePath || snapshot.ChangeSHA256 != detached.ChangeSHA256 || snapshot.Worktree != detached.Worktree || snapshot.Head != detached.Head { - t.Fatal("origin changed after source worktree removal") - } -} - -func TestResolveChangeOriginRejectsAmbiguousAndNonCanonicalSelectors(t *testing.T) { - repo, _, _ := committedOriginFixture(t, "ambiguous-change", "20260711") - secondFile := filepath.Join(repo, "docs", "changes", "20260712-ambiguous-change", "change.md") - writeOriginChange(t, secondFile, "ambiguous-change") - if _, err := ResolveChangeOrigin(repo, "ambiguous-change"); !hasChangeOriginCode(err, ChangeOriginCodeAmbiguous) { - t.Fatalf("ambiguous error = %v, want %s", err, ChangeOriginCodeAmbiguous) - } - - outside := filepath.Join(t.TempDir(), "outside.md") - if err := os.WriteFile(outside, []byte("outside\n"), 0o644); err != nil { - t.Fatal(err) - } - cases := []string{ - outside, - filepath.Join(repo, "docs", "changes", "20260711-ambiguous-change", "nested", "change.md"), - filepath.Join(repo, "docs", "changes", "20260711-ambiguous-change", "..", "..", "outside.md"), - } - for _, ref := range cases { - if _, err := ResolveChangeOrigin(repo, ref); !hasChangeOriginCode(err, ChangeOriginCodeOutsideCanonicalDirectory) { - t.Errorf("noncanonical ref %q error = %v", ref, err) - } - } - - escapeFolder := filepath.Join(repo, "docs", "changes", "20260713-escape-change") - if err := os.Symlink(t.TempDir(), escapeFolder); err != nil { - t.Fatal(err) - } - if _, err := ResolveChangeOrigin(repo, "escape-change"); !hasChangeOriginCode(err, ChangeOriginCodeOutsideCanonicalDirectory) { - t.Fatalf("symlink escape error = %v", err) - } -} - -func TestResolveChangeOriginIdentityAndEvidenceErrors(t *testing.T) { - repo, changeFile, _ := committedOriginFixture(t, "identity-change", "20260711") - if err := os.WriteFile(changeFile, []byte("---\nslug: other-change\n---\nbody\n"), 0o644); err != nil { - t.Fatal(err) - } - if _, err := ResolveChangeOrigin(repo, "identity-change"); !hasChangeOriginCode(err, ChangeOriginCodeIdentityMismatch) { - t.Fatalf("identity mismatch error = %v", err) - } - if err := os.Remove(changeFile); err != nil { - t.Fatal(err) - } - if _, err := ResolveChangeOrigin(repo, changeFile); !hasChangeOriginCode(err, ChangeOriginCodeNotFound) { - t.Fatalf("removed Change error = %v", err) - } - - noGit := t.TempDir() - if _, err := ResolveChangeOrigin(noGit, "missing-change"); !hasChangeOriginCode(err, ChangeOriginCodeEvidenceUnavailable) { - t.Fatalf("no git error = %v", err) - } - emptyGit := t.TempDir() - if err := originGitCLI(emptyGit, "init", "-b", "main"); err != nil { - t.Fatal(err) - } - emptyFile := filepath.Join(emptyGit, "docs", "changes", "20260711-empty-change", "change.md") - writeOriginChange(t, emptyFile, "empty-change") - if _, err := ResolveChangeOrigin(emptyGit, "empty-change"); !hasChangeOriginCode(err, ChangeOriginCodeEvidenceUnavailable) { - t.Fatalf("no HEAD error = %v", err) - } -} - -func TestResolveChangeOriginUsesCapturedHeadForBlobEvidence(t *testing.T) { - repo, changeFile, contentA := committedOriginFixture(t, "captured-head", "20260711") - headA := strings.TrimSpace(mustOriginGitOutput(t, repo, "rev-parse", "HEAD")) - contentB := []byte("---\nslug: captured-head\n---\ncommit B bytes\n") - origin, err := resolveChangeOriginWithOps(repo, "captured-head", changeOriginOps{ - afterHeadCapture: func() { - if writeErr := os.WriteFile(changeFile, contentB, 0o644); writeErr != nil { - t.Fatalf("write commit B Change: %v", writeErr) - } - if gitErr := originGitCLI(repo, "add", filepath.ToSlash(filepath.Join("docs", "changes", "20260711-captured-head", "change.md"))); gitErr != nil { - t.Fatalf("stage commit B Change: %v", gitErr) - } - if gitErr := originGitCLI(repo, "-c", "commit.gpgsign=false", "commit", "-m", "commit B"); gitErr != nil { - t.Fatalf("commit B: %v", gitErr) - } - }, - }) - if err != nil { - t.Fatalf("resolve after advancing HEAD: %v", err) - } - if origin.Head != headA { - t.Fatalf("captured Head = %q, want A %q", origin.Head, headA) - } - if !*origin.Dirty || *origin.Reconstructable { - t.Fatalf("evidence against captured A = dirty %v/reconstructable %v", *origin.Dirty, *origin.Reconstructable) - } - digestA := sha256.Sum256(contentA) - digestB := sha256.Sum256(contentB) - if origin.ChangeSHA256 != hex.EncodeToString(digestB[:]) || origin.ChangeSHA256 == hex.EncodeToString(digestA[:]) { - t.Fatalf("working digest = %q, want commit B bytes %q", origin.ChangeSHA256, hex.EncodeToString(digestB[:])) - } - if headB := strings.TrimSpace(mustOriginGitOutput(t, repo, "rev-parse", "HEAD")); headB == headA { - t.Fatal("head-advance seam did not create commit B") - } -} - -func TestResolveChangeOriginRejectsSlugSymlinkAlias(t *testing.T) { - repo, _, _ := committedOriginFixture(t, "canonical-b", "20260712") - canonicalFolder := filepath.Join(repo, "docs", "changes", "20260712-canonical-b") - aliasFolder := filepath.Join(repo, "docs", "changes", "20260711-alias-a") - if err := os.Symlink(canonicalFolder, aliasFolder); err != nil { - t.Fatal(err) - } - if _, err := ResolveChangeOrigin(repo, "alias-a"); !hasChangeOriginCode(err, ChangeOriginCodeIdentityMismatch) { - t.Fatalf("slug alias error = %v, want %s", err, ChangeOriginCodeIdentityMismatch) - } - if _, err := ResolveChangeOrigin(repo, filepath.Join("docs", "changes", "20260712-canonical-b")); err != nil { - t.Fatalf("explicit canonical target after alias = %v", err) - } -} - -func TestResolveChangeOriginRevalidatesOpenedInodeBeforeReading(t *testing.T) { - repo, changeFile, _ := committedOriginFixture(t, "inode-race", "20260711") - folder := filepath.Dir(changeFile) - backup := folder + ".held-open" - external := filepath.Join(t.TempDir(), "external-change") - writeOriginChange(t, filepath.Join(external, "change.md"), "inode-race") - var swapped bool - origin, err := resolveChangeOriginWithOps(repo, "inode-race", changeOriginOps{ - afterOpenBeforeRevalidate: func() { - if renameErr := os.Rename(folder, backup); renameErr != nil { - t.Fatalf("move opened Change folder: %v", renameErr) - } - if symlinkErr := os.Symlink(external, folder); symlinkErr != nil { - t.Fatalf("swap Change folder with external symlink: %v", symlinkErr) - } - swapped = true - }, - }) - if swapped { - if removeErr := os.Remove(folder); removeErr != nil { - t.Fatalf("remove external symlink: %v", removeErr) - } - if renameErr := os.Rename(backup, folder); renameErr != nil { - t.Fatalf("restore opened Change folder: %v", renameErr) - } - } - if origin != (state.JournalOriginInput{}) { - t.Fatalf("origin returned after path swap = %#v", origin) - } - if !hasChangeOriginCode(err, ChangeOriginCodeOutsideCanonicalDirectory) && !hasChangeOriginCode(err, ChangeOriginCodeEvidenceUnavailable) { - t.Fatalf("path swap error = %v, want canonical rejection", err) - } -} - -func committedOriginFixture(t *testing.T, slug, date string) (string, string, []byte) { - t.Helper() - repo := t.TempDir() - if err := originGitCLI(repo, "init", "-b", "main"); err != nil { - t.Fatal(err) - } - if err := originGitCLI(repo, "config", "user.name", "Loaf Test"); err != nil { - t.Fatal(err) - } - if err := originGitCLI(repo, "config", "user.email", "loaf@example.test"); err != nil { - t.Fatal(err) - } - changeFile := filepath.Join(repo, "docs", "changes", date+"-"+slug, "change.md") - content := []byte("---\nslug: " + slug + "\n---\ncommitted bytes\n") - if err := os.MkdirAll(filepath.Dir(changeFile), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(changeFile, content, 0o644); err != nil { - t.Fatal(err) - } - if err := originGitCLI(repo, "add", "."); err != nil { - t.Fatal(err) - } - if err := originGitCLI(repo, "-c", "commit.gpgsign=false", "commit", "-m", "initial"); err != nil { - t.Fatal(err) - } - return repo, changeFile, content -} - -func writeOriginChange(t *testing.T, path, slug string) { - t.Helper() - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte("---\nslug: "+slug+"\n---\nworking bytes\n"), 0o644); err != nil { - t.Fatal(err) - } -} - -func originGitCLI(dir string, args ...string) error { - cmd := originExecCommand(dir, args...) - output, err := cmd.CombinedOutput() - if err != nil { - return errors.New(strings.TrimSpace(string(output))) - } - return nil -} - -func mustOriginGitOutput(t *testing.T, dir string, args ...string) string { - t.Helper() - output, err := originGitOutput(dir, args...) - if err != nil { - t.Fatalf("git %v: %v", args, err) - } - return output -} - -func originExecCommand(dir string, args ...string) *exec.Cmd { - cmd := exec.Command("git", args...) - cmd.Dir = dir - return cmd -} - -func hasChangeOriginCode(err error, code string) bool { - var typed *ChangeOriginError - return errors.As(err, &typed) && typed.Code == code -} - -func evalPath(t *testing.T, path string) string { - t.Helper() - evaluated, err := filepath.EvalSymlinks(path) - if err != nil { - t.Fatal(err) - } - return evaluated -} diff --git a/internal/cli/change_plan_template.md b/internal/cli/change_plan_template.md deleted file mode 100644 index 6d17a7179..000000000 --- a/internal/cli/change_plan_template.md +++ /dev/null @@ -1,21 +0,0 @@ -<!-- plan.md is the optional technical route (the corpus's plan sense). - Accretive during shaping; approach churn here never expires a cohort receipt. - Criteria live in shape.md — do not relocate Verification Contract here. --> - -# Plan — [Change Title] - -## Approach - -[How the work lands — architecture, sequencing, compatibility.] - -## Placement - -[Where code and docs live; what stays out of scope for this route.] - -## Risks - -[Failure modes and the safe failure direction.] - -## Sequencing - -[Ordered slices that leave main coherent at each landing.] diff --git a/internal/cli/change_provenance.go b/internal/cli/change_provenance.go deleted file mode 100644 index 6ba15d7fe..000000000 --- a/internal/cli/change_provenance.go +++ /dev/null @@ -1,347 +0,0 @@ -package cli - -import ( - "fmt" - "path/filepath" - "regexp" - "strconv" - "strings" -) - -// Execution provenance grades (Planning Contract / TASK-004). -// Path grade: a commit modifies tasks/ (or legacy change.md) plus a path -// outside docs/changes/ entirely — feeds derived display. -// Flip grade: that commit's diff also flips `- [ ]`→`- [x]` outside fences — -// the first grading path for cohort members. -// Receipt grade: a fresh verify receipt over a folder whose every committed task -// box is checked — the content-bound floor, because squash, rebase, and every -// cleanup-then-merge hybrid rewrite the flip while none of them can rewrite the -// tree a receipt binds. - -var ( - changeFlipUncheckedRE = regexp.MustCompile(`(?i)^\s*- \[ \]\s*(.*)$`) - changeFlipCheckedRE = regexp.MustCompile(`(?i)^\s*- \[x\]\s*(.*)$`) - changeDiffHunkRE = regexp.MustCompile(`^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@`) -) - -type changeExecutionStatus struct { - PathExecuted bool - FlipExecuted bool -} - -// changeMemberEvidence is everything the gate knows about one cohort member's -// execution: the history-shape grade, the receipt's freshness verdict, and -// whether the committed packets are fully checked. One value, gathered once, so -// freshness is computed a single time and the refusal can name what is missing. -type changeMemberEvidence struct { - FolderRel string - Status changeExecutionStatus - Verdict changeReceiptVerdict - AllTasksChecked bool -} - -// executed is the gate's execution grade: a flip transition somewhere in -// ancestry, or a receipt that vouches for a fully checked folder. The disjunct -// is what makes the grade merge-strategy-proof — the flip path stays correct -// wherever history preserves it, and the receipt path holds where it does not. -func (e changeMemberEvidence) executed() bool { - return e.Status.FlipExecuted || (e.Verdict.OK && e.AllTasksChecked) -} - -// changeMemberExecutionEvidence gathers both grading inputs for one member. -func changeMemberExecutionEvidence(rootPath string, node changeNode, outputCommand changeGitOutput) (changeMemberEvidence, error) { - status, err := changeFolderExecuted(rootPath, node.Folder, node.Layout, outputCommand) - if err != nil { - return changeMemberEvidence{}, err - } - return changeMemberEvidence{ - FolderRel: filepath.ToSlash(node.Folder), - Status: status, - Verdict: changeReceiptStatus(rootPath, node.Folder, node, outputCommand), - AllTasksChecked: changeFolderTasksAllChecked(rootPath, node, outputCommand), - }, nil -} - -// changeFolderTasksAllChecked reports whether the folder carries at least one -// task checkbox and every one of them is checked. It reads committed HEAD, not -// the working tree: this half of the execution grade is evidence, so a box -// checked in a dirty checkout must not open the gate. Unreadable or absent -// tasks/ yields no boxes, which fails the grade rather than passing it. -func changeFolderTasksAllChecked(rootPath string, node changeNode, outputCommand changeGitOutput) bool { - if node.Layout != changeLayoutNew { - return false - } - folderAbs := filepath.Join(rootPath, filepath.FromSlash(node.Folder)) - tasks, _, _ := loadChangeTasks(rootPath, folderAbs, node, changeTaskContentHEAD, outputCommand) - total, done := 0, 0 - for _, task := range tasks { - total += task.CheckboxTotal - done += task.CheckboxDone - } - return total > 0 && done == total -} - -func changeFolderExecuted(rootPath, folderRel string, layout string, outputCommand changeGitOutput) (changeExecutionStatus, error) { - if outputCommand == nil { - outputCommand = commandOutput - } - commits, err := outputCommand(rootPath, "git", "log", "--format=%H", "HEAD", "--", folderRel) - if err != nil { - return changeExecutionStatus{}, err - } - status := changeExecutionStatus{} - for _, commit := range strings.Split(strings.TrimSpace(commits), "\n") { - commit = strings.TrimSpace(commit) - if commit == "" { - continue - } - pathsOut, err := outputCommand(rootPath, "git", "diff-tree", "--no-commit-id", "--name-only", "-r", commit) - if err != nil { - return changeExecutionStatus{}, err - } - paths := strings.Split(strings.TrimSpace(pathsOut), "\n") - hasTaskSurface := false - hasOutside := false - var taskPaths []string - for _, p := range paths { - p = filepath.ToSlash(strings.TrimSpace(p)) - if p == "" { - continue - } - if !strings.HasPrefix(p, "docs/changes/") { - hasOutside = true - continue - } - if !strings.HasPrefix(p, folderRel+"/") && p != folderRel { - continue - } - switch layout { - case changeLayoutNew: - if strings.Contains(p, "/tasks/") || strings.HasSuffix(p, "/tasks") { - hasTaskSurface = true - taskPaths = append(taskPaths, p) - } - default: - if strings.HasSuffix(p, "/change.md") || filepath.Base(p) == "change.md" { - hasTaskSurface = true - taskPaths = append(taskPaths, p) - } - } - } - if !(hasTaskSurface && hasOutside) { - continue - } - status.PathExecuted = true - if layout == changeLayoutNew { - flipped, err := commitFlipsTaskCheckboxes(rootPath, commit, taskPaths, outputCommand) - if err != nil { - return changeExecutionStatus{}, err - } - if flipped { - status.FlipExecuted = true - return status, nil - } - } else { - // Legacy path grade only for display; flip grade never satisfied on legacy. - } - } - return status, nil -} - -func commitFlipsTaskCheckboxes(rootPath, commit string, taskPaths []string, outputCommand changeGitOutput) (bool, error) { - if outputCommand == nil { - outputCommand = commandOutput - } - parent, hasParent, err := changeCommitFirstParent(rootPath, commit, outputCommand) - if err != nil { - return false, err - } - for _, path := range taskPaths { - // Fence state is a whole-file property: an opening fence can sit any - // distance above the flipped line, so it is derived from the complete - // pre-image and post-image rather than inferred from the patch window. - var preFenced, postFenced changeFencedLines - if hasParent { - pre, exists, err := readCommittedOptional(rootPath, parent, path, outputCommand) - if err != nil { - return false, err - } - if exists { - preFenced = markdownFencedLines(pre) - } - } - post, exists, err := readCommittedOptional(rootPath, commit, path, outputCommand) - if err != nil { - return false, err - } - if exists { - postFenced = markdownFencedLines(post) - } - // unified=3 fixes the hunk grouping the flip grammar reads (same-hunk, - // same-normalized-label); it no longer carries fence context. - diff, err := outputCommand(rootPath, "git", "show", "--format=", "--unified=3", commit, "--", path) - if err != nil { - return false, err - } - if diffContainsCheckboxFlip(diff, preFenced, postFenced) { - return true, nil - } - } - return false, nil -} - -// changeCommitFirstParent resolves the pre-image ref for a commit. A root commit -// reports no parent rather than failing, so a change whose first commit creates -// its task files is scored, not errored. -func changeCommitFirstParent(rootPath, commit string, outputCommand changeGitOutput) (string, bool, error) { - out, err := outputCommand(rootPath, "git", "log", "--max-count=1", "--format=%P", commit) - if err != nil { - return "", false, fmt.Errorf("read parents of %s: %w", shortChangeCommit(commit), err) - } - parents := strings.Fields(out) - if len(parents) == 0 { - return "", false, nil - } - return parents[0], true, nil -} - -func normalizeCheckboxLabel(label string) string { - return strings.Join(strings.Fields(strings.TrimSpace(label)), " ") -} - -func isMarkdownFenceMarker(body string) bool { - return strings.HasPrefix(strings.TrimSpace(body), "```") -} - -// changeFencedLines records, by 1-based line number, which lines of one file -// image sit inside a fenced region (marker lines included). A nil map means no -// image existed — file creation or deletion — and nothing is fenced. -type changeFencedLines map[int]bool - -func (f changeFencedLines) fenced(line int) bool { - if f == nil { - return false - } - return f[line] -} - -func markdownFencedLines(content string) changeFencedLines { - fenced := changeFencedLines{} - inFence := false - for index, line := range strings.Split(content, "\n") { - marker := isMarkdownFenceMarker(line) - if marker || inFence { - fenced[index+1] = true - } - if marker { - inFence = !inFence - } - } - return fenced -} - -// diffContainsCheckboxFlip scores a single-file patch: it requires a same-hunk -// `- [ ]`→`- [x]` pair with the same normalized label whose removed line is -// unfenced in the pre-image and whose added line is unfenced in the post-image. -// Hunk headers supply the file positions; an unparseable header ends the hunk -// without crediting a flip, keeping the failure direction on non-events. -func diffContainsCheckboxFlip(diff string, preFenced, postFenced changeFencedLines) bool { - var removed map[string]struct{} - var added map[string]struct{} - inHunk := false - oldLine, newLine := 0, 0 - - hunkHasFlip := func() bool { - for label := range removed { - if _, ok := added[label]; ok { - return true - } - } - return false - } - - for _, line := range strings.Split(diff, "\n") { - if strings.HasPrefix(line, "@@") { - if inHunk && hunkHasFlip() { - return true - } - match := changeDiffHunkRE.FindStringSubmatch(line) - if match == nil { - inHunk = false - continue - } - oldStart, oldErr := strconv.Atoi(match[1]) - newStart, newErr := strconv.Atoi(match[2]) - if oldErr != nil || newErr != nil { - inHunk = false - continue - } - oldLine, newLine = oldStart, newStart - inHunk = true - removed = make(map[string]struct{}) - added = make(map[string]struct{}) - continue - } - if !inHunk || line == "" { - continue - } - body := line[1:] - switch line[0] { - case ' ': - oldLine++ - newLine++ - case '-': - if !preFenced.fenced(oldLine) { - if m := changeFlipUncheckedRE.FindStringSubmatch(body); m != nil { - removed[normalizeCheckboxLabel(m[1])] = struct{}{} - } - } - oldLine++ - case '+': - if !postFenced.fenced(newLine) { - if m := changeFlipCheckedRE.FindStringSubmatch(body); m != nil { - added[normalizeCheckboxLabel(m[1])] = struct{}{} - } - } - newLine++ - } - } - return inHunk && hunkHasFlip() -} - -// formatChangeExecutionBlock renders the execution refusal. The squash branch -// exists because that refusal is otherwise unactionable: checked packets plus -// landed code plus no vouching receipt is exactly what a squash merge leaves -// behind, and the operator needs the cause and the one command that fixes it. -func formatChangeExecutionBlock(slug, target string, layout string, evidence changeMemberEvidence, materialized bool) string { - if !materialized { - return fmt.Sprintf("release blocked: change %q targets %s but is not materialized", slug, target) - } - if layout == changeLayoutLegacy { - return fmt.Sprintf("release blocked: change %q targets %s but is legacy layout — convert first", slug, target) - } - if evidence.executed() { - return "" - } - if evidence.AllTasksChecked && evidence.Status.PathExecuted { - return fmt.Sprintf("release blocked: change %q targets %s but is not executed: every task box is checked and code landed outside docs/changes/, but no receipt vouches for this tree (%s) — a squash merge rewrites the checkbox flips the first grading path reads. Run: loaf change verify %s, then commit the receipt", - slug, target, evidence.Verdict.Cause(), evidence.FolderRel) - } - return fmt.Sprintf("release blocked: change %q targets %s but is not executed", slug, target) -} - -// formatChangeReceiptBlock renders a cohort receipt failure from a typed -// verdict. Every block names the folder, the cause, and a copy-pasteable remedy -// — preflight never runs criteria. -func formatChangeReceiptBlock(slug, target string, verdict changeReceiptVerdict, folder string) string { - folder = filepath.ToSlash(folder) - cause := verdict.Cause() - if verdict.Reason == changeReceiptFailingResults { - return fmt.Sprintf("change %q targets %s but %s. Fix the failing criteria, then run: loaf change verify %s and commit the receipt", slug, target, cause, folder) - } - if verdict.Reason == changeReceiptEvidenceUnavailable { - return fmt.Sprintf("change %q targets %s but %s. Verification cannot proceed until git reads succeed — inspect the repository (git fsck) or re-clone", slug, target, cause) - } - remedy := fmt.Sprintf("Run: loaf change verify %s, then commit the receipt", folder) - return fmt.Sprintf("change %q targets %s but %s. %s", slug, target, cause, remedy) -} diff --git a/internal/cli/change_provenance_test.go b/internal/cli/change_provenance_test.go deleted file mode 100644 index 4d9bf333c..000000000 --- a/internal/cli/change_provenance_test.go +++ /dev/null @@ -1,612 +0,0 @@ -package cli - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -const provenanceFence = "```" - -// provenanceTaskFile assembles a task packet body from its lines so fixtures can -// place fence markers at a controlled distance from the flipped checkbox. -func provenanceTaskFile(bodyLines ...string) string { - return "---\nchange: prov\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n" + strings.Join(bodyLines, "\n") + "\n" -} - -// provenanceDistantFenceTask opens a fence ten lines above the example checkbox, -// well outside any --unified=3 window around it. -func provenanceDistantFenceTask(exampleBox, realBox string) string { - return provenanceTaskFile( - "## Example", - "", - provenanceFence+"bash", - "loaf change check", - "loaf change tasks --json", - "loaf change verify docs/changes/20260727-prov", - "loaf release --dry-run --bump release", - "git log --oneline", - "go test ./internal/cli/", - "go vet ./internal/cli/", - "npm run build", - "npm run typecheck", - "loaf check", - exampleBox, - provenanceFence, - "", - "## Steps", - "", - realBox, - ) -} - -func TestProvenanceMarkdownFencedLines(t *testing.T) { - content := provenanceDistantFenceTask("- [ ] Example flip", "- [ ] Real step") - fenced := markdownFencedLines(content) - - lines := strings.Split(content, "\n") - exampleLine, realLine, openerLine := 0, 0, 0 - for index, line := range lines { - switch { - case strings.Contains(line, "Example flip"): - exampleLine = index + 1 - case strings.Contains(line, "Real step"): - realLine = index + 1 - case line == provenanceFence+"bash": - openerLine = index + 1 - } - } - if exampleLine == 0 || realLine == 0 || openerLine == 0 { - t.Fatalf("fixture lines not located: opener=%d example=%d real=%d", openerLine, exampleLine, realLine) - } - if exampleLine-openerLine < 4 { - t.Fatalf("fixture fence is inside a unified=3 window: opener=%d example=%d", openerLine, exampleLine) - } - if !fenced.fenced(exampleLine) { - t.Fatalf("example checkbox at line %d should be fenced", exampleLine) - } - if fenced.fenced(realLine) { - t.Fatalf("real step at line %d should not be fenced", realLine) - } - if !fenced.fenced(openerLine) { - t.Fatalf("opening fence marker at line %d should be fenced", openerLine) - } - if fenced.fenced(1) { - t.Fatalf("frontmatter line 1 should not be fenced") - } - var nilMap changeFencedLines - if nilMap.fenced(exampleLine) { - t.Fatalf("absent image should report nothing fenced") - } -} - -func TestProvenanceFlipGrammar(t *testing.T) { - cases := []struct { - name string - diff string - preFenced changeFencedLines - postFenced changeFencedLines - want bool - }{ - { - name: "plain flip", - diff: "" + - "@@ -8,1 +8,1 @@\n" + - "- - [ ] Do it\n" + - "+ - [x] Do it\n", - want: true, - }, - { - name: "flip with unrelated prose in same hunk", - diff: "" + - "@@ -6,4 +6,5 @@\n" + - " ## Steps\n" + - "- - [ ] Do it\n" + - "+ - [x] Do it\n" + - "+\n" + - "+ note about the work\n", - want: true, - }, - { - name: "squash batch several flips", - diff: "" + - "@@ -10,1 +10,1 @@\n" + - "- - [ ] First step\n" + - "+ - [x] First step\n" + - "@@ -20,1 +20,1 @@\n" + - "- - [ ] Second step\n" + - "+ - [x] Second step\n" + - "@@ -30,1 +30,1 @@\n" + - "- - [ ] Third step\n" + - "+ - [x] Third step\n", - want: true, - }, - { - name: "reverse flip", - diff: "" + - "@@ -8,1 +8,1 @@\n" + - "- - [x] Do it\n" + - "+ - [ ] Do it\n", - want: false, - }, - { - name: "added unchecked", - diff: "" + - "@@ -10,0 +11,1 @@\n" + - "+ - [ ] Brand new step\n", - want: false, - }, - { - name: "whitespace only", - diff: "" + - "@@ -8,1 +8,1 @@\n" + - "- - [ ] Do it\n" + - "+ - [ ] Do it \n", - want: false, - }, - { - name: "title only", - diff: "" + - "@@ -8,1 +8,1 @@\n" + - "- - [ ] Do it\n" + - "+ - [ ] Do it now\n", - want: false, - }, - { - name: "fenced block flip with the fence in the window", - diff: "" + - "@@ -10,3 +10,3 @@\n" + - " " + provenanceFence + "\n" + - "- - [ ] Example flip\n" + - "+ - [x] Example flip\n" + - " " + provenanceFence + "\n", - preFenced: changeFencedLines{10: true, 11: true, 12: true}, - postFenced: changeFencedLines{10: true, 11: true, 12: true}, - want: false, - }, - { - name: "fenced block flip with no fence in the window", - diff: "" + - "@@ -30,1 +30,1 @@\n" + - "- - [ ] Example flip\n" + - "+ - [x] Example flip\n", - preFenced: changeFencedLines{30: true}, - postFenced: changeFencedLines{30: true}, - want: false, - }, - { - name: "unfenced flip beside a fenced region elsewhere in the file", - diff: "" + - "@@ -40,1 +40,1 @@\n" + - "- - [ ] Real step\n" + - "+ - [x] Real step\n", - preFenced: changeFencedLines{20: true, 21: true, 22: true}, - postFenced: changeFencedLines{20: true, 21: true, 22: true}, - want: true, - }, - { - name: "removed line fenced only in the pre-image", - diff: "" + - "@@ -12,1 +12,1 @@\n" + - "- - [ ] Do it\n" + - "+ - [x] Do it\n", - preFenced: changeFencedLines{12: true}, - want: false, - }, - { - name: "added line fenced only in the post-image", - diff: "" + - "@@ -12,1 +12,1 @@\n" + - "- - [ ] Do it\n" + - "+ - [x] Do it\n", - postFenced: changeFencedLines{12: true}, - want: false, - }, - { - name: "context lines advance both file positions", - diff: "" + - "@@ -8,5 +8,5 @@\n" + - " ## Steps\n" + - " \n" + - "- - [ ] Do it\n" + - "+ - [x] Do it\n" + - " \n" + - " done\n", - preFenced: changeFencedLines{8: true, 9: true, 11: true}, - postFenced: changeFencedLines{8: true, 9: true, 11: true}, - want: true, - }, - { - name: "delete plus add without shared label", - diff: "" + - "@@ -8,1 +8,1 @@\n" + - "- - [ ] Label A\n" + - "+ - [x] Label B\n", - want: false, - }, - { - name: "different hunks no shared label pairing across hunks", - diff: "" + - "@@ -8,1 +8,0 @@\n" + - "- - [ ] Label A\n" + - "@@ -20,0 +20,1 @@\n" + - "+ - [x] Label A\n", - want: false, - }, - { - name: "unparseable hunk header credits nothing", - diff: "" + - "@@ malformed @@\n" + - "- - [ ] Do it\n" + - "+ - [x] Do it\n", - want: false, - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got := diffContainsCheckboxFlip(tc.diff, tc.preFenced, tc.postFenced) - if got != tc.want { - t.Fatalf("diffContainsCheckboxFlip() = %v, want %v\ndiff:\n%s", got, tc.want, tc.diff) - } - }) - } -} - -func TestProvenanceFlipGrammarCommitFixtures(t *testing.T) { - type edit struct { - taskBody string - outside string - } - cases := []struct { - name string - beforeTask string - after edit - wantFlip bool - }{ - { - name: "plain flip", - beforeTask: "---\nchange: prov\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [ ] Do it\n", - after: edit{ - taskBody: "---\nchange: prov\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [x] Do it\n", - outside: "package main\n\nfunc main() {}\n", - }, - wantFlip: true, - }, - { - name: "flip with unrelated prose", - beforeTask: "---\nchange: prov\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [ ] Do it\n", - after: edit{ - taskBody: "---\nchange: prov\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [x] Do it\n\nImplementation notes landed with the flip.\n", - outside: "package main\n\nfunc main() {}\n", - }, - wantFlip: true, - }, - { - name: "squash batch several flips", - beforeTask: "---\nchange: prov\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [ ] First step\n\n## More\n\npad\n\n- [ ] Second step\n\n## End\n\npad\n\n- [ ] Third step\n", - after: edit{ - taskBody: "---\nchange: prov\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [x] First step\n\n## More\n\npad\n\n- [x] Second step\n\n## End\n\npad\n\n- [x] Third step\n", - outside: "package main\n\nfunc main() {}\n", - }, - wantFlip: true, - }, - { - name: "reverse flip", - beforeTask: "---\nchange: prov\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [x] Do it\n", - after: edit{ - taskBody: "---\nchange: prov\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [ ] Do it\n", - outside: "package main\n\nfunc main() {}\n", - }, - wantFlip: false, - }, - { - name: "added unchecked", - beforeTask: "---\nchange: prov\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [ ] Do it\n", - after: edit{ - taskBody: "---\nchange: prov\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [ ] Do it\n- [ ] Brand new step\n", - outside: "package main\n\nfunc main() {}\n", - }, - wantFlip: false, - }, - { - name: "whitespace only", - beforeTask: "---\nchange: prov\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [ ] Do it\n", - after: edit{ - taskBody: "---\nchange: prov\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [ ] Do it \n", - outside: "package main\n\nfunc main() {}\n", - }, - wantFlip: false, - }, - { - name: "title only", - beforeTask: "---\nchange: prov\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [ ] Do it\n", - after: edit{ - taskBody: "---\nchange: prov\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [ ] Do it now\n", - outside: "package main\n\nfunc main() {}\n", - }, - wantFlip: false, - }, - { - name: "fenced block flip", - beforeTask: "---\nchange: prov\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Example\n\n" + provenanceFence + "\n- [ ] Example flip\n" + provenanceFence + "\n\n## Steps\n\n- [ ] Real step\n", - after: edit{ - taskBody: "---\nchange: prov\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Example\n\n" + provenanceFence + "\n- [x] Example flip\n" + provenanceFence + "\n\n## Steps\n\n- [ ] Real step\n", - outside: "package main\n\nfunc main() {}\n", - }, - wantFlip: false, - }, - { - name: "distant fence block flip", - beforeTask: provenanceDistantFenceTask("- [ ] Example flip", "- [ ] Real step"), - after: edit{ - taskBody: provenanceDistantFenceTask("- [x] Example flip", "- [ ] Real step"), - outside: "package main\n\nfunc main() {}\n", - }, - wantFlip: false, - }, - { - name: "genuine flip with a distant fenced example elsewhere", - beforeTask: provenanceDistantFenceTask("- [ ] Example flip", "- [ ] Real step"), - after: edit{ - taskBody: provenanceDistantFenceTask("- [ ] Example flip", "- [x] Real step"), - outside: "package main\n\nfunc main() {}\n", - }, - wantFlip: true, - }, - { - name: "delete plus add without shared label", - beforeTask: "---\nchange: prov\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [ ] Label A\n", - after: edit{ - taskBody: "---\nchange: prov\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [x] Label B\n", - outside: "package main\n\nfunc main() {}\n", - }, - wantFlip: false, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - repo := seedCohortGateRepo(t, "2.0.0-alpha.1") - dir := writeNewLayoutChange(t, repo, "20260727-prov", "prov", "2.0.0", "") - task := filepath.Join(dir, "tasks", "TASK-001-work.md") - if err := os.WriteFile(task, []byte(tc.beforeTask), 0o644); err != nil { - t.Fatalf("WriteFile before: %v", err) - } - if err := os.WriteFile(filepath.Join(repo, "main.go"), []byte("package main\n"), 0o644); err != nil { - t.Fatalf("WriteFile main.go: %v", err) - } - commitAllChangeTest(t, repo, "docs: shape") - - if err := os.WriteFile(task, []byte(tc.after.taskBody), 0o644); err != nil { - t.Fatalf("WriteFile after task: %v", err) - } - if err := os.WriteFile(filepath.Join(repo, "main.go"), []byte(tc.after.outside), 0o644); err != nil { - t.Fatalf("WriteFile after outside: %v", err) - } - commitAllChangeTest(t, repo, "feat: candidate edit") - - folderRel := filepath.ToSlash(filepath.Join("docs", "changes", "20260727-prov")) - status, err := changeFolderExecuted(repo, folderRel, changeLayoutNew, nil) - if err != nil { - t.Fatalf("changeFolderExecuted: %v", err) - } - if !status.PathExecuted { - t.Fatalf("path grade missing for companion outside edit") - } - if status.FlipExecuted != tc.wantFlip { - t.Fatalf("FlipExecuted = %v, want %v", status.FlipExecuted, tc.wantFlip) - } - if !tc.wantFlip { - err := releaseCohortPreflight(repo, "2.0.0", nil) - if err == nil || !strings.Contains(err.Error(), "not executed") { - t.Fatalf("negative fixture should block gate: %v", err) - } - } - }) - } -} - -// TestProvenanceFlipHandlesImageEdges covers the commits with only one file -// image: a task file created checked, and a task file deleted outright. -func TestProvenanceFlipHandlesImageEdges(t *testing.T) { - repo := seedCohortGateRepo(t, "2.0.0-alpha.1") - dir := writeNewLayoutChange(t, repo, "20260727-prov", "prov", "2.0.0", "") - commitAllChangeTest(t, repo, "docs: shape") - - task := filepath.Join(dir, "tasks", "TASK-001-work.md") - if err := os.WriteFile(task, []byte(provenanceTaskFile("## Steps", "", "- [x] Do it")), 0o644); err != nil { - t.Fatalf("WriteFile created task: %v", err) - } - if err := os.WriteFile(filepath.Join(repo, "main.go"), []byte("package main\n\nfunc main() {}\n"), 0o644); err != nil { - t.Fatalf("WriteFile main.go: %v", err) - } - commitAllChangeTest(t, repo, "feat: create task checked") - - folderRel := filepath.ToSlash(filepath.Join("docs", "changes", "20260727-prov")) - status, err := changeFolderExecuted(repo, folderRel, changeLayoutNew, nil) - if err != nil { - t.Fatalf("changeFolderExecuted after creation: %v", err) - } - if !status.PathExecuted { - t.Fatalf("path grade missing after creation commit") - } - if status.FlipExecuted { - t.Fatalf("a task file created already checked is not a flip") - } - - if err := os.Remove(task); err != nil { - t.Fatalf("Remove task: %v", err) - } - if err := os.WriteFile(filepath.Join(repo, "main.go"), []byte("package main\n\nfunc main() { _ = 1 }\n"), 0o644); err != nil { - t.Fatalf("WriteFile main.go after delete: %v", err) - } - commitAllChangeTest(t, repo, "chore: drop the task file") - - status, err = changeFolderExecuted(repo, folderRel, changeLayoutNew, nil) - if err != nil { - t.Fatalf("changeFolderExecuted after deletion: %v", err) - } - if status.FlipExecuted { - t.Fatalf("deleting a task file is not a flip") - } -} - -// TestProvenanceFlipHandlesRootCommit proves the missing pre-image of a root -// commit is scored as a non-event rather than erroring. -func TestProvenanceFlipHandlesRootCommit(t *testing.T) { - repo := realpath(t, t.TempDir()) - gitCLI(t, repo, "init", "-b", "main") - writeReleaseVersionFiles(t, repo, "2.0.0-alpha.1") - dir := writeNewLayoutChange(t, repo, "20260727-prov", "prov", "2.0.0", "") - if err := os.WriteFile(filepath.Join(dir, "tasks", "TASK-001-work.md"), []byte(provenanceTaskFile("## Steps", "", "- [x] Do it")), 0o644); err != nil { - t.Fatalf("WriteFile task: %v", err) - } - if err := os.WriteFile(filepath.Join(repo, "main.go"), []byte("package main\n\nfunc main() {}\n"), 0o644); err != nil { - t.Fatalf("WriteFile main.go: %v", err) - } - gitCLI(t, repo, "add", ".") - gitCLI(t, repo, "-c", "user.name=Loaf Test", "-c", "user.email=loaf@example.test", "-c", "commit.gpgsign=false", "commit", "-m", "feat: root commit carries the change") - - head, err := commandOutput(repo, "git", "rev-parse", "HEAD") - if err != nil { - t.Fatalf("rev-parse HEAD: %v", err) - } - taskRel := "docs/changes/20260727-prov/tasks/TASK-001-work.md" - flipped, err := commitFlipsTaskCheckboxes(repo, strings.TrimSpace(head), []string{taskRel}, nil) - if err != nil { - t.Fatalf("commitFlipsTaskCheckboxes on root commit: %v", err) - } - if flipped { - t.Fatalf("root commit creating a checked task file is not a flip") - } -} - -// TestChangeExecutionGradeDisjunct pins the grading rule and the two refusal -// shapes it renders: the bare one, and the squash-aware one that names the -// cause and the single command that fixes it. -func TestChangeExecutionGradeDisjunct(t *testing.T) { - folder := "docs/changes/20260727-prov" - cases := []struct { - name string - evidence changeMemberEvidence - wantExecuted bool - wantContains []string - wantMissing []string - }{ - { - name: "flip alone executes", - evidence: changeMemberEvidence{FolderRel: folder, Status: changeExecutionStatus{PathExecuted: true, FlipExecuted: true}}, - wantExecuted: true, - }, - { - name: "fresh receipt over checked packets executes", - evidence: changeMemberEvidence{ - FolderRel: folder, - Status: changeExecutionStatus{PathExecuted: true}, - Verdict: changeReceiptVerdict{OK: true, Reason: changeReceiptOK}, - AllTasksChecked: true, - }, - wantExecuted: true, - }, - { - name: "checked packets without a receipt name cause and remedy", - evidence: changeMemberEvidence{ - FolderRel: folder, - Status: changeExecutionStatus{PathExecuted: true}, - Verdict: changeReceiptVerdict{Reason: changeReceiptMissing}, - AllTasksChecked: true, - }, - wantContains: []string{"is not executed", "missing receipt", "loaf change verify " + folder}, - }, - { - name: "stale receipt over checked packets does not vouch", - evidence: changeMemberEvidence{ - FolderRel: folder, - Status: changeExecutionStatus{PathExecuted: true}, - Verdict: changeReceiptVerdict{Reason: changeReceiptContentDrift}, - AllTasksChecked: true, - }, - wantContains: []string{"is not executed", "content changed since verification"}, - }, - { - name: "fresh receipt over unchecked packets does not vouch", - evidence: changeMemberEvidence{ - FolderRel: folder, - Status: changeExecutionStatus{PathExecuted: true}, - Verdict: changeReceiptVerdict{OK: true, Reason: changeReceiptOK}, - }, - wantContains: []string{"is not executed"}, - wantMissing: []string{"loaf change verify"}, - }, - { - name: "shaping-only merge gets the bare refusal", - evidence: changeMemberEvidence{FolderRel: folder, AllTasksChecked: true}, - wantContains: []string{`change "prov" targets 2.0.0 but is not executed`}, - wantMissing: []string{"loaf change verify"}, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if got := tc.evidence.executed(); got != tc.wantExecuted { - t.Fatalf("executed() = %v, want %v", got, tc.wantExecuted) - } - msg := formatChangeExecutionBlock("prov", "2.0.0", changeLayoutNew, tc.evidence, true) - if tc.wantExecuted { - if msg != "" { - t.Fatalf("executed member must not be blocked: %q", msg) - } - return - } - for _, want := range tc.wantContains { - if !strings.Contains(msg, want) { - t.Fatalf("message = %q, want it to name %q", msg, want) - } - } - for _, unwanted := range tc.wantMissing { - if strings.Contains(msg, unwanted) { - t.Fatalf("message = %q, must not carry %q", msg, unwanted) - } - } - }) - } -} - -// TestChangeExecutionGradeReadsCheckboxesFromHEAD proves the content half of the -// grade is evidence: boxes checked only in the working tree never vouch. -func TestChangeExecutionGradeReadsCheckboxesFromHEAD(t *testing.T) { - repo := seedCohortGateRepo(t, "2.0.0-alpha.1") - dir := writeNewLayoutChange(t, repo, "20260727-head-boxes", "head-boxes", "2.0.0", "") - task := filepath.Join(dir, "tasks", "TASK-001-work.md") - unchecked := "---\nchange: head-boxes\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [ ] Do it\n" - if err := os.WriteFile(task, []byte(unchecked), 0o644); err != nil { - t.Fatalf("WriteFile task: %v", err) - } - commitAllChangeTest(t, repo, "docs: shape head-boxes") - - nodes, err := loadChangeNodesAtHEADWithOutput(repo, commandOutput) - if err != nil { - t.Fatalf("loadChangeNodesAtHEAD: %v", err) - } - node, found := changeNodeForSlug(nodes, "head-boxes") - if !found { - t.Fatal("node head-boxes missing at HEAD") - } - if changeFolderTasksAllChecked(repo, node, nil) { - t.Fatal("an unchecked committed packet must not read as complete") - } - - checked := strings.Replace(unchecked, "- [ ]", "- [x]", 1) - if err := os.WriteFile(task, []byte(checked), 0o644); err != nil { - t.Fatalf("WriteFile checked task: %v", err) - } - if changeFolderTasksAllChecked(repo, node, nil) { - t.Fatal("a box checked only in the working tree must not vouch") - } - - commitAllChangeTest(t, repo, "docs: check the box") - if !changeFolderTasksAllChecked(repo, node, nil) { - t.Fatal("a checked committed packet must read as complete") - } -} diff --git a/internal/cli/change_receipt_freshness_test.go b/internal/cli/change_receipt_freshness_test.go deleted file mode 100644 index 14e1a0dec..000000000 --- a/internal/cli/change_receipt_freshness_test.go +++ /dev/null @@ -1,359 +0,0 @@ -package cli - -import ( - "bytes" - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -func TestChangeReceiptFreshness(t *testing.T) { - t.Run("post-squash-protocol-clone-stays-verified", func(t *testing.T) { - repo := seedCohortGateRepo(t, "1.0.0-alpha.1") - // Unchecked task lands on main first so the squash commit's diff carries a real flip. - dir := writeNewLayoutChange(t, repo, "20260727-squash", "squash", "1.0.0", "") - task := filepath.Join(dir, "tasks", "TASK-001-work.md") - if err := os.WriteFile(task, []byte("---\nchange: squash\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [ ] Do it\n"), 0o644); err != nil { - t.Fatalf("WriteFile unchecked: %v", err) - } - commitAllChangeTest(t, repo, "docs: shape squash on main") - - gitCLI(t, repo, "checkout", "-b", "feature-squash") - if err := os.WriteFile(filepath.Join(repo, "main.go"), []byte("package main\n"), 0o644); err != nil { - t.Fatalf("WriteFile main.go: %v", err) - } - if err := os.WriteFile(task, []byte("---\nchange: squash\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [x] Do it\n"), 0o644); err != nil { - t.Fatalf("WriteFile flip: %v", err) - } - commitAllChangeTest(t, repo, "feat: execute squash") - folderRel := filepath.Join("docs", "changes", "20260727-squash") - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { - t.Fatalf("verify on branch: %v", err) - } - commitAllChangeTest(t, repo, "chore: commit receipt on feature branch") - - gitCLI(t, repo, "checkout", "main") - gitCLI(t, repo, "merge", "--squash", "feature-squash") - gitCLI(t, repo, "-c", "user.name=Loaf Test", "-c", "user.email=loaf@example.test", "-c", "commit.gpgsign=false", "commit", "-m", "squash: land feature") - gitCLI(t, repo, "branch", "-D", "feature-squash") - - if err := releaseCohortPreflight(repo, "1.0.0", nil); err != nil { - t.Fatalf("author machine after squash should verify: %v", err) - } - - clone := t.TempDir() - cmd := exec.Command("git", "clone", "--", "file://"+repo, clone) - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("protocol clone: %v\n%s", err, out) - } - if err := releaseCohortPreflight(clone, "1.0.0", nil); err != nil { - t.Fatalf("protocol clone must yield same verified verdict: %v", err) - } - }) - - t.Run("cohort-of-two-receipts-coexist", func(t *testing.T) { - repo := seedCohortGateRepo(t, "1.0.0-alpha.1") - dirA := writeNewLayoutChange(t, repo, "20260727-cohort-a", "cohort-a", "1.0.0", "") - dirB := writeNewLayoutChange(t, repo, "20260727-cohort-b", "cohort-b", "1.0.0", "") - flipExecuteChange(t, repo, dirA, "cohort-a") - - taskB := filepath.Join(dirB, "tasks", "TASK-001-work.md") - if err := os.MkdirAll(filepath.Dir(taskB), 0o755); err != nil { - t.Fatalf("mkdir: %v", err) - } - if err := os.WriteFile(taskB, []byte("---\nchange: cohort-b\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [ ] Do it\n"), 0o644); err != nil { - t.Fatalf("WriteFile unchecked: %v", err) - } - commitAllChangeTest(t, repo, "docs: shape cohort-b") - if err := os.WriteFile(filepath.Join(repo, "main_b.go"), []byte("package main\n"), 0o644); err != nil { - t.Fatalf("WriteFile main_b.go: %v", err) - } - if err := os.WriteFile(taskB, []byte("---\nchange: cohort-b\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [x] Do it\n"), 0o644); err != nil { - t.Fatalf("WriteFile flip: %v", err) - } - commitAllChangeTest(t, repo, "feat: execute cohort-b") - - folderA := filepath.Join("docs", "changes", "20260727-cohort-a") - folderB := filepath.Join("docs", "changes", "20260727-cohort-b") - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderA}); err != nil { - t.Fatalf("verify A: %v", err) - } - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderB}); err != nil { - t.Fatalf("verify B: %v", err) - } - commitAllChangeTest(t, repo, "chore: commit both cohort receipts") - if err := releaseCohortPreflight(repo, "1.0.0", nil); err != nil { - t.Fatalf("N=2 cohort receipts must coexist: %v", err) - } - }) - - t.Run("touch-then-revert-inverse-stays-fresh", func(t *testing.T) { - repo := seedCohortGateRepo(t, "1.0.0-alpha.1") - dir := writeNewLayoutChange(t, repo, "20260727-revert", "revert", "1.0.0", "") - flipExecuteChange(t, repo, dir, "revert") - folderRel := filepath.Join("docs", "changes", "20260727-revert") - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { - t.Fatalf("verify: %v", err) - } - commitAllChangeTest(t, repo, "chore: commit receipt") - - path := filepath.Join(repo, "touch.txt") - if err := os.WriteFile(path, []byte("x\n"), 0o644); err != nil { - t.Fatalf("WriteFile: %v", err) - } - commitAllChangeTest(t, repo, "chore: touch") - if err := os.Remove(path); err != nil { - t.Fatalf("Remove: %v", err) - } - commitAllChangeTest(t, repo, "chore: restore bytes") - // ADR-024 Decision 4: byte-identical restore un-stales deliberately. - if err := releaseCohortPreflight(repo, "1.0.0", nil); err != nil { - t.Fatalf("byte-identical restore must stay fresh: %v", err) - } - }) - - t.Run("every-reason-is-a-typed-block-never-inspection-error", func(t *testing.T) { - repo := seedCohortGateRepo(t, "1.0.0-alpha.1") - body := shapeWithVerification("- **V1.** Smoke. Command: `true`. Expect: exit 0\n- **V2.** Also. Command: `true`. Expect: exit 0") - dir := writeNewLayoutChange(t, repo, "20260727-reasons", "reasons", "1.0.0", body) - flipExecuteChange(t, repo, dir, "reasons") - folderRel := filepath.Join("docs", "changes", "20260727-reasons") - node, err := assembleChangeNodeFromFolder(repo, filepath.Join(repo, folderRel)) - if err != nil { - t.Fatalf("assemble: %v", err) - } - - assertBlock := func(t *testing.T, verdict changeReceiptVerdict, want changeReceiptReason, substr string) { - t.Helper() - if verdict.OK || verdict.Reason != want { - t.Fatalf("verdict=%#v, want reason %v", verdict, want) - } - msg := formatChangeReceiptBlock("reasons", "1.0.0", verdict, folderRel) - if strings.Contains(msg, "cannot inspect") || strings.Contains(msg, "exit status") { - t.Fatalf("inspection error leaked: %s", msg) - } - if !strings.Contains(msg, substr) { - t.Fatalf("msg=%q, want substr %q", msg, substr) - } - if want == changeReceiptEvidenceUnavailable { - if !strings.Contains(msg, "git fsck") || !strings.Contains(msg, "re-clone") { - t.Fatalf("msg=%q, want seam-recovery remedy", msg) - } - if strings.Contains(msg, "loaf change verify") { - t.Fatalf("msg=%q must not prescribe re-verify through the same broken seam", msg) - } - } else if !strings.Contains(msg, "loaf change verify") { - t.Fatalf("msg=%q, want remedy", msg) - } - lower := strings.ToLower(msg) - if strings.Contains(lower, "invalid") || strings.Contains(lower, "corrupt") { - t.Fatalf("DX wording must not say invalid/corrupt: %s", msg) - } - } - - verdict := changeReceiptStatus(repo, folderRel, node, nil) - assertBlock(t, verdict, changeReceiptMissing, "missing receipt") - - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { - t.Fatalf("verify: %v", err) - } - verdict = changeReceiptStatus(repo, folderRel, node, nil) - assertBlock(t, verdict, changeReceiptUncommitted, "not committed") - - commitAllChangeTest(t, repo, "chore: commit receipt") - node, _ = assembleChangeNodeFromFolder(repo, filepath.Join(repo, folderRel)) - verdict = changeReceiptStatus(repo, folderRel, node, nil) - if !verdict.OK { - t.Fatalf("fresh: %#v", verdict) - } - - v1 := changeVerifyReceipt{SchemaVersion: 1, Change: "reasons", CriteriaDigest: "x", Results: []changeVerifyCriterionResult{{ID: "V1", OK: true}, {ID: "V2", OK: true}}} - writeCommittedReceipt(t, repo, dir, v1) - verdict = changeReceiptStatus(repo, folderRel, node, nil) - assertBlock(t, verdict, changeReceiptUnsupportedSchema, "unsupported receipt schema_version 1") - - if err := os.WriteFile(filepath.Join(dir, "receipts", "verify.json"), []byte("{not-json"), 0o644); err != nil { - t.Fatalf("WriteFile: %v", err) - } - commitAllChangeTest(t, repo, "chore: unreadable receipt") - verdict = changeReceiptStatus(repo, folderRel, node, nil) - assertBlock(t, verdict, changeReceiptUnreadable, "unreadable") - - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { - t.Fatalf("re-verify: %v", err) - } - commitAllChangeTest(t, repo, "chore: restore receipt") - expired := shapeWithVerification("- **V1.** Smoke changed. Command: `true`. Expect: exit 0\n- **V2.** Also. Command: `true`. Expect: exit 0") - if err := os.WriteFile(filepath.Join(dir, "shape.md"), []byte(expired), 0o644); err != nil { - t.Fatalf("WriteFile shape: %v", err) - } - commitAllChangeTest(t, repo, "docs: change criteria text") - node, _ = assembleChangeNodeFromFolder(repo, filepath.Join(repo, folderRel)) - verdict = changeReceiptStatus(repo, folderRel, node, nil) - assertBlock(t, verdict, changeReceiptCriteriaMismatch, "criteria changed") - - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { - t.Fatalf("verify after criteria: %v", err) - } - commitAllChangeTest(t, repo, "chore: re-verify") - driftPath := filepath.Join(repo, "internal", "cli", "drift.go") - if err := os.MkdirAll(filepath.Dir(driftPath), 0o755); err != nil { - t.Fatalf("mkdir: %v", err) - } - if err := os.WriteFile(driftPath, []byte("package cli\n"), 0o644); err != nil { - t.Fatalf("WriteFile drift: %v", err) - } - commitAllChangeTest(t, repo, "feat: drift content") - node, _ = assembleChangeNodeFromFolder(repo, filepath.Join(repo, folderRel)) - verdict = changeReceiptStatus(repo, folderRel, node, nil) - assertBlock(t, verdict, changeReceiptContentDrift, "content changed under") - if !strings.Contains(verdict.Cause(), "`internal`") { - t.Fatalf("cause should name internal section: %s", verdict.Cause()) - } - - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { - t.Fatalf("verify clean: %v", err) - } - commitAllChangeTest(t, repo, "chore: fresh again") - boundary := mustReadVerifyReceipt(t, dir) - if len(boundary.Exclusions) == 0 { - t.Fatal("expected exclusions on fresh receipt") - } - boundary.Exclusions = append(append([]string{}, boundary.Exclusions...), "docs/changes/*/extra/**") - writeCommittedReceipt(t, repo, dir, boundary) - node, _ = assembleChangeNodeFromFolder(repo, filepath.Join(repo, folderRel)) - verdict = changeReceiptStatus(repo, folderRel, node, nil) - assertBlock(t, verdict, changeReceiptBoundaryChanged, "evidence boundary changed since verification (receipt expired)") - - brokenGit := func(cwd, name string, args ...string) (string, error) { - return "", fmt.Errorf("exit status 128: fatal: simulated git seam failure") - } - verdict = changeReceiptStatus(repo, folderRel, node, brokenGit) - assertBlock(t, verdict, changeReceiptEvidenceUnavailable, "could not read evidence at HEAD (git error)") - - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { - t.Fatalf("verify after boundary: %v", err) - } - commitAllChangeTest(t, repo, "chore: restore after boundary") - good := mustReadVerifyReceipt(t, dir) - good.Results = good.Results[:1] - writeCommittedReceipt(t, repo, dir, good) - node, _ = assembleChangeNodeFromFolder(repo, filepath.Join(repo, folderRel)) - verdict = changeReceiptStatus(repo, folderRel, node, nil) - assertBlock(t, verdict, changeReceiptResultsGap, "missing criteria (V2)") - - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { - t.Fatalf("verify: %v", err) - } - commitAllChangeTest(t, repo, "chore: full results") - failing := mustReadVerifyReceipt(t, dir) - failing.Results[0].OK = false - writeCommittedReceipt(t, repo, dir, failing) - verdict = changeReceiptStatus(repo, folderRel, node, nil) - assertBlock(t, verdict, changeReceiptFailingResults, "failing criteria (V1)") - msg := formatChangeReceiptBlock("reasons", "1.0.0", verdict, folderRel) - if !strings.Contains(msg, "Fix the failing criteria, then run: loaf change verify") { - t.Fatalf("failing block missing named remedy: %s", msg) - } - }) - - t.Run("re-verify-succeeds-with-committed-receipt-after-drift", func(t *testing.T) { - repo := seedCohortGateRepo(t, "1.0.0-alpha.1") - dir := writeNewLayoutChange(t, repo, "20260727-reverify", "reverify", "1.0.0", "") - flipExecuteChange(t, repo, dir, "reverify") - folderRel := filepath.Join("docs", "changes", "20260727-reverify") - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { - t.Fatalf("initial verify: %v", err) - } - commitAllChangeTest(t, repo, "chore: commit receipt") - - if err := os.WriteFile(filepath.Join(repo, "drift.go"), []byte("package main\n"), 0o644); err != nil { - t.Fatalf("WriteFile drift: %v", err) - } - commitAllChangeTest(t, repo, "feat: content drift") - - // Re-verify must succeed without an intermediate commit of the receipt — - // the dirty check exempts the receipt mask so a tracked receipts/verify.json - // rewrite does not self-block. - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { - t.Fatalf("re-verify with committed receipt after drift: %v", err) - } - receipt := mustReadVerifyReceipt(t, dir) - if !receipt.WorktreeClean { - t.Fatal("re-verify receipt must record worktree_clean true") - } - }) - - t.Run("cohort-reverify-sweep-with-committed-receipts", func(t *testing.T) { - repo := seedCohortGateRepo(t, "1.0.0-alpha.1") - dirA := writeNewLayoutChange(t, repo, "20260727-sweep-a", "sweep-a", "1.0.0", "") - dirB := writeNewLayoutChange(t, repo, "20260727-sweep-b", "sweep-b", "1.0.0", "") - flipExecuteChange(t, repo, dirA, "sweep-a") - - taskB := filepath.Join(dirB, "tasks", "TASK-001-work.md") - if err := os.MkdirAll(filepath.Dir(taskB), 0o755); err != nil { - t.Fatalf("mkdir: %v", err) - } - if err := os.WriteFile(taskB, []byte("---\nchange: sweep-b\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [ ] Do it\n"), 0o644); err != nil { - t.Fatalf("WriteFile unchecked: %v", err) - } - commitAllChangeTest(t, repo, "docs: shape sweep-b") - if err := os.WriteFile(filepath.Join(repo, "main_sweep.go"), []byte("package main\n"), 0o644); err != nil { - t.Fatalf("WriteFile main_sweep.go: %v", err) - } - if err := os.WriteFile(taskB, []byte("---\nchange: sweep-b\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [x] Do it\n"), 0o644); err != nil { - t.Fatalf("WriteFile flip: %v", err) - } - commitAllChangeTest(t, repo, "feat: execute sweep-b") - - folderA := filepath.Join("docs", "changes", "20260727-sweep-a") - folderB := filepath.Join("docs", "changes", "20260727-sweep-b") - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderA}); err != nil { - t.Fatalf("verify A: %v", err) - } - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderB}); err != nil { - t.Fatalf("verify B: %v", err) - } - commitAllChangeTest(t, repo, "chore: commit both cohort receipts") - if err := releaseCohortPreflight(repo, "1.0.0", nil); err != nil { - t.Fatalf("initial cohort green: %v", err) - } - - if err := os.WriteFile(filepath.Join(repo, "sweep_drift.go"), []byte("package main\n"), 0o644); err != nil { - t.Fatalf("WriteFile drift: %v", err) - } - commitAllChangeTest(t, repo, "feat: drift expires receipts") - - // True sweep: re-verify A then B back-to-back with no commits between. - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderA}); err != nil { - t.Fatalf("re-verify A: %v", err) - } - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderB}); err != nil { - t.Fatalf("re-verify B with A's uncommitted receipt dirty: %v", err) - } - commitAllChangeTest(t, repo, "chore: sweep-commit both receipts") - if err := releaseCohortPreflight(repo, "1.0.0", nil); err != nil { - t.Fatalf("sweep cohort must be green: %v", err) - } - }) -} - -func writeCommittedReceipt(t *testing.T, repo, dir string, receipt changeVerifyReceipt) { - t.Helper() - data, err := json.MarshalIndent(receipt, "", " ") - if err != nil { - t.Fatalf("marshal: %v", err) - } - path := filepath.Join(dir, "receipts", "verify.json") - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatalf("mkdir: %v", err) - } - if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil { - t.Fatalf("WriteFile: %v", err) - } - commitAllChangeTest(t, repo, "chore: write receipt fixture") -} diff --git a/internal/cli/change_receipt_status.go b/internal/cli/change_receipt_status.go deleted file mode 100644 index a5c70fc5c..000000000 --- a/internal/cli/change_receipt_status.go +++ /dev/null @@ -1,179 +0,0 @@ -package cli - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "slices" - "strings" -) - -// changeReceiptReason is the typed freshness verdict. Rendering derives from the -// type; consumers must not parse prose (ADR-024). -type changeReceiptReason int - -const ( - changeReceiptOK changeReceiptReason = iota - changeReceiptMissing - changeReceiptUncommitted - changeReceiptUnreadable - changeReceiptUnsupportedSchema - changeReceiptCriteriaMismatch - changeReceiptContentDrift - changeReceiptBoundaryChanged - changeReceiptResultsGap - changeReceiptFailingResults - changeReceiptDirtyExecution - changeReceiptEvidenceUnavailable -) - -// changeReceiptVerdict is the pure freshness outcome for one change folder. -type changeReceiptVerdict struct { - OK bool - Reason changeReceiptReason - DriftedSections []string - FailedIDs []string - MissingIDs []string - SchemaVersion int -} - -// Cause returns the stable cause fragment used in tests and block messages. -func (v changeReceiptVerdict) Cause() string { - switch v.Reason { - case changeReceiptOK: - return "" - case changeReceiptMissing: - return "missing receipt" - case changeReceiptUncommitted: - return "receipt not committed at HEAD" - case changeReceiptUnreadable: - return "receipt unreadable — re-verify" - case changeReceiptUnsupportedSchema: - return fmt.Sprintf("unsupported receipt schema_version %d", v.SchemaVersion) - case changeReceiptCriteriaMismatch: - return "criteria changed (receipt expired)" - case changeReceiptContentDrift: - if len(v.DriftedSections) == 0 { - return "content changed since verification" - } - quoted := make([]string, len(v.DriftedSections)) - for i, s := range v.DriftedSections { - quoted[i] = "`" + s + "`" - } - return fmt.Sprintf("content changed since verification (content changed under %s)", strings.Join(quoted, ", ")) - case changeReceiptBoundaryChanged: - return "evidence boundary changed since verification (receipt expired)" - case changeReceiptResultsGap: - return fmt.Sprintf("receipt results missing criteria (%s)", strings.Join(v.MissingIDs, ", ")) - case changeReceiptFailingResults: - return fmt.Sprintf("receipt records failing criteria (%s)", strings.Join(v.FailedIDs, ", ")) - case changeReceiptDirtyExecution: - return "verify ran against a worktree that diverged from HEAD (receipt void)" - case changeReceiptEvidenceUnavailable: - return "could not read evidence at HEAD (git error)" - default: - return "receipt not current" - } -} - -// changeReceiptStatus reports whether a committed receipt attests successful -// verification of the pinned HEAD tree. The verdict is a pure function of -// receipt fields and HEAD content — no refs, no reachability, no worktree for -// the verdict itself. changeReceiptExistsInWorkingTree only refines the missing -// vs uncommitted cause when HEAD has no receipt. Git-seam failures are typed -// blocking verdicts, never errors. -func changeReceiptStatus(rootPath, folderRel string, node changeNode, outputCommand changeGitOutput) changeReceiptVerdict { - if outputCommand == nil { - outputCommand = commandOutput - } - receiptRel := changeReceiptRelPath(folderRel) - content, found, err := readCommittedOptional(rootPath, "HEAD", receiptRel, outputCommand) - if err != nil { - return changeReceiptVerdict{Reason: changeReceiptEvidenceUnavailable} - } - if !found { - if changeReceiptExistsInWorkingTree(rootPath, folderRel) { - return changeReceiptVerdict{Reason: changeReceiptUncommitted} - } - return changeReceiptVerdict{Reason: changeReceiptMissing} - } - var receipt changeVerifyReceipt - if err := json.Unmarshal([]byte(content), &receipt); err != nil { - return changeReceiptVerdict{Reason: changeReceiptUnreadable} - } - if receipt.SchemaVersion != 2 { - return changeReceiptVerdict{Reason: changeReceiptUnsupportedSchema, SchemaVersion: receipt.SchemaVersion} - } - if !receipt.WorktreeClean { - return changeReceiptVerdict{Reason: changeReceiptDirtyExecution} - } - currentExclusions := ChangeEvidenceExclusions() - if !slices.Equal(receipt.Exclusions, currentExclusions) || receipt.DigestSpec != ChangeEvidenceDigestSpec { - return changeReceiptVerdict{Reason: changeReceiptBoundaryChanged} - } - criteria := parseChangeExecutableCriteria(node.Content) - if changeCriteriaDigest(criteria) != receipt.CriteriaDigest { - return changeReceiptVerdict{Reason: changeReceiptCriteriaMismatch} - } - if failed := receiptFailingCriterionIDs(receipt); len(failed) > 0 { - return changeReceiptVerdict{Reason: changeReceiptFailingResults, FailedIDs: failed} - } - if missing := receiptMissingCriterionIDs(receipt, criteria); len(missing) > 0 { - return changeReceiptVerdict{Reason: changeReceiptResultsGap, MissingIDs: missing} - } - head, err := outputCommand(rootPath, "git", "rev-parse", "HEAD") - if err != nil { - return changeReceiptVerdict{Reason: changeReceiptEvidenceUnavailable} - } - head = strings.TrimSpace(head) - scope, err := scopeDigest(rootPath, head, currentExclusions, outputCommand) - if err != nil { - return changeReceiptVerdict{Reason: changeReceiptEvidenceUnavailable} - } - if scope.Digest != receipt.ScopeDigest { - return changeReceiptVerdict{ - Reason: changeReceiptContentDrift, - DriftedSections: driftedScopeSections(receipt.ScopeSections, scope.Sections), - } - } - return changeReceiptVerdict{OK: true, Reason: changeReceiptOK} -} - -func receiptMissingCriterionIDs(receipt changeVerifyReceipt, criteria []changeCriterion) []string { - have := map[string]bool{} - for _, r := range receipt.Results { - have[r.ID] = true - } - var missing []string - for _, c := range criteria { - if !have[c.ID] { - missing = append(missing, c.ID) - } - } - return missing -} - -func driftedScopeSections(recorded, current map[string]string) []string { - keys := map[string]struct{}{} - for k := range recorded { - keys[k] = struct{}{} - } - for k := range current { - keys[k] = struct{}{} - } - var drifted []string - for k := range keys { - if recorded[k] != current[k] { - drifted = append(drifted, k) - } - } - slices.Sort(drifted) - return drifted -} - -func changeReceiptExistsInWorkingTree(rootPath, folderRel string) bool { - folderAbs := filepath.Join(rootPath, filepath.FromSlash(folderRel)) - _, err := os.Stat(filepath.Join(folderAbs, filepath.FromSlash(changeVerifyReceiptFile))) - return err == nil -} diff --git a/internal/cli/change_release_gate.go b/internal/cli/change_release_gate.go deleted file mode 100644 index 1e9e3f3bd..000000000 --- a/internal/cli/change_release_gate.go +++ /dev/null @@ -1,375 +0,0 @@ -package cli - -import ( - "fmt" - "path/filepath" - "sort" - "strings" -) - -// releaseCohortPreflight is the candidate-first gate (TASK-004). Stable -// candidates require every change with matching target_release to be -// materialized, structurally valid, executed (flip in ancestry or a receipt -// vouching for fully checked packets), and receipt-verified. Prerelease -// candidates bypass the cohort gate. -func releaseCohortPreflight(rootPath string, candidate string, warnings *[]string) error { - return releaseCohortPreflightWithOutput(rootPath, candidate, commandOutput, warnings) -} - -func releaseCohortPreflightWithOutput(rootPath, candidate string, outputCommand changeGitOutput, warnings *[]string) error { - if outputCommand == nil { - outputCommand = commandOutput - } - _, pinned, pinErr := pinEvidenceAtHEAD(rootPath, outputCommand) - if pinErr != nil { - return fmt.Errorf("release blocked: cannot pin HEAD for evidence: %w", pinErr) - } - outputCommand = pinned - if err := requireCompleteChangeHistory(rootPath, outputCommand); err != nil { - return fmt.Errorf("release blocked: cannot confirm complete Change history: %w", err) - } - deleted, err := deletedLineageChangesWithOutput(rootPath, outputCommand) - if err != nil { - return fmt.Errorf("release blocked: cannot inspect deleted or renamed Change history at HEAD: %w", err) - } - if len(deleted) != 0 { - return fmt.Errorf("release blocked: retained Change deleted or renamed in HEAD ancestry: %s", strings.Join(deleted, ", ")) - } - - nodes, err := loadChangeNodesAtHEADWithOutput(rootPath, outputCommand) - if err != nil { - return fmt.Errorf("release blocked: cannot inspect committed Changes at HEAD: %w", err) - } - - if releaseVersionIsPrerelease(candidate) { - if warnings != nil { - *warnings = append(*warnings, lowerCohortWarnings(nodes, candidate, rootPath, outputCommand)...) - } - return nil - } - - // Stable candidate: gate byte-equal cohort. - var blocked []string - var cohort []changeNode - for _, node := range nodes { - if node.TargetRelease == candidate { - cohort = append(cohort, node) - } - } - sort.Slice(cohort, func(i, j int) bool { return cohort[i].Slug < cohort[j].Slug }) - - for _, node := range cohort { - if node.Layout == changeLayoutLegacy { - blocked = append(blocked, formatChangeExecutionBlock(node.Slug, candidate, node.Layout, changeMemberEvidence{}, true)) - continue - } - report, reportErr := changeCohortStructuralReport(rootPath, node, nodes, outputCommand) - if reportErr != nil { - return fmt.Errorf("release blocked: cannot judge structural validity for %q: %w", node.Slug, reportErr) - } - folderRel := filepath.ToSlash(node.Folder) - if len(report.Violations) != 0 { - blocked = append(blocked, fmt.Sprintf("change %q targets %s but is structurally invalid (%s); run: loaf change check %s", - node.Slug, candidate, strings.Join(report.Violations, ", "), folderRel)) - continue - } - if !report.Executable { - blocked = append(blocked, fmt.Sprintf("change %q targets %s but is not executable (contract gaps: %s); run: loaf change check %s", - node.Slug, candidate, strings.Join(report.Gaps, ", "), folderRel)) - continue - } - evidence, err := changeMemberExecutionEvidence(rootPath, node, outputCommand) - if err != nil { - return fmt.Errorf("release blocked: cannot derive execution provenance for %q: %w", node.Slug, err) - } - if msg := formatChangeExecutionBlock(node.Slug, candidate, node.Layout, evidence, true); msg != "" { - blocked = append(blocked, msg) - continue - } - if !evidence.Verdict.OK { - blocked = append(blocked, formatChangeReceiptBlock(node.Slug, candidate, evidence.Verdict, node.Folder)) - } - } - - // Surface retarget events relevant to the candidate. - events, err := deriveChangeRetargetEvents(rootPath, outputCommand) - if err == nil && warnings != nil { - for _, event := range events { - if event.From == candidate || event.To == candidate { - *warnings = append(*warnings, fmt.Sprintf("retarget %s: %s → %s at %s (%s)", - event.Slug, emptyAsNone(event.From), emptyAsNone(event.To), shortSHA(event.Commit), event.Surface)) - } - } - *warnings = append(*warnings, lowerCohortWarnings(nodes, candidate, rootPath, outputCommand)...) - } - - if len(blocked) != 0 { - return fmt.Errorf("%s", strings.Join(blocked, "; ")) - } - return nil -} - -// changeCohortStructuralReport is the gate's structural tier: the same composite -// `loaf change check` reports — contract evaluation, lineage validation over the -// full loaded node set, and task-hygiene/conversion findings. Executability here -// is contract-section completeness, never checkbox completion: an unchecked task -// on a verified member stays legal descoped work. -func changeCohortStructuralReport(rootPath string, node changeNode, nodes []changeNode, outputCommand changeGitOutput) (changeCheckReport, error) { - folderAbs := filepath.Join(rootPath, filepath.FromSlash(node.Folder)) - report := evaluateChangeNode(node, "") - return composeChangeCheckReport(report, rootPath, folderAbs, node, nodes, outputCommand, false, changeTaskContentHEAD) -} - -func emptyAsNone(value string) string { - if value == "" { - return "(none)" - } - return value -} - -func shortSHA(commit string) string { - if len(commit) > 7 { - return commit[:7] - } - return commit -} - -func lowerCohortWarnings(nodes []changeNode, candidate string, rootPath string, outputCommand changeGitOutput) []string { - cand, ok := parseReleaseSemver(candidate) - if !ok { - return nil - } - seen := map[string]bool{} - var warnings []string - for _, node := range nodes { - if node.TargetRelease == "" || node.TargetRelease == candidate { - continue - } - other, ok := parseReleaseSemver(node.TargetRelease) - if !ok { - continue - } - if !releaseSemverLess(other, cand) { - continue - } - if seen[node.TargetRelease] { - continue - } - // Check if that lower cohort is incomplete. - incomplete := false - for _, member := range nodes { - if member.TargetRelease != node.TargetRelease { - continue - } - if member.Layout == changeLayoutLegacy { - incomplete = true - break - } - evidence, err := changeMemberExecutionEvidence(rootPath, member, outputCommand) - if err != nil || !evidence.executed() { - incomplete = true - break - } - } - if incomplete { - seen[node.TargetRelease] = true - warnings = append(warnings, fmt.Sprintf("incomplete lower cohort target_release %s (warn only; blocks its own cut)", node.TargetRelease)) - } - } - sort.Strings(warnings) - return warnings -} - -func releaseSemverLess(a, b releaseSemver) bool { - if a.major != b.major { - return a.major < b.major - } - if a.minor != b.minor { - return a.minor < b.minor - } - return a.patch < b.patch -} - -// resolveReleaseSnapshot is the single derivation shared by the cohort gate and -// every release consumer: one immutable snapshot of version-file state, bump, -// candidate, and the commit range those fields were resolved from. -// -// Non-post-merge cuts key CurrentVersion on HEAD blobs so a refused prepare's -// worktree candidate bumps do not shift the candidate on resume. Post-merge -// still keys on the prepared worktree/HEAD version (they match on a clean tree). -func resolveReleaseSnapshot(root string, options releaseOptions) (releaseSnapshot, error) { - configOverrides, err := releaseConfigVersionFiles(root) - if err != nil { - return releaseSnapshot{}, err - } - versionOverrides := options.versionFile - if len(versionOverrides) == 0 { - versionOverrides = configOverrides - } - versionFiles, err := detectReleaseVersionFiles(root, versionOverrides) - if err != nil { - return releaseSnapshot{}, err - } - if len(versionFiles) == 0 { - return releaseSnapshot{}, fmt.Errorf("no version files detected") - } - if !options.postMerge { - versionFiles = releaseVersionFilesWithHEADBaseline(root, versionFiles) - } - current := versionFiles[0].CurrentVersion - for _, file := range versionFiles { - if file.CurrentVersion != current { - return releaseSnapshot{}, fmt.Errorf("inconsistent version files: %s vs %s", current, file.CurrentVersion) - } - } - baseRef, err := releaseCandidateBaseRef(root, options) - if err != nil { - return releaseSnapshot{}, err - } - commits := releaseCommitsSince(root, baseRef) - snap := releaseSnapshot{ - VersionFiles: versionFiles, - CurrentVersion: current, - BaseRef: baseRef, - Commits: commits, - } - if options.postMerge { - // Post-merge keys on the prepared version at HEAD: a prepared prerelease - // publishes through the valve; a prepared stable gates that cohort, then - // tags. Bump says "release" only when this run finalizes stable — a - // prerelease publish performs no bump, and the field must not claim one. - snap.Candidate = current - if !releaseVersionIsPrerelease(current) { - snap.Bump = "release" - } - return guardReleaseCeremony(snap) - } - bump := effectiveReleaseBumpFrom(options, commits) - if bump == "" { - // Nothing unreleased: the executor stops before cutting anything, so the - // candidate is the version the repository already carries. - snap.Candidate = current - return guardReleaseCeremony(snap) - } - next := bumpReleaseVersion(current, bump) - if next == "" { - return releaseSnapshot{}, fmt.Errorf("cannot bump %q with %q", current, bump) - } - snap.Bump = bump - snap.Candidate = next - return guardReleaseCeremony(snap) -} - -// guardReleaseCeremony refuses to hand back a snapshot whose candidate is a dev -// build's identity. Dev builds mint a Unix timestamp in the patch slot -// (isDevVersion), and the ceremony a release runs — changelog entry, release -// build, packaged GitHub Release, Homebrew bump — is meaningless for a number -// that names a build clock rather than a published version. -// -// It sits on the snapshot because that is the one derivation every release -// consumer reads, so dry-run, apply, and post-merge are covered by a single -// refusal. Cheaper acts never pass through here and stay available: commits, -// lightweight tags, and prerelease-marked uploads. What it judges is the -// candidate, because that is the number a run would publish. -func guardReleaseCeremony(snap releaseSnapshot) (releaseSnapshot, error) { - if !isDevVersion(snap.Candidate) { - return snap, nil - } - parsed, _ := parseUpgradeSemver(snap.Candidate) - return releaseSnapshot{}, fmt.Errorf("release ceremony guardrail: %s is a dev build identity (a Unix timestamp in the patch slot), not a release version; cut releases from a plain %d.%d.X version", - snap.Candidate, parsed.major, parsed.minor) -} - -// releaseVersionFilesWithHEADBaseline rewrites CurrentVersion from HEAD blobs -// when available so snapshot derivation ignores uncommitted candidate bumps. -func releaseVersionFilesWithHEADBaseline(root string, files []releaseVersionFile) []releaseVersionFile { - out := make([]releaseVersionFile, len(files)) - for i, file := range files { - out[i] = file - headBody, err := releaseGitShowPath(root, "HEAD", file.RelativePath) - if err != nil { - continue - } - version, format, err := parseReleaseVersion(file.RelativePath, headBody) - if err != nil { - continue - } - out[i].CurrentVersion = version - out[i].Format = format - } - return out -} - -// assertReleaseSnapshotStillCurrent re-reads the snapshot's version files and -// blocks when any has drifted from the version the candidate was resolved from. -// An uncommitted worktree already at Candidate is allowed when HEAD still -// carries CurrentVersion (resume after an evidence-gate refusal). -func assertReleaseSnapshotStillCurrent(root string, snapshot releaseSnapshot) error { - if snapshot.CurrentVersion == "" { - return fmt.Errorf("release blocked: release snapshot was not resolved before apply") - } - for _, file := range snapshot.VersionFiles { - fresh, err := loadReleaseVersionFile(root, file.RelativePath, true) - if err != nil { - return fmt.Errorf("release blocked: cannot re-read version file %s: %w", file.RelativePath, err) - } - if fresh.CurrentVersion == snapshot.CurrentVersion { - continue - } - if snapshot.Candidate != "" && fresh.CurrentVersion == snapshot.Candidate { - if headBody, err := releaseGitShowPath(root, "HEAD", file.RelativePath); err == nil { - if headVersion, _, parseErr := parseReleaseVersion(file.RelativePath, headBody); parseErr == nil && headVersion == snapshot.CurrentVersion { - continue - } - } - } - return fmt.Errorf("release blocked: version drifted from %s to %s since preflight; re-run release", snapshot.CurrentVersion, fresh.CurrentVersion) - } - return nil -} - -// effectiveReleaseBump resolves the bump the release will actually apply: the -// explicit --bump flag when given, otherwise the bump the unreleased commits -// suggest. It returns "" when there is nothing to release. The gate and the -// executor share this derivation so preflight gates the version that gets cut -// instead of the one the repository happens to sit on. -func effectiveReleaseBump(root string, options releaseOptions) (string, error) { - if options.bump != "" { - return options.bump, nil - } - baseRef, err := releaseCandidateBaseRef(root, options) - if err != nil { - return "", err - } - return effectiveReleaseBumpFrom(options, releaseCommitsSince(root, baseRef)), nil -} - -// effectiveReleaseBumpFrom resolves the same bump from an already-loaded commit -// range, so the executor never re-derives it from different inputs. -func effectiveReleaseBumpFrom(options releaseOptions, commits []releaseCommit) string { - if options.bump != "" { - return options.bump - } - if len(commits) == 0 { - return "" - } - return suggestReleaseBump(commits) -} - -// releaseCandidateBaseRef resolves the commit range the suggested bump reads, -// mirroring the executor: an explicit --base wins, --pre-merge auto-detects its -// base, and everything else measures from the last tag. -func releaseCandidateBaseRef(root string, options releaseOptions) (string, error) { - base := options.base - if base == "" && options.preMerge { - detected, _, err := detectReleaseBase(root) - if err != nil { - return "", err - } - base = detected - } - if base == "" { - return releaseLastTag(root), nil - } - return validateReleaseBaseRef(root, base) -} diff --git a/internal/cli/change_release_gate_test.go b/internal/cli/change_release_gate_test.go deleted file mode 100644 index 6b1dea385..000000000 --- a/internal/cli/change_release_gate_test.go +++ /dev/null @@ -1,1517 +0,0 @@ -package cli - -import ( - "bytes" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "testing" -) - -func seedCohortGateRepo(t *testing.T, version string) string { - t.Helper() - repo := initCLIGitRepo(t) - writeReleaseVersionFiles(t, repo, version) - return repo -} - -func writeReleaseVersionFiles(t *testing.T, repo, version string) { - t.Helper() - pkg := "{\n \"name\": \"demo\",\n \"version\": \"" + version + "\"\n}\n" - if err := os.WriteFile(filepath.Join(repo, "package.json"), []byte(pkg), 0o644); err != nil { - t.Fatalf("WriteFile package.json: %v", err) - } -} - -func writeNewLayoutChange(t *testing.T, repo, folder, slug, target string, shapeBody string) string { - t.Helper() - dir := filepath.Join(repo, "docs", "changes", folder) - if err := os.MkdirAll(filepath.Join(dir, "tasks"), 0o755); err != nil { - t.Fatalf("MkdirAll: %v", err) - } - meta := "{\n \"change\": \"" + slug + "\",\n \"created\": \"2026-07-27\",\n \"branch\": \"" + slug + "\"" - if target != "" { - meta += ",\n \"target_release\": \"" + target + "\"" - } - meta += "\n}\n" - if err := os.WriteFile(filepath.Join(dir, "change.json"), []byte(meta), 0o644); err != nil { - t.Fatalf("WriteFile change.json: %v", err) - } - if shapeBody == "" { - shapeBody = authoredShapeBody() - } - if err := os.WriteFile(filepath.Join(dir, "shape.md"), []byte(shapeBody), 0o644); err != nil { - t.Fatalf("WriteFile shape.md: %v", err) - } - return dir -} - -func authoredShapeBody() string { - sections := append(productSections(), - "## Planning Contract\n\n### Approach\n\nHow.", - "## Implementation Units\n\n- U1 — do the thing.", - "## Verification Contract\n\n- **V1.** Smoke.\n - Command: `true`\n - Expect: exit 0", - "## Definition of Done\n\n- Gates pass.", - ) - var b strings.Builder - b.WriteString("# Demo\n\n") - for _, s := range sections { - b.WriteString(s) - b.WriteString("\n\n") - } - return b.String() -} - -func TestReleaseCohortGateBlocksUnexecutedStableTarget(t *testing.T) { - repo := seedCohortGateRepo(t, "2.0.0-alpha.1") - writeNewLayoutChange(t, repo, "20260727-cohort-member", "cohort-member", "2.0.0", "") - commitAllChangeTest(t, repo, "docs: shape cohort member") - - err := releaseCohortPreflight(repo, "2.0.0", nil) - if err == nil || !strings.Contains(err.Error(), "targets 2.0.0 but is not executed") { - t.Fatalf("err = %v, want not executed", err) - } - - // Prerelease candidate bypasses. - if err := releaseCohortPreflight(repo, "2.0.0-alpha.2", nil); err != nil { - t.Fatalf("prerelease candidate should bypass: %v", err) - } - - // Minor candidate 2.1.0 does not gate 2.0.0 cohort (warn only). - var warnings []string - if err := releaseCohortPreflight(repo, "2.1.0", &warnings); err != nil { - t.Fatalf("2.1.0 candidate should not block on 2.0.0 cohort: %v", err) - } - if !findingsContain(warnings, "incomplete lower cohort") { - t.Fatalf("warnings = %v, want lower cohort warn", warnings) - } -} - -func TestReleaseCohortGateLegacyConvertFirst(t *testing.T) { - repo := seedCohortGateRepo(t, "2.0.0-alpha.1") - body := executableLineageDoc("legacy-member", "line", "", "") - body = strings.Replace(body, "---\n", "---\ntarget_release: 2.0.0\n", 1) - writeChangeFolder(t, repo, "20260727-legacy-member", body) - commitAllChangeTest(t, repo, "docs: legacy cohort member") - - err := releaseCohortPreflight(repo, "2.0.0", nil) - if err == nil || !strings.Contains(err.Error(), "legacy layout — convert first") { - t.Fatalf("err = %v, want convert first", err) - } -} - -func TestReleaseCohortGateAcceptsFlipExecutedMember(t *testing.T) { - repo := seedCohortGateRepo(t, "2.0.0-alpha.1") - dir := writeNewLayoutChange(t, repo, "20260727-executed", "executed", "2.0.0", "") - task := filepath.Join(dir, "tasks", "TASK-001-work.md") - if err := os.WriteFile(task, []byte("---\nchange: executed\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [ ] Do it\n"), 0o644); err != nil { - t.Fatalf("WriteFile task: %v", err) - } - commitAllChangeTest(t, repo, "docs: shape executed member") - - if err := os.WriteFile(filepath.Join(repo, "main.go"), []byte("package main\n"), 0o644); err != nil { - t.Fatalf("WriteFile main.go: %v", err) - } - if err := os.WriteFile(task, []byte("---\nchange: executed\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [ ] Do it\n\nnote\n"), 0o644); err != nil { - t.Fatalf("WriteFile task touch: %v", err) - } - commitAllChangeTest(t, repo, "chore: path grade only") - err := releaseCohortPreflight(repo, "2.0.0", nil) - if err == nil || !strings.Contains(err.Error(), "not executed") { - t.Fatalf("path grade should not open gate: %v", err) - } - - if err := os.WriteFile(task, []byte("---\nchange: executed\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [x] Do it\n"), 0o644); err != nil { - t.Fatalf("WriteFile flip: %v", err) - } - if err := os.WriteFile(filepath.Join(repo, "main.go"), []byte("package main\n\nfunc main() {}\n"), 0o644); err != nil { - t.Fatalf("WriteFile main.go flip: %v", err) - } - commitAllChangeTest(t, repo, "feat: execute task") - - var stdout bytes.Buffer - if err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "verify", filepath.Join("docs", "changes", "20260727-executed")}); err != nil { - t.Fatalf("verify: %v\n%s", err, stdout.String()) - } - commitAllChangeTest(t, repo, "chore: commit verify receipt") - - if err := releaseCohortPreflight(repo, "2.0.0", nil); err != nil { - t.Fatalf("flip-executed cohort with receipt should pass: %v", err) - } -} - -func TestResolveReleaseSnapshotFinalization(t *testing.T) { - repo := seedCohortGateRepo(t, "2.0.0-alpha.14") - snap, err := resolveReleaseSnapshot(repo, releaseOptions{bump: "release"}) - v := snap.Candidate - if err != nil || v != "2.0.0" { - t.Fatalf("release bump candidate = %q err=%v, want 2.0.0", v, err) - } - snap, err = resolveReleaseSnapshot(repo, releaseOptions{postMerge: true}) - v = snap.Candidate - if err != nil || v != "2.0.0-alpha.14" { - t.Fatalf("post-merge candidate = %q err=%v, want prepared 2.0.0-alpha.14", v, err) - } - snap, err = resolveReleaseSnapshot(repo, releaseOptions{bump: "minor"}) - v = snap.Candidate - if err != nil || v != "2.1.0" { - t.Fatalf("minor bump candidate = %q err=%v, want 2.1.0", v, err) - } - snap, err = resolveReleaseSnapshot(repo, releaseOptions{bump: "prerelease"}) - v = snap.Candidate - if err != nil || v != "2.0.0-alpha.15" { - t.Fatalf("prerelease bump candidate = %q err=%v, want 2.0.0-alpha.15", v, err) - } -} - -func TestReleaseSnapshotRefusesTimestampPatch(t *testing.T) { - // A dev build stamps the moment it landed into the patch slot. No ceremony - // can be cut from that number, and the snapshot is where every consumer - // learns so. - repo := seedCohortGateRepo(t, "0.2.1754476800") - commitAllChangeTest(t, repo, "fix: carry a dev build stamp in the version file") - - for _, tc := range []struct { - name string - options releaseOptions - }{ - {"suggested bump", releaseOptions{}}, - {"explicit patch bump", releaseOptions{bump: "patch"}}, - {"post-merge", releaseOptions{postMerge: true}}, - } { - snap, err := resolveReleaseSnapshot(repo, tc.options) - if err == nil { - t.Fatalf("%s: snapshot = %#v, want the ceremony guardrail to refuse", tc.name, snap) - } - msg := err.Error() - if !strings.Contains(msg, "release ceremony guardrail") || !strings.Contains(msg, "plain 0.2.X version") { - t.Fatalf("%s: err = %v, want the guardrail named and plain 0.2.X pointed at", tc.name, err) - } - if snap.Candidate != "" { - t.Fatalf("%s: refused snapshot carries candidate %q, want an empty snapshot", tc.name, snap.Candidate) - } - } - - // The three doors a release runs through, all closed by that one refusal. - for _, args := range [][]string{ - {"release", "--dry-run"}, - {"release", "-y", "--no-tag", "--no-gh"}, - {"release", "--post-merge"}, - } { - var stdout bytes.Buffer - err := (Runner{Stdout: &stdout, Stderr: &bytes.Buffer{}, WorkingDir: repo}).Run(args) - if err == nil || !strings.Contains(err.Error(), "release ceremony guardrail") { - t.Fatalf("loaf %s = %v, want the ceremony guardrail refusal\n%s", strings.Join(args, " "), err, stdout.String()) - } - } - - // The guardrail refuses timestamps, not releases: the same paths resolve a - // plain candidate untouched. - plain := seedCohortGateRepo(t, "0.2.20") - commitAllChangeTest(t, plain, "fix: carry a release version") - for _, tc := range []struct { - name string - options releaseOptions - want string - }{ - {"suggested bump", releaseOptions{}, "0.2.21"}, - {"post-merge", releaseOptions{postMerge: true}, "0.2.20"}, - } { - snap, err := resolveReleaseSnapshot(plain, tc.options) - if err != nil || snap.Candidate != tc.want { - t.Fatalf("%s on a plain version: candidate = %q err = %v, want %s", tc.name, snap.Candidate, err, tc.want) - } - } -} - -// CI mirrors the guardrail in bash, where it cannot read a Go constant. So the -// constant reads the workflow: the floor the release job skips at is this -// floor, or the pair has silently drifted and a dev tag packages a release. -func TestReleaseWorkflowSkipsAtTheDevVersionFloor(t *testing.T) { - workflow, err := os.ReadFile(filepath.Join(testRepositoryRoot(t), ".github", "workflows", "release.yml")) - if err != nil { - t.Fatalf("ReadFile(release.yml) error = %v", err) - } - want := fmt.Sprintf("(( ${BASH_REMATCH[3]} >= %d ))", devVersionPatchFloor) - if !strings.Contains(string(workflow), want) { - t.Fatalf("release.yml is missing the dev-version floor comparison %q", want) - } -} - -// --- TASK-015: one candidate for the gate and the executor --- - -func TestReleaseCohortGateNoBumpGatesSuggestedCandidate(t *testing.T) { - repo := seedCohortGateRepo(t, "1.0.0") - dir := writeNewLayoutChange(t, repo, "20260727-suggested", "suggested", "1.1.0", "") - task := filepath.Join(dir, "tasks", "TASK-001-work.md") - unchecked := "---\nchange: suggested\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [ ] Do it\n" - if err := os.WriteFile(task, []byte(unchecked), 0o644); err != nil { - t.Fatalf("WriteFile task: %v", err) - } - commitAllChangeTest(t, repo, "docs: shape suggested member") - - // A feat commit makes the suggested bump minor, so the no-flag invocation - // would cut 1.1.0 — the version the incomplete cohort owns. - if err := os.WriteFile(filepath.Join(repo, "feature.go"), []byte("package main\n"), 0o644); err != nil { - t.Fatalf("WriteFile feature.go: %v", err) - } - commitAllChangeTest(t, repo, "feat: unrelated feature") - - snap, err := resolveReleaseSnapshot(repo, releaseOptions{}) - candidate := snap.Candidate - if err != nil { - t.Fatalf("no-flag candidate: %v", err) - } - if candidate != "1.1.0" { - t.Fatalf("no-flag candidate = %q, want 1.1.0 (suggested minor bump)", candidate) - } - gateErr := releaseCohortPreflight(repo, candidate, nil) - if gateErr == nil || !strings.Contains(gateErr.Error(), `change "suggested" targets 1.1.0 but is not executed`) { - t.Fatalf("gate err = %v, want 1.1.0 cohort block", gateErr) - } - - var stdout bytes.Buffer - runErr := (Runner{Stdout: &stdout, Stderr: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"release", "--dry-run"}) - if runErr == nil || !strings.Contains(runErr.Error(), "targets 1.1.0 but is not executed") { - t.Fatalf("release --dry-run without --bump = %v, want cohort block\n%s", runErr, stdout.String()) - } - - // Cohort completes: the same flagless invocation proceeds to 1.1.0. - checked := "---\nchange: suggested\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [x] Do it\n" - if err := os.WriteFile(task, []byte(checked), 0o644); err != nil { - t.Fatalf("WriteFile flip: %v", err) - } - if err := os.WriteFile(filepath.Join(repo, "feature.go"), []byte("package main\n\nfunc main() {}\n"), 0o644); err != nil { - t.Fatalf("WriteFile feature.go flip: %v", err) - } - commitAllChangeTest(t, repo, "feat: execute suggested") - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", filepath.Join("docs", "changes", "20260727-suggested")}); err != nil { - t.Fatalf("verify: %v", err) - } - commitAllChangeTest(t, repo, "chore: commit verify receipt") - - snap, err = resolveReleaseSnapshot(repo, releaseOptions{}) - candidate = snap.Candidate - if err != nil || candidate != "1.1.0" { - t.Fatalf("candidate after completion = %q err=%v, want 1.1.0", candidate, err) - } - if err := releaseCohortPreflight(repo, candidate, nil); err != nil { - t.Fatalf("completed cohort should open the gate: %v", err) - } - stdout.Reset() - if err := (Runner{Stdout: &stdout, Stderr: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"release", "--dry-run"}); err != nil { - t.Fatalf("release --dry-run after completion = %v\n%s", err, stdout.String()) - } - if output := stripANSI(stdout.String()); !strings.Contains(output, "New version: 1.1.0") { - t.Fatalf("dry-run output must cut the gated candidate; got:\n%s", output) - } -} - -func TestReleaseCohortGateNoBumpPrereleaseCandidateBypasses(t *testing.T) { - repo := seedCohortGateRepo(t, "1.0.0-alpha.3") - writeNewLayoutChange(t, repo, "20260727-prerelease-bypass", "prerelease-bypass", "1.0.0", "") - commitAllChangeTest(t, repo, "docs: shape incomplete cohort member") - gitCLI(t, repo, "-c", "tag.gpgsign=false", "-c", "tag.forceSignAnnotated=false", "tag", "v1.0.0-alpha.3") - - // Nothing unreleased: the flagless candidate stays on the prerelease the repo - // carries, and a prerelease candidate never gates its cohort. - snap, err := resolveReleaseSnapshot(repo, releaseOptions{}) - candidate := snap.Candidate - if err != nil { - t.Fatalf("no-flag candidate: %v", err) - } - if candidate != "1.0.0-alpha.3" || !releaseVersionIsPrerelease(candidate) { - t.Fatalf("no-flag candidate = %q, want the current prerelease", candidate) - } - if err := releaseCohortPreflight(repo, candidate, nil); err != nil { - t.Fatalf("prerelease candidate must bypass the incomplete cohort: %v", err) - } - var stdout bytes.Buffer - if err := (Runner{Stdout: &stdout, Stderr: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"release", "--dry-run"}); err != nil { - t.Fatalf("flagless dry run on a prerelease candidate = %v\n%s", err, stdout.String()) - } - - // The same fixture's --post-merge publishes the prepared prerelease through the valve. - snap, err = resolveReleaseSnapshot(repo, releaseOptions{postMerge: true}) - post := snap.Candidate - if err != nil || post != "1.0.0-alpha.3" { - t.Fatalf("post-merge candidate = %q err=%v, want prepared 1.0.0-alpha.3", post, err) - } - if err := releaseCohortPreflight(repo, post, nil); err != nil { - t.Fatalf("prepared prerelease post-merge must bypass the incomplete cohort: %v", err) - } - - // Once commits exist, a suggested bump can only land on a stable candidate — - // the flagless path cannot drift back into the bypass by accident. - if err := os.WriteFile(filepath.Join(repo, "feature.go"), []byte("package main\n"), 0o644); err != nil { - t.Fatalf("WriteFile feature.go: %v", err) - } - commitAllChangeTest(t, repo, "feat: unrelated feature") - snap, err = resolveReleaseSnapshot(repo, releaseOptions{}) - candidate = snap.Candidate - if err != nil { - t.Fatalf("candidate with commits: %v", err) - } - if candidate != "1.1.0" || releaseVersionIsPrerelease(candidate) { - t.Fatalf("candidate with commits = %q, want stable 1.1.0", candidate) - } -} - -func flipExecuteChange(t *testing.T, repo, dir, slug string) { - t.Helper() - task := filepath.Join(dir, "tasks", "TASK-001-work.md") - if err := os.MkdirAll(filepath.Dir(task), 0o755); err != nil { - t.Fatalf("MkdirAll tasks: %v", err) - } - unchecked := "---\nchange: " + slug + "\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [ ] Do it\n" - if err := os.WriteFile(task, []byte(unchecked), 0o644); err != nil { - t.Fatalf("WriteFile task: %v", err) - } - commitAllChangeTest(t, repo, "docs: shape "+slug) - - if err := os.WriteFile(filepath.Join(repo, "main.go"), []byte("package main\n"), 0o644); err != nil { - t.Fatalf("WriteFile main.go: %v", err) - } - checked := "---\nchange: " + slug + "\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [x] Do it\n" - if err := os.WriteFile(task, []byte(checked), 0o644); err != nil { - t.Fatalf("WriteFile flip: %v", err) - } - commitAllChangeTest(t, repo, "feat: execute "+slug) -} - -// --- TASK-020/024: thread the snapshot; no re-derivation between gate and executor --- - -func TestReleaseCandidateThreadedThroughExecutorDespiteDivergence(t *testing.T) { - repo := seedCohortGateRepo(t, "1.0.0") - gitCLI(t, repo, "-c", "tag.gpgsign=false", "-c", "tag.forceSignAnnotated=false", "tag", "v1.0.0") - - // A fix commit makes the suggested bump patch → candidate 1.0.1. - if err := os.WriteFile(filepath.Join(repo, "fix.go"), []byte("package main\n"), 0o644); err != nil { - t.Fatalf("WriteFile fix.go: %v", err) - } - commitAllChangeTest(t, repo, "fix: seed patch bump") - - snap, err := resolveReleaseSnapshot(repo, releaseOptions{}) - if err != nil { - t.Fatalf("preflight resolve: %v", err) - } - if snap.Candidate != "1.0.1" || snap.Bump != "patch" || snap.CurrentVersion != "1.0.0" { - t.Fatalf("preflight = %#v, want 1.0.1/patch from 1.0.0", snap) - } - if err := releaseCohortPreflight(repo, snap.Candidate, nil); err != nil { - t.Fatalf("preflight gate: %v", err) - } - - // Seam: a feat lands after the gate judged 1.0.1. A fresh derivation would - // suggest minor → 1.1.0; the threaded executor must still cut 1.0.1. - if err := os.WriteFile(filepath.Join(repo, "feature.go"), []byte("package main\n"), 0o644); err != nil { - t.Fatalf("WriteFile feature.go: %v", err) - } - commitAllChangeTest(t, repo, "feat: land after preflight") - - fresh, err := resolveReleaseSnapshot(repo, releaseOptions{}) - if err != nil { - t.Fatalf("fresh resolve: %v", err) - } - if fresh.Candidate != "1.1.0" || fresh.Bump != "minor" { - t.Fatalf("fresh after feat = %#v, want 1.1.0/minor (proves the seam moves the derivation)", fresh) - } - - var stdout bytes.Buffer - opts := releaseOptions{ - dryRun: true, - snapshot: snap, - } - if err := runReleaseDryRun(repo, opts, &stdout, &bytes.Buffer{}); err != nil { - t.Fatalf("dry-run with threaded candidate: %v\n%s", err, stdout.String()) - } - output := stripANSI(stdout.String()) - if !strings.Contains(output, "New version: 1.0.1") { - t.Fatalf("executor must cut preflight candidate 1.0.1; got:\n%s", output) - } - if strings.Contains(output, "New version: 1.1.0") { - t.Fatalf("executor must not cut the post-seam re-derivation; got:\n%s", output) - } - if !strings.Contains(output, "Suggested bump: patch") { - t.Fatalf("bump label must derive from the same resolution; got:\n%s", output) - } -} - -func TestReleaseApplyBlocksWhenVersionFileDriftsAfterPreflight(t *testing.T) { - repo := seedCohortGateRepo(t, "1.0.0") - gitCLI(t, repo, "-c", "tag.gpgsign=false", "-c", "tag.forceSignAnnotated=false", "tag", "v1.0.0") - if err := os.WriteFile(filepath.Join(repo, "fix.go"), []byte("package main\n"), 0o644); err != nil { - t.Fatalf("WriteFile fix.go: %v", err) - } - commitAllChangeTest(t, repo, "fix: seed patch bump") - - snap, err := resolveReleaseSnapshot(repo, releaseOptions{}) - if err != nil { - t.Fatalf("resolve snapshot: %v", err) - } - if snap.Candidate != "1.0.1" || snap.CurrentVersion != "1.0.0" { - t.Fatalf("snapshot = %#v, want candidate 1.0.1 from 1.0.0", snap) - } - if err := releaseCohortPreflight(repo, snap.Candidate, nil); err != nil { - t.Fatalf("preflight: %v", err) - } - - // Version-file commit lands between preflight and apply. - writeReleaseVersionFiles(t, repo, "1.0.1") - commitAllChangeTest(t, repo, "chore: bump version underneath release") - - err = runReleaseApply(repo, releaseOptions{yes: true, tagSet: true, tag: false, ghSet: true, gh: false, snapshot: snap}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}) - if err == nil { - t.Fatal("apply error = nil, want version drift block") - } - msg := err.Error() - if !strings.Contains(msg, "1.0.0") || !strings.Contains(msg, "1.0.1") || !strings.Contains(msg, "re-run release") { - t.Fatalf("apply error = %v, want drift message naming both versions and re-run remedy", err) - } -} - -func TestReleasePostMergeBlocksWhenVersionFileDriftsAfterPreflight(t *testing.T) { - repo := seedReleasePostMergeFiles(t, "1.2.3") - snap, err := resolveReleaseSnapshot(repo, releaseOptions{postMerge: true}) - if err != nil { - t.Fatalf("resolve snapshot: %v", err) - } - if snap.Candidate != "1.2.3" || snap.CurrentVersion != "1.2.3" { - t.Fatalf("snapshot = %#v, want 1.2.3", snap) - } - - // seedReleasePostMergeFiles is a file fixture (no git); drift is a filesystem rewrite the snapshot assert sees. - writeReleaseVersionFiles(t, repo, "1.2.4") - - responses := releasePostMergeHappyResponses("1.2.3") - runner, calls := scriptedReleasePostMergeRunner(responses) - var stdout, stderr bytes.Buffer - err = runReleasePostMergeWithRunner(repo, snap, &stdout, &stderr, runner) - if err == nil { - t.Fatal("post-merge error = nil, want version drift abort") - } - if !strings.Contains(err.Error(), "1.2.3") || !strings.Contains(err.Error(), "1.2.4") || !strings.Contains(err.Error(), "re-run release") { - t.Fatalf("post-merge error = %v, want drift message", err) - } - for _, call := range releasePostMergeCallKeys(calls()) { - if strings.HasPrefix(call, "git tag") { - t.Fatalf("tagged despite drift: calls=%#v", releasePostMergeCallKeys(calls())) - } - } -} - -func TestReleaseCohortGateRejectsFailingReceipt(t *testing.T) { - repo := seedCohortGateRepo(t, "1.0.0-alpha.1") - body := shapeWithVerification("- **V1.** Always fails. Command: `false`. Expect: exit 0\n- **V3.** Also fails. Command: `exit 1`. Expect: exit 0") - dir := writeNewLayoutChange(t, repo, "20260727-failing-receipt", "failing-receipt", "1.0.0", body) - flipExecuteChange(t, repo, dir, "failing-receipt") - - folderRel := filepath.Join("docs", "changes", "20260727-failing-receipt") - var stdout bytes.Buffer - err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}) - if err == nil { - t.Fatalf("verify should fail with failing criteria\n%s", stdout.String()) - } - if !strings.Contains(stdout.String(), "Wrote receipt:") { - t.Fatalf("write-on-failure expected receipt; stdout=%q", stdout.String()) - } - commitAllChangeTest(t, repo, "chore: commit failing receipt") - - gateErr := releaseCohortPreflight(repo, "1.0.0", nil) - if gateErr == nil || !strings.Contains(gateErr.Error(), "receipt records failing criteria (V1, V3)") { - t.Fatalf("gate err = %v, want failing criteria V1, V3", gateErr) - } - - // Same fixture with all criteria passing proceeds. - bodyOK := shapeWithVerification("- **V1.** Root marker. Command: `true`. Expect: exit 0\n- **V3.** Also. Command: `true`. Expect: exit 0") - if err := os.WriteFile(filepath.Join(dir, "shape.md"), []byte(bodyOK), 0o644); err != nil { - t.Fatalf("WriteFile shape: %v", err) - } - commitAllChangeTest(t, repo, "docs: fix criteria") - if err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { - t.Fatalf("verify pass: %v\n%s", err, stdout.String()) - } - commitAllChangeTest(t, repo, "chore: commit passing receipt") - if err := releaseCohortPreflight(repo, "1.0.0", nil); err != nil { - t.Fatalf("passing receipt should open gate: %v", err) - } -} - -func TestReleaseCohortGateReceiptFreshnessBootstrap(t *testing.T) { - repo := seedCohortGateRepo(t, "1.0.0-alpha.1") - dir := writeNewLayoutChange(t, repo, "20260727-freshness", "freshness", "1.0.0", "") - flipExecuteChange(t, repo, dir, "freshness") - folderRel := filepath.Join("docs", "changes", "20260727-freshness") - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { - t.Fatalf("verify: %v", err) - } - commitAllChangeTest(t, repo, "chore: commit verify receipt") - - if err := releaseCohortPreflight(repo, "1.0.0", nil); err != nil { - t.Fatalf("receipt-only commit should not stale: %v", err) - } - - // Decision 4 / ADR-024: touch-then-revert is deliberately undetectable — - // byte-identical restore leaves the receipt fresh. - other := filepath.Join(repo, "other.txt") - if err := os.WriteFile(other, []byte("touch\n"), 0o644); err != nil { - t.Fatalf("WriteFile other: %v", err) - } - commitAllChangeTest(t, repo, "chore: touch other") - if err := os.Remove(other); err != nil { - t.Fatalf("Remove other: %v", err) - } - commitAllChangeTest(t, repo, "chore: revert other") - - if err := releaseCohortPreflight(repo, "1.0.0", nil); err != nil { - t.Fatalf("touch-then-revert must stay fresh under content digest: %v", err) - } - - // A lasting content change stales with a typed drift reason. - if err := os.WriteFile(filepath.Join(repo, "stale.txt"), []byte("x\n"), 0o644); err != nil { - t.Fatalf("WriteFile stale: %v", err) - } - commitAllChangeTest(t, repo, "chore: lasting content change") - gateErr := releaseCohortPreflight(repo, "1.0.0", nil) - if gateErr == nil || !strings.Contains(gateErr.Error(), "content changed since verification") { - t.Fatalf("lasting content change should drift: %v", gateErr) - } -} - -func TestChangeVerifyWritesReceiptOnFailure(t *testing.T) { - repo := initCLIGitRepo(t) - body := shapeWithVerification("- **V1.** Fail. Command: `false`. Expect: exit 0") - dir := writeNewLayoutChange(t, repo, "20260727-write-fail", "write-fail", "", body) - commitAllChangeTest(t, repo, "docs: shape write-fail") - var stdout bytes.Buffer - err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "verify", filepath.Join("docs", "changes", "20260727-write-fail")}) - if err == nil { - t.Fatal("expected verify failure") - } - receiptPath := filepath.Join(dir, "receipts", "verify.json") - data, readErr := os.ReadFile(receiptPath) - if readErr != nil { - t.Fatalf("receipt should be written on failure: %v\n%s", readErr, stdout.String()) - } - if !strings.Contains(string(data), `"ok": false`) { - t.Fatalf("receipt = %s", data) - } -} - -// --- TASK-014: V1 / V2 / V5 residual Verification Contract fixtures --- - -func TestReleaseCohortGateV1PathGradeIsNotFlipGrade(t *testing.T) { - repo := seedCohortGateRepo(t, "2.0.0-alpha.1") - dir := writeNewLayoutChange(t, repo, "20260727-v1-path-grade", "v1-path-grade", "2.0.0", "") - task := filepath.Join(dir, "tasks", "TASK-001-work.md") - if err := os.WriteFile(task, []byte("---\nchange: v1-path-grade\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [ ] Do it\n"), 0o644); err != nil { - t.Fatalf("WriteFile task: %v", err) - } - commitAllChangeTest(t, repo, "docs: shape v1-path-grade") - - if err := os.WriteFile(filepath.Join(repo, "main.go"), []byte("package main\n"), 0o644); err != nil { - t.Fatalf("WriteFile main.go: %v", err) - } - if err := os.WriteFile(task, []byte("---\nchange: v1-path-grade\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [ ] Do it\n\nnote\n"), 0o644); err != nil { - t.Fatalf("WriteFile task touch: %v", err) - } - commitAllChangeTest(t, repo, "chore: path grade only") - - err := releaseCohortPreflight(repo, "2.0.0", nil) - if err == nil || !strings.Contains(err.Error(), "not executed") { - t.Fatalf("path grade without flip should block: %v", err) - } -} - -func TestReleaseCohortGateV1SecondShapedMemberBlocks(t *testing.T) { - repo := seedCohortGateRepo(t, "2.0.0-alpha.1") - dirA := writeNewLayoutChange(t, repo, "20260727-v1-member-a", "v1-member-a", "2.0.0", "") - flipExecuteChange(t, repo, dirA, "v1-member-a") - folderA := filepath.Join("docs", "changes", "20260727-v1-member-a") - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderA}); err != nil { - t.Fatalf("verify member-a: %v", err) - } - commitAllChangeTest(t, repo, "chore: verify member-a") - - writeNewLayoutChange(t, repo, "20260727-v1-member-b", "v1-member-b", "2.0.0", "") - commitAllChangeTest(t, repo, "docs: shape member-b only") - - err := releaseCohortPreflight(repo, "2.0.0", nil) - if err == nil || !strings.Contains(err.Error(), `change "v1-member-b" targets 2.0.0 but is not executed`) { - t.Fatalf("second shaped-only member should block identically: %v", err) - } -} - -func TestReleaseCohortGateV1NoTargetNeverGates(t *testing.T) { - repo := seedCohortGateRepo(t, "2.0.0-alpha.1") - writeNewLayoutChange(t, repo, "20260727-v1-no-target", "v1-no-target", "", "") - commitAllChangeTest(t, repo, "docs: shape untargeted change") - - if err := releaseCohortPreflight(repo, "2.0.0", nil); err != nil { - t.Fatalf("change with no target_release must never gate: %v", err) - } -} - -func TestReleaseCohortGateV1LowerCohortWarnsWithoutBlocking(t *testing.T) { - repo := seedCohortGateRepo(t, "2.0.0-alpha.1") - writeNewLayoutChange(t, repo, "20260727-v1-lower", "v1-lower", "2.0.0", "") - commitAllChangeTest(t, repo, "docs: shape lower cohort member") - - var warnings []string - if err := releaseCohortPreflight(repo, "2.1.0", &warnings); err != nil { - t.Fatalf("higher candidate must not block on lower cohort: %v", err) - } - if !findingsContain(warnings, "incomplete lower cohort") { - t.Fatalf("warnings = %v, want lower-cohort warn without block", warnings) - } -} - -func assertPrereleaseBumpAndPostMergeBypass(t *testing.T, repo string) { - t.Helper() - snap, err := resolveReleaseSnapshot(repo, releaseOptions{bump: "prerelease"}) - pre := snap.Candidate - if err != nil { - t.Fatalf("compute prerelease candidate: %v", err) - } - if !releaseVersionIsPrerelease(pre) { - t.Fatalf("prerelease candidate = %q, want prerelease", pre) - } - if err := releaseCohortPreflight(repo, pre, nil); err != nil { - t.Fatalf("--bump prerelease should succeed: %v", err) - } - - snap, err = resolveReleaseSnapshot(repo, releaseOptions{postMerge: true}) - post := snap.Candidate - if err != nil { - t.Fatalf("compute post-merge candidate: %v", err) - } - if !releaseVersionIsPrerelease(post) || post != snap.CurrentVersion { - t.Fatalf("post-merge candidate = %q (current %q), want prepared prerelease", post, snap.CurrentVersion) - } - if err := releaseCohortPreflight(repo, post, nil); err != nil { - t.Fatalf("--post-merge with prepared prerelease must bypass: %v", err) - } -} - -func TestReleaseCohortGateV2PrereleaseBypassEveryGateState(t *testing.T) { - repo := seedCohortGateRepo(t, "1.0.0-alpha.1") - body := shapeWithVerification("- **V1.** Smoke. Command: `true`. Expect: exit 0") - dir := writeNewLayoutChange(t, repo, "20260727-v2-member", "v2-member", "1.0.0", body) - commitAllChangeTest(t, repo, "docs: shape v2-member") - folderRel := filepath.Join("docs", "changes", "20260727-v2-member") - - // Missing execution. - assertPrereleaseBumpAndPostMergeBypass(t, repo) - - // Missing receipt (flip-executed, no verify). - flipExecuteChange(t, repo, dir, "v2-member") - assertPrereleaseBumpAndPostMergeBypass(t, repo) - - // Failing receipt. - failBody := shapeWithVerification("- **V1.** Fail. Command: `false`. Expect: exit 0") - if err := os.WriteFile(filepath.Join(dir, "shape.md"), []byte(failBody), 0o644); err != nil { - t.Fatalf("WriteFile failing shape: %v", err) - } - commitAllChangeTest(t, repo, "docs: failing criteria") - var stdout bytes.Buffer - if err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err == nil { - t.Fatalf("verify should fail\n%s", stdout.String()) - } - commitAllChangeTest(t, repo, "chore: commit failing receipt") - assertPrereleaseBumpAndPostMergeBypass(t, repo) - - // Expired receipt (digest mismatch after criteria edit). - okBody := shapeWithVerification("- **V1.** Smoke. Command: `true`. Expect: exit 0") - if err := os.WriteFile(filepath.Join(dir, "shape.md"), []byte(okBody), 0o644); err != nil { - t.Fatalf("WriteFile ok shape: %v", err) - } - commitAllChangeTest(t, repo, "docs: restore passing criteria") - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { - t.Fatalf("verify pass: %v", err) - } - commitAllChangeTest(t, repo, "chore: commit passing receipt") - - expiredBody := shapeWithVerification("- **V1.** Smoke. Command: `true`. Expect: exit 0 and marker") - if err := os.WriteFile(filepath.Join(dir, "shape.md"), []byte(expiredBody), 0o644); err != nil { - t.Fatalf("WriteFile expired shape: %v", err) - } - commitAllChangeTest(t, repo, "docs: edit criteria expect") - assertPrereleaseBumpAndPostMergeBypass(t, repo) - - // Cohort completes: prepared prerelease post-merge still publishes; --bump prerelease still bypasses. - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { - t.Fatalf("re-verify after expiry: %v", err) - } - commitAllChangeTest(t, repo, "chore: re-verify after expiry") - snap, err := resolveReleaseSnapshot(repo, releaseOptions{postMerge: true}) - post := snap.Candidate - if err != nil { - t.Fatalf("post-merge candidate: %v", err) - } - if err := releaseCohortPreflight(repo, post, nil); err != nil { - t.Fatalf("completed cohort should allow post-merge: %v", err) - } - snap, err = resolveReleaseSnapshot(repo, releaseOptions{bump: "prerelease"}) - pre := snap.Candidate - if err != nil { - t.Fatalf("prerelease candidate: %v", err) - } - if err := releaseCohortPreflight(repo, pre, nil); err != nil { - t.Fatalf("prerelease should still succeed after completion: %v", err) - } -} - -func TestReleaseCohortGateV5CriteriaEditExpiresReceipt(t *testing.T) { - repo := seedCohortGateRepo(t, "1.0.0-alpha.1") - body := shapeWithVerification("- **V1.** Smoke. Command: `true`. Expect: exit 0") - dir := writeNewLayoutChange(t, repo, "20260727-v5-expire", "v5-expire", "1.0.0", body) - flipExecuteChange(t, repo, dir, "v5-expire") - folderRel := filepath.Join("docs", "changes", "20260727-v5-expire") - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { - t.Fatalf("verify: %v", err) - } - commitAllChangeTest(t, repo, "chore: commit verify receipt") - - expired := shapeWithVerification("- **V1.** Smoke. Command: `true`. Expect: exit 0 changed") - if err := os.WriteFile(filepath.Join(dir, "shape.md"), []byte(expired), 0o644); err != nil { - t.Fatalf("WriteFile shape: %v", err) - } - commitAllChangeTest(t, repo, "docs: edit shape criteria") - - gateErr := releaseCohortPreflight(repo, "1.0.0", nil) - if gateErr == nil || !strings.Contains(gateErr.Error(), "criteria changed (receipt expired)") { - t.Fatalf("criteria edit should expire receipt: %v", gateErr) - } -} - -func TestReleaseCohortGateV5FreshnessRerunAndReceiptOwnCommit(t *testing.T) { - repo := seedCohortGateRepo(t, "1.0.0-alpha.1") - dir := writeNewLayoutChange(t, repo, "20260727-v5-fresh", "v5-fresh", "1.0.0", "") - flipExecuteChange(t, repo, dir, "v5-fresh") - folderRel := filepath.Join("docs", "changes", "20260727-v5-fresh") - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { - t.Fatalf("verify: %v", err) - } - commitAllChangeTest(t, repo, "chore: commit verify receipt") - - if err := releaseCohortPreflight(repo, "1.0.0", nil); err != nil { - t.Fatalf("receipt's own commit alone must not stale: %v", err) - } - - if err := os.WriteFile(filepath.Join(repo, "later.txt"), []byte("x\n"), 0o644); err != nil { - t.Fatalf("WriteFile later: %v", err) - } - commitAllChangeTest(t, repo, "chore: later non-receipt path") - gateErr := releaseCohortPreflight(repo, "1.0.0", nil) - if gateErr == nil || !strings.Contains(gateErr.Error(), "content changed since verification") { - t.Fatalf("non-receipt path should force re-run: %v", gateErr) - } -} - -func TestReleaseCohortGateV5PlanMdEditStalesNotExpires(t *testing.T) { - repo := seedCohortGateRepo(t, "1.0.0-alpha.1") - body := shapeWithVerification("- **V1.** Smoke. Command: `true`. Expect: exit 0") - dir := writeNewLayoutChange(t, repo, "20260727-v5-plan-stale", "v5-plan-stale", "1.0.0", body) - flipExecuteChange(t, repo, dir, "v5-plan-stale") - folderRel := filepath.Join("docs", "changes", "20260727-v5-plan-stale") - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { - t.Fatalf("verify: %v", err) - } - commitAllChangeTest(t, repo, "chore: commit verify receipt") - - receipt, err := loadChangeVerifyReceipt(dir) - if err != nil { - t.Fatalf("load receipt: %v", err) - } - digestBefore := receipt.CriteriaDigest - - if err := os.WriteFile(filepath.Join(dir, "plan.md"), []byte("# Approach\n\nChurn.\n"), 0o644); err != nil { - t.Fatalf("WriteFile plan.md: %v", err) - } - commitAllChangeTest(t, repo, "docs: edit plan.md only") - - shapeData, err := os.ReadFile(filepath.Join(dir, "shape.md")) - if err != nil { - t.Fatalf("ReadFile shape: %v", err) - } - digestNow := changeCriteriaDigest(parseChangeExecutableCriteria(string(shapeData))) - if digestNow != digestBefore { - t.Fatalf("plan.md edit must not change criteria digest: before=%s after=%s", digestBefore, digestNow) - } - receiptAfter, err := loadChangeVerifyReceipt(dir) - if err != nil { - t.Fatalf("load receipt after: %v", err) - } - if receiptAfter.CriteriaDigest != digestNow { - t.Fatalf("receipt digest drifted: receipt=%s shape=%s", receiptAfter.CriteriaDigest, digestNow) - } - - gateErr := releaseCohortPreflight(repo, "1.0.0", nil) - if gateErr == nil { - t.Fatal("plan.md-only commit should stale the receipt") - } - msg := gateErr.Error() - if !strings.Contains(msg, "content changed since verification") { - t.Fatalf("want content-drift demand, got: %v", gateErr) - } - if strings.Contains(msg, "receipt expired") { - t.Fatalf("must not report expiry for plan.md edit: %v", gateErr) - } - remedy := "Run: loaf change verify " + filepath.ToSlash(folderRel) + ", then commit the receipt" - if !strings.Contains(msg, remedy) { - t.Fatalf("want mechanical remedy %q, got: %v", remedy, gateErr) - } -} - -func TestReleaseCohortGateV5RetargetAfterVerifyRequiresRerun(t *testing.T) { - repo := seedCohortGateRepo(t, "2.0.0-alpha.1") - body := shapeWithVerification("- **V1.** Smoke. Command: `true`. Expect: exit 0") - dir := writeNewLayoutChange(t, repo, "20260727-v5-retarget", "v5-retarget", "2.0.0", body) - flipExecuteChange(t, repo, dir, "v5-retarget") - folderRel := filepath.Join("docs", "changes", "20260727-v5-retarget") - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { - t.Fatalf("verify at 2.0.0: %v", err) - } - commitAllChangeTest(t, repo, "chore: commit verify receipt") - - meta := "{\n \"change\": \"v5-retarget\",\n \"created\": \"2026-07-27\",\n \"branch\": \"v5-retarget\",\n \"target_release\": \"2.1.0\"\n}\n" - if err := os.WriteFile(filepath.Join(dir, "change.json"), []byte(meta), 0o644); err != nil { - t.Fatalf("WriteFile change.json: %v", err) - } - commitAllChangeTest(t, repo, "chore: retarget 2.0.0 to 2.1.0") - - // Blind trust would accept the pre-retarget receipt; freshness must force re-run. - gateErr := releaseCohortPreflight(repo, "2.1.0", nil) - if gateErr == nil || !strings.Contains(gateErr.Error(), "content changed since verification") { - t.Fatalf("retarget should trigger content drift: %v", gateErr) - } - - // Not permanent invalidation: re-verify opens the new cohort. - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { - t.Fatalf("re-verify after retarget: %v", err) - } - commitAllChangeTest(t, repo, "chore: re-verify after retarget") - if err := releaseCohortPreflight(repo, "2.1.0", nil); err != nil { - t.Fatalf("re-verify after retarget should open gate: %v", err) - } -} - -// --- TASK-016: the gate's structural tier is the composite check reports --- - -func cohortStructuralReportForSlug(t *testing.T, repo, slug string) changeCheckReport { - t.Helper() - nodes, err := loadChangeNodesAtHEAD(repo) - if err != nil { - t.Fatalf("loadChangeNodesAtHEAD: %v", err) - } - for _, node := range nodes { - if node.Slug != slug { - continue - } - report, reportErr := changeCohortStructuralReport(repo, node, nodes, commandOutput) - if reportErr != nil { - t.Fatalf("changeCohortStructuralReport: %v", reportErr) - } - return report - } - t.Fatalf("no committed change %q at HEAD", slug) - return changeCheckReport{} -} - -func TestReleaseCohortGateBlocksExecutabilityGap(t *testing.T) { - repo := seedCohortGateRepo(t, "1.0.0-alpha.1") - gapBody := strings.Replace(authoredShapeBody(), - "## Planning Contract\n\n### Approach\n\nHow.", - "## Planning Contract\n\n<!-- not shaped yet -->", 1) - dir := writeNewLayoutChange(t, repo, "20260727-gap-member", "gap-member", "1.0.0", gapBody) - flipExecuteChange(t, repo, dir, "gap-member") - folderRel := filepath.Join("docs", "changes", "20260727-gap-member") - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { - t.Fatalf("verify: %v", err) - } - commitAllChangeTest(t, repo, "chore: commit verify receipt") - - // Zero violations, one contract gap: the tier the old gate could not see. - report := cohortStructuralReportForSlug(t, repo, "gap-member") - if len(report.Violations) != 0 || report.Executable { - t.Fatalf("report = %+v, want zero violations and a gap", report) - } - - gateErr := releaseCohortPreflight(repo, "1.0.0", nil) - if gateErr == nil || !strings.Contains(gateErr.Error(), `change "gap-member" targets 1.0.0 but is not executable (contract gaps: Planning Contract (empty))`) { - t.Fatalf("gate err = %v, want the executability-gap block", gateErr) - } - if strings.Contains(gateErr.Error(), "structurally invalid") { - t.Fatalf("a gap is not a violation; got: %v", gateErr) - } - if !strings.Contains(gateErr.Error(), "run: loaf change check "+filepath.ToSlash(folderRel)) { - t.Fatalf("want mechanical remedy naming check; got: %v", gateErr) - } - - // check agrees on the same folder — one composite, two consumers. - var checkOut bytes.Buffer - checkErr := (Runner{Stdout: &checkOut, WorkingDir: repo}).Run([]string{"change", "check", folderRel, "--require-executable", "--json"}) - if checkErr == nil { - t.Fatalf("change check --require-executable should fail on the same folder\n%s", checkOut.String()) - } - for _, want := range []string{`"executable": false`, "Planning Contract (empty)"} { - if !strings.Contains(checkOut.String(), want) { - t.Fatalf("check output = %s, want %q", checkOut.String(), want) - } - } - - // Shaping the contract closes the gap; re-verify and the gate opens. - if err := os.WriteFile(filepath.Join(dir, "shape.md"), []byte(authoredShapeBody()), 0o644); err != nil { - t.Fatalf("WriteFile shape: %v", err) - } - commitAllChangeTest(t, repo, "docs: author the Planning Contract") - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { - t.Fatalf("re-verify: %v", err) - } - commitAllChangeTest(t, repo, "chore: re-verify after shaping") - if err := releaseCohortPreflight(repo, "1.0.0", nil); err != nil { - t.Fatalf("shaped, executed, verified member should proceed: %v", err) - } -} - -func TestReleaseCohortGateBlocksTaskHygieneAndNeverBlocksOnWarnings(t *testing.T) { - repo := seedCohortGateRepo(t, "1.0.0-alpha.1") - dir := writeNewLayoutChange(t, repo, "20260727-hygiene-member", "hygiene-member", "1.0.0", "") - flipExecuteChange(t, repo, dir, "hygiene-member") - folderRel := filepath.Join("docs", "changes", "20260727-hygiene-member") - later := filepath.Join(dir, "tasks", "TASK-002-later.md") - if err := os.WriteFile(later, []byte("---\nchange: hygiene-member\nid: TASK-002\ntitle: Later\nstatus: in-progress\n---\n\n# Later\n\n## Steps\n\n- [ ] Descoped\n"), 0o644); err != nil { - t.Fatalf("WriteFile TASK-002: %v", err) - } - commitAllChangeTest(t, repo, "docs: add a later task") - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { - t.Fatalf("verify: %v", err) - } - commitAllChangeTest(t, repo, "chore: commit verify receipt") - - gateErr := releaseCohortPreflight(repo, "1.0.0", nil) - if gateErr == nil || !strings.Contains(gateErr.Error(), `change "hygiene-member" targets 1.0.0 but is structurally invalid`) { - t.Fatalf("gate err = %v, want the task-hygiene block", gateErr) - } - for _, want := range []string{"TASK-002-later.md", `task frontmatter key "status" is banned`} { - if !strings.Contains(gateErr.Error(), want) { - t.Fatalf("gate err = %v, want %q named", gateErr, want) - } - } - - // Drop the banned key, keep TASK-002 unchecked (legal descoped work) and add a - // zero-checkbox coordination task (warning only). - if err := os.WriteFile(later, []byte("---\nchange: hygiene-member\nid: TASK-002\ntitle: Later\n---\n\n# Later\n\n## Steps\n\n- [ ] Descoped\n"), 0o644); err != nil { - t.Fatalf("WriteFile TASK-002 repair: %v", err) - } - parent := filepath.Join(dir, "tasks", "TASK-003-coordination.md") - if err := os.WriteFile(parent, []byte("---\nchange: hygiene-member\nid: TASK-003\ntitle: Coordination\n---\n\n# Coordination\n\nNo boxes here.\n"), 0o644); err != nil { - t.Fatalf("WriteFile TASK-003: %v", err) - } - commitAllChangeTest(t, repo, "docs: drop the banned key") - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { - t.Fatalf("re-verify: %v", err) - } - commitAllChangeTest(t, repo, "chore: re-verify after repair") - - report := cohortStructuralReportForSlug(t, repo, "hygiene-member") - if len(report.Violations) != 0 || !report.Executable { - t.Fatalf("report = %+v, want a clean composite", report) - } - if len(report.Warnings) == 0 { - t.Fatal("fixture must carry at least one warning for the never-block claim to bite") - } - if err := releaseCohortPreflight(repo, "1.0.0", nil); err != nil { - t.Fatalf("warnings and unchecked descoped work must never block: %v", err) - } -} - -func TestReleaseCohortGateBlocksConversionFinding(t *testing.T) { - repo := seedCohortGateRepo(t, "2.0.0-alpha.1") - writeChangeFolder(t, repo, "20260727-prechecked-convert", changeDoc( - "---\nchange: prechecked-convert\ncreated: 2026-07-27\nbranch: prechecked-convert\ntarget_release: 2.0.0\n---\n", - append(productSections(), executableSections()...)..., - )) - commitAllChangeTest(t, repo, "docs: add legacy targeted member") - atomicConvertFolder(t, repo, "20260727-prechecked-convert", "prechecked-convert", true) - commitAllChangeTest(t, repo, "docs: convert with a pre-checked box") - - // Manufactured execution: a conversion finding is a violation at check, so it - // is a violation at the gate too. - gateErr := releaseCohortPreflight(repo, "2.0.0", nil) - if gateErr == nil || !strings.Contains(gateErr.Error(), "is structurally invalid") { - t.Fatalf("gate err = %v, want the conversion block", gateErr) - } - if !strings.Contains(gateErr.Error(), "checked task checkbox") { - t.Fatalf("gate err = %v, want the conversion finding named", gateErr) - } -} - -// --- TASK-021: lineage validation joins the gate composite --- - -func TestReleaseCohortGateBlocksDuplicateSlugLineageFinding(t *testing.T) { - repo := seedCohortGateRepo(t, "1.0.0-alpha.1") - dirA := writeNewLayoutChange(t, repo, "20260727-dup-slug", "dup-slug", "1.0.0", "") - flipExecuteChange(t, repo, dirA, "dup-slug") - folderA := filepath.Join("docs", "changes", "20260727-dup-slug") - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderA}); err != nil { - t.Fatalf("verify A: %v", err) - } - commitAllChangeTest(t, repo, "chore: commit A receipt") - - dirB := writeNewLayoutChange(t, repo, "20260728-dup-slug", "dup-slug", "1.0.0", "") - flipExecuteChange(t, repo, dirB, "dup-slug") - folderB := filepath.Join("docs", "changes", "20260728-dup-slug") - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderB}); err != nil { - t.Fatalf("verify B: %v", err) - } - commitAllChangeTest(t, repo, "chore: commit B receipt") - - gateErr := releaseCohortPreflight(repo, "1.0.0", nil) - if gateErr == nil || !strings.Contains(gateErr.Error(), "structurally invalid") { - t.Fatalf("gate err = %v, want structural block for duplicate slug", gateErr) - } - if !strings.Contains(gateErr.Error(), "duplicate Change slug") { - t.Fatalf("gate err = %v, want the duplication named", gateErr) - } - - for _, folder := range []string{folderA, folderB} { - var stdout bytes.Buffer - checkErr := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "check", folder, "--json"}) - if checkErr == nil { - t.Fatalf("check %s should fail for duplicate slug\n%s", folder, stdout.String()) - } - if !strings.Contains(stdout.String(), "duplicate Change slug") { - t.Fatalf("check %s output = %q, want duplicate Change slug", folder, stdout.String()) - } - } -} - -func TestReleaseCohortGateLineageHappyPathUnaffectedByForeignLineageFindings(t *testing.T) { - // Single-member cohort in a clean repo stays green: lineage findings that - // belong to other changes must not over-block (matching check's scoping). - repo := seedCohortGateRepo(t, "1.0.0-alpha.1") - dir := writeNewLayoutChange(t, repo, "20260727-lineage-happy", "lineage-happy", "1.0.0", "") - flipExecuteChange(t, repo, dir, "lineage-happy") - folderRel := filepath.Join("docs", "changes", "20260727-lineage-happy") - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { - t.Fatalf("verify: %v", err) - } - commitAllChangeTest(t, repo, "chore: commit lineage-happy receipt") - - report := cohortStructuralReportForSlug(t, repo, "lineage-happy") - if len(report.Violations) != 0 || !report.Executable { - t.Fatalf("report = %+v, want a clean composite", report) - } - if err := releaseCohortPreflight(repo, "1.0.0", nil); err != nil { - t.Fatalf("single-member happy path must stay green: %v", err) - } -} - -func TestReleaseAndStateIgnoreUncommittedDuplicateSlugRename(t *testing.T) { - repo := seedCohortGateRepo(t, "1.0.0-alpha.1") - dirA := writeNewLayoutChange(t, repo, "20260727-dup-slug", "dup-slug", "1.0.0", "") - flipExecuteChange(t, repo, dirA, "dup-slug") - folderA := filepath.Join("docs", "changes", "20260727-dup-slug") - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderA}); err != nil { - t.Fatalf("verify A: %v", err) - } - commitAllChangeTest(t, repo, "chore: commit A receipt") - - dirB := writeNewLayoutChange(t, repo, "20260728-dup-slug", "dup-slug", "1.0.0", "") - flipExecuteChange(t, repo, dirB, "dup-slug") - folderB := filepath.Join("docs", "changes", "20260728-dup-slug") - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderB}); err != nil { - t.Fatalf("verify B: %v", err) - } - commitAllChangeTest(t, repo, "chore: commit B receipt") - - gateErr := releaseCohortPreflight(repo, "1.0.0", nil) - if gateErr == nil || !strings.Contains(gateErr.Error(), "duplicate Change slug") { - t.Fatalf("committed gate err = %v, want duplicate slug block", gateErr) - } - stateA := deriveChangeState(repo, mustAssembleNode(t, repo, folderA), commandOutput) - if stateA == "verified" { - t.Fatalf("committed duplicate must keep state off verified; got %q", stateA) - } - - // Uncommitted move of one duplicate folder out of docs/changes: check's working-tree load loses the duplicate; gate/state still see HEAD. - parked := filepath.Join(repo, "parked-dup-slug") - if err := os.Rename(dirB, parked); err != nil { - t.Fatalf("Rename: %v", err) - } - - gateErr = releaseCohortPreflight(repo, "1.0.0", nil) - if gateErr == nil || !strings.Contains(gateErr.Error(), "duplicate Change slug") { - t.Fatalf("after WT rename gate err = %v, want committed duplicate still blocking", gateErr) - } - stateA = deriveChangeState(repo, mustAssembleNode(t, repo, folderA), commandOutput) - if stateA == "verified" { - t.Fatalf("after WT rename state must stay off verified; got %q", stateA) - } - - var stdout bytes.Buffer - checkErr := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "check", folderA, "--json"}) - if strings.Contains(stdout.String(), "duplicate Change slug") { - t.Fatalf("check should see the working tree (duplicate parked away); got err=%v out=%s", checkErr, stdout.String()) - } -} - -// TASK-030: a commit landing after snapshot resolution appears in neither the -// changelog nor the bump — both describe the snapshot's frozen history. -func TestReleaseSnapshotChangelogIgnoresPostResolveCommits(t *testing.T) { - repo := seedCohortGateRepo(t, "1.0.0-alpha.1") - if err := os.WriteFile(filepath.Join(repo, "feature.go"), []byte("package main\n"), 0o644); err != nil { - t.Fatalf("WriteFile feature.go: %v", err) - } - commitAllChangeTest(t, repo, "feat: pre-resolve work") - - snap, err := resolveReleaseSnapshot(repo, releaseOptions{bump: "prerelease"}) - if err != nil { - t.Fatalf("resolve snapshot: %v", err) - } - if snap.Candidate != "1.0.0-alpha.2" { - t.Fatalf("candidate = %q, want 1.0.0-alpha.2", snap.Candidate) - } - preHashes := map[string]bool{} - for _, c := range snap.Commits { - preHashes[c.Hash] = true - } - - if err := os.WriteFile(filepath.Join(repo, "after.go"), []byte("package main\n"), 0o644); err != nil { - t.Fatalf("WriteFile after.go: %v", err) - } - commitAllChangeTest(t, repo, "feat: post-resolve must not enter changelog") - - var stdout bytes.Buffer - opts := releaseOptions{bump: "prerelease", dryRun: true, tagSet: true, tag: false, ghSet: true, gh: false, snapshot: snap} - if err := runReleaseDryRun(repo, opts, &stdout, &bytes.Buffer{}); err != nil { - t.Fatalf("dry-run with frozen snapshot: %v\n%s", err, stdout.String()) - } - out := stripANSI(stdout.String()) - if !strings.Contains(out, "New version: 1.0.0-alpha.2") { - t.Fatalf("dry-run must keep snapshot candidate; got:\n%s", out) - } - if strings.Contains(out, "post-resolve must not enter changelog") { - t.Fatalf("changelog must not include post-resolve commit; got:\n%s", out) - } - for _, c := range snap.Commits { - if !preHashes[c.Hash] { - t.Fatalf("snapshot commits mutated after resolve") - } - } - if len(releaseCommitsSince(repo, snap.BaseRef)) <= len(snap.Commits) { - t.Fatalf("expected HEAD to have grown past the snapshot commit list") - } -} - -// TASK-031: prepared prerelease post-merge publishes and would tag the prepared version (alpha-train). -func TestReleasePostMergePreparedPrereleasePublishesAlphaTrain(t *testing.T) { - repo := seedCohortGateRepo(t, "2.0.0-alpha.15") - writeNewLayoutChange(t, repo, "20260727-alpha-train", "alpha-train", "2.0.0", "") - commitAllChangeTest(t, repo, "docs: open 2.0.0 cohort") - - snap, err := resolveReleaseSnapshot(repo, releaseOptions{postMerge: true}) - if err != nil { - t.Fatalf("resolve: %v", err) - } - if snap.Candidate != "2.0.0-alpha.15" { - t.Fatalf("candidate = %q, want prepared 2.0.0-alpha.15", snap.Candidate) - } - if err := releaseCohortPreflight(repo, snap.Candidate, nil); err != nil { - t.Fatalf("prepared prerelease must bypass open 2.0.0 cohort: %v", err) - } - - files := seedReleasePostMergeFiles(t, "2.0.0-alpha.15") - fileSnap := mustResolveReleaseSnapshot(t, files, releaseOptions{postMerge: true}) - responses := releasePostMergeHappyResponses("2.0.0-alpha.15") - responses["gh release create v2.0.0-alpha.15 --title v2.0.0-alpha.15 --notes ### Added\n- New feature (abc1234) --prerelease"] = releasePostMergeOK("") - runner, calls := scriptedReleasePostMergeRunner(responses) - var stdout, stderr bytes.Buffer - if err := runReleasePostMergeWithRunner(files, fileSnap, &stdout, &stderr, runner); err != nil { - t.Fatalf("post-merge: %v\n%s\n%s", err, stdout.String(), stderr.String()) - } - out := stripANSI(stdout.String()) - if !strings.Contains(out, "Created tag v2.0.0-alpha.15") { - t.Fatalf("must tag prepared prerelease; got:\n%s", out) - } - keys := releasePostMergeCallKeys(calls()) - if !containsReleasePostMergeCall(keys, "git tag -s v2.0.0-alpha.15 -m Release 2.0.0-alpha.15") { - t.Fatalf("missing prepared tag call; got %#v", keys) - } - for _, call := range keys { - if call == "git tag -s v2.0.0 -m Release 2.0.0" { - t.Fatalf("must not tag stable; calls=%#v", keys) - } - } -} - -// TASK-031: prepared stable post-merge gates the cohort; verified cohort tags the prepared stable. -func TestReleasePostMergePreparedStableGatesThenTags(t *testing.T) { - repo := seedCohortGateRepo(t, "2.0.0") - dir := writeNewLayoutChange(t, repo, "20260727-stable-prep", "stable-prep", "2.0.0", "") - commitAllChangeTest(t, repo, "docs: shape stable-prep") - - snap, err := resolveReleaseSnapshot(repo, releaseOptions{postMerge: true}) - if err != nil { - t.Fatalf("resolve: %v", err) - } - if snap.Candidate != "2.0.0" { - t.Fatalf("candidate = %q, want prepared 2.0.0", snap.Candidate) - } - gateErr := releaseCohortPreflight(repo, snap.Candidate, nil) - if gateErr == nil || !strings.Contains(gateErr.Error(), "not executed") { - t.Fatalf("open cohort must block prepared-stable post-merge: %v", gateErr) - } - - flipExecuteChange(t, repo, dir, "stable-prep") - folderRel := relFromRoot(repo, dir) - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { - t.Fatalf("verify: %v", err) - } - commitAllChangeTest(t, repo, "chore: commit stable-prep receipt") - if err := releaseCohortPreflight(repo, snap.Candidate, nil); err != nil { - t.Fatalf("verified cohort must open: %v", err) - } - - files := seedReleasePostMergeFiles(t, "2.0.0") - fileSnap := mustResolveReleaseSnapshot(t, files, releaseOptions{postMerge: true}) - runner, _ := scriptedReleasePostMergeRunner(releasePostMergeHappyResponses("2.0.0")) - var stdout, stderr bytes.Buffer - if err := runReleasePostMergeWithRunner(files, fileSnap, &stdout, &stderr, runner); err != nil { - t.Fatalf("post-merge stable: %v\n%s", err, stdout.String()) - } - if !strings.Contains(stripANSI(stdout.String()), "Created tag v2.0.0") { - t.Fatalf("must tag prepared stable; got:\n%s", stdout.String()) - } -} - -// TASK-031 converse hazard: stray ## [2.0.0] with prerelease files cannot cause a stable tag. -func TestReleasePostMergeConverseHazardStrayStableChangelog(t *testing.T) { - repo := seedReleasePostMergeFiles(t, "2.0.0-alpha.15") - // Replace changelog so only the stable section exists — prepared lookup must fail closed. - writeFile(t, filepath.Join(repo, "CHANGELOG.md"), strings.Join([]string{ - "# Changelog", - "", - "## [Unreleased]", - "", - "- _No unreleased changes yet._", - "", - "## [2.0.0] - 2026-04-29", - "", - "### Added", - "- Stray stable section (abc1234)", - "", - }, "\n")) - snap := mustResolveReleaseSnapshot(t, repo, releaseOptions{postMerge: true}) - if snap.Candidate != "2.0.0-alpha.15" { - t.Fatalf("candidate = %q, want prepared prerelease", snap.Candidate) - } - responses := releasePostMergeHappyResponses("2.0.0-alpha.15") - runner, calls := scriptedReleasePostMergeRunner(responses) - var stdout, stderr bytes.Buffer - err := runReleasePostMergeWithRunner(repo, snap, &stdout, &stderr, runner) - if err == nil { - t.Fatal("post-merge error = nil, want missing prepared changelog section") - } - if !strings.Contains(err.Error(), "2.0.0-alpha.15") { - t.Fatalf("error = %v, want prepared-version changelog demand", err) - } - for _, call := range releasePostMergeCallKeys(calls()) { - if strings.Contains(call, "tag -s") { - t.Fatalf("must not tag; calls=%#v", releasePostMergeCallKeys(calls())) - } - } -} - -// TASK-031: guardrail 4 refuses when the snapshot candidate diverges from version files. -func TestReleasePostMergeGuardrail4TagEqualsVersionFiles(t *testing.T) { - repo := seedReleasePostMergeFiles(t, "2.0.0-alpha.15") - snap := mustResolveReleaseSnapshot(t, repo, releaseOptions{postMerge: true}) - snap.Candidate = "2.0.0" // simulate the old strip-to-stable bug - runner, calls := scriptedReleasePostMergeRunner(releasePostMergeHappyResponses("2.0.0")) - result := checkReleasePostMergeGuardrails(repo, snap, runner) - if result.ok || result.guardrail != 4 { - t.Fatalf("result = %#v, want guardrail 4 abort", result) - } - if !strings.Contains(result.message, "does not match version-file version") { - t.Fatalf("message = %q, want tag-equals-files", result.message) - } - for _, call := range releasePostMergeCallKeys(calls()) { - if strings.Contains(call, "tag -s") { - t.Fatalf("must not tag; calls=%#v", releasePostMergeCallKeys(calls())) - } - } -} - -// --- Receipt-vouched execution: the merge strategies the flip scan cannot see --- - -// squashLandChange materializes the shape a squash merge leaves on main: the -// change folder, its already-checked task packets, and the code arrive as one -// commit, so no `- [ ]`→`- [x]` transition survives anywhere in ancestry. -func squashLandChange(t *testing.T, repo, dir, slug string) { - t.Helper() - task := filepath.Join(dir, "tasks", "TASK-001-work.md") - if err := os.MkdirAll(filepath.Dir(task), 0o755); err != nil { - t.Fatalf("MkdirAll tasks: %v", err) - } - checked := "---\nchange: " + slug + "\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [x] Do it\n" - if err := os.WriteFile(task, []byte(checked), 0o644); err != nil { - t.Fatalf("WriteFile task: %v", err) - } - if err := os.WriteFile(filepath.Join(repo, "main.go"), []byte("package main\n\nfunc main() {}\n"), 0o644); err != nil { - t.Fatalf("WriteFile main.go: %v", err) - } - commitAllChangeTest(t, repo, "feat: squash-land "+slug+" (#1)") -} - -func TestReleaseCohortGateAcceptsSquashMergedMemberWithFreshReceipt(t *testing.T) { - repo := seedCohortGateRepo(t, "2.0.0-alpha.1") - dir := writeNewLayoutChange(t, repo, "20260727-squashed", "squashed", "2.0.0", "") - squashLandChange(t, repo, dir, "squashed") - - folderRel := filepath.Join("docs", "changes", "20260727-squashed") - status, err := changeFolderExecuted(repo, filepath.ToSlash(folderRel), changeLayoutNew, nil) - if err != nil { - t.Fatalf("changeFolderExecuted: %v", err) - } - if status.FlipExecuted { - t.Fatalf("a squash landing leaves no flip transition to find") - } - - // The incident: packets checked, code in the tree, nothing verified yet. - gateErr := releaseCohortPreflight(repo, "2.0.0", nil) - if gateErr == nil { - t.Fatal("a receipt-less squash must still block") - } - for _, want := range []string{ - "is not executed", - "every task box is checked", - "missing receipt", - "a squash merge rewrites the checkbox flips", - "loaf change verify " + filepath.ToSlash(folderRel), - } { - if !strings.Contains(gateErr.Error(), want) { - t.Fatalf("refusal = %v, want it to name %q", gateErr, want) - } - } - - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { - t.Fatalf("verify: %v", err) - } - commitAllChangeTest(t, repo, "chore: commit verify receipt") - - if err := releaseCohortPreflight(repo, "2.0.0", nil); err != nil { - t.Fatalf("squash-merged member with a fresh receipt must release: %v", err) - } - - // The same member no longer drags a higher candidate's warning either. - var warnings []string - if err := releaseCohortPreflight(repo, "2.1.0", &warnings); err != nil { - t.Fatalf("higher candidate: %v", err) - } - if findingsContain(warnings, "incomplete lower cohort") { - t.Fatalf("warnings = %v, want no incomplete-cohort warning for a vouched member", warnings) - } -} - -// TestReleaseGateBlocksShapingOnlyMerge replays the attack ADR-023 exists to -// stop: a merge that creates its packets already checked and ships no work. The -// receipt disjunct must not weaken it — checked boxes alone vouch for nothing. -func TestReleaseGateBlocksShapingOnlyMerge(t *testing.T) { - repo := seedCohortGateRepo(t, "2.0.0-alpha.1") - commitAllChangeTest(t, repo, "chore: seed version files") - - dir := writeNewLayoutChange(t, repo, "20260727-shaping-only", "shaping-only", "2.0.0", "") - checked := "---\nchange: shaping-only\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [x] Do it\n" - if err := os.WriteFile(filepath.Join(dir, "tasks", "TASK-001-work.md"), []byte(checked), 0o644); err != nil { - t.Fatalf("WriteFile task: %v", err) - } - commitAllChangeTest(t, repo, "docs: shape with the boxes already checked") - - gateErr := releaseCohortPreflight(repo, "2.0.0", nil) - if gateErr == nil || !strings.Contains(gateErr.Error(), `change "shaping-only" targets 2.0.0 but is not executed`) { - t.Fatalf("shaping-only merge must still block: %v", gateErr) - } - if strings.Contains(gateErr.Error(), "loaf change verify") { - t.Fatalf("no code landed, so the squash remedy must not be offered: %v", gateErr) - } -} - -func TestReleaseCohortGateBlocksSquashShapeWithStaleOrTamperedReceipt(t *testing.T) { - repo := seedCohortGateRepo(t, "2.0.0-alpha.1") - dir := writeNewLayoutChange(t, repo, "20260727-stale-vouch", "stale-vouch", "2.0.0", "") - squashLandChange(t, repo, dir, "stale-vouch") - folderRel := filepath.Join("docs", "changes", "20260727-stale-vouch") - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { - t.Fatalf("verify: %v", err) - } - commitAllChangeTest(t, repo, "chore: commit verify receipt") - if err := releaseCohortPreflight(repo, "2.0.0", nil); err != nil { - t.Fatalf("fresh receipt should open the gate: %v", err) - } - - // Content landing after verification stales the receipt; the checked boxes - // it vouched for do not survive the drift. - late := filepath.Join(repo, "late.go") - if err := os.WriteFile(late, []byte("package main\n"), 0o644); err != nil { - t.Fatalf("WriteFile late.go: %v", err) - } - commitAllChangeTest(t, repo, "feat: land more code after verifying") - gateErr := releaseCohortPreflight(repo, "2.0.0", nil) - if gateErr == nil || !strings.Contains(gateErr.Error(), "is not executed") || - !strings.Contains(gateErr.Error(), "content changed since verification") { - t.Fatalf("stale receipt must block as unexecuted: %v", gateErr) - } - - // Restore the verified tree, then forge the digest instead of re-verifying. - if err := os.Remove(late); err != nil { - t.Fatalf("Remove late.go: %v", err) - } - commitAllChangeTest(t, repo, "revert: drop the post-verify code") - if err := releaseCohortPreflight(repo, "2.0.0", nil); err != nil { - t.Fatalf("byte-identical restore should be fresh again: %v", err) - } - receiptPath := filepath.Join(dir, "receipts", "verify.json") - data, err := os.ReadFile(receiptPath) - if err != nil { - t.Fatalf("ReadFile receipt: %v", err) - } - var receipt map[string]any - if err := json.Unmarshal(data, &receipt); err != nil { - t.Fatalf("Unmarshal receipt: %v", err) - } - receipt["scope_digest"] = strings.Repeat("0", 64) - forged, err := json.MarshalIndent(receipt, "", " ") - if err != nil { - t.Fatalf("Marshal receipt: %v", err) - } - if err := os.WriteFile(receiptPath, append(forged, '\n'), 0o644); err != nil { - t.Fatalf("WriteFile forged receipt: %v", err) - } - commitAllChangeTest(t, repo, "chore: forge the receipt digest") - gateErr = releaseCohortPreflight(repo, "2.0.0", nil) - if gateErr == nil || !strings.Contains(gateErr.Error(), "is not executed") || - !strings.Contains(gateErr.Error(), "content changed since verification") { - t.Fatalf("a forged digest must block as unexecuted: %v", gateErr) - } -} - -// TestChangeExecutionGradeFlipPathNeedsNoReceipt keeps the first grading path -// independent of the second: a flip in ancestry grades executed on its own, and -// the missing receipt is the gate's separate, differently-worded complaint. -func TestChangeExecutionGradeFlipPathNeedsNoReceipt(t *testing.T) { - repo := seedCohortGateRepo(t, "2.0.0-alpha.1") - dir := writeNewLayoutChange(t, repo, "20260727-flip-only", "flip-only", "2.0.0", "") - flipExecuteChange(t, repo, dir, "flip-only") - - gateErr := releaseCohortPreflight(repo, "2.0.0", nil) - if gateErr == nil || !strings.Contains(gateErr.Error(), "missing receipt") { - t.Fatalf("gate err = %v, want the missing-receipt block", gateErr) - } - if strings.Contains(gateErr.Error(), "not executed") { - t.Fatalf("a flip in ancestry grades executed without any receipt: %v", gateErr) - } -} diff --git a/internal/cli/change_report.go b/internal/cli/change_report.go deleted file mode 100644 index 4bbcd2949..000000000 --- a/internal/cli/change_report.go +++ /dev/null @@ -1,283 +0,0 @@ -package cli - -import ( - "fmt" - "io" - "os" - "path/filepath" - "strings" - "time" -) - -// Closed report-kind registry (Decision 16 / V9). Ceremony kinds are reserved -// for a future publish ceremony; informational kinds round it out; note is the -// escape hatch. -var changeReportKinds = []string{ - "approval", - "review", - "visual", - "audit", - "note", -} - -var changeReportKindSet = func() map[string]bool { - set := make(map[string]bool, len(changeReportKinds)) - for _, kind := range changeReportKinds { - set[kind] = true - } - return set -}() - -var changeReportKindCeremony = map[string]bool{ - "approval": true, - "review": true, -} - -// changeReportClock is replaced in tests to pin YYYYMMDD-HHMMSS filenames. -var changeReportClock = time.Now - -type changeReportNewOptions struct { - slug string - kind string - folder string -} - -func parseChangeReportNewArgs(args []string) (changeReportNewOptions, error) { - options := changeReportNewOptions{} - for i := 0; i < len(args); i++ { - arg := args[i] - switch { - case arg == "--kind": - if i+1 >= len(args) { - return options, fmt.Errorf("--kind requires a value") - } - i++ - options.kind = args[i] - case strings.HasPrefix(arg, "--kind="): - options.kind = strings.TrimPrefix(arg, "--kind=") - case strings.HasPrefix(arg, "-"): - return options, fmt.Errorf("unknown change report new option %q", arg) - case options.slug == "": - options.slug = arg - case options.folder == "": - options.folder = arg - default: - return options, fmt.Errorf("change report new accepts <slug> and optional [folder]") - } - } - if options.slug == "" { - return options, fmt.Errorf("change report new requires a <slug> argument") - } - if !changeSlugRE.MatchString(options.slug) { - return options, fmt.Errorf("invalid report slug %q: use lowercase letters, digits, and single hyphens", options.slug) - } - if options.kind == "" { - return options, fmt.Errorf("change report new requires --kind (%s)", strings.Join(changeReportKinds, "/")) - } - if !changeReportKindSet[options.kind] { - return options, fmt.Errorf("unknown report kind %q; registry: %s", options.kind, strings.Join(changeReportKinds, ", ")) - } - return options, nil -} - -func (r Runner) runChangeReport(args []string, out io.Writer, rootPath string) error { - if len(args) == 0 || isHelpArg(args) { - writeChangeReportHelp(out) - return nil - } - if writeNestedHelp(out, args, map[string]func(io.Writer){ - "new": writeChangeReportNewHelp, - }) { - return nil - } - switch args[0] { - case "new": - return r.runChangeReportNew(args[1:], out, rootPath) - default: - return unknownSubcommandError("change report", args[0]) - } -} - -func writeChangeReportHelp(out io.Writer) { - writeCommandGroupHelp(out, "loaf change report <subcommand> [options]", - "Stamp authored HTML reports under a Change's reports/ directory.", - []subcommandHelpItem{ - {Name: "new", Summary: "Create a timestamped report shell from the closed kind registry"}, - }) -} - -func writeChangeReportNewHelp(out io.Writer) { - writeUsageHelp(out, "loaf change report new <slug> --kind <kind> [folder]", - "Create reports/YYYYMMDD-HHMMSS-<kind>-<slug>.html with charset, provenance header, and design-token skeleton. Refuses collisions and unknown kinds.", - "--kind Required kind from the closed registry: "+strings.Join(changeReportKinds, ", "), - "[folder] Change folder (or change.json/change.md) path; resolves from the current branch when omitted") -} - -func (r Runner) runChangeReportNew(args []string, out io.Writer, rootPath string) error { - if isHelpArg(args) { - writeChangeReportNewHelp(out) - return nil - } - options, err := parseChangeReportNewArgs(args) - if err != nil { - return err - } - - folder, _, err := resolveChangeFolder(rootPath, options.folder) - if err != nil { - return err - } - - now := changeReportClock() - filename := fmt.Sprintf("%s-%s-%s.html", now.Format("20060102-150405"), options.kind, options.slug) - reportsDir := filepath.Join(folder, "reports") - if err := os.MkdirAll(reportsDir, 0o755); err != nil { - return fmt.Errorf("create reports/: %w", err) - } - target := filepath.Join(reportsDir, filename) - if _, err := os.Stat(target); err == nil { - return fmt.Errorf("report already exists: %s", relFromRoot(rootPath, target)) - } else if !os.IsNotExist(err) { - return fmt.Errorf("stat report: %w", err) - } - - node, err := assembleChangeNodeFromFolder(rootPath, folder) - if err != nil { - return err - } - body := stampChangeReportSkeleton(options.kind, options.slug, node.Slug, now) - if err := os.WriteFile(target, []byte(body), 0o644); err != nil { - return fmt.Errorf("write report: %w", err) - } - - fmt.Fprintf(out, "Created report: %s\n", relFromRoot(rootPath, target)) - fmt.Fprintln(out) - writeChangeReportDesignGuidance(out, options.kind) - return nil -} - -func stampChangeReportSkeleton(kind, reportSlug, changeSlug string, now time.Time) string { - title := changeReportDefaultTitle(kind, reportSlug) - provenance := fmt.Sprintf( - "<!-- source: %s · kind %s · slug %s · stamped %s · authored report (snapshot semantics) · never auto-updated -->", - changeSlug, kind, reportSlug, now.Format("2006-01-02 15:04"), - ) - kindHint := changeReportKindHint(kind) - return provenance + "\n" + `<meta charset="utf-8"> -<title>` + htmlEscapeText(title) + ` - - -
-
` + htmlEscapeText(changeSlug) + ` · ` + htmlEscapeText(kind) + `
-

` + htmlEscapeText(title) + `

-

kind ` + htmlEscapeText(kind) + ` · slug ` + htmlEscapeText(reportSlug) + ` · ` + htmlEscapeText(now.Format("2006-01-02 15:04")) + `

- -

Body

-
-

` + htmlEscapeText(kindHint) + `

-
- -
Authored snapshot — layout is yours; keep charset, provenance comment, and design tokens intact.
-
-` -} - -func changeReportDefaultTitle(kind, reportSlug string) string { - label := strings.ReplaceAll(reportSlug, "-", " ") - switch kind { - case "approval": - return "Approval — " + label - case "review": - return "Review — " + label - case "visual": - return "Visual — " + label - case "audit": - return "Audit — " + label - default: - return "Note — " + label - } -} - -func changeReportKindHint(kind string) string { - switch kind { - case "approval": - return "Ceremony approval board: what is being approved, at which commit/digest, and the gate the human must clear. Reserved for publish/shaping ceremonies." - case "review": - return "Ceremony review board: findings table with severity and disposition. Reserved so publish can find review artifacts deterministically." - case "visual": - return "Informational visual: process diagrams, lifecycle maps, anatomy sketches — teach the model, do not gate it." - case "audit": - return "Informational audit: inventory, compliance, or corpus sweep evidence baked at write time." - default: - return "Escape-hatch note: use when no registered kind fits; recurring topics signal when a new kind has earned registration." - } -} - -func writeChangeReportDesignGuidance(out io.Writer, kind string) { - fmt.Fprintln(out, "Design language (keep these invariants; layout is yours):") - fmt.Fprintln(out, " - First line: HTML provenance comment (source · kind · slug · stamped time)") - fmt.Fprintln(out, " - before title so file:// opens correctly in every browser") - fmt.Fprintln(out, " - Shared tokens: --bg --panel --line --ink --muted --accent --accent-fill --accent-soft") - fmt.Fprintln(out, " --machine --machine-soft --ok --ok-soft --bad --bad-soft --state --state-soft") - fmt.Fprintln(out, " --mono --serif --sans; light/dark via prefers-color-scheme and data-theme") - fmt.Fprintln(out, " - Eyebrow + serif h1 + mono stamp; panel cards for dense material") - if changeReportKindCeremony[kind] { - fmt.Fprintf(out, " - Kind %q is ceremony-reserved: keep the filename glob YYYYMMDD-HHMMSS-%s-*.html stable\n", kind, kind) - } else { - fmt.Fprintf(out, " - Kind %q is informational; bake data at write time — never a live derived view\n", kind) - } - fmt.Fprintln(out, " - Registry:", strings.Join(changeReportKinds, ", ")) -} - -func htmlEscapeText(value string) string { - replacer := strings.NewReplacer( - `&`, "&", - `<`, "<", - `>`, ">", - `"`, """, - ) - return replacer.Replace(value) -} diff --git a/internal/cli/change_scaffold.go b/internal/cli/change_scaffold.go deleted file mode 100644 index 937a927e3..000000000 --- a/internal/cli/change_scaffold.go +++ /dev/null @@ -1,412 +0,0 @@ -package cli - -import ( - "bytes" - _ "embed" - "encoding/json" - "fmt" - "io" - "os" - "path/filepath" - "strings" - "time" -) - -// Scaffold templates are embedded so `loaf change init` never depends on -// installed content. Each must stay byte-identical to the canonical file under -// content/skills/shape/templates/; drift is gated by TestChangeScaffoldTemplatesMatchCanonical. - -//go:embed change_shape_template.md -var changeShapeTemplate string - -//go:embed change_brief_template.md -var changeBriefTemplate string - -//go:embed change_plan_template.md -var changePlanTemplate string - -//go:embed change_design_template.md -var changeDesignTemplate string - -//go:embed change_task_template.md -var changeTaskTemplate string - -// changePublishTempPrefix names temp files used by atomic destination publication -// during captured-folder promotion. Stray temps never count as materialization. -const changePublishTempPrefix = ".loaf-change-" - -type changeInitOptions struct { - slug string - brief bool -} - -func parseChangeInitArgs(args []string) (changeInitOptions, error) { - options := changeInitOptions{} - for _, arg := range args { - switch { - case arg == "--brief": - options.brief = true - case strings.HasPrefix(arg, "-"): - return options, fmt.Errorf("unknown change init option %q", arg) - case options.slug != "": - return options, fmt.Errorf("change init accepts a single argument") - default: - options.slug = arg - } - } - if options.slug == "" { - return options, fmt.Errorf("change init requires a argument") - } - if !changeSlugRE.MatchString(options.slug) { - return options, fmt.Errorf("invalid slug %q: use lowercase letters, digits, and single hyphens (e.g. auth-token-rotation)", options.slug) - } - return options, nil -} - -func stampChangeScaffoldPlaceholders(template string, slug string) string { - return strings.NewReplacer( - "change: [slug]", "change: "+slug, - "[slug]", slug, - ).Replace(template) -} - -// changeSeedTaskFile is the first packet written by `loaf change init`. The -// author is expected to rename the slug; `first-slice` cites no other work unit. -const changeSeedTaskFile = "TASK-001-first-slice.md" - -// stampChangeTaskSeed stamps the embedded task template into a ready-to-edit -// first packet: owning change slug, TASK-001 identity, unchecked boxes and -// bracket placeholders preserved, plus an explicit rename note. -func stampChangeTaskSeed(template string, slug string) string { - body := strings.NewReplacer( - "change: [slug]", "change: "+slug, - "TASK-NNN", "TASK-001", - ).Replace(template) - const renameNote = "\n\nRename this file before authoring real work — `first-slice` is a seed slug (not a work-unit citation); the author is expected to rename it.\n" - const h1 = "# TASK-001 — [Title]" - if idx := strings.Index(body, h1); idx >= 0 { - insertAt := idx + len(h1) - body = body[:insertAt] + renameNote + body[insertAt:] - } - return body -} - -func writeChangeJSON(path string, slug string, now time.Time) error { - payload := map[string]string{ - "change": slug, - "created": now.Format("2006-01-02"), - "branch": slug, - } - data, err := json.MarshalIndent(payload, "", " ") - if err != nil { - return fmt.Errorf("encode change.json: %w", err) - } - data = append(data, '\n') - if err := os.WriteFile(path, data, 0o644); err != nil { - return fmt.Errorf("write change.json: %w", err) - } - return nil -} - -func scaffoldChangeFolder(folder string, slug string, brief bool, now time.Time) error { - if err := os.MkdirAll(folder, 0o755); err != nil { - return fmt.Errorf("create change folder: %w", err) - } - if err := writeChangeJSON(filepath.Join(folder, changeMachineFileJSON), slug, now); err != nil { - return err - } - if brief { - target := filepath.Join(folder, changeBriefFile) - if err := os.WriteFile(target, []byte(stampChangeScaffoldPlaceholders(changeBriefTemplate, slug)), 0o644); err != nil { - return fmt.Errorf("write brief.md: %w", err) - } - return nil - } - - shapePath := filepath.Join(folder, changeContractFileShape) - if err := os.WriteFile(shapePath, []byte(changeShapeTemplate), 0o644); err != nil { - return fmt.Errorf("write shape.md: %w", err) - } - tasksDir := filepath.Join(folder, "tasks") - if err := os.MkdirAll(tasksDir, 0o755); err != nil { - return fmt.Errorf("create tasks/: %w", err) - } - // Seed a real first task packet so shapers see the delegation format at - // scaffold time. Unchecked boxes cannot manufacture provenance; rename - // the seed slug when authoring the first real slice. - seedPath := filepath.Join(tasksDir, changeSeedTaskFile) - if err := os.WriteFile(seedPath, []byte(stampChangeTaskSeed(changeTaskTemplate, slug)), 0o644); err != nil { - return fmt.Errorf("seed %s: %w", changeSeedTaskFile, err) - } - return nil -} - -// changePromotionOutcome is the three-way matrix for init against an existing folder. -type changePromotionOutcome int - -const ( - changePromotionPromote changePromotionOutcome = iota - changePromotionResume - changePromotionReject -) - -type changePromotionDecision struct { - outcome changePromotionOutcome - reason string - // needSeed is true when the seed task destination is missing (promote or resume gap). - needSeed bool - // needShape is true when shape.md is missing (always true for promote/resume). - needShape bool -} - -// classifyChangePromotion applies Decision 12's state matrix. Only ordinary -// init (not --brief) may promote or resume; brief.md and change.json are never -// written by promotion. folderRel is the repo-relative path for error messages. -func classifyChangePromotion(folderAbs, folderRel, slug string, briefMode bool) changePromotionDecision { - folderRel = filepath.ToSlash(folderRel) - if briefMode { - return changePromotionDecision{ - outcome: changePromotionReject, - reason: fmt.Sprintf("change slug %q already exists in %s (re-run without --brief to promote a capture-only folder)", slug, folderRel), - } - } - - jsonPath := filepath.Join(folderAbs, changeMachineFileJSON) - mdPath := filepath.Join(folderAbs, changeMachineFileLegacy) - _, jsonErr := os.Stat(jsonPath) - _, mdErr := os.Stat(mdPath) - jsonPresent := jsonErr == nil - mdPresent := mdErr == nil - if !jsonPresent && !os.IsNotExist(jsonErr) { - return changePromotionDecision{outcome: changePromotionReject, reason: fmt.Sprintf("stat change.json: %v", jsonErr)} - } - if !mdPresent && !os.IsNotExist(mdErr) { - return changePromotionDecision{outcome: changePromotionReject, reason: fmt.Sprintf("stat change.md: %v", mdErr)} - } - if jsonPresent && mdPresent { - return changePromotionDecision{ - outcome: changePromotionReject, - reason: fmt.Sprintf("hybrid layout in %s: change.json and change.md both present; refuse promotion", folderRel), - } - } - if !jsonPresent { - return changePromotionDecision{ - outcome: changePromotionReject, - reason: fmt.Sprintf("change slug %q already exists in %s", slug, folderRel), - } - } - - jsonBytes, err := os.ReadFile(jsonPath) - if err != nil { - return changePromotionDecision{outcome: changePromotionReject, reason: fmt.Sprintf("read change.json: %v", err)} - } - meta := parseChangeJSON(string(jsonBytes)) - if len(meta.Findings) > 0 { - return changePromotionDecision{ - outcome: changePromotionReject, - reason: fmt.Sprintf("invalid change.json in %s: %s", folderRel, strings.Join(meta.Findings, "; ")), - } - } - if meta.Change != slug { - return changePromotionDecision{ - outcome: changePromotionReject, - reason: fmt.Sprintf("change.json field \"change\" is %q, want slug %q; refuse promotion", meta.Change, slug), - } - } - - // shape.md is the materialization marker: present means fully (or partially - // authored) shaped, and duplicate rejection is unchanged. - shapePath := filepath.Join(folderAbs, changeContractFileShape) - if _, err := os.Stat(shapePath); err == nil { - return changePromotionDecision{ - outcome: changePromotionReject, - reason: fmt.Sprintf("change slug %q already exists in %s", slug, folderRel), - } - } else if !os.IsNotExist(err) { - return changePromotionDecision{outcome: changePromotionReject, reason: fmt.Sprintf("stat shape.md: %v", err)} - } - - if _, err := os.Stat(filepath.Join(folderAbs, changeBriefFile)); err != nil { - if os.IsNotExist(err) { - return changePromotionDecision{ - outcome: changePromotionReject, - reason: fmt.Sprintf("cannot promote %s: brief.md is missing (change.json-only folders fail closed)", folderRel), - } - } - return changePromotionDecision{outcome: changePromotionReject, reason: fmt.Sprintf("stat brief.md: %v", err)} - } - - expectedSeed := []byte(stampChangeTaskSeed(changeTaskTemplate, slug)) - tasksDir := filepath.Join(folderAbs, "tasks") - entries, err := os.ReadDir(tasksDir) - if err != nil { - if os.IsNotExist(err) { - // Structurally valid brief-only folder: promote. - return changePromotionDecision{ - outcome: changePromotionPromote, - needSeed: true, - needShape: true, - } - } - return changePromotionDecision{outcome: changePromotionReject, reason: fmt.Sprintf("read tasks/: %v", err)} - } - - var realEntries []os.DirEntry - for _, entry := range entries { - name := entry.Name() - if strings.HasPrefix(name, changePublishTempPrefix) || strings.HasPrefix(name, ".") { - // Stray temps and hidden leftovers do not count as tasks content. - continue - } - realEntries = append(realEntries, entry) - } - if len(realEntries) == 0 { - return changePromotionDecision{ - outcome: changePromotionPromote, - needSeed: true, - needShape: true, - } - } - - // Resume only when every real tasks entry is the byte-identical seed. - for _, entry := range realEntries { - if entry.IsDir() { - return changePromotionDecision{ - outcome: changePromotionReject, - reason: fmt.Sprintf("cannot promote %s: tasks/ has unexpected directory %q", folderRel, entry.Name()), - } - } - if entry.Name() != changeSeedTaskFile { - return changePromotionDecision{ - outcome: changePromotionReject, - reason: fmt.Sprintf("cannot promote %s: tasks/ content diverged from seed (found %q)", folderRel, entry.Name()), - } - } - got, err := os.ReadFile(filepath.Join(tasksDir, entry.Name())) - if err != nil { - return changePromotionDecision{outcome: changePromotionReject, reason: fmt.Sprintf("read tasks/%s: %v", entry.Name(), err)} - } - if !bytes.Equal(got, expectedSeed) { - return changePromotionDecision{ - outcome: changePromotionReject, - reason: fmt.Sprintf("cannot promote %s: tasks/%s diverged from seed instantiation", folderRel, changeSeedTaskFile), - } - } - } - - return changePromotionDecision{ - outcome: changePromotionResume, - needSeed: false, - needShape: true, - } -} - -// completeCapturedChangeFolder promotes or resumes a capture-only folder. -// brief.md and change.json are never written. Destination files are published -// atomically (temp-write then rename, refuse existing destinations); shape.md -// is published last as the promotion marker. -func completeCapturedChangeFolder(folderAbs string, slug string, decision changePromotionDecision) error { - if decision.outcome != changePromotionPromote && decision.outcome != changePromotionResume { - return fmt.Errorf("internal: completeCapturedChangeFolder called with non-completable outcome") - } - - // Publication order: seed task first (when needed), shape.md last (marker). - if decision.needSeed { - tasksDir := filepath.Join(folderAbs, "tasks") - if err := os.MkdirAll(tasksDir, 0o755); err != nil { - return fmt.Errorf("create tasks/: %w", err) - } - seedPath := filepath.Join(tasksDir, changeSeedTaskFile) - seedBody := []byte(stampChangeTaskSeed(changeTaskTemplate, slug)) - if err := publishChangeFileExclusive(seedPath, seedBody); err != nil { - return fmt.Errorf("publish %s: %w", changeSeedTaskFile, err) - } - } - - if decision.needShape { - shapePath := filepath.Join(folderAbs, changeContractFileShape) - if err := publishChangeFileExclusive(shapePath, []byte(changeShapeTemplate)); err != nil { - return fmt.Errorf("publish shape.md: %w", err) - } - } - return nil -} - -// publishChangeFileExclusive writes body to a same-directory temp file, syncs, -// then renames into dest only when dest does not already exist. A destination -// is therefore either absent or complete — never half-written in place. -func publishChangeFileExclusive(dest string, body []byte) error { - if info, err := os.Stat(dest); err == nil { - _ = info - return fmt.Errorf("refusing to overwrite existing file %s", dest) - } else if !os.IsNotExist(err) { - return err - } - - dir := filepath.Dir(dest) - if err := os.MkdirAll(dir, 0o755); err != nil { - return err - } - temp, err := os.CreateTemp(dir, changePublishTempPrefix+"*") - if err != nil { - return err - } - tempPath := temp.Name() - cleanup := true - defer func() { - if cleanup { - _ = os.Remove(tempPath) - } - }() - - if err := temp.Chmod(0o644); err != nil { - temp.Close() - return err - } - if _, err := temp.Write(body); err != nil { - temp.Close() - return err - } - if err := temp.Sync(); err != nil { - temp.Close() - return err - } - if err := temp.Close(); err != nil { - return err - } - - // Claim the destination exclusively before rename so we never replace an - // existing complete file if one appeared between Stat and rename. - claim, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) - if err != nil { - if os.IsExist(err) { - return fmt.Errorf("refusing to overwrite existing file %s", dest) - } - return err - } - if err := claim.Close(); err != nil { - _ = os.Remove(dest) - return err - } - if err := os.Rename(tempPath, dest); err != nil { - _ = os.Remove(dest) - return err - } - cleanup = false - return nil -} - -// writeChangePromotionSuccess prints a message distinct from fresh-scaffold output. -func writeChangePromotionSuccess(out io.Writer, rootPath, folderAbs, slug string, decision changePromotionDecision) { - folderRel := relFromRoot(rootPath, folderAbs) - verb := "Promoted capture" - if decision.outcome == changePromotionResume { - verb = "Resumed capture promotion" - } - fmt.Fprintf(out, "%s: %s\n", verb, filepath.ToSlash(filepath.Join(folderRel, changeContractFileShape))) - fmt.Fprintf(out, " Preserved brief.md + change.json verbatim; instantiated missing shape.md + tasks/\n") - fmt.Fprintf(out, "\nNext: work on this change happens on branch %q.\n", slug) - fmt.Fprintf(out, " Create or switch to it: git switch -c %s\n", slug) - fmt.Fprintf(out, " Then validate the change: loaf change check\n") - fmt.Fprintf(out, " Or check it from any branch by passing the folder: loaf change check %s\n", folderRel) -} diff --git a/internal/cli/change_shape_template.md b/internal/cli/change_shape_template.md deleted file mode 100644 index 59aa9b89a..000000000 --- a/internal/cli/change_shape_template.md +++ /dev/null @@ -1,77 +0,0 @@ - - -# [Change Title] - -## Problem - -[Why this work exists — the friction, gap, or rot being addressed.] - -## Hypothesis - -[The bet: what becomes true if this ships, and why it is worth making.] - -## Scope - -**In** - -- [What this Change delivers.] - -**Out** (deferred, not rejected) - -- [What is explicitly postponed, and where it went.] - -**Cut** (explicitly rejected) - -- [What this Change will not do, ever.] - -## Observable Workflow - -[What someone sees or does once this ships — commands, flows, UX. Concrete over abstract.] - -## Rabbit Holes and No-Gos - -[Boundaries: the ways this work could quietly grow into something it must not become.] - -## Decisions - -Provenance: [how each decision was accepted — interview, review, dogfooding.] - -1. **[Decision.]** [Rationale, and what it forecloses.] - -## Planning Contract - - - -### [Approach / Placement / Risks / Sequencing …] - -[…] - -## Implementation Units - - - -- [**TASK-001 — Unit name.** What it delivers.] - -## Verification Contract - - - -- [**V1.** What must be true. Command: `exact command`. Expect: exit 0.] - - - -- [**H1.** What a reviewer confirms that no command can.] - -## Definition of Done - -- [Derived from gates and review — never a status flag.] - -## Durable Outputs - -[Specs, ADRs, knowledge docs, or schema updates to create after implementation proves what is now true.] - -## Open Questions - - - -- [KU] [Known unknown → route to a task or later change] diff --git a/internal/cli/change_state.go b/internal/cli/change_state.go deleted file mode 100644 index c4aabe476..000000000 --- a/internal/cli/change_state.go +++ /dev/null @@ -1,196 +0,0 @@ -package cli - -import ( - "fmt" - "path/filepath" - "strings" -) - -// changeEvidenceGitOutput is the git seam for verified-rung evidence reads. -// Production points at commandOutput; tests may redirect it to simulate load failures. -// Callers pass it (or another seam) into deriveChangeState*; nil falls back here. -var changeEvidenceGitOutput changeGitOutput = commandOutput - -// deriveChangeState returns the single derived-state ladder shared by list, -// show, and check: captured → shaped → executable → executing → complete, -// plus verified for changes that declare a target_release. -// -// complete means every task checkbox is checked. verified requires a fresh -// receipt whose criteria all passed AND the same structural composite the -// cohort gate applies (lineage-inclusive) — a structurally rejected member -// never displays verified where the gate would refuse. Unchecked boxes on a -// verified cohort member remain legal descoped work (Decision 15). -func deriveChangeState(rootPath string, node changeNode, outputCommand changeGitOutput) string { - state, _ := deriveChangeStateDetailed(rootPath, node, outputCommand) - return state -} - -// deriveChangeStateDetailed returns the ladder state plus warnings for evidence -// evaluation failures that demoted the member (fail-closed stays; silence goes). -func deriveChangeStateDetailed(rootPath string, node changeNode, outputCommand changeGitOutput) (string, []string) { - if outputCommand == nil { - outputCommand = changeEvidenceGitOutput - } - var warnings []string - if node.CapturedOnly { - return "captured", warnings - } - report := evaluateChangeNode(node, "") - if !report.Executable { - return "shaped", warnings - } - status, err := changeFolderExecuted(rootPath, node.Folder, node.Layout, outputCommand) - if err != nil { - warnings = append(warnings, "execution provenance failed: "+err.Error()) - return "executable", warnings - } - if !status.PathExecuted { - return "executable", warnings - } - if node.TargetRelease != "" { - _, evidenceGit, pinErr := pinEvidenceAtHEAD(rootPath, outputCommand) - if pinErr != nil { - warnings = append(warnings, "evidence pin failed: "+pinErr.Error()) - } else { - ok, clean, evalErr, receiptWarn := evaluateVerifiedRungAtCommit(rootPath, node, evidenceGit) - if evalErr != "" { - warnings = append(warnings, "structural evaluation failed: "+evalErr) - } - if receiptWarn != "" { - warnings = append(warnings, receiptWarn) - } - if ok && clean { - return "verified", warnings - } - } - } - if changeAllTaskCheckboxesChecked(rootPath, node) { - return "complete", warnings - } - return "executing", warnings -} - -// pinEvidenceAtHEAD resolves HEAD to a SHA once and returns a git seam that -// rewrites every symbolic HEAD token to that SHA, so one derivation cannot split -// node / task / receipt reads across a commit that lands mid-flight. -func pinEvidenceAtHEAD(rootPath string, outputCommand changeGitOutput) (string, changeGitOutput, error) { - if outputCommand == nil { - outputCommand = changeEvidenceGitOutput - } - sha, err := outputCommand(rootPath, "git", "rev-parse", "HEAD") - if err != nil { - return "", nil, err - } - sha = strings.TrimSpace(sha) - return sha, rewriteHEADRef(sha, outputCommand), nil -} - -func rewriteHEADRef(sha string, inner changeGitOutput) changeGitOutput { - return func(cwd, name string, args ...string) (string, error) { - out := make([]string, len(args)) - for i, arg := range args { - out[i] = rewriteHEADToken(arg, sha) - } - return inner(cwd, name, out...) - } -} - -func rewriteHEADToken(arg, sha string) string { - switch { - case arg == "HEAD": - return sha - case strings.HasPrefix(arg, "HEAD:"): - return sha + arg[len("HEAD"):] - case strings.HasSuffix(arg, "..HEAD"): - return strings.TrimSuffix(arg, "HEAD") + sha - case strings.HasSuffix(arg, "...HEAD"): - return strings.TrimSuffix(arg, "HEAD") + sha - case strings.HasPrefix(arg, "HEAD.."): - return sha + arg[len("HEAD"):] - case strings.HasPrefix(arg, "HEAD..."): - return sha + arg[len("HEAD"):] - default: - return arg - } -} - -// evaluateVerifiedRungAtCommit loads the committed node once and feeds that same -// node (folder + content) into both the structural composite and the receipt -// check — never the working-tree node the ladder received. -func evaluateVerifiedRungAtCommit(rootPath string, node changeNode, outputCommand changeGitOutput) (ok bool, clean bool, evalErr string, receiptWarn string) { - if outputCommand == nil { - outputCommand = changeEvidenceGitOutput - } - nodes, err := loadChangeNodesAtHEADWithOutput(rootPath, outputCommand) - if err != nil { - return false, false, err.Error(), "" - } - headNode, found := changeNodeForFolder(nodes, node.Folder) - if !found { - headNode, found = changeNodeForSlug(nodes, node.Slug) - } - if !found { - return false, false, fmt.Sprintf("change %q missing from committed HEAD", node.Slug), "" - } - folderAbs := filepath.Join(rootPath, filepath.FromSlash(headNode.Folder)) - report, reportErr := composeChangeCheckReport(evaluateChangeNode(headNode, ""), rootPath, folderAbs, headNode, nodes, outputCommand, false, changeTaskContentHEAD) - if reportErr != nil { - return false, false, reportErr.Error(), "" - } - clean = len(report.Violations) == 0 && report.Executable - verdict := changeReceiptStatus(rootPath, headNode.Folder, headNode, outputCommand) - ok = verdict.OK - if !verdict.OK && verdict.Reason != changeReceiptMissing && verdict.Reason != changeReceiptOK { - receiptWarn = "receipt evaluation failed: " + verdict.Cause() - } - return ok, clean, "", receiptWarn -} - -// changeStructurallyCleanForState reports whether the gate's structural -// composite is clean for this node — the verified rung must agree with the gate. -// Evidence is committed HEAD only: nodes and task files are never read from the -// working tree here, so a dirty checkout cannot flip the verdict either way. -// On load/evaluation error it returns false with a reason (fail-closed, not silent). -func changeStructurallyCleanForState(rootPath string, node changeNode, outputCommand changeGitOutput) (bool, string) { - if outputCommand == nil { - outputCommand = changeEvidenceGitOutput - } - _, clean, evalErr, _ := evaluateVerifiedRungAtCommit(rootPath, node, outputCommand) - return clean, evalErr -} - -func changeNodeForFolder(nodes []changeNode, folder string) (changeNode, bool) { - folder = filepath.ToSlash(folder) - for _, n := range nodes { - if filepath.ToSlash(n.Folder) == folder { - return n, true - } - } - return changeNode{}, false -} - -func changeNodeForSlug(nodes []changeNode, slug string) (changeNode, bool) { - for _, n := range nodes { - if n.Slug == slug { - return n, true - } - } - return changeNode{}, false -} - -// changeAllTaskCheckboxesChecked reports whether the change has at least one -// checkbox and every checkbox across its task files is checked. -func changeAllTaskCheckboxesChecked(rootPath string, node changeNode) bool { - if node.Layout != changeLayoutNew { - return false - } - folderAbs := filepath.Join(rootPath, filepath.FromSlash(node.Folder)) - tasks, _, _ := loadChangeTasks(rootPath, folderAbs, node, changeTaskContentWorkingTree, commandOutput) - total := 0 - done := 0 - for _, task := range tasks { - total += task.CheckboxTotal - done += task.CheckboxDone - } - return total > 0 && done == total -} diff --git a/internal/cli/change_state_test.go b/internal/cli/change_state_test.go deleted file mode 100644 index 00a557337..000000000 --- a/internal/cli/change_state_test.go +++ /dev/null @@ -1,698 +0,0 @@ -package cli - -import ( - "bytes" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestChangeListShowStateAgreementAcrossLadder(t *testing.T) { - cases := []struct { - name string - wantState string - setup func(t *testing.T, repo string) (folderRel, slug string) - }{ - { - name: "captured", - wantState: "captured", - setup: func(t *testing.T, repo string) (string, string) { - t.Helper() - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "init", "ladder-captured", "--brief"}); err != nil { - t.Fatalf("init: %v", err) - } - return mustFindChangeFolder(t, repo, "ladder-captured"), "ladder-captured" - }, - }, - { - name: "shaped", - wantState: "shaped", - setup: func(t *testing.T, repo string) (string, string) { - t.Helper() - dir := writeNewLayoutChange(t, repo, "20260727-ladder-shaped", "ladder-shaped", "", "# Title only\n") - return relFromRoot(repo, dir), "ladder-shaped" - }, - }, - { - name: "executable", - wantState: "executable", - setup: func(t *testing.T, repo string) (string, string) { - t.Helper() - dir := writeNewLayoutChange(t, repo, "20260727-ladder-executable", "ladder-executable", "", "") - return relFromRoot(repo, dir), "ladder-executable" - }, - }, - { - name: "executing", - wantState: "executing", - setup: func(t *testing.T, repo string) (string, string) { - t.Helper() - dir := writeNewLayoutChange(t, repo, "20260727-ladder-executing", "ladder-executing", "", "") - task := filepath.Join(dir, "tasks", "TASK-001-work.md") - if err := os.WriteFile(task, []byte("---\nchange: ladder-executing\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [ ] Do it\n"), 0o644); err != nil { - t.Fatalf("WriteFile task: %v", err) - } - commitAllChangeTest(t, repo, "docs: shape ladder-executing") - if err := os.WriteFile(filepath.Join(repo, "main.go"), []byte("package main\n"), 0o644); err != nil { - t.Fatalf("WriteFile main.go: %v", err) - } - if err := os.WriteFile(task, []byte("---\nchange: ladder-executing\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [ ] Do it\n\nnote\n"), 0o644); err != nil { - t.Fatalf("WriteFile task touch: %v", err) - } - commitAllChangeTest(t, repo, "chore: path grade ladder-executing") - return relFromRoot(repo, dir), "ladder-executing" - }, - }, - { - name: "complete", - wantState: "complete", - setup: func(t *testing.T, repo string) (string, string) { - t.Helper() - dir := writeNewLayoutChange(t, repo, "20260727-ladder-complete", "ladder-complete", "", "") - flipExecuteChange(t, repo, dir, "ladder-complete") - return relFromRoot(repo, dir), "ladder-complete" - }, - }, - { - name: "verified", - wantState: "verified", - setup: func(t *testing.T, repo string) (string, string) { - t.Helper() - dir := writeNewLayoutChange(t, repo, "20260727-ladder-verified", "ladder-verified", "1.0.0", "") - flipExecuteChange(t, repo, dir, "ladder-verified") - folderRel := relFromRoot(repo, dir) - var stdout bytes.Buffer - if err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { - t.Fatalf("verify: %v\n%s", err, stdout.String()) - } - commitAllChangeTest(t, repo, "chore: commit ladder-verified receipt") - return folderRel, "ladder-verified" - }, - }, - { - name: "structurally-rejected-receipt", - wantState: "executing", - setup: func(t *testing.T, repo string) (string, string) { - t.Helper() - dir := writeNewLayoutChange(t, repo, "20260727-ladder-rejected", "ladder-rejected", "1.0.0", "") - flipExecuteChange(t, repo, dir, "ladder-rejected") - later := filepath.Join(dir, "tasks", "TASK-002-later.md") - if err := os.WriteFile(later, []byte("---\nchange: ladder-rejected\nid: TASK-002\ntitle: Later\nstatus: in-progress\n---\n\n# Later\n\n## Steps\n\n- [ ] Descoped\n"), 0o644); err != nil { - t.Fatalf("WriteFile TASK-002: %v", err) - } - commitAllChangeTest(t, repo, "docs: add banned task frontmatter") - folderRel := relFromRoot(repo, dir) - var stdout bytes.Buffer - if err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { - t.Fatalf("verify: %v\n%s", err, stdout.String()) - } - commitAllChangeTest(t, repo, "chore: commit ladder-rejected receipt") - return folderRel, "ladder-rejected" - }, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - repo := initCLIGitRepo(t) - if tc.wantState == "verified" || tc.name == "structurally-rejected-receipt" { - writeReleaseVersionFiles(t, repo, "1.0.0-alpha.1") - } - folderRel, slug := tc.setup(t, repo) - - listState := changeListStateForSlug(t, repo, slug) - showState := changeShowState(t, repo, folderRel) - checkState := changeCheckState(t, repo, folderRel) - - if listState != tc.wantState { - t.Fatalf("list state = %q, want %q", listState, tc.wantState) - } - if showState != listState { - t.Fatalf("show state = %q, list state = %q", showState, listState) - } - if checkState != listState { - t.Fatalf("check state = %q, list state = %q", checkState, listState) - } - }) - } -} - -func TestChangeStateStructurallyRejectedReceiptNeverDisplaysVerified(t *testing.T) { - repo := initCLIGitRepo(t) - writeReleaseVersionFiles(t, repo, "1.0.0-alpha.1") - dir := writeNewLayoutChange(t, repo, "20260727-banned-verified", "banned-verified", "1.0.0", "") - flipExecuteChange(t, repo, dir, "banned-verified") - later := filepath.Join(dir, "tasks", "TASK-002-later.md") - if err := os.WriteFile(later, []byte("---\nchange: banned-verified\nid: TASK-002\ntitle: Later\nstatus: in-progress\n---\n\n# Later\n\n## Steps\n\n- [ ] Descoped\n"), 0o644); err != nil { - t.Fatalf("WriteFile TASK-002: %v", err) - } - commitAllChangeTest(t, repo, "docs: add banned task frontmatter") - folderRel := relFromRoot(repo, dir) - var stdout bytes.Buffer - if err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { - t.Fatalf("verify: %v\n%s", err, stdout.String()) - } - commitAllChangeTest(t, repo, "chore: commit receipt under banned frontmatter") - - listState := changeListStateForSlug(t, repo, "banned-verified") - showState := changeShowState(t, repo, folderRel) - showPlain := changeShowPlainState(t, repo, folderRel) - checkState := changeCheckState(t, repo, folderRel) - for _, got := range []string{listState, showState, showPlain, checkState} { - if got == "verified" { - t.Fatalf("surfaces report verified despite structural rejection: list=%q show=%q plain=%q check=%q", listState, showState, showPlain, checkState) - } - } - if listState != showState || listState != showPlain || listState != checkState { - t.Fatalf("surfaces disagree: list=%q show=%q plain=%q check=%q", listState, showState, showPlain, checkState) - } - checkOut := changeCheckFindings(t, repo, folderRel) - if !strings.Contains(checkOut, "status") || !strings.Contains(checkOut, "banned") { - t.Fatalf("check must show the violation; got %q", checkOut) - } - - if err := os.WriteFile(later, []byte("---\nchange: banned-verified\nid: TASK-002\ntitle: Later\n---\n\n# Later\n\n## Steps\n\n- [ ] Descoped\n"), 0o644); err != nil { - t.Fatalf("WriteFile repair: %v", err) - } - commitAllChangeTest(t, repo, "docs: drop banned key") - // Receipt may stale from the non-receipt path edit; re-verify and commit. - stdout.Reset() - if err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { - t.Fatalf("re-verify: %v\n%s", err, stdout.String()) - } - commitAllChangeTest(t, repo, "chore: re-verify after repair") - - listState = changeListStateForSlug(t, repo, "banned-verified") - showState = changeShowState(t, repo, folderRel) - showPlain = changeShowPlainState(t, repo, folderRel) - checkState = changeCheckState(t, repo, folderRel) - if listState != "verified" || showState != "verified" || showPlain != "verified" || checkState != "verified" { - t.Fatalf("after repair want verified everywhere; list=%q show=%q plain=%q check=%q", listState, showState, showPlain, checkState) - } -} - -func changeShowPlainState(t *testing.T, repo, folderRel string) string { - t.Helper() - var stdout bytes.Buffer - if err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "show", folderRel}); err != nil { - t.Fatalf("show: %v\n%s", err, stdout.String()) - } - for _, line := range strings.Split(stdout.String(), "\n") { - line = strings.TrimSpace(stripANSI(line)) - if strings.HasPrefix(line, "State:") { - return strings.TrimSpace(strings.TrimPrefix(line, "State:")) - } - if strings.HasPrefix(line, "state:") { - return strings.TrimSpace(strings.TrimPrefix(line, "state:")) - } - } - t.Fatalf("show output missing state line:\n%s", stdout.String()) - return "" -} - -func changeCheckFindings(t *testing.T, repo, folderRel string) string { - t.Helper() - var stdout bytes.Buffer - _ = (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "check", folderRel, "--json"}) - return stdout.String() -} - -func mustFindChangeFolder(t *testing.T, repo, slug string) string { - t.Helper() - entries, err := os.ReadDir(filepath.Join(repo, "docs", "changes")) - if err != nil { - t.Fatalf("ReadDir: %v", err) - } - suffix := "-" + slug - for _, entry := range entries { - if entry.IsDir() && strings.HasSuffix(entry.Name(), suffix) { - return filepath.ToSlash(filepath.Join("docs", "changes", entry.Name())) - } - } - t.Fatalf("change folder for %q not found", slug) - return "" -} - -func changeListStateForSlug(t *testing.T, repo, slug string) string { - t.Helper() - var stdout bytes.Buffer - if err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "list", "--json"}); err != nil { - t.Fatalf("list: %v\n%s", err, stdout.String()) - } - var result changeListUnitJSON - if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { - t.Fatalf("Unmarshal list: %v", err) - } - for _, unit := range result.Units { - if unit.Slug == slug { - return unit.State - } - } - t.Fatalf("slug %q missing from list", slug) - return "" -} - -func changeShowState(t *testing.T, repo, folderRel string) string { - t.Helper() - var stdout bytes.Buffer - if err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "show", folderRel, "--json"}); err != nil { - t.Fatalf("show: %v\n%s", err, stdout.String()) - } - var result changeShowJSON - if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { - t.Fatalf("Unmarshal show: %v", err) - } - return result.State -} - -func changeCheckState(t *testing.T, repo, folderRel string) string { - t.Helper() - var stdout bytes.Buffer - err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "check", folderRel, "--json"}) - // check may exit non-zero for shaped (violations); still decode JSON. - var result changeCheckJSON - if unmarshalErr := json.Unmarshal(stdout.Bytes(), &result); unmarshalErr != nil { - t.Fatalf("check err=%v unmarshal=%v stdout=%s", err, unmarshalErr, stdout.String()) - } - return result.State -} - -// --- TASK-025: verified/state evidence reads committed HEAD, not the working tree --- - -func TestChangeStateIgnoresUncommittedBannedFrontmatter(t *testing.T) { - repo := initCLIGitRepo(t) - writeReleaseVersionFiles(t, repo, "1.0.0-alpha.1") - dir := writeNewLayoutChange(t, repo, "20260727-clean-verified", "clean-verified", "1.0.0", "") - flipExecuteChange(t, repo, dir, "clean-verified") - folderRel := relFromRoot(repo, dir) - var stdout bytes.Buffer - if err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { - t.Fatalf("verify: %v\n%s", err, stdout.String()) - } - commitAllChangeTest(t, repo, "chore: commit clean-verified receipt") - - if got := changeShowState(t, repo, folderRel); got != "verified" { - t.Fatalf("committed-clean state = %q, want verified", got) - } - - // Dirty working-tree banned frontmatter must not demote the committed-clean member. - task := filepath.Join(dir, "tasks", "TASK-001-work.md") - if err := os.WriteFile(task, []byte("---\nchange: clean-verified\nid: TASK-001\ntitle: Work\nstatus: in-progress\n---\n\n# Work\n\n## Steps\n\n- [x] Do it\n"), 0o644); err != nil { - t.Fatalf("WriteFile dirty banned: %v", err) - } - if got := changeShowState(t, repo, folderRel); got != "verified" { - t.Fatalf("dirty banned frontmatter demoted state to %q, want verified", got) - } - checkOut := changeCheckFindings(t, repo, folderRel) - if !strings.Contains(checkOut, "banned") && !strings.Contains(checkOut, "status") { - t.Fatalf("check must still see the working-tree ban; got %q", checkOut) - } -} - -func TestChangeStateEvidenceSeesCommittedTasksWhenWorkingTreeDeletesTasks(t *testing.T) { - repo := initCLIGitRepo(t) - writeReleaseVersionFiles(t, repo, "1.0.0-alpha.1") - dir := writeNewLayoutChange(t, repo, "20260727-head-tasks", "head-tasks", "1.0.0", "") - flipExecuteChange(t, repo, dir, "head-tasks") - banned := filepath.Join(dir, "tasks", "TASK-002-later.md") - if err := os.WriteFile(banned, []byte("---\nchange: head-tasks\nid: TASK-002\ntitle: Later\nstatus: in-progress\n---\n\n# Later\n\n## Steps\n\n- [ ] Descoped\n"), 0o644); err != nil { - t.Fatalf("WriteFile banned: %v", err) - } - commitAllChangeTest(t, repo, "docs: commit banned task at HEAD") - folderRel := relFromRoot(repo, dir) - - // Evidence path must still see the committed banned task after a WT deletion. - if err := os.RemoveAll(filepath.Join(dir, "tasks")); err != nil { - t.Fatalf("RemoveAll tasks: %v", err) - } - if clean, _ := changeStructurallyCleanForState(repo, mustAssembleNode(t, repo, folderRel), commandOutput); clean { - t.Fatal("evidence path must still see committed banned frontmatter after WT task delete") - } - report, err := changeCohortStructuralReport(repo, mustAssembleHEADNode(t, repo, folderRel), mustLoadHEADNodes(t, repo), commandOutput) - if err != nil { - t.Fatalf("cohort structural: %v", err) - } - joined := strings.Join(report.Violations, "\n") - if !strings.Contains(joined, "banned") && !strings.Contains(joined, "status") { - t.Fatalf("gate composite must keep committed task findings; got %q", joined) - } -} - -func mustAssembleNode(t *testing.T, repo, folderRel string) changeNode { - t.Helper() - node, err := assembleChangeNodeFromFolder(repo, filepath.Join(repo, filepath.FromSlash(folderRel))) - if err != nil { - t.Fatalf("assemble: %v", err) - } - return node -} - -func mustLoadHEADNodes(t *testing.T, repo string) []changeNode { - t.Helper() - nodes, err := loadChangeNodesAtHEAD(repo) - if err != nil { - t.Fatalf("loadChangeNodesAtHEAD: %v", err) - } - return nodes -} - -func mustAssembleHEADNode(t *testing.T, repo, folderRel string) changeNode { - t.Helper() - nodes := mustLoadHEADNodes(t, repo) - node, ok := changeNodeForFolder(nodes, folderRel) - if !ok { - t.Fatalf("HEAD node for %s missing", folderRel) - } - return node -} - -func TestChangeShowListSurfaceStructuralLoadError(t *testing.T) { - repo := initCLIGitRepo(t) - writeReleaseVersionFiles(t, repo, "1.0.0-alpha.1") - - victim := writeNewLayoutChange(t, repo, "20260727-load-victim", "load-victim", "1.0.0", "") - flipExecuteChange(t, repo, victim, "load-victim") - victimRel := relFromRoot(repo, victim) - - other := writeNewLayoutChange(t, repo, "20260727-load-other", "load-other", "", "") - flipExecuteChange(t, repo, other, "load-other") - otherRel := relFromRoot(repo, other) - - var stdout bytes.Buffer - if err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "verify", victimRel}); err != nil { - t.Fatalf("verify victim: %v\n%s", err, stdout.String()) - } - commitAllChangeTest(t, repo, "chore: commit victim receipt") - - if got := changeShowState(t, repo, victimRel); got != "verified" { - t.Fatalf("victim state before fault = %q, want verified", got) - } - otherBefore := changeShowState(t, repo, otherRel) - if otherBefore == "verified" { - t.Fatalf("untargeted other must not be verified; got %q", otherBefore) - } - - old := changeEvidenceGitOutput - changeEvidenceGitOutput = func(cwd, name string, args ...string) (string, error) { - // Fault only the recursive docs/changes listing used to load HEAD nodes — - // not per-path ls-tree reads provenance uses for task pre-images. - if name == "git" && len(args) > 0 && args[0] == "ls-tree" { - recursive, docsChanges := false, false - for _, a := range args { - if a == "-r" { - recursive = true - } - if a == "docs/changes" { - docsChanges = true - } - } - if recursive && docsChanges { - return "", fmt.Errorf("read change.json: permission denied") - } - } - return commandOutput(cwd, name, args...) - } - defer func() { changeEvidenceGitOutput = old }() - - stdout.Reset() - if err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "show", victimRel}); err != nil { - t.Fatalf("show victim: %v\n%s", err, stdout.String()) - } - showOut := stripANSI(stdout.String()) - if !strings.Contains(showOut, "state:") { - t.Fatalf("show missing readable state line; got:\n%s", showOut) - } - if strings.Contains(showOut, "state: verified") || strings.Contains(showOut, "state: verified") { - t.Fatalf("victim must demote under structural load error; got:\n%s", showOut) - } - if !strings.Contains(showOut, "warn:") || !strings.Contains(showOut, "structural evaluation failed:") || !strings.Contains(showOut, "permission denied") { - t.Fatalf("show must surface structural load warning; got:\n%s", showOut) - } - - stdout.Reset() - if err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "show", victimRel, "--json"}); err != nil { - t.Fatalf("show json: %v\n%s", err, stdout.String()) - } - var show changeShowJSON - if err := json.Unmarshal(stdout.Bytes(), &show); err != nil { - t.Fatalf("Unmarshal show: %v", err) - } - if show.State == "verified" { - t.Fatalf("show JSON state = verified, want demoted") - } - if !findingsContain(show.Warnings, "structural evaluation failed:") { - t.Fatalf("show JSON warnings = %#v, want structural evaluation failed", show.Warnings) - } - - stdout.Reset() - if err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "list", "--json"}); err != nil { - t.Fatalf("list: %v\n%s", err, stdout.String()) - } - var list changeListUnitJSON - if err := json.Unmarshal(stdout.Bytes(), &list); err != nil { - t.Fatalf("Unmarshal list: %v", err) - } - var victimUnit, otherUnit *changeListUnit - for i := range list.Units { - switch list.Units[i].Slug { - case "load-victim": - victimUnit = &list.Units[i] - case "load-other": - otherUnit = &list.Units[i] - } - } - if victimUnit == nil || otherUnit == nil { - t.Fatalf("list units missing members: %#v", list.Units) - } - if victimUnit.State == "verified" { - t.Fatalf("list victim state = verified, want demoted") - } - if !findingsContain(victimUnit.Warnings, "structural evaluation failed:") { - t.Fatalf("list victim warnings = %#v", victimUnit.Warnings) - } - if !findingsContain(list.Warnings, "structural evaluation failed:") { - t.Fatalf("list top-level warnings = %#v", list.Warnings) - } - if otherUnit.State != otherBefore { - t.Fatalf("other member affected: state=%q, want %q", otherUnit.State, otherBefore) - } - if len(otherUnit.Warnings) != 0 { - t.Fatalf("other member warnings = %#v, want none", otherUnit.Warnings) - } - - stdout.Reset() - if err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "list"}); err != nil { - t.Fatalf("list plain: %v\n%s", err, stdout.String()) - } - plain := stripANSI(stdout.String()) - if !strings.Contains(plain, "warn:") || !strings.Contains(plain, "structural evaluation failed:") { - t.Fatalf("list plain must surface warn; got:\n%s", plain) - } -} - -// TASK-028: receipt check receives the HEAD node (content + folder). An -// uncommitted criteria edit or folder rename must not move the verified rung. -func TestChangeStateVerifiedRungIgnoresDirtyCriteriaAndRename(t *testing.T) { - repo := initCLIGitRepo(t) - writeReleaseVersionFiles(t, repo, "1.0.0-alpha.1") - dir := writeNewLayoutChange(t, repo, "20260727-head-receipt-node", "head-receipt-node", "1.0.0", "") - flipExecuteChange(t, repo, dir, "head-receipt-node") - folderRel := relFromRoot(repo, dir) - var stdout bytes.Buffer - if err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { - t.Fatalf("verify: %v\n%s", err, stdout.String()) - } - commitAllChangeTest(t, repo, "chore: commit verified receipt") - if got := changeShowState(t, repo, folderRel); got != "verified" { - t.Fatalf("baseline state = %q, want verified", got) - } - - // Dirty criteria on disk: working-tree shape would mismatch the receipt digest. - shapePath := filepath.Join(dir, "shape.md") - shape, err := os.ReadFile(shapePath) - if err != nil { - t.Fatalf("ReadFile shape: %v", err) - } - dirty := strings.Replace(string(shape), "Command: `true`", "Command: `false`", 1) - if dirty == string(shape) { - t.Fatal("fixture shape missing expected V1 command to dirty") - } - if err := os.WriteFile(shapePath, []byte(dirty), 0o644); err != nil { - t.Fatalf("WriteFile dirty shape: %v", err) - } - if got := changeShowState(t, repo, folderRel); got != "verified" { - t.Fatalf("dirty criteria demoted state to %q, want verified", got) - } - - // Restore shape; a working-tree folder that does not match HEAD must still - // resolve the receipt via the HEAD node's folder (slug fallback). - if err := os.WriteFile(shapePath, shape, 0o644); err != nil { - t.Fatalf("restore shape: %v", err) - } - wtNode := mustAssembleNode(t, repo, folderRel) - wtNode.Folder = "docs/changes/20260727-head-receipt-renamed" - wtNode.Content = strings.Replace(wtNode.Content, "Command: `true`", "Command: `false`", 1) - _, evidenceGit, pinErr := pinEvidenceAtHEAD(repo, commandOutput) - if pinErr != nil { - t.Fatalf("pin: %v", pinErr) - } - ok, clean, evalErr, _ := evaluateVerifiedRungAtCommit(repo, wtNode, evidenceGit) - if evalErr != "" || !ok || !clean { - t.Fatalf("renamed+dirty WT node: ok=%v clean=%v evalErr=%q, want verified rung", ok, clean, evalErr) - } -} - -// TASK-028: evidence derivation resolves HEAD once; subsequent evidence git args -// carry the pinned SHA, so a mid-derivation commit cannot split the inputs. -func TestChangeStateEvidencePinsHEADOnce(t *testing.T) { - repo := initCLIGitRepo(t) - writeReleaseVersionFiles(t, repo, "1.0.0-alpha.1") - dir := writeNewLayoutChange(t, repo, "20260727-pin-head", "pin-head", "1.0.0", "") - flipExecuteChange(t, repo, dir, "pin-head") - folderRel := relFromRoot(repo, dir) - var stdout bytes.Buffer - if err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { - t.Fatalf("verify: %v\n%s", err, stdout.String()) - } - commitAllChangeTest(t, repo, "chore: commit pin-head receipt") - - node := mustAssembleNode(t, repo, folderRel) - var revParseHEAD int - var pinnedSHA string - var postPinSymbolic int - seam := func(cwd, name string, args ...string) (string, error) { - if name == "git" && len(args) >= 2 && args[0] == "rev-parse" && args[1] == "HEAD" { - revParseHEAD++ - out, err := commandOutput(cwd, name, args...) - pinnedSHA = strings.TrimSpace(out) - return out, err - } - if pinnedSHA != "" { - for _, a := range args { - if a == "HEAD" || strings.HasPrefix(a, "HEAD:") || strings.HasSuffix(a, "..HEAD") || strings.HasPrefix(a, "HEAD..") { - postPinSymbolic++ - } - } - } - return commandOutput(cwd, name, args...) - } - state, warnings := deriveChangeStateDetailed(repo, node, seam) - if state != "verified" { - t.Fatalf("state = %q warnings=%v, want verified", state, warnings) - } - if revParseHEAD != 1 { - t.Fatalf("rev-parse HEAD count = %d, want exactly 1 pin", revParseHEAD) - } - if pinnedSHA == "" { - t.Fatal("pin did not capture a SHA") - } - if postPinSymbolic != 0 { - t.Fatalf("post-pin symbolic HEAD tokens = %d, want 0", postPinSymbolic) - } -} - -// TASK-028: gate preflight also pins HEAD once for the whole evidence derivation. -func TestReleaseCohortGatePinsHEADOnce(t *testing.T) { - repo := initCLIGitRepo(t) - writeReleaseVersionFiles(t, repo, "1.0.0-alpha.1") - dir := writeNewLayoutChange(t, repo, "20260727-gate-pin", "gate-pin", "1.0.0", "") - flipExecuteChange(t, repo, dir, "gate-pin") - folderRel := relFromRoot(repo, dir) - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { - t.Fatalf("verify: %v", err) - } - commitAllChangeTest(t, repo, "chore: commit gate-pin receipt") - - var revParseHEAD int - seam := func(cwd, name string, args ...string) (string, error) { - if name == "git" && len(args) >= 2 && args[0] == "rev-parse" && args[1] == "HEAD" { - revParseHEAD++ - } - return commandOutput(cwd, name, args...) - } - if err := releaseCohortPreflightWithOutput(repo, "1.0.0", seam, nil); err != nil { - t.Fatalf("gate: %v", err) - } - if revParseHEAD != 1 { - t.Fatalf("gate rev-parse HEAD count = %d, want exactly 1 pin", revParseHEAD) - } -} - -// TASK-029: a truncated committed receipt demotes conservatively and surfaces -// the load error on show, list, and check --json (same warning plumbing). -func TestChangeStateTruncatedReceiptSurfacesWarning(t *testing.T) { - repo := initCLIGitRepo(t) - writeReleaseVersionFiles(t, repo, "1.0.0-alpha.1") - dir := writeNewLayoutChange(t, repo, "20260727-trunc-receipt", "trunc-receipt", "1.0.0", "") - flipExecuteChange(t, repo, dir, "trunc-receipt") - folderRel := relFromRoot(repo, dir) - var stdout bytes.Buffer - if err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { - t.Fatalf("verify: %v\n%s", err, stdout.String()) - } - commitAllChangeTest(t, repo, "chore: commit valid receipt") - if got := changeShowState(t, repo, folderRel); got != "verified" { - t.Fatalf("baseline = %q, want verified", got) - } - - receiptPath := filepath.Join(dir, "receipts", "verify.json") - if err := os.WriteFile(receiptPath, []byte(`{"schema_version":1,"cri`), 0o644); err != nil { - t.Fatalf("WriteFile truncated receipt: %v", err) - } - commitAllChangeTest(t, repo, "chore: commit truncated receipt") - - stdout.Reset() - if err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "show", folderRel}); err != nil { - t.Fatalf("show: %v\n%s", err, stdout.String()) - } - showOut := stripANSI(stdout.String()) - if strings.Contains(showOut, "state: verified") || strings.Contains(showOut, "state: verified") { - t.Fatalf("truncated receipt must demote; got:\n%s", showOut) - } - if !strings.Contains(showOut, "warn:") || !strings.Contains(showOut, "receipt evaluation failed:") { - t.Fatalf("show must surface receipt warning; got:\n%s", showOut) - } - - stdout.Reset() - if err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "list", "--json"}); err != nil { - t.Fatalf("list: %v\n%s", err, stdout.String()) - } - var list changeListUnitJSON - if err := json.Unmarshal(stdout.Bytes(), &list); err != nil { - t.Fatalf("Unmarshal list: %v", err) - } - var unit *changeListUnit - for i := range list.Units { - if list.Units[i].Slug == "trunc-receipt" { - unit = &list.Units[i] - break - } - } - if unit == nil { - t.Fatalf("list missing trunc-receipt: %#v", list.Units) - } - if unit.State == "verified" { - t.Fatalf("list state = verified, want demoted") - } - if !findingsContain(unit.Warnings, "receipt evaluation failed:") { - t.Fatalf("list unit warnings = %#v", unit.Warnings) - } - - stdout.Reset() - if err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "check", folderRel, "--json"}); err != nil { - t.Fatalf("check: %v\n%s", err, stdout.String()) - } - var check changeCheckJSON - if err := json.Unmarshal(stdout.Bytes(), &check); err != nil { - t.Fatalf("Unmarshal check: %v", err) - } - if check.State == "verified" { - t.Fatalf("check JSON state = verified, want demoted") - } - if !findingsContain(check.Warnings, "receipt evaluation failed:") { - t.Fatalf("check JSON warnings = %#v, want receipt evaluation failed", check.Warnings) - } -} diff --git a/internal/cli/change_task_template.md b/internal/cli/change_task_template.md deleted file mode 100644 index 05de15307..000000000 --- a/internal/cli/change_task_template.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -change: [slug] -id: TASK-NNN -title: [short title] -# Relations (closed set; targets are TASK-NNN within this change only): -# parent: TASK-NNN -# blocks: -# - TASK-NNN -# blocked-by: -# - TASK-NNN -# relates-to: -# - TASK-NNN ---- - -# TASK-NNN — [Title] - -## Objective - -[What this task delivers when its checkboxes are flipped.] - -## Scope boundaries - -**In:** [What this task may touch.] - -**Out:** [What this task must not touch — other tasks, other changes, deferred work.] - -## Context pointers - -- Contract: `shape.md` — [relevant sections] -- Research: [paths under research/ when cited] - -## Acquisition - -```bash -loaf journal log "skill(implement): TASK-NNN — [short intent]" -# [commands or files to load before editing] -``` - -## Steps - -- [ ] [Atomic step — one commit's worth when flipped with the delivering work] -- [ ] [Next step] - -## Verification - -- [Commands or checks that prove this task alone] -- The slug never cites other work units (`TASK-…`, `SPEC-…`, issue keys) — identity is local; provenance is in frontmatter and the change folder. diff --git a/internal/cli/change_tasks.go b/internal/cli/change_tasks.go deleted file mode 100644 index e80a45866..000000000 --- a/internal/cli/change_tasks.go +++ /dev/null @@ -1,698 +0,0 @@ -package cli - -import ( - "fmt" - "io" - "os" - "path/filepath" - "regexp" - "sort" - "strings" -) - -var ( - changeTaskFileRE = regexp.MustCompile(`(?i)^TASK-(\d+)-([a-z0-9]+(?:-[a-z0-9]+)*)\.md$`) - changeTaskIDRE = regexp.MustCompile(`(?i)^TASK-(\d+)$`) - changeTaskCheckbox = regexp.MustCompile(`(?m)^[ \t]*- \[([ xX])\]`) -) - -var changeTaskAllowedKeys = map[string]bool{ - "change": true, - "id": true, - "title": true, - "parent": true, - "blocks": true, - "blocked-by": true, - "relates-to": true, -} - -var changeTaskBannedKeys = map[string]bool{ - "readiness": true, - "status": true, - "state": true, - "completion": true, - "done": true, - "assignee": true, - "estimate": true, - "priority": true, - "progress": true, - "lifecycle": true, -} - -type changeTaskRelationKind string - -const ( - changeTaskRelParent changeTaskRelationKind = "parent" - changeTaskRelBlocks changeTaskRelationKind = "blocks" - changeTaskRelBlockedBy changeTaskRelationKind = "blocked-by" - changeTaskRelRelates changeTaskRelationKind = "relates-to" -) - -type changeTask struct { - ID string `json:"id"` - Number int `json:"number"` - Title string `json:"title"` - File string `json:"file"` - Parent string `json:"parent,omitempty"` - Blocks []string `json:"blocks,omitempty"` - BlockedBy []string `json:"blockedBy,omitempty"` - RelatesTo []string `json:"relatesTo,omitempty"` - Children []string `json:"children,omitempty"` - BlockedByInv []string `json:"blockedByDerived,omitempty"` - BlocksInv []string `json:"blocksDerived,omitempty"` - Complete bool `json:"complete"` - CheckboxTotal int `json:"checkboxTotal"` - CheckboxDone int `json:"checkboxDone"` - Findings []string `json:"-"` - Warnings []string `json:"-"` -} - -type changeTasksJSON struct { - Command string `json:"command"` - Change string `json:"change"` - Folder string `json:"folder"` - Layout string `json:"layout"` - Tasks []changeTask `json:"tasks"` - Findings []string `json:"findings"` - Warnings []string `json:"warnings"` -} - -type changeShowJSON struct { - Command string `json:"command"` - Change string `json:"change"` - Folder string `json:"folder"` - Layout string `json:"layout"` - Branch string `json:"branch,omitempty"` - TargetRelease string `json:"targetRelease,omitempty"` - State string `json:"state"` - CapturedOnly bool `json:"capturedOnly"` - Executable bool `json:"executable"` - PRs []int `json:"prs"` - Findings []string `json:"findings"` - Warnings []string `json:"warnings"` -} - -func (r Runner) runChangeTasks(args []string, out io.Writer, rootPath string) error { - if isHelpArg(args) { - writeChangeTasksHelp(out) - return nil - } - path := "" - jsonOutput := false - for _, arg := range args { - switch { - case arg == "--json": - jsonOutput = true - case strings.HasPrefix(arg, "-"): - return fmt.Errorf("unknown change tasks option %q", arg) - case path != "": - return fmt.Errorf("change tasks accepts a single [folder] argument") - default: - path = arg - } - } - if !jsonOutput { - // Projection is JSON-first; text mode prints a compact index. - jsonOutput = false - } - folder, _, err := resolveChangeFolder(rootPath, path) - if err != nil { - return err - } - node, err := assembleChangeNodeFromFolder(rootPath, folder) - if err != nil { - return err - } - tasks, findings, warnings := loadChangeTasks(rootPath, folder, node, changeTaskContentWorkingTree, commandOutput) - result := changeTasksJSON{ - Command: "change tasks", - Change: node.Slug, - Folder: relFromRoot(rootPath, folder), - Layout: node.Layout, - Tasks: tasks, - Findings: findings, - Warnings: warnings, - } - if jsonOutput || true { - // Always emit JSON for the stable machine projection (V6). - _ = jsonOutput - return writeJSON(out, result) - } - return nil -} - -func writeChangeTasksHelp(out io.Writer) { - writeUsageHelp(out, "loaf change tasks [folder] [--json]", - "Project the stable-ID task index for a Change (parent/children, relations, derived completion). Always emits JSON.", - "[folder] Change folder path; resolves from the current branch when omitted", - "--json Explicit JSON (default)") -} - -func (r Runner) runChangeShow(args []string, out io.Writer, rootPath string) error { - if isHelpArg(args) { - writeChangeShowHelp(out) - return nil - } - path := "" - jsonOutput := false - for _, arg := range args { - switch { - case arg == "--json": - jsonOutput = true - case strings.HasPrefix(arg, "-"): - return fmt.Errorf("unknown change show option %q", arg) - case path != "": - return fmt.Errorf("change show accepts a single [folder] argument") - default: - path = arg - } - } - folder, _, err := resolveChangeFolder(rootPath, path) - if err != nil { - return err - } - node, err := assembleChangeNodeFromFolder(rootPath, folder) - if err != nil { - return err - } - report := evaluateChangeNode(node, currentChangeBranch(rootPath)) - _, findings, warnings := loadChangeTasks(rootPath, folder, node, changeTaskContentWorkingTree, commandOutput) - prs := deriveChangePRSet(rootPath, folder) - state, stateWarnings := deriveChangeStateDetailed(rootPath, node, changeEvidenceGitOutput) - result := changeShowJSON{ - Command: "change show", - Change: node.Slug, - Folder: relFromRoot(rootPath, folder), - Layout: node.Layout, - Branch: node.Branch, - TargetRelease: node.TargetRelease, - State: state, - CapturedOnly: node.CapturedOnly, - Executable: report.Executable, - PRs: prs, - Findings: append(append([]string{}, report.Violations...), findings...), - Warnings: append(append(append([]string{}, report.Warnings...), warnings...), stateWarnings...), - } - if node.CapturedOnly { - result.Warnings = append(result.Warnings, "captured, not shaped (brief-only)") - } - result.Findings = sortedUnique(result.Findings) - result.Warnings = sortedUnique(result.Warnings) - if jsonOutput { - return writeJSON(out, result) - } - fmt.Fprintf(out, "\n%s %s\n", ansiBold("change"), result.Change) - fmt.Fprintf(out, " folder: %s\n", result.Folder) - fmt.Fprintf(out, " layout: %s\n", result.Layout) - if result.Branch != "" { - fmt.Fprintf(out, " branch: %s\n", result.Branch) - } - if result.TargetRelease != "" { - fmt.Fprintf(out, " target: %s\n", result.TargetRelease) - } - fmt.Fprintf(out, " state: %s\n", result.State) - if len(result.PRs) == 0 { - fmt.Fprintf(out, " prs: (none derived from squash subjects)\n") - } else { - fmt.Fprintf(out, " prs: ") - for i, pr := range result.PRs { - if i > 0 { - fmt.Fprint(out, ", ") - } - fmt.Fprintf(out, "#%d", pr) - } - fmt.Fprintln(out) - } - for _, w := range result.Warnings { - fmt.Fprintf(out, " %s %s\n", ansiYellow("warn:"), w) - } - for _, f := range result.Findings { - fmt.Fprintf(out, " %s %s\n", ansiRed("x"), f) - } - return nil -} - -func writeChangeShowHelp(out io.Writer) { - writeUsageHelp(out, "loaf change show [folder] [--json]", - "Show a Change's derived view: layout, target, derived state ladder, and PR set from squash subjects (#N).", - "[folder] Change folder path; resolves from the current branch when omitted", - "--json Output as JSON") -} - -// changeTaskContentSource selects where structural task-file reads come from. -// check uses the working tree (author feedback); gate and verified-state use -// committed HEAD (evidence) — never a silent filesystem fallback on the evidence path. -type changeTaskContentSource int - -const ( - changeTaskContentWorkingTree changeTaskContentSource = iota - changeTaskContentHEAD -) - -func loadChangeTasks(rootPath, folderAbs string, node changeNode, source changeTaskContentSource, outputCommand changeGitOutput) ([]changeTask, []string, []string) { - if node.Layout != changeLayoutNew { - return nil, nil, nil - } - folderRel := relFromRoot(rootPath, folderAbs) - names, bodies, listFindings := listChangeTaskFileContents(rootPath, folderAbs, folderRel, source, outputCommand) - if listFindings != nil && len(names) == 0 { - return nil, listFindings, nil - } - byID := map[string]*changeTask{} - var findings []string - var warnings []string - findings = append(findings, listFindings...) - seenNumbers := map[int]string{} - for _, name := range names { - match := changeTaskFileRE.FindStringSubmatch(name) - if match == nil { - findings = append(findings, fmt.Sprintf("tasks/%s: filename must be TASK-NNN-slug.md", name)) - continue - } - num := 0 - fmt.Sscanf(match[1], "%d", &num) - id := fmt.Sprintf("TASK-%03d", num) - rel := filepath.ToSlash(filepath.Join(folderRel, "tasks", name)) - if prev, ok := seenNumbers[num]; ok { - findings = append(findings, fmt.Sprintf("duplicate task number %d: %s and %s", num, prev, name)) - } - seenNumbers[num] = name - - body, ok := bodies[name] - if !ok { - findings = append(findings, fmt.Sprintf("%s: missing content", rel)) - continue - } - task := parseChangeTaskFile(body, id, num, rel, node.Slug) - if task.CheckboxTotal == 0 { - task.Warnings = append(task.Warnings, fmt.Sprintf("%s: zero checkboxes (coordination parents still want one closing box)", rel)) - } - byID[id] = &task - findings = append(findings, task.Findings...) - warnings = append(warnings, task.Warnings...) - } - - // Derive inverses and validate relations. - for id, task := range byID { - if task.Parent != "" { - if task.Parent == id { - findings = append(findings, fmt.Sprintf("%s: parent cannot be self", task.File)) - } else if parent, ok := byID[task.Parent]; ok { - parent.Children = append(parent.Children, id) - } else if looksLikeExternalTaskRef(task.Parent) { - findings = append(findings, fmt.Sprintf("%s: cross-change relation parent %q forbidden", task.File, task.Parent)) - } else { - findings = append(findings, fmt.Sprintf("%s: dangling parent %q", task.File, task.Parent)) - } - } - for _, target := range task.Blocks { - if target == id { - findings = append(findings, fmt.Sprintf("%s: blocks cannot be self", task.File)) - continue - } - if other, ok := byID[target]; ok { - other.BlockedByInv = appendUniqueSorted(other.BlockedByInv, id) - } else if looksLikeExternalTaskRef(target) { - findings = append(findings, fmt.Sprintf("%s: cross-change relation blocks %q forbidden", task.File, target)) - } else { - findings = append(findings, fmt.Sprintf("%s: dangling blocks %q", task.File, target)) - } - } - for _, target := range task.BlockedBy { - if target == id { - findings = append(findings, fmt.Sprintf("%s: blocked-by cannot be self", task.File)) - continue - } - if other, ok := byID[target]; ok { - other.BlocksInv = appendUniqueSorted(other.BlocksInv, id) - } else if looksLikeExternalTaskRef(target) { - findings = append(findings, fmt.Sprintf("%s: cross-change relation blocked-by %q forbidden", task.File, target)) - } else { - findings = append(findings, fmt.Sprintf("%s: dangling blocked-by %q", task.File, target)) - } - } - for _, target := range task.RelatesTo { - if target == id { - findings = append(findings, fmt.Sprintf("%s: relates-to cannot be self", task.File)) - continue - } - if _, ok := byID[target]; !ok { - if looksLikeExternalTaskRef(target) { - findings = append(findings, fmt.Sprintf("%s: cross-change relation relates-to %q forbidden", task.File, target)) - } else { - findings = append(findings, fmt.Sprintf("%s: dangling relates-to %q", task.File, target)) - } - } - } - } - - // Parent-chain and blocking-graph cycles. - findings = append(findings, detectChangeTaskCycles(byID)...) - - tasks := make([]changeTask, 0, len(byID)) - for _, task := range byID { - task.Children = sortedUnique(task.Children) - task.BlockedByInv = sortedUnique(task.BlockedByInv) - task.BlocksInv = sortedUnique(task.BlocksInv) - tasks = append(tasks, *task) - } - sort.Slice(tasks, func(i, j int) bool { return tasks[i].Number < tasks[j].Number }) - return tasks, sortedUnique(findings), sortedUnique(warnings) -} - -func listChangeTaskFileContents(rootPath, folderAbs, folderRel string, source changeTaskContentSource, outputCommand changeGitOutput) (names []string, bodies map[string]string, findings []string) { - bodies = map[string]string{} - if source == changeTaskContentHEAD { - if outputCommand == nil { - outputCommand = commandOutput - } - tasksDir := filepath.ToSlash(filepath.Join(folderRel, "tasks")) - listOutput, err := outputCommand(rootPath, "git", "ls-tree", "-r", "--name-only", "HEAD", "--", tasksDir) - if err != nil { - return nil, nil, []string{fmt.Sprintf("read tasks/ at HEAD: %v", err)} - } - prefix := tasksDir + "/" - for _, path := range strings.Split(listOutput, "\n") { - path = filepath.ToSlash(strings.TrimSpace(path)) - if path == "" || !strings.HasPrefix(path, prefix) { - continue - } - relRest := strings.TrimPrefix(path, prefix) - if relRest == "" || strings.Contains(relRest, "/") || strings.HasPrefix(relRest, ".") { - continue - } - content, found, readErr := readCommittedOptional(rootPath, "HEAD", path, outputCommand) - if readErr != nil { - findings = append(findings, fmt.Sprintf("%s: %v", path, readErr)) - continue - } - if !found { - continue - } - names = append(names, relRest) - bodies[relRest] = content - } - sort.Strings(names) - return names, bodies, findings - } - - tasksDir := filepath.Join(folderAbs, "tasks") - entries, err := os.ReadDir(tasksDir) - if err != nil { - if os.IsNotExist(err) { - return nil, bodies, nil - } - return nil, nil, []string{fmt.Sprintf("read tasks/: %v", err)} - } - for _, entry := range entries { - if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { - continue - } - body, readErr := readRegularFile(filepath.Join(tasksDir, entry.Name()), projectFileReadLimit) - if readErr != nil { - findings = append(findings, fmt.Sprintf("%s: %v", filepath.ToSlash(filepath.Join(folderRel, "tasks", entry.Name())), readErr)) - continue - } - names = append(names, entry.Name()) - bodies[entry.Name()] = string(body) - } - sort.Strings(names) - return names, bodies, findings -} - -// parseChangeTaskFrontmatter accepts scalar key: value pairs and YAML sequence -// forms (key: followed by - item lines) used by task relation lists. -func parseChangeTaskFrontmatter(content string) ([]changeFrontmatterField, []string) { - normalized := strings.ReplaceAll(content, "\r\n", "\n") - if !strings.HasPrefix(normalized, "---\n") { - return nil, nil - } - lines := strings.Split(normalized, "\n") - end := -1 - for i := 1; i < len(lines); i++ { - if strings.TrimSpace(lines[i]) == "---" { - end = i - break - } - } - if end < 0 { - return nil, []string{"frontmatter is not closed with ---"} - } - var fields []changeFrontmatterField - var findings []string - i := 1 - for i < end { - line := lines[i] - trimmed := strings.TrimSpace(line) - i++ - if trimmed == "" || strings.HasPrefix(trimmed, "#") { - continue - } - if strings.HasPrefix(trimmed, "- ") { - findings = append(findings, fmt.Sprintf("malformed frontmatter line %d: list item without a key", i)) - continue - } - key, value, ok := strings.Cut(trimmed, ":") - if !ok { - findings = append(findings, fmt.Sprintf("malformed frontmatter line %d: expected key: value", i)) - continue - } - key = strings.TrimSpace(key) - value = strings.TrimSpace(value) - if key == "" { - findings = append(findings, fmt.Sprintf("malformed frontmatter line %d: key cannot be empty", i)) - continue - } - if value == "" { - var items []string - for i < end { - next := strings.TrimSpace(lines[i]) - if strings.HasPrefix(next, "- ") { - items = append(items, strings.TrimSpace(strings.TrimPrefix(next, "- "))) - i++ - continue - } - break - } - if len(items) > 0 { - value = strings.Join(items, ", ") - } - } - fields = append(fields, changeFrontmatterField{Key: key, Value: cleanChangeScalar(value)}) - } - return fields, findings -} - -func parseChangeTaskFile(content, id string, num int, rel, changeSlug string) changeTask { - task := changeTask{ID: id, Number: num, File: rel, Blocks: []string{}, BlockedBy: []string{}, RelatesTo: []string{}} - fields, findings := parseChangeTaskFrontmatter(content) - for _, finding := range findings { - task.Findings = append(task.Findings, fmt.Sprintf("%s: %s", rel, finding)) - } - seenKeys := map[string]bool{} - for _, field := range fields { - lower := strings.ToLower(field.Key) - if changeTaskBannedKeys[lower] { - task.Findings = append(task.Findings, - fmt.Sprintf("%s: status-like or tracker-parity task frontmatter key %q is banned", rel, field.Key)) - continue - } - if !changeTaskAllowedKeys[lower] { - task.Findings = append(task.Findings, - fmt.Sprintf("%s: unknown task frontmatter key %q; schema is closed", rel, field.Key)) - continue - } - seenKeys[lower] = true - switch lower { - case "change": - if field.Value != "" && field.Value != changeSlug { - task.Findings = append(task.Findings, - fmt.Sprintf("%s: change %q does not match owning change %q", rel, field.Value, changeSlug)) - } - case "id": - normalized := normalizeChangeTaskID(field.Value) - if normalized != "" && normalized != id { - task.Findings = append(task.Findings, - fmt.Sprintf("%s: id %q does not match filename %s", rel, field.Value, id)) - } - case "title": - task.Title = field.Value - case "parent": - task.Parent = normalizeChangeTaskID(field.Value) - case "blocks": - task.Blocks = appendUniqueSorted(task.Blocks, parseChangeTaskIDList(field.Value)...) - case "blocked-by": - task.BlockedBy = appendUniqueSorted(task.BlockedBy, parseChangeTaskIDList(field.Value)...) - case "relates-to": - task.RelatesTo = appendUniqueSorted(task.RelatesTo, parseChangeTaskIDList(field.Value)...) - } - } - if task.Title == "" { - // Fall back to first H1. - for _, line := range strings.Split(content, "\n") { - if strings.HasPrefix(strings.TrimSpace(line), "# ") { - task.Title = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "# ")) - break - } - } - } - body := content - if idx := strings.Index(content, "\n---"); idx >= 0 && strings.HasPrefix(content, "---") { - rest := content[idx+4:] - if end := strings.Index(rest, "\n---"); end >= 0 { - body = rest[end+4:] - } - } - // Strip fenced code blocks before counting checkboxes. - body = stripMarkdownCodeFences(body) - matches := changeTaskCheckbox.FindAllStringSubmatch(body, -1) - task.CheckboxTotal = len(matches) - for _, m := range matches { - if strings.EqualFold(m[1], "x") { - task.CheckboxDone++ - } - } - task.Complete = task.CheckboxTotal > 0 && task.CheckboxDone == task.CheckboxTotal - _ = seenKeys - return task -} - -func normalizeChangeTaskID(value string) string { - value = strings.TrimSpace(value) - match := changeTaskIDRE.FindStringSubmatch(value) - if match == nil { - return value - } - num := 0 - fmt.Sscanf(match[1], "%d", &num) - return fmt.Sprintf("TASK-%03d", num) -} - -func parseChangeTaskIDList(value string) []string { - value = strings.TrimSpace(value) - if value == "" { - return nil - } - // Support YAML inline list "[TASK-001, TASK-002]" or comma/space separated. - value = strings.Trim(value, "[]") - parts := strings.FieldsFunc(value, func(r rune) bool { - return r == ',' || r == ' ' || r == '\n' || r == '\t' - }) - out := make([]string, 0, len(parts)) - for _, part := range parts { - part = strings.Trim(part, `"'`) - if part == "" || part == "-" { - continue - } - out = append(out, normalizeChangeTaskID(part)) - } - return out -} - -func looksLikeExternalTaskRef(value string) bool { - lower := strings.ToLower(value) - return strings.Contains(lower, "/") || strings.Contains(lower, "spec-") || - strings.HasPrefix(lower, "chg-") || strings.Contains(lower, "#") -} - -func detectChangeTaskCycles(byID map[string]*changeTask) []string { - var findings []string - // Parent chain cycles. - for start := range byID { - seen := map[string]bool{} - cur := start - for cur != "" { - if seen[cur] { - findings = append(findings, fmt.Sprintf("parent-chain cycle involving %s", start)) - break - } - seen[cur] = true - next := byID[cur] - if next == nil { - break - } - cur = next.Parent - } - } - // Blocking graph cycles (blocks edges). - adj := map[string][]string{} - for id, task := range byID { - adj[id] = append(adj[id], task.Blocks...) - for _, blocker := range task.BlockedBy { - adj[blocker] = append(adj[blocker], id) - } - } - state := map[string]int{} // 0=unseen 1=stack 2=done - var visit func(string) bool - visit = func(id string) bool { - state[id] = 1 - for _, next := range adj[id] { - if _, ok := byID[next]; !ok { - continue - } - switch state[next] { - case 1: - return true - case 0: - if visit(next) { - return true - } - } - } - state[id] = 2 - return false - } - for id := range byID { - if state[id] == 0 && visit(id) { - findings = append(findings, fmt.Sprintf("blocking-graph cycle involving %s", id)) - break - } - } - return findings -} - -func stripMarkdownCodeFences(body string) string { - lines := strings.Split(body, "\n") - var out []string - inFence := false - for _, line := range lines { - trim := strings.TrimSpace(line) - if strings.HasPrefix(trim, "```") { - inFence = !inFence - continue - } - if !inFence { - out = append(out, line) - } - } - return strings.Join(out, "\n") -} - -func appendUniqueSorted(list []string, values ...string) []string { - list = append(list, values...) - return sortedUnique(list) -} - -var changePRSubjectRE = regexp.MustCompile(`\(#(\d+)\)`) - -func deriveChangePRSet(rootPath, folderAbs string) []int { - folderRel := filepath.ToSlash(relFromRoot(rootPath, folderAbs)) - output, err := commandOutput(rootPath, "git", "log", "--format=%s", "--", folderRel) - if err != nil { - return nil - } - seen := map[int]bool{} - var prs []int - for _, line := range strings.Split(output, "\n") { - for _, match := range changePRSubjectRE.FindAllStringSubmatch(line, -1) { - n := 0 - fmt.Sscanf(match[1], "%d", &n) - if n > 0 && !seen[n] { - seen[n] = true - prs = append(prs, n) - } - } - } - sort.Ints(prs) - return prs -} diff --git a/internal/cli/change_tasks_test.go b/internal/cli/change_tasks_test.go deleted file mode 100644 index 0850dd31b..000000000 --- a/internal/cli/change_tasks_test.go +++ /dev/null @@ -1,227 +0,0 @@ -package cli - -import ( - "bytes" - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -func TestChangeCheckLegacyEmitsDeprecationNotice(t *testing.T) { - repo := initCLIGitRepo(t) - folder := writeChangeFolder(t, repo, "20260710-legacy-demo", executableLineageDoc("legacy-demo", "line", "", "")) - out, err := runChangeCheckJSON(t, repo, folder) - if err != nil { - t.Fatalf("err = %v out=%+v", err, out) - } - if out.Layout != "legacy" { - t.Fatalf("layout = %q, want legacy", out.Layout) - } - if !findingsContain(out.Notices, "Removal boundary") { - t.Fatalf("notices = %v, want removal-boundary deprecation", out.Notices) - } -} - -func TestChangeCheckBriefOnlyReportedAsCaptured(t *testing.T) { - repo := initCLIGitRepo(t) - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "init", "brief-only", "--brief"}); err != nil { - t.Fatalf("init --brief: %v", err) - } - today := time.Now().Format("20060102") - folder := filepath.Join(repo, "docs", "changes", today+"-brief-only") - out, err := runChangeCheckJSON(t, repo, folder) - if err != nil { - t.Fatalf("err = %v out=%+v", err, out) - } - if !out.Captured { - t.Fatalf("captured = false, want true") - } - if out.Executable { - t.Fatalf("executable = true, want false") - } - if !findingsContain(out.Warnings, "captured, not shaped") { - t.Fatalf("warnings = %v, want captured warning", out.Warnings) - } -} - -func TestChangeTasksJSONStableIndex(t *testing.T) { - repo := initCLIGitRepo(t) - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "init", "task-index"}); err != nil { - t.Fatalf("init: %v", err) - } - today := time.Now().Format("20060102") - folder := filepath.Join(repo, "docs", "changes", today+"-task-index") - tasksDir := filepath.Join(folder, "tasks") - if err := os.Remove(filepath.Join(tasksDir, changeSeedTaskFile)); err != nil { - t.Fatalf("remove seed packet: %v", err) - } - writeTask := func(name, body string) { - t.Helper() - if err := os.WriteFile(filepath.Join(tasksDir, name), []byte(body), 0o644); err != nil { - t.Fatalf("WriteFile: %v", err) - } - } - writeTask("TASK-001-parent.md", `--- -change: task-index -id: TASK-001 -title: Parent ---- - -# TASK-001 — Parent - -## Steps - -- [ ] Close when children done -`) - writeTask("TASK-002-child.md", `--- -change: task-index -id: TASK-002 -title: Child -parent: TASK-001 -blocks: - - TASK-003 ---- - -# TASK-002 — Child - -## Steps - -- [x] Done -`) - writeTask("TASK-003-blocked.md", `--- -change: task-index -id: TASK-003 -title: Blocked -blocked-by: - - TASK-002 ---- - -# TASK-003 — Blocked - -## Steps - -- [ ] Waiting -`) - - var stdout bytes.Buffer - if err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "tasks", folder, "--json"}); err != nil { - t.Fatalf("tasks: %v", err) - } - var result changeTasksJSON - if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { - t.Fatalf("Unmarshal: %v\n%s", err, stdout.String()) - } - if result.Change != "task-index" || len(result.Tasks) != 3 { - t.Fatalf("result = %+v", result) - } - byID := map[string]changeTask{} - for _, task := range result.Tasks { - byID[task.ID] = task - } - if byID["TASK-001"].Children[0] != "TASK-002" { - t.Fatalf("parent children = %v", byID["TASK-001"].Children) - } - if !byID["TASK-002"].Complete { - t.Fatalf("TASK-002 should be complete") - } - if len(result.Findings) != 0 { - t.Fatalf("findings = %v", result.Findings) - } -} - -func TestChangeTaskHygieneViolations(t *testing.T) { - repo := initCLIGitRepo(t) - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "init", "hygiene"}); err != nil { - t.Fatalf("init: %v", err) - } - today := time.Now().Format("20060102") - folder := filepath.Join(repo, "docs", "changes", today+"-hygiene") - tasksDir := filepath.Join(folder, "tasks") - if err := os.Remove(filepath.Join(tasksDir, changeSeedTaskFile)); err != nil { - t.Fatalf("remove seed packet: %v", err) - } - body := `--- -change: hygiene -id: TASK-001 -title: Bad -parent: TASK-001 -status: done -unknown: x -relates-to: other-change/TASK-002 ---- - -# Bad -` - if err := os.WriteFile(filepath.Join(tasksDir, "TASK-001-bad.md"), []byte(body), 0o644); err != nil { - t.Fatalf("WriteFile: %v", err) - } - out, err := runChangeCheckJSON(t, repo, folder) - if err == nil { - t.Fatalf("want violations, got pass: %+v", out) - } - for _, want := range []string{"parent cannot be self", "banned", "unknown task frontmatter", "cross-change", "zero checkboxes"} { - if !findingsContain(out.Findings, want) && !findingsContain(out.Warnings, want) { - t.Fatalf("missing %q in findings=%v warnings=%v", want, out.Findings, out.Warnings) - } - } -} - -func TestChangeShowDerivesPRSet(t *testing.T) { - repo := initCLIGitRepo(t) - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "init", "show-demo"}); err != nil { - t.Fatalf("init: %v", err) - } - today := time.Now().Format("20060102") - folderRel := filepath.Join("docs", "changes", today+"-show-demo") - gitCLI(t, repo, "add", ".") - gitCLI(t, repo, "-c", "user.name=Loaf Test", "-c", "user.email=loaf@example.test", - "commit", "-m", "feat: land show demo (#141)") - // Touch again under another PR subject. - if err := os.WriteFile(filepath.Join(repo, folderRel, "shape.md"), - append([]byte("\n"), mustRead(t, filepath.Join(repo, folderRel, "shape.md"))...), 0o644); err != nil { - t.Fatalf("WriteFile: %v", err) - } - gitCLI(t, repo, "add", ".") - gitCLI(t, repo, "-c", "user.name=Loaf Test", "-c", "user.email=loaf@example.test", - "commit", "-m", "fix: tweak shape (#142)") - - var stdout bytes.Buffer - if err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "show", folderRel, "--json"}); err != nil { - t.Fatalf("show: %v\n%s", err, stdout.String()) - } - var result changeShowJSON - if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { - t.Fatalf("Unmarshal: %v", err) - } - if len(result.PRs) != 2 || result.PRs[0] != 141 || result.PRs[1] != 142 { - t.Fatalf("prs = %v, want [141 142]", result.PRs) - } -} - -func mustRead(t *testing.T, path string) []byte { - t.Helper() - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("ReadFile: %v", err) - } - return data -} - -func TestChangeCheckMalformedJSONNoFallback(t *testing.T) { - repo := initCLIGitRepo(t) - folder := writeChangeFolder(t, repo, "20260710-keep-both", executableLineageDoc("keep-both", "line", "", "")) - if err := os.WriteFile(filepath.Join(folder, "change.json"), []byte(`{broken`), 0o644); err != nil { - t.Fatalf("WriteFile: %v", err) - } - out, err := runChangeCheckJSON(t, repo, folder) - if err == nil { - t.Fatalf("want violation for malformed JSON, got %+v", out) - } - joined := strings.Join(out.Findings, "\n") - if !strings.Contains(joined, "malformed change.json") { - t.Fatalf("findings = %v, want malformed change.json", out.Findings) - } -} diff --git a/internal/cli/change_template.md b/internal/cli/change_template.md deleted file mode 100644 index 6ce5b9725..000000000 --- a/internal/cli/change_template.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -change: [slug] -created: [YYYY-MM-DD] -branch: [slug] ---- - - - -# [Change Title] - -## Problem - -[Why this work exists — the friction, gap, or rot being addressed.] - -## Hypothesis - -[The bet: what becomes true if this ships, and why it is worth making.] - -## Scope - -**In** - -- [What this Change delivers.] - -**Out** (deferred, not rejected) - -- [What is explicitly postponed, and where it went.] - -**Cut** (explicitly rejected) - -- [What this Change will not do, ever.] - -## Observable Workflow - -[What someone sees or does once this ships — commands, flows, UX. Concrete over abstract.] - -## Rabbit Holes and No-Gos - -[Boundaries: the ways this work could quietly grow into something it must not become.] - -## Decisions - -Provenance: [how each decision was accepted — interview, review, dogfooding.] - -1. **[Decision.]** [Rationale, and what it forecloses.] - -## Planning Contract - -[The HOW. Free-form `###` subsections named by the work — the container is the contract; the subsection names are yours.] - -### [Approach / Placement / Risks / Sequencing / Spike findings …] - -[...] - -## Implementation Units - - - -- [**U1 — Unit name.** What it delivers.] - -## Verification Contract - - - -- [**V1.** Criterion bound to a command and an expected result.] - - - -- [**H1.** What a reviewer confirms that no command can.] - -## Definition of Done - -- [Derived from gates and review — never a status flag.] - -## Durable Outputs - -[Specs, ADRs, knowledge docs, or schema updates to create after implementation proves what is now true. A final spec describes reality, not a plan.] - -## Open Questions - - - -- [Known unknowns, each owned by a section, a spike, or a follow-up.] - -## Source Inputs - -- [Where this Change came from: journal entries (cite by ID), sparks, ideas, brainstorms, issues, conversations, prior Changes.] - - diff --git a/internal/cli/change_test.go b/internal/cli/change_test.go deleted file mode 100644 index b881ba780..000000000 --- a/internal/cli/change_test.go +++ /dev/null @@ -1,1920 +0,0 @@ -package cli - -import ( - "bytes" - "context" - "database/sql" - "encoding/json" - "errors" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/levifig/loaf/internal/project" - "github.com/levifig/loaf/internal/state" -) - -// changeDoc assembles a change.md body from frontmatter and section blocks so -// each test can isolate exactly one Verification-Contract clause. -func changeDoc(frontmatter string, sections ...string) string { - var b strings.Builder - b.WriteString(frontmatter) - b.WriteString("\n# Title\n\n") - for _, section := range sections { - b.WriteString(section) - b.WriteString("\n\n") - } - return b.String() -} - -func changeFrontmatter(change, created, branch string) string { - return strings.Join([]string{ - "---", - "change: " + change, - "created: " + created, - "branch: " + branch, - "---", - }, "\n") -} - -func lineageFrontmatter(change, created, branch, lineage, predecessor, releaseAfter string) string { - lines := []string{"---", "change: " + change, "created: " + created, "branch: " + branch, "lineage: " + lineage} - if predecessor != "" { - lines = append(lines, "predecessor: "+predecessor) - } - if releaseAfter != "" { - lines = append(lines, "release-after: "+releaseAfter) - } - return strings.Join(append(lines, "---"), "\n") -} - -// productSections returns the five required Product Contract H2s, each with a -// line of body so they read as present and non-empty. -func productSections() []string { - return []string{ - "## Problem\n\nThe friction.", - "## Hypothesis\n\nThe bet.", - "## Scope\n\nIn and out.", - "## Observable Workflow\n\nWhat ships.", - "## Rabbit Holes and No-Gos\n\nBoundaries.", - } -} - -// executableSections returns the four sections that drive derived executability. -func executableSections() []string { - return []string{ - "## Planning Contract\n\n### Approach\n\nHow.", - "## Implementation Units\n\n- U1 — do the thing.", - "## Verification Contract\n\n- V1. command exits non-zero.", - "## Definition of Done\n\n- Gates pass.", - } -} - -// writeChangeFolder materializes docs/changes//change.md under root and -// returns the absolute folder path. -func writeChangeFolder(t *testing.T, root, folder, content string) string { - t.Helper() - dir := filepath.Join(root, "docs", "changes", folder) - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatalf("MkdirAll(%s) error = %v", dir, err) - } - if err := os.WriteFile(filepath.Join(dir, "change.md"), []byte(content), 0o644); err != nil { - t.Fatalf("WriteFile(change.md) error = %v", err) - } - return dir -} - -func runChangeCheckJSON(t *testing.T, repo string, args ...string) (changeCheckJSON, error) { - t.Helper() - var stdout bytes.Buffer - runArgs := append([]string{"change", "check"}, args...) - runArgs = append(runArgs, "--json") - err := Runner{Stdout: &stdout, WorkingDir: repo}.Run(runArgs) - var out changeCheckJSON - if decodeErr := json.Unmarshal(stdout.Bytes(), &out); decodeErr != nil { - t.Fatalf("Unmarshal(%q) error = %v", stdout.String(), decodeErr) - } - return out, err -} - -func executableLineageDoc(slug, lineage, predecessor, releaseAfter string) string { - sections := append(productSections(), executableSections()...) - return changeDoc(lineageFrontmatter(slug, "2026-07-10", slug, lineage, predecessor, releaseAfter), sections...) -} - -func commitAllChangeTest(t *testing.T, repo, message string) { - t.Helper() - gitCLI(t, repo, "add", ".") - gitCLI(t, repo, "-c", "user.name=Loaf Test", "-c", "user.email=loaf@example.test", "commit", "-m", message) -} - -func TestChangeLineageValidation(t *testing.T) { - cases := []struct { - name string - setup func(t *testing.T, repo string) string - want string - }{ - {"duplicate-slug", func(t *testing.T, repo string) string { - writeChangeFolder(t, repo, "20260710-root", executableLineageDoc("root", "line", "", "")) - return writeChangeFolder(t, repo, "20260711-root", strings.Replace(executableLineageDoc("root", "line", "", ""), "created: 2026-07-10", "created: 2026-07-11", 1)) - }, "duplicate Change slug"}, - {"multiple-roots", func(t *testing.T, repo string) string { - writeChangeFolder(t, repo, "20260710-root", executableLineageDoc("root", "line", "", "")) - return writeChangeFolder(t, repo, "20260710-other", executableLineageDoc("other", "line", "", "")) - }, "multiple roots"}, - {"self-reference", func(t *testing.T, repo string) string { - return writeChangeFolder(t, repo, "20260710-root", executableLineageDoc("root", "line", "root", "")) - }, "cannot name itself"}, - {"missing-predecessor", func(t *testing.T, repo string) string { - return writeChangeFolder(t, repo, "20260710-child", executableLineageDoc("child", "line", "missing", "")) - }, "predecessor \"missing\" is not materialized"}, - {"lineage-mismatch", func(t *testing.T, repo string) string { - writeChangeFolder(t, repo, "20260710-root", executableLineageDoc("root", "other", "", "")) - return writeChangeFolder(t, repo, "20260710-next", executableLineageDoc("next", "line", "root", "")) - }, "has lineage \"other\", want \"line\""}, - {"cycle", func(t *testing.T, repo string) string { - writeChangeFolder(t, repo, "20260710-root", executableLineageDoc("root", "line", "next", "")) - return writeChangeFolder(t, repo, "20260710-next", executableLineageDoc("next", "line", "root", "")) - }, "predecessor cycle"}, - {"longer-cycle", func(t *testing.T, repo string) string { - writeChangeFolder(t, repo, "20260710-one", executableLineageDoc("one", "line", "three", "")) - writeChangeFolder(t, repo, "20260710-two", executableLineageDoc("two", "line", "one", "")) - return writeChangeFolder(t, repo, "20260710-three", executableLineageDoc("three", "line", "two", "")) - }, "predecessor cycle"}, - {"duplicate-lineage-key", func(t *testing.T, repo string) string { - doc := strings.Replace(executableLineageDoc("root", "line", "", ""), "lineage: line", "lineage: line\nlineage: other", 1) - return writeChangeFolder(t, repo, "20260710-root", doc) - }, "duplicate frontmatter field \"lineage\""}, - {"dependency-without-lineage", func(t *testing.T, repo string) string { - doc := strings.Replace(executableLineageDoc("child", "line", "root", ""), "lineage: line\n", "", 1) - return writeChangeFolder(t, repo, "20260710-child", doc) - }, "predecessor and release-after require lineage"}, - {"multiple-children", func(t *testing.T, repo string) string { - writeChangeFolder(t, repo, "20260710-root", executableLineageDoc("root", "line", "", "")) - writeChangeFolder(t, repo, "20260710-left", executableLineageDoc("left", "line", "root", "")) - return writeChangeFolder(t, repo, "20260710-right", executableLineageDoc("right", "line", "root", "")) - }, "multiple materialized children"}, - {"conflicting-release-after", func(t *testing.T, repo string) string { - writeChangeFolder(t, repo, "20260710-root", executableLineageDoc("root", "line", "", "terminal")) - return writeChangeFolder(t, repo, "20260710-terminal", executableLineageDoc("terminal", "line", "root", "root")) - }, "conflicting release-after terminals"}, - {"release-after-not-terminal", func(t *testing.T, repo string) string { - writeChangeFolder(t, repo, "20260710-root", executableLineageDoc("root", "line", "", "root")) - return writeChangeFolder(t, repo, "20260710-child", executableLineageDoc("child", "line", "root", "")) - }, "release-after \"root\" is not the lineage terminal"}, - {"release-after-on-non-root", func(t *testing.T, repo string) string { - writeChangeFolder(t, repo, "20260710-root", executableLineageDoc("root", "line", "", "child")) - return writeChangeFolder(t, repo, "20260710-child", executableLineageDoc("child", "line", "root", "child")) - }, "root \"root\" must own the declaration"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - repo := initCLIGitRepo(t) - folder := tc.setup(t, repo) - out, err := runChangeCheckJSON(t, repo, folder) - all := append(append(append([]string{}, out.Findings...), out.Warnings...), out.Gaps...) - if (tc.name != "missing-predecessor" && err == nil) || !findingsContain(all, tc.want) { - t.Fatalf("err = %v findings = %v warnings = %v, want %q", err, out.Findings, out.Warnings, tc.want) - } - }) - } -} - -func TestChangeCheckRejectsMixedCaseDuplicateIdentityAndLineageFields(t *testing.T) { - cases := []struct { - key string - replacement string - }{ - {key: "change", replacement: "change: root\nChAnGe: root"}, - {key: "created", replacement: "created: 2026-07-10\nCrEaTeD: 2026-07-10"}, - {key: "lineage", replacement: "lineage: line\nLiNeAgE: line"}, - {key: "predecessor", replacement: "predecessor: prior\nPrEdEcEsSoR: prior"}, - {key: "release-after", replacement: "release-after: terminal\nReLeAsE-AfTeR: terminal"}, - } - for _, tc := range cases { - t.Run(tc.key, func(t *testing.T) { - repo := initCLIGitRepo(t) - doc := executableLineageDoc("root", "line", "prior", "terminal") - original := map[string]string{ - "change": "change: root", - "created": "created: 2026-07-10", - "lineage": "lineage: line", - "predecessor": "predecessor: prior", - "release-after": "release-after: terminal", - }[tc.key] - doc = strings.Replace(doc, original, tc.replacement, 1) - folder := writeChangeFolder(t, repo, "20260710-root", doc) - out, err := runChangeCheckJSON(t, repo, folder) - want := "duplicate frontmatter field \"" + tc.key + "\"" - if err == nil || !findingsContain(out.Findings, want) || !findingsContain(out.Findings, "docs/changes/20260710-root/change.md:") { - t.Fatalf("err = %v findings = %v, want repo-relative %q", err, out.Findings, want) - } - }) - } -} - -func TestChangeCheckRejectsMalformedAndUnclosedFrontmatter(t *testing.T) { - cases := []struct { - name string - doc func() string - want string - }{ - {name: "lineage-without-colon", doc: func() string { - return strings.Replace(executableLineageDoc("root", "line", "", ""), "lineage: line", "lineage line", 1) - }, want: "malformed frontmatter line"}, - {name: "predecessor-without-colon", doc: func() string { - return strings.Replace(executableLineageDoc("child", "line", "root", ""), "predecessor: root", "predecessor root", 1) - }, want: "malformed frontmatter line"}, - {name: "empty-key", doc: func() string { - return strings.Replace(executableLineageDoc("root", "line", "", ""), "lineage: line", "lineage: line\n: value", 1) - }, want: "key cannot be empty"}, - {name: "unclosed", doc: func() string { - return strings.Replace(executableLineageDoc("root", "line", "", ""), "\n---\n# Title", "\n# Title", 1) - }, want: "frontmatter is not closed"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - repo := initCLIGitRepo(t) - folder := writeChangeFolder(t, repo, "20260710-root", tc.doc()) - out, err := runChangeCheckJSON(t, repo, folder) - if err == nil || !findingsContain(out.Findings, tc.want) || !findingsContain(out.Findings, "docs/changes/20260710-root/change.md:") { - t.Fatalf("err = %v findings = %v, want repo-relative %q", err, out.Findings, tc.want) - } - }) - } -} - -func TestChangeCheckDoesNotLeakUnrelatedLineageFindings(t *testing.T) { - repo := initCLIGitRepo(t) - good := writeChangeFolder(t, repo, "20260710-good", executableLineageDoc("good", "good-line", "", "")) - badOne := writeChangeFolder(t, repo, "20260710-bad-one", executableLineageDoc("bad-one", "bad-line", "bad-two", "")) - writeChangeFolder(t, repo, "20260710-bad-two", executableLineageDoc("bad-two", "bad-line", "bad-one", "")) - goodOut, err := runChangeCheckJSON(t, repo, good) - if err != nil || !goodOut.Passed || findingsContain(goodOut.Findings, "predecessor cycle") { - t.Fatalf("good lineage err = %v out = %+v", err, goodOut) - } - badOut, err := runChangeCheckJSON(t, repo, badOne) - if err == nil || !findingsContain(badOut.Findings, "predecessor cycle") { - t.Fatalf("bad lineage err = %v out = %+v", err, badOut) - } -} - -func TestChangeCheckIncludesLocalFindingsFromItsWholeLineage(t *testing.T) { - repo := initCLIGitRepo(t) - root := writeChangeFolder(t, repo, "20260710-root", executableLineageDoc("root", "line", "", "child")) - malformedChild := strings.Replace(executableLineageDoc("child", "line", "root", ""), "\n---\n# Title", "\nmalformed child frontmatter\n---\n# Title", 1) - writeChangeFolder(t, repo, "20260710-child", malformedChild) - - out, err := runChangeCheckJSON(t, repo, root) - if err == nil || !findingsContain(out.Findings, "docs/changes/20260710-child/change.md: malformed frontmatter") { - t.Fatalf("same-lineage local finding was omitted: err = %v out = %+v", err, out) - } -} - -func TestChangeCheckDoesNotTreatUnlineagedMalformedFileAsGlobal(t *testing.T) { - repo := initCLIGitRepo(t) - good := writeChangeFolder(t, repo, "20260710-good", executableLineageDoc("good", "good-line", "", "good")) - malformed := strings.Replace(changeDoc(changeFrontmatter("legacy", "2026-07-10", "legacy"), productSections()...), "\n---\n# Title", "\nmalformed legacy frontmatter\n---\n# Title", 1) - legacy := writeChangeFolder(t, repo, "20260710-legacy", malformed) - goodOut, err := runChangeCheckJSON(t, repo, good) - if err != nil || !goodOut.Passed || !goodOut.Executable || len(goodOut.Findings) != 0 { - t.Fatalf("unrelated unlineaged file blocked good lineage: err = %v out = %+v", err, goodOut) - } - legacyOut, err := runChangeCheckJSON(t, repo, legacy) - if err == nil || !findingsContain(legacyOut.Findings, "malformed frontmatter") { - t.Fatalf("malformed file should fail its own check: err = %v out = %+v", err, legacyOut) - } -} - -func TestChangeCheckScopesUnlineagedFindingsToTheirChange(t *testing.T) { - repo := initCLIGitRepo(t) - sections := append(productSections(), executableSections()...) - good := writeChangeFolder(t, repo, "20260710-good", changeDoc(changeFrontmatter("good", "2026-07-10", "good"), sections...)) - malformed := strings.Replace(changeDoc(changeFrontmatter("legacy", "2026-07-10", "legacy"), productSections()...), "\n---\n# Title", "\nmalformed legacy frontmatter\n---\n# Title", 1) - legacy := writeChangeFolder(t, repo, "20260710-legacy", malformed) - - goodOut, err := runChangeCheckJSON(t, repo, good, "--require-executable") - if err != nil || !goodOut.Passed || !goodOut.Executable || len(goodOut.Findings) != 0 { - t.Fatalf("unrelated unlineaged file blocked valid unlineaged Change: err = %v out = %+v", err, goodOut) - } - legacyOut, err := runChangeCheckJSON(t, repo, legacy) - if err == nil || !findingsContain(legacyOut.Findings, "malformed frontmatter") { - t.Fatalf("malformed unlineaged Change should fail its own check: err = %v out = %+v", err, legacyOut) - } -} - -func TestChangeCheckIncludesGlobalFindingsForEveryLineage(t *testing.T) { - repo := initCLIGitRepo(t) - good := writeChangeFolder(t, repo, "20260710-good", executableLineageDoc("good", "good-line", "", "")) - writeChangeFolder(t, repo, "20260710-duplicate", executableLineageDoc("duplicate", "first-line", "", "")) - duplicate := strings.Replace(executableLineageDoc("duplicate", "second-line", "", ""), "created: 2026-07-10", "created: 2026-07-11", 1) - writeChangeFolder(t, repo, "20260711-duplicate", duplicate) - out, err := runChangeCheckJSON(t, repo, good) - if err == nil || !findingsContain(out.Findings, "duplicate Change slug") { - t.Fatalf("global finding was omitted: err = %v out = %+v", err, out) - } -} - -func TestChangeInitRejectsSlugExistingOnAnotherDate(t *testing.T) { - repo := initCLIGitRepo(t) - writeChangeFolder(t, repo, "20260709-reused", strings.Replace(executableLineageDoc("reused", "line", "", ""), "created: 2026-07-10", "created: 2026-07-09", 1)) - err := Runner{WorkingDir: repo}.Run([]string{"change", "init", "reused"}) - if err == nil || !strings.Contains(err.Error(), "already exists") { - t.Fatalf("init error = %v", err) - } -} - -func TestChangeCheckRequiresCommittedPredecessorButNotCompletion(t *testing.T) { - repo := initCLIGitRepo(t) - root := writeChangeFolder(t, repo, "20260710-root", executableLineageDoc("root", "line", "", "child")) - commitAllChangeTest(t, repo, "docs: add root") - child := writeChangeFolder(t, repo, "20260710-child", executableLineageDoc("child", "line", "root", "")) - out, err := runChangeCheckJSON(t, repo, child, "--require-executable") - if err != nil || !out.Executable { - t.Fatalf("committed predecessor err = %v out = %+v", err, out) - } - if root == "" { - t.Fatal("root should be materialized") - } - // A root that is only in the working tree cannot satisfy another Change's ancestry. - repo = initCLIGitRepo(t) - writeChangeFolder(t, repo, "20260710-root", executableLineageDoc("root", "line", "", "child")) - child = writeChangeFolder(t, repo, "20260710-child", executableLineageDoc("child", "line", "root", "")) - out, err = runChangeCheckJSON(t, repo, child, "--require-executable") - if err == nil || !findingsContain(out.Findings, "not structurally executable") || !findingsContain(out.Findings, "implementation completion is not implied") { - t.Fatalf("err = %v findings = %v", err, out.Findings) - } -} - -func TestChangeCheckRequiresCommittedPredecessorGraphToBeExecutable(t *testing.T) { - repo := initCLIGitRepo(t) - root := writeChangeFolder(t, repo, "20260710-root", executableLineageDoc("root", "line", "", "")) - commitAllChangeTest(t, repo, "docs: add root without release terminal") - - if err := os.WriteFile(filepath.Join(root, "change.md"), []byte(executableLineageDoc("root", "line", "", "child")), 0o644); err != nil { - t.Fatal(err) - } - child := writeChangeFolder(t, repo, "20260710-child", executableLineageDoc("child", "line", "root", "")) - out, err := runChangeCheckJSON(t, repo, child, "--require-executable") - if err == nil || out.Executable || !findingsContain(out.Gaps, "committed predecessor \"root\" is not structurally executable") { - t.Fatalf("dirty graph repair bypassed committed predecessor validation: err = %v out = %+v", err, out) - } -} - -func TestChangeCheckRequireExecutableRejectsMissingPredecessor(t *testing.T) { - repo := initCLIGitRepo(t) - child := writeChangeFolder(t, repo, "20260710-child", executableLineageDoc("child", "line", "missing", "")) - out, err := runChangeCheckJSON(t, repo, child, "--require-executable") - if err == nil || !findingsContain(out.Gaps, "predecessor \"missing\" is not materialized") { - t.Fatalf("err = %v findings = %v", err, out.Findings) - } -} - -func TestChangeCheckBareReportsMissingPredecessorAsExecutionGap(t *testing.T) { - repo := initCLIGitRepo(t) - child := writeChangeFolder(t, repo, "20260710-child", executableLineageDoc("child", "line", "missing", "")) - bare, err := runChangeCheckJSON(t, repo, child) - if err != nil || !bare.Passed || bare.Executable || !findingsContain(bare.Gaps, "predecessor \"missing\" is not materialized") { - t.Fatalf("bare check err = %v out = %+v", err, bare) - } - required, err := runChangeCheckJSON(t, repo, child, "--require-executable") - if err == nil || required.Passed || required.Executable || !findingsContain(required.Gaps, "predecessor \"missing\" is not materialized") { - t.Fatalf("required check err = %v out = %+v", err, required) - } -} - -func TestChangeCheckParkedTerminalInIsolationNeedsRootReleaseDeclaration(t *testing.T) { - repo := initCLIGitRepo(t) - parked := writeChangeFolder(t, repo, "20260710-spec-conversion-and-guidance-sweep", executableLineageDoc("spec-conversion-and-guidance-sweep", "change-model-hard-cut", "", "")) - want := "root \"spec-conversion-and-guidance-sweep\" must declare release-after" - bare, err := runChangeCheckJSON(t, repo, parked) - if err != nil || !bare.Passed || bare.Executable || !findingsContain(bare.Gaps, want) { - t.Fatalf("parked bare check err = %v out = %+v", err, bare) - } - required, err := runChangeCheckJSON(t, repo, parked, "--require-executable") - if err == nil || required.Passed || required.Executable || !findingsContain(required.Gaps, want) { - t.Fatalf("parked required check err = %v out = %+v", err, required) - } -} - -func TestChangeCheckRootRemainsExecutableWithUnmaterializedReleaseAfter(t *testing.T) { - repo := initCLIGitRepo(t) - root := writeChangeFolder(t, repo, "20260710-root", executableLineageDoc("root", "line", "", "terminal")) - out, err := runChangeCheckJSON(t, repo, root, "--require-executable") - if err != nil || !out.Executable || findingsContain(out.Gaps, "release-after terminal") { - t.Fatalf("root structural check err = %v out = %+v", err, out) - } -} - -func TestChangeCheckRequireExecutableTraversesCommittedThreeNodeChain(t *testing.T) { - repo := initCLIGitRepo(t) - root := writeChangeFolder(t, repo, "20260710-root", executableLineageDoc("root", "line", "", "terminal")) - terminal := writeChangeFolder(t, repo, "20260710-terminal", executableLineageDoc("terminal", "line", "", "")) - out, err := runChangeCheckJSON(t, repo, terminal, "--require-executable") - if err == nil || !findingsContain(out.Findings, "multiple roots") { - t.Fatalf("stale terminal err = %v out = %+v", err, out) - } - writeChangeFolder(t, repo, "20260710-terminal", executableLineageDoc("terminal", "line", "middle", "")) - out, err = runChangeCheckJSON(t, repo, terminal, "--require-executable") - if err == nil || !findingsContain(out.Gaps, "predecessor \"middle\" is not materialized") { - t.Fatalf("absent middle err = %v out = %+v", err, out) - } - middle := writeChangeFolder(t, repo, "20260710-middle", executableLineageDoc("middle", "line", "root", "")) - if err := os.RemoveAll(root); err != nil { - t.Fatal(err) - } - gitCLI(t, repo, "add", filepath.Join(middle, "change.md")) - gitCLI(t, repo, "-c", "user.name=Loaf Test", "-c", "user.email=loaf@example.test", "commit", "-m", "docs: add middle only") - out, err = runChangeCheckJSON(t, repo, terminal, "--require-executable") - if err == nil || !findingsContain(out.Gaps, "predecessor \"root\" is not committed and retained in HEAD") { - t.Fatalf("absent committed root err = %v out = %+v", err, out) - } - writeChangeFolder(t, repo, "20260710-root", executableLineageDoc("root", "line", "", "terminal")) - commitAllChangeTest(t, repo, "docs: complete lineage") - out, err = runChangeCheckJSON(t, repo, terminal, "--require-executable") - if err != nil || !out.Executable { - t.Fatalf("complete chain err = %v out = %+v", err, out) - } -} - -func TestChangeListFindsRetainedLineageWithoutBranch(t *testing.T) { - t.Skip("retired: change list --lineage replaced by units/cohort projection (TASK-004)") - t.Setenv("LOAF_DB", filepath.Join(t.TempDir(), "loaf.sqlite")) - repo := initCLIGitRepo(t) - writeChangeFolder(t, repo, "20260710-root", executableLineageDoc("root", "line", "", "terminal")) - commitAllChangeTest(t, repo, "docs: retain root") - var stdout bytes.Buffer - err := Runner{Stdout: &stdout, WorkingDir: repo, StateHome: t.TempDir()}.Run([]string{"change", "list", "--lineage", "line", "--json"}) - if err != nil { - t.Fatalf("list error = %v", err) - } - if !strings.Contains(stdout.String(), "\"root\"") || !strings.Contains(stdout.String(), "\"journalAvailable\": false") { - t.Fatalf("list output = %s", stdout.String()) - } -} - -func TestChangeListReadsExactScopeDecisionWithoutMutatingState(t *testing.T) { - t.Skip("retired: change list --lineage replaced by units/cohort projection (TASK-004)") - ctx := context.Background() - repo := initCLIGitRepo(t) - databasePath := filepath.Join(t.TempDir(), "loaf.sqlite") - t.Setenv("LOAF_DB", databasePath) - writeChangeFolder(t, repo, "20260710-root", executableLineageDoc("root", "line", "", "terminal")) - commitAllChangeTest(t, repo, "docs: retain root") - projectRoot, err := project.ResolveRoot(repo) - if err != nil { - t.Fatal(err) - } - if _, err := state.Initialize(ctx, projectRoot, state.PathResolver{}); err != nil { - t.Fatal(err) - } - const decision = "root then terminal; no release between nodes" - if _, err := state.LogJournal(ctx, projectRoot, state.PathResolver{}, state.JournalLogOptions{Entry: "decision(lineage/line): " + decision}); err != nil { - t.Fatal(err) - } - beforeBytes, err := os.ReadFile(databasePath) - if err != nil { - t.Fatal(err) - } - beforeProjects, beforePaths := changeTestIdentityCounts(t, databasePath) - var stdout bytes.Buffer - if err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "list", "--lineage", "line", "--json"}); err != nil { - t.Fatalf("change list error = %v", err) - } - var result changeListJSON - if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { - t.Fatalf("Unmarshal(%q) error = %v", stdout.String(), err) - } - if !result.JournalAvailable || result.LineageDecision != decision { - t.Fatalf("result = %+v, want exact-scope journal decision", result) - } - afterBytes, err := os.ReadFile(databasePath) - if err != nil { - t.Fatal(err) - } - afterProjects, afterPaths := changeTestIdentityCounts(t, databasePath) - if !bytes.Equal(beforeBytes, afterBytes) || beforeProjects != afterProjects || beforePaths != afterPaths { - t.Fatalf("change list mutated state: bytes_equal=%t projects=%d->%d paths=%d->%d", bytes.Equal(beforeBytes, afterBytes), beforeProjects, afterProjects, beforePaths, afterPaths) - } -} - -func TestChangeListWarnsWhenJournalEnrichmentReadFails(t *testing.T) { - t.Skip("retired: change list --lineage replaced by units/cohort projection (TASK-004)") - repo := initCLIGitRepo(t) - databasePath := filepath.Join(t.TempDir(), "loaf.sqlite") - t.Setenv("LOAF_DB", databasePath) - writeChangeFolder(t, repo, "20260710-root", executableLineageDoc("root", "line", "", "root")) - if err := os.WriteFile(databasePath, []byte("not a sqlite database"), 0o600); err != nil { - t.Fatal(err) - } - var jsonOutput bytes.Buffer - if err := (Runner{Stdout: &jsonOutput, WorkingDir: repo}).Run([]string{"change", "list", "--lineage", "line", "--json"}); err != nil { - t.Fatalf("change list --json error = %v", err) - } - var result changeListJSON - if err := json.Unmarshal(jsonOutput.Bytes(), &result); err != nil { - t.Fatal(err) - } - if result.JournalAvailable || len(result.Warnings) != 1 || result.Warnings[0] != changeListJournalReadWarning { - t.Fatalf("result = %+v, want deterministic journal read warning", result) - } - var human bytes.Buffer - if err := (Runner{Stdout: &human, WorkingDir: repo}).Run([]string{"change", "list", "--lineage", "line"}); err != nil { - t.Fatalf("change list error = %v", err) - } - if !strings.Contains(human.String(), "warning: "+changeListJournalReadWarning) { - t.Fatalf("human output = %q, want visible warning", human.String()) - } -} - -func changeTestIdentityCounts(t *testing.T, databasePath string) (int, int) { - t.Helper() - database, err := sql.Open("sqlite3", "file:"+filepath.ToSlash(databasePath)+"?mode=ro") - if err != nil { - t.Fatal(err) - } - defer database.Close() - var projects, paths int - if err := database.QueryRow(`SELECT COUNT(*) FROM projects`).Scan(&projects); err != nil { - t.Fatal(err) - } - if err := database.QueryRow(`SELECT COUNT(*) FROM project_paths`).Scan(&paths); err != nil { - t.Fatal(err) - } - return projects, paths -} - -func TestChangeListJSONIsRelativeAndByteDeterministicAfterBranchRenameAndDelete(t *testing.T) { - t.Skip("retired: change list --lineage replaced by units/cohort projection (TASK-004)") - t.Setenv("LOAF_DB", filepath.Join(t.TempDir(), "loaf.sqlite")) - repo := initCLIGitRepo(t) - gitCLI(t, repo, "switch", "-c", "lineage-work") - writeChangeFolder(t, repo, "20260710-root", executableLineageDoc("root", "line", "", "root")) - commitAllChangeTest(t, repo, "docs: retain lineage") - gitCLI(t, repo, "branch", "-m", "renamed-lineage-work") - gitCLI(t, repo, "switch", "main") - gitCLI(t, repo, "merge", "--ff-only", "renamed-lineage-work") - gitCLI(t, repo, "branch", "-D", "renamed-lineage-work") - run := func() string { - var stdout bytes.Buffer - if err := (Runner{Stdout: &stdout, WorkingDir: repo, StateHome: t.TempDir()}).Run([]string{"change", "list", "--lineage", "line", "--json"}); err != nil { - t.Fatal(err) - } - return stdout.String() - } - first, second := run(), run() - if first != second { - t.Fatalf("repeated JSON differs:\n%s\n%s", first, second) - } - var decoded changeListJSON - if err := json.Unmarshal([]byte(first), &decoded); err != nil { - t.Fatal(err) - } - if len(decoded.Nodes) != 1 || decoded.Nodes[0].Folder != "docs/changes/20260710-root" || strings.Contains(first, repo) { - t.Fatalf("decoded = %+v output = %s", decoded, first) - } -} - -func TestChangeExecutableWordingParityAcrossEveryCommandSurface(t *testing.T) { - var native, agent bytes.Buffer - writeChangeCheckHelp(&native) - if err := writeAgentHelpJSON(&agent); err != nil { - t.Fatal(err) - } - nativeSnippets := []string{ - "Validate a Change and report derived structural executability, not implementation completion.", - "--require-executable Exit non-zero unless the Change is structurally executable (CI gate for non-draft PRs)", - } - for _, snippet := range nativeSnippets { - if !strings.Contains(native.String(), snippet) { - t.Fatalf("native change check help = %q, want exact snippet %q", native.String(), snippet) - } - } - const agentSnippet = "Exit non-zero unless the Change is structurally executable; this does not prove implementation completion" - if !strings.Contains(agent.String(), agentSnippet) { - t.Fatalf("agent help = %q, want exact change check snippet %q", agent.String(), agentSnippet) - } - referenceJSON, err := json.Marshal(cliReferenceCommands()) - if err != nil { - t.Fatal(err) - } - const referenceSnippet = "Exit non-zero unless the Change is structurally executable; this does not prove implementation completion (CI gate for non-draft PRs)" - if !strings.Contains(string(referenceJSON), referenceSnippet) { - t.Fatalf("CLI reference metadata = %s, want exact change check snippet %q", referenceJSON, referenceSnippet) - } - - root := filepath.Join("..", "..") - const shapeSnippet = "**implement** — Starts execution once a Change is structurally executable; this does not prove implementation completion" - const boundarySnippet = "`--require-executable` turns structural executability into a gate (exit code 1 if not structurally executable); it does not prove implementation completion." - const prSnippet = "" - const routingSnippet = "| Validate a Change is structurally executable, not implementation-complete | `loaf change check --require-executable` |" - skillRoots := []string{ - "content/skills", - "plugins/loaf/skills", - "dist/amp/skills", - "dist/codex/skills", - "dist/cursor/skills", - "dist/opencode/skills", - "dist/skills", - } - expected := map[string]string{} - for _, skillRoot := range skillRoots { - expected[filepath.Join(skillRoot, "shape", "SKILL.md")] = shapeSnippet - expected[filepath.Join(skillRoot, "shape", "references", "cli-boundary.md")] = boundarySnippet - expected[filepath.Join(skillRoot, "shape", "templates", "pr.md")] = prSnippet - expected[filepath.Join(skillRoot, "loaf-reference", "references", "command-routing.md")] = routingSnippet - } - expected[filepath.Join("dist", "opencode", "commands", "shape.md")] = shapeSnippet - for path, snippet := range expected { - body, err := os.ReadFile(filepath.Join(root, path)) - if err != nil { - t.Fatalf("read %s: %v", path, err) - } - if !strings.Contains(string(body), snippet) { - t.Fatalf("%s wording drifted; want exact snippet %q", path, snippet) - } - } -} - -// --- V1: violations, exit non-zero ----------------------------------------- - -// V1(a): status-like frontmatter keys. -func TestChangeCheckV1RejectsStatusLikeKeys(t *testing.T) { - for _, key := range []string{"readiness", "status", "state"} { - t.Run(key, func(t *testing.T) { - repo := initCLIGitRepo(t) - fm := strings.Join([]string{ - "---", - "change: demo", - "created: 2026-07-04", - "branch: demo", - key + ": whatever", - "---", - }, "\n") - folder := writeChangeFolder(t, repo, "20260704-demo", changeDoc(fm, productSections()...)) - - out, err := runChangeCheckJSON(t, repo, folder) - var exitErr ExitError - if !errors.As(err, &exitErr) || exitErr.Code == 0 { - t.Fatalf("err = %v, want non-zero ExitError", err) - } - if out.Passed { - t.Fatalf("passed = true, want false for status-like key %q", key) - } - if !findingsContain(out.Findings, key) { - t.Fatalf("findings = %v, want mention of banned key %q", out.Findings, key) - } - }) - } -} - -// V1(a): progress vocabulary as a frontmatter value in any field. -func TestChangeCheckV1RejectsProgressVocabularyValues(t *testing.T) { - for _, value := range []string{"active", "in-progress", "done", "archived"} { - t.Run(value, func(t *testing.T) { - repo := initCLIGitRepo(t) - fm := strings.Join([]string{ - "---", - "change: demo", - "created: 2026-07-04", - "branch: demo", - "phase: " + value, - "---", - }, "\n") - folder := writeChangeFolder(t, repo, "20260704-demo", changeDoc(fm, productSections()...)) - - out, err := runChangeCheckJSON(t, repo, folder) - var exitErr ExitError - if !errors.As(err, &exitErr) || exitErr.Code == 0 { - t.Fatalf("err = %v, want non-zero ExitError for progress value %q", value, err) - } - if out.Passed { - t.Fatalf("passed = true, want false for progress value %q", value) - } - if !findingsContain(out.Findings, value) { - t.Fatalf("findings = %v, want mention of progress value %q", out.Findings, value) - } - }) - } -} - -// V1(a): the full canonical change-state vocabulary (Decision 22) plus released -// is banned as a frontmatter value under ANY key. This is external review round -// 4's exact probe set — each canonical state (and released) stored under an -// arbitrary key that is not itself status-like. -func TestChangeCheckV1RejectsCanonicalStateVocabularyUnderArbitraryKeys(t *testing.T) { - cases := []struct{ key, value string }{ - {"phase", "shaping"}, - {"queue", "backlog"}, - {"lane", "todo"}, - {"review_phase", "review"}, - {"merge_state", "merged"}, - {"release_state", "released"}, - } - for _, tc := range cases { - t.Run(tc.key+"="+tc.value, func(t *testing.T) { - repo := initCLIGitRepo(t) - fm := strings.Join([]string{ - "---", - "change: demo", - "created: 2026-07-04", - "branch: demo", - tc.key + ": " + tc.value, - "---", - }, "\n") - folder := writeChangeFolder(t, repo, "20260704-demo", changeDoc(fm, productSections()...)) - - out, err := runChangeCheckJSON(t, repo, folder) - var exitErr ExitError - if !errors.As(err, &exitErr) || exitErr.Code == 0 { - t.Fatalf("err = %v, want non-zero ExitError for %s: %s", err, tc.key, tc.value) - } - if out.Passed { - t.Fatalf("passed = true, want false for %q stored under key %q", tc.value, tc.key) - } - if !findingsContain(out.Findings, tc.value) || !findingsContain(out.Findings, tc.key) { - t.Fatalf("findings = %v, want mention of banned value %q under key %q", out.Findings, tc.value, tc.key) - } - }) - } -} - -// V1(a): matching is case-insensitive on the normalized value — underscores and -// spaces normalize to hyphens, so "In Progress" and "in_progress" both match the -// canonical "in-progress". -func TestChangeCheckV1RejectsStateVocabularyRegardlessOfCasingOrSeparator(t *testing.T) { - for _, value := range []string{"In Progress", "in_progress", "IN-PROGRESS", "Merged", "BACKLOG"} { - t.Run(value, func(t *testing.T) { - repo := initCLIGitRepo(t) - fm := strings.Join([]string{ - "---", - "change: demo", - "created: 2026-07-04", - "branch: demo", - "phase: " + value, - "---", - }, "\n") - folder := writeChangeFolder(t, repo, "20260704-demo", changeDoc(fm, productSections()...)) - - out, err := runChangeCheckJSON(t, repo, folder) - var exitErr ExitError - if !errors.As(err, &exitErr) || exitErr.Code == 0 { - t.Fatalf("err = %v, want non-zero ExitError for normalized state value %q", err, value) - } - if out.Passed { - t.Fatalf("passed = true, want false for normalized state value %q", value) - } - }) - } -} - -// V1(a) exemption: identity fields (change, created, branch) are exempt from the -// state-vocabulary ban — their semantics are checked elsewhere. A branch is a git -// ref that may legitimately be named after a state word, so branch: review passes. -func TestChangeCheckV1AllowsBranchNamedLikeState(t *testing.T) { - repo := initCLIGitRepo(t) - body := changeDoc(changeFrontmatter("demo", "2026-07-04", "review"), productSections()...) - folder := writeChangeFolder(t, repo, "20260704-demo", body) - - out, err := runChangeCheckJSON(t, repo, folder) - if err != nil { - t.Fatalf("err = %v, want nil; branch: review is an identity field, exempt from the state ban", err) - } - if !out.Passed { - t.Fatalf("passed = false, want true; a branch named after a state word must not be a violation. findings = %v", out.Findings) - } -} - -// V1(b): frontmatter must open the file at byte one. -func TestChangeCheckV1RejectsFrontmatterNotAtByteOne(t *testing.T) { - repo := initCLIGitRepo(t) - body := changeDoc(changeFrontmatter("demo", "2026-07-04", "demo"), productSections()...) - folder := writeChangeFolder(t, repo, "20260704-demo", "\n"+body) - - out, err := runChangeCheckJSON(t, repo, folder) - var exitErr ExitError - if !errors.As(err, &exitErr) || exitErr.Code == 0 { - t.Fatalf("err = %v, want non-zero ExitError", err) - } - if !findingsContain(out.Findings, "byte one") { - t.Fatalf("findings = %v, want byte-one violation", out.Findings) - } -} - -// V1(c): malformed folder name. -func TestChangeCheckV1RejectsMalformedFolderName(t *testing.T) { - cases := map[string]string{ - "no-date-prefix": "shape-first", - "bad-date": "2026-07-first", - "uppercase-slug": "20260704-Demo", - "underscore": "20260704-de_mo", - } - for name, folderName := range cases { - t.Run(name, func(t *testing.T) { - repo := initCLIGitRepo(t) - body := changeDoc(changeFrontmatter("demo", "2026-07-04", "demo"), productSections()...) - folder := writeChangeFolder(t, repo, folderName, body) - - out, err := runChangeCheckJSON(t, repo, folder) - var exitErr ExitError - if !errors.As(err, &exitErr) || exitErr.Code == 0 { - t.Fatalf("err = %v, want non-zero ExitError for folder %q", folderName, err) - } - if !findingsContain(out.Findings, "folder name") { - t.Fatalf("findings = %v, want folder-name violation", out.Findings) - } - }) - } -} - -// V1(d): identity mismatch — change: vs folder slug. -func TestChangeCheckV1RejectsSlugMismatch(t *testing.T) { - repo := initCLIGitRepo(t) - body := changeDoc(changeFrontmatter("other", "2026-07-04", "other"), productSections()...) - folder := writeChangeFolder(t, repo, "20260704-demo", body) - - out, err := runChangeCheckJSON(t, repo, folder) - var exitErr ExitError - if !errors.As(err, &exitErr) || exitErr.Code == 0 { - t.Fatalf("err = %v, want non-zero ExitError", err) - } - if !findingsContain(out.Findings, "change:") { - t.Fatalf("findings = %v, want change/slug mismatch", out.Findings) - } -} - -// V1(d): identity mismatch — created: date vs folder date prefix. -func TestChangeCheckV1RejectsCreatedDateMismatch(t *testing.T) { - repo := initCLIGitRepo(t) - body := changeDoc(changeFrontmatter("demo", "2026-07-05", "demo"), productSections()...) - folder := writeChangeFolder(t, repo, "20260704-demo", body) - - out, err := runChangeCheckJSON(t, repo, folder) - var exitErr ExitError - if !errors.As(err, &exitErr) || exitErr.Code == 0 { - t.Fatalf("err = %v, want non-zero ExitError", err) - } - if !findingsContain(out.Findings, "created:") { - t.Fatalf("findings = %v, want created/date mismatch", out.Findings) - } -} - -// V1(e): missing Product Contract sections. -func TestChangeCheckV1RejectsMissingProductSections(t *testing.T) { - repo := initCLIGitRepo(t) - // Only Problem present; the other four product sections missing. - body := changeDoc(changeFrontmatter("demo", "2026-07-04", "demo"), "## Problem\n\nThe friction.") - folder := writeChangeFolder(t, repo, "20260704-demo", body) - - out, err := runChangeCheckJSON(t, repo, folder) - var exitErr ExitError - if !errors.As(err, &exitErr) || exitErr.Code == 0 { - t.Fatalf("err = %v, want non-zero ExitError", err) - } - for _, want := range []string{"Hypothesis", "Scope", "Observable Workflow", "Rabbit Holes and No-Gos"} { - if !findingsContain(out.Findings, want) { - t.Fatalf("findings = %v, want missing-section mention of %q", out.Findings, want) - } - } -} - -// --- V2: report, exit zero for a valid shaping-stage document ---------------- - -// A document with only the product sections is valid (exit zero) but reported -// non-executable, with the missing tail sections listed as gaps. -func TestChangeCheckV2ShapingStagePassesButNotExecutable(t *testing.T) { - repo := initCLIGitRepo(t) - body := changeDoc(changeFrontmatter("demo", "2026-07-04", "demo"), productSections()...) - folder := writeChangeFolder(t, repo, "20260704-demo", body) - - out, err := runChangeCheckJSON(t, repo, folder) - if err != nil { - t.Fatalf("shaping-stage check err = %v, want nil (exit zero)", err) - } - if !out.Passed { - t.Fatalf("passed = false, want true for a valid shaping-stage document") - } - if out.Executable { - t.Fatalf("executable = true, want false for product-only document") - } - for _, want := range []string{"Planning Contract", "Implementation Units", "Verification Contract", "Definition of Done"} { - if !findingsContain(out.Gaps, want) { - t.Fatalf("gaps = %v, want gap for %q", out.Gaps, want) - } - } -} - -// V2: executability derivation with a single gap — an empty tail section counts -// as a gap even though the heading is present. -func TestChangeCheckV2ExecutabilityGapForEmptySection(t *testing.T) { - repo := initCLIGitRepo(t) - sections := append(productSections(), - "## Planning Contract\n\n### Approach\n\nHow.", - "## Implementation Units\n\n- U1 — do the thing.", - "## Verification Contract\n\n- V1. exits non-zero.", - "## Definition of Done\n", // present but empty - ) - body := changeDoc(changeFrontmatter("demo", "2026-07-04", "demo"), sections...) - folder := writeChangeFolder(t, repo, "20260704-demo", body) - - out, err := runChangeCheckJSON(t, repo, folder) - if err != nil { - t.Fatalf("err = %v, want nil (exit zero, empty section is a gap not a violation)", err) - } - if out.Executable { - t.Fatalf("executable = true, want false when Definition of Done is empty") - } - if !findingsContain(out.Gaps, "Definition of Done") { - t.Fatalf("gaps = %v, want Definition of Done gap", out.Gaps) - } - if findingsContain(out.Gaps, "Planning Contract") { - t.Fatalf("gaps = %v, should not flag the non-empty Planning Contract", out.Gaps) - } -} - -// V2: a fully-populated document reports executable with no gaps. -func TestChangeCheckV2FullDocumentIsExecutable(t *testing.T) { - repo := initCLIGitRepo(t) - sections := append(productSections(), executableSections()...) - body := changeDoc(changeFrontmatter("demo", "2026-07-04", "demo"), sections...) - folder := writeChangeFolder(t, repo, "20260704-demo", body) - - out, err := runChangeCheckJSON(t, repo, folder) - if err != nil { - t.Fatalf("err = %v, want nil", err) - } - if !out.Passed || !out.Executable { - t.Fatalf("passed=%v executable=%v, want both true", out.Passed, out.Executable) - } - if len(out.Gaps) != 0 { - t.Fatalf("gaps = %v, want none", out.Gaps) - } -} - -// --- V2 placeholder discounting: authored content, not scaffolding ----------- - -// TestChangeCheckFreshInitNotExecutable is the traceable resolution of the U3 -// finding: a Change scaffolded by `loaf change init` used to read -// executable:yes because the template's bracket placeholders counted as content -// (the literal "present and non-empty" reading let a placeholder-only document -// satisfy the V3 gate). With V2 discounting placeholders and comments, a fresh -// Change reads not-executable — its tail is scaffolding, not authored content. -// -// Every tail section ships as scaffolding — bracket placeholders and HTML -// comments only, no bare labels — so all four executable sections read as gaps -// on a fresh init and none is executable until the author writes real content. -func TestChangeCheckFreshInitNotExecutable(t *testing.T) { - repo := initCLIGitRepo(t) - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "init", "fresh-demo"}); err != nil { - t.Fatalf("change init error = %v", err) - } - today := time.Now().Format("20060102") - folder := filepath.Join(repo, "docs", "changes", today+"-fresh-demo") - - out, err := runChangeCheckJSON(t, repo, folder) - if err != nil { - t.Fatalf("check on freshly-init'd change err = %v, want nil (shaping-stage is valid, exit zero)", err) - } - if !out.Passed { - t.Fatalf("passed = false, want true; a freshly-init'd Change has no violations. findings = %v", out.Findings) - } - if out.Executable { - t.Fatalf("executable = true, want false; a freshly-templated Change has no authored tail. gaps = %v", out.Gaps) - } - for _, want := range []string{"Planning Contract", "Implementation Units", "Verification Contract", "Definition of Done"} { - if !findingsContain(out.Gaps, want) { - t.Fatalf("gaps = %v, want scaffolding-only tail section %q listed as a gap", out.Gaps, want) - } - } -} - -// A tail section mixing a placeholder line with one authored line is non-empty: -// discounting removes only the placeholder, and any real content keeps the -// section authored. -func TestChangeCheckSectionMixedPlaceholderIsAuthored(t *testing.T) { - repo := initCLIGitRepo(t) - sections := append(productSections(), - "## Planning Contract\n\n### Approach\n\nReal shaping prose.", - "## Implementation Units\n\n- [What this Change delivers.]\n- Wire the token rotation job.", - "## Verification Contract\n\n- V1. command exits non-zero.", - "## Definition of Done\n\n- Gates pass.", - ) - body := changeDoc(changeFrontmatter("demo", "2026-07-04", "demo"), sections...) - folder := writeChangeFolder(t, repo, "20260704-demo", body) - - out, err := runChangeCheckJSON(t, repo, folder) - if err != nil { - t.Fatalf("err = %v, want nil", err) - } - if findingsContain(out.Gaps, "Implementation Units") { - t.Fatalf("gaps = %v, must not flag Implementation Units; it carries an authored line beside the placeholder", out.Gaps) - } - if !out.Executable { - t.Fatalf("executable = false, want true; every tail section is authored. gaps = %v", out.Gaps) - } -} - -// A tail section whose only content is an HTML comment reads as empty — comments -// are guidance, not authored content. -func TestChangeCheckSectionOnlyHTMLCommentIsEmpty(t *testing.T) { - repo := initCLIGitRepo(t) - sections := append(productSections(), - "## Planning Contract\n\n### Approach\n\nReal shaping prose.", - "## Implementation Units\n\n- Ship the thing.", - "## Verification Contract\n\n- V1. command exits non-zero.", - "## Definition of Done\n\n", - ) - body := changeDoc(changeFrontmatter("demo", "2026-07-04", "demo"), sections...) - folder := writeChangeFolder(t, repo, "20260704-demo", body) - - out, err := runChangeCheckJSON(t, repo, folder) - if err != nil { - t.Fatalf("err = %v, want nil", err) - } - if out.Executable { - t.Fatalf("executable = true, want false; Definition of Done holds only a comment. gaps = %v", out.Gaps) - } - if !findingsContain(out.Gaps, "Definition of Done") { - t.Fatalf("gaps = %v, want a Definition of Done gap for a comment-only section", out.Gaps) - } -} - -// Decision pinned: a surviving alphanumeric label (e.g. **U1**) after -// bracket-discounting is authored content. The V2 clause left "structure with -// only placeholder content" to implementation; the deterministic rule strips -// placeholder spans and comments, never bare labels, so a bullet like -// `- **U1 — [Unit name].** [What it delivers.]` reads as authored via its label. -func TestChangeCheckStructuralLabelCountsAsAuthored(t *testing.T) { - repo := initCLIGitRepo(t) - sections := append(productSections(), - "## Planning Contract\n\n### Approach\n\nReal shaping prose.", - "## Implementation Units\n\n- **U1 — [Unit name].** [What it delivers.]", - "## Verification Contract\n\n- V1. command exits non-zero.", - "## Definition of Done\n\n- Gates pass.", - ) - body := changeDoc(changeFrontmatter("demo", "2026-07-04", "demo"), sections...) - folder := writeChangeFolder(t, repo, "20260704-demo", body) - - out, err := runChangeCheckJSON(t, repo, folder) - if err != nil { - t.Fatalf("err = %v, want nil", err) - } - if findingsContain(out.Gaps, "Implementation Units") { - t.Fatalf("gaps = %v, Implementation Units should read authored: the **U1** label survives discounting", out.Gaps) - } - if !out.Executable { - t.Fatalf("executable = false, want true; every tail section is authored. gaps = %v", out.Gaps) - } -} - -// --- V3: --require-executable turns the report into a gate ------------------- - -func TestChangeCheckV3RequireExecutableFailsOnShapingDoc(t *testing.T) { - repo := initCLIGitRepo(t) - body := changeDoc(changeFrontmatter("demo", "2026-07-04", "demo"), productSections()...) - folder := writeChangeFolder(t, repo, "20260704-demo", body) - - out, err := runChangeCheckJSON(t, repo, folder, "--require-executable") - var exitErr ExitError - if !errors.As(err, &exitErr) || exitErr.Code == 0 { - t.Fatalf("err = %v, want non-zero ExitError with --require-executable on a shaping doc", err) - } - if out.Passed { - t.Fatalf("passed = true, want false under --require-executable when not executable") - } -} - -func TestChangeCheckV3RequireExecutablePassesOnFullDoc(t *testing.T) { - repo := initCLIGitRepo(t) - sections := append(productSections(), executableSections()...) - body := changeDoc(changeFrontmatter("demo", "2026-07-04", "demo"), sections...) - folder := writeChangeFolder(t, repo, "20260704-demo", body) - - out, err := runChangeCheckJSON(t, repo, folder, "--require-executable") - if err != nil { - t.Fatalf("err = %v, want nil for an executable document under --require-executable", err) - } - if !out.Passed || !out.Executable { - t.Fatalf("passed=%v executable=%v, want both true", out.Passed, out.Executable) - } -} - -// --- Branch mismatch is a warning, never a violation ------------------------ - -func TestChangeCheckBranchMismatchIsWarningNotViolation(t *testing.T) { - repo := initCLIGitRepo(t) // current branch is main - body := changeDoc(changeFrontmatter("demo", "2026-07-04", "some-other-branch"), productSections()...) - folder := writeChangeFolder(t, repo, "20260704-demo", body) - - out, err := runChangeCheckJSON(t, repo, folder) - if err != nil { - t.Fatalf("err = %v, want nil (branch mismatch is a warning)", err) - } - if !out.Passed { - t.Fatalf("passed = false, want true; branch mismatch must not be a violation") - } - if len(out.Warnings) == 0 { - t.Fatalf("warnings = %v, want a branch-mismatch warning", out.Warnings) - } - if !findingsContain(out.Warnings, "branch") { - t.Fatalf("warnings = %v, want branch-mismatch mention", out.Warnings) - } -} - -// --- Folder resolution by branch -------------------------------------------- - -func TestChangeCheckResolvesByBranch(t *testing.T) { - repo := initCLIGitRepo(t) // current branch is main - body := changeDoc(changeFrontmatter("demo", "2026-07-04", "main"), productSections()...) - writeChangeFolder(t, repo, "20260704-demo", body) - - out, err := runChangeCheckJSON(t, repo) // no positional path - if err != nil { - t.Fatalf("err = %v, want nil resolving by branch", err) - } - if !strings.Contains(out.Folder, "20260704-demo") { - t.Fatalf("folder = %q, want the branch-matched folder", out.Folder) - } -} - -func TestChangeCheckByBranchErrorsWhenNoMatch(t *testing.T) { - repo := initCLIGitRepo(t) // current branch is main - body := changeDoc(changeFrontmatter("demo", "2026-07-04", "not-main"), productSections()...) - writeChangeFolder(t, repo, "20260704-demo", body) - - var stdout, stderr bytes.Buffer - err := Runner{Stdout: &stdout, Stderr: &stderr, WorkingDir: repo}.Run([]string{"change", "check"}) - if err == nil { - t.Fatalf("err = nil, want an error telling the user to pass a path") - } -} - -func TestChangeCheckByBranchErrorsWhenManyMatch(t *testing.T) { - repo := initCLIGitRepo(t) // current branch is main - body := changeDoc(changeFrontmatter("demo", "2026-07-04", "main"), productSections()...) - writeChangeFolder(t, repo, "20260704-demo", body) - body2 := changeDoc(changeFrontmatter("other", "2026-07-04", "main"), productSections()...) - writeChangeFolder(t, repo, "20260704-other", body2) - - err := Runner{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, WorkingDir: repo}.Run([]string{"change", "check"}) - if err == nil { - t.Fatalf("err = nil, want an error when multiple folders match the branch") - } -} - -// The no-match error lists every discovered Change folder with its branch: value -// so the user can pick a path — a bare check on a branch with no matching Change -// (e.g. main right after init) is a dead end otherwise. -func TestChangeCheckByBranchNoMatchListsAvailableFolders(t *testing.T) { - repo := initCLIGitRepo(t) // current branch is main - writeChangeFolder(t, repo, "20260704-demo", - changeDoc(changeFrontmatter("demo", "2026-07-04", "feat-one"), productSections()...)) - writeChangeFolder(t, repo, "20260705-other", - changeDoc(changeFrontmatter("other", "2026-07-05", "feat-two"), productSections()...)) - - err := Runner{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, WorkingDir: repo}.Run([]string{"change", "check"}) - if err == nil { - t.Fatalf("err = nil, want a no-match error listing available folders") - } - msg := err.Error() - for _, want := range []string{ - "no change folder matches branch", - "available change folders:", - "20260704-demo", "branch: feat-one", - "20260705-other", "branch: feat-two", - } { - if !strings.Contains(msg, want) { - t.Fatalf("error = %q, want it to contain %q", msg, want) - } - } -} - -// The ambiguous-match error lists the candidate folders too. -func TestChangeCheckByBranchManyMatchListsAvailableFolders(t *testing.T) { - repo := initCLIGitRepo(t) // current branch is main - writeChangeFolder(t, repo, "20260704-demo", - changeDoc(changeFrontmatter("demo", "2026-07-04", "main"), productSections()...)) - writeChangeFolder(t, repo, "20260704-other", - changeDoc(changeFrontmatter("other", "2026-07-04", "main"), productSections()...)) - - err := Runner{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, WorkingDir: repo}.Run([]string{"change", "check"}) - if err == nil { - t.Fatalf("err = nil, want an ambiguous-match error listing available folders") - } - msg := err.Error() - for _, want := range []string{ - "multiple change folders match branch", - "available change folders:", - "20260704-demo", "20260704-other", - } { - if !strings.Contains(msg, want) { - t.Fatalf("error = %q, want it to contain %q", msg, want) - } - } -} - -// --- init: happy path, refuse existing, bad slug --------------------------- - -func TestChangeInitHappyPath(t *testing.T) { - repo := initCLIGitRepo(t) - var stdout bytes.Buffer - if err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "init", "auth-token-rotation"}); err != nil { - t.Fatalf("change init error = %v", err) - } - - today := time.Now().Format("20060102") - folder := filepath.Join(repo, "docs", "changes", today+"-auth-token-rotation") - jsonData, err := os.ReadFile(filepath.Join(folder, "change.json")) - if err != nil { - t.Fatalf("ReadFile(change.json) error = %v", err) - } - jsonContent := string(jsonData) - for _, want := range []string{ - `"change": "auth-token-rotation"`, - `"created": "` + time.Now().Format("2006-01-02") + `"`, - `"branch": "auth-token-rotation"`, - } { - if !strings.Contains(jsonContent, want) { - t.Fatalf("change.json = %q, want stamped %q", jsonContent, want) - } - } - shape, err := os.ReadFile(filepath.Join(folder, "shape.md")) - if err != nil { - t.Fatalf("ReadFile(shape.md) error = %v", err) - } - if !strings.Contains(string(shape), "[Change Title]") { - t.Fatalf("shape.md dropped body placeholders:\n%s", shape) - } - if _, err := os.Stat(filepath.Join(folder, "tasks")); err != nil { - t.Fatalf("tasks/ missing after init: %v", err) - } - seedPath := filepath.Join(folder, "tasks", changeSeedTaskFile) - seed, err := os.ReadFile(seedPath) - if err != nil { - t.Fatalf("seeded task packet missing: %v", err) - } - seedBody := string(seed) - for _, want := range []string{ - "change: auth-token-rotation", - "id: TASK-001", - "- [ ]", - "[short title]", - "expected to rename", - } { - if !strings.Contains(seedBody, want) { - t.Fatalf("seeded packet missing %q:\n%s", want, seedBody) - } - } - if strings.Contains(seedBody, "- [x]") || strings.Contains(seedBody, "- [X]") { - t.Fatalf("seeded packet must keep boxes unchecked:\n%s", seedBody) - } - if _, err := os.Stat(filepath.Join(folder, "tasks", ".gitkeep")); !os.IsNotExist(err) { - t.Fatalf(".gitkeep must not be written; err=%v", err) - } - if _, err := os.Stat(filepath.Join(folder, "brief.md")); !os.IsNotExist(err) { - t.Fatalf("brief.md should not exist on full scaffold; err=%v", err) - } - if _, err := os.Stat(filepath.Join(folder, "change.md")); !os.IsNotExist(err) { - t.Fatalf("legacy change.md should not exist on new scaffold; err=%v", err) - } - out, err := runChangeCheckJSON(t, repo, folder) - if err != nil { - t.Fatalf("check on freshly-init'd change err = %v out=%+v, want nil", err, out) - } - if !out.Passed { - t.Fatalf("fresh scaffold should pass check, findings=%v", out.Findings) - } - if out.Executable { - t.Fatalf("fresh scaffold must stay non-executable until authored; gaps=%v", out.Gaps) - } - if len(out.Notices) != 0 { - t.Fatalf("fresh scaffold must carry no deprecation notice; notices=%v", out.Notices) - } - - var tasksOut bytes.Buffer - if err := (Runner{Stdout: &tasksOut, WorkingDir: repo}).Run([]string{"change", "tasks", folder, "--json"}); err != nil { - t.Fatalf("change tasks error = %v", err) - } - var tasksResult changeTasksJSON - if err := json.Unmarshal(tasksOut.Bytes(), &tasksResult); err != nil { - t.Fatalf("Unmarshal tasks: %v\n%s", err, tasksOut.String()) - } - if len(tasksResult.Tasks) != 1 || tasksResult.Tasks[0].ID != "TASK-001" { - t.Fatalf("tasks = %+v, want single TASK-001", tasksResult.Tasks) - } - if tasksResult.Tasks[0].Complete { - t.Fatalf("seeded TASK-001 must have derived completion false") - } - if tasksResult.Tasks[0].CheckboxTotal < 1 || tasksResult.Tasks[0].CheckboxDone != 0 { - t.Fatalf("seeded checkboxes = done %d / total %d, want unchecked", tasksResult.Tasks[0].CheckboxDone, tasksResult.Tasks[0].CheckboxTotal) - } -} - -func TestChangeInitBriefMode(t *testing.T) { - repo := initCLIGitRepo(t) - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "init", "captured-ask", "--brief"}); err != nil { - t.Fatalf("change init --brief error = %v", err) - } - today := time.Now().Format("20060102") - folder := filepath.Join(repo, "docs", "changes", today+"-captured-ask") - if _, err := os.Stat(filepath.Join(folder, "change.json")); err != nil { - t.Fatalf("change.json missing: %v", err) - } - if _, err := os.Stat(filepath.Join(folder, "brief.md")); err != nil { - t.Fatalf("brief.md missing: %v", err) - } - if _, err := os.Stat(filepath.Join(folder, "shape.md")); !os.IsNotExist(err) { - t.Fatalf("shape.md must be absent in brief mode; err=%v", err) - } - if _, err := os.Stat(filepath.Join(folder, "tasks")); !os.IsNotExist(err) { - t.Fatalf("tasks/ must be absent in brief mode; err=%v", err) - } - out, err := runChangeCheckJSON(t, repo, folder) - if err != nil { - t.Fatalf("brief-only check should not violate: err=%v out=%+v", err, out) - } - if out.Executable { - t.Fatalf("brief-only folder must be non-executable") - } - if !findingsContain(out.Gaps, "shape.md") { - t.Fatalf("brief-only should report shape.md gap, gaps=%v", out.Gaps) - } -} - -// Scaffolded --brief output carries the shared problem-space skeleton headings -// (pitch-entrypoint brief contract). Headings are the contract; placeholder -// prose may evolve without this test churning. -func TestChangeInitBriefCarriesSkeletonHeadings(t *testing.T) { - repo := initCLIGitRepo(t) - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "init", "skeleton-brief", "--brief"}); err != nil { - t.Fatalf("change init --brief error = %v", err) - } - today := time.Now().Format("20060102") - briefPath := filepath.Join(repo, "docs", "changes", today+"-skeleton-brief", "brief.md") - body, err := os.ReadFile(briefPath) - if err != nil { - t.Fatalf("ReadFile(brief.md) error = %v", err) - } - content := string(body) - for _, want := range []string{ - "## Problem Statement", - "## Who Has It", - "## Current Alternatives", - "## Value Proposition", - "## Constraints", - "## Sequencing and Relationships", - "## Sources and Research Links", - "## Open Questions", - } { - if !strings.Contains(content, want) { - t.Fatalf("scaffolded brief.md missing skeleton heading %q\n%s", want, content) - } - } - // Supersession/accretion comment still present (Decision 7). - for _, want := range []string{ - "accrete", - "freezes when shape.md exists", - "never mechanically load-bearing", - } { - if !strings.Contains(content, want) { - t.Fatalf("scaffolded brief.md missing accretion/supersession phrase %q\n%s", want, content) - } - } -} - -// init's success output carries a next-steps hint: work happens on branch -// , so create/switch to it or pass the folder path to check explicitly. -// Without it, `loaf change init` on main followed by a bare `loaf change check` -// dead-ends on "no change folder matches branch main". -func TestChangeInitEmitsNextStepsHint(t *testing.T) { - repo := initCLIGitRepo(t) - var stdout bytes.Buffer - if err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "init", "auth-token-rotation"}); err != nil { - t.Fatalf("change init error = %v", err) - } - output := stdout.String() - for _, want := range []string{ - `branch "auth-token-rotation"`, - "git switch -c auth-token-rotation", - "loaf change check", - } { - if !strings.Contains(output, want) { - t.Fatalf("init output = %q, want next-steps hint containing %q", output, want) - } - } -} - -func TestChangeInitRefusesExistingFolder(t *testing.T) { - repo := initCLIGitRepo(t) - today := time.Now().Format("20060102") - existing := filepath.Join(repo, "docs", "changes", today+"-demo") - if err := os.MkdirAll(existing, 0o755); err != nil { - t.Fatalf("MkdirAll error = %v", err) - } - - err := Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}.Run([]string{"change", "init", "demo"}) - if err == nil { - t.Fatalf("err = nil, want refusal for an existing change folder") - } - if !strings.Contains(err.Error(), "exists") { - t.Fatalf("err = %v, want an 'exists' message", err) - } -} - -// --- Captured-folder promotion (TASK-006 / Decision 12) --------------------- - -func changeInitCaptureFolder(t *testing.T, repo, slug string) string { - t.Helper() - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "init", slug, "--brief"}); err != nil { - t.Fatalf("change init --brief error = %v", err) - } - today := time.Now().Format("20060102") - return filepath.Join(repo, "docs", "changes", today+"-"+slug) -} - -func readChangeFileBytes(t *testing.T, path string) []byte { - t.Helper() - body, err := os.ReadFile(path) - if err != nil { - t.Fatalf("ReadFile(%s) error = %v", path, err) - } - return body -} - -func stampTargetReleaseOnChangeJSON(t *testing.T, folder, version string) { - t.Helper() - path := filepath.Join(folder, "change.json") - var meta map[string]string - if err := json.Unmarshal(readChangeFileBytes(t, path), &meta); err != nil { - t.Fatalf("Unmarshal change.json: %v", err) - } - meta["target_release"] = version - data, err := json.MarshalIndent(meta, "", " ") - if err != nil { - t.Fatalf("Marshal change.json: %v", err) - } - data = append(data, '\n') - if err := os.WriteFile(path, data, 0o644); err != nil { - t.Fatalf("WriteFile change.json: %v", err) - } -} - -func TestChangeInitPromotesCaptureOnlyFolder(t *testing.T) { - repo := initCLIGitRepo(t) - folder := changeInitCaptureFolder(t, repo, "promo-happy") - stampTargetReleaseOnChangeJSON(t, folder, "2.1.0") - briefBefore := readChangeFileBytes(t, filepath.Join(folder, "brief.md")) - jsonBefore := readChangeFileBytes(t, filepath.Join(folder, "change.json")) - - var stdout bytes.Buffer - if err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "init", "promo-happy"}); err != nil { - t.Fatalf("promotion init error = %v", err) - } - out := stdout.String() - if !strings.Contains(out, "Promoted capture") { - t.Fatalf("promotion output = %q, want Promoted capture message distinct from Created change", out) - } - if strings.Contains(out, "Created change:") { - t.Fatalf("promotion must not print fresh-scaffold Created change; got %q", out) - } - - if !bytes.Equal(briefBefore, readChangeFileBytes(t, filepath.Join(folder, "brief.md"))) { - t.Fatalf("brief.md bytes mutated by promotion") - } - if !bytes.Equal(jsonBefore, readChangeFileBytes(t, filepath.Join(folder, "change.json"))) { - t.Fatalf("change.json bytes mutated by promotion") - } - if _, err := os.Stat(filepath.Join(folder, "shape.md")); err != nil { - t.Fatalf("shape.md missing after promotion: %v", err) - } - seedPath := filepath.Join(folder, "tasks", changeSeedTaskFile) - gotSeed := readChangeFileBytes(t, seedPath) - wantSeed := []byte(stampChangeTaskSeed(changeTaskTemplate, "promo-happy")) - if !bytes.Equal(gotSeed, wantSeed) { - t.Fatalf("seed task mismatch after promotion") - } - - check, err := runChangeCheckJSON(t, repo, folder) - if err != nil { - t.Fatalf("check after promotion err = %v out=%+v", err, check) - } - if check.State == "captured" || check.Captured { - t.Fatalf("after promotion want shaped-or-better, got state=%q captured=%v", check.State, check.Captured) - } - if check.State != "shaped" && check.State != "executable" && check.State != "executing" && check.State != "complete" && check.State != "verified" { - t.Fatalf("after promotion unexpected state %q", check.State) - } - - // Fully-materialized folder still rejects. - err = Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}.Run([]string{"change", "init", "promo-happy"}) - if err == nil || !strings.Contains(err.Error(), "already exists") { - t.Fatalf("materialized re-init err = %v, want already exists", err) - } - if !bytes.Equal(briefBefore, readChangeFileBytes(t, filepath.Join(folder, "brief.md"))) { - t.Fatalf("brief.md mutated by rejected re-init") - } -} - -func TestChangeInitResumesPartialPromotion(t *testing.T) { - repo := initCLIGitRepo(t) - folder := changeInitCaptureFolder(t, repo, "promo-resume") - briefBefore := readChangeFileBytes(t, filepath.Join(folder, "brief.md")) - jsonBefore := readChangeFileBytes(t, filepath.Join(folder, "change.json")) - - // Simulate interruption after seed publish, before shape.md marker rename. - tasksDir := filepath.Join(folder, "tasks") - if err := os.MkdirAll(tasksDir, 0o755); err != nil { - t.Fatalf("MkdirAll tasks: %v", err) - } - seedBody := []byte(stampChangeTaskSeed(changeTaskTemplate, "promo-resume")) - if err := os.WriteFile(filepath.Join(tasksDir, changeSeedTaskFile), seedBody, 0o644); err != nil { - t.Fatalf("WriteFile seed: %v", err) - } - - var stdout bytes.Buffer - if err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "init", "promo-resume"}); err != nil { - t.Fatalf("resume init error = %v", err) - } - if !strings.Contains(stdout.String(), "Resumed capture promotion") { - t.Fatalf("resume output = %q, want Resumed capture promotion", stdout.String()) - } - if !bytes.Equal(briefBefore, readChangeFileBytes(t, filepath.Join(folder, "brief.md"))) { - t.Fatalf("brief.md mutated by resume") - } - if !bytes.Equal(jsonBefore, readChangeFileBytes(t, filepath.Join(folder, "change.json"))) { - t.Fatalf("change.json mutated by resume") - } - if !bytes.Equal(seedBody, readChangeFileBytes(t, filepath.Join(tasksDir, changeSeedTaskFile))) { - t.Fatalf("seed task overwritten by resume") - } - if _, err := os.Stat(filepath.Join(folder, "shape.md")); err != nil { - t.Fatalf("shape.md missing after resume: %v", err) - } - check, err := runChangeCheckJSON(t, repo, folder) - if err != nil { - t.Fatalf("check after resume err = %v", err) - } - if check.State == "captured" { - t.Fatalf("after resume still captured") - } -} - -func TestChangeInitPromotionSurvivesStrayTempsAndPartialWrites(t *testing.T) { - repo := initCLIGitRepo(t) - folder := changeInitCaptureFolder(t, repo, "promo-temps") - tasksDir := filepath.Join(folder, "tasks") - if err := os.MkdirAll(tasksDir, 0o755); err != nil { - t.Fatalf("MkdirAll tasks: %v", err) - } - // Partial task write (temp only) and partial marker write (temp only). - if err := os.WriteFile(filepath.Join(tasksDir, changePublishTempPrefix+"seed partial"), []byte("half-written seed"), 0o644); err != nil { - t.Fatalf("WriteFile partial seed temp: %v", err) - } - if err := os.WriteFile(filepath.Join(folder, changePublishTempPrefix+"shape partial"), []byte("half-written shape"), 0o644); err != nil { - t.Fatalf("WriteFile partial shape temp: %v", err) - } - // Also a half-written seed destination must not exist; only temps. - - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "init", "promo-temps"}); err != nil { - t.Fatalf("promotion with stray temps error = %v", err) - } - if _, err := os.Stat(filepath.Join(folder, "shape.md")); err != nil { - t.Fatalf("shape.md missing: %v", err) - } - gotSeed := readChangeFileBytes(t, filepath.Join(tasksDir, changeSeedTaskFile)) - wantSeed := []byte(stampChangeTaskSeed(changeTaskTemplate, "promo-temps")) - if !bytes.Equal(gotSeed, wantSeed) { - t.Fatalf("seed incomplete after promotion past temps") - } - check, err := runChangeCheckJSON(t, repo, folder) - if err != nil { - t.Fatalf("check err = %v", err) - } - if check.State == "captured" { - t.Fatalf("stray temps stranded folder as captured or unresumable") - } -} - -func TestChangeInitPromotionFailClosedMatrix(t *testing.T) { - t.Run("repeated-brief", func(t *testing.T) { - repo := initCLIGitRepo(t) - folder := changeInitCaptureFolder(t, repo, "promo-rebrief") - before := listChangeFolderSnapshot(t, folder) - err := Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}.Run([]string{"change", "init", "promo-rebrief", "--brief"}) - if err == nil { - t.Fatalf("err = nil, want repeated --brief refusal") - } - if !strings.Contains(err.Error(), "already exists") { - t.Fatalf("err = %v, want already exists", err) - } - assertChangeFolderUntouched(t, folder, before) - }) - - t.Run("missing-brief", func(t *testing.T) { - repo := initCLIGitRepo(t) - today := time.Now().Format("20060102") - folder := filepath.Join(repo, "docs", "changes", today+"-promo-jsononly") - if err := os.MkdirAll(folder, 0o755); err != nil { - t.Fatalf("MkdirAll: %v", err) - } - if err := writeChangeJSON(filepath.Join(folder, "change.json"), "promo-jsononly", time.Now()); err != nil { - t.Fatalf("writeChangeJSON: %v", err) - } - before := listChangeFolderSnapshot(t, folder) - err := Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}.Run([]string{"change", "init", "promo-jsononly"}) - if err == nil || !strings.Contains(err.Error(), "brief.md is missing") { - t.Fatalf("err = %v, want missing brief", err) - } - assertChangeFolderUntouched(t, folder, before) - }) - - t.Run("hybrid-layout", func(t *testing.T) { - repo := initCLIGitRepo(t) - folder := changeInitCaptureFolder(t, repo, "promo-hybrid") - if err := os.WriteFile(filepath.Join(folder, "change.md"), []byte("---\nchange: promo-hybrid\n---\n"), 0o644); err != nil { - t.Fatalf("WriteFile change.md: %v", err) - } - before := listChangeFolderSnapshot(t, folder) - err := Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}.Run([]string{"change", "init", "promo-hybrid"}) - if err == nil || !strings.Contains(err.Error(), "hybrid") { - t.Fatalf("err = %v, want hybrid refusal", err) - } - assertChangeFolderUntouched(t, folder, before) - }) - - t.Run("invalid-schema", func(t *testing.T) { - repo := initCLIGitRepo(t) - folder := changeInitCaptureFolder(t, repo, "promo-badmeta") - if err := os.WriteFile(filepath.Join(folder, "change.json"), []byte(`{"change":"promo-badmeta","created":"2026-07-30","branch":"promo-badmeta","status":"todo"}`+"\n"), 0o644); err != nil { - t.Fatalf("WriteFile change.json: %v", err) - } - before := listChangeFolderSnapshot(t, folder) - err := Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}.Run([]string{"change", "init", "promo-badmeta"}) - if err == nil || !strings.Contains(err.Error(), "invalid change.json") { - t.Fatalf("err = %v, want invalid change.json", err) - } - assertChangeFolderUntouched(t, folder, before) - }) - - t.Run("diverged-tasks", func(t *testing.T) { - repo := initCLIGitRepo(t) - folder := changeInitCaptureFolder(t, repo, "promo-diverged") - tasksDir := filepath.Join(folder, "tasks") - if err := os.MkdirAll(tasksDir, 0o755); err != nil { - t.Fatalf("MkdirAll: %v", err) - } - if err := os.WriteFile(filepath.Join(tasksDir, changeSeedTaskFile), []byte("not the seed\n"), 0o644); err != nil { - t.Fatalf("WriteFile diverged seed: %v", err) - } - before := listChangeFolderSnapshot(t, folder) - err := Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}.Run([]string{"change", "init", "promo-diverged"}) - if err == nil || !strings.Contains(err.Error(), "diverged") { - t.Fatalf("err = %v, want diverged tasks refusal", err) - } - assertChangeFolderUntouched(t, folder, before) - }) - - t.Run("materialized", func(t *testing.T) { - repo := initCLIGitRepo(t) - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "init", "promo-full"}); err != nil { - t.Fatalf("full init: %v", err) - } - today := time.Now().Format("20060102") - folder := filepath.Join(repo, "docs", "changes", today+"-promo-full") - before := listChangeFolderSnapshot(t, folder) - err := Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}.Run([]string{"change", "init", "promo-full"}) - if err == nil || !strings.Contains(err.Error(), "already exists") { - t.Fatalf("err = %v, want already exists for materialized", err) - } - assertChangeFolderUntouched(t, folder, before) - }) -} - -func TestPublishChangeFileExclusiveRefusesOverwrite(t *testing.T) { - dir := t.TempDir() - dest := filepath.Join(dir, "shape.md") - if err := os.WriteFile(dest, []byte("original\n"), 0o644); err != nil { - t.Fatalf("WriteFile: %v", err) - } - err := publishChangeFileExclusive(dest, []byte("replacement\n")) - if err == nil || !strings.Contains(err.Error(), "refusing to overwrite") { - t.Fatalf("err = %v, want refusing to overwrite", err) - } - if got := string(readChangeFileBytes(t, dest)); got != "original\n" { - t.Fatalf("dest mutated: %q", got) - } -} - -func listChangeFolderSnapshot(t *testing.T, folder string) map[string][]byte { - t.Helper() - snap := map[string][]byte{} - err := filepath.Walk(folder, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if info.IsDir() { - return nil - } - rel, err := filepath.Rel(folder, path) - if err != nil { - return err - } - body, err := os.ReadFile(path) - if err != nil { - return err - } - snap[filepath.ToSlash(rel)] = body - return nil - }) - if err != nil { - t.Fatalf("snapshot walk: %v", err) - } - return snap -} - -func assertChangeFolderUntouched(t *testing.T, folder string, before map[string][]byte) { - t.Helper() - after := listChangeFolderSnapshot(t, folder) - if len(before) != len(after) { - t.Fatalf("folder file count changed: before=%d after=%d", len(before), len(after)) - } - for rel, body := range before { - got, ok := after[rel] - if !ok { - t.Fatalf("file vanished: %s", rel) - } - if !bytes.Equal(body, got) { - t.Fatalf("file mutated: %s", rel) - } - } -} - -func TestChangeInitRejectsBadSlug(t *testing.T) { - for _, slug := range []string{"Bad", "under_score", "with space", "trailing-", "-leading", "double--hyphen", ""} { - t.Run(slug, func(t *testing.T) { - repo := initCLIGitRepo(t) - args := []string{"change", "init"} - if slug != "" { - args = append(args, slug) - } - err := Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}.Run(args) - if err == nil { - t.Fatalf("err = nil, want rejection for slug %q", slug) - } - }) - } -} - -// --- Embedded-template drift gate ------------------------------------------ - -func TestChangeTemplateMatchesCanonicalContent(t *testing.T) { - canonical, err := os.ReadFile(filepath.Join("..", "..", "content", "skills", "shape", "templates", "change.md")) - if err != nil { - t.Fatalf("ReadFile(canonical change template) error = %v", err) - } - if changeTemplate != string(canonical) { - t.Fatalf("embedded change_template.md drifted from content/skills/shape/templates/change.md; re-copy the canonical file") - } -} - -func TestChangeScaffoldTemplatesMatchCanonical(t *testing.T) { - cases := []struct { - name string - embedded string - rel string - }{ - {"shape", changeShapeTemplate, "shape.md"}, - {"brief", changeBriefTemplate, "brief.md"}, - {"plan", changePlanTemplate, "plan.md"}, - {"design", changeDesignTemplate, "design.md"}, - {"task", changeTaskTemplate, "task.md"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - canonical, err := os.ReadFile(filepath.Join("..", "..", "content", "skills", "shape", "templates", tc.rel)) - if err != nil { - t.Fatalf("ReadFile(%s) error = %v", tc.rel, err) - } - if tc.embedded != string(canonical) { - t.Fatalf("embedded change_%s_template.md drifted from content/skills/shape/templates/%s", tc.name, tc.rel) - } - }) - } -} - -func TestChangeReportNewHappyPath(t *testing.T) { - repo := initCLIGitRepo(t) - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "init", "report-demo"}); err != nil { - t.Fatalf("init error = %v", err) - } - today := time.Now().Format("20060102") - folder := filepath.Join("docs", "changes", today+"-report-demo") - var stdout bytes.Buffer - if err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "report", "new", "shaping", "--kind", "approval", folder}); err != nil { - t.Fatalf("report new error = %v", err) - } - output := stdout.String() - if !strings.Contains(output, "Created report:") { - t.Fatalf("output = %q, want Created report", output) - } - if !strings.Contains(output, "Design language") { - t.Fatalf("output = %q, want design-language guidance", output) - } - matches, err := filepath.Glob(filepath.Join(repo, folder, "reports", "*-approval-shaping.html")) - if err != nil { - t.Fatalf("Glob error = %v", err) - } - if len(matches) != 1 { - t.Fatalf("want 1 approval report, got %v", matches) - } - body, err := os.ReadFile(matches[0]) - if err != nil { - t.Fatalf("ReadFile error = %v", err) - } - content := string(body) - for _, want := range []string{ - `") { - t.Fatalf("render content = %q, want body and stamp", text) - } -} - -func TestRunnerSpecFinalizeWritesTrackedRender(t *testing.T) { - workingDir := realpath(t, t.TempDir()) - stateHome := t.TempDir() - writeCLIAgentsFile(t, workingDir, "specs/SPEC-001-finalize.md", `--- -id: SPEC-001 -title: Finalize Spec -status: implementing ---- -# Finalize Spec - -Spec body. -`) - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "migrate", "markdown", "--apply"}); err != nil { - t.Fatalf("state migrate markdown --apply error = %v", err) - } - - var jsonOut bytes.Buffer - err := Runner{ - Stdout: &jsonOut, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"spec", "finalize", "SPEC-001", "--json"}) - if err != nil { - t.Fatalf("spec finalize --json error = %v", err) - } - var result state.DurableFinalizeResult - if err := json.Unmarshal(jsonOut.Bytes(), &result); err != nil { - t.Fatalf("json.Unmarshal(%q) error = %v", jsonOut.String(), err) - } - if result.Kind != "spec" || result.Ref != "SPEC-001" || result.RelativePath != ".agents/specs/SPEC-001-finalize.md" { - t.Fatalf("result = %#v, want tracked spec finalize path", result) - } - content, err := os.ReadFile(filepath.Join(workingDir, filepath.FromSlash(result.RelativePath))) - if err != nil { - t.Fatalf("read finalized spec error = %v", err) - } - text := string(content) - if !strings.Contains(text, "# Finalize Spec") || !strings.Contains(text, "") { - t.Fatalf("finalized spec = %q, want body and render stamp", text) - } -} - func TestRunnerRenderSweepScansCommittedRendersWithoutDatabase(t *testing.T) { workingDir := realpath(t, t.TempDir()) writeCLIAgentsFile(t, workingDir, "specs/SPEC-001-sweep.md", `--- @@ -2917,7 +2824,7 @@ func TestRunnerTaskStatusErrorsNameValidStatuses(t *testing.T) { want string }{ {name: "task list", args: []string{"task", "list", "--status", "open"}, want: `invalid status "open" (valid: in_progress, blocked, todo, review, done, archived)`}, - {name: "task update", args: []string{"task", "update", "TASK-001", "--status", "archived"}, want: `invalid status "archived" (valid: in_progress, blocked, todo, review, done)`}, + {name: "task update", args: []string{"task", "update", "TASK-001", "--status", "archived"}, want: "frozen pending migration"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -2972,8 +2879,8 @@ func TestRunnerTaskPriorityErrorsNameValidPriorities(t *testing.T) { args []string want string }{ - {name: "task create", args: []string{"task", "create", "--title", "Bad", "--priority", "P9"}, want: `invalid priority "P9" (valid: P0, P1, P2, P3)`}, - {name: "task update", args: []string{"task", "update", "TASK-001", "--priority", "P9"}, want: `invalid priority "P9" (valid: P0, P1, P2, P3)`}, + {name: "task create", args: []string{"task", "create", "--title", "Bad", "--priority", "P9"}, want: "frozen pending migration"}, + {name: "task update", args: []string{"task", "update", "TASK-001", "--priority", "P9"}, want: "frozen pending migration"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -3009,19 +2916,19 @@ func TestRunnerTaskJSONValidationErrorsAreMachineReadable(t *testing.T) { name: "create invalid priority", args: []string{"task", "create", "--title", "Bad", "--priority", "P9", "--json"}, command: "task create", - want: `invalid priority "P9"`, + want: "frozen pending migration", }, { name: "update invalid status", args: []string{"task", "update", "TASK-001", "--status", "archived", "--json"}, - command: "task update", - want: `invalid status "archived"`, + command: "task update TASK-001", + want: "frozen pending migration", }, { name: "update invalid priority", args: []string{"task", "update", "TASK-001", "--priority", "P9", "--json"}, - command: "task update", - want: `invalid priority "P9"`, + command: "task update TASK-001", + want: "frozen pending migration", }, } @@ -6235,7 +6142,6 @@ func TestRunnerHybridCommandHelpAndUnknownSubcommandsAreNative(t *testing.T) { wantSubcommand string }{ {command: "task", wantHelp: "Usage: loaf task ", wantSubcommand: "create"}, - {command: "spec", wantHelp: "Usage: loaf spec ", wantSubcommand: "list"}, {command: "report", wantHelp: "Usage: loaf report ", wantSubcommand: "generate"}, } @@ -6629,9 +6535,18 @@ status: open } run("state", "migrate", "markdown", "--apply") - run("task", "create", "--title", "Matrix Task", "--spec", "SPEC-001", "--json") - run("task", "update", "TASK-001", "--status", "in_progress", "--json") - run("task", "archive", "TASK-002", "--json") + runFrozenTaskWrite(t, workingDir, stateHome, "task", "create", "--title", "Matrix Task", "--spec", "SPEC-001", "--json") + ctx := context.Background() + resolver := state.PathResolver{StateHome: stateHome} + if _, err := state.CreateTask(ctx, root, resolver, state.TaskCreateOptions{Title: "Matrix Task", Spec: "SPEC-001"}); err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + if _, err := state.UpdateTaskStatus(ctx, root, resolver, "TASK-001", "in_progress"); err != nil { + t.Fatalf("UpdateTaskStatus() error = %v", err) + } + if _, err := state.ArchiveTasks(ctx, root, resolver, state.TaskArchiveOptions{Refs: []string{"TASK-002"}}); err != nil { + t.Fatalf("ArchiveTasks() error = %v", err) + } run("idea", "capture", "--title", "Matrix Idea", "--json") run("idea", "promote", "20260528-source-idea", "--to-spec", "SPEC-001", "--json") run("idea", "resolve", "20260528-source-idea", "--by", "SPEC-001", "--json") @@ -6640,7 +6555,9 @@ status: open run("spark", "resolve", "SPARK-matrix", "--by", "20260528-target-idea", "--reason", "matrix resolved", "--json") run("brainstorm", "promote", "20260528-brainstorm-matrix", "--to-idea", "20260528-target-idea", "--json") run("brainstorm", "archive", "20260528-brainstorm-matrix", "--reason", "matrix archived", "--json") - run("spec", "archive", "SPEC-002", "--json") + if _, err := state.ArchiveSpecs(context.Background(), root, state.PathResolver{StateHome: stateHome}, []string{"SPEC-002"}); err != nil { + t.Fatalf("ArchiveSpecs(SPEC-002) error = %v", err) + } run("journal", "log", "--json", "--harness-session-id", "matrix-harness", "decision(sqlite): matrix write") run("tag", "add", "SPEC-001", "matrix", "--json") run("tag", "remove", "SPEC-001", "matrix", "--json") @@ -8179,39 +8096,19 @@ func TestRunnerTaskCreateUsesSQLiteStateWhenInitialized(t *testing.T) { t.Fatalf("state migrate markdown --apply error = %v", err) } - var createOut bytes.Buffer - err := Runner{ - Stdout: &createOut, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"task", "create", "--title", "Created Task", "--spec", "SPEC-001", "--priority", "P1", "--depends-on", "TASK-001", "--json"}) + runFrozenTaskWrite(t, workingDir, stateHome, "task", "create", "--title", "Created Task", "--spec", "SPEC-001", "--priority", "P1", "--depends-on", "TASK-001", "--json") + root, err := project.ResolveRoot(workingDir) if err != nil { - t.Fatalf("task create --json error = %v", err) - } - created := decodeTaskCreateResult(t, createOut.Bytes()) - if created.Task.Alias != "TASK-002" || created.Task.Title != "Created Task" || created.Task.Status != "todo" || created.Priority != "P1" || created.Spec == nil || created.Spec.Alias != "SPEC-001" || created.EventID == "" { - t.Fatalf("created = %#v, want TASK-002 under SPEC-001", created) - } - if created.ContractVersion != state.StateJSONContractVersion { - t.Fatalf("created ContractVersion = %d, want %d", created.ContractVersion, state.StateJSONContractVersion) - } - if created.DatabaseScope != "global" { - t.Fatalf("created DatabaseScope = %q, want global", created.DatabaseScope) - } - if created.DatabasePath == "" { - t.Fatal("created DatabasePath is empty") - } - if created.ProjectID == "" { - t.Fatal("created ProjectID is empty") - } - if created.ProjectName != filepath.Base(workingDir) { - t.Fatalf("created ProjectName = %q, want %q", created.ProjectName, filepath.Base(workingDir)) + t.Fatalf("ResolveRoot() error = %v", err) } - if created.ProjectCurrentPath != workingDir { - t.Fatalf("created ProjectCurrentPath = %q, want %q", created.ProjectCurrentPath, workingDir) + created, err := state.CreateTask(context.Background(), root, state.PathResolver{StateHome: stateHome}, state.TaskCreateOptions{ + Title: "Created Task", Spec: "SPEC-001", Priority: "P1", DependsOn: []string{"TASK-001"}, + }) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) } - if len(created.Depends) != 1 || created.Depends[0].Alias != "TASK-001" { - t.Fatalf("created.Depends = %#v, want TASK-001", created.Depends) + if created.Task.Alias != "TASK-002" { + t.Fatalf("seeded alias = %q, want TASK-002", created.Task.Alias) } var showOut bytes.Buffer @@ -8253,20 +8150,17 @@ func TestRunnerTaskCreateHumanUsesSQLiteStateWhenInitialized(t *testing.T) { t.Fatalf("state init error = %v", err) } - var stdout bytes.Buffer - err := Runner{ - Stdout: &stdout, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"task", "create", "--title", "Human Task"}) + runFrozenTaskWrite(t, workingDir, stateHome, "task", "create", "--title", "Human Task") + root, err := project.ResolveRoot(workingDir) if err != nil { - t.Fatalf("task create human error = %v", err) + t.Fatalf("ResolveRoot() error = %v", err) } - output := stdout.String() - for _, want := range []string{"created task TASK-001: Human Task", "scope: global database", "database:", "project:", "project name:", "project path:", "status: todo", "priority: P2", "event:"} { - if !strings.Contains(output, want) { - t.Fatalf("output = %q, want %q", output, want) - } + listed, err := state.ListTasks(context.Background(), root, state.PathResolver{StateHome: stateHome}, state.TaskListOptions{}) + if err != nil { + t.Fatalf("ListTasks() error = %v", err) + } + if len(listed.Tasks) != 0 { + t.Fatalf("tasks after frozen create = %#v, want none written", listed.Tasks) } } @@ -8277,22 +8171,7 @@ func TestRunnerTaskCreateJSONOmitsEmptySpecWhenInitialized(t *testing.T) { t.Fatalf("state init error = %v", err) } - var stdout bytes.Buffer - err := Runner{ - Stdout: &stdout, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"task", "create", "--title", "No Spec Task", "--json"}) - if err != nil { - t.Fatalf("task create --json error = %v", err) - } - created := decodeTaskCreateResult(t, stdout.Bytes()) - if created.Spec != nil { - t.Fatalf("created.Spec = %#v, want nil", created.Spec) - } - if bytes.Contains(stdout.Bytes(), []byte(`"spec"`)) { - t.Fatalf("output = %s, want spec omitted", stdout.String()) - } + runFrozenTaskWrite(t, workingDir, stateHome, "task", "create", "--title", "No Spec Task", "--json") } func TestRunnerTaskShowJSONUsesSQLiteStateWhenInitialized(t *testing.T) { @@ -8546,107 +8425,13 @@ func TestRunnerTaskCreateUsesMarkdownIndexWhenMarkdownOnly(t *testing.T) { }, "custom_root": "preserve me" }`) - var createOut bytes.Buffer - err := Runner{ - Stdout: &createOut, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"task", "create", "--title", "Created Task!", "--spec", "SPEC-001", "--priority", "P1", "--depends-on", "TASK-001", "--json"}) - if err != nil { - t.Fatalf("task create markdown --json error = %v", err) - } - created := decodeTaskCreateResult(t, createOut.Bytes()) - if created.Task.Alias != "TASK-002" || created.Task.Title != "Created Task!" || created.Task.Status != "todo" || created.Priority != "P1" || created.Spec == nil || created.Spec.Alias != "SPEC-001" { - t.Fatalf("created = %#v, want TASK-002 under SPEC-001", created) - } - if len(created.Depends) != 1 || created.Depends[0].Alias != "TASK-001" { - t.Fatalf("created.Depends = %#v, want TASK-001", created.Depends) - } - if created.ContractVersion != state.StateJSONContractVersion { - t.Fatalf("created ContractVersion = %d, want %d", created.ContractVersion, state.StateJSONContractVersion) - } - if created.DatabaseScope != "" || created.DatabasePath != "" || created.ProjectID != "" || created.ProjectName != "" || created.ProjectCurrentPath != "" { - t.Fatalf("created database context = %#v, want empty for markdown fallback", created) - } - - var index map[string]any - rawIndex, err := os.ReadFile(filepath.Join(workingDir, ".agents", "TASKS.json")) + runFrozenTaskWrite(t, workingDir, stateHome, "task", "create", "--title", "Created Task!", "--spec", "SPEC-001", "--priority", "P1", "--depends-on", "TASK-001", "--json") + before, err := os.ReadFile(filepath.Join(workingDir, ".agents", "TASKS.json")) if err != nil { t.Fatalf("ReadFile(TASKS.json) error = %v", err) } - if err := json.Unmarshal(rawIndex, &index); err != nil { - t.Fatalf("Unmarshal(TASKS.json) error = %v", err) - } - if index["custom_root"] != "preserve me" { - t.Fatalf("index custom_root = %#v, want preserved", index["custom_root"]) - } - if int(index["next_id"].(float64)) != 3 { - t.Fatalf("next_id = %#v, want 3", index["next_id"]) - } - tasks := index["tasks"].(map[string]any) - task := tasks["TASK-002"].(map[string]any) - if task["title"] != "Created Task!" || task["slug"] != "created-task" || task["status"] != "todo" || task["priority"] != "P1" || task["spec"] != "SPEC-001" || task["file"] != "TASK-002-created-task.md" { - t.Fatalf("TASK-002 index = %#v, want created metadata", task) - } - deps := task["depends_on"].([]any) - if len(deps) != 1 || deps[0] != "TASK-001" { - t.Fatalf("depends_on = %#v, want TASK-001", deps) - } - existing := tasks["TASK-001"].(map[string]any) - files := existing["files"].([]any) - if len(files) != 1 || files[0] != "keep.go" { - t.Fatalf("existing task = %#v, want unknown fields preserved", existing) - } - spec := index["specs"].(map[string]any)["SPEC-001"].(map[string]any) - if spec["requirement"] != "preserve spec field" { - t.Fatalf("spec = %#v, want unknown spec fields preserved", spec) - } - - taskFile := filepath.Join(workingDir, ".agents", "tasks", "TASK-002-created-task.md") - body, err := os.ReadFile(taskFile) - if err != nil { - t.Fatalf("ReadFile(created task) error = %v", err) - } - frontmatter, ok := parseKnowledgeFrontmatter(body) - if !ok { - t.Fatal("created task frontmatter missing") - } - if firstFieldValue(frontmatter["id"]) != "TASK-002" || firstFieldValue(frontmatter["title"]) != "Created Task!" || firstFieldValue(frontmatter["spec"]) != "SPEC-001" || !frontmatter["depends_on"].Array || strings.Join(frontmatter["depends_on"].Values, ",") != "TASK-001" { - t.Fatalf("frontmatter = %#v, want created task metadata", frontmatter) - } - content := markdownContentWithoutFrontmatter(string(body)) - if !strings.Contains(content, "# TASK-002: Created Task!") || !strings.Contains(content, "## Acceptance Criteria") || !strings.Contains(content, "## Verification") { - t.Fatalf("content = %q, want task scaffold body", content) - } - - var humanOut bytes.Buffer - err = Runner{ - Stdout: &humanOut, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"task", "create", "--title", "Human Task"}) - if err != nil { - t.Fatalf("task create markdown human error = %v", err) - } - if !strings.Contains(humanOut.String(), "created task TASK-003: Human Task") || !strings.Contains(humanOut.String(), "priority: P2") { - t.Fatalf("human output = %q, want created task summary", humanOut.String()) - } - - err = Runner{ - Stdout: &bytes.Buffer{}, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"task", "create", "--title", "Bad", "--spec", "SPEC-999"}) - if err == nil || !strings.Contains(err.Error(), "Spec \"SPEC-999\" not found in index") { - t.Fatalf("missing spec error = %v, want index validation", err) - } - err = Runner{ - Stdout: &bytes.Buffer{}, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"task", "create", "--title", "Bad", "--depends-on", "TASK-999"}) - if err == nil || !strings.Contains(err.Error(), "Dependency \"TASK-999\" not found in index") { - t.Fatalf("missing dependency error = %v, want index validation", err) + if !bytes.Contains(before, []byte(`"next_id": 2`)) { + t.Fatalf("TASKS.json changed after frozen create:\n%s", before) } assertNoStateDatabase(t, workingDir, stateHome) } @@ -8779,17 +8564,7 @@ func TestRunnerTaskCreateReportsValidationAndInvalidSQLiteState(t *testing.T) { t.Fatalf("state init error = %v", err) } - err := Runner{ - Stdout: &bytes.Buffer{}, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"task", "create", "--title", "Bad", "--priority", "PX"}) - if err == nil { - t.Fatal("task create invalid priority error = nil, want error") - } - if !strings.Contains(err.Error(), "invalid priority") { - t.Fatalf("error = %v, want invalid priority", err) - } + runFrozenTaskWrite(t, workingDir, stateHome, "task", "create", "--title", "Bad", "--priority", "PX") if _, err := parseTaskCreateArgs([]string{"--title", "--json"}); err == nil || !strings.Contains(err.Error(), "--title requires a value") { t.Fatalf("parseTaskCreateArgs flag value error = %v, want --title requires a value", err) @@ -8810,17 +8585,7 @@ func TestRunnerTaskCreateReportsValidationAndInvalidSQLiteState(t *testing.T) { t.Fatalf("WriteFile() error = %v", err) } - err = Runner{ - Stdout: &bytes.Buffer{}, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"task", "create", "--title", "Created"}) - if err == nil { - t.Fatal("task create invalid state error = nil, want error") - } - if !strings.Contains(err.Error(), "state database is invalid") { - t.Fatalf("error = %v, want invalid state error", err) - } + runFrozenTaskWrite(t, workingDir, stateHome, "task", "create", "--title", "Created") } func TestRunnerTaskShowReportsInvalidSQLiteStateAndMissingTargets(t *testing.T) { @@ -8893,36 +8658,13 @@ func TestRunnerTaskUpdateStatusUsesSQLiteStateWhenInitialized(t *testing.T) { t.Fatalf("state migrate markdown --apply error = %v", err) } - var updateOut bytes.Buffer - err := Runner{ - Stdout: &updateOut, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"task", "update", "TASK-001", "--status", "in_progress", "--json"}) + runFrozenTaskWrite(t, workingDir, stateHome, "task", "update", "TASK-001", "--status", "in_progress", "--json") + root, err := project.ResolveRoot(workingDir) if err != nil { - t.Fatalf("task update --status error = %v", err) - } - updated := decodeTaskStatusUpdateResult(t, updateOut.Bytes()) - if updated.Task.Alias != "TASK-001" || updated.Previous != "todo" || updated.Status != "in_progress" || updated.EventID == "" { - t.Fatalf("updated = %#v, want TASK-001 todo -> in_progress", updated) - } - if updated.ContractVersion != state.StateJSONContractVersion { - t.Fatalf("updated ContractVersion = %d, want %d", updated.ContractVersion, state.StateJSONContractVersion) - } - if updated.DatabaseScope != "global" { - t.Fatalf("updated DatabaseScope = %q, want global", updated.DatabaseScope) - } - if updated.DatabasePath == "" { - t.Fatal("updated DatabasePath is empty") - } - if updated.ProjectID == "" { - t.Fatal("updated ProjectID is empty") - } - if updated.ProjectName != filepath.Base(workingDir) { - t.Fatalf("updated ProjectName = %q, want %q", updated.ProjectName, filepath.Base(workingDir)) + t.Fatalf("ResolveRoot() error = %v", err) } - if updated.ProjectCurrentPath != workingDir { - t.Fatalf("updated ProjectCurrentPath = %q, want %q", updated.ProjectCurrentPath, workingDir) + if _, err := state.UpdateTaskStatus(context.Background(), root, state.PathResolver{StateHome: stateHome}, "TASK-001", "in_progress"); err != nil { + t.Fatalf("UpdateTaskStatus() error = %v", err) } var listOut bytes.Buffer @@ -8969,21 +8711,15 @@ func TestRunnerTaskUpdateMetadataUsesSQLiteStateWhenInitialized(t *testing.T) { t.Fatalf("state migrate markdown --apply error = %v", err) } - var updateOut bytes.Buffer - err := Runner{ - Stdout: &updateOut, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"task", "update", "TASK-001", "--priority", "P0", "--spec", "SPEC-002", "--depends-on", "TASK-002", "--json"}) + runFrozenTaskWrite(t, workingDir, stateHome, "task", "update", "TASK-001", "--priority", "P0", "--spec", "SPEC-002", "--depends-on", "TASK-002", "--json") + root, err := project.ResolveRoot(workingDir) if err != nil { - t.Fatalf("task update metadata error = %v", err) - } - updated := decodeTaskStatusUpdateResult(t, updateOut.Bytes()) - if updated.Priority != "P0" || updated.Spec == nil || updated.Spec.Alias != "SPEC-002" { - t.Fatalf("updated = %#v, want priority/spec update", updated) + t.Fatalf("ResolveRoot() error = %v", err) } - if len(updated.Depends) != 1 || updated.Depends[0].Alias != "TASK-002" { - t.Fatalf("updated.Depends = %#v, want TASK-002", updated.Depends) + if _, err := state.UpdateTask(context.Background(), root, state.PathResolver{StateHome: stateHome}, state.TaskUpdateOptions{ + Ref: "TASK-001", Priority: "P0", SetPriority: true, Spec: "SPEC-002", SetSpec: true, DependsOn: []string{"TASK-002"}, SetDependsOn: true, + }); err != nil { + t.Fatalf("UpdateTask() error = %v", err) } var showOut bytes.Buffer @@ -9017,18 +8753,11 @@ func TestRunnerTaskUpdateMetadataUsesSQLiteStateWhenInitialized(t *testing.T) { t.Fatalf("trace relationships = %#v, want spec and dependency relationships", trace.Relationships) } - var clearOut bytes.Buffer - err = Runner{ - Stdout: &clearOut, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"task", "update", "TASK-001", "--spec", "none", "--depends-on", "none"}) - if err != nil { - t.Fatalf("task update clear metadata error = %v", err) - } - output := clearOut.String() - if !strings.Contains(output, "updated task TASK-001") || !strings.Contains(output, "priority: P0") { - t.Fatalf("output = %q, want human update summary", output) + runFrozenTaskWrite(t, workingDir, stateHome, "task", "update", "TASK-001", "--spec", "none", "--depends-on", "none") + if _, err := state.UpdateTask(context.Background(), root, state.PathResolver{StateHome: stateHome}, state.TaskUpdateOptions{ + Ref: "TASK-001", Spec: "none", SetSpec: true, DependsOn: []string{"none"}, SetDependsOn: true, + }); err != nil { + t.Fatalf("UpdateTask(clear) error = %v", err) } showOut.Reset() err = Runner{ @@ -9093,231 +8822,74 @@ Preserve this body. "custom_root": "preserve me" }`) - var updateOut bytes.Buffer - err := Runner{ - Stdout: &updateOut, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"task", "update", "TASK-001", "--status", "done", "--priority", "P0", "--spec", "SPEC-002", "--depends-on", "TASK-003", "--json"}) + runFrozenTaskWrite(t, workingDir, stateHome, "task", "update", "TASK-001", "--status", "done", "--priority", "P0", "--spec", "SPEC-002", "--depends-on", "TASK-003", "--json") + rawIndex, err := os.ReadFile(filepath.Join(workingDir, ".agents", "TASKS.json")) if err != nil { - t.Fatalf("task update markdown --json error = %v", err) - } - updated := decodeTaskStatusUpdateResult(t, updateOut.Bytes()) - if updated.Task.Alias != "TASK-001" || updated.Previous != "todo" || updated.Status != "done" || updated.Priority != "P0" || updated.Spec == nil || updated.Spec.Alias != "SPEC-002" { - t.Fatalf("updated = %#v, want markdown metadata update", updated) - } - if len(updated.Depends) != 1 || updated.Depends[0].Alias != "TASK-003" { - t.Fatalf("updated.Depends = %#v, want TASK-003", updated.Depends) + t.Fatalf("ReadFile(TASKS.json) error = %v", err) } - if updated.ContractVersion != state.StateJSONContractVersion { - t.Fatalf("updated ContractVersion = %d, want %d", updated.ContractVersion, state.StateJSONContractVersion) + if !bytes.Contains(rawIndex, []byte(`"priority": "P2"`)) { + t.Fatalf("TASKS.json mutated after frozen update:\n%s", rawIndex) } - if updated.DatabaseScope != "" || updated.DatabasePath != "" || updated.ProjectID != "" || updated.ProjectName != "" || updated.ProjectCurrentPath != "" { - t.Fatalf("updated database context = %#v, want empty for markdown fallback", updated) + assertNoStateDatabase(t, workingDir, stateHome) +} + +func TestRunnerTaskUpdateReportsValidationAndInvalidSQLiteState(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + writeCLIAgentsFile(t, workingDir, "tasks/TASK-001-status.md", "# Status Task\n") + writeCLIAgentsFile(t, workingDir, "TASKS.json", `{"tasks":{"TASK-001":{"title":"Status Task","status":"todo","priority":"P1"}}}`) + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "migrate", "markdown", "--apply"}); err != nil { + t.Fatalf("state migrate markdown --apply error = %v", err) } - rawIndex, err := os.ReadFile(filepath.Join(workingDir, ".agents", "TASKS.json")) + runFrozenTaskWrite(t, workingDir, stateHome, "task", "update", "TASK-001") + runFrozenTaskWrite(t, workingDir, stateHome, "task", "update", "TASK-001", "--priority", "P9") + + stateHome = t.TempDir() + root, err := project.ResolveRoot(workingDir) if err != nil { - t.Fatalf("ReadFile(TASKS.json) error = %v", err) + t.Fatalf("ResolveRoot() error = %v", err) } - var index map[string]any - if err := json.Unmarshal(rawIndex, &index); err != nil { - t.Fatalf("Unmarshal(TASKS.json) error = %v", err) + databasePath, err := (state.PathResolver{StateHome: stateHome}).DatabasePath(root) + if err != nil { + t.Fatalf("DatabasePath() error = %v", err) } - if index["custom_root"] != "preserve me" { - t.Fatalf("index custom_root = %#v, want preserved", index["custom_root"]) + if err := os.MkdirAll(filepath.Dir(databasePath), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) } - task := index["tasks"].(map[string]any)["TASK-001"].(map[string]any) - if task["status"] != "done" || task["priority"] != "P0" || task["spec"] != "SPEC-002" || task["completed_at"] == nil || task["verify"] != "go test ./..." { - t.Fatalf("TASK-001 index = %#v, want updated metadata with unknown fields preserved", task) + if err := os.WriteFile(databasePath, []byte("not sqlite"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) } - deps := task["depends_on"].([]any) - if len(deps) != 1 || deps[0] != "TASK-003" { - t.Fatalf("depends_on = %#v, want TASK-003", deps) + + runFrozenTaskWrite(t, workingDir, stateHome, "task", "update", "TASK-001", "--status", "done") +} + +func TestRunnerTaskArchiveUsesSQLiteStateWhenInitialized(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + writeCLIAgentsFile(t, workingDir, "specs/SPEC-001-archive.md", "# Archive Spec\n") + writeCLIAgentsFile(t, workingDir, "tasks/TASK-001-done.md", "# Done Task\n") + writeCLIAgentsFile(t, workingDir, "tasks/TASK-002-todo.md", "# Todo Task\n") + writeCLIAgentsFile(t, workingDir, "TASKS.json", `{"tasks":{ + "TASK-001":{"title":"Done Task","spec":"SPEC-001","status":"done","priority":"P1"}, + "TASK-002":{"title":"Todo Task","spec":"SPEC-001","status":"todo","priority":"P2"} +}}`) + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "migrate", "markdown", "--apply"}); err != nil { + t.Fatalf("state migrate markdown --apply error = %v", err) } - body, err := os.ReadFile(filepath.Join(workingDir, ".agents", "tasks", "TASK-001-update.md")) + + runFrozenTaskWrite(t, workingDir, stateHome, "task", "archive", "TASK-001", "TASK-002", "SPEC-001", "TASK-999", "--json") + root, err := project.ResolveRoot(workingDir) if err != nil { - t.Fatalf("ReadFile(updated task) error = %v", err) - } - frontmatter, ok := parseKnowledgeFrontmatter(body) - if !ok { - t.Fatal("updated task frontmatter missing") - } - if firstFieldValue(frontmatter["status"]) != "done" || firstFieldValue(frontmatter["priority"]) != "P0" || firstFieldValue(frontmatter["spec"]) != "SPEC-002" || strings.Join(frontmatter["depends_on"].Values, ",") != "TASK-003" { - t.Fatalf("frontmatter = %#v, want synced updated metadata", frontmatter) + t.Fatalf("ResolveRoot() error = %v", err) } - if !strings.Contains(markdownContentWithoutFrontmatter(string(body)), "Preserve this body.") { - t.Fatalf("body = %q, want preserved task body", markdownContentWithoutFrontmatter(string(body))) + if _, err := state.ArchiveTasks(context.Background(), root, state.PathResolver{StateHome: stateHome}, state.TaskArchiveOptions{Refs: []string{"TASK-001"}}); err != nil { + t.Fatalf("ArchiveTasks() error = %v", err) } - var clearOut bytes.Buffer + var listOut bytes.Buffer err = Runner{ - Stdout: &clearOut, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"task", "update", "TASK-001", "--status", "todo", "--spec", "none", "--depends-on", "none", "--json"}) - if err != nil { - t.Fatalf("task update markdown clear error = %v", err) - } - cleared := decodeTaskStatusUpdateResult(t, clearOut.Bytes()) - if cleared.Previous != "done" || cleared.Status != "todo" || cleared.Spec != nil || len(cleared.Depends) != 0 { - t.Fatalf("cleared = %#v, want cleared metadata", cleared) - } - rawIndex, err = os.ReadFile(filepath.Join(workingDir, ".agents", "TASKS.json")) - if err != nil { - t.Fatalf("ReadFile(TASKS.json after clear) error = %v", err) - } - if err := json.Unmarshal(rawIndex, &index); err != nil { - t.Fatalf("Unmarshal(TASKS.json after clear) error = %v", err) - } - task = index["tasks"].(map[string]any)["TASK-001"].(map[string]any) - if task["status"] != "todo" || task["completed_at"] != nil || task["spec"] != nil || len(task["depends_on"].([]any)) != 0 { - t.Fatalf("TASK-001 after clear = %#v, want cleared index metadata", task) - } - body, err = os.ReadFile(filepath.Join(workingDir, ".agents", "tasks", "TASK-001-update.md")) - if err != nil { - t.Fatalf("ReadFile(cleared task) error = %v", err) - } - frontmatter, ok = parseKnowledgeFrontmatter(body) - if !ok { - t.Fatal("cleared task frontmatter missing") - } - if firstFieldValue(frontmatter["spec"]) != "" || len(frontmatter["depends_on"].Values) != 0 || firstFieldValue(frontmatter["status"]) != "todo" { - t.Fatalf("frontmatter after clear = %#v, want cleared frontmatter metadata", frontmatter) - } - - err = Runner{ - Stdout: &bytes.Buffer{}, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"task", "update", "TASK-001", "--spec", "SPEC-999"}) - if err == nil || !strings.Contains(err.Error(), "Unknown spec") { - t.Fatalf("missing spec error = %v, want unknown spec", err) - } - err = Runner{ - Stdout: &bytes.Buffer{}, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"task", "update", "TASK-001", "--depends-on", "TASK-999"}) - if err == nil || !strings.Contains(err.Error(), "Unknown task ID") { - t.Fatalf("missing dependency error = %v, want unknown dependency", err) - } - assertNoStateDatabase(t, workingDir, stateHome) -} - -func TestRunnerTaskUpdateReportsValidationAndInvalidSQLiteState(t *testing.T) { - workingDir := realpath(t, t.TempDir()) - stateHome := t.TempDir() - writeCLIAgentsFile(t, workingDir, "tasks/TASK-001-status.md", "# Status Task\n") - writeCLIAgentsFile(t, workingDir, "TASKS.json", `{"tasks":{"TASK-001":{"title":"Status Task","status":"todo","priority":"P1"}}}`) - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "migrate", "markdown", "--apply"}); err != nil { - t.Fatalf("state migrate markdown --apply error = %v", err) - } - - err := Runner{ - Stdout: &bytes.Buffer{}, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"task", "update", "TASK-001"}) - if err == nil { - t.Fatal("task update empty update error = nil, want error") - } - if !strings.Contains(err.Error(), "at least one update") { - t.Fatalf("error = %v, want empty update error", err) - } - err = Runner{ - Stdout: &bytes.Buffer{}, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"task", "update", "TASK-001", "--priority", "P9"}) - if err == nil { - t.Fatal("task update invalid priority error = nil, want error") - } - if !strings.Contains(err.Error(), "invalid priority") { - t.Fatalf("error = %v, want invalid priority", err) - } - - stateHome = t.TempDir() - root, err := project.ResolveRoot(workingDir) - if err != nil { - t.Fatalf("ResolveRoot() error = %v", err) - } - databasePath, err := (state.PathResolver{StateHome: stateHome}).DatabasePath(root) - if err != nil { - t.Fatalf("DatabasePath() error = %v", err) - } - if err := os.MkdirAll(filepath.Dir(databasePath), 0o755); err != nil { - t.Fatalf("MkdirAll() error = %v", err) - } - if err := os.WriteFile(databasePath, []byte("not sqlite"), 0o600); err != nil { - t.Fatalf("WriteFile() error = %v", err) - } - - err = Runner{ - Stdout: &bytes.Buffer{}, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"task", "update", "TASK-001", "--status", "done"}) - if err == nil { - t.Fatal("task update invalid state error = nil, want error") - } - if !strings.Contains(err.Error(), "state database is invalid") { - t.Fatalf("error = %v, want invalid state error", err) - } -} - -func TestRunnerTaskArchiveUsesSQLiteStateWhenInitialized(t *testing.T) { - workingDir := realpath(t, t.TempDir()) - stateHome := t.TempDir() - writeCLIAgentsFile(t, workingDir, "specs/SPEC-001-archive.md", "# Archive Spec\n") - writeCLIAgentsFile(t, workingDir, "tasks/TASK-001-done.md", "# Done Task\n") - writeCLIAgentsFile(t, workingDir, "tasks/TASK-002-todo.md", "# Todo Task\n") - writeCLIAgentsFile(t, workingDir, "TASKS.json", `{"tasks":{ - "TASK-001":{"title":"Done Task","spec":"SPEC-001","status":"done","priority":"P1"}, - "TASK-002":{"title":"Todo Task","spec":"SPEC-001","status":"todo","priority":"P2"} -}}`) - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "migrate", "markdown", "--apply"}); err != nil { - t.Fatalf("state migrate markdown --apply error = %v", err) - } - - var archiveOut bytes.Buffer - err := Runner{ - Stdout: &archiveOut, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"task", "archive", "TASK-001", "TASK-002", "SPEC-001", "TASK-999", "--json"}) - if err != nil { - t.Fatalf("task archive --json error = %v", err) - } - archive := decodeTaskArchiveResult(t, archiveOut.Bytes()) - if len(archive.Archived) != 1 || archive.Archived[0].Task == nil || archive.Archived[0].Task.Alias != "TASK-001" || archive.Archived[0].EventID == "" { - t.Fatalf("Archived = %#v, want TASK-001 archived with event", archive.Archived) - } - if archive.ContractVersion != state.StateJSONContractVersion { - t.Fatalf("archive ContractVersion = %d, want %d", archive.ContractVersion, state.StateJSONContractVersion) - } - if archive.DatabaseScope != "global" { - t.Fatalf("archive DatabaseScope = %q, want global", archive.DatabaseScope) - } - if archive.DatabasePath == "" { - t.Fatal("archive DatabasePath is empty") - } - if archive.ProjectID == "" { - t.Fatal("archive ProjectID is empty") - } - if archive.ProjectName != filepath.Base(workingDir) { - t.Fatalf("archive ProjectName = %q, want %q", archive.ProjectName, filepath.Base(workingDir)) - } - if archive.ProjectCurrentPath != workingDir { - t.Fatalf("archive ProjectCurrentPath = %q, want %q", archive.ProjectCurrentPath, workingDir) - } - if len(archive.Skipped) != 3 { - t.Fatalf("Skipped = %#v, want three skipped refs", archive.Skipped) - } - - var listOut bytes.Buffer - err = Runner{ - Stdout: &listOut, + Stdout: &listOut, WorkingDir: workingDir, StateHome: stateHome, }.Run([]string{"task", "list", "--json", "--status", "archived"}) @@ -9370,19 +8942,7 @@ func TestRunnerTaskArchiveUsesSQLiteStateWhenInitialized(t *testing.T) { t.Fatalf("trace status = %q, want archived", trace.Entity.Status) } - var humanOut bytes.Buffer - err = Runner{ - Stdout: &humanOut, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"task", "archive", "TASK-001"}) - if err != nil { - t.Fatalf("task archive human error = %v", err) - } - output := humanOut.String() - if !strings.Contains(output, "loaf task archive") || !strings.Contains(output, "skipped TASK-001: already archived") || !strings.Contains(output, "Skipped 1 task(s)") { - t.Fatalf("output = %q, want already-archived human summary", output) - } + runFrozenTaskWrite(t, workingDir, stateHome, "task", "archive", "TASK-001") } func TestRunnerTaskArchiveBySpecUsesSQLiteStateWhenInitialized(t *testing.T) { @@ -9399,31 +8959,13 @@ func TestRunnerTaskArchiveBySpecUsesSQLiteStateWhenInitialized(t *testing.T) { t.Fatalf("state migrate markdown --apply error = %v", err) } - var archiveOut bytes.Buffer - err := Runner{ - Stdout: &archiveOut, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"task", "archive", "--spec", "SPEC-001", "--json"}) - if err != nil { - t.Fatalf("task archive --spec --json error = %v", err) - } - archive := decodeTaskArchiveResult(t, archiveOut.Bytes()) - if archive.Spec == nil || archive.Spec.Alias != "SPEC-001" || len(archive.Archived) != 1 || archive.Archived[0].Task == nil || archive.Archived[0].Task.Alias != "TASK-001" || len(archive.Skipped) != 0 { - t.Fatalf("archive = %#v, want only done task archived by spec", archive) - } - - var humanOut bytes.Buffer - err = Runner{ - Stdout: &humanOut, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"task", "archive", "--spec", "SPEC-001"}) + runFrozenTaskWrite(t, workingDir, stateHome, "task", "archive", "--spec", "SPEC-001", "--json") + root, err := project.ResolveRoot(workingDir) if err != nil { - t.Fatalf("task archive --spec human empty error = %v", err) + t.Fatalf("ResolveRoot() error = %v", err) } - if !strings.Contains(humanOut.String(), "No completed tasks found for SPEC-001") { - t.Fatalf("output = %q, want no completed tasks message", humanOut.String()) + if _, err := state.ArchiveTasks(context.Background(), root, state.PathResolver{StateHome: stateHome}, state.TaskArchiveOptions{Spec: "SPEC-001"}); err != nil { + t.Fatalf("ArchiveTasks() error = %v", err) } } @@ -9461,83 +9003,7 @@ func TestRunnerTaskArchiveUsesMarkdownIndexWhenMarkdownOnly(t *testing.T) { } }`) - var archiveOut bytes.Buffer - err := Runner{ - Stdout: &archiveOut, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"task", "archive", "TASK-001", "TASK-002", "TASK-999", "--json"}) - if err != nil { - t.Fatalf("task archive markdown --json error = %v", err) - } - archive := decodeTaskArchiveResult(t, archiveOut.Bytes()) - if len(archive.Archived) != 1 || archive.Archived[0].Task == nil || archive.Archived[0].Task.Alias != "TASK-001" || archive.Archived[0].Previous != "done" || archive.Archived[0].Status != "archived" { - t.Fatalf("Archived = %#v, want TASK-001 archived", archive.Archived) - } - if len(archive.Skipped) != 2 { - t.Fatalf("Skipped = %#v, want two skipped refs", archive.Skipped) - } - if archive.Skipped[0].Ref != "TASK-002" || !strings.Contains(archive.Skipped[0].Reason, "must be done") { - t.Fatalf("Skipped[0] = %#v, want todo skip", archive.Skipped[0]) - } - if archive.Skipped[1].Ref != "TASK-999" || archive.Skipped[1].Reason != "not found in index" { - t.Fatalf("Skipped[1] = %#v, want not-found skip", archive.Skipped[1]) - } - if archive.ContractVersion != state.StateJSONContractVersion { - t.Fatalf("archive ContractVersion = %d, want %d", archive.ContractVersion, state.StateJSONContractVersion) - } - if archive.DatabaseScope != "" || archive.DatabasePath != "" || archive.ProjectID != "" || archive.ProjectName != "" || archive.ProjectCurrentPath != "" { - t.Fatalf("archive database context = %#v, want empty for markdown fallback", archive) - } - if _, err := os.Stat(filepath.Join(workingDir, ".agents", "tasks", "TASK-001-done.md")); !os.IsNotExist(err) { - t.Fatalf("active task file stat error = %v, want not exist", err) - } - if _, err := os.Stat(filepath.Join(workingDir, ".agents", "tasks", "archive", "TASK-001-done.md")); err != nil { - t.Fatalf("archived task file stat error = %v", err) - } - if _, err := os.Stat(filepath.Join(workingDir, ".agents", "tasks", "TASK-002-todo.md")); err != nil { - t.Fatalf("todo task file stat error = %v", err) - } - - var index map[string]any - rawIndex, err := os.ReadFile(filepath.Join(workingDir, ".agents", "TASKS.json")) - if err != nil { - t.Fatalf("ReadFile(TASKS.json) error = %v", err) - } - if err := json.Unmarshal(rawIndex, &index); err != nil { - t.Fatalf("Unmarshal(TASKS.json) error = %v", err) - } - tasks := index["tasks"].(map[string]any) - task := tasks["TASK-001"].(map[string]any) - if task["file"] != "archive/TASK-001-done.md" || task["status"] != "done" || task["review_notes"] != "preserve me" { - t.Fatalf("TASK-001 index = %#v, want archived file with legacy status and unknown fields preserved", task) - } - - var humanOut bytes.Buffer - err = Runner{ - Stdout: &humanOut, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"task", "archive", "TASK-001"}) - if err != nil { - t.Fatalf("task archive already archived error = %v", err) - } - if !strings.Contains(humanOut.String(), "skipped TASK-001: already archived") { - t.Fatalf("human output = %q, want already archived skip", humanOut.String()) - } - - var specOut bytes.Buffer - err = Runner{ - Stdout: &specOut, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"task", "archive", "--spec", "SPEC-001"}) - if err != nil { - t.Fatalf("task archive --spec markdown error = %v", err) - } - if !strings.Contains(specOut.String(), "skipped TASK-001: already archived") { - t.Fatalf("spec output = %q, want already archived skip", specOut.String()) - } + runFrozenTaskWrite(t, workingDir, stateHome, "task", "archive", "TASK-001", "TASK-002", "TASK-999", "--json") assertNoStateDatabase(t, workingDir, stateHome) } @@ -9559,17 +9025,7 @@ func TestRunnerTaskArchiveReportsInvalidSQLiteState(t *testing.T) { t.Fatalf("WriteFile() error = %v", err) } - err = Runner{ - Stdout: &bytes.Buffer{}, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"task", "archive", "TASK-001"}) - if err == nil { - t.Fatal("task archive invalid state error = nil, want error") - } - if !strings.Contains(err.Error(), "state database is invalid") { - t.Fatalf("error = %v, want invalid state error", err) - } + runFrozenTaskWrite(t, workingDir, stateHome, "task", "archive", "TASK-001") } func TestRunnerBrainstormListUsesSQLiteStateWhenInitialized(t *testing.T) { @@ -11910,25 +11366,57 @@ func TestRunnerLinkCommandReportsInvalidSQLiteState(t *testing.T) { t.Fatalf("error = %v, want invalid state error", err) } } +func assertCLISessionContext(t *testing.T, contractVersion int, databaseScope string, databasePath string, projectID string, projectName string, projectCurrentPath string, workingDir string) { + t.Helper() + if contractVersion != state.StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", contractVersion, state.StateJSONContractVersion) + } + if databaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", databaseScope) + } + if databasePath == "" { + t.Fatal("DatabasePath is empty") + } + if projectID == "" { + t.Fatal("ProjectID is empty") + } + if projectName != filepath.Base(workingDir) { + t.Fatalf("ProjectName = %q, want %q", projectName, filepath.Base(workingDir)) + } + if projectCurrentPath != workingDir { + t.Fatalf("ProjectCurrentPath = %q, want %q", projectCurrentPath, workingDir) + } +} -func TestRunnerSpecListJSONUsesSQLiteStateWhenInitialized(t *testing.T) { +func TestRunnerReportListJSONUsesSQLiteStateWhenInitialized(t *testing.T) { workingDir := realpath(t, t.TempDir()) stateHome := t.TempDir() - writeCLIAgentsFile(t, workingDir, "specs/SPEC-001-example.md", `--- -id: SPEC-001 -title: Example Spec -status: implementing + writeCLIAgentsFile(t, workingDir, "reports/draft.md", `--- +title: Draft Report +type: research +status: draft +source: ad-hoc --- -# Example Spec +# Draft Report +`) + writeCLIAgentsFile(t, workingDir, "reports/final.md", `--- +title: Final Report +kind: audit +status: final +source: SPEC-001 +--- +# Final Report +`) + writeCLIAgentsFile(t, workingDir, "reports/archive/old.md", `--- +title: Old Report +type: research +status: final +source: old +--- +# Old Report +`) + writeCLIAgentsFile(t, workingDir, "TASKS.json", `{"tasks":{}} `) - writeCLIAgentsFile(t, workingDir, "tasks/TASK-001-example.md", "# Task\n") - writeCLIAgentsFile(t, workingDir, "tasks/TASK-002-done.md", "# Done\n") - writeCLIAgentsFile(t, workingDir, "TASKS.json", `{ - "tasks": { - "TASK-001": {"title": "Example Task", "spec": "SPEC-001", "status": "todo", "priority": "P1"}, - "TASK-002": {"title": "Done Task", "spec": "SPEC-001", "status": "done", "priority": "P2"} - } -}`) if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "migrate", "markdown", "--apply"}); err != nil { t.Fatalf("state migrate markdown --apply error = %v", err) } @@ -11938,863 +11426,14 @@ status: implementing Stdout: &stdout, WorkingDir: workingDir, StateHome: stateHome, - }.Run([]string{"spec", "list", "--json"}) + }.Run([]string{"report", "list", "--json", "--type", "research"}) if err != nil { - t.Fatalf("spec list --json error = %v", err) + t.Fatalf("report list --json --type research error = %v", err) } - specs := decodeSpecList(t, stdout.Bytes()) - assertCLIProjectContext(t, workingDir, specs.ContractVersion, specs.DatabaseScope, specs.DatabasePath, specs.ProjectID, specs.ProjectName, specs.ProjectCurrentPath) - spec := specs.Specs["SPEC-001"] - if spec.Title != "Example Spec" || spec.Status != "in_progress" || spec.SourcePath != ".agents/specs/SPEC-001-example.md" { - t.Fatalf("SPEC-001 = %#v, want imported spec metadata", spec) - } - if spec.Tasks.Todo != 1 || spec.Tasks.InProgress != 0 || spec.Tasks.Done != 1 { - t.Fatalf("SPEC-001 task counts = %#v, want todo=1 in_progress=0 done=1", spec.Tasks) - } -} - -func TestRunnerSpecListHumanUsesSQLiteStateWhenInitialized(t *testing.T) { - workingDir := realpath(t, t.TempDir()) - stateHome := t.TempDir() - writeCLIAgentsFile(t, workingDir, "specs/SPEC-001-example.md", `--- -id: SPEC-001 -title: Example Spec -status: implementing ---- -# Example Spec -`) - writeCLIAgentsFile(t, workingDir, "tasks/TASK-001-example.md", "# Task\n") - writeCLIAgentsFile(t, workingDir, "TASKS.json", `{"tasks":{"TASK-001":{"title":"Example Task","spec":"SPEC-001","status":"in_progress","priority":"P1"}}}`) - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "migrate", "markdown", "--apply"}); err != nil { - t.Fatalf("state migrate markdown --apply error = %v", err) - } - - var stdout bytes.Buffer - err := Runner{ - Stdout: &stdout, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"spec", "list"}) - if err != nil { - t.Fatalf("spec list error = %v", err) - } - output := stdout.String() - for _, want := range []string{"loaf spec list", "scope: global database", "database:", "project:", "project name:", "project path:", "In Progress (1)", "SPEC-001", "Example Spec", "0 todo / 1 in_progress / 0 done"} { - if !strings.Contains(output, want) { - t.Fatalf("output = %q, want %q", output, want) - } - } -} - -func TestRunnerSpecListUsesMarkdownSpecsWhenMarkdownOnly(t *testing.T) { - workingDir := realpath(t, t.TempDir()) - stateHome := t.TempDir() - writeCLIAgentsFile(t, workingDir, "specs/SPEC-001-example.md", `--- -id: SPEC-001 -title: Example Spec -status: implementing ---- -# Example Spec -`) - writeCLIAgentsFile(t, workingDir, "specs/SPEC-002-draft.md", `--- -id: SPEC-002 -title: Draft Spec -status: drafting ---- -# Draft Spec -`) - writeCLIAgentsFile(t, workingDir, "TASKS.json", `{ - "tasks": { - "TASK-001": {"title": "Todo Task", "spec": "SPEC-001", "status": "todo", "priority": "P1"}, - "TASK-002": {"title": "Progress Task", "spec": "SPEC-001", "status": "in_progress", "priority": "P1"}, - "TASK-003": {"title": "Done Task", "spec": "SPEC-001", "status": "done", "priority": "P2"}, - "TASK-004": {"title": "Review Task", "spec": "SPEC-001", "status": "review", "priority": "P2"} - } -}`) - - var jsonOut bytes.Buffer - err := Runner{ - Stdout: &jsonOut, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"spec", "list", "--json"}) - if err != nil { - t.Fatalf("spec list markdown --json error = %v", err) - } - specs := decodeSpecList(t, jsonOut.Bytes()) - if specs.ContractVersion != 0 || specs.DatabaseScope != "" || specs.DatabasePath != "" || specs.ProjectID != "" || specs.ProjectName != "" || specs.ProjectCurrentPath != "" { - t.Fatalf("markdown spec list context = %#v, want empty", specs) - } - spec := specs.Specs["SPEC-001"] - if spec.Title != "Example Spec" || spec.Status != "in_progress" || spec.SourcePath != ".agents/specs/SPEC-001-example.md" { - t.Fatalf("SPEC-001 = %#v, want markdown spec metadata", spec) - } - if spec.Tasks.Todo != 2 || spec.Tasks.InProgress != 1 || spec.Tasks.Done != 1 { - t.Fatalf("SPEC-001 task counts = %#v, want todo=2 in_progress=1 done=1", spec.Tasks) - } - if specs.Specs["SPEC-002"].Tasks != (state.SpecTaskCounts{}) { - t.Fatalf("SPEC-002 task counts = %#v, want zero counts", specs.Specs["SPEC-002"].Tasks) - } - - var humanOut bytes.Buffer - err = Runner{ - Stdout: &humanOut, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"spec", "list"}) - if err != nil { - t.Fatalf("spec list markdown human error = %v", err) - } - output := humanOut.String() - for _, want := range []string{"loaf spec list", "In Progress (1)", "SPEC-001", "Example Spec", "2 todo / 1 in_progress / 1 done", "Draft (1)", "SPEC-002"} { - if !strings.Contains(output, want) { - t.Fatalf("output = %q, want %q", output, want) - } - } - assertNoStateDatabase(t, workingDir, stateHome) -} - -func TestRunnerSpecListReportsInvalidSQLiteState(t *testing.T) { - workingDir := realpath(t, t.TempDir()) - stateHome := t.TempDir() - root, err := project.ResolveRoot(workingDir) - if err != nil { - t.Fatalf("ResolveRoot() error = %v", err) - } - databasePath, err := (state.PathResolver{StateHome: stateHome}).DatabasePath(root) - if err != nil { - t.Fatalf("DatabasePath() error = %v", err) - } - if err := os.MkdirAll(filepath.Dir(databasePath), 0o755); err != nil { - t.Fatalf("MkdirAll() error = %v", err) - } - if err := os.WriteFile(databasePath, []byte("not sqlite"), 0o600); err != nil { - t.Fatalf("WriteFile() error = %v", err) - } - - err = Runner{ - Stdout: &bytes.Buffer{}, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"spec", "list"}) - if err == nil { - t.Fatal("spec list error = nil, want invalid state error") - } - if !strings.Contains(err.Error(), "state database is invalid") { - t.Fatalf("error = %v, want invalid state error", err) - } -} - -func TestRunnerSpecShowUsesSQLiteStateWhenInitialized(t *testing.T) { - workingDir := realpath(t, t.TempDir()) - stateHome := t.TempDir() - writeCLIAgentsFile(t, workingDir, "specs/SPEC-001-example.md", `--- -id: SPEC-001 -title: Example Spec -status: implementing ---- -# Example Spec - -Imported spec prose. -`) - writeCLIAgentsFile(t, workingDir, "tasks/TASK-001-example.md", "# Task\n") - writeCLIAgentsFile(t, workingDir, "TASKS.json", `{"tasks":{"TASK-001":{"title":"Example Task","spec":"SPEC-001","status":"todo","priority":"P1"}}}`) - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "migrate", "markdown", "--apply"}); err != nil { - t.Fatalf("state migrate markdown --apply error = %v", err) - } - - var showOut bytes.Buffer - err := Runner{ - Stdout: &showOut, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"spec", "show", "SPEC-001", "--json"}) - if err != nil { - t.Fatalf("spec show --json error = %v", err) - } - show := decodeSpecShow(t, showOut.Bytes()) - assertCLIProjectContext(t, workingDir, show.ContractVersion, show.DatabaseScope, show.DatabasePath, show.ProjectID, show.ProjectName, show.ProjectCurrentPath) - if show.Spec.Alias != "SPEC-001" || show.Spec.Title != "Example Spec" || show.Spec.Status != "in_progress" { - t.Fatalf("show = %#v, want imported spec metadata", show) - } - if show.Spec.Tasks.Todo != 1 || show.Spec.Tasks.InProgress != 0 || show.Spec.Tasks.Done != 0 { - t.Fatalf("show.Spec.Tasks = %#v, want one todo task", show.Spec.Tasks) - } - if len(show.Spec.Sources) != 1 || show.Spec.Sources[0].Path != ".agents/specs/SPEC-001-example.md" || show.Spec.Sources[0].Hash == "" { - t.Fatalf("Sources = %#v, want spec source with hash", show.Spec.Sources) - } - if !strings.Contains(show.Spec.Body, "Imported spec prose.") || strings.Contains(show.Spec.Body, "status: implementing") { - t.Fatalf("Body = %q, want frontmatter-stripped imported body", show.Spec.Body) - } - if !hasTraceRelationship(show.Spec.Relationships, "inbound", "implements", "task", "TASK-001") { - t.Fatalf("Relationships = %#v, want inbound task implements relationship", show.Spec.Relationships) - } - - var humanOut bytes.Buffer - err = Runner{ - Stdout: &humanOut, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"spec", "show", "SPEC-001"}) - if err != nil { - t.Fatalf("spec show human error = %v", err) - } - human := humanOut.String() - for _, want := range []string{"spec SPEC-001", "scope: global database", "database:", "project:", "project name:", "project path:", "title: Example Spec", "status: in_progress", "tasks: 1 todo / 0 in_progress / 0 done", "render: .agents/specs/SPEC-001-example.md", "inbound implements task TASK-001", "Imported spec prose."} { - if !strings.Contains(human, want) { - t.Fatalf("human output = %q, want %q", human, want) - } - } -} - -func TestRunnerSpecShowUsesMarkdownSpecWhenMarkdownOnly(t *testing.T) { - workingDir := realpath(t, t.TempDir()) - stateHome := t.TempDir() - writeCLIAgentsFile(t, workingDir, "specs/SPEC-001-example.md", `--- -id: SPEC-001 -title: Frontmatter Spec -status: drafting -created: 2026-05-27T09:00:00Z ---- -# Spec Body - -Markdown spec prose. -`) - writeCLIAgentsFile(t, workingDir, "TASKS.json", `{ - "specs": { - "SPEC-001": { - "title": "Example Spec", - "status": "implementing", - "created": "2026-05-28T10:00:00Z", - "updated": "2026-05-29T11:00:00Z" - } - }, - "tasks": { - "TASK-001": {"title": "Todo Task", "spec": "SPEC-001", "status": "todo", "priority": "P1"}, - "TASK-002": {"title": "Progress Task", "spec": "SPEC-001", "status": "in_progress", "priority": "P1"}, - "TASK-003": {"title": "Done Task", "spec": "SPEC-001", "status": "done", "priority": "P2"} - } -}`) - - var jsonOut bytes.Buffer - err := Runner{ - Stdout: &jsonOut, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"spec", "show", "SPEC-001", "--json"}) - if err != nil { - t.Fatalf("spec show markdown --json error = %v", err) - } - show := decodeSpecShow(t, jsonOut.Bytes()) - if show.ContractVersion != 0 || show.DatabaseScope != "" || show.DatabasePath != "" || show.ProjectID != "" || show.ProjectName != "" || show.ProjectCurrentPath != "" { - t.Fatalf("markdown spec show context = %#v, want empty", show) - } - spec := show.Spec - if show.Query != "SPEC-001" || spec.Alias != "SPEC-001" || spec.Title != "Example Spec" || spec.Status != "in_progress" { - t.Fatalf("show = %#v, want TASKS.json spec metadata over frontmatter", show) - } - if spec.Tasks.Todo != 1 || spec.Tasks.InProgress != 1 || spec.Tasks.Done != 1 { - t.Fatalf("spec.Tasks = %#v, want one todo/in_progress/done", spec.Tasks) - } - if len(spec.Sources) != 1 || spec.Sources[0].Path != ".agents/specs/SPEC-001-example.md" || spec.Sources[0].Hash == "" { - t.Fatalf("Sources = %#v, want markdown spec source with hash", spec.Sources) - } - if !strings.Contains(spec.Body, "Markdown spec prose.") || strings.Contains(spec.Body, "---") { - t.Fatalf("Body = %q, want markdown body without frontmatter", spec.Body) - } - if spec.CreatedAt != "2026-05-28T10:00:00Z" || spec.UpdatedAt != "2026-05-29T11:00:00Z" { - t.Fatalf("timestamps = %q/%q, want index timestamps", spec.CreatedAt, spec.UpdatedAt) - } - if !hasTraceRelationship(spec.Relationships, "inbound", "implements", "task", "TASK-001") || !hasTraceRelationship(spec.Relationships, "inbound", "implements", "task", "TASK-002") || !hasTraceRelationship(spec.Relationships, "inbound", "implements", "task", "TASK-003") { - t.Fatalf("Relationships = %#v, want inbound task relationships", spec.Relationships) - } - - var humanOut bytes.Buffer - err = Runner{ - Stdout: &humanOut, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"spec", "show", "SPEC-001"}) - if err != nil { - t.Fatalf("spec show markdown human error = %v", err) - } - output := humanOut.String() - for _, want := range []string{"spec SPEC-001", "title: Example Spec", "status: in_progress", "tasks: 1 todo / 1 in_progress / 1 done", "render: .agents/specs/SPEC-001-example.md", "render hash:", "inbound implements task TASK-001", "# Spec Body", "Markdown spec prose."} { - if !strings.Contains(output, want) { - t.Fatalf("output = %q, want %q", output, want) - } - } - if strings.Contains(output, "scope: global database") || strings.Contains(output, "project path:") { - t.Fatalf("output = %q, want markdown fallback without database context", output) - } - assertNoStateDatabase(t, workingDir, stateHome) -} - -func TestRunnerSpecShowReportsInvalidSQLiteState(t *testing.T) { - workingDir := realpath(t, t.TempDir()) - stateHome := t.TempDir() - root, err := project.ResolveRoot(workingDir) - if err != nil { - t.Fatalf("ResolveRoot() error = %v", err) - } - databasePath, err := (state.PathResolver{StateHome: stateHome}).DatabasePath(root) - if err != nil { - t.Fatalf("DatabasePath() error = %v", err) - } - if err := os.MkdirAll(filepath.Dir(databasePath), 0o755); err != nil { - t.Fatalf("MkdirAll() error = %v", err) - } - if err := os.WriteFile(databasePath, []byte("not sqlite"), 0o600); err != nil { - t.Fatalf("WriteFile() error = %v", err) - } - - err = Runner{ - Stdout: &bytes.Buffer{}, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"spec", "show", "SPEC-001"}) - if err == nil { - t.Fatal("spec show invalid state error = nil, want error") - } - if !strings.Contains(err.Error(), "state database is invalid") { - t.Fatalf("error = %v, want invalid state error", err) - } -} - -func TestRunnerSpecArchiveUsesSQLiteStateWhenInitialized(t *testing.T) { - workingDir := realpath(t, t.TempDir()) - stateHome := t.TempDir() - writeCLIAgentsFile(t, workingDir, "specs/SPEC-001-complete.md", `--- -id: SPEC-001 -title: Complete Spec -status: complete ---- -# Complete Spec -`) - writeCLIAgentsFile(t, workingDir, "specs/SPEC-002-draft.md", `--- -id: SPEC-002 -title: Draft Spec -status: drafting ---- -# Draft Spec -`) - writeCLIAgentsFile(t, workingDir, "tasks/TASK-001-task.md", "# Task\n") - writeCLIAgentsFile(t, workingDir, "TASKS.json", `{"tasks":{"TASK-001":{"title":"Task","status":"todo","priority":"P1"}}}`) - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "migrate", "markdown", "--apply"}); err != nil { - t.Fatalf("state migrate markdown --apply error = %v", err) - } - - var archiveOut bytes.Buffer - err := Runner{ - Stdout: &archiveOut, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"spec", "archive", "SPEC-001", "SPEC-002", "TASK-001", "SPEC-999", "--json"}) - if err != nil { - t.Fatalf("spec archive --json error = %v", err) - } - archive := decodeSpecArchiveResult(t, archiveOut.Bytes()) - if len(archive.Archived) != 1 || archive.Archived[0].Spec == nil || archive.Archived[0].Spec.Alias != "SPEC-001" || archive.Archived[0].EventID == "" { - t.Fatalf("Archived = %#v, want SPEC-001 archived with event", archive.Archived) - } - if archive.ContractVersion != state.StateJSONContractVersion { - t.Fatalf("archive ContractVersion = %d, want %d", archive.ContractVersion, state.StateJSONContractVersion) - } - if archive.DatabaseScope != "global" { - t.Fatalf("archive DatabaseScope = %q, want global", archive.DatabaseScope) - } - if archive.DatabasePath == "" { - t.Fatal("archive DatabasePath is empty") - } - if archive.ProjectID == "" { - t.Fatal("archive ProjectID is empty") - } - if archive.ProjectName != filepath.Base(workingDir) { - t.Fatalf("archive ProjectName = %q, want %q", archive.ProjectName, filepath.Base(workingDir)) - } - if archive.ProjectCurrentPath != workingDir { - t.Fatalf("archive ProjectCurrentPath = %q, want %q", archive.ProjectCurrentPath, workingDir) - } - if len(archive.Skipped) != 3 { - t.Fatalf("Skipped = %#v, want three skipped specs", archive.Skipped) - } - - var listOut bytes.Buffer - err = Runner{ - Stdout: &listOut, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"spec", "list", "--json"}) - if err != nil { - t.Fatalf("spec list after archive error = %v", err) - } - specs := decodeSpecList(t, listOut.Bytes()) - if specs.Specs["SPEC-001"].Status != "archived" || specs.Specs["SPEC-002"].Status != "draft" { - t.Fatalf("specs = %#v, want SPEC-001 archived and SPEC-002 unchanged", specs.Specs) - } - - var traceOut bytes.Buffer - err = Runner{ - Stdout: &traceOut, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"trace", "SPEC-001", "--json"}) - if err != nil { - t.Fatalf("trace after archive error = %v", err) - } - trace := decodeTraceResult(t, traceOut.Bytes()) - if trace.Entity.Status != "archived" { - t.Fatalf("trace status = %q, want archived", trace.Entity.Status) - } - - var humanOut bytes.Buffer - err = Runner{ - Stdout: &humanOut, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"spec", "archive", "SPEC-001"}) - if err != nil { - t.Fatalf("spec archive human error = %v", err) - } - output := humanOut.String() - for _, want := range []string{"loaf spec archive", "scope: global database", "database:", "project:", "project name:", "project path:", "skipped SPEC-001: already archived", "Skipped 1 spec(s)"} { - if !strings.Contains(output, want) { - t.Fatalf("output = %q, want %q", output, want) - } - } -} - -func TestRunnerSpecStatusTransitionsThroughLifecycle(t *testing.T) { - workingDir := realpath(t, t.TempDir()) - stateHome := t.TempDir() - writeCLIAgentsFile(t, workingDir, "specs/SPEC-001-draft.md", `--- -id: SPEC-001 -title: Draft Spec -status: draft ---- -# Draft Spec -`) - writeCLIAgentsFile(t, workingDir, "TASKS.json", `{"tasks":{}}`) - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "migrate", "markdown", "--apply"}); err != nil { - t.Fatalf("state migrate markdown --apply error = %v", err) - } - - var firstOut bytes.Buffer - if err := (Runner{Stdout: &firstOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"spec", "status", "SPEC-001", "implementing", "--json"}); err != nil { - t.Fatalf("spec status implementing --json error = %v", err) - } - first := decodeSpecStatusResult(t, firstOut.Bytes()) - if first.Previous != state.LifecycleStatusDraft || first.Status != state.LifecycleStatusInProgress { - t.Fatalf("first transition = %s -> %s, want draft -> in_progress", first.Previous, first.Status) - } - if first.EventID == "" { - t.Fatal("first transition missing event id") - } - if first.ContractVersion != state.StateJSONContractVersion { - t.Fatalf("ContractVersion = %d, want %d", first.ContractVersion, state.StateJSONContractVersion) - } - if first.DatabaseScope != "global" || first.DatabasePath == "" || first.ProjectID == "" { - t.Fatalf("project context = %#v, want populated", first) - } - - var secondOut bytes.Buffer - if err := (Runner{Stdout: &secondOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"spec", "status", "SPEC-001", "complete", "--json"}); err != nil { - t.Fatalf("spec status complete --json error = %v", err) - } - second := decodeSpecStatusResult(t, secondOut.Bytes()) - if second.Previous != state.LifecycleStatusInProgress || second.Status != state.LifecycleStatusDone { - t.Fatalf("second transition = %s -> %s, want in_progress -> done", second.Previous, second.Status) - } - - var showOut bytes.Buffer - if err := (Runner{Stdout: &showOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"spec", "show", "SPEC-001", "--json"}); err != nil { - t.Fatalf("spec show --json error = %v", err) - } - show := decodeSpecShow(t, showOut.Bytes()) - if show.Spec.Status != state.LifecycleStatusDone { - t.Fatalf("spec show status = %q, want done", show.Spec.Status) - } - - var humanOut bytes.Buffer - if err := (Runner{Stdout: &humanOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"spec", "status", "SPEC-001", "todo"}); err != nil { - t.Fatalf("spec status human error = %v", err) - } - output := humanOut.String() - for _, want := range []string{"spec SPEC-001", "scope: global database", "status: done -> todo", "event:"} { - if !strings.Contains(output, want) { - t.Fatalf("output = %q, want %q", output, want) - } - } -} - -func TestRunnerSpecStatusRejectsInvalidStatus(t *testing.T) { - workingDir := realpath(t, t.TempDir()) - stateHome := t.TempDir() - writeCLIAgentsFile(t, workingDir, "specs/SPEC-001-draft.md", `--- -id: SPEC-001 -title: Draft Spec -status: draft ---- -# Draft Spec -`) - writeCLIAgentsFile(t, workingDir, "TASKS.json", `{"tasks":{}}`) - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "migrate", "markdown", "--apply"}); err != nil { - t.Fatalf("state migrate markdown --apply error = %v", err) - } - - err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"spec", "status", "SPEC-001", "bogus"}) - if err == nil { - t.Fatal("spec status bogus error = nil, want error") - } - - var showOut bytes.Buffer - if err := (Runner{Stdout: &showOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"spec", "show", "SPEC-001", "--json"}); err != nil { - t.Fatalf("spec show --json error = %v", err) - } - show := decodeSpecShow(t, showOut.Bytes()) - if show.Spec.Status != state.LifecycleStatusDraft { - t.Fatalf("spec show status = %q, want draft (unchanged)", show.Spec.Status) - } -} - -func TestRunnerSpecArchiveUsesMarkdownIndexWhenMarkdownOnly(t *testing.T) { - workingDir := realpath(t, t.TempDir()) - stateHome := t.TempDir() - writeCLIAgentsFile(t, workingDir, "specs/SPEC-001-complete.md", `--- -id: SPEC-001 -title: Complete Spec -status: complete ---- -# Complete Spec -`) - writeCLIAgentsFile(t, workingDir, "specs/SPEC-002-draft.md", `--- -id: SPEC-002 -title: Draft Spec -status: drafting ---- -# Draft Spec -`) - writeCLIAgentsFile(t, workingDir, "TASKS.json", `{ - "version": 1, - "next_id": 7, - "tasks": { - "TASK-001": { - "title": "Preserved Task", - "status": "todo", - "priority": "P1", - "files": ["keep.go"] - } - }, - "specs": { - "SPEC-001": { - "title": "Complete Spec", - "status": "complete", - "requirement": "preserve me", - "file": "SPEC-001-complete.md" - }, - "SPEC-002": { - "title": "Draft Spec", - "status": "drafting", - "file": "SPEC-002-draft.md" - } - } -}`) - - var jsonOut bytes.Buffer - err := Runner{ - Stdout: &jsonOut, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"spec", "archive", "SPEC-001", "SPEC-002", "SPEC-999", "--json"}) - if err != nil { - t.Fatalf("spec archive markdown --json error = %v", err) - } - archive := decodeSpecArchiveResult(t, jsonOut.Bytes()) - if len(archive.Archived) != 1 || archive.Archived[0].Spec == nil || archive.Archived[0].Spec.Alias != "SPEC-001" || archive.Archived[0].Previous != "done" || archive.Archived[0].Status != "archived" { - t.Fatalf("Archived = %#v, want SPEC-001 archived", archive.Archived) - } - if len(archive.Skipped) != 2 || archive.Skipped[0].Ref != "SPEC-002" || archive.Skipped[0].Reason != "status is draft, must be done" || archive.Skipped[1].Ref != "SPEC-999" || archive.Skipped[1].Reason != "not found in index" { - t.Fatalf("Skipped = %#v, want draft and missing skips", archive.Skipped) - } - if archive.ContractVersion != state.StateJSONContractVersion { - t.Fatalf("archive ContractVersion = %d, want %d", archive.ContractVersion, state.StateJSONContractVersion) - } - if archive.DatabaseScope != "" || archive.DatabasePath != "" || archive.ProjectID != "" || archive.ProjectName != "" || archive.ProjectCurrentPath != "" { - t.Fatalf("archive database context = %#v, want empty for markdown fallback", archive) - } - if _, err := os.Stat(filepath.Join(workingDir, ".agents", "specs", "SPEC-001-complete.md")); !os.IsNotExist(err) { - t.Fatalf("active spec still exists or stat failed: %v", err) - } - if _, err := os.Stat(filepath.Join(workingDir, ".agents", "specs", "archive", "SPEC-001-complete.md")); err != nil { - t.Fatalf("archived spec missing: %v", err) - } - var index struct { - Tasks map[string]map[string]any `json:"tasks"` - Specs map[string]map[string]any `json:"specs"` - } - content, err := os.ReadFile(filepath.Join(workingDir, ".agents", "TASKS.json")) - if err != nil { - t.Fatalf("ReadFile(TASKS.json) error = %v", err) - } - if err := json.Unmarshal(content, &index); err != nil { - t.Fatalf("json.Unmarshal(TASKS.json) error = %v", err) - } - if got := index.Specs["SPEC-001"]["file"]; got != "archive/SPEC-001-complete.md" { - t.Fatalf("SPEC-001 file = %#v, want archive path", got) - } - if got := index.Specs["SPEC-001"]["status"]; got != "complete" { - t.Fatalf("SPEC-001 status = %#v, want legacy markdown status preserved", got) - } - if got := index.Specs["SPEC-001"]["requirement"]; got != "preserve me" { - t.Fatalf("SPEC-001 requirement = %#v, want unknown spec fields preserved", got) - } - files, ok := index.Tasks["TASK-001"]["files"].([]any) - if !ok || len(files) != 1 || files[0] != "keep.go" { - t.Fatalf("TASK-001 files = %#v, want task fields preserved", index.Tasks["TASK-001"]["files"]) - } - - var humanOut bytes.Buffer - err = Runner{ - Stdout: &humanOut, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"spec", "archive", "SPEC-001"}) - if err != nil { - t.Fatalf("spec archive markdown human error = %v", err) - } - output := humanOut.String() - if !strings.Contains(output, "loaf spec archive") || !strings.Contains(output, "skipped SPEC-001: already archived") || !strings.Contains(output, "Skipped 1 spec(s)") { - t.Fatalf("output = %q, want already-archived human summary", output) - } - assertNoStateDatabase(t, workingDir, stateHome) -} - -func TestRunnerSpecArchiveAcceptsCanonicalDoneWhenMarkdownOnly(t *testing.T) { - workingDir := realpath(t, t.TempDir()) - stateHome := t.TempDir() - writeCLIAgentsFile(t, workingDir, "specs/SPEC-001-done.md", `--- -id: SPEC-001 -title: Done Spec -status: done ---- -# Done Spec -`) - writeCLIAgentsFile(t, workingDir, "specs/SPEC-002-active.md", `--- -id: SPEC-002 -title: Active Spec -status: in_progress ---- -# Active Spec -`) - writeCLIAgentsFile(t, workingDir, "TASKS.json", `{ - "version": 1, - "tasks": {}, - "specs": { - "SPEC-001": { - "title": "Done Spec", - "status": "done", - "file": "SPEC-001-done.md" - }, - "SPEC-002": { - "title": "Active Spec", - "status": "in_progress", - "file": "SPEC-002-active.md" - } - } -}`) - - var jsonOut bytes.Buffer - err := Runner{ - Stdout: &jsonOut, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"spec", "archive", "SPEC-001", "SPEC-002", "--json"}) - if err != nil { - t.Fatalf("spec archive markdown --json error = %v", err) - } - archive := decodeSpecArchiveResult(t, jsonOut.Bytes()) - if len(archive.Archived) != 1 || archive.Archived[0].Spec == nil || archive.Archived[0].Spec.Alias != "SPEC-001" || archive.Archived[0].Previous != "done" || archive.Archived[0].Status != "archived" { - t.Fatalf("Archived = %#v, want SPEC-001 archived from canonical done", archive.Archived) - } - if len(archive.Skipped) != 1 || archive.Skipped[0].Ref != "SPEC-002" || archive.Skipped[0].Reason != "status is in_progress, must be done" || archive.Skipped[0].Previous != "in_progress" { - t.Fatalf("Skipped = %#v, want in_progress skip with must-be-done reason", archive.Skipped) - } - if _, err := os.Stat(filepath.Join(workingDir, ".agents", "specs", "archive", "SPEC-001-done.md")); err != nil { - t.Fatalf("archived spec missing: %v", err) - } - if _, err := os.Stat(filepath.Join(workingDir, ".agents", "specs", "SPEC-002-active.md")); err != nil { - t.Fatalf("active spec missing: %v", err) - } - assertNoStateDatabase(t, workingDir, stateHome) -} - -func TestRunnerSpecArchiveCanonicalizesNestedSpecStatusWhenMarkdownOnly(t *testing.T) { - workingDir := realpath(t, t.TempDir()) - stateHome := t.TempDir() - writeCLIAgentsFile(t, workingDir, "specs/archive/SPEC-001-complete.md", `--- -id: SPEC-001 -title: Complete Spec -status: complete ---- -# Complete Spec -`) - writeCLIAgentsFile(t, workingDir, "specs/SPEC-002-draft.md", `--- -id: SPEC-002 -title: Draft Spec -status: drafting ---- -# Draft Spec -`) - writeCLIAgentsFile(t, workingDir, "TASKS.json", `{ - "version": 1, - "tasks": {}, - "specs": { - "SPEC-001": { - "title": "Complete Spec", - "status": "complete", - "file": "archive/SPEC-001-complete.md" - }, - "SPEC-002": { - "title": "Draft Spec", - "status": "drafting", - "file": "SPEC-002-draft.md" - } - } -}`) - - var jsonOut bytes.Buffer - err := Runner{ - Stdout: &jsonOut, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"spec", "archive", "SPEC-001", "SPEC-002", "--json"}) - if err != nil { - t.Fatalf("spec archive markdown --json error = %v", err) - } - archive := decodeSpecArchiveResult(t, jsonOut.Bytes()) - if len(archive.Archived) != 0 { - t.Fatalf("Archived = %#v, want none", archive.Archived) - } - if len(archive.Skipped) != 2 || archive.Skipped[0].Ref != "SPEC-001" || archive.Skipped[1].Ref != "SPEC-002" { - t.Fatalf("Skipped = %#v, want SPEC-001 and SPEC-002 skips", archive.Skipped) - } - archived := archive.Skipped[0] - if archived.Reason != "already archived" || archived.Previous != "done" || archived.Status != "done" { - t.Fatalf("Skipped[0] = %#v, want already-archived skip with canonical done statuses", archived) - } - if archived.Spec == nil || archived.Spec.Status != "done" { - t.Fatalf("Skipped[0].Spec = %#v, want nested spec status canonicalized to done", archived.Spec) - } - draft := archive.Skipped[1] - if draft.Reason != "status is draft, must be done" || draft.Previous != "draft" || draft.Status != "draft" { - t.Fatalf("Skipped[1] = %#v, want draft skip with canonical draft statuses", draft) - } - if draft.Spec == nil || draft.Spec.Status != "draft" { - t.Fatalf("Skipped[1].Spec = %#v, want nested spec status canonicalized to draft", draft.Spec) - } - assertNoStateDatabase(t, workingDir, stateHome) -} - -func TestRunnerSpecArchiveReportsInvalidSQLiteState(t *testing.T) { - workingDir := realpath(t, t.TempDir()) - stateHome := t.TempDir() - root, err := project.ResolveRoot(workingDir) - if err != nil { - t.Fatalf("ResolveRoot() error = %v", err) - } - databasePath, err := (state.PathResolver{StateHome: stateHome}).DatabasePath(root) - if err != nil { - t.Fatalf("DatabasePath() error = %v", err) - } - if err := os.MkdirAll(filepath.Dir(databasePath), 0o755); err != nil { - t.Fatalf("MkdirAll() error = %v", err) - } - if err := os.WriteFile(databasePath, []byte("not sqlite"), 0o600); err != nil { - t.Fatalf("WriteFile() error = %v", err) - } - - err = Runner{ - Stdout: &bytes.Buffer{}, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"spec", "archive", "SPEC-001"}) - if err == nil { - t.Fatal("spec archive invalid state error = nil, want error") - } - if !strings.Contains(err.Error(), "state database is invalid") { - t.Fatalf("error = %v, want invalid state error", err) - } -} - -func assertCLISessionContext(t *testing.T, contractVersion int, databaseScope string, databasePath string, projectID string, projectName string, projectCurrentPath string, workingDir string) { - t.Helper() - if contractVersion != state.StateJSONContractVersion { - t.Fatalf("ContractVersion = %d, want %d", contractVersion, state.StateJSONContractVersion) - } - if databaseScope != "global" { - t.Fatalf("DatabaseScope = %q, want global", databaseScope) - } - if databasePath == "" { - t.Fatal("DatabasePath is empty") - } - if projectID == "" { - t.Fatal("ProjectID is empty") - } - if projectName != filepath.Base(workingDir) { - t.Fatalf("ProjectName = %q, want %q", projectName, filepath.Base(workingDir)) - } - if projectCurrentPath != workingDir { - t.Fatalf("ProjectCurrentPath = %q, want %q", projectCurrentPath, workingDir) - } -} - -func TestRunnerReportListJSONUsesSQLiteStateWhenInitialized(t *testing.T) { - workingDir := realpath(t, t.TempDir()) - stateHome := t.TempDir() - writeCLIAgentsFile(t, workingDir, "reports/draft.md", `--- -title: Draft Report -type: research -status: draft -source: ad-hoc ---- -# Draft Report -`) - writeCLIAgentsFile(t, workingDir, "reports/final.md", `--- -title: Final Report -kind: audit -status: final -source: SPEC-001 ---- -# Final Report -`) - writeCLIAgentsFile(t, workingDir, "reports/archive/old.md", `--- -title: Old Report -type: research -status: final -source: old ---- -# Old Report -`) - writeCLIAgentsFile(t, workingDir, "TASKS.json", `{"tasks":{}} -`) - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "migrate", "markdown", "--apply"}); err != nil { - t.Fatalf("state migrate markdown --apply error = %v", err) - } - - var stdout bytes.Buffer - err := Runner{ - Stdout: &stdout, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"report", "list", "--json", "--type", "research"}) - if err != nil { - t.Fatalf("report list --json --type research error = %v", err) - } - - reports := decodeReportList(t, stdout.Bytes()) - if len(reports.Reports) != 2 { - t.Fatalf("reports = %#v, want two research reports", reports.Reports) + reports := decodeReportList(t, stdout.Bytes()) + if len(reports.Reports) != 2 { + t.Fatalf("reports = %#v, want two research reports", reports.Reports) } draft := reports.Reports["draft"] if draft.Title != "Draft Report" || draft.Kind != "research" || draft.Status != "draft" || draft.SourcePath != ".agents/reports/draft.md" { @@ -13006,286 +11645,6 @@ func assertCLIReportContext(t *testing.T, contractVersion int, databaseScope str t.Fatalf("ProjectCurrentPath = %q, want %q", projectCurrentPath, workingDir) } } - -func TestRunnerSpecNewCreatesShowsAndFinalizes(t *testing.T) { - workingDir := realpath(t, t.TempDir()) - stateHome := t.TempDir() - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "init"}); err != nil { - t.Fatalf("state init error = %v", err) - } - - var createOut bytes.Buffer - err := Runner{Stdout: &createOut, WorkingDir: workingDir, StateHome: stateHome}.Run([]string{"spec", "new", "auth-rotation", "--title", "Auth Rotation", "--message", "# Auth Rotation\n\nRotate the keys.", "--json"}) - if err != nil { - t.Fatalf("spec new --json error = %v", err) - } - created := decodeSpecCreateResult(t, createOut.Bytes()) - if created.Spec.Alias != "SPEC-001" || created.Spec.Title != "Auth Rotation" || created.Spec.Status != "draft" { - t.Fatalf("created.Spec = %#v, want draft SPEC-001 Auth Rotation", created.Spec) - } - // `new` must not write any .agents file directly; only finalize renders. - if _, err := os.Stat(filepath.Join(workingDir, ".agents", "specs", "SPEC-001-auth-rotation.md")); !os.IsNotExist(err) { - t.Fatalf("spec render file exists before finalize or stat failed: %v", err) - } - - var listOut bytes.Buffer - if err := (Runner{Stdout: &listOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"spec", "list", "--json"}); err != nil { - t.Fatalf("spec list --json error = %v", err) - } - list := decodeSpecList(t, listOut.Bytes()) - if _, ok := list.Specs["SPEC-001"]; !ok { - t.Fatalf("spec list = %#v, want SPEC-001 present", list.Specs) - } - - var showOut bytes.Buffer - if err := (Runner{Stdout: &showOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"spec", "show", "SPEC-001", "--json"}); err != nil { - t.Fatalf("spec show --json error = %v", err) - } - show := decodeSpecShow(t, showOut.Bytes()) - if show.Spec.Body != "# Auth Rotation\n\nRotate the keys." { - t.Fatalf("show.Spec.Body = %q, want byte-exact CLI body", show.Spec.Body) - } - if !show.Spec.HasBody { - t.Fatalf("show.Spec.HasBody = false, want true") - } - - var finalizeOut bytes.Buffer - if err := (Runner{Stdout: &finalizeOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"spec", "finalize", "SPEC-001"}); err != nil { - t.Fatalf("spec finalize error = %v", err) - } - renderPath := filepath.Join(workingDir, ".agents", "specs", "SPEC-001-auth-rotation.md") - if _, err := os.Stat(renderPath); err != nil { - t.Fatalf("expected finalized render at %s: %v", renderPath, err) - } - - var driftOut bytes.Buffer - if err := (Runner{Stdout: &driftOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"check", "--hook", "render-drift", "--json"}); err != nil { - t.Fatalf("check render-drift error = %v", err) - } - if !strings.Contains(driftOut.String(), "\"passed\":true") && !strings.Contains(driftOut.String(), "\"passed\": true") { - t.Fatalf("render-drift output = %s, want passed", driftOut.String()) - } -} - -func TestRunnerSpecNewAllocatesExplicitIDAndRejectsDuplicates(t *testing.T) { - workingDir := realpath(t, t.TempDir()) - stateHome := t.TempDir() - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "init"}); err != nil { - t.Fatalf("state init error = %v", err) - } - - var createOut bytes.Buffer - if err := (Runner{Stdout: &createOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"spec", "new", "explicit", "--id", "SPEC-050", "--message", "body", "--json"}); err != nil { - t.Fatalf("spec new --id error = %v", err) - } - created := decodeSpecCreateResult(t, createOut.Bytes()) - if created.Spec.Alias != "SPEC-050" { - t.Fatalf("created.Spec.Alias = %q, want SPEC-050", created.Spec.Alias) - } - - err := Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}.Run([]string{"spec", "new", "dup", "--id", "SPEC-050", "--message", "body"}) - if err == nil || !strings.Contains(err.Error(), "already exists") { - t.Fatalf("spec new duplicate error = %v, want already exists", err) - } -} - -func TestRunnerSpecNewStoresBranchSourceAndRelated(t *testing.T) { - workingDir := realpath(t, t.TempDir()) - stateHome := t.TempDir() - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "init"}); err != nil { - t.Fatalf("state init error = %v", err) - } - - for _, id := range []string{"SPEC-001", "SPEC-002"} { - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"spec", "new", "dep-" + id, "--id", id, "--message", "body"}); err != nil { - t.Fatalf("spec new %s error = %v", id, err) - } - } - - var createOut bytes.Buffer - if err := (Runner{Stdout: &createOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{ - "spec", "new", "body-edit-path", - "--id", "SPEC-003", - "--branch", "feat/body-edit-path", - "--source", "SPARK-7", - "--related", "SPEC-001,SPEC-002", - "--json", - }); err != nil { - t.Fatalf("spec new --branch --related error = %v", err) - } - created := decodeSpecCreateResult(t, createOut.Bytes()) - if created.Branch != "feat/body-edit-path" { - t.Fatalf("created.Branch = %q, want feat/body-edit-path", created.Branch) - } - if created.Source != "SPARK-7" { - t.Fatalf("created.Source = %q, want SPARK-7", created.Source) - } - - var showOut bytes.Buffer - if err := (Runner{Stdout: &showOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"spec", "show", "SPEC-003", "--json"}); err != nil { - t.Fatalf("spec show --json error = %v", err) - } - show := decodeSpecShow(t, showOut.Bytes()) - if show.Spec.Branch != "feat/body-edit-path" { - t.Fatalf("show.Spec.Branch = %q, want feat/body-edit-path", show.Spec.Branch) - } - if show.Spec.Source != "SPARK-7" { - t.Fatalf("show.Spec.Source = %q, want SPARK-7", show.Spec.Source) - } - relatedAliases := map[string]bool{} - for _, related := range show.Spec.Related { - relatedAliases[related.Alias] = true - } - if !relatedAliases["SPEC-001"] || !relatedAliases["SPEC-002"] { - t.Fatalf("show.Spec.Related = %#v, want SPEC-001 and SPEC-002", show.Spec.Related) - } - if !hasTraceRelationship(show.Spec.Relationships, "outbound", "related_to", "spec", "SPEC-001") { - t.Fatalf("Relationships = %#v, want outbound related_to SPEC-001", show.Spec.Relationships) - } - - var humanOut bytes.Buffer - if err := (Runner{Stdout: &humanOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"spec", "show", "SPEC-003"}); err != nil { - t.Fatalf("spec show human error = %v", err) - } - for _, want := range []string{"branch: feat/body-edit-path", "source: SPARK-7", "related: SPEC-001, SPEC-002"} { - if !strings.Contains(humanOut.String(), want) { - t.Fatalf("human output = %q, want %q", humanOut.String(), want) - } - } -} - -func TestRunnerSpecEditUpdatesBodyAndFinalizeRoundTrips(t *testing.T) { - workingDir := realpath(t, t.TempDir()) - stateHome := t.TempDir() - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "init"}); err != nil { - t.Fatalf("state init error = %v", err) - } - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"spec", "new", "auth-rotation", "--title", "Auth Rotation", "--message", "# Auth Rotation\n\nInitial body.", "--json"}); err != nil { - t.Fatalf("spec new error = %v", err) - } - beforeFiles := repoFileList(t, workingDir) - - var messageOut bytes.Buffer - if err := (Runner{Stdout: &messageOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"spec", "edit", "SPEC-001", "--message", "# Auth Rotation\n\nMessage body."}); err != nil { - t.Fatalf("spec edit --message error = %v", err) - } - for _, want := range []string{"edited spec SPEC-001", "scope: global database", "next: loaf spec finalize SPEC-001"} { - if !strings.Contains(messageOut.String(), want) { - t.Fatalf("spec edit output = %q, want %q", messageOut.String(), want) - } - } - - finalBody := "# Auth Rotation\n\nFile body wins." - bodyFile := filepath.Join(t.TempDir(), "body.md") - if err := os.WriteFile(bodyFile, []byte(finalBody), 0o600); err != nil { - t.Fatalf("WriteFile(body file) error = %v", err) - } - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"spec", "edit", "SPEC-001", "--body-file", bodyFile}); err != nil { - t.Fatalf("spec edit --body-file error = %v", err) - } - - var showOut bytes.Buffer - if err := (Runner{Stdout: &showOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"spec", "show", "SPEC-001", "--json"}); err != nil { - t.Fatalf("spec show --json error = %v", err) - } - show := decodeSpecShow(t, showOut.Bytes()) - if show.Spec.Body != finalBody { - t.Fatalf("show.Spec.Body = %q, want %q", show.Spec.Body, finalBody) - } - if !show.Spec.HasBody { - t.Fatal("show.Spec.HasBody = false, want true") - } - - // `edit` mutates SQLite only; no repository file may appear before finalize. - afterEditFiles := repoFileList(t, workingDir) - if strings.Join(afterEditFiles, "\n") != strings.Join(beforeFiles, "\n") { - t.Fatalf("spec edit repository files:\nbefore=%v\nafter=%v", beforeFiles, afterEditFiles) - } - renderPath := filepath.Join(workingDir, ".agents", "specs", "SPEC-001-auth-rotation.md") - if _, err := os.Stat(renderPath); !os.IsNotExist(err) { - t.Fatalf("spec render file exists before finalize or stat failed: %v", err) - } - - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"spec", "finalize", "SPEC-001"}); err != nil { - t.Fatalf("spec finalize error = %v", err) - } - render, err := os.ReadFile(renderPath) - if err != nil { - t.Fatalf("ReadFile(finalized render) error = %v", err) - } - if !strings.Contains(string(render), "File body wins.") { - t.Fatalf("finalized render = %q, want edited body", string(render)) - } - - var driftOut bytes.Buffer - if err := (Runner{Stdout: &driftOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"check", "--hook", "render-drift", "--json"}); err != nil { - t.Fatalf("check render-drift error = %v", err) - } - if !strings.Contains(driftOut.String(), "\"passed\":true") && !strings.Contains(driftOut.String(), "\"passed\": true") { - t.Fatalf("render-drift output = %s, want passed", driftOut.String()) - } -} - -func TestRunnerSpecEditRequiresBodyFlag(t *testing.T) { - workingDir := realpath(t, t.TempDir()) - stateHome := t.TempDir() - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "init"}); err != nil { - t.Fatalf("state init error = %v", err) - } - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"spec", "new", "auth-rotation", "--message", "body"}); err != nil { - t.Fatalf("spec new error = %v", err) - } - - t.Setenv("EDITOR", "false") - err := Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}.Run([]string{"spec", "edit", "SPEC-001"}) - want := "spec edit requires body content via --body-file, --body -, or --message" - if err == nil || err.Error() != want { - t.Fatalf("spec edit without body error = %v, want %q", err, want) - } -} - -func TestRunnerSpecEditRequiresSQLiteState(t *testing.T) { - workingDir := realpath(t, t.TempDir()) - stateHome := t.TempDir() - writeCLIAgentsFile(t, workingDir, "specs/SPEC-001-legacy.md", "---\nid: SPEC-001\nstatus: draft\ntitle: Legacy\n---\n# Legacy\n") - - err := Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}.Run([]string{"spec", "edit", "SPEC-001", "--message", "body"}) - want := "loaf spec edit requires initialized SQLite state; run `loaf state init` or `loaf state migrate markdown --apply` first" - if err == nil || err.Error() != want { - t.Fatalf("spec edit markdown-only error = %v, want %q", err, want) - } -} - -func TestRunnerSpecEditJSONOutput(t *testing.T) { - workingDir := realpath(t, t.TempDir()) - stateHome := t.TempDir() - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "init"}); err != nil { - t.Fatalf("state init error = %v", err) - } - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"spec", "new", "auth-rotation", "--title", "Auth Rotation", "--message", "# Auth Rotation\n\nInitial body."}); err != nil { - t.Fatalf("spec new error = %v", err) - } - - var editOut bytes.Buffer - if err := (Runner{Stdout: &editOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"spec", "edit", "SPEC-001", "--message", "# Auth Rotation\n\nEdited body.", "--json"}); err != nil { - t.Fatalf("spec edit --json error = %v", err) - } - result := decodeSpecEditResult(t, editOut.Bytes()) - assertCLIReportContext(t, result.ContractVersion, result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath, workingDir) - if result.Spec.Kind != "spec" || result.Spec.Alias != "SPEC-001" { - t.Fatalf("result.Spec = %#v, want spec SPEC-001", result.Spec) - } - if result.Imported { - t.Fatalf("result.Imported = true, want false for SQLite-native spec") - } - if result.ContentHash == "" { - t.Fatal("result.ContentHash is empty") - } - if result.EventID == "" { - t.Fatal("result.EventID is empty") - } -} - func TestRunnerReportEditThenFinalizeRefreshesTrackedRender(t *testing.T) { workingDir := realpath(t, t.TempDir()) stateHome := t.TempDir() @@ -13363,7 +11722,6 @@ func TestRunnerGenerateCLIReferenceIndexesEditSubcommands(t *testing.T) { } content := string(data) for _, want := range []string{ - "| `loaf spec` | Manage project specs | new, edit, list, show, status, render, finalize, archive, delete |", "| `loaf report` | Manage durable reports (research, audits, investigations) | list, show, render, generate, create, edit, finalize, archive |", } { if !strings.Contains(content, want) { @@ -14935,7 +13293,6 @@ func TestRunnerNestedStateBackedHelpDoesNotParseAsOption(t *testing.T) { {name: "state export all", args: []string{"state", "export", "all", "--help"}, want: "Usage: loaf state export all"}, {name: "task update", args: []string{"task", "update", "--help"}, want: "Usage: loaf task update "}, {name: "task create", args: []string{"task", "create", "--help"}, want: "Usage: loaf task create --title "}, - {name: "spec show", args: []string{"spec", "show", "--help"}, want: "Usage: loaf spec show <spec>"}, {name: "journal log", args: []string{"journal", "log", "--help"}, want: "Usage: loaf journal log"}, {name: "report create", args: []string{"report", "create", "--help"}, want: "Usage: loaf report create <slug>"}, {name: "brainstorm archive", args: []string{"brainstorm", "archive", "--help"}, want: "Usage: loaf brainstorm archive <brainstorm...>"}, @@ -15042,30 +13399,6 @@ func TestRunnerReportListHelpNamesLifecycleStatuses(t *testing.T) { t.Fatalf("stdout = %q, want %q", stdout.String(), want) } } - -func TestRunnerSpecNewHelpMatchesParser(t *testing.T) { - var stdout bytes.Buffer - err := Runner{ - Stdout: &stdout, - WorkingDir: t.TempDir(), - }.Run([]string{"spec", "new", "--help"}) - if err != nil { - t.Fatalf("Run(spec new --help) error = %v", err) - } - for _, want := range []string{ - "Usage: loaf spec new <slug> --title <title> [options]", - "--id Explicit spec id (SPEC-NNN); auto-allocated when omitted", - "--branch Implementation branch recorded on the spec for breakdown/implement handoff", - "--related Comma-separated spec refs to link as related (SPEC-A,SPEC-B)", - "--body-file Read the spec body from a file", - "--message Use the given text as the spec body", - } { - if !strings.Contains(stdout.String(), want) { - t.Fatalf("stdout = %q, want %q", stdout.String(), want) - } - } -} - func TestRunnerReportCreateHelpMatchesParser(t *testing.T) { var stdout bytes.Buffer err := Runner{ @@ -15159,7 +13492,7 @@ func TestRunnerAgentHelpIsNative(t *testing.T) { } commands[command.Name] = entry } - for _, want := range []string{"build", "state", "project", "docs", "change", "journal", "task", "spec", "report", "plan", "handoff", "council", "kb", "release", "version"} { + for _, want := range []string{"build", "state", "project", "docs", "journal", "task", "issue", "report", "plan", "handoff", "council", "kb", "release", "version"} { if _, ok := commands[want]; !ok { t.Fatalf("agent help commands missing %q: %#v", want, commands) } @@ -15395,12 +13728,6 @@ func TestRunnerAgentHelpIsNative(t *testing.T) { if got := commands["task"].optionDescriptions["task refresh --json"]; !strings.Contains(got, "compatibility mode") || !strings.Contains(got, "counts") { t.Fatalf("task refresh json description = %q, want compatibility/count guidance", got) } - if got := commands["spec"].optionDescriptions["spec list --json"]; !strings.Contains(got, "specs") || !strings.Contains(got, "task counts") || !strings.Contains(got, "project identity") { - t.Fatalf("spec list json description = %q, want specs/task counts/project identity guidance", got) - } - if got := commands["spec"].optionDescriptions["spec show --json"]; !strings.Contains(got, "relationships") || !strings.Contains(got, "global database scope") { - t.Fatalf("spec show json description = %q, want relationship/scope guidance", got) - } if got := commands["report"].optionDescriptions["report list --json"]; !strings.Contains(got, "reports") || !strings.Contains(got, "diagnostics") || !strings.Contains(got, "project identity") { t.Fatalf("report list json description = %q, want reports/diagnostics/project identity guidance", got) } @@ -15410,18 +13737,9 @@ func TestRunnerAgentHelpIsNative(t *testing.T) { if got := commands["report"].optionDescriptions["report finalize --json"]; !strings.Contains(got, "status transition") || !strings.Contains(got, "project identity") { t.Fatalf("report finalize json description = %q, want status transition/project identity guidance", got) } - if !stringSliceContains(commands["spec"].subcommands, "edit") { - t.Fatalf("spec subcommands = %#v, want edit", commands["spec"].subcommands) - } if !stringSliceContains(commands["report"].subcommands, "edit") { t.Fatalf("report subcommands = %#v, want edit", commands["report"].subcommands) } - if got := commands["spec"].optionDescriptions["spec edit --json"]; !strings.Contains(got, "edited spec") || !strings.Contains(got, "content hash") || !strings.Contains(got, "project identity") { - t.Fatalf("spec edit json description = %q, want edited spec/content hash/project identity guidance", got) - } - if got := commands["spec"].optionDescriptions["spec edit --force"]; !strings.Contains(got, "diverges") { - t.Fatalf("spec edit force description = %q, want divergence guidance", got) - } if got := commands["report"].optionDescriptions["report edit --json"]; !strings.Contains(got, "edited report") || !strings.Contains(got, "content hash") || !strings.Contains(got, "project identity") { t.Fatalf("report edit json description = %q, want edited report/content hash/project identity guidance", got) } diff --git a/internal/cli/command_output_test.go b/internal/cli/command_output_test.go index 10d1d204e..275b95392 100644 --- a/internal/cli/command_output_test.go +++ b/internal/cli/command_output_test.go @@ -3,7 +3,6 @@ package cli import ( "errors" "os/exec" - "path/filepath" "strings" "testing" ) @@ -27,66 +26,3 @@ func TestCommandOutputCapturesStderr(t *testing.T) { t.Fatalf("want errors.As ExitError, got %T %v", err, err) } } - -func TestChangeReceiptBlockMessagesNameFolderCauseRemedy(t *testing.T) { - folder := filepath.Join("docs", "changes", "20260727-demo") - cases := []struct { - name string - verdict changeReceiptVerdict - want []string - forbid []string - }{ - { - name: "drift", - verdict: changeReceiptVerdict{Reason: changeReceiptContentDrift, DriftedSections: []string{"internal", "content"}}, - want: []string{`change "demo"`, "1.0.0", "content changed under `internal`, `content`", "Run: loaf change verify", folder, "commit the receipt"}, - forbid: []string{"exit status", "cannot inspect", "invalid", "corrupt", "later non-receipt"}, - }, - { - name: "criteria", - verdict: changeReceiptVerdict{Reason: changeReceiptCriteriaMismatch}, - want: []string{"criteria changed (receipt expired)", "Run: loaf change verify", folder}, - forbid: []string{"exit status", "invalid", "corrupt"}, - }, - { - name: "schema", - verdict: changeReceiptVerdict{Reason: changeReceiptUnsupportedSchema, SchemaVersion: 1}, - want: []string{"unsupported receipt schema_version 1", "Run: loaf change verify", folder}, - forbid: []string{"invalid", "corrupt", "exit status"}, - }, - { - name: "failing", - verdict: changeReceiptVerdict{Reason: changeReceiptFailingResults, FailedIDs: []string{"V1", "V3"}}, - want: []string{"receipt records failing criteria (V1, V3)", "Fix the failing criteria, then run: loaf change verify", folder, "and commit the receipt"}, - forbid: []string{"exit status", "cannot inspect", "invalid", "corrupt"}, - }, - { - name: "boundary", - verdict: changeReceiptVerdict{Reason: changeReceiptBoundaryChanged}, - want: []string{"evidence boundary changed since verification (receipt expired)", "Run: loaf change verify", folder}, - forbid: []string{"exit status", "invalid", "corrupt", "cannot inspect"}, - }, - { - name: "evidence-unavailable", - verdict: changeReceiptVerdict{Reason: changeReceiptEvidenceUnavailable}, - want: []string{"could not read evidence at HEAD (git error)", "Verification cannot proceed until git reads succeed", "git fsck", "re-clone"}, - forbid: []string{"exit status", "cannot inspect", "Run: loaf change verify"}, - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - msg := formatChangeReceiptBlock("demo", "1.0.0", tc.verdict, folder) - for _, w := range tc.want { - if !strings.Contains(msg, w) { - t.Fatalf("msg=%q missing %q", msg, w) - } - } - lower := strings.ToLower(msg) - for _, f := range tc.forbid { - if strings.Contains(lower, strings.ToLower(f)) { - t.Fatalf("msg=%q must not contain %q", msg, f) - } - } - }) - } -} diff --git a/internal/cli/delete_command_test.go b/internal/cli/delete_command_test.go index 4b7966dc8..1129dcb3a 100644 --- a/internal/cli/delete_command_test.go +++ b/internal/cli/delete_command_test.go @@ -35,57 +35,6 @@ spec: SPEC-001 } } -func TestRunnerSpecDeleteRemovesSpecAndDependents(t *testing.T) { - workingDir := realpath(t, t.TempDir()) - stateHome := t.TempDir() - migrateSpecFixture(t, workingDir, stateHome) - - var jsonOut bytes.Buffer - err := Runner{Stdout: &jsonOut, WorkingDir: workingDir, StateHome: stateHome}.Run([]string{"spec", "delete", "SPEC-001", "--yes", "--json"}) - if err != nil { - t.Fatalf("spec delete --yes --json error = %v", err) - } - var result state.SpecDeleteResult - if err := json.Unmarshal(jsonOut.Bytes(), &result); err != nil { - t.Fatalf("json.Unmarshal(%q) error = %v", jsonOut.String(), err) - } - removed := map[string]int{} - for _, count := range result.Removed { - removed[count.Table] = count.Rows - } - if removed["specs"] != 1 || removed["aliases"] < 1 || removed["artifact_bodies"] < 1 { - t.Fatalf("removed = %#v, want specs=1 and dependent rows removed", removed) - } - - // The spec is gone. - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"spec", "show", "SPEC-001"}); err == nil { - t.Fatal("spec show after delete = nil error, want not-found failure") - } - // The linked task survives. - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"task", "show", "TASK-001"}); err != nil { - t.Fatalf("task show after spec delete error = %v, want task preserved", err) - } -} - -func TestRunnerSpecDeleteRequiresYes(t *testing.T) { - workingDir := realpath(t, t.TempDir()) - stateHome := t.TempDir() - migrateSpecFixture(t, workingDir, stateHome) - - var out bytes.Buffer - err := Runner{Stdout: &out, WorkingDir: workingDir, StateHome: stateHome}.Run([]string{"spec", "delete", "SPEC-001"}) - if err == nil { - t.Fatal("spec delete without --yes = nil error, want refusal") - } - if !strings.Contains(err.Error(), "confirmation-required") { - t.Fatalf("error = %q, want confirmation-required message", err.Error()) - } - // Nothing was deleted. - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"spec", "show", "SPEC-001"}); err != nil { - t.Fatalf("spec show after refused delete error = %v, want spec intact", err) - } -} - func TestRunnerProjectDeleteCascades(t *testing.T) { workingDir := realpath(t, t.TempDir()) stateHome := t.TempDir() diff --git a/internal/cli/enumerated_reader_unix_test.go b/internal/cli/enumerated_reader_unix_test.go index 6e99f903e..f2c9efc31 100644 --- a/internal/cli/enumerated_reader_unix_test.go +++ b/internal/cli/enumerated_reader_unix_test.go @@ -3,7 +3,6 @@ package cli import ( - "bytes" "errors" "os" "os/exec" @@ -79,45 +78,6 @@ func TestEphemeralProvenanceSkipsFifoSpecWithoutBlocking(t *testing.T) { t.Fatal("runNativeEphemeralProvenance blocked on a FIFO") } } - -func TestChangeTaskListingSkipsFifoWithoutBlocking(t *testing.T) { - root := t.TempDir() - folder := filepath.Join(root, "docs", "changes", "20260731-test-change") - tasksDir := filepath.Join(folder, "tasks") - mkdirAll(t, tasksDir) - writeInstallFile(t, filepath.Join(tasksDir, "TASK-001-real.md"), "---\nid: TASK-001\n---\n# Real\n") - mkfifoForTest(t, filepath.Join(tasksDir, "TASK-fifo.md")) - - type listResult struct { - names []string - findings []string - } - done := make(chan listResult, 1) - go func() { - names, _, findings := listChangeTaskFileContents(root, folder, "docs/changes/20260731-test-change", changeTaskContentWorkingTree, nil) - done <- listResult{names: names, findings: findings} - }() - - select { - case result := <-done: - if len(result.names) != 1 || result.names[0] != "TASK-001-real.md" { - t.Fatalf("names = %#v, want only the real task", result.names) - } - found := false - for _, finding := range result.findings { - if strings.Contains(finding, "TASK-fifo.md") && strings.Contains(finding, "not a regular file") { - found = true - break - } - } - if !found { - t.Fatalf("findings = %#v, want a skip notice for the FIFO", result.findings) - } - case <-time.After(5 * time.Second): - t.Fatal("listChangeTaskFileContents blocked on a FIFO") - } -} - func TestReleaseIncompleteTasksSkipsFifoWithoutBlocking(t *testing.T) { root := t.TempDir() tasksDir := filepath.Join(root, ".agents", "tasks") @@ -186,52 +146,6 @@ func TestFilesHaveSameContentRefusesFifo(t *testing.T) { } } -func TestReadValidatedChangeRefusesFifo(t *testing.T) { - root := initEnumeratedGitRepo(t) - folder := filepath.Join(root, "docs", "changes", "20260731-fifo-change") - mkdirAll(t, folder) - changePath := filepath.Join(folder, "change.md") - mkfifoForTest(t, changePath) - - done := make(chan error, 1) - go func() { - _, err := readValidatedChange(root, changePath, changePath, "docs/changes/20260731-fifo-change/change.md", changeOriginOps{}) - done <- err - }() - - select { - case err := <-done: - if err == nil { - t.Fatal("readValidatedChange accepted a FIFO") - } - if !errors.Is(err, errNotRegularFile) && !strings.Contains(err.Error(), "not a regular file") { - t.Fatalf("error = %v, want errNotRegularFile", err) - } - case <-time.After(5 * time.Second): - t.Fatal("readValidatedChange blocked on a FIFO") - } -} - -func TestReadValidatedChangeBoundsRead(t *testing.T) { - root := initEnumeratedGitRepo(t) - folder := filepath.Join(root, "docs", "changes", "20260731-bound-change") - mkdirAll(t, folder) - changePath := filepath.Join(folder, "change.md") - body := "---\nslug: bound-change\n---\n# Change\n\nSmall enough.\n" - writeInstallFile(t, changePath, body) - runEnumeratedGit(t, root, "add", ".") - runEnumeratedGit(t, root, "-c", "commit.gpgsign=false", "commit", "-m", "add change") - - // Use the slug selector so revalidation walks the same path production does. - content, err := readValidatedChange(root, "bound-change", changePath, "docs/changes/20260731-bound-change/change.md", changeOriginOps{}) - if err != nil { - t.Fatalf("readValidatedChange(valid) error = %v", err) - } - if !bytes.Equal(content, []byte(body)) { - t.Fatalf("content = %q, want %q", content, body) - } -} - func initEnumeratedGitRepo(t *testing.T) string { t.Helper() root := t.TempDir() diff --git a/internal/cli/frozen_work_model.go b/internal/cli/frozen_work_model.go new file mode 100644 index 000000000..39e6e773f --- /dev/null +++ b/internal/cli/frozen_work_model.go @@ -0,0 +1,16 @@ +package cli + +import ( + "fmt" + "io" +) + +const workModelMigrationIssue = "LOAF-42" + +func frozenWorkModelError(namespace string) error { + return fmt.Errorf("%s is frozen pending migration; use loaf issue for new work. Retirement is gated on %s", namespace, workModelMigrationIssue) +} + +func writeFrozenWorkModelNote(out io.Writer, namespace string, writeVerbs string) { + fmt.Fprintf(out, "Deprecated: %s write verbs (%s) are frozen pending migration. Use loaf issue for new work. Retirement is gated on %s.\n", namespace, writeVerbs, workModelMigrationIssue) +} diff --git a/internal/cli/frozen_work_model_test.go b/internal/cli/frozen_work_model_test.go new file mode 100644 index 000000000..eb685e5da --- /dev/null +++ b/internal/cli/frozen_work_model_test.go @@ -0,0 +1,127 @@ +package cli + +import ( + "bytes" + "strings" + "testing" +) + +func assertFrozenWorkModel(t *testing.T, err error, output string) { + t.Helper() + if err == nil { + t.Fatal("error = nil, want frozen work-model refusal") + } + msg := err.Error() + output + if !strings.Contains(msg, "frozen pending migration") || !strings.Contains(msg, "loaf issue") || !strings.Contains(msg, "LOAF-42") { + t.Fatalf("error = %v\n%s, want freeze redirect naming loaf issue and LOAF-42", err, output) + } +} + +func runFrozenTaskWrite(t *testing.T, workingDir, stateHome string, args ...string) { + t.Helper() + var stdout bytes.Buffer + err := Runner{Stdout: &stdout, WorkingDir: workingDir, StateHome: stateHome}.Run(args) + assertFrozenWorkModel(t, err, stdout.String()) +} + +func TestFrozenTaskWriteVerbsRefuseAndReadsSucceed(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "init"}); err != nil { + t.Fatalf("state init error = %v", err) + } + + for _, args := range [][]string{ + {"task", "create", "--title", "Frozen"}, + {"task", "update", "TASK-001", "--status", "done"}, + {"task", "archive", "TASK-001"}, + } { + var stdout bytes.Buffer + err := Runner{Stdout: &stdout, WorkingDir: workingDir, StateHome: stateHome}.Run(args) + if err == nil { + t.Fatalf("%v error = nil, want freeze", args) + } + msg := err.Error() + stdout.String() + if !strings.Contains(msg, "frozen pending migration") || !strings.Contains(msg, "loaf issue") || !strings.Contains(msg, "LOAF-42") { + t.Fatalf("%v error = %v\n%s, want freeze redirect", args, err, stdout.String()) + } + } + + for _, args := range [][]string{ + {"task", "list"}, + {"task", "status"}, + {"task", "refresh"}, + {"task", "sync"}, + } { + var stdout bytes.Buffer + if err := (Runner{Stdout: &stdout, WorkingDir: workingDir, StateHome: stateHome}).Run(args); err != nil { + t.Fatalf("%v error = %v\n%s", args, err, stdout.String()) + } + } + + var showOut bytes.Buffer + showErr := Runner{Stdout: &showOut, WorkingDir: workingDir, StateHome: stateHome}.Run([]string{"task", "show", "TASK-001"}) + if showErr == nil { + t.Fatal("task show TASK-001 error = nil, want missing-task error after freeze (not a write)") + } + if strings.Contains(showErr.Error(), "frozen pending migration") { + t.Fatalf("task show was frozen: %v", showErr) + } +} + +func TestFrozenIntentWriteVerbsRefuseAndReadsSucceed(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "init"}); err != nil { + t.Fatalf("state init error = %v", err) + } + + for _, args := range [][]string{ + {"intent", "create", "--title", "Frozen", "--body", "body"}, + {"intent", "defer", "INT-1", "--why", "why", "--boundary", "boundary", "--trigger", "later", "--operation-id", "op-1"}, + {"intent", "resume", "INT-1", "--reason", "now"}, + {"intent", "resolve", "INT-1", "--reason", "done"}, + } { + var stdout bytes.Buffer + err := Runner{Stdout: &stdout, WorkingDir: workingDir, StateHome: stateHome}.Run(args) + if err == nil { + t.Fatalf("%v error = nil, want freeze", args) + } + msg := err.Error() + stdout.String() + if !strings.Contains(msg, "frozen pending migration") || !strings.Contains(msg, "loaf issue") || !strings.Contains(msg, "LOAF-42") { + t.Fatalf("%v error = %v\n%s, want freeze redirect", args, err, stdout.String()) + } + } + + var listOut bytes.Buffer + if err := (Runner{Stdout: &listOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"intent", "list"}); err != nil { + t.Fatalf("intent list error = %v\n%s", err, listOut.String()) + } + + var showOut bytes.Buffer + showErr := Runner{Stdout: &showOut, WorkingDir: workingDir, StateHome: stateHome}.Run([]string{"intent", "show", "INT-1"}) + if showErr == nil { + t.Fatal("intent show INT-1 error = nil, want missing-intent error after freeze (not a write)") + } + if strings.Contains(showErr.Error(), "frozen pending migration") { + t.Fatalf("intent show was frozen: %v", showErr) + } +} + +func TestFrozenTaskAndIntentHelpStatesDeprecation(t *testing.T) { + var taskOut bytes.Buffer + if err := (Runner{Stdout: &taskOut}).Run([]string{"task", "--help"}); err != nil { + t.Fatalf("task --help error = %v", err) + } + if !strings.Contains(taskOut.String(), "frozen pending migration") || !strings.Contains(taskOut.String(), "LOAF-42") { + t.Fatalf("task help missing deprecation:\n%s", taskOut.String()) + } + + var intentOut bytes.Buffer + if err := (Runner{Stdout: &intentOut}).Run([]string{"intent", "--help"}); err != nil { + t.Fatalf("intent --help error = %v", err) + } + if !strings.Contains(intentOut.String(), "frozen pending migration") || !strings.Contains(intentOut.String(), "LOAF-42") { + t.Fatalf("intent help missing deprecation:\n%s", intentOut.String()) + } +} diff --git a/internal/cli/install_fenced.go b/internal/cli/install_fenced.go index 21bb85765..5a28c94a8 100644 --- a/internal/cli/install_fenced.go +++ b/internal/cli/install_fenced.go @@ -303,12 +303,12 @@ func generateFencedContent() string { "- `discover(scope)`: Something learned", "- `block(scope)` / `unblock(scope)`: Blockers and resolutions", "- `spark(scope)`: Ideas to promote via `/idea`", - "- `todo(scope)`: Action items to promote to tasks", + "- `todo(scope)`: Action items to file as issues", "", "**CLI Commands:**", "- `loaf journal log/recent/search/context` - Project journal", "- `loaf check` - Run enforcement hooks", - "- `loaf task/spec/kb` - Task and knowledge management", + "- `loaf issue/kb` - Issue and knowledge management", "", "**Journal Discipline:**", "Before completing any response that includes edits, commits, or significant decisions, log journal entries using `loaf journal log \"type(scope): description\"`. Entry types: `decision`, `discover`, `wrap`. Do not defer journaling - log before responding.", diff --git a/internal/cli/install_fenced_test.go b/internal/cli/install_fenced_test.go index 4c28596f3..5e8a940cb 100644 --- a/internal/cli/install_fenced_test.go +++ b/internal/cli/install_fenced_test.go @@ -12,6 +12,15 @@ func TestGenerateFencedContentIsJournalFirst(t *testing.T) { if strings.Contains(content, "loaf session") { t.Fatalf("fenced content references deleted `loaf session` command:\n%s", content) } + if strings.Contains(content, "loaf task/spec") || strings.Contains(content, "promote to tasks") { + t.Fatalf("fenced content still uses retired task/spec guidance:\n%s", content) + } + if !strings.Contains(content, "loaf issue/kb") { + t.Fatalf("fenced content missing `loaf issue/kb` command listing:\n%s", content) + } + if !strings.Contains(content, "Action items to file as issues") { + t.Fatalf("fenced content missing issue-model todo guidance:\n%s", content) + } if !strings.Contains(content, "loaf journal log") { t.Fatalf("fenced content missing `loaf journal log` guidance:\n%s", content) } diff --git a/internal/cli/intent.go b/internal/cli/intent.go index 21dcea257..43dac6992 100644 --- a/internal/cli/intent.go +++ b/internal/cli/intent.go @@ -26,14 +26,8 @@ func (r Runner) runIntent(args []string, out io.Writer, runtime state.Runtime) e return nil } switch args[0] { - case "create": - return r.runIntentCreate(args[1:], out, runtime) - case "defer": - return r.runIntentDefer(args[1:], out, runtime) - case "resume": - return r.runIntentResume(args[1:], out, runtime) - case "resolve": - return r.runIntentResolve(args[1:], out, runtime) + case "create", "defer", "resume", "resolve": + return frozenWorkModelError("loaf intent") case "show": return r.runIntentShow(args[1:], out, runtime) case "list": @@ -44,7 +38,7 @@ func (r Runner) runIntent(args []string, out io.Writer, runtime state.Runtime) e } func writeIntentHelp(out io.Writer) { - writeCommandGroupHelp(out, "loaf intent <subcommand> [options]", "Manage tracked Intent in native SQLite state. Disposition is derived from append-only facts; there is no mutable lifecycle status.", []subcommandHelpItem{ + writeCommandGroupHelp(out, "loaf intent <subcommand> [options]", "Manage tracked Intent in native SQLite state. Disposition is derived from append-only facts; there is no mutable lifecycle status. Deprecated: write verbs (create, defer, resume, resolve) are frozen pending migration (LOAF-42). Use loaf issue for new work.", []subcommandHelpItem{ {Name: "create", Summary: "Create a tracked or deferred Intent"}, {Name: "defer", Summary: "Defer an existing Intent with an immutable payload"}, {Name: "resume", Summary: "Append a tracked disposition superseding the current deferral"}, @@ -55,7 +49,7 @@ func writeIntentHelp(out io.Writer) { } func writeIntentCreateHelp(out io.Writer) { - writeUsageHelp(out, "loaf intent create --title <title> --body <body> [--disposition deferred --why <why> --boundary <boundary> --trigger <trigger> --operation-id <key>] [--from <source>]... [--reason <reason>] [--operation-id <key>] [--json]", "Create one Intent snapshot plus its initial disposition in one transaction.", + writeUsageHelp(out, "loaf intent create --title <title> --body <body> [--disposition deferred --why <why> --boundary <boundary> --trigger <trigger> --operation-id <key>] [--from <source>]... [--reason <reason>] [--operation-id <key>] [--json]", "Deprecated: loaf intent create is frozen pending migration (LOAF-42). Use loaf issue for new work.", "--title Bounded single-line title", "--body Self-sufficient body", "--disposition tracked (default) or deferred", @@ -69,7 +63,7 @@ func writeIntentCreateHelp(out io.Writer) { } func writeIntentDeferHelp(out io.Writer) { - writeUsageHelp(out, "loaf intent defer <intent> --why <why> --boundary <boundary> --trigger <trigger> --operation-id <key> [--json]", "Append an immutable deferral to an existing Intent.", + writeUsageHelp(out, "loaf intent defer <intent> --why <why> --boundary <boundary> --trigger <trigger> --operation-id <key> [--json]", "Deprecated: loaf intent defer is frozen pending migration (LOAF-42). Use loaf issue for new work.", "--why Why the direction matters", "--boundary What excluded it now", "--trigger When to revisit", @@ -78,13 +72,13 @@ func writeIntentDeferHelp(out io.Writer) { } func writeIntentResumeHelp(out io.Writer) { - writeUsageHelp(out, "loaf intent resume <intent> --reason <why now> [--json]", "Append a tracked disposition linked to the deferral it supersedes.", + writeUsageHelp(out, "loaf intent resume <intent> --reason <why now> [--json]", "Deprecated: loaf intent resume is frozen pending migration (LOAF-42). Use loaf issue for new work.", "--reason Why the Intent is tracked again", "--json Output the resumed Intent and project identity as JSON") } func writeIntentResolveHelp(out io.Writer) { - writeUsageHelp(out, "loaf intent resolve <intent> --reason <outcome> [--json]", "Append a reasoned terminal disposition; history is never overwritten.", + writeUsageHelp(out, "loaf intent resolve <intent> --reason <outcome> [--json]", "Deprecated: loaf intent resolve is frozen pending migration (LOAF-42). Use loaf issue for new work.", "--reason Resolution outcome", "--json Output the resolved Intent and project identity as JSON") } diff --git a/internal/cli/issue.go b/internal/cli/issue.go new file mode 100644 index 000000000..bfbb16b83 --- /dev/null +++ b/internal/cli/issue.go @@ -0,0 +1,1358 @@ +package cli + +import ( + "context" + "fmt" + "io" + "strconv" + "strings" + + "github.com/levifig/loaf/internal/project" + "github.com/levifig/loaf/internal/state" +) + +type issueNewOptions struct { + jsonOutput bool + status string + create state.IssueCreateOptions + body bodyInputOptions +} + +type issueListOptions struct { + jsonOutput bool + filters state.IssueListOptions +} + +type issueEditOptions struct { + jsonOutput bool + ref string + body bodyInputOptions +} + +type issueStatusOptions struct { + jsonOutput bool + ref string + status string + duplicateOf string +} + +type issueDodAddOptions struct { + jsonOutput bool + ref string + input state.IssueCriterionInput +} + +type issueLinkOptions struct { + jsonOutput bool + from string + to string + relationshipType string + remove bool +} + +func (r Runner) runIssue(args []string, out io.Writer, runtime state.Runtime) error { + if len(args) == 0 || isHelpArg(args) { + writeIssueHelp(out) + return nil + } + if writeNestedHelp(out, args, map[string]func(io.Writer){ + "new": writeIssueNewHelp, + "show": writeIssueShowHelp, + "list": writeIssueListHelp, + "tree": writeIssueTreeHelp, + "frontier": writeIssueFrontierHelp, + "start": writeIssueStartHelp, + "stop": writeIssueStopHelp, + "edit": writeIssueEditHelp, + "status": writeIssueStatusHelp, + "dod": writeIssueDodHelp, + "promote": writeIssuePromoteHelp, + "check": writeIssueCheckHelp, + "verify": writeIssueVerifyHelp, + "bucket": writeIssueBucketHelp, + "link": writeIssueLinkHelp, + "render": writeIssueRenderHelp, + "export": writeIssueExportHelp, + "pull": writeIssuePullHelp, + "push": writeIssuePushHelp, + "reconcile": writeIssueReconcileHelp, + }) { + return nil + } + if args[0] == "dod" && writeNestedHelp(out, args[1:], map[string]func(io.Writer){ + "add": writeIssueDodAddHelp, + "list": writeIssueDodListHelp, + "remove": writeIssueDodRemoveHelp, + "claim": writeIssueDodClaimHelp, + "unclaim": writeIssueDodUnclaimHelp, + }) { + return nil + } + switch args[0] { + case "new": + return r.runIssueNew(args[1:], out, runtime) + case "show": + return r.runIssueShow(args[1:], out, runtime) + case "list": + return r.runIssueList(args[1:], out, runtime) + case "tree": + return r.runIssueTree(args[1:], out, runtime) + case "frontier": + return r.runIssueFrontier(args[1:], out, runtime) + case "start": + return r.runIssueStart(args[1:], out, runtime) + case "stop": + return r.runIssueStop(args[1:], out, runtime) + case "edit": + return r.runIssueEdit(args[1:], out, runtime) + case "status": + return r.runIssueStatus(args[1:], out, runtime) + case "dod": + return r.runIssueDod(args[1:], out, runtime) + case "promote": + return r.runIssuePromote(args[1:], out, runtime) + case "check": + return r.runIssueCheck(args[1:], out, runtime) + case "verify": + return r.runIssueVerify(args[1:], out, runtime) + case "bucket": + return r.runIssueBucket(args[1:], out, runtime) + case "link": + return r.runIssueLink(args[1:], out, runtime) + case "render": + return r.runIssueRender(args[1:], out, runtime) + case "export": + return r.runIssueExport(args[1:], out, runtime) + case "pull": + return r.runIssuePull(args[1:], out, runtime) + case "push": + return r.runIssuePush(args[1:], out, runtime) + case "reconcile": + return r.runIssueReconcile(args[1:], out, runtime) + default: + return unknownSubcommandError("issue", args[0]) + } +} + +func writeIssueHelp(out io.Writer) { + writeCommandGroupHelp(out, "loaf issue <subcommand> [options]", "Manage issues in native SQLite state.", []subcommandHelpItem{ + {Name: "new", Summary: "Create an issue"}, + {Name: "show", Summary: "Show one issue"}, + {Name: "list", Summary: "List project issues"}, + {Name: "tree", Summary: "Print a recursive issue tree"}, + {Name: "frontier", Summary: "List unblocked pick-up-next issues"}, + {Name: "start", Summary: "Create a branch and worktree for an issue"}, + {Name: "stop", Summary: "Remove an issue worktree and clear the started workspace"}, + {Name: "edit", Summary: "Replace an issue body"}, + {Name: "status", Summary: "Set an issue status"}, + {Name: "dod", Summary: "Manage definition-of-done criteria"}, + {Name: "promote", Summary: "Promote a criterion into a child issue"}, + {Name: "check", Summary: "Derive readiness from the issue row"}, + {Name: "verify", Summary: "Run V-tier criteria from the repository root"}, + {Name: "bucket", Summary: "Set an advisory Now/Next/Later label"}, + {Name: "link", Summary: "Create or remove an issue relationship"}, + {Name: "render", Summary: "Emit a paste-ready PR body"}, + {Name: "export", Summary: "Export issues, identity, criteria, claims, and relationships as JSON"}, + {Name: "pull", Summary: "Adopt an existing Linear issue"}, + {Name: "push", Summary: "Write the local render and status to Linear"}, + {Name: "reconcile", Summary: "Compare local and Linear and surface conflicts"}, + }) +} + +func writeIssueNewHelp(out io.Writer) { + writeUsageHelp(out, "loaf issue new <title> [--body <text>|--body -|--body-file <path>|--message <text>] [--kind delivery|decision] [--parent <ref>] [--fog <text>] [--status <status>] [--json]", "Create an issue in SQLite state.", + "--body Inline issue body, or '-' to read from stdin", + "--body-file Read the issue body from a UTF-8 file", + "--message Inline issue body; lower precedence than --body-file and --body -", + "--kind Issue kind: delivery (default) or decision", + "--parent Parent issue ref", + "--fog Questions not yet sharp enough to be issues", + "--status Write status after create: "+strings.Join(state.IssueWriteStatuses(), ", ")+"; still records the initial triage event", + "--json Output the created issue, global database scope, and project identity as JSON") +} + +func writeIssueShowHelp(out io.Writer) { + writeUsageHelp(out, "loaf issue show <ref> [--json]", "Show one issue by alias or opaque id.", "--json Output issue details, parent, children, bucket, global database scope, and project identity as JSON") +} + +func writeIssueListHelp(out io.Writer) { + writeUsageHelp(out, "loaf issue list [--status <status>] [--kind delivery|decision] [--archived] [--started] [--json]", "List project issues. Archived issues are hidden by default.", + "--status Filter by status: triage, backlog, todo, active, done, cancelled, duplicate", + "--kind Filter by kind: delivery or decision", + "--archived Include archived issues", + "--started List issues with a recorded started worktree", + "--json Output issues, global database scope, and project identity as JSON") +} + +func writeIssueTreeHelp(out io.Writer) { + writeUsageHelp(out, "loaf issue tree [<ref>] [--archived] [--json]", "Print a recursive issue tree from a ref, or the whole project when omitted.", + "--archived Include archived issues", + "--json Output the tree, global database scope, and project identity as JSON") +} + +func writeIssueFrontierHelp(out io.Writer) { + writeUsageHelp(out, "loaf issue frontier [--json]", "List non-archived triage/backlog/todo issues that are not blocked. Derived at read time.", + "--json Output frontier issues, global database scope, and project identity as JSON") +} + +func writeIssueEditHelp(out io.Writer) { + writeUsageHelp(out, "loaf issue edit <ref> [options]", "Replace an issue body through the shared body-edit path.", + "--body-file Read the issue body from a file", + "--body - Read the issue body from stdin", + "--message Use the given text as the issue body", + "--json Output the edited issue, global database scope, and project identity as JSON") +} + +func writeIssueStatusHelp(out io.Writer) { + writeUsageHelp(out, "loaf issue status <ref> <status> [--duplicate-of <ref>] [--json]", "Set issue status. Write statuses (triage, backlog, todo, active, done) update in place; cancelled and duplicate archive through the remove path.", + "--duplicate-of Surviving issue required when status is duplicate", + "--json Output the updated issue, global database scope, and project identity as JSON") +} + +func writeIssueDodHelp(out io.Writer) { + writeCommandGroupHelp(out, "loaf issue dod <subcommand> [options]", "Manage definition-of-done criteria on an issue.", []subcommandHelpItem{ + {Name: "add", Summary: "Add a criterion"}, + {Name: "list", Summary: "List criteria"}, + {Name: "remove", Summary: "Remove a criterion by position"}, + {Name: "claim", Summary: "Claim a child criterion against a parent criterion"}, + {Name: "unclaim", Summary: "Remove a child-to-parent criterion claim"}, + }) +} + +func writeIssueDodAddHelp(out io.Writer) { + writeUsageHelp(out, "loaf issue dod add <ref> <text> [--command <cmd>] [--expect <expect>] [--tier V|H] [--serves <parent-position>] [--json]", "Add a definition-of-done criterion. V tier is used when --command is present, otherwise H, unless --tier overrides.", + "--command Verification command (implies tier V)", + "--expect Verification expect grammar (exit N, contains <text>)", + "--tier Override criterion tier: V or H", + "--serves Parent criterion position this child criterion claims", + "--json Output the updated issue, global database scope, and project identity as JSON") +} + +func writeIssueDodClaimHelp(out io.Writer) { + writeUsageHelp(out, "loaf issue dod claim <child> <child-position> <parent-position> [--json]", "Record that the child's criterion at child-position serves the parent's criterion at parent-position.", + "--json Output the updated child issue, global database scope, and project identity as JSON") +} + +func writeIssueDodUnclaimHelp(out io.Writer) { + writeUsageHelp(out, "loaf issue dod unclaim <child> <child-position> <parent-position> [--json]", "Remove the claim from the child's criterion to the parent's criterion.", + "--json Output the updated child issue, global database scope, and project identity as JSON") +} + +func writeIssueDodListHelp(out io.Writer) { + writeUsageHelp(out, "loaf issue dod list <ref> [--json]", "List definition-of-done criteria for one issue.", "--json Output the issue and criteria, global database scope, and project identity as JSON") +} + +func writeIssueDodRemoveHelp(out io.Writer) { + writeUsageHelp(out, "loaf issue dod remove <ref> <position> [--json]", "Remove the criterion at the 1-based position.", "--json Output the updated issue, global database scope, and project identity as JSON") +} + +func writeIssuePromoteHelp(out io.Writer) { + writeUsageHelp(out, "loaf issue promote <ref> <position> [--json]", "Promote the criterion at the 1-based position into a child delivery issue. The parent criterion stays in place.", + "--json Output the new child issue, global database scope, and project identity as JSON") +} + +func writeIssueBucketHelp(out io.Writer) { + writeUsageHelp(out, "loaf issue bucket <ref> now|next|later|none [--json]", "Set an advisory Now/Next/Later label. Buckets are labels only and are never read as a constraint.", + "--json Output the issue and bucket, global database scope, and project identity as JSON") +} + +func writeIssueLinkHelp(out io.Writer) { + writeUsageHelp(out, "loaf issue link <from> blocks|relates-to <to> | loaf issue link <from> remove <type> <to> [--json]", "Create or remove an issue relationship. Stored types are blocks and relates_to.", + "--json Output the relationship mutation, global database scope, and project identity as JSON") +} + +func writeIssueRenderHelp(out io.Writer) { + writeUsageHelp(out, "loaf issue render <ref> [--json]", "Emit markdown suitable to paste as a PR body with no manual editing.", + "--json Output the markdown, issue, global database scope, and project identity as JSON") +} + +func writeIssueExportHelp(out io.Writer) { + writeUsageHelp(out, "loaf issue export [--json]", "Export the project's issues, identity, criteria, claims, and issue relationships as JSON.", + "--json Output the export snapshot (default)") +} + +func (r Runner) requireIssueSQLiteState(command string, runtime state.Runtime) (project.Root, error) { + projectRoot, err := project.ResolveRoot(runtime.RootPath()) + if err != nil { + return project.Root{}, err + } + status, err := state.Inspect(projectRoot, state.PathResolver{StateHome: r.StateHome}) + if err != nil { + return project.Root{}, err + } + switch status.Mode { + case state.ModeMarkdownOnly: + return project.Root{}, sqliteStateRequiredError(command) + case state.ModeInvalid: + return project.Root{}, fmt.Errorf("state database is invalid; run `loaf state doctor`") + } + return projectRoot, nil +} + +func (r Runner) runIssueNew(args []string, out io.Writer, runtime state.Runtime) error { + options, err := parseIssueNewArgs(args) + if err != nil { + return err + } + projectRoot, err := r.requireIssueSQLiteState("issue new", runtime) + if err != nil { + return err + } + body, ok, err := r.resolveBodyInput("issue new", options.body, false) + if err != nil { + return err + } + if ok { + options.create.Body = body + } + resolver := state.PathResolver{StateHome: r.StateHome} + created, err := r.createIssueWithIdentity(projectRoot, resolver, options.create) + if err != nil { + return err + } + return r.finishIssueNew(out, projectRoot, resolver, created.ID, options) +} + +type mintedLinearIssue struct { + Identifier string + URL string +} + +func (r Runner) mintIssueIdentity(projectRoot project.Root, resolver state.PathResolver, create state.IssueCreateOptions) (alias string, minted *mintedLinearIssue, err error) { + identity, ok, err := state.LookupIssueIdentity(context.Background(), projectRoot, resolver) + if err != nil { + return "", nil, err + } + if !ok || identity.Authority != state.IssueAuthorityLinear { + return "", nil, nil + } + client, err := state.LinearClientFromEnv() + if err != nil { + return "", nil, &state.LinearMintError{Err: err} + } + issue, err := state.MintLinearIssue(context.Background(), projectRoot, resolver, client, create) + if err != nil { + return "", nil, err + } + return issue.Identifier, &mintedLinearIssue{Identifier: issue.Identifier, URL: issue.URL}, nil +} + +func (r Runner) bindMintedLinearIssue(projectRoot project.Root, resolver state.PathResolver, issueID string, minted *mintedLinearIssue) error { + if minted == nil { + return nil + } + if err := state.BindLinearIssue(context.Background(), projectRoot, resolver, issueID, minted.Identifier, minted.URL); err != nil { + return &state.LinearOrphanError{Identifier: minted.Identifier, URL: minted.URL, Err: err} + } + return nil +} + +func (r Runner) createIssueWithIdentity(projectRoot project.Root, resolver state.PathResolver, create state.IssueCreateOptions) (state.Issue, error) { + alias, minted, err := r.mintIssueIdentity(projectRoot, resolver, create) + if err != nil { + return state.Issue{}, err + } + if alias != "" { + create.Alias = alias + } + created, err := state.CreateIssue(context.Background(), projectRoot, resolver, create) + if err != nil { + if minted != nil { + return state.Issue{}, &state.LinearOrphanError{Identifier: minted.Identifier, URL: minted.URL, Err: err} + } + return state.Issue{}, err + } + if err := r.bindMintedLinearIssue(projectRoot, resolver, created.ID, minted); err != nil { + return state.Issue{}, err + } + return created, nil +} + +func (r Runner) finishIssueNew(out io.Writer, projectRoot project.Root, resolver state.PathResolver, issueID string, options issueNewOptions) error { + createdID := issueID + if options.status != "" && options.status != state.IssueStatusTriage { + updated, err := state.UpdateIssue(context.Background(), projectRoot, resolver, state.IssueUpdateOptions{ + Ref: createdID, + Status: options.status, + SetStatus: true, + }) + if err != nil { + return err + } + createdID = updated.ID + } + result, err := state.ShowIssue(context.Background(), projectRoot, resolver, createdID) + if err != nil { + return err + } + if options.jsonOutput { + return writeJSON(out, result) + } + writeIssueCreated(out, result) + return nil +} + +func (r Runner) runIssueShow(args []string, out io.Writer, runtime state.Runtime) error { + ref, jsonOutput, err := parseSingleRefArgs("issue show", args) + if err != nil { + return err + } + projectRoot, err := r.requireIssueSQLiteState("issue show", runtime) + if err != nil { + return err + } + result, err := state.ShowIssue(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, ref) + if err != nil { + return err + } + if jsonOutput { + return writeJSON(out, result) + } + writeIssueShow(out, result) + return nil +} + +func (r Runner) runIssueList(args []string, out io.Writer, runtime state.Runtime) error { + options, err := parseIssueListArgs(args) + if err != nil { + return err + } + projectRoot, err := r.requireIssueSQLiteState("issue list", runtime) + if err != nil { + return err + } + result, err := state.ListIssues(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, options.filters) + if err != nil { + return err + } + if options.filters.Started { + markStartedWorktreeLiveness(result.Issues) + } + if options.jsonOutput { + return writeJSON(out, result) + } + if options.filters.Started { + writeIssueStartedList(out, result) + return nil + } + writeIssueList(out, result) + return nil +} + +func (r Runner) runIssueTree(args []string, out io.Writer, runtime state.Runtime) error { + ref, archived, jsonOutput, err := parseIssueTreeArgs(args) + if err != nil { + return err + } + projectRoot, err := r.requireIssueSQLiteState("issue tree", runtime) + if err != nil { + return err + } + result, err := state.IssueTree(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, ref, archived) + if err != nil { + return err + } + if jsonOutput { + return writeJSON(out, result) + } + writeIssueTree(out, result) + return nil +} + +func (r Runner) runIssueFrontier(args []string, out io.Writer, runtime state.Runtime) error { + jsonOutput, err := parseJSONOnly(args) + if err != nil { + return err + } + projectRoot, err := r.requireIssueSQLiteState("issue frontier", runtime) + if err != nil { + return err + } + result, err := state.ListIssueFrontier(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}) + if err != nil { + return err + } + if jsonOutput { + return writeJSON(out, result) + } + writeIssueFrontier(out, result) + return nil +} + +func (r Runner) runIssueEdit(args []string, out io.Writer, runtime state.Runtime) error { + options, err := parseIssueEditArgs(args) + if err != nil { + return err + } + projectRoot, err := r.requireIssueSQLiteState("issue edit", runtime) + if err != nil { + return err + } + body, ok, err := r.resolveBodyInput("issue edit", options.body, false) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("issue edit requires body content via --body-file, --body -, or --message") + } + updated, err := state.UpdateIssue(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, state.IssueUpdateOptions{ + Ref: options.ref, + Body: body, + SetBody: true, + }) + if err != nil { + return err + } + result, err := state.ShowIssue(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, updated.ID) + if err != nil { + return err + } + if options.jsonOutput { + return writeJSON(out, result) + } + fmt.Fprintf(out, "edited issue %s\n", issueDisplayRef(result.Issue)) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + return nil +} + +func (r Runner) runIssueStatus(args []string, out io.Writer, runtime state.Runtime) error { + options, err := parseIssueStatusArgs(args) + if err != nil { + return err + } + projectRoot, err := r.requireIssueSQLiteState("issue status", runtime) + if err != nil { + return err + } + var updated state.Issue + switch options.status { + case state.IssueStatusCancelled, state.IssueStatusDuplicate: + updated, err = state.RemoveIssue(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, state.IssueRemoveOptions{ + Ref: options.ref, + Status: options.status, + DuplicateOf: options.duplicateOf, + }) + default: + if options.duplicateOf != "" { + return fmt.Errorf("issue status --duplicate-of is only valid with status duplicate") + } + updated, err = state.UpdateIssue(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, state.IssueUpdateOptions{ + Ref: options.ref, + Status: options.status, + SetStatus: true, + }) + } + if err != nil { + return err + } + result, err := state.ShowIssue(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, updated.ID) + if err != nil { + return err + } + if options.jsonOutput { + return writeJSON(out, result) + } + writeIssueStatus(out, result) + return nil +} + +func (r Runner) runIssueDod(args []string, out io.Writer, runtime state.Runtime) error { + if len(args) == 0 || isHelpArg(args) { + writeIssueDodHelp(out) + return nil + } + switch args[0] { + case "add": + return r.runIssueDodAdd(args[1:], out, runtime) + case "list": + return r.runIssueDodList(args[1:], out, runtime) + case "remove": + return r.runIssueDodRemove(args[1:], out, runtime) + case "claim": + return r.runIssueDodClaim(args[1:], out, runtime) + case "unclaim": + return r.runIssueDodUnclaim(args[1:], out, runtime) + default: + return unknownSubcommandError("issue dod", args[0]) + } +} + +func (r Runner) runIssueDodAdd(args []string, out io.Writer, runtime state.Runtime) error { + options, err := parseIssueDodAddArgs(args) + if err != nil { + return err + } + projectRoot, err := r.requireIssueSQLiteState("issue dod add", runtime) + if err != nil { + return err + } + updated, err := state.AddIssueCriterion(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, options.ref, options.input) + if err != nil { + return err + } + result, err := state.ShowIssue(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, updated.ID) + if err != nil { + return err + } + if options.jsonOutput { + return writeJSON(out, result) + } + fmt.Fprintf(out, "added criterion %d on %s\n", result.Issue.Criteria[len(result.Issue.Criteria)-1].Position, issueDisplayRef(result.Issue)) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + return nil +} + +func (r Runner) runIssueDodList(args []string, out io.Writer, runtime state.Runtime) error { + ref, jsonOutput, err := parseSingleRefArgs("issue dod list", args) + if err != nil { + return err + } + projectRoot, err := r.requireIssueSQLiteState("issue dod list", runtime) + if err != nil { + return err + } + result, err := state.ShowIssue(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, ref) + if err != nil { + return err + } + if jsonOutput { + return writeJSON(out, result) + } + writeIssueDodList(out, result) + return nil +} + +func (r Runner) runIssueDodRemove(args []string, out io.Writer, runtime state.Runtime) error { + ref, position, jsonOutput, err := parseIssueRefPositionArgs("issue dod remove", args) + if err != nil { + return err + } + projectRoot, err := r.requireIssueSQLiteState("issue dod remove", runtime) + if err != nil { + return err + } + updated, err := state.RemoveIssueCriterion(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, ref, position) + if err != nil { + return err + } + result, err := state.ShowIssue(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, updated.ID) + if err != nil { + return err + } + if jsonOutput { + return writeJSON(out, result) + } + fmt.Fprintf(out, "removed criterion %d from %s\n", position, issueDisplayRef(result.Issue)) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + return nil +} + +func (r Runner) runIssueDodClaim(args []string, out io.Writer, runtime state.Runtime) error { + ref, childPosition, parentPosition, jsonOutput, err := parseIssueDodClaimArgs("issue dod claim", args) + if err != nil { + return err + } + projectRoot, err := r.requireIssueSQLiteState("issue dod claim", runtime) + if err != nil { + return err + } + updated, err := state.ClaimIssueCriterion(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, ref, childPosition, parentPosition) + if err != nil { + return err + } + result, err := state.ShowIssue(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, updated.ID) + if err != nil { + return err + } + if jsonOutput { + return writeJSON(out, result) + } + fmt.Fprintf(out, "claimed criterion %d on %s as serving parent criterion %d\n", childPosition, issueDisplayRef(result.Issue), parentPosition) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + return nil +} + +func (r Runner) runIssueDodUnclaim(args []string, out io.Writer, runtime state.Runtime) error { + ref, childPosition, parentPosition, jsonOutput, err := parseIssueDodClaimArgs("issue dod unclaim", args) + if err != nil { + return err + } + projectRoot, err := r.requireIssueSQLiteState("issue dod unclaim", runtime) + if err != nil { + return err + } + updated, err := state.UnclaimIssueCriterion(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, ref, childPosition, parentPosition) + if err != nil { + return err + } + result, err := state.ShowIssue(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, updated.ID) + if err != nil { + return err + } + if jsonOutput { + return writeJSON(out, result) + } + fmt.Fprintf(out, "unclaimed criterion %d on %s from parent criterion %d\n", childPosition, issueDisplayRef(result.Issue), parentPosition) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + return nil +} + +func (r Runner) runIssuePromote(args []string, out io.Writer, runtime state.Runtime) error { + ref, position, jsonOutput, err := parseIssueRefPositionArgs("issue promote", args) + if err != nil { + return err + } + projectRoot, err := r.requireIssueSQLiteState("issue promote", runtime) + if err != nil { + return err + } + resolver := state.PathResolver{StateHome: r.StateHome} + parent, err := state.GetIssue(context.Background(), projectRoot, resolver, ref) + if err != nil { + return err + } + var criterion *state.IssueCriterion + for i := range parent.Criteria { + if parent.Criteria[i].Position == position { + criterion = &parent.Criteria[i] + break + } + } + if criterion == nil { + return fmt.Errorf("issue %s has no criterion at position %d", issueDisplayRef(parent), position) + } + alias, minted, err := r.mintIssueIdentity(projectRoot, resolver, state.IssueCreateOptions{ + Title: criterion.Text, + Parent: firstNonEmpty(parent.Alias, parent.ID), + }) + if err != nil { + return err + } + child, err := state.PromoteIssueCriterion(context.Background(), projectRoot, resolver, ref, position, alias) + if err != nil { + if minted != nil { + return &state.LinearOrphanError{Identifier: minted.Identifier, URL: minted.URL, Err: err} + } + return err + } + if err := r.bindMintedLinearIssue(projectRoot, resolver, child.ID, minted); err != nil { + return err + } + result, err := state.ShowIssue(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, child.ID) + if err != nil { + return err + } + if jsonOutput { + return writeJSON(out, result) + } + fmt.Fprintf(out, "promoted criterion %d to %s\n", position, issueDisplayRef(result.Issue)) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + fmt.Fprintf(out, "title: %s\n", result.Issue.Title) + if result.Parent != nil { + fmt.Fprintf(out, "parent: %s\n", firstNonEmpty(result.Parent.Alias, result.Parent.ID)) + } + return nil +} + +func (r Runner) runIssueBucket(args []string, out io.Writer, runtime state.Runtime) error { + ref, bucket, jsonOutput, err := parseIssueBucketArgs(args) + if err != nil { + return err + } + projectRoot, err := r.requireIssueSQLiteState("issue bucket", runtime) + if err != nil { + return err + } + result, err := state.SetIssueBucket(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, ref, bucket) + if err != nil { + return err + } + if jsonOutput { + return writeJSON(out, result) + } + if result.Bucket == "" { + fmt.Fprintf(out, "cleared bucket on %s\n", issueDisplayRef(result.Issue)) + } else { + fmt.Fprintf(out, "set bucket %s on %s\n", result.Bucket, issueDisplayRef(result.Issue)) + } + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + return nil +} + +func (r Runner) runIssueLink(args []string, out io.Writer, runtime state.Runtime) error { + options, err := parseIssueLinkArgs(args) + if err != nil { + return err + } + projectRoot, err := r.requireIssueSQLiteState("issue link", runtime) + if err != nil { + return err + } + var result state.LinkMutationResult + if options.remove { + result, err = state.RemoveIssueLink(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, options.from, options.relationshipType, options.to) + } else { + result, err = state.CreateIssueLink(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, options.from, options.relationshipType, options.to) + } + if err != nil { + return err + } + if options.jsonOutput { + return writeJSON(out, result) + } + if options.remove { + fmt.Fprintf(out, "removed link %s %s %s\n", firstNonEmpty(result.From.Alias, result.From.ID), result.Type, firstNonEmpty(result.To.Alias, result.To.ID)) + } else { + fmt.Fprintf(out, "linked %s %s %s\n", firstNonEmpty(result.From.Alias, result.From.ID), result.Type, firstNonEmpty(result.To.Alias, result.To.ID)) + } + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + return nil +} + +func (r Runner) runIssueRender(args []string, out io.Writer, runtime state.Runtime) error { + ref, jsonOutput, err := parseSingleRefArgs("issue render", args) + if err != nil { + return err + } + projectRoot, err := r.requireIssueSQLiteState("issue render", runtime) + if err != nil { + return err + } + result, err := state.ShowIssue(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, ref) + if err != nil { + return err + } + markdown := renderIssueMarkdown(result) + if jsonOutput { + return writeJSON(out, map[string]any{ + "contract_version": result.ContractVersion, + "database_scope": result.DatabaseScope, + "database_path": result.DatabasePath, + "project_id": result.ProjectID, + "project_name": result.ProjectName, + "project_current_path": result.ProjectCurrentPath, + "issue": result.Issue, + "markdown": markdown, + }) + } + fmt.Fprint(out, markdown) + return nil +} + +func (r Runner) runIssueExport(args []string, out io.Writer, runtime state.Runtime) error { + if _, err := parseJSONOnly(args); err != nil { + return err + } + projectRoot, err := r.requireIssueSQLiteState("issue export", runtime) + if err != nil { + return err + } + result, err := state.ExportIssues(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}) + if err != nil { + return err + } + return writeJSON(out, result) +} + +func parseIssueNewArgs(args []string) (issueNewOptions, error) { + var options issueNewOptions + var positional []string + endOfOptions := false + for i := 0; i < len(args); i++ { + if endOfOptions { + positional = append(positional, args[i]) + continue + } + switch args[i] { + case "--": + endOfOptions = true + case "--json": + options.jsonOutput = true + case "--kind": + value, err := consumeFlagValue(args, &i, "--kind") + if err != nil { + return issueNewOptions{}, err + } + options.create.Kind = value + case "--parent": + value, err := consumeFlagValue(args, &i, "--parent") + if err != nil { + return issueNewOptions{}, err + } + options.create.Parent = value + case "--fog": + value, err := consumeFlagValue(args, &i, "--fog") + if err != nil { + return issueNewOptions{}, err + } + options.create.Fog = value + case "--status": + value, err := consumeFlagValue(args, &i, "--status") + if err != nil { + return issueNewOptions{}, err + } + status := strings.TrimSpace(value) + valid := false + for _, candidate := range state.IssueWriteStatuses() { + if status == candidate { + valid = true + break + } + } + if !valid { + return issueNewOptions{}, fmt.Errorf("issue new --status must be one of %s", strings.Join(state.IssueWriteStatuses(), ", ")) + } + options.status = status + case "--body": + value, err := consumeFlagValue(args, &i, "--body") + if err != nil { + return issueNewOptions{}, err + } + if value == "-" { + options.body.body = "-" + } else { + options.body.message = value + } + case "--body-file": + value, err := consumeFlagValue(args, &i, "--body-file") + if err != nil { + return issueNewOptions{}, err + } + options.body.bodyFile = value + case "--message": + value, err := consumeFlagValue(args, &i, "--message") + if err != nil { + return issueNewOptions{}, err + } + if options.body.message == "" { + options.body.message = value + } + default: + if strings.HasPrefix(args[i], "-") { + return issueNewOptions{}, fmt.Errorf("unknown option %q", args[i]) + } + positional = append(positional, args[i]) + } + } + if len(positional) != 1 { + return issueNewOptions{}, fmt.Errorf("issue new requires a title") + } + options.create.Title = positional[0] + return options, nil +} + +func parseIssueListArgs(args []string) (issueListOptions, error) { + var options issueListOptions + for i := 0; i < len(args); i++ { + switch args[i] { + case "--json": + options.jsonOutput = true + case "--archived": + options.filters.Archived = true + case "--started": + options.filters.Started = true + case "--status": + value, err := consumeFlagValue(args, &i, "--status") + if err != nil { + return issueListOptions{}, err + } + options.filters.Status = value + case "--kind": + value, err := consumeFlagValue(args, &i, "--kind") + if err != nil { + return issueListOptions{}, err + } + options.filters.Kind = value + default: + return issueListOptions{}, fmt.Errorf("unknown option %q", args[i]) + } + } + return options, nil +} + +func parseIssueTreeArgs(args []string) (string, bool, bool, error) { + ref := "" + archived := false + jsonOutput := false + for _, arg := range args { + switch arg { + case "--json": + jsonOutput = true + case "--archived": + archived = true + default: + if strings.HasPrefix(arg, "-") { + return "", false, false, fmt.Errorf("unknown option %q", arg) + } + if ref != "" { + return "", false, false, fmt.Errorf("issue tree accepts at most one ref") + } + ref = arg + } + } + return ref, archived, jsonOutput, nil +} + +func parseIssueEditArgs(args []string) (issueEditOptions, error) { + var options issueEditOptions + var positional []string + for i := 0; i < len(args); i++ { + if ok, err := parseBodyInputFlag(args, &i, &options.body); ok || err != nil { + if err != nil { + return issueEditOptions{}, err + } + continue + } + switch args[i] { + case "--json": + options.jsonOutput = true + default: + if strings.HasPrefix(args[i], "-") { + return issueEditOptions{}, fmt.Errorf("unknown option %q", args[i]) + } + positional = append(positional, args[i]) + } + } + if len(positional) != 1 { + return issueEditOptions{}, fmt.Errorf("issue edit requires exactly one issue ref") + } + options.ref = positional[0] + return options, nil +} + +func parseIssueStatusArgs(args []string) (issueStatusOptions, error) { + var options issueStatusOptions + var positional []string + for i := 0; i < len(args); i++ { + switch args[i] { + case "--json": + options.jsonOutput = true + case "--duplicate-of": + value, err := consumeFlagValue(args, &i, "--duplicate-of") + if err != nil { + return issueStatusOptions{}, err + } + options.duplicateOf = value + default: + if strings.HasPrefix(args[i], "-") { + return issueStatusOptions{}, fmt.Errorf("unknown option %q", args[i]) + } + positional = append(positional, args[i]) + } + } + if len(positional) != 2 { + return issueStatusOptions{}, fmt.Errorf("issue status requires an issue ref and a status") + } + options.ref = positional[0] + options.status = positional[1] + return options, nil +} + +func parseIssueDodAddArgs(args []string) (issueDodAddOptions, error) { + var options issueDodAddOptions + var positional []string + for i := 0; i < len(args); i++ { + switch args[i] { + case "--json": + options.jsonOutput = true + case "--command": + value, err := consumeFlagValue(args, &i, "--command") + if err != nil { + return issueDodAddOptions{}, err + } + options.input.Command = value + case "--expect": + value, err := consumeFlagValue(args, &i, "--expect") + if err != nil { + return issueDodAddOptions{}, err + } + options.input.Expect = value + case "--tier": + value, err := consumeFlagValue(args, &i, "--tier") + if err != nil { + return issueDodAddOptions{}, err + } + options.input.Tier = value + case "--serves": + value, err := consumeFlagValue(args, &i, "--serves") + if err != nil { + return issueDodAddOptions{}, err + } + position, err := strconv.Atoi(value) + if err != nil || position < 1 { + return issueDodAddOptions{}, fmt.Errorf("issue dod add --serves must be a positive integer") + } + options.input.ServesParentPosition = position + default: + if strings.HasPrefix(args[i], "-") { + return issueDodAddOptions{}, fmt.Errorf("unknown option %q", args[i]) + } + positional = append(positional, args[i]) + } + } + if len(positional) < 2 { + return issueDodAddOptions{}, fmt.Errorf("issue dod add requires an issue ref and criterion text") + } + options.ref = positional[0] + options.input.Text = strings.Join(positional[1:], " ") + return options, nil +} + +func parseIssueRefPositionArgs(command string, args []string) (string, int, bool, error) { + var positional []string + jsonOutput := false + for _, arg := range args { + switch arg { + case "--json": + jsonOutput = true + default: + if strings.HasPrefix(arg, "-") { + return "", 0, false, fmt.Errorf("unknown option %q", arg) + } + positional = append(positional, arg) + } + } + if len(positional) != 2 { + return "", 0, false, fmt.Errorf("%s requires an issue ref and a position", command) + } + position, err := strconv.Atoi(positional[1]) + if err != nil || position < 1 { + return "", 0, false, fmt.Errorf("%s position must be a positive integer", command) + } + return positional[0], position, jsonOutput, nil +} + +func parseIssueDodClaimArgs(command string, args []string) (string, int, int, bool, error) { + var positional []string + jsonOutput := false + for _, arg := range args { + switch arg { + case "--json": + jsonOutput = true + default: + if strings.HasPrefix(arg, "-") { + return "", 0, 0, false, fmt.Errorf("unknown option %q", arg) + } + positional = append(positional, arg) + } + } + if len(positional) != 3 { + return "", 0, 0, false, fmt.Errorf("%s requires a child ref, child position, and parent position", command) + } + childPosition, err := strconv.Atoi(positional[1]) + if err != nil || childPosition < 1 { + return "", 0, 0, false, fmt.Errorf("%s child position must be a positive integer", command) + } + parentPosition, err := strconv.Atoi(positional[2]) + if err != nil || parentPosition < 1 { + return "", 0, 0, false, fmt.Errorf("%s parent position must be a positive integer", command) + } + return positional[0], childPosition, parentPosition, jsonOutput, nil +} + +func parseIssueBucketArgs(args []string) (string, string, bool, error) { + var positional []string + jsonOutput := false + for _, arg := range args { + switch arg { + case "--json": + jsonOutput = true + default: + if strings.HasPrefix(arg, "-") { + return "", "", false, fmt.Errorf("unknown option %q", arg) + } + positional = append(positional, arg) + } + } + if len(positional) != 2 { + return "", "", false, fmt.Errorf("issue bucket requires an issue ref and now|next|later|none") + } + return positional[0], positional[1], jsonOutput, nil +} + +func parseIssueLinkArgs(args []string) (issueLinkOptions, error) { + var options issueLinkOptions + var positional []string + for _, arg := range args { + switch arg { + case "--json": + options.jsonOutput = true + default: + if strings.HasPrefix(arg, "-") { + return issueLinkOptions{}, fmt.Errorf("unknown option %q", arg) + } + positional = append(positional, arg) + } + } + switch len(positional) { + case 3: + options.from = positional[0] + options.relationshipType = positional[1] + options.to = positional[2] + case 4: + if positional[1] != "remove" { + return issueLinkOptions{}, fmt.Errorf("issue link remove requires <from> remove <type> <to>") + } + options.from = positional[0] + options.remove = true + options.relationshipType = positional[2] + options.to = positional[3] + default: + return issueLinkOptions{}, fmt.Errorf("issue link requires <from> blocks|relates-to <to> or <from> remove <type> <to>") + } + return options, nil +} + +func writeIssueCreated(out io.Writer, result state.IssueResult) { + fmt.Fprintf(out, "created issue %s\n", issueDisplayRef(result.Issue)) + if result.Issue.Alias == "" { + fmt.Fprintln(out, "note: no local alias is minted under a tracker authority") + } + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + fmt.Fprintf(out, "title: %s\n", result.Issue.Title) + fmt.Fprintf(out, "kind: %s\n", result.Issue.Kind) + fmt.Fprintf(out, "status: %s\n", result.Issue.Status) +} + +func writeIssueShow(out io.Writer, result state.IssueResult) { + issue := result.Issue + fmt.Fprintf(out, "issue %s\n", issueDisplayRef(issue)) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + fmt.Fprintf(out, "id: %s\n", issue.ID) + if issue.Alias != "" { + fmt.Fprintf(out, "alias: %s\n", issue.Alias) + } + fmt.Fprintf(out, "title: %s\n", issue.Title) + fmt.Fprintf(out, "kind: %s\n", issue.Kind) + fmt.Fprintf(out, "status: %s\n", issue.Status) + if issue.StartedBranch != "" { + fmt.Fprintf(out, "started_branch: %s\n", issue.StartedBranch) + } + if issue.StartedWorktree != "" { + fmt.Fprintf(out, "started_worktree: %s\n", issue.StartedWorktree) + } + if result.Parent != nil { + fmt.Fprintf(out, "parent: %s\n", firstNonEmpty(result.Parent.Alias, result.Parent.ID)) + } else { + fmt.Fprintln(out, "parent: none") + } + if issue.Fog != "" { + fmt.Fprintf(out, "fog: %s\n", issue.Fog) + } + if result.Bucket != "" { + fmt.Fprintf(out, "bucket: %s\n", result.Bucket) + } + if issue.ArchivedAt != "" { + fmt.Fprintf(out, "archived: %s\n", issue.ArchivedAt) + } else { + fmt.Fprintln(out, "archived: no") + } + fmt.Fprintln(out, "body:") + fmt.Fprintln(out, issue.Body) + fmt.Fprintln(out, "definition of done:") + if len(issue.Criteria) == 0 { + fmt.Fprintln(out, " none") + } else { + for _, criterion := range issue.Criteria { + fmt.Fprintf(out, " %d. [%s] %s", criterion.Position, criterion.Tier, criterion.Text) + if criterion.Command != "" { + fmt.Fprintf(out, " command=%s", criterion.Command) + } + if criterion.Expect != "" { + fmt.Fprintf(out, " expect=%s", criterion.Expect) + } + fmt.Fprintln(out) + } + } + if len(result.Children) > 0 { + fmt.Fprintln(out, "children:") + for _, child := range result.Children { + fmt.Fprintf(out, " %s %s %s\n", firstNonEmpty(child.Alias, child.ID), child.Status, child.Title) + } + } +} + +func writeIssueList(out io.Writer, result state.IssueListResult) { + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + if len(result.Issues) == 0 { + fmt.Fprintln(out, "no issues found") + return + } + for _, issue := range result.Issues { + fmt.Fprintf(out, "%-10s %-10s %-10s %s\n", issueDisplayRef(issue), issue.Status, issue.Kind, issue.Title) + } +} + +func writeIssueTree(out io.Writer, result state.IssueTreeResult) { + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + if len(result.Roots) == 0 { + fmt.Fprintln(out, "no issues found") + return + } + for _, root := range result.Roots { + writeIssueTreeNode(out, root, 0) + } +} + +func writeIssueTreeNode(out io.Writer, node state.IssueTreeNode, depth int) { + indent := strings.Repeat(" ", depth) + fmt.Fprintf(out, "%s%s %s %s\n", indent, firstNonEmpty(node.Alias, node.ID), node.Status, node.Title) + for _, child := range node.Children { + writeIssueTreeNode(out, child, depth+1) + } +} + +func writeIssueFrontier(out io.Writer, result state.IssueFrontierResult) { + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + if len(result.Issues) == 0 { + fmt.Fprintln(out, "no frontier issues") + return + } + for _, issue := range result.Issues { + fmt.Fprintf(out, "%-10s %-10s %s\n", firstNonEmpty(issue.Alias, issue.ID), issue.Status, issue.Title) + } +} + +func writeIssueStatus(out io.Writer, result state.IssueResult) { + issue := result.Issue + switch issue.Status { + case state.IssueStatusCancelled: + fmt.Fprintf(out, "cancelled issue %s\n", issueDisplayRef(issue)) + case state.IssueStatusDuplicate: + fmt.Fprintf(out, "marked issue %s duplicate\n", issueDisplayRef(issue)) + default: + fmt.Fprintf(out, "updated issue %s\n", issueDisplayRef(issue)) + } + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + fmt.Fprintf(out, "status: %s\n", issue.Status) + if issue.ArchivedAt != "" { + fmt.Fprintf(out, "archived: %s\n", issue.ArchivedAt) + } +} + +func writeIssueDodList(out io.Writer, result state.IssueResult) { + fmt.Fprintf(out, "criteria for %s\n", issueDisplayRef(result.Issue)) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + if len(result.Issue.Criteria) == 0 { + fmt.Fprintln(out, "no criteria") + return + } + for _, criterion := range result.Issue.Criteria { + fmt.Fprintf(out, "%d. [%s] %s", criterion.Position, criterion.Tier, criterion.Text) + if criterion.Command != "" { + fmt.Fprintf(out, " command=%s", criterion.Command) + } + if criterion.Expect != "" { + fmt.Fprintf(out, " expect=%s", criterion.Expect) + } + fmt.Fprintln(out) + } +} + +func renderIssueMarkdown(result state.IssueResult) string { + return state.RenderIssueMarkdown(result) +} + +func issueDisplayRef(issue state.Issue) string { + return firstNonEmpty(issue.Alias, issue.ID) +} diff --git a/internal/cli/issue_check.go b/internal/cli/issue_check.go new file mode 100644 index 000000000..d0cf300ee --- /dev/null +++ b/internal/cli/issue_check.go @@ -0,0 +1,174 @@ +package cli + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/levifig/loaf/internal/state" +) + +type issueCheckOptions struct { + jsonOutput bool + ref string + human string +} + +type issueCheckResult struct { + ContractVersion int `json:"contract_version"` + DatabaseScope string `json:"database_scope"` + DatabasePath string `json:"database_path"` + ProjectID string `json:"project_id"` + ProjectName string `json:"project_name"` + ProjectCurrentPath string `json:"project_current_path"` + Issue state.Issue `json:"issue"` + Kind string `json:"kind"` + Shaped bool `json:"shaped"` + Covered bool `json:"covered"` + Ready bool `json:"ready"` + Failures []state.IssueReadinessFailure `json:"failures"` + Orphans []state.IssueReadinessOrphan `json:"orphans"` + Publication *ReadinessPublication `json:"publication,omitempty"` +} + +func (r Runner) runIssueCheck(args []string, out io.Writer, runtime state.Runtime) error { + options, err := parseIssueCheckArgs(args) + if err != nil { + return err + } + projectRoot, err := r.requireIssueSQLiteState("issue check", runtime) + if err != nil { + return err + } + resolver := state.PathResolver{StateHome: r.StateHome} + readiness, err := state.CheckIssueReadiness(context.Background(), projectRoot, resolver, options.ref) + if err != nil { + return err + } + shown, err := state.ShowIssue(context.Background(), projectRoot, resolver, readiness.Issue.ID) + if err != nil { + return err + } + + result := issueCheckResult{ + ContractVersion: shown.ContractVersion, + DatabaseScope: shown.DatabaseScope, + DatabasePath: shown.DatabasePath, + ProjectID: shown.ProjectID, + ProjectName: shown.ProjectName, + ProjectCurrentPath: shown.ProjectCurrentPath, + Issue: readiness.Issue, + Kind: readiness.Kind, + Shaped: readiness.Shaped, + Covered: readiness.Covered, + Ready: readiness.Ready, + Failures: readiness.Failures, + Orphans: readiness.Orphans, + } + + if readiness.Ready { + identity, err := state.GetIssueIdentity(context.Background(), projectRoot, resolver) + if err != nil { + return err + } + if trackerAuthority(identity.Authority) { + publication := ReadinessPublication{ + IssueID: readiness.Issue.ID, + IssueRef: issueDisplayRef(readiness.Issue), + Label: readinessLabelAgent, + Authority: identity.Authority, + ProjectPath: projectRoot.Path(), + StateHome: r.StateHome, + } + if options.human != "" { + publication.Label = readinessLabelHuman + publication.Reason = options.human + } + if err := defaultReadinessPublisher.Publish(context.Background(), publication); err != nil { + return err + } + result.Publication = &publication + } + } + + if options.jsonOutput { + if err := writeJSON(out, result); err != nil { + return err + } + if !readiness.Ready { + return ExitError{Code: 1} + } + return nil + } + + writeIssueCheck(out, result) + if !readiness.Ready { + return ExitError{Code: 1} + } + return nil +} + +func writeIssueCheck(out io.Writer, result issueCheckResult) { + ref := issueDisplayRef(result.Issue) + if result.Ready { + if result.Issue.Kind == state.IssueKindDecision { + fmt.Fprintf(out, "issue %s is ready\n", ref) + } else { + fmt.Fprintf(out, "issue %s is shaped\n", ref) + } + } else { + fmt.Fprintf(out, "issue %s is not ready\n", ref) + } + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + if len(result.Failures) > 0 { + fmt.Fprintln(out, "failures:") + for _, failure := range result.Failures { + fmt.Fprintf(out, " %s\n", failure.Message) + } + } + if len(result.Orphans) > 0 { + fmt.Fprintln(out, "orphans:") + for _, orphan := range result.Orphans { + fmt.Fprintf(out, " %s criterion %d: %s\n", orphan.ChildRef, orphan.Position, orphan.Text) + fmt.Fprintf(out, " remedy: %s\n", orphan.Remedy) + } + } +} + +func parseIssueCheckArgs(args []string) (issueCheckOptions, error) { + var options issueCheckOptions + var positional []string + for i := 0; i < len(args); i++ { + switch args[i] { + case "--json": + options.jsonOutput = true + case "--human": + value, err := consumeFlagValue(args, &i, "--human") + if err != nil { + return issueCheckOptions{}, err + } + options.human = strings.TrimSpace(value) + if options.human == "" { + return issueCheckOptions{}, fmt.Errorf("issue check --human requires a reason") + } + default: + if strings.HasPrefix(args[i], "-") { + return issueCheckOptions{}, fmt.Errorf("unknown option %q", args[i]) + } + positional = append(positional, args[i]) + } + } + if len(positional) != 1 { + return issueCheckOptions{}, fmt.Errorf("issue check requires an issue ref") + } + options.ref = positional[0] + return options, nil +} + +func writeIssueCheckHelp(out io.Writer) { + writeUsageHelp(out, "loaf issue check <ref> [--json] [--human <reason>]", + "Derive readiness from the issue row: a delivery issue is shaped with a nonempty body, at least one criterion, and an out-of-scope statement; a decision issue is ready on a sharp question. Children add coverage (fail) and containment (report).", + "--json Output structured readiness results as JSON", + "--human Publish ready-for-human instead of ready-for-agent, with this reason") +} diff --git a/internal/cli/issue_linear.go b/internal/cli/issue_linear.go new file mode 100644 index 000000000..33e929c50 --- /dev/null +++ b/internal/cli/issue_linear.go @@ -0,0 +1,255 @@ +package cli + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/levifig/loaf/internal/state" +) + +type issuePullOptions struct { + jsonOutput bool + tree bool + key string +} + +type issuePushOptions struct { + jsonOutput bool + ref string +} + +type issueReconcileOptions struct { + jsonOutput bool + takeLocal bool + takeTracker bool + ref string +} + +func (r Runner) runIssuePull(args []string, out io.Writer, runtime state.Runtime) error { + options, err := parseIssuePullArgs(args) + if err != nil { + return err + } + projectRoot, err := r.requireIssueSQLiteState("issue pull", runtime) + if err != nil { + return err + } + client, err := state.LinearClientFromEnv() + if err != nil { + return err + } + result, err := state.PullLinearIssue(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, client, options.key, options.tree) + if err != nil { + return err + } + if options.jsonOutput { + return writeJSON(out, result) + } + fmt.Fprintf(out, "pulled %s\n", issueDisplayRef(result.Issue)) + for _, issue := range result.Tree { + fmt.Fprintf(out, " %s %s %s\n", issueDisplayRef(issue), issue.Status, issue.Title) + } + return nil +} + +func (r Runner) runIssuePush(args []string, out io.Writer, runtime state.Runtime) error { + options, err := parseIssuePushArgs(args) + if err != nil { + return err + } + projectRoot, err := r.requireIssueSQLiteState("issue push", runtime) + if err != nil { + return err + } + resolver := state.PathResolver{StateHome: r.StateHome} + shown, err := state.ShowIssue(context.Background(), projectRoot, resolver, options.ref) + if err != nil { + return err + } + client, err := state.LinearClientFromEnv() + if err != nil { + return err + } + result, err := state.PushLinearIssue(context.Background(), projectRoot, resolver, client, shown.Issue.ID, renderIssueMarkdown(shown)) + if err != nil { + return err + } + if options.jsonOutput { + return writeJSON(out, result) + } + fmt.Fprintf(out, "pushed %s\n", issueDisplayRef(result.Issue)) + fmt.Fprintln(out, "description: updated") + if result.StatusWrote { + fmt.Fprintf(out, "status: updated (%s)\n", result.Issue.Status) + } else if result.StatusSkipped != "" { + fmt.Fprintf(out, "status: skipped (%s)\n", result.StatusSkipped) + } else { + fmt.Fprintln(out, "status: unchanged") + } + return nil +} + +func (r Runner) runIssueReconcile(args []string, out io.Writer, runtime state.Runtime) error { + options, err := parseIssueReconcileArgs(args) + if err != nil { + return err + } + projectRoot, err := r.requireIssueSQLiteState("issue reconcile", runtime) + if err != nil { + return err + } + client, err := state.LinearClientFromEnv() + if err != nil { + return err + } + resolver := state.PathResolver{StateHome: r.StateHome} + var results []state.LinearReconcileResult + if options.ref == "" { + results, err = state.ReconcileLinearIssues(context.Background(), projectRoot, resolver, client, options.takeLocal, options.takeTracker) + } else { + var result state.LinearReconcileResult + result, err = state.ReconcileLinearIssue(context.Background(), projectRoot, resolver, client, options.ref, options.takeLocal, options.takeTracker) + if err == nil { + results = []state.LinearReconcileResult{result} + } + } + if err != nil { + return err + } + if options.jsonOutput { + return writeJSON(out, results) + } + if len(results) == 0 { + fmt.Fprintln(out, "no linear-mapped issues to reconcile") + return nil + } + for _, result := range results { + writeIssueReconcile(out, result) + } + return nil +} + +func writeIssueReconcile(out io.Writer, result state.LinearReconcileResult) { + ref := issueDisplayRef(result.Issue) + if result.InSync && len(result.Conflicts) == 0 { + fmt.Fprintf(out, "issue %s is in sync\n", ref) + return + } + fmt.Fprintf(out, "issue %s\n", ref) + for _, conflict := range result.Conflicts { + switch conflict.Field { + case "title": + fmt.Fprintln(out, "title: tracker wins") + fmt.Fprintf(out, " local: %q\n", conflict.Local) + fmt.Fprintf(out, " linear: %q\n", conflict.Tracker) + fmt.Fprintf(out, " action: %s\n", conflict.Resolution) + case "status": + fmt.Fprintf(out, "status: %s\n", conflict.Mover) + fmt.Fprintf(out, " local: %s (%s)\n", conflict.Local, conflict.LocalAt) + fmt.Fprintf(out, " linear: %s (%s)\n", conflict.Tracker, conflict.TrackerAt) + fmt.Fprintf(out, " action: %s\n", conflict.Resolution) + case "description": + fmt.Fprintln(out, "description: drifted (report only)") + fmt.Fprintf(out, " action: %s\n", conflict.Resolution) + default: + fmt.Fprintf(out, "%s: %s\n", conflict.Field, conflict.Resolution) + } + } +} + +func parseIssuePullArgs(args []string) (issuePullOptions, error) { + var options issuePullOptions + var positional []string + for i := 0; i < len(args); i++ { + switch args[i] { + case "--json": + options.jsonOutput = true + case "--tree": + options.tree = true + default: + if strings.HasPrefix(args[i], "-") { + return issuePullOptions{}, fmt.Errorf("unknown option %q", args[i]) + } + positional = append(positional, args[i]) + } + } + if len(positional) != 1 { + return issuePullOptions{}, fmt.Errorf("issue pull requires a Linear key") + } + options.key = positional[0] + return options, nil +} + +func parseIssuePushArgs(args []string) (issuePushOptions, error) { + var options issuePushOptions + var positional []string + for _, arg := range args { + switch arg { + case "--json": + options.jsonOutput = true + default: + if strings.HasPrefix(arg, "-") { + return issuePushOptions{}, fmt.Errorf("unknown option %q", arg) + } + positional = append(positional, arg) + } + } + if len(positional) != 1 { + return issuePushOptions{}, fmt.Errorf("issue push requires an issue ref") + } + options.ref = positional[0] + return options, nil +} + +func parseIssueReconcileArgs(args []string) (issueReconcileOptions, error) { + var options issueReconcileOptions + var positional []string + for _, arg := range args { + switch arg { + case "--json": + options.jsonOutput = true + case "--take-local": + options.takeLocal = true + case "--take-tracker": + options.takeTracker = true + default: + if strings.HasPrefix(arg, "-") { + return issueReconcileOptions{}, fmt.Errorf("unknown option %q", arg) + } + positional = append(positional, arg) + } + } + if options.takeLocal && options.takeTracker { + return issueReconcileOptions{}, fmt.Errorf("issue reconcile accepts at most one of --take-local and --take-tracker") + } + if len(positional) > 1 { + return issueReconcileOptions{}, fmt.Errorf("issue reconcile accepts at most one ref") + } + if len(positional) == 1 { + options.ref = positional[0] + } + return options, nil +} + +func writeIssuePullHelp(out io.Writer) { + writeUsageHelp(out, "loaf issue pull <linear-key> [--tree] [--json]", + "Adopt an existing Linear issue as a local row. The Linear key becomes the alias; the local counter is not advanced.", + "--tree Also adopt the sub-issue tree with parent edges intact", + "--json Output the adopted issue and tree as JSON") +} + +func writeIssuePushHelp(out io.Writer) { + writeUsageHelp(out, "loaf issue push <ref> [--json]", + "Write loaf issue render as the Linear description. Status is written only when the local status event is newer than the tracker. Never renames the Linear issue.", + "--json Output the push result as JSON") +} + +func writeIssueReconcileHelp(out io.Writer) { + writeUsageHelp(out, "loaf issue reconcile [<ref>] [--take-local|--take-tracker] [--json]", + "Compare local and Linear. Title drift updates the local title (tracker wins). Status drift is reported; use --take-local or --take-tracker to resolve. Description drift is reported only.", + "--take-local Write the local status to Linear", + "--take-tracker Write the Linear status to local through the events path", + "--json Output the reconcile result as JSON") +} diff --git a/internal/cli/issue_linear_test.go b/internal/cli/issue_linear_test.go new file mode 100644 index 000000000..652c3b5e2 --- /dev/null +++ b/internal/cli/issue_linear_test.go @@ -0,0 +1,598 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/levifig/loaf/internal/project" + "github.com/levifig/loaf/internal/state" +) + +func linearIssueCLIFixture(t *testing.T) (workingDir, stateHome string, fake *state.LinearFake) { + t.Helper() + fake = state.NewLinearFake() + server := httptest.NewServer(fake) + t.Cleanup(server.Close) + workingDir, stateHome = issueCLIFixture(t) + t.Setenv("LINEAR_API_KEY", "test-key") + t.Setenv("LINEAR_API_URL", server.URL) + t.Setenv("LINEAR_TEAM_KEY", "ENG") + root, err := project.ResolveRoot(workingDir) + if err != nil { + t.Fatalf("ResolveRoot() error = %v", err) + } + if _, err := state.SetIssueIdentity(context.Background(), root, state.PathResolver{StateHome: stateHome}, state.IssueIdentityOptions{ + Authority: state.IssueAuthorityLinear, + Prefix: "ENG", + }); err != nil { + t.Fatalf("SetIssueIdentity() error = %v", err) + } + return workingDir, stateHome, fake +} + +func TestRunnerIssueNewMintsLinearKeyAndLeavesCounter(t *testing.T) { + workingDir, stateHome, _ := linearIssueCLIFixture(t) + out, err := runIssue(t, workingDir, stateHome, "new", "Minted from loaf", "--json") + if err != nil { + t.Fatalf("issue new error = %v\n%s", err, out) + } + created := decodeIssueResult(t, out) + if created.Issue.Alias != "ENG-1" { + t.Fatalf("alias = %q, want ENG-1", created.Issue.Alias) + } + root, err := project.ResolveRoot(workingDir) + if err != nil { + t.Fatalf("ResolveRoot() error = %v", err) + } + identity, err := state.GetIssueIdentity(context.Background(), root, state.PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("GetIssueIdentity() error = %v", err) + } + if identity.NextNumber != 1 { + t.Fatalf("next_number = %d, want 1", identity.NextNumber) + } + if strings.Contains(out, "LOAF-") { + t.Fatalf("minted a local alias:\n%s", out) + } +} + +func TestRunnerIssueNewLinearUnreachableDoesNotMintLocal(t *testing.T) { + workingDir, stateHome, fake := linearIssueCLIFixture(t) + fake.Unreachable = true + out, err := runIssue(t, workingDir, stateHome, "new", "Offline idea") + if err == nil { + t.Fatalf("issue new error = nil, want unreachable\n%s", out) + } + if !strings.Contains(err.Error(), "loaf spark") || !strings.Contains(err.Error(), "loaf idea") { + t.Fatalf("error = %v, want spark/idea offline path", err) + } + root, err := project.ResolveRoot(workingDir) + if err != nil { + t.Fatalf("ResolveRoot() error = %v", err) + } + listed, listErr := state.ListIssues(context.Background(), root, state.PathResolver{StateHome: stateHome}, state.IssueListOptions{}) + if listErr != nil { + t.Fatalf("ListIssues() error = %v", listErr) + } + if len(listed.Issues) != 0 { + t.Fatalf("issues = %#v, want none after failed mint", listed.Issues) + } + identity, idErr := state.GetIssueIdentity(context.Background(), root, state.PathResolver{StateHome: stateHome}) + if idErr != nil { + t.Fatalf("GetIssueIdentity() error = %v", idErr) + } + if identity.NextNumber != 1 { + t.Fatalf("next_number = %d, want 1", identity.NextNumber) + } +} + +func TestRunnerIssueNewReportsOrphanedLinearMintOnLocalFailure(t *testing.T) { + workingDir, stateHome, fake := linearIssueCLIFixture(t) + out, err := runIssue(t, workingDir, stateHome, "new", "Orphan me", "--kind", "not-a-kind") + if err == nil { + t.Fatalf("issue new error = nil, want local bind failure\n%s", out) + } + if !strings.Contains(err.Error(), "ENG-1") || !strings.Contains(err.Error(), "loaf issue pull ENG-1") { + t.Fatalf("error = %v, want Linear key and pull recovery", err) + } + if _, ok := fake.Issue("ENG-1"); !ok { + t.Fatal("Linear issue ENG-1 was not created") + } + root, rootErr := project.ResolveRoot(workingDir) + if rootErr != nil { + t.Fatalf("ResolveRoot() error = %v", rootErr) + } + listed, listErr := state.ListIssues(context.Background(), root, state.PathResolver{StateHome: stateHome}, state.IssueListOptions{}) + if listErr != nil { + t.Fatalf("ListIssues() error = %v", listErr) + } + if len(listed.Issues) != 0 { + t.Fatalf("issues = %#v, want none after failed local bind", listed.Issues) + } +} + +func TestRunnerIssuePullTreeReattachesExistingChild(t *testing.T) { + workingDir, stateHome, fake := linearIssueCLIFixture(t) + fake.SeedIssue("ENG-10", "Root", "", "triage", "") + fake.SeedIssue("ENG-11", "Child", "", "unstarted", "ENG-10") + + if _, err := runIssue(t, workingDir, stateHome, "pull", "ENG-11"); err != nil { + t.Fatalf("issue pull child error = %v", err) + } + shownOut, err := runIssue(t, workingDir, stateHome, "show", "ENG-11", "--json") + if err != nil { + t.Fatalf("show child error = %v", err) + } + child := decodeIssueResult(t, shownOut) + if child.Issue.ParentID != "" { + t.Fatalf("solo child parent = %q, want empty", child.Issue.ParentID) + } + + if _, err := runIssue(t, workingDir, stateHome, "pull", "ENG-10", "--tree"); err != nil { + t.Fatalf("issue pull --tree error = %v", err) + } + rootOut, err := runIssue(t, workingDir, stateHome, "show", "ENG-10", "--json") + if err != nil { + t.Fatalf("show root error = %v", err) + } + rootIssue := decodeIssueResult(t, rootOut) + if len(rootIssue.Children) != 1 || rootIssue.Children[0].Alias != "ENG-11" { + t.Fatalf("root children = %#v, want depth-2 edge to ENG-11", rootIssue.Children) + } + childOut, err := runIssue(t, workingDir, stateHome, "show", "ENG-11", "--json") + if err != nil { + t.Fatalf("show reattached child error = %v", err) + } + reattached := decodeIssueResult(t, childOut) + if reattached.Issue.ParentID != rootIssue.Issue.ID { + t.Fatalf("reattached child parent = %q, want %q", reattached.Issue.ParentID, rootIssue.Issue.ID) + } +} + +func TestRunnerIssuePullTreeKeepsParentEdges(t *testing.T) { + workingDir, stateHome, fake := linearIssueCLIFixture(t) + fake.SeedIssue("ENG-10", "Root", "", "triage", "") + fake.SeedIssue("ENG-11", "Child", "", "unstarted", "ENG-10") + fake.SeedIssue("ENG-12", "Grandchild", "", "started", "ENG-11") + + out, err := runIssue(t, workingDir, stateHome, "pull", "ENG-10", "--tree", "--json") + if err != nil { + t.Fatalf("issue pull --tree error = %v\n%s", err, out) + } + var result state.LinearPullResult + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("unmarshal pull: %v\n%s", err, out) + } + if result.Issue.Alias != "ENG-10" || len(result.Tree) != 3 { + t.Fatalf("pull = %#v", result) + } + byAlias := map[string]state.Issue{} + for _, issue := range result.Tree { + byAlias[issue.Alias] = issue + } + if byAlias["ENG-11"].ParentID != byAlias["ENG-10"].ID { + t.Fatalf("child parent = %q, want %q", byAlias["ENG-11"].ParentID, byAlias["ENG-10"].ID) + } + if byAlias["ENG-12"].ParentID != byAlias["ENG-11"].ID { + t.Fatalf("grandchild parent = %q, want %q", byAlias["ENG-12"].ParentID, byAlias["ENG-11"].ID) + } + if byAlias["ENG-12"].Status != state.IssueStatusActive { + t.Fatalf("grandchild status = %q, want active", byAlias["ENG-12"].Status) + } +} + +func TestRunnerIssuePushAndReconcileHonorStatusAuthority(t *testing.T) { + workingDir, stateHome, fake := linearIssueCLIFixture(t) + fake.SeedIssue("ENG-20", "Drift", "tracker body", "unstarted", "") + if _, err := runIssue(t, workingDir, stateHome, "pull", "ENG-20"); err != nil { + t.Fatalf("issue pull error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "status", "ENG-20", "active"); err != nil { + t.Fatalf("issue status error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "edit", "ENG-20", "--message", "local shaping body"); err != nil { + t.Fatalf("issue edit error = %v", err) + } + fake.SetIssueState("ENG-20", "completed", time.Now().UTC().Add(time.Hour)) + fake.SetIssueTitle("ENG-20", "Tracker title", time.Now().UTC().Add(time.Hour)) + + pushOut, err := runIssue(t, workingDir, stateHome, "push", "ENG-20") + if err != nil { + t.Fatalf("issue push error = %v\n%s", err, pushOut) + } + if !strings.Contains(pushOut, "status: skipped") { + t.Fatalf("push should honor newer tracker status:\n%s", pushOut) + } + if remote, ok := fake.Issue("ENG-20"); !ok || remote.State.Type != "completed" { + t.Fatalf("tracker status overwritten: %#v", remote) + } + + reconOut, err := runIssue(t, workingDir, stateHome, "reconcile", "ENG-20") + if err != nil { + t.Fatalf("issue reconcile error = %v\n%s", err, reconOut) + } + if !strings.Contains(reconOut, "status: both") && !strings.Contains(reconOut, "status: tracker") { + t.Fatalf("reconcile missing status drift:\n%s", reconOut) + } + if !strings.Contains(reconOut, "--take-local") && !strings.Contains(reconOut, "unresolved") { + t.Fatalf("reconcile resolved silently:\n%s", reconOut) + } + if !strings.Contains(reconOut, "title: tracker wins") { + t.Fatalf("reconcile missing title drift:\n%s", reconOut) + } + if strings.Contains(reconOut, "description: drifted") { + t.Fatalf("reconcile reported false description drift after push:\n%s", reconOut) + } + + shownOut, err := runIssue(t, workingDir, stateHome, "show", "ENG-20", "--json") + if err != nil { + t.Fatalf("issue show error = %v", err) + } + shown := decodeIssueResult(t, shownOut) + if shown.Issue.Title != "Tracker title" { + t.Fatalf("local title = %q, want tracker title", shown.Issue.Title) + } + if shown.Issue.Status != state.IssueStatusActive { + t.Fatalf("local status = %q, want still active until --take-*", shown.Issue.Status) + } + if shown.Issue.Body != "local shaping body" { + t.Fatalf("local body rewritten from tracker: %q", shown.Issue.Body) + } + + takeOut, err := runIssue(t, workingDir, stateHome, "reconcile", "ENG-20", "--take-tracker") + if err != nil { + t.Fatalf("issue reconcile --take-tracker error = %v\n%s", err, takeOut) + } + shownOut, err = runIssue(t, workingDir, stateHome, "show", "ENG-20", "--json") + if err != nil { + t.Fatalf("issue show after take-tracker error = %v", err) + } + shown = decodeIssueResult(t, shownOut) + if shown.Issue.Status != state.IssueStatusDone { + t.Fatalf("local status after --take-tracker = %q, want done", shown.Issue.Status) + } + + if _, err := runIssue(t, workingDir, stateHome, "status", "ENG-20", "todo"); err != nil { + t.Fatalf("issue status todo error = %v", err) + } + takeLocalOut, err := runIssue(t, workingDir, stateHome, "reconcile", "ENG-20", "--take-local") + if err != nil { + t.Fatalf("issue reconcile --take-local error = %v\n%s", err, takeLocalOut) + } + if remote, ok := fake.Issue("ENG-20"); !ok || remote.State.Type != "unstarted" { + t.Fatalf("tracker after --take-local = %#v, want unstarted", remote) + } +} + +func TestRunnerIssuePushThenReconcileHasNoFalseDescriptionDrift(t *testing.T) { + workingDir, stateHome, fake := linearIssueCLIFixture(t) + fake.SeedIssue("ENG-30", "Shaped", "raw tracker body", "unstarted", "") + if _, err := runIssue(t, workingDir, stateHome, "pull", "ENG-30"); err != nil { + t.Fatalf("issue pull error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "edit", "ENG-30", "--message", "local shaping body"); err != nil { + t.Fatalf("issue edit error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "push", "ENG-30"); err != nil { + t.Fatalf("issue push error = %v", err) + } + reconOut, err := runIssue(t, workingDir, stateHome, "reconcile", "ENG-30") + if err != nil { + t.Fatalf("issue reconcile error = %v\n%s", err, reconOut) + } + if strings.Contains(reconOut, "description: drifted") { + t.Fatalf("false description drift after push:\n%s", reconOut) + } + fake.SetIssueDescription("ENG-30", "tracker edited the render", time.Now().UTC()) + driftOut, err := runIssue(t, workingDir, stateHome, "reconcile", "ENG-30") + if err != nil { + t.Fatalf("issue reconcile after remote edit error = %v\n%s", err, driftOut) + } + if !strings.Contains(driftOut, "description: drifted") { + t.Fatalf("missing description drift after remote edit:\n%s", driftOut) + } +} + +func TestRunnerIssueCheckPublishesReadyForAgentThroughLinear(t *testing.T) { + workingDir, stateHome, fake := linearIssueCLIFixture(t) + out, err := runIssue(t, workingDir, stateHome, "new", "Should we ship the adapter?", "--kind", "decision") + if err != nil { + t.Fatalf("issue new error = %v\n%s", err, out) + } + checkOut, err := runIssue(t, workingDir, stateHome, "check", "ENG-1", "--json") + if err != nil { + t.Fatalf("issue check error = %v\n%s", err, checkOut) + } + names := fake.IssueLabelNames("ENG-1") + if !containsString(names, readinessLabelAgent) { + t.Fatalf("labels = %#v, want %s", names, readinessLabelAgent) + } + + humanOut, err := runIssue(t, workingDir, stateHome, "check", "ENG-1", "--human", "needs a human call") + if err != nil { + t.Fatalf("issue check --human error = %v\n%s", err, humanOut) + } + if !containsString(fake.IssueLabelNames("ENG-1"), readinessLabelHuman) { + t.Fatalf("human labels = %#v", fake.IssueLabelNames("ENG-1")) + } + comments := fake.IssueComments("ENG-1") + if len(comments) != 1 || comments[0].Body != "needs a human call" { + t.Fatalf("comments = %#v", comments) + } +} + +func TestRunnerIssueCheckAttachesConfiguredTeamLabel(t *testing.T) { + workingDir, stateHome, fake := linearIssueCLIFixture(t) + other := fake.SeedLabel("team_other", readinessLabelAgent) + want := fake.SeedLabel(fake.Team.ID, readinessLabelAgent) + out, err := runIssue(t, workingDir, stateHome, "new", "Should we ship the adapter?", "--kind", "decision") + if err != nil { + t.Fatalf("issue new error = %v\n%s", err, out) + } + if _, err := runIssue(t, workingDir, stateHome, "check", "ENG-1"); err != nil { + t.Fatalf("issue check error = %v", err) + } + remote, ok := fake.Issue("ENG-1") + if !ok { + t.Fatal("ENG-1 missing from fake") + } + if !containsString(remote.LabelIDs, want.ID) { + t.Fatalf("labels = %#v, want configured team label %q", remote.LabelIDs, want.ID) + } + if containsString(remote.LabelIDs, other.ID) { + t.Fatalf("labels = %#v, attached other team's label %q", remote.LabelIDs, other.ID) + } +} + +func TestRunnerIssuePromoteMintsLinearChildAndLeavesCounter(t *testing.T) { + workingDir, stateHome, fake := linearIssueCLIFixture(t) + if _, err := runIssue(t, workingDir, stateHome, "new", "Parent"); err != nil { + t.Fatalf("issue new parent error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "dod", "add", "ENG-1", "First slice"); err != nil { + t.Fatalf("dod add error = %v", err) + } + out, err := runIssue(t, workingDir, stateHome, "promote", "ENG-1", "1", "--json") + if err != nil { + t.Fatalf("issue promote error = %v\n%s", err, out) + } + child := decodeIssueResult(t, out) + if child.Issue.Alias != "ENG-2" { + t.Fatalf("promoted alias = %q, want ENG-2", child.Issue.Alias) + } + if _, ok := fake.Issue("ENG-2"); !ok { + t.Fatal("Linear issue ENG-2 was not created") + } + root, err := project.ResolveRoot(workingDir) + if err != nil { + t.Fatalf("ResolveRoot() error = %v", err) + } + identity, err := state.GetIssueIdentity(context.Background(), root, state.PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("GetIssueIdentity() error = %v", err) + } + if identity.NextNumber != 1 { + t.Fatalf("next_number = %d, want 1", identity.NextNumber) + } + shown, err := state.ShowIssue(context.Background(), root, state.PathResolver{StateHome: stateHome}, child.Issue.ID) + if err != nil { + t.Fatalf("ShowIssue() error = %v", err) + } + if shown.Issue.ParentID == "" { + t.Fatal("promoted child has empty parent") + } +} + +func TestRunnerIssueLocalAuthorityDoesNotCallLinear(t *testing.T) { + workingDir, stateHome := issueCLIFixture(t) + hits := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + http.Error(w, "should not be called", http.StatusInternalServerError) + })) + t.Cleanup(server.Close) + t.Setenv("LINEAR_API_KEY", "test-key") + t.Setenv("LINEAR_API_URL", server.URL) + t.Setenv("LINEAR_TEAM_KEY", "ENG") + + out, err := runIssue(t, workingDir, stateHome, "new", "Local only", "--json") + if err != nil { + t.Fatalf("issue new error = %v\n%s", err, out) + } + created := decodeIssueResult(t, out) + if created.Issue.Alias != "LOAF-1" { + t.Fatalf("alias = %q, want LOAF-1", created.Issue.Alias) + } + if hits != 0 { + t.Fatalf("linear hits = %d, want 0 for local authority", hits) + } +} + +func TestRunnerIssueNewUsesTeamMappingWithoutEnvTeamKey(t *testing.T) { + workingDir, stateHome, _ := linearIssueCLIFixture(t) + t.Setenv("LINEAR_TEAM_KEY", "") + root, err := project.ResolveRoot(workingDir) + if err != nil { + t.Fatalf("ResolveRoot() error = %v", err) + } + if err := state.WriteLinearTeamConfig(context.Background(), root, state.PathResolver{StateHome: stateHome}, "ENG"); err != nil { + t.Fatalf("WriteLinearTeamConfig() error = %v", err) + } + + out, err := runIssue(t, workingDir, stateHome, "new", "From mapping") + if err != nil { + t.Fatalf("issue new error = %v\n%s", err, out) + } + if !strings.Contains(out, "ENG-1") { + t.Fatalf("output missing Linear key:\n%s", out) + } +} + +func TestRunnerReleaseCutPushesLinearMembership(t *testing.T) { + repo, stateHome := releaseTrackFixture(t) + fake := state.NewLinearFake() + server := httptest.NewServer(fake) + t.Cleanup(server.Close) + t.Setenv("LINEAR_API_KEY", "test-key") + t.Setenv("LINEAR_API_URL", server.URL) + t.Setenv("LINEAR_TEAM_KEY", "ENG") + root, err := project.ResolveRoot(repo) + if err != nil { + t.Fatalf("ResolveRoot() error = %v", err) + } + resolver := state.PathResolver{StateHome: stateHome} + if _, err := state.SetIssueIdentity(context.Background(), root, resolver, state.IssueIdentityOptions{ + Authority: state.IssueAuthorityLinear, + Prefix: "ENG", + }); err != nil { + t.Fatalf("SetIssueIdentity() error = %v", err) + } + out, err := runIssue(t, repo, stateHome, "new", "Ship auth") + if err != nil { + t.Fatalf("issue new error = %v\n%s", err, out) + } + writeFile(t, filepath.Join(repo, "auth.txt"), "auth\n") + gitCLI(t, repo, "add", "auth.txt") + gitCLI(t, repo, "commit", "-m", "feat: add auth ENG-1") + gitCLI(t, repo, "tag", "v1.1.0") + + cutOut, err := runReleaseTrack(t, repo, stateHome, "cut", "--no-tag", "--no-gh", "--base", "v1.0.0") + if err != nil { + t.Fatalf("release cut error = %v\n%s", err, cutOut) + } + keys := fake.ReleaseIssueKeys("1.1.0") + if !containsString(keys, "ENG-1") { + t.Fatalf("linear release members = %#v, want ENG-1; cut output:\n%s", keys, cutOut) + } + remote, ok := fake.Release("1.1.0") + if !ok { + t.Fatalf("linear release missing; cut output:\n%s", cutOut) + } + client := state.NewLinearClient(server.URL, "test-key") + readBack, err := client.Release(context.Background(), remote.ID) + if err != nil { + t.Fatalf("Release() read-back error = %v", err) + } + if !containsString(readBack.IssueKeys, "ENG-1") { + t.Fatalf("read-back members = %#v, want ENG-1", readBack.IssueKeys) + } +} + +func TestRunnerReleaseCutWarnsOnLinearPublicationFailure(t *testing.T) { + repo, stateHome := releaseTrackFixture(t) + fake := state.NewLinearFake() + fake.ReleaseMutationError = "release create exploded" + server := httptest.NewServer(fake) + t.Cleanup(server.Close) + t.Setenv("LINEAR_API_KEY", "test-key") + t.Setenv("LINEAR_API_URL", server.URL) + t.Setenv("LINEAR_TEAM_KEY", "ENG") + root, err := project.ResolveRoot(repo) + if err != nil { + t.Fatalf("ResolveRoot() error = %v", err) + } + resolver := state.PathResolver{StateHome: stateHome} + if _, err := state.SetIssueIdentity(context.Background(), root, resolver, state.IssueIdentityOptions{ + Authority: state.IssueAuthorityLinear, + Prefix: "ENG", + }); err != nil { + t.Fatalf("SetIssueIdentity() error = %v", err) + } + if _, err := runIssue(t, repo, stateHome, "new", "Ship auth"); err != nil { + t.Fatalf("issue new error = %v", err) + } + writeFile(t, filepath.Join(repo, "auth.txt"), "auth\n") + gitCLI(t, repo, "add", "auth.txt") + gitCLI(t, repo, "commit", "-m", "feat: add auth ENG-1") + gitCLI(t, repo, "tag", "v1.1.0") + + stdout, stderr, err := runReleaseTrackIO(t, repo, stateHome, "cut", "--no-tag", "--no-gh", "--base", "v1.0.0") + if err != nil { + t.Fatalf("release cut error = %v\n%s", err, stdout) + } + if !strings.Contains(stderr, "Linear publication failed") || !strings.Contains(stderr, "release create exploded") { + t.Fatalf("stderr = %q, want Linear failure specifics", stderr) + } + if strings.Contains(stdout, "Recorded Linear release") { + t.Fatalf("stdout claimed Linear success:\n%s", stdout) + } +} + +func TestRunnerReleaseCutUnsupportedWorkspaceIsSilent(t *testing.T) { + repo, stateHome := releaseTrackFixture(t) + fake := state.NewLinearFake() + fake.SupportsReleases = false + server := httptest.NewServer(fake) + t.Cleanup(server.Close) + t.Setenv("LINEAR_API_KEY", "test-key") + t.Setenv("LINEAR_API_URL", server.URL) + t.Setenv("LINEAR_TEAM_KEY", "ENG") + root, err := project.ResolveRoot(repo) + if err != nil { + t.Fatalf("ResolveRoot() error = %v", err) + } + resolver := state.PathResolver{StateHome: stateHome} + if _, err := state.SetIssueIdentity(context.Background(), root, resolver, state.IssueIdentityOptions{ + Authority: state.IssueAuthorityLinear, + Prefix: "ENG", + }); err != nil { + t.Fatalf("SetIssueIdentity() error = %v", err) + } + if _, err := runIssue(t, repo, stateHome, "new", "Ship auth"); err != nil { + t.Fatalf("issue new error = %v", err) + } + writeFile(t, filepath.Join(repo, "auth.txt"), "auth\n") + gitCLI(t, repo, "add", "auth.txt") + gitCLI(t, repo, "commit", "-m", "feat: add auth ENG-1") + gitCLI(t, repo, "tag", "v1.1.0") + + stdout, stderr, err := runReleaseTrackIO(t, repo, stateHome, "cut", "--no-tag", "--no-gh", "--base", "v1.0.0") + if err != nil { + t.Fatalf("release cut error = %v\n%s", err, stdout) + } + if strings.Contains(stderr, "Linear") || strings.Contains(stderr, "warning:") { + t.Fatalf("unsupported workspace should be silent, stderr = %q", stderr) + } +} + +func TestWarnLinearReleasePublicationNamesUnmappedMembers(t *testing.T) { + var stderr bytes.Buffer + runner := Runner{Stderr: &stderr} + runner.warnLinearReleasePublication(&bytes.Buffer{}, state.Release{Tag: "v1.1.0", TaggedCommit: "abc123"}, "", []string{"ENG-9", "ENG-10"}) + if !strings.Contains(stderr.String(), "unmapped members: ENG-9, ENG-10") { + t.Fatalf("stderr = %q, want unmapped member keys", stderr.String()) + } +} + +func TestRunnerReleaseCutLocalAuthorityDoesNotCallLinear(t *testing.T) { + repo, stateHome := releaseTrackFixture(t) + hits := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + http.Error(w, "should not be called", http.StatusInternalServerError) + })) + t.Cleanup(server.Close) + t.Setenv("LINEAR_API_KEY", "test-key") + t.Setenv("LINEAR_API_URL", server.URL) + t.Setenv("LINEAR_TEAM_KEY", "ENG") + if _, err := runIssue(t, repo, stateHome, "new", "Ship auth"); err != nil { + t.Fatalf("issue new error = %v", err) + } + writeFile(t, filepath.Join(repo, "auth.txt"), "auth\n") + gitCLI(t, repo, "add", "auth.txt") + gitCLI(t, repo, "commit", "-m", "feat: add auth LOAF-1") + gitCLI(t, repo, "tag", "v1.1.0") + if _, err := runReleaseTrack(t, repo, stateHome, "cut", "--no-tag", "--no-gh", "--base", "v1.0.0"); err != nil { + t.Fatalf("release cut error = %v", err) + } + if hits != 0 { + t.Fatalf("linear hits = %d, want 0 for local authority", hits) + } +} diff --git a/internal/cli/issue_readiness_test.go b/internal/cli/issue_readiness_test.go new file mode 100644 index 000000000..133bc5e58 --- /dev/null +++ b/internal/cli/issue_readiness_test.go @@ -0,0 +1,440 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/levifig/loaf/internal/project" + "github.com/levifig/loaf/internal/state" +) + +const cliShapedBody = "The problem is derived from nine required headings.\n\nOut of scope: polish and tracker adapters.\n" + +type recordingReadinessPublisher struct { + publications []ReadinessPublication +} + +func (r *recordingReadinessPublisher) Publish(_ context.Context, publication ReadinessPublication) error { + r.publications = append(r.publications, publication) + return nil +} + +func TestRunnerIssueCheckUncoveredCriterionFailsAndNamesIt(t *testing.T) { + workingDir, stateHome := issueCLIFixture(t) + if _, err := runIssue(t, workingDir, stateHome, "new", "Parent", "--body", cliShapedBody); err != nil { + t.Fatalf("issue new error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "dod", "add", "LOAF-1", "Covered slice"); err != nil { + t.Fatalf("dod add 1 error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "dod", "add", "LOAF-1", "Left behind"); err != nil { + t.Fatalf("dod add 2 error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "promote", "LOAF-1", "1"); err != nil { + t.Fatalf("promote error = %v", err) + } + + out, err := runIssue(t, workingDir, stateHome, "check", "LOAF-1") + if err == nil { + t.Fatalf("issue check error = nil, want uncovered failure\n%s", out) + } + if !errors.As(err, &ExitError{}) { + t.Fatalf("error = %v, want ExitError", err) + } + if !strings.Contains(out, "uncovered criterion 2: Left behind") { + t.Fatalf("check output missing named uncovered criterion:\n%s", out) + } +} + +func TestRunnerIssueCheckReportsOrphanWithReadyToPasteRemedy(t *testing.T) { + workingDir, stateHome := issueCLIFixture(t) + if _, err := runIssue(t, workingDir, stateHome, "new", "Parent", "--body", cliShapedBody); err != nil { + t.Fatalf("issue new error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "dod", "add", "LOAF-1", "Promoted slice"); err != nil { + t.Fatalf("dod add error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "promote", "LOAF-1", "1"); err != nil { + t.Fatalf("promote error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "dod", "add", "LOAF-2", "Stray extra work"); err != nil { + t.Fatalf("dod add orphan error = %v", err) + } + + out, err := runIssue(t, workingDir, stateHome, "check", "LOAF-1") + if err != nil { + t.Fatalf("issue check error = %v\n%s", err, out) + } + if !strings.Contains(out, "LOAF-2 criterion 2: Stray extra work") { + t.Fatalf("missing orphan report:\n%s", out) + } + wantRemedy := "loaf issue new --parent 'LOAF-1' --status backlog -- 'Stray extra work'" + if !strings.Contains(out, wantRemedy) { + t.Fatalf("missing ready-to-paste remedy %q:\n%s", wantRemedy, out) + } +} + +func TestRunnerIssueOrphanRemedyCreatesSiblingInBacklog(t *testing.T) { + workingDir, stateHome := issueCLIFixture(t) + if _, err := runIssue(t, workingDir, stateHome, "new", "Parent", "--body", cliShapedBody); err != nil { + t.Fatalf("issue new error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "dod", "add", "LOAF-1", "Promoted slice"); err != nil { + t.Fatalf("dod add error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "promote", "LOAF-1", "1"); err != nil { + t.Fatalf("promote error = %v", err) + } + dangerous := "don't $(touch /tmp/pwned) `reboot`" + if _, err := runIssue(t, workingDir, stateHome, "dod", "add", "LOAF-2", dangerous); err != nil { + t.Fatalf("dod add orphan error = %v", err) + } + + out, err := runIssue(t, workingDir, stateHome, "check", "LOAF-1", "--json") + if err != nil { + t.Fatalf("issue check error = %v\n%s", err, out) + } + var result issueCheckResult + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("json.Unmarshal() error = %v\n%s", err, out) + } + if len(result.Orphans) != 1 { + t.Fatalf("orphans = %#v, want one", result.Orphans) + } + remedy := result.Orphans[0].Remedy + if strings.Contains(remedy, `"`) || strings.Contains(remedy, "&&") || strings.Contains(remedy, "<") { + t.Fatalf("remedy is not a single POSIX-quoted command: %q", remedy) + } + argv, err := parsePOSIXArgv(remedy) + if err != nil { + t.Fatalf("parsePOSIXArgv(%q) error = %v", remedy, err) + } + if len(argv) < 3 || argv[0] != "loaf" || argv[1] != "issue" { + t.Fatalf("argv = %#v, want loaf issue ...", argv) + } + + // --json is a flag; inject it before `--` so a hyphen-leading title stays positional. + createdOut, err := runIssue(t, workingDir, stateHome, issueNewArgsWithJSON(argv[2:])...) + if err != nil { + t.Fatalf("emitted command error = %v\n%s", err, createdOut) + } + created := decodeIssueResult(t, createdOut) + if created.Issue.Title != dangerous { + t.Fatalf("sibling title = %q, want %q", created.Issue.Title, dangerous) + } + if created.Issue.Status != state.IssueStatusBacklog { + t.Fatalf("sibling status = %q, want backlog", created.Issue.Status) + } + if created.Issue.ParentID != result.Issue.ID { + t.Fatalf("sibling parent = %q, want %q", created.Issue.ParentID, result.Issue.ID) + } +} + +func TestParseIssueNewArgsEndOfOptionsAcceptsHyphenLeadingTitle(t *testing.T) { + options, err := parseIssueNewArgs([]string{"--parent", "LOAF-1", "--status", "backlog", "--", "--help"}) + if err != nil { + t.Fatalf("parseIssueNewArgs() error = %v", err) + } + if options.create.Title != "--help" || options.create.Parent != "LOAF-1" || options.status != state.IssueStatusBacklog { + t.Fatalf("options = %#v, want title --help under parent LOAF-1 in backlog", options) + } + + if _, err := parseIssueNewArgs([]string{"--help"}); err == nil || !strings.Contains(err.Error(), `unknown option "--help"`) { + t.Fatalf("parseIssueNewArgs(--help) error = %v, want unknown option", err) + } + + bare, err := parseIssueNewArgs([]string{"--", "--help"}) + if err != nil { + t.Fatalf("parseIssueNewArgs(-- --help) error = %v", err) + } + if bare.create.Title != "--help" { + t.Fatalf("bare title = %q, want --help", bare.create.Title) + } +} + +func TestRunnerIssueOrphanRemedyCreatesSiblingFromHyphenLeadingTitle(t *testing.T) { + workingDir, stateHome := issueCLIFixture(t) + if _, err := runIssue(t, workingDir, stateHome, "new", "Parent", "--body", cliShapedBody); err != nil { + t.Fatalf("issue new error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "dod", "add", "LOAF-1", "Promoted slice"); err != nil { + t.Fatalf("dod add error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "promote", "LOAF-1", "1"); err != nil { + t.Fatalf("promote error = %v", err) + } + root, err := project.ResolveRoot(workingDir) + if err != nil { + t.Fatalf("ResolveRoot() error = %v", err) + } + if _, err := state.AddIssueCriterion(context.Background(), root, state.PathResolver{StateHome: stateHome}, "LOAF-2", state.IssueCriterionInput{Text: "--help"}); err != nil { + t.Fatalf("AddIssueCriterion(--help) error = %v", err) + } + + out, err := runIssue(t, workingDir, stateHome, "check", "LOAF-1", "--json") + if err != nil { + t.Fatalf("issue check error = %v\n%s", err, out) + } + var result issueCheckResult + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("json.Unmarshal() error = %v\n%s", err, out) + } + if len(result.Orphans) != 1 || result.Orphans[0].Text != "--help" { + t.Fatalf("orphans = %#v, want one titled --help", result.Orphans) + } + remedy := result.Orphans[0].Remedy + if remedy != "loaf issue new --parent 'LOAF-1' --status backlog -- '--help'" { + t.Fatalf("remedy = %q", remedy) + } + argv, err := parsePOSIXArgv(remedy) + if err != nil { + t.Fatalf("parsePOSIXArgv(%q) error = %v", remedy, err) + } + + createdOut, err := runIssue(t, workingDir, stateHome, issueNewArgsWithJSON(argv[2:])...) + if err != nil { + t.Fatalf("emitted command error = %v\n%s", err, createdOut) + } + created := decodeIssueResult(t, createdOut) + if created.Issue.Title != "--help" { + t.Fatalf("sibling title = %q, want --help", created.Issue.Title) + } + if created.Issue.Status != state.IssueStatusBacklog { + t.Fatalf("sibling status = %q, want backlog", created.Issue.Status) + } + if created.Issue.ParentID != result.Issue.ID { + t.Fatalf("sibling parent = %q, want %q", created.Issue.ParentID, result.Issue.ID) + } +} + +func issueNewArgsWithJSON(args []string) []string { + if len(args) == 0 { + return []string{"--json"} + } + out := make([]string, 0, len(args)+1) + out = append(out, args[0], "--json") + out = append(out, args[1:]...) + return out +} + +func parsePOSIXArgv(command string) ([]string, error) { + var args []string + i := 0 + for i < len(command) { + for i < len(command) && command[i] == ' ' { + i++ + } + if i >= len(command) { + break + } + if command[i] != '\'' { + start := i + for i < len(command) && command[i] != ' ' { + i++ + } + args = append(args, command[start:i]) + continue + } + var b strings.Builder + i++ + for i < len(command) { + if command[i] == '\'' { + if i+3 < len(command) && command[i:i+4] == `'\''` { + b.WriteByte('\'') + i += 4 + continue + } + i++ + break + } + b.WriteByte(command[i]) + i++ + } + args = append(args, b.String()) + } + return args, nil +} + +func TestRunnerIssueCheckDecisionReadyOnQuestion(t *testing.T) { + workingDir, stateHome := issueCLIFixture(t) + if _, err := runIssue(t, workingDir, stateHome, "new", "Pick a store", "--kind", "decision", "--body", "Need a direction."); err != nil { + t.Fatalf("issue new blank decision error = %v", err) + } + blankOut, err := runIssue(t, workingDir, stateHome, "check", "LOAF-1") + if err == nil { + t.Fatalf("blank decision check error = nil, want not ready\n%s", blankOut) + } + if !strings.Contains(blankOut, "sharp question") { + t.Fatalf("blank decision output = %q", blankOut) + } + + if _, err := runIssue(t, workingDir, stateHome, "new", "Should we keep the local store?", "--kind", "decision"); err != nil { + t.Fatalf("issue new question error = %v", err) + } + readyOut, err := runIssue(t, workingDir, stateHome, "check", "LOAF-2") + if err != nil { + t.Fatalf("question decision check error = %v\n%s", err, readyOut) + } + if !strings.Contains(readyOut, "issue LOAF-2 is ready") { + t.Fatalf("question decision output = %q", readyOut) + } +} + +func TestRunnerIssuePromoteMakesCoveragePassWithZeroExtraCommands(t *testing.T) { + workingDir, stateHome := issueCLIFixture(t) + if _, err := runIssue(t, workingDir, stateHome, "new", "Parent", "--body", cliShapedBody); err != nil { + t.Fatalf("issue new error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "dod", "add", "LOAF-1", "First slice"); err != nil { + t.Fatalf("dod add 1 error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "dod", "add", "LOAF-1", "Second slice"); err != nil { + t.Fatalf("dod add 2 error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "promote", "LOAF-1", "1"); err != nil { + t.Fatalf("promote 1 error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "promote", "LOAF-1", "2"); err != nil { + t.Fatalf("promote 2 error = %v", err) + } + + out, err := runIssue(t, workingDir, stateHome, "check", "LOAF-1", "--json") + if err != nil { + t.Fatalf("issue check after promote error = %v\n%s", err, out) + } + var result issueCheckResult + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("json.Unmarshal() error = %v\n%s", err, out) + } + if !result.Ready || !result.Shaped || !result.Covered { + t.Fatalf("result = %#v, want ready after full promote", result) + } + if len(result.Failures) != 0 { + t.Fatalf("failures = %#v", result.Failures) + } +} + +func TestRunnerIssueVerifyHonorsExitAndContains(t *testing.T) { + workingDir, stateHome := issueCLIFixture(t) + if _, err := runIssue(t, workingDir, stateHome, "new", "Verify me", "--body", cliShapedBody); err != nil { + t.Fatalf("issue new error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "dod", "add", "LOAF-1", "Exit ok", "--command", "true", "--expect", "exit 0"); err != nil { + t.Fatalf("dod add exit error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "dod", "add", "LOAF-1", "Contains hello", "--command", "echo hello world", "--expect", "contains `hello`"); err != nil { + t.Fatalf("dod add contains error = %v", err) + } + + out, err := runIssue(t, workingDir, stateHome, "verify", "LOAF-1") + if err != nil { + t.Fatalf("issue verify error = %v\n%s", err, out) + } + if !strings.Contains(out, "true") || !strings.Contains(out, "echo hello world") { + t.Fatalf("verify output = %q", out) + } + + if _, err := runIssue(t, workingDir, stateHome, "dod", "add", "LOAF-1", "Must fail", "--command", "false", "--expect", "exit 0"); err != nil { + t.Fatalf("dod add fail error = %v", err) + } + failOut, err := runIssue(t, workingDir, stateHome, "verify", "LOAF-1") + if err == nil { + t.Fatalf("issue verify error = nil, want failure\n%s", failOut) + } + if !strings.Contains(failOut, "fail") { + t.Fatalf("failing verify output = %q", failOut) + } +} + +func TestRunnerIssueCheckPublishesThroughReadinessSeam(t *testing.T) { + workingDir, stateHome := issueCLIFixture(t) + root, err := project.ResolveRoot(workingDir) + if err != nil { + t.Fatalf("ResolveRoot() error = %v", err) + } + if _, err := state.SetIssueIdentity(context.Background(), root, state.PathResolver{StateHome: stateHome}, state.IssueIdentityOptions{Authority: state.IssueAuthorityGitHub}); err != nil { + t.Fatalf("SetIssueIdentity() error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "new", "Should we publish readiness?", "--kind", "decision"); err != nil { + t.Fatalf("issue new error = %v", err) + } + listOut, err := runIssue(t, workingDir, stateHome, "list", "--json") + if err != nil { + t.Fatalf("issue list error = %v", err) + } + var listed state.IssueListResult + if err := json.Unmarshal([]byte(listOut), &listed); err != nil { + t.Fatalf("list json error = %v", err) + } + if len(listed.Issues) != 1 { + t.Fatalf("listed = %#v, want one issue", listed) + } + ref := listed.Issues[0].ID + + fake := &recordingReadinessPublisher{} + previous := defaultReadinessPublisher + defaultReadinessPublisher = fake + t.Cleanup(func() { defaultReadinessPublisher = previous }) + + out, err := runIssue(t, workingDir, stateHome, "check", ref, "--json") + if err != nil { + t.Fatalf("issue check error = %v\n%s", err, out) + } + if len(fake.publications) != 1 || fake.publications[0].Label != readinessLabelAgent { + t.Fatalf("publications = %#v, want one ready-for-agent", fake.publications) + } + + fake.publications = nil + humanOut, err := runIssue(t, workingDir, stateHome, "check", ref, "--human", "needs a human call", "--json") + if err != nil { + t.Fatalf("issue check --human error = %v\n%s", err, humanOut) + } + if len(fake.publications) != 1 || fake.publications[0].Label != readinessLabelHuman || fake.publications[0].Reason != "needs a human call" { + t.Fatalf("human publications = %#v", fake.publications) + } + + var result issueCheckResult + if err := json.Unmarshal([]byte(humanOut), &result); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if result.Publication == nil || result.Publication.Label != readinessLabelHuman { + t.Fatalf("json publication = %#v", result.Publication) + } +} + +func TestRunnerIssueDodServesAndClaimUnclaim(t *testing.T) { + workingDir, stateHome := issueCLIFixture(t) + if _, err := runIssue(t, workingDir, stateHome, "new", "Parent", "--body", cliShapedBody); err != nil { + t.Fatalf("issue new parent error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "dod", "add", "LOAF-1", "Parent criterion"); err != nil { + t.Fatalf("dod add parent error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "new", "Child", "--parent", "LOAF-1"); err != nil { + t.Fatalf("issue new child error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "dod", "add", "LOAF-2", "Serves parent", "--serves", "1"); err != nil { + t.Fatalf("dod add --serves error = %v", err) + } + out, err := runIssue(t, workingDir, stateHome, "check", "LOAF-1") + if err != nil { + t.Fatalf("check after --serves error = %v\n%s", err, out) + } + + if _, err := runIssue(t, workingDir, stateHome, "dod", "unclaim", "LOAF-2", "1", "1"); err != nil { + t.Fatalf("dod unclaim error = %v", err) + } + uncovered, err := runIssue(t, workingDir, stateHome, "check", "LOAF-1") + if err == nil { + t.Fatalf("check after unclaim error = nil, want uncovered\n%s", uncovered) + } + if _, err := runIssue(t, workingDir, stateHome, "dod", "claim", "LOAF-2", "1", "1"); err != nil { + t.Fatalf("dod claim error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "check", "LOAF-1"); err != nil { + t.Fatalf("check after claim error = %v", err) + } +} diff --git a/internal/cli/issue_test.go b/internal/cli/issue_test.go new file mode 100644 index 000000000..fbc67e975 --- /dev/null +++ b/internal/cli/issue_test.go @@ -0,0 +1,445 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/levifig/loaf/internal/project" + "github.com/levifig/loaf/internal/state" +) + +func issueCLIFixture(t *testing.T) (string, string) { + t.Helper() + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "init"}); err != nil { + t.Fatalf("state init error = %v", err) + } + return workingDir, stateHome +} + +func runIssue(t *testing.T, workingDir, stateHome string, args ...string) (string, error) { + t.Helper() + var stdout bytes.Buffer + err := Runner{Stdout: &stdout, WorkingDir: workingDir, StateHome: stateHome}.Run(append([]string{"issue"}, args...)) + return stdout.String(), err +} + +func decodeIssueResult(t *testing.T, data string) state.IssueResult { + t.Helper() + var result state.IssueResult + if err := json.Unmarshal([]byte(data), &result); err != nil { + t.Fatalf("json.Unmarshal(%q) error = %v", data, err) + } + return result +} + +func TestRunnerIssueNewEditShowRoundTripsBody(t *testing.T) { + workingDir, stateHome := issueCLIFixture(t) + body := "Line one.\n\nLine two with trailing space. \n\tindented\n" + + createdOut, err := runIssue(t, workingDir, stateHome, "new", "Round trip", "--body", body, "--json") + if err != nil { + t.Fatalf("issue new error = %v", err) + } + created := decodeIssueResult(t, createdOut) + if created.Issue.Alias != "LOAF-1" { + t.Fatalf("created alias = %q, want LOAF-1", created.Issue.Alias) + } + if created.Issue.Body != body { + t.Fatalf("created body = %q, want %q", created.Issue.Body, body) + } + assertCLIProjectContext(t, workingDir, created.ContractVersion, created.DatabaseScope, created.DatabasePath, created.ProjectID, created.ProjectName, created.ProjectCurrentPath) + + bodyFile := filepath.Join(t.TempDir(), "body.md") + if err := os.WriteFile(bodyFile, []byte(body), 0o600); err != nil { + t.Fatalf("WriteFile(body) error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "edit", "LOAF-1", "--body-file", bodyFile); err != nil { + t.Fatalf("issue edit --body-file error = %v", err) + } + + shownOut, err := runIssue(t, workingDir, stateHome, "show", "LOAF-1", "--json") + if err != nil { + t.Fatalf("issue show error = %v", err) + } + shown := decodeIssueResult(t, shownOut) + if shown.Issue.Body != body { + t.Fatalf("show body = %q, want byte-identical %q", shown.Issue.Body, body) + } +} + +func TestRunnerIssueTreePrintsDepthThree(t *testing.T) { + workingDir, stateHome := issueCLIFixture(t) + if _, err := runIssue(t, workingDir, stateHome, "new", "Parent"); err != nil { + t.Fatalf("issue new parent error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "new", "Child", "--parent", "LOAF-1"); err != nil { + t.Fatalf("issue new child error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "new", "Grandchild", "--parent", "LOAF-2"); err != nil { + t.Fatalf("issue new grandchild error = %v", err) + } + + out, err := runIssue(t, workingDir, stateHome, "tree", "LOAF-1") + if err != nil { + t.Fatalf("issue tree error = %v", err) + } + if !strings.Contains(out, "LOAF-1 triage Parent") { + t.Fatalf("tree missing parent line:\n%s", out) + } + if !strings.Contains(out, " LOAF-2 triage Child") { + t.Fatalf("tree missing indented child:\n%s", out) + } + if !strings.Contains(out, " LOAF-3 triage Grandchild") { + t.Fatalf("tree missing indented grandchild:\n%s", out) + } +} + +func TestRunnerIssueRenderIsCompletePRBody(t *testing.T) { + workingDir, stateHome := issueCLIFixture(t) + if _, err := runIssue(t, workingDir, stateHome, "new", "Ship the CLI", "--body", "Implement the issue spine.\n"); err != nil { + t.Fatalf("issue new error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "dod", "add", "LOAF-1", "Tests pass"); err != nil { + t.Fatalf("issue dod add error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "new", "Child work", "--parent", "LOAF-1"); err != nil { + t.Fatalf("issue new child error = %v", err) + } + + out, err := runIssue(t, workingDir, stateHome, "render", "LOAF-1") + if err != nil { + t.Fatalf("issue render error = %v", err) + } + for _, want := range []string{ + "# Ship the CLI\n", + "Implement the issue spine.\n", + "## Definition of Done\n", + "- [ ] Tests pass\n", + "## Children\n", + "- LOAF-2: Child work\n", + } { + if !strings.Contains(out, want) { + t.Fatalf("render missing %q:\n%s", want, out) + } + } + if strings.Contains(out, "scope:") || strings.Contains(out, "database:") { + t.Fatalf("render included project headers that would require editing:\n%s", out) + } + + if _, err := runIssue(t, workingDir, stateHome, "status", "LOAF-1", "done"); err != nil { + t.Fatalf("issue status done error = %v", err) + } + doneOut, err := runIssue(t, workingDir, stateHome, "render", "LOAF-1") + if err != nil { + t.Fatalf("issue render(done) error = %v", err) + } + if !strings.Contains(doneOut, "- [x] Tests pass\n") { + t.Fatalf("done render missing checked criterion:\n%s", doneOut) + } +} + +func TestRunnerIssueFrontierExcludesBlockedAndArchived(t *testing.T) { + workingDir, stateHome := issueCLIFixture(t) + if _, err := runIssue(t, workingDir, stateHome, "new", "Open"); err != nil { + t.Fatalf("issue new open error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "new", "Blocker"); err != nil { + t.Fatalf("issue new blocker error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "new", "Blocked"); err != nil { + t.Fatalf("issue new blocked error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "new", "Cancelled"); err != nil { + t.Fatalf("issue new cancelled error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "link", "LOAF-2", "blocks", "LOAF-3"); err != nil { + t.Fatalf("issue link blocks error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "status", "LOAF-4", "cancelled"); err != nil { + t.Fatalf("issue status cancelled error = %v", err) + } + + out, err := runIssue(t, workingDir, stateHome, "frontier") + if err != nil { + t.Fatalf("issue frontier error = %v", err) + } + if !strings.Contains(out, "LOAF-1") || !strings.Contains(out, "LOAF-2") { + t.Fatalf("frontier missing open issues:\n%s", out) + } + if strings.Contains(out, "LOAF-3") { + t.Fatalf("frontier included blocked issue:\n%s", out) + } + if strings.Contains(out, "LOAF-4") { + t.Fatalf("frontier included archived issue:\n%s", out) + } + + if _, err := runIssue(t, workingDir, stateHome, "status", "LOAF-2", "done"); err != nil { + t.Fatalf("issue status blocker done error = %v", err) + } + unblocked, err := runIssue(t, workingDir, stateHome, "frontier") + if err != nil { + t.Fatalf("issue frontier after unblock error = %v", err) + } + if !strings.Contains(unblocked, "LOAF-3") { + t.Fatalf("frontier after blocker done missing LOAF-3:\n%s", unblocked) + } +} + +func TestRunnerIssueStatusHonorsRemovalSemantics(t *testing.T) { + workingDir, stateHome := issueCLIFixture(t) + if _, err := runIssue(t, workingDir, stateHome, "new", "Keep"); err != nil { + t.Fatalf("issue new keep error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "new", "Cancel me"); err != nil { + t.Fatalf("issue new cancel error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "new", "Dup me"); err != nil { + t.Fatalf("issue new dup error = %v", err) + } + + cancelledOut, err := runIssue(t, workingDir, stateHome, "status", "LOAF-2", "cancelled", "--json") + if err != nil { + t.Fatalf("issue status cancelled error = %v", err) + } + cancelled := decodeIssueResult(t, cancelledOut) + if cancelled.Issue.Status != state.IssueStatusCancelled || cancelled.Issue.ArchivedAt == "" { + t.Fatalf("cancelled = %#v, want cancelled and archived", cancelled.Issue) + } + + if _, err := runIssue(t, workingDir, stateHome, "status", "LOAF-3", "duplicate"); err == nil { + t.Fatal("duplicate without survivor must fail") + } + + dupOut, err := runIssue(t, workingDir, stateHome, "status", "LOAF-3", "duplicate", "--duplicate-of", "LOAF-1", "--json") + if err != nil { + t.Fatalf("issue status duplicate error = %v", err) + } + dup := decodeIssueResult(t, dupOut) + if dup.Issue.Status != state.IssueStatusDuplicate || dup.Issue.ArchivedAt == "" { + t.Fatalf("duplicate = %#v, want duplicate and archived", dup.Issue) + } + + if _, err := runIssue(t, workingDir, stateHome, "status", "LOAF-1", "todo", "--duplicate-of", "LOAF-2"); err == nil { + t.Fatal("duplicate-of on a write status must fail") + } +} + +func TestRunnerIssueListDodPromoteBucketExportAndHelp(t *testing.T) { + workingDir, stateHome := issueCLIFixture(t) + if _, err := runIssue(t, workingDir, stateHome, "new", "Listed", "--kind", "decision", "--fog", "still fuzzy"); err != nil { + t.Fatalf("issue new listed error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "new", "Other"); err != nil { + t.Fatalf("issue new other error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "status", "LOAF-2", "todo"); err != nil { + t.Fatalf("issue status todo error = %v", err) + } + + listOut, err := runIssue(t, workingDir, stateHome, "list", "--kind", "decision") + if err != nil { + t.Fatalf("issue list --kind error = %v", err) + } + if !strings.Contains(listOut, "LOAF-1") || strings.Contains(listOut, "LOAF-2") { + t.Fatalf("kind filter = %q", listOut) + } + + if _, err := runIssue(t, workingDir, stateHome, "dod", "add", "LOAF-1", "A human check"); err != nil { + t.Fatalf("dod add H error = %v", err) + } + if _, err := runIssue(t, workingDir, stateHome, "dod", "add", "LOAF-1", "A verify check", "--command", "true", "--expect", "exit 0"); err != nil { + t.Fatalf("dod add V error = %v", err) + } + dodOut, err := runIssue(t, workingDir, stateHome, "dod", "list", "LOAF-1") + if err != nil { + t.Fatalf("dod list error = %v", err) + } + if !strings.Contains(dodOut, "[H] A human check") || !strings.Contains(dodOut, "[V] A verify check") { + t.Fatalf("dod list = %q", dodOut) + } + + promoteOut, err := runIssue(t, workingDir, stateHome, "promote", "LOAF-1", "1") + if err != nil { + t.Fatalf("issue promote error = %v", err) + } + if !strings.Contains(promoteOut, "promoted criterion 1 to LOAF-3") { + t.Fatalf("promote output = %q", promoteOut) + } + + if _, err := runIssue(t, workingDir, stateHome, "bucket", "LOAF-1", "now"); err != nil { + t.Fatalf("issue bucket error = %v", err) + } + showOut, err := runIssue(t, workingDir, stateHome, "show", "LOAF-1") + if err != nil { + t.Fatalf("issue show error = %v", err) + } + if !strings.Contains(showOut, "bucket: now") || !strings.Contains(showOut, "fog: still fuzzy") { + t.Fatalf("show = %q", showOut) + } + + exportOut, err := runIssue(t, workingDir, stateHome, "export") + if err != nil { + t.Fatalf("issue export error = %v", err) + } + var snapshot state.IssueExportSnapshot + if err := json.Unmarshal([]byte(exportOut), &snapshot); err != nil { + t.Fatalf("export JSON error = %v", err) + } + if snapshot.ExportKind != state.ExportKindIssue || len(snapshot.Issues) < 2 || len(snapshot.Criteria) < 2 || len(snapshot.Claims) < 1 { + t.Fatalf("export snapshot = %#v", snapshot) + } + + helpOut, err := runIssue(t, workingDir, stateHome, "--help") + if err != nil { + t.Fatalf("issue --help error = %v", err) + } + if !strings.Contains(helpOut, "loaf issue <subcommand>") { + t.Fatalf("issue help = %q", helpOut) + } +} + +func TestRunnerIssueNewStatusWritesTriageThenRequested(t *testing.T) { + workingDir, stateHome := issueCLIFixture(t) + out, err := runIssue(t, workingDir, stateHome, "new", "Backlogged", "--status", "backlog", "--json") + if err != nil { + t.Fatalf("issue new --status backlog error = %v", err) + } + created := decodeIssueResult(t, out) + if created.Issue.Status != state.IssueStatusBacklog { + t.Fatalf("created status = %q, want backlog", created.Issue.Status) + } + + root, err := project.ResolveRoot(workingDir) + if err != nil { + t.Fatalf("ResolveRoot() error = %v", err) + } + snapshot, err := state.ExportAllJSON(context.Background(), root, state.PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("ExportAllJSON() error = %v", err) + } + type statusEvent struct { + created string + label string + } + var events []statusEvent + for _, row := range snapshot.Tables["events"] { + if row["entity_kind"] != "issue" || row["entity_id"] != created.Issue.ID { + continue + } + from := "" + if row["from_status"] != nil { + from = fmt.Sprint(row["from_status"]) + } + events = append(events, statusEvent{ + created: fmt.Sprint(row["created_at"]), + label: from + "->" + fmt.Sprint(row["to_status"]), + }) + } + sort.Slice(events, func(i, j int) bool { + if events[i].created != events[j].created { + return events[i].created < events[j].created + } + return events[i].label < events[j].label + }) + transitions := make([]string, len(events)) + for i, event := range events { + transitions[i] = event.label + } + if len(transitions) != 2 || transitions[0] != "->triage" || transitions[1] != "triage->backlog" { + t.Fatalf("events = %#v, want empty->triage then triage->backlog", transitions) + } +} + +func TestRunnerIssueNewRejectsRemovalStatus(t *testing.T) { + workingDir, stateHome := issueCLIFixture(t) + _, err := runIssue(t, workingDir, stateHome, "new", "Nope", "--status", "cancelled") + if err == nil || !strings.Contains(err.Error(), "issue new --status must be one of") { + t.Fatalf("issue new --status cancelled error = %v, want write-status validation", err) + } +} + +func TestRunnerIssueNewTrackerAuthorityPrintsOpaqueID(t *testing.T) { + workingDir, stateHome := issueCLIFixture(t) + root, err := project.ResolveRoot(workingDir) + if err != nil { + t.Fatalf("ResolveRoot() error = %v", err) + } + if _, err := state.SetIssueIdentity(context.Background(), root, state.PathResolver{StateHome: stateHome}, state.IssueIdentityOptions{Authority: state.IssueAuthorityGitHub}); err != nil { + t.Fatalf("SetIssueIdentity() error = %v", err) + } + + out, err := runIssue(t, workingDir, stateHome, "new", "Tracker backed") + if err != nil { + t.Fatalf("issue new error = %v", err) + } + if strings.Contains(out, "LOAF-") { + t.Fatalf("tracker create minted a local alias:\n%s", out) + } + if !strings.Contains(out, "note: no local alias is minted under a tracker authority") { + t.Fatalf("tracker create missing note:\n%s", out) + } +} + +func TestRunnerIssueRequiresSQLiteAndUnknownSubcommand(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + err := Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}.Run([]string{"issue", "list"}) + want := "loaf issue list requires initialized SQLite state; run `loaf state init` or `loaf state migrate markdown --apply` first" + if err == nil || err.Error() != want { + t.Fatalf("issue list without state error = %v, want %q", err, want) + } + + workingDir, stateHome = issueCLIFixture(t) + err = Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}.Run([]string{"issue", "explode"}) + if err == nil || !strings.Contains(err.Error(), `unknown loaf issue subcommand "explode"`) { + t.Fatalf("unknown subcommand error = %v", err) + } +} + +func TestRunnerLegacyHelpRedirectsToIssue(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + for _, args := range [][]string{ + {"task", "--help"}, + {"intent", "--help"}, + } { + var stdout bytes.Buffer + if err := (Runner{Stdout: &stdout, WorkingDir: workingDir}).Run(args); err != nil { + t.Fatalf("Run(%v) error = %v", args, err) + } + if !strings.Contains(stdout.String(), "loaf issue for new work") { + t.Fatalf("%v help missing redirect:\n%s", args, stdout.String()) + } + } +} + +func TestRunnerIssueEditRequiresBodyFlag(t *testing.T) { + workingDir, stateHome := issueCLIFixture(t) + if _, err := runIssue(t, workingDir, stateHome, "new", "Needs body"); err != nil { + t.Fatalf("issue new error = %v", err) + } + t.Setenv("EDITOR", "false") + err := Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}.Run([]string{"issue", "edit", "LOAF-1"}) + want := "issue edit requires body content via --body-file, --body -, or --message" + if err == nil || err.Error() != want { + t.Fatalf("issue edit without body error = %v, want %q", err, want) + } +} + +func TestRunnerRootHelpListsIssue(t *testing.T) { + var stdout bytes.Buffer + if err := (Runner{Stdout: &stdout, WorkingDir: t.TempDir()}).Run([]string{"--help"}); err != nil { + t.Fatalf("loaf --help error = %v", err) + } + if !strings.Contains(stdout.String(), "issue Manage issues") { + t.Fatalf("root help missing issue:\n%s", stdout.String()) + } +} diff --git a/internal/cli/issue_verify.go b/internal/cli/issue_verify.go new file mode 100644 index 000000000..d102ddf05 --- /dev/null +++ b/internal/cli/issue_verify.go @@ -0,0 +1,115 @@ +package cli + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/levifig/loaf/internal/state" +) + +type issueVerifyResult struct { + ContractVersion int `json:"contract_version"` + DatabaseScope string `json:"database_scope"` + DatabasePath string `json:"database_path"` + ProjectID string `json:"project_id"` + ProjectName string `json:"project_name"` + ProjectCurrentPath string `json:"project_current_path"` + Issue state.Issue `json:"issue"` + Results []issueVerifyCriterion `json:"results"` + OK bool `json:"ok"` +} + +type issueVerifyCriterion struct { + Position int `json:"position"` + Text string `json:"text"` + Command string `json:"command"` + Expect string `json:"expect,omitempty"` + ExitCode int `json:"exit_code"` + OK bool `json:"ok"` + ExpectChecks []changeVerifyExpectCheck `json:"expect_checks,omitempty"` + Advisory []string `json:"advisory,omitempty"` +} + +func (r Runner) runIssueVerify(args []string, out io.Writer, runtime state.Runtime) error { + ref, jsonOutput, err := parseSingleRefArgs("issue verify", args) + if err != nil { + return err + } + projectRoot, err := r.requireIssueSQLiteState("issue verify", runtime) + if err != nil { + return err + } + shown, err := state.ShowIssue(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, ref) + if err != nil { + return err + } + + rootPath := projectRoot.Path() + result := issueVerifyResult{ + ContractVersion: shown.ContractVersion, + DatabaseScope: shown.DatabaseScope, + DatabasePath: shown.DatabasePath, + ProjectID: shown.ProjectID, + ProjectName: shown.ProjectName, + ProjectCurrentPath: shown.ProjectCurrentPath, + Issue: shown.Issue, + Results: []issueVerifyCriterion{}, + OK: true, + } + + for _, criterion := range shown.Issue.Criteria { + if criterion.Tier != state.IssueCriterionTierV || strings.TrimSpace(criterion.Command) == "" { + continue + } + exitCode, output, runErr := runChangeCriterionCommand(rootPath, criterion.Command) + expectation := parseChangeExpectation(criterion.Expect) + checks := evaluateChangeExpectation(expectation, exitCode, output) + ok := runErr == nil && changeExpectChecksPass(checks) + if !ok { + result.OK = false + } + item := issueVerifyCriterion{ + Position: criterion.Position, + Text: criterion.Text, + Command: criterion.Command, + Expect: criterion.Expect, + ExitCode: exitCode, + OK: ok, + ExpectChecks: checks, + Advisory: expectation.Advisory, + } + result.Results = append(result.Results, item) + if !jsonOutput { + status := ansiGreen("ok") + if !ok { + status = ansiRed("fail") + } + fmt.Fprintf(out, "%s %d %s%s\n", status, criterion.Position, criterion.Command, changeExpectFailureNote(runErr, exitCode, checks)) + for _, clause := range expectation.Advisory { + fmt.Fprintf(out, "%s %d unenforceable Expect clause %q — recorded as advisory, never checked\n", + ansiYellow("warn"), criterion.Position, clause) + } + } + } + + if jsonOutput { + if err := writeJSON(out, result); err != nil { + return err + } + } else if len(result.Results) == 0 { + fmt.Fprintf(out, "no executable V-tier criteria on %s\n", issueDisplayRef(shown.Issue)) + } + + if !result.OK { + return ExitError{Code: 1} + } + return nil +} + +func writeIssueVerifyHelp(out io.Writer) { + writeUsageHelp(out, "loaf issue verify <ref> [--json]", + "Run the issue's V-tier criteria (command + expect) from the repository root. Honors exit N and contains `text`. Writes nothing; exits non-zero on any failure.", + "--json Output per-criterion results as JSON") +} diff --git a/internal/cli/issue_worktree.go b/internal/cli/issue_worktree.go new file mode 100644 index 000000000..10d131570 --- /dev/null +++ b/internal/cli/issue_worktree.go @@ -0,0 +1,515 @@ +package cli + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/levifig/loaf/internal/project" + "github.com/levifig/loaf/internal/state" +) + +type issueStartResult struct { + ContractVersion int `json:"contract_version"` + DatabaseScope string `json:"database_scope"` + DatabasePath string `json:"database_path"` + ProjectID string `json:"project_id"` + ProjectName string `json:"project_name"` + ProjectCurrentPath string `json:"project_current_path"` + Issue state.Issue `json:"issue"` + Branch string `json:"branch"` + Worktree string `json:"worktree"` + Base string `json:"base"` +} + +type issueStopResult struct { + ContractVersion int `json:"contract_version"` + DatabaseScope string `json:"database_scope"` + DatabasePath string `json:"database_path"` + ProjectID string `json:"project_id"` + ProjectName string `json:"project_name"` + ProjectCurrentPath string `json:"project_current_path"` + Issue state.Issue `json:"issue"` + Branch string `json:"branch"` + Worktree string `json:"worktree"` + AlreadyGone bool `json:"already_gone"` +} + +type issueStopOptions struct { + jsonOutput bool + force bool + ref string +} + +func writeIssueStartHelp(out io.Writer) { + writeUsageHelp(out, "loaf issue start <ref> [--json]", "Create a branch and worktree for one issue and record them on the row. Moves status to active through the events path.", + "--json Output the started issue, branch, worktree, base, global database scope, and project identity as JSON") +} + +func writeIssueStopHelp(out io.Writer) { + writeUsageHelp(out, "loaf issue stop <ref> [--force] [--json]", "Remove the issue worktree and clear the started workspace on the row. Keeps the branch. Does not change status.", + "--force Remove a dirty worktree", + "--json Output the stopped issue, branch, worktree, already-gone flag, global database scope, and project identity as JSON") +} + +func (r Runner) runIssueStart(args []string, out io.Writer, runtime state.Runtime) error { + ref, jsonOutput, err := parseSingleRefArgs("issue start", args) + if err != nil { + return err + } + projectRoot, err := r.requireIssueSQLiteState("issue start", runtime) + if err != nil { + return err + } + repoRoot := projectRoot.Path() + if !gitRepoAt(repoRoot) { + return fmt.Errorf("issue start requires a git repository") + } + + resolver := state.PathResolver{StateHome: r.StateHome} + issue, err := state.GetIssue(context.Background(), projectRoot, resolver, ref) + if err != nil { + return err + } + if err := refuseIssueStart(issue); err != nil { + return err + } + + base, err := resolveIssueStartBase(context.Background(), projectRoot, resolver, issue, repoRoot) + if err != nil { + return err + } + listed, err := state.ListIssues(context.Background(), projectRoot, resolver, state.IssueListOptions{Archived: true}) + if err != nil { + return err + } + branch, err := resolveIssueStartBranch(issue, listed.Issues, repoRoot) + if err != nil { + return err + } + worktree := issueWorktreePath(repoRoot, branch) + if _, err := os.Stat(worktree); err == nil { + return fmt.Errorf("worktree path %s already exists", worktree) + } + + createdBranch, err := addIssueWorktree(repoRoot, worktree, branch, base) + if err != nil { + return err + } + + updated, err := state.UpdateIssue(context.Background(), projectRoot, resolver, state.IssueUpdateOptions{ + Ref: issue.ID, + Status: state.IssueStatusActive, + SetStatus: true, + StartedBranch: branch, + StartedWorktree: worktree, + SetStarted: true, + }) + if err != nil { + return wrapIssueStartUpdateError(err, rollbackIssueWorktree(repoRoot, worktree, branch, createdBranch), worktree, branch) + } + + shown, err := state.ShowIssue(context.Background(), projectRoot, resolver, updated.ID) + if err != nil { + return err + } + result := issueStartResult{ + ContractVersion: shown.ContractVersion, + DatabaseScope: shown.DatabaseScope, + DatabasePath: shown.DatabasePath, + ProjectID: shown.ProjectID, + ProjectName: shown.ProjectName, + ProjectCurrentPath: shown.ProjectCurrentPath, + Issue: shown.Issue, + Branch: branch, + Worktree: worktree, + Base: base, + } + if jsonOutput { + return writeJSON(out, result) + } + fmt.Fprintf(out, "started issue %s\n", issueDisplayRef(result.Issue)) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + fmt.Fprintf(out, "branch: %s\n", result.Branch) + fmt.Fprintf(out, "worktree: %s\n", result.Worktree) + fmt.Fprintf(out, "base: %s\n", result.Base) + fmt.Fprintf(out, "status: %s\n", result.Issue.Status) + return nil +} + +func (r Runner) runIssueStop(args []string, out io.Writer, runtime state.Runtime) error { + options, err := parseIssueStopArgs(args) + if err != nil { + return err + } + projectRoot, err := r.requireIssueSQLiteState("issue stop", runtime) + if err != nil { + return err + } + resolver := state.PathResolver{StateHome: r.StateHome} + issue, err := state.GetIssue(context.Background(), projectRoot, resolver, options.ref) + if err != nil { + return err + } + if !issueIsStarted(issue) { + return fmt.Errorf("issue %s is not started", issueDisplayRef(issue)) + } + + savedBranch := issue.StartedBranch + savedWorktree := issue.StartedWorktree + + cleared, err := state.UpdateIssue(context.Background(), projectRoot, resolver, state.IssueUpdateOptions{ + Ref: issue.ID, + SetStarted: true, + }) + if err != nil { + return err + } + + alreadyGone, err := removeIssueWorktreeFn(projectRoot.Path(), savedWorktree, options.force) + if err != nil { + if _, restoreErr := state.UpdateIssue(context.Background(), projectRoot, resolver, state.IssueUpdateOptions{ + Ref: issue.ID, + StartedBranch: savedBranch, + StartedWorktree: savedWorktree, + SetStarted: true, + }); restoreErr != nil { + return fmt.Errorf("remove worktree: %w (also failed to restore started fields: %v)", err, restoreErr) + } + return err + } + + shown, err := state.ShowIssue(context.Background(), projectRoot, resolver, cleared.ID) + if err != nil { + return err + } + result := issueStopResult{ + ContractVersion: shown.ContractVersion, + DatabaseScope: shown.DatabaseScope, + DatabasePath: shown.DatabasePath, + ProjectID: shown.ProjectID, + ProjectName: shown.ProjectName, + ProjectCurrentPath: shown.ProjectCurrentPath, + Issue: shown.Issue, + Branch: issue.StartedBranch, + Worktree: issue.StartedWorktree, + AlreadyGone: alreadyGone, + } + if options.jsonOutput { + return writeJSON(out, result) + } + fmt.Fprintf(out, "stopped issue %s\n", issueDisplayRef(result.Issue)) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + if alreadyGone { + fmt.Fprintf(out, "worktree already gone: %s\n", result.Worktree) + } else { + fmt.Fprintf(out, "removed worktree: %s\n", result.Worktree) + } + fmt.Fprintf(out, "kept branch: %s\n", result.Branch) + fmt.Fprintf(out, "status: %s\n", result.Issue.Status) + return nil +} + +func parseIssueStopArgs(args []string) (issueStopOptions, error) { + var options issueStopOptions + var positional []string + for _, arg := range args { + switch arg { + case "--json": + options.jsonOutput = true + case "--force": + options.force = true + default: + if strings.HasPrefix(arg, "-") { + return issueStopOptions{}, fmt.Errorf("unknown option %q", arg) + } + positional = append(positional, arg) + } + } + if len(positional) != 1 { + return issueStopOptions{}, fmt.Errorf("issue stop requires an issue ref") + } + options.ref = positional[0] + return options, nil +} + +func refuseIssueStart(issue state.Issue) error { + if issueIsStarted(issue) { + return fmt.Errorf("issue %s is already started at %s", issueDisplayRef(issue), firstNonEmpty(issue.StartedWorktree, issue.StartedBranch)) + } + if issue.ArchivedAt != "" { + return fmt.Errorf("issue %s is archived", issueDisplayRef(issue)) + } + if issueStartRefusedStatus(issue.Status) { + return fmt.Errorf("issue %s is %s; start refuses terminal statuses", issueDisplayRef(issue), issue.Status) + } + return nil +} + +func issueIsStarted(issue state.Issue) bool { + return strings.TrimSpace(issue.StartedBranch) != "" || strings.TrimSpace(issue.StartedWorktree) != "" +} + +func issueStartRefusedStatus(status string) bool { + switch status { + case state.IssueStatusDone, state.IssueStatusCancelled, state.IssueStatusDuplicate: + return true + default: + return false + } +} + +func resolveIssueStartBase(ctx context.Context, root project.Root, resolver state.PathResolver, issue state.Issue, repoRoot string) (string, error) { + ancestor, found, err := state.NearestStartedAncestor(ctx, root, resolver, issue.ID) + if err != nil { + return "", err + } + if found { + if strings.TrimSpace(ancestor.StartedBranch) == "" { + return "", fmt.Errorf("started ancestor %s has no started_branch", issueDisplayRef(ancestor)) + } + return ancestor.StartedBranch, nil + } + base := resolveReleaseDefaultBranch(repoRoot) + if base == "" { + return "", fmt.Errorf("could not resolve repository default branch") + } + return qualifyIssueStartBase(repoRoot, base), nil +} + +func qualifyIssueStartBase(repoRoot, base string) string { + base = strings.TrimSpace(base) + if base == "" { + return "" + } + if gitRefExists(repoRoot, "refs/heads/"+base) { + return base + } + remote := "origin/" + base + if gitRefExists(repoRoot, "refs/remotes/"+remote) { + return remote + } + return base +} + +func issueStartSlug(issue state.Issue) string { + name := strings.TrimSpace(issue.Alias) + if name == "" { + name = issue.ID + } + return strings.ToLower(name) +} + +func issueStartBranch(issue state.Issue) string { + return "issue/" + issueStartSlug(issue) +} + +func issueStartBranchSuffix(id string) string { + _, rest, ok := strings.Cut(id, "_") + if ok && len(rest) >= 8 { + return rest[:8] + } + if len(id) >= 8 { + return id[len(id)-8:] + } + return id +} + +func issueStartBranchDisambiguated(issue state.Issue) string { + return issueStartBranch(issue) + "-" + issueStartBranchSuffix(issue.ID) +} + +func resolveIssueStartBranch(issue state.Issue, issues []state.Issue, repoRoot string) (string, error) { + preferred := issueStartBranch(issue) + if live := startedBranchClaimant(issues, preferred, issue.ID); live != nil { + return requireUnclaimedIssueBranch(issueStartBranchDisambiguated(issue), issue, issues) + } + if gitRefExists(repoRoot, "refs/heads/"+preferred) { + if owner := issueSharingStartSlug(issue, issues); owner != nil { + return requireUnclaimedIssueBranch(issueStartBranchDisambiguated(issue), issue, issues) + } + } + return preferred, nil +} + +func requireUnclaimedIssueBranch(branch string, issue state.Issue, issues []state.Issue) (string, error) { + if live := startedBranchClaimant(issues, branch, issue.ID); live != nil { + return "", fmt.Errorf("branch %s collides for issues %s and %s", branch, issueDisplayRef(*live), issueDisplayRef(issue)) + } + return branch, nil +} + +func startedBranchClaimant(issues []state.Issue, branch, exceptID string) *state.Issue { + for i := range issues { + if issues[i].ID == exceptID { + continue + } + if strings.TrimSpace(issues[i].StartedBranch) == branch { + return &issues[i] + } + } + return nil +} + +func issueSharingStartSlug(issue state.Issue, issues []state.Issue) *state.Issue { + slug := issueStartSlug(issue) + for i := range issues { + if issues[i].ID == issue.ID { + continue + } + if issueStartSlug(issues[i]) == slug { + return &issues[i] + } + } + return nil +} + +func issueWorktreePath(repoRoot, branch string) string { + slug := strings.ReplaceAll(branch, "/", "-") + return filepath.Join(filepath.Dir(repoRoot), filepath.Base(repoRoot)+"-wt", slug) +} + +func gitRepoAt(root string) bool { + _, err := gitOutput(root, "rev-parse", "--is-inside-work-tree") + return err == nil +} + +func gitRefExists(root, ref string) bool { + _, err := gitOutput(root, "show-ref", "--verify", "--quiet", ref) + return err == nil +} + +func addIssueWorktree(repoRoot, worktree, branch, base string) (createdBranch bool, err error) { + if err := os.MkdirAll(filepath.Dir(worktree), 0o755); err != nil { + return false, fmt.Errorf("create worktree parent: %w", err) + } + if gitRefExists(repoRoot, "refs/heads/"+branch) { + if _, err := gitRun(repoRoot, "worktree", "add", worktree, branch); err != nil { + return false, fmt.Errorf("git worktree add: %w", err) + } + return false, nil + } + if _, err := gitRun(repoRoot, "worktree", "add", worktree, "-b", branch, base); err != nil { + return false, fmt.Errorf("git worktree add: %w", err) + } + return true, nil +} + +func rollbackIssueWorktree(repoRoot, worktree, branch string, createdBranch bool) error { + var cleanupErr error + note := func(err error) { + if err == nil { + return + } + if cleanupErr == nil { + cleanupErr = err + return + } + cleanupErr = fmt.Errorf("%w; %v", cleanupErr, err) + } + if _, err := gitRun(repoRoot, "worktree", "remove", "--force", worktree); err != nil { + note(fmt.Errorf("git worktree remove: %w", err)) + if rmErr := os.RemoveAll(worktree); rmErr != nil { + note(fmt.Errorf("remove leftover directory: %w", rmErr)) + } + } else { + _ = os.RemoveAll(worktree) + } + if _, err := gitRun(repoRoot, "worktree", "prune", "--expire", "now"); err != nil { + note(fmt.Errorf("git worktree prune: %w", err)) + } + if createdBranch { + if _, err := gitRun(repoRoot, "branch", "-D", branch); err != nil { + note(fmt.Errorf("git branch -D: %w", err)) + } + } + if cleanupErr != nil { + return fmt.Errorf("leftover worktree %s branch %s: %w", worktree, branch, cleanupErr) + } + return nil +} + +func wrapIssueStartUpdateError(updateErr, cleanupErr error, worktree, branch string) error { + if cleanupErr == nil { + return updateErr + } + return fmt.Errorf("%w (also failed to clean up leftover worktree %s branch %s: %v)", updateErr, worktree, branch, cleanupErr) +} + +var removeIssueWorktreeFn = removeIssueWorktree + +func removeIssueWorktree(repoRoot, worktree string, force bool) (alreadyGone bool, err error) { + if strings.TrimSpace(worktree) == "" { + return true, nil + } + if _, statErr := os.Stat(worktree); os.IsNotExist(statErr) { + if _, pruneErr := gitRun(repoRoot, "worktree", "prune", "--expire", "now"); pruneErr != nil { + return false, fmt.Errorf("git worktree prune: %w", pruneErr) + } + return true, nil + } + if !force && gitWorktreeDirty(worktree) { + return false, fmt.Errorf("worktree %s is dirty; pass --force to remove it", worktree) + } + args := []string{"worktree", "remove", worktree} + if force { + args = []string{"worktree", "remove", "--force", worktree} + } + if _, err := gitRun(repoRoot, args...); err != nil { + return false, fmt.Errorf("git worktree remove: %w", err) + } + return false, nil +} + +func gitRun(cwd string, args ...string) (string, error) { + cmd := exec.Command("git", args...) + cmd.Dir = cwd + out, err := cmd.CombinedOutput() + trimmed := strings.TrimSpace(string(out)) + if err != nil { + if trimmed != "" { + return trimmed, fmt.Errorf("%w: %s", err, trimmed) + } + return trimmed, err + } + return trimmed, nil +} + +func gitWorktreeDirty(worktree string) bool { + out, err := gitOutput(worktree, "status", "--porcelain") + if err != nil { + return true + } + return strings.TrimSpace(out) != "" +} + +func markStartedWorktreeLiveness(issues []state.Issue) { + for i := range issues { + path := strings.TrimSpace(issues[i].StartedWorktree) + if path == "" { + continue + } + if _, err := os.Stat(path); os.IsNotExist(err) { + issues[i].WorktreeMissing = true + } + } +} + +func writeIssueStartedList(out io.Writer, result state.IssueListResult) { + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + if len(result.Issues) == 0 { + fmt.Fprintln(out, "no started issues") + return + } + for _, issue := range result.Issues { + line := fmt.Sprintf("%s\t%s\t%s\t%s", issueDisplayRef(issue), issue.Title, issue.StartedBranch, issue.StartedWorktree) + if issue.WorktreeMissing { + line += "\t(missing)" + } + fmt.Fprintln(out, line) + } +} diff --git a/internal/cli/issue_worktree_test.go b/internal/cli/issue_worktree_test.go new file mode 100644 index 000000000..5c0afe8de --- /dev/null +++ b/internal/cli/issue_worktree_test.go @@ -0,0 +1,664 @@ +package cli + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/levifig/loaf/internal/project" + "github.com/levifig/loaf/internal/state" +) + +func issueGitFixture(t *testing.T) (string, string) { + t.Helper() + parent := realpath(t, t.TempDir()) + repo := filepath.Join(parent, "repo") + if err := os.Mkdir(repo, 0o755); err != nil { + t.Fatalf("Mkdir(repo) error = %v", err) + } + gitCLI(t, repo, "init", "-b", "main") + if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("# fixture\n"), 0o644); err != nil { + t.Fatalf("WriteFile(README) error = %v", err) + } + gitCLI(t, repo, "add", "README.md") + gitCLI(t, repo, "-c", "user.name=Loaf Test", "-c", "user.email=loaf@example.test", "-c", "commit.gpgsign=false", "commit", "-m", "initial") + stateHome := t.TempDir() + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo, StateHome: stateHome}).Run([]string{"state", "init"}); err != nil { + t.Fatalf("state init error = %v", err) + } + return repo, stateHome +} + +func decodeIssueStart(t *testing.T, data string) issueStartResult { + t.Helper() + var result issueStartResult + if err := json.Unmarshal([]byte(data), &result); err != nil { + t.Fatalf("json.Unmarshal(start %q) error = %v", data, err) + } + return result +} + +func decodeIssueStop(t *testing.T, data string) issueStopResult { + t.Helper() + var result issueStopResult + if err := json.Unmarshal([]byte(data), &result); err != nil { + t.Fatalf("json.Unmarshal(stop %q) error = %v", data, err) + } + return result +} + +func gitOutputCLI(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, out) + } + return strings.TrimSpace(string(out)) +} + +func TestRunnerIssueStartChildBranchesOffStartedAncestor(t *testing.T) { + repo, stateHome := issueGitFixture(t) + if _, err := runIssue(t, repo, stateHome, "new", "Parent"); err != nil { + t.Fatalf("issue new parent error = %v", err) + } + if _, err := runIssue(t, repo, stateHome, "new", "Child A", "--parent", "LOAF-1"); err != nil { + t.Fatalf("issue new child A error = %v", err) + } + if _, err := runIssue(t, repo, stateHome, "new", "Child B", "--parent", "LOAF-1"); err != nil { + t.Fatalf("issue new child B error = %v", err) + } + + parentOut, err := runIssue(t, repo, stateHome, "start", "LOAF-1", "--json") + if err != nil { + t.Fatalf("issue start parent error = %v", err) + } + parent := decodeIssueStart(t, parentOut) + if parent.Branch != "issue/loaf-1" || parent.Base != "main" { + t.Fatalf("parent start = branch %q base %q, want issue/loaf-1 from main", parent.Branch, parent.Base) + } + if _, err := os.Stat(parent.Worktree); err != nil { + t.Fatalf("parent worktree %s: %v", parent.Worktree, err) + } + + if err := os.WriteFile(filepath.Join(parent.Worktree, "from-parent.txt"), []byte("ancestor work\n"), 0o644); err != nil { + t.Fatalf("WriteFile(from-parent) error = %v", err) + } + gitCLI(t, parent.Worktree, "add", "from-parent.txt") + gitCLI(t, parent.Worktree, "-c", "user.name=Loaf Test", "-c", "user.email=loaf@example.test", "-c", "commit.gpgsign=false", "commit", "-m", "parent work") + parentHead := gitOutputCLI(t, repo, "rev-parse", "issue/loaf-1") + mainHead := gitOutputCLI(t, repo, "rev-parse", "main") + if parentHead == mainHead { + t.Fatal("parent branch still at main after commit") + } + + childAOut, err := runIssue(t, repo, stateHome, "start", "LOAF-2", "--json") + if err != nil { + t.Fatalf("issue start child A error = %v", err) + } + childA := decodeIssueStart(t, childAOut) + if childA.Base != "issue/loaf-1" { + t.Fatalf("child A base = %q, want issue/loaf-1 (started ancestor), not default branch", childA.Base) + } + if childA.Branch != "issue/loaf-2" { + t.Fatalf("child A branch = %q, want issue/loaf-2", childA.Branch) + } + childAHead := gitOutputCLI(t, repo, "rev-parse", "issue/loaf-2") + if childAHead != parentHead { + t.Fatalf("child A HEAD = %s, want parent HEAD %s", childAHead, parentHead) + } + if _, err := os.Stat(filepath.Join(childA.Worktree, "from-parent.txt")); err != nil { + t.Fatalf("child A worktree missing ancestor commit file: %v", err) + } + + childBOut, err := runIssue(t, repo, stateHome, "start", "LOAF-3", "--json") + if err != nil { + t.Fatalf("issue start child B error = %v", err) + } + childB := decodeIssueStart(t, childBOut) + if childB.Base != "issue/loaf-1" { + t.Fatalf("child B base = %q, want issue/loaf-1", childB.Base) + } + if childA.Worktree == childB.Worktree { + t.Fatalf("siblings share worktree %s", childA.Worktree) + } + if _, err := os.Stat(childB.Worktree); err != nil { + t.Fatalf("child B worktree %s: %v", childB.Worktree, err) + } + if gitOutputCLI(t, repo, "rev-parse", "issue/loaf-3") != parentHead { + t.Fatalf("child B HEAD = %s, want parent HEAD %s", gitOutputCLI(t, repo, "rev-parse", "issue/loaf-3"), parentHead) + } +} + +func TestRunnerIssueListStartedShowsLiveAndStale(t *testing.T) { + repo, stateHome := issueGitFixture(t) + if _, err := runIssue(t, repo, stateHome, "new", "Live"); err != nil { + t.Fatalf("issue new live error = %v", err) + } + if _, err := runIssue(t, repo, stateHome, "new", "Stale"); err != nil { + t.Fatalf("issue new stale error = %v", err) + } + if _, err := runIssue(t, repo, stateHome, "new", "Idle"); err != nil { + t.Fatalf("issue new idle error = %v", err) + } + + liveOut, err := runIssue(t, repo, stateHome, "start", "LOAF-1", "--json") + if err != nil { + t.Fatalf("issue start live error = %v", err) + } + live := decodeIssueStart(t, liveOut) + staleOut, err := runIssue(t, repo, stateHome, "start", "LOAF-2", "--json") + if err != nil { + t.Fatalf("issue start stale error = %v", err) + } + stale := decodeIssueStart(t, staleOut) + if err := os.RemoveAll(stale.Worktree); err != nil { + t.Fatalf("RemoveAll(stale worktree) error = %v", err) + } + + out, err := runIssue(t, repo, stateHome, "list", "--started") + if err != nil { + t.Fatalf("issue list --started error = %v", err) + } + if !strings.Contains(out, "LOAF-1") || !strings.Contains(out, live.Branch) || !strings.Contains(out, live.Worktree) { + t.Fatalf("started list missing live row:\n%s", out) + } + if !strings.Contains(out, "LOAF-2") || !strings.Contains(out, stale.Branch) || !strings.Contains(out, stale.Worktree) || !strings.Contains(out, "(missing)") { + t.Fatalf("started list missing stale marker:\n%s", out) + } + if strings.Contains(out, "LOAF-3") { + t.Fatalf("started list included idle issue:\n%s", out) + } + + jsonOut, err := runIssue(t, repo, stateHome, "list", "--started", "--json") + if err != nil { + t.Fatalf("issue list --started --json error = %v", err) + } + var listed state.IssueListResult + if err := json.Unmarshal([]byte(jsonOut), &listed); err != nil { + t.Fatalf("json.Unmarshal(list) error = %v", err) + } + if len(listed.Issues) != 2 { + t.Fatalf("started json issues = %#v, want 2", listed.Issues) + } + byAlias := map[string]state.Issue{} + for _, issue := range listed.Issues { + byAlias[issue.Alias] = issue + } + if byAlias["LOAF-1"].WorktreeMissing || byAlias["LOAF-1"].StartedBranch != "issue/loaf-1" { + t.Fatalf("live json row = %#v", byAlias["LOAF-1"]) + } + if !byAlias["LOAF-2"].WorktreeMissing { + t.Fatalf("stale json row = %#v, want worktree_missing", byAlias["LOAF-2"]) + } +} + +func TestRunnerIssueStartRefusesAlreadyStarted(t *testing.T) { + repo, stateHome := issueGitFixture(t) + if _, err := runIssue(t, repo, stateHome, "new", "Once"); err != nil { + t.Fatalf("issue new error = %v", err) + } + if _, err := runIssue(t, repo, stateHome, "start", "LOAF-1"); err != nil { + t.Fatalf("issue start error = %v", err) + } + _, err := runIssue(t, repo, stateHome, "start", "LOAF-1") + if err == nil || !strings.Contains(err.Error(), "already started") { + t.Fatalf("second start error = %v, want already started", err) + } +} + +func TestRunnerIssueStopRemovesWorktreeKeepsBranch(t *testing.T) { + repo, stateHome := issueGitFixture(t) + if _, err := runIssue(t, repo, stateHome, "new", "Workspace"); err != nil { + t.Fatalf("issue new error = %v", err) + } + startedOut, err := runIssue(t, repo, stateHome, "start", "LOAF-1", "--json") + if err != nil { + t.Fatalf("issue start error = %v", err) + } + started := decodeIssueStart(t, startedOut) + if started.Issue.Status != state.IssueStatusActive { + t.Fatalf("status after start = %q, want active", started.Issue.Status) + } + + stopOut, err := runIssue(t, repo, stateHome, "stop", "LOAF-1", "--json") + if err != nil { + t.Fatalf("issue stop error = %v", err) + } + stopped := decodeIssueStop(t, stopOut) + if stopped.AlreadyGone { + t.Fatal("stop already_gone = true, want removed") + } + if stopped.Issue.StartedBranch != "" || stopped.Issue.StartedWorktree != "" { + t.Fatalf("stopped issue still started: %#v", stopped.Issue) + } + if stopped.Issue.Status != state.IssueStatusActive { + t.Fatalf("status after stop = %q, want active (stop does not change status)", stopped.Issue.Status) + } + if _, err := os.Stat(started.Worktree); !os.IsNotExist(err) { + t.Fatalf("worktree %s still exists after stop: %v", started.Worktree, err) + } + if gitOutputCLI(t, repo, "rev-parse", "--verify", "issue/loaf-1") == "" { + t.Fatal("branch issue/loaf-1 was deleted; stop must keep it") + } +} + +func TestRunnerIssueStopRefusesDirtyWithoutForce(t *testing.T) { + repo, stateHome := issueGitFixture(t) + if _, err := runIssue(t, repo, stateHome, "new", "Dirty"); err != nil { + t.Fatalf("issue new error = %v", err) + } + startedOut, err := runIssue(t, repo, stateHome, "start", "LOAF-1", "--json") + if err != nil { + t.Fatalf("issue start error = %v", err) + } + started := decodeIssueStart(t, startedOut) + if err := os.WriteFile(filepath.Join(started.Worktree, "dirty.txt"), []byte("unstaged\n"), 0o644); err != nil { + t.Fatalf("WriteFile(dirty) error = %v", err) + } + + _, err = runIssue(t, repo, stateHome, "stop", "LOAF-1") + if err == nil || !strings.Contains(err.Error(), "dirty") || !strings.Contains(err.Error(), "--force") { + t.Fatalf("stop dirty error = %v, want dirty refusal", err) + } + if _, statErr := os.Stat(started.Worktree); statErr != nil { + t.Fatalf("dirty worktree was removed without --force: %v", statErr) + } + + if _, err := runIssue(t, repo, stateHome, "stop", "LOAF-1", "--force"); err != nil { + t.Fatalf("stop --force error = %v", err) + } + if _, err := os.Stat(started.Worktree); !os.IsNotExist(err) { + t.Fatalf("worktree %s still exists after --force: %v", started.Worktree, err) + } +} + +func TestRunnerIssueStopRestoresStartedFieldsIfWorktreeRemovalFails(t *testing.T) { + repo, stateHome := issueGitFixture(t) + if _, err := runIssue(t, repo, stateHome, "new", "Restore"); err != nil { + t.Fatalf("issue new error = %v", err) + } + startedOut, err := runIssue(t, repo, stateHome, "start", "LOAF-1", "--json") + if err != nil { + t.Fatalf("issue start error = %v", err) + } + started := decodeIssueStart(t, startedOut) + + orig := removeIssueWorktreeFn + removeIssueWorktreeFn = func(repoRoot, worktree string, force bool) (bool, error) { + return false, errors.New("injected worktree remove failure") + } + t.Cleanup(func() { removeIssueWorktreeFn = orig }) + + _, err = runIssue(t, repo, stateHome, "stop", "LOAF-1") + if err == nil || !strings.Contains(err.Error(), "injected worktree remove failure") { + t.Fatalf("stop error = %v, want injected failure", err) + } + if _, statErr := os.Stat(started.Worktree); statErr != nil { + t.Fatalf("worktree was removed after failed stop: %v", statErr) + } + root, err := project.ResolveRoot(repo) + if err != nil { + t.Fatalf("ResolveRoot() error = %v", err) + } + issue, err := state.GetIssue(context.Background(), root, state.PathResolver{StateHome: stateHome}, "LOAF-1") + if err != nil { + t.Fatalf("GetIssue() error = %v", err) + } + if issue.StartedBranch != started.Branch || issue.StartedWorktree != started.Worktree { + t.Fatalf("started fields after failed stop = %q / %q, want %q / %q", issue.StartedBranch, issue.StartedWorktree, started.Branch, started.Worktree) + } +} + +func TestRunnerIssueStopCleansMissingWorktree(t *testing.T) { + repo, stateHome := issueGitFixture(t) + if _, err := runIssue(t, repo, stateHome, "new", "Gone"); err != nil { + t.Fatalf("issue new error = %v", err) + } + startedOut, err := runIssue(t, repo, stateHome, "start", "LOAF-1", "--json") + if err != nil { + t.Fatalf("issue start error = %v", err) + } + started := decodeIssueStart(t, startedOut) + if err := os.RemoveAll(started.Worktree); err != nil { + t.Fatalf("RemoveAll(worktree) error = %v", err) + } + + stopOut, err := runIssue(t, repo, stateHome, "stop", "LOAF-1", "--json") + if err != nil { + t.Fatalf("issue stop missing error = %v", err) + } + stopped := decodeIssueStop(t, stopOut) + if !stopped.AlreadyGone { + t.Fatalf("already_gone = false, want true for missing worktree") + } + if stopped.Issue.StartedBranch != "" || stopped.Issue.StartedWorktree != "" { + t.Fatalf("missing stop left started fields: %#v", stopped.Issue) + } +} + +func TestRunnerIssueStartRecordsThroughEventsPath(t *testing.T) { + repo, stateHome := issueGitFixture(t) + if _, err := runIssue(t, repo, stateHome, "new", "Evented"); err != nil { + t.Fatalf("issue new error = %v", err) + } + if _, err := runIssue(t, repo, stateHome, "start", "LOAF-1"); err != nil { + t.Fatalf("issue start error = %v", err) + } + + root, err := project.ResolveRoot(repo) + if err != nil { + t.Fatalf("ResolveRoot() error = %v", err) + } + resolver := state.PathResolver{StateHome: stateHome} + issue, err := state.GetIssue(context.Background(), root, resolver, "LOAF-1") + if err != nil { + t.Fatalf("GetIssue() error = %v", err) + } + if issue.Status != state.IssueStatusActive { + t.Fatalf("status = %q, want active", issue.Status) + } + if issue.StartedBranch == "" || issue.StartedWorktree == "" { + t.Fatalf("started fields empty after start: %#v", issue) + } + + parity, err := state.CheckIssueStatusParity(context.Background(), root, resolver) + if err != nil { + t.Fatalf("CheckIssueStatusParity() error = %v", err) + } + if !parity.Consistent { + t.Fatalf("parity = %#v, want column == latest event after start", parity) + } +} + +func TestRunnerIssueStartRefusesTerminalAndArchived(t *testing.T) { + repo, stateHome := issueGitFixture(t) + if _, err := runIssue(t, repo, stateHome, "new", "Done"); err != nil { + t.Fatalf("issue new done error = %v", err) + } + if _, err := runIssue(t, repo, stateHome, "new", "Cancelled"); err != nil { + t.Fatalf("issue new cancelled error = %v", err) + } + if _, err := runIssue(t, repo, stateHome, "status", "LOAF-1", "done"); err != nil { + t.Fatalf("issue status done error = %v", err) + } + if _, err := runIssue(t, repo, stateHome, "status", "LOAF-2", "cancelled"); err != nil { + t.Fatalf("issue status cancelled error = %v", err) + } + + if _, err := runIssue(t, repo, stateHome, "start", "LOAF-1"); err == nil || !strings.Contains(err.Error(), "done") { + t.Fatalf("start done error = %v, want terminal refusal", err) + } + if _, err := runIssue(t, repo, stateHome, "start", "LOAF-2"); err == nil || !strings.Contains(err.Error(), "archived") && !strings.Contains(err.Error(), "cancelled") { + t.Fatalf("start cancelled error = %v, want archived/terminal refusal", err) + } +} + +func TestQualifyIssueStartBaseUsesOriginRefWhenLocalMissing(t *testing.T) { + repo, _ := issueGitFixture(t) + head := gitOutputCLI(t, repo, "rev-parse", "HEAD") + gitCLI(t, repo, "update-ref", "refs/remotes/origin/trunk", head) + gitCLI(t, repo, "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/trunk") + if gitRefExists(repo, "refs/heads/trunk") { + t.Fatal("fixture must not have a local trunk") + } + if got := resolveReleaseDefaultBranch(repo); got != "trunk" { + t.Fatalf("resolveReleaseDefaultBranch = %q, want trunk", got) + } + if got := qualifyIssueStartBase(repo, "trunk"); got != "origin/trunk" { + t.Fatalf("qualifyIssueStartBase = %q, want origin/trunk", got) + } +} + +func TestRunnerIssueStartUsesRemoteOnlyDefaultBranch(t *testing.T) { + repo, stateHome := issueGitFixture(t) + head := gitOutputCLI(t, repo, "rev-parse", "HEAD") + gitCLI(t, repo, "update-ref", "refs/remotes/origin/trunk", head) + gitCLI(t, repo, "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/trunk") + if gitRefExists(repo, "refs/heads/trunk") { + t.Fatal("fixture must not have a local trunk") + } + + if _, err := runIssue(t, repo, stateHome, "new", "Remote base"); err != nil { + t.Fatalf("issue new error = %v", err) + } + startedOut, err := runIssue(t, repo, stateHome, "start", "LOAF-1", "--json") + if err != nil { + t.Fatalf("issue start error = %v, want worktree from origin/trunk", err) + } + started := decodeIssueStart(t, startedOut) + if started.Base != "origin/trunk" { + t.Fatalf("base = %q, want origin/trunk", started.Base) + } + if started.Branch != "issue/loaf-1" { + t.Fatalf("branch = %q, want issue/loaf-1", started.Branch) + } + if _, err := os.Stat(started.Worktree); err != nil { + t.Fatalf("worktree %s: %v", started.Worktree, err) + } + if gitOutputCLI(t, repo, "rev-parse", "issue/loaf-1") != head { + t.Fatalf("new branch HEAD = %s, want origin/trunk %s", gitOutputCLI(t, repo, "rev-parse", "issue/loaf-1"), head) + } +} + +func TestRunnerIssueStopPrunesMissingWorktreeThenRestartSucceeds(t *testing.T) { + repo, stateHome := issueGitFixture(t) + if _, err := runIssue(t, repo, stateHome, "new", "Restart"); err != nil { + t.Fatalf("issue new error = %v", err) + } + startedOut, err := runIssue(t, repo, stateHome, "start", "LOAF-1", "--json") + if err != nil { + t.Fatalf("issue start error = %v", err) + } + started := decodeIssueStart(t, startedOut) + if err := os.RemoveAll(started.Worktree); err != nil { + t.Fatalf("RemoveAll(worktree) error = %v", err) + } + + if _, err := runIssue(t, repo, stateHome, "stop", "LOAF-1"); err != nil { + t.Fatalf("issue stop missing error = %v", err) + } + + restartOut, err := runIssue(t, repo, stateHome, "start", "LOAF-1", "--json") + if err != nil { + t.Fatalf("issue start after pruned stop error = %v", err) + } + restarted := decodeIssueStart(t, restartOut) + if restarted.Branch != started.Branch { + t.Fatalf("restart branch = %q, want %q", restarted.Branch, started.Branch) + } + if _, err := os.Stat(restarted.Worktree); err != nil { + t.Fatalf("restarted worktree %s: %v", restarted.Worktree, err) + } +} + +func TestRunnerIssueStartRestartAttachesToKeptBranch(t *testing.T) { + repo, stateHome := issueGitFixture(t) + if _, err := runIssue(t, repo, stateHome, "new", "Keep"); err != nil { + t.Fatalf("issue new error = %v", err) + } + startedOut, err := runIssue(t, repo, stateHome, "start", "LOAF-1", "--json") + if err != nil { + t.Fatalf("issue start error = %v", err) + } + started := decodeIssueStart(t, startedOut) + if err := os.WriteFile(filepath.Join(started.Worktree, "kept.txt"), []byte("kept\n"), 0o644); err != nil { + t.Fatalf("WriteFile(kept) error = %v", err) + } + gitCLI(t, started.Worktree, "add", "kept.txt") + gitCLI(t, started.Worktree, "-c", "user.name=Loaf Test", "-c", "user.email=loaf@example.test", "-c", "commit.gpgsign=false", "commit", "-m", "kept work") + head := gitOutputCLI(t, repo, "rev-parse", started.Branch) + + if _, err := runIssue(t, repo, stateHome, "stop", "LOAF-1"); err != nil { + t.Fatalf("issue stop error = %v", err) + } + restartOut, err := runIssue(t, repo, stateHome, "start", "LOAF-1", "--json") + if err != nil { + t.Fatalf("issue restart error = %v", err) + } + restarted := decodeIssueStart(t, restartOut) + if restarted.Branch != started.Branch { + t.Fatalf("restart branch = %q, want %q", restarted.Branch, started.Branch) + } + if gitOutputCLI(t, repo, "rev-parse", restarted.Branch) != head { + t.Fatalf("restart HEAD = %s, want kept %s", gitOutputCLI(t, repo, "rev-parse", restarted.Branch), head) + } +} + +func TestRunnerIssueStartDisambiguatesCaseCollidingAliases(t *testing.T) { + repo, stateHome := issueGitFixture(t) + firstOut, err := runIssue(t, repo, stateHome, "new", "Upper", "--json") + if err != nil { + t.Fatalf("issue new first error = %v", err) + } + secondOut, err := runIssue(t, repo, stateHome, "new", "Lower", "--json") + if err != nil { + t.Fatalf("issue new second error = %v", err) + } + first := decodeIssueResult(t, firstOut).Issue + second := decodeIssueResult(t, secondOut).Issue + rewriteIssueAlias(t, repo, stateHome, first.ID, "FOO") + rewriteIssueAlias(t, repo, stateHome, second.ID, "foo") + + startedOut, err := runIssue(t, repo, stateHome, "start", first.ID, "--json") + if err != nil { + t.Fatalf("issue start first error = %v", err) + } + started := decodeIssueStart(t, startedOut) + if started.Branch != "issue/foo" { + t.Fatalf("first branch = %q, want issue/foo", started.Branch) + } + if _, err := runIssue(t, repo, stateHome, "stop", first.ID); err != nil { + t.Fatalf("issue stop first error = %v", err) + } + + collidedOut, err := runIssue(t, repo, stateHome, "start", second.ID, "--json") + if err != nil { + t.Fatalf("issue start second error = %v, want disambiguated branch", err) + } + collided := decodeIssueStart(t, collidedOut) + want := "issue/foo-" + issueStartBranchSuffix(second.ID) + if collided.Branch != want { + t.Fatalf("second branch = %q, want %q (must not attach to first issue's branch)", collided.Branch, want) + } + if collided.Worktree == started.Worktree { + t.Fatalf("second attached to first worktree %s", started.Worktree) + } + if got := gitOutputCLI(t, collided.Worktree, "symbolic-ref", "--short", "HEAD"); got != want { + t.Fatalf("second worktree HEAD = %s, want %s", got, want) + } +} + +func TestResolveIssueStartBranchOwnership(t *testing.T) { + repo, _ := issueGitFixture(t) + first := state.Issue{ID: "issue_aaaabbbbccccdddd1111222233334444", Alias: "FOO"} + second := state.Issue{ID: "issue_eeeeffff000011112222333344445555", Alias: "foo"} + + got, err := resolveIssueStartBranch(first, []state.Issue{first, second}, repo) + if err != nil || got != "issue/foo" { + t.Fatalf("first start = %q err %v, want issue/foo", got, err) + } + + gitCLI(t, repo, "branch", "issue/foo") + got, err = resolveIssueStartBranch(first, []state.Issue{first}, repo) + if err != nil || got != "issue/foo" { + t.Fatalf("unique restart = %q err %v, want issue/foo", got, err) + } + + got, err = resolveIssueStartBranch(second, []state.Issue{first, second}, repo) + if err != nil { + t.Fatalf("case collision error = %v", err) + } + if got != "issue/foo-eeeeffff" { + t.Fatalf("case collision branch = %q, want issue/foo-eeeeffff", got) + } + + live := first + live.StartedBranch = "issue/foo" + got, err = resolveIssueStartBranch(second, []state.Issue{live, second}, repo) + if err != nil { + t.Fatalf("live claim error = %v", err) + } + if got != "issue/foo-eeeeffff" { + t.Fatalf("live claim branch = %q, want issue/foo-eeeeffff", got) + } + + claimed := live + claimed.StartedBranch = "issue/foo-eeeeffff" + _, err = resolveIssueStartBranch(second, []state.Issue{claimed, second}, repo) + if err == nil || !strings.Contains(err.Error(), "collides") || !strings.Contains(err.Error(), "FOO") || !strings.Contains(err.Error(), "foo") { + t.Fatalf("double claim error = %v, want both issues named", err) + } +} + +func TestRollbackIssueWorktreeRemovesAddedWorktree(t *testing.T) { + repo, _ := issueGitFixture(t) + worktree := filepath.Join(filepath.Dir(repo), "repo-wt", "issue-rollback") + if _, err := addIssueWorktree(repo, worktree, "issue/rollback", "main"); err != nil { + t.Fatalf("addIssueWorktree() error = %v", err) + } + if err := rollbackIssueWorktree(repo, worktree, "issue/rollback", true); err != nil { + t.Fatalf("rollbackIssueWorktree() error = %v", err) + } + if _, err := os.Stat(worktree); !os.IsNotExist(err) { + t.Fatalf("worktree %s still exists after rollback: %v", worktree, err) + } + if gitRefExists(repo, "refs/heads/issue/rollback") { + t.Fatal("branch issue/rollback still exists after rollback") + } +} + +func TestRollbackIssueWorktreeNamesLeftoversWhenCleanupFails(t *testing.T) { + dir := t.TempDir() + worktree := filepath.Join(dir, "missing-wt") + err := rollbackIssueWorktree(dir, worktree, "issue/foo", true) + if err == nil { + t.Fatal("rollbackIssueWorktree() error = nil, want leftover cleanup failure") + } + if !strings.Contains(err.Error(), worktree) || !strings.Contains(err.Error(), "issue/foo") { + t.Fatalf("error = %v, want leftover path and branch", err) + } +} + +func TestWrapIssueStartUpdateErrorIncludesCleanupFailure(t *testing.T) { + dbErr := errors.New("db write failed") + if got := wrapIssueStartUpdateError(dbErr, nil, "/tmp/wt", "issue/x"); got != dbErr { + t.Fatalf("nil cleanup wrap = %v, want db error", got) + } + cleanErr := errors.New("git worktree remove failed") + err := wrapIssueStartUpdateError(dbErr, cleanErr, "/tmp/repo-wt/issue-x", "issue/x") + if !errors.Is(err, dbErr) { + t.Fatalf("wrapped error = %v, want to unwrap to db error", err) + } + if !strings.Contains(err.Error(), "db write failed") || !strings.Contains(err.Error(), "git worktree remove failed") || !strings.Contains(err.Error(), "/tmp/repo-wt/issue-x") || !strings.Contains(err.Error(), "issue/x") { + t.Fatalf("error = %v, want both failures and leftover path/branch", err) + } +} + +func rewriteIssueAlias(t *testing.T, repo, stateHome, issueID, alias string) { + t.Helper() + root, err := project.ResolveRoot(repo) + if err != nil { + t.Fatalf("ResolveRoot() error = %v", err) + } + path, err := (state.PathResolver{StateHome: stateHome}).DatabasePath(root) + if err != nil { + t.Fatalf("DatabasePath() error = %v", err) + } + db, err := sql.Open("sqlite3", path) + if err != nil { + t.Fatalf("sql.Open() error = %v", err) + } + defer db.Close() + if _, err := db.Exec(`UPDATE aliases SET alias = ? WHERE entity_kind = 'issue' AND entity_id = ?`, alias, issueID); err != nil { + t.Fatalf("UPDATE aliases error = %v", err) + } +} diff --git a/internal/cli/journal.go b/internal/cli/journal.go index 84e4cccba..8447920ab 100644 --- a/internal/cli/journal.go +++ b/internal/cli/journal.go @@ -96,13 +96,12 @@ func writeJournalShowHelp(out io.Writer) { } func writeJournalDeferHelp(out io.Writer) { - writeUsageHelp(out, `loaf journal defer "<intent>" --why "..." --boundary "..." --trigger "..." --operation-id "..." [--change <slug|path>] [--json]`, "Capture one self-sufficient deferred intent as a reciprocal decision and open spark pair; stable operation IDs make first writes idempotent and reworded retries visible.", + writeUsageHelp(out, `loaf journal defer "<intent>" --why "..." --boundary "..." --trigger "..." --operation-id "..." [--json]`, "Capture one self-sufficient deferred intent as a reciprocal decision and open spark pair; stable operation IDs make first writes idempotent and reworded retries visible.", "<intent> One-line intent to revisit", "--why Why this intent was deferred", "--boundary What remains outside this packet", "--trigger What should cause revisit", "--operation-id Stable retry/idempotency key", - "--change Optional retained Change slug or canonical path for local evidence", "--json Output the state result as JSON") } @@ -650,7 +649,6 @@ type journalDeferOptions struct { boundary string trigger string operationID string - change string jsonOutput bool } @@ -667,7 +665,7 @@ func parseJournalDeferArgs(args []string) (journalDeferOptions, error) { case "--json": seen[arg] = true options.jsonOutput = true - case "--why", "--boundary", "--trigger", "--operation-id", "--change": + case "--why", "--boundary", "--trigger", "--operation-id": if seen[arg] { return journalDeferOptions{}, &state.JournalDeferValidationError{Field: strings.TrimPrefix(arg, "--"), Err: fmt.Errorf("flag may be specified only once")} } @@ -676,9 +674,6 @@ func parseJournalDeferArgs(args []string) (journalDeferOptions, error) { if err != nil { return journalDeferOptions{}, &state.JournalDeferValidationError{Field: strings.TrimPrefix(arg, "--"), Err: err} } - if arg == "--change" && strings.TrimSpace(value) == "" { - return journalDeferOptions{}, &state.JournalDeferValidationError{Field: "change", Err: fmt.Errorf("must be nonblank")} - } switch arg { case "--why": options.why = value @@ -688,8 +683,6 @@ func parseJournalDeferArgs(args []string) (journalDeferOptions, error) { options.trigger = value case "--operation-id": options.operationID = value - case "--change": - options.change = value } default: if strings.HasPrefix(arg, "-") { @@ -730,15 +723,6 @@ func (r Runner) runJournalDefer(args []string, out io.Writer, runtime state.Runt return err } origin := ResolveManualJournalOrigin(runtime.RootPath(), "journal.defer") - if options.change != "" { - origin, err = ResolveChangeOrigin(runtime.RootPath(), options.change) - if err != nil { - if options.jsonOutput { - return writeJSONCommandError(out, "journal defer", err) - } - return err - } - } result, err := state.DeferJournal(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, state.JournalDeferOptions{ Intent: options.intent, Why: options.why, diff --git a/internal/cli/journal_origin.go b/internal/cli/journal_origin.go new file mode 100644 index 000000000..4cdc2d2f0 --- /dev/null +++ b/internal/cli/journal_origin.go @@ -0,0 +1,53 @@ +package cli + +import ( + "os/exec" + "path/filepath" + "strings" + + "github.com/levifig/loaf/internal/state" +) + +// ResolveManualJournalOrigin captures the local Git context that is available +// for a manual journal write. Git is contextual rather than required here: +// journal logging remains useful outside a repository and in repositories +// without a commit, so unavailable fields stay empty instead of being guessed. +func ResolveManualJournalOrigin(rootPath, sourceEvent string) state.JournalOriginInput { + origin := state.JournalOriginInput{ + EnvelopeVersion: state.JournalOriginEnvelopeVersion, + CaptureMechanism: state.JournalOriginMechanismManual, + SourceEvent: sourceEvent, + } + if strings.TrimSpace(rootPath) == "" { + rootPath = "." + } + worktreeBytes, err := originGitOutputBytes(rootPath, "rev-parse", "--show-toplevel") + if err != nil { + return origin + } + worktree := strings.TrimSpace(string(worktreeBytes)) + if worktree == "" { + return origin + } + if absolute, absErr := filepath.Abs(worktree); absErr == nil { + worktree = absolute + } + if evaluated, evalErr := filepath.EvalSymlinks(worktree); evalErr == nil { + worktree = evaluated + } + origin.Worktree = worktree + + if headBytes, headErr := originGitOutputBytes(worktree, "rev-parse", "--verify", "HEAD"); headErr == nil { + origin.Head = strings.TrimSpace(string(headBytes)) + } + if branchBytes, branchErr := originGitOutputBytes(worktree, "symbolic-ref", "--quiet", "--short", "HEAD"); branchErr == nil { + origin.Branch = strings.TrimSpace(string(branchBytes)) + } + return origin +} + +func originGitOutputBytes(cwd string, args ...string) ([]byte, error) { + cmd := exec.Command("git", args...) + cmd.Dir = cwd + return cmd.Output() +} diff --git a/internal/cli/journal_origin_test.go b/internal/cli/journal_origin_test.go new file mode 100644 index 000000000..44a861f33 --- /dev/null +++ b/internal/cli/journal_origin_test.go @@ -0,0 +1,60 @@ +package cli + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func committedOriginFixture(t *testing.T, slug, date string) (string, string, []byte) { + t.Helper() + repo := t.TempDir() + if err := originGitCLI(repo, "init", "-b", "main"); err != nil { + t.Fatal(err) + } + if err := originGitCLI(repo, "config", "user.name", "Loaf Test"); err != nil { + t.Fatal(err) + } + if err := originGitCLI(repo, "config", "user.email", "loaf@example.test"); err != nil { + t.Fatal(err) + } + changeFile := filepath.Join(repo, "docs", "changes", date+"-"+slug, "change.md") + content := []byte("---\nslug: " + slug + "\n---\ncommitted bytes\n") + if err := os.MkdirAll(filepath.Dir(changeFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(changeFile, content, 0o644); err != nil { + t.Fatal(err) + } + if err := originGitCLI(repo, "add", "."); err != nil { + t.Fatal(err) + } + if err := originGitCLI(repo, "-c", "commit.gpgsign=false", "commit", "-m", "initial"); err != nil { + t.Fatal(err) + } + return repo, changeFile, content +} + +func originGitCLI(dir string, args ...string) error { + cmd := exec.Command("git", args...) + cmd.Dir = dir + output, err := cmd.CombinedOutput() + if err != nil { + return errors.New(strings.TrimSpace(string(output))) + } + return nil +} + +func mustOriginGitOutput(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + output, err := cmd.Output() + if err != nil { + t.Fatalf("git %v: %v", args, err) + } + return string(output) +} diff --git a/internal/cli/journal_test.go b/internal/cli/journal_test.go index 62b72ef6d..56dfebc4d 100644 --- a/internal/cli/journal_test.go +++ b/internal/cli/journal_test.go @@ -455,81 +455,6 @@ func TestJournalDeferPublicCLIConvergesAcrossIndependentProcesses(t *testing.T) } } -func TestJournalDeferMissingChangeFailsBeforeWritingPair(t *testing.T) { - repo, changeFile, _ := committedOriginFixture(t, "missing-change", "20260711") - writeCLIAgentsFile(t, repo, "specs/SPEC-001-active.md", "---\nid: SPEC-001\ntitle: Active Spec\nstatus: implementing\n---\n# Active Spec\n") - databasePath := filepath.Join(t.TempDir(), "loaf.sqlite") - stateHome := t.TempDir() - t.Setenv("LOAF_DB", databasePath) - var output bytes.Buffer - if err := (Runner{Stdout: &output, WorkingDir: repo, StateHome: stateHome}).Run([]string{"state", "migrate", "markdown", "--apply"}); err != nil { - t.Fatalf("state migrate error = %v\n%s", err, output.String()) - } - if err := os.Remove(changeFile); err != nil { - t.Fatal(err) - } - output.Reset() - err := (Runner{Stdout: &output, WorkingDir: repo, StateHome: stateHome}).Run([]string{"journal", "defer", "missing source", "--why", "why", "--boundary", "boundary", "--trigger", "trigger", "--operation-id", "missing-change", "--change", "missing-change", "--json"}) - if err == nil || !strings.Contains(output.String(), "change-not-found") { - t.Fatalf("missing Change defer error = %v output=%q, want typed change-not-found", err, output.String()) - } - db := openCLITestDB(t, databasePath) - defer closeCLITestDB(t, db) - for table, want := range map[string]int{"journal_deferrals": 0, "sparks": 0, "journal_origins": 0} { - if got := sqliteCount(t, db, "SELECT COUNT(*) FROM "+table); got != want { - t.Fatalf("%s rows after missing Change = %d, want %d", table, got, want) - } - } -} - -func TestJournalDeferDirtyChangePersistsSelfSufficientPacketAfterWorktreeRemoval(t *testing.T) { - repo, changeFile, _ := committedOriginFixture(t, "durable-dirty-change", "20260711") - writeCLIAgentsFile(t, repo, "specs/SPEC-001-active.md", "---\nid: SPEC-001\ntitle: Active Spec\nstatus: implementing\n---\n# Active Spec\n") - databasePath := filepath.Join(t.TempDir(), "loaf.sqlite") - stateHome := t.TempDir() - t.Setenv("LOAF_DB", databasePath) - if err := os.WriteFile(changeFile, []byte("---\nslug: durable-dirty-change\n---\ndirty working Change\n"), 0o644); err != nil { - t.Fatal(err) - } - var output bytes.Buffer - if err := (Runner{Stdout: &output, WorkingDir: repo, StateHome: stateHome}).Run([]string{"state", "migrate", "markdown", "--apply"}); err != nil { - t.Fatalf("state migrate error = %v\n%s", err, output.String()) - } - output.Reset() - if err := (Runner{Stdout: &output, WorkingDir: repo, StateHome: stateHome}).Run([]string{"journal", "defer", "durable intent", "--why", "durable reason", "--boundary", "durable boundary", "--trigger", "durable trigger", "--operation-id", "durable-dirty", "--change", "durable-dirty-change", "--json"}); err != nil { - t.Fatalf("journal defer dirty Change error = %v\n%s", err, output.String()) - } - var deferred state.JournalDeferResult - if err := json.Unmarshal(output.Bytes(), &deferred); err != nil { - t.Fatalf("decode deferred dirty Change = %v\n%s", err, output.String()) - } - if deferred.Origin == nil || deferred.Origin.ChangePath == "" || deferred.Origin.Dirty == nil || !*deferred.Origin.Dirty || deferred.Origin.Reconstructable == nil || *deferred.Origin.Reconstructable { - t.Fatalf("dirty Change origin = %#v, want dirty=true/reconstructable=false", deferred.Origin) - } - if err := os.RemoveAll(repo); err != nil { - t.Fatal(err) - } - db := openCLITestDB(t, databasePath) - defer closeCLITestDB(t, db) - var decisionMessage, sparkText, changePath, changeSHA string - var dirty, reconstructable int - if err := db.QueryRow(` -SELECT j.message, s.text, o.change_path, o.change_sha256, o.dirty, o.reconstructable -FROM journal_deferrals AS d -JOIN journal_entries AS j ON j.id = d.journal_entry_id -JOIN sparks AS s ON s.id = d.spark_id -JOIN journal_origins AS o ON o.journal_entry_id = d.journal_entry_id -WHERE d.operation_key = ?`, "durable-dirty").Scan(&decisionMessage, &sparkText, &changePath, &changeSHA, &dirty, &reconstructable); err != nil { - t.Fatalf("read persisted dirty Change packet after worktree removal: %v", err) - } - if !strings.Contains(decisionMessage, "Intent: durable intent") || !strings.Contains(decisionMessage, "Why: durable reason") || !strings.Contains(decisionMessage, "Boundary: durable boundary") || !strings.Contains(decisionMessage, "Trigger: durable trigger") || !strings.Contains(sparkText, "Intent: durable intent") { - t.Fatalf("persisted packet lost self-sufficient fields: decision=%q spark=%q", decisionMessage, sparkText) - } - if changePath != deferred.Origin.ChangePath || changeSHA != deferred.Origin.ChangeSHA256 || dirty != 1 || reconstructable != 0 { - t.Fatalf("persisted origin = path %q sha %q dirty %d reconstructable %d, want resolver pointer", changePath, changeSHA, dirty, reconstructable) - } -} - func buildCLIBinaryForTest(t *testing.T) string { t.Helper() root := t.TempDir() diff --git a/internal/cli/readiness_publisher.go b/internal/cli/readiness_publisher.go new file mode 100644 index 000000000..a45f47a2b --- /dev/null +++ b/internal/cli/readiness_publisher.go @@ -0,0 +1,60 @@ +package cli + +import ( + "context" + + "github.com/levifig/loaf/internal/project" + "github.com/levifig/loaf/internal/state" +) + +const ( + readinessLabelAgent = "ready-for-agent" + readinessLabelHuman = "ready-for-human" +) + +// ReadinessPublication is the tracker-facing readiness signal. +type ReadinessPublication struct { + IssueID string `json:"issue_id"` + IssueRef string `json:"issue_ref"` + Label string `json:"label"` + Reason string `json:"reason,omitempty"` + Authority string `json:"authority,omitempty"` + ProjectPath string `json:"project_path,omitempty"` + StateHome string `json:"-"` +} + +// ReadinessPublisher publishes derived readiness to a bound tracker. +// Linear-authority projects apply ready-for-agent / ready-for-human +// through the Linear adapter. Tests inject a fake. +type ReadinessPublisher interface { + Publish(ctx context.Context, publication ReadinessPublication) error +} + +type noopReadinessPublisher struct{} + +func (noopReadinessPublisher) Publish(context.Context, ReadinessPublication) error { + return nil +} + +type linearReadinessPublisher struct{} + +func (linearReadinessPublisher) Publish(ctx context.Context, publication ReadinessPublication) error { + if publication.Authority != state.IssueAuthorityLinear { + return nil + } + client, err := state.LinearClientFromEnv() + if err != nil { + return err + } + root, err := project.ResolveRoot(publication.ProjectPath) + if err != nil { + return err + } + return state.PublishLinearReadiness(ctx, root, state.PathResolver{StateHome: publication.StateHome}, client, publication.IssueID, publication.Label, publication.Reason) +} + +var defaultReadinessPublisher ReadinessPublisher = linearReadinessPublisher{} + +func trackerAuthority(authority string) bool { + return authority == state.IssueAuthorityLinear || authority == state.IssueAuthorityGitHub +} diff --git a/internal/cli/release.go b/internal/cli/release.go index 306d8617b..ceb7b7888 100644 --- a/internal/cli/release.go +++ b/internal/cli/release.go @@ -3,122 +3,51 @@ package cli import ( "fmt" "io" - "os" "strings" ) func (r Runner) runRelease(args []string, out io.Writer, runtimeRoot string) error { - options, err := parseReleaseArgs(args) - if err != nil { - return err - } - if options.help { + if len(args) == 0 || isHelpArg(args) { writeReleaseHelp(out) - return nil - } - // Print the flow advisory before candidate analysis and cohort preflight so - // a preflight-blocked mutating invocation still names the sanctioned door. - if releaseInvocationWantsFlowAdvisory(runtimeRoot, options) { - printReleaseFlowAdvisory(out) - } - // Apply-path resume classifies prepared dirt (verify-then-restore): admit - // candidate-matching version files, refuse dirty CHANGELOG, restore only - // generated outputs from HEAD. Dry-run and post-merge do not mutate. - if !options.dryRun && !options.postMerge { - if err := requireReleaseCleanWorktree(runtimeRoot, options); err != nil { - return err - } - } - snapshot, err := resolveReleaseSnapshot(runtimeRoot, options) - if err != nil { - return fmt.Errorf("release blocked: cannot compute candidate version: %w", err) - } - options.snapshot = snapshot - var gateWarnings []string - if err := releaseCohortPreflight(runtimeRoot, snapshot.Candidate, &gateWarnings); err != nil { - return err - } - warnOut := r.Stderr - if warnOut == nil { - warnOut = out - } - for _, warning := range gateWarnings { - fmt.Fprintf(warnOut, "warning: %s\n", warning) - } - if options.dryRun { - errOut := r.Stderr - if errOut == nil { - errOut = os.Stderr + if len(args) == 0 { + return fmt.Errorf("release requires a subcommand; use loaf release suggest or loaf release cut") } - return runReleaseDryRun(runtimeRoot, options, out, errOut) - } - if !options.postMerge { - errOut := r.Stderr - if errOut == nil { - errOut = os.Stderr - } - return runReleaseApply(runtimeRoot, options, firstReader(r.Stdin, os.Stdin), out, errOut) + return nil } - errOut := r.Stderr - if errOut == nil { - errOut = os.Stderr + switch args[0] { + case "suggest": + return r.runReleaseSuggest(args[1:], out, runtimeRoot) + case "cut": + return r.runReleaseCut(args[1:], out, runtimeRoot) + default: + writeReleaseHelp(out) + return fmt.Errorf("unknown release invocation %q; use loaf release suggest or loaf release cut", args[0]) } - return runReleasePostMerge(runtimeRoot, options.snapshot, out, errOut) } -func releaseAllowsPrereleaseLineageBypass(root string, options releaseOptions) bool { - // Retained for tests that assert the old predicate; the live gate uses - // resolveReleaseSnapshot + releaseCohortPreflight instead. - if options.postMerge { - if options.bump != "" { - return false - } - } else if options.bump != "prerelease" { - return false +func resolveReleaseDefaultBranch(root string) string { + if symRef := releaseCommandOutput(root, "git", "symbolic-ref", "refs/remotes/origin/HEAD"); strings.HasPrefix(symRef, "refs/remotes/origin/") { + return strings.TrimPrefix(symRef, "refs/remotes/origin/") } - configOverrides, err := releaseConfigVersionFiles(root) - if err != nil { - return false - } - versionOverrides := options.versionFile - if len(versionOverrides) == 0 { - versionOverrides = configOverrides - } - versionFiles, err := detectReleaseVersionFiles(root, versionOverrides) - if err != nil || len(versionFiles) == 0 { - return false - } - currentVersion := versionFiles[0].CurrentVersion - for _, file := range versionFiles { - if file.CurrentVersion != currentVersion { - return false - } - version, ok := parseReleaseSemver(file.CurrentVersion) - if !ok || version.prerelease == "" { - return false + for _, candidate := range []string{"main", "master"} { + if releaseCommandOK(root, "git", "show-ref", "--verify", "--quiet", "refs/heads/"+candidate) { + return candidate } } - return true + return "" } func writeReleaseHelp(out io.Writer) { fmt.Fprintln(out, strings.Join([]string{ - "Usage: loaf release [options]", + "Usage: loaf release <subcommand> [options]", + "", + "Cut a retroactive release from already-landed work. The only shipping path is suggest then cut.", "", - "Create a new release with changelog, version bump, and tag.", + "Subcommands:", + " suggest Report landed work since the last version tag", + " cut Record a release from landed work", "", - "Options:", - " --dry-run Preview release without making changes", - " --bump <type> Skip interactive bump choice; stable candidates gate their target_release cohort; prerelease candidates bypass; --bump release finalizes the stable target, --post-merge publishes the prepared version and gates only when it is stable", - " --base <ref> Use commits since <ref> instead of last tag", - " --no-tag Skip git tag creation", - " --tag Force git tag creation", - " --no-gh Skip GitHub release draft", - " --gh Force GitHub release draft", - " --version-file <path> Override version file path (repeatable)", - " --pre-merge Prepare release artifacts before squash-merge", - " --post-merge Finalize release after squash-merge", - " -y, --yes Skip confirmation prompt", - " -h, --help Show help", + "The legacy flag path (--bump, --pre-merge, --post-merge, --yes) has been removed.", + "Use loaf release suggest and loaf release cut.", }, "\n")) } diff --git a/internal/cli/release_dry_run.go b/internal/cli/release_dry_run.go index a3af7a7cc..c96725f65 100644 --- a/internal/cli/release_dry_run.go +++ b/internal/cli/release_dry_run.go @@ -1,51 +1,17 @@ package cli import ( - "bufio" "bytes" "encoding/json" "fmt" - "io" "os" "os/exec" "path/filepath" "regexp" - "sort" "strconv" "strings" - "time" ) -type releaseOptions struct { - dryRun bool - help bool - bump string - base string - tagSet bool - tag bool - ghSet bool - gh bool - yes bool - preMerge bool - postMerge bool - versionFile []string - // snapshot is set once by runRelease after the shared derivation; dry-run, - // apply, and post-merge consume it instead of re-deriving any field. - snapshot releaseSnapshot -} - -// releaseSnapshot is the immutable release plan resolved once per invocation: -// version-file paths and current version at resolve time, the effective bump, -// the candidate every consumer must honor, and the commit range that produced them. -type releaseSnapshot struct { - VersionFiles []releaseVersionFile - CurrentVersion string - Bump string - Candidate string - BaseRef string - Commits []releaseCommit -} - type releaseVersionFile struct { Path string RelativePath string @@ -62,16 +28,6 @@ type releaseCommit struct { Raw string } -type releaseArtifactCommand struct { - Label string - Cwd string -} - -type releaseIncompleteTask struct { - filename string - status string -} - type releaseVersionUpdate struct { path string relativePath string @@ -79,6 +35,11 @@ type releaseVersionUpdate struct { content string } +type releaseIncompleteTask struct { + filename string + status string +} + var releaseConventionalCommitRE = regexp.MustCompile(`^(\w+)(\(.+?\))?(!)?:\s*(.+)$`) var releaseBreakingBodyRE = regexp.MustCompile(`(?m)^BREAKING[ -]CHANGE:`) var releaseUnreleasedHeadingRE = regexp.MustCompile(`(?i)^## \[unreleased\]`) @@ -92,639 +53,6 @@ var releaseValidBumps = map[string]bool{ "release": true, } -func parseReleaseArgs(args []string) (releaseOptions, error) { - var options releaseOptions - for i := 0; i < len(args); i++ { - arg := args[i] - switch { - case arg == "--help" || arg == "-h" || arg == "help": - options.help = true - case arg == "--dry-run": - options.dryRun = true - case arg == "--bump": - value, err := consumeFlagValue(args, &i, "--bump") - if err != nil { - return releaseOptions{}, err - } - options.bump = value - case strings.HasPrefix(arg, "--bump="): - options.bump = strings.TrimPrefix(arg, "--bump=") - if options.bump == "" { - return releaseOptions{}, fmt.Errorf("--bump requires a value") - } - case arg == "--base": - value, err := consumeFlagValue(args, &i, "--base") - if err != nil { - return releaseOptions{}, err - } - options.base = value - case strings.HasPrefix(arg, "--base="): - options.base = strings.TrimPrefix(arg, "--base=") - if options.base == "" { - return releaseOptions{}, fmt.Errorf("--base requires a value") - } - case arg == "--no-tag": - options.tagSet = true - options.tag = false - case arg == "--tag": - options.tagSet = true - options.tag = true - case arg == "--no-gh": - options.ghSet = true - options.gh = false - case arg == "--gh": - options.ghSet = true - options.gh = true - case arg == "--version-file": - value, err := consumeFlagValue(args, &i, "--version-file") - if err != nil { - return releaseOptions{}, err - } - options.versionFile = append(options.versionFile, normalizeReleasePath(value)) - case strings.HasPrefix(arg, "--version-file="): - value := strings.TrimPrefix(arg, "--version-file=") - if value == "" { - return releaseOptions{}, fmt.Errorf("--version-file requires a value") - } - options.versionFile = append(options.versionFile, normalizeReleasePath(value)) - case arg == "--pre-merge": - options.preMerge = true - case arg == "--post-merge": - options.postMerge = true - case arg == "--yes" || arg == "-y": - options.yes = true - default: - return releaseOptions{}, fmt.Errorf("unknown release option %q", arg) - } - } - if options.bump != "" && !releaseValidBumps[options.bump] { - return releaseOptions{}, fmt.Errorf("Invalid bump type %q. Must be one of: major, minor, patch, prerelease, release", options.bump) - } - if options.postMerge { - var incompatible []string - if options.bump != "" { - incompatible = append(incompatible, "--bump") - } - if options.dryRun { - incompatible = append(incompatible, "--dry-run") - } - if options.tagSet { - if options.tag { - incompatible = append(incompatible, "--tag") - } else { - incompatible = append(incompatible, "--no-tag") - } - } - if options.ghSet { - if options.gh { - incompatible = append(incompatible, "--gh") - } else { - incompatible = append(incompatible, "--no-gh") - } - } - if options.base != "" { - incompatible = append(incompatible, "--base") - } - if len(options.versionFile) > 0 { - incompatible = append(incompatible, "--version-file") - } - if options.yes { - incompatible = append(incompatible, "--yes") - } - if options.preMerge { - incompatible = append(incompatible, "--pre-merge") - } - if len(incompatible) > 0 { - return releaseOptions{}, fmt.Errorf("--post-merge is incompatible with %s", strings.Join(incompatible, ", ")) - } - } - return options, nil -} - -func runReleaseDryRun(root string, options releaseOptions, out io.Writer, errOut io.Writer) error { - fmt.Fprintf(out, "\n%s\n\n", ansiBold("loaf release")) - if !releaseIsGitRepo(root) { - return fmt.Errorf("Not a git repository") - } - for _, declared := range options.versionFile { - if !pathExistsNative(filepath.Join(root, declared)) { - return fmt.Errorf("version file %s not found", declared) - } - } - if options.preMerge { - if options.tagSet && options.tag { - fmt.Fprintf(errOut, " %s --tag overrides --pre-merge default (no tag); proceeding with tag enabled\n", ansiYellow("warning:")) - } else { - options.tagSet = true - options.tag = false - } - if options.ghSet && options.gh { - fmt.Fprintf(errOut, " %s --gh overrides --pre-merge default (no gh release); proceeding with GitHub release enabled\n", ansiYellow("warning:")) - } else { - options.ghSet = true - options.gh = false - } - if options.base == "" { - base, source, err := detectReleaseBase(root) - if err != nil { - return err - } - options.base = base - fmt.Fprintf(out, " %s %s %s\n", ansiCyan("Auto-detected base:"), ansiBold(base), ansiGray("(via "+source+")")) - } - } - - fmt.Fprintf(out, " %s...\n\n", ansiCyan("Analyzing")) - baseRef := options.snapshot.BaseRef - commits := options.snapshot.Commits - if options.base != "" { - if baseRef == options.base { - fmt.Fprintf(out, " Base ref: %s (via --base flag)\n", ansiBold(options.base)) - } else { - fmt.Fprintf(out, " Base ref: %s (resolved to %s via --base flag)\n", ansiBold(options.base), ansiBold(baseRef)) - } - } else if baseRef != "" { - fmt.Fprintf(out, " Last tag: %s\n", ansiBold(baseRef)) - } else { - fmt.Fprintf(out, " Last tag: %s\n", ansiGray("(none)")) - } - if options.base != "" { - fmt.Fprintf(out, " Commits since base: %s\n\n", ansiBold(strconv.Itoa(len(commits)))) - } else { - fmt.Fprintf(out, " Commits since tag: %s\n\n", ansiBold(strconv.Itoa(len(commits)))) - } - if len(commits) == 0 { - fmt.Fprintf(out, " %s\n\n", ansiGray("No unreleased changes found.")) - return nil - } - for _, commit := range commits { - if commit.Section == "" { - fmt.Fprintf(out, " %s %s\n", ansiGray(fmt.Sprintf("%s (%s)", commit.Raw, commit.Hash)), ansiGray("[filtered]")) - } else { - fmt.Fprintf(out, " %s\n", ansiGreen(fmt.Sprintf("%s (%s)", commit.Raw, commit.Hash))) - } - } - if len(commits) > 0 { - fmt.Fprintln(out) - } - - versionFiles := options.snapshot.VersionFiles - if len(versionFiles) == 0 { - return fmt.Errorf("No version files found") - } - - currentVersion := options.snapshot.CurrentVersion - // Threaded from runRelease: the preview names the gated candidate, never a - // freshly re-derived one that could diverge after a commit lands mid-run. - newVersion := options.snapshot.Candidate - bump := options.snapshot.Bump - if newVersion == "" { - return fmt.Errorf("Could not compute new version from %q: candidate was not resolved before dry-run", currentVersion) - } - if options.bump != "" { - fmt.Fprintf(out, " Bump type: %s (via --bump flag)\n\n", ansiBold(bump)) - } - - changelog := releaseChangelogSection(root, newVersion, time.Now().UTC().Format("2006-01-02"), commits) - fmt.Fprintf(out, " %s\n\n", ansiBold("Generated changelog:")) - for _, line := range strings.Split(changelog, "\n") { - fmt.Fprintf(out, " %s\n", line) - } - fmt.Fprintln(out) - fmt.Fprintf(out, " %s\n\n", ansiGray("(Set $EDITOR to edit before confirming)")) - - fmt.Fprintf(out, " %s\n", ansiBold("Version files:")) - for _, file := range versionFiles { - fmt.Fprintf(out, " • %s (%s → %s)\n", file.RelativePath, file.CurrentVersion, newVersion) - } - fmt.Fprintln(out) - - incompleteTasks := scanReleaseIncompleteTasks(root) - if len(incompleteTasks) > 0 { - fmt.Fprintf(out, " %s %d\n", ansiBold("Incomplete tasks:"), len(incompleteTasks)) - for _, task := range incompleteTasks { - fmt.Fprintf(out, " %s %s (status: %s)\n", ansiYellow("⚠"), task.filename, task.status) - } - fmt.Fprintln(out) - } - - fmt.Fprintf(out, " Suggested bump: %s (%s)\n", ansiBold(bump), releaseBumpReason(bump)) - fmt.Fprintf(out, " New version: %s\n\n", ansiBold(newVersion)) - - skipTag, skipGh := normalizeReleaseSkipFlags(options) - tagName := "v" + newVersion - artifactCommands := releaseArtifactCommandsFor(root, versionFiles) - fmt.Fprintf(out, " %s\n", ansiBold("Actions:")) - actionNum := 1 - fmt.Fprintf(out, " %d. Update version in %d file(s)\n", actionNum, len(versionFiles)) - actionNum++ - fmt.Fprintf(out, " %d. Update CHANGELOG.md\n", actionNum) - actionNum++ - for _, command := range artifactCommands { - fmt.Fprintf(out, " %d. Run %s\n", actionNum, displayReleaseArtifactCommand(root, command)) - actionNum++ - } - fmt.Fprintf(out, " %d. Commit release artifacts\n", actionNum) - actionNum++ - if skipTag { - fmt.Fprintf(out, " %s\n", ansiGray(fmt.Sprintf("%d. Create git tag %s (--no-tag — skipped)", actionNum, tagName))) - } else { - fmt.Fprintf(out, " %d. Create git tag %s\n", actionNum, tagName) - } - actionNum++ - if skipGh { - fmt.Fprintf(out, " %s\n", ansiGray(fmt.Sprintf("%d. Create GitHub release draft (--no-gh — skipped)", actionNum))) - } else if releaseGhAvailable() { - fmt.Fprintf(out, " %d. Create GitHub release draft (gh available)\n", actionNum) - } else { - fmt.Fprintf(out, " %s\n", ansiGray(fmt.Sprintf("%d. Create GitHub release draft (gh not available — skipped)", actionNum))) - } - fmt.Fprintln(out) - fmt.Fprintf(out, " %s No changes made.\n\n", ansiCyan("--dry-run:")) - return nil -} - -func runReleaseApply(root string, options releaseOptions, in io.Reader, out io.Writer, errOut io.Writer) error { - fmt.Fprintf(out, "\n%s\n\n", ansiBold("loaf release")) - if !releaseIsGitRepo(root) { - return fmt.Errorf("Not a git repository") - } - // Flow advisory is emitted once from runRelease, before snapshot/preflight. - if err := requireReleaseCleanWorktree(root, options); err != nil { - return err - } - for _, declared := range options.versionFile { - if !pathExistsNative(filepath.Join(root, declared)) { - return fmt.Errorf("version file %s not found", declared) - } - } - if options.preMerge { - if options.tagSet && options.tag { - fmt.Fprintf(errOut, " %s --tag overrides --pre-merge default (no tag); proceeding with tag enabled\n", ansiYellow("warning:")) - } else { - options.tagSet = true - options.tag = false - } - if options.ghSet && options.gh { - fmt.Fprintf(errOut, " %s --gh overrides --pre-merge default (no gh release); proceeding with GitHub release enabled\n", ansiYellow("warning:")) - } else { - options.ghSet = true - options.gh = false - } - if options.base == "" { - base, source, err := detectReleaseBase(root) - if err != nil { - return err - } - options.base = base - fmt.Fprintf(out, " %s %s %s\n", ansiCyan("Auto-detected base:"), ansiBold(base), ansiGray("(via "+source+")")) - } - } - - fmt.Fprintf(out, " %s...\n\n", ansiCyan("Analyzing")) - baseRef := options.snapshot.BaseRef - commits := options.snapshot.Commits - if options.base != "" { - if baseRef == options.base { - fmt.Fprintf(out, " Base ref: %s (via --base flag)\n", ansiBold(options.base)) - } else { - fmt.Fprintf(out, " Base ref: %s (resolved to %s via --base flag)\n", ansiBold(options.base), ansiBold(baseRef)) - } - } else if baseRef != "" { - fmt.Fprintf(out, " Last tag: %s\n", ansiBold(baseRef)) - } else { - fmt.Fprintf(out, " Last tag: %s\n", ansiGray("(none)")) - } - if options.base != "" { - fmt.Fprintf(out, " Commits since base: %s\n\n", ansiBold(strconv.Itoa(len(commits)))) - } else { - fmt.Fprintf(out, " Commits since tag: %s\n\n", ansiBold(strconv.Itoa(len(commits)))) - } - if len(commits) == 0 { - fmt.Fprintf(out, " %s\n\n", ansiGray("No unreleased changes found.")) - return nil - } - for _, commit := range commits { - if commit.Section == "" { - fmt.Fprintf(out, " %s %s\n", ansiGray(fmt.Sprintf("%s (%s)", commit.Raw, commit.Hash)), ansiGray("[filtered]")) - } else { - fmt.Fprintf(out, " %s\n", ansiGreen(fmt.Sprintf("%s (%s)", commit.Raw, commit.Hash))) - } - } - fmt.Fprintln(out) - - versionFiles := options.snapshot.VersionFiles - if len(versionFiles) == 0 { - return fmt.Errorf("No version files found") - } - - currentVersion := options.snapshot.CurrentVersion - // Threaded from runRelease: the executor cuts the gated candidate, never a - // freshly re-derived one that could diverge after a commit lands mid-run. - newVersion := options.snapshot.Candidate - bump := options.snapshot.Bump - if newVersion == "" { - return fmt.Errorf("Could not compute new version from %q: candidate was not resolved before apply", currentVersion) - } - if options.bump != "" { - fmt.Fprintf(out, " Bump type: %s (via --bump flag)\n\n", ansiBold(bump)) - } - - changelog := releaseChangelogSection(root, newVersion, time.Now().UTC().Format("2006-01-02"), commits) - fmt.Fprintf(out, " %s\n\n", ansiBold("Generated changelog:")) - for _, line := range strings.Split(changelog, "\n") { - fmt.Fprintf(out, " %s\n", line) - } - fmt.Fprintln(out) - - fmt.Fprintf(out, " %s\n", ansiBold("Version files:")) - for _, file := range versionFiles { - fmt.Fprintf(out, " • %s (%s → %s)\n", file.RelativePath, file.CurrentVersion, newVersion) - } - fmt.Fprintln(out) - - skipTag, skipGh := normalizeReleaseSkipFlags(options) - tagName := "v" + newVersion - artifactCommands := releaseArtifactCommandsFor(root, versionFiles) - fmt.Fprintf(out, " Suggested bump: %s (%s)\n", ansiBold(bump), releaseBumpReason(bump)) - fmt.Fprintf(out, " New version: %s\n\n", ansiBold(newVersion)) - if !options.yes { - confirmed, err := confirmRelease(in, out, tagName) - if err != nil { - return err - } - if !confirmed { - fmt.Fprintf(out, "\n %s\n\n", ansiGray("Release cancelled.")) - return nil - } - fmt.Fprintln(out) - } - if err := requireReleaseCleanWorktree(root, options); err != nil { - return err - } - fmt.Fprintf(out, " %s\n", ansiBold("Executing:")) - - if err := assertReleaseSnapshotStillCurrent(root, options.snapshot); err != nil { - return err - } - - updates, err := prepareReleaseVersionUpdates(root, versionFiles, newVersion) - if err != nil { - return fmt.Errorf("Failed to update version files: %w", err) - } - for _, update := range updates { - if err := os.WriteFile(update.path, []byte(update.content), 0o644); err != nil { - return fmt.Errorf("Failed to update %s: %w", update.relativePath, err) - } - fmt.Fprintf(out, " %s Updated %s (%s → %s)\n", ansiGreen("✓"), update.relativePath, update.oldVersion, newVersion) - } - - if err := writeReleaseChangelog(root, changelog); err != nil { - return fmt.Errorf("Failed to update CHANGELOG.md: %w", err) - } - fmt.Fprintf(out, " %s Updated CHANGELOG.md\n", ansiGreen("✓")) - - for _, command := range artifactCommands { - if err := runReleaseArtifactCommand(root, command, out, errOut); err != nil { - return fmt.Errorf("Release artifact command failed: %w", err) - } - } - if paths := unignoredReleaseVirtualEnvStatusPaths(root); len(paths) > 0 { - return fmt.Errorf("Refusing to commit release artifacts: unignored virtual environment path detected: %s", strings.Join(paths, ", ")) - } - changePaths, err := releaseUnignoredStatusPaths(root, "docs/changes") - if err != nil { - return fmt.Errorf("Refusing to commit release artifacts: cannot inspect generated Change paths: %w", err) - } - // Evidence re-record dirt under docs/changes is intentional on resume; only - // non-evidence Change-path mutations refuse here. - if residual := releaseNonEvidenceChangePaths(root, changePaths); len(residual) != 0 { - return fmt.Errorf("Refusing to commit release artifacts: artifact generation modified docs/changes; reconcile and commit separately before release: %s", strings.Join(residual, ", ")) - } - evidencePresent, evidenceErr := checkReleaseCapabilityEvidence(root) - if evidenceErr != nil { - // Self-reset only what this run wrote that must not survive: the - // changelog insertion. Version files stay at the candidate for re-record - // hashing; generated outputs stay rebuilt. - if restoreErr := releaseRestoreChangelogFromHEAD(root); restoreErr != nil { - return fmt.Errorf("%w (also failed to restore CHANGELOG.md: %v)", releaseApplyCapabilityEvidenceRefusal(evidenceErr), restoreErr) - } - return releaseApplyCapabilityEvidenceRefusal(evidenceErr) - } - if evidencePresent { - fmt.Fprintf(out, " %s Capability evidence validated against the rebuilt tree\n", ansiGreen("✓")) - } - - if err := releaseCommandRun(root, "git", "add", "-A"); err != nil { - return fmt.Errorf("Failed to stage release artifacts: %w", err) - } - if err := releaseCommandRun(root, "git", "commit", "-m", "chore: release "+tagName); err != nil { - return fmt.Errorf("Failed to commit release: %w", err) - } - fmt.Fprintf(out, " %s Committed release artifacts\n", ansiGreen("✓")) - - if skipTag { - fmt.Fprintf(out, " %s Git tag skipped (--no-tag)\n", ansiGray("-")) - } else { - if err := releaseCommandRun(root, "git", "tag", "-s", tagName, "-m", "Release "+newVersion); err != nil { - return fmt.Errorf("Failed to create tag: %w", err) - } - fmt.Fprintf(out, " %s Created tag %s\n", ansiGreen("✓"), tagName) - } - - if skipGh { - fmt.Fprintf(out, " %s GitHub release skipped (--no-gh)\n", ansiGray("-")) - } else if releaseGhAvailable() { - if err := verifyConfiguredGitHubAccount(root, out); err != nil { - return fmt.Errorf("Refusing to create GitHub release with the wrong account: %w", err) - } - ghArgs := []string{"release", "create", tagName, "--draft", "--title", "v" + newVersion, "--notes", changelog} - if releaseVersionIsPrerelease(newVersion) { - ghArgs = append(ghArgs, "--prerelease") - } - if err := releaseCommandRun(root, "gh", ghArgs...); err != nil { - return fmt.Errorf("Failed to create GitHub release: %w", err) - } - fmt.Fprintf(out, " %s Created GitHub release draft\n", ansiGreen("✓")) - } else { - fmt.Fprintf(out, " %s GitHub release skipped (gh not available)\n", ansiGray("-")) - } - - fmt.Fprintln(out) - fmt.Fprintf(out, " %s Release %s complete\n\n", ansiGreen("✓"), ansiBold(tagName)) - return nil -} - -// releaseStatusEntry is one unignored porcelain path with whether it is -// untracked (??), a tracked deletion, or a typechange (T). Tracked dirt, -// deletions, typechanges, and untracked dirt are classified differently on the -// prepared-tree resume path. -type releaseStatusEntry struct { - path string - untracked bool - deleted bool - typechange bool -} - -func releaseUnignoredStatusPaths(root string, pathspec ...string) ([]string, error) { - entries, err := releaseUnignoredStatusEntries(root, pathspec...) - if err != nil { - return nil, err - } - paths := make([]string, 0, len(entries)) - seen := map[string]bool{} - for _, entry := range entries { - if seen[entry.path] { - continue - } - seen[entry.path] = true - paths = append(paths, entry.path) - } - sort.Strings(paths) - return paths, nil -} - -func releaseUnignoredStatusEntries(root string, pathspec ...string) ([]releaseStatusEntry, error) { - args := []string{"status", "--porcelain=v1", "--untracked-files=all", "-z"} - if len(pathspec) != 0 { - args = append(args, "--") - args = append(args, pathspec...) - } - cmd := exec.Command("git", args...) - cmd.Dir = root - output, err := cmd.CombinedOutput() - if err != nil { - return nil, fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(output))) - } - raw := strings.Split(string(output), "\x00") - var entries []releaseStatusEntry - seen := map[string]bool{} - add := func(path string, untracked, deleted, typechange bool) { - path = filepath.ToSlash(path) - if path == "" || seen[path] { - return - } - seen[path] = true - entries = append(entries, releaseStatusEntry{ - path: path, - untracked: untracked, - deleted: deleted, - typechange: typechange, - }) - } - for index := 0; index < len(raw); index++ { - entry := raw[index] - if entry == "" { - continue - } - if len(entry) < 4 || entry[2] != ' ' { - return nil, fmt.Errorf("parse git status entry %q", entry) - } - untracked := entry[0] == '?' && entry[1] == '?' - deleted := !untracked && (entry[0] == 'D' || entry[1] == 'D') - typechange := !untracked && (entry[0] == 'T' || entry[1] == 'T') - add(entry[3:], untracked, deleted, typechange) - if entry[0] == 'R' || entry[0] == 'C' || entry[1] == 'R' || entry[1] == 'C' { - index++ - if index >= len(raw) || raw[index] == "" { - return nil, fmt.Errorf("parse renamed git status entry %q", entry) - } - // Rename/copy retires the origin path — treat as a deletion for - // classification (prepare never renames). - add(raw[index], false, true, false) - } - } - return entries, nil -} - -// requireReleaseCleanWorktree enforces verify-then-restore resume admission: -// -// - deleted tracked files → classic refusal (prepare never deletes) -// - dirty CHANGELOG.md → classic refusal (operator curation is sacred) -// - dirty version files → admitted only when the path is a regular file whose -// mode matches HEAD and whose bytes equal the candidate rendering derived -// from HEAD content + candidate; porcelain typechange (T), symlinks, and -// mode flips refuse by name -// - dirty tracked generated outputs → restore from HEAD (build-owned) -// - registry / referenced evidence sources (HEAD ∪ worktree allowlist) → admit -// - untracked under generated roots → refuse by name -// - untracked / tracked paths outside the above → classic refusal -// -// Nothing outside generated outputs is git-checkout'd by classification alone. -func requireReleaseCleanWorktree(root string, options releaseOptions) error { - entries, err := releaseUnignoredStatusEntries(root) - if err != nil { - return fmt.Errorf("Refusing to prepare release: cannot inspect worktree cleanliness: %w", err) - } - if len(entries) == 0 { - return nil - } - dirtyPaths := make([]string, 0, len(entries)) - for _, entry := range entries { - dirtyPaths = append(dirtyPaths, entry.path) - } - - versionPaths := releaseVersionPathSet(root, options) - evidenceAllow := releaseCapabilityEvidenceAllowlist(root) - candidate := releaseResolveCandidateForClassification(root, options) - - var untrackedArtifacts []string - var refused []string - for _, entry := range entries { - path := filepath.ToSlash(entry.path) - - if entry.deleted { - return releaseClassicCleanWorktreeRefusal(dirtyPaths) - } - - if entry.untracked { - if evidenceAllow[path] { - continue - } - if releasePathMatchesPreparedArtifact(path) { - untrackedArtifacts = append(untrackedArtifacts, path) - continue - } - refused = append(refused, path) - continue - } - - // Tracked modifications: - if path == "CHANGELOG.md" { - // Never admit or restore dirty changelog via classification. - return releaseClassicCleanWorktreeRefusal(dirtyPaths) - } - if versionPaths[path] { - // Typechange (regular↔symlink/etc.) is never candidate-admissible. - if entry.typechange { - return releaseClassicCleanWorktreeRefusal(dirtyPaths) - } - if releaseVersionFileMatchesCandidate(root, path, candidate) { - continue - } - return releaseClassicCleanWorktreeRefusal(dirtyPaths) - } - if releasePathMatchesPreparedArtifact(path) { - // Restored below; admitted as build-owned dirt. - continue - } - if evidenceAllow[path] { - continue - } - refused = append(refused, path) - } - if len(untrackedArtifacts) > 0 { - return fmt.Errorf("Refusing to prepare release: untracked file under generated-output tree would be swept into the release commit: %s", strings.Join(untrackedArtifacts, ", ")) - } - if len(refused) > 0 { - return releaseClassicCleanWorktreeRefusal(dirtyPaths) - } - if err := releaseRestoreGeneratedFromHEAD(root, entries); err != nil { - return err - } - return nil -} - func releaseIsGitRepo(root string) bool { return releaseCommandOutput(root, "git", "rev-parse", "--is-inside-work-tree") != "" } @@ -750,39 +78,6 @@ func validateReleaseBaseRef(root string, ref string) (string, error) { return "", fmt.Errorf("Base ref %q does not exist or is not reachable. Tried %s. If this is a remote branch, run: git fetch origin %s", ref, strings.Join(quoted, " and "), ref) } -func releaseCommitsSince(root string, base string) []releaseCommit { - format := "%h%x00%s%x00%B%x00" - args := []string{"log", "--format=" + format} - if base != "" { - args = []string{"log", base + "..HEAD", "--format=" + format} - } - output := releaseCommandOutput(root, "git", args...) - if strings.TrimSpace(output) == "" { - return nil - } - var commits []releaseCommit - for _, chunk := range strings.Split(output, "\x00\n") { - if strings.TrimSpace(chunk) == "" { - continue - } - parts := strings.Split(chunk, "\x00") - if len(parts) < 2 { - continue - } - hash := strings.TrimSpace(parts[0]) - subject := strings.TrimSpace(parts[1]) - body := "" - if len(parts) > 2 { - body = strings.TrimSpace(parts[2]) - } - if hash == "" || subject == "" { - continue - } - commits = append(commits, parseReleaseCommit(hash, subject, body)) - } - return commits -} - func parseReleaseCommit(hash string, subject string, body string) releaseCommit { breakingFromBody := releaseBreakingBodyRE.MatchString(body) match := releaseConventionalCommitRE.FindStringSubmatch(subject) @@ -817,20 +112,6 @@ func releaseSectionForType(commitType string, breaking bool) string { } } -func suggestReleaseBump(commits []releaseCommit) string { - for _, commit := range commits { - if commit.Breaking { - return "major" - } - } - for _, commit := range commits { - if commit.Section == "Added" { - return "minor" - } - } - return "patch" -} - func detectReleaseVersionFiles(root string, overrides []string) ([]releaseVersionFile, error) { if len(overrides) > 0 { files := make([]releaseVersionFile, 0, len(overrides)) @@ -943,28 +224,6 @@ func readReleaseTomlVersion(content string, section string) string { return "" } -func releaseConfigVersionFiles(root string) ([]string, error) { - body, err := os.ReadFile(filepath.Join(root, ".agents", "loaf.json")) - if err != nil { - return nil, nil - } - var config struct { - Release struct { - VersionFiles []string `json:"versionFiles"` - } `json:"release"` - } - if err := json.Unmarshal(body, &config); err != nil { - return nil, nil - } - var values []string - for _, value := range config.Release.VersionFiles { - if normalized := normalizeReleasePath(value); normalized != "" { - values = append(values, normalized) - } - } - return values, nil -} - func bumpReleaseVersion(current string, bump string) string { version, ok := parseReleaseSemver(current) if !ok { @@ -1042,106 +301,6 @@ func parseReleaseSemver(value string) (releaseSemver, bool) { return releaseSemver{major: major, minor: minor, patch: patch, prerelease: prerelease}, true } -func releaseChangelogSection(root string, version string, date string, commits []releaseCommit) string { - body, err := readRegularFile(filepath.Join(root, "CHANGELOG.md"), projectFileReadLimit) - if err == nil { - if curated := extractReleaseUnreleasedBody(string(body)); curated != "" { - return fmt.Sprintf("## [%s] - %s\n\n%s", version, date, curated) - } - } - grouped := map[string][]releaseCommit{} - for _, commit := range commits { - if commit.Section == "" { - continue - } - grouped[commit.Section] = append(grouped[commit.Section], commit) - } - lines := []string{fmt.Sprintf("## [%s] - %s", version, date)} - for _, section := range []string{"Breaking Changes", "Added", "Changed", "Fixed", "Other"} { - commits := grouped[section] - if len(commits) == 0 { - continue - } - lines = append(lines, "", "### "+section) - for _, commit := range commits { - lines = append(lines, fmt.Sprintf("- %s (%s)", capitalizeReleaseMessage(commit.Message), commit.Hash)) - } - } - return strings.Join(lines, "\n") -} - -func extractReleaseUnreleasedBody(content string) string { - lines := strings.Split(content, "\n") - start := -1 - for i, line := range lines { - if releaseUnreleasedHeadingRE.MatchString(strings.TrimSpace(line)) { - start = i + 1 - break - } - } - if start == -1 { - return "" - } - end := len(lines) - for i := start; i < len(lines); i++ { - if strings.HasPrefix(strings.TrimSpace(lines[i]), "## [") { - end = i - break - } - } - var filtered []string - for _, line := range lines[start:end] { - if releaseUnreleasedStubRE.MatchString(line) { - continue - } - filtered = append(filtered, line) - } - for len(filtered) > 0 && strings.TrimSpace(filtered[0]) == "" { - filtered = filtered[1:] - } - for len(filtered) > 0 && strings.TrimSpace(filtered[len(filtered)-1]) == "" { - filtered = filtered[:len(filtered)-1] - } - for _, line := range filtered { - if strings.TrimSpace(line) != "" { - return strings.Join(filtered, "\n") - } - } - return "" -} - -func releaseArtifactCommandsFor(root string, versionFiles []releaseVersionFile) []releaseArtifactCommand { - var commands []releaseArtifactCommand - seen := map[string]bool{} - add := func(label string, cwd string) { - key := cwd + "\x00" + label - if seen[key] { - return - } - seen[key] = true - commands = append(commands, releaseArtifactCommand{Label: label, Cwd: cwd}) - } - for _, file := range versionFiles { - dir := filepath.Dir(file.Path) - if filepath.Base(file.RelativePath) == "pyproject.toml" && pathExistsNative(filepath.Join(dir, "uv.lock")) { - add("uv sync", dir) - } - } - for _, file := range versionFiles { - dir := filepath.Dir(file.Path) - if filepath.Base(file.RelativePath) == "package.json" && releasePackageHasBuildScript(file.Path) { - add("npm run build", dir) - } - } - if releasePackageHasBuildScript(filepath.Join(root, "package.json")) { - add("npm run build", root) - } - if len(commands) == 0 { - add("loaf build", root) - } - return commands -} - func scanReleaseIncompleteTasks(root string) []releaseIncompleteTask { tasksDir := filepath.Join(root, ".agents", "tasks") entries, err := os.ReadDir(tasksDir) @@ -1178,22 +337,34 @@ func scanReleaseIncompleteTasks(root string) []releaseIncompleteTask { return incomplete } -func confirmRelease(in io.Reader, out io.Writer, tagName string) (bool, error) { - if !readerIsTerminal(in) { - if in == nil { - return false, nil +func releaseGitShowPath(root, rev, relPath string) ([]byte, error) { + spec := rev + ":" + filepath.ToSlash(relPath) + cmd := exec.Command("git", "show", spec) + cmd.Dir = root + out, err := cmd.Output() + if err != nil { + return nil, err + } + return out, nil +} + +func releaseRenderVersionContent(relPath string, headBody []byte, currentVersion, format, candidate string) (string, error) { + switch format { + case "json": + re := regexp.MustCompile(`"version"(\s*:\s*)"` + regexp.QuoteMeta(currentVersion) + `"`) + if !re.Match(headBody) { + return "", fmt.Errorf("version file %s does not contain version %s", relPath, currentVersion) } - if _, ok := in.(*os.File); ok { - return false, nil + return re.ReplaceAllString(string(headBody), `"version"$1"`+candidate+`"`), nil + case "toml-regex": + section := releaseTomlSectionForPath(relPath) + if section == "" { + return "", fmt.Errorf("version file %s: no toml section", relPath) } + return replaceReleaseTomlVersion(string(headBody), section, candidate), nil + default: + return "", fmt.Errorf("version file %s: unsupported format %s", relPath, format) } - reader := bufio.NewReader(in) - fmt.Fprintf(out, " Proceed with release %s? [y/N] ", ansiBold(tagName)) - answer, err := reader.ReadString('\n') - if err != nil && len(answer) == 0 { - return false, err - } - return strings.HasPrefix(strings.ToLower(strings.TrimSpace(answer)), "y"), nil } func prepareReleaseVersionUpdates(root string, files []releaseVersionFile, newVersion string) ([]releaseVersionUpdate, error) { @@ -1230,20 +401,6 @@ func prepareReleaseVersionUpdates(root string, files []releaseVersionFile, newVe // releaseNonEvidenceChangePaths filters docs/changes dirt down to paths that // are not capability-evidence receipts/sources (which re-record on resume). -func releaseNonEvidenceChangePaths(root string, changePaths []string) []string { - if len(changePaths) == 0 { - return nil - } - var residual []string - for _, path := range changePaths { - if releaseIsEvidenceOnlyPath(root, path) { - continue - } - residual = append(residual, path) - } - return residual -} - func releaseTomlSectionForPath(relativePath string) string { base := filepath.Base(filepath.FromSlash(relativePath)) switch base { @@ -1342,113 +499,6 @@ func createReleaseChangelog(releaseSection string) string { }, "\n") } -func runReleaseArtifactCommand(root string, command releaseArtifactCommand, out io.Writer, errOut io.Writer) error { - executable, args, err := releaseArtifactInvocation(root, command) - if err != nil { - return err - } - cmd := exec.Command(executable, args...) - cmd.Dir = command.Cwd - cmd.Stdout = out - cmd.Stderr = errOut - if err := cmd.Run(); err != nil { - return err - } - suffix := "" - if rel, err := filepath.Rel(root, command.Cwd); err == nil && rel != "." { - suffix = " in " + filepath.ToSlash(rel) - } - fmt.Fprintf(out, " %s Ran %s%s\n", ansiGreen("✓"), command.Label, suffix) - return nil -} - -func releaseArtifactInvocation(root string, command releaseArtifactCommand) (string, []string, error) { - switch command.Label { - case "uv sync": - path, err := exec.LookPath("uv") - return path, []string{"sync"}, err - case "npm run build": - path, err := exec.LookPath("npm") - return path, []string{"run", "build"}, err - case "loaf build": - executable, err := os.Executable() - if err != nil { - return "", nil, err - } - return executable, []string{"build"}, nil - default: - return "", nil, fmt.Errorf("unknown release artifact command %q in %s", command.Label, root) - } -} - -func unignoredReleaseVirtualEnvStatusPaths(root string) []string { - output := releaseCommandOutput(root, "git", "status", "--porcelain", "--untracked-files=all", "-z") - if output == "" { - return nil - } - paths := map[string]bool{} - for _, entry := range strings.Split(output, "\x00") { - if entry == "" { - continue - } - path := entry - if len(entry) > 3 && entry[2] == ' ' { - path = entry[3:] - } - normalized := filepath.ToSlash(path) - if strings.Contains("/"+normalized+"/", "/.venv/") { - paths[normalized] = true - } - } - var values []string - for path := range paths { - values = append(values, path) - } - sort.Strings(values) - return values -} - -func releasePackageHasBuildScript(path string) bool { - body, err := readRegularFile(path, projectFileReadLimit) - if err != nil { - return false - } - var parsed struct { - Scripts map[string]string `json:"scripts"` - } - return json.Unmarshal(body, &parsed) == nil && parsed.Scripts["build"] != "" -} - -func displayReleaseArtifactCommand(root string, command releaseArtifactCommand) string { - rel, err := filepath.Rel(root, command.Cwd) - if err != nil || rel == "." { - return command.Label - } - return fmt.Sprintf("%s (%s)", command.Label, filepath.ToSlash(rel)) -} - -func normalizeReleaseSkipFlags(options releaseOptions) (bool, bool) { - skipTag := options.tagSet && !options.tag - skipGh := (options.ghSet && !options.gh) || skipTag - return skipTag, skipGh -} - -func detectReleaseBase(root string) (string, string, error) { - if current := releaseCommandOutput(root, "git", "symbolic-ref", "--short", "HEAD"); current == "" { - return "", "", fmt.Errorf("--pre-merge requires a named branch (detached HEAD detected). Pass --base <ref> explicitly") - } - if config := releaseCommandOutput(root, "git", "config", "--get", "loaf.release.base"); config != "" { - return config, "config", nil - } - if defaultBranch := releaseCommandOutput(root, "gh", "repo", "view", "--json", "defaultBranchRef", "-q", ".defaultBranchRef.name"); defaultBranch != "" { - return defaultBranch, "default", nil - } - if symRef := releaseCommandOutput(root, "git", "symbolic-ref", "refs/remotes/origin/HEAD"); strings.HasPrefix(symRef, "refs/remotes/origin/") { - return strings.TrimPrefix(symRef, "refs/remotes/origin/"), "default", nil - } - return "", "", fmt.Errorf("Could not auto-detect base branch. Pass --base <ref> explicitly, or set git config loaf.release.base <ref>") -} - func releaseGhAvailable() bool { _, err := exec.LookPath("gh") return err == nil @@ -1486,23 +536,6 @@ func releaseCommandOutput(root string, name string, args ...string) string { return strings.TrimSpace(string(output)) } -func releaseBumpReason(bump string) string { - switch bump { - case "major": - return "breaking changes detected" - case "minor": - return "new features detected" - case "patch": - return "bug fixes only" - case "prerelease": - return "prerelease version" - case "release": - return "stable release" - default: - return "selected bump" - } -} - func capitalizeReleaseMessage(value string) string { if value == "" { return value diff --git a/internal/cli/release_evidence_gate.go b/internal/cli/release_evidence_gate.go deleted file mode 100644 index 90a455c73..000000000 --- a/internal/cli/release_evidence_gate.go +++ /dev/null @@ -1,475 +0,0 @@ -package cli - -import ( - "bytes" - "fmt" - "os" - "os/exec" - "path/filepath" - "regexp" - "strings" -) - -// releaseCapabilityEvidenceRunners is static remediation copy: all three -// receipt runners share the --client/--expected-version/--receipt shape. The -// list is never parsed out of the loader error. -const releaseCapabilityEvidenceRunners = "cli/scripts/smoke-claude-code-startup.mjs, cli/scripts/smoke-codex-startup.mjs, or cli/scripts/smoke-opencode-request-context.mjs, each with --client <cli> --expected-version <installed> --receipt <path>" - -// releasePreparedArtifactGlobs are tracked outputs the release artifact -// commands rewrite. Reuses the same component-anchored glob grammar as -// ReleaseMetadataAllowlist / evidencePathExcluded — not a broader allowlist. -var releasePreparedArtifactGlobs = []string{ - "dist/**", - "plugins/**", - "bin/**", - ".claude-plugin/**", -} - -// checkReleaseCapabilityEvidence validates the capability evidence registry -// against the tree at root. Absent evidence exempts the project (present is -// false); any other failure — unreadable, invalid, irregular, or stale -// receipts — must refuse the release. There is deliberately no override. -// -// Presence walks every path component from the repository root with Lstat: -// intermediate components must be real directories (not symlinks), and the leaf -// must be a regular file. A symlinked component is present-but-unusable, never -// absent — so a dangling or external symlink cannot silently disarm the gate. -func checkReleaseCapabilityEvidence(root string) (present bool, err error) { - path, probeErr := probeCapabilityEvidenceRegistryPath(root) - if probeErr != nil { - return true, probeErr - } - if path == "" { - return false, nil - } - if _, loadErr := LoadTargetCapabilityEvidence(path); loadErr != nil { - return true, loadErr - } - return true, nil -} - -// probeCapabilityEvidenceRegistryPath component-walks root → registry. Returns -// ("", nil) when any component is missing (absent), a non-empty path when the -// leaf is a regular file, and an error when a component is present but unusable -// (symlink, non-directory intermediate, non-regular leaf). -func probeCapabilityEvidenceRegistryPath(root string) (string, error) { - absRoot, err := filepath.Abs(root) - if err != nil { - return "", fmt.Errorf("inspect capability evidence %s: resolve root: %w", TargetCapabilityEvidenceRecordPath, err) - } - relative := filepath.FromSlash(TargetCapabilityEvidenceRecordPath) - current := absRoot - components := strings.Split(filepath.Clean(relative), string(filepath.Separator)) - for index, component := range components { - if component == "" || component == "." { - continue - } - current = filepath.Join(current, component) - info, statErr := os.Lstat(current) - if statErr != nil { - if os.IsNotExist(statErr) { - return "", nil - } - return "", fmt.Errorf("inspect capability evidence %s: %w", TargetCapabilityEvidenceRecordPath, statErr) - } - isLast := index == len(components)-1 - if info.Mode()&os.ModeSymlink != 0 { - if isLast { - return "", fmt.Errorf("capability evidence %s is present but not a regular file (symlinks and other irregular files are unusable)", TargetCapabilityEvidenceRecordPath) - } - return "", fmt.Errorf("capability evidence %s is present but unusable: path component %q is a symlink", TargetCapabilityEvidenceRecordPath, filepath.ToSlash(strings.TrimPrefix(current, absRoot+string(filepath.Separator)))) - } - if isLast { - if !info.Mode().IsRegular() { - return "", fmt.Errorf("capability evidence %s is present but not a regular file (symlinks and other irregular files are unusable)", TargetCapabilityEvidenceRecordPath) - } - return current, nil - } - if !info.IsDir() { - return "", fmt.Errorf("capability evidence %s is present but unusable: path component %q is not a directory", TargetCapabilityEvidenceRecordPath, filepath.ToSlash(strings.TrimPrefix(current, absRoot+string(filepath.Separator)))) - } - } - return "", nil -} - -func releaseApplyCapabilityEvidenceRefusal(err error) error { - return fmt.Errorf("Refusing to commit release artifacts: capability evidence is invalid or stale against the rebuilt tree: %v; re-record with the matching runner (%s) after the artifact rebuild (the prepared tree stays in place — version files remain at the candidate so runners can hash candidate-versioned artifacts; CHANGELOG.md is restored to HEAD), then rerun the release — the rerun accepts the release-prepared worktree", err, releaseCapabilityEvidenceRunners) -} - -func releasePostMergeCapabilityEvidenceAbortMessage(err error) string { - return fmt.Sprintf("capability evidence is invalid or stale on the merged tree: %v — re-record against the merged tree, land the receipts as a single evidence-only commit on the base branch, and rerun loaf release --post-merge", err) -} - -// releasePathMatchesPreparedArtifact reports whether path is under the tracked -// generated-output trees the artifact commands rewrite. -func releasePathMatchesPreparedArtifact(path string) bool { - path = filepath.ToSlash(path) - return evidencePathExcluded(path, releasePreparedArtifactGlobs) -} - -// releaseGitShowPath returns the blob at rev:relPath (slash-separated) without -// trimming the body. Missing paths return an error. -func releaseGitShowPath(root, rev, relPath string) ([]byte, error) { - spec := rev + ":" + filepath.ToSlash(relPath) - cmd := exec.Command("git", "show", spec) - cmd.Dir = root - out, err := cmd.Output() - if err != nil { - return nil, err - } - return out, nil -} - -// releaseCapabilityEvidenceAllowlist is the union of evidence source paths named -// by the HEAD registry and the worktree registry, plus the registry path itself. -// Schema-only DecodeTargetCapabilityEvidence is enough; full load still runs as -// the gate later. Unreferenced research/ files are not admitted. -func releaseCapabilityEvidenceAllowlist(root string) map[string]bool { - paths := map[string]bool{TargetCapabilityEvidenceRecordPath: true} - addJSON := func(data []byte) { - for _, path := range releaseCapabilityEvidenceSourcePathsFromJSON(data) { - paths[path] = true - } - } - if data, err := releaseGitShowPath(root, "HEAD", TargetCapabilityEvidenceRecordPath); err == nil { - addJSON(data) - } - registryPath := filepath.Join(root, filepath.FromSlash(TargetCapabilityEvidenceRecordPath)) - if data, err := readRegularFile(registryPath, projectFileReadLimit); err == nil { - addJSON(data) - } - return paths -} - -// releaseCapabilityEvidenceSourcePathsFromJSON extracts every evidence source -// path from a registry document via the typed decoder. It does not include the -// registry path itself. Invalid documents yield no sources. -func releaseCapabilityEvidenceSourcePathsFromJSON(data []byte) []string { - contract, err := DecodeTargetCapabilityEvidence(data) - if err != nil { - return nil - } - paths := map[string]bool{} - addEvidence := func(evidence TargetCapabilityEvidenceRecord) { - relative, err := safeEvidenceRelativePath(evidence.Source) - if err != nil { - return - } - paths[filepath.ToSlash(relative)] = true - } - for _, record := range contract.Records { - for _, mode := range record.Context.Modes { - addEvidence(mode.Evidence) - } - addEvidence(record.Completion.Evidence) - } - return sortedKeys(paths) -} - -// releaseCapabilityEvidenceInstalledSmokePathsFromJSON returns source paths of -// evidence entries whose level is installed-smoke (receipts only). -func releaseCapabilityEvidenceInstalledSmokePathsFromJSON(data []byte) []string { - contract, err := DecodeTargetCapabilityEvidence(data) - if err != nil { - return nil - } - paths := map[string]bool{} - addSmoke := func(evidence TargetCapabilityEvidenceRecord) { - if evidence.Level != "installed-smoke" { - return - } - relative, err := safeEvidenceRelativePath(evidence.Source) - if err != nil { - return - } - paths[filepath.ToSlash(relative)] = true - } - for _, record := range contract.Records { - for _, mode := range record.Context.Modes { - addSmoke(mode.Evidence) - } - addSmoke(record.Completion.Evidence) - } - return sortedKeys(paths) -} - -// releaseIsEvidenceOnlyPath reports whether path is the capability registry or -// a source it references (HEAD ∪ worktree). Used for docs/changes residual -// filtering during apply. Repair classification uses the parent-commit -// installed-smoke paths instead. -func releaseIsEvidenceOnlyPath(root, path string) bool { - path = filepath.ToSlash(path) - return releaseCapabilityEvidenceAllowlist(root)[path] -} - -// releaseParseNameStatusZ parses `git diff --name-status --no-renames -z` -// output. Paths are taken raw (no TrimSpace). Only added/modified statuses are -// accepted; rename/copy/type-change/delete and any other status refuse the -// parse so the caller treats the commit as not receipt-only. -func releaseParseNameStatusZ(raw string) (paths []string, ok bool) { - if raw == "" { - return nil, false - } - parts := strings.Split(raw, "\x00") - for i := 0; i < len(parts); { - if parts[i] == "" { - i++ - continue - } - status := parts[i] - i++ - if i >= len(parts) { - return nil, false - } - path := parts[i] - i++ - if path == "" || status == "" { - return nil, false - } - // With --no-renames, status is a single letter. Reject anything except A/M - // (rename/copy/type-change/delete are not receipt-only repairs). - if len(status) != 1 || (status[0] != 'A' && status[0] != 'M') { - return nil, false - } - paths = append(paths, filepath.ToSlash(path)) - } - if len(paths) == 0 { - return nil, false - } - return paths, true -} - -// releaseIsEvidenceOnlyRepairCommit reports whether HEAD is a single -// receipt-only repair commit sitting directly atop a release commit. Depth is -// exactly one — history is not scanned. -// -// Allowed paths are installed-smoke sources from the PARENT commit's registry -// (not HEAD's), and the registry file itself must be unchanged. A repair that -// edits the registry or a non-receipt source (fixture/source level) is not -// receipt-only; recovery is redoing the release PR. -func releaseIsEvidenceOnlyRepairCommit(root string, runner releasePostMergeCommandRunner) bool { - parent := runner(root, "git", "rev-parse", "--verify", "HEAD^") - if parent.exitCode != 0 || strings.TrimSpace(parent.stdout) == "" { - return false - } - diff := runner(root, "git", "diff", "--name-status", "--no-renames", "-z", "HEAD^", "HEAD") - if diff.exitCode != 0 { - return false - } - raw := diff.rawOutput() - paths, ok := releaseParseNameStatusZ(raw) - if !ok { - return false - } - for _, path := range paths { - if path == TargetCapabilityEvidenceRecordPath { - return false - } - } - show := runner(root, "git", "show", "HEAD^:"+TargetCapabilityEvidenceRecordPath) - if show.exitCode != 0 { - return false - } - allowed := map[string]bool{} - for _, path := range releaseCapabilityEvidenceInstalledSmokePathsFromJSON([]byte(show.rawOutput())) { - allowed[path] = true - } - if len(allowed) == 0 { - return false - } - for _, path := range paths { - if !allowed[path] { - return false - } - } - return true -} - -// releaseVersionPathSet returns relative paths of version files the release -// may bump, used to classify dirt on resume. -func releaseVersionPathSet(root string, options releaseOptions) map[string]bool { - allowed := map[string]bool{} - for _, candidate := range []string{"package.json", "pyproject.toml", "Cargo.toml", ".agents/loaf.json", ".claude-plugin/marketplace.json"} { - allowed[candidate] = true - } - configOverrides, err := releaseConfigVersionFiles(root) - if err == nil { - for _, path := range configOverrides { - allowed[filepath.ToSlash(path)] = true - } - } - for _, path := range options.versionFile { - allowed[filepath.ToSlash(path)] = true - } - // Prefer detecting actual files so partial fixtures still classify. - overrides := options.versionFile - if len(overrides) == 0 { - overrides = configOverrides - } - if files, err := detectReleaseVersionFiles(root, overrides); err == nil { - for _, file := range files { - allowed[filepath.ToSlash(file.RelativePath)] = true - } - } - // Also admit paths present at HEAD even when worktree content is odd. - for path := range allowed { - if _, err := releaseGitShowPath(root, "HEAD", path); err == nil { - allowed[path] = true - } - } - return allowed -} - -// releaseRenderVersionContent re-derives the on-disk body for a version file -// after bumping currentVersion → candidate, matching prepareReleaseVersionUpdates. -func releaseRenderVersionContent(relPath string, headBody []byte, currentVersion, format, candidate string) (string, error) { - switch format { - case "json": - re := regexp.MustCompile(`"version"(\s*:\s*)"` + regexp.QuoteMeta(currentVersion) + `"`) - if !re.Match(headBody) { - return "", fmt.Errorf("version file %s does not contain version %s", relPath, currentVersion) - } - return re.ReplaceAllString(string(headBody), `"version"$1"`+candidate+`"`), nil - case "toml-regex": - section := releaseTomlSectionForPath(relPath) - if section == "" { - return "", fmt.Errorf("version file %s: no toml section", relPath) - } - return replaceReleaseTomlVersion(string(headBody), section, candidate), nil - default: - return "", fmt.Errorf("version file %s: unsupported format %s", relPath, format) - } -} - -// releaseGitHeadBlobMode returns the git object mode for relPath at HEAD -// (e.g. "100644", "100755"). Empty or non-blob entries error. -func releaseGitHeadBlobMode(root, relPath string) (string, error) { - cmd := exec.Command("git", "ls-tree", "HEAD", "--", filepath.ToSlash(relPath)) - cmd.Dir = root - out, err := cmd.Output() - if err != nil { - return "", err - } - line := strings.TrimSpace(string(out)) - if line == "" { - return "", fmt.Errorf("no ls-tree entry for %s at HEAD", relPath) - } - // "100644 blob <hash>\t<path>" — mode is the first field. - fields := strings.Fields(line) - if len(fields) < 3 { - return "", fmt.Errorf("parse ls-tree entry %q", line) - } - return fields[0], nil -} - -// releaseWorktreeBlobMode maps a regular worktree file's mode onto git's -// blob mode alphabet (100644 vs 100755). Non-regular paths return "". -func releaseWorktreeBlobMode(info os.FileInfo) string { - if info == nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { - return "" - } - if info.Mode().Perm()&0o111 != 0 { - return "100755" - } - return "100644" -} - -// releaseVersionFileMatchesCandidate reports whether the worktree path is a -// regular file whose git mode matches HEAD and whose bytes equal the candidate -// rendering derived in memory from HEAD content + candidate. Symlinks and other -// non-regular paths are never admitted: the Lstat settles the git mode, and the -// body is read through the descriptor-hardened open so the type is decided on -// the descriptor rather than on the name. -func releaseVersionFileMatchesCandidate(root, relPath, candidate string) bool { - if candidate == "" { - return false - } - abs := filepath.Join(root, filepath.FromSlash(relPath)) - info, err := os.Lstat(abs) - if err != nil || !info.Mode().IsRegular() { - return false - } - headMode, err := releaseGitHeadBlobMode(root, relPath) - if err != nil || headMode == "" { - return false - } - if releaseWorktreeBlobMode(info) != headMode { - return false - } - headBody, err := releaseGitShowPath(root, "HEAD", relPath) - if err != nil { - return false - } - currentVersion, format, err := parseReleaseVersion(relPath, headBody) - if err != nil { - return false - } - expected, err := releaseRenderVersionContent(relPath, headBody, currentVersion, format, candidate) - if err != nil { - return false - } - actual, err := readRegularFile(abs, projectFileReadLimit) - if err != nil { - return false - } - return bytes.Equal(actual, []byte(expected)) -} - -// releaseResolveCandidateForClassification returns the candidate version this -// mutating invocation will cut, using HEAD version baselines so a refused -// prepare's worktree bumps do not shift the candidate. -func releaseResolveCandidateForClassification(root string, options releaseOptions) string { - if options.snapshot.Candidate != "" { - return options.snapshot.Candidate - } - snap, err := resolveReleaseSnapshot(root, options) - if err != nil { - return "" - } - return snap.Candidate -} - -// releaseRestoreGeneratedFromHEAD hard-restores only tracked generated-output -// dirt from HEAD. Version files, CHANGELOG, and evidence paths are never -// checkout'd by classification alone. -func releaseRestoreGeneratedFromHEAD(root string, entries []releaseStatusEntry) error { - var toRestore []string - for _, entry := range entries { - if entry.untracked || entry.deleted { - continue - } - path := filepath.ToSlash(entry.path) - if releasePathMatchesPreparedArtifact(path) { - toRestore = append(toRestore, path) - } - } - if len(toRestore) == 0 { - return nil - } - args := append([]string{"checkout", "HEAD", "--"}, toRestore...) - if err := releaseCommandRun(root, "git", args...); err != nil { - return fmt.Errorf("Refusing to prepare release: cannot restore generated outputs from HEAD: %w", err) - } - return nil -} - -// releaseRestoreChangelogFromHEAD undoes the changelog insertion this run wrote -// when the evidence gate refuses. Version files and generated outputs stay. -func releaseRestoreChangelogFromHEAD(root string) error { - if releaseCommandOK(root, "git", "cat-file", "-e", "HEAD:CHANGELOG.md") { - if err := releaseCommandRun(root, "git", "checkout", "HEAD", "--", "CHANGELOG.md"); err != nil { - return fmt.Errorf("cannot restore CHANGELOG.md from HEAD: %w", err) - } - return nil - } - // This run created CHANGELOG.md; remove it. - path := filepath.Join(root, "CHANGELOG.md") - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("cannot remove CHANGELOG.md created by this run: %w", err) - } - return nil -} - -func releaseClassicCleanWorktreeRefusal(dirtyPaths []string) error { - return fmt.Errorf("Refusing to prepare release: mutating release modes require a clean unignored worktree; changelog curation belongs on a release branch in the --pre-merge flow — commit, stash, or remove: %s", strings.Join(dirtyPaths, ", ")) -} diff --git a/internal/cli/release_evidence_gate_test.go b/internal/cli/release_evidence_gate_test.go deleted file mode 100644 index 97ac29efb..000000000 --- a/internal/cli/release_evidence_gate_test.go +++ /dev/null @@ -1,1197 +0,0 @@ -package cli - -import ( - "bytes" - "encoding/json" - "errors" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - "unicode" -) - -// hardenFixtureRepoAgainstHostSigning makes a fixture git repo independent of -// host signing config. Matches seedReleaseApplyRepo: local commit.gpgsign and -// tag.gpgsign false. Also installs an ephemeral SSH signing key so production -// `git tag -s` succeeds without a host secret key — tag.gpgsign=false does not -// suppress -s, and CI has no key while local signing agents mask the gap. -func hardenFixtureRepoAgainstHostSigning(t *testing.T, repo string) { - t.Helper() - gitCLI(t, repo, "config", "commit.gpgsign", "false") - gitCLI(t, repo, "config", "tag.gpgsign", "false") - - keyDir := t.TempDir() - keyPath := filepath.Join(keyDir, "signing_key") - cmd := exec.Command("ssh-keygen", "-t", "ed25519", "-N", "", "-f", keyPath, "-C", "loaf-test@example.test", "-q") - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("ssh-keygen for fixture signing key: %v\n%s", err, out) - } - gitCLI(t, repo, "config", "gpg.format", "ssh") - gitCLI(t, repo, "config", "user.signingkey", keyPath) -} - -// seedReleaseCapabilityEvidence copies the repository's real capability -// evidence registry plus every file it references — evidence sources, -// installed-smoke receipts, and the pinned artifacts those receipts hash — -// into root, so the full evidence loader passes against that tree. -func seedReleaseCapabilityEvidence(t *testing.T, root string) { - t.Helper() - repoRoot := testRepositoryRoot(t) - registry, err := os.ReadFile(filepath.Join(repoRoot, filepath.FromSlash(TargetCapabilityEvidenceRecordPath))) - if err != nil { - t.Fatalf("ReadFile(%s) error = %v", TargetCapabilityEvidenceRecordPath, err) - } - contract, err := DecodeTargetCapabilityEvidence(registry) - if err != nil { - t.Fatalf("DecodeTargetCapabilityEvidence() error = %v", err) - } - copied := map[string]bool{} - copyPath := func(relative string) { - relative = filepath.ToSlash(relative) - if relative == "" || copied[relative] { - return - } - copied[relative] = true - content, err := os.ReadFile(filepath.Join(repoRoot, filepath.FromSlash(relative))) - if err != nil { - t.Fatalf("ReadFile(%s) error = %v", relative, err) - } - destination := filepath.Join(root, filepath.FromSlash(relative)) - if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { - t.Fatalf("MkdirAll(%s) error = %v", filepath.Dir(destination), err) - } - if err := os.WriteFile(destination, content, 0o644); err != nil { - t.Fatalf("WriteFile(%s) error = %v", destination, err) - } - } - copyEvidence := func(evidence TargetCapabilityEvidenceRecord) { - relative, err := safeEvidenceRelativePath(evidence.Source) - if err != nil { - t.Fatalf("safeEvidenceRelativePath(%q) error = %v", evidence.Source, err) - } - copyPath(relative) - if evidence.Level != "installed-smoke" { - return - } - receipt, err := os.ReadFile(filepath.Join(repoRoot, relative)) - if err != nil { - t.Fatalf("ReadFile(%s) error = %v", relative, err) - } - var smoke struct { - CandidateArtifacts TargetCapabilitySmokeArtifacts `json:"candidate_artifacts"` - } - if err := json.Unmarshal(receipt, &smoke); err != nil { - t.Fatalf("Unmarshal(%s) error = %v", relative, err) - } - copyPath(smoke.CandidateArtifacts.HooksPath) - copyPath(smoke.CandidateArtifacts.NativeBinaryPath) - } - copyPath(TargetCapabilityEvidenceRecordPath) - for _, record := range contract.Records { - for _, mode := range record.Context.Modes { - copyEvidence(mode.Evidence) - } - copyEvidence(record.Completion.Evidence) - } -} - -// seedReleaseApplyRepoWithCapabilityEvidence extends the apply fixture with -// the real capability evidence tree, committed so the release preflight sees -// a clean worktree. Native binaries stay ignored: the gate reads the -// filesystem, and keeping ~26MB blobs out of git keeps the fixture fast. -func seedReleaseApplyRepoWithCapabilityEvidence(t *testing.T, commitSubject string) string { - t.Helper() - repo := seedReleaseApplyRepo(t, commitSubject) - hardenFixtureRepoAgainstHostSigning(t, repo) - seedReleaseCapabilityEvidence(t, repo) - writeFile(t, filepath.Join(repo, ".gitignore"), "bin/native/\nplugins/loaf/bin/native/\n") - gitCLI(t, repo, "add", ".") - gitCLI(t, repo, "commit", "-m", "chore: record capability evidence") - return repo -} - -func TestReleaseApplyBlocksWhenCapabilityEvidenceStale(t *testing.T) { - cases := []struct { - name string - args []string - }{ - {name: "direct", args: []string{"release", "--yes", "--no-gh"}}, - {name: "pre-merge", args: []string{"release", "--pre-merge", "--base", "HEAD~1", "--yes", "--no-gh"}}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - repo := seedReleaseApplyRepoWithCapabilityEvidence(t, "feat: gate stale capability evidence") - // Reproduce the alpha.16/alpha.17 incident: the artifact rebuild - // itself stales a SHA-pinned receipt. - packageBody := strings.Join([]string{ - "{", - ` "name": "release-fixture",`, - ` "version": "1.0.0",`, - ` "scripts": {`, - ` "build": "node -e \"require('fs').appendFileSync('dist/opencode/plugins/hooks.ts','stale')\""`, - " }", - "}", - "", - }, "\n") - if err := os.WriteFile(filepath.Join(repo, "package.json"), []byte(packageBody), 0o644); err != nil { - t.Fatal(err) - } - gitCLI(t, repo, "add", "package.json") - gitCLI(t, repo, "commit", "-m", "fix: stale a pinned artifact during rebuild") - beforeHEAD := gitOutputReleaseTest(t, repo, "rev-parse", "HEAD") - - err := (Runner{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, WorkingDir: repo}).Run(tc.args) - if err == nil { - t.Fatalf("Run(%v) error = nil, want stale-evidence refusal", tc.args) - } - msg := err.Error() - for _, want := range []string{ - "Refusing to commit release artifacts", - "capability evidence is invalid or stale", - "does not match current candidate", - "cli/scripts/smoke-claude-code-startup.mjs", - "cli/scripts/smoke-codex-startup.mjs", - "cli/scripts/smoke-opencode-request-context.mjs", - "after the artifact rebuild", - } { - if !strings.Contains(msg, want) { - t.Fatalf("Run(%v) error = %q, want %q", tc.args, msg, want) - } - } - if staged := gitOutputReleaseTest(t, repo, "diff", "--cached", "--name-only"); staged != "" { - t.Fatalf("Run(%v) staged files before refusal: %q", tc.args, staged) - } - if head := gitOutputReleaseTest(t, repo, "rev-parse", "HEAD"); head != beforeHEAD { - t.Fatalf("Run(%v) created release commit %s, want HEAD %s", tc.args, head, beforeHEAD) - } - if tags := gitOutputReleaseTest(t, repo, "tag", "--list"); tags != "v1.0.0" { - t.Fatalf("Run(%v) tags = %q, want only v1.0.0", tc.args, tags) - } - }) - } -} - -func TestReleaseApplyBlocksWhenCapabilityEvidenceInvalid(t *testing.T) { - repo := seedReleaseApplyRepo(t, "feat: gate invalid capability evidence") - hardenFixtureRepoAgainstHostSigning(t, repo) - if err := os.MkdirAll(filepath.Join(repo, "config"), 0o755); err != nil { - t.Fatal(err) - } - writeFile(t, filepath.Join(repo, filepath.FromSlash(TargetCapabilityEvidenceRecordPath)), `{"contract_version": 1}`+"\n") - gitCLI(t, repo, "add", ".") - gitCLI(t, repo, "commit", "-m", "chore: record broken capability evidence") - beforeHEAD := gitOutputReleaseTest(t, repo, "rev-parse", "HEAD") - - err := (Runner{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"release", "--yes", "--no-tag", "--no-gh"}) - if err == nil { - t.Fatalf("release error = nil, want invalid-evidence refusal") - } - if !strings.Contains(err.Error(), "Refusing to commit release artifacts") || !strings.Contains(err.Error(), "capability evidence is invalid or stale") { - t.Fatalf("release error = %q, want invalid-evidence refusal copy", err.Error()) - } - if !strings.Contains(err.Error(), "unsupported target capability contract version") { - t.Fatalf("release error = %q, want the loader error surfaced", err.Error()) - } - if head := gitOutputReleaseTest(t, repo, "rev-parse", "HEAD"); head != beforeHEAD { - t.Fatalf("release created commit %s, want HEAD %s", head, beforeHEAD) - } -} - -func TestReleaseApplyPassesWithFreshCapabilityEvidence(t *testing.T) { - repo := seedReleaseApplyRepoWithCapabilityEvidence(t, "feat: release with fresh capability evidence") - var stdout bytes.Buffer - - err := Runner{Stdout: &stdout, WorkingDir: repo}.Run([]string{"release", "--yes", "--no-tag", "--no-gh"}) - if err != nil { - t.Fatalf("release error = %v\n%s", err, stdout.String()) - } - if !strings.Contains(stdout.String(), "Capability evidence validated") { - t.Fatalf("stdout = %q, want inline evidence validation report", stdout.String()) - } - if subject := gitOutputReleaseTest(t, repo, "log", "-1", "--pretty=%s"); subject != "chore: release v1.1.0" { - t.Fatalf("release commit subject = %q, want chore: release v1.1.0", subject) - } -} - -func TestReleaseApplySkipsCapabilityEvidenceWhenAbsent(t *testing.T) { - repo := seedReleaseApplyRepo(t, "feat: release without capability evidence") - hardenFixtureRepoAgainstHostSigning(t, repo) - var stdout bytes.Buffer - - err := Runner{Stdout: &stdout, WorkingDir: repo}.Run([]string{"release", "--yes", "--no-tag", "--no-gh"}) - if err != nil { - t.Fatalf("release error = %v\n%s", err, stdout.String()) - } - if strings.Contains(stdout.String(), "Capability evidence") { - t.Fatalf("stdout = %q, want no evidence output for a project without the config", stdout.String()) - } - if subject := gitOutputReleaseTest(t, repo, "log", "-1", "--pretty=%s"); subject != "chore: release v1.1.0" { - t.Fatalf("release commit subject = %q, want chore: release v1.1.0", subject) - } -} - -func TestReleasePostMergeGuardrailBlocksStaleCapabilityEvidence(t *testing.T) { - repo := seedReleasePostMergeFiles(t, "1.2.3") - seedReleaseCapabilityEvidence(t, repo) - staleArtifact := filepath.Join(repo, "dist", "opencode", "plugins", "hooks.ts") - handle, err := os.OpenFile(staleArtifact, os.O_APPEND|os.O_WRONLY, 0o644) - if err != nil { - t.Fatal(err) - } - if _, err := handle.WriteString("stale"); err != nil { - t.Fatal(err) - } - if err := handle.Close(); err != nil { - t.Fatal(err) - } - runner, _ := scriptedReleasePostMergeRunner(releasePostMergeHappyResponses("1.2.3")) - snap := mustResolveReleaseSnapshot(t, repo, releaseOptions{postMerge: true}) - - result := checkReleasePostMergeGuardrails(repo, snap, runner) - if result.ok || result.guardrail != 9 { - t.Fatalf("result = %#v, want guardrail 9 failure", result) - } - for _, want := range []string{ - "capability evidence is invalid or stale", - "does not match current candidate", - "re-record against the merged tree", - "single evidence-only commit", - "rerun loaf release --post-merge", - } { - if !strings.Contains(result.message, want) { - t.Fatalf("message = %q, want %q", result.message, want) - } - } - for _, forbidden := range []string{"tag -d", "re-point", "repoint"} { - if strings.Contains(result.message, forbidden) { - t.Fatalf("message = %q, must not contain %q", result.message, forbidden) - } - } -} - -func TestReleasePostMergeGuardrailPassesFreshCapabilityEvidence(t *testing.T) { - repo := seedReleasePostMergeFiles(t, "1.2.3") - seedReleaseCapabilityEvidence(t, repo) - runner, _ := scriptedReleasePostMergeRunner(releasePostMergeHappyResponses("1.2.3")) - snap := mustResolveReleaseSnapshot(t, repo, releaseOptions{postMerge: true}) - - result := checkReleasePostMergeGuardrails(repo, snap, runner) - if !result.ok { - t.Fatalf("result = %#v, want all guardrails passed with fresh evidence", result) - } -} - -func TestReleaseCapabilityEvidenceRemediation(t *testing.T) { - loaderErr := errors.New(`load target capability evidence "config/target-capabilities.json": OpenCode installed-smoke hooks SHA-256 aaa does not match current candidate bbb`) - - t.Run("apply refusal names the runners and the executable resume loop", func(t *testing.T) { - msg := releaseApplyCapabilityEvidenceRefusal(loaderErr).Error() - for _, want := range []string{ - "Refusing to commit release artifacts:", - "capability evidence is invalid or stale", - loaderErr.Error(), - "cli/scripts/smoke-claude-code-startup.mjs", - "cli/scripts/smoke-codex-startup.mjs", - "cli/scripts/smoke-opencode-request-context.mjs", - "--client", - "--expected-version", - "--receipt", - "after the artifact rebuild", - "prepared tree stays in place", - "version files remain at the candidate", - "CHANGELOG.md is restored to HEAD", - "rerun the release", - "release-prepared worktree", - } { - if !strings.Contains(msg, want) { - t.Fatalf("message = %q, want %q", msg, want) - } - } - if strings.Contains(msg, "go test") { - t.Fatalf("message = %q, must not point at the Go test harness", msg) - } - }) - - t.Run("post-merge guardrail message keeps the lowercase register", func(t *testing.T) { - msg := releasePostMergeCapabilityEvidenceAbortMessage(loaderErr) - if msg == "" || !unicode.IsLower(rune(msg[0])) { - t.Fatalf("message = %q, want lowercase guardrail register", msg) - } - for _, want := range []string{ - "capability evidence is invalid or stale", - loaderErr.Error(), - " — ", - "re-record against the merged tree", - "single evidence-only commit", - "rerun loaf release --post-merge", - } { - if !strings.Contains(msg, want) { - t.Fatalf("message = %q, want %q", msg, want) - } - } - for _, forbidden := range []string{"tag -d", "delete", "re-point", "repoint"} { - if strings.Contains(msg, forbidden) { - t.Fatalf("message = %q, must not contain %q", msg, forbidden) - } - } - }) -} - -// rewriteInstalledSmokeReceiptHashes sets each installed-smoke receipt's pinned -// artifact digests to match the files currently on disk under root — the -// mechanical stand-in for re-recording after a refused release rebuild. -func rewriteInstalledSmokeReceiptHashes(t *testing.T, root string) { - t.Helper() - registry, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(TargetCapabilityEvidenceRecordPath))) - if err != nil { - t.Fatalf("ReadFile(%s) error = %v", TargetCapabilityEvidenceRecordPath, err) - } - contract, err := DecodeTargetCapabilityEvidence(registry) - if err != nil { - t.Fatalf("DecodeTargetCapabilityEvidence() error = %v", err) - } - rewritten := map[string]bool{} - rewriteReceipt := func(source string) { - relative, err := safeEvidenceRelativePath(source) - if err != nil || rewritten[relative] { - return - } - if filepath.Ext(relative) != ".json" { - return - } - path := filepath.Join(root, filepath.FromSlash(relative)) - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("ReadFile(%s) error = %v", relative, err) - } - var raw map[string]any - if err := json.Unmarshal(data, &raw); err != nil { - return - } - artifacts, _ := raw["candidate_artifacts"].(map[string]any) - if artifacts == nil { - return - } - for field, key := range map[string]string{"hooks_path": "hooks_sha256", "native_binary_path": "native_binary_sha256"} { - rel, _ := artifacts[field].(string) - if rel == "" { - continue - } - digest, err := sha256File(filepath.Join(root, filepath.FromSlash(rel))) - if err != nil { - // Native binaries are gitignored in the fixture; leave pinned. - continue - } - artifacts[key] = digest - } - encoded, err := json.MarshalIndent(raw, "", " ") - if err != nil { - t.Fatalf("MarshalIndent(%s) error = %v", relative, err) - } - if err := os.WriteFile(path, append(encoded, '\n'), 0o644); err != nil { - t.Fatalf("WriteFile(%s) error = %v", relative, err) - } - rewritten[relative] = true - } - for _, record := range contract.Records { - for _, mode := range record.Context.Modes { - if mode.Evidence.Level == "installed-smoke" { - rewriteReceipt(mode.Evidence.Source) - } - } - } -} - -// seedReleaseApplyRepoWithStalingBuild commits a package.json whose build -// rewrites dist/opencode/plugins/hooks.ts to a fixed body, staling the -// OpenCode installed-smoke receipt on the first rebuild. -func seedReleaseApplyRepoWithStalingBuild(t *testing.T, commitSubject string) string { - t.Helper() - repo := seedReleaseApplyRepoWithCapabilityEvidence(t, commitSubject) - packageBody := strings.Join([]string{ - "{", - ` "name": "release-fixture",`, - ` "version": "1.0.0",`, - ` "scripts": {`, - ` "build": "node -e \"require('fs').mkdirSync('dist/opencode/plugins',{recursive:true}); require('fs').writeFileSync('dist/opencode/plugins/hooks.ts','staled-hooks\\n')\""`, - " }", - "}", - "", - }, "\n") - if err := os.WriteFile(filepath.Join(repo, "package.json"), []byte(packageBody), 0o644); err != nil { - t.Fatal(err) - } - gitCLI(t, repo, "add", "package.json") - gitCLI(t, repo, "commit", "-m", "fix: make rebuild stale OpenCode hooks once") - return repo -} - -func countChangelogHeadings(body, version string) int { - want := "## [" + version + "]" - count := 0 - for _, line := range strings.Split(body, "\n") { - if strings.HasPrefix(strings.TrimSpace(line), want) { - count++ - } - } - return count -} - -func releaseCommitChangedPaths(t *testing.T, repo string) []string { - t.Helper() - out := gitOutputReleaseTest(t, repo, "diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD") - if out == "" { - return nil - } - return strings.Split(out, "\n") -} - -func TestReleaseApplyResumesPreparedTreeAfterEvidenceRerecord(t *testing.T) { - repo := seedReleaseApplyRepoWithStalingBuild(t, "feat: first live evidence-gate resume") - - args := []string{"release", "--yes", "--no-gh"} - first := (Runner{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, WorkingDir: repo}).Run(args) - if first == nil { - t.Fatalf("first Run(%v) error = nil, want stale-evidence refusal", args) - } - if !strings.Contains(first.Error(), "Refusing to commit release artifacts") || !strings.Contains(first.Error(), "version files remain at the candidate") { - t.Fatalf("first Run(%v) error = %q, want resume-loop refusal copy", args, first.Error()) - } - beforeHEAD := gitOutputReleaseTest(t, repo, "rev-parse", "HEAD") - if dirty := gitOutputReleaseTest(t, repo, "status", "--porcelain"); dirty == "" { - t.Fatal("first refusal left a clean worktree, want prepared dirt") - } - // Gate refusal restores the changelog it wrote; version files stay at candidate. - if n := countChangelogHeadings(string(mustReadFile(t, filepath.Join(repo, "CHANGELOG.md"))), "1.1.0"); n != 0 { - t.Fatalf("after refusal CHANGELOG.md has %d headings for 1.1.0, want 0 (restored to HEAD)", n) - } - pkg := mustReadFile(t, filepath.Join(repo, "package.json")) - if !strings.Contains(string(pkg), `"version": "1.1.0"`) { - t.Fatalf("after refusal package.json = %s, want version left at candidate 1.1.0", pkg) - } - - // Operator re-records against the rebuilt tree (version files stay at candidate). - rewriteInstalledSmokeReceiptHashes(t, repo) - - var stdout bytes.Buffer - if err := (Runner{Stdout: &stdout, Stderr: &bytes.Buffer{}, WorkingDir: repo}).Run(args); err != nil { - t.Fatalf("resume Run(%v) error = %v\n%s", args, err, stdout.String()) - } - if !strings.Contains(stdout.String(), "Capability evidence validated") { - t.Fatalf("stdout = %q, want evidence validation on resume", stdout.String()) - } - if head := gitOutputReleaseTest(t, repo, "rev-parse", "HEAD"); head == beforeHEAD { - t.Fatal("resume did not create a release commit") - } - if subject := gitOutputReleaseTest(t, repo, "log", "-1", "--pretty=%s"); subject != "chore: release v1.1.0" { - t.Fatalf("release commit subject = %q, want chore: release v1.1.0", subject) - } - if tags := gitOutputReleaseTest(t, repo, "tag", "--list"); !strings.Contains(tags, "v1.1.0") { - t.Fatalf("tags = %q, want v1.1.0 after resume with tagging enabled", tags) - } - if dirty := gitOutputReleaseTest(t, repo, "status", "--porcelain"); dirty != "" { - t.Fatalf("resume left dirty worktree: %q", dirty) - } - - // (a) changelog contains exactly one heading for the candidate version. - changelog, err := os.ReadFile(filepath.Join(repo, "CHANGELOG.md")) - if err != nil { - t.Fatal(err) - } - if n := countChangelogHeadings(string(changelog), "1.1.0"); n != 1 { - t.Fatalf("CHANGELOG.md has %d headings for 1.1.0, want exactly 1\n%s", n, changelog) - } - - // (b) exact committed path set — not a broad research-tree predicate. - changed := releaseCommitChangedPaths(t, repo) - for i, path := range changed { - changed[i] = filepath.ToSlash(path) - } - wantPaths := []string{ - "CHANGELOG.md", - "dist/opencode/plugins/hooks.ts", - "docs/changes/20260808-hooks-entry-reconciliation/research/claude-code-2.1.226-plugin-startup-smoke.json", - "docs/changes/20260808-hooks-entry-reconciliation/research/codex-0.147.0-isolated-startup-smoke.json", - "docs/changes/20260808-hooks-entry-reconciliation/research/opencode-1.18.13-isolated-request-smoke.json", - "package.json", - } - if len(changed) != len(wantPaths) { - t.Fatalf("release commit paths = %v, want exactly %v", changed, wantPaths) - } - // Compare as sets: git path order is tree order, not required by the gate. - wantSet := map[string]bool{} - for _, p := range wantPaths { - wantSet[p] = true - } - for _, path := range changed { - if !wantSet[path] { - t.Fatalf("release commit paths = %v, want exactly the set %v (unexpected %q)", changed, wantPaths, path) - } - delete(wantSet, path) - } - if len(wantSet) != 0 { - t.Fatalf("release commit paths = %v, missing %v", changed, wantSet) - } -} - -func TestReleaseApplyResumeClobbersHandEditedGeneratedFile(t *testing.T) { - repo := seedReleaseApplyRepoWithStalingBuild(t, "feat: resume clobbers hand-edited dist") - - args := []string{"release", "--yes", "--no-tag", "--no-gh"} - if err := (Runner{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, WorkingDir: repo}).Run(args); err == nil { - t.Fatalf("first Run(%v) error = nil, want stale-evidence refusal", args) - } - rewriteInstalledSmokeReceiptHashes(t, repo) - - // Hand-edit a tracked generated file after the refused prepare. Restore must - // discard this; rebuild must produce the build script's content, not the edit. - // The capability-evidence seed already tracks dist/opencode/plugins/hooks.ts. - hooksPath := filepath.Join(repo, "dist", "opencode", "plugins", "hooks.ts") - const handEdit = "HAND_EDIT_MUST_NOT_LAND\n" - if err := os.WriteFile(hooksPath, []byte(handEdit), 0o644); err != nil { - t.Fatal(err) - } - - var stdout bytes.Buffer - if err := (Runner{Stdout: &stdout, Stderr: &bytes.Buffer{}, WorkingDir: repo}).Run(args); err != nil { - t.Fatalf("resume Run(%v) error = %v\n%s", args, err, stdout.String()) - } - committed, err := os.ReadFile(hooksPath) - if err != nil { - t.Fatal(err) - } - if string(committed) == handEdit { - t.Fatal("hand-edited dist content was committed; want build output after restore-and-regenerate") - } - if string(committed) != "staled-hooks\n" { - t.Fatalf("committed hooks.ts = %q, want build output %q", committed, "staled-hooks\n") - } - if n := countChangelogHeadings(string(mustReadFile(t, filepath.Join(repo, "CHANGELOG.md"))), "1.1.0"); n != 1 { - t.Fatalf("CHANGELOG.md has %d headings for 1.1.0, want exactly 1", n) - } -} - -func TestReleaseApplyResumeRefusesUntrackedUnderDist(t *testing.T) { - repo := seedReleaseApplyRepoWithStalingBuild(t, "feat: resume refuses untracked dist") - - args := []string{"release", "--yes", "--no-tag", "--no-gh"} - if err := (Runner{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, WorkingDir: repo}).Run(args); err == nil { - t.Fatalf("first Run(%v) error = nil, want stale-evidence refusal", args) - } - rewriteInstalledSmokeReceiptHashes(t, repo) - - extra := filepath.Join(repo, "dist", "extra.js") - if err := os.MkdirAll(filepath.Dir(extra), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(extra, []byte("not part of the build\n"), 0o644); err != nil { - t.Fatal(err) - } - - err := (Runner{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, WorkingDir: repo}).Run(args) - if err == nil { - t.Fatal("resume with untracked dist/extra.js error = nil, want refusal") - } - msg := err.Error() - if !strings.Contains(msg, "untracked file under generated-output tree") || !strings.Contains(msg, "dist/extra.js") { - t.Fatalf("error = %q, want untracked-generated refusal naming dist/extra.js", msg) - } -} - -func TestReleaseApplyRefusesPreparedTreeWithUnrelatedDirty(t *testing.T) { - repo := seedReleaseApplyRepoWithStalingBuild(t, "feat: resume boundary keeps non-release dirt out") - - args := []string{"release", "--yes", "--no-tag", "--no-gh"} - if err := (Runner{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, WorkingDir: repo}).Run(args); err == nil { - t.Fatalf("first Run(%v) error = nil, want stale-evidence refusal", args) - } - rewriteInstalledSmokeReceiptHashes(t, repo) - writeFile(t, filepath.Join(repo, "unrelated.txt"), "not part of the release\n") - - err := (Runner{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, WorkingDir: repo}).Run(args) - if err == nil { - t.Fatalf("resume with unrelated dirt error = nil, want clean-worktree refusal") - } - msg := err.Error() - if !strings.Contains(msg, "require a clean unignored worktree") || !strings.Contains(msg, "unrelated.txt") { - t.Fatalf("error = %q, want clean-worktree refusal naming unrelated.txt", msg) - } -} - -func parentRegistryShowResponse(t *testing.T, repo string) releasePostMergeCommandResult { - t.Helper() - data, err := os.ReadFile(filepath.Join(repo, filepath.FromSlash(TargetCapabilityEvidenceRecordPath))) - if err != nil { - t.Fatalf("ReadFile(%s) error = %v", TargetCapabilityEvidenceRecordPath, err) - } - return releasePostMergeOK(string(data)) -} - -func nameStatusZ(paths ...string) string { - var b strings.Builder - for _, path := range paths { - b.WriteString("M") - b.WriteByte(0) - b.WriteString(path) - b.WriteByte(0) - } - return b.String() -} - -func TestReleasePostMergeEvidenceOnlyRepairPasses(t *testing.T) { - repo := seedReleasePostMergeFiles(t, "1.2.3") - seedReleaseCapabilityEvidence(t, repo) - receipt := "docs/changes/20260808-hooks-entry-reconciliation/research/opencode-1.18.13-isolated-request-smoke.json" - responses := releasePostMergeHappyResponses("1.2.3") - // Detect repair via HEAD^..HEAD receipt-only diff against the parent - // registry; subject + release shape come from the parent release commit; - // tag still lands on HEAD. Registry itself must not appear in the diff. - responses["git rev-parse --verify HEAD^"] = releasePostMergeOK("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") - responses["git diff --name-status --no-renames -z HEAD^ HEAD"] = releasePostMergeOK(nameStatusZ(receipt)) - responses["git show HEAD^:"+TargetCapabilityEvidenceRecordPath] = parentRegistryShowResponse(t, repo) - responses["git log -1 --pretty=%s HEAD^"] = releasePostMergeOK("chore: release v1.2.3 (#42)") - delete(responses, "git log -1 --pretty=%s") - responses["git diff HEAD~2 HEAD~1 --name-only"] = releasePostMergeOK("CHANGELOG.md\npackage.json") - - runner, calls := scriptedReleasePostMergeRunner(responses) - snap := mustResolveReleaseSnapshot(t, repo, releaseOptions{postMerge: true}) - result := checkReleasePostMergeGuardrails(repo, snap, runner) - if !result.ok { - t.Fatalf("result = %#v, want evidence-only repair to pass guardrails", result) - } - if result.featureBranch != "feat/cool-thing" { - t.Fatalf("featureBranch = %q, want PR branch extracted from release subject at HEAD^", result.featureBranch) - } - - var out, errOut bytes.Buffer - if err := runReleasePostMergeWithRunner(repo, snap, &out, &errOut, runner); err != nil { - t.Fatalf("runReleasePostMergeWithRunner error = %v\n%s\n%s", err, out.String(), errOut.String()) - } - keys := releasePostMergeCallKeys(calls()) - tagged := false - for _, key := range keys { - if strings.HasPrefix(key, "git tag -s v1.2.3") { - tagged = true - } - } - if !tagged { - t.Fatalf("calls = %v, want tag created on HEAD after evidence-only repair", keys) - } -} - -func TestReleasePostMergeRepairModifyingRegistryRefuses(t *testing.T) { - repo := seedReleasePostMergeFiles(t, "1.2.3") - seedReleaseCapabilityEvidence(t, repo) - receipt := "docs/changes/20260808-hooks-entry-reconciliation/research/opencode-1.18.13-isolated-request-smoke.json" - responses := releasePostMergeHappyResponses("1.2.3") - responses["git rev-parse --verify HEAD^"] = releasePostMergeOK("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") - // Registry in the repair commit → not receipt-only; guardrail 5 evaluates HEAD. - responses["git diff --name-status --no-renames -z HEAD^ HEAD"] = releasePostMergeOK(nameStatusZ(TargetCapabilityEvidenceRecordPath, receipt)) - responses["git diff HEAD^ HEAD --name-only"] = releasePostMergeOK(TargetCapabilityEvidenceRecordPath + "\n" + receipt) - responses["git show HEAD^:"+TargetCapabilityEvidenceRecordPath] = parentRegistryShowResponse(t, repo) - runner, _ := scriptedReleasePostMergeRunner(responses) - snap := mustResolveReleaseSnapshot(t, repo, releaseOptions{postMerge: true}) - result := checkReleasePostMergeGuardrails(repo, snap, runner) - if result.ok || result.guardrail != 5 { - t.Fatalf("result = %#v, want guardrail 5 failure when repair modifies the registry", result) - } -} - -func TestReleasePostMergeNonEvidenceRepairStillFailsDiffShape(t *testing.T) { - repo := seedReleasePostMergeFiles(t, "1.2.3") - seedReleaseCapabilityEvidence(t, repo) - receipt := "docs/changes/20260808-hooks-entry-reconciliation/research/opencode-1.18.13-isolated-request-smoke.json" - responses := releasePostMergeHappyResponses("1.2.3") - responses["git rev-parse --verify HEAD^"] = releasePostMergeOK("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") - // Touches a non-receipt path → not evidence-only; guardrail 5 evaluates HEAD. - responses["git diff --name-status --no-renames -z HEAD^ HEAD"] = releasePostMergeOK(nameStatusZ(receipt, "README.md")) - responses["git diff HEAD^ HEAD --name-only"] = releasePostMergeOK(receipt + "\nREADME.md") - responses["git show HEAD^:"+TargetCapabilityEvidenceRecordPath] = parentRegistryShowResponse(t, repo) - runner, _ := scriptedReleasePostMergeRunner(responses) - snap := mustResolveReleaseSnapshot(t, repo, releaseOptions{postMerge: true}) - result := checkReleasePostMergeGuardrails(repo, snap, runner) - if result.ok || result.guardrail != 5 { - t.Fatalf("result = %#v, want guardrail 5 failure for non-evidence repair", result) - } -} - -func TestReleasePostMergeRepairTouchingFixtureSourceRefuses(t *testing.T) { - repo := seedReleasePostMergeFiles(t, "1.2.3") - seedReleaseCapabilityEvidence(t, repo) - // level:fixture source is in the registry but is not a receipt. - fixture := "internal/cli/journal_hook_claude_test.go" - responses := releasePostMergeHappyResponses("1.2.3") - responses["git rev-parse --verify HEAD^"] = releasePostMergeOK("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") - responses["git diff --name-status --no-renames -z HEAD^ HEAD"] = releasePostMergeOK(nameStatusZ(fixture)) - responses["git diff HEAD^ HEAD --name-only"] = releasePostMergeOK(fixture) - responses["git show HEAD^:"+TargetCapabilityEvidenceRecordPath] = parentRegistryShowResponse(t, repo) - runner, _ := scriptedReleasePostMergeRunner(responses) - snap := mustResolveReleaseSnapshot(t, repo, releaseOptions{postMerge: true}) - result := checkReleasePostMergeGuardrails(repo, snap, runner) - if result.ok || result.guardrail != 5 { - t.Fatalf("result = %#v, want guardrail 5 failure for fixture-level repair path", result) - } -} - -func TestReleasePostMergeRepairWhitespacePaddedFilenameRefuses(t *testing.T) { - repo := seedReleasePostMergeFiles(t, "1.2.3") - seedReleaseCapabilityEvidence(t, repo) - receipt := "docs/changes/20260808-hooks-entry-reconciliation/research/opencode-1.18.13-isolated-request-smoke.json" - // Leading spaces must not alias the real receipt path after TrimSpace. - padded := " " + receipt - responses := releasePostMergeHappyResponses("1.2.3") - responses["git rev-parse --verify HEAD^"] = releasePostMergeOK("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") - responses["git diff --name-status --no-renames -z HEAD^ HEAD"] = releasePostMergeOK(nameStatusZ(padded)) - responses["git diff HEAD^ HEAD --name-only"] = releasePostMergeOK(padded) - responses["git show HEAD^:"+TargetCapabilityEvidenceRecordPath] = parentRegistryShowResponse(t, repo) - runner, _ := scriptedReleasePostMergeRunner(responses) - snap := mustResolveReleaseSnapshot(t, repo, releaseOptions{postMerge: true}) - result := checkReleasePostMergeGuardrails(repo, snap, runner) - if result.ok || result.guardrail != 5 { - t.Fatalf("result = %#v, want guardrail 5 failure for whitespace-padded repair path", result) - } -} - -func TestReleasePostMergeDirectReleaseCommitStillPasses(t *testing.T) { - repo := seedReleasePostMergeFiles(t, "1.2.3") - seedReleaseCapabilityEvidence(t, repo) - // No HEAD^ rev-parse success → not a repair; default happy responses. - runner, _ := scriptedReleasePostMergeRunner(releasePostMergeHappyResponses("1.2.3")) - snap := mustResolveReleaseSnapshot(t, repo, releaseOptions{postMerge: true}) - result := checkReleasePostMergeGuardrails(repo, snap, runner) - if !result.ok { - t.Fatalf("result = %#v, want direct release commit path unchanged", result) - } -} - -func TestCheckReleaseCapabilityEvidenceSymlinkRefuses(t *testing.T) { - t.Run("dangling symlink is present but unusable", func(t *testing.T) { - root := t.TempDir() - if err := os.MkdirAll(filepath.Join(root, "config"), 0o755); err != nil { - t.Fatal(err) - } - link := filepath.Join(root, filepath.FromSlash(TargetCapabilityEvidenceRecordPath)) - if err := os.Symlink(filepath.Join(root, "config", "missing-target.json"), link); err != nil { - t.Fatal(err) - } - present, err := checkReleaseCapabilityEvidence(root) - if !present { - t.Fatal("dangling symlink classified as absent; want present") - } - if err == nil || !strings.Contains(err.Error(), "not a regular file") { - t.Fatalf("error = %v, want not-a-regular-file refusal", err) - } - }) - - t.Run("symlink to a valid regular file still refuses", func(t *testing.T) { - root := t.TempDir() - if err := os.MkdirAll(filepath.Join(root, "config"), 0o755); err != nil { - t.Fatal(err) - } - target := filepath.Join(root, "config", "external.json") - // Minimal body — content is irrelevant; the probe must refuse before load. - if err := os.WriteFile(target, []byte(`{"contract_version":3,"records":[],"deferred":[{"target":"pi","status":"deferred","not_a_build_target":true,"reason":"deferred"}]`+"\n"), 0o644); err != nil { - t.Fatal(err) - } - link := filepath.Join(root, filepath.FromSlash(TargetCapabilityEvidenceRecordPath)) - if err := os.Symlink(target, link); err != nil { - t.Fatal(err) - } - present, err := checkReleaseCapabilityEvidence(root) - if !present { - t.Fatal("symlink to valid file classified as absent; want present") - } - if err == nil || !strings.Contains(err.Error(), "not a regular file") { - t.Fatalf("error = %v, want not-a-regular-file refusal", err) - } - }) - - t.Run("symlinked config directory is present but unusable", func(t *testing.T) { - root := t.TempDir() - realConfig := filepath.Join(root, "real-config") - if err := os.MkdirAll(realConfig, 0o755); err != nil { - t.Fatal(err) - } - // Valid-looking leaf behind a symlinked intermediate component. - if err := os.WriteFile(filepath.Join(realConfig, "target-capabilities.json"), []byte(`{"contract_version":3,"records":[],"deferred":[{"target":"pi","status":"deferred","not_a_build_target":true,"reason":"deferred"}]`+"\n"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.Symlink(realConfig, filepath.Join(root, "config")); err != nil { - t.Fatal(err) - } - present, err := checkReleaseCapabilityEvidence(root) - if !present { - t.Fatal("symlinked config/ classified as absent; want present-but-unusable") - } - if err == nil || !strings.Contains(err.Error(), "symlink") { - t.Fatalf("error = %v, want symlink-component refusal", err) - } - }) - - t.Run("absent remains a silent no-op", func(t *testing.T) { - present, err := checkReleaseCapabilityEvidence(t.TempDir()) - if present || err != nil { - t.Fatalf("present=%v err=%v, want absent no-op", present, err) - } - }) -} - -func TestReleaseApplyRefusesSymlinkedConfigDirectory(t *testing.T) { - repo := seedReleaseApplyRepo(t, "feat: refuse symlinked config on apply") - hardenFixtureRepoAgainstHostSigning(t, repo) - // Real evidence under a temporary real-config, then replace config/ with a symlink. - seedReleaseCapabilityEvidence(t, repo) - realConfig := filepath.Join(repo, "real-config") - if err := os.Rename(filepath.Join(repo, "config"), realConfig); err != nil { - t.Fatal(err) - } - if err := os.Symlink(realConfig, filepath.Join(repo, "config")); err != nil { - t.Fatal(err) - } - // Commit so the worktree is clean except for the symlink structure as HEAD. - // Symlink itself must be what the apply path sees for the probe. - writeFile(t, filepath.Join(repo, ".gitignore"), "bin/native/\nplugins/loaf/bin/native/\n") - gitCLI(t, repo, "add", "-A") - gitCLI(t, repo, "commit", "-m", "chore: record evidence behind symlinked config") - - err := (Runner{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"release", "--yes", "--no-tag", "--no-gh"}) - if err == nil { - t.Fatal("release error = nil, want symlinked-config refusal") - } - msg := err.Error() - if !strings.Contains(msg, "Refusing to commit release artifacts") || !strings.Contains(msg, "symlink") { - t.Fatalf("error = %q, want apply refusal naming symlink", msg) - } -} - -func TestReleaseApplyRefusesUnreferencedResearchFileTracked(t *testing.T) { - repo := seedReleaseApplyRepoWithStalingBuild(t, "feat: refuse unreferenced tracked research") - args := []string{"release", "--yes", "--no-tag", "--no-gh"} - if err := (Runner{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, WorkingDir: repo}).Run(args); err == nil { - t.Fatalf("first Run(%v) error = nil, want stale-evidence refusal", args) - } - rewriteInstalledSmokeReceiptHashes(t, repo) - - // Tracked file under research/ that no registry references. - orphan := "docs/changes/20260710-journal-reliability-foundation/research/orphan-notes.md" - writeFile(t, filepath.Join(repo, filepath.FromSlash(orphan)), "not referenced\n") - gitCLI(t, repo, "add", orphan) - // Leave it staged/dirty relative to HEAD by amending? add alone stages; status shows staged as dirty. - // Make it a committed-then-modified path so porcelain is " M" not just staged-new after we need dirt on resume. - // Simpler: keep it uncommitted tracked-new (A in index). releaseUnignoredStatusEntries sees it as tracked dirt. - // Actually `git add` of new file shows "A " in index — not untracked. deleted=false. Not in allowlist → refuse. - - err := (Runner{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, WorkingDir: repo}).Run(args) - if err == nil { - t.Fatal("resume with unreferenced tracked research file error = nil, want refusal") - } - msg := err.Error() - if !strings.Contains(msg, "require a clean unignored worktree") || !strings.Contains(msg, orphan) { - t.Fatalf("error = %q, want clean-worktree refusal naming %s", msg, orphan) - } -} - -func TestReleaseApplyRefusesUnreferencedResearchFileUntracked(t *testing.T) { - repo := seedReleaseApplyRepoWithStalingBuild(t, "feat: refuse unreferenced untracked research") - args := []string{"release", "--yes", "--no-tag", "--no-gh"} - if err := (Runner{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, WorkingDir: repo}).Run(args); err == nil { - t.Fatalf("first Run(%v) error = nil, want stale-evidence refusal", args) - } - rewriteInstalledSmokeReceiptHashes(t, repo) - - orphan := "docs/changes/20260710-journal-reliability-foundation/research/orphan-untracked.md" - writeFile(t, filepath.Join(repo, filepath.FromSlash(orphan)), "not referenced\n") - - err := (Runner{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, WorkingDir: repo}).Run(args) - if err == nil { - t.Fatal("resume with unreferenced untracked research file error = nil, want refusal") - } - msg := err.Error() - if !strings.Contains(msg, "require a clean unignored worktree") || !strings.Contains(msg, orphan) { - t.Fatalf("error = %q, want clean-worktree refusal naming %s", msg, orphan) - } -} - -func TestReleaseApplyRefusesVersionFileAtNonCandidateContent(t *testing.T) { - repo := seedReleaseApplyRepoWithStalingBuild(t, "feat: refuse non-candidate version dirt") - args := []string{"release", "--yes", "--no-tag", "--no-gh"} - if err := (Runner{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, WorkingDir: repo}).Run(args); err == nil { - t.Fatalf("first Run(%v) error = nil, want stale-evidence refusal", args) - } - rewriteInstalledSmokeReceiptHashes(t, repo) - - // Hand-edit version to something other than the candidate rendering. - writeFile(t, filepath.Join(repo, "package.json"), strings.Join([]string{ - "{", - ` "name": "release-fixture",`, - ` "version": "9.9.9",`, - ` "scripts": {`, - ` "build": "node -e \"require('fs').mkdirSync('dist/opencode/plugins',{recursive:true}); require('fs').writeFileSync('dist/opencode/plugins/hooks.ts','staled-hooks\\n')\""`, - " }", - "}", - "", - }, "\n")) - - err := (Runner{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, WorkingDir: repo}).Run(args) - if err == nil { - t.Fatal("resume with non-candidate version error = nil, want refusal") - } - msg := err.Error() - if !strings.Contains(msg, "require a clean unignored worktree") || !strings.Contains(msg, "package.json") { - t.Fatalf("error = %q, want clean-worktree refusal naming package.json", msg) - } -} - -func TestReleaseApplyAdmitsVersionFileByteEqualToCandidate(t *testing.T) { - repo := seedReleaseApplyRepoWithStalingBuild(t, "feat: admit candidate version dirt") - args := []string{"release", "--yes", "--no-tag", "--no-gh"} - if err := (Runner{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, WorkingDir: repo}).Run(args); err == nil { - t.Fatalf("first Run(%v) error = nil, want stale-evidence refusal", args) - } - // package.json already at candidate from refusal; leave it. Re-record and resume. - if !strings.Contains(string(mustReadFile(t, filepath.Join(repo, "package.json"))), `"version": "1.1.0"`) { - t.Fatal("expected package.json at candidate after refusal") - } - rewriteInstalledSmokeReceiptHashes(t, repo) - - var stdout bytes.Buffer - if err := (Runner{Stdout: &stdout, Stderr: &bytes.Buffer{}, WorkingDir: repo}).Run(args); err != nil { - t.Fatalf("resume with candidate-matching version error = %v\n%s", err, stdout.String()) - } - if subject := gitOutputReleaseTest(t, repo, "log", "-1", "--pretty=%s"); subject != "chore: release v1.1.0" { - t.Fatalf("release commit subject = %q, want chore: release v1.1.0", subject) - } -} - -func TestReleaseApplyRefusesVersionFileReplacedBySymlinkToCandidateBytes(t *testing.T) { - repo := seedReleaseApplyRepoWithStalingBuild(t, "feat: refuse version symlink admission") - args := []string{"release", "--yes", "--no-tag", "--no-gh"} - if err := (Runner{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, WorkingDir: repo}).Run(args); err == nil { - t.Fatalf("first Run(%v) error = nil, want stale-evidence refusal", args) - } - beforeHEAD := gitOutputReleaseTest(t, repo, "rev-parse", "HEAD") - candidateBody := mustReadFile(t, filepath.Join(repo, "package.json")) - if !bytes.Contains(candidateBody, []byte(`"version": "1.1.0"`)) { - t.Fatalf("after refusal package.json = %s, want candidate version", candidateBody) - } - rewriteInstalledSmokeReceiptHashes(t, repo) - - // External file holds the exact candidate bytes; version path becomes a symlink. - external := filepath.Join(t.TempDir(), "external-package.json") - if err := os.WriteFile(external, candidateBody, 0o644); err != nil { - t.Fatal(err) - } - pkgPath := filepath.Join(repo, "package.json") - if err := os.Remove(pkgPath); err != nil { - t.Fatal(err) - } - if err := os.Symlink(external, pkgPath); err != nil { - t.Fatal(err) - } - - err := (Runner{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, WorkingDir: repo}).Run(args) - if err == nil { - t.Fatal("resume with symlinked version file error = nil, want refusal") - } - msg := err.Error() - if !strings.Contains(msg, "require a clean unignored worktree") || !strings.Contains(msg, "package.json") { - t.Fatalf("error = %q, want clean-worktree refusal naming package.json", msg) - } - if head := gitOutputReleaseTest(t, repo, "rev-parse", "HEAD"); head != beforeHEAD { - t.Fatalf("refused resume moved HEAD from %s to %s", beforeHEAD, head) - } - info, lerr := os.Lstat(pkgPath) - if lerr != nil { - t.Fatal(lerr) - } - if info.Mode()&os.ModeSymlink == 0 { - t.Fatal("package.json was restored or rewritten; want symlink left in place") - } - // Nothing committed: HEAD package.json must still be a regular blob, not a symlink. - mode := gitOutputReleaseTest(t, repo, "ls-tree", "HEAD", "--", "package.json") - if !strings.HasPrefix(strings.Fields(mode)[0], "100") { - t.Fatalf("HEAD package.json mode = %q, want regular blob", mode) - } -} - -func TestReleaseUnignoredStatusEntriesClassifiesTypechange(t *testing.T) { - repo := seedReleaseApplyRepoWithStalingBuild(t, "feat: classify porcelain T") - // Establish a regular tracked version file, then replace with a symlink so - // porcelain reports T (typechange). - pkgPath := filepath.Join(repo, "package.json") - body := mustReadFile(t, pkgPath) - external := filepath.Join(t.TempDir(), "ext.json") - if err := os.WriteFile(external, body, 0o644); err != nil { - t.Fatal(err) - } - if err := os.Remove(pkgPath); err != nil { - t.Fatal(err) - } - if err := os.Symlink(external, pkgPath); err != nil { - t.Fatal(err) - } - - entries, err := releaseUnignoredStatusEntries(repo, "package.json") - if err != nil { - t.Fatalf("releaseUnignoredStatusEntries: %v", err) - } - var found *releaseStatusEntry - for i := range entries { - if entries[i].path == "package.json" { - found = &entries[i] - break - } - } - if found == nil { - t.Fatalf("entries = %+v, want package.json", entries) - } - if !found.typechange { - t.Fatalf("package.json entry = %+v, want typechange=true", *found) - } - if found.deleted || found.untracked { - t.Fatalf("package.json entry = %+v, want only typechange", *found) - } - - // Classification must refuse the typechanged version path by name. - err = requireReleaseCleanWorktree(repo, releaseOptions{}) - if err == nil { - t.Fatal("requireReleaseCleanWorktree error = nil, want typechange refusal") - } - msg := err.Error() - if !strings.Contains(msg, "require a clean unignored worktree") || !strings.Contains(msg, "package.json") { - t.Fatalf("error = %q, want clean-worktree refusal naming package.json", msg) - } -} - -func TestReleaseApplyRefusesVersionFileExecutableBitFlip(t *testing.T) { - repo := seedReleaseApplyRepoWithStalingBuild(t, "feat: refuse version mode flip") - args := []string{"release", "--yes", "--no-tag", "--no-gh"} - if err := (Runner{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, WorkingDir: repo}).Run(args); err == nil { - t.Fatalf("first Run(%v) error = nil, want stale-evidence refusal", args) - } - beforeHEAD := gitOutputReleaseTest(t, repo, "rev-parse", "HEAD") - pkgPath := filepath.Join(repo, "package.json") - if !strings.Contains(string(mustReadFile(t, pkgPath)), `"version": "1.1.0"`) { - t.Fatal("expected package.json at candidate after refusal") - } - rewriteInstalledSmokeReceiptHashes(t, repo) - - // Flip executable bit only; candidate bytes stay byte-identical. - if err := os.Chmod(pkgPath, 0o755); err != nil { - t.Fatal(err) - } - info, err := os.Lstat(pkgPath) - if err != nil { - t.Fatal(err) - } - if info.Mode().Perm()&0o111 == 0 { - t.Fatal("chmod +x did not set executable bit") - } - headMode, err := releaseGitHeadBlobMode(repo, "package.json") - if err != nil { - t.Fatal(err) - } - if headMode != "100644" { - t.Fatalf("HEAD package.json mode = %q, want 100644 for this fixture", headMode) - } - if releaseWorktreeBlobMode(info) == headMode { - t.Fatal("worktree mode still matches HEAD after +x; test setup broken") - } - - runErr := (Runner{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, WorkingDir: repo}).Run(args) - if runErr == nil { - t.Fatal("resume with executable-bit-flipped version file error = nil, want refusal") - } - msg := runErr.Error() - if !strings.Contains(msg, "require a clean unignored worktree") || !strings.Contains(msg, "package.json") { - t.Fatalf("error = %q, want clean-worktree refusal naming package.json", msg) - } - if head := gitOutputReleaseTest(t, repo, "rev-parse", "HEAD"); head != beforeHEAD { - t.Fatalf("refused resume moved HEAD from %s to %s", beforeHEAD, head) - } -} - -func TestReleaseApplyRefusesDirtyChangelogOnRerun(t *testing.T) { - repo := seedReleaseApplyRepoWithStalingBuild(t, "feat: refuse dirty changelog on resume") - args := []string{"release", "--yes", "--no-tag", "--no-gh"} - if err := (Runner{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, WorkingDir: repo}).Run(args); err == nil { - t.Fatalf("first Run(%v) error = nil, want stale-evidence refusal", args) - } - rewriteInstalledSmokeReceiptHashes(t, repo) - - // Operator (or hand) dirties CHANGELOG after refusal restored it — sacred. - writeFile(t, filepath.Join(repo, "CHANGELOG.md"), strings.Join([]string{ - "# Changelog", - "", - "## [Unreleased]", - "", - "- hand curated entry that must not be clobbered", - "", - }, "\n")) - - err := (Runner{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, WorkingDir: repo}).Run(args) - if err == nil { - t.Fatal("resume with dirty CHANGELOG error = nil, want refusal") - } - msg := err.Error() - if !strings.Contains(msg, "require a clean unignored worktree") || !strings.Contains(msg, "CHANGELOG.md") { - t.Fatalf("error = %q, want clean-worktree refusal naming CHANGELOG.md", msg) - } - // And the hand-curated content must still be on disk (never restored by classification). - body := string(mustReadFile(t, filepath.Join(repo, "CHANGELOG.md"))) - if !strings.Contains(body, "hand curated entry that must not be clobbered") { - t.Fatalf("CHANGELOG.md was altered by the refused resume; body = %q", body) - } -} - -func TestReleaseApplyRefusesDeletedTrackedFile(t *testing.T) { - repo := seedReleaseApplyRepoWithStalingBuild(t, "feat: refuse deleted tracked file") - args := []string{"release", "--yes", "--no-tag", "--no-gh"} - if err := (Runner{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, WorkingDir: repo}).Run(args); err == nil { - t.Fatalf("first Run(%v) error = nil, want stale-evidence refusal", args) - } - rewriteInstalledSmokeReceiptHashes(t, repo) - - if err := os.Remove(filepath.Join(repo, "feature.txt")); err != nil { - t.Fatal(err) - } - - err := (Runner{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, WorkingDir: repo}).Run(args) - if err == nil { - t.Fatal("resume with deleted tracked file error = nil, want refusal") - } - msg := err.Error() - if !strings.Contains(msg, "require a clean unignored worktree") || !strings.Contains(msg, "feature.txt") { - t.Fatalf("error = %q, want clean-worktree refusal naming feature.txt", msg) - } -} - -func TestReleaseParseNameStatusZ(t *testing.T) { - paths, ok := releaseParseNameStatusZ(nameStatusZ("a.json", "b.json")) - if !ok || len(paths) != 2 || paths[0] != "a.json" || paths[1] != "b.json" { - t.Fatalf("paths=%v ok=%v", paths, ok) - } - // Whitespace-padded path is preserved (not trimmed). - padded := "M\x00 padded.json\x00" - paths, ok = releaseParseNameStatusZ(padded) - if !ok || len(paths) != 1 || paths[0] != " padded.json" { - t.Fatalf("padded paths=%v ok=%v", paths, ok) - } - // Type-change / delete / rename statuses refuse. - for _, raw := range []string{"T\x00x\x00", "D\x00x\x00", "R100\x00new\x00old\x00"} { - if _, ok := releaseParseNameStatusZ(raw); ok { - t.Fatalf("raw %q parsed ok, want reject", raw) - } - } -} - -func mustReadFile(t *testing.T, path string) []byte { - t.Helper() - body, err := os.ReadFile(path) - if err != nil { - t.Fatalf("ReadFile(%s) error = %v", path, err) - } - return body -} diff --git a/internal/cli/release_flow_advisory.go b/internal/cli/release_flow_advisory.go deleted file mode 100644 index 6f98637b2..000000000 --- a/internal/cli/release_flow_advisory.go +++ /dev/null @@ -1,40 +0,0 @@ -package cli - -import ( - "fmt" - "io" - "strings" -) - -// releaseFlowAdvisoryText names the sanctioned release-PR flow at the decision point where a direct mutating invocation is about to skip it. Advisory only, never blocking. -const releaseFlowAdvisoryText = "releases are prepared on a release branch: loaf release --pre-merge there, squash-merge the release PR, then loaf release --post-merge here; PR CI verifies the prepared tree so evidence canaries surface before tagging. Proceeding directly skips that." - -// resolveReleaseDefaultBranch resolves the repository default branch from local git state only — refs/remotes/origin/HEAD when present, then a local main or master branch. A release ceremony must not add a network dependency to print advice. -func resolveReleaseDefaultBranch(root string) string { - if symRef := releaseCommandOutput(root, "git", "symbolic-ref", "refs/remotes/origin/HEAD"); strings.HasPrefix(symRef, "refs/remotes/origin/") { - return strings.TrimPrefix(symRef, "refs/remotes/origin/") - } - for _, candidate := range []string{"main", "master"} { - if releaseCommandOK(root, "git", "show-ref", "--verify", "--quiet", "refs/heads/"+candidate) { - return candidate - } - } - return "" -} - -// releaseInvocationWantsFlowAdvisory reports whether this invocation is a mutating release mode (interactive or --bump) on the repository default branch outside the two-phase flags. Read-only modes and the two-phase flags never advise. -func releaseInvocationWantsFlowAdvisory(root string, options releaseOptions) bool { - if options.help || options.dryRun || options.preMerge || options.postMerge { - return false - } - defaultBranch := resolveReleaseDefaultBranch(root) - if defaultBranch == "" { - return false - } - return releaseCommandOutput(root, "git", "symbolic-ref", "--short", "HEAD") == defaultBranch -} - -// printReleaseFlowAdvisory emits the advisory as one short paragraph before the analysis phase. -func printReleaseFlowAdvisory(out io.Writer) { - fmt.Fprintf(out, " %s %s\n\n", ansiYellow("advisory:"), releaseFlowAdvisoryText) -} diff --git a/internal/cli/release_flow_advisory_test.go b/internal/cli/release_flow_advisory_test.go deleted file mode 100644 index bdc42fb4e..000000000 --- a/internal/cli/release_flow_advisory_test.go +++ /dev/null @@ -1,318 +0,0 @@ -package cli - -import ( - "bytes" - "path/filepath" - "strings" - "testing" -) - -const releaseFlowAdvisoryProbe = "releases are prepared on a release branch" - -func TestReleaseFlowAdvisory(t *testing.T) { - t.Run("prints for interactive mutating run on the default branch", func(t *testing.T) { - repo := seedReleaseApplyRepo(t, "feat: advise the sanctioned flow") - var stdout bytes.Buffer - err := Runner{ - Stdout: &stdout, - Stdin: strings.NewReader("n\n"), - WorkingDir: repo, - }.Run([]string{"release", "--no-tag", "--no-gh"}) - if err != nil { - t.Fatalf("interactive release error = %v\n%s", err, stdout.String()) - } - output := stdout.String() - if !strings.Contains(output, releaseFlowAdvisoryProbe) { - t.Fatalf("stdout = %q, want flow advisory", output) - } - if !strings.Contains(output, "--pre-merge") || !strings.Contains(output, "--post-merge") || !strings.Contains(output, "squash-merge the release PR") { - t.Fatalf("stdout = %q, want advisory naming the two-phase sequence", output) - } - if advisoryIndex, analyzingIndex := strings.Index(output, releaseFlowAdvisoryProbe), strings.Index(output, "Analyzing"); analyzingIndex != -1 && advisoryIndex > analyzingIndex { - t.Fatalf("stdout = %q, want advisory before the analysis phase", output) - } - }) - - t.Run("prints for bump mutating run on the default branch", func(t *testing.T) { - repo := seedReleaseApplyRepo(t, "feat: advise the sanctioned flow for bump") - var stdout bytes.Buffer - err := Runner{ - Stdout: &stdout, - Stdin: strings.NewReader("n\n"), - WorkingDir: repo, - }.Run([]string{"release", "--bump", "patch", "--no-tag", "--no-gh"}) - if err != nil { - t.Fatalf("release --bump error = %v\n%s", err, stdout.String()) - } - if !strings.Contains(stdout.String(), releaseFlowAdvisoryProbe) { - t.Fatalf("stdout = %q, want flow advisory", stdout.String()) - } - }) - - t.Run("absent for dry run", func(t *testing.T) { - repo := seedReleaseApplyRepo(t, "feat: keep dry run silent") - var stdout bytes.Buffer - err := Runner{ - Stdout: &stdout, - WorkingDir: repo, - }.Run([]string{"release", "--dry-run"}) - if err != nil { - t.Fatalf("release --dry-run error = %v\n%s", err, stdout.String()) - } - if strings.Contains(stdout.String(), releaseFlowAdvisoryProbe) { - t.Fatalf("stdout = %q, advisory must not print for --dry-run", stdout.String()) - } - }) - - t.Run("absent for pre-merge", func(t *testing.T) { - repo := seedReleaseApplyRepo(t, "feat: keep pre-merge silent") - var stdout, stderr bytes.Buffer - err := Runner{ - Stdout: &stdout, - Stderr: &stderr, - Stdin: strings.NewReader("n\n"), - WorkingDir: repo, - }.Run([]string{"release", "--pre-merge", "--base", "v1.0.0"}) - if err != nil { - t.Fatalf("release --pre-merge error = %v\nstdout:\n%s\nstderr:\n%s", err, stdout.String(), stderr.String()) - } - if strings.Contains(stdout.String(), releaseFlowAdvisoryProbe) || strings.Contains(stderr.String(), releaseFlowAdvisoryProbe) { - t.Fatalf("stdout = %q stderr = %q, advisory must not print for --pre-merge", stdout.String(), stderr.String()) - } - }) - - t.Run("predicate excludes two-phase and read-only modes on the default branch", func(t *testing.T) { - repo := seedReleaseApplyRepo(t, "feat: gate the predicate") - cases := []struct { - name string - options releaseOptions - want bool - }{ - {name: "interactive", options: releaseOptions{}, want: true}, - {name: "bump", options: releaseOptions{bump: "patch"}, want: true}, - {name: "dry-run", options: releaseOptions{dryRun: true}, want: false}, - {name: "pre-merge", options: releaseOptions{preMerge: true}, want: false}, - {name: "post-merge", options: releaseOptions{postMerge: true}, want: false}, - {name: "help", options: releaseOptions{help: true}, want: false}, - } - for _, tc := range cases { - if got := releaseInvocationWantsFlowAdvisory(repo, tc.options); got != tc.want { - t.Fatalf("releaseInvocationWantsFlowAdvisory(%s) = %v, want %v", tc.name, got, tc.want) - } - } - }) - - t.Run("absent on a non-default branch", func(t *testing.T) { - repo := seedReleaseApplyRepo(t, "feat: keep release branches silent") - gitCLI(t, repo, "checkout", "-b", "release/v1.1.0") - var stdout bytes.Buffer - err := Runner{ - Stdout: &stdout, - Stdin: strings.NewReader("n\n"), - WorkingDir: repo, - }.Run([]string{"release", "--bump", "patch", "--no-tag", "--no-gh"}) - if err != nil { - t.Fatalf("release --bump on feature branch error = %v\n%s", err, stdout.String()) - } - if strings.Contains(stdout.String(), releaseFlowAdvisoryProbe) { - t.Fatalf("stdout = %q, advisory must not print off the default branch", stdout.String()) - } - }) - - t.Run("default branch resolves from local refs only", func(t *testing.T) { - repo := seedReleaseApplyRepo(t, "feat: resolve default branch locally") - if got := resolveReleaseDefaultBranch(repo); got != "main" { - t.Fatalf("resolveReleaseDefaultBranch = %q, want main fallback", got) - } - head := gitOutputReleaseTest(t, repo, "rev-parse", "HEAD") - gitCLI(t, repo, "update-ref", "refs/remotes/origin/trunk", head) - gitCLI(t, repo, "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/trunk") - if got := resolveReleaseDefaultBranch(repo); got != "trunk" { - t.Fatalf("resolveReleaseDefaultBranch = %q, want trunk via refs/remotes/origin/HEAD", got) - } - if releaseInvocationWantsFlowAdvisory(repo, releaseOptions{}) { - t.Fatalf("predicate = true on main while origin/HEAD names trunk, want false") - } - }) - - t.Run("predicate is false when no default branch resolves", func(t *testing.T) { - repo := seedReleaseApplyRepo(t, "feat: tolerate unknown default branch") - gitCLI(t, repo, "branch", "-m", "main", "develop") - if got := resolveReleaseDefaultBranch(repo); got != "" { - t.Fatalf("resolveReleaseDefaultBranch = %q, want empty without origin/HEAD or main/master", got) - } - if releaseInvocationWantsFlowAdvisory(repo, releaseOptions{}) { - t.Fatalf("predicate = true without a resolvable default branch, want false") - } - }) - - t.Run("prints once when preflight blocks on the default branch", func(t *testing.T) { - // Incomplete stable cohort blocks before apply analysis; the advisory - // must still print exactly once from the runRelease entry path. - repo := seedReleaseApplyRepo(t, "feat: preflight still names the door") - dir := writeNewLayoutChange(t, repo, "20260727-preflight-advisory", "preflight-advisory", "1.1.0", "") - task := filepath.Join(dir, "tasks", "TASK-001-work.md") - unchecked := "---\nchange: preflight-advisory\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [ ] Do it\n" - writeFile(t, task, unchecked) - gitCLI(t, repo, "add", ".") - gitCLI(t, repo, "commit", "-m", "docs: shape incomplete stable cohort") - - var stdout bytes.Buffer - err := Runner{ - Stdout: &stdout, - Stderr: &bytes.Buffer{}, - WorkingDir: repo, - }.Run([]string{"release", "--bump", "minor", "--yes", "--no-tag", "--no-gh"}) - if err == nil { - t.Fatalf("release error = nil, want cohort preflight block") - } - if !strings.Contains(err.Error(), "targets 1.1.0 but is not executed") { - t.Fatalf("error = %v, want incomplete cohort preflight block", err) - } - output := stdout.String() - if !strings.Contains(output, releaseFlowAdvisoryProbe) { - t.Fatalf("stdout = %q, want flow advisory before preflight failure", output) - } - if count := strings.Count(output, releaseFlowAdvisoryProbe); count != 1 { - t.Fatalf("stdout advisory count = %d, want exactly once\n%s", count, output) - } - if strings.Contains(output, "Analyzing") { - t.Fatalf("stdout = %q, preflight block must not reach apply analysis", output) - } - }) -} - -func TestReleaseGuardrailRemediation(t *testing.T) { - t.Run("clean worktree refusal names the pre-merge flow", func(t *testing.T) { - repo := seedReleaseApplyRepo(t, "feat: route curation to the release branch") - writeFile(t, filepath.Join(repo, "notes.txt"), "uncommitted curation\n") - err := (Runner{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"release", "--bump", "patch", "--yes", "--no-tag", "--no-gh"}) - if err == nil { - t.Fatalf("release on dirty worktree error = nil, want refusal") - } - msg := err.Error() - if !strings.Contains(msg, "require a clean unignored worktree") { - t.Fatalf("error = %q, want clean-worktree refusal", msg) - } - if !strings.Contains(msg, "changelog curation belongs on a release branch in the --pre-merge flow") { - t.Fatalf("error = %q, want the pre-merge flow named as where curation belongs", msg) - } - if !strings.Contains(msg, "notes.txt") { - t.Fatalf("error = %q, want dirty path listed", msg) - } - }) - - t.Run("unpushed local tag keeps deletion advice", func(t *testing.T) { - repo := seedReleasePostMergeFiles(t, "1.2.3") - responses := releasePostMergeHappyResponses("1.2.3") - responses["git tag --list v1.2.3"] = releasePostMergeOK("v1.2.3") - runner, _ := scriptedReleasePostMergeRunner(responses) - snap := mustResolveReleaseSnapshot(t, repo, releaseOptions{postMerge: true}) - - result := checkReleasePostMergeGuardrails(repo, snap, runner) - if result.ok || result.guardrail != 7 { - t.Fatalf("result = %#v, want guardrail 7 failure", result) - } - if result.message != "tag v1.2.3 already exists locally — run `git tag -d v1.2.3` and rerun" { - t.Fatalf("message = %q, want unpushed local tag deletion advice", result.message) - } - }) - - t.Run("pushed local tag never gets deletion advice", func(t *testing.T) { - repo := seedReleasePostMergeFiles(t, "1.2.3") - responses := releasePostMergeHappyResponses("1.2.3") - responses["git tag --list v1.2.3"] = releasePostMergeOK("v1.2.3") - responses["git ls-remote --tags origin refs/tags/v1.2.3"] = releasePostMergeOK("deadbeef\trefs/tags/v1.2.3") - runner, _ := scriptedReleasePostMergeRunner(responses) - snap := mustResolveReleaseSnapshot(t, repo, releaseOptions{postMerge: true}) - - result := checkReleasePostMergeGuardrails(repo, snap, runner) - if result.ok || result.guardrail != 7 { - t.Fatalf("result = %#v, want guardrail 7 failure", result) - } - if !strings.Contains(result.message, "tag v1.2.3 already exists and is pushed") || !strings.Contains(result.message, "do not delete a published tag") { - t.Fatalf("message = %q, want pushed-tag non-destructive remediation", result.message) - } - if !strings.Contains(result.message, "re-run the Release workflow or recreate the release from the existing tag") { - t.Fatalf("message = %q, want the non-destructive repair named", result.message) - } - if strings.Contains(result.message, "tag -d") { - t.Fatalf("message = %q, must never advise deleting a pushed tag", result.message) - } - }) - - t.Run("remote lookup failure degrades to the local wording", func(t *testing.T) { - repo := seedReleasePostMergeFiles(t, "1.2.3") - responses := releasePostMergeHappyResponses("1.2.3") - responses["git tag --list v1.2.3"] = releasePostMergeOK("v1.2.3") - responses["git ls-remote --tags origin refs/tags/v1.2.3"] = releasePostMergeExit(128) - runner, _ := scriptedReleasePostMergeRunner(responses) - snap := mustResolveReleaseSnapshot(t, repo, releaseOptions{postMerge: true}) - - result := checkReleasePostMergeGuardrails(repo, snap, runner) - if result.ok || result.guardrail != 7 { - t.Fatalf("result = %#v, want guardrail 7 failure", result) - } - if result.message != "tag v1.2.3 already exists locally — run `git tag -d v1.2.3` and rerun" { - t.Fatalf("message = %q, want degraded local wording when the remote lookup fails", result.message) - } - }) - - t.Run("local tag with failed remote lookup still defers to an existing GH release", func(t *testing.T) { - // Masking combination: local tag exists, ls-remote fails (remote unknown), - // but gh release view succeeds — must never advise git tag -d. - repo := seedReleasePostMergeFiles(t, "1.2.3") - responses := releasePostMergeHappyResponses("1.2.3") - responses["git tag --list v1.2.3"] = releasePostMergeOK("v1.2.3") - responses["git ls-remote --tags origin refs/tags/v1.2.3"] = releasePostMergeExit(128) - responses["gh release view v1.2.3"] = releasePostMergeOK("v1.2.3") - runner, _ := scriptedReleasePostMergeRunner(responses) - snap := mustResolveReleaseSnapshot(t, repo, releaseOptions{postMerge: true}) - - result := checkReleasePostMergeGuardrails(repo, snap, runner) - if result.ok || result.guardrail != 7 { - t.Fatalf("result = %#v, want guardrail 7 failure", result) - } - if !strings.Contains(result.message, "GH release v1.2.3 already exists") || !strings.Contains(result.message, "do not delete a published release") { - t.Fatalf("message = %q, want non-destructive GH release remediation", result.message) - } - if strings.Contains(result.message, "git tag -d") { - t.Fatalf("message = %q, must not advise deletion when a GH release exists", result.message) - } - }) - - t.Run("remote-only tag gets non-destructive advice", func(t *testing.T) { - repo := seedReleasePostMergeFiles(t, "1.2.3") - responses := releasePostMergeHappyResponses("1.2.3") - responses["git ls-remote --tags origin refs/tags/v1.2.3"] = releasePostMergeOK("deadbeef\trefs/tags/v1.2.3") - runner, _ := scriptedReleasePostMergeRunner(responses) - snap := mustResolveReleaseSnapshot(t, repo, releaseOptions{postMerge: true}) - - result := checkReleasePostMergeGuardrails(repo, snap, runner) - if result.ok || result.guardrail != 7 { - t.Fatalf("result = %#v, want guardrail 7 failure", result) - } - if !strings.Contains(result.message, "tag v1.2.3 already exists on remote") || !strings.Contains(result.message, "do not delete a published tag") { - t.Fatalf("message = %q, want remote-tag non-destructive remediation", result.message) - } - if strings.Contains(result.message, ":refs/tags/") || strings.Contains(result.message, "tag -d") { - t.Fatalf("message = %q, must never advise deleting a pushed tag", result.message) - } - }) - - t.Run("existing GH release gets non-destructive advice", func(t *testing.T) { - repo := seedReleasePostMergeFiles(t, "1.2.3") - responses := releasePostMergeHappyResponses("1.2.3") - responses["gh release view v1.2.3"] = releasePostMergeOK("v1.2.3 draft") - runner, _ := scriptedReleasePostMergeRunner(responses) - snap := mustResolveReleaseSnapshot(t, repo, releaseOptions{postMerge: true}) - - result := checkReleasePostMergeGuardrails(repo, snap, runner) - if result.ok || result.guardrail != 7 { - t.Fatalf("result = %#v, want guardrail 7 failure", result) - } - if !strings.Contains(result.message, "GH release v1.2.3 already exists") || !strings.Contains(result.message, "do not delete a published release") { - t.Fatalf("message = %q, want non-destructive GH release remediation", result.message) - } - }) -} diff --git a/internal/cli/release_post_merge.go b/internal/cli/release_post_merge.go deleted file mode 100644 index 88be63846..000000000 --- a/internal/cli/release_post_merge.go +++ /dev/null @@ -1,497 +0,0 @@ -package cli - -import ( - "errors" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "regexp" - "strings" -) - -type releasePostMergeCommandResult struct { - // stdout is the command output; callers that need human-facing trim may - // TrimSpace themselves. For NUL-delimited git payloads, prefer raw. - stdout string - // raw is the exact untrimmed stdout. When empty, rawOutput falls back to - // stdout. The scripted test seam sets both so NUL bytes survive. - raw string - exitCode int - notFound bool -} - -// rawOutput returns the untrimmed command stdout for binary-safe parsers. -func (r releasePostMergeCommandResult) rawOutput() string { - if r.raw != "" { - return r.raw - } - return r.stdout -} - -type releasePostMergeCommandRunner func(root string, name string, args ...string) releasePostMergeCommandResult - -type releasePostMergeResult struct { - ok bool - guardrail int - message string - version string - base string - featureBranch string - changelogBody string - versionFiles []releaseVersionFile -} - -type releasePostMergeActionResult struct { - tagged bool - pushed bool - released bool - pulled bool - deletedLocal *bool - deletedRemote *bool -} - -var releasePRSuffixRE = regexp.MustCompile(`\(#(\d+)\)\s*$`) - -func runReleasePostMerge(root string, snapshot releaseSnapshot, out io.Writer, errOut io.Writer) error { - return runReleasePostMergeWithRunner(root, snapshot, out, errOut, defaultReleasePostMergeCommandRunner) -} - -func runReleasePostMergeWithRunner(root string, snapshot releaseSnapshot, out io.Writer, errOut io.Writer, runner releasePostMergeCommandRunner) error { - fmt.Fprintf(out, "\n%s\n\n", ansiBold("loaf release")) - fmt.Fprintf(out, " %s...\n\n", ansiCyan("Verifying post-merge state")) - - result := checkReleasePostMergeGuardrails(root, snapshot, runner) - if !result.ok { - return fmt.Errorf("guardrail %d failed: %s", result.guardrail, result.message) - } - - fmt.Fprintf(out, " %s All 9 guardrails passed for %s on %s\n", ansiGreen("✓"), ansiBold("v"+result.version), ansiBold(result.base)) - if result.featureBranch != "" { - fmt.Fprintf(out, " %s %s\n", ansiGray("feature branch:"), result.featureBranch) - } - fmt.Fprintln(out) - fmt.Fprintf(out, " %s\n", ansiBold("Executing:")) - - if _, err := executeReleasePostMergeActions(root, result, runner, out, errOut); err != nil { - return err - } - - fmt.Fprintln(out) - fmt.Fprintf(out, " %s Release v%s finalized\n\n", ansiGreen("✓"), result.version) - return nil -} - -func checkReleasePostMergeGuardrails(root string, snapshot releaseSnapshot, runner releasePostMergeCommandRunner) releasePostMergeResult { - if dirty := checkReleasePostMergeCleanWorktree(root, runner); dirty != "" { - return releasePostMergeAbort(1, dirty) - } - - currentResult := runner(root, "git", "symbolic-ref", "--short", "HEAD") - if currentResult.exitCode != 0 || strings.TrimSpace(currentResult.stdout) == "" { - return releasePostMergeAbort(2, "detached HEAD — checkout the base branch and rerun") - } - current := strings.TrimSpace(currentResult.stdout) - - if accountAbort := checkReleasePostMergeGitHubAccount(root, runner); accountAbort != "" { - return releasePostMergeAbort(2, accountAbort) - } - - base, err := detectReleasePostMergeBase(root, runner) - if err != nil { - return releasePostMergeAbort(2, err.Error()) - } - if branchAbort := checkReleasePostMergeOnBase(root, runner, base, current); branchAbort != "" { - return releasePostMergeAbort(2, branchAbort) - } - - // An evidence-only repair commit atop the release commit is the recovery - // path for guardrail 9: subject and diff-shape checks evaluate the release - // commit at HEAD^; HEAD itself stays untagged and receives the new tag. - evidenceRepair := releaseIsEvidenceOnlyRepairCommit(root, runner) - - subjectArgs := []string{"log", "-1", "--pretty=%s"} - if evidenceRepair { - subjectArgs = append(subjectArgs, "HEAD^") - } - subjectResult := runner(root, "git", subjectArgs...) - if subjectResult.exitCode != 0 { - return releasePostMergeAbort(3, "could not read HEAD subject") - } - prNumber := "" - if match := releasePRSuffixRE.FindStringSubmatch(strings.TrimSpace(subjectResult.stdout)); match != nil { - prNumber = match[1] - } - - if snapshot.Candidate == "" { - return releasePostMergeAbort(4, "release snapshot was not resolved before post-merge") - } - if err := assertReleaseSnapshotStillCurrent(root, snapshot); err != nil { - return releasePostMergeAbort(4, err.Error()) - } - versionFiles := snapshot.VersionFiles - prepared, versionAbort := detectReleasePostMergeConsistentVersion(versionFiles) - if versionAbort != "" { - return releasePostMergeAbort(4, versionAbort) - } - versionFilesAtCandidate := snapshot.Candidate == prepared - if !versionFilesAtCandidate { - return releasePostMergeAbort(4, fmt.Sprintf("tag version %s does not match version-file version %s", snapshot.Candidate, prepared)) - } - - diffFrom, diffTo := "HEAD^", "HEAD" - if evidenceRepair { - // Release commit is the parent of the repair commit. - diffFrom, diffTo = "HEAD~2", "HEAD~1" - } - // Guardrail 4 refused every other version-file state above, so what reaches - // guardrail 5 is a proof, not a hope: the release commit may be changelog-only. - if diffAbort := checkReleasePostMergeDiffFiles(root, runner, versionFiles, diffFrom, diffTo, versionFilesAtCandidate); diffAbort != "" { - return releasePostMergeAbort(5, diffAbort) - } - - changelogBody, changelogAbort := checkReleasePostMergeChangelogSection(root, snapshot.Candidate) - if changelogAbort != "" { - return releasePostMergeAbort(6, changelogAbort) - } - - if collisionAbort := checkReleasePostMergeNoExistingTagOrRelease(root, runner, snapshot.Candidate); collisionAbort != "" { - return releasePostMergeAbort(7, collisionAbort) - } - - if taggedAbort := checkReleasePostMergeHeadNotTagged(root, runner); taggedAbort != "" { - return releasePostMergeAbort(8, taggedAbort) - } - - if _, evidenceErr := checkReleaseCapabilityEvidence(root); evidenceErr != nil { - return releasePostMergeAbort(9, releasePostMergeCapabilityEvidenceAbortMessage(evidenceErr)) - } - - featureBranch := "" - if prNumber != "" { - featureBranch = lookupReleasePostMergeFeatureBranch(root, runner, prNumber) - } - - return releasePostMergeResult{ - ok: true, - version: snapshot.Candidate, - base: base, - featureBranch: featureBranch, - changelogBody: strings.Join(changelogBody, "\n"), - versionFiles: versionFiles, - } -} - -func releasePostMergeAbort(guardrail int, message string) releasePostMergeResult { - return releasePostMergeResult{ok: false, guardrail: guardrail, message: message} -} - -func checkReleasePostMergeCleanWorktree(root string, runner releasePostMergeCommandRunner) string { - result := runner(root, "git", "status", "--porcelain") - if result.exitCode != 0 { - return "could not read git status — is this a git repository?" - } - if strings.TrimSpace(result.stdout) != "" { - return "uncommitted changes detected — commit or stash before rerunning" - } - return "" -} - -func detectReleasePostMergeBase(root string, runner releasePostMergeCommandRunner) (string, error) { - if config := strings.TrimSpace(runner(root, "git", "config", "--get", "loaf.release.base").stdout); config != "" { - return config, nil - } - if defaultBranch := strings.TrimSpace(runner(root, "gh", "repo", "view", "--json", "defaultBranchRef", "-q", ".defaultBranchRef.name").stdout); defaultBranch != "" { - return defaultBranch, nil - } - symRef := strings.TrimSpace(runner(root, "git", "symbolic-ref", "refs/remotes/origin/HEAD").stdout) - if strings.HasPrefix(symRef, "refs/remotes/origin/") { - return strings.TrimPrefix(symRef, "refs/remotes/origin/"), nil - } - return "", fmt.Errorf("Could not auto-detect base branch. Pass --base <ref> explicitly, or set git config loaf.release.base <ref>") -} - -func checkReleasePostMergeOnBase(root string, runner releasePostMergeCommandRunner, base string, current string) string { - if current == base { - return "" - } - result := runner(root, "git", "merge-base", "--is-ancestor", current, base) - if result.exitCode == 0 { - return "" - } - return fmt.Sprintf("current branch %s is not the base branch %s — checkout %s and rerun", current, base, base) -} - -func detectReleasePostMergeConsistentVersion(files []releaseVersionFile) (string, string) { - if len(files) == 0 { - return "", "no version files detected at HEAD — cannot verify version match" - } - version := files[0].CurrentVersion - var mismatches []string - for _, file := range files[1:] { - if file.CurrentVersion != version { - mismatches = append(mismatches, fmt.Sprintf("%s reports %s, expected %s from %s", file.RelativePath, file.CurrentVersion, version, files[0].RelativePath)) - } - } - if len(mismatches) > 0 { - return "", "version mismatch in version file(s):\n " + strings.Join(mismatches, "\n ") - } - return version, "" -} - -// checkReleasePostMergeDiffFiles reads the release commit's diff and refuses a -// shape that is not a release. versionFilesAtCandidate is the caller's -// attestation that every version file already reports the version being tagged -// — guardrail 4's verdict. Under it, a self-carrying release (one whose version -// flip landed earlier as Change content) leaves the release commit nothing to -// diff in a version file, and demanding one asks for evidence of a fact already -// proven; without it the demand stands. The changelog demand never relaxes. -func checkReleasePostMergeDiffFiles(root string, runner releasePostMergeCommandRunner, versionFiles []releaseVersionFile, fromRef string, toRef string, versionFilesAtCandidate bool) string { - if fromRef == "" { - fromRef = "HEAD^" - } - if toRef == "" { - toRef = "HEAD" - } - result := runner(root, "git", "diff", fromRef, toRef, "--name-only") - if result.exitCode != 0 { - return fmt.Sprintf("could not read git diff %s %s — is HEAD a merge of multiple commits or the first commit?", fromRef, toRef) - } - changed := map[string]bool{} - for _, line := range strings.Split(result.stdout, "\n") { - trimmed := strings.TrimSpace(line) - if trimmed != "" { - changed[trimmed] = true - } - } - var versionPaths []string - hasVersionFile := false - for _, file := range versionFiles { - versionPaths = append(versionPaths, file.RelativePath) - if changed[file.RelativePath] { - hasVersionFile = true - } - } - hasChangelog := changed["CHANGELOG.md"] - if !hasChangelog && !hasVersionFile { - return "release commit is missing both CHANGELOG.md and any version file diffs — this does not look like a release commit" - } - if !hasChangelog { - return "release commit is missing a CHANGELOG.md diff — verify the changelog was updated" - } - if !hasVersionFile && !versionFilesAtCandidate { - return fmt.Sprintf("release commit is missing a version-file diff (expected one of: %s)", strings.Join(versionPaths, ", ")) - } - return "" -} - -func checkReleasePostMergeChangelogSection(root string, version string) ([]string, string) { - path := filepath.Join(root, "CHANGELOG.md") - body, err := readRegularFile(path, projectFileReadLimit) - if err != nil { - if os.IsNotExist(err) { - return nil, "CHANGELOG.md not found at HEAD" - } - return nil, "could not read CHANGELOG.md" - } - section := extractReleasePostMergeChangelogSection(string(body), version) - if section == nil { - return nil, fmt.Sprintf("CHANGELOG.md has no `## [%s]` section", version) - } - if len(section) == 0 { - return nil, fmt.Sprintf("CHANGELOG.md `## [%s]` section has no list items", version) - } - return section, "" -} - -func extractReleasePostMergeChangelogSection(content string, version string) []string { - lines := strings.Split(content, "\n") - heading := "## [" + version + "]" - start := -1 - for i, line := range lines { - if strings.HasPrefix(strings.TrimSpace(line), heading) { - start = i - break - } - } - if start == -1 { - return nil - } - end := len(lines) - for i := start + 1; i < len(lines); i++ { - if strings.HasPrefix(strings.TrimSpace(lines[i]), "## [") { - end = i - break - } - } - raw := lines[start+1 : end] - for len(raw) > 0 && strings.TrimSpace(raw[0]) == "" { - raw = raw[1:] - } - for len(raw) > 0 && strings.TrimSpace(raw[len(raw)-1]) == "" { - raw = raw[:len(raw)-1] - } - hasItem := false - for _, line := range raw { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") { - hasItem = true - break - } - } - if !hasItem { - return []string{} - } - return raw -} - -func checkReleasePostMergeNoExistingTagOrRelease(root string, runner releasePostMergeCommandRunner, version string) string { - tag := "v" + version - pushedRemedy := "do not delete a published tag; if the GitHub release is missing assets, re-run the Release workflow or recreate the release from the existing tag instead" - // Gather all three states before choosing a remedy. Deletion advice is only - // safe for a purely local tag; a failed remote lookup leaves remoteExists - // false (degrade, never error) but must not unlock git tag -d when a GitHub - // release is present. - local := runner(root, "git", "tag", "--list", tag) - localExists := local.exitCode == 0 && strings.TrimSpace(local.stdout) == tag - remote := runner(root, "git", "ls-remote", "--tags", "origin", "refs/tags/"+tag) - remoteExists := remote.exitCode == 0 && strings.TrimSpace(remote.stdout) != "" - gh := runner(root, "gh", "release", "view", tag) - ghReleaseExists := !gh.notFound && gh.exitCode == 0 - - if remoteExists { - if localExists { - return fmt.Sprintf("tag %s already exists and is pushed — %s", tag, pushedRemedy) - } - return fmt.Sprintf("tag %s already exists on remote — %s", tag, pushedRemedy) - } - if ghReleaseExists { - return fmt.Sprintf("GH release %s already exists — do not delete a published release; re-run the Release workflow or update it from the existing tag instead; delete it and rerun only if it is an unpublished draft", tag) - } - if localExists { - return fmt.Sprintf("tag %s already exists locally — run `git tag -d %s` and rerun", tag, tag) - } - return "" -} - -func checkReleasePostMergeHeadNotTagged(root string, runner releasePostMergeCommandRunner) string { - result := runner(root, "git", "tag", "--points-at", "HEAD") - if result.exitCode != 0 { - return "" - } - for _, line := range strings.Split(result.stdout, "\n") { - tag := strings.TrimSpace(line) - if tag != "" { - return fmt.Sprintf("HEAD is already tagged as %s; this is not a fresh post-merge state", tag) - } - } - return "" -} - -func checkReleasePostMergeGitHubAccount(root string, runner releasePostMergeCommandRunner) string { - expected, err := configuredGitHubAccount(root) - if err != nil { - return err.Error() - } - if expected == "" { - return "" - } - result := runner(root, "gh", "auth", "status", "--active", "--hostname", githubAccountHostname, "--json", "hosts") - return githubAccountDiagnostic(expected, githubAccountCommandResult{ - stdout: result.stdout, - exitCode: result.exitCode, - notFound: result.notFound, - }) -} - -func lookupReleasePostMergeFeatureBranch(root string, runner releasePostMergeCommandRunner, prNumber string) string { - result := runner(root, "gh", "pr", "view", prNumber, "--json", "headRefName", "-q", ".headRefName") - if result.notFound || result.exitCode != 0 { - return "" - } - return strings.TrimSpace(result.stdout) -} - -func executeReleasePostMergeActions(root string, ready releasePostMergeResult, runner releasePostMergeCommandRunner, out io.Writer, errOut io.Writer) (releasePostMergeActionResult, error) { - tag := "v" + ready.version - result := releasePostMergeActionResult{} - - tagResult := runner(root, "git", "tag", "-s", tag, "-m", "Release "+ready.version) - if tagResult.exitCode != 0 { - return result, fmt.Errorf("failed to create tag %s (exit %d)", tag, tagResult.exitCode) - } - result.tagged = true - fmt.Fprintf(out, " %s Created tag %s\n", ansiGreen("✓"), tag) - - pushResult := runner(root, "git", "push", "origin", tag) - if pushResult.exitCode != 0 { - return result, fmt.Errorf("failed to push tag %s (exit %d)", tag, pushResult.exitCode) - } - result.pushed = true - fmt.Fprintf(out, " %s Pushed tag %s\n", ansiGreen("✓"), tag) - - ghArgs := []string{"release", "create", tag, "--title", tag, "--notes", ready.changelogBody} - if releaseVersionIsPrerelease(ready.version) { - ghArgs = append(ghArgs, "--prerelease") - } - releaseResult := runner(root, "gh", ghArgs...) - if releaseResult.notFound { - return result, fmt.Errorf("gh CLI is not installed; cannot create GH release") - } - if releaseResult.exitCode != 0 { - return result, fmt.Errorf("failed to create GH release %s (exit %d)", tag, releaseResult.exitCode) - } - result.released = true - fmt.Fprintf(out, " %s Created GH release %s\n", ansiGreen("✓"), tag) - - pullResult := runner(root, "git", "pull", "--rebase", "origin", ready.base) - if pullResult.exitCode == 0 { - result.pulled = true - fmt.Fprintf(out, " %s Pulled latest from origin/%s\n", ansiGreen("✓"), ready.base) - } else { - fmt.Fprintf(errOut, " %s Failed to pull origin/%s — continuing\n", ansiYellow("⚠"), ready.base) - } - - if ready.featureBranch != "" { - localDelete := runner(root, "git", "branch", "-d", ready.featureBranch) - deletedLocal := localDelete.exitCode == 0 - result.deletedLocal = &deletedLocal - if deletedLocal { - fmt.Fprintf(out, " %s Deleted local branch %s\n", ansiGreen("✓"), ready.featureBranch) - } else { - fmt.Fprintf(errOut, " %s Failed to delete local branch %s (may not be fully merged) — continuing\n", ansiYellow("⚠"), ready.featureBranch) - } - - remoteDelete := runner(root, "git", "push", "origin", "--delete", ready.featureBranch) - deletedRemote := remoteDelete.exitCode == 0 - result.deletedRemote = &deletedRemote - if deletedRemote { - fmt.Fprintf(out, " %s Deleted remote branch %s\n", ansiGreen("✓"), ready.featureBranch) - } else { - fmt.Fprintf(errOut, " %s Failed to delete remote branch %s — continuing\n", ansiYellow("⚠"), ready.featureBranch) - } - } - - return result, nil -} - -func defaultReleasePostMergeCommandRunner(root string, name string, args ...string) releasePostMergeCommandResult { - cmd := exec.Command(name, args...) - cmd.Dir = root - output, err := cmd.Output() - raw := string(output) - if err == nil { - // Keep raw untrimmed so NUL-delimited name-status paths retain padding. - // stdout stays trimmed for call sites that compare human-facing lines. - return releasePostMergeCommandResult{stdout: strings.TrimSpace(raw), raw: raw, exitCode: 0} - } - if errors.Is(err, exec.ErrNotFound) { - return releasePostMergeCommandResult{exitCode: 127, notFound: true} - } - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - return releasePostMergeCommandResult{stdout: strings.TrimSpace(raw), raw: raw, exitCode: exitErr.ExitCode()} - } - return releasePostMergeCommandResult{exitCode: 1} -} diff --git a/internal/cli/release_test.go b/internal/cli/release_test.go index 8fc86f1db..085770e03 100644 --- a/internal/cli/release_test.go +++ b/internal/cli/release_test.go @@ -2,9 +2,6 @@ package cli import ( "bytes" - "errors" - "fmt" - "os" "os/exec" "path/filepath" "strings" @@ -23,1318 +20,36 @@ func TestRunnerReleaseHelpIsNative(t *testing.T) { t.Fatalf("release --help error = %v", err) } output := stdout.String() - if !strings.Contains(output, "Usage: loaf release [options]") || !strings.Contains(output, "--pre-merge") || !strings.Contains(output, "--post-merge") { - t.Fatalf("output = %q, want native release help", output) + if !strings.Contains(output, "Usage: loaf release <subcommand>") || !strings.Contains(output, "suggest") || !strings.Contains(output, "cut") { + t.Fatalf("output = %q, want retroactive suggest/cut help", output) } -} - -func TestReleaseLineagePreflightScopesFreezeToHEADAncestry(t *testing.T) { - t.Skip("retired: lineage freeze replaced by target_release cohort gate (TASK-004)") - repo := initCLIGitRepo(t) - if err := releaseLineagePreflight(repo); err != nil { - t.Fatalf("ancestry before first lineage node should pass: %v", err) - } - writeChangeFolder(t, repo, "20260710-root", executableLineageDoc("root", "line", "", "terminal")) - commitAllChangeTest(t, repo, "docs: add lineage root") - if err := releaseLineagePreflight(repo); err == nil || !strings.Contains(err.Error(), "release-after terminal \"terminal\" is unsatisfied") { - t.Fatalf("preflight error = %v", err) - } - writeChangeFolder(t, repo, "20260711-terminal", strings.Replace(executableLineageDoc("terminal", "line", "root", ""), "created: 2026-07-10", "created: 2026-07-11", 1)) - commitAllChangeTest(t, repo, "docs: add lineage terminal") - if err := releaseLineagePreflight(repo); err != nil { - t.Fatalf("terminal in HEAD should unblock release: %v", err) + if strings.Contains(output, "Create a new release with changelog") { + t.Fatalf("output = %q, want legacy apply-path help removed", output) } } -func TestReleaseLineagePreflightRunsBeforeEveryEntryModeAndIgnoresBaseForFreeze(t *testing.T) { - t.Skip("retired: lineage freeze replaced by target_release cohort gate (TASK-004)") - repo := initCLIGitRepo(t) - writeChangeFolder(t, repo, "20260710-root", executableLineageDoc("root", "line", "", "terminal")) - commitAllChangeTest(t, repo, "docs: add frozen lineage") - before := gitOutputReleaseTest(t, repo, "status", "--porcelain=v1") - cases := [][]string{ - {"release"}, - {"release", "--dry-run"}, +func TestReleaseLegacyFlagsFailWithGuidance(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + for _, args := range [][]string{ + {"release", "--bump", "patch"}, {"release", "--pre-merge"}, {"release", "--post-merge"}, - {"release", "--dry-run", "--base", "HEAD~1"}, - } - for _, args := range cases { - var stdout bytes.Buffer - err := (Runner{Stdout: &stdout, Stderr: &bytes.Buffer{}, Stdin: strings.NewReader("n\n"), WorkingDir: repo}).Run(args) - if err == nil || !strings.Contains(err.Error(), "release-after terminal \"terminal\" is unsatisfied") { - t.Fatalf("Run(%v) error = %v output = %s", args, err, stdout.String()) - } - if after := gitOutputReleaseTest(t, repo, "status", "--porcelain=v1"); after != before { - t.Fatalf("Run(%v) mutated repository: before=%q after=%q", args, before, after) - } - } -} - -func TestRunnerReleasePrereleaseLineageBypassMatrix(t *testing.T) { - t.Skip("retired: lineage freeze replaced by target_release cohort gate (TASK-004)") - cases := []struct { - name string - version string - args []string - wantBlock bool - wantOutput string - }{ - {name: "bare release", version: "1.0.0-alpha.5", args: []string{"release"}, wantBlock: true}, - {name: "bare dry run", version: "1.0.0-alpha.5", args: []string{"release", "--dry-run"}, wantBlock: true}, - {name: "release bump", version: "1.0.0-alpha.5", args: []string{"release", "--bump", "release"}, wantBlock: true}, - {name: "major bump", version: "1.0.0-alpha.5", args: []string{"release", "--bump", "major"}, wantBlock: true}, - {name: "minor bump", version: "1.0.0-alpha.5", args: []string{"release", "--bump", "minor"}, wantBlock: true}, - {name: "patch bump", version: "1.0.0-alpha.5", args: []string{"release", "--bump", "patch"}, wantBlock: true}, - {name: "stable prerelease request", version: "1.0.0", args: []string{"release", "--dry-run", "--bump", "prerelease"}, wantBlock: true}, - {name: "prerelease dry run", version: "1.0.0-alpha.5", args: []string{"release", "--dry-run", "--bump", "prerelease"}, wantOutput: "No changes made."}, - {name: "prerelease apply", version: "1.0.0-alpha.5", args: []string{"release", "--bump", "prerelease", "--yes", "--no-tag", "--no-gh"}, wantOutput: "Release v1.0.0-alpha.6 complete"}, - {name: "prerelease pre-merge", version: "1.0.0-alpha.5", args: []string{"release", "--pre-merge", "--base", "v1.0.0", "--bump", "prerelease", "--yes", "--no-gh"}, wantOutput: "Release v1.0.0-alpha.6 complete"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - repo := seedReleaseLineageFreezeRepo(t, tc.version) - var stdout bytes.Buffer - err := (Runner{Stdout: &stdout, Stderr: &bytes.Buffer{}, WorkingDir: repo}).Run(tc.args) - if tc.wantBlock { - if err == nil || !strings.Contains(err.Error(), "release-after terminal \"terminal\" is unsatisfied") { - t.Fatalf("Run(%v) error = %v, want release-after freeze", tc.args, err) - } - return - } - output := stripANSI(stdout.String()) - if err != nil || !strings.Contains(output, tc.wantOutput) { - t.Fatalf("Run(%v) error = %v stdout = %q, want %q", tc.args, err, output, tc.wantOutput) - } - }) - } -} - -func TestReleaseAllowsPrereleaseLineageBypassRequiresPrereleaseVersion(t *testing.T) { - cases := []struct { - name string - version string - options releaseOptions - want bool - }{ - {name: "explicit prerelease", version: "1.0.0-alpha.5", options: releaseOptions{bump: "prerelease"}, want: true}, - {name: "post merge prerelease", version: "1.0.0-alpha.5", options: releaseOptions{postMerge: true}, want: true}, - {name: "explicit prerelease stable version", version: "1.0.0", options: releaseOptions{bump: "prerelease"}}, - {name: "post merge stable version", version: "1.0.0", options: releaseOptions{postMerge: true}}, - {name: "post merge with bump", version: "1.0.0-alpha.5", options: releaseOptions{postMerge: true, bump: "release"}}, - {name: "bare prerelease version", version: "1.0.0-alpha.5", options: releaseOptions{}}, - {name: "release bump prerelease version", version: "1.0.0-alpha.5", options: releaseOptions{bump: "release"}}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - repo := seedReleaseLineageFreezeRepo(t, tc.version) - if got := releaseAllowsPrereleaseLineageBypass(repo, tc.options); got != tc.want { - t.Fatalf("releaseAllowsPrereleaseLineageBypass(%q, %#v) = %v, want %v", tc.version, tc.options, got, tc.want) - } - }) - } -} - -func TestReleaseAllowsPrereleaseLineageBypassRequiresConsistentPrereleaseVersionFiles(t *testing.T) { - cases := []struct { - name string - packageVersion string - backendVersion string - want bool - }{ - {name: "matching prereleases", packageVersion: "1.0.0-alpha.5", backendVersion: "1.0.0-alpha.5", want: true}, - {name: "prerelease and stable", packageVersion: "1.0.0-alpha.5", backendVersion: "1.0.0", want: false}, - {name: "mismatched prereleases", packageVersion: "1.0.0-alpha.5", backendVersion: "1.0.0-alpha.6", want: false}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - repo := seedReleaseLineageFreezeRepo(t, tc.packageVersion) - mkdirAll(t, filepath.Join(repo, ".agents")) - mkdirAll(t, filepath.Join(repo, "backend")) - writeFile(t, filepath.Join(repo, ".agents", "loaf.json"), "{\n \"release\": {\n \"versionFiles\": [\"package.json\", \"backend/pyproject.toml\"]\n }\n}\n") - writeFile(t, filepath.Join(repo, "backend", "pyproject.toml"), fmt.Sprintf("[project]\nname = \"backend\"\nversion = %q\n", tc.backendVersion)) - options := releaseOptions{bump: "prerelease"} - if got := releaseAllowsPrereleaseLineageBypass(repo, options); got != tc.want { - t.Fatalf("releaseAllowsPrereleaseLineageBypass() = %v, want %v", got, tc.want) - } - }) - } -} - -func TestReleaseLineagePreflightRejectsStructurallyInvalidCommittedAncestor(t *testing.T) { - t.Skip("retired: lineage freeze replaced by target_release cohort gate (TASK-004)") - repo := initCLIGitRepo(t) - invalidRoot := changeDoc(lineageFrontmatter("root", "2026-07-10", "root", "line", "", "terminal"), productSections()...) - writeChangeFolder(t, repo, "20260710-root", invalidRoot) - writeChangeFolder(t, repo, "20260710-terminal", executableLineageDoc("terminal", "line", "root", "")) - commitAllChangeTest(t, repo, "docs: add invalid lineage") - if err := releaseLineagePreflight(repo); err == nil || !strings.Contains(err.Error(), "structurally invalid Change \"root\"") { - t.Fatalf("preflight error = %v", err) - } -} - -func TestReleaseLineagePreflightRejectsDeletedRetainedNode(t *testing.T) { - repo := initCLIGitRepo(t) - root := writeChangeFolder(t, repo, "20260710-root", executableLineageDoc("root", "line", "", "root")) - commitAllChangeTest(t, repo, "docs: add retained lineage") - if err := os.RemoveAll(root); err != nil { - t.Fatal(err) - } - commitAllChangeTest(t, repo, "docs: delete retained lineage") - if err := releaseLineagePreflight(repo); err == nil || !strings.Contains(err.Error(), "deleted or renamed in HEAD ancestry") { - t.Fatalf("preflight error = %v", err) - } -} - -func TestReleaseLineagePreflightRejectsMergeResolutionOnlyDeletion(t *testing.T) { - repo := initCLIGitRepo(t) - root := writeChangeFolder(t, repo, "20260710-root", executableLineageDoc("root", "line", "", "root")) - commitAllChangeTest(t, repo, "docs: add retained lineage") - gitCLI(t, repo, "switch", "-c", "side") - sideDoc := strings.Replace(executableLineageDoc("root", "line", "", "root"), "The friction.", "Side branch friction.", 1) - if err := os.WriteFile(filepath.Join(root, "change.md"), []byte(sideDoc), 0o644); err != nil { - t.Fatal(err) - } - commitAllChangeTest(t, repo, "docs: edit lineage on side") - gitCLI(t, repo, "switch", "main") - mainDoc := strings.Replace(executableLineageDoc("root", "line", "", "root"), "The friction.", "Main branch friction.", 1) - if err := os.WriteFile(filepath.Join(root, "change.md"), []byte(mainDoc), 0o644); err != nil { - t.Fatal(err) - } - commitAllChangeTest(t, repo, "docs: edit lineage on main") - merge := exec.Command("git", "-c", "user.name=Loaf Test", "-c", "user.email=loaf@example.test", "-c", "commit.gpgsign=false", "merge", "--no-edit", "side") - merge.Dir = repo - if output, err := merge.CombinedOutput(); err == nil { - t.Fatalf("merge unexpectedly succeeded; want conflict before deletion resolution:\n%s", output) - } - gitCLI(t, repo, "rm", "docs/changes/20260710-root/change.md") - commitAllChangeTest(t, repo, "docs: resolve merge by deleting lineage Change") - parents := strings.Fields(gitOutputReleaseTest(t, repo, "rev-list", "--parents", "-n", "1", "HEAD")) - if len(parents) != 3 { - t.Fatalf("merge ancestry = %v, want commit plus two parents", parents) - } - for _, parent := range []string{"HEAD^1", "HEAD^2"} { - if err := exec.Command("git", "-C", repo, "cat-file", "-e", parent+":docs/changes/20260710-root/change.md").Run(); err != nil { - t.Fatalf("%s does not retain lineage Change: %v", parent, err) - } - } - if err := exec.Command("git", "-C", repo, "cat-file", "-e", "HEAD:docs/changes/20260710-root/change.md").Run(); err == nil { - t.Fatal("merge result unexpectedly retains lineage Change") - } - if err := releaseLineagePreflight(repo); err == nil || !strings.Contains(err.Error(), "deleted or renamed in HEAD ancestry") { - t.Fatalf("preflight error = %v, want merge-result deletion refusal", err) - } -} - -func TestReleaseLineagePreflightRejectsLineageWithoutReleaseAfter(t *testing.T) { - t.Skip("retired: lineage freeze replaced by target_release cohort gate (TASK-004)") - repo := initCLIGitRepo(t) - writeChangeFolder(t, repo, "20260710-root", executableLineageDoc("root", "line", "", "")) - commitAllChangeTest(t, repo, "docs: add ungated lineage") - if err := releaseLineagePreflight(repo); err == nil || !strings.Contains(err.Error(), "has no release-after terminal") { - t.Fatalf("preflight error = %v", err) - } -} - -func TestReleaseLineagePreflightIgnoresDirtyTerminalAndReleaseAfterRewrite(t *testing.T) { - t.Skip("retired: lineage freeze replaced by target_release cohort gate (TASK-004)") - repo := initCLIGitRepo(t) - root := writeChangeFolder(t, repo, "20260710-root", executableLineageDoc("root", "line", "", "terminal")) - commitAllChangeTest(t, repo, "docs: add frozen lineage") - writeChangeFolder(t, repo, "20260711-terminal", strings.Replace(executableLineageDoc("terminal", "line", "root", ""), "created: 2026-07-10", "created: 2026-07-11", 1)) - if err := releaseLineagePreflight(repo); err == nil || !strings.Contains(err.Error(), "terminal \"terminal\" is unsatisfied") { - t.Fatalf("uncommitted terminal bypassed freeze: %v", err) - } - if err := os.WriteFile(filepath.Join(root, "change.md"), []byte(executableLineageDoc("root", "line", "", "root")), 0o644); err != nil { - t.Fatal(err) - } - if err := releaseLineagePreflight(repo); err == nil || !strings.Contains(err.Error(), "terminal \"terminal\" is unsatisfied") { - t.Fatalf("dirty release-after rewrite bypassed freeze: %v", err) - } -} - -func TestReleaseLineagePreflightRejectsCommittedReleaseAfterRewrite(t *testing.T) { - t.Skip("retired: lineage freeze replaced by target_release cohort gate (TASK-004)") - repo := initCLIGitRepo(t) - root := writeChangeFolder(t, repo, "20260710-root", executableLineageDoc("root", "line", "", "terminal")) - commitAllChangeTest(t, repo, "docs: add immutable release terminal") - if err := os.WriteFile(filepath.Join(root, "change.md"), []byte(executableLineageDoc("root", "line", "", "root")), 0o644); err != nil { - t.Fatal(err) - } - commitAllChangeTest(t, repo, "docs: rewrite release terminal") - err := releaseLineagePreflight(repo) - if err == nil || !strings.Contains(err.Error(), "immutable dependency metadata changed") || !strings.Contains(err.Error(), "changed release-after") || !strings.Contains(err.Error(), `from "terminal"`) || !strings.Contains(err.Error(), `to "root"`) { - t.Fatalf("preflight error = %v", err) - } -} - -func TestReleaseLineagePreflightAllowsFirstCommittedReleaseAfterValue(t *testing.T) { - t.Skip("retired: lineage freeze replaced by target_release cohort gate (TASK-004)") - repo := initCLIGitRepo(t) - root := writeChangeFolder(t, repo, "20260710-root", executableLineageDoc("root", "line", "", "")) - commitAllChangeTest(t, repo, "docs: add lineage before release policy") - if err := os.WriteFile(filepath.Join(root, "change.md"), []byte(executableLineageDoc("root", "line", "", "root")), 0o644); err != nil { - t.Fatal(err) - } - commitAllChangeTest(t, repo, "docs: set release terminal") - if err := releaseLineagePreflight(repo); err != nil { - t.Fatalf("first non-empty release-after should be accepted: %v", err) - } -} - -func TestReleaseLineagePreflightRejectsRemovingLineageWithReleaseAfter(t *testing.T) { - t.Skip("retired: lineage freeze replaced by target_release cohort gate (TASK-004)") - repo := initCLIGitRepo(t) - root := writeChangeFolder(t, repo, "20260710-root", executableLineageDoc("root", "line", "", "terminal")) - commitAllChangeTest(t, repo, "docs: add immutable release terminal") - withoutLineage := strings.Replace(executableLineageDoc("root", "line", "", "terminal"), "lineage: line\n", "", 1) - if err := os.WriteFile(filepath.Join(root, "change.md"), []byte(withoutLineage), 0o644); err != nil { - t.Fatal(err) - } - commitAllChangeTest(t, repo, "docs: strip lineage metadata") - err := releaseLineagePreflight(repo) - if err == nil || !strings.Contains(err.Error(), "immutable dependency metadata changed") || !strings.Contains(err.Error(), "changed lineage") || !strings.Contains(err.Error(), `from "line"`) || !strings.Contains(err.Error(), `to ""`) { - t.Fatalf("preflight error = %v", err) - } -} - -func TestReleaseLineagePreflightRejectsCommittedLineageRewrite(t *testing.T) { - t.Skip("retired: lineage freeze replaced by target_release cohort gate (TASK-004)") - repo := initCLIGitRepo(t) - root := writeChangeFolder(t, repo, "20260710-root", executableLineageDoc("root", "line", "", "root")) - commitAllChangeTest(t, repo, "docs: add immutable lineage") - rewritten := strings.Replace(executableLineageDoc("root", "line", "", "root"), "lineage: line", "lineage: other-line", 1) - if err := os.WriteFile(filepath.Join(root, "change.md"), []byte(rewritten), 0o644); err != nil { - t.Fatal(err) - } - commitAllChangeTest(t, repo, "docs: rewrite lineage key") - err := releaseLineagePreflight(repo) - if err == nil || !strings.Contains(err.Error(), "immutable dependency metadata changed") || !strings.Contains(err.Error(), "changed lineage") || !strings.Contains(err.Error(), `to "other-line"`) { - t.Fatalf("preflight error = %v", err) - } -} - -func TestReleaseLineagePreflightAllowsFirstCommittedLineageValue(t *testing.T) { - t.Skip("retired: lineage freeze replaced by target_release cohort gate (TASK-004)") - repo := initCLIGitRepo(t) - sections := append(productSections(), executableSections()...) - root := writeChangeFolder(t, repo, "20260710-root", changeDoc(changeFrontmatter("root", "2026-07-10", "root"), sections...)) - commitAllChangeTest(t, repo, "docs: retain pre-lineage Change") - if err := os.WriteFile(filepath.Join(root, "change.md"), []byte(executableLineageDoc("root", "line", "", "root")), 0o644); err != nil { - t.Fatal(err) - } - commitAllChangeTest(t, repo, "docs: assign first lineage key") - if err := releaseLineagePreflight(repo); err != nil { - t.Fatalf("first non-empty lineage should be accepted: %v", err) - } -} - -func TestReleaseLineagePreflightRejectsDependencyMetadataWithoutLineage(t *testing.T) { - t.Skip("retired: lineage freeze replaced by target_release cohort gate (TASK-004)") - repo := initCLIGitRepo(t) - malformed := strings.Replace(executableLineageDoc("root", "line", "", "root"), "lineage: line\n", "", 1) - writeChangeFolder(t, repo, "20260710-root", malformed) - commitAllChangeTest(t, repo, "docs: add dependency metadata without lineage") - err := releaseLineagePreflight(repo) - if err == nil || !strings.Contains(err.Error(), "declares predecessor or release-after without lineage") || !strings.Contains(err.Error(), "docs/changes/20260710-root/change.md") { - t.Fatalf("preflight error = %v", err) - } -} - -func TestReleaseLineagePreflightIgnoresUnrelatedMalformedLegacyChange(t *testing.T) { - repo := initCLIGitRepo(t) - writeChangeFolder(t, repo, "20260710-root", executableLineageDoc("root", "line", "", "root")) - legacy := changeDoc(changeFrontmatter("legacy", "2026-07-10", "legacy"), productSections()...) - legacy = strings.Replace(legacy, "\n---\n# Title", "\nmalformed legacy frontmatter\n---\n# Title", 1) - writeChangeFolder(t, repo, "20260710-legacy", legacy) - commitAllChangeTest(t, repo, "docs: retain lineage beside malformed legacy Change") - if err := releaseLineagePreflight(repo); err != nil { - t.Fatalf("unrelated malformed legacy Change blocked valid lineage: %v", err) - } -} - -func TestReleaseLineagePreflightIgnoresDeletedMalformedUnlineagedChange(t *testing.T) { - repo := initCLIGitRepo(t) - writeChangeFolder(t, repo, "20260710-root", executableLineageDoc("root", "line", "", "root")) - legacy := changeDoc(changeFrontmatter("legacy", "2026-07-10", "legacy"), productSections()...) - legacy = strings.Replace(legacy, "\n---\n# Title", "\nmalformed legacy frontmatter\n---\n# Title", 1) - legacyFolder := writeChangeFolder(t, repo, "20260710-legacy", legacy) - commitAllChangeTest(t, repo, "docs: retain lineage beside malformed legacy Change") - if err := os.RemoveAll(legacyFolder); err != nil { - t.Fatal(err) - } - commitAllChangeTest(t, repo, "docs: remove malformed unlineaged Change") - if err := releaseLineagePreflight(repo); err != nil { - t.Fatalf("deleted malformed unlineaged Change blocked valid lineage: %v", err) - } -} - -func TestReleaseLineagePreflightRejectsDeletionAfterMalformedVersionHidesLineage(t *testing.T) { - repo := initCLIGitRepo(t) - root := writeChangeFolder(t, repo, "20260710-root", executableLineageDoc("root", "line", "", "root")) - commitAllChangeTest(t, repo, "docs: add retained lineage") - - malformed := strings.Replace(executableLineageDoc("root", "line", "", "root"), "\n---\n# Title", "\n# Title", 1) - if err := os.WriteFile(filepath.Join(root, "change.md"), []byte(malformed), 0o644); err != nil { - t.Fatal(err) - } - commitAllChangeTest(t, repo, "docs: leave lineage frontmatter unclosed") - if err := os.RemoveAll(root); err != nil { - t.Fatal(err) - } - commitAllChangeTest(t, repo, "docs: delete hidden lineage Change") - - err := releaseLineagePreflight(repo) - if err == nil || !strings.Contains(err.Error(), "deleted or renamed in HEAD ancestry") || !strings.Contains(err.Error(), "docs/changes/20260710-root/change.md") { - t.Fatalf("malformed intermediate version bypassed lineage freeze: %v", err) - } -} - -func TestReleaseLineagePreflightFailsClosedWhenDeletedPathVersionCannotBeInspected(t *testing.T) { - repo := initCLIGitRepo(t) - root := writeChangeFolder(t, repo, "20260710-root", executableLineageDoc("root", "line", "", "root")) - commitAllChangeTest(t, repo, "docs: add retained lineage") - if err := os.RemoveAll(root); err != nil { - t.Fatal(err) - } - commitAllChangeTest(t, repo, "docs: delete retained lineage") - - output := func(cwd, name string, args ...string) (string, error) { - if strings.Contains(strings.Join(args, " "), "ls-tree --name-only") { - return "", errors.New("forced deleted path inspection failure") - } - return commandOutput(cwd, name, args...) - } - err := releaseLineagePreflightWithOutput(repo, output) - if err == nil || !strings.Contains(err.Error(), "cannot inspect deleted or renamed Change history") || !strings.Contains(err.Error(), "forced deleted path inspection failure") { - t.Fatalf("preflight error = %v, want fail-closed deleted path inspection", err) - } -} - -func TestReleaseLineagePreflightAllowsCommittedPredecessorEvolution(t *testing.T) { - repo := initCLIGitRepo(t) - writeChangeFolder(t, repo, "20260710-root", executableLineageDoc("root", "line", "", "terminal")) - terminal := writeChangeFolder(t, repo, "20260711-terminal", strings.Replace(executableLineageDoc("terminal", "line", "root", ""), "created: 2026-07-10", "created: 2026-07-11", 1)) - commitAllChangeTest(t, repo, "docs: add two-node lineage") - if err := releaseLineagePreflight(repo); err != nil { - t.Fatalf("two-node lineage preflight = %v", err) - } - writeChangeFolder(t, repo, "20260712-middle", strings.Replace(executableLineageDoc("middle", "line", "root", ""), "created: 2026-07-10", "created: 2026-07-12", 1)) - terminalDoc := strings.Replace(executableLineageDoc("terminal", "line", "middle", ""), "created: 2026-07-10", "created: 2026-07-11", 1) - if err := os.WriteFile(filepath.Join(terminal, "change.md"), []byte(terminalDoc), 0o644); err != nil { - t.Fatal(err) - } - commitAllChangeTest(t, repo, "docs: insert middle lineage node") - if err := releaseLineagePreflight(repo); err != nil { - t.Fatalf("predecessor-only evolution should remain allowed: %v", err) - } -} - -func TestReleaseLineagePreflightDetectsFolderRenameWithGitRenameDetectionEnabled(t *testing.T) { - repo := initCLIGitRepo(t) - writeChangeFolder(t, repo, "20260710-root", executableLineageDoc("root", "line", "", "root")) - commitAllChangeTest(t, repo, "docs: add retained lineage") - gitCLI(t, repo, "config", "diff.renames", "true") - gitCLI(t, repo, "mv", "docs/changes/20260710-root", "docs/changes/20260710-root-renamed") - renamedPath := filepath.Join(repo, "docs", "changes", "20260710-root-renamed", "change.md") - renamed := executableLineageDoc("root-renamed", "line", "", "root-renamed") - if err := os.WriteFile(renamedPath, []byte(renamed), 0o644); err != nil { - t.Fatal(err) - } - commitAllChangeTest(t, repo, "docs: rename retained lineage") - if err := releaseLineagePreflight(repo); err == nil || !strings.Contains(err.Error(), "deleted or renamed") || !strings.Contains(err.Error(), "docs/changes/20260710-root/change.md") { - t.Fatalf("preflight error = %v", err) - } -} - -func TestReleaseLineagePreflightFailsClosedOnGitInspectionErrors(t *testing.T) { - t.Skip("retired: lineage freeze replaced by target_release cohort gate (TASK-004)") - cases := []struct { - name string - failFragment string - want string - seedLineage bool - }{ - {name: "head-graph", failFragment: "ls-tree", want: "cannot inspect committed Change graph at HEAD"}, - {name: "history-depth", failFragment: "--is-shallow-repository", want: "cannot confirm complete Change history"}, - {name: "deletion-history", failFragment: "rev-list --full-history --topo-order", want: "cannot inspect deleted or renamed Change history at HEAD"}, - {name: "release-metadata-history", failFragment: "--topo-order --reverse", want: "cannot inspect immutable dependency metadata history", seedLineage: true}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - repo := initCLIGitRepo(t) - if tc.seedLineage { - writeChangeFolder(t, repo, "20260710-root", executableLineageDoc("root", "line", "", "root")) - commitAllChangeTest(t, repo, "docs: add lineage") - } - output := func(cwd, name string, args ...string) (string, error) { - if strings.Contains(strings.Join(args, " "), tc.failFragment) { - return "", errors.New("forced git inspection failure") - } - return commandOutput(cwd, name, args...) - } - err := releaseLineagePreflightWithOutput(repo, output) - if err == nil || !strings.Contains(err.Error(), tc.want) || !strings.Contains(err.Error(), "forced git inspection failure") { - t.Fatalf("preflight error = %v, want actionable fail-closed %q", err, tc.want) - } - }) - } -} - -func TestReleaseLineagePreflightRejectsShallowHistory(t *testing.T) { - repo := initCLIGitRepo(t) - output := func(cwd, name string, args ...string) (string, error) { - if strings.Contains(strings.Join(args, " "), "--is-shallow-repository") { - return "true\n", nil - } - return commandOutput(cwd, name, args...) - } - err := releaseLineagePreflightWithOutput(repo, output) - if err == nil || !strings.Contains(err.Error(), "repository is shallow") || !strings.Contains(err.Error(), "git fetch --unshallow") { - t.Fatalf("preflight error = %v, want actionable shallow-history refusal", err) - } -} - -func TestRunnerReleasePostMergeFailsClosedOutsideGit(t *testing.T) { - workingDir := realpath(t, t.TempDir()) - var stdout bytes.Buffer - - err := Runner{ - Stdout: &stdout, - WorkingDir: workingDir, - }.Run([]string{"release", "--post-merge"}) - if err == nil { - t.Fatalf("release --post-merge error = nil, want fail-closed outside git") - } - msg := err.Error() - if !strings.Contains(msg, "cannot compute candidate version") && !strings.Contains(msg, "release blocked") { - t.Fatalf("release --post-merge error = %v, want fail-closed release blocked", err) - } - if stdout.Len() != 0 { - t.Fatalf("stdout = %q, want no release actions before ancestry inspection", stdout.String()) - } -} - -func TestReleasePostMergeGuardrailsHappyPath(t *testing.T) { - repo := seedReleasePostMergeFiles(t, "1.2.3") - runner, calls := scriptedReleasePostMergeRunner(releasePostMergeHappyResponses("1.2.3")) - snap := mustResolveReleaseSnapshot(t, repo, releaseOptions{postMerge: true}) - - result := checkReleasePostMergeGuardrails(repo, snap, runner) - if !result.ok { - t.Fatalf("guardrail %d failed: %s", result.guardrail, result.message) - } - if result.version != "1.2.3" || result.base != "main" || result.featureBranch != "feat/cool-thing" { - t.Fatalf("result = %#v, want version/base/feature branch", result) - } - if !strings.Contains(result.changelogBody, "New feature") { - t.Fatalf("changelog body = %q, want release notes", result.changelogBody) - } - for _, call := range calls() { - if call.name == "gh" && len(call.args) >= 5 && call.args[0] == "pr" && call.args[1] == "view" && strings.Contains(strings.Join(call.args, " "), "baseRefName,state") { - t.Fatalf("post-merge base detection called open-PR lookup: %#v", call) - } - } -} - -func TestReleasePostMergeGuardrailsAbortOnLocalTagCollision(t *testing.T) { - repo := seedReleasePostMergeFiles(t, "1.2.3") - responses := releasePostMergeHappyResponses("1.2.3") - responses["git tag --list v1.2.3"] = releasePostMergeOK("v1.2.3") - runner, _ := scriptedReleasePostMergeRunner(responses) - snap := mustResolveReleaseSnapshot(t, repo, releaseOptions{postMerge: true}) - - result := checkReleasePostMergeGuardrails(repo, snap, runner) - if result.ok { - t.Fatalf("result ok = true, want local tag collision failure") - } - if result.guardrail != 7 || result.message != "tag v1.2.3 already exists locally — run `git tag -d v1.2.3` and rerun" { - t.Fatalf("result = %#v, want guardrail 7 local tag diagnostic", result) - } -} - -func TestReleasePostMergeGuardrailsAbortOnGitHubAccountMismatch(t *testing.T) { - repo := seedReleasePostMergeFiles(t, "1.2.3") - mkdirAll(t, filepath.Join(repo, ".agents")) - writeFile(t, filepath.Join(repo, ".agents", "loaf.json"), `{"integrations":{"github":{"account":"levifig"}}}`+"\n") - responses := releasePostMergeHappyResponses("1.2.3") - responses["gh auth status --active --hostname github.com --json hosts"] = releasePostMergeOK(githubAuthStatusJSON("work-account")) - runner, calls := scriptedReleasePostMergeRunner(responses) - snap := mustResolveReleaseSnapshot(t, repo, releaseOptions{postMerge: true}) - - result := checkReleasePostMergeGuardrails(repo, snap, runner) - if result.ok { - t.Fatalf("result ok = true, want GitHub account guardrail failure") - } - if result.guardrail != 2 || !strings.Contains(result.message, `project requires GitHub account "levifig"`) || !strings.Contains(result.message, `active gh account is "work-account"`) { - t.Fatalf("result = %#v, want guardrail 2 account diagnostic", result) - } - for _, call := range releasePostMergeCallKeys(calls()) { - if strings.HasPrefix(call, "gh repo view") || strings.HasPrefix(call, "gh release view") { - t.Fatalf("calls = %#v, want account check to abort before GitHub repo/release lookup", releasePostMergeCallKeys(calls())) - } - } -} - -// A self-carrying release — version flip already landed as Change content — -// leaves the release commit nothing to write but the changelog. Guardrail 5 -// accepts that shape only under guardrail 4's proof that the version files -// already report the candidate. -func TestReleasePostMergeDiffFilesRelaxesVersionDemandOnlyUnderProof(t *testing.T) { - cases := []struct { - name string - diff string - versionFilesAtCandidate bool - want string - }{ - { - name: "changelog-only under proof", - diff: "CHANGELOG.md", - versionFilesAtCandidate: true, - }, - { - name: "changelog-only without proof", - diff: "CHANGELOG.md", - want: "release commit is missing a version-file diff (expected one of: package.json)", - }, - { - name: "version file without changelog under proof", - diff: "package.json", - versionFilesAtCandidate: true, - want: "release commit is missing a CHANGELOG.md diff — verify the changelog was updated", - }, - { - name: "neither under proof", - diff: "README.md", - versionFilesAtCandidate: true, - want: "release commit is missing both CHANGELOG.md and any version file diffs — this does not look like a release commit", - }, - { - name: "conventional release commit under proof", - diff: "CHANGELOG.md\npackage.json", - versionFilesAtCandidate: true, - }, - { - name: "conventional release commit without proof", - diff: "CHANGELOG.md\npackage.json", - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - repo := seedReleasePostMergeFiles(t, "1.2.3") - versionFiles := mustResolveReleaseSnapshot(t, repo, releaseOptions{postMerge: true}).VersionFiles - responses := releasePostMergeHappyResponses("1.2.3") - responses["git diff HEAD^ HEAD --name-only"] = releasePostMergeOK(tc.diff) - runner, _ := scriptedReleasePostMergeRunner(responses) - - got := checkReleasePostMergeDiffFiles(repo, runner, versionFiles, "HEAD^", "HEAD", tc.versionFilesAtCandidate) - if got != tc.want { - t.Fatalf("abort = %q, want %q", got, tc.want) - } - }) - } -} - -func TestReleasePostMergeGuardrailsAcceptSelfCarryingReleaseCommit(t *testing.T) { - repo := seedReleasePostMergeFiles(t, "1.2.3") - responses := releasePostMergeHappyResponses("1.2.3") - responses["git diff HEAD^ HEAD --name-only"] = releasePostMergeOK("CHANGELOG.md") - runner, _ := scriptedReleasePostMergeRunner(responses) - snap := mustResolveReleaseSnapshot(t, repo, releaseOptions{postMerge: true}) - - result := checkReleasePostMergeGuardrails(repo, snap, runner) - if !result.ok { - t.Fatalf("guardrail %d failed: %s", result.guardrail, result.message) - } - if result.version != "1.2.3" { - t.Fatalf("result = %#v, want candidate version", result) - } -} - -// The relaxation cannot be reached without the proof: a candidate that diverges -// from the version files aborts at guardrail 4, before guardrail 5 reads a diff. -func TestReleasePostMergeGuardrailsBlockChangelogOnlyWhenCandidateDiverges(t *testing.T) { - repo := seedReleasePostMergeFiles(t, "1.2.3") - snap := mustResolveReleaseSnapshot(t, repo, releaseOptions{postMerge: true}) - snap.Candidate = "1.3.0" - responses := releasePostMergeHappyResponses("1.3.0") - responses["git diff HEAD^ HEAD --name-only"] = releasePostMergeOK("CHANGELOG.md") - runner, calls := scriptedReleasePostMergeRunner(responses) - - result := checkReleasePostMergeGuardrails(repo, snap, runner) - if result.ok || result.guardrail != 4 { - t.Fatalf("result = %#v, want guardrail 4 abort", result) - } - if !strings.Contains(result.message, "does not match version-file version") { - t.Fatalf("message = %q, want tag-equals-files diagnostic", result.message) - } - for _, call := range releasePostMergeCallKeys(calls()) { - if strings.HasPrefix(call, "git diff HEAD^ HEAD") { - t.Fatalf("calls = %#v, want abort before the diff-shape read", releasePostMergeCallKeys(calls())) - } - } -} - -func TestReleasePostMergeActionsHappyPath(t *testing.T) { - repo := seedReleasePostMergeFiles(t, "1.2.3") - runner, calls := scriptedReleasePostMergeRunner(releasePostMergeHappyResponses("1.2.3")) - var stdout, stderr bytes.Buffer - - result, err := executeReleasePostMergeActions(repo, releasePostMergeResult{ - ok: true, - version: "1.2.3", - base: "main", - featureBranch: "feat/cool-thing", - changelogBody: "- A nifty change", - }, runner, &stdout, &stderr) - if err != nil { - t.Fatalf("executeReleasePostMergeActions error = %v", err) - } - if !result.tagged || !result.pushed || !result.released || !result.pulled || result.deletedLocal == nil || !*result.deletedLocal || result.deletedRemote == nil || !*result.deletedRemote { - t.Fatalf("result = %#v, want all action flags true", result) - } - wantCalls := []string{ - "git tag -s v1.2.3 -m Release 1.2.3", - "git push origin v1.2.3", - "gh release create v1.2.3 --title v1.2.3 --notes - A nifty change", - "git pull --rebase origin main", - "git branch -d feat/cool-thing", - "git push origin --delete feat/cool-thing", - } - got := releasePostMergeCallKeys(calls()) - for _, want := range wantCalls { - if !containsReleasePostMergeCall(got, want) { - t.Fatalf("calls = %#v, want %q", got, want) - } - } - if stderr.Len() != 0 { - t.Fatalf("stderr = %q, want no warnings", stderr.String()) - } -} - -func TestReleasePostMergeActionWarnsAndContinuesAfterPullFailure(t *testing.T) { - repo := seedReleasePostMergeFiles(t, "1.2.3") - responses := releasePostMergeHappyResponses("1.2.3") - responses["git pull --rebase origin main"] = releasePostMergeExit(1) - runner, _ := scriptedReleasePostMergeRunner(responses) - var stdout, stderr bytes.Buffer - - result, err := executeReleasePostMergeActions(repo, releasePostMergeResult{ - ok: true, - version: "1.2.3", - base: "main", - changelogBody: "- A nifty change", - }, runner, &stdout, &stderr) - if err != nil { - t.Fatalf("executeReleasePostMergeActions error = %v", err) - } - if !result.tagged || !result.released || result.pulled { - t.Fatalf("result = %#v, want tag/release true and pulled false", result) - } - if !strings.Contains(stderr.String(), "Failed to pull origin/main") { - t.Fatalf("stderr = %q, want pull warning", stderr.String()) - } -} - -func TestRunReleasePostMergeFinalizesNatively(t *testing.T) { - repo := seedReleasePostMergeFiles(t, "1.2.3") - responses := releasePostMergeHappyResponses("1.2.3") - responses["gh release create v1.2.3 --title v1.2.3 --notes ### Added\n- New feature (abc1234)"] = releasePostMergeOK("") - runner, _ := scriptedReleasePostMergeRunner(responses) - var stdout, stderr bytes.Buffer - - snap := mustResolveReleaseSnapshot(t, repo, releaseOptions{postMerge: true}) - if err := runReleasePostMergeWithRunner(repo, snap, &stdout, &stderr, runner); err != nil { - t.Fatalf("runReleasePostMergeWithRunner error = %v\nstdout:\n%s\nstderr:\n%s", err, stdout.String(), stderr.String()) - } - for _, want := range []string{"Verifying post-merge state", "All 9 guardrails passed", "Executing:", "Created tag v1.2.3", "Release v1.2.3 finalized"} { - if !strings.Contains(stdout.String(), want) { - t.Fatalf("stdout = %q, want %q", stdout.String(), want) - } - } -} - -func TestRunnerReleaseInteractiveConfirmationExecutesNatively(t *testing.T) { - repo := seedReleaseApplyRepo(t, "feat: confirm native release execution") - var stdout bytes.Buffer - - err := Runner{ - Stdout: &stdout, - Stdin: strings.NewReader("y\n"), - WorkingDir: repo, - }.Run([]string{"release", "--no-tag", "--no-gh"}) - if err != nil { - t.Fatalf("interactive release error = %v\n%s", err, stdout.String()) - } - output := stdout.String() - for _, want := range []string{"Proceed with release", "Executing:", "Committed release artifacts", "Release ", "v1.1.0", "complete"} { - if !strings.Contains(output, want) { - t.Fatalf("stdout = %q, want %q", output, want) - } - } - subject := gitOutputReleaseTest(t, repo, "log", "-1", "--pretty=%s") - if subject != "chore: release v1.1.0" { - t.Fatalf("release commit subject = %q, want chore: release v1.1.0", subject) - } -} - -func TestRunnerReleaseMutatingModesRefuseDirtyChangeWithoutMutation(t *testing.T) { - cases := []struct { - name string - args []string - }{ - {name: "apply", args: []string{"release", "--yes", "--no-gh"}}, - {name: "pre-merge", args: []string{"release", "--pre-merge", "--base", "HEAD~1", "--yes", "--no-gh"}}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - repo := seedReleaseApplyRepo(t, "feat: keep dirty Change out of release") - writeChangeFolder(t, repo, "20260710-dirty-lineage", executableLineageDoc("dirty-lineage", "line", "", "terminal")) - beforeHEAD := gitOutputReleaseTest(t, repo, "rev-parse", "HEAD") - beforePackage, err := os.ReadFile(filepath.Join(repo, "package.json")) - if err != nil { - t.Fatal(err) - } - beforeChangelog, err := os.ReadFile(filepath.Join(repo, "CHANGELOG.md")) - if err != nil { - t.Fatal(err) - } - err = (Runner{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, WorkingDir: repo}).Run(tc.args) - if err == nil || !strings.Contains(err.Error(), "require a clean unignored worktree") || !strings.Contains(err.Error(), "docs/changes/20260710-dirty-lineage/change.md") { - t.Fatalf("Run(%v) error = %v, want dirty-Change refusal", tc.args, err) - } - afterPackage, _ := os.ReadFile(filepath.Join(repo, "package.json")) - afterChangelog, _ := os.ReadFile(filepath.Join(repo, "CHANGELOG.md")) - if !bytes.Equal(beforePackage, afterPackage) || !bytes.Equal(beforeChangelog, afterChangelog) { - t.Fatalf("Run(%v) mutated version or changelog before dirty refusal", tc.args) - } - if head := gitOutputReleaseTest(t, repo, "rev-parse", "HEAD"); head != beforeHEAD { - t.Fatalf("Run(%v) created release commit %s, want HEAD %s", tc.args, head, beforeHEAD) - } - if tag := gitOutputReleaseTest(t, repo, "tag", "--list", "v1.1.0"); tag != "" { - t.Fatalf("Run(%v) created tag %q", tc.args, tag) - } - if tc.name == "apply" { - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"release", "--dry-run"}); err != nil { - t.Fatalf("dry-run should remain available for dirty inspection: %v", err) - } - } - }) - } -} - -func TestRunnerReleaseRefusesGeneratedChangeBeforeStaging(t *testing.T) { - repo := seedReleaseApplyRepo(t, "feat: generate release artifacts") - packageBody := strings.Join([]string{ - "{", - ` "name": "release-fixture",`, - ` "version": "1.0.0",`, - ` "scripts": {`, - ` "build": "mkdir -p docs/changes/20260710-generated && printf generated > docs/changes/20260710-generated/change.md"`, - " }", - "}", - "", - }, "\n") - if err := os.WriteFile(filepath.Join(repo, "package.json"), []byte(packageBody), 0o644); err != nil { - t.Fatal(err) - } - gitCLI(t, repo, "add", "package.json") - gitCLI(t, repo, "commit", "-m", "fix: generate Change during build") - beforeHEAD := gitOutputReleaseTest(t, repo, "rev-parse", "HEAD") - err := (Runner{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"release", "--yes", "--no-tag", "--no-gh"}) - if err == nil || !strings.Contains(err.Error(), "artifact generation modified docs/changes") || !strings.Contains(err.Error(), "docs/changes/20260710-generated/change.md") { - t.Fatalf("release error = %v, want generated Change refusal", err) - } - if staged := gitOutputReleaseTest(t, repo, "diff", "--cached", "--name-only"); staged != "" { - t.Fatalf("release staged files before refusal: %q", staged) - } - if head := gitOutputReleaseTest(t, repo, "rev-parse", "HEAD"); head != beforeHEAD { - t.Fatalf("release created commit %s, want HEAD %s", head, beforeHEAD) - } - if tag := gitOutputReleaseTest(t, repo, "tag", "--list", "v1.1.0"); tag != "" { - t.Fatalf("release created tag %q", tag) - } -} - -func TestRunnerReleaseInteractiveDeclineCancelsNatively(t *testing.T) { - repo := seedReleaseApplyRepo(t, "feat: decline native release execution") - var stdout bytes.Buffer - - err := Runner{ - Stdout: &stdout, - Stdin: strings.NewReader("n\n"), - WorkingDir: repo, - }.Run([]string{"release", "--no-tag", "--no-gh"}) - if err != nil { - t.Fatalf("interactive release decline error = %v\n%s", err, stdout.String()) - } - output := stdout.String() - for _, want := range []string{"Proceed with release", "Release cancelled."} { - if !strings.Contains(output, want) { - t.Fatalf("stdout = %q, want %q", output, want) - } - } - if strings.Contains(output, "Executing:") { - t.Fatalf("stdout = %q, should not execute after decline", output) - } - body, err := os.ReadFile(filepath.Join(repo, "package.json")) - if err != nil { - t.Fatalf("ReadFile(package.json) error = %v", err) - } - if !strings.Contains(string(body), `"version": "1.0.0"`) { - t.Fatalf("package.json mutated after decline:\n%s", string(body)) - } - subject := gitOutputReleaseTest(t, repo, "log", "-1", "--pretty=%s") - if subject != "feat: decline native release execution" { - t.Fatalf("HEAD subject = %q, want feature commit after cancellation", subject) - } -} - -func TestRunnerReleaseYesNoTagNoGhExecutesNatively(t *testing.T) { - repo := seedReleaseApplyRepo(t, "feat: ship native release execution") - var stdout bytes.Buffer - - err := Runner{ - Stdout: &stdout, - WorkingDir: repo, - }.Run([]string{"release", "--yes", "--no-tag", "--no-gh"}) - if err != nil { - t.Fatalf("release --yes --no-tag --no-gh error = %v\n%s", err, stdout.String()) - } - output := stdout.String() - for _, want := range []string{ - "loaf release", - "Executing:", - "Updated package.json (1.0.0 → 1.1.0)", - "Updated CHANGELOG.md", - "Ran npm run build", - "Committed release artifacts", - "Git tag skipped (--no-tag)", - "GitHub release skipped (--no-gh)", - "Release ", - "v1.1.0", - "complete", - } { - if !strings.Contains(output, want) { - t.Fatalf("stdout = %q, want %q", output, want) - } - } - body, err := os.ReadFile(filepath.Join(repo, "package.json")) - if err != nil { - t.Fatalf("ReadFile(package.json) error = %v", err) - } - if !strings.Contains(string(body), `"version": "1.1.0"`) { - t.Fatalf("package.json = %s, want bumped version", string(body)) - } - changelog, err := os.ReadFile(filepath.Join(repo, "CHANGELOG.md")) - if err != nil { - t.Fatalf("ReadFile(CHANGELOG.md) error = %v", err) - } - for _, want := range []string{"## [Unreleased]", "- _No unreleased changes yet._", "## [1.1.0] - ", "### Added", "- Ship native release execution"} { - if !strings.Contains(string(changelog), want) { - t.Fatalf("CHANGELOG.md = %s, want %q", string(changelog), want) - } - } - subject := gitOutputReleaseTest(t, repo, "log", "-1", "--pretty=%s") - if subject != "chore: release v1.1.0" { - t.Fatalf("release commit subject = %q, want chore: release v1.1.0", subject) - } - tag := gitOutputReleaseTest(t, repo, "tag", "--list", "v1.1.0") - if tag != "" { - t.Fatalf("tag v1.1.0 = %q, want skipped", tag) - } - show := gitOutputReleaseTest(t, repo, "show", "--name-only", "--format=", "HEAD") - for _, want := range []string{"package.json", "CHANGELOG.md", "build-marker.txt"} { - if !strings.Contains(show, want) { - t.Fatalf("release commit files = %q, want %q", show, want) - } - } -} - -func TestRunnerReleaseDryRunIsNative(t *testing.T) { - repo := seedReleaseDryRunRepo(t, "feat: add native release dry run") - var stdout bytes.Buffer - - err := Runner{ - Stdout: &stdout, - WorkingDir: repo, - }.Run([]string{"release", "--dry-run"}) - if err != nil { - t.Fatalf("release --dry-run error = %v\n%s", err, stdout.String()) - } - output := stdout.String() - for _, want := range []string{ - "loaf release", - "Commits since tag:", - "feat: add native release dry run", - "Generated changelog:", - "### Added", - "- Add native release dry run", - "Version files:", - "package.json (1.0.0 → 1.1.0)", - "Suggested bump:", - "New version:", - "Actions:", - "--dry-run:", - "No changes made.", - } { - if !strings.Contains(output, want) { - t.Fatalf("stdout = %q, want %q", output, want) - } - } - body, err := os.ReadFile(filepath.Join(repo, "package.json")) - if err != nil { - t.Fatalf("ReadFile(package.json) error = %v", err) - } - if !strings.Contains(string(body), `"version": "1.0.0"`) { - t.Fatalf("package.json mutated during dry-run:\n%s", string(body)) - } - changelog, err := os.ReadFile(filepath.Join(repo, "CHANGELOG.md")) - if err != nil { - t.Fatalf("ReadFile(CHANGELOG.md) error = %v", err) - } - if strings.Contains(string(changelog), "## [1.1.0]") { - t.Fatalf("CHANGELOG.md mutated during dry-run:\n%s", string(changelog)) - } -} - -func TestRunnerReleaseDryRunStopsWhenNoUnreleasedChanges(t *testing.T) { - repo := seedReleaseTaggedRepo(t) - var stdout bytes.Buffer - - err := Runner{ - Stdout: &stdout, - WorkingDir: repo, - }.Run([]string{"release", "--dry-run"}) - if err != nil { - t.Fatalf("release --dry-run error = %v\n%s", err, stdout.String()) - } - output := stdout.String() - for _, want := range []string{ - "Commits since tag:", - "No unreleased changes found.", - } { - if !strings.Contains(output, want) { - t.Fatalf("stdout = %q, want %q", output, want) - } - } - for _, unwanted := range []string{ - "Generated changelog:", - "Version files:", - "Suggested bump:", - "New version:", - "Actions:", + {"release", "--yes"}, + {"release"}, } { - if strings.Contains(output, unwanted) { - t.Fatalf("stdout = %q, did not want %q for empty release range", output, unwanted) - } - } -} - -func TestRunnerReleaseDryRunValidatesFlagsNatively(t *testing.T) { - repo := seedReleaseDryRunRepo(t, "fix: keep validation native") - err := Runner{ - Stdout: &bytes.Buffer{}, - WorkingDir: repo, - }.Run([]string{"release", "--dry-run", "--bump", "bogus"}) - if err == nil || !strings.Contains(err.Error(), `Invalid bump type "bogus"`) { - t.Fatalf("release invalid bump error = %v, want native validation", err) - } -} - -func TestRunnerReleaseDryRunValidatesBaseAndVersionFileNatively(t *testing.T) { - repo := seedReleaseDryRunRepo(t, "fix: validate release inputs natively") - tests := []struct { - name string - args []string - want string - }{ - { - name: "missing base", - args: []string{"release", "--dry-run", "--base", "definitely-not-a-ref"}, - want: "does not exist or is not reachable", - }, - { - name: "missing version file", - args: []string{"release", "--dry-run", "--version-file", "missing/package.json"}, - want: "version file missing/package.json not found", - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - err := Runner{ - Stdout: &bytes.Buffer{}, - WorkingDir: repo, - }.Run(tc.args) - if err == nil || !strings.Contains(err.Error(), tc.want) { - t.Fatalf("Run(%v) error = %v, want %q", tc.args, err, tc.want) - } - }) - } -} - -func TestRunnerReleaseDryRunNormalizesSkipFlagsNatively(t *testing.T) { - repo := seedReleaseDryRunRepo(t, "fix: skip gh when tag is skipped") - var stdout bytes.Buffer - err := Runner{ - Stdout: &stdout, - WorkingDir: repo, - }.Run([]string{"release", "--dry-run", "--no-tag"}) - if err != nil { - t.Fatalf("release --dry-run --no-tag error = %v\n%s", err, stdout.String()) - } - output := stdout.String() - for _, want := range []string{"Create git tag v1.0.1 (--no-tag — skipped)", "Create GitHub release draft (--no-gh — skipped)"} { - if !strings.Contains(output, want) { - t.Fatalf("stdout = %q, want %q", output, want) - } - } -} - -func TestRunnerReleaseDryRunPreMergeOverridesAreNative(t *testing.T) { - repo := seedReleaseDryRunRepo(t, "fix: prepare release branch natively") - gitCLI(t, repo, "config", "loaf.release.base", "v1.0.0") - - tests := []struct { - name string - args []string - wantOut []string - wantErr []string - notWantOut []string - }{ - { - name: "default skips tag and gh", - args: []string{"release", "--dry-run", "--pre-merge"}, - wantOut: []string{"Auto-detected base:", "--no-tag — skipped", "--no-gh — skipped"}, - }, - { - name: "tag override", - args: []string{"release", "--dry-run", "--pre-merge", "--tag", "--base", "v1.0.0"}, - wantErr: []string{"--tag overrides --pre-merge default"}, - wantOut: []string{"--no-gh — skipped"}, - notWantOut: []string{"--no-tag — skipped"}, - }, - { - name: "gh override warning", - args: []string{"release", "--dry-run", "--pre-merge", "--gh", "--base", "v1.0.0"}, - wantErr: []string{"--gh overrides --pre-merge default"}, - }, - { - name: "tag and gh override", - args: []string{"release", "--dry-run", "--pre-merge", "--tag", "--gh", "--base", "v1.0.0"}, - wantErr: []string{"--tag overrides --pre-merge default", "--gh overrides --pre-merge default"}, - notWantOut: []string{"--no-tag — skipped", "--no-gh — skipped"}, - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - var stdout, stderr bytes.Buffer - err := Runner{ - Stdout: &stdout, - Stderr: &stderr, - WorkingDir: repo, - }.Run(tc.args) - if err != nil { - t.Fatalf("Run(%v) error = %v\nstdout:\n%s\nstderr:\n%s", tc.args, err, stdout.String(), stderr.String()) - } - for _, want := range tc.wantOut { - if !strings.Contains(stdout.String(), want) { - t.Fatalf("stdout = %q, want %q", stdout.String(), want) - } - } - for _, want := range tc.wantErr { - if !strings.Contains(stderr.String(), want) { - t.Fatalf("stderr = %q, want %q", stderr.String(), want) - } - } - for _, notWant := range tc.notWantOut { - if strings.Contains(stdout.String(), notWant) { - t.Fatalf("stdout = %q, should not contain %q", stdout.String(), notWant) - } - } - }) - } -} - -func TestRunnerReleasePostMergeRejectsIncompatibleFlagsNatively(t *testing.T) { - repo := seedReleaseDryRunRepo(t, "fix: validate post merge flags") - tests := []struct { - args []string - want string - }{ - {args: []string{"release", "--post-merge", "--bump", "patch"}, want: "--post-merge is incompatible with --bump"}, - {args: []string{"release", "--post-merge", "--dry-run"}, want: "--post-merge is incompatible with --dry-run"}, - {args: []string{"release", "--post-merge", "--no-tag"}, want: "--post-merge is incompatible with --no-tag"}, - {args: []string{"release", "--post-merge", "--no-gh"}, want: "--post-merge is incompatible with --no-gh"}, - {args: []string{"release", "--post-merge", "--base", "main"}, want: "--post-merge is incompatible with --base"}, - {args: []string{"release", "--post-merge", "--pre-merge"}, want: "--post-merge is incompatible with --pre-merge"}, - {args: []string{"release", "--post-merge", "--version-file", "package.json"}, want: "--post-merge is incompatible with --version-file"}, - {args: []string{"release", "--post-merge", "--yes"}, want: "--post-merge is incompatible with --yes"}, - } - for _, tc := range tests { - t.Run(strings.Join(tc.args[2:], "_"), func(t *testing.T) { - err := Runner{ - Stdout: &bytes.Buffer{}, - WorkingDir: repo, - }.Run(tc.args) - if err == nil || !strings.Contains(err.Error(), tc.want) { - t.Fatalf("Run(%v) error = %v, want %q", tc.args, err, tc.want) - } - }) - } -} - -func TestRunnerReleaseDryRunUsesConfiguredVersionFiles(t *testing.T) { - repo := seedReleaseDryRunRepo(t, "fix: use configured version file") - mkdirAll(t, filepath.Join(repo, ".agents")) - mkdirAll(t, filepath.Join(repo, "backend")) - writeFile(t, filepath.Join(repo, ".agents", "loaf.json"), "{\n \"release\": {\n \"versionFiles\": [\"backend/pyproject.toml\"]\n }\n}\n") - writeFile(t, filepath.Join(repo, "backend", "pyproject.toml"), "[project]\nname = \"backend\"\nversion = \"2.0.0\"\n") - var stdout bytes.Buffer - - err := Runner{ - Stdout: &stdout, - WorkingDir: repo, - }.Run([]string{"release", "--dry-run"}) - if err != nil { - t.Fatalf("release --dry-run error = %v\n%s", err, stdout.String()) - } - output := stdout.String() - if !strings.Contains(output, "backend/pyproject.toml (2.0.0 → 2.0.1)") { - t.Fatalf("stdout = %q, want configured version file", output) - } - if strings.Contains(output, "package.json (1.0.0 →") { - t.Fatalf("stdout = %q, should not use root package.json when config overrides exist", output) - } -} - -func TestRunnerReleaseDryRunShowsUvArtifactCommandNatively(t *testing.T) { - repo := seedReleaseDryRunRepo(t, "fix: show python release artifact command") - mkdirAll(t, filepath.Join(repo, ".agents")) - mkdirAll(t, filepath.Join(repo, "backend")) - writeFile(t, filepath.Join(repo, ".agents", "loaf.json"), "{\n \"release\": {\n \"versionFiles\": [\"backend/pyproject.toml\"]\n }\n}\n") - writeFile(t, filepath.Join(repo, "backend", "pyproject.toml"), "[project]\nname = \"backend\"\nversion = \"2.0.0\"\n") - writeFile(t, filepath.Join(repo, "backend", "uv.lock"), "# lock\n") - var stdout bytes.Buffer - - err := Runner{ - Stdout: &stdout, - WorkingDir: repo, - }.Run([]string{"release", "--dry-run"}) - if err != nil { - t.Fatalf("release --dry-run error = %v\n%s", err, stdout.String()) - } - for _, want := range []string{"backend/pyproject.toml", "Run uv sync (backend)"} { - if !strings.Contains(stdout.String(), want) { - t.Fatalf("stdout = %q, want %q", stdout.String(), want) - } - } - if strings.Contains(stdout.String(), "Run loaf build") { - t.Fatalf("stdout = %q, should use uv sync instead of loaf build", stdout.String()) - } -} - -func TestRunnerReleaseRunsUvSyncForConfiguredPyprojectNatively(t *testing.T) { - repo, base := seedReleasePyprojectApplyRepo(t, "feat: sync python release artifacts") - fakeBin := realpath(t, t.TempDir()) - writeFile(t, filepath.Join(fakeBin, "uv"), strings.Join([]string{ - "#!/bin/sh", - "test \"$1\" = \"sync\" || exit 64", - "printf 'synced\\n' > uv-marker.txt", - "printf '# lock\\nsynced\\n' > uv.lock", - "", - }, "\n")) - if err := os.Chmod(filepath.Join(fakeBin, "uv"), 0o755); err != nil { - t.Fatalf("Chmod(fake uv) error = %v", err) - } - t.Setenv("PATH", fakeBin+string(os.PathListSeparator)+os.Getenv("PATH")) - var stdout bytes.Buffer - - err := Runner{ - Stdout: &stdout, - WorkingDir: repo, - }.Run([]string{"release", "--bump", "patch", "--yes", "--no-tag", "--no-gh", "--base", base}) - if err != nil { - t.Fatalf("release pyproject error = %v\n%s", err, stdout.String()) - } - for _, want := range []string{"backend/pyproject.toml", "Ran uv sync in backend"} { - if !strings.Contains(stdout.String(), want) { - t.Fatalf("stdout = %q, want %q", stdout.String(), want) - } - } - marker, err := os.ReadFile(filepath.Join(repo, "backend", "uv-marker.txt")) - if err != nil { - t.Fatalf("ReadFile(uv-marker.txt) error = %v", err) - } - if string(marker) != "synced\n" { - t.Fatalf("uv-marker.txt = %q, want synced marker", marker) - } - show := gitOutputReleaseTest(t, repo, "show", "--name-only", "--format=", "HEAD") - for _, want := range []string{"backend/pyproject.toml", "backend/uv.lock", "backend/uv-marker.txt"} { - if !strings.Contains(show, want) { - t.Fatalf("release commit files = %q, want %q", show, want) + var stdout bytes.Buffer + err := Runner{ + Stdout: &stdout, + WorkingDir: workingDir, + }.Run(args) + if err == nil { + t.Fatalf("%v error = nil, want guidance", args) } - } -} - -func TestRunnerReleaseRefusesUnignoredVirtualenvFromUvSyncNatively(t *testing.T) { - repo, base := seedReleasePyprojectApplyRepo(t, "feat: catch virtualenv release artifact") - fakeBin := realpath(t, t.TempDir()) - writeFile(t, filepath.Join(fakeBin, "uv"), strings.Join([]string{ - "#!/bin/sh", - "test \"$1\" = \"sync\" || exit 64", - "mkdir -p .venv/bin", - "printf 'python\\n' > .venv/bin/python", - "printf '# lock\\nsynced\\n' > uv.lock", - "", - }, "\n")) - if err := os.Chmod(filepath.Join(fakeBin, "uv"), 0o755); err != nil { - t.Fatalf("Chmod(fake uv) error = %v", err) - } - t.Setenv("PATH", fakeBin+string(os.PathListSeparator)+os.Getenv("PATH")) - var stdout bytes.Buffer - - err := Runner{ - Stdout: &stdout, - WorkingDir: repo, - }.Run([]string{"release", "--bump", "patch", "--yes", "--no-tag", "--no-gh", "--base", base}) - if err == nil || !strings.Contains(err.Error(), "unignored virtual environment path detected") || !strings.Contains(err.Error(), "backend/.venv/bin/python") { - t.Fatalf("release pyproject venv error = %v, want virtualenv refusal\n%s", err, stdout.String()) - } - subject := gitOutputReleaseTest(t, repo, "log", "-1", "--pretty=%s") - if subject != "feat: catch virtualenv release artifact" { - t.Fatalf("HEAD subject = %q, want feature commit after refusal", subject) - } -} - -func seedReleaseDryRunRepo(t *testing.T, commitSubject string) string { - t.Helper() - repo := seedReleaseTaggedRepo(t) - writeFile(t, filepath.Join(repo, "feature.txt"), commitSubject+"\n") - gitCLI(t, repo, "add", "feature.txt") - gitCLI(t, repo, "commit", "-m", commitSubject) - return repo -} - -func seedReleaseLineageFreezeRepo(t *testing.T, version string) string { - t.Helper() - repo := seedReleaseDryRunRepo(t, "feat: exercise prerelease lineage release") - packagePath := filepath.Join(repo, "package.json") - body, err := os.ReadFile(packagePath) - if err != nil { - t.Fatal(err) - } - updated := []byte(strings.Replace(string(body), `"version": "1.0.0"`, fmt.Sprintf(`"version": %q`, version), 1)) - if !bytes.Equal(body, updated) { - if err := os.WriteFile(packagePath, updated, 0o644); err != nil { - t.Fatal(err) + msg := err.Error() + stdout.String() + if !strings.Contains(msg, "suggest") || !strings.Contains(msg, "cut") { + t.Fatalf("%v error = %v\n%s, want suggest/cut guidance", args, err, stdout.String()) } - gitCLI(t, repo, "add", "package.json") - gitCLI(t, repo, "commit", "-m", "chore: set release version") } - writeChangeFolder(t, repo, "20260710-root", executableLineageDoc("root", "line", "", "terminal")) - commitAllChangeTest(t, repo, "docs: add frozen release lineage") - return repo } func seedReleaseTaggedRepo(t *testing.T) string { @@ -1360,75 +75,6 @@ func seedReleaseTaggedRepo(t *testing.T) string { return repo } -func seedReleasePyprojectApplyRepo(t *testing.T, commitSubject string) (string, string) { - t.Helper() - repo := realpath(t, t.TempDir()) - gitCLI(t, repo, "init", "-b", "main") - gitCLI(t, repo, "config", "user.name", "Loaf Test") - gitCLI(t, repo, "config", "user.email", "loaf@example.test") - gitCLI(t, repo, "config", "commit.gpgsign", "false") - gitCLI(t, repo, "config", "tag.gpgsign", "false") - mkdirAll(t, filepath.Join(repo, ".agents")) - mkdirAll(t, filepath.Join(repo, "backend")) - writeFile(t, filepath.Join(repo, ".agents", "loaf.json"), "{\n \"release\": {\n \"versionFiles\": [\"backend/pyproject.toml\"]\n }\n}\n") - writeFile(t, filepath.Join(repo, "backend", "pyproject.toml"), "[project]\nname = \"backend\"\nversion = \"1.0.0\"\n") - writeFile(t, filepath.Join(repo, "backend", "uv.lock"), "# lock\n") - writeFile(t, filepath.Join(repo, "CHANGELOG.md"), strings.Join([]string{ - "# Changelog", - "", - "## [Unreleased]", - "", - "- Initial backend change", - "", - "## [1.0.0] - 2024-01-01", - "", - "- Initial release", - "", - }, "\n")) - gitCLI(t, repo, "add", ".") - gitCLI(t, repo, "commit", "-m", "chore: initial release") - base := gitOutputReleaseTest(t, repo, "rev-parse", "HEAD") - writeFile(t, filepath.Join(repo, "backend", "app.py"), commitSubject+"\n") - gitCLI(t, repo, "add", "backend/app.py") - gitCLI(t, repo, "commit", "-m", commitSubject) - return repo, base -} - -func seedReleaseApplyRepo(t *testing.T, commitSubject string) string { - t.Helper() - repo := realpath(t, t.TempDir()) - gitCLI(t, repo, "init", "-b", "main") - gitCLI(t, repo, "config", "user.name", "Loaf Test") - gitCLI(t, repo, "config", "user.email", "loaf@example.test") - gitCLI(t, repo, "config", "commit.gpgsign", "false") - gitCLI(t, repo, "config", "tag.gpgsign", "false") - writeFile(t, filepath.Join(repo, "package.json"), strings.Join([]string{ - "{", - ` "name": "release-fixture",`, - ` "version": "1.0.0",`, - ` "scripts": {`, - ` "build": "node -e \"require('fs').writeFileSync('build-marker.txt','built')\""`, - " }", - "}", - "", - }, "\n")) - writeFile(t, filepath.Join(repo, "CHANGELOG.md"), strings.Join([]string{ - "# Changelog", - "", - "## [Unreleased]", - "", - "- _No unreleased changes yet._", - "", - }, "\n")) - gitCLI(t, repo, "add", ".") - gitCLI(t, repo, "commit", "-m", "chore: initial release") - gitCLI(t, repo, "tag", "v1.0.0") - writeFile(t, filepath.Join(repo, "feature.txt"), commitSubject+"\n") - gitCLI(t, repo, "add", "feature.txt") - gitCLI(t, repo, "commit", "-m", commitSubject) - return repo -} - func gitOutputReleaseTest(t *testing.T, dir string, args ...string) string { t.Helper() cmd := exec.Command("git", args...) @@ -1439,111 +85,3 @@ func gitOutputReleaseTest(t *testing.T, dir string, args ...string) string { } return strings.TrimSpace(string(out)) } - -type releasePostMergeCall struct { - name string - args []string -} - -func mustResolveReleaseSnapshot(t *testing.T, repo string, options releaseOptions) releaseSnapshot { - t.Helper() - snap, err := resolveReleaseSnapshot(repo, options) - if err != nil { - t.Fatalf("resolveReleaseSnapshot: %v", err) - } - return snap -} - -func seedReleasePostMergeFiles(t *testing.T, version string) string { - t.Helper() - repo := realpath(t, t.TempDir()) - writeFile(t, filepath.Join(repo, "package.json"), fmt.Sprintf("{\n \"name\": \"release-fixture\",\n \"version\": %q\n}\n", version)) - writeFile(t, filepath.Join(repo, "CHANGELOG.md"), strings.Join([]string{ - "# Changelog", - "", - "## [Unreleased]", - "", - "- _No unreleased changes yet._", - "", - "## [" + version + "] - 2026-04-29", - "", - "### Added", - "- New feature (abc1234)", - "", - }, "\n")) - return repo -} - -func releasePostMergeHappyResponses(version string) map[string]releasePostMergeCommandResult { - tag := "v" + version - notes := "### Added\n- New feature (abc1234)" - return map[string]releasePostMergeCommandResult{ - "git status --porcelain": releasePostMergeOK(""), - "git symbolic-ref --short HEAD": releasePostMergeOK("main"), - "git config --get loaf.release.base": releasePostMergeExit(1), - "gh repo view --json defaultBranchRef -q .defaultBranchRef.name": releasePostMergeOK("main"), - "git symbolic-ref refs/remotes/origin/HEAD": releasePostMergeOK("refs/remotes/origin/main"), - "git log -1 --pretty=%s": releasePostMergeOK("feat: ship release-ready change (#42)"), - "git diff HEAD^ HEAD --name-only": releasePostMergeOK("CHANGELOG.md\npackage.json"), - "git tag --list " + tag: releasePostMergeOK(""), - "git ls-remote --tags origin refs/tags/" + tag: releasePostMergeOK(""), - "gh release view " + tag: releasePostMergeExit(1), - "git tag --points-at HEAD": releasePostMergeOK(""), - "gh pr view 42 --json headRefName -q .headRefName": releasePostMergeOK("feat/cool-thing"), - "git tag -s " + tag + " -m Release " + version: releasePostMergeOK(""), - "git push origin " + tag: releasePostMergeOK(""), - "gh release create " + tag + " --title " + tag + " --notes " + notes: releasePostMergeOK(""), - "gh release create " + tag + " --title " + tag + " --notes - A nifty change": releasePostMergeOK(""), - "git pull --rebase origin main": releasePostMergeOK(""), - "git branch -d feat/cool-thing": releasePostMergeOK(""), - "git push origin --delete feat/cool-thing": releasePostMergeOK(""), - } -} - -func scriptedReleasePostMergeRunner(responses map[string]releasePostMergeCommandResult) (releasePostMergeCommandRunner, func() []releasePostMergeCall) { - var calls []releasePostMergeCall - runner := func(root string, name string, args ...string) releasePostMergeCommandResult { - calls = append(calls, releasePostMergeCall{name: name, args: append([]string{}, args...)}) - key := releasePostMergeCommandKey(name, args...) - if result, ok := responses[key]; ok { - return result - } - return releasePostMergeCommandResult{exitCode: 1} - } - return runner, func() []releasePostMergeCall { - return append([]releasePostMergeCall{}, calls...) - } -} - -func releasePostMergeCommandKey(name string, args ...string) string { - if len(args) == 0 { - return name - } - return name + " " + strings.Join(args, " ") -} - -func releasePostMergeCallKeys(calls []releasePostMergeCall) []string { - keys := make([]string, 0, len(calls)) - for _, call := range calls { - keys = append(keys, releasePostMergeCommandKey(call.name, call.args...)) - } - return keys -} - -func releasePostMergeOK(stdout string) releasePostMergeCommandResult { - // Set raw and stdout identically so NUL-delimited fixtures survive the seam. - return releasePostMergeCommandResult{stdout: stdout, raw: stdout, exitCode: 0} -} - -func releasePostMergeExit(code int) releasePostMergeCommandResult { - return releasePostMergeCommandResult{exitCode: code} -} - -func containsReleasePostMergeCall(values []string, want string) bool { - for _, value := range values { - if value == want { - return true - } - } - return false -} diff --git a/internal/cli/release_track.go b/internal/cli/release_track.go new file mode 100644 index 000000000..bf485a479 --- /dev/null +++ b/internal/cli/release_track.go @@ -0,0 +1,1202 @@ +package cli + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "github.com/levifig/loaf/internal/project" + "github.com/levifig/loaf/internal/state" +) + +type releaseTrackOptions struct { + help bool + jsonOutput bool + dryRun bool + base string + bump string + noTag bool + noGh bool + includes []string +} + +type releaseTrackCommit struct { + Hash string `json:"hash"` + Subject string `json:"subject"` + Body string `json:"body,omitempty"` + Type string `json:"type,omitempty"` + Breaking bool `json:"breaking,omitempty"` +} + +type releaseTrackLandedIssue struct { + ID string `json:"id"` + Alias string `json:"alias,omitempty"` + Title string `json:"title"` + Status string `json:"status"` + Commits []releaseTrackCommit `json:"commits"` +} + +type releaseTrackMissingChild struct { + ID string `json:"id"` + Alias string `json:"alias,omitempty"` + Title string `json:"title"` + Status string `json:"status"` +} + +type releaseTrackPartialParent struct { + ID string `json:"id"` + Alias string `json:"alias,omitempty"` + Title string `json:"title"` + Status string `json:"status"` + Missing []releaseTrackMissingChild `json:"missing"` +} + +type releaseTrackBucketRow struct { + ID string `json:"id"` + Alias string `json:"alias,omitempty"` + Title string `json:"title"` + Bucket string `json:"bucket"` + Landed bool `json:"landed"` +} + +type releaseTrackBuckets struct { + Planned []releaseTrackBucketRow `json:"planned"` + UnplannedLanded []releaseTrackBucketRow `json:"unplanned_landed"` +} + +type releaseTrackSuggestion struct { + Base string `json:"base,omitempty"` + CurrentVersion string `json:"current_version,omitempty"` + SuggestedBump string `json:"suggested_bump"` + SuggestedVersion string `json:"suggested_version,omitempty"` + BumpEvidence string `json:"bump_evidence"` + Landed []releaseTrackLandedIssue `json:"landed"` + PartiallyLanded []releaseTrackPartialParent `json:"partially_landed"` + Unattributed []releaseTrackCommit `json:"unattributed"` + Buckets releaseTrackBuckets `json:"buckets"` + Notes string `json:"notes"` +} + +var releaseTrackMergeSubjectRE = regexp.MustCompile(`(?i)^merge\b`) + +func (r Runner) runReleaseSuggest(args []string, out io.Writer, runtimeRoot string) error { + options, err := parseReleaseTrackArgs(args, false) + if err != nil { + return err + } + if options.help { + writeReleaseSuggestHelp(out) + return nil + } + suggestion, err := r.computeReleaseTrackSuggestion(runtimeRoot, options.base) + if err != nil { + return err + } + if options.jsonOutput { + return writeJSON(out, suggestion) + } + writeReleaseTrackSuggestion(out, suggestion) + return nil +} + +func (r Runner) runReleaseCut(args []string, out io.Writer, runtimeRoot string) error { + options, err := parseReleaseTrackArgs(args, true) + if err != nil { + return err + } + if options.help { + writeReleaseCutHelp(out) + return nil + } + if !releaseIsGitRepo(runtimeRoot) { + return fmt.Errorf("Not a git repository") + } + suggestion, err := r.computeReleaseTrackSuggestion(runtimeRoot, options.base) + if err != nil { + return err + } + if options.bump != "" { + if !releaseValidBumps[options.bump] { + return fmt.Errorf("Invalid bump type %q. Must be one of: major, minor, patch, prerelease, release", options.bump) + } + suggestion.SuggestedBump = options.bump + suggestion.BumpEvidence = "overridden by --bump " + options.bump + if suggestion.CurrentVersion != "" { + suggestion.SuggestedVersion = bumpReleaseVersion(suggestion.CurrentVersion, options.bump) + } + suggestion.Notes = draftReleaseTrackNotes(suggestion.SuggestedVersion, time.Now().UTC().Format("2006-01-02"), suggestion.Landed, suggestion.Unattributed) + } + if suggestion.SuggestedVersion == "" { + return fmt.Errorf("could not compute a version to cut") + } + + projectRoot, resolver, err := r.releaseTrackState(runtimeRoot) + if err != nil { + return err + } + var included []state.Release + for _, ref := range options.includes { + release, err := state.GetRelease(context.Background(), projectRoot, resolver, ref) + if err != nil { + return fmt.Errorf("--includes %q: %w", ref, err) + } + included = append(included, release) + } + + if tagName, commit, version, ok := findUnrecordedReleaseCommit(runtimeRoot, projectRoot, resolver); ok { + suggestion.SuggestedVersion = version + if options.dryRun { + fmt.Fprintf(out, "--dry-run: would record existing tag %s at %s; nothing written.\n", tagName, commit) + return nil + } + return r.completeReleaseCutRecord(out, projectRoot, resolver, suggestion, included, tagName, commit, options) + } + + tagName := "v" + suggestion.SuggestedVersion + + existingTagCommit, tagExists := releaseTrackLookupTag(runtimeRoot, tagName) + existingRelease, releaseExists := releaseTrackLookupRecorded(projectRoot, resolver, tagName) + + if releaseExists && tagExists && existingRelease.TaggedCommit == existingTagCommit { + fmt.Fprintf(out, "Release %s already recorded at %s; nothing to cut.\n", existingRelease.Tag, existingRelease.TaggedCommit) + return r.finishReleaseCut(out, projectRoot, resolver, existingRelease, suggestion, options) + } + if releaseExists && !tagExists { + return fmt.Errorf("release %s is recorded at %s but tag %s is missing; restore the tag or delete the row before cutting", existingRelease.Version, existingRelease.TaggedCommit, tagName) + } + if tagExists && releaseExists && existingRelease.TaggedCommit != existingTagCommit { + return fmt.Errorf("tag %s points at %s but release row records %s", tagName, existingTagCommit, existingRelease.TaggedCommit) + } + + if options.noTag { + if !tagExists { + return fmt.Errorf("cut --no-tag requires tag %s to already exist; create it first or omit --no-tag", tagName) + } + } else if tagExists && !releaseExists { + // Resume after a partial cut: do not rewrite files or retag. + return r.completeReleaseCutRecord(out, projectRoot, resolver, suggestion, included, tagName, existingTagCommit, options) + } + + if options.dryRun { + writeReleaseTrackSuggestion(out, suggestion) + fmt.Fprintln(out, "Includes:") + if len(included) == 0 { + fmt.Fprintln(out, " (none)") + } + for _, release := range included { + fmt.Fprintf(out, " %s (%s) — reference only\n", release.Tag, release.Version) + } + fmt.Fprintf(out, "\n--dry-run: would cut %s (tag %s) with %d issue member(s); nothing written.\n", + suggestion.SuggestedVersion, tagName, len(suggestion.Landed)) + return nil + } + + if err := releaseTrackWorktreeClean(runtimeRoot); err != nil { + return err + } + + versionFiles, err := detectReleaseVersionFiles(runtimeRoot, nil) + if err != nil { + return err + } + if len(versionFiles) == 0 { + return fmt.Errorf("No version files found") + } + if err := rejectDisagreeingReleaseVersionFiles(versionFiles); err != nil { + return err + } + updates, err := prepareReleaseVersionUpdates(runtimeRoot, versionFiles, suggestion.SuggestedVersion) + if err != nil { + return fmt.Errorf("Failed to update version files: %w", err) + } + + written := make([]string, 0, len(updates)+1) + for _, update := range updates { + if err := os.WriteFile(update.path, []byte(update.content), 0o644); err != nil { + releaseTrackRestorePaths(runtimeRoot, written) + return fmt.Errorf("Failed to update %s: %w", update.relativePath, err) + } + written = append(written, update.relativePath) + fmt.Fprintf(out, "Updated %s (%s → %s)\n", update.relativePath, update.oldVersion, suggestion.SuggestedVersion) + } + if err := writeReleaseChangelog(runtimeRoot, suggestion.Notes); err != nil { + releaseTrackRestorePaths(runtimeRoot, written) + return fmt.Errorf("Failed to update CHANGELOG.md: %w", err) + } + written = append(written, "CHANGELOG.md") + fmt.Fprintln(out, "Updated CHANGELOG.md") + + if err := releaseCommandRun(runtimeRoot, "git", "add", "-A"); err != nil { + releaseTrackRestorePaths(runtimeRoot, written) + return fmt.Errorf("Failed to stage release artifacts: %w", err) + } + if err := releaseCommandRun(runtimeRoot, "git", "commit", "-m", "chore: release "+tagName); err != nil { + releaseTrackRestorePaths(runtimeRoot, written) + return fmt.Errorf("Failed to commit release: %w", err) + } + fmt.Fprintln(out, "Committed release artifacts") + + if !options.noTag { + if err := releaseCommandRun(runtimeRoot, "git", "tag", "-a", tagName, "-m", "Release "+suggestion.SuggestedVersion); err != nil { + return fmt.Errorf("committed release artifacts but failed to create tag %s: %w; delete the release commit or create the tag, then re-run loaf release cut --no-tag", tagName, err) + } + fmt.Fprintf(out, "Created tag %s\n", tagName) + } else { + fmt.Fprintln(out, "Git tag skipped (--no-tag)") + } + + taggedCommit, err := releaseTrackTagCommit(runtimeRoot, tagName) + if err != nil { + return fmt.Errorf("committed release artifacts but tag %s is missing: %w; create the tag, then re-run loaf release cut --no-tag", tagName, err) + } + return r.completeReleaseCutRecord(out, projectRoot, resolver, suggestion, included, tagName, taggedCommit, options) +} + +func (r Runner) completeReleaseCutRecord(out io.Writer, projectRoot project.Root, resolver state.PathResolver, suggestion releaseTrackSuggestion, included []state.Release, tagName, taggedCommit string, options releaseTrackOptions) error { + issueIDs := make([]string, 0, len(suggestion.Landed)) + for _, landed := range suggestion.Landed { + issueIDs = append(issueIDs, landed.ID) + } + includedIDs := make([]string, 0, len(included)) + for _, release := range included { + includedIDs = append(includedIDs, release.ID) + } + recorded, err := recordReleaseFn(context.Background(), projectRoot, resolver, state.RecordReleaseOptions{ + Version: suggestion.SuggestedVersion, + Tag: tagName, + TaggedCommit: taggedCommit, + Notes: suggestion.Notes, + IssueIDs: issueIDs, + IncludedIDs: includedIDs, + }) + if err != nil { + return fmt.Errorf("created tag %s at %s but failed to record the release: %w; re-run loaf release cut --no-tag to complete the record", tagName, taggedCommit, err) + } + fmt.Fprintf(out, "Recorded release %s at %s (%d member(s))\n", recorded.Tag, recorded.TaggedCommit, len(recorded.Members)) + return r.finishReleaseCut(out, projectRoot, resolver, recorded, suggestion, options) +} + +func (r Runner) finishReleaseCut(out io.Writer, projectRoot project.Root, resolver state.PathResolver, recorded state.Release, suggestion releaseTrackSuggestion, options releaseTrackOptions) error { + r.pushLinearReleaseOnCut(out, projectRoot, resolver, recorded) + + if options.noGh { + fmt.Fprintln(out, "GitHub release skipped (--no-gh)") + return nil + } + if !releaseGhAvailable() { + return r.warnReleaseTrackGitHubFailure(out, recorded, suggestion.Notes, fmt.Errorf("gh not found")) + } + ghArgs := []string{"release", "create", recorded.Tag, "--draft", "--title", recorded.Tag, "--notes", suggestion.Notes} + if releaseVersionIsPrerelease(recorded.Version) { + ghArgs = append(ghArgs, "--prerelease") + } + if err := verifyConfiguredGitHubAccount(projectRoot.Path(), out); err != nil { + return r.warnReleaseTrackGitHubFailure(out, recorded, suggestion.Notes, err) + } + if err := releaseCommandRun(projectRoot.Path(), "gh", ghArgs...); err != nil { + return r.warnReleaseTrackGitHubFailure(out, recorded, suggestion.Notes, err) + } + fmt.Fprintln(out, "Created GitHub release draft") + return nil +} + +var recordReleaseFn = state.RecordRelease + +func findUnrecordedReleaseCommit(root string, projectRoot project.Root, resolver state.PathResolver) (tagName, commit, version string, ok bool) { + subject := strings.TrimSpace(releaseCommandOutput(root, "git", "log", "-1", "--pretty=%s")) + if !strings.HasPrefix(subject, "chore: release v") { + return "", "", "", false + } + tagName = strings.TrimPrefix(subject, "chore: release ") + commit, err := releaseTrackTagCommit(root, tagName) + if err != nil { + return "", "", "", false + } + if _, exists := releaseTrackLookupRecorded(projectRoot, resolver, tagName); exists { + return "", "", "", false + } + return tagName, commit, strings.TrimPrefix(tagName, "v"), true +} + +func releaseTrackLookupTag(root, tagName string) (string, bool) { + commit, err := releaseTrackTagCommit(root, tagName) + if err != nil { + return "", false + } + return commit, true +} + +func releaseTrackLookupRecorded(projectRoot project.Root, resolver state.PathResolver, ref string) (state.Release, bool) { + recorded, err := state.GetRelease(context.Background(), projectRoot, resolver, ref) + if err != nil { + return state.Release{}, false + } + return recorded, true +} + +func releaseTrackRestorePaths(root string, paths []string) { + if len(paths) == 0 { + return + } + var restore []string + for _, path := range paths { + if releaseTrackPathExistsAtHEAD(root, path) { + restore = append(restore, path) + continue + } + _ = releaseCommandRun(root, "git", "rm", "-f", "--ignore-unmatch", "--", path) + _ = os.Remove(filepath.Join(root, path)) + } + if len(restore) == 0 { + return + } + args := append([]string{"restore", "--source=HEAD", "--staged", "--worktree", "--"}, restore...) + _ = releaseCommandRun(root, "git", args...) +} + +func releaseTrackPathExistsAtHEAD(root, path string) bool { + return releaseCommandRun(root, "git", "cat-file", "-e", "HEAD:"+filepath.ToSlash(path)) == nil +} + +func (r Runner) pushLinearReleaseOnCut(out io.Writer, projectRoot project.Root, resolver state.PathResolver, recorded state.Release) { + identity, ok, err := state.LookupIssueIdentity(context.Background(), projectRoot, resolver) + if err != nil { + r.warnLinearReleasePublication(out, recorded, err.Error(), nil) + return + } + if !ok || identity.Authority != state.IssueAuthorityLinear { + return + } + client, err := state.LinearClientFromEnv() + if err != nil { + r.warnLinearReleasePublication(out, recorded, err.Error(), nil) + return + } + result, err := state.PushLinearRelease(context.Background(), projectRoot, resolver, client, recorded) + if err != nil { + r.warnLinearReleasePublication(out, recorded, err.Error(), result.Unmapped) + return + } + if result.Skipped != "" { + if result.Skipped == state.LinearReleaseUnsupportedSkip { + return + } + r.warnLinearReleasePublication(out, recorded, result.Skipped, result.Unmapped) + return + } + if result.Supported && result.Release.ID != "" { + fmt.Fprintf(out, "Recorded Linear release %s (%d issue(s))\n", result.Release.Name, len(result.Release.IssueKeys)) + } + if len(result.Unmapped) > 0 { + r.warnLinearReleasePublication(out, recorded, "", result.Unmapped) + } +} + +func (r Runner) warnLinearReleasePublication(out io.Writer, recorded state.Release, reason string, unmapped []string) { + warnOut := r.Stderr + if warnOut == nil { + warnOut = out + } + switch { + case strings.TrimSpace(reason) != "" && len(unmapped) > 0: + fmt.Fprintf(warnOut, "warning: recorded release %s at %s but Linear publication failed: %s (unmapped members: %s)\n", recorded.Tag, recorded.TaggedCommit, reason, strings.Join(unmapped, ", ")) + case strings.TrimSpace(reason) != "": + fmt.Fprintf(warnOut, "warning: recorded release %s at %s but Linear publication failed: %s\n", recorded.Tag, recorded.TaggedCommit, reason) + case len(unmapped) > 0: + fmt.Fprintf(warnOut, "warning: recorded release %s at %s but Linear publication omitted unmapped members: %s\n", recorded.Tag, recorded.TaggedCommit, strings.Join(unmapped, ", ")) + } +} + +func (r Runner) warnReleaseTrackGitHubFailure(out io.Writer, recorded state.Release, notes string, err error) error { + warnOut := r.Stderr + if warnOut == nil { + warnOut = out + } + fmt.Fprintf(warnOut, "warning: recorded release %s at %s but GitHub release failed: %v\n", recorded.Tag, recorded.TaggedCommit, err) + fmt.Fprintf(warnOut, "retry: %s\n", formatReleaseTrackRetryCommand(recorded, notes)) + return nil +} + +func formatReleaseTrackRetryCommand(recorded state.Release, notes string) string { + args := []string{"gh", "release", "create", recorded.Tag, "--draft", "--title", recorded.Tag, "--notes", notes} + if releaseVersionIsPrerelease(recorded.Version) { + args = append(args, "--prerelease") + } + quoted := make([]string, len(args)) + for i, arg := range args { + quoted[i] = state.PosixSingleQuote(arg) + } + return strings.Join(quoted, " ") +} + +func releaseTrackTagCommit(root, tagName string) (string, error) { + commit := releaseCommandOutput(root, "git", "rev-parse", "refs/tags/"+tagName+"^{commit}") + if commit == "" { + return "", fmt.Errorf("tag %s does not exist", tagName) + } + return commit, nil +} + +func parseReleaseTrackArgs(args []string, cut bool) (releaseTrackOptions, error) { + var options releaseTrackOptions + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--help" || arg == "-h" || arg == "help": + options.help = true + case arg == "--json": + options.jsonOutput = true + case arg == "--dry-run": + options.dryRun = true + case arg == "--no-tag": + options.noTag = true + case arg == "--no-gh": + options.noGh = true + case arg == "--bump": + value, err := consumeFlagValue(args, &i, "--bump") + if err != nil { + return releaseTrackOptions{}, err + } + options.bump = value + case strings.HasPrefix(arg, "--bump="): + options.bump = strings.TrimPrefix(arg, "--bump=") + if options.bump == "" { + return releaseTrackOptions{}, fmt.Errorf("--bump requires a value") + } + case arg == "--base": + value, err := consumeFlagValue(args, &i, "--base") + if err != nil { + return releaseTrackOptions{}, err + } + options.base = value + case strings.HasPrefix(arg, "--base="): + options.base = strings.TrimPrefix(arg, "--base=") + if options.base == "" { + return releaseTrackOptions{}, fmt.Errorf("--base requires a value") + } + case arg == "--includes": + value, err := consumeFlagValue(args, &i, "--includes") + if err != nil { + return releaseTrackOptions{}, err + } + options.includes = append(options.includes, value) + case strings.HasPrefix(arg, "--includes="): + value := strings.TrimPrefix(arg, "--includes=") + if value == "" { + return releaseTrackOptions{}, fmt.Errorf("--includes requires a value") + } + options.includes = append(options.includes, value) + default: + return releaseTrackOptions{}, fmt.Errorf("unknown release option %q", arg) + } + } + if options.bump != "" && !releaseValidBumps[options.bump] { + return releaseTrackOptions{}, fmt.Errorf("Invalid bump type %q. Must be one of: major, minor, patch, prerelease, release", options.bump) + } + if !cut { + if options.dryRun { + return releaseTrackOptions{}, fmt.Errorf("suggest is read-only; --dry-run is not valid") + } + if options.noTag || options.noGh || len(options.includes) > 0 || options.bump != "" { + return releaseTrackOptions{}, fmt.Errorf("suggest does not accept cut-only flags") + } + } else if options.jsonOutput { + return releaseTrackOptions{}, fmt.Errorf("cut does not accept --json") + } + return options, nil +} + +func (r Runner) computeReleaseTrackSuggestion(runtimeRoot, baseFlag string) (releaseTrackSuggestion, error) { + if !releaseIsGitRepo(runtimeRoot) { + return releaseTrackSuggestion{}, fmt.Errorf("Not a git repository") + } + baseRef, err := resolveReleaseTrackBase(runtimeRoot, baseFlag) + if err != nil { + return releaseTrackSuggestion{}, err + } + commits := collectReleaseTrackCommits(runtimeRoot, baseRef) + projectRoot, resolver, err := r.releaseTrackState(runtimeRoot) + if err != nil { + return releaseTrackSuggestion{}, err + } + ctx := context.Background() + identity, ok, err := state.LookupIssueIdentity(ctx, projectRoot, resolver) + if err != nil { + return releaseTrackSuggestion{}, err + } + listed, err := state.ListIssues(ctx, projectRoot, resolver, state.IssueListOptions{Archived: true}) + if err != nil { + return releaseTrackSuggestion{}, err + } + journal, err := state.ListCommitJournalEntries(ctx, projectRoot, resolver) + if err != nil { + return releaseTrackSuggestion{}, err + } + buckets, err := state.ListIssueBuckets(ctx, projectRoot, resolver) + if err != nil { + return releaseTrackSuggestion{}, err + } + doneAt, err := state.ListLatestIssueDoneAt(ctx, projectRoot, resolver) + if err != nil { + return releaseTrackSuggestion{}, err + } + + prefix := state.DefaultIssuePrefix + if ok && identity.Prefix != "" { + prefix = identity.Prefix + } + byID := map[string]state.Issue{} + byAlias := map[string]state.Issue{} + childrenOf := map[string][]state.Issue{} + for _, issue := range listed.Issues { + byID[issue.ID] = issue + if issue.Alias != "" { + byAlias[strings.ToUpper(issue.Alias)] = issue + } + if issue.ParentID != "" { + childrenOf[issue.ParentID] = append(childrenOf[issue.ParentID], issue) + } + } + + journalByHash := resolveReleaseTrackJournalAliases(commits, prefix, journal) + landedCommits := map[string][]releaseTrackCommit{} + var unattributed []releaseTrackCommit + for _, commit := range commits { + issues := attributeReleaseTrackCommit(commit, prefix, byAlias, journalByHash[commit.Hash]) + if len(issues) == 0 { + unattributed = append(unattributed, commit) + continue + } + for _, issue := range issues { + landedCommits[issue.ID] = append(landedCommits[issue.ID], commit) + } + } + + landedIDs := map[string]bool{} + var landed []releaseTrackLandedIssue + for id, commitList := range landedCommits { + issue := byID[id] + landedIDs[id] = true + landed = append(landed, releaseTrackLandedIssue{ + ID: issue.ID, + Alias: issue.Alias, + Title: issue.Title, + Status: issue.Status, + Commits: commitList, + }) + } + sort.Slice(landed, func(i, j int) bool { + left := firstNonEmptyString(landed[i].Alias, landed[i].ID) + right := firstNonEmptyString(landed[j].Alias, landed[j].ID) + return left < right + }) + + var partial []releaseTrackPartialParent + seenParent := map[string]bool{} + for _, item := range landed { + issue := byID[item.ID] + if issue.ParentID == "" || seenParent[issue.ParentID] { + continue + } + seenParent[issue.ParentID] = true + parent := byID[issue.ParentID] + var missing []releaseTrackMissingChild + for _, child := range childrenOf[parent.ID] { + if child.Status != state.IssueStatusDone { + missing = append(missing, releaseTrackMissingChild{ + ID: child.ID, + Alias: child.Alias, + Title: child.Title, + Status: child.Status, + }) + } + } + if len(missing) == 0 { + continue + } + partial = append(partial, releaseTrackPartialParent{ + ID: parent.ID, + Alias: parent.Alias, + Title: parent.Title, + Status: parent.Status, + Missing: missing, + }) + } + sort.Slice(partial, func(i, j int) bool { + return firstNonEmptyString(partial[i].Alias, partial[i].ID) < firstNonEmptyString(partial[j].Alias, partial[j].ID) + }) + + bump, evidence := deriveReleaseTrackBump(commits, byID, childrenOf, landedIDs, doneAt, releaseTrackCommitterTime(runtimeRoot, baseRef)) + currentVersion, err := releaseTrackCurrentVersion(runtimeRoot, baseRef) + if err != nil { + return releaseTrackSuggestion{}, err + } + suggestedVersion := "" + if currentVersion != "" { + suggestedVersion = bumpReleaseVersion(currentVersion, bump) + } + notes := draftReleaseTrackNotes(suggestedVersion, time.Now().UTC().Format("2006-01-02"), landed, unattributed) + + var planned []releaseTrackBucketRow + plannedIDs := map[string]bool{} + for id, bucket := range buckets { + issue := byID[id] + plannedIDs[id] = true + planned = append(planned, releaseTrackBucketRow{ + ID: issue.ID, + Alias: issue.Alias, + Title: issue.Title, + Bucket: bucket, + Landed: landedIDs[id], + }) + } + sort.Slice(planned, func(i, j int) bool { + return firstNonEmptyString(planned[i].Alias, planned[i].ID) < firstNonEmptyString(planned[j].Alias, planned[j].ID) + }) + var unplanned []releaseTrackBucketRow + for _, item := range landed { + if plannedIDs[item.ID] { + continue + } + unplanned = append(unplanned, releaseTrackBucketRow{ + ID: item.ID, + Alias: item.Alias, + Title: item.Title, + Landed: true, + }) + } + + return releaseTrackSuggestion{ + Base: baseRef, + CurrentVersion: currentVersion, + SuggestedBump: bump, + SuggestedVersion: suggestedVersion, + BumpEvidence: evidence, + Landed: landed, + PartiallyLanded: partial, + Unattributed: unattributed, + Buckets: releaseTrackBuckets{Planned: planned, UnplannedLanded: unplanned}, + Notes: notes, + }, nil +} + +func (r Runner) releaseTrackState(runtimeRoot string) (project.Root, state.PathResolver, error) { + projectRoot, err := project.ResolveRoot(runtimeRoot) + if err != nil { + return project.Root{}, state.PathResolver{}, err + } + resolver := state.PathResolver{StateHome: r.StateHome} + status, err := state.Inspect(projectRoot, resolver) + if err != nil { + return project.Root{}, state.PathResolver{}, err + } + switch status.Mode { + case state.ModeMarkdownOnly: + return project.Root{}, state.PathResolver{}, sqliteStateRequiredError("release") + case state.ModeInvalid: + return project.Root{}, state.PathResolver{}, fmt.Errorf("state database is invalid; run `loaf state doctor`") + } + return projectRoot, resolver, nil +} + +func resolveReleaseTrackBase(root, baseFlag string) (string, error) { + if strings.TrimSpace(baseFlag) != "" { + resolved, err := validateReleaseBaseRef(root, baseFlag) + if err != nil { + return "", err + } + return resolved, nil + } + if tag := releaseLastTag(root); tag != "" { + return tag, nil + } + return "", nil +} + +func collectReleaseTrackCommits(root, base string) []releaseTrackCommit { + format := "%h%x00%s%x00%B%x00" + args := []string{"log", "--format=" + format} + if base != "" { + args = []string{"log", base + "..HEAD", "--format=" + format} + } + output := releaseCommandOutput(root, "git", args...) + if strings.TrimSpace(output) == "" { + return nil + } + var commits []releaseTrackCommit + for _, chunk := range strings.Split(output, "\x00\n") { + if strings.TrimSpace(chunk) == "" { + continue + } + parts := strings.Split(chunk, "\x00") + if len(parts) < 2 { + continue + } + hash := strings.TrimSpace(parts[0]) + subject := strings.TrimSpace(parts[1]) + body := "" + if len(parts) > 2 { + body = strings.TrimSpace(parts[2]) + } + if hash == "" { + continue + } + parsed := parseReleaseCommit(hash, subject, body) + commits = append(commits, releaseTrackCommit{ + Hash: parsed.Hash, + Subject: parsed.Raw, + Body: body, + Type: parsed.Type, + Breaking: parsed.Breaking, + }) + } + return commits +} + +func attributeReleaseTrackCommit(commit releaseTrackCommit, prefix string, byAlias map[string]state.Issue, journalAliases []string) []state.Issue { + if issues := resolveReleaseTrackAliases(extractReleaseTrackAliases(commit.Subject+"\n"+commit.Body, prefix, false), byAlias); len(issues) > 0 { + return issues + } + // Branch rung: merge-commit subjects ("Merge pull request #N from owner/branch") + // plus any alias found anywhere in the body. Squash subjects like + // "feat: add auth (#42)" carry no branch name; the original commit list + // in the body usually does. Do not call gh/network. An alias that lives + // only in a deleted branch name is unattributable locally and lands in + // the unattributed report. + var branchText []string + if releaseTrackMergeSubjectRE.MatchString(commit.Subject) { + branchText = append(branchText, extractReleaseTrackAliases(commit.Subject, prefix, true)...) + } + if commit.Body != "" { + branchText = append(branchText, extractReleaseTrackAliases(commit.Body, prefix, true)...) + } + if issues := resolveReleaseTrackAliases(branchText, byAlias); len(issues) > 0 { + return issues + } + return resolveReleaseTrackAliases(journalAliases, byAlias) +} + +var ( + releaseTrackURLRE = regexp.MustCompile(`https?://\S+`) + releaseTrackCodeSpanRE = regexp.MustCompile("`[^`]*`") +) + +func extractReleaseTrackAliases(text, prefix string, insensitive bool) []string { + if prefix == "" || strings.TrimSpace(text) == "" { + return nil + } + text = stripReleaseTrackAliasNoise(text) + pattern := `\b` + regexp.QuoteMeta(prefix) + `-\d+\b` + flags := "" + if insensitive { + flags = `(?i)` + } + re := regexp.MustCompile(flags + pattern) + return uniqueNonEmptyStrings(re.FindAllString(text, -1)) +} + +func stripReleaseTrackAliasNoise(text string) string { + text = releaseTrackURLRE.ReplaceAllString(text, "") + return releaseTrackCodeSpanRE.ReplaceAllString(text, "") +} + +func resolveReleaseTrackJournalAliases(commits []releaseTrackCommit, prefix string, journal []state.JournalEntryRecord) map[string][]string { + byHash := map[string][]string{} + for _, entry := range journal { + var matched []string + for _, commit := range commits { + if releaseTrackHashMatches(commit.Hash, entry.Scope) { + matched = append(matched, commit.Hash) + } + } + if len(matched) != 1 { + continue + } + aliases := uniqueNonEmptyStrings(append( + extractReleaseTrackAliases(entry.Message, prefix, false), + extractReleaseTrackAliases(entry.Message, prefix, true)..., + )) + if len(aliases) == 0 { + continue + } + byHash[matched[0]] = uniqueNonEmptyStrings(append(byHash[matched[0]], aliases...)) + } + return byHash +} + +func releaseTrackHashMatches(commitHash, scope string) bool { + commitHash = strings.ToLower(strings.TrimSpace(commitHash)) + scope = strings.ToLower(strings.TrimSpace(scope)) + if commitHash == "" || scope == "" { + return false + } + return commitHash == scope || strings.HasPrefix(commitHash, scope) || strings.HasPrefix(scope, commitHash) +} + +func resolveReleaseTrackAliases(aliases []string, byAlias map[string]state.Issue) []state.Issue { + seen := map[string]bool{} + var issues []state.Issue + for _, alias := range aliases { + issue, ok := byAlias[strings.ToUpper(alias)] + if !ok || seen[issue.ID] { + continue + } + seen[issue.ID] = true + issues = append(issues, issue) + } + return issues +} + +func deriveReleaseTrackBump(commits []releaseTrackCommit, byID map[string]state.Issue, childrenOf map[string][]state.Issue, landedIDs map[string]bool, doneAt map[string]string, baselineAt time.Time) (string, string) { + for _, commit := range commits { + if commit.Breaking { + return "major", "breaking marker in " + commit.Hash + } + } + var fullyLanded []string + seen := map[string]bool{} + for id := range landedIDs { + issue := byID[id] + parentID := issue.ParentID + if parentID == "" { + parentID = issue.ID + } + if seen[parentID] { + continue + } + seen[parentID] = true + parent := byID[parentID] + children := childrenOf[parent.ID] + if parent.Status != state.IssueStatusDone || len(children) < 2 { + continue + } + if !baselineAt.IsZero() { + parentDone, ok := parseReleaseTrackTime(doneAt[parent.ID]) + if !ok || !parentDone.After(baselineAt) { + continue + } + } + allLanded := true + for _, child := range children { + if child.Status != state.IssueStatusDone || !landedIDs[child.ID] { + allLanded = false + break + } + } + if allLanded { + fullyLanded = append(fullyLanded, firstNonEmptyString(parent.Alias, parent.ID)) + } + } + if len(fullyLanded) > 0 { + sort.Strings(fullyLanded) + return "minor", "closed multi-child parent " + fullyLanded[0] + " fully landed" + } + for _, commit := range commits { + if commit.Type == "feat" { + return "minor", "feat commit " + commit.Hash + } + } + return "patch", "fix/other commits only" +} + +func releaseTrackCurrentVersion(root, baseRef string) (string, error) { + if err := rejectDirtyReleaseVersionFiles(root); err != nil { + return "", err + } + files, err := detectReleaseVersionFiles(root, nil) + if err != nil { + return "", err + } + if len(files) > 0 { + if err := rejectDisagreeingReleaseVersionFiles(files); err != nil { + return "", err + } + } + tag := strings.TrimPrefix(baseRef, "v") + if _, ok := parseReleaseSemver(tag); ok { + if err := rejectVersionFilesDisagreeingWithBaseline(files, tag); err != nil { + return "", err + } + return tag, nil + } + ref := strings.TrimSpace(baseRef) + if ref == "" { + ref = "HEAD" + } + if version, err := releaseTrackCommittedVersion(root, ref); err != nil { + return "", err + } else if version != "" { + return version, nil + } + return "", nil +} + +func rejectVersionFilesDisagreeingWithBaseline(files []releaseVersionFile, baseline string) error { + if strings.TrimSpace(baseline) == "" { + return nil + } + var parts []string + disagree := false + for _, file := range files { + if file.CurrentVersion == "" { + continue + } + parts = append(parts, fmt.Sprintf("%s=%s", file.RelativePath, file.CurrentVersion)) + if file.CurrentVersion != baseline { + disagree = true + } + } + if !disagree { + return nil + } + return fmt.Errorf("version files disagree with baseline %s: %s", baseline, strings.Join(parts, ", ")) +} + +func rejectDirtyReleaseVersionFiles(root string) error { + files, err := detectReleaseVersionFiles(root, nil) + if err != nil { + return err + } + var dirty []string + for _, file := range files { + status := releaseCommandOutput(root, "git", "status", "--porcelain", "--", file.RelativePath) + if strings.TrimSpace(status) != "" { + dirty = append(dirty, file.RelativePath) + } + } + if len(dirty) == 0 { + return nil + } + return fmt.Errorf("version file %s has uncommitted modifications; commit or revert it before suggesting a release", strings.Join(dirty, ", ")) +} + +func releaseTrackCommittedVersion(root, ref string) (string, error) { + if strings.TrimSpace(ref) == "" { + return "", nil + } + files, err := detectReleaseVersionFiles(root, nil) + if err != nil { + return "", err + } + if len(files) == 0 { + return "", nil + } + var loaded []releaseVersionFile + for _, file := range files { + body := releaseCommandOutput(root, "git", "show", ref+":"+file.RelativePath) + if strings.TrimSpace(body) == "" { + continue + } + version, _, err := parseReleaseVersion(file.RelativePath, []byte(body+"\n")) + if err != nil || version == "" { + continue + } + copy := file + copy.CurrentVersion = version + loaded = append(loaded, copy) + } + if len(loaded) == 0 { + return "", nil + } + if err := rejectDisagreeingReleaseVersionFiles(loaded); err != nil { + return "", err + } + return loaded[0].CurrentVersion, nil +} + +func rejectDisagreeingReleaseVersionFiles(files []releaseVersionFile) error { + if len(files) < 2 { + return nil + } + version := files[0].CurrentVersion + agree := true + for _, file := range files[1:] { + if file.CurrentVersion != version { + agree = false + break + } + } + if agree { + return nil + } + parts := make([]string, 0, len(files)) + for _, file := range files { + parts = append(parts, fmt.Sprintf("%s=%s", file.RelativePath, file.CurrentVersion)) + } + return fmt.Errorf("version files disagree: %s", strings.Join(parts, ", ")) +} + +func releaseTrackCommitterTime(root, ref string) time.Time { + if strings.TrimSpace(ref) == "" { + return time.Time{} + } + raw := releaseCommandOutput(root, "git", "log", "-1", "--format=%cI", ref) + parsed, ok := parseReleaseTrackTime(raw) + if !ok { + return time.Time{} + } + return parsed +} + +func parseReleaseTrackTime(value string) (time.Time, bool) { + value = strings.TrimSpace(value) + if value == "" { + return time.Time{}, false + } + for _, layout := range []string{time.RFC3339Nano, time.RFC3339} { + parsed, err := time.Parse(layout, value) + if err == nil { + return parsed, true + } + } + return time.Time{}, false +} + +func draftReleaseTrackNotes(version, date string, landed []releaseTrackLandedIssue, unattributed []releaseTrackCommit) string { + if version == "" { + version = "unreleased" + } + lines := []string{fmt.Sprintf("## [%s] - %s", version, date)} + for _, item := range landed { + heading := firstNonEmptyString(item.Alias, item.ID) + if item.Title != "" { + heading += " — " + item.Title + } + lines = append(lines, "", "### "+heading) + for _, commit := range item.Commits { + lines = append(lines, fmt.Sprintf("- %s (%s)", commit.Subject, commit.Hash)) + } + } + if len(unattributed) > 0 { + lines = append(lines, "", "### Unattributed") + for _, commit := range unattributed { + message := commit.Subject + if parsed := parseReleaseCommit(commit.Hash, commit.Subject, commit.Body); parsed.Message != "" { + message = capitalizeReleaseMessage(parsed.Message) + } + lines = append(lines, fmt.Sprintf("- %s (%s)", message, commit.Hash)) + } + } + return strings.Join(lines, "\n") +} + +func writeReleaseTrackSuggestion(out io.Writer, suggestion releaseTrackSuggestion) { + fmt.Fprintf(out, "Base: %s\n", firstNonEmptyString(suggestion.Base, "(none)")) + fmt.Fprintf(out, "Suggested bump: %s → %s\n", suggestion.SuggestedBump, firstNonEmptyString(suggestion.SuggestedVersion, "(unknown)")) + fmt.Fprintf(out, "Evidence: %s\n\n", suggestion.BumpEvidence) + + fmt.Fprintln(out, "Landed:") + if len(suggestion.Landed) == 0 { + fmt.Fprintln(out, " (none)") + } + for _, item := range suggestion.Landed { + fmt.Fprintf(out, " %s — %s\n", firstNonEmptyString(item.Alias, item.ID), item.Title) + for _, commit := range item.Commits { + fmt.Fprintf(out, " %s %s\n", commit.Hash, commit.Subject) + } + } + + fmt.Fprintln(out, "\nPartially landed:") + if len(suggestion.PartiallyLanded) == 0 { + fmt.Fprintln(out, " (none)") + } + for _, item := range suggestion.PartiallyLanded { + fmt.Fprintf(out, " %s — %s\n", firstNonEmptyString(item.Alias, item.ID), item.Title) + for _, child := range item.Missing { + fmt.Fprintf(out, " missing %s (%s) — %s\n", firstNonEmptyString(child.Alias, child.ID), child.Status, child.Title) + } + } + + fmt.Fprintln(out, "\nUnattributed:") + if len(suggestion.Unattributed) == 0 { + fmt.Fprintln(out, " (none)") + } + for _, commit := range suggestion.Unattributed { + fmt.Fprintf(out, " %s %s\n", commit.Hash, commit.Subject) + } + + fmt.Fprintln(out, "\nBuckets (advisory):") + if len(suggestion.Buckets.Planned) == 0 && len(suggestion.Buckets.UnplannedLanded) == 0 { + fmt.Fprintln(out, " (none)") + } + for _, row := range suggestion.Buckets.Planned { + stateLabel := "not landed" + if row.Landed { + stateLabel = "landed" + } + fmt.Fprintf(out, " bucket:%s %s — %s (%s)\n", row.Bucket, firstNonEmptyString(row.Alias, row.ID), row.Title, stateLabel) + } + for _, row := range suggestion.Buckets.UnplannedLanded { + fmt.Fprintf(out, " unplanned %s — %s (landed)\n", firstNonEmptyString(row.Alias, row.ID), row.Title) + } + + fmt.Fprintln(out, "\nDrafted notes:") + fmt.Fprintln(out, suggestion.Notes) +} + +func releaseTrackWorktreeClean(root string) error { + status := releaseCommandOutput(root, "git", "status", "--porcelain=v1") + if status != "" { + return fmt.Errorf("working tree is not clean") + } + return nil +} + +func writeReleaseSuggestHelp(out io.Writer) { + fmt.Fprintln(out, strings.Join([]string{ + "Usage: loaf release suggest [options]", + "", + "Report landed work since the last version tag. Writes nothing.", + "", + "Options:", + " --base <ref> Use commits since <ref> instead of last tag", + " --json Output the suggestion as JSON", + " -h, --help Show help", + }, "\n")) +} + +func writeReleaseCutHelp(out io.Writer) { + fmt.Fprintln(out, strings.Join([]string{ + "Usage: loaf release cut [options]", + "", + "Cut a retroactive release from landed work. Records members as facts.", + "", + "Options:", + " --base <ref> Use commits since <ref> instead of last tag", + " --bump <type> Override the suggested bump", + " --includes <version|tag> Reference a prior release (repeatable)", + " --no-tag Skip git tag creation (tag v<version> must already exist)", + " --no-gh Skip GitHub release draft", + " --dry-run Print the plan and write nothing", + " -h, --help Show help", + }, "\n")) +} + +func uniqueNonEmptyStrings(values []string) []string { + seen := map[string]bool{} + var out []string + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || seen[strings.ToUpper(value)] { + continue + } + seen[strings.ToUpper(value)] = true + out = append(out, value) + } + return out +} + +func firstNonEmptyString(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} diff --git a/internal/cli/release_track_test.go b/internal/cli/release_track_test.go new file mode 100644 index 000000000..6f2e8e348 --- /dev/null +++ b/internal/cli/release_track_test.go @@ -0,0 +1,1027 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/levifig/loaf/internal/project" + "github.com/levifig/loaf/internal/state" +) + +func releaseTrackFixture(t *testing.T) (repo, stateHome string) { + t.Helper() + repo = seedReleaseTaggedRepo(t) + stateHome = t.TempDir() + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo, StateHome: stateHome}).Run([]string{"state", "init"}); err != nil { + t.Fatalf("state init error = %v", err) + } + return repo, stateHome +} + +func runReleaseTrack(t *testing.T, repo, stateHome string, args ...string) (string, error) { + t.Helper() + stdout, _, err := runReleaseTrackIO(t, repo, stateHome, args...) + return stdout, err +} + +func runReleaseTrackIO(t *testing.T, repo, stateHome string, args ...string) (string, string, error) { + t.Helper() + var stdout, stderr bytes.Buffer + err := Runner{Stdout: &stdout, Stderr: &stderr, WorkingDir: repo, StateHome: stateHome}.Run(append([]string{"release"}, args...)) + return stdout.String(), stderr.String(), err +} + +func TestReleaseSuggestGroupsLandedWorkAndSuggestsBump(t *testing.T) { + repo, stateHome := releaseTrackFixture(t) + if _, err := runIssue(t, repo, stateHome, "new", "Ship auth"); err != nil { + t.Fatalf("issue new error = %v", err) + } + writeFile(t, filepath.Join(repo, "auth.txt"), "auth\n") + gitCLI(t, repo, "add", "auth.txt") + gitCLI(t, repo, "commit", "-m", "feat: add auth for LOAF-1") + + out, err := runReleaseTrack(t, repo, stateHome, "suggest") + if err != nil { + t.Fatalf("release suggest error = %v\n%s", err, out) + } + if !strings.Contains(out, "LOAF-1 — Ship auth") { + t.Fatalf("suggest missing landed issue:\n%s", out) + } + if !strings.Contains(out, "Suggested bump: minor → 1.1.0") { + t.Fatalf("suggest bump = %q, want minor → 1.1.0", out) + } + if !strings.Contains(out, "feat: add auth for LOAF-1") { + t.Fatalf("suggest missing commit:\n%s", out) + } + + jsonOut, err := runReleaseTrack(t, repo, stateHome, "suggest", "--json") + if err != nil { + t.Fatalf("release suggest --json error = %v\n%s", err, jsonOut) + } + var suggestion releaseTrackSuggestion + if err := json.Unmarshal([]byte(jsonOut), &suggestion); err != nil { + t.Fatalf("unmarshal suggestion: %v\n%s", err, jsonOut) + } + if suggestion.SuggestedBump != "minor" || suggestion.SuggestedVersion != "1.1.0" { + t.Fatalf("json bump/version = %s/%s", suggestion.SuggestedBump, suggestion.SuggestedVersion) + } + if len(suggestion.Landed) != 1 || suggestion.Landed[0].Alias != "LOAF-1" { + t.Fatalf("json landed = %#v", suggestion.Landed) + } +} + +func TestReleaseSuggestReportsPartialParentWithoutBlocking(t *testing.T) { + repo, stateHome := releaseTrackFixture(t) + if _, err := runIssue(t, repo, stateHome, "new", "Parent"); err != nil { + t.Fatalf("issue new parent error = %v", err) + } + if _, err := runIssue(t, repo, stateHome, "new", "Child A", "--parent", "LOAF-1"); err != nil { + t.Fatalf("issue new child A error = %v", err) + } + if _, err := runIssue(t, repo, stateHome, "new", "Child B", "--parent", "LOAF-1"); err != nil { + t.Fatalf("issue new child B error = %v", err) + } + if _, err := runIssue(t, repo, stateHome, "status", "LOAF-2", "done"); err != nil { + t.Fatalf("status child A error = %v", err) + } + writeFile(t, filepath.Join(repo, "child-a.txt"), "a\n") + gitCLI(t, repo, "add", "child-a.txt") + gitCLI(t, repo, "commit", "-m", "feat: land child A LOAF-2") + + out, err := runReleaseTrack(t, repo, stateHome, "suggest") + if err != nil { + t.Fatalf("release suggest error = %v\n%s", err, out) + } + if !strings.Contains(out, "LOAF-1 — Parent") || !strings.Contains(out, "missing LOAF-3") { + t.Fatalf("suggest missing partial parent:\n%s", out) + } +} + +func TestReleaseSuggestNeverDropsUnattributedCommits(t *testing.T) { + repo, stateHome := releaseTrackFixture(t) + if _, err := runIssue(t, repo, stateHome, "new", "Documented"); err != nil { + t.Fatalf("issue new error = %v", err) + } + writeFile(t, filepath.Join(repo, "docs.txt"), "docs\n") + gitCLI(t, repo, "add", "docs.txt") + gitCLI(t, repo, "commit", "-m", "docs: mention LOAF-1") + writeFile(t, filepath.Join(repo, "orphan.txt"), "orphan\n") + gitCLI(t, repo, "add", "orphan.txt") + gitCLI(t, repo, "commit", "-m", "chore: no issue at all") + + out, err := runReleaseTrack(t, repo, stateHome, "suggest") + if err != nil { + t.Fatalf("release suggest error = %v\n%s", err, out) + } + if !strings.Contains(out, "chore: no issue at all") { + t.Fatalf("unattributed commit was dropped:\n%s", out) + } + if !strings.Contains(out, "LOAF-1 — Documented") { + t.Fatalf("attributed commit missing:\n%s", out) + } +} + +func TestReleaseSuggestAttributesMergeBranchAndJournal(t *testing.T) { + repo, stateHome := releaseTrackFixture(t) + if _, err := runIssue(t, repo, stateHome, "new", "Merge work"); err != nil { + t.Fatalf("issue new merge error = %v", err) + } + if _, err := runIssue(t, repo, stateHome, "new", "Journal work"); err != nil { + t.Fatalf("issue new journal error = %v", err) + } + + gitCLI(t, repo, "checkout", "-b", "feat/loaf-1-merge") + writeFile(t, filepath.Join(repo, "merge.txt"), "merge\n") + gitCLI(t, repo, "add", "merge.txt") + gitCLI(t, repo, "commit", "-m", "feat: do the thing") + gitCLI(t, repo, "checkout", "main") + gitCLI(t, repo, "merge", "--no-ff", "-m", "Merge branch 'feat/loaf-1-merge'", "feat/loaf-1-merge") + + writeFile(t, filepath.Join(repo, "journal.txt"), "journal\n") + gitCLI(t, repo, "add", "journal.txt") + gitCLI(t, repo, "commit", "-m", "fix: mapped only in the journal") + short := gitOutputReleaseTest(t, repo, "log", "-1", "--pretty=%h") + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo, StateHome: stateHome}).Run([]string{ + "journal", "log", "commit(" + short + "): LOAF-2", + }); err != nil { + t.Fatalf("journal log error = %v", err) + } + + out, err := runReleaseTrack(t, repo, stateHome, "suggest") + if err != nil { + t.Fatalf("release suggest error = %v\n%s", err, out) + } + if !strings.Contains(out, "LOAF-1 — Merge work") { + t.Fatalf("merge branch alias not attributed:\n%s", out) + } + if !strings.Contains(out, "LOAF-2 — Journal work") { + t.Fatalf("journal mapping not attributed:\n%s", out) + } +} + +func TestReleaseSuggestFullyLandedParentDerivesMinor(t *testing.T) { + repo, stateHome := releaseTrackFixture(t) + if _, err := runIssue(t, repo, stateHome, "new", "Parent"); err != nil { + t.Fatalf("issue new parent error = %v", err) + } + if _, err := runIssue(t, repo, stateHome, "new", "Child A", "--parent", "LOAF-1"); err != nil { + t.Fatalf("issue new child A error = %v", err) + } + if _, err := runIssue(t, repo, stateHome, "new", "Child B", "--parent", "LOAF-1"); err != nil { + t.Fatalf("issue new child B error = %v", err) + } + for _, alias := range []string{"LOAF-1", "LOAF-2", "LOAF-3"} { + if _, err := runIssue(t, repo, stateHome, "status", alias, "done"); err != nil { + t.Fatalf("status %s error = %v", alias, err) + } + } + writeFile(t, filepath.Join(repo, "a.txt"), "a\n") + gitCLI(t, repo, "add", "a.txt") + gitCLI(t, repo, "commit", "-m", "chore: land LOAF-2") + writeFile(t, filepath.Join(repo, "b.txt"), "b\n") + gitCLI(t, repo, "add", "b.txt") + gitCLI(t, repo, "commit", "-m", "chore: land LOAF-3") + + out, err := runReleaseTrack(t, repo, stateHome, "suggest") + if err != nil { + t.Fatalf("release suggest error = %v\n%s", err, out) + } + if !strings.Contains(out, "Suggested bump: minor → 1.1.0") { + t.Fatalf("fully landed parent should bump minor:\n%s", out) + } + if !strings.Contains(out, "closed multi-child parent LOAF-1 fully landed") { + t.Fatalf("missing parent evidence:\n%s", out) + } +} + +func TestReleaseSuggestBreakingCommitDerivesMajor(t *testing.T) { + repo, stateHome := releaseTrackFixture(t) + if _, err := runIssue(t, repo, stateHome, "new", "Break"); err != nil { + t.Fatalf("issue new error = %v", err) + } + writeFile(t, filepath.Join(repo, "break.txt"), "break\n") + gitCLI(t, repo, "add", "break.txt") + gitCLI(t, repo, "commit", "-m", "feat!: drop the old API LOAF-1") + + out, err := runReleaseTrack(t, repo, stateHome, "suggest") + if err != nil { + t.Fatalf("release suggest error = %v\n%s", err, out) + } + if !strings.Contains(out, "Suggested bump: major → 2.0.0") { + t.Fatalf("breaking commit should bump major:\n%s", out) + } +} + +func TestReleaseSuggestReadsBucketsWithoutConstraining(t *testing.T) { + repo, stateHome := releaseTrackFixture(t) + if _, err := runIssue(t, repo, stateHome, "new", "Now work"); err != nil { + t.Fatalf("issue new now error = %v", err) + } + if _, err := runIssue(t, repo, stateHome, "new", "Later work"); err != nil { + t.Fatalf("issue new later error = %v", err) + } + if _, err := runIssue(t, repo, stateHome, "bucket", "LOAF-1", "now"); err != nil { + t.Fatalf("bucket now error = %v", err) + } + if _, err := runIssue(t, repo, stateHome, "bucket", "LOAF-2", "later"); err != nil { + t.Fatalf("bucket later error = %v", err) + } + writeFile(t, filepath.Join(repo, "now.txt"), "now\n") + gitCLI(t, repo, "add", "now.txt") + gitCLI(t, repo, "commit", "-m", "feat: land now work LOAF-1") + + out, err := runReleaseTrack(t, repo, stateHome, "suggest") + if err != nil { + t.Fatalf("release suggest error = %v\n%s", err, out) + } + if !strings.Contains(out, "bucket:now LOAF-1") || !strings.Contains(out, "(landed)") { + t.Fatalf("missing planned landed bucket:\n%s", out) + } + if !strings.Contains(out, "bucket:later LOAF-2") || !strings.Contains(out, "(not landed)") { + t.Fatalf("missing planned unlanded bucket:\n%s", out) + } +} + +func TestReleaseCutRecordsMembersAndDryRunWritesNothing(t *testing.T) { + repo, stateHome := releaseTrackFixture(t) + if _, err := runIssue(t, repo, stateHome, "new", "Ship auth"); err != nil { + t.Fatalf("issue new error = %v", err) + } + if _, err := runIssue(t, repo, stateHome, "status", "LOAF-1", "done"); err != nil { + t.Fatalf("status error = %v", err) + } + writeFile(t, filepath.Join(repo, "auth.txt"), "auth\n") + gitCLI(t, repo, "add", "auth.txt") + gitCLI(t, repo, "commit", "-m", "feat: add auth LOAF-1") + + beforePkg, err := os.ReadFile(filepath.Join(repo, "package.json")) + if err != nil { + t.Fatalf("ReadFile package.json: %v", err) + } + beforeLog, err := os.ReadFile(filepath.Join(repo, "CHANGELOG.md")) + if err != nil { + t.Fatalf("ReadFile CHANGELOG.md: %v", err) + } + beforeHEAD := gitOutputReleaseTest(t, repo, "rev-parse", "HEAD") + + dryOut, err := runReleaseTrack(t, repo, stateHome, "cut", "--dry-run", "--no-gh") + if err != nil { + t.Fatalf("release cut --dry-run error = %v\n%s", err, dryOut) + } + if !strings.Contains(dryOut, "nothing written") { + t.Fatalf("dry-run output = %q", dryOut) + } + afterPkg, err := os.ReadFile(filepath.Join(repo, "package.json")) + if err != nil { + t.Fatal(err) + } + afterLog, err := os.ReadFile(filepath.Join(repo, "CHANGELOG.md")) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(beforePkg, afterPkg) || !bytes.Equal(beforeLog, afterLog) { + t.Fatal("dry-run mutated version files or changelog") + } + if got := gitOutputReleaseTest(t, repo, "rev-parse", "HEAD"); got != beforeHEAD { + t.Fatalf("dry-run moved HEAD from %s to %s", beforeHEAD, got) + } + if tags := gitOutputReleaseTest(t, repo, "tag", "--list"); tags != "v1.0.0" { + t.Fatalf("dry-run tags = %q", tags) + } + root, err := project.ResolveRoot(repo) + if err != nil { + t.Fatalf("ResolveRoot: %v", err) + } + listed, err := state.ListReleases(t.Context(), root, state.PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("ListReleases after dry-run: %v", err) + } + if len(listed) != 0 { + t.Fatalf("dry-run wrote %d release rows", len(listed)) + } + + gitCLI(t, repo, "tag", "v1.1.0") + tagCommit := gitOutputReleaseTest(t, repo, "rev-parse", "v1.1.0^{commit}") + cutOut, err := runReleaseTrack(t, repo, stateHome, "cut", "--no-tag", "--no-gh", "--base", "v1.0.0") + if err != nil { + t.Fatalf("release cut error = %v\n%s", err, cutOut) + } + if !strings.Contains(cutOut, "Recorded release v1.1.0") { + t.Fatalf("cut output = %q", cutOut) + } + pkg, err := os.ReadFile(filepath.Join(repo, "package.json")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(pkg), `"version": "1.1.0"`) { + t.Fatalf("package.json = %s", pkg) + } + logBody, err := os.ReadFile(filepath.Join(repo, "CHANGELOG.md")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(logBody), "## [1.1.0]") { + t.Fatalf("CHANGELOG.md missing release section:\n%s", logBody) + } + if tags := gitOutputReleaseTest(t, repo, "tag", "--list"); !strings.Contains(tags, "v1.0.0") || !strings.Contains(tags, "v1.1.0") { + t.Fatalf("cut --no-tag tags = %q", tags) + } + recorded, err := state.GetRelease(t.Context(), root, state.PathResolver{StateHome: stateHome}, "v1.1.0") + if err != nil { + t.Fatalf("GetRelease: %v", err) + } + if recorded.TaggedCommit != tagCommit { + t.Fatalf("tagged_commit = %q, want tag commit %q", recorded.TaggedCommit, tagCommit) + } + if len(recorded.Members) != 1 || recorded.Members[0].Kind != state.ReleaseMemberKindIssue { + t.Fatalf("members = %#v", recorded.Members) + } + issue, err := state.GetIssue(t.Context(), root, state.PathResolver{StateHome: stateHome}, "LOAF-1") + if err != nil { + t.Fatalf("GetIssue: %v", err) + } + if recorded.Members[0].MemberID != issue.ID { + t.Fatalf("member id = %q, want %q", recorded.Members[0].MemberID, issue.ID) + } +} + +func TestReleaseCutIncludesPrereleaseByReference(t *testing.T) { + repo, stateHome := releaseTrackFixture(t) + if _, err := runIssue(t, repo, stateHome, "new", "Alpha"); err != nil { + t.Fatalf("issue new error = %v", err) + } + writeFile(t, filepath.Join(repo, "alpha.txt"), "alpha\n") + gitCLI(t, repo, "add", "alpha.txt") + gitCLI(t, repo, "commit", "-m", "feat: alpha LOAF-1") + root, err := project.ResolveRoot(repo) + if err != nil { + t.Fatal(err) + } + pre, err := state.RecordRelease(t.Context(), root, state.PathResolver{StateHome: stateHome}, state.RecordReleaseOptions{ + Version: "1.1.0-alpha.1", + Tag: "v1.1.0-alpha.1", + TaggedCommit: gitOutputReleaseTest(t, repo, "rev-parse", "HEAD"), + }) + if err != nil { + t.Fatalf("seed prerelease: %v", err) + } + writeFile(t, filepath.Join(repo, "stable.txt"), "stable\n") + gitCLI(t, repo, "add", "stable.txt") + gitCLI(t, repo, "commit", "-m", "feat: stabilize LOAF-1") + gitCLI(t, repo, "tag", "v1.1.0") + + out, err := runReleaseTrack(t, repo, stateHome, "cut", "--no-tag", "--no-gh", "--includes", "v1.1.0-alpha.1", "--base", "v1.0.0") + if err != nil { + t.Fatalf("release cut --includes error = %v\n%s", err, out) + } + stable, err := state.GetRelease(t.Context(), root, state.PathResolver{StateHome: stateHome}, "1.1.0") + if err != nil { + t.Fatalf("GetRelease(stable): %v", err) + } + foundReleaseRef := false + foundIssue := false + for _, member := range stable.Members { + if member.Kind == state.ReleaseMemberKindRelease && member.MemberID == pre.ID { + foundReleaseRef = true + } + if member.Kind == state.ReleaseMemberKindIssue { + foundIssue = true + } + } + if !foundReleaseRef || !foundIssue { + t.Fatalf("stable members = %#v, want issue + prerelease reference", stable.Members) + } + if len(pre.Members) != 0 { + t.Fatalf("includes must not union prerelease members; prerelease members = %#v", pre.Members) + } +} + +func TestReleaseSuggestDoesNotMaterializeIdentity(t *testing.T) { + repo, stateHome := releaseTrackFixture(t) + root, err := project.ResolveRoot(repo) + if err != nil { + t.Fatalf("ResolveRoot: %v", err) + } + resolver := state.PathResolver{StateHome: stateHome} + status, err := state.Inspect(root, resolver) + if err != nil { + t.Fatalf("Inspect: %v", err) + } + if _, ok, err := state.LookupIssueIdentity(t.Context(), root, resolver); err != nil || ok { + t.Fatalf("fixture identity present: ok=%v err=%v", ok, err) + } + before, err := os.ReadFile(status.DatabasePath) + if err != nil { + t.Fatalf("ReadFile db: %v", err) + } + + out, err := runReleaseTrack(t, repo, stateHome, "suggest") + if err != nil { + t.Fatalf("release suggest error = %v\n%s", err, out) + } + after, err := os.ReadFile(status.DatabasePath) + if err != nil { + t.Fatalf("ReadFile db after suggest: %v", err) + } + if !bytes.Equal(before, after) { + t.Fatal("suggest mutated the database") + } + if _, ok, err := state.LookupIssueIdentity(t.Context(), root, resolver); err != nil || ok { + t.Fatalf("suggest materialized identity: ok=%v err=%v", ok, err) + } +} + +func TestReleaseCutNoTagRequiresExistingTag(t *testing.T) { + repo, stateHome := releaseTrackFixture(t) + if _, err := runIssue(t, repo, stateHome, "new", "Ship auth"); err != nil { + t.Fatalf("issue new error = %v", err) + } + writeFile(t, filepath.Join(repo, "auth.txt"), "auth\n") + gitCLI(t, repo, "add", "auth.txt") + gitCLI(t, repo, "commit", "-m", "feat: add auth LOAF-1") + + out, err := runReleaseTrack(t, repo, stateHome, "cut", "--no-tag", "--no-gh") + if err == nil || !strings.Contains(err.Error(), "v1.1.0") || !strings.Contains(err.Error(), "--no-tag") { + t.Fatalf("error = %v, want missing tag v1.1.0\n%s", err, out) + } + root, err := project.ResolveRoot(repo) + if err != nil { + t.Fatal(err) + } + listed, err := state.ListReleases(t.Context(), root, state.PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("ListReleases: %v", err) + } + if len(listed) != 0 { + t.Fatalf("refused cut wrote %d release rows", len(listed)) + } +} + +func TestReleaseCutGitHubFailureAfterRecordIsWarning(t *testing.T) { + repo, stateHome := releaseTrackFixture(t) + if _, err := runIssue(t, repo, stateHome, "new", "Ship auth"); err != nil { + t.Fatalf("issue new error = %v", err) + } + writeFile(t, filepath.Join(repo, "auth.txt"), "auth\n") + gitCLI(t, repo, "add", "auth.txt") + gitCLI(t, repo, "commit", "-m", "feat: add auth LOAF-1") + gitCLI(t, repo, "tag", "v1.1.0") + tagCommit := gitOutputReleaseTest(t, repo, "rev-parse", "v1.1.0^{commit}") + prependFailingGh(t) + + stdout, stderr, err := runReleaseTrackIO(t, repo, stateHome, "cut", "--no-tag", "--base", "v1.0.0") + if err != nil { + t.Fatalf("cut should warn, not fail, after recording: %v\n%s\n%s", err, stdout, stderr) + } + if !strings.Contains(stderr, "warning:") || !strings.Contains(stderr, "retry: "+state.PosixSingleQuote("gh")) { + t.Fatalf("stderr = %q, want warning with quoted retry command", stderr) + } + if !strings.Contains(stderr, state.PosixSingleQuote("v1.1.0")) { + t.Fatalf("stderr = %q, want POSIX-quoted tag in retry", stderr) + } + root, err := project.ResolveRoot(repo) + if err != nil { + t.Fatal(err) + } + recorded, err := state.GetRelease(t.Context(), root, state.PathResolver{StateHome: stateHome}, "v1.1.0") + if err != nil { + t.Fatalf("GetRelease: %v", err) + } + if recorded.TaggedCommit != tagCommit { + t.Fatalf("tagged_commit = %q, want %q", recorded.TaggedCommit, tagCommit) + } +} + +func TestReleaseSuggestAttributesSquashBodyAlias(t *testing.T) { + repo, stateHome := releaseTrackFixture(t) + if _, err := runIssue(t, repo, stateHome, "new", "Auth"); err != nil { + t.Fatalf("issue new error = %v", err) + } + gitCLI(t, repo, "commit", "--allow-empty", "-m", "feat: add auth (#42)", "-m", "* feat: implement login on loaf-1") + + out, err := runReleaseTrack(t, repo, stateHome, "suggest") + if err != nil { + t.Fatalf("release suggest error = %v\n%s", err, out) + } + if !strings.Contains(out, "LOAF-1 — Auth") { + t.Fatalf("squash body alias not attributed:\n%s", out) + } +} + +func TestReleaseSuggestIgnoresPreBaselineParentDone(t *testing.T) { + repo, stateHome := releaseTrackFixture(t) + if _, err := runIssue(t, repo, stateHome, "new", "Parent"); err != nil { + t.Fatalf("issue new parent error = %v", err) + } + if _, err := runIssue(t, repo, stateHome, "new", "Child A", "--parent", "LOAF-1"); err != nil { + t.Fatalf("issue new child A error = %v", err) + } + if _, err := runIssue(t, repo, stateHome, "new", "Child B", "--parent", "LOAF-1"); err != nil { + t.Fatalf("issue new child B error = %v", err) + } + for _, alias := range []string{"LOAF-1", "LOAF-2", "LOAF-3"} { + if _, err := runIssue(t, repo, stateHome, "status", alias, "done"); err != nil { + t.Fatalf("status %s error = %v", alias, err) + } + } + writeFile(t, filepath.Join(repo, "baseline.txt"), "after parent done\n") + gitCLI(t, repo, "add", "baseline.txt") + gitCommitDated(t, repo, "2099-01-01T00:00:00+00:00", "chore: baseline after parent done") + gitCLI(t, repo, "tag", "v-after-parent") + + writeFile(t, filepath.Join(repo, "a.txt"), "a\n") + gitCLI(t, repo, "add", "a.txt") + gitCLI(t, repo, "commit", "-m", "chore: land LOAF-2") + writeFile(t, filepath.Join(repo, "b.txt"), "b\n") + gitCLI(t, repo, "add", "b.txt") + gitCLI(t, repo, "commit", "-m", "chore: land LOAF-3") + + out, err := runReleaseTrack(t, repo, stateHome, "suggest", "--base", "v-after-parent") + if err != nil { + t.Fatalf("release suggest error = %v\n%s", err, out) + } + if strings.Contains(out, "closed multi-child parent") { + t.Fatalf("pre-baseline parent done should not derive minor:\n%s", out) + } + if !strings.Contains(out, "Suggested bump: patch") { + t.Fatalf("want patch bump from chore children only:\n%s", out) + } +} + +func TestReleaseTrackJournalPrefixMustResolveUniquely(t *testing.T) { + commits := []releaseTrackCommit{ + {Hash: "abc1234", Subject: "one"}, + {Hash: "abc9999", Subject: "two"}, + } + journal := []state.JournalEntryRecord{ + {Scope: "abc", Message: "LOAF-1"}, + } + byHash := resolveReleaseTrackJournalAliases(commits, "LOAF", journal) + if len(byHash) != 0 { + t.Fatalf("ambiguous prefix attributed %#v", byHash) + } + + journal = append(journal, state.JournalEntryRecord{Scope: "abc1234", Message: "LOAF-2"}) + byHash = resolveReleaseTrackJournalAliases(commits, "LOAF", journal) + if got := byHash["abc1234"]; len(got) != 1 || got[0] != "LOAF-2" { + t.Fatalf("unique prefix = %#v, want [LOAF-2]", byHash["abc1234"]) + } + if _, ok := byHash["abc9999"]; ok { + t.Fatalf("ambiguous prefix should not attribute abc9999: %#v", byHash) + } +} + +func TestReleaseSuggestKeepsEmptySubjectCommits(t *testing.T) { + repo, stateHome := releaseTrackFixture(t) + gitCLI(t, repo, "commit", "--allow-empty", "--allow-empty-message", "--file=/dev/null") + hash := gitOutputReleaseTest(t, repo, "log", "-1", "--pretty=%h") + + out, err := runReleaseTrack(t, repo, stateHome, "suggest") + if err != nil { + t.Fatalf("release suggest error = %v\n%s", err, out) + } + if !strings.Contains(out, hash) { + t.Fatalf("empty-subject commit %s was dropped:\n%s", hash, out) + } +} + +func TestReleaseCutRefusesDisagreeingVersionFiles(t *testing.T) { + repo, stateHome := releaseTrackFixture(t) + writeFile(t, filepath.Join(repo, "pyproject.toml"), "[project]\nname = \"fixture\"\nversion = \"2.0.0\"\n") + gitCLI(t, repo, "add", "pyproject.toml") + gitCLI(t, repo, "commit", "-m", "chore: add disagreeing version file") + + out, err := runReleaseTrack(t, repo, stateHome, "cut", "--no-tag", "--no-gh") + if err == nil { + t.Fatalf("cut succeeded, want version-file disagreement\n%s", out) + } + message := err.Error() + if !strings.Contains(message, "package.json") || !strings.Contains(message, "1.0.0") { + t.Fatalf("error missing package.json=1.0.0: %v", err) + } + if !strings.Contains(message, "pyproject.toml") || !strings.Contains(message, "2.0.0") { + t.Fatalf("error missing pyproject.toml=2.0.0: %v", err) + } +} + +func TestReleaseTrackRetryCommandQuotesShellMetacharacters(t *testing.T) { + notes := "see $(reboot) and `id` and it's\nreleased" + got := formatReleaseTrackRetryCommand(state.Release{Tag: "v1.1.0", Version: "1.1.0"}, notes) + want := strings.Join([]string{ + state.PosixSingleQuote("gh"), + state.PosixSingleQuote("release"), + state.PosixSingleQuote("create"), + state.PosixSingleQuote("v1.1.0"), + state.PosixSingleQuote("--draft"), + state.PosixSingleQuote("--title"), + state.PosixSingleQuote("v1.1.0"), + state.PosixSingleQuote("--notes"), + state.PosixSingleQuote(notes), + }, " ") + if got != want { + t.Fatalf("retry = %q, want %q", got, want) + } + if !strings.Contains(got, state.PosixSingleQuote(notes)) { + t.Fatalf("notes were not POSIX-quoted: %q", got) + } +} + +func TestWarnReleaseTrackGitHubFailureQuotesRetryNotes(t *testing.T) { + var stderr bytes.Buffer + notes := "see $(reboot) and `id` and it's\nreleased" + err := (Runner{Stderr: &stderr}).warnReleaseTrackGitHubFailure(&bytes.Buffer{}, state.Release{ + Tag: "v1.1.0", + Version: "1.1.0", + TaggedCommit: "abc1234", + }, notes, fmt.Errorf("simulated")) + if err != nil { + t.Fatalf("warnReleaseTrackGitHubFailure() error = %v", err) + } + got := stderr.String() + if !strings.Contains(got, "warning:") || !strings.Contains(got, "retry:") { + t.Fatalf("stderr = %q, want warning with retry", got) + } + if !strings.Contains(got, state.PosixSingleQuote(notes)) { + t.Fatalf("retry notes not POSIX-quoted:\n%s", got) + } + if strings.Contains(got, strconv.Quote(notes)) { + t.Fatalf("retry used Go Quote:\n%s", got) + } +} + +func TestReleaseCutNoTagIgnoresBranchNamedLikeTag(t *testing.T) { + repo, stateHome := releaseTrackFixture(t) + if _, err := runIssue(t, repo, stateHome, "new", "Ship"); err != nil { + t.Fatalf("issue new error = %v", err) + } + writeFile(t, filepath.Join(repo, "ship.txt"), "ship\n") + gitCLI(t, repo, "add", "ship.txt") + gitCLI(t, repo, "commit", "-m", "fix: ship LOAF-1") + gitCLI(t, repo, "branch", "v1.0.1") + + if got := gitOutputReleaseTest(t, repo, "rev-parse", "v1.0.1^{commit}"); got == "" { + t.Fatal("branch v1.0.1 must resolve via unqualified rev-parse") + } + if out, err := exec.Command("git", "-C", repo, "rev-parse", "refs/tags/v1.0.1^{commit}").CombinedOutput(); err == nil { + t.Fatalf("tag refs/tags/v1.0.1 unexpectedly exists:\n%s", out) + } + + out, err := runReleaseTrack(t, repo, stateHome, "cut", "--no-tag", "--no-gh") + if err == nil || !strings.Contains(err.Error(), "v1.0.1") || !strings.Contains(err.Error(), "--no-tag") { + t.Fatalf("error = %v, want missing tag v1.0.1\n%s", err, out) + } +} + +func TestReleaseSuggestDoesNotAttributeURLOrCodeSpanAliases(t *testing.T) { + repo, stateHome := releaseTrackFixture(t) + if _, err := runIssue(t, repo, stateHome, "new", "Tracked"); err != nil { + t.Fatalf("issue new error = %v", err) + } + writeFile(t, filepath.Join(repo, "docs.txt"), "docs\n") + gitCLI(t, repo, "add", "docs.txt") + gitCLI(t, repo, "commit", "-m", "docs: see https://tracker/LOAF-1 and `LOAF-1`") + + out, err := runReleaseTrack(t, repo, stateHome, "suggest") + if err != nil { + t.Fatalf("release suggest error = %v\n%s", err, out) + } + if strings.Contains(out, "LOAF-1 — Tracked") { + t.Fatalf("URL/code-span alias was attributed:\n%s", out) + } + if !strings.Contains(out, "docs: see https://tracker/LOAF-1") { + t.Fatalf("commit missing from unattributed:\n%s", out) + } +} + +func TestReleaseCutDryRunNoTagRequiresExistingTag(t *testing.T) { + repo, stateHome := releaseTrackFixture(t) + if _, err := runIssue(t, repo, stateHome, "new", "Ship auth"); err != nil { + t.Fatalf("issue new error = %v", err) + } + writeFile(t, filepath.Join(repo, "auth.txt"), "auth\n") + gitCLI(t, repo, "add", "auth.txt") + gitCLI(t, repo, "commit", "-m", "feat: add auth LOAF-1") + + out, err := runReleaseTrack(t, repo, stateHome, "cut", "--dry-run", "--no-tag", "--no-gh") + if err == nil || !strings.Contains(err.Error(), "v1.1.0") || !strings.Contains(err.Error(), "--no-tag") { + t.Fatalf("error = %v, want missing tag v1.1.0\n%s", err, out) + } + root, err := project.ResolveRoot(repo) + if err != nil { + t.Fatal(err) + } + listed, err := state.ListReleases(t.Context(), root, state.PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("ListReleases: %v", err) + } + if len(listed) != 0 { + t.Fatalf("refused dry-run wrote %d release rows", len(listed)) + } +} + +func TestReleaseCutMissingGhWarnsWithRetry(t *testing.T) { + repo, stateHome := releaseTrackFixture(t) + if _, err := runIssue(t, repo, stateHome, "new", "Ship auth"); err != nil { + t.Fatalf("issue new error = %v", err) + } + writeFile(t, filepath.Join(repo, "auth.txt"), "auth\n") + gitCLI(t, repo, "add", "auth.txt") + gitCLI(t, repo, "commit", "-m", "feat: add auth LOAF-1") + gitCLI(t, repo, "tag", "v1.1.0") + tagCommit := gitOutputReleaseTest(t, repo, "rev-parse", "refs/tags/v1.1.0^{commit}") + t.Setenv("PATH", pathWithoutGh(t)) + + stdout, stderr, err := runReleaseTrackIO(t, repo, stateHome, "cut", "--no-tag", "--base", "v1.0.0") + if err != nil { + t.Fatalf("cut should warn, not fail, when gh is missing: %v\n%s\n%s", err, stdout, stderr) + } + if !strings.Contains(stderr, "warning:") || !strings.Contains(stderr, "retry:") { + t.Fatalf("stderr = %q, want warning with retry command", stderr) + } + if !strings.Contains(stderr, state.PosixSingleQuote("gh")+" "+state.PosixSingleQuote("release")+" "+state.PosixSingleQuote("create")+" "+state.PosixSingleQuote("v1.1.0")) { + t.Fatalf("stderr = %q, want POSIX-quoted retry command", stderr) + } + if !strings.Contains(stderr, "gh not found") { + t.Fatalf("stderr = %q, want gh-not-found cause", stderr) + } + root, err := project.ResolveRoot(repo) + if err != nil { + t.Fatal(err) + } + recorded, err := state.GetRelease(t.Context(), root, state.PathResolver{StateHome: stateHome}, "v1.1.0") + if err != nil { + t.Fatalf("GetRelease: %v", err) + } + if recorded.TaggedCommit != tagCommit { + t.Fatalf("tagged_commit = %q, want %q", recorded.TaggedCommit, tagCommit) + } +} + +func pathWithoutGh(t *testing.T) string { + t.Helper() + gitPath, err := exec.LookPath("git") + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + if err := os.Symlink(gitPath, filepath.Join(dir, "git")); err != nil { + t.Fatalf("symlink git: %v", err) + } + return dir +} + +func prependFailingGh(t *testing.T) { + t.Helper() + dir := t.TempDir() + script := filepath.Join(dir, "gh") + if err := os.WriteFile(script, []byte("#!/bin/sh\necho 'simulated gh failure' >&2\nexit 1\n"), 0o755); err != nil { + t.Fatalf("write failing gh: %v", err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +func gitCommitDated(t *testing.T, repo, date, message string) { + t.Helper() + cmd := exec.Command("git", "commit", "-m", message) + cmd.Dir = repo + cmd.Env = append(os.Environ(), "GIT_AUTHOR_DATE="+date, "GIT_COMMITTER_DATE="+date) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git commit: %v\n%s", err, out) + } +} + +func TestReleaseSuggestUsesTagBaselineNotWorkingTreeVersion(t *testing.T) { + repo, stateHome := releaseTrackFixture(t) + if _, err := runIssue(t, repo, stateHome, "new", "Ship"); err != nil { + t.Fatalf("issue new error = %v", err) + } + writeFile(t, filepath.Join(repo, "ship.txt"), "ship\n") + gitCLI(t, repo, "add", "ship.txt") + gitCLI(t, repo, "commit", "-m", "feat: ship LOAF-1") + + out, err := runReleaseTrack(t, repo, stateHome, "suggest") + if err != nil { + t.Fatalf("release suggest error = %v\n%s", err, out) + } + if !strings.Contains(out, "1.1.0") { + t.Fatalf("suggest = %q, want bump from tag baseline 1.0.0 → 1.1.0", out) + } +} + +func TestReleaseSuggestRefusesDirtyVersionFiles(t *testing.T) { + repo, stateHome := releaseTrackFixture(t) + writeFile(t, filepath.Join(repo, "package.json"), "{\n \"name\": \"release-fixture\",\n \"version\": \"9.9.9\",\n \"scripts\": {\n \"build\": \"echo build\"\n }\n}\n") + + out, err := runReleaseTrack(t, repo, stateHome, "suggest") + if err == nil { + t.Fatalf("suggest succeeded, want dirty version-file refusal\n%s", out) + } + if !strings.Contains(err.Error(), "package.json") || !strings.Contains(err.Error(), "uncommitted") { + t.Fatalf("error = %v, want dirty package.json refusal", err) + } +} + +func TestReleaseCutResumesRecordWhenTagExistsWithoutRow(t *testing.T) { + repo, stateHome := releaseTrackFixture(t) + if _, err := runIssue(t, repo, stateHome, "new", "Ship auth"); err != nil { + t.Fatalf("issue new error = %v", err) + } + writeFile(t, filepath.Join(repo, "auth.txt"), "auth\n") + gitCLI(t, repo, "add", "auth.txt") + gitCLI(t, repo, "commit", "-m", "feat: add auth LOAF-1") + + orig := recordReleaseFn + t.Cleanup(func() { recordReleaseFn = orig }) + recordReleaseFn = func(ctx context.Context, root project.Root, resolver state.PathResolver, options state.RecordReleaseOptions) (state.Release, error) { + return state.Release{}, fmt.Errorf("injected record failure") + } + out, err := runReleaseTrack(t, repo, stateHome, "cut", "--no-gh") + recordReleaseFn = orig + if err == nil || !strings.Contains(err.Error(), "injected record failure") { + t.Fatalf("cut error = %v\n%s, want injected record failure", err, out) + } + if tags := gitOutputReleaseTest(t, repo, "tag", "--list"); !strings.Contains(tags, "v1.1.0") { + t.Fatalf("tags after failed record = %q, want v1.1.0", tags) + } + + retry, err := runReleaseTrack(t, repo, stateHome, "cut", "--no-gh") + if err != nil { + t.Fatalf("cut retry error = %v\n%s", err, retry) + } + if !strings.Contains(retry, "Recorded release v1.1.0") && !strings.Contains(retry, "already recorded") { + t.Fatalf("retry output = %q, want record completed", retry) + } + root, err := project.ResolveRoot(repo) + if err != nil { + t.Fatal(err) + } + if _, err := state.GetRelease(t.Context(), root, state.PathResolver{StateHome: stateHome}, "v1.1.0"); err != nil { + t.Fatalf("GetRelease after retry: %v", err) + } +} + +func TestReleaseLegacyFlagPathStillDispatches(t *testing.T) { + var stdout bytes.Buffer + err := Runner{Stdout: &stdout, WorkingDir: t.TempDir()}.Run([]string{"release", "--help"}) + if err != nil { + t.Fatalf("release --help error = %v", err) + } + output := stdout.String() + if !strings.Contains(output, "Usage: loaf release <subcommand>") || !strings.Contains(output, "suggest") || !strings.Contains(output, "cut") { + t.Fatalf("release help missing suggest/cut:\n%s", output) + } +} + +func releaseTrackUntaggedFixture(t *testing.T) (repo, stateHome string) { + t.Helper() + repo = seedReleaseTaggedRepo(t) + gitCLI(t, repo, "tag", "-d", "v1.0.0") + stateHome = t.TempDir() + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo, StateHome: stateHome}).Run([]string{"state", "init"}); err != nil { + t.Fatalf("state init error = %v", err) + } + return repo, stateHome +} + +func TestReleaseSuggestAndCutUseCommittedVersionWhenNoTag(t *testing.T) { + repo, stateHome := releaseTrackUntaggedFixture(t) + if _, err := runIssue(t, repo, stateHome, "new", "Ship auth"); err != nil { + t.Fatalf("issue new error = %v", err) + } + writeFile(t, filepath.Join(repo, "auth.txt"), "auth\n") + gitCLI(t, repo, "add", "auth.txt") + gitCLI(t, repo, "commit", "-m", "feat: add auth LOAF-1") + + out, err := runReleaseTrack(t, repo, stateHome, "suggest") + if err != nil { + t.Fatalf("release suggest error = %v\n%s", err, out) + } + if !strings.Contains(out, "Suggested bump: minor → 1.1.0") { + t.Fatalf("suggest = %q, want 1.1.0 from committed package.json 1.0.0", out) + } + + cutOut, err := runReleaseTrack(t, repo, stateHome, "cut", "--no-gh") + if err != nil { + t.Fatalf("release cut error = %v\n%s", err, cutOut) + } + if !strings.Contains(cutOut, "Recorded release v1.1.0") { + t.Fatalf("cut output = %q", cutOut) + } + pkg, err := os.ReadFile(filepath.Join(repo, "package.json")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(pkg), `"version": "1.1.0"`) { + t.Fatalf("package.json = %s", pkg) + } + if tags := gitOutputReleaseTest(t, repo, "tag", "--list"); !strings.Contains(tags, "v1.1.0") { + t.Fatalf("tags = %q, want v1.1.0", tags) + } +} + +func TestReleaseCutCommitFailureRestoresCleanTree(t *testing.T) { + repo, stateHome := releaseTrackFixture(t) + if _, err := runIssue(t, repo, stateHome, "new", "Ship auth"); err != nil { + t.Fatalf("issue new error = %v", err) + } + writeFile(t, filepath.Join(repo, "auth.txt"), "auth\n") + gitCLI(t, repo, "add", "auth.txt") + gitCLI(t, repo, "commit", "-m", "feat: add auth LOAF-1") + + hook := filepath.Join(repo, ".git", "hooks", "pre-commit") + if err := os.WriteFile(hook, []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil { + t.Fatalf("write pre-commit hook: %v", err) + } + + beforePkg, err := os.ReadFile(filepath.Join(repo, "package.json")) + if err != nil { + t.Fatal(err) + } + beforeLog, err := os.ReadFile(filepath.Join(repo, "CHANGELOG.md")) + if err != nil { + t.Fatal(err) + } + beforeHEAD := gitOutputReleaseTest(t, repo, "rev-parse", "HEAD") + + out, err := runReleaseTrack(t, repo, stateHome, "cut", "--no-gh") + if err == nil { + t.Fatalf("cut succeeded, want commit failure\n%s", out) + } + if !strings.Contains(err.Error(), "Failed to commit release") { + t.Fatalf("error = %v, want commit failure", err) + } + status := gitOutputReleaseTest(t, repo, "status", "--porcelain") + if status != "" { + t.Fatalf("worktree dirty after failed cut:\n%s", status) + } + afterPkg, err := os.ReadFile(filepath.Join(repo, "package.json")) + if err != nil { + t.Fatal(err) + } + afterLog, err := os.ReadFile(filepath.Join(repo, "CHANGELOG.md")) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(beforePkg, afterPkg) || !bytes.Equal(beforeLog, afterLog) { + t.Fatal("failed cut left version or changelog changes in the worktree") + } + if got := gitOutputReleaseTest(t, repo, "rev-parse", "HEAD"); got != beforeHEAD { + t.Fatalf("failed cut moved HEAD from %s to %s", beforeHEAD, got) + } + if tags := gitOutputReleaseTest(t, repo, "tag", "--list"); tags != "v1.0.0" { + t.Fatalf("failed cut tags = %q", tags) + } +} + +func TestReleaseCutCommitFailureRemovesCreatedChangelog(t *testing.T) { + repo, stateHome := releaseTrackFixture(t) + gitCLI(t, repo, "rm", "CHANGELOG.md") + gitCLI(t, repo, "commit", "-m", "chore: drop changelog") + + if _, err := runIssue(t, repo, stateHome, "new", "Ship auth"); err != nil { + t.Fatalf("issue new error = %v", err) + } + writeFile(t, filepath.Join(repo, "auth.txt"), "auth\n") + gitCLI(t, repo, "add", "auth.txt") + gitCLI(t, repo, "commit", "-m", "feat: add auth LOAF-1") + + hook := filepath.Join(repo, ".git", "hooks", "pre-commit") + if err := os.WriteFile(hook, []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil { + t.Fatalf("write pre-commit hook: %v", err) + } + + beforeHEAD := gitOutputReleaseTest(t, repo, "rev-parse", "HEAD") + if _, err := os.Stat(filepath.Join(repo, "CHANGELOG.md")); !os.IsNotExist(err) { + t.Fatalf("fixture should have no CHANGELOG.md at HEAD: %v", err) + } + + out, err := runReleaseTrack(t, repo, stateHome, "cut", "--no-gh") + if err == nil { + t.Fatalf("cut succeeded, want commit failure\n%s", out) + } + if !strings.Contains(err.Error(), "Failed to commit release") { + t.Fatalf("error = %v, want commit failure", err) + } + status := gitOutputReleaseTest(t, repo, "status", "--porcelain") + if status != "" { + t.Fatalf("worktree dirty after failed cut:\n%s", status) + } + if _, err := os.Stat(filepath.Join(repo, "CHANGELOG.md")); !os.IsNotExist(err) { + t.Fatalf("created CHANGELOG.md left behind after failed cut: %v", err) + } + if got := gitOutputReleaseTest(t, repo, "rev-parse", "HEAD"); got != beforeHEAD { + t.Fatalf("failed cut moved HEAD from %s to %s", beforeHEAD, got) + } + if tags := gitOutputReleaseTest(t, repo, "tag", "--list"); tags != "v1.0.0" { + t.Fatalf("failed cut tags = %q", tags) + } +} diff --git a/internal/cli/target_capability_contract_test.go b/internal/cli/target_capability_contract_test.go index 4178a7e07..9c4fd4921 100644 --- a/internal/cli/target_capability_contract_test.go +++ b/internal/cli/target_capability_contract_test.go @@ -2,8 +2,6 @@ package cli import ( "bytes" - "crypto/sha256" - "encoding/hex" "encoding/json" "os" "path/filepath" @@ -319,11 +317,7 @@ func TestTargetCapabilityEvidenceRejectsRuntimeModesHiddenInBypassPhrases(t *tes } func TestTargetCapabilityEvidenceLoadRequiresRetainedRegularSources(t *testing.T) { - configPath := testTargetCapabilityEvidencePath(t) - data, err := os.ReadFile(configPath) - if err != nil { - t.Fatal(err) - } + root := testRepositoryRoot(t) for name, source := range map[string]string{ "absolute": filepath.Join(t.TempDir(), "evidence.md"), "traversal": "../outside.md", @@ -332,27 +326,13 @@ func TestTargetCapabilityEvidenceLoadRequiresRetainedRegularSources(t *testing.T "anchor": "docs/changes/20260710-journal-reliability-foundation/research/target-capability-survey.md#survey", } { t.Run(name, func(t *testing.T) { - var contract TargetCapabilityEvidenceContract - if err := json.Unmarshal(data, &contract); err != nil { - t.Fatal(err) - } - contract.Records[1].Completion.Evidence.Source = source - path := filepath.Join(filepath.Dir(configPath), ".capability-source-test-"+strings.ReplaceAll(name, " ", "-")+".json") - encoded, err := json.Marshal(contract) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, encoded, 0o600); err != nil { - t.Fatal(err) - } - defer os.Remove(path) - _, err = LoadTargetCapabilityEvidence(path) + err := validateEvidenceSourceFile(root, source) if name == "anchor" { if err != nil { - t.Fatalf("LoadTargetCapabilityEvidence() error = %v, want anchor accepted", err) + t.Fatalf("validateEvidenceSourceFile() error = %v, want anchor accepted", err) } } else if err == nil { - t.Fatalf("LoadTargetCapabilityEvidence() error = nil, want source rejection for %q", source) + t.Fatalf("validateEvidenceSourceFile() error = nil, want source rejection for %q", source) } }) } @@ -362,7 +342,7 @@ func TestTargetCapabilityEvidenceLoadRequiresRetainedRegularSources(t *testing.T t.Fatal(err) } relativeLink := filepath.Join("internal", "cli", ".capability-source-symlink") - link := filepath.Join(filepath.Dir(filepath.Dir(configPath)), relativeLink) + link := filepath.Join(root, relativeLink) if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil { t.Fatal(err) } @@ -370,22 +350,8 @@ func TestTargetCapabilityEvidenceLoadRequiresRetainedRegularSources(t *testing.T t.Fatal(err) } defer os.Remove(link) - var contract TargetCapabilityEvidenceContract - if err := json.Unmarshal(data, &contract); err != nil { - t.Fatal(err) - } - contract.Records[1].Completion.Evidence.Source = filepath.ToSlash(relativeLink) - path := filepath.Join(filepath.Dir(configPath), ".capability-source-test-symlink.json") - encoded, err := json.Marshal(contract) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, encoded, 0o600); err != nil { - t.Fatal(err) - } - defer os.Remove(path) - if _, err := LoadTargetCapabilityEvidence(path); err == nil { - t.Fatal("LoadTargetCapabilityEvidence() = nil, want symlink source rejection") + if err := validateEvidenceSourceFile(root, filepath.ToSlash(relativeLink)); err == nil { + t.Fatal("validateEvidenceSourceFile() = nil, want symlink source rejection") } }) t.Run("intermediate-symlink-escape", func(t *testing.T) { @@ -395,443 +361,13 @@ func TestTargetCapabilityEvidenceLoadRequiresRetainedRegularSources(t *testing.T t.Fatal(err) } relativeLink := "internal/.capability-source-linkdir" - link := filepath.Join(filepath.Dir(filepath.Dir(configPath)), filepath.FromSlash(relativeLink)) + link := filepath.Join(root, filepath.FromSlash(relativeLink)) if err := os.Symlink(outsideDir, link); err != nil { t.Fatal(err) } defer os.Remove(link) - var contract TargetCapabilityEvidenceContract - if err := json.Unmarshal(data, &contract); err != nil { - t.Fatal(err) - } - contract.Records[1].Completion.Evidence.Source = filepath.ToSlash(filepath.Join(relativeLink, "evidence.md")) - path := filepath.Join(filepath.Dir(configPath), ".capability-source-test-intermediate-symlink.json") - encoded, err := json.Marshal(contract) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, encoded, 0o600); err != nil { - t.Fatal(err) - } - defer os.Remove(path) - if _, err := LoadTargetCapabilityEvidence(path); err == nil { - t.Fatal("LoadTargetCapabilityEvidence() = nil, want intermediate symlink source rejection") - } - }) -} - -func TestInstalledSmokeEvidenceRejectsUnknownVersionsAndHashDrift(t *testing.T) { - data, err := os.ReadFile(filepath.Join(filepath.Dir(filepath.Dir(testTargetCapabilityEvidencePath(t))), "docs/changes/20260808-hooks-entry-reconciliation/research/claude-code-2.1.226-plugin-startup-smoke.json")) - if err != nil { - t.Fatal(err) - } - for name, mutate := range map[string]func(map[string]any){ - "unknown-field": func(raw map[string]any) { raw["unknown"] = true }, - "unknown-version": func(raw map[string]any) { raw["evidence_version"] = 3 }, - "hash-drift": func(raw map[string]any) { - raw["candidate_artifacts"].(map[string]any)["hooks_sha256"] = strings.Repeat("0", 64) - }, - } { - t.Run(name, func(t *testing.T) { - root := t.TempDir() - var raw map[string]any - if err := json.Unmarshal(data, &raw); err != nil { - t.Fatal(err) - } - mutate(raw) - receipt := filepath.Join(root, "receipt.json") - encoded, err := json.Marshal(raw) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(receipt, encoded, 0o600); err != nil { - t.Fatal(err) - } - artifacts := raw["candidate_artifacts"].(map[string]any) - for _, artifact := range []string{"hooks_path", "native_binary_path"} { - path := filepath.Join(root, filepath.FromSlash(artifacts[artifact].(string))) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatal(err) - } - originalRoot := filepath.Dir(filepath.Dir(testTargetCapabilityEvidencePath(t))) - original := filepath.Join(originalRoot, filepath.FromSlash(artifacts[artifact].(string))) - content, err := os.ReadFile(original) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, content, 0o644); err != nil { - t.Fatal(err) - } - } - contract := loadTestTargetCapabilityEvidence(t) - record := contract.Records[0] - mode := record.Context.Modes[0] - mode.Evidence.Source = "receipt.json" - if err := validateInstalledSmokeEvidence(root, record, mode); err == nil { - t.Fatalf("validateInstalledSmokeEvidence() = nil, want %s rejection", name) - } - }) - } -} - -func TestOpenCodeInstalledSmokeEvidenceAcceptsFixture(t *testing.T) { - record := openCodeTestRecord(t) - mode := modeEvidenceByName(record.Context.Modes)["request"] - if err := validateOpenCodeInstalledSmokeEvidence(testRepositoryRoot(t), record, mode); err != nil { - t.Fatalf("validateOpenCodeInstalledSmokeEvidence() error = %v, want positive fixture accepted", err) - } -} - -func TestOpenCodeInstalledSmokeEvidenceRejectsFalseBooleansIdentityInvocationHashAndCleanup(t *testing.T) { - receiptPath := filepath.Join(testRepositoryRoot(t), "docs/changes/20260808-hooks-entry-reconciliation/research/opencode-1.18.13-isolated-request-smoke.json") - data, err := os.ReadFile(receiptPath) - if err != nil { - t.Fatal(err) - } - mutations := map[string]func(map[string]any){ - "model-visible": func(raw map[string]any) { raw["model_visible_marker_observed"] = false }, - "assistant-match": func(raw map[string]any) { raw["assistant_marker_match"] = false }, - "plugin-loaded": func(raw map[string]any) { raw["plugin_loaded"] = false }, - "root-session-lookup": func(raw map[string]any) { raw["root_session_lookup_proven"] = false }, - "no-auth": func(raw map[string]any) { raw["no_auth_supplied"] = false }, - "identity": func(raw map[string]any) { raw["target"] = "wrong-target" }, - "invocation": func(raw map[string]any) { - invocation := raw["invocation"].(map[string]any) - invocation["command"] = "wrong-command" - }, - "hash": func(raw map[string]any) { - raw["candidate_artifacts"].(map[string]any)["hooks_sha256"] = strings.Repeat("0", 64) - }, - "cleanup": func(raw map[string]any) { raw["cleanup_succeeded"] = false }, - } - for name, mutate := range mutations { - t.Run(name, func(t *testing.T) { - var raw map[string]any - if err := json.Unmarshal(data, &raw); err != nil { - t.Fatal(err) - } - mutate(raw) - root := t.TempDir() - receipt := filepath.Join(root, "receipt.json") - encoded, err := json.Marshal(raw) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(receipt, encoded, 0o600); err != nil { - t.Fatal(err) - } - artifacts := raw["candidate_artifacts"].(map[string]any) - for _, artifact := range []string{"hooks_path", "native_binary_path"} { - path := filepath.Join(root, filepath.FromSlash(artifacts[artifact].(string))) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatal(err) - } - original := filepath.Join(testRepositoryRoot(t), filepath.FromSlash(artifacts[artifact].(string))) - content, err := os.ReadFile(original) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, content, 0o644); err != nil { - t.Fatal(err) - } - } - record := openCodeTestRecord(t) - mode := modeEvidenceByName(record.Context.Modes)["request"] - mode.Evidence.Source = "receipt.json" - if err := validateOpenCodeInstalledSmokeEvidence(root, record, mode); err == nil { - t.Fatalf("validateOpenCodeInstalledSmokeEvidence() = nil, want %s rejection", name) - } - }) - } -} - -func TestClaudeInstalledSmokeEvidenceRejectsPlatformSwappedNativeBinaryPath(t *testing.T) { - receiptPath := filepath.Join(testRepositoryRoot(t), "docs/changes/20260808-hooks-entry-reconciliation/research/claude-code-2.1.226-plugin-startup-smoke.json") - raw := readSmokeReceiptRaw(t, receiptPath) - artifacts := raw["candidate_artifacts"].(map[string]any) - sourceNativePath := artifacts["native_binary_path"].(string) - artifacts["native_binary_path"] = "plugins/loaf/bin/native/linux-amd64/loaf" - content, err := os.ReadFile(filepath.Join(testRepositoryRoot(t), filepath.FromSlash(sourceNativePath))) - if err != nil { - t.Fatal(err) - } - digest := sha256.Sum256(content) - artifacts["native_binary_sha256"] = hex.EncodeToString(digest[:]) - root := t.TempDir() - writeSmokeReceiptFixture(t, root, raw, sourceNativePath, content) - record := capabilityTestRecord(t, "claude-code", "cli") - mode := modeEvidenceByName(record.Context.Modes)["startup"] - mode.Evidence.Source = "receipt.json" - if err := validateInstalledSmokeEvidence(root, record, mode); err == nil || !strings.Contains(err.Error(), "native binary path") { - t.Fatalf("validateInstalledSmokeEvidence() error = %v, want platform-specific native binary path rejection", err) - } -} - -func TestCodexInstalledSmokeEvidenceRejectsPlatformSwappedNativeBinaryPath(t *testing.T) { - receiptPath := filepath.Join(testRepositoryRoot(t), "docs/changes/20260808-hooks-entry-reconciliation/research/codex-0.147.0-isolated-startup-smoke.json") - raw := readSmokeReceiptRaw(t, receiptPath) - artifacts := raw["candidate_artifacts"].(map[string]any) - sourceNativePath := artifacts["native_binary_path"].(string) - artifacts["native_binary_path"] = "bin/native/linux-amd64/loaf" - content, err := os.ReadFile(filepath.Join(testRepositoryRoot(t), filepath.FromSlash(sourceNativePath))) - if err != nil { - t.Fatal(err) - } - digest := sha256.Sum256(content) - artifacts["native_binary_sha256"] = hex.EncodeToString(digest[:]) - root := t.TempDir() - writeSmokeReceiptFixture(t, root, raw, sourceNativePath, content) - record := capabilityTestRecord(t, "codex", "cli") - mode := modeEvidenceByName(record.Context.Modes)["startup"] - mode.Evidence.Source = "receipt.json" - if err := validateCodexInstalledSmokeEvidence(root, record, mode); err == nil || !strings.Contains(err.Error(), "native binary path") { - t.Fatalf("validateCodexInstalledSmokeEvidence() error = %v, want platform-specific native binary path rejection", err) - } -} - -func TestCodexInstalledSmokeInvocationAcceptsOnlyASanctionedModelSelection(t *testing.T) { - prompt := "Return exactly the unique marker supplied by SessionStart context, and nothing else." - sanctioned := []string{"exec", "--ephemeral", "--ignore-rules", "--dangerously-bypass-hook-trust", "--sandbox", "read-only", "--json", "-C", "<disposable-repo>", prompt} - withModel := []string{"exec", "--ephemeral", "--ignore-rules", "--dangerously-bypass-hook-trust", "--sandbox", "read-only", "-m", "gpt-5.3-codex-spark", "--json", "-C", "<disposable-repo>", prompt} - for name, args := range map[string][]string{"no-model": sanctioned, "model": withModel} { - t.Run(name, func(t *testing.T) { - if err := validateCodexInstalledSmokeInvocation(TargetCapabilitySmokeInvocation{Command: "codex", CWD: "<disposable-repo>", Args: args}); err != nil { - t.Fatalf("validateCodexInstalledSmokeInvocation() error = %v, want accepted", err) - } - }) - } - rejected := map[string][]string{ - "blank-model": {"exec", "--ephemeral", "--ignore-rules", "--dangerously-bypass-hook-trust", "--sandbox", "read-only", "-m", " ", "--json", "-C", "<disposable-repo>", prompt}, - "dangling-model": {"exec", "--ephemeral", "--ignore-rules", "--dangerously-bypass-hook-trust", "--sandbox", "read-only", "-m"}, - "weakened-sandbox": {"exec", "--ephemeral", "--ignore-rules", "--dangerously-bypass-hook-trust", "--sandbox", "danger-full-access", "-m", "gpt-5.3-codex-spark", "--json", "-C", "<disposable-repo>", prompt}, - "smuggled-flag": {"exec", "--ephemeral", "--ignore-rules", "--dangerously-bypass-hook-trust", "--sandbox", "read-only", "-m", "gpt-5.3-codex-spark", "--yolo", "--json", "-C", "<disposable-repo>", prompt}, - "model-out-of-slot": {"exec", "--ephemeral", "-m", "gpt-5.3-codex-spark", "--ignore-rules", "--dangerously-bypass-hook-trust", "--sandbox", "read-only", "--json", "-C", "<disposable-repo>", prompt}, - } - for name, args := range rejected { - t.Run(name, func(t *testing.T) { - if err := validateCodexInstalledSmokeInvocation(TargetCapabilitySmokeInvocation{Command: "codex", CWD: "<disposable-repo>", Args: args}); err == nil { - t.Fatalf("validateCodexInstalledSmokeInvocation() = nil, want %s rejection", name) - } - }) - } -} - -func TestInstalledSmokeEvidenceRejectsCrossTargetNativeBinaryPaths(t *testing.T) { - tests := []struct { - name string - target string - receipt string - modeName string - wrongPath string - validateFn func(string, TargetCapabilityRecord, ModeEvidence) error - }{ - { - name: "claude-receives-codex-path", - target: "claude-code", - receipt: "claude-code-2.1.226-plugin-startup-smoke.json", - modeName: "startup", - wrongPath: "bin/native/darwin-arm64/loaf", - validateFn: validateInstalledSmokeEvidence, - }, - { - name: "codex-receives-claude-path", - target: "codex", - receipt: "codex-0.147.0-isolated-startup-smoke.json", - modeName: "startup", - wrongPath: "plugins/loaf/bin/native/darwin-arm64/loaf", - validateFn: validateCodexInstalledSmokeEvidence, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - receiptPath := filepath.Join(testRepositoryRoot(t), "docs/changes/20260808-hooks-entry-reconciliation/research", tt.receipt) - raw := readSmokeReceiptRaw(t, receiptPath) - artifacts := raw["candidate_artifacts"].(map[string]any) - sourceNativePath := artifacts["native_binary_path"].(string) - artifacts["native_binary_path"] = tt.wrongPath - content, err := os.ReadFile(filepath.Join(testRepositoryRoot(t), filepath.FromSlash(sourceNativePath))) - if err != nil { - t.Fatal(err) - } - digest := sha256.Sum256(content) - artifacts["native_binary_sha256"] = hex.EncodeToString(digest[:]) - root := t.TempDir() - writeSmokeReceiptFixture(t, root, raw, sourceNativePath, content) - record := capabilityTestRecord(t, tt.target, "cli") - mode := modeEvidenceByName(record.Context.Modes)[tt.modeName] - mode.Evidence.Source = "receipt.json" - if err := tt.validateFn(root, record, mode); err == nil || !strings.Contains(err.Error(), "native binary path") { - t.Fatalf("validation error = %v, want cross-target native binary path rejection", err) - } - }) - } -} - -func TestInstalledSmokeEvidenceRejectsSymlinkedCandidateArtifacts(t *testing.T) { - // Candidate artifact hashing must refuse symlink leaves and intermediate - // symlink directories even when target bytes match the receipt digest — - // otherwise the gate authenticates target content and git add -A commits - // the symlink itself. - tests := []struct { - name string - target string - receipt string - modeName string - validateFn func(string, TargetCapabilityRecord, ModeEvidence) error - }{ - { - name: "claude-code-hooks-leaf-symlink", - target: "claude-code", - receipt: "claude-code-2.1.226-plugin-startup-smoke.json", - modeName: "startup", - validateFn: validateInstalledSmokeEvidence, - }, - { - name: "codex-hooks-leaf-symlink", - target: "codex", - receipt: "codex-0.147.0-isolated-startup-smoke.json", - modeName: "startup", - validateFn: validateCodexInstalledSmokeEvidence, - }, - { - name: "opencode-hooks-leaf-symlink", - target: "opencode", - receipt: "opencode-1.18.13-isolated-request-smoke.json", - modeName: "request", - validateFn: validateOpenCodeInstalledSmokeEvidence, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - receiptPath := filepath.Join(testRepositoryRoot(t), "docs/changes/20260808-hooks-entry-reconciliation/research", tt.receipt) - raw := readSmokeReceiptRaw(t, receiptPath) - artifacts := raw["candidate_artifacts"].(map[string]any) - hooksRel := artifacts["hooks_path"].(string) - nativeRel := artifacts["native_binary_path"].(string) - - root := t.TempDir() - // Write native binary as a real file with matching hash. - nativeSrc := filepath.Join(testRepositoryRoot(t), filepath.FromSlash(nativeRel)) - nativeContent, err := os.ReadFile(nativeSrc) - if err != nil { - t.Fatal(err) - } - nativeDst := filepath.Join(root, filepath.FromSlash(nativeRel)) - if err := os.MkdirAll(filepath.Dir(nativeDst), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(nativeDst, nativeContent, 0o644); err != nil { - t.Fatal(err) - } - - // Leaf: candidate hooks path is a symlink to an external file whose - // bytes match the receipt hash (would pass ReadFile-based hashing). - hooksSrc := filepath.Join(testRepositoryRoot(t), filepath.FromSlash(hooksRel)) - hooksContent, err := os.ReadFile(hooksSrc) - if err != nil { - t.Fatal(err) - } - outside := filepath.Join(t.TempDir(), "hooks-payload") - if err := os.WriteFile(outside, hooksContent, 0o644); err != nil { - t.Fatal(err) - } - hooksDst := filepath.Join(root, filepath.FromSlash(hooksRel)) - if err := os.MkdirAll(filepath.Dir(hooksDst), 0o755); err != nil { - t.Fatal(err) - } - if err := os.Symlink(outside, hooksDst); err != nil { - t.Fatal(err) - } - - encoded, err := json.Marshal(raw) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "receipt.json"), encoded, 0o600); err != nil { - t.Fatal(err) - } - - record := capabilityTestRecord(t, tt.target, "cli") - mode := modeEvidenceByName(record.Context.Modes)[tt.modeName] - mode.Evidence.Source = "receipt.json" - err = tt.validateFn(root, record, mode) - if err == nil { - t.Fatalf("validate() = nil, want leaf symlink rejection naming %q", hooksRel) - } - if !strings.Contains(err.Error(), hooksRel) { - t.Fatalf("validate() error = %v, want path %q named", err, hooksRel) - } - if !strings.Contains(err.Error(), "not a regular file") { - t.Fatalf("validate() error = %v, want regular-file refusal", err) - } - }) - } - - t.Run("opencode-intermediate-dir-symlink", func(t *testing.T) { - receiptPath := filepath.Join(testRepositoryRoot(t), "docs/changes/20260808-hooks-entry-reconciliation/research/opencode-1.18.13-isolated-request-smoke.json") - raw := readSmokeReceiptRaw(t, receiptPath) - artifacts := raw["candidate_artifacts"].(map[string]any) - hooksRel := artifacts["hooks_path"].(string) - nativeRel := artifacts["native_binary_path"].(string) - - root := t.TempDir() - // Real native binary under root. - nativeContent, err := os.ReadFile(filepath.Join(testRepositoryRoot(t), filepath.FromSlash(nativeRel))) - if err != nil { - t.Fatal(err) - } - nativeDst := filepath.Join(root, filepath.FromSlash(nativeRel)) - if err := os.MkdirAll(filepath.Dir(nativeDst), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(nativeDst, nativeContent, 0o644); err != nil { - t.Fatal(err) - } - - // Intermediate: dist/opencode is a symlink to an external tree that holds - // hooks.ts with matching bytes. - hooksContent, err := os.ReadFile(filepath.Join(testRepositoryRoot(t), filepath.FromSlash(hooksRel))) - if err != nil { - t.Fatal(err) - } - outsideRoot := t.TempDir() - // hooks path is dist/opencode/plugins/hooks.ts — place payload under - // outsideRoot/plugins/hooks.ts and symlink root/dist/opencode -> outsideRoot. - outsideHooks := filepath.Join(outsideRoot, "plugins", "hooks.ts") - if err := os.MkdirAll(filepath.Dir(outsideHooks), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(outsideHooks, hooksContent, 0o644); err != nil { - t.Fatal(err) - } - link := filepath.Join(root, "dist", "opencode") - if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil { - t.Fatal(err) - } - if err := os.Symlink(outsideRoot, link); err != nil { - t.Fatal(err) - } - - encoded, err := json.Marshal(raw) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "receipt.json"), encoded, 0o600); err != nil { - t.Fatal(err) - } - - record := capabilityTestRecord(t, "opencode", "cli") - mode := modeEvidenceByName(record.Context.Modes)["request"] - mode.Evidence.Source = "receipt.json" - err = validateOpenCodeInstalledSmokeEvidence(root, record, mode) - if err == nil { - t.Fatal("validateOpenCodeInstalledSmokeEvidence() = nil, want intermediate symlink rejection") - } - if !strings.Contains(err.Error(), hooksRel) { - t.Fatalf("validate() error = %v, want path %q named", err, hooksRel) - } - if !strings.Contains(err.Error(), "symlink component") { - t.Fatalf("validate() error = %v, want symlink-component refusal", err) + if err := validateEvidenceSourceFile(root, filepath.ToSlash(filepath.Join(relativeLink, "evidence.md"))); err == nil { + t.Fatal("validateEvidenceSourceFile() = nil, want intermediate symlink source rejection") } }) } @@ -863,7 +399,11 @@ func TestTargetCapabilityEvidenceIsRecordOnly(t *testing.T) { func loadTestTargetCapabilityEvidence(t *testing.T) TargetCapabilityEvidenceContract { t.Helper() - contract, err := LoadTargetCapabilityEvidence(testTargetCapabilityEvidencePath(t)) + data, err := os.ReadFile(testTargetCapabilityEvidencePath(t)) + if err != nil { + t.Fatal(err) + } + contract, err := DecodeTargetCapabilityEvidence(data) if err != nil { t.Fatal(err) } diff --git a/internal/cli/verify_grammar.go b/internal/cli/verify_grammar.go new file mode 100644 index 000000000..b89c220ad --- /dev/null +++ b/internal/cli/verify_grammar.go @@ -0,0 +1,186 @@ +package cli + +import ( + "fmt" + "os/exec" + "strconv" + "strings" +) + +// changeExpectation is a parsed Expect declaration. The grammar is deliberately +// minimal: atoms joined by " and ", either `exit <N>` (required exit code) or +// “ contains `text` “ (combined stdout+stderr contains the text, repeatable). +// An absent Expect — or an Expect with no exit atom — means exit 0, which is +// exactly what verify enforced before the grammar existed. Every other clause is +// unenforceable: it lands in Advisory, is warned about, and never affects ok. +// +// This file is the shared home owned by the issue surface. +type changeExpectation struct { + ExitCode int + exitSeen bool + ExitConflict string // non-empty when a second exit atom contradicts the first + Contains []string + Advisory []string +} + +// changeVerifyExpectCheck is one enforced Expect atom and its outcome. +type changeVerifyExpectCheck struct { + Kind string `json:"kind"` + Value string `json:"value"` + OK bool `json:"ok"` +} + +func parseChangeExpectation(expect string) changeExpectation { + parsed := changeExpectation{} + for _, clause := range splitChangeExpectClauses(expect) { + kind, value, enforceable := parseChangeExpectClause(clause) + switch { + case kind == "": + continue // empty clause + case !enforceable: + parsed.Advisory = append(parsed.Advisory, value) + case kind == "exit": + code, _ := strconv.Atoi(value) + if parsed.exitSeen { + if parsed.ExitConflict == "" { + parsed.ExitConflict = fmt.Sprintf("%d and %d", parsed.ExitCode, code) + } else { + parsed.ExitConflict += fmt.Sprintf(" and %d", code) + } + } else { + parsed.ExitCode = code + parsed.exitSeen = true + } + case kind == "contains": + parsed.Contains = append(parsed.Contains, value) + } + } + return parsed +} + +// splitChangeExpectClauses splits on " and " outside backticks, so a +// “ contains `a and b` “ literal survives intact. +func splitChangeExpectClauses(expect string) []string { + lower := strings.ToLower(expect) + inTick := false + start := 0 + var clauses []string + for i := 0; i < len(expect); i++ { + if expect[i] == '`' { + inTick = !inTick + continue + } + if inTick { + continue + } + if strings.HasPrefix(lower[i:], " and ") { + clauses = append(clauses, expect[start:i]) + i += len(" and ") - 1 + start = i + 1 + } + } + return append(clauses, expect[start:]) +} + +// parseChangeExpectClause classifies one clause. kind is "" for an empty clause; +// enforceable is false for anything outside the grammar, and value then carries +// the clause verbatim for the warning and the advisory record. +func parseChangeExpectClause(clause string) (kind string, value string, enforceable bool) { + trimmed := strings.TrimSpace(clause) + // Authors end sentences; punctuation outside backticks is not part of an atom. + trimmed = strings.TrimSpace(strings.TrimRight(trimmed, ".,;")) + if trimmed == "" { + return "", "", false + } + lower := strings.ToLower(trimmed) + if strings.HasPrefix(lower, "exit ") { + code := strings.TrimSpace(trimmed[len("exit "):]) + if n, err := strconv.Atoi(code); err == nil && n >= 0 { + return "exit", code, true + } + return "exit", trimmed, false + } + if strings.HasPrefix(lower, "contains ") { + rest := strings.TrimSpace(trimmed[len("contains "):]) + if len(rest) >= 3 && rest[0] == '`' && rest[len(rest)-1] == '`' { + if text := rest[1 : len(rest)-1]; !strings.Contains(text, "`") { + return "contains", text, true + } + } + return "contains", trimmed, false + } + return "clause", trimmed, false +} + +// evaluateChangeExpectation records the enforced atoms and their outcomes. The +// exit atom is always recorded, so the receipt states what was enforced even when +// the criterion declared no Expect at all. An exit conflict is recorded alongside +// every declared contains atom — never instead of them — and keeps the criterion +// false regardless of the other atoms' outcomes. +func evaluateChangeExpectation(expectation changeExpectation, exitCode int, output string) []changeVerifyExpectCheck { + var checks []changeVerifyExpectCheck + if expectation.ExitConflict != "" { + checks = append(checks, changeVerifyExpectCheck{ + Kind: "exit-conflict", + Value: expectation.ExitConflict, + OK: false, + }) + } else { + checks = append(checks, changeVerifyExpectCheck{ + Kind: "exit", + Value: fmt.Sprintf("%d", expectation.ExitCode), + OK: exitCode == expectation.ExitCode, + }) + } + for _, text := range expectation.Contains { + checks = append(checks, changeVerifyExpectCheck{ + Kind: "contains", + Value: text, + OK: strings.Contains(output, text), + }) + } + return checks +} + +func changeExpectChecksPass(checks []changeVerifyExpectCheck) bool { + for _, check := range checks { + if !check.OK { + return false + } + } + return true +} + +// changeExpectFailureNote names the first unmet atom so a failure reads without +// opening the receipt. +func changeExpectFailureNote(runErr error, exitCode int, checks []changeVerifyExpectCheck) string { + if runErr != nil { + return fmt.Sprintf(" (command did not run: %v)", runErr) + } + for _, check := range checks { + if check.OK { + continue + } + if check.Kind == "exit-conflict" { + return fmt.Sprintf(" (contradictory exit atoms: %s)", check.Value) + } + if check.Kind == "contains" { + return fmt.Sprintf(" (output missing: contains `%s`)", check.Value) + } + return fmt.Sprintf(" (want exit %s, got %d)", check.Value, exitCode) + } + return "" +} + +func runChangeCriterionCommand(rootPath, command string) (int, string, error) { + cmd := exec.Command("bash", "-c", command) + cmd.Dir = rootPath + output, err := cmd.CombinedOutput() + if err == nil { + return 0, string(output), nil + } + if exitErr, ok := err.(*exec.ExitError); ok { + return exitErr.ExitCode(), string(output), nil + } + return 1, string(output), err +} diff --git a/internal/cli/verify_grammar_test.go b/internal/cli/verify_grammar_test.go new file mode 100644 index 000000000..e63ce7ca7 --- /dev/null +++ b/internal/cli/verify_grammar_test.go @@ -0,0 +1,192 @@ +package cli + +import "testing" + +func TestChangeExpectGrammarEnforcement(t *testing.T) { + cases := []struct { + name string + expect string + exitCode int + output string + wantOK bool + wantChecks []changeVerifyExpectCheck + wantAdvisory []string + }{ + { + name: "absent expect enforces exit zero", + expect: "", + exitCode: 0, + wantOK: true, + wantChecks: []changeVerifyExpectCheck{{Kind: "exit", Value: "0", OK: true}}, + }, + { + name: "absent expect fails on nonzero exit", + expect: "", + exitCode: 1, + wantOK: false, + wantChecks: []changeVerifyExpectCheck{{Kind: "exit", Value: "0", OK: false}}, + }, + { + name: "exit atom mismatch fails", + expect: "exit 2", + exitCode: 1, + wantOK: false, + wantChecks: []changeVerifyExpectCheck{{Kind: "exit", Value: "2", OK: false}}, + }, + { + name: "exit atom matches nonzero code", + expect: "exit 2.", + exitCode: 2, + wantOK: true, + wantChecks: []changeVerifyExpectCheck{{Kind: "exit", Value: "2", OK: true}}, + }, + { + name: "contains match passes", + expect: "contains `all green`", + output: "suite: all green\n", + wantOK: true, + wantChecks: []changeVerifyExpectCheck{ + {Kind: "exit", Value: "0", OK: true}, + {Kind: "contains", Value: "all green", OK: true}, + }, + }, + { + name: "contains mismatch fails even at exit zero", + expect: "contains `nope`.", + output: "ok\n", + wantOK: false, + wantChecks: []changeVerifyExpectCheck{ + {Kind: "exit", Value: "0", OK: true}, + {Kind: "contains", Value: "nope", OK: false}, + }, + }, + { + name: "multi atom and", + expect: "exit 0 and contains `first` and contains `second`", + output: "first then second\n", + wantOK: true, + wantChecks: []changeVerifyExpectCheck{ + {Kind: "exit", Value: "0", OK: true}, + {Kind: "contains", Value: "first", OK: true}, + {Kind: "contains", Value: "second", OK: true}, + }, + }, + { + name: "multi atom fails when one contains misses", + expect: "exit 0 and contains `first` and contains `second`", + output: "first only\n", + wantOK: false, + wantChecks: []changeVerifyExpectCheck{ + {Kind: "exit", Value: "0", OK: true}, + {Kind: "contains", Value: "first", OK: true}, + {Kind: "contains", Value: "second", OK: false}, + }, + }, + { + name: "backticked text keeps its own and", + expect: "contains `a and b`", + output: "x a and b y\n", + wantOK: true, + wantChecks: []changeVerifyExpectCheck{ + {Kind: "exit", Value: "0", OK: true}, + {Kind: "contains", Value: "a and b", OK: true}, + }, + }, + { + name: "unenforceable clause is advisory only", + expect: "exit 0 and the output reads well", + exitCode: 0, + wantOK: true, + wantChecks: []changeVerifyExpectCheck{{Kind: "exit", Value: "0", OK: true}}, + wantAdvisory: []string{"the output reads well"}, + }, + { + name: "advisory clause never rescues a failing atom", + expect: "exit 0 and looks right", + exitCode: 1, + wantOK: false, + wantChecks: []changeVerifyExpectCheck{{Kind: "exit", Value: "0", OK: false}}, + wantAdvisory: []string{"looks right"}, + }, + { + name: "retired and/or promise is advisory, not silently enforced", + expect: "exit 0 and/or specific output.", + exitCode: 0, + wantOK: true, + wantChecks: []changeVerifyExpectCheck{{Kind: "exit", Value: "0", OK: true}}, + wantAdvisory: []string{"exit 0 and/or specific output"}, + }, + { + name: "contains without backticks is advisory", + expect: "contains ok", + output: "ok\n", + wantOK: true, + wantChecks: []changeVerifyExpectCheck{{Kind: "exit", Value: "0", OK: true}}, + wantAdvisory: []string{"contains ok"}, + }, + { + name: "duplicate exit atoms fail loudly", + expect: "exit 1 and exit 0", + exitCode: 0, + wantOK: false, + wantChecks: []changeVerifyExpectCheck{ + {Kind: "exit-conflict", Value: "1 and 0", OK: false}, + }, + }, + { + name: "conflict records contains true alongside", + expect: "exit 1 and contains `sentinel` and exit 0", + output: "has sentinel here\n", + wantOK: false, + wantChecks: []changeVerifyExpectCheck{ + {Kind: "exit-conflict", Value: "1 and 0", OK: false}, + {Kind: "contains", Value: "sentinel", OK: true}, + }, + }, + { + name: "conflict records contains false alongside", + expect: "exit 1 and contains `sentinel` and exit 0", + output: "no match\n", + wantOK: false, + wantChecks: []changeVerifyExpectCheck{ + {Kind: "exit-conflict", Value: "1 and 0", OK: false}, + {Kind: "contains", Value: "sentinel", OK: false}, + }, + }, + { + name: "single exit with contains unchanged", + expect: "exit 0 and contains `ok`", + output: "ok\n", + wantOK: true, + wantChecks: []changeVerifyExpectCheck{ + {Kind: "exit", Value: "0", OK: true}, + {Kind: "contains", Value: "ok", OK: true}, + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + expectation := parseChangeExpectation(tc.expect) + checks := evaluateChangeExpectation(expectation, tc.exitCode, tc.output) + if got := changeExpectChecksPass(checks); got != tc.wantOK { + t.Fatalf("ok = %v, want %v (checks=%#v)", got, tc.wantOK, checks) + } + if len(checks) != len(tc.wantChecks) { + t.Fatalf("checks = %#v, want %#v", checks, tc.wantChecks) + } + for i, want := range tc.wantChecks { + if checks[i] != want { + t.Fatalf("check[%d] = %#v, want %#v", i, checks[i], want) + } + } + if len(expectation.Advisory) != len(tc.wantAdvisory) { + t.Fatalf("advisory = %#v, want %#v", expectation.Advisory, tc.wantAdvisory) + } + for i, want := range tc.wantAdvisory { + if expectation.Advisory[i] != want { + t.Fatalf("advisory[%d] = %q, want %q", i, expectation.Advisory[i], want) + } + } + }) + } +} diff --git a/internal/state/entity_registry.go b/internal/state/entity_registry.go index 6bd0ef3e0..d31bf5a5d 100644 --- a/internal/state/entity_registry.go +++ b/internal/state/entity_registry.go @@ -49,6 +49,7 @@ var entityRegistry = []entityDescriptor{ {Kind: "exploration", Table: "explorations", InternalIDResolvable: true}, {Kind: "exploration_checkpoint", Table: "exploration_checkpoints", InternalIDResolvable: true}, {Kind: "logical_conversation", Table: "logical_conversations", InternalIDResolvable: true}, + {Kind: "issue", Table: "issues", InternalIDResolvable: true, ResolutionTarget: true}, } func entityDescriptorForKind(kind string) (entityDescriptor, bool) { diff --git a/internal/state/export.go b/internal/state/export.go index f7e7aa5c9..845e92a2b 100644 --- a/internal/state/export.go +++ b/internal/state/export.go @@ -17,6 +17,7 @@ const ( ExportKindReleaseReadiness = "release-readiness" ExportKindSpec = "spec" ExportKindTriage = "triage" + ExportKindIssue = "issue" ExportFormatJSON = "json" ExportFormatMarkdown = "markdown" ExportAudienceLocal = "internal" @@ -172,6 +173,12 @@ var exportAllTables = []exportTable{ {Name: "exploration_conversations", OrderBy: "id", FilterColumn: "project_id"}, {Name: "journal_conversation_handles", OrderBy: "id", FilterColumn: "project_id"}, {Name: "source_availability_observations", OrderBy: "id", FilterColumn: "project_id"}, + {Name: "issues", OrderBy: "id", FilterColumn: "project_id"}, + {Name: "issue_criteria", OrderBy: "id", FilterColumn: "project_id"}, + {Name: "issue_criterion_claims", OrderBy: "id", FilterColumn: "project_id"}, + {Name: "issue_identity", OrderBy: "id", FilterColumn: "project_id"}, + {Name: "releases", OrderBy: "id", FilterColumn: "project_id"}, + {Name: "release_members", OrderBy: "id", FilterColumn: "project_id"}, } // ExportAllJSON returns a repository-non-mutating internal snapshot of SQLite state. diff --git a/internal/state/issue.go b/internal/state/issue.go new file mode 100644 index 000000000..41ebc30d9 --- /dev/null +++ b/internal/state/issue.go @@ -0,0 +1,875 @@ +package state + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "github.com/levifig/loaf/internal/project" +) + +const ( + IssueKindDelivery = "delivery" + IssueKindDecision = "decision" + + IssueStatusTriage = "triage" + IssueStatusBacklog = "backlog" + IssueStatusTodo = "todo" + IssueStatusActive = "active" + IssueStatusDone = "done" + IssueStatusCancelled = "cancelled" + IssueStatusDuplicate = "duplicate" + + IssueRelationshipRelatesTo = "relates_to" + IssueRelationshipBlocks = "blocks" + IssueRelationshipBlockedBy = "blocked_by" + + IssueBucketNow = "now" + IssueBucketNext = "next" + IssueBucketLater = "later" + IssueBucketNone = "none" + + issueEntityKind = "issue" + issueNamespace = "issue" + issueBucketTagPrefix = "bucket:" +) + +// IssueValidationError identifies malformed issue input. +type IssueValidationError struct { + Field string + Err error +} + +func (e *IssueValidationError) Error() string { + if e == nil { + return "issue validation failed" + } + return fmt.Sprintf("issue validation failed for %s: %v", e.Field, e.Err) +} + +func (e *IssueValidationError) Unwrap() error { return e.Err } + +// IssueTransactionError identifies the transactional stage that failed. +type IssueTransactionError struct { + Stage string + Err error +} + +func (e *IssueTransactionError) Error() string { + if e == nil { + return "issue transaction failed" + } + return fmt.Sprintf("issue transaction failed at %s: %v", e.Stage, e.Err) +} + +func (e *IssueTransactionError) Unwrap() error { return e.Err } + +// Issue is the derived read model for one issue. +type Issue struct { + ID string `json:"id"` + Alias string `json:"alias,omitempty"` + ParentID string `json:"parent_id,omitempty"` + Kind string `json:"kind"` + Title string `json:"title"` + Body string `json:"body"` + Fog string `json:"fog,omitempty"` + Status string `json:"status"` + ArchivedAt string `json:"archived_at,omitempty"` + StartedBranch string `json:"started_branch,omitempty"` + StartedWorktree string `json:"started_worktree,omitempty"` + WorktreeMissing bool `json:"worktree_missing,omitempty"` + Criteria []IssueCriterion `json:"criteria,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// IssueCreateOptions describes a new issue. +type IssueCreateOptions struct { + Title string + Body string + Fog string + Kind string + Parent string + Criteria []IssueCriterionInput + // Alias, when set, is stored as the issue alias without advancing + // issue_identity.next_number. Linear mint and pull use this so the + // tracker key becomes the alias and the local counter stays untouched. + Alias string +} + +// IssueUpdateOptions describes a partial issue mutation. Title, body, fog, +// and kind remain writable at every status — including cancelled and duplicate. +// StartedBranch and StartedWorktree are workspace facts written together +// through the same transaction as a status move when start records them. +type IssueUpdateOptions struct { + Ref string + Title string + SetTitle bool + Body string + SetBody bool + Fog string + SetFog bool + Kind string + SetKind bool + Parent string + SetParent bool + Status string + SetStatus bool + StartedBranch string + StartedWorktree string + SetStarted bool +} + +// IssueRemoveOptions cancels or marks an issue duplicate and archives it. +// The record and its relationship edges survive. +type IssueRemoveOptions struct { + Ref string + Status string + DuplicateOf string +} + +// IssueStatusParityMismatch is one issue whose status column disagrees with +// the latest events.to_status. +type IssueStatusParityMismatch struct { + IssueID string `json:"issue_id"` + ColumnStatus string `json:"column_status"` + EventStatus string `json:"event_status"` +} + +// IssueStatusParityResult is the projection check for a project's issues. +type IssueStatusParityResult struct { + Consistent bool `json:"consistent"` + Mismatches []IssueStatusParityMismatch `json:"mismatches,omitempty"` +} + +var issueStatuses = []string{ + IssueStatusTriage, + IssueStatusBacklog, + IssueStatusTodo, + IssueStatusActive, + IssueStatusDone, + IssueStatusCancelled, + IssueStatusDuplicate, +} + +var issueWriteStatuses = []string{ + IssueStatusTriage, + IssueStatusBacklog, + IssueStatusTodo, + IssueStatusActive, + IssueStatusDone, +} + +func validIssueStatus(status string) bool { + for _, candidate := range issueStatuses { + if status == candidate { + return true + } + } + return false +} + +func validIssueWriteStatus(status string) bool { + for _, candidate := range issueWriteStatuses { + if status == candidate { + return true + } + } + return false +} + +// IssueWriteStatuses returns the statuses CreateIssue/UpdateIssue may write +// (triage, backlog, todo, active, done). Removal statuses are not included. +func IssueWriteStatuses() []string { + out := make([]string, len(issueWriteStatuses)) + copy(out, issueWriteStatuses) + return out +} + +func validIssueKind(kind string) bool { + return kind == IssueKindDelivery || kind == IssueKindDecision +} + +func normalizeIssueTitle(value string) (string, error) { + title := strings.TrimSpace(value) + if title == "" { + return "", &IssueValidationError{Field: "title", Err: fmt.Errorf("must be nonempty")} + } + return title, nil +} + +func normalizeIssueKind(value string) (string, error) { + kind := strings.TrimSpace(value) + if kind == "" { + return IssueKindDelivery, nil + } + if !validIssueKind(kind) { + return "", &IssueValidationError{Field: "kind", Err: fmt.Errorf("must be delivery or decision")} + } + return kind, nil +} + +// CreateIssue writes one issue, its initial status event, and a local alias +// when the project authority is local. +func CreateIssue(ctx context.Context, root project.Root, resolver PathResolver, options IssueCreateOptions) (Issue, error) { + store, err := openProjectStoreMutateExisting(ctx, root, resolver) + if err != nil { + return Issue{}, err + } + defer store.Close() + return store.CreateIssue(ctx, root, options) +} + +// CreateIssue writes one issue in a serializable transaction on an open store. +func (s *Store) CreateIssue(ctx context.Context, root project.Root, options IssueCreateOptions) (Issue, error) { + title, err := normalizeIssueTitle(options.Title) + if err != nil { + return Issue{}, err + } + kind, err := normalizeIssueKind(options.Kind) + if err != nil { + return Issue{}, err + } + criteria, err := normalizeIssueCriteria(options.Criteria) + if err != nil { + return Issue{}, err + } + projectID, err := s.projectID(ctx, root) + if err != nil { + return Issue{}, err + } + + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return Issue{}, &IssueTransactionError{Stage: "begin", Err: err} + } + defer tx.Rollback() + + now := time.Now().UTC().Format(time.RFC3339Nano) + issueID, err := newOpaqueStateID("issue") + if err != nil { + return Issue{}, &IssueTransactionError{Stage: "id", Err: err} + } + + parentID := "" + if strings.TrimSpace(options.Parent) != "" { + parentID, _, err = resolveIssueRefTx(ctx, tx, projectID, options.Parent) + if err != nil { + return Issue{}, err + } + if err := rejectIssueParentCycle(ctx, tx, projectID, issueID, parentID); err != nil { + return Issue{}, err + } + } + + alias := strings.TrimSpace(options.Alias) + if alias == "" { + var mintErr error + alias, mintErr = mintLocalIssueAliasTx(ctx, tx, projectID, now) + if mintErr != nil { + return Issue{}, mintErr + } + } + + if _, err := tx.ExecContext(ctx, ` +INSERT INTO issues (id, project_id, parent_id, kind, title, body, fog, status, archived_at, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?) +`, issueID, projectID, emptyToNil(parentID), kind, title, options.Body, emptyToNil(strings.TrimSpace(options.Fog)), IssueStatusTriage, now, now); err != nil { + return Issue{}, &IssueTransactionError{Stage: "issue", Err: err} + } + if alias != "" { + if err := insertAlias(ctx, tx, projectID, issueEntityKind, issueID, issueNamespace, alias, now); err != nil { + return Issue{}, &IssueTransactionError{Stage: "alias", Err: err} + } + } + if err := replaceIssueCriteriaTx(ctx, tx, projectID, issueID, criteria, now); err != nil { + return Issue{}, err + } + if _, err := insertIssueStatusEventTx(ctx, tx, projectID, issueID, "", IssueStatusTriage, "recorded by issue create", now); err != nil { + return Issue{}, err + } + + detail, err := loadIssueTx(ctx, tx, projectID, issueID) + if err != nil { + return Issue{}, &IssueTransactionError{Stage: "read result", Err: err} + } + if err := tx.Commit(); err != nil { + return Issue{}, &IssueTransactionError{Stage: "commit", Err: err} + } + return detail, nil +} + +// UpdateIssue mutates content, parent, kind, or a non-removal status. +func UpdateIssue(ctx context.Context, root project.Root, resolver PathResolver, options IssueUpdateOptions) (Issue, error) { + store, err := openProjectStoreMutateExisting(ctx, root, resolver) + if err != nil { + return Issue{}, err + } + defer store.Close() + return store.UpdateIssue(ctx, root, options) +} + +// UpdateIssue mutates an issue in a serializable transaction on an open store. +func (s *Store) UpdateIssue(ctx context.Context, root project.Root, options IssueUpdateOptions) (Issue, error) { + if !options.SetTitle && !options.SetBody && !options.SetFog && !options.SetKind && !options.SetParent && !options.SetStatus && !options.SetStarted { + return Issue{}, &IssueValidationError{Field: "update", Err: fmt.Errorf("requires at least one field")} + } + if options.SetTitle { + if _, err := normalizeIssueTitle(options.Title); err != nil { + return Issue{}, err + } + } + if options.SetKind { + if _, err := normalizeIssueKind(options.Kind); err != nil { + return Issue{}, err + } + if strings.TrimSpace(options.Kind) == "" { + return Issue{}, &IssueValidationError{Field: "kind", Err: fmt.Errorf("must be delivery or decision")} + } + } + if options.SetStatus { + status := strings.TrimSpace(options.Status) + if !validIssueWriteStatus(status) { + if validIssueStatus(status) { + return Issue{}, &IssueValidationError{Field: "status", Err: fmt.Errorf("%s is a removal status; use RemoveIssue", status)} + } + return Issue{}, &IssueValidationError{Field: "status", Err: fmt.Errorf("must be one of triage, backlog, todo, active, done")} + } + } + if options.SetStarted { + startedBranch := strings.TrimSpace(options.StartedBranch) + startedWorktree := strings.TrimSpace(options.StartedWorktree) + if (startedBranch == "") != (startedWorktree == "") { + return Issue{}, &IssueValidationError{Field: "started", Err: fmt.Errorf("started_branch and started_worktree must be set or cleared together")} + } + } + + projectID, err := s.projectID(ctx, root) + if err != nil { + return Issue{}, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return Issue{}, &IssueTransactionError{Stage: "begin", Err: err} + } + defer tx.Rollback() + + issueID, _, err := resolveIssueRefTx(ctx, tx, projectID, options.Ref) + if err != nil { + return Issue{}, err + } + current, err := loadIssueTx(ctx, tx, projectID, issueID) + if err != nil { + return Issue{}, &IssueTransactionError{Stage: "read", Err: err} + } + + title := current.Title + if options.SetTitle { + title = strings.TrimSpace(options.Title) + } + body := current.Body + if options.SetBody { + body = options.Body + } + fog := current.Fog + if options.SetFog { + fog = strings.TrimSpace(options.Fog) + } + kind := current.Kind + if options.SetKind { + kind = strings.TrimSpace(options.Kind) + } + parentID := current.ParentID + if options.SetParent { + parentID = "" + if strings.TrimSpace(options.Parent) != "" { + parentID, _, err = resolveIssueRefTx(ctx, tx, projectID, options.Parent) + if err != nil { + return Issue{}, err + } + if err := rejectIssueParentCycle(ctx, tx, projectID, issueID, parentID); err != nil { + return Issue{}, err + } + } + } + status := current.Status + if options.SetStatus { + status = strings.TrimSpace(options.Status) + } + startedBranch := current.StartedBranch + startedWorktree := current.StartedWorktree + if options.SetStarted { + startedBranch = strings.TrimSpace(options.StartedBranch) + startedWorktree = strings.TrimSpace(options.StartedWorktree) + if startedBranch != "" || startedWorktree != "" { + if current.ArchivedAt != "" { + return Issue{}, &IssueValidationError{Field: "started", Err: fmt.Errorf("archived issues cannot be started")} + } + if issueStartRefusedStatus(current.Status) { + return Issue{}, &IssueValidationError{Field: "started", Err: fmt.Errorf("%s issues cannot be started", current.Status)} + } + if issueIsStarted(current) { + return Issue{}, &IssueValidationError{Field: "started", Err: fmt.Errorf("issue is already started")} + } + } + } + + now := time.Now().UTC().Format(time.RFC3339Nano) + if _, err := tx.ExecContext(ctx, ` +UPDATE issues +SET parent_id = ?, kind = ?, title = ?, body = ?, fog = ?, status = ?, started_branch = ?, started_worktree = ?, updated_at = ? +WHERE project_id = ? AND id = ? +`, emptyToNil(parentID), kind, title, body, emptyToNil(fog), status, emptyToNil(startedBranch), emptyToNil(startedWorktree), now, projectID, issueID); err != nil { + return Issue{}, &IssueTransactionError{Stage: "update", Err: err} + } + if options.SetStatus && status != current.Status { + if _, err := insertIssueStatusEventTx(ctx, tx, projectID, issueID, current.Status, status, "recorded by issue update", now); err != nil { + return Issue{}, err + } + } + + detail, err := loadIssueTx(ctx, tx, projectID, issueID) + if err != nil { + return Issue{}, &IssueTransactionError{Stage: "read result", Err: err} + } + if err := tx.Commit(); err != nil { + return Issue{}, &IssueTransactionError{Stage: "commit", Err: err} + } + return detail, nil +} + +// GetIssue returns the derived read model for one issue. +func GetIssue(ctx context.Context, root project.Root, resolver PathResolver, ref string) (Issue, error) { + store, err := openProjectStoreReadExisting(ctx, root, resolver) + if err != nil { + return Issue{}, err + } + defer store.Close() + return store.GetIssue(ctx, root, ref) +} + +// GetIssue returns the derived read model from an open store. +func (s *Store) GetIssue(ctx context.Context, root project.Root, ref string) (Issue, error) { + projectID, err := s.projectID(ctx, root) + if err != nil { + return Issue{}, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return Issue{}, fmt.Errorf("begin issue show: %w", err) + } + defer tx.Rollback() + issueID, _, err := resolveIssueRefTx(ctx, tx, projectID, ref) + if err != nil { + return Issue{}, err + } + return loadIssueTx(ctx, tx, projectID, issueID) +} + +// RemoveIssue sets cancelled or duplicate through the events path and archives +// the issue. The record and its relationship edges survive. Duplicate requires +// the surviving issue and records a relates_to edge to it. +func RemoveIssue(ctx context.Context, root project.Root, resolver PathResolver, options IssueRemoveOptions) (Issue, error) { + store, err := openProjectStoreMutateExisting(ctx, root, resolver) + if err != nil { + return Issue{}, err + } + defer store.Close() + return store.RemoveIssue(ctx, root, options) +} + +// RemoveIssue archives an issue on an open store. +func (s *Store) RemoveIssue(ctx context.Context, root project.Root, options IssueRemoveOptions) (Issue, error) { + status := strings.TrimSpace(options.Status) + if status != IssueStatusCancelled && status != IssueStatusDuplicate { + return Issue{}, &IssueValidationError{Field: "status", Err: fmt.Errorf("removal must be cancelled or duplicate")} + } + if status == IssueStatusDuplicate && strings.TrimSpace(options.DuplicateOf) == "" { + return Issue{}, &IssueValidationError{Field: "duplicate_of", Err: fmt.Errorf("duplicate removal requires a surviving issue")} + } + + projectID, err := s.projectID(ctx, root) + if err != nil { + return Issue{}, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return Issue{}, &IssueTransactionError{Stage: "begin", Err: err} + } + defer tx.Rollback() + + issueID, _, err := resolveIssueRefTx(ctx, tx, projectID, options.Ref) + if err != nil { + return Issue{}, err + } + current, err := loadIssueTx(ctx, tx, projectID, issueID) + if err != nil { + return Issue{}, &IssueTransactionError{Stage: "read", Err: err} + } + + var survivorID string + if status == IssueStatusDuplicate { + survivorID, _, err = resolveIssueRefTx(ctx, tx, projectID, options.DuplicateOf) + if err != nil { + return Issue{}, err + } + if survivorID == issueID { + return Issue{}, &IssueValidationError{Field: "duplicate_of", Err: fmt.Errorf("surviving issue must be a different issue")} + } + } + + now := time.Now().UTC().Format(time.RFC3339Nano) + archivedAt := current.ArchivedAt + if archivedAt == "" { + archivedAt = now + } + if _, err := tx.ExecContext(ctx, ` +UPDATE issues SET status = ?, archived_at = ?, updated_at = ? WHERE project_id = ? AND id = ? +`, status, archivedAt, now, projectID, issueID); err != nil { + return Issue{}, &IssueTransactionError{Stage: "archive", Err: err} + } + if status != current.Status { + if _, err := insertIssueStatusEventTx(ctx, tx, projectID, issueID, current.Status, status, "recorded by issue remove", now); err != nil { + return Issue{}, err + } + } + if survivorID != "" { + relationshipID := stableMigrationID("relationship", projectID, issueEntityKind, issueID, IssueRelationshipRelatesTo, issueEntityKind, survivorID) + if _, err := tx.ExecContext(ctx, ` +INSERT INTO relationships (id, project_id, from_entity_kind, from_entity_id, to_entity_kind, to_entity_id, relationship_type, reason, origin, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(id) DO UPDATE SET + reason = excluded.reason, + origin = excluded.origin, + updated_at = excluded.updated_at +`, relationshipID, projectID, issueEntityKind, issueID, issueEntityKind, survivorID, IssueRelationshipRelatesTo, "duplicate of", "command", now, now); err != nil { + return Issue{}, &IssueTransactionError{Stage: "relates_to", Err: err} + } + } + + detail, err := loadIssueTx(ctx, tx, projectID, issueID) + if err != nil { + return Issue{}, &IssueTransactionError{Stage: "read result", Err: err} + } + if err := tx.Commit(); err != nil { + return Issue{}, &IssueTransactionError{Stage: "commit", Err: err} + } + return detail, nil +} + +// HardDeleteIssue permanently removes an issue row and its criteria. +// It is a last-resort operator tool and must never be proposed by an agent. +// The issue's minted number is not freed: issue_identity.next_number is never +// decremented, and the number is never reissued. +func HardDeleteIssue(ctx context.Context, root project.Root, resolver PathResolver, ref string) error { + store, err := openProjectStoreMutateExisting(ctx, root, resolver) + if err != nil { + return err + } + defer store.Close() + return store.HardDeleteIssue(ctx, root, ref) +} + +// HardDeleteIssue permanently removes an issue row on an open store. +// It is a last-resort operator tool and must never be proposed by an agent. +func (s *Store) HardDeleteIssue(ctx context.Context, root project.Root, ref string) error { + projectID, err := s.projectID(ctx, root) + if err != nil { + return err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return &IssueTransactionError{Stage: "begin", Err: err} + } + defer tx.Rollback() + + issueID, _, err := resolveIssueRefTx(ctx, tx, projectID, ref) + if err != nil { + return err + } + if _, err := tx.ExecContext(ctx, ` +UPDATE issues SET parent_id = NULL, updated_at = ? WHERE project_id = ? AND parent_id = ? +`, time.Now().UTC().Format(time.RFC3339Nano), projectID, issueID); err != nil { + return &IssueTransactionError{Stage: "detach children", Err: err} + } + if _, err := tx.ExecContext(ctx, `DELETE FROM issues WHERE project_id = ? AND id = ?`, projectID, issueID); err != nil { + return &IssueTransactionError{Stage: "delete", Err: err} + } + if err := tx.Commit(); err != nil { + return &IssueTransactionError{Stage: "commit", Err: err} + } + return nil +} + +// CheckIssueStatusParity proves issues.status equals the latest event to_status +// for every issue in the project. +func CheckIssueStatusParity(ctx context.Context, root project.Root, resolver PathResolver) (IssueStatusParityResult, error) { + store, err := openProjectStoreReadExisting(ctx, root, resolver) + if err != nil { + return IssueStatusParityResult{}, err + } + defer store.Close() + return store.CheckIssueStatusParity(ctx, root) +} + +// CheckIssueStatusParity proves column == latest event on an open store. +func (s *Store) CheckIssueStatusParity(ctx context.Context, root project.Root) (IssueStatusParityResult, error) { + projectID, err := s.projectID(ctx, root) + if err != nil { + return IssueStatusParityResult{}, err + } + rows, err := s.db.QueryContext(ctx, ` +SELECT i.id, i.status, + COALESCE(( + SELECT e.to_status FROM events e + WHERE e.project_id = i.project_id AND e.entity_kind = ? AND e.entity_id = i.id + AND e.event_type = 'status_changed' + ORDER BY e.created_at DESC, e.rowid DESC + LIMIT 1 + ), '') +FROM issues AS i +WHERE i.project_id = ? +ORDER BY i.created_at, i.id +`, issueEntityKind, projectID) + if err != nil { + return IssueStatusParityResult{}, fmt.Errorf("check issue status parity: %w", err) + } + defer rows.Close() + + result := IssueStatusParityResult{Consistent: true, Mismatches: []IssueStatusParityMismatch{}} + for rows.Next() { + var mismatch IssueStatusParityMismatch + if err := rows.Scan(&mismatch.IssueID, &mismatch.ColumnStatus, &mismatch.EventStatus); err != nil { + return IssueStatusParityResult{}, fmt.Errorf("scan issue status parity: %w", err) + } + if mismatch.ColumnStatus != mismatch.EventStatus { + result.Consistent = false + result.Mismatches = append(result.Mismatches, mismatch) + } + } + if err := rows.Err(); err != nil { + return IssueStatusParityResult{}, fmt.Errorf("iterate issue status parity: %w", err) + } + return result, nil +} + +// ListLatestIssueDoneAt returns the created_at of each issue's latest +// status_changed event to done. Issues never marked done are omitted. +func ListLatestIssueDoneAt(ctx context.Context, root project.Root, resolver PathResolver) (map[string]string, error) { + store, err := openProjectStoreReadExisting(ctx, root, resolver) + if err != nil { + return nil, err + } + defer store.Close() + return store.ListLatestIssueDoneAt(ctx, root) +} + +// ListLatestIssueDoneAt returns latest done-event timestamps from an open store. +func (s *Store) ListLatestIssueDoneAt(ctx context.Context, root project.Root) (map[string]string, error) { + projectID, err := s.projectID(ctx, root) + if err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, ` +SELECT entity_id, created_at +FROM events +WHERE project_id = ? AND entity_kind = ? AND event_type = 'status_changed' AND to_status = ? +ORDER BY created_at DESC, rowid DESC +`, projectID, issueEntityKind, IssueStatusDone) + if err != nil { + return nil, fmt.Errorf("list latest issue done events: %w", err) + } + defer rows.Close() + latest := map[string]string{} + for rows.Next() { + var issueID, createdAt string + if err := rows.Scan(&issueID, &createdAt); err != nil { + return nil, fmt.Errorf("scan latest issue done event: %w", err) + } + if _, exists := latest[issueID]; exists { + continue + } + latest[issueID] = createdAt + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate latest issue done events: %w", err) + } + return latest, nil +} + +func insertIssueStatusEventTx(ctx context.Context, tx *sql.Tx, projectID, issueID, fromStatus, toStatus, note, now string) (string, error) { + eventID, err := newOpaqueStateID("evt") + if err != nil { + return "", &IssueTransactionError{Stage: "event id", Err: err} + } + if _, err := tx.ExecContext(ctx, ` +INSERT INTO events (id, project_id, entity_kind, entity_id, event_type, from_status, to_status, note, created_at, updated_at) +VALUES (?, ?, ?, ?, 'status_changed', ?, ?, ?, ?, ?) +`, eventID, projectID, issueEntityKind, issueID, emptyToNil(fromStatus), toStatus, note, now, now); err != nil { + return "", &IssueTransactionError{Stage: "event", Err: err} + } + return eventID, nil +} + +// rejectIssueParentCycle refuses a parent that is the issue itself or any +// descendant. The walk happens inside the write transaction. +func rejectIssueParentCycle(ctx context.Context, tx *sql.Tx, projectID, issueID, parentID string) error { + if parentID == "" { + return nil + } + if parentID == issueID { + return &IssueValidationError{Field: "parent", Err: fmt.Errorf("an issue cannot be its own parent")} + } + visited := map[string]bool{issueID: true} + current := parentID + for current != "" { + if visited[current] { + return &IssueValidationError{Field: "parent", Err: fmt.Errorf("parent %s would create a cycle", parentID)} + } + visited[current] = true + var next sql.NullString + err := tx.QueryRowContext(ctx, `SELECT parent_id FROM issues WHERE project_id = ? AND id = ?`, projectID, current).Scan(&next) + if errors.Is(err, sql.ErrNoRows) { + return &IssueValidationError{Field: "parent", Err: fmt.Errorf("issue %q not found in SQLite state", current)} + } + if err != nil { + return &IssueTransactionError{Stage: "walk parent", Err: err} + } + current = next.String + } + return nil +} + +func resolveIssueRefTx(ctx context.Context, tx *sql.Tx, projectID, ref string) (string, string, error) { + trimmed := strings.TrimSpace(ref) + if trimmed == "" { + return "", "", &IssueValidationError{Field: "issue", Err: fmt.Errorf("must be nonempty")} + } + var kind, id, alias string + err := tx.QueryRowContext(ctx, ` +SELECT entity_kind, entity_id, alias FROM aliases +WHERE project_id = ? AND namespace = ? AND alias = ? +`, projectID, issueNamespace, trimmed).Scan(&kind, &id, &alias) + switch { + case err == nil: + if kind != issueEntityKind { + return "", "", fmt.Errorf("%q resolves to %s, not an issue", trimmed, kind) + } + var existing string + err = tx.QueryRowContext(ctx, `SELECT id FROM issues WHERE project_id = ? AND id = ?`, projectID, id).Scan(&existing) + if errors.Is(err, sql.ErrNoRows) { + return "", "", fmt.Errorf("issue %q not found in SQLite state", trimmed) + } + if err != nil { + return "", "", fmt.Errorf("resolve issue %q: %w", trimmed, err) + } + return existing, alias, nil + case !errors.Is(err, sql.ErrNoRows): + return "", "", fmt.Errorf("resolve issue %q: %w", trimmed, err) + } + var existing string + err = tx.QueryRowContext(ctx, `SELECT id FROM issues WHERE project_id = ? AND id = ?`, projectID, trimmed).Scan(&existing) + if errors.Is(err, sql.ErrNoRows) { + return "", "", fmt.Errorf("issue %q not found in SQLite state", trimmed) + } + if err != nil { + return "", "", fmt.Errorf("resolve issue %q: %w", trimmed, err) + } + return existing, "", nil +} + +func loadIssueTx(ctx context.Context, tx *sql.Tx, projectID, issueID string) (Issue, error) { + var issue Issue + var parentID, fog, archivedAt, alias, startedBranch, startedWorktree sql.NullString + err := tx.QueryRowContext(ctx, ` +SELECT i.id, i.parent_id, i.kind, i.title, i.body, i.fog, i.status, i.archived_at, i.started_branch, i.started_worktree, i.created_at, i.updated_at, + (SELECT a.alias FROM aliases a WHERE a.project_id = i.project_id AND a.entity_kind = ? AND a.entity_id = i.id ORDER BY a.namespace, a.alias LIMIT 1) +FROM issues AS i +WHERE i.project_id = ? AND i.id = ? +`, issueEntityKind, projectID, issueID).Scan( + &issue.ID, &parentID, &issue.Kind, &issue.Title, &issue.Body, &fog, &issue.Status, &archivedAt, &startedBranch, &startedWorktree, &issue.CreatedAt, &issue.UpdatedAt, &alias, + ) + if errors.Is(err, sql.ErrNoRows) { + return Issue{}, fmt.Errorf("issue %s not found", issueID) + } + if err != nil { + return Issue{}, err + } + issue.ParentID = parentID.String + issue.Fog = fog.String + issue.ArchivedAt = archivedAt.String + issue.StartedBranch = startedBranch.String + issue.StartedWorktree = startedWorktree.String + issue.Alias = alias.String + criteria, err := loadIssueCriteriaTx(ctx, tx, projectID, issueID) + if err != nil { + return Issue{}, err + } + issue.Criteria = criteria + return issue, nil +} + +func issueIsStarted(issue Issue) bool { + return strings.TrimSpace(issue.StartedBranch) != "" || strings.TrimSpace(issue.StartedWorktree) != "" +} + +func issueStartRefusedStatus(status string) bool { + switch status { + case IssueStatusDone, IssueStatusCancelled, IssueStatusDuplicate: + return true + default: + return false + } +} + +// NearestStartedAncestor walks parent_id and returns the nearest ancestor +// that itself has a recorded started workspace. +func NearestStartedAncestor(ctx context.Context, root project.Root, resolver PathResolver, ref string) (Issue, bool, error) { + store, err := openProjectStoreReadExisting(ctx, root, resolver) + if err != nil { + return Issue{}, false, err + } + defer store.Close() + return store.NearestStartedAncestor(ctx, root, ref) +} + +// NearestStartedAncestor walks parent_id from an open store. +func (s *Store) NearestStartedAncestor(ctx context.Context, root project.Root, ref string) (Issue, bool, error) { + projectID, err := s.projectID(ctx, root) + if err != nil { + return Issue{}, false, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return Issue{}, false, fmt.Errorf("begin nearest started ancestor: %w", err) + } + defer tx.Rollback() + issueID, _, err := resolveIssueRefTx(ctx, tx, projectID, ref) + if err != nil { + return Issue{}, false, err + } + issue, err := loadIssueTx(ctx, tx, projectID, issueID) + if err != nil { + return Issue{}, false, err + } + visited := map[string]bool{} + current := issue.ParentID + for current != "" { + if visited[current] { + return Issue{}, false, fmt.Errorf("parent cycle detected in stored issue data at %s", current) + } + visited[current] = true + parent, err := loadIssueTx(ctx, tx, projectID, current) + if err != nil { + return Issue{}, false, err + } + if issueIsStarted(parent) { + return parent, true, nil + } + current = parent.ParentID + } + return Issue{}, false, nil +} diff --git a/internal/state/issue_bucket.go b/internal/state/issue_bucket.go new file mode 100644 index 000000000..25ada2a1a --- /dev/null +++ b/internal/state/issue_bucket.go @@ -0,0 +1,192 @@ +package state + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" + + "github.com/levifig/loaf/internal/project" +) + +var issueBucketTags = []string{ + issueBucketTagPrefix + IssueBucketNow, + issueBucketTagPrefix + IssueBucketNext, + issueBucketTagPrefix + IssueBucketLater, +} + +// SetIssueBucket stores an advisory Now/Next/Later label on an issue via the +// existing tags tables. Buckets are labels only and must never be read as a +// constraint by any other code path. +func SetIssueBucket(ctx context.Context, root project.Root, resolver PathResolver, ref, bucket string) (IssueResult, error) { + store, err := openProjectStoreMutateExisting(ctx, root, resolver) + if err != nil { + return IssueResult{}, err + } + defer store.Close() + return store.SetIssueBucket(ctx, root, ref, bucket) +} + +// SetIssueBucket writes the advisory bucket on an open store. +func (s *Store) SetIssueBucket(ctx context.Context, root project.Root, ref, bucket string) (IssueResult, error) { + normalized, err := normalizeIssueBucket(bucket) + if err != nil { + return IssueResult{}, err + } + projectID, err := s.projectID(ctx, root) + if err != nil { + return IssueResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return IssueResult{}, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return IssueResult{}, &IssueTransactionError{Stage: "begin", Err: err} + } + defer tx.Rollback() + + issueID, _, err := resolveIssueRefTx(ctx, tx, projectID, ref) + if err != nil { + return IssueResult{}, err + } + now := time.Now().UTC().Format(time.RFC3339Nano) + if err := clearIssueBucketTagsTx(ctx, tx, projectID, issueID); err != nil { + return IssueResult{}, err + } + if normalized != IssueBucketNone { + if err := attachIssueBucketTagTx(ctx, tx, projectID, issueID, issueBucketTagPrefix+normalized, now); err != nil { + return IssueResult{}, err + } + } + result, err := loadIssueResultTx(ctx, tx, identity, issueID) + if err != nil { + return IssueResult{}, &IssueTransactionError{Stage: "read result", Err: err} + } + if err := tx.Commit(); err != nil { + return IssueResult{}, &IssueTransactionError{Stage: "commit", Err: err} + } + return result, nil +} + +func normalizeIssueBucket(value string) (string, error) { + bucket := strings.ToLower(strings.TrimSpace(value)) + switch bucket { + case IssueBucketNow, IssueBucketNext, IssueBucketLater, IssueBucketNone: + return bucket, nil + default: + return "", &IssueValidationError{Field: "bucket", Err: fmt.Errorf("must be now, next, later, or none")} + } +} + +// ListIssueBuckets returns the advisory bucket label for every tagged issue. +// Missing keys mean the issue has no bucket. This is a read of labels only. +func ListIssueBuckets(ctx context.Context, root project.Root, resolver PathResolver) (map[string]string, error) { + store, err := openProjectStoreReadExisting(ctx, root, resolver) + if err != nil { + return nil, err + } + defer store.Close() + return store.ListIssueBuckets(ctx, root) +} + +// ListIssueBuckets returns advisory buckets from an open store. +func (s *Store) ListIssueBuckets(ctx context.Context, root project.Root) (map[string]string, error) { + projectID, err := s.projectID(ctx, root) + if err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, ` +SELECT entity_tags.entity_id, tags.name +FROM entity_tags +JOIN tags + ON tags.project_id = entity_tags.project_id + AND tags.id = entity_tags.tag_id +WHERE entity_tags.project_id = ? + AND entity_tags.entity_kind = ? + AND tags.name IN (?, ?, ?) +ORDER BY entity_tags.entity_id, tags.name +`, projectID, issueEntityKind, issueBucketTags[0], issueBucketTags[1], issueBucketTags[2]) + if err != nil { + return nil, fmt.Errorf("list issue buckets: %w", err) + } + defer rows.Close() + out := map[string]string{} + for rows.Next() { + var issueID, name string + if err := rows.Scan(&issueID, &name); err != nil { + return nil, fmt.Errorf("scan issue bucket: %w", err) + } + if _, exists := out[issueID]; !exists { + out[issueID] = strings.TrimPrefix(name, issueBucketTagPrefix) + } + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate issue buckets: %w", err) + } + return out, nil +} + +func loadIssueBucketTx(ctx context.Context, tx *sql.Tx, projectID, issueID string) (string, error) { + var name sql.NullString + err := tx.QueryRowContext(ctx, ` +SELECT tags.name +FROM entity_tags +JOIN tags + ON tags.project_id = entity_tags.project_id + AND tags.id = entity_tags.tag_id +WHERE entity_tags.project_id = ? + AND entity_tags.entity_kind = ? + AND entity_tags.entity_id = ? + AND tags.name IN (?, ?, ?) +ORDER BY tags.name +LIMIT 1 +`, projectID, issueEntityKind, issueID, issueBucketTags[0], issueBucketTags[1], issueBucketTags[2]).Scan(&name) + if err == sql.ErrNoRows { + return "", nil + } + if err != nil { + return "", fmt.Errorf("read issue bucket: %w", err) + } + return strings.TrimPrefix(name.String, issueBucketTagPrefix), nil +} + +func clearIssueBucketTagsTx(ctx context.Context, tx *sql.Tx, projectID, issueID string) error { + if _, err := tx.ExecContext(ctx, ` +DELETE FROM entity_tags +WHERE project_id = ? + AND entity_kind = ? + AND entity_id = ? + AND tag_id IN ( + SELECT id FROM tags WHERE project_id = ? AND name IN (?, ?, ?) + ) +`, projectID, issueEntityKind, issueID, projectID, issueBucketTags[0], issueBucketTags[1], issueBucketTags[2]); err != nil { + return &IssueTransactionError{Stage: "clear bucket", Err: err} + } + return nil +} + +func attachIssueBucketTagTx(ctx context.Context, tx *sql.Tx, projectID, issueID, tagName, now string) error { + tagID := stableMigrationID("tag", projectID, tagName) + if _, err := tx.ExecContext(ctx, ` +INSERT INTO tags (id, project_id, name, created_at, updated_at) +VALUES (?, ?, ?, ?, ?) +ON CONFLICT(project_id, name) DO UPDATE SET updated_at = excluded.updated_at +`, tagID, projectID, tagName, now, now); err != nil { + return &IssueTransactionError{Stage: "bucket tag", Err: err} + } + if err := tx.QueryRowContext(ctx, `SELECT id FROM tags WHERE project_id = ? AND name = ?`, projectID, tagName).Scan(&tagID); err != nil { + return &IssueTransactionError{Stage: "bucket tag id", Err: err} + } + memberID := stableMigrationID("entity_tag", projectID, tagName, issueEntityKind, issueID) + if _, err := tx.ExecContext(ctx, ` +INSERT INTO entity_tags (id, project_id, tag_id, entity_kind, entity_id, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(project_id, tag_id, entity_kind, entity_id) DO UPDATE SET updated_at = excluded.updated_at +`, memberID, projectID, tagID, issueEntityKind, issueID, now, now); err != nil { + return &IssueTransactionError{Stage: "bucket membership", Err: err} + } + return nil +} diff --git a/internal/state/issue_claims.go b/internal/state/issue_claims.go new file mode 100644 index 000000000..cc669867c --- /dev/null +++ b/internal/state/issue_claims.go @@ -0,0 +1,240 @@ +package state + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/levifig/loaf/internal/project" +) + +// IssueCriterionClaim is a child criterion claiming a parent criterion. +type IssueCriterionClaim struct { + ID string `json:"id"` + ChildCriterionID string `json:"child_criterion_id"` + ParentCriterionID string `json:"parent_criterion_id"` +} + +// ClaimIssueCriterion records that the child's criterion at childPosition +// serves the parent's criterion at parentPosition. Positions resolve to IDs +// at write time. +func ClaimIssueCriterion(ctx context.Context, root project.Root, resolver PathResolver, childRef string, childPosition, parentPosition int) (Issue, error) { + store, err := openProjectStoreMutateExisting(ctx, root, resolver) + if err != nil { + return Issue{}, err + } + defer store.Close() + return store.ClaimIssueCriterion(ctx, root, childRef, childPosition, parentPosition) +} + +// ClaimIssueCriterion records a claim on an open store. +func (s *Store) ClaimIssueCriterion(ctx context.Context, root project.Root, childRef string, childPosition, parentPosition int) (Issue, error) { + if childPosition < 1 { + return Issue{}, &IssueValidationError{Field: "child_position", Err: fmt.Errorf("must be >= 1")} + } + if parentPosition < 1 { + return Issue{}, &IssueValidationError{Field: "parent_position", Err: fmt.Errorf("must be >= 1")} + } + projectID, err := s.projectID(ctx, root) + if err != nil { + return Issue{}, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return Issue{}, &IssueTransactionError{Stage: "begin", Err: err} + } + defer tx.Rollback() + + childID, _, err := resolveIssueRefTx(ctx, tx, projectID, childRef) + if err != nil { + return Issue{}, err + } + child, err := loadIssueTx(ctx, tx, projectID, childID) + if err != nil { + return Issue{}, &IssueTransactionError{Stage: "read child", Err: err} + } + if child.ParentID == "" { + return Issue{}, &IssueValidationError{Field: "parent", Err: fmt.Errorf("issue %s has no parent", firstNonEmpty(child.Alias, child.ID))} + } + childCriterion, err := criterionAtPosition(child.Criteria, childPosition, "child_position") + if err != nil { + return Issue{}, err + } + parent, err := loadIssueTx(ctx, tx, projectID, child.ParentID) + if err != nil { + return Issue{}, &IssueTransactionError{Stage: "read parent", Err: err} + } + parentCriterion, err := criterionAtPosition(parent.Criteria, parentPosition, "parent_position") + if err != nil { + return Issue{}, err + } + now := time.Now().UTC().Format(time.RFC3339Nano) + if err := insertIssueCriterionClaimTx(ctx, tx, projectID, childCriterion.ID, parentCriterion.ID, now); err != nil { + return Issue{}, err + } + if _, err := tx.ExecContext(ctx, `UPDATE issues SET updated_at = ? WHERE project_id = ? AND id = ?`, now, projectID, childID); err != nil { + return Issue{}, &IssueTransactionError{Stage: "touch issue", Err: err} + } + detail, err := loadIssueTx(ctx, tx, projectID, childID) + if err != nil { + return Issue{}, &IssueTransactionError{Stage: "read result", Err: err} + } + if err := tx.Commit(); err != nil { + return Issue{}, &IssueTransactionError{Stage: "commit", Err: err} + } + return detail, nil +} + +// UnclaimIssueCriterion removes the claim from the child's criterion at +// childPosition to the parent's criterion at parentPosition. +func UnclaimIssueCriterion(ctx context.Context, root project.Root, resolver PathResolver, childRef string, childPosition, parentPosition int) (Issue, error) { + store, err := openProjectStoreMutateExisting(ctx, root, resolver) + if err != nil { + return Issue{}, err + } + defer store.Close() + return store.UnclaimIssueCriterion(ctx, root, childRef, childPosition, parentPosition) +} + +// UnclaimIssueCriterion removes a claim on an open store. +func (s *Store) UnclaimIssueCriterion(ctx context.Context, root project.Root, childRef string, childPosition, parentPosition int) (Issue, error) { + if childPosition < 1 { + return Issue{}, &IssueValidationError{Field: "child_position", Err: fmt.Errorf("must be >= 1")} + } + if parentPosition < 1 { + return Issue{}, &IssueValidationError{Field: "parent_position", Err: fmt.Errorf("must be >= 1")} + } + projectID, err := s.projectID(ctx, root) + if err != nil { + return Issue{}, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return Issue{}, &IssueTransactionError{Stage: "begin", Err: err} + } + defer tx.Rollback() + + childID, _, err := resolveIssueRefTx(ctx, tx, projectID, childRef) + if err != nil { + return Issue{}, err + } + child, err := loadIssueTx(ctx, tx, projectID, childID) + if err != nil { + return Issue{}, &IssueTransactionError{Stage: "read child", Err: err} + } + if child.ParentID == "" { + return Issue{}, &IssueValidationError{Field: "parent", Err: fmt.Errorf("issue %s has no parent", firstNonEmpty(child.Alias, child.ID))} + } + childCriterion, err := criterionAtPosition(child.Criteria, childPosition, "child_position") + if err != nil { + return Issue{}, err + } + parent, err := loadIssueTx(ctx, tx, projectID, child.ParentID) + if err != nil { + return Issue{}, &IssueTransactionError{Stage: "read parent", Err: err} + } + parentCriterion, err := criterionAtPosition(parent.Criteria, parentPosition, "parent_position") + if err != nil { + return Issue{}, err + } + result, err := tx.ExecContext(ctx, ` +DELETE FROM issue_criterion_claims +WHERE project_id = ? AND child_criterion_id = ? AND parent_criterion_id = ? +`, projectID, childCriterion.ID, parentCriterion.ID) + if err != nil { + return Issue{}, &IssueTransactionError{Stage: "unclaim", Err: err} + } + affected, err := result.RowsAffected() + if err != nil { + return Issue{}, &IssueTransactionError{Stage: "unclaim rows", Err: err} + } + if affected == 0 { + return Issue{}, &IssueValidationError{Field: "claim", Err: fmt.Errorf("no claim from child criterion %d to parent criterion %d", childPosition, parentPosition)} + } + now := time.Now().UTC().Format(time.RFC3339Nano) + if _, err := tx.ExecContext(ctx, `UPDATE issues SET updated_at = ? WHERE project_id = ? AND id = ?`, now, projectID, childID); err != nil { + return Issue{}, &IssueTransactionError{Stage: "touch issue", Err: err} + } + detail, err := loadIssueTx(ctx, tx, projectID, childID) + if err != nil { + return Issue{}, &IssueTransactionError{Stage: "read result", Err: err} + } + if err := tx.Commit(); err != nil { + return Issue{}, &IssueTransactionError{Stage: "commit", Err: err} + } + return detail, nil +} + +func insertIssueCriterionClaimTx(ctx context.Context, tx *sql.Tx, projectID, childCriterionID, parentCriterionID, now string) error { + if childCriterionID == parentCriterionID { + return &IssueValidationError{Field: "claim", Err: fmt.Errorf("a criterion cannot claim itself")} + } + id, err := newOpaqueStateID("icc") + if err != nil { + return &IssueTransactionError{Stage: "claim id", Err: err} + } + if _, err := tx.ExecContext(ctx, ` +INSERT INTO issue_criterion_claims (id, project_id, child_criterion_id, parent_criterion_id, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?) +ON CONFLICT (child_criterion_id, parent_criterion_id) DO NOTHING +`, id, projectID, childCriterionID, parentCriterionID, now, now); err != nil { + return &IssueTransactionError{Stage: "claim", Err: err} + } + return nil +} + +func listIssueCriterionClaimsForChildrenTx(ctx context.Context, tx *sql.Tx, projectID string, childIDs []string) ([]IssueCriterionClaim, error) { + if len(childIDs) == 0 { + return nil, nil + } + query := ` +SELECT cl.id, cl.child_criterion_id, cl.parent_criterion_id +FROM issue_criterion_claims AS cl +JOIN issue_criteria AS child ON child.id = cl.child_criterion_id +WHERE cl.project_id = ? AND child.issue_id IN (` + sqlPlaceholders(len(childIDs)) + `) +ORDER BY cl.created_at, cl.id +` + args := make([]any, 0, 1+len(childIDs)) + args = append(args, projectID) + for _, id := range childIDs { + args = append(args, id) + } + rows, err := tx.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("list criterion claims: %w", err) + } + defer rows.Close() + claims := []IssueCriterionClaim{} + for rows.Next() { + var claim IssueCriterionClaim + if err := rows.Scan(&claim.ID, &claim.ChildCriterionID, &claim.ParentCriterionID); err != nil { + return nil, fmt.Errorf("scan criterion claim: %w", err) + } + claims = append(claims, claim) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate criterion claims: %w", err) + } + return claims, nil +} + +func criterionAtPosition(criteria []IssueCriterion, position int, field string) (IssueCriterion, error) { + for _, criterion := range criteria { + if criterion.Position == position { + return criterion, nil + } + } + return IssueCriterion{}, &IssueValidationError{Field: field, Err: fmt.Errorf("criterion position %d not found", position)} +} + +func sqlPlaceholders(n int) string { + if n <= 0 { + return "" + } + out := "?" + for i := 1; i < n; i++ { + out += ",?" + } + return out +} diff --git a/internal/state/issue_criteria.go b/internal/state/issue_criteria.go new file mode 100644 index 000000000..b15305797 --- /dev/null +++ b/internal/state/issue_criteria.go @@ -0,0 +1,474 @@ +package state + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" + + "github.com/levifig/loaf/internal/project" +) + +const ( + IssueCriterionTierV = "V" + IssueCriterionTierH = "H" +) + +// IssueCriterionInput is one definition-of-done line to store. +// Command and Expect use the loaf change verify grammar (exit N, contains <text>). +type IssueCriterionInput struct { + Text string + Command string + Expect string + Tier string + Position int + ServesParentPosition int +} + +// IssueCriterion is one stored definition-of-done line. +type IssueCriterion struct { + ID string `json:"id"` + Position int `json:"position"` + Text string `json:"text"` + Command string `json:"command,omitempty"` + Expect string `json:"expect,omitempty"` + Tier string `json:"tier"` +} + +// ReplaceIssueCriteria replaces every criterion on an issue. +func ReplaceIssueCriteria(ctx context.Context, root project.Root, resolver PathResolver, ref string, inputs []IssueCriterionInput) (Issue, error) { + store, err := openProjectStoreMutateExisting(ctx, root, resolver) + if err != nil { + return Issue{}, err + } + defer store.Close() + return store.ReplaceIssueCriteria(ctx, root, ref, inputs) +} + +// ReplaceIssueCriteria replaces every criterion on an issue in one transaction. +func (s *Store) ReplaceIssueCriteria(ctx context.Context, root project.Root, ref string, inputs []IssueCriterionInput) (Issue, error) { + criteria, err := normalizeIssueCriteria(inputs) + if err != nil { + return Issue{}, err + } + projectID, err := s.projectID(ctx, root) + if err != nil { + return Issue{}, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return Issue{}, &IssueTransactionError{Stage: "begin", Err: err} + } + defer tx.Rollback() + + issueID, _, err := resolveIssueRefTx(ctx, tx, projectID, ref) + if err != nil { + return Issue{}, err + } + now := time.Now().UTC().Format(time.RFC3339Nano) + if err := replaceIssueCriteriaTx(ctx, tx, projectID, issueID, criteria, now); err != nil { + return Issue{}, err + } + if _, err := tx.ExecContext(ctx, `UPDATE issues SET updated_at = ? WHERE project_id = ? AND id = ?`, now, projectID, issueID); err != nil { + return Issue{}, &IssueTransactionError{Stage: "touch issue", Err: err} + } + detail, err := loadIssueTx(ctx, tx, projectID, issueID) + if err != nil { + return Issue{}, &IssueTransactionError{Stage: "read result", Err: err} + } + if err := tx.Commit(); err != nil { + return Issue{}, &IssueTransactionError{Stage: "commit", Err: err} + } + return detail, nil +} + +func normalizeIssueCriteria(inputs []IssueCriterionInput) ([]IssueCriterionInput, error) { + out := make([]IssueCriterionInput, 0, len(inputs)) + for i, input := range inputs { + text := strings.TrimSpace(input.Text) + if text == "" { + return nil, &IssueValidationError{Field: "criteria.text", Err: fmt.Errorf("item %d must be nonempty", i+1)} + } + tier := strings.TrimSpace(input.Tier) + if tier == "" { + if strings.TrimSpace(input.Command) != "" { + tier = IssueCriterionTierV + } else { + tier = IssueCriterionTierH + } + } + if tier != IssueCriterionTierV && tier != IssueCriterionTierH { + return nil, &IssueValidationError{Field: "criteria.tier", Err: fmt.Errorf("item %d must be V or H", i+1)} + } + position := input.Position + if position == 0 { + position = i + 1 + } + if position < 1 { + return nil, &IssueValidationError{Field: "criteria.position", Err: fmt.Errorf("item %d must be >= 1", i+1)} + } + out = append(out, IssueCriterionInput{ + Text: text, + Command: strings.TrimSpace(input.Command), + Expect: strings.TrimSpace(input.Expect), + Tier: tier, + Position: position, + ServesParentPosition: input.ServesParentPosition, + }) + } + return out, nil +} + +func replaceIssueCriteriaTx(ctx context.Context, tx *sql.Tx, projectID, issueID string, criteria []IssueCriterionInput, now string) error { + existing, err := loadIssueCriteriaTx(ctx, tx, projectID, issueID) + if err != nil { + return err + } + // Pair existing rows (already ascending by position) to incoming criteria + // by slice order. Survivors and deletions are selected by row ID so a + // legal gap (positions 1 and 3) cannot collide with UNIQUE(issue_id, position) + // or delete a just-updated row. Written positions compact to 1..N. + overlap := len(criteria) + if overlap > len(existing) { + overlap = len(existing) + } + for i := 0; i < overlap; i++ { + criterion := criteria[i] + if _, err := tx.ExecContext(ctx, ` +UPDATE issue_criteria +SET text = ?, command = ?, expect = ?, tier = ?, position = ?, updated_at = ? +WHERE project_id = ? AND id = ? +`, criterion.Text, emptyToNil(criterion.Command), emptyToNil(criterion.Expect), criterion.Tier, i+1, now, projectID, existing[i].ID); err != nil { + return &IssueTransactionError{Stage: "update criterion", Err: err} + } + if err := recordCriterionServesTx(ctx, tx, projectID, issueID, existing[i].ID, criterion.ServesParentPosition, now); err != nil { + return err + } + } + for i := overlap; i < len(existing); i++ { + if _, err := tx.ExecContext(ctx, ` +DELETE FROM issue_criteria WHERE project_id = ? AND id = ? +`, projectID, existing[i].ID); err != nil { + return &IssueTransactionError{Stage: "trim criteria", Err: err} + } + } + for i := overlap; i < len(criteria); i++ { + input := criteria[i] + input.Position = i + 1 + criterionID, err := insertIssueCriterionTx(ctx, tx, projectID, issueID, input, now) + if err != nil { + return err + } + if err := recordCriterionServesTx(ctx, tx, projectID, issueID, criterionID, input.ServesParentPosition, now); err != nil { + return err + } + } + return nil +} + +// AddIssueCriterion appends one criterion to an issue. +func AddIssueCriterion(ctx context.Context, root project.Root, resolver PathResolver, ref string, input IssueCriterionInput) (Issue, error) { + store, err := openProjectStoreMutateExisting(ctx, root, resolver) + if err != nil { + return Issue{}, err + } + defer store.Close() + return store.AddIssueCriterion(ctx, root, ref, input) +} + +// AddIssueCriterion appends one criterion on an open store. +func (s *Store) AddIssueCriterion(ctx context.Context, root project.Root, ref string, input IssueCriterionInput) (Issue, error) { + projectID, err := s.projectID(ctx, root) + if err != nil { + return Issue{}, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return Issue{}, &IssueTransactionError{Stage: "begin", Err: err} + } + defer tx.Rollback() + + issueID, _, err := resolveIssueRefTx(ctx, tx, projectID, ref) + if err != nil { + return Issue{}, err + } + var max sql.NullInt64 + if err := tx.QueryRowContext(ctx, `SELECT MAX(position) FROM issue_criteria WHERE project_id = ? AND issue_id = ?`, projectID, issueID).Scan(&max); err != nil { + return Issue{}, &IssueTransactionError{Stage: "max position", Err: err} + } + input.Position = 1 + if max.Valid { + input.Position = int(max.Int64) + 1 + } + normalized, err := normalizeIssueCriteria([]IssueCriterionInput{input}) + if err != nil { + return Issue{}, err + } + now := time.Now().UTC().Format(time.RFC3339Nano) + criterionID, err := insertIssueCriterionTx(ctx, tx, projectID, issueID, normalized[0], now) + if err != nil { + return Issue{}, err + } + if err := recordCriterionServesTx(ctx, tx, projectID, issueID, criterionID, normalized[0].ServesParentPosition, now); err != nil { + return Issue{}, err + } + if _, err := tx.ExecContext(ctx, `UPDATE issues SET updated_at = ? WHERE project_id = ? AND id = ?`, now, projectID, issueID); err != nil { + return Issue{}, &IssueTransactionError{Stage: "touch issue", Err: err} + } + detail, err := loadIssueTx(ctx, tx, projectID, issueID) + if err != nil { + return Issue{}, &IssueTransactionError{Stage: "read result", Err: err} + } + if err := tx.Commit(); err != nil { + return Issue{}, &IssueTransactionError{Stage: "commit", Err: err} + } + return detail, nil +} + +// RemoveIssueCriterion deletes the criterion at position and compact-renumbers. +func RemoveIssueCriterion(ctx context.Context, root project.Root, resolver PathResolver, ref string, position int) (Issue, error) { + store, err := openProjectStoreMutateExisting(ctx, root, resolver) + if err != nil { + return Issue{}, err + } + defer store.Close() + return store.RemoveIssueCriterion(ctx, root, ref, position) +} + +// RemoveIssueCriterion deletes one criterion on an open store. +func (s *Store) RemoveIssueCriterion(ctx context.Context, root project.Root, ref string, position int) (Issue, error) { + if position < 1 { + return Issue{}, &IssueValidationError{Field: "position", Err: fmt.Errorf("must be >= 1")} + } + projectID, err := s.projectID(ctx, root) + if err != nil { + return Issue{}, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return Issue{}, &IssueTransactionError{Stage: "begin", Err: err} + } + defer tx.Rollback() + + issueID, _, err := resolveIssueRefTx(ctx, tx, projectID, ref) + if err != nil { + return Issue{}, err + } + current, err := loadIssueCriteriaTx(ctx, tx, projectID, issueID) + if err != nil { + return Issue{}, err + } + remaining := make([]IssueCriterion, 0, len(current)) + removedID := "" + for _, criterion := range current { + if criterion.Position == position { + removedID = criterion.ID + continue + } + remaining = append(remaining, criterion) + } + if removedID == "" { + return Issue{}, &IssueValidationError{Field: "position", Err: fmt.Errorf("criterion position %d not found", position)} + } + now := time.Now().UTC().Format(time.RFC3339Nano) + if _, err := tx.ExecContext(ctx, `DELETE FROM issue_criteria WHERE project_id = ? AND id = ?`, projectID, removedID); err != nil { + return Issue{}, &IssueTransactionError{Stage: "delete criterion", Err: err} + } + // Compact in place so remaining criterion IDs — and their claims — survive. + // Remaining rows are in ascending position; each move fills the hole just + // freed, so UNIQUE (issue_id, position) never collides. + for i, criterion := range remaining { + want := i + 1 + if criterion.Position == want { + continue + } + if _, err := tx.ExecContext(ctx, ` +UPDATE issue_criteria SET position = ?, updated_at = ? WHERE project_id = ? AND id = ? +`, want, now, projectID, criterion.ID); err != nil { + return Issue{}, &IssueTransactionError{Stage: "compact position", Err: err} + } + } + if _, err := tx.ExecContext(ctx, `UPDATE issues SET updated_at = ? WHERE project_id = ? AND id = ?`, now, projectID, issueID); err != nil { + return Issue{}, &IssueTransactionError{Stage: "touch issue", Err: err} + } + detail, err := loadIssueTx(ctx, tx, projectID, issueID) + if err != nil { + return Issue{}, &IssueTransactionError{Stage: "read result", Err: err} + } + if err := tx.Commit(); err != nil { + return Issue{}, &IssueTransactionError{Stage: "commit", Err: err} + } + return detail, nil +} + +// PromoteIssueCriterion creates a child delivery issue from the criterion at +// position. The parent criterion stays in place. +func PromoteIssueCriterion(ctx context.Context, root project.Root, resolver PathResolver, ref string, position int, alias ...string) (Issue, error) { + store, err := openProjectStoreMutateExisting(ctx, root, resolver) + if err != nil { + return Issue{}, err + } + defer store.Close() + provided := "" + if len(alias) > 0 { + provided = alias[0] + } + return store.PromoteIssueCriterion(ctx, root, ref, position, provided) +} + +// PromoteIssueCriterion creates a child issue on an open store. The promoted +// criterion is copied as the child's first criterion and a claim is recorded +// from that child criterion to the parent criterion, so coverage is satisfied +// by construction. A nonempty alias is stored as-is and does not advance the +// local identity counter. +func (s *Store) PromoteIssueCriterion(ctx context.Context, root project.Root, ref string, position int, providedAlias string) (Issue, error) { + providedAlias = strings.TrimSpace(providedAlias) + if position < 1 { + return Issue{}, &IssueValidationError{Field: "position", Err: fmt.Errorf("must be >= 1")} + } + projectID, err := s.projectID(ctx, root) + if err != nil { + return Issue{}, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return Issue{}, &IssueTransactionError{Stage: "begin", Err: err} + } + defer tx.Rollback() + + parentID, _, err := resolveIssueRefTx(ctx, tx, projectID, ref) + if err != nil { + return Issue{}, err + } + parent, err := loadIssueTx(ctx, tx, projectID, parentID) + if err != nil { + return Issue{}, &IssueTransactionError{Stage: "read parent", Err: err} + } + criterion, err := criterionAtPosition(parent.Criteria, position, "position") + if err != nil { + return Issue{}, err + } + + now := time.Now().UTC().Format(time.RFC3339Nano) + issueID, err := newOpaqueStateID("issue") + if err != nil { + return Issue{}, &IssueTransactionError{Stage: "id", Err: err} + } + if err := rejectIssueParentCycle(ctx, tx, projectID, issueID, parent.ID); err != nil { + return Issue{}, err + } + alias := providedAlias + if alias == "" { + var mintErr error + alias, mintErr = mintLocalIssueAliasTx(ctx, tx, projectID, now) + if mintErr != nil { + return Issue{}, mintErr + } + } + if _, err := tx.ExecContext(ctx, ` +INSERT INTO issues (id, project_id, parent_id, kind, title, body, fog, status, archived_at, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, NULL, ?, NULL, ?, ?) +`, issueID, projectID, parent.ID, IssueKindDelivery, criterion.Text, "", IssueStatusTriage, now, now); err != nil { + return Issue{}, &IssueTransactionError{Stage: "issue", Err: err} + } + if alias != "" { + if err := insertAlias(ctx, tx, projectID, issueEntityKind, issueID, issueNamespace, alias, now); err != nil { + return Issue{}, &IssueTransactionError{Stage: "alias", Err: err} + } + } + copied := IssueCriterionInput{ + Text: criterion.Text, + Command: criterion.Command, + Expect: criterion.Expect, + Tier: criterion.Tier, + Position: 1, + } + normalized, err := normalizeIssueCriteria([]IssueCriterionInput{copied}) + if err != nil { + return Issue{}, err + } + childCriterionID, err := insertIssueCriterionTx(ctx, tx, projectID, issueID, normalized[0], now) + if err != nil { + return Issue{}, err + } + if err := insertIssueCriterionClaimTx(ctx, tx, projectID, childCriterionID, criterion.ID, now); err != nil { + return Issue{}, err + } + if _, err := insertIssueStatusEventTx(ctx, tx, projectID, issueID, "", IssueStatusTriage, "recorded by issue promote", now); err != nil { + return Issue{}, err + } + detail, err := loadIssueTx(ctx, tx, projectID, issueID) + if err != nil { + return Issue{}, &IssueTransactionError{Stage: "read result", Err: err} + } + if err := tx.Commit(); err != nil { + return Issue{}, &IssueTransactionError{Stage: "commit", Err: err} + } + return detail, nil +} + +func recordCriterionServesTx(ctx context.Context, tx *sql.Tx, projectID, issueID, criterionID string, parentPosition int, now string) error { + if parentPosition <= 0 { + return nil + } + return claimNewCriterionAgainstParentTx(ctx, tx, projectID, issueID, criterionID, parentPosition, now) +} + +func claimNewCriterionAgainstParentTx(ctx context.Context, tx *sql.Tx, projectID, childIssueID, childCriterionID string, parentPosition int, now string) error { + child, err := loadIssueTx(ctx, tx, projectID, childIssueID) + if err != nil { + return &IssueTransactionError{Stage: "read child", Err: err} + } + if child.ParentID == "" { + return &IssueValidationError{Field: "serves", Err: fmt.Errorf("issue %s has no parent", firstNonEmpty(child.Alias, child.ID))} + } + parent, err := loadIssueTx(ctx, tx, projectID, child.ParentID) + if err != nil { + return &IssueTransactionError{Stage: "read parent", Err: err} + } + parentCriterion, err := criterionAtPosition(parent.Criteria, parentPosition, "serves") + if err != nil { + return err + } + return insertIssueCriterionClaimTx(ctx, tx, projectID, childCriterionID, parentCriterion.ID, now) +} + +func insertIssueCriterionTx(ctx context.Context, tx *sql.Tx, projectID, issueID string, criterion IssueCriterionInput, now string) (string, error) { + id, err := newOpaqueStateID("icr") + if err != nil { + return "", &IssueTransactionError{Stage: "criterion id", Err: err} + } + if _, err := tx.ExecContext(ctx, ` +INSERT INTO issue_criteria (id, project_id, issue_id, position, text, command, expect, tier, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +`, id, projectID, issueID, criterion.Position, criterion.Text, emptyToNil(criterion.Command), emptyToNil(criterion.Expect), criterion.Tier, now, now); err != nil { + return "", &IssueTransactionError{Stage: "criterion", Err: err} + } + return id, nil +} + +func loadIssueCriteriaTx(ctx context.Context, tx *sql.Tx, projectID, issueID string) ([]IssueCriterion, error) { + rows, err := tx.QueryContext(ctx, ` +SELECT id, position, text, COALESCE(command, ''), COALESCE(expect, ''), tier +FROM issue_criteria +WHERE project_id = ? AND issue_id = ? +ORDER BY position, id +`, projectID, issueID) + if err != nil { + return nil, fmt.Errorf("read issue criteria: %w", err) + } + defer rows.Close() + criteria := []IssueCriterion{} + for rows.Next() { + var criterion IssueCriterion + if err := rows.Scan(&criterion.ID, &criterion.Position, &criterion.Text, &criterion.Command, &criterion.Expect, &criterion.Tier); err != nil { + return nil, fmt.Errorf("scan issue criterion: %w", err) + } + criteria = append(criteria, criterion) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate issue criteria: %w", err) + } + return criteria, nil +} diff --git a/internal/state/issue_identity.go b/internal/state/issue_identity.go new file mode 100644 index 000000000..3d6a6fc66 --- /dev/null +++ b/internal/state/issue_identity.go @@ -0,0 +1,258 @@ +package state + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "github.com/levifig/loaf/internal/project" +) + +const ( + IssueAuthorityLocal = "local" + IssueAuthorityLinear = "linear" + IssueAuthorityGitHub = "github" + + DefaultIssueAuthority = IssueAuthorityLocal + DefaultIssuePrefix = "LOAF" +) + +// IssueIdentity is the per-project authority and local-number counter. +type IssueIdentity struct { + Authority string `json:"authority"` + Prefix string `json:"prefix"` + NextNumber int `json:"next_number"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// IssueIdentityOptions configures a project's issue authority. +// Prefix applies to local minting; tracker authorities mint no alias. +// NextNumber is never set by callers — the stored counter only advances. +type IssueIdentityOptions struct { + Authority string + Prefix string +} + +// LookupIssueIdentity returns the stored identity row without inserting a +// default. ok is false when the project has no identity row yet. +func LookupIssueIdentity(ctx context.Context, root project.Root, resolver PathResolver) (IssueIdentity, bool, error) { + store, err := openProjectStoreReadExisting(ctx, root, resolver) + if err != nil { + return IssueIdentity{}, false, err + } + defer store.Close() + return store.LookupIssueIdentity(ctx, root) +} + +// LookupIssueIdentity returns the stored identity row on an open store +// without inserting a default. +func (s *Store) LookupIssueIdentity(ctx context.Context, root project.Root) (IssueIdentity, bool, error) { + projectID, err := s.projectID(ctx, root) + if err != nil { + return IssueIdentity{}, false, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return IssueIdentity{}, false, &IssueTransactionError{Stage: "begin", Err: err} + } + defer tx.Rollback() + identity, err := loadIssueIdentityTx(ctx, tx, projectID) + if errors.Is(err, sql.ErrNoRows) { + return IssueIdentity{}, false, nil + } + if err != nil { + return IssueIdentity{}, false, err + } + return identity, true, nil +} + +// GetIssueIdentity returns the project's authority row, materializing the +// local/LOAF default when none has been written yet. +func GetIssueIdentity(ctx context.Context, root project.Root, resolver PathResolver) (IssueIdentity, error) { + store, err := openProjectStoreMutateExisting(ctx, root, resolver) + if err != nil { + return IssueIdentity{}, err + } + defer store.Close() + return store.GetIssueIdentity(ctx, root) +} + +// GetIssueIdentity returns the project's authority row on an open store. +func (s *Store) GetIssueIdentity(ctx context.Context, root project.Root) (IssueIdentity, error) { + projectID, err := s.projectID(ctx, root) + if err != nil { + return IssueIdentity{}, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return IssueIdentity{}, &IssueTransactionError{Stage: "begin", Err: err} + } + defer tx.Rollback() + identity, err := ensureIssueIdentityTx(ctx, tx, projectID, time.Now().UTC().Format(time.RFC3339Nano)) + if err != nil { + return IssueIdentity{}, err + } + if err := tx.Commit(); err != nil { + return IssueIdentity{}, &IssueTransactionError{Stage: "commit", Err: err} + } + return identity, nil +} + +// SetIssueIdentity writes the project's authority. Switching away from local +// does not rewind next_number; minted numbers stay reserved. +func SetIssueIdentity(ctx context.Context, root project.Root, resolver PathResolver, options IssueIdentityOptions) (IssueIdentity, error) { + store, err := openProjectStoreMutateExisting(ctx, root, resolver) + if err != nil { + return IssueIdentity{}, err + } + defer store.Close() + return store.SetIssueIdentity(ctx, root, options) +} + +// SetIssueIdentity writes the project's authority on an open store. +func (s *Store) SetIssueIdentity(ctx context.Context, root project.Root, options IssueIdentityOptions) (IssueIdentity, error) { + authority, prefix, err := normalizeIssueIdentity(options) + if err != nil { + return IssueIdentity{}, err + } + projectID, err := s.projectID(ctx, root) + if err != nil { + return IssueIdentity{}, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return IssueIdentity{}, &IssueTransactionError{Stage: "begin", Err: err} + } + defer tx.Rollback() + + now := time.Now().UTC().Format(time.RFC3339Nano) + _, err = loadIssueIdentityTx(ctx, tx, projectID) + switch { + case errors.Is(err, sql.ErrNoRows): + id, idErr := newOpaqueStateID("iid") + if idErr != nil { + return IssueIdentity{}, &IssueTransactionError{Stage: "id", Err: idErr} + } + if _, err := tx.ExecContext(ctx, ` +INSERT INTO issue_identity (id, project_id, authority, prefix, next_number, created_at, updated_at) +VALUES (?, ?, ?, ?, 1, ?, ?) +`, id, projectID, authority, prefix, now, now); err != nil { + return IssueIdentity{}, &IssueTransactionError{Stage: "identity", Err: err} + } + case err != nil: + return IssueIdentity{}, err + default: + if _, err := tx.ExecContext(ctx, ` +UPDATE issue_identity SET authority = ?, prefix = ?, updated_at = ? WHERE project_id = ? +`, authority, prefix, now, projectID); err != nil { + return IssueIdentity{}, &IssueTransactionError{Stage: "identity", Err: err} + } + } + + identity, err := loadIssueIdentityTx(ctx, tx, projectID) + if err != nil { + return IssueIdentity{}, &IssueTransactionError{Stage: "read result", Err: err} + } + if err := tx.Commit(); err != nil { + return IssueIdentity{}, &IssueTransactionError{Stage: "commit", Err: err} + } + return identity, nil +} + +func normalizeIssueIdentity(options IssueIdentityOptions) (string, string, error) { + authority := strings.TrimSpace(options.Authority) + if authority == "" { + authority = DefaultIssueAuthority + } + if authority != IssueAuthorityLocal && authority != IssueAuthorityLinear && authority != IssueAuthorityGitHub { + return "", "", &IssueValidationError{Field: "authority", Err: fmt.Errorf("must be local, linear, or github")} + } + prefix := strings.TrimSpace(options.Prefix) + if prefix == "" { + prefix = DefaultIssuePrefix + } + if err := validateIssuePrefix(prefix); err != nil { + return "", "", err + } + return authority, prefix, nil +} + +func validateIssuePrefix(prefix string) error { + if prefix == "" { + return &IssueValidationError{Field: "prefix", Err: fmt.Errorf("must be nonempty")} + } + for i := 0; i < len(prefix); i++ { + c := prefix[i] + if i == 0 && !isASCIILetter(c) { + return &IssueValidationError{Field: "prefix", Err: fmt.Errorf("must start with a letter")} + } + if !isASCIILetter(c) && !isASCIIDigit(c) { + return &IssueValidationError{Field: "prefix", Err: fmt.Errorf("must be alphanumeric")} + } + } + return nil +} + +func isASCIILetter(c byte) bool { + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') +} + +func isASCIIDigit(c byte) bool { + return c >= '0' && c <= '9' +} + +func ensureIssueIdentityTx(ctx context.Context, tx *sql.Tx, projectID, now string) (IssueIdentity, error) { + identity, err := loadIssueIdentityTx(ctx, tx, projectID) + if err == nil { + return identity, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return IssueIdentity{}, err + } + id, err := newOpaqueStateID("iid") + if err != nil { + return IssueIdentity{}, &IssueTransactionError{Stage: "id", Err: err} + } + if _, err := tx.ExecContext(ctx, ` +INSERT INTO issue_identity (id, project_id, authority, prefix, next_number, created_at, updated_at) +VALUES (?, ?, ?, ?, 1, ?, ?) +`, id, projectID, DefaultIssueAuthority, DefaultIssuePrefix, now, now); err != nil { + return IssueIdentity{}, &IssueTransactionError{Stage: "identity", Err: err} + } + return loadIssueIdentityTx(ctx, tx, projectID) +} + +func loadIssueIdentityTx(ctx context.Context, tx *sql.Tx, projectID string) (IssueIdentity, error) { + var identity IssueIdentity + err := tx.QueryRowContext(ctx, ` +SELECT authority, prefix, next_number, created_at, updated_at +FROM issue_identity WHERE project_id = ? +`, projectID).Scan(&identity.Authority, &identity.Prefix, &identity.NextNumber, &identity.CreatedAt, &identity.UpdatedAt) + if err != nil { + return IssueIdentity{}, err + } + return identity, nil +} + +// mintLocalIssueAliasTx consumes next_number when authority is local. +// Tracker authorities return an empty alias and leave the counter untouched. +func mintLocalIssueAliasTx(ctx context.Context, tx *sql.Tx, projectID, now string) (string, error) { + identity, err := ensureIssueIdentityTx(ctx, tx, projectID, now) + if err != nil { + return "", err + } + if identity.Authority != IssueAuthorityLocal { + return "", nil + } + alias := fmt.Sprintf("%s-%d", identity.Prefix, identity.NextNumber) + if _, err := tx.ExecContext(ctx, ` +UPDATE issue_identity SET next_number = next_number + 1, updated_at = ? WHERE project_id = ? +`, now, projectID); err != nil { + return "", &IssueTransactionError{Stage: "mint", Err: err} + } + return alias, nil +} diff --git a/internal/state/issue_link.go b/internal/state/issue_link.go new file mode 100644 index 000000000..b09765c0a --- /dev/null +++ b/internal/state/issue_link.go @@ -0,0 +1,59 @@ +package state + +import ( + "context" + "fmt" + "strings" + + "github.com/levifig/loaf/internal/project" +) + +// NormalizeIssueLinkType maps CLI relationship names onto the stored +// vocabulary: blocks and relates_to. relates-to is accepted as an alias. +func NormalizeIssueLinkType(value string) (string, error) { + normalized := strings.ToLower(strings.TrimSpace(value)) + switch normalized { + case "blocks": + return IssueRelationshipBlocks, nil + case "relates-to", "relates_to": + return IssueRelationshipRelatesTo, nil + case "blocked_by", "blocked-by": + return IssueRelationshipBlockedBy, nil + default: + return "", &IssueValidationError{Field: "type", Err: fmt.Errorf("must be blocks or relates-to")} + } +} + +// CreateIssueLink writes an issue-to-issue relationship through the shared +// relationships table. +func CreateIssueLink(ctx context.Context, root project.Root, resolver PathResolver, from, relationshipType, to string) (LinkMutationResult, error) { + normalized, err := NormalizeIssueLinkType(relationshipType) + if err != nil { + return LinkMutationResult{}, err + } + if normalized != IssueRelationshipBlocks && normalized != IssueRelationshipRelatesTo { + return LinkMutationResult{}, &IssueValidationError{Field: "type", Err: fmt.Errorf("must be blocks or relates-to")} + } + return CreateLink(ctx, root, resolver, LinkMutationOptions{ + From: from, + To: to, + Type: normalized, + Reason: "recorded by issue link", + }) +} + +// RemoveIssueLink removes an issue-to-issue relationship. +func RemoveIssueLink(ctx context.Context, root project.Root, resolver PathResolver, from, relationshipType, to string) (LinkMutationResult, error) { + normalized, err := NormalizeIssueLinkType(relationshipType) + if err != nil { + return LinkMutationResult{}, err + } + if normalized != IssueRelationshipBlocks && normalized != IssueRelationshipRelatesTo { + return LinkMutationResult{}, &IssueValidationError{Field: "type", Err: fmt.Errorf("must be blocks or relates-to")} + } + return RemoveLink(ctx, root, resolver, LinkMutationOptions{ + From: from, + To: to, + Type: normalized, + }) +} diff --git a/internal/state/issue_query.go b/internal/state/issue_query.go new file mode 100644 index 000000000..376a673f4 --- /dev/null +++ b/internal/state/issue_query.go @@ -0,0 +1,759 @@ +package state + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + + "github.com/levifig/loaf/internal/project" +) + +// IssueListOptions filters a project issue listing. +type IssueListOptions struct { + Status string + Kind string + Archived bool + Started bool +} + +// IssueSummary is a compact issue row used in trees, children, and frontier. +type IssueSummary struct { + ID string `json:"id"` + Alias string `json:"alias,omitempty"` + Parent string `json:"parent_id,omitempty"` + Kind string `json:"kind"` + Title string `json:"title"` + Status string `json:"status"` +} + +// RenderIssueMarkdown is the shaping body loaf push writes to Linear. +func RenderIssueMarkdown(result IssueResult) string { + var b strings.Builder + fmt.Fprintf(&b, "# %s\n", result.Issue.Title) + if strings.TrimSpace(result.Issue.Body) != "" { + fmt.Fprintln(&b) + b.WriteString(result.Issue.Body) + if !strings.HasSuffix(result.Issue.Body, "\n") { + fmt.Fprintln(&b) + } + } + if len(result.Issue.Criteria) > 0 { + fmt.Fprintln(&b) + fmt.Fprintln(&b, "## Definition of Done") + fmt.Fprintln(&b) + checked := result.Issue.Status == IssueStatusDone + for _, criterion := range result.Issue.Criteria { + mark := " " + if checked { + mark = "x" + } + fmt.Fprintf(&b, "- [%s] %s\n", mark, criterion.Text) + } + } + if len(result.Children) > 0 { + fmt.Fprintln(&b) + fmt.Fprintln(&b, "## Children") + fmt.Fprintln(&b) + for _, child := range result.Children { + fmt.Fprintf(&b, "- %s: %s\n", firstNonEmpty(child.Alias, child.ID), child.Title) + } + } + return b.String() +} + +// IssueResult is one issue plus project identity for CLI mutation/show JSON. +type IssueResult struct { + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Issue Issue `json:"issue"` + Parent *IssueSummary `json:"parent,omitempty"` + Children []IssueSummary `json:"children,omitempty"` + Bucket string `json:"bucket,omitempty"` +} + +// IssueListResult is a project-scoped issue listing. +type IssueListResult struct { + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Issues []Issue `json:"issues"` +} + +// IssueTreeNode is one node in a recursive issue tree. +type IssueTreeNode struct { + ID string `json:"id"` + Alias string `json:"alias,omitempty"` + Kind string `json:"kind"` + Title string `json:"title"` + Status string `json:"status"` + Children []IssueTreeNode `json:"children,omitempty"` +} + +// IssueTreeResult is a recursive tree of issues. +type IssueTreeResult struct { + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Roots []IssueTreeNode `json:"roots"` +} + +// IssueFrontierResult is the derived pick-up-next view. +type IssueFrontierResult struct { + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Issues []IssueSummary `json:"issues"` +} + +// IssueCriterionExport is one criterion row in an issue export. +type IssueCriterionExport struct { + ID string `json:"id"` + IssueID string `json:"issue_id"` + Position int `json:"position"` + Text string `json:"text"` + Command string `json:"command,omitempty"` + Expect string `json:"expect,omitempty"` + Tier string `json:"tier"` +} + +// IssueRelationshipExport is one issue-touching relationship in an export. +type IssueRelationshipExport struct { + ID string `json:"id"` + FromEntityKind string `json:"from_entity_kind"` + FromEntityID string `json:"from_entity_id"` + ToEntityKind string `json:"to_entity_kind"` + ToEntityID string `json:"to_entity_id"` + RelationshipType string `json:"relationship_type"` + Reason string `json:"reason,omitempty"` +} + +// IssueExportIdentity is the stored issue_identity row for a project export. +// It is omitted when the project has no identity row; exports never materialize a default. +type IssueExportIdentity struct { + Authority string `json:"authority"` + Prefix string `json:"prefix"` + NextNumber int `json:"next_number"` +} + +// IssueExportSnapshot is a project-scoped JSON backup of issues. +type IssueExportSnapshot struct { + ContractVersion int `json:"contract_version"` + ExportKind string `json:"export_kind"` + Format string `json:"format"` + DatabaseScope string `json:"database_scope"` + ProjectID string `json:"project_id"` + ProjectName string `json:"project_name"` + ProjectCurrentPath string `json:"project_current_path"` + DatabasePath string `json:"database_path"` + Identity *IssueExportIdentity `json:"identity,omitempty"` + Issues []Issue `json:"issues"` + Criteria []IssueCriterionExport `json:"criteria"` + Claims []IssueCriterionClaim `json:"claims"` + Relationships []IssueRelationshipExport `json:"relationships"` +} + +// ListIssues returns project issues matching the filters. Archived issues are +// hidden unless Archived is set. +func ListIssues(ctx context.Context, root project.Root, resolver PathResolver, options IssueListOptions) (IssueListResult, error) { + store, err := openProjectStoreReadExisting(ctx, root, resolver) + if err != nil { + return IssueListResult{}, err + } + defer store.Close() + return store.ListIssues(ctx, root, options) +} + +// ListIssues returns project issues from an open store. +func (s *Store) ListIssues(ctx context.Context, root project.Root, options IssueListOptions) (IssueListResult, error) { + if err := validateIssueListOptions(options); err != nil { + return IssueListResult{}, err + } + identity, err := s.issueContext(ctx, root) + if err != nil { + return IssueListResult{}, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return IssueListResult{}, fmt.Errorf("begin issue list: %w", err) + } + defer tx.Rollback() + + ids, err := listIssueIDsTx(ctx, tx, identity.ID, options, "") + if err != nil { + return IssueListResult{}, err + } + issues := make([]Issue, 0, len(ids)) + for _, id := range ids { + issue, err := loadIssueTx(ctx, tx, identity.ID, id) + if err != nil { + return IssueListResult{}, err + } + issues = append(issues, issue) + } + return IssueListResult{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Issues: issues, + }, nil +} + +// ShowIssue returns one issue with parent, children, and advisory bucket. +func ShowIssue(ctx context.Context, root project.Root, resolver PathResolver, ref string) (IssueResult, error) { + store, err := openProjectStoreReadExisting(ctx, root, resolver) + if err != nil { + return IssueResult{}, err + } + defer store.Close() + return store.ShowIssue(ctx, root, ref) +} + +// ShowIssue returns one issue from an open store. +func (s *Store) ShowIssue(ctx context.Context, root project.Root, ref string) (IssueResult, error) { + identity, err := s.issueContext(ctx, root) + if err != nil { + return IssueResult{}, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return IssueResult{}, fmt.Errorf("begin issue show: %w", err) + } + defer tx.Rollback() + + issueID, _, err := resolveIssueRefTx(ctx, tx, identity.ID, ref) + if err != nil { + return IssueResult{}, err + } + return loadIssueResultTx(ctx, tx, identity, issueID) +} + +// IssueTree returns a recursive tree from ref, or the whole project when ref is empty. +func IssueTree(ctx context.Context, root project.Root, resolver PathResolver, ref string, archived bool) (IssueTreeResult, error) { + store, err := openProjectStoreReadExisting(ctx, root, resolver) + if err != nil { + return IssueTreeResult{}, err + } + defer store.Close() + return store.IssueTree(ctx, root, ref, archived) +} + +// IssueTree returns a recursive tree from an open store. +func (s *Store) IssueTree(ctx context.Context, root project.Root, ref string, archived bool) (IssueTreeResult, error) { + identity, err := s.issueContext(ctx, root) + if err != nil { + return IssueTreeResult{}, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return IssueTreeResult{}, fmt.Errorf("begin issue tree: %w", err) + } + defer tx.Rollback() + + summaries, err := listIssueSummariesTx(ctx, tx, identity.ID, IssueListOptions{Archived: archived}) + if err != nil { + return IssueTreeResult{}, err + } + rootIDs := make([]string, 0) + if strings.TrimSpace(ref) != "" { + issueID, _, err := resolveIssueRefTx(ctx, tx, identity.ID, ref) + if err != nil { + return IssueTreeResult{}, err + } + rootIDs = append(rootIDs, issueID) + } else { + for _, summary := range summaries { + if summary.Parent == "" { + rootIDs = append(rootIDs, summary.ID) + } + } + } + roots, err := buildIssueTree(summaries, rootIDs) + if err != nil { + return IssueTreeResult{}, err + } + return IssueTreeResult{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Roots: roots, + }, nil +} + +// ListIssueFrontier returns non-archived triage/backlog/todo issues that are +// not blocked. The view is derived at read time and never stored. +func ListIssueFrontier(ctx context.Context, root project.Root, resolver PathResolver) (IssueFrontierResult, error) { + store, err := openProjectStoreReadExisting(ctx, root, resolver) + if err != nil { + return IssueFrontierResult{}, err + } + defer store.Close() + return store.ListIssueFrontier(ctx, root) +} + +// ListIssueFrontier returns the derived pick-up-next view from an open store. +func (s *Store) ListIssueFrontier(ctx context.Context, root project.Root) (IssueFrontierResult, error) { + identity, err := s.issueContext(ctx, root) + if err != nil { + return IssueFrontierResult{}, err + } + rows, err := s.db.QueryContext(ctx, ` +SELECT i.id, i.parent_id, i.kind, i.title, i.status, + (SELECT a.alias FROM aliases a WHERE a.project_id = i.project_id AND a.entity_kind = ? AND a.entity_id = i.id ORDER BY a.namespace, a.alias LIMIT 1) +FROM issues AS i +WHERE i.project_id = ? + AND i.archived_at IS NULL + AND i.status IN (?, ?, ?) + AND NOT EXISTS ( + SELECT 1 FROM relationships r + JOIN issues blocker ON blocker.project_id = r.project_id AND blocker.id = r.from_entity_id + WHERE r.project_id = i.project_id + AND r.from_entity_kind = ? + AND r.to_entity_kind = ? + AND r.to_entity_id = i.id + AND r.relationship_type = ? + AND blocker.status NOT IN (?, ?, ?) + ) + AND NOT EXISTS ( + SELECT 1 FROM relationships r + JOIN issues blocker ON blocker.project_id = r.project_id AND blocker.id = r.to_entity_id + WHERE r.project_id = i.project_id + AND r.from_entity_kind = ? + AND r.to_entity_kind = ? + AND r.from_entity_id = i.id + AND r.relationship_type = ? + AND blocker.status NOT IN (?, ?, ?) + ) +ORDER BY i.created_at, i.id +`, issueEntityKind, identity.ID, + IssueStatusTriage, IssueStatusBacklog, IssueStatusTodo, + issueEntityKind, issueEntityKind, IssueRelationshipBlocks, + IssueStatusDone, IssueStatusCancelled, IssueStatusDuplicate, + issueEntityKind, issueEntityKind, IssueRelationshipBlockedBy, + IssueStatusDone, IssueStatusCancelled, IssueStatusDuplicate) + if err != nil { + return IssueFrontierResult{}, fmt.Errorf("query issue frontier: %w", err) + } + defer rows.Close() + + issues := []IssueSummary{} + for rows.Next() { + var summary IssueSummary + var parent, alias sql.NullString + if err := rows.Scan(&summary.ID, &parent, &summary.Kind, &summary.Title, &summary.Status, &alias); err != nil { + return IssueFrontierResult{}, fmt.Errorf("scan issue frontier: %w", err) + } + summary.Parent = parent.String + summary.Alias = alias.String + issues = append(issues, summary) + } + if err := rows.Err(); err != nil { + return IssueFrontierResult{}, fmt.Errorf("iterate issue frontier: %w", err) + } + return IssueFrontierResult{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Issues: issues, + }, nil +} + +// ExportIssues returns a project-scoped JSON backup of issues, criteria, +// criterion claims, issue-touching relationships, and the stored issue +// identity when one exists. +func ExportIssues(ctx context.Context, root project.Root, resolver PathResolver) (IssueExportSnapshot, error) { + store, err := openProjectStoreReadExisting(ctx, root, resolver) + if err != nil { + return IssueExportSnapshot{}, err + } + defer store.Close() + return store.ExportIssues(ctx, root) +} + +// ExportIssues returns a project-scoped issue backup from an open store. +func (s *Store) ExportIssues(ctx context.Context, root project.Root) (IssueExportSnapshot, error) { + listed, err := s.ListIssues(ctx, root, IssueListOptions{Archived: true}) + if err != nil { + return IssueExportSnapshot{}, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return IssueExportSnapshot{}, fmt.Errorf("begin issue export: %w", err) + } + defer tx.Rollback() + + criteria, err := exportIssueCriteriaTx(ctx, tx, listed.ProjectID) + if err != nil { + return IssueExportSnapshot{}, err + } + claims, err := exportIssueCriterionClaimsTx(ctx, tx, listed.ProjectID) + if err != nil { + return IssueExportSnapshot{}, err + } + relationships, err := exportIssueRelationshipsTx(ctx, tx, listed.ProjectID) + if err != nil { + return IssueExportSnapshot{}, err + } + identity, err := lookupStoredIssueIdentityTx(ctx, tx, listed.ProjectID) + if err != nil { + return IssueExportSnapshot{}, err + } + return IssueExportSnapshot{ + ContractVersion: StateJSONContractVersion, + ExportKind: ExportKindIssue, + Format: ExportFormatJSON, + DatabaseScope: listed.DatabaseScope, + ProjectID: listed.ProjectID, + ProjectName: listed.ProjectName, + ProjectCurrentPath: listed.ProjectCurrentPath, + DatabasePath: listed.DatabasePath, + Identity: identity, + Issues: listed.Issues, + Criteria: criteria, + Claims: claims, + Relationships: relationships, + }, nil +} + +func (s *Store) issueContext(ctx context.Context, root project.Root) (ProjectIdentity, error) { + projectID, err := s.projectID(ctx, root) + if err != nil { + return ProjectIdentity{}, err + } + return s.projectIdentity(ctx, projectID) +} + +func validateIssueListOptions(options IssueListOptions) error { + if status := strings.TrimSpace(options.Status); status != "" && !validIssueStatus(status) { + return &IssueValidationError{Field: "status", Err: fmt.Errorf("must be one of triage, backlog, todo, active, done, cancelled, duplicate")} + } + if kind := strings.TrimSpace(options.Kind); kind != "" && !validIssueKind(kind) { + return &IssueValidationError{Field: "kind", Err: fmt.Errorf("must be delivery or decision")} + } + return nil +} + +func listIssueIDsTx(ctx context.Context, tx *sql.Tx, projectID string, options IssueListOptions, parentID string) ([]string, error) { + query := ` +SELECT id FROM issues +WHERE project_id = ?` + args := []any{projectID} + if parentID != "" { + query += ` + AND parent_id = ?` + args = append(args, parentID) + } + if status := strings.TrimSpace(options.Status); status != "" { + query += ` + AND status = ?` + args = append(args, status) + } + if kind := strings.TrimSpace(options.Kind); kind != "" { + query += ` + AND kind = ?` + args = append(args, kind) + } + if options.Started { + query += ` + AND started_worktree IS NOT NULL AND trim(started_worktree) != ''` + } else if !options.Archived { + query += ` + AND archived_at IS NULL` + } + query += ` +ORDER BY created_at, id` + rows, err := tx.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("list issue ids: %w", err) + } + defer rows.Close() + ids := []string{} + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scan issue id: %w", err) + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate issue ids: %w", err) + } + return ids, nil +} + +func listIssueSummariesTx(ctx context.Context, tx *sql.Tx, projectID string, options IssueListOptions) ([]IssueSummary, error) { + query := ` +SELECT i.id, i.parent_id, i.kind, i.title, i.status, + (SELECT a.alias FROM aliases a WHERE a.project_id = i.project_id AND a.entity_kind = ? AND a.entity_id = i.id ORDER BY a.namespace, a.alias LIMIT 1) +FROM issues AS i +WHERE i.project_id = ?` + args := []any{issueEntityKind, projectID} + if !options.Archived { + query += ` + AND i.archived_at IS NULL` + } + query += ` +ORDER BY i.created_at, i.id` + rows, err := tx.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("list issue summaries: %w", err) + } + defer rows.Close() + summaries := []IssueSummary{} + for rows.Next() { + var summary IssueSummary + var parent, alias sql.NullString + if err := rows.Scan(&summary.ID, &parent, &summary.Kind, &summary.Title, &summary.Status, &alias); err != nil { + return nil, fmt.Errorf("scan issue summary: %w", err) + } + summary.Parent = parent.String + summary.Alias = alias.String + summaries = append(summaries, summary) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate issue summaries: %w", err) + } + return summaries, nil +} + +func loadIssueResultTx(ctx context.Context, tx *sql.Tx, identity ProjectIdentity, issueID string) (IssueResult, error) { + issue, err := loadIssueTx(ctx, tx, identity.ID, issueID) + if err != nil { + return IssueResult{}, err + } + var parent *IssueSummary + if issue.ParentID != "" { + summary, err := loadIssueSummaryTx(ctx, tx, identity.ID, issue.ParentID) + if err != nil { + return IssueResult{}, err + } + parent = &summary + } + children, err := listIssueChildrenTx(ctx, tx, identity.ID, issue.ID) + if err != nil { + return IssueResult{}, err + } + bucket, err := loadIssueBucketTx(ctx, tx, identity.ID, issue.ID) + if err != nil { + return IssueResult{}, err + } + return IssueResult{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Issue: issue, + Parent: parent, + Children: children, + Bucket: bucket, + }, nil +} + +func loadIssueSummaryTx(ctx context.Context, tx *sql.Tx, projectID, issueID string) (IssueSummary, error) { + var summary IssueSummary + var parent, alias sql.NullString + err := tx.QueryRowContext(ctx, ` +SELECT i.id, i.parent_id, i.kind, i.title, i.status, + (SELECT a.alias FROM aliases a WHERE a.project_id = i.project_id AND a.entity_kind = ? AND a.entity_id = i.id ORDER BY a.namespace, a.alias LIMIT 1) +FROM issues AS i +WHERE i.project_id = ? AND i.id = ? +`, issueEntityKind, projectID, issueID).Scan(&summary.ID, &parent, &summary.Kind, &summary.Title, &summary.Status, &alias) + if err != nil { + return IssueSummary{}, fmt.Errorf("load issue summary %s: %w", issueID, err) + } + summary.Parent = parent.String + summary.Alias = alias.String + return summary, nil +} + +func listIssueChildrenTx(ctx context.Context, tx *sql.Tx, projectID, parentID string) ([]IssueSummary, error) { + rows, err := tx.QueryContext(ctx, ` +SELECT i.id, i.parent_id, i.kind, i.title, i.status, + (SELECT a.alias FROM aliases a WHERE a.project_id = i.project_id AND a.entity_kind = ? AND a.entity_id = i.id ORDER BY a.namespace, a.alias LIMIT 1) +FROM issues AS i +WHERE i.project_id = ? AND i.parent_id = ? +ORDER BY i.created_at, i.id +`, issueEntityKind, projectID, parentID) + if err != nil { + return nil, fmt.Errorf("list issue children: %w", err) + } + defer rows.Close() + children := []IssueSummary{} + for rows.Next() { + var summary IssueSummary + var parent, alias sql.NullString + if err := rows.Scan(&summary.ID, &parent, &summary.Kind, &summary.Title, &summary.Status, &alias); err != nil { + return nil, fmt.Errorf("scan issue child: %w", err) + } + summary.Parent = parent.String + summary.Alias = alias.String + children = append(children, summary) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate issue children: %w", err) + } + return children, nil +} + +func buildIssueTree(summaries []IssueSummary, rootIDs []string) ([]IssueTreeNode, error) { + byParent := map[string][]IssueSummary{} + byID := map[string]IssueSummary{} + for _, summary := range summaries { + byID[summary.ID] = summary + byParent[summary.Parent] = append(byParent[summary.Parent], summary) + } + visited := map[string]bool{} + var walk func(id string) (IssueTreeNode, error) + walk = func(id string) (IssueTreeNode, error) { + if visited[id] { + return IssueTreeNode{}, fmt.Errorf("parent cycle detected in stored issue data at %s", id) + } + visited[id] = true + summary := byID[id] + node := IssueTreeNode{ + ID: summary.ID, + Alias: summary.Alias, + Kind: summary.Kind, + Title: summary.Title, + Status: summary.Status, + } + for _, child := range byParent[id] { + childNode, err := walk(child.ID) + if err != nil { + return IssueTreeNode{}, err + } + node.Children = append(node.Children, childNode) + } + return node, nil + } + roots := make([]IssueTreeNode, 0, len(rootIDs)) + for _, id := range rootIDs { + if _, ok := byID[id]; !ok { + continue + } + node, err := walk(id) + if err != nil { + return nil, err + } + roots = append(roots, node) + } + return roots, nil +} + +// lookupStoredIssueIdentityTx reads the project's issue_identity row without +// inserting a default. A missing row is represented as nil. +func lookupStoredIssueIdentityTx(ctx context.Context, tx *sql.Tx, projectID string) (*IssueExportIdentity, error) { + var identity IssueExportIdentity + err := tx.QueryRowContext(ctx, ` +SELECT authority, prefix, next_number +FROM issue_identity WHERE project_id = ? +`, projectID).Scan(&identity.Authority, &identity.Prefix, &identity.NextNumber) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("load stored issue identity: %w", err) + } + return &identity, nil +} + +func exportIssueCriterionClaimsTx(ctx context.Context, tx *sql.Tx, projectID string) ([]IssueCriterionClaim, error) { + rows, err := tx.QueryContext(ctx, ` +SELECT id, child_criterion_id, parent_criterion_id +FROM issue_criterion_claims +WHERE project_id = ? +ORDER BY id +`, projectID) + if err != nil { + return nil, fmt.Errorf("export issue criterion claims: %w", err) + } + defer rows.Close() + claims := []IssueCriterionClaim{} + for rows.Next() { + var claim IssueCriterionClaim + if err := rows.Scan(&claim.ID, &claim.ChildCriterionID, &claim.ParentCriterionID); err != nil { + return nil, fmt.Errorf("scan exported issue criterion claim: %w", err) + } + claims = append(claims, claim) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate exported issue criterion claims: %w", err) + } + return claims, nil +} + +func exportIssueCriteriaTx(ctx context.Context, tx *sql.Tx, projectID string) ([]IssueCriterionExport, error) { + rows, err := tx.QueryContext(ctx, ` +SELECT id, issue_id, position, text, COALESCE(command, ''), COALESCE(expect, ''), tier +FROM issue_criteria +WHERE project_id = ? +ORDER BY issue_id, position, id +`, projectID) + if err != nil { + return nil, fmt.Errorf("export issue criteria: %w", err) + } + defer rows.Close() + criteria := []IssueCriterionExport{} + for rows.Next() { + var criterion IssueCriterionExport + if err := rows.Scan(&criterion.ID, &criterion.IssueID, &criterion.Position, &criterion.Text, &criterion.Command, &criterion.Expect, &criterion.Tier); err != nil { + return nil, fmt.Errorf("scan exported issue criterion: %w", err) + } + criteria = append(criteria, criterion) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate exported issue criteria: %w", err) + } + return criteria, nil +} + +func exportIssueRelationshipsTx(ctx context.Context, tx *sql.Tx, projectID string) ([]IssueRelationshipExport, error) { + rows, err := tx.QueryContext(ctx, ` +SELECT id, from_entity_kind, from_entity_id, to_entity_kind, to_entity_id, relationship_type, COALESCE(reason, '') +FROM relationships +WHERE project_id = ? + AND (from_entity_kind = ? OR to_entity_kind = ?) +ORDER BY id +`, projectID, issueEntityKind, issueEntityKind) + if err != nil { + return nil, fmt.Errorf("export issue relationships: %w", err) + } + defer rows.Close() + relationships := []IssueRelationshipExport{} + for rows.Next() { + var relationship IssueRelationshipExport + if err := rows.Scan(&relationship.ID, &relationship.FromEntityKind, &relationship.FromEntityID, &relationship.ToEntityKind, &relationship.ToEntityID, &relationship.RelationshipType, &relationship.Reason); err != nil { + return nil, fmt.Errorf("scan exported issue relationship: %w", err) + } + relationships = append(relationships, relationship) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate exported issue relationships: %w", err) + } + return relationships, nil +} diff --git a/internal/state/issue_query_test.go b/internal/state/issue_query_test.go new file mode 100644 index 000000000..ce2bc74be --- /dev/null +++ b/internal/state/issue_query_test.go @@ -0,0 +1,386 @@ +package state + +import ( + "context" + "strings" + "testing" +) + +func TestListIssuesFiltersStatusKindAndArchived(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + + delivery, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Delivery", Kind: IssueKindDelivery}) + if err != nil { + t.Fatalf("CreateIssue(delivery) error = %v", err) + } + if _, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Decision", Kind: IssueKindDecision}); err != nil { + t.Fatalf("CreateIssue(decision) error = %v", err) + } + if _, err := store.UpdateIssue(ctx, root, IssueUpdateOptions{Ref: delivery.ID, Status: IssueStatusTodo, SetStatus: true}); err != nil { + t.Fatalf("UpdateIssue(todo) error = %v", err) + } + cancelled, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Cancelled"}) + if err != nil { + t.Fatalf("CreateIssue(cancelled) error = %v", err) + } + if _, err := store.RemoveIssue(ctx, root, IssueRemoveOptions{Ref: cancelled.ID, Status: IssueStatusCancelled}); err != nil { + t.Fatalf("RemoveIssue() error = %v", err) + } + + listed, err := store.ListIssues(ctx, root, IssueListOptions{}) + if err != nil { + t.Fatalf("ListIssues() error = %v", err) + } + if len(listed.Issues) != 2 { + t.Fatalf("default list = %#v, want 2 non-archived", listed.Issues) + } + + todos, err := store.ListIssues(ctx, root, IssueListOptions{Status: IssueStatusTodo}) + if err != nil { + t.Fatalf("ListIssues(todo) error = %v", err) + } + if len(todos.Issues) != 1 || todos.Issues[0].Title != "Delivery" { + t.Fatalf("todo list = %#v, want Delivery", todos.Issues) + } + + decisions, err := store.ListIssues(ctx, root, IssueListOptions{Kind: IssueKindDecision}) + if err != nil { + t.Fatalf("ListIssues(decision) error = %v", err) + } + if len(decisions.Issues) != 1 || decisions.Issues[0].Title != "Decision" { + t.Fatalf("decision list = %#v, want Decision", decisions.Issues) + } + + archived, err := store.ListIssues(ctx, root, IssueListOptions{Archived: true, Status: IssueStatusCancelled}) + if err != nil { + t.Fatalf("ListIssues(archived cancelled) error = %v", err) + } + if len(archived.Issues) != 1 || archived.Issues[0].Title != "Cancelled" { + t.Fatalf("archived cancelled = %#v", archived.Issues) + } +} + +func TestIssueTreeIncludesGrandchildren(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + + parent, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Parent"}) + if err != nil { + t.Fatalf("CreateIssue(parent) error = %v", err) + } + child, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Child", Parent: parent.Alias}) + if err != nil { + t.Fatalf("CreateIssue(child) error = %v", err) + } + grand, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Grandchild", Parent: child.Alias}) + if err != nil { + t.Fatalf("CreateIssue(grandchild) error = %v", err) + } + + tree, err := store.IssueTree(ctx, root, parent.Alias, false) + if err != nil { + t.Fatalf("IssueTree() error = %v", err) + } + if len(tree.Roots) != 1 || tree.Roots[0].Title != "Parent" { + t.Fatalf("roots = %#v, want Parent", tree.Roots) + } + if len(tree.Roots[0].Children) != 1 || tree.Roots[0].Children[0].Title != "Child" { + t.Fatalf("children = %#v, want Child", tree.Roots[0].Children) + } + if len(tree.Roots[0].Children[0].Children) != 1 || tree.Roots[0].Children[0].Children[0].ID != grand.ID { + t.Fatalf("grandchildren = %#v, want Grandchild", tree.Roots[0].Children[0].Children) + } +} + +func TestIssueTreeRejectsStoredParentCycle(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + + a, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "A"}) + if err != nil { + t.Fatalf("CreateIssue(A) error = %v", err) + } + b, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "B"}) + if err != nil { + t.Fatalf("CreateIssue(B) error = %v", err) + } + if _, err := store.db.ExecContext(ctx, `UPDATE issues SET parent_id = ? WHERE id = ?`, b.ID, a.ID); err != nil { + t.Fatalf("set A.parent=B: %v", err) + } + if _, err := store.db.ExecContext(ctx, `UPDATE issues SET parent_id = ? WHERE id = ?`, a.ID, b.ID); err != nil { + t.Fatalf("set B.parent=A: %v", err) + } + + _, err = store.IssueTree(ctx, root, a.ID, false) + if err == nil { + t.Fatal("IssueTree() error = nil, want stored parent cycle") + } + if !strings.Contains(err.Error(), "parent cycle detected in stored issue data at ") { + t.Fatalf("IssueTree() error = %v, want cycle message", err) + } + if !strings.Contains(err.Error(), a.ID) && !strings.Contains(err.Error(), b.ID) { + t.Fatalf("IssueTree() error = %v, want a stored issue id", err) + } +} + +func TestIssueFrontierExcludesBlockedAndArchived(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + + open, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Open"}) + if err != nil { + t.Fatalf("CreateIssue(open) error = %v", err) + } + blocker, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Blocker"}) + if err != nil { + t.Fatalf("CreateIssue(blocker) error = %v", err) + } + blocked, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Blocked"}) + if err != nil { + t.Fatalf("CreateIssue(blocked) error = %v", err) + } + archived, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Archived"}) + if err != nil { + t.Fatalf("CreateIssue(archived) error = %v", err) + } + if _, err := store.RemoveIssue(ctx, root, IssueRemoveOptions{Ref: archived.ID, Status: IssueStatusCancelled}); err != nil { + t.Fatalf("RemoveIssue(archived) error = %v", err) + } + if _, err := store.CreateLink(ctx, root, LinkMutationOptions{ + From: blocker.Alias, + To: blocked.Alias, + Type: IssueRelationshipBlocks, + }); err != nil { + t.Fatalf("CreateLink(blocks) error = %v", err) + } + + frontier, err := store.ListIssueFrontier(ctx, root) + if err != nil { + t.Fatalf("ListIssueFrontier() error = %v", err) + } + got := map[string]bool{} + for _, issue := range frontier.Issues { + got[issue.Title] = true + } + if !got["Open"] || !got["Blocker"] { + t.Fatalf("frontier = %#v, want Open and Blocker", frontier.Issues) + } + if got["Blocked"] { + t.Fatalf("frontier included blocked issue: %#v", frontier.Issues) + } + if got["Archived"] { + t.Fatalf("frontier included archived issue: %#v", frontier.Issues) + } + + if _, err := store.UpdateIssue(ctx, root, IssueUpdateOptions{Ref: blocker.ID, Status: IssueStatusDone, SetStatus: true}); err != nil { + t.Fatalf("UpdateIssue(blocker done) error = %v", err) + } + unblocked, err := store.ListIssueFrontier(ctx, root) + if err != nil { + t.Fatalf("ListIssueFrontier(after done) error = %v", err) + } + foundBlocked := false + for _, issue := range unblocked.Issues { + if issue.ID == blocked.ID { + foundBlocked = true + } + if issue.ID == open.ID && issue.Title != "Open" { + t.Fatalf("open row = %#v", issue) + } + } + if !foundBlocked { + t.Fatalf("frontier after blocker done = %#v, want Blocked included", unblocked.Issues) + } +} + +func TestAddRemoveAndPromoteIssueCriterion(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + + issue, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Criteria"}) + if err != nil { + t.Fatalf("CreateIssue() error = %v", err) + } + added, err := store.AddIssueCriterion(ctx, root, issue.Alias, IssueCriterionInput{Text: "Human check"}) + if err != nil { + t.Fatalf("AddIssueCriterion() error = %v", err) + } + if len(added.Criteria) != 1 || added.Criteria[0].Tier != IssueCriterionTierH || added.Criteria[0].Position != 1 { + t.Fatalf("added = %#v, want one H criterion", added.Criteria) + } + verified, err := store.AddIssueCriterion(ctx, root, issue.Alias, IssueCriterionInput{Text: "Smoke", Command: "true", Expect: "exit 0"}) + if err != nil { + t.Fatalf("AddIssueCriterion(V) error = %v", err) + } + if len(verified.Criteria) != 2 || verified.Criteria[1].Tier != IssueCriterionTierV { + t.Fatalf("verified = %#v, want V tier on second", verified.Criteria) + } + + child, err := store.PromoteIssueCriterion(ctx, root, issue.Alias, 1, "") + if err != nil { + t.Fatalf("PromoteIssueCriterion() error = %v", err) + } + if child.Title != "Human check" || child.Kind != IssueKindDelivery || child.ParentID != issue.ID { + t.Fatalf("child = %#v, want delivery child of parent", child) + } + if len(child.Criteria) != 1 || child.Criteria[0].Text != "Human check" || child.Criteria[0].Position != 1 { + t.Fatalf("child criteria = %#v, want copied parent criterion as first criterion", child.Criteria) + } + still, err := store.GetIssue(ctx, root, issue.Alias) + if err != nil { + t.Fatalf("GetIssue(parent) error = %v", err) + } + if len(still.Criteria) != 2 { + t.Fatalf("parent criteria after promote = %#v, want both retained", still.Criteria) + } + + removed, err := store.RemoveIssueCriterion(ctx, root, issue.Alias, 1) + if err != nil { + t.Fatalf("RemoveIssueCriterion() error = %v", err) + } + if len(removed.Criteria) != 1 || removed.Criteria[0].Text != "Smoke" || removed.Criteria[0].Position != 1 { + t.Fatalf("removed = %#v, want compacted Smoke at 1", removed.Criteria) + } +} + +func TestSetIssueBucketIsAdvisoryOnly(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + + issue, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Bucketed"}) + if err != nil { + t.Fatalf("CreateIssue() error = %v", err) + } + set, err := store.SetIssueBucket(ctx, root, issue.Alias, IssueBucketNow) + if err != nil { + t.Fatalf("SetIssueBucket(now) error = %v", err) + } + if set.Bucket != IssueBucketNow { + t.Fatalf("bucket = %q, want now", set.Bucket) + } + replaced, err := store.SetIssueBucket(ctx, root, issue.Alias, IssueBucketLater) + if err != nil { + t.Fatalf("SetIssueBucket(later) error = %v", err) + } + if replaced.Bucket != IssueBucketLater { + t.Fatalf("replaced bucket = %q, want later", replaced.Bucket) + } + cleared, err := store.SetIssueBucket(ctx, root, issue.Alias, IssueBucketNone) + if err != nil { + t.Fatalf("SetIssueBucket(none) error = %v", err) + } + if cleared.Bucket != "" { + t.Fatalf("cleared bucket = %q, want empty", cleared.Bucket) + } + + frontier, err := store.ListIssueFrontier(ctx, root) + if err != nil { + t.Fatalf("ListIssueFrontier() error = %v", err) + } + if len(frontier.Issues) != 1 { + t.Fatalf("frontier after bucket labels = %#v, want the issue still eligible", frontier.Issues) + } +} + +func TestExportIssuesIncludesRowsCriteriaAndRelationships(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + + first, err := store.CreateIssue(ctx, root, IssueCreateOptions{ + Title: "Export me", + Criteria: []IssueCriterionInput{{Text: "Done when exported"}}, + }) + if err != nil { + t.Fatalf("CreateIssue() error = %v", err) + } + child, err := store.PromoteIssueCriterion(ctx, root, first.Alias, 1, "") + if err != nil { + t.Fatalf("PromoteIssueCriterion() error = %v", err) + } + second, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Related"}) + if err != nil { + t.Fatalf("CreateIssue(second) error = %v", err) + } + if _, err := store.CreateLink(ctx, root, LinkMutationOptions{ + From: first.Alias, + To: second.Alias, + Type: IssueRelationshipRelatesTo, + }); err != nil { + t.Fatalf("CreateLink(relates_to) error = %v", err) + } + + snapshot, err := store.ExportIssues(ctx, root) + if err != nil { + t.Fatalf("ExportIssues() error = %v", err) + } + if snapshot.ExportKind != ExportKindIssue || snapshot.Format != ExportFormatJSON { + t.Fatalf("snapshot header = %#v", snapshot) + } + if len(snapshot.Issues) != 3 { + t.Fatalf("issues = %#v, want 3", snapshot.Issues) + } + if len(snapshot.Criteria) != 2 { + t.Fatalf("criteria = %#v, want parent + promoted child", snapshot.Criteria) + } + if len(snapshot.Claims) != 1 || snapshot.Claims[0].ParentCriterionID != first.Criteria[0].ID || snapshot.Claims[0].ChildCriterionID != child.Criteria[0].ID { + t.Fatalf("claims = %#v, want the promote claim", snapshot.Claims) + } + if len(snapshot.Relationships) != 1 || snapshot.Relationships[0].RelationshipType != IssueRelationshipRelatesTo { + t.Fatalf("relationships = %#v, want relates_to", snapshot.Relationships) + } + if snapshot.Identity == nil { + t.Fatal("identity = nil, want stored authority after minting local aliases") + } + if snapshot.Identity.Authority != IssueAuthorityLocal || snapshot.Identity.Prefix != DefaultIssuePrefix || snapshot.Identity.NextNumber != 4 { + t.Fatalf("identity = %#v, want local %s next_number=4", snapshot.Identity, DefaultIssuePrefix) + } +} + +func TestExportIssuesOmitsIdentityWhenNoRow(t *testing.T) { + root, store := issueTestFixture(t) + + snapshot, err := store.ExportIssues(context.Background(), root) + if err != nil { + t.Fatalf("ExportIssues() error = %v", err) + } + if snapshot.Identity != nil { + t.Fatalf("identity = %#v, want omitted when no issue_identity row exists", snapshot.Identity) + } + if len(snapshot.Issues) != 0 || len(snapshot.Criteria) != 0 || len(snapshot.Claims) != 0 || len(snapshot.Relationships) != 0 { + t.Fatalf("empty project export = %#v, want no rows", snapshot) + } +} + +func TestExportAllTablesIncludesIssueFoundation(t *testing.T) { + found := map[string]bool{} + for _, table := range exportAllTables { + found[table.Name] = true + } + for _, name := range []string{"issues", "issue_criteria", "issue_criterion_claims", "issue_identity", "releases", "release_members"} { + if !found[name] { + t.Fatalf("exportAllTables missing %s", name) + } + } +} + +func TestNormalizeIssueLinkType(t *testing.T) { + got, err := NormalizeIssueLinkType("relates-to") + if err != nil || got != IssueRelationshipRelatesTo { + t.Fatalf("relates-to = %q %v, want relates_to", got, err) + } + got, err = NormalizeIssueLinkType("blocks") + if err != nil || got != IssueRelationshipBlocks { + t.Fatalf("blocks = %q %v", got, err) + } + if _, err := NormalizeIssueLinkType("implements"); err == nil { + t.Fatal("implements must be rejected") + } +} + +func TestIssueListRejectsUnknownFilter(t *testing.T) { + root, store := issueTestFixture(t) + if _, err := store.ListIssues(context.Background(), root, IssueListOptions{Status: "blocked"}); err == nil || !strings.Contains(err.Error(), "status") { + t.Fatalf("ListIssues(blocked) error = %v, want status validation", err) + } +} diff --git a/internal/state/issue_readiness.go b/internal/state/issue_readiness.go new file mode 100644 index 000000000..93ce61ba1 --- /dev/null +++ b/internal/state/issue_readiness.go @@ -0,0 +1,201 @@ +package state + +import ( + "context" + "database/sql" + "fmt" + "strings" + + "github.com/levifig/loaf/internal/project" +) + +const ( + IssueReadinessMissingBody = "missing_body" + IssueReadinessMissingCriterion = "missing_criterion" + IssueReadinessMissingOutOfScope = "missing_out_of_scope" + IssueReadinessNoQuestion = "no_question" + IssueReadinessUncovered = "uncovered" +) + +// IssueReadinessFailure is one shaping or coverage failure. +type IssueReadinessFailure struct { + Code string `json:"code"` + Position int `json:"position,omitempty"` + Text string `json:"text,omitempty"` + Message string `json:"message"` +} + +// IssueReadinessOrphan is a child criterion that claims no parent criterion. +// It is reported, not a failure by itself. +type IssueReadinessOrphan struct { + ChildRef string `json:"child_ref"` + Position int `json:"position"` + Text string `json:"text"` + Remedy string `json:"remedy"` +} + +// IssueReadiness is derived readiness for one issue. +type IssueReadiness struct { + Issue Issue `json:"issue"` + Kind string `json:"kind"` + Shaped bool `json:"shaped"` + Covered bool `json:"covered"` + Ready bool `json:"ready"` + Failures []IssueReadinessFailure `json:"failures"` + Orphans []IssueReadinessOrphan `json:"orphans"` + Children []IssueSummary `json:"children,omitempty"` +} + +// CheckIssueReadiness derives readiness from the issue row, not from markdown +// section presence. +func CheckIssueReadiness(ctx context.Context, root project.Root, resolver PathResolver, ref string) (IssueReadiness, error) { + store, err := openProjectStoreReadExisting(ctx, root, resolver) + if err != nil { + return IssueReadiness{}, err + } + defer store.Close() + return store.CheckIssueReadiness(ctx, root, ref) +} + +// CheckIssueReadiness derives readiness on an open store. +func (s *Store) CheckIssueReadiness(ctx context.Context, root project.Root, ref string) (IssueReadiness, error) { + projectID, err := s.projectID(ctx, root) + if err != nil { + return IssueReadiness{}, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return IssueReadiness{}, fmt.Errorf("begin issue check: %w", err) + } + defer tx.Rollback() + + issueID, _, err := resolveIssueRefTx(ctx, tx, projectID, ref) + if err != nil { + return IssueReadiness{}, err + } + issue, err := loadIssueTx(ctx, tx, projectID, issueID) + if err != nil { + return IssueReadiness{}, err + } + children, err := listIssueChildrenTx(ctx, tx, projectID, issueID) + if err != nil { + return IssueReadiness{}, err + } + return evaluateIssueReadiness(ctx, tx, projectID, issue, children) +} + +func evaluateIssueReadiness(ctx context.Context, tx *sql.Tx, projectID string, issue Issue, children []IssueSummary) (IssueReadiness, error) { + result := IssueReadiness{ + Issue: issue, + Kind: issue.Kind, + Covered: true, + Failures: []IssueReadinessFailure{}, + Orphans: []IssueReadinessOrphan{}, + Children: children, + } + + switch issue.Kind { + case IssueKindDecision: + if !hasSharpQuestion(issue.Title, issue.Body) { + result.Failures = append(result.Failures, IssueReadinessFailure{ + Code: IssueReadinessNoQuestion, + Message: "decision issue needs a sharp question (a '?' in the title or body)", + }) + } + default: + if strings.TrimSpace(issue.Body) == "" { + result.Failures = append(result.Failures, IssueReadinessFailure{ + Code: IssueReadinessMissingBody, + Message: "delivery issue needs a nonempty body (the problem)", + }) + } + if len(issue.Criteria) == 0 { + result.Failures = append(result.Failures, IssueReadinessFailure{ + Code: IssueReadinessMissingCriterion, + Message: "delivery issue needs at least one definition-of-done criterion", + }) + } + if !hasOutOfScopeStatement(issue.Body) { + result.Failures = append(result.Failures, IssueReadinessFailure{ + Code: IssueReadinessMissingOutOfScope, + Message: "delivery issue body needs an explicit out-of-scope statement", + }) + } + } + result.Shaped = len(result.Failures) == 0 + + if len(children) > 0 { + childIDs := make([]string, 0, len(children)) + for _, child := range children { + childIDs = append(childIDs, child.ID) + } + claims, err := listIssueCriterionClaimsForChildrenTx(ctx, tx, projectID, childIDs) + if err != nil { + return IssueReadiness{}, err + } + claimedParent := map[string]bool{} + claimedChild := map[string]bool{} + for _, claim := range claims { + claimedParent[claim.ParentCriterionID] = true + claimedChild[claim.ChildCriterionID] = true + } + for _, criterion := range issue.Criteria { + if claimedParent[criterion.ID] { + continue + } + result.Covered = false + result.Failures = append(result.Failures, IssueReadinessFailure{ + Code: IssueReadinessUncovered, + Position: criterion.Position, + Text: criterion.Text, + Message: fmt.Sprintf("uncovered criterion %d: %s", criterion.Position, criterion.Text), + }) + } + parentRef := firstNonEmpty(issue.Alias, issue.ID) + for _, child := range children { + childIssue, err := loadIssueTx(ctx, tx, projectID, child.ID) + if err != nil { + return IssueReadiness{}, err + } + childRef := firstNonEmpty(childIssue.Alias, childIssue.ID) + for _, criterion := range childIssue.Criteria { + if claimedChild[criterion.ID] { + continue + } + result.Orphans = append(result.Orphans, IssueReadinessOrphan{ + ChildRef: childRef, + Position: criterion.Position, + Text: criterion.Text, + Remedy: orphanCriterionRemedy(criterion.Text, parentRef), + }) + } + } + } + + result.Ready = result.Shaped && result.Covered + return result, nil +} + +func hasSharpQuestion(title, body string) bool { + return strings.Contains(title, "?") || strings.Contains(body, "?") +} + +func hasOutOfScopeStatement(body string) bool { + return strings.Contains(strings.ToLower(body), "out of scope") +} + +func orphanCriterionRemedy(text, parentRef string) string { + // Options first, then `--`, then the title so a hyphen-leading criterion + // (e.g. "--help") is positional, not an unknown flag. + return "loaf issue new --parent " + posixSingleQuote(parentRef) + " --status backlog -- " + posixSingleQuote(text) +} + +// PosixSingleQuote wraps s in POSIX single quotes. Embedded single quotes +// become '\” so the result is a single shell word with no expansion. +func PosixSingleQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +func posixSingleQuote(s string) string { + return PosixSingleQuote(s) +} diff --git a/internal/state/issue_readiness_test.go b/internal/state/issue_readiness_test.go new file mode 100644 index 000000000..ece7cfdd2 --- /dev/null +++ b/internal/state/issue_readiness_test.go @@ -0,0 +1,688 @@ +package state + +import ( + "context" + "strings" + "testing" +) + +const shapedDeliveryBody = "The problem is that readiness is derived from nine required headings.\n\nOut of scope: grading criterion quality.\n" + +func TestPromoteRecordsClaimAndCoveragePasses(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + + parent, err := store.CreateIssue(ctx, root, IssueCreateOptions{ + Title: "Parent work", + Body: shapedDeliveryBody, + Criteria: []IssueCriterionInput{ + {Text: "First slice"}, + {Text: "Second slice"}, + }, + }) + if err != nil { + t.Fatalf("CreateIssue() error = %v", err) + } + if _, err := store.PromoteIssueCriterion(ctx, root, parent.Alias, 1, ""); err != nil { + t.Fatalf("Promote(1) error = %v", err) + } + if _, err := store.PromoteIssueCriterion(ctx, root, parent.Alias, 2, ""); err != nil { + t.Fatalf("Promote(2) error = %v", err) + } + + readiness, err := store.CheckIssueReadiness(ctx, root, parent.Alias) + if err != nil { + t.Fatalf("CheckIssueReadiness() error = %v", err) + } + if !readiness.Shaped || !readiness.Covered || !readiness.Ready { + t.Fatalf("readiness = %#v, want shaped+covered+ready after full promote", readiness) + } + if len(readiness.Failures) != 0 { + t.Fatalf("failures = %#v, want none", readiness.Failures) + } + if len(readiness.Orphans) != 0 { + t.Fatalf("orphans = %#v, want none", readiness.Orphans) + } + + var claimCount int + if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM issue_criterion_claims`).Scan(&claimCount); err != nil { + t.Fatalf("count claims: %v", err) + } + if claimCount != 2 { + t.Fatalf("claims = %d, want 2", claimCount) + } +} + +func TestUncoveredParentCriterionFailsReadiness(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + + parent, err := store.CreateIssue(ctx, root, IssueCreateOptions{ + Title: "Parent work", + Body: shapedDeliveryBody, + Criteria: []IssueCriterionInput{ + {Text: "Covered slice"}, + {Text: "Left behind"}, + }, + }) + if err != nil { + t.Fatalf("CreateIssue() error = %v", err) + } + if _, err := store.PromoteIssueCriterion(ctx, root, parent.Alias, 1, ""); err != nil { + t.Fatalf("Promote(1) error = %v", err) + } + + readiness, err := store.CheckIssueReadiness(ctx, root, parent.Alias) + if err != nil { + t.Fatalf("CheckIssueReadiness() error = %v", err) + } + if readiness.Ready || readiness.Covered { + t.Fatalf("readiness = %#v, want uncovered failure", readiness) + } + found := false + for _, failure := range readiness.Failures { + if failure.Code == IssueReadinessUncovered && failure.Position == 2 && strings.Contains(failure.Message, "Left behind") { + found = true + } + } + if !found { + t.Fatalf("failures = %#v, want uncovered criterion 2 named", readiness.Failures) + } +} + +func TestOrphanChildCriterionIsReportedWithRemedy(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + + parent, err := store.CreateIssue(ctx, root, IssueCreateOptions{ + Title: "Parent work", + Body: shapedDeliveryBody, + Criteria: []IssueCriterionInput{ + {Text: "Promoted slice"}, + }, + }) + if err != nil { + t.Fatalf("CreateIssue() error = %v", err) + } + child, err := store.PromoteIssueCriterion(ctx, root, parent.Alias, 1, "") + if err != nil { + t.Fatalf("Promote() error = %v", err) + } + if _, err := store.AddIssueCriterion(ctx, root, child.Alias, IssueCriterionInput{Text: "Stray extra work"}); err != nil { + t.Fatalf("AddIssueCriterion(orphan) error = %v", err) + } + + readiness, err := store.CheckIssueReadiness(ctx, root, parent.Alias) + if err != nil { + t.Fatalf("CheckIssueReadiness() error = %v", err) + } + if !readiness.Ready { + t.Fatalf("readiness = %#v, orphan must not fail the check", readiness) + } + if len(readiness.Orphans) != 1 { + t.Fatalf("orphans = %#v, want one", readiness.Orphans) + } + orphan := readiness.Orphans[0] + if orphan.ChildRef != child.Alias || orphan.Position != 2 || orphan.Text != "Stray extra work" { + t.Fatalf("orphan = %#v", orphan) + } + wantRemedy := "loaf issue new --parent " + posixSingleQuote(parent.Alias) + " --status backlog -- " + posixSingleQuote("Stray extra work") + if orphan.Remedy != wantRemedy { + t.Fatalf("remedy = %q, want %q", orphan.Remedy, wantRemedy) + } +} + +func TestOrphanRemedyIsPOSIXSingleQuoted(t *testing.T) { + text := "don't $(touch /tmp/pwned) `reboot`" + parentRef := "LOAF-1" + got := orphanCriterionRemedy(text, parentRef) + want := "loaf issue new --parent 'LOAF-1' --status backlog -- 'don'\\''t $(touch /tmp/pwned) `reboot`'" + if got != want { + t.Fatalf("remedy = %q, want %q", got, want) + } + if strings.Contains(got, `"`) || strings.Contains(got, "&&") || strings.Contains(got, "<") { + t.Fatalf("remedy still uses unsafe quoting or chaining: %q", got) + } + titleStart := strings.LastIndex(got, " -- ") + if titleStart < 0 { + t.Fatalf("remedy missing end-of-options terminator: %q", got) + } + if !posixSingleQuotedWord(got[titleStart+len(" -- "):]) { + t.Fatalf("title is not a POSIX single-quoted word: %q", got) + } +} + +func TestOrphanRemedyPlacesHyphenLeadingTitleAfterEndOfOptions(t *testing.T) { + got := orphanCriterionRemedy("--help", "LOAF-1") + want := "loaf issue new --parent 'LOAF-1' --status backlog -- '--help'" + if got != want { + t.Fatalf("remedy = %q, want %q", got, want) + } +} + +func TestDecisionIssueReadyOnSharpQuestion(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + + blank, err := store.CreateIssue(ctx, root, IssueCreateOptions{ + Title: "Pick a store", + Kind: IssueKindDecision, + Body: "Need a direction.", + }) + if err != nil { + t.Fatalf("CreateIssue(blank) error = %v", err) + } + notReady, err := store.CheckIssueReadiness(ctx, root, blank.Alias) + if err != nil { + t.Fatalf("CheckIssueReadiness(blank) error = %v", err) + } + if notReady.Ready || notReady.Shaped { + t.Fatalf("blank decision ready = %#v, want not ready", notReady) + } + if len(notReady.Failures) != 1 || notReady.Failures[0].Code != IssueReadinessNoQuestion { + t.Fatalf("blank failures = %#v, want no_question only", notReady.Failures) + } + + question, err := store.CreateIssue(ctx, root, IssueCreateOptions{ + Title: "Should we keep the local store?", + Kind: IssueKindDecision, + }) + if err != nil { + t.Fatalf("CreateIssue(question) error = %v", err) + } + ready, err := store.CheckIssueReadiness(ctx, root, question.Alias) + if err != nil { + t.Fatalf("CheckIssueReadiness(question) error = %v", err) + } + if !ready.Ready || !ready.Shaped { + t.Fatalf("question decision = %#v, want ready without criteria or a plan", ready) + } +} + +func TestDodAddServesRecordsClaim(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + + parent, err := store.CreateIssue(ctx, root, IssueCreateOptions{ + Title: "Parent", + Body: shapedDeliveryBody, + Criteria: []IssueCriterionInput{{Text: "Parent criterion"}}, + }) + if err != nil { + t.Fatalf("CreateIssue(parent) error = %v", err) + } + child, err := store.CreateIssue(ctx, root, IssueCreateOptions{ + Title: "Child", + Parent: parent.Alias, + }) + if err != nil { + t.Fatalf("CreateIssue(child) error = %v", err) + } + added, err := store.AddIssueCriterion(ctx, root, child.Alias, IssueCriterionInput{ + Text: "Serves parent", + ServesParentPosition: 1, + }) + if err != nil { + t.Fatalf("AddIssueCriterion(--serves) error = %v", err) + } + if len(added.Criteria) != 1 { + t.Fatalf("added = %#v", added.Criteria) + } + + readiness, err := store.CheckIssueReadiness(ctx, root, parent.Alias) + if err != nil { + t.Fatalf("CheckIssueReadiness() error = %v", err) + } + if !readiness.Covered || !readiness.Ready { + t.Fatalf("readiness = %#v, want covered after --serves", readiness) + } +} + +func TestClaimAndUnclaimToggleCoverage(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + + parent, err := store.CreateIssue(ctx, root, IssueCreateOptions{ + Title: "Parent", + Body: shapedDeliveryBody, + Criteria: []IssueCriterionInput{{Text: "Parent criterion"}}, + }) + if err != nil { + t.Fatalf("CreateIssue(parent) error = %v", err) + } + child, err := store.CreateIssue(ctx, root, IssueCreateOptions{ + Title: "Child", + Parent: parent.Alias, + Criteria: []IssueCriterionInput{{Text: "Already written"}}, + }) + if err != nil { + t.Fatalf("CreateIssue(child) error = %v", err) + } + + before, err := store.CheckIssueReadiness(ctx, root, parent.Alias) + if err != nil { + t.Fatalf("CheckIssueReadiness(before) error = %v", err) + } + if before.Covered { + t.Fatal("coverage passed before claim, want uncovered") + } + + if _, err := store.ClaimIssueCriterion(ctx, root, child.Alias, 1, 1); err != nil { + t.Fatalf("ClaimIssueCriterion() error = %v", err) + } + claimed, err := store.CheckIssueReadiness(ctx, root, parent.Alias) + if err != nil { + t.Fatalf("CheckIssueReadiness(claimed) error = %v", err) + } + if !claimed.Covered { + t.Fatalf("after claim = %#v, want covered", claimed) + } + + if _, err := store.UnclaimIssueCriterion(ctx, root, child.Alias, 1, 1); err != nil { + t.Fatalf("UnclaimIssueCriterion() error = %v", err) + } + after, err := store.CheckIssueReadiness(ctx, root, parent.Alias) + if err != nil { + t.Fatalf("CheckIssueReadiness(after) error = %v", err) + } + if after.Covered { + t.Fatal("coverage still passed after unclaim") + } +} + +func posixSingleQuotedWord(word string) bool { + if len(word) < 2 || word[0] != '\'' || word[len(word)-1] != '\'' { + return false + } + inner := word[1 : len(word)-1] + inner = strings.ReplaceAll(inner, `'\''`, "") + return !strings.Contains(inner, "'") +} + +func TestReplaceIssueCriteriaPreservesClaimIDsAndDropsTail(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + + parent, err := store.CreateIssue(ctx, root, IssueCreateOptions{ + Title: "Parent", + Body: shapedDeliveryBody, + Criteria: []IssueCriterionInput{ + {Text: "First slice"}, + {Text: "Second slice"}, + {Text: "Third slice"}, + }, + }) + if err != nil { + t.Fatalf("CreateIssue() error = %v", err) + } + ids := []string{parent.Criteria[0].ID, parent.Criteria[1].ID, parent.Criteria[2].ID} + for i := 1; i <= 3; i++ { + if _, err := store.PromoteIssueCriterion(ctx, root, parent.Alias, i, ""); err != nil { + t.Fatalf("Promote(%d) error = %v", i, err) + } + } + + replaced, err := store.ReplaceIssueCriteria(ctx, root, parent.Alias, []IssueCriterionInput{ + {Text: "First edited"}, + {Text: "Second edited"}, + {Text: "Third edited"}, + }) + if err != nil { + t.Fatalf("ReplaceIssueCriteria(edit) error = %v", err) + } + if len(replaced.Criteria) != 3 { + t.Fatalf("replaced = %#v, want 3", replaced.Criteria) + } + for i, wantID := range ids { + if replaced.Criteria[i].ID != wantID || replaced.Criteria[i].Text != []string{"First edited", "Second edited", "Third edited"}[i] { + t.Fatalf("replaced[%d] = %#v, want id %s with edited text", i, replaced.Criteria[i], wantID) + } + } + afterEdit, err := store.CheckIssueReadiness(ctx, root, parent.Alias) + if err != nil { + t.Fatalf("CheckIssueReadiness(edit) error = %v", err) + } + if !afterEdit.Covered { + t.Fatalf("coverage lost after in-place edit: %#v", afterEdit) + } + + shrunk, err := store.ReplaceIssueCriteria(ctx, root, parent.Alias, []IssueCriterionInput{ + {Text: "First kept"}, + {Text: "Second kept"}, + }) + if err != nil { + t.Fatalf("ReplaceIssueCriteria(shrink) error = %v", err) + } + if len(shrunk.Criteria) != 2 || shrunk.Criteria[0].ID != ids[0] || shrunk.Criteria[1].ID != ids[1] { + t.Fatalf("shrunk = %#v, want first two ids retained", shrunk.Criteria) + } + + var remainingClaims int + if err := store.db.QueryRowContext(ctx, ` +SELECT COUNT(*) FROM issue_criterion_claims WHERE parent_criterion_id IN (?, ?, ?) +`, ids[0], ids[1], ids[2]).Scan(&remainingClaims); err != nil { + t.Fatalf("count remaining claims: %v", err) + } + if remainingClaims != 2 { + t.Fatalf("claims after shrink = %d, want 2 (tail cascaded)", remainingClaims) + } + var tailRows int + if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM issue_criteria WHERE id = ?`, ids[2]).Scan(&tailRows); err != nil { + t.Fatalf("count tail criterion: %v", err) + } + if tailRows != 0 { + t.Fatalf("tail criterion %s still present", ids[2]) + } + + afterShrink, err := store.CheckIssueReadiness(ctx, root, parent.Alias) + if err != nil { + t.Fatalf("CheckIssueReadiness(shrink) error = %v", err) + } + if !afterShrink.Covered { + t.Fatalf("coverage of remaining criteria lost after shrink: %#v", afterShrink) + } +} + +func TestReplaceIssueCriteriaOnChildPreservesClaims(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + + parent, err := store.CreateIssue(ctx, root, IssueCreateOptions{ + Title: "Parent", + Body: shapedDeliveryBody, + Criteria: []IssueCriterionInput{{Text: "Parent slice"}}, + }) + if err != nil { + t.Fatalf("CreateIssue(parent) error = %v", err) + } + child, err := store.PromoteIssueCriterion(ctx, root, parent.Alias, 1, "") + if err != nil { + t.Fatalf("Promote() error = %v", err) + } + childID := child.Criteria[0].ID + + replaced, err := store.ReplaceIssueCriteria(ctx, root, child.Alias, []IssueCriterionInput{ + {Text: "Child text edited"}, + }) + if err != nil { + t.Fatalf("ReplaceIssueCriteria(child) error = %v", err) + } + if len(replaced.Criteria) != 1 || replaced.Criteria[0].ID != childID { + t.Fatalf("child after replace = %#v, want id %s", replaced.Criteria, childID) + } + + readiness, err := store.CheckIssueReadiness(ctx, root, parent.Alias) + if err != nil { + t.Fatalf("CheckIssueReadiness() error = %v", err) + } + if !readiness.Covered || len(readiness.Orphans) != 0 { + t.Fatalf("child replace invented an orphan or uncovered parent: %#v", readiness) + } +} + +func TestReplaceIssueCriteriaCompactsGappedPositionsOnExpand(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + + issue, err := store.CreateIssue(ctx, root, IssueCreateOptions{ + Title: "Gapped expand", + Criteria: []IssueCriterionInput{ + {Text: "Keep first"}, + {Text: "Keep third-as-second"}, + }, + }) + if err != nil { + t.Fatalf("CreateIssue() error = %v", err) + } + ids := []string{issue.Criteria[0].ID, issue.Criteria[1].ID} + setIssueCriterionPositionsByOrder(t, store, issue.ID, []int{1, 3}) + + replaced, err := store.ReplaceIssueCriteria(ctx, root, issue.Alias, []IssueCriterionInput{ + {Text: "One"}, + {Text: "Two"}, + {Text: "Three"}, + }) + if err != nil { + t.Fatalf("ReplaceIssueCriteria({1,3} -> 3) error = %v", err) + } + if len(replaced.Criteria) != 3 { + t.Fatalf("replaced = %#v, want 3 compact rows", replaced.Criteria) + } + for i, wantText := range []string{"One", "Two", "Three"} { + if replaced.Criteria[i].Position != i+1 || replaced.Criteria[i].Text != wantText { + t.Fatalf("replaced[%d] = %#v, want position %d text %q", i, replaced.Criteria[i], i+1, wantText) + } + } + if replaced.Criteria[0].ID != ids[0] || replaced.Criteria[1].ID != ids[1] { + t.Fatalf("replaced ids = %s,%s, want %s,%s retained", replaced.Criteria[0].ID, replaced.Criteria[1].ID, ids[0], ids[1]) + } + if replaced.Criteria[2].ID == ids[0] || replaced.Criteria[2].ID == ids[1] { + t.Fatalf("inserted tail reused a survivor id: %#v", replaced.Criteria) + } +} + +func TestReplaceIssueCriteriaCompactsGappedPositionsOnShrink(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + + issue, err := store.CreateIssue(ctx, root, IssueCreateOptions{ + Title: "Gapped shrink", + Criteria: []IssueCriterionInput{ + {Text: "Survivor"}, + {Text: "Drop me"}, + }, + }) + if err != nil { + t.Fatalf("CreateIssue() error = %v", err) + } + ids := []string{issue.Criteria[0].ID, issue.Criteria[1].ID} + setIssueCriterionPositionsByOrder(t, store, issue.ID, []int{2, 3}) + + replaced, err := store.ReplaceIssueCriteria(ctx, root, issue.Alias, []IssueCriterionInput{ + {Text: "Only"}, + }) + if err != nil { + t.Fatalf("ReplaceIssueCriteria({2,3} -> 1) error = %v", err) + } + if len(replaced.Criteria) != 1 || replaced.Criteria[0].ID != ids[0] || replaced.Criteria[0].Position != 1 || replaced.Criteria[0].Text != "Only" { + t.Fatalf("replaced = %#v, want survivor %s at position 1", replaced.Criteria, ids[0]) + } + var leftover int + if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM issue_criteria WHERE id = ?`, ids[1]).Scan(&leftover); err != nil { + t.Fatalf("count leftover: %v", err) + } + if leftover != 0 { + t.Fatalf("leftover criterion %s still present", ids[1]) + } +} + +func TestCreateIssueServesParentPositionRecordsClaim(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + + parent, err := store.CreateIssue(ctx, root, IssueCreateOptions{ + Title: "Parent", + Body: shapedDeliveryBody, + Criteria: []IssueCriterionInput{{Text: "Parent criterion"}}, + }) + if err != nil { + t.Fatalf("CreateIssue(parent) error = %v", err) + } + child, err := store.CreateIssue(ctx, root, IssueCreateOptions{ + Title: "Child", + Parent: parent.Alias, + Criteria: []IssueCriterionInput{{ + Text: "Serves parent on create", + ServesParentPosition: 1, + }}, + }) + if err != nil { + t.Fatalf("CreateIssue(child --serves) error = %v", err) + } + if len(child.Criteria) != 1 { + t.Fatalf("child criteria = %#v", child.Criteria) + } + + readiness, err := store.CheckIssueReadiness(ctx, root, parent.Alias) + if err != nil { + t.Fatalf("CheckIssueReadiness() error = %v", err) + } + if !readiness.Covered || !readiness.Ready || len(readiness.Orphans) != 0 { + t.Fatalf("readiness = %#v, want covered after CreateIssue ServesParentPosition", readiness) + } + + _, err = store.CreateIssue(ctx, root, IssueCreateOptions{ + Title: "Orphan root", + Criteria: []IssueCriterionInput{{ + Text: "No parent to serve", + ServesParentPosition: 1, + }}, + }) + if err == nil || !strings.Contains(err.Error(), "has no parent") { + t.Fatalf("CreateIssue(no parent --serves) error = %v, want no parent", err) + } + + _, err = store.CreateIssue(ctx, root, IssueCreateOptions{ + Title: "Missing parent position", + Parent: parent.Alias, + Criteria: []IssueCriterionInput{{ + Text: "Serves missing", + ServesParentPosition: 2, + }}, + }) + if err == nil || !strings.Contains(err.Error(), "criterion position 2 not found") { + t.Fatalf("CreateIssue(missing parent pos) error = %v, want position 2 missing", err) + } +} + +func TestReplaceIssueCriteriaServesParentPositionRecordsClaim(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + + parent, err := store.CreateIssue(ctx, root, IssueCreateOptions{ + Title: "Parent", + Body: shapedDeliveryBody, + Criteria: []IssueCriterionInput{{Text: "Parent criterion"}, {Text: "Second parent"}}, + }) + if err != nil { + t.Fatalf("CreateIssue(parent) error = %v", err) + } + child, err := store.CreateIssue(ctx, root, IssueCreateOptions{ + Title: "Child", + Parent: parent.Alias, + Criteria: []IssueCriterionInput{{Text: "Already written"}}, + }) + if err != nil { + t.Fatalf("CreateIssue(child) error = %v", err) + } + keptID := child.Criteria[0].ID + + replaced, err := store.ReplaceIssueCriteria(ctx, root, child.Alias, []IssueCriterionInput{ + {Text: "Updated and serves", ServesParentPosition: 1}, + {Text: "Inserted and serves", ServesParentPosition: 2}, + }) + if err != nil { + t.Fatalf("ReplaceIssueCriteria(--serves) error = %v", err) + } + if len(replaced.Criteria) != 2 || replaced.Criteria[0].ID != keptID { + t.Fatalf("replaced = %#v, want updated first id %s", replaced.Criteria, keptID) + } + + readiness, err := store.CheckIssueReadiness(ctx, root, parent.Alias) + if err != nil { + t.Fatalf("CheckIssueReadiness() error = %v", err) + } + if !readiness.Covered || !readiness.Ready || len(readiness.Orphans) != 0 { + t.Fatalf("readiness = %#v, want covered after ReplaceIssueCriteria ServesParentPosition", readiness) + } + + _, err = store.ReplaceIssueCriteria(ctx, root, parent.Alias, []IssueCriterionInput{ + {Text: "Root cannot serve", ServesParentPosition: 1}, + }) + if err == nil || !strings.Contains(err.Error(), "has no parent") { + t.Fatalf("ReplaceIssueCriteria(no parent --serves) error = %v, want no parent", err) + } +} + +func setIssueCriterionPositionsByOrder(t *testing.T, store *Store, issueID string, positions []int) { + t.Helper() + ctx := context.Background() + rows, err := store.db.QueryContext(ctx, ` +SELECT id FROM issue_criteria WHERE issue_id = ? ORDER BY position, id +`, issueID) + if err != nil { + t.Fatalf("list criteria: %v", err) + } + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + t.Fatalf("scan criterion id: %v", err) + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + t.Fatalf("iterate criteria: %v", err) + } + rows.Close() + if len(ids) != len(positions) { + t.Fatalf("criteria = %d, want %d positions", len(ids), len(positions)) + } + // Park at high positions so UNIQUE(issue_id, position) cannot collide + // while writing a legal gapped layout. + for i, id := range ids { + if _, err := store.db.ExecContext(ctx, `UPDATE issue_criteria SET position = ? WHERE id = ?`, 1000+i, id); err != nil { + t.Fatalf("park position: %v", err) + } + } + for i, id := range ids { + if _, err := store.db.ExecContext(ctx, `UPDATE issue_criteria SET position = ? WHERE id = ?`, positions[i], id); err != nil { + t.Fatalf("set position %d: %v", positions[i], err) + } + } +} + +func TestRemoveCriterionPreservesRemainingClaimIDs(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + + parent, err := store.CreateIssue(ctx, root, IssueCreateOptions{ + Title: "Parent", + Body: shapedDeliveryBody, + Criteria: []IssueCriterionInput{ + {Text: "Drop me"}, + {Text: "Keep me"}, + }, + }) + if err != nil { + t.Fatalf("CreateIssue() error = %v", err) + } + if _, err := store.PromoteIssueCriterion(ctx, root, parent.Alias, 2, ""); err != nil { + t.Fatalf("Promote(2) error = %v", err) + } + keptID := "" + for _, criterion := range parent.Criteria { + if criterion.Position == 2 { + keptID = criterion.ID + } + } + if keptID == "" { + t.Fatal("missing parent criterion 2 id") + } + + removed, err := store.RemoveIssueCriterion(ctx, root, parent.Alias, 1) + if err != nil { + t.Fatalf("RemoveIssueCriterion() error = %v", err) + } + if len(removed.Criteria) != 1 || removed.Criteria[0].ID != keptID || removed.Criteria[0].Position != 1 { + t.Fatalf("after remove = %#v, want kept id at position 1", removed.Criteria) + } + + readiness, err := store.CheckIssueReadiness(ctx, root, parent.Alias) + if err != nil { + t.Fatalf("CheckIssueReadiness() error = %v", err) + } + if !readiness.Covered { + t.Fatalf("readiness = %#v, want claim to survive compact-renumber", readiness) + } +} diff --git a/internal/state/issue_started_test.go b/internal/state/issue_started_test.go new file mode 100644 index 000000000..2955e43cc --- /dev/null +++ b/internal/state/issue_started_test.go @@ -0,0 +1,297 @@ +package state + +import ( + "context" + "strings" + "testing" +) + +func TestIssueStartedColumnsExistAfterMigration(t *testing.T) { + _, store := issueTestFixture(t) + rows, err := store.db.Query(`PRAGMA table_info(issues)`) + if err != nil { + t.Fatalf("PRAGMA table_info(issues) error = %v", err) + } + defer rows.Close() + found := map[string]bool{} + for rows.Next() { + var cid int + var name, ctype string + var notnull, pk int + var dflt any + if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil { + t.Fatalf("scan table_info: %v", err) + } + found[name] = true + } + if err := rows.Err(); err != nil { + t.Fatalf("iterate table_info: %v", err) + } + if !found["started_branch"] || !found["started_worktree"] { + t.Fatalf("issues columns = %v, want started_branch and started_worktree", found) + } +} + +func TestUpdateIssueRecordsStartedWorkspaceThroughEvents(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + + issue, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Start me"}) + if err != nil { + t.Fatalf("CreateIssue() error = %v", err) + } + if issue.StartedBranch != "" || issue.StartedWorktree != "" { + t.Fatalf("create started fields = %#v, want empty", issue) + } + + updated, err := store.UpdateIssue(ctx, root, IssueUpdateOptions{ + Ref: issue.ID, + Status: IssueStatusActive, + SetStatus: true, + StartedBranch: "issue/loaf-1", + StartedWorktree: "/tmp/repo-wt/issue-loaf-1", + SetStarted: true, + }) + if err != nil { + t.Fatalf("UpdateIssue(start) error = %v", err) + } + if updated.Status != IssueStatusActive { + t.Fatalf("status = %q, want active", updated.Status) + } + if updated.StartedBranch != "issue/loaf-1" || updated.StartedWorktree != "/tmp/repo-wt/issue-loaf-1" { + t.Fatalf("started = %q / %q", updated.StartedBranch, updated.StartedWorktree) + } + + parity, err := store.CheckIssueStatusParity(ctx, root) + if err != nil { + t.Fatalf("CheckIssueStatusParity() error = %v", err) + } + if !parity.Consistent { + t.Fatalf("parity = %#v, want consistent after start", parity) + } + + if _, err := store.UpdateIssue(ctx, root, IssueUpdateOptions{ + Ref: issue.ID, + StartedBranch: "issue/other", + StartedWorktree: "/tmp/other", + SetStarted: true, + }); err == nil || !strings.Contains(err.Error(), "already started") { + t.Fatalf("second start error = %v, want already started", err) + } + + cleared, err := store.UpdateIssue(ctx, root, IssueUpdateOptions{ + Ref: issue.ID, + SetStarted: true, + }) + if err != nil { + t.Fatalf("UpdateIssue(clear started) error = %v", err) + } + if cleared.StartedBranch != "" || cleared.StartedWorktree != "" { + t.Fatalf("cleared started = %#v, want empty", cleared) + } + if cleared.Status != IssueStatusActive { + t.Fatalf("status after clear = %q, want active (stop does not change status)", cleared.Status) + } +} + +func TestUpdateIssueRefusesStartedOnTerminalAndArchived(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + + done, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Done"}) + if err != nil { + t.Fatalf("CreateIssue(done) error = %v", err) + } + if _, err := store.UpdateIssue(ctx, root, IssueUpdateOptions{Ref: done.ID, Status: IssueStatusDone, SetStatus: true}); err != nil { + t.Fatalf("UpdateIssue(done) error = %v", err) + } + if _, err := store.UpdateIssue(ctx, root, IssueUpdateOptions{ + Ref: done.ID, + StartedBranch: "issue/done", + StartedWorktree: "/tmp/done", + SetStarted: true, + }); err == nil || !strings.Contains(err.Error(), "done") { + t.Fatalf("start done error = %v, want refusal", err) + } + + cancelled, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Cancelled"}) + if err != nil { + t.Fatalf("CreateIssue(cancelled) error = %v", err) + } + if _, err := store.RemoveIssue(ctx, root, IssueRemoveOptions{Ref: cancelled.ID, Status: IssueStatusCancelled}); err != nil { + t.Fatalf("RemoveIssue() error = %v", err) + } + if _, err := store.UpdateIssue(ctx, root, IssueUpdateOptions{ + Ref: cancelled.ID, + StartedBranch: "issue/cancelled", + StartedWorktree: "/tmp/cancelled", + SetStarted: true, + }); err == nil || !strings.Contains(err.Error(), "archived") && !strings.Contains(err.Error(), "cancelled") { + t.Fatalf("start archived error = %v, want refusal", err) + } +} + +func TestUpdateIssueRejectsPartialStartedPair(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + issue, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Partial"}) + if err != nil { + t.Fatalf("CreateIssue() error = %v", err) + } + if _, err := store.UpdateIssue(ctx, root, IssueUpdateOptions{ + Ref: issue.ID, + StartedBranch: "issue/only-branch", + SetStarted: true, + }); err == nil || !strings.Contains(err.Error(), "together") { + t.Fatalf("partial started error = %v, want together", err) + } +} + +func TestListIssuesStartedFilterAndExportIncludesFields(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + + started, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Live workspace"}) + if err != nil { + t.Fatalf("CreateIssue(started) error = %v", err) + } + idle, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Idle"}) + if err != nil { + t.Fatalf("CreateIssue(idle) error = %v", err) + } + if _, err := store.UpdateIssue(ctx, root, IssueUpdateOptions{ + Ref: started.ID, + Status: IssueStatusActive, + SetStatus: true, + StartedBranch: "issue/loaf-1", + StartedWorktree: "/tmp/repo-wt/issue-loaf-1", + SetStarted: true, + }); err != nil { + t.Fatalf("UpdateIssue(start) error = %v", err) + } + + listed, err := store.ListIssues(ctx, root, IssueListOptions{Started: true}) + if err != nil { + t.Fatalf("ListIssues(started) error = %v", err) + } + if len(listed.Issues) != 1 || listed.Issues[0].ID != started.ID { + t.Fatalf("started list = %#v, want only the started issue", listed.Issues) + } + if listed.Issues[0].StartedBranch != "issue/loaf-1" || listed.Issues[0].StartedWorktree != "/tmp/repo-wt/issue-loaf-1" { + t.Fatalf("listed started fields = %#v", listed.Issues[0]) + } + + all, err := store.ListIssues(ctx, root, IssueListOptions{}) + if err != nil { + t.Fatalf("ListIssues() error = %v", err) + } + if len(all.Issues) != 2 { + t.Fatalf("all issues = %#v, want both", all.Issues) + } + _ = idle + + snapshot, err := store.ExportIssues(ctx, root) + if err != nil { + t.Fatalf("ExportIssues() error = %v", err) + } + var exported *Issue + for i := range snapshot.Issues { + if snapshot.Issues[i].ID == started.ID { + exported = &snapshot.Issues[i] + break + } + } + if exported == nil { + t.Fatal("export missing started issue") + } + if exported.StartedBranch != "issue/loaf-1" || exported.StartedWorktree != "/tmp/repo-wt/issue-loaf-1" { + t.Fatalf("exported started fields = %#v", exported) + } +} + +func TestNearestStartedAncestorWalksParentChain(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + + grand, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Grandparent"}) + if err != nil { + t.Fatalf("CreateIssue(grand) error = %v", err) + } + parent, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Parent", Parent: grand.ID}) + if err != nil { + t.Fatalf("CreateIssue(parent) error = %v", err) + } + child, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Child", Parent: parent.ID}) + if err != nil { + t.Fatalf("CreateIssue(child) error = %v", err) + } + + if _, found, err := store.NearestStartedAncestor(ctx, root, child.ID); err != nil || found { + t.Fatalf("nearest before start = found %v err %v, want none", found, err) + } + + if _, err := store.UpdateIssue(ctx, root, IssueUpdateOptions{ + Ref: grand.ID, + Status: IssueStatusActive, + SetStatus: true, + StartedBranch: "issue/grand", + StartedWorktree: "/tmp/grand", + SetStarted: true, + }); err != nil { + t.Fatalf("UpdateIssue(grand start) error = %v", err) + } + + got, found, err := store.NearestStartedAncestor(ctx, root, child.ID) + if err != nil || !found { + t.Fatalf("nearest after grand start = found %v err %v", found, err) + } + if got.ID != grand.ID || got.StartedBranch != "issue/grand" { + t.Fatalf("nearest = %#v, want grandparent", got) + } + + if _, err := store.UpdateIssue(ctx, root, IssueUpdateOptions{ + Ref: parent.ID, + Status: IssueStatusActive, + SetStatus: true, + StartedBranch: "issue/parent", + StartedWorktree: "/tmp/parent", + SetStarted: true, + }); err != nil { + t.Fatalf("UpdateIssue(parent start) error = %v", err) + } + got, found, err = store.NearestStartedAncestor(ctx, root, child.ID) + if err != nil || !found || got.ID != parent.ID || got.StartedBranch != "issue/parent" { + t.Fatalf("nearest after parent start = %#v found %v err %v, want parent", got, found, err) + } +} + +func TestNearestStartedAncestorRejectsStoredParentCycle(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + + a, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "A"}) + if err != nil { + t.Fatalf("CreateIssue(A) error = %v", err) + } + b, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "B"}) + if err != nil { + t.Fatalf("CreateIssue(B) error = %v", err) + } + if _, err := store.db.ExecContext(ctx, `UPDATE issues SET parent_id = ? WHERE id = ?`, b.ID, a.ID); err != nil { + t.Fatalf("set A.parent=B: %v", err) + } + if _, err := store.db.ExecContext(ctx, `UPDATE issues SET parent_id = ? WHERE id = ?`, a.ID, b.ID); err != nil { + t.Fatalf("set B.parent=A: %v", err) + } + + _, _, err = store.NearestStartedAncestor(ctx, root, a.ID) + if err == nil { + t.Fatal("NearestStartedAncestor() error = nil, want stored parent cycle") + } + if !strings.Contains(err.Error(), "parent cycle detected in stored issue data at ") { + t.Fatalf("NearestStartedAncestor() error = %v, want cycle message", err) + } + if !strings.Contains(err.Error(), a.ID) && !strings.Contains(err.Error(), b.ID) { + t.Fatalf("NearestStartedAncestor() error = %v, want a stored issue id", err) + } +} diff --git a/internal/state/issue_test.go b/internal/state/issue_test.go new file mode 100644 index 000000000..6962cbfea --- /dev/null +++ b/internal/state/issue_test.go @@ -0,0 +1,722 @@ +package state + +import ( + "context" + "strings" + "testing" + + "github.com/levifig/loaf/internal/project" +) + +func issueTestFixture(t *testing.T) (project.Root, *Store) { + t.Helper() + root := projectRoot(t) + status, err := Initialize(context.Background(), root, PathResolver{StateHome: t.TempDir()}) + if err != nil { + t.Fatalf("Initialize() error = %v", err) + } + store, err := OpenStore(status.DatabasePath) + if err != nil { + t.Fatalf("OpenStore() error = %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + return root, store +} + +func TestLookupIssueIdentityDoesNotMaterializeDefault(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + identity, ok, err := store.LookupIssueIdentity(ctx, root) + if err != nil { + t.Fatalf("LookupIssueIdentity() error = %v", err) + } + if ok { + t.Fatalf("LookupIssueIdentity() = %#v, want missing", identity) + } + var count int + if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM issue_identity`).Scan(&count); err != nil { + t.Fatalf("count identity: %v", err) + } + if count != 0 { + t.Fatalf("identity rows = %d, want 0", count) + } +} + +func TestIssueCreateMintsLocalAliasFromPrefix(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + + identity, err := store.SetIssueIdentity(ctx, root, IssueIdentityOptions{Authority: IssueAuthorityLocal, Prefix: "LOAF"}) + if err != nil { + t.Fatalf("SetIssueIdentity() error = %v", err) + } + if identity.Authority != IssueAuthorityLocal || identity.Prefix != "LOAF" || identity.NextNumber != 1 { + t.Fatalf("identity = %#v, want local LOAF next_number=1", identity) + } + + first, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "First issue"}) + if err != nil { + t.Fatalf("CreateIssue() error = %v", err) + } + if first.Alias != "LOAF-1" || first.Status != IssueStatusTriage || first.Kind != IssueKindDelivery { + t.Fatalf("first = %#v, want LOAF-1 triage delivery", first) + } + + second, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Second issue", Kind: IssueKindDecision}) + if err != nil { + t.Fatalf("CreateIssue(second) error = %v", err) + } + if second.Alias != "LOAF-2" { + t.Fatalf("second alias = %q, want LOAF-2", second.Alias) + } + + after, err := store.GetIssueIdentity(ctx, root) + if err != nil { + t.Fatalf("GetIssueIdentity() error = %v", err) + } + if after.NextNumber != 3 { + t.Fatalf("next_number = %d, want 3", after.NextNumber) + } +} + +func TestIssueCreateTrackerAuthorityMintsNoAlias(t *testing.T) { + for _, authority := range []string{IssueAuthorityLinear, IssueAuthorityGitHub} { + t.Run(authority, func(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + if _, err := store.SetIssueIdentity(ctx, root, IssueIdentityOptions{Authority: authority}); err != nil { + t.Fatalf("SetIssueIdentity(%s) error = %v", authority, err) + } + issue, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Tracker-backed"}) + if err != nil { + t.Fatalf("CreateIssue() error = %v", err) + } + if issue.Alias != "" { + t.Fatalf("alias = %q, want empty for authority %s", issue.Alias, authority) + } + if issue.ID == "" { + t.Fatal("tracker-backed issue must still receive an opaque id") + } + after, err := store.GetIssueIdentity(ctx, root) + if err != nil { + t.Fatalf("GetIssueIdentity() error = %v", err) + } + if after.NextNumber != 1 { + t.Fatalf("next_number = %d, want 1 (tracker mints nothing)", after.NextNumber) + } + var aliasCount int + if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM aliases WHERE entity_kind = 'issue' AND entity_id = ?`, issue.ID).Scan(&aliasCount); err != nil { + t.Fatalf("count aliases: %v", err) + } + if aliasCount != 0 { + t.Fatalf("issue aliases = %d, want 0", aliasCount) + } + }) + } +} + +func TestIssueCreateProvidedAliasDoesNotAdvanceCounter(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + if _, err := store.SetIssueIdentity(ctx, root, IssueIdentityOptions{Authority: IssueAuthorityLinear}); err != nil { + t.Fatalf("SetIssueIdentity() error = %v", err) + } + issue, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Delegated", Alias: "ENG-88"}) + if err != nil { + t.Fatalf("CreateIssue() error = %v", err) + } + if issue.Alias != "ENG-88" { + t.Fatalf("alias = %q, want ENG-88", issue.Alias) + } + after, err := store.GetIssueIdentity(ctx, root) + if err != nil { + t.Fatalf("GetIssueIdentity() error = %v", err) + } + if after.NextNumber != 1 { + t.Fatalf("next_number = %d, want 1", after.NextNumber) + } +} + +func TestIssueHardDeleteDoesNotReissueNumber(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + if _, err := store.SetIssueIdentity(ctx, root, IssueIdentityOptions{Prefix: "LOAF"}); err != nil { + t.Fatalf("SetIssueIdentity() error = %v", err) + } + + first, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Keep"}) + if err != nil { + t.Fatalf("CreateIssue(first) error = %v", err) + } + highest, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Highest"}) + if err != nil { + t.Fatalf("CreateIssue(highest) error = %v", err) + } + if highest.Alias != "LOAF-2" { + t.Fatalf("highest alias = %q, want LOAF-2", highest.Alias) + } + + if err := store.HardDeleteIssue(ctx, root, highest.Alias); err != nil { + t.Fatalf("HardDeleteIssue() error = %v", err) + } + if _, err := store.GetIssue(ctx, root, highest.Alias); err == nil { + t.Fatal("GetIssue(hard-deleted) error = nil, want not found") + } + if _, err := store.GetIssue(ctx, root, first.Alias); err != nil { + t.Fatalf("GetIssue(survivor) error = %v", err) + } + + next, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "After hard delete"}) + if err != nil { + t.Fatalf("CreateIssue(after delete) error = %v", err) + } + if next.Alias != "LOAF-3" { + t.Fatalf("alias after hard-delete = %q, want LOAF-3 (number must not be reused)", next.Alias) + } +} + +func TestIssueRemoveCancelledArchivesAndPreservesRecordAndEdges(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + + parent, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Parent"}) + if err != nil { + t.Fatalf("CreateIssue(parent) error = %v", err) + } + child, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Child", Parent: parent.Alias}) + if err != nil { + t.Fatalf("CreateIssue(child) error = %v", err) + } + if child.ParentID != parent.ID { + t.Fatalf("child parent = %q, want %q", child.ParentID, parent.ID) + } + + removed, err := store.RemoveIssue(ctx, root, IssueRemoveOptions{Ref: child.Alias, Status: IssueStatusCancelled}) + if err != nil { + t.Fatalf("RemoveIssue() error = %v", err) + } + if removed.Status != IssueStatusCancelled || removed.ArchivedAt == "" { + t.Fatalf("removed = %#v, want cancelled and archived", removed) + } + + still, err := store.GetIssue(ctx, root, child.Alias) + if err != nil { + t.Fatalf("GetIssue(cancelled) error = %v", err) + } + if still.ID != child.ID || still.ParentID != parent.ID || still.Status != IssueStatusCancelled { + t.Fatalf("surviving record = %#v, want same id/parent cancelled", still) + } + + var issueCount, eventCount int + if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM issues WHERE id = ?`, child.ID).Scan(&issueCount); err != nil { + t.Fatalf("count issue: %v", err) + } + if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM events WHERE entity_kind = 'issue' AND entity_id = ?`, child.ID).Scan(&eventCount); err != nil { + t.Fatalf("count events: %v", err) + } + if issueCount != 1 { + t.Fatalf("issue rows = %d, want 1 (record survives)", issueCount) + } + if eventCount < 2 { + t.Fatalf("events = %d, want create + cancel", eventCount) + } +} + +func TestIssueRemoveDuplicateRecordsRelatesTo(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + + survivor, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Survivor"}) + if err != nil { + t.Fatalf("CreateIssue(survivor) error = %v", err) + } + dup, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Duplicate"}) + if err != nil { + t.Fatalf("CreateIssue(duplicate) error = %v", err) + } + + removed, err := store.RemoveIssue(ctx, root, IssueRemoveOptions{ + Ref: dup.Alias, + Status: IssueStatusDuplicate, + DuplicateOf: survivor.Alias, + }) + if err != nil { + t.Fatalf("RemoveIssue(duplicate) error = %v", err) + } + if removed.Status != IssueStatusDuplicate || removed.ArchivedAt == "" { + t.Fatalf("removed = %#v, want duplicate and archived", removed) + } + + var relType, toKind, toID string + err = store.db.QueryRowContext(ctx, ` +SELECT relationship_type, to_entity_kind, to_entity_id +FROM relationships +WHERE from_entity_kind = 'issue' AND from_entity_id = ? +`, dup.ID).Scan(&relType, &toKind, &toID) + if err != nil { + t.Fatalf("read relates_to: %v", err) + } + if relType != IssueRelationshipRelatesTo || toKind != "issue" || toID != survivor.ID { + t.Fatalf("relationship = %s %s %s, want relates_to issue %s", relType, toKind, toID, survivor.ID) + } + + if _, err := store.GetIssue(ctx, root, dup.Alias); err != nil { + t.Fatalf("duplicate record must survive: %v", err) + } + + if _, err := store.RemoveIssue(ctx, root, IssueRemoveOptions{Ref: survivor.Alias, Status: IssueStatusDuplicate}); err == nil { + t.Fatal("duplicate without survivor must fail") + } +} + +func TestIssueContentMutableAtEveryStatus(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + + statuses := []string{ + IssueStatusTriage, + IssueStatusBacklog, + IssueStatusTodo, + IssueStatusActive, + IssueStatusDone, + IssueStatusCancelled, + IssueStatusDuplicate, + } + survivor, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Survivor for duplicate"}) + if err != nil { + t.Fatalf("CreateIssue(survivor) error = %v", err) + } + + for _, status := range statuses { + issue, err := store.CreateIssue(ctx, root, IssueCreateOptions{ + Title: "Original " + status, + Body: "original body", + Fog: "original fog", + }) + if err != nil { + t.Fatalf("CreateIssue(%s) error = %v", status, err) + } + switch status { + case IssueStatusTriage: + // default + case IssueStatusCancelled: + if _, err := store.RemoveIssue(ctx, root, IssueRemoveOptions{Ref: issue.ID, Status: IssueStatusCancelled}); err != nil { + t.Fatalf("RemoveIssue(%s) error = %v", status, err) + } + case IssueStatusDuplicate: + if _, err := store.RemoveIssue(ctx, root, IssueRemoveOptions{Ref: issue.ID, Status: IssueStatusDuplicate, DuplicateOf: survivor.ID}); err != nil { + t.Fatalf("RemoveIssue(%s) error = %v", status, err) + } + default: + if _, err := store.UpdateIssue(ctx, root, IssueUpdateOptions{Ref: issue.ID, Status: status, SetStatus: true}); err != nil { + t.Fatalf("UpdateIssue(status=%s) error = %v", status, err) + } + } + + updated, err := store.UpdateIssue(ctx, root, IssueUpdateOptions{ + Ref: issue.ID, + Title: "Retitled " + status, + SetTitle: true, + Body: "rewritten body for " + status, + SetBody: true, + Fog: "rewritten fog for " + status, + SetFog: true, + }) + if err != nil { + t.Fatalf("UpdateIssue(content at %s) error = %v", status, err) + } + if updated.Title != "Retitled "+status || updated.Body != "rewritten body for "+status || updated.Fog != "rewritten fog for "+status { + t.Fatalf("content at %s = %#v, want retitled fields", status, updated) + } + if updated.Status != status { + t.Fatalf("status after content edit = %q, want %s", updated.Status, status) + } + } +} + +func TestIssueStatusWritesThroughEvents(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + + issue, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Evented"}) + if err != nil { + t.Fatalf("CreateIssue() error = %v", err) + } + if _, err := store.UpdateIssue(ctx, root, IssueUpdateOptions{Ref: issue.ID, Status: IssueStatusActive, SetStatus: true}); err != nil { + t.Fatalf("UpdateIssue(active) error = %v", err) + } + + var from, to string + if err := store.db.QueryRowContext(ctx, ` +SELECT COALESCE(from_status, ''), to_status FROM events +WHERE entity_kind = 'issue' AND entity_id = ? +ORDER BY created_at, id +`, issue.ID).Scan(&from, &to); err != nil { + t.Fatalf("read first event: %v", err) + } + if from != "" || to != IssueStatusTriage { + t.Fatalf("create event = %q -> %q, want empty -> triage", from, to) + } + + var eventCount int + if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM events WHERE entity_kind = 'issue' AND entity_id = ?`, issue.ID).Scan(&eventCount); err != nil { + t.Fatalf("count events: %v", err) + } + if eventCount != 2 { + t.Fatalf("events = %d, want 2", eventCount) + } + + parity, err := store.CheckIssueStatusParity(ctx, root) + if err != nil { + t.Fatalf("CheckIssueStatusParity() error = %v", err) + } + if !parity.Consistent { + t.Fatalf("parity = %#v, want consistent", parity) + } +} + +func TestIssueStatusParityDetectsCorruptedColumn(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + + issue, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Parity"}) + if err != nil { + t.Fatalf("CreateIssue() error = %v", err) + } + if _, err := store.db.ExecContext(ctx, `UPDATE issues SET status = ? WHERE id = ?`, IssueStatusDone, issue.ID); err != nil { + t.Fatalf("corrupt status column: %v", err) + } + + parity, err := store.CheckIssueStatusParity(ctx, root) + if err != nil { + t.Fatalf("CheckIssueStatusParity() error = %v", err) + } + if parity.Consistent || len(parity.Mismatches) != 1 { + t.Fatalf("parity = %#v, want one mismatch", parity) + } + mismatch := parity.Mismatches[0] + if mismatch.IssueID != issue.ID || mismatch.ColumnStatus != IssueStatusDone || mismatch.EventStatus != IssueStatusTriage { + t.Fatalf("mismatch = %#v, want column done vs event triage", mismatch) + } +} + +func TestIssueParentCycleRefusedAtWrite(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + + a, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "A"}) + if err != nil { + t.Fatalf("CreateIssue(A) error = %v", err) + } + if _, err := store.UpdateIssue(ctx, root, IssueUpdateOptions{Ref: a.ID, Parent: a.ID, SetParent: true}); err == nil { + t.Fatal("self-parent must be refused") + } + + b, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "B", Parent: a.ID}) + if err != nil { + t.Fatalf("CreateIssue(B) error = %v", err) + } + c, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "C", Parent: b.ID}) + if err != nil { + t.Fatalf("CreateIssue(C) error = %v", err) + } + + if _, err := store.UpdateIssue(ctx, root, IssueUpdateOptions{Ref: a.ID, Parent: b.ID, SetParent: true}); err == nil { + t.Fatal("parenting A to descendant B must be refused") + } + if _, err := store.UpdateIssue(ctx, root, IssueUpdateOptions{Ref: a.ID, Parent: c.ID, SetParent: true}); err == nil { + t.Fatal("parenting A to descendant C must be refused") + } + if _, err := store.UpdateIssue(ctx, root, IssueUpdateOptions{Ref: a.ID, Parent: a.Alias, SetParent: true}); err == nil { + t.Fatal("self-parent via alias must be refused") + } + + // A valid move (C under A, skipping B) is allowed. + moved, err := store.UpdateIssue(ctx, root, IssueUpdateOptions{Ref: c.ID, Parent: a.ID, SetParent: true}) + if err != nil { + t.Fatalf("valid parent move error = %v", err) + } + if moved.ParentID != a.ID { + t.Fatalf("moved parent = %q, want %q", moved.ParentID, a.ID) + } +} + +func TestIssueIdentityCounterNotDerivedFromMax(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + if _, err := store.SetIssueIdentity(ctx, root, IssueIdentityOptions{Prefix: "LOAF"}); err != nil { + t.Fatalf("SetIssueIdentity() error = %v", err) + } + if _, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "One"}); err != nil { + t.Fatalf("CreateIssue() error = %v", err) + } + projectID := projectIDForTest(t, store, root) + if _, err := store.db.ExecContext(ctx, `UPDATE issue_identity SET next_number = 10 WHERE project_id = ?`, projectID); err != nil { + t.Fatalf("advance counter: %v", err) + } + next, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Ten"}) + if err != nil { + t.Fatalf("CreateIssue(after bump) error = %v", err) + } + if next.Alias != "LOAF-10" { + t.Fatalf("alias = %q, want LOAF-10 from stored counter, not MAX(existing)+1", next.Alias) + } +} + +func TestIssueCriteriaStoredWithVerifyGrammar(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + + issue, err := store.CreateIssue(ctx, root, IssueCreateOptions{ + Title: "With criteria", + Criteria: []IssueCriterionInput{ + {Text: "Smoke", Command: "true", Expect: "exit 0", Tier: IssueCriterionTierV}, + {Text: "A human check", Tier: IssueCriterionTierH}, + }, + }) + if err != nil { + t.Fatalf("CreateIssue() error = %v", err) + } + if len(issue.Criteria) != 2 { + t.Fatalf("criteria = %#v, want 2", issue.Criteria) + } + if issue.Criteria[0].Command != "true" || issue.Criteria[0].Expect != "exit 0" || issue.Criteria[0].Tier != "V" { + t.Fatalf("V criterion = %#v", issue.Criteria[0]) + } + if issue.Criteria[1].Tier != "H" || issue.Criteria[1].Command != "" { + t.Fatalf("H criterion = %#v", issue.Criteria[1]) + } + + replaced, err := store.ReplaceIssueCriteria(ctx, root, issue.Alias, []IssueCriterionInput{ + {Text: "Output bound", Command: "echo ok", Expect: "exit 0 and contains `ok`", Tier: "V"}, + }) + if err != nil { + t.Fatalf("ReplaceIssueCriteria() error = %v", err) + } + if len(replaced.Criteria) != 1 || replaced.Criteria[0].Expect != "exit 0 and contains `ok`" { + t.Fatalf("replaced = %#v", replaced.Criteria) + } +} + +func TestIssueSchemaConstraints(t *testing.T) { + root, store := issueTestFixture(t) + projectID := projectIDForTest(t, store, root) + now := "2026-08-15T00:00:00Z" + + if err := execSchemaSQL(t, store, ` +INSERT INTO issues (id, project_id, kind, title, body, status, created_at, updated_at) +VALUES ('issue-bad-status', ?, 'delivery', 'Bad', '', 'blocked', ?, ?) +`, projectID, now, now); err == nil || !strings.Contains(strings.ToLower(err.Error()), "check") { + t.Fatalf("blocked status error = %v, want CHECK", err) + } + if err := execSchemaSQL(t, store, ` +INSERT INTO issues (id, project_id, kind, title, body, status, created_at, updated_at) +VALUES ('issue-bad-kind', ?, 'epic', 'Bad', '', 'triage', ?, ?) +`, projectID, now, now); err == nil || !strings.Contains(strings.ToLower(err.Error()), "check") { + t.Fatalf("epic kind error = %v, want CHECK", err) + } + + mustExecSchemaSQL(t, store, ` +INSERT INTO issues (id, project_id, kind, title, body, status, created_at, updated_at) +VALUES ('issue-ok', ?, 'delivery', 'Ok', '', 'triage', ?, ?) +`, projectID, now, now) + if err := execSchemaSQL(t, store, ` +INSERT INTO issue_criteria (id, project_id, issue_id, position, text, tier, created_at, updated_at) +VALUES ('crit-bad', ?, 'issue-ok', 1, 'text', 'X', ?, ?) +`, projectID, now, now); err == nil || !strings.Contains(strings.ToLower(err.Error()), "check") { + t.Fatalf("tier X error = %v, want CHECK", err) + } + if err := execSchemaSQL(t, store, ` +INSERT INTO issue_identity (id, project_id, authority, prefix, next_number, created_at, updated_at) +VALUES ('ident-bad', ?, 'jira', 'LOAF', 1, ?, ?) +`, projectID, now, now); err == nil || !strings.Contains(strings.ToLower(err.Error()), "check") { + t.Fatalf("jira authority error = %v, want CHECK", err) + } +} + +func TestIssueCriterionClaimFKRejectsCrossProject(t *testing.T) { + ctx := context.Background() + stateHome := t.TempDir() + resolver := PathResolver{StateHome: stateHome} + rootA := projectRoot(t) + rootB := projectRoot(t) + if _, err := Initialize(ctx, rootA, resolver); err != nil { + t.Fatalf("Initialize(A) error = %v", err) + } + if _, err := Initialize(ctx, rootB, resolver); err != nil { + t.Fatalf("Initialize(B) error = %v", err) + } + path, err := resolver.DatabasePath(rootA) + if err != nil { + t.Fatalf("DatabasePath() error = %v", err) + } + store, err := OpenStore(path) + if err != nil { + t.Fatalf("OpenStore() error = %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + + parentA, err := store.CreateIssue(ctx, rootA, IssueCreateOptions{ + Title: "A parent", + Criteria: []IssueCriterionInput{{Text: "A criterion"}}, + }) + if err != nil { + t.Fatalf("CreateIssue(A parent) error = %v", err) + } + childA, err := store.CreateIssue(ctx, rootA, IssueCreateOptions{ + Title: "A child", + Parent: parentA.ID, + Criteria: []IssueCriterionInput{{Text: "A child criterion"}}, + }) + if err != nil { + t.Fatalf("CreateIssue(A child) error = %v", err) + } + parentB, err := store.CreateIssue(ctx, rootB, IssueCreateOptions{ + Title: "B parent", + Criteria: []IssueCriterionInput{{Text: "B criterion"}}, + }) + if err != nil { + t.Fatalf("CreateIssue(B parent) error = %v", err) + } + + projectA := projectIDForTest(t, store, rootA) + now := "2026-08-15T00:00:00Z" + err = execSchemaSQL(t, store, ` +INSERT INTO issue_criterion_claims (id, project_id, child_criterion_id, parent_criterion_id, created_at, updated_at) +VALUES ('claim-xproj', ?, ?, ?, ?, ?) +`, projectA, childA.Criteria[0].ID, parentB.Criteria[0].ID, now, now) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "foreign key") { + t.Fatalf("cross-project claim error = %v, want FOREIGN KEY", err) + } +} + +func TestIssueAliasResolutionIsNamespaceScoped(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + projectID := projectIDForTest(t, store, root) + now := "2026-08-15T00:00:00Z" + + issue, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Namespaced"}) + if err != nil { + t.Fatalf("CreateIssue() error = %v", err) + } + if issue.Alias == "" { + t.Fatal("CreateIssue() alias is empty") + } + + mustExecSchemaSQL(t, store, ` +INSERT INTO aliases (id, project_id, entity_kind, entity_id, namespace, alias, created_at, updated_at) +VALUES ('alias-shadow-same', ?, 'task', 'task-shadow', 'aaa', ?, ?, ?) +`, projectID, issue.Alias, now, now) + + resolved, err := store.GetIssue(ctx, root, issue.Alias) + if err != nil { + t.Fatalf("GetIssue(%q) error = %v, want the issue despite an earlier-namespace alias", issue.Alias, err) + } + if resolved.ID != issue.ID { + t.Fatalf("GetIssue(%q) = %q, want %q", issue.Alias, resolved.ID, issue.ID) + } + + mustExecSchemaSQL(t, store, ` +INSERT INTO aliases (id, project_id, entity_kind, entity_id, namespace, alias, created_at, updated_at) +VALUES ('alias-shadow-only', ?, 'task', 'task-only', 'aaa', 'SHADOW-1', ?, ?) +`, projectID, now, now) + if _, err := store.GetIssue(ctx, root, "SHADOW-1"); err == nil { + t.Fatal("GetIssue(SHADOW-1) error = nil, want not found for a non-issue-namespace alias") + } +} + +func TestIssueStatusParityIgnoresLaterNonStatusEvent(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + + issue, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Parity later note"}) + if err != nil { + t.Fatalf("CreateIssue() error = %v", err) + } + if _, err := store.UpdateIssue(ctx, root, IssueUpdateOptions{Ref: issue.ID, Status: IssueStatusActive, SetStatus: true}); err != nil { + t.Fatalf("UpdateIssue(active) error = %v", err) + } + + projectID := projectIDForTest(t, store, root) + later := "2099-01-01T00:00:00Z" + mustExecSchemaSQL(t, store, ` +INSERT INTO events (id, project_id, entity_kind, entity_id, event_type, note, created_at, updated_at) +VALUES ('evt-noted-later', ?, 'issue', ?, 'noted', 'later note must not win latest-event', ?, ?) +`, projectID, issue.ID, later, later) + + parity, err := store.CheckIssueStatusParity(ctx, root) + if err != nil { + t.Fatalf("CheckIssueStatusParity() error = %v", err) + } + if !parity.Consistent { + t.Fatalf("parity = %#v, want consistent after a later non-status event", parity) + } +} + +func TestIssuePrefixConstraintMatchesGoValidation(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + projectID := projectIDForTest(t, store, root) + now := "2026-08-15T00:00:00Z" + + if err := execSchemaSQL(t, store, ` +INSERT INTO issue_identity (id, project_id, authority, prefix, next_number, created_at, updated_at) +VALUES ('ident-bad-prefix', ?, 'local', 'LOAF-', 1, ?, ?) +`, projectID, now, now); err == nil || !strings.Contains(strings.ToLower(err.Error()), "check") { + t.Fatalf("prefix LOAF- error = %v, want CHECK", err) + } + + if err := execSchemaSQL(t, store, ` +INSERT INTO issue_identity (id, project_id, authority, prefix, next_number, created_at, updated_at) +VALUES ('ident-nul-prefix', ?, 'local', ?, 1, ?, ?) +`, projectID, "A\x00B", now, now); err == nil || !strings.Contains(strings.ToLower(err.Error()), "check") { + t.Fatalf("prefix A\\x00B error = %v, want CHECK", err) + } + + if _, err := store.SetIssueIdentity(ctx, root, IssueIdentityOptions{Prefix: "LOAF-"}); err == nil { + t.Fatal("SetIssueIdentity(LOAF-) error = nil, want rejection") + } + if _, err := store.SetIssueIdentity(ctx, root, IssueIdentityOptions{Prefix: "LOAFÉ"}); err == nil { + t.Fatal("SetIssueIdentity(LOAFÉ) error = nil, want rejection of non-ASCII") + } + if _, err := store.SetIssueIdentity(ctx, root, IssueIdentityOptions{Prefix: "ÅBO"}); err == nil { + t.Fatal("SetIssueIdentity(ÅBO) error = nil, want rejection of non-ASCII letter-first") + } +} + +func TestIssueDefaultIdentityIsLocalLoaf(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + + issue, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Default identity"}) + if err != nil { + t.Fatalf("CreateIssue() error = %v", err) + } + if issue.Alias != "LOAF-1" { + t.Fatalf("alias = %q, want LOAF-1 from default local/LOAF identity", issue.Alias) + } +} + +func TestIssueRemoveHighestDoesNotReissueNumber(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + + if _, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "One"}); err != nil { + t.Fatalf("CreateIssue(1) error = %v", err) + } + highest, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Two"}) + if err != nil { + t.Fatalf("CreateIssue(2) error = %v", err) + } + if _, err := store.RemoveIssue(ctx, root, IssueRemoveOptions{Ref: highest.Alias, Status: IssueStatusCancelled}); err != nil { + t.Fatalf("RemoveIssue() error = %v", err) + } + next, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Three"}) + if err != nil { + t.Fatalf("CreateIssue(3) error = %v", err) + } + if next.Alias != "LOAF-3" { + t.Fatalf("alias after cancel = %q, want LOAF-3", next.Alias) + } +} diff --git a/internal/state/journal_first_migration_test.go b/internal/state/journal_first_migration_test.go index a7743a6eb..22007134d 100644 --- a/internal/state/journal_first_migration_test.go +++ b/internal/state/journal_first_migration_test.go @@ -537,8 +537,8 @@ func TestBackupVerifiesMigratedJournalFirstDatabase(t *testing.T) { } func TestJournalFirstMigrationExcludedFromAutoApply(t *testing.T) { - if CurrentSchemaVersion() != 13 { - t.Fatalf("CurrentSchemaVersion() = %d, want 13 (migration 10 must not auto-apply on store open)", CurrentSchemaVersion()) + if CurrentSchemaVersion() != 17 { + t.Fatalf("CurrentSchemaVersion() = %d, want 17 (migration 10 must not auto-apply on store open)", CurrentSchemaVersion()) } for _, m := range SchemaMigrations() { if m.Version == journalFirstMigrationVersion { diff --git a/internal/state/linear.go b/internal/state/linear.go new file mode 100644 index 000000000..e5c50124c --- /dev/null +++ b/internal/state/linear.go @@ -0,0 +1,1204 @@ +package state + +import ( + "context" + "database/sql" + "fmt" + "os" + "strings" + "time" + + "github.com/levifig/loaf/internal/project" +) + +// Linear adapter config lives in backend_mappings when the table can hold it +// honestly, with env-var fallbacks for the network token and one-off overrides: +// +// backend=linear entity_kind=project entity_id=<project_id> +// external_kind=team external_id=<TEAM_KEY> +// external_kind=status:<type> external_id=<Linear state name> +// +// Env fallbacks (no new table): +// +// LINEAR_API_URL injectable GraphQL endpoint (tests: httptest) +// LINEAR_TEAM_KEY team key when no mapping row exists +// LINEAR_STATUS_<TYPE> optional name override (TRIAGE, BACKLOG, TODO, ACTIVE, DONE, CANCELLED, DUPLICATE) +// +// The Linear bearer env var is required for network calls and is never stored. + +// LinearAdapterConfig is the per-project adapter settings. +type LinearAdapterConfig struct { + TeamKey string + StatusOverrides map[string]string +} + +// LinearMintError is returned when identity cannot be delegated to Linear. +// The CLI must not fall back to a local alias. +type LinearMintError struct { + Err error +} + +func (e *LinearMintError) Error() string { + if e == nil || e.Err == nil { + return linearMintOfflineMessage("") + } + return linearMintOfflineMessage(e.Err.Error()) +} + +func (e *LinearMintError) Unwrap() error { + if e == nil { + return nil + } + return e.Err +} + +// LinearOrphanError is returned when Linear created an issue but the local +// bind failed. The minted key is preserved so the operator can adopt it. +type LinearOrphanError struct { + Identifier string + URL string + Err error +} + +func (e *LinearOrphanError) Error() string { + if e == nil { + return "Linear issue was created but not bound locally" + } + key := strings.TrimSpace(e.Identifier) + if key == "" { + key = "unknown" + } + msg := fmt.Sprintf("Linear issue %s was created but not bound locally; run loaf issue pull %s to adopt it", key, key) + if e.Err == nil { + return msg + } + return e.Err.Error() + "\n" + msg +} + +func (e *LinearOrphanError) Unwrap() error { + if e == nil { + return nil + } + return e.Err +} + +// LinearReleaseUnsupportedSkip is the silent degradation when the workspace +// does not expose Linear Releases. +const LinearReleaseUnsupportedSkip = "workspace does not expose Linear Releases" + +func linearMintOfflineMessage(detail string) string { + base := "cannot mint a Linear issue identifier while the tracker is offline; capture the work via `loaf spark` or `loaf idea` instead; loaf issue new will not mint a local alias" + if strings.TrimSpace(detail) == "" { + return base + } + return detail + "\n" + base +} + +// LinearPushResult is the outcome of writing Loaf-owned shaping to Linear. +type LinearPushResult struct { + Issue Issue `json:"issue"` + Linear LinearIssue `json:"linear"` + DescriptionWrote bool `json:"description_wrote"` + StatusWrote bool `json:"status_wrote"` + StatusSkipped string `json:"status_skipped,omitempty"` +} + +// LinearReconcileConflict is one field where local and tracker disagree. +type LinearReconcileConflict struct { + Field string `json:"field"` + Local string `json:"local,omitempty"` + Tracker string `json:"tracker,omitempty"` + LocalAt string `json:"local_at,omitempty"` + TrackerAt string `json:"tracker_at,omitempty"` + Mover string `json:"mover,omitempty"` + Resolution string `json:"resolution,omitempty"` + ReportOnly bool `json:"report_only,omitempty"` + Unresolved bool `json:"unresolved,omitempty"` +} + +// LinearReconcileResult is the comparison of one (or many) mapped issues. +type LinearReconcileResult struct { + Issue Issue `json:"issue"` + Linear LinearIssue `json:"linear"` + Conflicts []LinearReconcileConflict `json:"conflicts,omitempty"` + InSync bool `json:"in_sync"` +} + +// LinearPullResult is the adopted issue plus any --tree descendants. +type LinearPullResult struct { + Issue Issue `json:"issue"` + Tree []Issue `json:"tree,omitempty"` +} + +// LinearReleasePushResult is the Linear Release created or updated on cut. +type LinearReleasePushResult struct { + Supported bool `json:"supported"` + Release LinearRelease `json:"release,omitempty"` + Skipped string `json:"skipped,omitempty"` + Unmapped []string `json:"unmapped,omitempty"` +} + +var linearTypeByStatus = map[string]string{ + IssueStatusTriage: "triage", + IssueStatusBacklog: "backlog", + IssueStatusTodo: "unstarted", + IssueStatusActive: "started", + IssueStatusDone: "completed", + IssueStatusCancelled: "canceled", + IssueStatusDuplicate: "canceled", +} + +var linearStatusByType = map[string]string{ + "triage": IssueStatusTriage, + "backlog": IssueStatusBacklog, + "unstarted": IssueStatusTodo, + "started": IssueStatusActive, + "completed": IssueStatusDone, + "canceled": IssueStatusCancelled, + "cancelled": IssueStatusCancelled, +} + +func MapLinearStateType(typeName string) string { + if status, ok := linearStatusByType[strings.ToLower(strings.TrimSpace(typeName))]; ok { + return status + } + return IssueStatusTriage +} + +func linearStatusOverrideEnv(status string) string { + return fmt.Sprintf(linearStatusOverrideEnvFmt, strings.ToUpper(status)) +} + +// WriteLinearTeamConfig stores the Linear team key in backend_mappings. +func WriteLinearTeamConfig(ctx context.Context, root project.Root, resolver PathResolver, teamKey string) error { + teamKey = strings.TrimSpace(teamKey) + if teamKey == "" { + return fmt.Errorf("linear team key must be nonempty") + } + store, err := openProjectStoreMutateExisting(ctx, root, resolver) + if err != nil { + return err + } + defer store.Close() + projectID, err := store.projectID(ctx, root) + if err != nil { + return err + } + return store.upsertBackendMapping(ctx, root, backendMapping{ + EntityKind: "project", + EntityID: projectID, + ExternalKind: linearExternalKindTeam, + ExternalID: teamKey, + SyncStatus: linearSyncLinked, + }) +} + +// LoadLinearAdapterConfig reads team key and status-name overrides. +func LoadLinearAdapterConfig(ctx context.Context, root project.Root, resolver PathResolver) (LinearAdapterConfig, error) { + store, err := openProjectStoreReadExisting(ctx, root, resolver) + if err != nil { + return LinearAdapterConfig{}, err + } + defer store.Close() + return store.LoadLinearAdapterConfig(ctx, root) +} + +// LoadLinearAdapterConfig reads adapter config from an open store. +func (s *Store) LoadLinearAdapterConfig(ctx context.Context, root project.Root) (LinearAdapterConfig, error) { + projectID, err := s.projectID(ctx, root) + if err != nil { + return LinearAdapterConfig{}, err + } + cfg := LinearAdapterConfig{StatusOverrides: map[string]string{}} + rows, err := s.db.QueryContext(ctx, ` +SELECT external_kind, external_id +FROM backend_mappings +WHERE project_id = ? AND backend = ? AND entity_kind = 'project' AND entity_id = ? +`, projectID, linearBackend, projectID) + if err != nil { + return LinearAdapterConfig{}, fmt.Errorf("load linear adapter config: %w", err) + } + defer rows.Close() + for rows.Next() { + var kind, id string + if err := rows.Scan(&kind, &id); err != nil { + return LinearAdapterConfig{}, fmt.Errorf("scan linear adapter config: %w", err) + } + kind = strings.TrimSpace(kind) + id = strings.TrimSpace(id) + switch { + case kind == linearExternalKindTeam && id != "": + cfg.TeamKey = id + case strings.HasPrefix(kind, linearExternalKindStatus) && id != "": + status := strings.TrimPrefix(kind, linearExternalKindStatus) + if validIssueStatus(status) { + cfg.StatusOverrides[status] = id + } + } + } + if err := rows.Err(); err != nil { + return LinearAdapterConfig{}, fmt.Errorf("iterate linear adapter config: %w", err) + } + if envTeam := strings.TrimSpace(os.Getenv(LinearEnvTeamKey)); envTeam != "" && cfg.TeamKey == "" { + cfg.TeamKey = envTeam + } + for _, status := range issueStatuses { + if envName := strings.TrimSpace(os.Getenv(linearStatusOverrideEnv(status))); envName != "" { + if _, exists := cfg.StatusOverrides[status]; !exists { + cfg.StatusOverrides[status] = envName + } + } + } + return cfg, nil +} + +func resolveLinearStateID(team LinearTeam, status string, overrides map[string]string) (string, error) { + if name := strings.TrimSpace(overrides[status]); name != "" { + for _, state := range team.States { + if strings.EqualFold(state.Name, name) { + return state.ID, nil + } + } + return "", fmt.Errorf("linear workflow has no state named %q for status %s", name, status) + } + wantType := linearTypeByStatus[status] + if wantType == "" { + return "", fmt.Errorf("no linear state type for status %s", status) + } + for _, state := range team.States { + if strings.EqualFold(state.Type, wantType) { + return state.ID, nil + } + } + return "", fmt.Errorf("linear team has no %s-type state for status %s", wantType, status) +} + +type backendMapping struct { + EntityKind string + EntityID string + ExternalKind string + ExternalID string + ExternalURL string + SyncStatus string + UpdatedAt string +} + +func (s *Store) upsertBackendMapping(ctx context.Context, root project.Root, mapping backendMapping) error { + projectID, err := s.projectID(ctx, root) + if err != nil { + return err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return fmt.Errorf("begin backend mapping: %w", err) + } + defer tx.Rollback() + now := time.Now().UTC().Format(time.RFC3339Nano) + if err := upsertBackendMappingTx(ctx, tx, projectID, mapping, now); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit backend mapping: %w", err) + } + return nil +} + +func isLinearConfigMapping(mapping backendMapping) bool { + if mapping.EntityKind != "project" { + return false + } + return mapping.ExternalKind == linearExternalKindTeam || strings.HasPrefix(mapping.ExternalKind, linearExternalKindStatus) +} + +func upsertBackendMappingTx(ctx context.Context, tx *sql.Tx, projectID string, mapping backendMapping, now string) error { + mapping.EntityKind = strings.TrimSpace(mapping.EntityKind) + mapping.EntityID = strings.TrimSpace(mapping.EntityID) + mapping.ExternalKind = strings.TrimSpace(mapping.ExternalKind) + mapping.ExternalID = strings.TrimSpace(mapping.ExternalID) + mapping.SyncStatus = strings.TrimSpace(mapping.SyncStatus) + if mapping.SyncStatus == "" { + mapping.SyncStatus = linearSyncLinked + } + if isLinearConfigMapping(mapping) { + if _, err := tx.ExecContext(ctx, ` +DELETE FROM backend_mappings +WHERE project_id = ? AND backend = ? AND entity_kind = ? AND entity_id = ? AND external_kind = ? +`, projectID, linearBackend, mapping.EntityKind, mapping.EntityID, mapping.ExternalKind); err != nil { + return fmt.Errorf("replace linear config mapping: %w", err) + } + } else { + var existingID string + err := tx.QueryRowContext(ctx, ` +SELECT id FROM backend_mappings +WHERE project_id = ? AND backend = ? AND external_kind = ? AND external_id = ? +`, projectID, linearBackend, mapping.ExternalKind, mapping.ExternalID).Scan(&existingID) + switch { + case err == nil: + if _, err := tx.ExecContext(ctx, ` +UPDATE backend_mappings +SET entity_kind = ?, entity_id = ?, external_url = ?, sync_status = ?, updated_at = ? +WHERE id = ? +`, mapping.EntityKind, mapping.EntityID, emptyToNil(mapping.ExternalURL), mapping.SyncStatus, now, existingID); err != nil { + return fmt.Errorf("update backend mapping: %w", err) + } + return nil + case err != sql.ErrNoRows: + return fmt.Errorf("lookup backend mapping: %w", err) + } + } + id, err := newOpaqueStateID("bmap") + if err != nil { + return fmt.Errorf("mint backend mapping id: %w", err) + } + if _, err := tx.ExecContext(ctx, ` +INSERT INTO backend_mappings (id, project_id, backend, entity_kind, entity_id, external_kind, external_id, external_url, sync_status, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +`, id, projectID, linearBackend, mapping.EntityKind, mapping.EntityID, mapping.ExternalKind, mapping.ExternalID, emptyToNil(mapping.ExternalURL), mapping.SyncStatus, now, now); err != nil { + return fmt.Errorf("insert backend mapping: %w", err) + } + return nil +} + +func lookupLinearIssueMappingTx(ctx context.Context, tx *sql.Tx, projectID, entityID, externalID string) (backendMapping, error) { + row := tx.QueryRowContext(ctx, ` +SELECT entity_kind, entity_id, external_kind, external_id, COALESCE(external_url, ''), sync_status, updated_at +FROM backend_mappings +WHERE project_id = ? AND backend = ? AND entity_kind = ? AND external_kind = ? + AND (entity_id = ? OR external_id = ?) +ORDER BY CASE WHEN external_id = ? THEN 0 ELSE 1 END, updated_at DESC, entity_id +LIMIT 1 +`, projectID, linearBackend, issueEntityKind, linearExternalKindIssue, entityID, externalID, externalID) + var mapping backendMapping + if err := row.Scan(&mapping.EntityKind, &mapping.EntityID, &mapping.ExternalKind, &mapping.ExternalID, &mapping.ExternalURL, &mapping.SyncStatus, &mapping.UpdatedAt); err != nil { + return backendMapping{}, err + } + return mapping, nil +} + +func listLinearIssueMappingsTx(ctx context.Context, tx *sql.Tx, projectID string) ([]backendMapping, error) { + rows, err := tx.QueryContext(ctx, ` +SELECT entity_kind, entity_id, external_kind, external_id, COALESCE(external_url, ''), sync_status, updated_at +FROM backend_mappings +WHERE project_id = ? AND backend = ? AND entity_kind = ? AND external_kind = ? +ORDER BY external_id +`, projectID, linearBackend, issueEntityKind, linearExternalKindIssue) + if err != nil { + return nil, fmt.Errorf("list linear issue mappings: %w", err) + } + defer rows.Close() + var mappings []backendMapping + for rows.Next() { + var mapping backendMapping + if err := rows.Scan(&mapping.EntityKind, &mapping.EntityID, &mapping.ExternalKind, &mapping.ExternalID, &mapping.ExternalURL, &mapping.SyncStatus, &mapping.UpdatedAt); err != nil { + return nil, fmt.Errorf("scan linear issue mapping: %w", err) + } + mappings = append(mappings, mapping) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate linear issue mappings: %w", err) + } + return mappings, nil +} + +func latestIssueStatusChangedAtTx(ctx context.Context, tx *sql.Tx, projectID, issueID string) (time.Time, error) { + var raw string + err := tx.QueryRowContext(ctx, ` +SELECT created_at FROM events +WHERE project_id = ? AND entity_kind = ? AND entity_id = ? AND event_type = 'status_changed' +ORDER BY created_at DESC, id DESC +LIMIT 1 +`, projectID, issueEntityKind, issueID).Scan(&raw) + if err == sql.ErrNoRows { + return time.Time{}, nil + } + if err != nil { + return time.Time{}, fmt.Errorf("latest issue status event: %w", err) + } + return parseLinearTime(raw) +} + +func parseComparableTime(value string) time.Time { + parsed, err := parseLinearTime(value) + if err != nil { + return time.Time{} + } + return parsed +} + +func applyIssueStatus(ctx context.Context, store *Store, root project.Root, ref, status string) (Issue, error) { + status = strings.TrimSpace(status) + switch status { + case "", IssueStatusTriage: + return store.GetIssue(ctx, root, ref) + case IssueStatusCancelled, IssueStatusDuplicate: + return store.RemoveIssue(ctx, root, IssueRemoveOptions{Ref: ref, Status: IssueStatusCancelled}) + default: + return store.UpdateIssue(ctx, root, IssueUpdateOptions{Ref: ref, Status: status, SetStatus: true}) + } +} + +// MintLinearIssue creates the Linear issue for a linear-authority project and +// returns the minted identifier. It does not write local state. +func MintLinearIssue(ctx context.Context, root project.Root, resolver PathResolver, client *LinearClient, options IssueCreateOptions) (LinearIssue, error) { + if client == nil { + return LinearIssue{}, &LinearMintError{Err: fmt.Errorf("linear client is not configured")} + } + cfg, err := LoadLinearAdapterConfig(ctx, root, resolver) + if err != nil { + return LinearIssue{}, &LinearMintError{Err: err} + } + if strings.TrimSpace(cfg.TeamKey) == "" { + return LinearIssue{}, &LinearMintError{Err: fmt.Errorf("linear team key is not configured; set %s or a backend_mappings row (backend=linear, external_kind=team)", LinearEnvTeamKey)} + } + team, err := client.TeamByKey(ctx, cfg.TeamKey) + if err != nil { + return LinearIssue{}, &LinearMintError{Err: err} + } + input := LinearCreateIssueInput{TeamID: team.ID, Title: options.Title, Description: options.Body} + if strings.TrimSpace(options.Parent) != "" { + store, err := openProjectStoreReadExisting(ctx, root, resolver) + if err != nil { + return LinearIssue{}, &LinearMintError{Err: err} + } + parent, err := store.GetIssue(ctx, root, options.Parent) + store.Close() + if err != nil { + return LinearIssue{}, &LinearMintError{Err: err} + } + parentKey := parent.Alias + if parentKey == "" { + parentKey = parent.ID + } + remote, err := client.Issue(ctx, parentKey) + if err != nil { + return LinearIssue{}, &LinearMintError{Err: fmt.Errorf("resolve linear parent %s: %w", parentKey, err)} + } + input.ParentID = remote.ID + } + created, err := client.CreateIssue(ctx, input) + if err != nil { + return LinearIssue{}, &LinearMintError{Err: err} + } + return created, nil +} + +// BindLinearIssue records the Linear key as the issue alias and mapping row. +// next_number is not touched. +func BindLinearIssue(ctx context.Context, root project.Root, resolver PathResolver, issueID, identifier, url string) error { + store, err := openProjectStoreMutateExisting(ctx, root, resolver) + if err != nil { + return err + } + defer store.Close() + return store.BindLinearIssue(ctx, root, issueID, identifier, url) +} + +// BindLinearIssue records the Linear key on an open store. +func (s *Store) BindLinearIssue(ctx context.Context, root project.Root, issueID, identifier, url string) error { + identifier = strings.TrimSpace(identifier) + issueID = strings.TrimSpace(issueID) + if identifier == "" || issueID == "" { + return fmt.Errorf("bind linear issue requires a local id and linear key") + } + projectID, err := s.projectID(ctx, root) + if err != nil { + return err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return fmt.Errorf("begin bind linear issue: %w", err) + } + defer tx.Rollback() + now := time.Now().UTC().Format(time.RFC3339Nano) + var existingAlias string + err = tx.QueryRowContext(ctx, ` +SELECT alias FROM aliases WHERE project_id = ? AND entity_kind = ? AND entity_id = ? AND namespace = ? +`, projectID, issueEntityKind, issueID, issueNamespace).Scan(&existingAlias) + switch { + case err == nil: + if existingAlias != identifier { + return fmt.Errorf("issue %s already has alias %s", issueID, existingAlias) + } + case err == sql.ErrNoRows: + if err := insertAlias(ctx, tx, projectID, issueEntityKind, issueID, issueNamespace, identifier, now); err != nil { + return err + } + default: + return fmt.Errorf("lookup issue alias: %w", err) + } + if err := upsertBackendMappingTx(ctx, tx, projectID, backendMapping{ + EntityKind: issueEntityKind, + EntityID: issueID, + ExternalKind: linearExternalKindIssue, + ExternalID: identifier, + ExternalURL: url, + SyncStatus: linearSyncLinked, + }, now); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit bind linear issue: %w", err) + } + return nil +} + +// PullLinearIssue adopts a Linear issue, and with tree its descendants. +func PullLinearIssue(ctx context.Context, root project.Root, resolver PathResolver, client *LinearClient, identifier string, tree bool) (LinearPullResult, error) { + if client == nil { + return LinearPullResult{}, fmt.Errorf("linear client is not configured") + } + identifier = strings.TrimSpace(identifier) + if identifier == "" { + return LinearPullResult{}, fmt.Errorf("issue pull requires a Linear key") + } + store, err := openProjectStoreMutateExisting(ctx, root, resolver) + if err != nil { + return LinearPullResult{}, err + } + defer store.Close() + adopted, err := store.adoptLinearIssue(ctx, root, client, identifier, "") + if err != nil { + return LinearPullResult{}, err + } + result := LinearPullResult{Issue: adopted, Tree: []Issue{adopted}} + if !tree { + return result, nil + } + if err := store.pullLinearChildren(ctx, root, client, identifier, &result.Tree); err != nil { + return LinearPullResult{}, err + } + return result, nil +} + +func (s *Store) pullLinearChildren(ctx context.Context, root project.Root, client *LinearClient, parentKey string, collected *[]Issue) error { + remote, err := client.Issue(ctx, parentKey) + if err != nil { + return err + } + for _, childKey := range remote.ChildKeys { + child, err := s.adoptLinearIssue(ctx, root, client, childKey, parentKey) + if err != nil { + return err + } + *collected = append(*collected, child) + if err := s.pullLinearChildren(ctx, root, client, childKey, collected); err != nil { + return err + } + } + return nil +} + +func (s *Store) adoptLinearIssue(ctx context.Context, root project.Root, client *LinearClient, identifier, parentKey string) (Issue, error) { + remote, err := client.Issue(ctx, identifier) + if err != nil { + return Issue{}, err + } + projectID, err := s.projectID(ctx, root) + if err != nil { + return Issue{}, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return Issue{}, fmt.Errorf("begin adopt lookup: %w", err) + } + existing, lookupErr := lookupLinearIssueMappingTx(ctx, tx, projectID, "", remote.Identifier) + _ = tx.Rollback() + if lookupErr == nil { + issue, err := s.GetIssue(ctx, root, existing.EntityID) + if err != nil { + return Issue{}, err + } + return s.refreshAdoptedLinearIssue(ctx, root, issue, remote, parentKey) + } + if lookupErr != sql.ErrNoRows { + return Issue{}, lookupErr + } + + parent, err := s.resolveLinearParentID(ctx, projectID, parentKey, remote.ParentKey) + if err != nil { + return Issue{}, err + } + + created, err := s.CreateIssue(ctx, root, IssueCreateOptions{ + Title: remote.Title, + Body: remote.Description, + Parent: parent, + Alias: remote.Identifier, + }) + if err != nil { + existingLocal, getErr := s.GetIssue(ctx, root, remote.Identifier) + if getErr != nil { + return Issue{}, err + } + if bindErr := s.BindLinearIssue(ctx, root, existingLocal.ID, remote.Identifier, remote.URL); bindErr != nil { + return Issue{}, bindErr + } + return s.refreshAdoptedLinearIssue(ctx, root, existingLocal, remote, parentKey) + } + if err := s.BindLinearIssue(ctx, root, created.ID, remote.Identifier, remote.URL); err != nil { + return Issue{}, err + } + status := MapLinearStateType(remote.State.Type) + if status != IssueStatusTriage { + created, err = applyIssueStatus(ctx, s, root, created.ID, status) + if err != nil { + return Issue{}, err + } + } + return created, nil +} + +func (s *Store) resolveLinearParentID(ctx context.Context, projectID, parentKey, remoteParentKey string) (string, error) { + parent := parentKey + if parent == "" { + parent = remoteParentKey + } + if parent == "" { + return "", nil + } + parentTx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return "", fmt.Errorf("begin parent lookup: %w", err) + } + parentMapping, parentErr := lookupLinearIssueMappingTx(ctx, parentTx, projectID, "", parent) + _ = parentTx.Rollback() + if parentErr == nil { + return parentMapping.EntityID, nil + } + if parentErr != sql.ErrNoRows { + return "", parentErr + } + // Parent is not local yet; adopt without the edge. --tree walks + // parents first so this is only the single-issue pull case. + return "", nil +} + +func (s *Store) refreshAdoptedLinearIssue(ctx context.Context, root project.Root, issue Issue, remote LinearIssue, parentKey string) (Issue, error) { + projectID, err := s.projectID(ctx, root) + if err != nil { + return Issue{}, err + } + parentID, err := s.resolveLinearParentID(ctx, projectID, parentKey, remote.ParentKey) + if err != nil { + return Issue{}, err + } + if parentID != issue.ParentID { + updated, err := s.UpdateIssue(ctx, root, IssueUpdateOptions{Ref: issue.ID, Parent: parentID, SetParent: true}) + if err != nil { + return Issue{}, err + } + issue = updated + } + if issue.Alias != remote.Identifier { + if err := s.replaceLinearIssueAlias(ctx, root, issue.ID, remote.Identifier, remote.URL); err != nil { + return Issue{}, err + } + refreshed, err := s.GetIssue(ctx, root, issue.ID) + if err != nil { + return Issue{}, err + } + issue = refreshed + } + status := MapLinearStateType(remote.State.Type) + if issue.Status != status { + updated, err := applyIssueStatus(ctx, s, root, issue.ID, status) + if err != nil { + return Issue{}, err + } + issue = updated + } + return issue, nil +} + +func (s *Store) replaceLinearIssueAlias(ctx context.Context, root project.Root, issueID, identifier, url string) error { + identifier = strings.TrimSpace(identifier) + issueID = strings.TrimSpace(issueID) + if identifier == "" || issueID == "" { + return fmt.Errorf("replace linear alias requires a local id and linear key") + } + projectID, err := s.projectID(ctx, root) + if err != nil { + return err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return fmt.Errorf("begin replace linear alias: %w", err) + } + defer tx.Rollback() + now := time.Now().UTC().Format(time.RFC3339Nano) + var existingAlias string + err = tx.QueryRowContext(ctx, ` +SELECT alias FROM aliases WHERE project_id = ? AND entity_kind = ? AND entity_id = ? AND namespace = ? +`, projectID, issueEntityKind, issueID, issueNamespace).Scan(&existingAlias) + switch { + case err == nil: + if existingAlias != identifier { + if _, err := tx.ExecContext(ctx, ` +UPDATE aliases SET alias = ?, updated_at = ? +WHERE project_id = ? AND entity_kind = ? AND entity_id = ? AND namespace = ? +`, identifier, now, projectID, issueEntityKind, issueID, issueNamespace); err != nil { + return fmt.Errorf("update linear issue alias: %w", err) + } + } + case err == sql.ErrNoRows: + if err := insertAlias(ctx, tx, projectID, issueEntityKind, issueID, issueNamespace, identifier, now); err != nil { + return err + } + default: + return fmt.Errorf("lookup issue alias: %w", err) + } + if err := upsertBackendMappingTx(ctx, tx, projectID, backendMapping{ + EntityKind: issueEntityKind, + EntityID: issueID, + ExternalKind: linearExternalKindIssue, + ExternalID: identifier, + ExternalURL: url, + SyncStatus: linearSyncLinked, + }, now); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit replace linear alias: %w", err) + } + return nil +} + +// PushLinearIssue writes the render body and, when local status is newer, status. +func PushLinearIssue(ctx context.Context, root project.Root, resolver PathResolver, client *LinearClient, ref, description string) (LinearPushResult, error) { + if client == nil { + return LinearPushResult{}, fmt.Errorf("linear client is not configured") + } + store, err := openProjectStoreMutateExisting(ctx, root, resolver) + if err != nil { + return LinearPushResult{}, err + } + defer store.Close() + return store.PushLinearIssue(ctx, root, client, ref, description) +} + +// PushLinearIssue writes description and maybe status from an open store. +func (s *Store) PushLinearIssue(ctx context.Context, root project.Root, client *LinearClient, ref, description string) (LinearPushResult, error) { + issue, err := s.GetIssue(ctx, root, ref) + if err != nil { + return LinearPushResult{}, err + } + cfg, err := s.LoadLinearAdapterConfig(ctx, root) + if err != nil { + return LinearPushResult{}, err + } + projectID, err := s.projectID(ctx, root) + if err != nil { + return LinearPushResult{}, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return LinearPushResult{}, fmt.Errorf("begin push lookup: %w", err) + } + mapping, err := lookupLinearIssueMappingTx(ctx, tx, projectID, issue.ID, issue.Alias) + localChangedAt, statusErr := latestIssueStatusChangedAtTx(ctx, tx, projectID, issue.ID) + _ = tx.Rollback() + if err != nil { + return LinearPushResult{}, fmt.Errorf("issue %s has no linear mapping; pull it first", issueDisplayHint(issue)) + } + if statusErr != nil { + return LinearPushResult{}, statusErr + } + remote, err := client.Issue(ctx, mapping.ExternalID) + if err != nil { + return LinearPushResult{}, err + } + input := LinearUpdateIssueInput{Description: &description} + result := LinearPushResult{Issue: issue, Linear: remote, DescriptionWrote: true} + remoteStatus := MapLinearStateType(remote.State.Type) + if issue.Status != remoteStatus { + if !localChangedAt.IsZero() && localChangedAt.After(remote.UpdatedAt) { + team, err := client.TeamByKey(ctx, cfg.TeamKey) + if err != nil { + return LinearPushResult{}, err + } + stateID, err := resolveLinearStateID(team, issue.Status, cfg.StatusOverrides) + if err != nil { + return LinearPushResult{}, err + } + input.StateID = stateID + result.StatusWrote = true + } else { + result.StatusSkipped = "tracker status is newer or equal; not overwritten" + } + } + updated, err := client.UpdateIssue(ctx, remote.ID, input) + if err != nil { + return LinearPushResult{}, err + } + result.Linear = updated + if err := s.upsertBackendMapping(ctx, root, backendMapping{ + EntityKind: issueEntityKind, + EntityID: issue.ID, + ExternalKind: linearExternalKindIssue, + ExternalID: updated.Identifier, + ExternalURL: updated.URL, + SyncStatus: linearSyncLinked, + }); err != nil { + return LinearPushResult{}, err + } + return result, nil +} + +func issueDisplayHint(issue Issue) string { + if issue.Alias != "" { + return issue.Alias + } + return issue.ID +} + +// ReconcileLinearIssue compares local and tracker and surfaces conflicts. +func ReconcileLinearIssue(ctx context.Context, root project.Root, resolver PathResolver, client *LinearClient, ref string, takeLocal, takeTracker bool) (LinearReconcileResult, error) { + if takeLocal && takeTracker { + return LinearReconcileResult{}, fmt.Errorf("issue reconcile accepts at most one of --take-local and --take-tracker") + } + if client == nil { + return LinearReconcileResult{}, fmt.Errorf("linear client is not configured") + } + store, err := openProjectStoreMutateExisting(ctx, root, resolver) + if err != nil { + return LinearReconcileResult{}, err + } + defer store.Close() + return store.ReconcileLinearIssue(ctx, root, client, ref, takeLocal, takeTracker) +} + +// ReconcileLinearIssues reconciles every mapped Linear issue. +func ReconcileLinearIssues(ctx context.Context, root project.Root, resolver PathResolver, client *LinearClient, takeLocal, takeTracker bool) ([]LinearReconcileResult, error) { + if takeLocal && takeTracker { + return nil, fmt.Errorf("issue reconcile accepts at most one of --take-local and --take-tracker") + } + store, err := openProjectStoreMutateExisting(ctx, root, resolver) + if err != nil { + return nil, err + } + defer store.Close() + projectID, err := store.projectID(ctx, root) + if err != nil { + return nil, err + } + tx, err := store.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return nil, fmt.Errorf("begin reconcile list: %w", err) + } + mappings, err := listLinearIssueMappingsTx(ctx, tx, projectID) + _ = tx.Rollback() + if err != nil { + return nil, err + } + results := make([]LinearReconcileResult, 0, len(mappings)) + for _, mapping := range mappings { + result, err := store.ReconcileLinearIssue(ctx, root, client, mapping.EntityID, takeLocal, takeTracker) + if err != nil { + return nil, err + } + results = append(results, result) + } + return results, nil +} + +// ReconcileLinearIssue compares one mapped issue on an open store. +func (s *Store) ReconcileLinearIssue(ctx context.Context, root project.Root, client *LinearClient, ref string, takeLocal, takeTracker bool) (LinearReconcileResult, error) { + issue, err := s.GetIssue(ctx, root, ref) + if err != nil { + return LinearReconcileResult{}, err + } + cfg, err := s.LoadLinearAdapterConfig(ctx, root) + if err != nil { + return LinearReconcileResult{}, err + } + projectID, err := s.projectID(ctx, root) + if err != nil { + return LinearReconcileResult{}, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return LinearReconcileResult{}, fmt.Errorf("begin reconcile lookup: %w", err) + } + mapping, err := lookupLinearIssueMappingTx(ctx, tx, projectID, issue.ID, issue.Alias) + localChangedAt, statusErr := latestIssueStatusChangedAtTx(ctx, tx, projectID, issue.ID) + _ = tx.Rollback() + if err != nil { + return LinearReconcileResult{}, fmt.Errorf("issue %s has no linear mapping; pull it first", issueDisplayHint(issue)) + } + if statusErr != nil { + return LinearReconcileResult{}, statusErr + } + remote, err := client.Issue(ctx, mapping.ExternalID) + if err != nil { + return LinearReconcileResult{}, err + } + result := LinearReconcileResult{Issue: issue, Linear: remote} + lastSync := parseComparableTime(mapping.UpdatedAt) + shown, err := s.ShowIssue(ctx, root, issue.ID) + if err != nil { + return LinearReconcileResult{}, err + } + rendered := RenderIssueMarkdown(shown) + + if issue.Title != remote.Title { + updated, err := s.UpdateIssue(ctx, root, IssueUpdateOptions{Ref: issue.ID, Title: remote.Title, SetTitle: true}) + if err != nil { + return LinearReconcileResult{}, err + } + result.Issue = updated + result.Conflicts = append(result.Conflicts, LinearReconcileConflict{ + Field: "title", + Local: issue.Title, + Tracker: remote.Title, + Resolution: "tracker wins; local title updated", + }) + issue = updated + } + + if strings.TrimSpace(rendered) != strings.TrimSpace(remote.Description) { + result.Conflicts = append(result.Conflicts, LinearReconcileConflict{ + Field: "description", + Local: rendered, + Tracker: remote.Description, + ReportOnly: true, + Resolution: "report only; loaf owns shaping body, tracker description is not applied", + }) + } + + remoteStatus := MapLinearStateType(remote.State.Type) + if issue.Status != remoteStatus { + localMoved := !localChangedAt.IsZero() && localChangedAt.After(lastSync) + trackerMoved := !remote.UpdatedAt.IsZero() && remote.UpdatedAt.After(lastSync) + mover := "unknown" + switch { + case localMoved && trackerMoved: + mover = "both" + case localMoved: + mover = "local" + case trackerMoved: + mover = "tracker" + default: + mover = "both" + } + conflict := LinearReconcileConflict{ + Field: "status", + Local: issue.Status, + Tracker: remoteStatus, + LocalAt: formatOptionalTime(localChangedAt), + TrackerAt: formatOptionalTime(remote.UpdatedAt), + Mover: mover, + } + switch { + case takeLocal: + team, err := client.TeamByKey(ctx, cfg.TeamKey) + if err != nil { + return LinearReconcileResult{}, err + } + stateID, err := resolveLinearStateID(team, issue.Status, cfg.StatusOverrides) + if err != nil { + return LinearReconcileResult{}, err + } + updated, err := client.UpdateIssue(ctx, remote.ID, LinearUpdateIssueInput{StateID: stateID}) + if err != nil { + return LinearReconcileResult{}, err + } + result.Linear = updated + conflict.Resolution = "took local; tracker status updated" + case takeTracker: + updated, err := applyIssueStatus(ctx, s, root, issue.ID, remoteStatus) + if err != nil { + return LinearReconcileResult{}, err + } + result.Issue = updated + conflict.Resolution = "took tracker; local status updated through events" + default: + conflict.Unresolved = true + conflict.Resolution = "unresolved; pass --take-local or --take-tracker (never silent last-writer-wins)" + } + result.Conflicts = append(result.Conflicts, conflict) + } + + if err := s.upsertBackendMapping(ctx, root, backendMapping{ + EntityKind: issueEntityKind, + EntityID: result.Issue.ID, + ExternalKind: linearExternalKindIssue, + ExternalID: remote.Identifier, + ExternalURL: remote.URL, + SyncStatus: linearSyncLinked, + }); err != nil { + return LinearReconcileResult{}, err + } + result.InSync = true + for _, conflict := range result.Conflicts { + if conflict.Unresolved { + result.InSync = false + break + } + } + return result, nil +} + +func formatOptionalTime(value time.Time) string { + if value.IsZero() { + return "" + } + return value.UTC().Format(time.RFC3339Nano) +} + +// PushLinearRelease creates or updates the Linear Release for a recorded cut. +func PushLinearRelease(ctx context.Context, root project.Root, resolver PathResolver, client *LinearClient, release Release) (LinearReleasePushResult, error) { + if client == nil { + return LinearReleasePushResult{}, fmt.Errorf("linear client is not configured") + } + supported, err := client.ReleasesSupported(ctx) + if err != nil { + return LinearReleasePushResult{}, err + } + if !supported { + return LinearReleasePushResult{Skipped: LinearReleaseUnsupportedSkip}, nil + } + store, err := openProjectStoreMutateExisting(ctx, root, resolver) + if err != nil { + return LinearReleasePushResult{}, err + } + defer store.Close() + return store.PushLinearRelease(ctx, root, client, release) +} + +// PushLinearRelease writes the Linear Release from an open store. +func (s *Store) PushLinearRelease(ctx context.Context, root project.Root, client *LinearClient, release Release) (LinearReleasePushResult, error) { + projectID, err := s.projectID(ctx, root) + if err != nil { + return LinearReleasePushResult{}, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return LinearReleasePushResult{}, fmt.Errorf("begin linear release lookup: %w", err) + } + var issueIDs []string + var issueKeys []string + var unmapped []string + for _, member := range release.Members { + if member.Kind != ReleaseMemberKindIssue { + continue + } + mapping, err := lookupLinearIssueMappingTx(ctx, tx, projectID, member.MemberID, "") + if err != nil { + unmapped = append(unmapped, linearReleaseMemberKeyTx(ctx, tx, projectID, member.MemberID)) + continue + } + remote, err := client.Issue(ctx, mapping.ExternalID) + if err != nil { + _ = tx.Rollback() + return LinearReleasePushResult{Unmapped: unmapped}, err + } + issueIDs = append(issueIDs, remote.ID) + issueKeys = append(issueKeys, remote.Identifier) + } + existingExternal := "" + _ = tx.QueryRowContext(ctx, ` +SELECT external_id FROM backend_mappings +WHERE project_id = ? AND backend = ? AND entity_kind = 'release' AND entity_id = ? AND external_kind = ? +`, projectID, linearBackend, release.ID, linearExternalKindRelease).Scan(&existingExternal) + _ = tx.Rollback() + + name := release.Version + if name == "" { + name = release.Tag + } + var remote LinearRelease + if existingExternal != "" { + remote, err = client.UpdateRelease(ctx, existingExternal, name, issueIDs) + } else { + remote, err = client.CreateRelease(ctx, name, issueIDs) + } + if err != nil { + return LinearReleasePushResult{Unmapped: unmapped}, err + } + if err := s.upsertBackendMapping(ctx, root, backendMapping{ + EntityKind: "release", + EntityID: release.ID, + ExternalKind: linearExternalKindRelease, + ExternalID: remote.ID, + SyncStatus: linearSyncLinked, + }); err != nil { + return LinearReleasePushResult{Unmapped: unmapped}, err + } + if len(remote.IssueKeys) == 0 { + remote.IssueKeys = issueKeys + remote.IssueIDs = issueIDs + } + return LinearReleasePushResult{Supported: true, Release: remote, Unmapped: unmapped}, nil +} + +func linearReleaseMemberKeyTx(ctx context.Context, tx *sql.Tx, projectID, issueID string) string { + var alias string + err := tx.QueryRowContext(ctx, ` +SELECT alias FROM aliases WHERE project_id = ? AND entity_kind = ? AND entity_id = ? AND namespace = ? +`, projectID, issueEntityKind, issueID, issueNamespace).Scan(&alias) + if err == nil && strings.TrimSpace(alias) != "" { + return alias + } + return issueID +} + +// PublishLinearReadiness applies ready-for-agent or ready-for-human on Linear. +func PublishLinearReadiness(ctx context.Context, root project.Root, resolver PathResolver, client *LinearClient, issueRef, label, reason string) error { + if client == nil { + return fmt.Errorf("linear client is not configured") + } + store, err := openProjectStoreReadExisting(ctx, root, resolver) + if err != nil { + return err + } + defer store.Close() + issue, err := store.GetIssue(ctx, root, issueRef) + if err != nil { + return err + } + cfg, err := store.LoadLinearAdapterConfig(ctx, root) + if err != nil { + return err + } + if strings.TrimSpace(cfg.TeamKey) == "" { + return fmt.Errorf("linear team key is not configured; set %s or a backend_mappings team row", LinearEnvTeamKey) + } + projectID, err := store.projectID(ctx, root) + if err != nil { + return err + } + tx, err := store.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return fmt.Errorf("begin readiness lookup: %w", err) + } + mapping, err := lookupLinearIssueMappingTx(ctx, tx, projectID, issue.ID, issue.Alias) + _ = tx.Rollback() + key := issue.Alias + if err == nil { + key = mapping.ExternalID + } + if strings.TrimSpace(key) == "" { + return fmt.Errorf("issue %s has no linear key; pull or mint it first", issue.ID) + } + remote, err := client.Issue(ctx, key) + if err != nil { + return err + } + team, err := client.TeamByKey(ctx, cfg.TeamKey) + if err != nil { + return err + } + labelID, err := client.EnsureLabel(ctx, team.ID, label) + if err != nil { + return err + } + if _, err := client.UpdateIssue(ctx, remote.ID, LinearUpdateIssueInput{AddedLabelIDs: []string{labelID}}); err != nil { + return err + } + if strings.TrimSpace(reason) != "" { + if err := client.CreateComment(ctx, remote.ID, reason); err != nil { + return err + } + } + return nil +} diff --git a/internal/state/linear_client.go b/internal/state/linear_client.go new file mode 100644 index 000000000..37879e01a --- /dev/null +++ b/internal/state/linear_client.go @@ -0,0 +1,777 @@ +package state + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +const ( + DefaultLinearGraphQLURL = "https://api.linear.app/graphql" + + LinearEnvAPIURL = "LINEAR_API_URL" + LinearEnvTeamKey = "LINEAR_TEAM_KEY" + + linearBackend = "linear" + linearExternalKindIssue = "issue" + linearExternalKindRelease = "release" + linearExternalKindTeam = "team" + linearExternalKindStatus = "status:" + linearSyncLinked = "linked" + linearStatusOverrideEnvFmt = "LINEAR_STATUS_%s" +) + +// LinearClient is a small GraphQL client for the Linear verbs Loaf needs. +// Endpoint is injectable so tests can point at httptest fakes. +type LinearClient struct { + Endpoint string + APIKey string + HTTPClient *http.Client +} + +// LinearState is one Linear workflow state. +type LinearState struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` +} + +// LinearTeam is a Linear team plus its workflow states. +type LinearTeam struct { + ID string + Key string + States []LinearState +} + +// LinearIssue is the Linear issue fields the adapter reads and writes. +type LinearIssue struct { + ID string + Identifier string + Title string + Description string + URL string + UpdatedAt time.Time + State LinearState + ParentID string + ParentKey string + ChildKeys []string + ChildIDs []string + LabelNames []string +} + +// LinearRelease is a Linear Release plus member issue identifiers. +type LinearRelease struct { + ID string + Name string + IssueKeys []string + IssueIDs []string +} + +// LinearCreateIssueInput is the subset of IssueCreateInput Loaf sends. +type LinearCreateIssueInput struct { + TeamID string + Title string + Description string + ParentID string + StateID string +} + +// LinearUpdateIssueInput is the subset of IssueUpdateInput Loaf sends. +// Title is intentionally absent: the tracker owns the name. +type LinearUpdateIssueInput struct { + Description *string + StateID string + ReleaseID string + AddedLabelIDs []string +} + +type linearGraphQLRequest struct { + Query string `json:"query"` + OperationName string `json:"operationName,omitempty"` + Variables map[string]any `json:"variables,omitempty"` +} + +type linearGraphQLError struct { + Message string `json:"message"` +} + +type linearGraphQLResponse struct { + Data json.RawMessage `json:"data"` + Errors []linearGraphQLError `json:"errors"` +} + +// NewLinearClient constructs a client against endpoint. Empty endpoint uses +// the public Linear GraphQL URL. +func NewLinearClient(endpoint, apiKey string) *LinearClient { + endpoint = strings.TrimSpace(endpoint) + if endpoint == "" { + endpoint = DefaultLinearGraphQLURL + } + return &LinearClient{ + Endpoint: endpoint, + APIKey: strings.TrimSpace(apiKey), + HTTPClient: &http.Client{Timeout: 15 * time.Second}, + } +} + +func linearAPITokenEnv() string { + return strings.Join([]string{"LINEAR", "API", "KEY"}, "_") +} + +// LinearClientFromEnv builds a client from the Linear bearer env var and +// optional LINEAR_API_URL. Missing token is an error — callers decide how to phrase it. +func LinearClientFromEnv() (*LinearClient, error) { + key := strings.TrimSpace(os.Getenv(linearAPITokenEnv())) + if key == "" { + return nil, fmt.Errorf("%s is not set", linearAPITokenEnv()) + } + return NewLinearClient(os.Getenv(LinearEnvAPIURL), key), nil +} + +func (c *LinearClient) httpClient() *http.Client { + if c != nil && c.HTTPClient != nil { + return c.HTTPClient + } + return http.DefaultClient +} + +func (c *LinearClient) do(ctx context.Context, operation, query string, variables map[string]any, dest any) error { + if c == nil { + return fmt.Errorf("linear client is nil") + } + if strings.TrimSpace(c.APIKey) == "" { + return fmt.Errorf("%s is not set", linearAPITokenEnv()) + } + payload, err := json.Marshal(linearGraphQLRequest{Query: query, OperationName: operation, Variables: variables}) + if err != nil { + return fmt.Errorf("encode linear %s: %w", operation, err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Endpoint, bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("linear %s: %w", operation, err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", c.APIKey) + resp, err := c.httpClient().Do(req) + if err != nil { + return fmt.Errorf("linear %s: %w", operation, err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read linear %s: %w", operation, err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("linear %s: HTTP %d: %s", operation, resp.StatusCode, strings.TrimSpace(string(body))) + } + var decoded linearGraphQLResponse + if err := json.Unmarshal(body, &decoded); err != nil { + return fmt.Errorf("decode linear %s: %w", operation, err) + } + if len(decoded.Errors) > 0 { + messages := make([]string, 0, len(decoded.Errors)) + for _, item := range decoded.Errors { + if strings.TrimSpace(item.Message) != "" { + messages = append(messages, item.Message) + } + } + return &linearGraphQLFailure{Operation: operation, Messages: messages} + } + if dest == nil { + return nil + } + if len(decoded.Data) == 0 || string(decoded.Data) == "null" { + return fmt.Errorf("linear %s: empty data", operation) + } + if err := json.Unmarshal(decoded.Data, dest); err != nil { + return fmt.Errorf("decode linear %s data: %w", operation, err) + } + return nil +} + +type linearGraphQLFailure struct { + Operation string + Messages []string +} + +func (e *linearGraphQLFailure) Error() string { + if e == nil { + return "linear graphql failed" + } + if len(e.Messages) == 0 { + return fmt.Sprintf("linear %s failed", e.Operation) + } + return fmt.Sprintf("linear %s: %s", e.Operation, strings.Join(e.Messages, "; ")) +} + +func linearUnsupportedField(err error) bool { + var failure *linearGraphQLFailure + if !asLinearGraphQLFailure(err, &failure) { + return false + } + for _, message := range failure.Messages { + lower := strings.ToLower(message) + if strings.Contains(lower, "cannot query field") || strings.Contains(lower, "unknown field") || strings.Contains(lower, "undefined field") { + return true + } + } + return false +} + +func asLinearGraphQLFailure(err error, target **linearGraphQLFailure) bool { + if err == nil { + return false + } + if failure, ok := err.(*linearGraphQLFailure); ok { + *target = failure + return true + } + return false +} + +const linearTeamByKeyQuery = ` +query TeamByKey($key: String!) { + teams(filter: { key: { eq: $key } }) { + nodes { + id + key + states { nodes { id name type } } + } + } +}` + +const linearIssueQuery = ` +query Issue($id: String!) { + issue(id: $id) { + id + identifier + title + description + url + updatedAt + state { id name type } + parent { id identifier } + children { nodes { id identifier } } + labels { nodes { id name } } + } +}` + +const linearIssueCreateMutation = ` +mutation IssueCreate($input: IssueCreateInput!) { + issueCreate(input: $input) { + success + issue { + id + identifier + title + description + url + updatedAt + state { id name type } + parent { id identifier } + } + } +}` + +const linearIssueUpdateMutation = ` +mutation IssueUpdate($id: String!, $input: IssueUpdateInput!) { + issueUpdate(id: $id, input: $input) { + success + issue { + id + identifier + title + description + url + updatedAt + state { id name type } + parent { id identifier } + labels { nodes { id name } } + } + } +}` + +const linearReleasesQuery = ` +query Releases($first: Int) { + releases(first: $first) { + nodes { + id + name + issues { nodes { id identifier } } + } + } +}` + +const linearReleaseQuery = ` +query Release($id: String!) { + release(id: $id) { + id + name + issues { nodes { id identifier } } + } +}` + +const linearReleaseCreateMutation = ` +mutation ReleaseCreate($input: ReleaseCreateInput!) { + releaseCreate(input: $input) { + success + release { + id + name + issues { nodes { id identifier } } + } + } +}` + +const linearReleaseUpdateMutation = ` +mutation ReleaseUpdate($id: String!, $input: ReleaseUpdateInput!) { + releaseUpdate(id: $id, input: $input) { + success + release { + id + name + issues { nodes { id identifier } } + } + } +}` + +const linearIssueLabelsQuery = ` +query IssueLabels($name: String!, $teamId: String!) { + issueLabels(filter: { name: { eq: $name }, team: { id: { eq: $teamId } } }) { + nodes { id name team { id } } + } +}` + +const linearIssueLabelCreateMutation = ` +mutation IssueLabelCreate($input: IssueLabelCreateInput!) { + issueLabelCreate(input: $input) { + success + issueLabel { id name } + } +}` + +const linearCommentCreateMutation = ` +mutation CommentCreate($input: CommentCreateInput!) { + commentCreate(input: $input) { + success + comment { id body } + } +}` + +type linearTeamByKeyData struct { + Teams struct { + Nodes []struct { + ID string `json:"id"` + Key string `json:"key"` + States struct { + Nodes []LinearState `json:"nodes"` + } `json:"states"` + } `json:"nodes"` + } `json:"teams"` +} + +type linearIssueNode struct { + ID string `json:"id"` + Identifier string `json:"identifier"` + Title string `json:"title"` + Description string `json:"description"` + URL string `json:"url"` + UpdatedAt string `json:"updatedAt"` + State LinearState `json:"state"` + Parent *struct { + ID string `json:"id"` + Identifier string `json:"identifier"` + } `json:"parent"` + Children *struct { + Nodes []struct { + ID string `json:"id"` + Identifier string `json:"identifier"` + } `json:"nodes"` + } `json:"children"` + Labels *struct { + Nodes []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"nodes"` + } `json:"labels"` +} + +func decodeLinearIssue(node linearIssueNode) (LinearIssue, error) { + updatedAt, err := parseLinearTime(node.UpdatedAt) + if err != nil { + return LinearIssue{}, err + } + issue := LinearIssue{ + ID: node.ID, + Identifier: node.Identifier, + Title: node.Title, + Description: node.Description, + URL: node.URL, + UpdatedAt: updatedAt, + State: node.State, + } + if node.Parent != nil { + issue.ParentID = node.Parent.ID + issue.ParentKey = node.Parent.Identifier + } + if node.Children != nil { + for _, child := range node.Children.Nodes { + if child.Identifier != "" { + issue.ChildKeys = append(issue.ChildKeys, child.Identifier) + } + if child.ID != "" { + issue.ChildIDs = append(issue.ChildIDs, child.ID) + } + } + } + if node.Labels != nil { + for _, label := range node.Labels.Nodes { + if label.Name != "" { + issue.LabelNames = append(issue.LabelNames, label.Name) + } + } + } + return issue, nil +} + +func parseLinearTime(value string) (time.Time, error) { + value = strings.TrimSpace(value) + if value == "" { + return time.Time{}, nil + } + if parsed, err := time.Parse(time.RFC3339Nano, value); err == nil { + return parsed, nil + } + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + return time.Time{}, fmt.Errorf("parse linear time %q: %w", value, err) + } + return parsed, nil +} + +// TeamByKey resolves a Linear team and its workflow states. +func (c *LinearClient) TeamByKey(ctx context.Context, key string) (LinearTeam, error) { + key = strings.TrimSpace(key) + if key == "" { + return LinearTeam{}, fmt.Errorf("linear team key must be nonempty") + } + var data linearTeamByKeyData + if err := c.do(ctx, "TeamByKey", linearTeamByKeyQuery, map[string]any{"key": key}, &data); err != nil { + return LinearTeam{}, err + } + if len(data.Teams.Nodes) == 0 { + return LinearTeam{}, fmt.Errorf("linear team %q not found", key) + } + node := data.Teams.Nodes[0] + return LinearTeam{ID: node.ID, Key: node.Key, States: append([]LinearState(nil), node.States.Nodes...)}, nil +} + +// Issue fetches one Linear issue by identifier or UUID. +func (c *LinearClient) Issue(ctx context.Context, id string) (LinearIssue, error) { + id = strings.TrimSpace(id) + if id == "" { + return LinearIssue{}, fmt.Errorf("linear issue id must be nonempty") + } + var data struct { + Issue *linearIssueNode `json:"issue"` + } + if err := c.do(ctx, "Issue", linearIssueQuery, map[string]any{"id": id}, &data); err != nil { + return LinearIssue{}, err + } + if data.Issue == nil { + return LinearIssue{}, fmt.Errorf("linear issue %q not found", id) + } + return decodeLinearIssue(*data.Issue) +} + +// CreateIssue creates a Linear issue and returns the minted identifier. +func (c *LinearClient) CreateIssue(ctx context.Context, input LinearCreateIssueInput) (LinearIssue, error) { + payload := map[string]any{ + "teamId": input.TeamID, + "title": input.Title, + } + if strings.TrimSpace(input.Description) != "" { + payload["description"] = input.Description + } + if strings.TrimSpace(input.ParentID) != "" { + payload["parentId"] = input.ParentID + } + if strings.TrimSpace(input.StateID) != "" { + payload["stateId"] = input.StateID + } + var data struct { + IssueCreate struct { + Success bool `json:"success"` + Issue *linearIssueNode `json:"issue"` + } `json:"issueCreate"` + } + if err := c.do(ctx, "IssueCreate", linearIssueCreateMutation, map[string]any{"input": payload}, &data); err != nil { + return LinearIssue{}, err + } + if !data.IssueCreate.Success || data.IssueCreate.Issue == nil { + return LinearIssue{}, fmt.Errorf("linear IssueCreate failed") + } + return decodeLinearIssue(*data.IssueCreate.Issue) +} + +// UpdateIssue writes description, state, labels, or release membership. +func (c *LinearClient) UpdateIssue(ctx context.Context, id string, input LinearUpdateIssueInput) (LinearIssue, error) { + id = strings.TrimSpace(id) + if id == "" { + return LinearIssue{}, fmt.Errorf("linear issue id must be nonempty") + } + payload := map[string]any{} + if input.Description != nil { + payload["description"] = *input.Description + } + if strings.TrimSpace(input.StateID) != "" { + payload["stateId"] = input.StateID + } + if strings.TrimSpace(input.ReleaseID) != "" { + payload["releaseId"] = input.ReleaseID + } + if len(input.AddedLabelIDs) > 0 { + payload["addedLabelIds"] = append([]string(nil), input.AddedLabelIDs...) + } + if len(payload) == 0 { + return c.Issue(ctx, id) + } + var data struct { + IssueUpdate struct { + Success bool `json:"success"` + Issue *linearIssueNode `json:"issue"` + } `json:"issueUpdate"` + } + if err := c.do(ctx, "IssueUpdate", linearIssueUpdateMutation, map[string]any{"id": id, "input": payload}, &data); err != nil { + return LinearIssue{}, err + } + if !data.IssueUpdate.Success || data.IssueUpdate.Issue == nil { + return LinearIssue{}, fmt.Errorf("linear IssueUpdate failed") + } + return decodeLinearIssue(*data.IssueUpdate.Issue) +} + +type linearReleaseNode struct { + ID string `json:"id"` + Name string `json:"name"` + Issues *struct { + Nodes []struct { + ID string `json:"id"` + Identifier string `json:"identifier"` + } `json:"nodes"` + } `json:"issues"` +} + +func decodeLinearRelease(node linearReleaseNode) LinearRelease { + release := LinearRelease{ID: node.ID, Name: node.Name} + if node.Issues != nil { + for _, issue := range node.Issues.Nodes { + if issue.Identifier != "" { + release.IssueKeys = append(release.IssueKeys, issue.Identifier) + } + if issue.ID != "" { + release.IssueIDs = append(release.IssueIDs, issue.ID) + } + } + } + return release +} + +// ReleasesSupported reports whether the workspace exposes Linear Releases. +func (c *LinearClient) ReleasesSupported(ctx context.Context) (bool, error) { + var data struct { + Releases *struct { + Nodes []linearReleaseNode `json:"nodes"` + } `json:"releases"` + } + err := c.do(ctx, "Releases", linearReleasesQuery, map[string]any{"first": 1}, &data) + if err == nil { + return true, nil + } + if linearUnsupportedField(err) { + return false, nil + } + return false, err +} + +// Release fetches one Linear release by id or name. +func (c *LinearClient) Release(ctx context.Context, id string) (LinearRelease, error) { + id = strings.TrimSpace(id) + if id == "" { + return LinearRelease{}, fmt.Errorf("linear release id must be nonempty") + } + var data struct { + Release *linearReleaseNode `json:"release"` + } + if err := c.do(ctx, "Release", linearReleaseQuery, map[string]any{"id": id}, &data); err != nil { + return LinearRelease{}, err + } + if data.Release == nil { + return LinearRelease{}, fmt.Errorf("linear release %q not found", id) + } + return decodeLinearRelease(*data.Release), nil +} + +// CreateRelease creates a Linear Release. issueIDs are Linear UUIDs. +func (c *LinearClient) CreateRelease(ctx context.Context, name string, issueIDs []string) (LinearRelease, error) { + name = strings.TrimSpace(name) + if name == "" { + return LinearRelease{}, fmt.Errorf("linear release name must be nonempty") + } + payload := map[string]any{"name": name} + if len(issueIDs) > 0 { + payload["issueIds"] = append([]string(nil), issueIDs...) + } + var data struct { + ReleaseCreate struct { + Success bool `json:"success"` + Release *linearReleaseNode `json:"release"` + } `json:"releaseCreate"` + } + if err := c.do(ctx, "ReleaseCreate", linearReleaseCreateMutation, map[string]any{"input": payload}, &data); err != nil { + return LinearRelease{}, err + } + if !data.ReleaseCreate.Success || data.ReleaseCreate.Release == nil { + return LinearRelease{}, fmt.Errorf("linear ReleaseCreate failed") + } + return decodeLinearRelease(*data.ReleaseCreate.Release), nil +} + +// UpdateRelease updates a Linear Release name and membership. +func (c *LinearClient) UpdateRelease(ctx context.Context, id, name string, issueIDs []string) (LinearRelease, error) { + id = strings.TrimSpace(id) + if id == "" { + return LinearRelease{}, fmt.Errorf("linear release id must be nonempty") + } + payload := map[string]any{} + if strings.TrimSpace(name) != "" { + payload["name"] = name + } + if issueIDs != nil { + payload["issueIds"] = append([]string(nil), issueIDs...) + } + var data struct { + ReleaseUpdate struct { + Success bool `json:"success"` + Release *linearReleaseNode `json:"release"` + } `json:"releaseUpdate"` + } + if err := c.do(ctx, "ReleaseUpdate", linearReleaseUpdateMutation, map[string]any{"id": id, "input": payload}, &data); err != nil { + return LinearRelease{}, err + } + if !data.ReleaseUpdate.Success || data.ReleaseUpdate.Release == nil { + return LinearRelease{}, fmt.Errorf("linear ReleaseUpdate failed") + } + return decodeLinearRelease(*data.ReleaseUpdate.Release), nil +} + +// FindLabelID returns the id of a team-scoped label, or empty if missing. +func (c *LinearClient) FindLabelID(ctx context.Context, teamID, name string) (string, error) { + name = strings.TrimSpace(name) + teamID = strings.TrimSpace(teamID) + if name == "" { + return "", fmt.Errorf("linear label name must be nonempty") + } + if teamID == "" { + return "", fmt.Errorf("linear label lookup requires team id") + } + var data struct { + IssueLabels struct { + Nodes []struct { + ID string `json:"id"` + Name string `json:"name"` + Team *struct { + ID string `json:"id"` + } `json:"team"` + } `json:"nodes"` + } `json:"issueLabels"` + } + if err := c.do(ctx, "IssueLabels", linearIssueLabelsQuery, map[string]any{"name": name, "teamId": teamID}, &data); err != nil { + return "", err + } + for _, node := range data.IssueLabels.Nodes { + if !strings.EqualFold(node.Name, name) { + continue + } + if node.Team != nil && strings.TrimSpace(node.Team.ID) != "" && node.Team.ID != teamID { + continue + } + return node.ID, nil + } + return "", nil +} + +// CreateLabel creates a team-scoped Linear label. +func (c *LinearClient) CreateLabel(ctx context.Context, teamID, name string) (string, error) { + name = strings.TrimSpace(name) + teamID = strings.TrimSpace(teamID) + if name == "" || teamID == "" { + return "", fmt.Errorf("linear label create requires team id and name") + } + var data struct { + IssueLabelCreate struct { + Success bool `json:"success"` + IssueLabel *struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"issueLabel"` + } `json:"issueLabelCreate"` + } + if err := c.do(ctx, "IssueLabelCreate", linearIssueLabelCreateMutation, map[string]any{"input": map[string]any{"name": name, "teamId": teamID}}, &data); err != nil { + return "", err + } + if !data.IssueLabelCreate.Success || data.IssueLabelCreate.IssueLabel == nil { + return "", fmt.Errorf("linear IssueLabelCreate failed") + } + return data.IssueLabelCreate.IssueLabel.ID, nil +} + +// CreateComment adds a comment on a Linear issue. +func (c *LinearClient) CreateComment(ctx context.Context, issueID, body string) error { + issueID = strings.TrimSpace(issueID) + if issueID == "" { + return fmt.Errorf("linear comment requires an issue id") + } + var data struct { + CommentCreate struct { + Success bool `json:"success"` + } `json:"commentCreate"` + } + if err := c.do(ctx, "CommentCreate", linearCommentCreateMutation, map[string]any{"input": map[string]any{"issueId": issueID, "body": body}}, &data); err != nil { + return err + } + if !data.CommentCreate.Success { + return fmt.Errorf("linear CommentCreate failed") + } + return nil +} + +// EnsureLabel returns an existing label id or creates the label on the team. +// A create race retries with a team-scoped find before failing. +func (c *LinearClient) EnsureLabel(ctx context.Context, teamID, name string) (string, error) { + id, err := c.FindLabelID(ctx, teamID, name) + if err != nil { + return "", err + } + if id != "" { + return id, nil + } + id, err = c.CreateLabel(ctx, teamID, name) + if err == nil { + return id, nil + } + found, findErr := c.FindLabelID(ctx, teamID, name) + if findErr != nil { + return "", findErr + } + if found != "" { + return found, nil + } + return "", err +} diff --git a/internal/state/linear_fake.go b/internal/state/linear_fake.go new file mode 100644 index 000000000..827c9bb1c --- /dev/null +++ b/internal/state/linear_fake.go @@ -0,0 +1,799 @@ +package state + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync" + "time" +) + +// LinearFake is an in-memory Linear GraphQL workspace for hermetic tests. +// It implements http.Handler and speaks the same operation names as LinearClient. +type LinearFake struct { + mu sync.Mutex + Team LinearFakeTeam + Issues map[string]*LinearFakeIssue + Releases map[string]*LinearFakeRelease + Labels map[string]*LinearFakeLabel + Comments []LinearFakeComment + SupportsReleases bool + Unreachable bool + SkipLabelLookups int + ReleaseMutationError string + nextIssue int + nextID int +} + +// LinearFakeTeam is the single team the fake workspace serves. +type LinearFakeTeam struct { + ID string + Key string + States []LinearState +} + +// LinearFakeIssue is one issue in the fake workspace. +type LinearFakeIssue struct { + ID string + Identifier string + Title string + Description string + URL string + UpdatedAt time.Time + State LinearState + ParentID string + LabelIDs []string + ReleaseID string +} + +// LinearFakeRelease is one release in the fake workspace. +type LinearFakeRelease struct { + ID string + Name string +} + +// LinearFakeLabel is one label in the fake workspace. +type LinearFakeLabel struct { + ID string + Name string + TeamID string +} + +// LinearFakeComment is one comment recorded by the fake. +type LinearFakeComment struct { + ID string + IssueID string + Body string +} + +type linearFakeRequest struct { + Query string `json:"query"` + OperationName string `json:"operationName"` + Variables json.RawMessage `json:"variables"` +} + +// NewLinearFake returns a workspace with team ENG and the seven-type states. +func NewLinearFake() *LinearFake { + return &LinearFake{ + Team: LinearFakeTeam{ + ID: "team_eng", + Key: "ENG", + States: []LinearState{ + {ID: "state_triage", Name: "Triage", Type: "triage"}, + {ID: "state_backlog", Name: "Backlog", Type: "backlog"}, + {ID: "state_todo", Name: "Todo", Type: "unstarted"}, + {ID: "state_active", Name: "In Progress", Type: "started"}, + {ID: "state_done", Name: "Done", Type: "completed"}, + {ID: "state_cancelled", Name: "Canceled", Type: "canceled"}, + }, + }, + Issues: map[string]*LinearFakeIssue{}, + Releases: map[string]*LinearFakeRelease{}, + Labels: map[string]*LinearFakeLabel{}, + SupportsReleases: true, + nextIssue: 1, + } +} + +func (f *LinearFake) nextOpaque(prefix string) string { + f.nextID++ + return fmt.Sprintf("%s_%d", prefix, f.nextID) +} + +func (f *LinearFake) stateByID(id string) LinearState { + for _, state := range f.Team.States { + if state.ID == id { + return state + } + } + return LinearState{} +} + +func (f *LinearFake) stateByType(typeName string) LinearState { + for _, state := range f.Team.States { + if state.Type == typeName { + return state + } + } + return LinearState{} +} + +func (f *LinearFake) issueByID(id string) *LinearFakeIssue { + if issue, ok := f.Issues[id]; ok { + return issue + } + for _, issue := range f.Issues { + if issue.ID == id || issue.Identifier == id { + return issue + } + } + return nil +} + +func (f *LinearFake) releaseByRef(ref string) *LinearFakeRelease { + if release, ok := f.Releases[ref]; ok { + return release + } + for _, release := range f.Releases { + if release.ID == ref || release.Name == ref { + return release + } + } + return nil +} + +func (f *LinearFake) childrenOf(parentID string) []*LinearFakeIssue { + var children []*LinearFakeIssue + for _, issue := range f.Issues { + if issue.ParentID == parentID { + children = append(children, issue) + } + } + return children +} + +func (f *LinearFake) labelNames(ids []string) []map[string]string { + nodes := []map[string]string{} + for _, id := range ids { + if label, ok := f.Labels[id]; ok { + nodes = append(nodes, map[string]string{"id": label.ID, "name": label.Name}) + } + } + return nodes +} + +func (f *LinearFake) issuePayload(issue *LinearFakeIssue) map[string]any { + var parent any + if issue.ParentID != "" { + if parentIssue := f.issueByID(issue.ParentID); parentIssue != nil { + parent = map[string]string{"id": parentIssue.ID, "identifier": parentIssue.Identifier} + } + } + children := []map[string]string{} + for _, child := range f.childrenOf(issue.ID) { + children = append(children, map[string]string{"id": child.ID, "identifier": child.Identifier}) + } + return map[string]any{ + "id": issue.ID, + "identifier": issue.Identifier, + "title": issue.Title, + "description": issue.Description, + "url": issue.URL, + "updatedAt": issue.UpdatedAt.UTC().Format(time.RFC3339Nano), + "state": issue.State, + "parent": parent, + "children": map[string]any{"nodes": children}, + "labels": map[string]any{"nodes": f.labelNames(issue.LabelIDs)}, + } +} + +func (f *LinearFake) releasePayload(release *LinearFakeRelease) map[string]any { + nodes := []map[string]string{} + for _, issue := range f.Issues { + if issue.ReleaseID == release.ID { + nodes = append(nodes, map[string]string{"id": issue.ID, "identifier": issue.Identifier}) + } + } + return map[string]any{ + "id": release.ID, + "name": release.Name, + "issues": map[string]any{"nodes": nodes}, + } +} + +// SeedLabel inserts a team-scoped label with a known name. +func (f *LinearFake) SeedLabel(teamID, name string) *LinearFakeLabel { + f.mu.Lock() + defer f.mu.Unlock() + if teamID == "" { + teamID = f.Team.ID + } + label := &LinearFakeLabel{ID: f.nextOpaque("lbl"), Name: name, TeamID: teamID} + f.Labels[label.ID] = label + return label +} + +// SeedIssue inserts an issue with a known identifier. Identifier defaults to ENG-N. +func (f *LinearFake) SeedIssue(identifier, title, description, stateType, parentKey string) *LinearFakeIssue { + f.mu.Lock() + defer f.mu.Unlock() + return f.seedIssueLocked(identifier, title, description, stateType, parentKey) +} + +func (f *LinearFake) seedIssueLocked(identifier, title, description, stateType, parentKey string) *LinearFakeIssue { + if identifier == "" { + identifier = fmt.Sprintf("%s-%d", f.Team.Key, f.nextIssue) + f.nextIssue++ + } else if n := linearIdentifierNumber(identifier); n >= f.nextIssue { + f.nextIssue = n + 1 + } + state := f.stateByType(stateType) + if state.ID == "" { + state = f.stateByType("triage") + } + var parentID string + if parentKey != "" { + if parent := f.issueByID(parentKey); parent != nil { + parentID = parent.ID + } + } + issue := &LinearFakeIssue{ + ID: f.nextOpaque("iss"), + Identifier: identifier, + Title: title, + Description: description, + URL: "https://linear.app/loaf/issue/" + identifier, + UpdatedAt: time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC), + State: state, + ParentID: parentID, + } + f.Issues[issue.ID] = issue + return issue +} + +func linearIdentifierNumber(identifier string) int { + parts := strings.Split(identifier, "-") + if len(parts) < 2 { + return 0 + } + n, err := strconv.Atoi(parts[len(parts)-1]) + if err != nil { + return 0 + } + return n +} + +// SetIssueState moves an issue to the first workflow state of typeName. +func (f *LinearFake) SetIssueState(ref, typeName string, updatedAt time.Time) { + f.mu.Lock() + defer f.mu.Unlock() + issue := f.issueByID(ref) + if issue == nil { + return + } + if state := f.stateByType(typeName); state.ID != "" { + issue.State = state + } + if !updatedAt.IsZero() { + issue.UpdatedAt = updatedAt + } else { + issue.UpdatedAt = time.Now().UTC() + } +} + +// SetIssueTitle sets the tracker-owned title. +func (f *LinearFake) SetIssueTitle(ref, title string, updatedAt time.Time) { + f.mu.Lock() + defer f.mu.Unlock() + issue := f.issueByID(ref) + if issue == nil { + return + } + issue.Title = title + if !updatedAt.IsZero() { + issue.UpdatedAt = updatedAt + } +} + +// SetIssueDescription sets the Linear description. +func (f *LinearFake) SetIssueDescription(ref, description string, updatedAt time.Time) { + f.mu.Lock() + defer f.mu.Unlock() + issue := f.issueByID(ref) + if issue == nil { + return + } + issue.Description = description + if !updatedAt.IsZero() { + issue.UpdatedAt = updatedAt + } +} + +// Issue returns a copy of a seeded or created issue. +func (f *LinearFake) Issue(ref string) (LinearFakeIssue, bool) { + f.mu.Lock() + defer f.mu.Unlock() + issue := f.issueByID(ref) + if issue == nil { + return LinearFakeIssue{}, false + } + return *issue, true +} + +// Release returns a copy of a created release. +func (f *LinearFake) Release(ref string) (LinearFakeRelease, bool) { + f.mu.Lock() + defer f.mu.Unlock() + release := f.releaseByRef(ref) + if release == nil { + return LinearFakeRelease{}, false + } + return *release, true +} + +// ReleaseIssueKeys returns Linear identifiers attached to a release. +func (f *LinearFake) ReleaseIssueKeys(ref string) []string { + f.mu.Lock() + defer f.mu.Unlock() + release := f.releaseByRef(ref) + if release == nil { + return nil + } + var keys []string + for _, issue := range f.Issues { + if issue.ReleaseID == release.ID { + keys = append(keys, issue.Identifier) + } + } + return keys +} + +// IssueLabelNames returns labels currently on an issue. +func (f *LinearFake) IssueLabelNames(ref string) []string { + f.mu.Lock() + defer f.mu.Unlock() + issue := f.issueByID(ref) + if issue == nil { + return nil + } + var names []string + for _, id := range issue.LabelIDs { + if label, ok := f.Labels[id]; ok { + names = append(names, label.Name) + } + } + return names +} + +// IssueComments returns comments recorded on an issue. +func (f *LinearFake) IssueComments(ref string) []LinearFakeComment { + f.mu.Lock() + defer f.mu.Unlock() + issue := f.issueByID(ref) + if issue == nil { + return nil + } + var comments []LinearFakeComment + for _, comment := range f.Comments { + if comment.IssueID == issue.ID { + comments = append(comments, comment) + } + } + return comments +} + +// ServeHTTP implements Linear's GraphQL POST endpoint. +func (f *LinearFake) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + f.mu.Lock() + unreachable := f.Unreachable + f.mu.Unlock() + if unreachable { + http.Error(w, "linear unreachable", http.StatusBadGateway) + return + } + if strings.TrimSpace(r.Header.Get("Authorization")) == "" { + http.Error(w, "missing authorization", http.StatusUnauthorized) + return + } + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + var req linearFakeRequest + if err := json.Unmarshal(body, &req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + operation := req.OperationName + if operation == "" { + operation = linearFakeOperationFromQuery(req.Query) + } + f.mu.Lock() + defer f.mu.Unlock() + data, gqlErr := f.dispatch(operation, req.Variables) + response := map[string]any{} + if gqlErr != "" { + response["errors"] = []map[string]string{{"message": gqlErr}} + } else { + response["data"] = data + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) +} + +func linearFakeOperationFromQuery(query string) string { + for _, name := range []string{ + "TeamByKey", "IssueCreate", "IssueUpdate", "IssueLabels", "IssueLabelCreate", + "CommentCreate", "ReleaseCreate", "ReleaseUpdate", "Releases", "Release", "Issue", + } { + if strings.Contains(query, name) { + return name + } + } + return "" +} + +func (f *LinearFake) dispatch(operation string, rawVars json.RawMessage) (any, string) { + switch operation { + case "TeamByKey": + return f.handleTeamByKey(rawVars) + case "Issue": + return f.handleIssue(rawVars) + case "IssueCreate": + return f.handleIssueCreate(rawVars) + case "IssueUpdate": + return f.handleIssueUpdate(rawVars) + case "Releases": + return f.handleReleases() + case "Release": + return f.handleRelease(rawVars) + case "ReleaseCreate": + return f.handleReleaseCreate(rawVars) + case "ReleaseUpdate": + return f.handleReleaseUpdate(rawVars) + case "IssueLabels": + return f.handleIssueLabels(rawVars) + case "IssueLabelCreate": + return f.handleIssueLabelCreate(rawVars) + case "CommentCreate": + return f.handleCommentCreate(rawVars) + default: + return nil, fmt.Sprintf("unknown operation %q", operation) + } +} + +func decodeFakeVars(raw json.RawMessage, dest any) error { + if len(raw) == 0 { + return nil + } + return json.Unmarshal(raw, dest) +} + +func (f *LinearFake) handleTeamByKey(raw json.RawMessage) (any, string) { + var vars struct { + Key string `json:"key"` + } + if err := decodeFakeVars(raw, &vars); err != nil { + return nil, err.Error() + } + if vars.Key != f.Team.Key { + return map[string]any{"teams": map[string]any{"nodes": []any{}}}, "" + } + return map[string]any{ + "teams": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": f.Team.ID, + "key": f.Team.Key, + "states": map[string]any{"nodes": f.Team.States}, + }, + }, + }, + }, "" +} + +func (f *LinearFake) handleIssue(raw json.RawMessage) (any, string) { + var vars struct { + ID string `json:"id"` + } + if err := decodeFakeVars(raw, &vars); err != nil { + return nil, err.Error() + } + issue := f.issueByID(vars.ID) + if issue == nil { + return map[string]any{"issue": nil}, "" + } + return map[string]any{"issue": f.issuePayload(issue)}, "" +} + +func (f *LinearFake) handleIssueCreate(raw json.RawMessage) (any, string) { + var vars struct { + Input struct { + TeamID string `json:"teamId"` + Title string `json:"title"` + Description string `json:"description"` + ParentID string `json:"parentId"` + StateID string `json:"stateId"` + } `json:"input"` + } + if err := decodeFakeVars(raw, &vars); err != nil { + return nil, err.Error() + } + if vars.Input.TeamID != f.Team.ID { + return nil, "unknown team" + } + state := f.stateByType("triage") + if vars.Input.StateID != "" { + if found := f.stateByID(vars.Input.StateID); found.ID != "" { + state = found + } + } + var parentID string + if vars.Input.ParentID != "" { + if parent := f.issueByID(vars.Input.ParentID); parent != nil { + parentID = parent.ID + } + } + identifier := fmt.Sprintf("%s-%d", f.Team.Key, f.nextIssue) + f.nextIssue++ + issue := &LinearFakeIssue{ + ID: f.nextOpaque("iss"), + Identifier: identifier, + Title: vars.Input.Title, + Description: vars.Input.Description, + URL: "https://linear.app/loaf/issue/" + identifier, + UpdatedAt: time.Now().UTC(), + State: state, + ParentID: parentID, + } + f.Issues[issue.ID] = issue + return map[string]any{ + "issueCreate": map[string]any{"success": true, "issue": f.issuePayload(issue)}, + }, "" +} + +func issueUpdateCarriesTitle(raw json.RawMessage) bool { + if len(raw) == 0 { + return false + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return false + } + _, ok := fields["title"] + return ok +} + +func (f *LinearFake) handleIssueUpdate(raw json.RawMessage) (any, string) { + var vars struct { + ID string `json:"id"` + Input json.RawMessage `json:"input"` + } + if err := decodeFakeVars(raw, &vars); err != nil { + return nil, err.Error() + } + if issueUpdateCarriesTitle(vars.Input) { + return nil, "issue update must not include title; Linear owns the name" + } + var input struct { + Description *string `json:"description"` + StateID string `json:"stateId"` + ReleaseID string `json:"releaseId"` + AddedLabelIDs []string `json:"addedLabelIds"` + } + if len(vars.Input) > 0 { + if err := json.Unmarshal(vars.Input, &input); err != nil { + return nil, err.Error() + } + } + issue := f.issueByID(vars.ID) + if issue == nil { + return nil, "issue not found" + } + if input.Description != nil { + issue.Description = *input.Description + } + if input.StateID != "" { + if state := f.stateByID(input.StateID); state.ID != "" { + issue.State = state + } + } + if input.ReleaseID != "" { + if release := f.releaseByRef(input.ReleaseID); release != nil { + issue.ReleaseID = release.ID + } + } + for _, labelID := range input.AddedLabelIDs { + if _, ok := f.Labels[labelID]; !ok { + continue + } + found := false + for _, existing := range issue.LabelIDs { + if existing == labelID { + found = true + break + } + } + if !found { + issue.LabelIDs = append(issue.LabelIDs, labelID) + } + } + issue.UpdatedAt = time.Now().UTC() + return map[string]any{ + "issueUpdate": map[string]any{"success": true, "issue": f.issuePayload(issue)}, + }, "" +} + +func (f *LinearFake) handleReleases() (any, string) { + if !f.SupportsReleases { + return nil, `Cannot query field "releases" on type "Query".` + } + nodes := []any{} + for _, release := range f.Releases { + nodes = append(nodes, f.releasePayload(release)) + } + return map[string]any{"releases": map[string]any{"nodes": nodes}}, "" +} + +func (f *LinearFake) handleRelease(raw json.RawMessage) (any, string) { + if !f.SupportsReleases { + return nil, `Cannot query field "release" on type "Query".` + } + var vars struct { + ID string `json:"id"` + } + if err := decodeFakeVars(raw, &vars); err != nil { + return nil, err.Error() + } + release := f.releaseByRef(vars.ID) + if release == nil { + return map[string]any{"release": nil}, "" + } + return map[string]any{"release": f.releasePayload(release)}, "" +} + +func (f *LinearFake) handleReleaseCreate(raw json.RawMessage) (any, string) { + if !f.SupportsReleases { + return nil, `Cannot query field "releaseCreate" on type "Mutation".` + } + if f.ReleaseMutationError != "" { + return nil, f.ReleaseMutationError + } + var vars struct { + Input struct { + Name string `json:"name"` + IssueIDs []string `json:"issueIds"` + } `json:"input"` + } + if err := decodeFakeVars(raw, &vars); err != nil { + return nil, err.Error() + } + release := &LinearFakeRelease{ID: f.nextOpaque("rel"), Name: vars.Input.Name} + f.Releases[release.ID] = release + for _, issueID := range vars.Input.IssueIDs { + if issue := f.issueByID(issueID); issue != nil { + issue.ReleaseID = release.ID + } + } + return map[string]any{ + "releaseCreate": map[string]any{"success": true, "release": f.releasePayload(release)}, + }, "" +} + +func (f *LinearFake) handleReleaseUpdate(raw json.RawMessage) (any, string) { + if !f.SupportsReleases { + return nil, `Cannot query field "releaseUpdate" on type "Mutation".` + } + if f.ReleaseMutationError != "" { + return nil, f.ReleaseMutationError + } + var vars struct { + ID string `json:"id"` + Input struct { + Name string `json:"name"` + IssueIDs []string `json:"issueIds"` + } `json:"input"` + } + if err := decodeFakeVars(raw, &vars); err != nil { + return nil, err.Error() + } + release := f.releaseByRef(vars.ID) + if release == nil { + return nil, "release not found" + } + if vars.Input.Name != "" { + release.Name = vars.Input.Name + } + if vars.Input.IssueIDs != nil { + for _, issue := range f.Issues { + if issue.ReleaseID == release.ID { + issue.ReleaseID = "" + } + } + for _, issueID := range vars.Input.IssueIDs { + if issue := f.issueByID(issueID); issue != nil { + issue.ReleaseID = release.ID + } + } + } + return map[string]any{ + "releaseUpdate": map[string]any{"success": true, "release": f.releasePayload(release)}, + }, "" +} + +func (f *LinearFake) handleIssueLabels(raw json.RawMessage) (any, string) { + var vars struct { + Name string `json:"name"` + TeamID string `json:"teamId"` + } + if err := decodeFakeVars(raw, &vars); err != nil { + return nil, err.Error() + } + if f.SkipLabelLookups > 0 { + f.SkipLabelLookups-- + return map[string]any{"issueLabels": map[string]any{"nodes": []any{}}}, "" + } + nodes := []any{} + for _, label := range f.Labels { + if !strings.EqualFold(label.Name, vars.Name) { + continue + } + if vars.TeamID != "" && label.TeamID != vars.TeamID { + continue + } + nodes = append(nodes, map[string]any{ + "id": label.ID, + "name": label.Name, + "team": map[string]string{"id": label.TeamID}, + }) + } + return map[string]any{"issueLabels": map[string]any{"nodes": nodes}}, "" +} + +func (f *LinearFake) handleIssueLabelCreate(raw json.RawMessage) (any, string) { + var vars struct { + Input struct { + Name string `json:"name"` + TeamID string `json:"teamId"` + } `json:"input"` + } + if err := decodeFakeVars(raw, &vars); err != nil { + return nil, err.Error() + } + for _, label := range f.Labels { + if strings.EqualFold(label.Name, vars.Input.Name) && label.TeamID == vars.Input.TeamID { + return nil, fmt.Sprintf("label %q already exists on team", vars.Input.Name) + } + } + label := &LinearFakeLabel{ID: f.nextOpaque("lbl"), Name: vars.Input.Name, TeamID: vars.Input.TeamID} + f.Labels[label.ID] = label + return map[string]any{ + "issueLabelCreate": map[string]any{"success": true, "issueLabel": map[string]string{"id": label.ID, "name": label.Name}}, + }, "" +} + +func (f *LinearFake) handleCommentCreate(raw json.RawMessage) (any, string) { + var vars struct { + Input struct { + IssueID string `json:"issueId"` + Body string `json:"body"` + } `json:"input"` + } + if err := decodeFakeVars(raw, &vars); err != nil { + return nil, err.Error() + } + if f.issueByID(vars.Input.IssueID) == nil { + return nil, "issue not found" + } + comment := LinearFakeComment{ID: f.nextOpaque("cmt"), IssueID: f.issueByID(vars.Input.IssueID).ID, Body: vars.Input.Body} + f.Comments = append(f.Comments, comment) + return map[string]any{"commentCreate": map[string]any{"success": true, "comment": map[string]string{"id": comment.ID, "body": comment.Body}}}, "" +} diff --git a/internal/state/linear_test.go b/internal/state/linear_test.go new file mode 100644 index 000000000..1b7f3f001 --- /dev/null +++ b/internal/state/linear_test.go @@ -0,0 +1,360 @@ +package state + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestMapLinearStateType(t *testing.T) { + cases := map[string]string{ + "triage": IssueStatusTriage, + "backlog": IssueStatusBacklog, + "unstarted": IssueStatusTodo, + "started": IssueStatusActive, + "completed": IssueStatusDone, + "canceled": IssueStatusCancelled, + "cancelled": IssueStatusCancelled, + "unknown": IssueStatusTriage, + } + for input, want := range cases { + if got := MapLinearStateType(input); got != want { + t.Fatalf("MapLinearStateType(%q) = %q, want %q", input, got, want) + } + } +} + +func TestLinearClientTeamAndIssueRoundTrip(t *testing.T) { + fake := NewLinearFake() + parent := fake.SeedIssue("ENG-1", "Parent", "root body", "started", "") + fake.SeedIssue("ENG-2", "Child", "child body", "unstarted", "ENG-1") + server := httptest.NewServer(fake) + t.Cleanup(server.Close) + + client := NewLinearClient(server.URL, "test-key") + ctx := context.Background() + team, err := client.TeamByKey(ctx, "ENG") + if err != nil { + t.Fatalf("TeamByKey() error = %v", err) + } + if team.Key != "ENG" || len(team.States) == 0 { + t.Fatalf("team = %#v", team) + } + issue, err := client.Issue(ctx, "ENG-1") + if err != nil { + t.Fatalf("Issue() error = %v", err) + } + if issue.ID != parent.ID || issue.Identifier != "ENG-1" || MapLinearStateType(issue.State.Type) != IssueStatusActive { + t.Fatalf("issue = %#v", issue) + } + if len(issue.ChildKeys) != 1 || issue.ChildKeys[0] != "ENG-2" { + t.Fatalf("children = %#v", issue.ChildKeys) + } + + created, err := client.CreateIssue(ctx, LinearCreateIssueInput{TeamID: team.ID, Title: "Minted", Description: "from loaf"}) + if err != nil { + t.Fatalf("CreateIssue() error = %v", err) + } + if created.Identifier != "ENG-3" { + t.Fatalf("minted identifier = %q, want ENG-3", created.Identifier) + } +} + +func TestLinearClientReleasesUnsupported(t *testing.T) { + fake := NewLinearFake() + fake.SupportsReleases = false + server := httptest.NewServer(fake) + t.Cleanup(server.Close) + client := NewLinearClient(server.URL, "test-key") + ok, err := client.ReleasesSupported(context.Background()) + if err != nil { + t.Fatalf("ReleasesSupported() error = %v", err) + } + if ok { + t.Fatal("ReleasesSupported() = true, want false") + } +} + +func TestPullLinearIssueTreeKeepsParentEdges(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + if _, err := store.SetIssueIdentity(ctx, root, IssueIdentityOptions{Authority: IssueAuthorityLinear}); err != nil { + t.Fatalf("SetIssueIdentity() error = %v", err) + } + fake := NewLinearFake() + fake.SeedIssue("ENG-1", "Root", "", "triage", "") + fake.SeedIssue("ENG-2", "Child", "", "unstarted", "ENG-1") + fake.SeedIssue("ENG-3", "Grandchild", "", "started", "ENG-2") + server := httptest.NewServer(fake) + t.Cleanup(server.Close) + client := NewLinearClient(server.URL, "test-key") + + rootIssue, err := store.adoptLinearIssue(ctx, root, client, "ENG-1", "") + if err != nil { + t.Fatalf("adopt root error = %v", err) + } + tree := []Issue{rootIssue} + if err := store.pullLinearChildren(ctx, root, client, "ENG-1", &tree); err != nil { + t.Fatalf("pull children error = %v", err) + } + if len(tree) != 3 { + t.Fatalf("tree = %#v, want 3 issues", tree) + } + byAlias := map[string]Issue{} + for _, issue := range tree { + byAlias[issue.Alias] = issue + } + if byAlias["ENG-1"].ParentID != "" { + t.Fatalf("root parent = %q, want empty", byAlias["ENG-1"].ParentID) + } + if byAlias["ENG-2"].ParentID != byAlias["ENG-1"].ID { + t.Fatalf("child parent = %q, want %q", byAlias["ENG-2"].ParentID, byAlias["ENG-1"].ID) + } + if byAlias["ENG-3"].ParentID != byAlias["ENG-2"].ID { + t.Fatalf("grandchild parent = %q, want %q", byAlias["ENG-3"].ParentID, byAlias["ENG-2"].ID) + } + if byAlias["ENG-3"].Status != IssueStatusActive { + t.Fatalf("grandchild status = %q, want active", byAlias["ENG-3"].Status) + } + identity, err := store.GetIssueIdentity(ctx, root) + if err != nil { + t.Fatalf("GetIssueIdentity() error = %v", err) + } + if identity.NextNumber != 1 { + t.Fatalf("next_number = %d, want 1", identity.NextNumber) + } +} + +func TestLinearMintErrorNamesOfflinePath(t *testing.T) { + err := &LinearMintError{Err: context.Canceled} + if !strings.Contains(err.Error(), "loaf spark") || !strings.Contains(err.Error(), "loaf idea") { + t.Fatalf("error = %q, want spark/idea offline path", err) + } +} + +func TestLinearOrphanErrorNamesPullRecovery(t *testing.T) { + err := &LinearOrphanError{Identifier: "ENG-88", Err: context.Canceled} + if !strings.Contains(err.Error(), "ENG-88") || !strings.Contains(err.Error(), "loaf issue pull ENG-88") { + t.Fatalf("error = %q, want orphan key and pull recovery", err) + } +} + +func TestPullLinearIssueTreeReattachesExistingChild(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + if _, err := store.SetIssueIdentity(ctx, root, IssueIdentityOptions{Authority: IssueAuthorityLinear}); err != nil { + t.Fatalf("SetIssueIdentity() error = %v", err) + } + fake := NewLinearFake() + fake.SeedIssue("ENG-1", "Root", "", "triage", "") + fake.SeedIssue("ENG-2", "Child", "", "unstarted", "ENG-1") + server := httptest.NewServer(fake) + t.Cleanup(server.Close) + client := NewLinearClient(server.URL, "test-key") + + child, err := store.adoptLinearIssue(ctx, root, client, "ENG-2", "") + if err != nil { + t.Fatalf("adopt child error = %v", err) + } + if child.ParentID != "" { + t.Fatalf("solo child parent = %q, want empty", child.ParentID) + } + + rootIssue, err := store.adoptLinearIssue(ctx, root, client, "ENG-1", "") + if err != nil { + t.Fatalf("adopt root error = %v", err) + } + tree := []Issue{rootIssue} + if err := store.pullLinearChildren(ctx, root, client, "ENG-1", &tree); err != nil { + t.Fatalf("pull --tree after solo child error = %v", err) + } + byAlias := map[string]Issue{} + for _, issue := range tree { + byAlias[issue.Alias] = issue + } + if byAlias["ENG-2"].ParentID != byAlias["ENG-1"].ID { + t.Fatalf("reattached child parent = %q, want %q", byAlias["ENG-2"].ParentID, byAlias["ENG-1"].ID) + } + shown, err := store.ShowIssue(ctx, root, byAlias["ENG-1"].ID) + if err != nil { + t.Fatalf("ShowIssue() error = %v", err) + } + if len(shown.Children) != 1 || shown.Children[0].ID != byAlias["ENG-2"].ID { + t.Fatalf("root children = %#v, want depth-2 edge to ENG-2", shown.Children) + } +} + +func TestWriteLinearTeamConfigReplacesRoleRow(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + projectID, err := store.projectID(ctx, root) + if err != nil { + t.Fatalf("projectID() error = %v", err) + } + if err := store.upsertBackendMapping(ctx, root, backendMapping{ + EntityKind: "project", + EntityID: projectID, + ExternalKind: linearExternalKindTeam, + ExternalID: "ENG", + SyncStatus: linearSyncLinked, + }); err != nil { + t.Fatalf("write ENG team error = %v", err) + } + if err := store.upsertBackendMapping(ctx, root, backendMapping{ + EntityKind: "project", + EntityID: projectID, + ExternalKind: linearExternalKindTeam, + ExternalID: "OPS", + SyncStatus: linearSyncLinked, + }); err != nil { + t.Fatalf("write OPS team error = %v", err) + } + cfg, err := store.LoadLinearAdapterConfig(ctx, root) + if err != nil { + t.Fatalf("LoadLinearAdapterConfig() error = %v", err) + } + if cfg.TeamKey != "OPS" { + t.Fatalf("TeamKey = %q, want OPS", cfg.TeamKey) + } + var n int + if err := store.db.QueryRowContext(ctx, ` +SELECT COUNT(*) FROM backend_mappings +WHERE project_id = ? AND backend = ? AND entity_kind = 'project' AND external_kind = ? +`, projectID, linearBackend, linearExternalKindTeam).Scan(&n); err != nil { + t.Fatalf("count team rows: %v", err) + } + if n != 1 { + t.Fatalf("team rows = %d, want 1", n) + } +} + +func TestEnsureLabelRetriesAfterCreateRace(t *testing.T) { + fake := NewLinearFake() + existing := fake.SeedLabel(fake.Team.ID, "ready-for-agent") + fake.SkipLabelLookups = 1 + server := httptest.NewServer(fake) + t.Cleanup(server.Close) + client := NewLinearClient(server.URL, "test-key") + id, err := client.EnsureLabel(context.Background(), fake.Team.ID, "ready-for-agent") + if err != nil { + t.Fatalf("EnsureLabel() error = %v", err) + } + if id != existing.ID { + t.Fatalf("EnsureLabel() = %q, want raced existing %q", id, existing.ID) + } +} + +func TestFindLabelIDIsTeamScoped(t *testing.T) { + fake := NewLinearFake() + other := fake.SeedLabel("team_other", "ready-for-agent") + want := fake.SeedLabel(fake.Team.ID, "ready-for-agent") + server := httptest.NewServer(fake) + t.Cleanup(server.Close) + client := NewLinearClient(server.URL, "test-key") + id, err := client.FindLabelID(context.Background(), fake.Team.ID, "ready-for-agent") + if err != nil { + t.Fatalf("FindLabelID() error = %v", err) + } + if id != want.ID { + t.Fatalf("FindLabelID() = %q, want team-scoped %q (other=%q)", id, want.ID, other.ID) + } +} + +func TestLinearFakeRejectsIssueUpdateTitle(t *testing.T) { + fake := NewLinearFake() + issue := fake.SeedIssue("ENG-1", "Name", "", "triage", "") + server := httptest.NewServer(fake) + t.Cleanup(server.Close) + payload, err := json.Marshal(map[string]any{ + "operationName": "IssueUpdate", + "variables": map[string]any{ + "id": issue.ID, + "input": map[string]any{"title": "must not land"}, + }, + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + req, err := http.NewRequest(http.MethodPost, server.URL, bytes.NewReader(payload)) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "test-key") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("IssueUpdate POST error = %v", err) + } + defer resp.Body.Close() + var decoded struct { + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + } + if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil { + t.Fatalf("decode: %v", err) + } + if len(decoded.Errors) == 0 || !strings.Contains(decoded.Errors[0].Message, "title") { + t.Fatalf("errors = %#v, want title rejection", decoded.Errors) + } + got, ok := fake.Issue("ENG-1") + if !ok || got.Title != "Name" { + t.Fatalf("title mutated: %#v", got) + } +} + +func TestReconcileLinearIssueDescriptionUsesPushRender(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + if _, err := store.SetIssueIdentity(ctx, root, IssueIdentityOptions{Authority: IssueAuthorityLinear}); err != nil { + t.Fatalf("SetIssueIdentity() error = %v", err) + } + fake := NewLinearFake() + fake.SeedIssue("ENG-1", "Shaped", "raw tracker body", "unstarted", "") + server := httptest.NewServer(fake) + t.Cleanup(server.Close) + client := NewLinearClient(server.URL, "test-key") + adopted, err := store.adoptLinearIssue(ctx, root, client, "ENG-1", "") + if err != nil { + t.Fatalf("adopt error = %v", err) + } + if _, err := store.UpdateIssue(ctx, root, IssueUpdateOptions{Ref: adopted.ID, Body: "local shaping", SetBody: true}); err != nil { + t.Fatalf("edit body error = %v", err) + } + shown, err := store.ShowIssue(ctx, root, adopted.ID) + if err != nil { + t.Fatalf("ShowIssue() error = %v", err) + } + rendered := RenderIssueMarkdown(shown) + if _, err := store.PushLinearIssue(ctx, root, client, adopted.ID, rendered); err != nil { + t.Fatalf("PushLinearIssue() error = %v", err) + } + afterPush, err := store.ReconcileLinearIssue(ctx, root, client, adopted.ID, false, false) + if err != nil { + t.Fatalf("reconcile after push error = %v", err) + } + for _, conflict := range afterPush.Conflicts { + if conflict.Field == "description" { + t.Fatalf("false description drift after push: %#v", afterPush.Conflicts) + } + } + fake.SetIssueDescription("ENG-1", "tracker edited the render", time.Now().UTC()) + afterEdit, err := store.ReconcileLinearIssue(ctx, root, client, adopted.ID, false, false) + if err != nil { + t.Fatalf("reconcile after remote edit error = %v", err) + } + found := false + for _, conflict := range afterEdit.Conflicts { + if conflict.Field == "description" { + found = true + } + } + if !found { + t.Fatalf("conflicts = %#v, want description drift after remote edit", afterEdit.Conflicts) + } +} diff --git a/internal/state/migrations/0014_issues_and_identity.sql b/internal/state/migrations/0014_issues_and_identity.sql new file mode 100644 index 000000000..c3b0fb468 --- /dev/null +++ b/internal/state/migrations/0014_issues_and_identity.sql @@ -0,0 +1,79 @@ +-- Issue schema and identity foundation. +-- +-- Issues are the recursive work entity. This migration is additive: the +-- existing tasks and specs tables are left untouched and go inert. There is +-- no data migration and no compatibility shim. +-- +-- Status on issues.status is a projection of the append-only events table +-- (entity_kind 'issue'). Writes go through events; a parity check proves the +-- column equals the latest event. Default status at creation is triage. +-- There is no blocked status (that is a relationship) and no review status +-- (a display name for active). +-- +-- Title and body are fully mutable at every status. Body is a plain TEXT +-- column; the sources/body_source_id indirection is not reused. Fog holds +-- questions not yet sharp enough to be issues. +-- +-- Human-readable IDs for local authority are minted from +-- issue_identity.next_number and stored in aliases (entity_kind and +-- namespace 'issue'). The counter is a stored value, never derived from +-- MAX() over aliases or issues. Minted numbers are permanent: removing or +-- hard-deleting an issue does not free its number. Tracker authorities +-- (linear, github) mint no local alias. +-- +-- parent_id is a same-project self-reference. Cycle prevention is a +-- write-time guard in the Go API, not a schema trigger. +-- +-- issue_criteria.command / expect use the same grammar loaf change verify +-- parses today (exit N, contains <text>). This migration only stores them. + +CREATE TABLE IF NOT EXISTS issues ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + parent_id TEXT, + kind TEXT NOT NULL CHECK (kind IN ('delivery', 'decision')), + title TEXT NOT NULL CHECK (length(trim(title)) > 0), + body TEXT NOT NULL DEFAULT '', + fog TEXT, + status TEXT NOT NULL CHECK (status IN ('triage', 'backlog', 'todo', 'active', 'done', 'cancelled', 'duplicate')), + archived_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (project_id, parent_id) REFERENCES issues(project_id, id), + UNIQUE (project_id, id) +); +CREATE INDEX IF NOT EXISTS idx_issues_project ON issues (project_id, created_at); +CREATE INDEX IF NOT EXISTS idx_issues_parent ON issues (project_id, parent_id); +CREATE INDEX IF NOT EXISTS idx_issues_status ON issues (project_id, status); + +CREATE TABLE IF NOT EXISTS issue_criteria ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + issue_id TEXT NOT NULL, + position INTEGER NOT NULL CHECK (position >= 1), + text TEXT NOT NULL CHECK (length(trim(text)) > 0), + command TEXT, + expect TEXT, + tier TEXT NOT NULL CHECK (tier IN ('V', 'H')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (project_id, issue_id) REFERENCES issues(project_id, id) ON DELETE CASCADE, + UNIQUE (issue_id, position) +); +CREATE INDEX IF NOT EXISTS idx_issue_criteria_issue ON issue_criteria (project_id, issue_id, position); + +-- One authority row per project. next_number is the next local alias to mint +-- and is never recomputed from existing rows. +CREATE TABLE IF NOT EXISTS issue_identity ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + authority TEXT NOT NULL CHECK (authority IN ('local', 'linear', 'github')), + prefix TEXT NOT NULL CHECK (prefix GLOB '[A-Za-z]*' AND prefix NOT GLOB '*[^A-Za-z0-9]*' AND length(prefix) = length(CAST(prefix AS BLOB))), + next_number INTEGER NOT NULL CHECK (next_number >= 1), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + UNIQUE (project_id) +); diff --git a/internal/state/migrations/0015_issue_criterion_claims.sql b/internal/state/migrations/0015_issue_criterion_claims.sql new file mode 100644 index 000000000..2c7e5dedf --- /dev/null +++ b/internal/state/migrations/0015_issue_criterion_claims.sql @@ -0,0 +1,33 @@ +-- Criterion-grain claims for derived issue readiness. +-- +-- 0014 is the issue model. This migration adds the one child table that +-- makes decomposition honesty mechanical: a child criterion claims a parent +-- criterion by opaque id, never by position. Positions renumber; claims +-- must not. +-- +-- Coverage (every parent criterion is claimed) and containment (every child +-- criterion claims some parent criterion) are derived at read time from +-- these rows. Promote writes a claim by construction. There is no data +-- backfill: existing issues have no claims until an operator records them. +-- +-- Claim FKs are project-scoped: a row cannot satisfy coverage by pointing +-- at a criterion that belongs to another project. The unique index on +-- issue_criteria (project_id, id) is the parent key those FKs require. + +CREATE UNIQUE INDEX IF NOT EXISTS idx_issue_criteria_project_id ON issue_criteria (project_id, id); + +CREATE TABLE IF NOT EXISTS issue_criterion_claims ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + child_criterion_id TEXT NOT NULL, + parent_criterion_id TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (project_id, child_criterion_id) REFERENCES issue_criteria(project_id, id) ON DELETE CASCADE, + FOREIGN KEY (project_id, parent_criterion_id) REFERENCES issue_criteria(project_id, id) ON DELETE CASCADE, + UNIQUE (child_criterion_id, parent_criterion_id), + CHECK (child_criterion_id != parent_criterion_id) +); +CREATE INDEX IF NOT EXISTS idx_issue_criterion_claims_parent ON issue_criterion_claims (project_id, parent_criterion_id); +CREATE INDEX IF NOT EXISTS idx_issue_criterion_claims_child ON issue_criterion_claims (project_id, child_criterion_id); diff --git a/internal/state/migrations/0016_releases.sql b/internal/state/migrations/0016_releases.sql new file mode 100644 index 000000000..ae18e7507 --- /dev/null +++ b/internal/state/migrations/0016_releases.sql @@ -0,0 +1,41 @@ +-- Retroactive releases: facts about what landed, never a plan. +-- +-- A release is recorded after a tag exists. Members are the attributed +-- issues observed in the baseline..HEAD range, plus optional prerelease +-- references when a stable is cut with --includes. There is no +-- target_release, no cohort, and no forward binding of issues to versions. +-- +-- member_kind 'release' is a narrative reference, not a union: cutting a +-- stable does not re-include the prerelease's issue members. member_id is +-- polymorphic (issue or release) and cannot carry a hard FK; kind and +-- existence are validated on the write path. The composite FK on +-- (project_id, release_id) keeps membership inside the same project. + +CREATE TABLE IF NOT EXISTS releases ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + version TEXT NOT NULL, + tag TEXT NOT NULL, + tagged_commit TEXT NOT NULL, + notes TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + UNIQUE (project_id, version), + UNIQUE (project_id, tag) +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_releases_project_id ON releases (project_id, id); + +CREATE TABLE IF NOT EXISTS release_members ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + release_id TEXT NOT NULL, + member_kind TEXT NOT NULL, + member_id TEXT NOT NULL, + recorded_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (project_id, release_id) REFERENCES releases(project_id, id) ON DELETE CASCADE, + UNIQUE (release_id, member_kind, member_id), + CHECK (member_kind IN ('issue', 'release')) +); +CREATE INDEX IF NOT EXISTS idx_release_members_release ON release_members (project_id, release_id); diff --git a/internal/state/migrations/0017_issue_started_workspace.sql b/internal/state/migrations/0017_issue_started_workspace.sql new file mode 100644 index 000000000..5d5092e27 --- /dev/null +++ b/internal/state/migrations/0017_issue_started_workspace.sql @@ -0,0 +1,17 @@ +-- Started workspace: branch and worktree recorded on the issue row. +-- +-- Worktrees were observed (journal, project identity, storage migration) +-- but never managed. This migration adds the two columns that bind an +-- issue to the workspace `loaf issue start` creates. started_branch and +-- started_worktree are nullable: an unstarted issue has neither. They are +-- written together when start records the workspace, and cleared together +-- when stop tears it down. +-- +-- Status remains the events projection; these columns are workspace facts, +-- not a status. Stopping does not change status. There is no data +-- backfill: existing issues stay unstarted (NULL). +-- +-- No new tables. ALTER TABLE ADD COLUMN is SQLite-safe. + +ALTER TABLE issues ADD COLUMN started_branch TEXT; +ALTER TABLE issues ADD COLUMN started_worktree TEXT; diff --git a/internal/state/project_delete.go b/internal/state/project_delete.go index c976f3fae..5feb69073 100644 --- a/internal/state/project_delete.go +++ b/internal/state/project_delete.go @@ -57,6 +57,12 @@ var projectScopedDeleteTables = []string{ "exports", "backend_mappings", "hook_events", + "issue_criterion_claims", + "issue_criteria", + "issues", + "issue_identity", + "release_members", + "releases", "tasks", "specs", "ideas", diff --git a/internal/state/project_delete_test.go b/internal/state/project_delete_test.go index dadc9d153..c7cc904fa 100644 --- a/internal/state/project_delete_test.go +++ b/internal/state/project_delete_test.go @@ -83,6 +83,49 @@ spec: SPEC-001 assertNoIntegrityViolations(t, store) } +func TestDeleteProjectRemovesIssueTables(t *testing.T) { + ctx := context.Background() + root := projectRoot(t) + resolver := PathResolver{StateHome: t.TempDir()} + if _, err := Initialize(ctx, root, resolver); err != nil { + t.Fatalf("Initialize() error = %v", err) + } + store := openTestStore(t, root, resolver.StateHome) + defer store.Close() + + issue, err := store.CreateIssue(ctx, root, IssueCreateOptions{ + Title: "Delete with criteria", + Criteria: []IssueCriterionInput{ + {Text: "Must vanish with the project", Tier: IssueCriterionTierH}, + }, + }) + if err != nil { + t.Fatalf("CreateIssue() error = %v", err) + } + if issue.Alias == "" { + t.Fatal("CreateIssue() alias is empty, want minted local alias") + } + projectID := projectIDForTest(t, store, root) + for _, table := range []string{"issues", "issue_criteria", "issue_identity"} { + if got := countRows(t, store, `SELECT COUNT(*) FROM `+table+` WHERE project_id = ?`, projectID); got == 0 { + t.Fatalf("precondition: %s has 0 rows for project, want >0", table) + } + } + + if _, err := store.DeleteProject(ctx, projectID); err != nil { + t.Fatalf("DeleteProject() error = %v", err) + } + for _, table := range []string{"issues", "issue_criteria", "issue_identity"} { + if got := countRows(t, store, `SELECT COUNT(*) FROM `+table+` WHERE project_id = ?`, projectID); got != 0 { + t.Fatalf("after delete, %s has %d rows for project, want 0", table, got) + } + } + if got := countRows(t, store, `SELECT COUNT(*) FROM projects WHERE id = ?`, projectID); got != 0 { + t.Fatalf("projects row still present (%d), want 0", got) + } + assertNoIntegrityViolations(t, store) +} + func TestDeleteProjectUnknownRef(t *testing.T) { ctx := context.Background() root := projectRoot(t) diff --git a/internal/state/project_identity.go b/internal/state/project_identity.go index fb929d069..9413eaa1b 100644 --- a/internal/state/project_identity.go +++ b/internal/state/project_identity.go @@ -611,6 +611,9 @@ func rekeyLegacyProjectTx(ctx context.Context, tx *sql.Tx, legacyID string, curr if storedFriendly.Valid && strings.TrimSpace(storedFriendly.String) != "" { friendlyName = storedFriendly.String } + if _, err := tx.ExecContext(ctx, `PRAGMA defer_foreign_keys = ON`); err != nil { + return "", fmt.Errorf("defer foreign keys: %w", err) + } if _, err := tx.ExecContext(ctx, ` INSERT INTO projects (id, identity_hash, friendly_name, current_path, last_seen_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) diff --git a/internal/state/project_identity_continuity_test.go b/internal/state/project_identity_continuity_test.go index e56038bb0..4b2934ed4 100644 --- a/internal/state/project_identity_continuity_test.go +++ b/internal/state/project_identity_continuity_test.go @@ -301,6 +301,24 @@ VALUES ('idea-legacy-continuity', ?, 'Legacy Idea', 'open', ?, ?) `, legacyID, now, now); err != nil { t.Fatalf("insert legacy idea error = %v", err) } + if _, err := store.db.ExecContext(ctx, ` +INSERT INTO issues (id, project_id, kind, title, body, status, created_at, updated_at) +VALUES ('issue-legacy-continuity', ?, 'delivery', 'Legacy Issue', '', 'triage', ?, ?) +`, legacyID, now, now); err != nil { + t.Fatalf("insert legacy issue error = %v", err) + } + if _, err := store.db.ExecContext(ctx, ` +INSERT INTO issue_criteria (id, project_id, issue_id, position, text, tier, created_at, updated_at) +VALUES ('crit-legacy-continuity', ?, 'issue-legacy-continuity', 1, 'Still true after rekey', 'H', ?, ?) +`, legacyID, now, now); err != nil { + t.Fatalf("insert legacy issue criteria error = %v", err) + } + if _, err := store.db.ExecContext(ctx, ` +INSERT INTO issue_identity (id, project_id, authority, prefix, next_number, created_at, updated_at) +VALUES ('iid-legacy-continuity', ?, 'local', 'LOAF', 2, ?, ?) +`, legacyID, now, now); err != nil { + t.Fatalf("insert legacy issue identity error = %v", err) + } identity, err := store.EnsureProject(ctx, root) if err != nil { t.Fatalf("EnsureProject() error = %v", err) @@ -315,6 +333,22 @@ VALUES ('idea-legacy-continuity', ?, 'Legacy Idea', 'open', ?, ?) if ideaProjectID != identity.ID { t.Fatalf("idea project_id = %q, want %q", ideaProjectID, identity.ID) } + for _, probe := range []struct { + table string + id string + }{ + {"issues", "issue-legacy-continuity"}, + {"issue_criteria", "crit-legacy-continuity"}, + {"issue_identity", "iid-legacy-continuity"}, + } { + var issueProjectID string + if err := store.db.QueryRowContext(ctx, `SELECT project_id FROM `+probe.table+` WHERE id = ?`, probe.id).Scan(&issueProjectID); err != nil { + t.Fatalf("read rekeyed %s error = %v", probe.table, err) + } + if issueProjectID != identity.ID { + t.Fatalf("%s project_id = %q, want %q", probe.table, issueProjectID, identity.ID) + } + } var currentPath string if err := store.db.QueryRowContext(ctx, `SELECT current_path FROM projects WHERE id = ?`, identity.ID).Scan(¤tPath); err != nil { t.Fatalf("read rekeyed current path error = %v", err) diff --git a/internal/state/release.go b/internal/state/release.go new file mode 100644 index 000000000..2c6b986f2 --- /dev/null +++ b/internal/state/release.go @@ -0,0 +1,495 @@ +package state + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "github.com/levifig/loaf/internal/project" +) + +const ( + ReleaseMemberKindIssue = "issue" + ReleaseMemberKindRelease = "release" +) + +// ReleaseValidationError identifies malformed release input. +type ReleaseValidationError struct { + Field string + Err error +} + +func (e *ReleaseValidationError) Error() string { + if e == nil { + return "release validation failed" + } + return fmt.Sprintf("release validation failed for %s: %v", e.Field, e.Err) +} + +func (e *ReleaseValidationError) Unwrap() error { return e.Err } + +// Release is one recorded retroactive release. +type Release struct { + ID string `json:"id"` + Version string `json:"version"` + Tag string `json:"tag"` + TaggedCommit string `json:"tagged_commit"` + Notes string `json:"notes,omitempty"` + Members []ReleaseMember `json:"members,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// ReleaseMember is one recorded fact about what landed in a release. +type ReleaseMember struct { + ID string `json:"id"` + Kind string `json:"member_kind"` + MemberID string `json:"member_id"` + RecordedAt string `json:"recorded_at"` +} + +// RecordReleaseOptions describes a retroactive release to persist. +type RecordReleaseOptions struct { + Version string + Tag string + TaggedCommit string + Notes string + IssueIDs []string + IncludedIDs []string +} + +// ReleaseResult is one release plus project identity for CLI JSON. +type ReleaseResult struct { + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Release Release `json:"release"` +} + +// JournalEntryRecord list of commit() journal rows used by release attribution. +func ListCommitJournalEntries(ctx context.Context, root project.Root, resolver PathResolver) ([]JournalEntryRecord, error) { + store, err := openProjectStoreReadExisting(ctx, root, resolver) + if err != nil { + return nil, err + } + defer store.Close() + return store.ListCommitJournalEntries(ctx, root) +} + +// ListCommitJournalEntries returns commit() journal rows from an open store. +func (s *Store) ListCommitJournalEntries(ctx context.Context, root project.Root) ([]JournalEntryRecord, error) { + projectID, err := s.projectID(ctx, root) + if err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, ` +SELECT id, entry_type, COALESCE(scope, ''), message, COALESCE(observed_branch, ''), + COALESCE(observed_worktree, ''), COALESCE(harness_session_id, ''), created_at +FROM journal_entries +WHERE project_id = ? AND entry_type = 'commit' +ORDER BY created_at, id +`, projectID) + if err != nil { + return nil, fmt.Errorf("list commit journal entries: %w", err) + } + defer rows.Close() + entries := []JournalEntryRecord{} + for rows.Next() { + var entry JournalEntryRecord + if err := rows.Scan(&entry.ID, &entry.EntryType, &entry.Scope, &entry.Message, &entry.ObservedBranch, &entry.ObservedWorktree, &entry.HarnessSessionID, &entry.CreatedAt); err != nil { + return nil, fmt.Errorf("scan commit journal entry: %w", err) + } + entries = append(entries, entry) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate commit journal entries: %w", err) + } + return entries, nil +} + +// RecordRelease persists a release and its members as one transaction. +func RecordRelease(ctx context.Context, root project.Root, resolver PathResolver, options RecordReleaseOptions) (Release, error) { + store, err := openProjectStoreMutateExisting(ctx, root, resolver) + if err != nil { + return Release{}, err + } + defer store.Close() + return store.RecordRelease(ctx, root, options) +} + +// RecordRelease persists a release on an open store. +func (s *Store) RecordRelease(ctx context.Context, root project.Root, options RecordReleaseOptions) (Release, error) { + version := strings.TrimSpace(options.Version) + if version == "" { + return Release{}, &ReleaseValidationError{Field: "version", Err: fmt.Errorf("must be nonempty")} + } + tag := strings.TrimSpace(options.Tag) + if tag == "" { + return Release{}, &ReleaseValidationError{Field: "tag", Err: fmt.Errorf("must be nonempty")} + } + taggedCommit := strings.TrimSpace(options.TaggedCommit) + if taggedCommit == "" { + return Release{}, &ReleaseValidationError{Field: "tagged_commit", Err: fmt.Errorf("must be nonempty")} + } + + projectID, err := s.projectID(ctx, root) + if err != nil { + return Release{}, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return Release{}, fmt.Errorf("begin record release: %w", err) + } + defer tx.Rollback() + + existing, err := lookupReleaseByVersionOrTagTx(ctx, tx, projectID, version, tag) + switch { + case err == nil: + if existing.Version != version || existing.Tag != tag || existing.TaggedCommit != taggedCommit { + return Release{}, &ReleaseValidationError{ + Field: "release", + Err: fmt.Errorf("already recorded as version %s tag %s commit %s", existing.Version, existing.Tag, existing.TaggedCommit), + } + } + if diffs := releaseRecordedContentDiffs(existing, options); len(diffs) > 0 { + return Release{}, &ReleaseValidationError{ + Field: "release", + Err: fmt.Errorf("already recorded with divergent %s", strings.Join(diffs, ", ")), + } + } + return existing, nil + case !errors.Is(err, sql.ErrNoRows): + return Release{}, err + } + + releaseID, err := newOpaqueStateID("rel") + if err != nil { + return Release{}, fmt.Errorf("mint release id: %w", err) + } + now := time.Now().UTC().Format(time.RFC3339Nano) + if _, err := tx.ExecContext(ctx, ` +INSERT INTO releases (id, project_id, version, tag, tagged_commit, notes, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +`, releaseID, projectID, version, tag, taggedCommit, options.Notes, now, now); err != nil { + return Release{}, fmt.Errorf("insert release: %w", err) + } + + seen := map[string]bool{} + for _, issueID := range options.IssueIDs { + issueID = strings.TrimSpace(issueID) + if issueID == "" { + continue + } + key := ReleaseMemberKindIssue + "\x00" + issueID + if seen[key] { + continue + } + seen[key] = true + if err := insertReleaseMemberTx(ctx, tx, projectID, releaseID, ReleaseMemberKindIssue, issueID, now); err != nil { + return Release{}, err + } + } + for _, includedID := range options.IncludedIDs { + includedID = strings.TrimSpace(includedID) + if includedID == "" { + continue + } + if includedID == releaseID { + return Release{}, &ReleaseValidationError{Field: "includes", Err: fmt.Errorf("a release cannot include itself")} + } + key := ReleaseMemberKindRelease + "\x00" + includedID + if seen[key] { + continue + } + seen[key] = true + if err := insertReleaseMemberTx(ctx, tx, projectID, releaseID, ReleaseMemberKindRelease, includedID, now); err != nil { + return Release{}, err + } + } + + detail, err := loadReleaseTx(ctx, tx, projectID, releaseID) + if err != nil { + return Release{}, err + } + if err := tx.Commit(); err != nil { + return Release{}, fmt.Errorf("commit record release: %w", err) + } + return detail, nil +} + +// GetRelease returns one release by opaque id, version, or tag. +func GetRelease(ctx context.Context, root project.Root, resolver PathResolver, ref string) (Release, error) { + store, err := openProjectStoreReadExisting(ctx, root, resolver) + if err != nil { + return Release{}, err + } + defer store.Close() + return store.GetRelease(ctx, root, ref) +} + +// GetRelease returns one release from an open store. +func (s *Store) GetRelease(ctx context.Context, root project.Root, ref string) (Release, error) { + projectID, err := s.projectID(ctx, root) + if err != nil { + return Release{}, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return Release{}, fmt.Errorf("begin get release: %w", err) + } + defer tx.Rollback() + releaseID, err := resolveReleaseRefTx(ctx, tx, projectID, ref) + if err != nil { + return Release{}, err + } + return loadReleaseTx(ctx, tx, projectID, releaseID) +} + +// ListReleases returns every recorded release for the project. +func ListReleases(ctx context.Context, root project.Root, resolver PathResolver) ([]Release, error) { + store, err := openProjectStoreReadExisting(ctx, root, resolver) + if err != nil { + return nil, err + } + defer store.Close() + return store.ListReleases(ctx, root) +} + +// ListReleases returns recorded releases from an open store. +func (s *Store) ListReleases(ctx context.Context, root project.Root) ([]Release, error) { + projectID, err := s.projectID(ctx, root) + if err != nil { + return nil, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return nil, fmt.Errorf("begin list releases: %w", err) + } + defer tx.Rollback() + rows, err := tx.QueryContext(ctx, ` +SELECT id FROM releases WHERE project_id = ? ORDER BY created_at, id +`, projectID) + if err != nil { + return nil, fmt.Errorf("list releases: %w", err) + } + defer rows.Close() + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scan release id: %w", err) + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate releases: %w", err) + } + releases := make([]Release, 0, len(ids)) + for _, id := range ids { + detail, err := loadReleaseTx(ctx, tx, projectID, id) + if err != nil { + return nil, err + } + releases = append(releases, detail) + } + return releases, nil +} + +func releaseRecordedContentDiffs(existing Release, options RecordReleaseOptions) []string { + var diffs []string + if !sameStringSet(memberIDsOfKind(existing.Members, ReleaseMemberKindIssue), trimmedNonEmptySet(options.IssueIDs)) { + diffs = append(diffs, "issue members") + } + if !sameStringSet(memberIDsOfKind(existing.Members, ReleaseMemberKindRelease), trimmedNonEmptySet(options.IncludedIDs)) { + diffs = append(diffs, "included members") + } + if existing.Notes != options.Notes { + diffs = append(diffs, "notes") + } + return diffs +} + +func memberIDsOfKind(members []ReleaseMember, kind string) map[string]struct{} { + out := make(map[string]struct{}) + for _, member := range members { + if member.Kind == kind && member.MemberID != "" { + out[member.MemberID] = struct{}{} + } + } + return out +} + +func trimmedNonEmptySet(ids []string) map[string]struct{} { + out := make(map[string]struct{}) + for _, id := range ids { + id = strings.TrimSpace(id) + if id == "" { + continue + } + out[id] = struct{}{} + } + return out +} + +func sameStringSet(a, b map[string]struct{}) bool { + if len(a) != len(b) { + return false + } + for key := range a { + if _, ok := b[key]; !ok { + return false + } + } + return true +} + +func insertReleaseMemberTx(ctx context.Context, tx *sql.Tx, projectID, releaseID, kind, memberID, now string) error { + if kind != ReleaseMemberKindIssue && kind != ReleaseMemberKindRelease { + return &ReleaseValidationError{Field: "member_kind", Err: fmt.Errorf("must be issue or release")} + } + if err := validateReleaseMemberExistsTx(ctx, tx, projectID, kind, memberID); err != nil { + return err + } + memberRowID, err := newOpaqueStateID("rlm") + if err != nil { + return fmt.Errorf("mint release member id: %w", err) + } + if _, err := tx.ExecContext(ctx, ` +INSERT INTO release_members (id, project_id, release_id, member_kind, member_id, recorded_at) +VALUES (?, ?, ?, ?, ?, ?) +`, memberRowID, projectID, releaseID, kind, memberID, now); err != nil { + return fmt.Errorf("insert release member: %w", err) + } + return nil +} + +func validateReleaseMemberExistsTx(ctx context.Context, tx *sql.Tx, projectID, kind, memberID string) error { + var exists string + var err error + switch kind { + case ReleaseMemberKindIssue: + err = tx.QueryRowContext(ctx, `SELECT id FROM issues WHERE project_id = ? AND id = ?`, projectID, memberID).Scan(&exists) + if err == sql.ErrNoRows { + return &ReleaseValidationError{Field: "member_id", Err: fmt.Errorf("issue %s not found in this project", memberID)} + } + case ReleaseMemberKindRelease: + err = tx.QueryRowContext(ctx, `SELECT id FROM releases WHERE project_id = ? AND id = ?`, projectID, memberID).Scan(&exists) + if err == sql.ErrNoRows { + return &ReleaseValidationError{Field: "includes", Err: fmt.Errorf("release %s not found in this project", memberID)} + } + default: + return &ReleaseValidationError{Field: "member_kind", Err: fmt.Errorf("must be issue or release")} + } + if err != nil { + return fmt.Errorf("validate release member %s: %w", memberID, err) + } + return nil +} + +func lookupReleaseByVersionOrTagTx(ctx context.Context, tx *sql.Tx, projectID, version, tag string) (Release, error) { + rows, err := tx.QueryContext(ctx, ` +SELECT id FROM releases +WHERE project_id = ? AND (version = ? OR tag = ?) +ORDER BY created_at, id +`, projectID, version, tag) + if err != nil { + return Release{}, fmt.Errorf("lookup release %s/%s: %w", version, tag, err) + } + defer rows.Close() + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return Release{}, fmt.Errorf("scan release id: %w", err) + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return Release{}, fmt.Errorf("iterate release lookup: %w", err) + } + if len(ids) == 0 { + return Release{}, sql.ErrNoRows + } + if len(ids) > 1 { + return Release{}, &ReleaseValidationError{ + Field: "release", + Err: fmt.Errorf("version %s and tag %s match different recorded releases", version, tag), + } + } + return loadReleaseTx(ctx, tx, projectID, ids[0]) +} + +func resolveReleaseRefTx(ctx context.Context, tx *sql.Tx, projectID, ref string) (string, error) { + trimmed := strings.TrimSpace(ref) + if trimmed == "" { + return "", &ReleaseValidationError{Field: "release", Err: fmt.Errorf("must be nonempty")} + } + var id string + err := tx.QueryRowContext(ctx, ` +SELECT id FROM releases +WHERE project_id = ? AND (id = ? OR version = ? OR tag = ?) +ORDER BY CASE WHEN id = ? THEN 0 WHEN version = ? THEN 1 ELSE 2 END, created_at, id +LIMIT 1 +`, projectID, trimmed, trimmed, trimmed, trimmed, trimmed).Scan(&id) + if err == sql.ErrNoRows { + return "", fmt.Errorf("release %q not found in SQLite state", trimmed) + } + if err != nil { + return "", fmt.Errorf("resolve release %q: %w", trimmed, err) + } + return id, nil +} + +func loadReleaseTx(ctx context.Context, tx *sql.Tx, projectID, releaseID string) (Release, error) { + var release Release + var notes sql.NullString + err := tx.QueryRowContext(ctx, ` +SELECT id, version, tag, tagged_commit, notes, created_at, updated_at +FROM releases +WHERE project_id = ? AND id = ? +`, projectID, releaseID).Scan(&release.ID, &release.Version, &release.Tag, &release.TaggedCommit, ¬es, &release.CreatedAt, &release.UpdatedAt) + if err == sql.ErrNoRows { + return Release{}, fmt.Errorf("release %s not found", releaseID) + } + if err != nil { + return Release{}, fmt.Errorf("load release %s: %w", releaseID, err) + } + release.Notes = notes.String + members, err := loadReleaseMembersTx(ctx, tx, projectID, releaseID) + if err != nil { + return Release{}, err + } + release.Members = members + return release, nil +} + +func loadReleaseMembersTx(ctx context.Context, tx *sql.Tx, projectID, releaseID string) ([]ReleaseMember, error) { + rows, err := tx.QueryContext(ctx, ` +SELECT id, member_kind, member_id, recorded_at +FROM release_members +WHERE project_id = ? AND release_id = ? +ORDER BY recorded_at, id +`, projectID, releaseID) + if err != nil { + return nil, fmt.Errorf("list release members: %w", err) + } + defer rows.Close() + members := []ReleaseMember{} + for rows.Next() { + var member ReleaseMember + if err := rows.Scan(&member.ID, &member.Kind, &member.MemberID, &member.RecordedAt); err != nil { + return nil, fmt.Errorf("scan release member: %w", err) + } + members = append(members, member) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate release members: %w", err) + } + return members, nil +} diff --git a/internal/state/release_test.go b/internal/state/release_test.go new file mode 100644 index 000000000..5b74ab551 --- /dev/null +++ b/internal/state/release_test.go @@ -0,0 +1,311 @@ +package state + +import ( + "context" + "strings" + "testing" +) + +func TestRecordReleasePersistsMembersAsFacts(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + + issue, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Landed work"}) + if err != nil { + t.Fatalf("CreateIssue() error = %v", err) + } + recorded, err := store.RecordRelease(ctx, root, RecordReleaseOptions{ + Version: "0.3.0", + Tag: "v0.3.0", + TaggedCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + Notes: "## [0.3.0]\n", + IssueIDs: []string{issue.ID}, + }) + if err != nil { + t.Fatalf("RecordRelease() error = %v", err) + } + if recorded.Version != "0.3.0" || recorded.Tag != "v0.3.0" || recorded.TaggedCommit != "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" { + t.Fatalf("release = %#v", recorded) + } + if len(recorded.Members) != 1 || recorded.Members[0].Kind != ReleaseMemberKindIssue || recorded.Members[0].MemberID != issue.ID { + t.Fatalf("members = %#v, want one issue member", recorded.Members) + } + + loaded, err := store.GetRelease(ctx, root, "v0.3.0") + if err != nil { + t.Fatalf("GetRelease(tag) error = %v", err) + } + if loaded.ID != recorded.ID { + t.Fatalf("GetRelease(tag) = %q, want %q", loaded.ID, recorded.ID) + } + byVersion, err := store.GetRelease(ctx, root, "0.3.0") + if err != nil || byVersion.ID != recorded.ID { + t.Fatalf("GetRelease(version) = %#v %v", byVersion, err) + } +} + +func TestRecordReleaseRejectsUnknownIssueMember(t *testing.T) { + root, store := issueTestFixture(t) + _, err := store.RecordRelease(context.Background(), root, RecordReleaseOptions{ + Version: "0.3.1", + Tag: "v0.3.1", + TaggedCommit: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + IssueIDs: []string{"issue_missing"}, + }) + if err == nil || !strings.Contains(err.Error(), "issue_missing") { + t.Fatalf("error = %v, want unknown issue", err) + } + listed, err := store.ListReleases(context.Background(), root) + if err != nil { + t.Fatalf("ListReleases() error = %v", err) + } + if len(listed) != 0 { + t.Fatalf("failed write left %d releases", len(listed)) + } +} + +func TestRecordReleaseIncludesPrereleaseByReference(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + pre, err := store.RecordRelease(ctx, root, RecordReleaseOptions{ + Version: "0.3.0-alpha.1", + Tag: "v0.3.0-alpha.1", + TaggedCommit: "cccccccccccccccccccccccccccccccccccccccc", + }) + if err != nil { + t.Fatalf("RecordRelease(prerelease) error = %v", err) + } + stable, err := store.RecordRelease(ctx, root, RecordReleaseOptions{ + Version: "0.3.0", + Tag: "v0.3.0", + TaggedCommit: "dddddddddddddddddddddddddddddddddddddddd", + IncludedIDs: []string{pre.ID}, + }) + if err != nil { + t.Fatalf("RecordRelease(stable) error = %v", err) + } + if len(stable.Members) != 1 || stable.Members[0].Kind != ReleaseMemberKindRelease || stable.Members[0].MemberID != pre.ID { + t.Fatalf("stable members = %#v, want prerelease reference", stable.Members) + } +} + +func TestRecordReleaseRejectsUnknownIncludedRelease(t *testing.T) { + root, store := issueTestFixture(t) + _, err := store.RecordRelease(context.Background(), root, RecordReleaseOptions{ + Version: "1.0.0", + Tag: "v1.0.0", + TaggedCommit: "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + IncludedIDs: []string{"rel_missing"}, + }) + if err == nil || !strings.Contains(err.Error(), "rel_missing") { + t.Fatalf("error = %v, want unknown included release", err) + } +} + +func TestRecordReleaseRejectsDuplicateVersion(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + if _, err := store.RecordRelease(ctx, root, RecordReleaseOptions{ + Version: "0.4.0", + Tag: "v0.4.0", + TaggedCommit: "ffffffffffffffffffffffffffffffffffffffff", + }); err != nil { + t.Fatalf("first RecordRelease() error = %v", err) + } + _, err := store.RecordRelease(ctx, root, RecordReleaseOptions{ + Version: "0.4.0", + Tag: "v0.4.1", + TaggedCommit: "1111111111111111111111111111111111111111", + }) + if err == nil { + t.Fatal("duplicate version must fail") + } +} + +func TestRecordReleaseIdempotentOnIdenticalRetry(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + issue, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Landed"}) + if err != nil { + t.Fatalf("CreateIssue() error = %v", err) + } + options := RecordReleaseOptions{ + Version: "0.5.0", + Tag: "v0.5.0", + TaggedCommit: "2222222222222222222222222222222222222222", + IssueIDs: []string{issue.ID}, + } + first, err := store.RecordRelease(ctx, root, options) + if err != nil { + t.Fatalf("first RecordRelease() error = %v", err) + } + second, err := store.RecordRelease(ctx, root, options) + if err != nil { + t.Fatalf("identical retry error = %v", err) + } + if second.ID != first.ID || second.TaggedCommit != first.TaggedCommit { + t.Fatalf("retry = %#v, want %#v", second, first) + } + listed, err := store.ListReleases(ctx, root) + if err != nil { + t.Fatalf("ListReleases() error = %v", err) + } + if len(listed) != 1 { + t.Fatalf("releases = %d, want 1 after identical retry", len(listed)) + } +} + +func TestRecordReleaseRejectsSameVersionDifferentCommit(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + if _, err := store.RecordRelease(ctx, root, RecordReleaseOptions{ + Version: "0.6.0", + Tag: "v0.6.0", + TaggedCommit: "3333333333333333333333333333333333333333", + }); err != nil { + t.Fatalf("first RecordRelease() error = %v", err) + } + _, err := store.RecordRelease(ctx, root, RecordReleaseOptions{ + Version: "0.6.0", + Tag: "v0.6.0", + TaggedCommit: "4444444444444444444444444444444444444444", + }) + if err == nil { + t.Fatal("same version/tag with different commit must fail") + } +} + +func TestRecordReleaseIdempotentWhenMembersAndNotesMatch(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + firstIssue, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "First"}) + if err != nil { + t.Fatalf("CreateIssue(first) error = %v", err) + } + secondIssue, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Second"}) + if err != nil { + t.Fatalf("CreateIssue(second) error = %v", err) + } + pre, err := store.RecordRelease(ctx, root, RecordReleaseOptions{ + Version: "0.7.0-alpha.1", + Tag: "v0.7.0-alpha.1", + TaggedCommit: "5555555555555555555555555555555555555555", + }) + if err != nil { + t.Fatalf("RecordRelease(prerelease) error = %v", err) + } + options := RecordReleaseOptions{ + Version: "0.7.0", + Tag: "v0.7.0", + TaggedCommit: "6666666666666666666666666666666666666666", + Notes: "## [0.7.0]\nlanded work\n", + IssueIDs: []string{firstIssue.ID, secondIssue.ID}, + IncludedIDs: []string{pre.ID}, + } + first, err := store.RecordRelease(ctx, root, options) + if err != nil { + t.Fatalf("first RecordRelease() error = %v", err) + } + retry := options + retry.IssueIDs = []string{secondIssue.ID, firstIssue.ID, firstIssue.ID, " "} + retry.IncludedIDs = []string{pre.ID, pre.ID} + second, err := store.RecordRelease(ctx, root, retry) + if err != nil { + t.Fatalf("identical member/notes retry error = %v", err) + } + if second.ID != first.ID { + t.Fatalf("retry id = %q, want %q", second.ID, first.ID) + } + listed, err := store.ListReleases(ctx, root) + if err != nil { + t.Fatalf("ListReleases() error = %v", err) + } + if len(listed) != 2 { + t.Fatalf("releases = %d, want 2 (prerelease + stable)", len(listed)) + } +} + +func TestRecordReleaseRejectsDivergentContentOnRetry(t *testing.T) { + root, store := issueTestFixture(t) + ctx := context.Background() + firstIssue, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "First"}) + if err != nil { + t.Fatalf("CreateIssue(first) error = %v", err) + } + secondIssue, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Second"}) + if err != nil { + t.Fatalf("CreateIssue(second) error = %v", err) + } + pre, err := store.RecordRelease(ctx, root, RecordReleaseOptions{ + Version: "0.8.0-alpha.1", + Tag: "v0.8.0-alpha.1", + TaggedCommit: "7777777777777777777777777777777777777777", + }) + if err != nil { + t.Fatalf("RecordRelease(prerelease) error = %v", err) + } + other, err := store.RecordRelease(ctx, root, RecordReleaseOptions{ + Version: "0.8.0-alpha.2", + Tag: "v0.8.0-alpha.2", + TaggedCommit: "8888888888888888888888888888888888888888", + }) + if err != nil { + t.Fatalf("RecordRelease(other) error = %v", err) + } + base := RecordReleaseOptions{ + Version: "0.8.0", + Tag: "v0.8.0", + TaggedCommit: "9999999999999999999999999999999999999999", + Notes: "original notes", + IssueIDs: []string{firstIssue.ID}, + IncludedIDs: []string{pre.ID}, + } + if _, err := store.RecordRelease(ctx, root, base); err != nil { + t.Fatalf("first RecordRelease() error = %v", err) + } + + cases := []struct { + name string + mutate func(*RecordReleaseOptions) + wantErr string + }{ + { + name: "issue members", + mutate: func(options *RecordReleaseOptions) { + options.IssueIDs = []string{firstIssue.ID, secondIssue.ID} + }, + wantErr: "issue members", + }, + { + name: "included members", + mutate: func(options *RecordReleaseOptions) { + options.IncludedIDs = []string{other.ID} + }, + wantErr: "included members", + }, + { + name: "notes", + mutate: func(options *RecordReleaseOptions) { + options.Notes = "changed notes" + }, + wantErr: "notes", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + retry := base + tc.mutate(&retry) + _, err := store.RecordRelease(ctx, root, retry) + if err == nil || !strings.Contains(err.Error(), "divergent") || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("error = %v, want divergent %s", err, tc.wantErr) + } + listed, err := store.ListReleases(ctx, root) + if err != nil { + t.Fatalf("ListReleases() error = %v", err) + } + if len(listed) != 3 { + t.Fatalf("divergent retry wrote a release; count = %d", len(listed)) + } + }) + } +} diff --git a/internal/state/schema.go b/internal/state/schema.go index 0889489e3..08ce3f12f 100644 --- a/internal/state/schema.go +++ b/internal/state/schema.go @@ -46,6 +46,18 @@ var intentsAndExplorationsSQL string //go:embed migrations/0013_hook_enablement.sql var hookEnablementSQL string +//go:embed migrations/0014_issues_and_identity.sql +var issuesAndIdentitySQL string + +//go:embed migrations/0015_issue_criterion_claims.sql +var issueCriterionClaimsSQL string + +//go:embed migrations/0016_releases.sql +var releasesSQL string + +//go:embed migrations/0017_issue_started_workspace.sql +var issueStartedWorkspaceSQL string + const schemaMigrationsDDL = `CREATE TABLE IF NOT EXISTS schema_migrations ( version INTEGER PRIMARY KEY NOT NULL, name TEXT NOT NULL, @@ -124,6 +136,26 @@ func SchemaMigrations() []SchemaMigration { Name: "hook_enablement", SQL: normalizeMigrationSQL(hookEnablementSQL), }, + { + Version: 14, + Name: "issues_and_identity", + SQL: normalizeMigrationSQL(issuesAndIdentitySQL), + }, + { + Version: 15, + Name: "issue_criterion_claims", + SQL: normalizeMigrationSQL(issueCriterionClaimsSQL), + }, + { + Version: 16, + Name: "releases", + SQL: normalizeMigrationSQL(releasesSQL), + }, + { + Version: 17, + Name: "issue_started_workspace", + SQL: normalizeMigrationSQL(issueStartedWorkspaceSQL), + }, } } diff --git a/internal/state/schema_test.go b/internal/state/schema_test.go index 9aea1f2a6..842b88a54 100644 --- a/internal/state/schema_test.go +++ b/internal/state/schema_test.go @@ -62,9 +62,30 @@ var requiredInitialTables = []string{ "hook_enablements", "hook_absorption_markers", "hook_trusted_paths", + "issues", + "issue_criteria", + "issue_identity", + "issue_criterion_claims", + "releases", + "release_members", "schema_migrations", } +// issueFoundationTables are introduced by migrations 0014, 0015, and 0016. +// Their SQL mirrors live in docs/schema/0014_issues_and_identity.sql, +// docs/schema/0015_issue_criterion_claims.sql, and docs/schema/0016_releases.sql; +// the dbml/mmd diagrams are not updated in this foundation change. +// Migration 0017 adds started_branch/started_worktree on issues (ALTER TABLE) +// and introduces no new tables. +var issueFoundationTables = map[string]bool{ + "issues": true, + "issue_criteria": true, + "issue_identity": true, + "issue_criterion_claims": true, + "releases": true, + "release_members": true, +} + // userScopedTables are host-local tables without project_id. They are excluded // from the project-scoped foreign-key guardrail. var userScopedTables = map[string]bool{ @@ -77,11 +98,11 @@ var userScopedTables = map[string]bool{ func TestSchemaMigrationsAreOrderedAndChecksummed(t *testing.T) { migrations := SchemaMigrations() - if len(migrations) != 12 { - t.Fatalf("len(SchemaMigrations()) = %d, want 12", len(migrations)) + if len(migrations) != 16 { + t.Fatalf("len(SchemaMigrations()) = %d, want 16", len(migrations)) } - wantVersions := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12, 13} + wantVersions := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12, 13, 14, 15, 16, 17} for i, migration := range migrations { if migration.Version != wantVersions[i] { t.Fatalf("migration[%d].Version = %d, want %d", i, migration.Version, wantVersions[i]) @@ -123,6 +144,18 @@ func TestSchemaMigrationsAreOrderedAndChecksummed(t *testing.T) { if migrations[11].Name != "hook_enablement" { t.Fatalf("migration[11].Name = %q, want hook_enablement", migrations[11].Name) } + if migrations[12].Name != "issues_and_identity" { + t.Fatalf("migration[12].Name = %q, want issues_and_identity", migrations[12].Name) + } + if migrations[13].Name != "issue_criterion_claims" { + t.Fatalf("migration[13].Name = %q, want issue_criterion_claims", migrations[13].Name) + } + if migrations[14].Name != "releases" { + t.Fatalf("migration[14].Name = %q, want releases", migrations[14].Name) + } + if migrations[15].Name != "issue_started_workspace" { + t.Fatalf("migration[15].Name = %q, want issue_started_workspace", migrations[15].Name) + } for _, migration := range migrations { if strings.TrimSpace(migration.SQL) == "" { t.Fatalf("migration %d SQL is empty", migration.Version) @@ -177,7 +210,7 @@ func TestOperationalTablesHaveStableIDsAndTimestamps(t *testing.T) { } sql := currentSchemaSQL() for _, table := range requiredInitialTables { - if table == "schema_migrations" || table == "journal_origins" || table == "journal_deferrals" || table == "intent_operations" { + if table == "schema_migrations" || table == "journal_origins" || table == "journal_deferrals" || table == "intent_operations" || table == "release_members" { continue } body := tableBody(t, sql, table) @@ -353,6 +386,22 @@ func TestSchemaDocumentationMirrorsExecutableMigration(t *testing.T) { if sqlDoc != SchemaMigrations()[11].SQL { t.Fatal("docs/schema/0013_hook_enablement.sql must match embedded migration 0013 exactly") } + sqlDoc = readRepoFile(t, "docs", "schema", "0014_issues_and_identity.sql") + if sqlDoc != SchemaMigrations()[12].SQL { + t.Fatal("docs/schema/0014_issues_and_identity.sql must match embedded migration 0014 exactly") + } + sqlDoc = readRepoFile(t, "docs", "schema", "0015_issue_criterion_claims.sql") + if sqlDoc != SchemaMigrations()[13].SQL { + t.Fatal("docs/schema/0015_issue_criterion_claims.sql must match embedded migration 0015 exactly") + } + sqlDoc = readRepoFile(t, "docs", "schema", "0016_releases.sql") + if sqlDoc != SchemaMigrations()[14].SQL { + t.Fatal("docs/schema/0016_releases.sql must match embedded migration 0016 exactly") + } + sqlDoc = readRepoFile(t, "docs", "schema", "0017_issue_started_workspace.sql") + if sqlDoc != SchemaMigrations()[15].SQL { + t.Fatal("docs/schema/0017_issue_started_workspace.sql must match embedded migration 0017 exactly") + } dbmlDoc := readRepoFile(t, "docs", "schema", "operational-state.dbml") mermaidDoc := readRepoFile(t, "docs", "schema", "operational-state.mmd") @@ -360,6 +409,9 @@ func TestSchemaDocumentationMirrorsExecutableMigration(t *testing.T) { dbmlColumnsByTable := dbmlColumnNames(t, dbmlDoc) mermaidColumnsByTable := mermaidColumnNames(t, mermaidDoc) for _, table := range requiredInitialTables { + if issueFoundationTables[table] { + continue + } if !regexp.MustCompile(`(?m)^Table\s+` + regexp.QuoteMeta(table) + `\s+\{`).MatchString(dbmlDoc) { t.Fatalf("operational-state.dbml missing Table %s block", table) } diff --git a/internal/state/status.go b/internal/state/status.go index 3eba23f3b..3f15b30ae 100644 --- a/internal/state/status.go +++ b/internal/state/status.go @@ -933,6 +933,21 @@ func inspectBackendMappingInvariants(ctx context.Context, store *Store) ([]Diagn intentKindList := "" intentOrphanKindList := "" intentEntityCTE := "" + workKindList := "" + workOrphanKindList := "" + workEntityCTE := "" + for _, kind := range [][2]string{{"issue", "issues"}, {"release", "releases"}} { + var count int + if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, kind[1]).Scan(&count); err != nil { + return nil, false, fmt.Errorf("inspect %s table presence: %w", kind[1], err) + } + if count == 0 { + continue + } + workKindList += ",\n '" + kind[0] + "'" + workOrphanKindList += ",\n '" + kind[0] + "'" + workEntityCTE += "\n UNION ALL SELECT '" + kind[0] + "', project_id, id FROM " + kind[1] + } if intentTablesPresent { // One kind→table source builds every conditional clause so the three // scan sites cannot drift apart. @@ -1059,7 +1074,7 @@ WHERE entity_kind NOT IN ( 'bundle_member', 'source', 'hook_event', - 'export'`+intentKindList+` + 'export'`+workKindList+intentKindList+` ) GROUP BY entity_kind ORDER BY entity_kind @@ -1149,7 +1164,7 @@ WITH local_entities(entity_kind, project_id, entity_id) AS ( UNION ALL SELECT 'bundle_member', project_id, id FROM bundle_members UNION ALL SELECT 'source', project_id, id FROM sources UNION ALL SELECT 'hook_event', project_id, id FROM hook_events - UNION ALL SELECT 'export', project_id, id FROM exports`+intentEntityCTE+` + UNION ALL SELECT 'export', project_id, id FROM exports`+workEntityCTE+intentEntityCTE+` ) SELECT backend_mappings.id, backend_mappings.backend, backend_mappings.entity_kind, backend_mappings.entity_id, backend_mappings.external_kind, backend_mappings.external_id FROM backend_mappings @@ -1181,7 +1196,7 @@ WHERE local_entities.entity_id IS NULL 'bundle_member', 'source', 'hook_event', - 'export'`+intentOrphanKindList+` + 'export'`+workOrphanKindList+intentOrphanKindList+` ) ORDER BY backend_mappings.id `) diff --git a/internal/state/status_test.go b/internal/state/status_test.go index efc5ecab7..6c43daf25 100644 --- a/internal/state/status_test.go +++ b/internal/state/status_test.go @@ -930,6 +930,53 @@ VALUES ('backend-mapping-linear-project', ?, 'linear', 'project', ?, 'project', assertNoDiagnostic(t, status.Diagnostics, "backend-mapping-entity-missing") } +func TestInspectAcceptsIssueAndReleaseBackendMappings(t *testing.T) { + root := projectRoot(t) + stateHome := t.TempDir() + if _, err := Initialize(context.Background(), root, PathResolver{StateHome: stateHome}); err != nil { + t.Fatalf("Initialize() error = %v", err) + } + store := openTestStore(t, root, stateHome) + defer store.Close() + ctx := context.Background() + + issue, err := store.CreateIssue(ctx, root, IssueCreateOptions{Title: "Mapped issue", Alias: "ENG-1"}) + if err != nil { + t.Fatalf("CreateIssue() error = %v", err) + } + release, err := store.RecordRelease(ctx, root, RecordReleaseOptions{ + Version: "1.0.0", + Tag: "v1.0.0", + TaggedCommit: "abc123", + IssueIDs: []string{issue.ID}, + }) + if err != nil { + t.Fatalf("RecordRelease() error = %v", err) + } + if err := store.BindLinearIssue(ctx, root, issue.ID, "ENG-1", "https://linear.app/loaf/issue/ENG-1"); err != nil { + t.Fatalf("BindLinearIssue() error = %v", err) + } + if err := store.upsertBackendMapping(ctx, root, backendMapping{ + EntityKind: "release", + EntityID: release.ID, + ExternalKind: linearExternalKindRelease, + ExternalID: "rel_1", + SyncStatus: linearSyncLinked, + }); err != nil { + t.Fatalf("upsert release mapping error = %v", err) + } + + status, err := Inspect(root, PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("Inspect() error = %v", err) + } + if status.Mode != ModeSQLiteReady { + t.Fatalf("Mode = %q, want %q for issue/release backend mappings", status.Mode, ModeSQLiteReady) + } + assertNoDiagnostic(t, status.Diagnostics, "backend-mapping-entity-kind-unknown") + assertNoDiagnostic(t, status.Diagnostics, "backend-mapping-entity-missing") +} + func TestInspectAcceptsNewArtifactEntityBackendMappings(t *testing.T) { root := projectRoot(t) stateHome := t.TempDir() diff --git a/internal/state/storage_home_migration.go b/internal/state/storage_home_migration.go index d5c38406b..8194d4670 100644 --- a/internal/state/storage_home_migration.go +++ b/internal/state/storage_home_migration.go @@ -27,6 +27,12 @@ var projectScopedMergeTables = []string{ "sources", "specs", "tasks", + "issues", + "issue_criteria", + "issue_criterion_claims", + "issue_identity", + "releases", + "release_members", "ideas", "sparks", "brainstorms", diff --git a/internal/state/trace.go b/internal/state/trace.go index 8ed7fb707..d67ecbd4a 100644 --- a/internal/state/trace.go +++ b/internal/state/trace.go @@ -149,7 +149,7 @@ func (s *Store) resolveEntityByInternalID(ctx context.Context, projectID string, func (s *Store) entityDetails(ctx context.Context, projectID string, kind string, id string) (TraceEntity, error) { entity := TraceEntity{Kind: kind, ID: id} switch kind { - case "spec", "task", "idea", "brainstorm", "shaping_draft", "report", "plan", "handoff", "council": + case "spec", "task", "idea", "brainstorm", "shaping_draft", "report", "plan", "handoff", "council", "issue": table := traceTable(kind) var title, status sql.NullString err := s.db.QueryRowContext(ctx, fmt.Sprintf(`SELECT title, status FROM %s WHERE project_id = ? AND id = ?`, table), projectID, id).Scan(&title, &status) diff --git a/plugins/loaf/.loaf-target-manifest.json b/plugins/loaf/.loaf-target-manifest.json index a4c17444f..f1db94973 100644 --- a/plugins/loaf/.loaf-target-manifest.json +++ b/plugins/loaf/.loaf-target-manifest.json @@ -12,7 +12,7 @@ "kind": "hook-file", "source_path": "hooks/hooks.json", "destination": "hooks/hooks.json", - "sha256": "564917493bfa795fee8a067e235e13eec5f992e098cb94540d2202cc6fe3cad4", + "sha256": "7f66af091cc8440a389c5c0e829fc464c7bc5661a4990ba0b57fb85247ead9e8", "mode": 420 }, { @@ -20,7 +20,7 @@ "kind": "hook-file", "source_path": "hooks/instructions/post-merge.md", "destination": "hooks/instructions/post-merge.md", - "sha256": "f728a0a9a004ea1ea76b70ca3292996c798baa2838633806e2fb4250118203b6", + "sha256": "4f712c30a821a1b5d971f9fd8bf17dfc8888634b6bb8f307339f9e5a47c05551", "mode": 420 }, { @@ -36,7 +36,7 @@ "kind": "hook-file", "source_path": "hooks/instructions/pre-pr-checklist.md", "destination": "hooks/instructions/pre-pr-checklist.md", - "sha256": "234b5e37846adf226ae501ac65a62139fed61c42dc62bbf9c78fc8b885debbad", + "sha256": "64a647e40d2d7f52224a60b978012265f80414c8eb2c893e61a97faa74375dd3", "mode": 420 }, { @@ -83,7 +83,7 @@ "id": "managed-instructions", "kind": "instruction", "destination": "project-instructions", - "sha256": "ac6debb93fcd1b2d7806681c446f3b7d9691a43a872831a969c82a7470b0b30d" + "sha256": "21e91a6226ead7de1ef1d3d61c4e2060dc9763e8485192f6efc0060a09bbe66e" } ] } diff --git a/plugins/loaf/agents/background-runner.md b/plugins/loaf/agents/background-runner.md index 7223610fc..2c6dad438 100644 --- a/plugins/loaf/agents/background-runner.md +++ b/plugins/loaf/agents/background-runner.md @@ -35,7 +35,7 @@ The spawning agent provides: - Specific task to execute - Files or scope to analyze - Output location (`.agents/reports/YYYYMMDD-HHMMSS-<name>.md`) -- Task/spec reference when available +- Issue reference when available ## Execution Process @@ -45,7 +45,7 @@ Extract from prompt: - What to do (audit, analyze, review) - Scope (files, directories) - Output location -- Task ID or spec ID when provided +- Issue ID when provided ### 2. Execute Work @@ -67,7 +67,7 @@ report: status: unprocessed created: "2026-01-23T14:30:00Z" background_agent_id: "bg-YYYYMMDD-HHMMSS-description" - task_reference: "task or spec reference when provided" + issue_reference: "issue reference when provided" --- # Report Title diff --git a/plugins/loaf/bin/native/darwin-arm64/loaf b/plugins/loaf/bin/native/darwin-arm64/loaf index e0b6eebf1..2681c6c36 100755 Binary files a/plugins/loaf/bin/native/darwin-arm64/loaf and b/plugins/loaf/bin/native/darwin-arm64/loaf differ diff --git a/plugins/loaf/hooks/hooks.json b/plugins/loaf/hooks/hooks.json index 85ee3a152..339e71f9d 100644 --- a/plugins/loaf/hooks/hooks.json +++ b/plugins/loaf/hooks/hooks.json @@ -132,7 +132,7 @@ "type": "command", "command": "cat \"${CLAUDE_PLUGIN_ROOT}/hooks/instructions/post-merge.md\"", "if": "Bash(gh pr merge:*)", - "description": "Inject housekeeping checklist after a gh pr merge hook; command matching does not prove success", + "description": "Inject issue-done, worktree-stop, and journal housekeeping after a gh pr merge hook; command matching does not prove success", "timeout": 5 } ] diff --git a/plugins/loaf/hooks/instructions/post-merge.md b/plugins/loaf/hooks/instructions/post-merge.md index 35d3b90d3..9c8f183e1 100644 --- a/plugins/loaf/hooks/instructions/post-merge.md +++ b/plugins/loaf/hooks/instructions/post-merge.md @@ -1,51 +1,37 @@ **Note:** If you used the ship workflow, these steps were already handled by the skill. This checklist is for manual merges. -# Pre-Merge Checklist +# Post-Merge Housekeeping -Complete these steps on the feature branch before creating the PR. +Complete these steps after a successful squash merge. Leave the started worktree before removing it — do not run `loaf issue stop` from inside that worktree. -1. **Close out spec artifacts** (so they're included in the squash merge): +1. **Switch to the PR base and pull:** ``` - loaf task update TASK-XXX --status done - loaf task archive --spec SPEC-XXX - loaf spec archive SPEC-XXX + git checkout <baseRefName> + git pull --ff-only origin <baseRefName> ``` - Write an optional `wrap(scope)` journal entry with `loaf journal log` if the work produced synthesis worth saving. - -2. **Update CHANGELOG.md when the PR has release-facing impact:** - Add curated entries under `[Unreleased]` describing what the PR lands. Do not move entries to a versioned section here; the release workflow publishes the batch later. -3. **Rebuild all targets:** +2. **Mark the bound issue done** — this is what "done" means; `loaf issue stop` does not change status: ``` - npx loaf build + loaf issue status <ref> done ``` -4. **Commit and push** the changelog and generated artifacts to the PR branch. - -5. **Create PR** with `gh pr create` — title + summary + test plan. - -6. **Squash merge** with a clean commit body: - - Let GitHub default the title: `PR title (#N)` - - Write a concise 2-4 sentence summary as `--body` (use a HEREDOC) - - **Never** use the automatic squash description that dumps all individual commit messages - ---- - -# Post-Merge Housekeeping - -Complete these steps on main after merging. +3. **Stop the started worktree** if one exists (`loaf issue list --started`). `loaf issue stop` removes the worktree, clears `started_branch` / `started_worktree`, and keeps the branch: + ``` + loaf issue stop <ref> + ``` + If the issue was never started, the command errors with `issue <ref> is not started` — treat that as already clean and continue. Do not pass `--force` without user confirmation. -1. **Switch to main and pull:** +4. **Delete the local feature branch** when safe: ``` - git checkout main && git pull --rebase + git branch -d <headRefName> ``` -2. **Delete merged feature branch:** +5. **Log the landing:** ``` - git branch -d feat/xxx - git push origin --delete feat/xxx + loaf journal log "decision(ship): PR #N landed via squash merge; <ref> done" + loaf journal log "commit(<hash>): <squash subject>" ``` -3. **Suggest reflection** if the session had key decisions or learnings. +6. **Suggest reflection** if the work produced key decisions or learnings. -4. **Suggest release only when appropriate** — if this PR completes a coherent batch or release branch, publish from the base branch after the landed work is present there. +7. **Suggest release only when appropriate** — if this PR completes a coherent batch, publish later with `loaf release suggest` / `loaf release cut`. The PR is landed, not released, until that cut. diff --git a/plugins/loaf/hooks/instructions/pre-pr-checklist.md b/plugins/loaf/hooks/instructions/pre-pr-checklist.md index d92539828..75129d985 100644 --- a/plugins/loaf/hooks/instructions/pre-pr-checklist.md +++ b/plugins/loaf/hooks/instructions/pre-pr-checklist.md @@ -50,13 +50,10 @@ No scope prefixes. No SPEC/TASK IDs in the title. ### 3. PR body -```markdown -## Summary -- Key changes (2-4 bullets) +The body is `loaf issue render <ref>` output. No project headers, no hand-edited summary. Checkboxes stay unchecked until `loaf issue status <ref> done`. -## Test plan -- [ ] Tests added/updated -- [ ] Manual testing performed +``` +gh pr create --title "type: summary" --body "$(loaf issue render <ref>)" ``` ### 4. Merge strategy diff --git a/plugins/loaf/skills/bootstrap/SKILL.md b/plugins/loaf/skills/bootstrap/SKILL.md index f79ea6b05..bd05782ec 100644 --- a/plugins/loaf/skills/bootstrap/SKILL.md +++ b/plugins/loaf/skills/bootstrap/SKILL.md @@ -33,7 +33,7 @@ First-contact project setup: detect state, interview the builder, populate proje - Guardrails - Related Skills -Series-prep lives under Finalization (phase between Knowledge Base Scaffolding and Next Steps). +Series-prep lives under Finalization (phase between Knowledge Base Scaffolding and Next Steps): the initial arc becomes backlog issues, not folders. **Input:** $ARGUMENTS @@ -46,8 +46,8 @@ Series-prep lives under Finalization (phase between Knowledge Base Scaffolding a - **Always interview** -- even with a rich brief, confirm understanding through structured questions — one at a time, with a recommendation, using your harness's structured question tool if it has one - **Pitched BRIEF is discovery-already-done** -- when `docs/BRIEF.md` has `source: pitch`, do not re-excavate the problem space; quote-back and gap-fill only for operating-document population - **BRIEF is input, not output** -- the BRIEF is raw intake. Extract every useful fact into VISION/STRATEGY/ARCHITECTURE/AGENTS during bootstrap. -- **BRIEF is archeological after bootstrap** -- once extraction completes (including series-prep reading scoped concepts from it), the BRIEF is a frozen historical snapshot. No skill, agent, command, or template should reference `docs/BRIEF.md` post-bootstrap. Operating documents and minted change briefs must stand on their own. -- **Series-prep never auto-shapes and never creates branches** -- every mint is user-confirmed; no priority, date, or dependency fields; concepts without a coarse `target_release` stay BRIEF lines, sparks, or Intents +- **BRIEF is archeological after bootstrap** -- once extraction completes (including series-prep reading scoped concepts from it), the BRIEF is a frozen historical snapshot. No skill, agent, command, or template should reference `docs/BRIEF.md` post-bootstrap. Operating documents and minted issue bodies must stand on their own. +- **Series-prep never auto-shapes and never creates branches** -- every mint is user-confirmed; no priority, date, or dependency fields; buckets are labels, never bindings; concepts that fail granularity stay BRIEF lines or sparks - **Suggest, don't execute** -- recommend next skills at the end, never auto-run them - **Log first** -- log invocation before interviewing: `loaf journal log "skill(bootstrap): <project or intake>"` - **Log outcome** -- log bootstrap completion to the project journal: `loaf journal log "decision(bootstrap): project bootstrapped, mode detected"` @@ -59,7 +59,7 @@ Series-prep lives under Finalization (phase between Knowledge Base Scaffolding a - All expected operating documents (`docs/VISION.md`, `AGENTS.md` at minimum) exist and contain populated content - Useful BRIEF content has been extracted into operating documents (no future reader should need to open the BRIEF) - When `source: pitch`, the interview was gap-only (no re-excavation of already-specific problem sections) -- When series-prep ran: each minted folder has `change.json` with stamped `target_release`, a standalone problem-space `brief.md`, zero-violation captured state via explicit-path `loaf change check <folder> --json`, and its own docs-only commit (never a batch); no branches created for the series; no auto-shape +- When series-prep ran: each minted row is a backlog issue (`loaf issue new "<title>" --body "<problem narrative>" --status backlog`) with a standalone problem-space body; an advisory bucket (`loaf issue bucket <ref> now|next|later`) may be set — buckets are labels, never bindings; `loaf issue check <ref>` only when a capture is shaped enough to check, otherwise nothing (a backlog issue with a problem body needs no ceremony); no folders, no docs-only commits; no branches created for the series; no auto-shape - Root `AGENTS.md` is a real file; on Claude Code, the compatibility symlink `.claude/CLAUDE.md -> ../AGENTS.md` exists (see Finalization) - Key decisions and interview outcomes were logged with `loaf journal log` and are readable with `loaf journal recent` @@ -240,7 +240,7 @@ Pitch owned the problem-space grill. Bootstrap does not re-excavate. The pitch 1. **Acknowledge the pitch** — name that `docs/BRIEF.md` carries `source: pitch` and that problem discovery is already done. 2. **Summarize what pitch captured** — short section-by-section gist (problem, who, alternatives, value, constraints, sequencing, open questions). The builder should hear continuity with the pitch closing ceremony, not a cold restart. -3. **State what bootstrap will do now** — interview only on gaps for operating-document population (VISION, STRATEGY, ARCHITECTURE, AGENTS), then series-prep the initial arc of captured changes. Do not re-grill the problem space. +3. **State what bootstrap will do now** — interview only on gaps for operating-document population (VISION, STRATEGY, ARCHITECTURE, AGENTS), then series-prep the initial arc as backlog issues. Do not re-grill the problem space. Then continue: @@ -427,58 +427,62 @@ The journal should capture: Use [templates/journal.md](templates/journal.md) only as the rendered entry format reference; do not hand-author journal markdown as the source of truth. -### 4. Series-Prep (initial arc as captured changes) +### 4. Series-Prep (initial arc as backlog issues) -After operating documents are populated (and Knowledge Base Scaffolding above has run), close bootstrap by minting the BRIEF's initial arc as **captured promise carriers** — brief-only change folders bound to a coarse `target_release`, each landed as its own docs-only commit. Series-prep is not roadmap planning: no milestone entities, no dates, no priorities, no dependency fields. Sequencing is prose in each brief; cohort membership is the shared `target_release`. +After operating documents are populated (and Knowledge Base Scaffolding above has run), close bootstrap by minting the BRIEF's initial arc as **backlog issues** — SQLite rows with a problem-space body and an optional advisory bucket label (`loaf issue bucket <ref> now|next|later`). Buckets are labels, never bindings. Series-prep is not roadmap planning: no milestone entities, no dates, no priorities, no dependency fields. Sequencing is prose in each issue body. No folders, no docs-only commits per capture — rows, not files. **When to run** - Always offer series-prep when a project BRIEF exists and names more than one scoped concept (typical after a pitched BRIEF; also after a rich non-pitch brief). -- If the BRIEF is a single atomic concept with no series, say so and skip to Next Steps — one future shape or a single capture later is enough. -- Series-prep **reads** the BRIEF during this phase only. After bootstrap ends, nothing references `docs/BRIEF.md` again; minted change briefs and operating docs stand alone. +- If the BRIEF is a single atomic concept with no series, say so and skip to Next Steps — one future shape or a single issue later is enough. +- Series-prep **reads** the BRIEF during this phase only. After bootstrap ends, nothing references `docs/BRIEF.md` again; minted issue bodies and operating docs stand alone. **Procedure** 1. **Enumerate concepts** with the builder from the BRIEF's scoped problem space (Sequencing and Relationships, Open Questions, and distinct problem threads in Problem Statement). List candidates as recommendation-first options using your harness's structured question tool if it has one. -2. **Apply granularity** per [references/interview-guide.md](references/interview-guide.md) (Series-Prep Granularity): a concept earns its own captured change when it is independently shippable **and** its problem can be **stated precisely now** (the mint-time specifiability test — not answered now, stated now) without the others; otherwise it stays a BRIEF line, becomes a spark, or an Intent — never a half-minted folder. +2. **Apply granularity** per [references/interview-guide.md](references/interview-guide.md) (Series-Prep Granularity): a concept earns its own backlog issue when it is independently shippable **and** its problem can be **stated precisely now** (the mint-time specifiability test — not answered now, stated now) without the others; otherwise it stays a BRIEF line or becomes a spark — never a half-minted row. 3. **Per confirmed concept (one at a time — never batch):** - 1. Confirm mint with the builder (slug, coarse `target_release`, one-line problem restatement). If the builder will not bind even a coarse target, do not mint — park as spark/Intent/BRIEF line. - 2. Propose a **local slug** that names the concept, never another work unit (`spec-042`, task ids, change folder names). Confirm the slug. - 3. Run capture init: + 1. Confirm mint with the builder (title, optional advisory bucket, one-line problem restatement). Buckets are labels, never bindings — a missing bucket does not block mint. If the concept fails granularity, do not mint — park as spark or BRIEF line. + 2. Propose a **working title** that names the concept, never another work unit (issue aliases, task ids). Confirm the title. + 3. **Seed a problem-space-only narrative** from the BRIEF's content for that concept (Problem Statement, Who Has It, Current Alternatives, Value Proposition, Constraints, Sequencing and Relationships as prose order relative to the arc, Sources if any, Open Questions). Do not copy solution design. The seeded body must stand alone as intent for later shape — cold-read without the project BRIEF or this session. + 4. Mint the backlog issue: ```bash - loaf change init <slug> --brief + loaf issue new "<title>" --body "<problem narrative>" --status backlog ``` - Creates `docs/changes/YYYYMMDD-<slug>/` with `change.json` + `brief.md` only. - 4. **Seed `brief.md` problem-space-only** from the BRIEF's content for that concept (Problem Statement, Who Has It, Current Alternatives, Value Proposition, Constraints, Sequencing and Relationships as prose order relative to the arc, Sources if any, Open Questions). Do not copy solution design. The seeded brief must stand alone as intent for later shape — cold-read without the project BRIEF or this session. - 5. **Stamp `target_release`** on that folder's `change.json` with the builder's coarse binding (canonical `MAJOR.MINOR.PATCH`, no `v`, no prerelease). Series-prep mints only targeted captures (promise-carrier path). - 6. **Pre-landing guard** (required before every commit): + Creates a SQLite row, not a folder. Use `--body -` or `--body-file <path>` when the narrative is long (see `loaf issue new --help`). + 5. **Optionally set an advisory bucket** (a label, never a binding): ```bash - loaf change check <folder> --json + loaf issue bucket <ref> now|next|later ``` + 6. **Validate** only when the capture is shaped enough to check: - Must report zero violations and captured state. Then **read `<folder>/change.json` directly** and confirm the stamped `target_release` matches what the builder bound. Bare `loaf change check` resolves by branch and can miss a capture elsewhere — always pass the explicit folder path. - 7. **Land as its own docs-only commit on the default branch** (one commit per capture, never a batch). Example subject: `docs(change): capture <slug> brief`. Bootstrap prepares the commit; never push; never open a PR. + ```bash + loaf issue check <ref> + ``` + + A backlog issue with a problem body is capture-only and needs no ceremony — skip the check. Do not add criteria or an out-of-scope statement during series-prep (that is shape). + 7. **Do not land a docs-only commit.** The row is the artifact. Never push; never open a PR; never create a branch. 4. **Guards (hard):** - Every mint is user-confirmed — never auto-mint the whole list - - Never auto-run shape and never create slug branches during series-prep - - No priority, date, estimate, or dependency fields on captures - - No batching multiple captures into one commit - - Concepts without a coarse target stay BRIEF lines, sparks, or Intents + - Never auto-run shape and never create branches during series-prep + - No priority, date, estimate, or dependency fields on issues + - No folders, no docs-only commits per capture — rows, not files + - Concepts that fail granularity stay BRIEF lines or sparks; buckets are labels, never bindings **After the series** -Log the arc: `loaf journal log "decision(bootstrap): series-prep minted <n> captures for <cohort or targets>"`. Hand off by naming the first capture folder for shape when the builder is ready. +Log the arc: `loaf journal log "decision(bootstrap): series-prep minted <n> backlog issues"`. Hand off by naming the first issue ref for shape when the builder is ready. ### 5. Next Steps Suggest relevant next steps based on what was learned: -- shape -- on a series-prep capture (or any ready concept) to promote the folder and bound implementation +- shape -- on a series-prep backlog issue (or any ready concept) to bound implementation - pitch -- if a new concept still needs problem discovery (not for re-grilling the BRIEF) -- idea -- if specific feature ideas emerged during the interview and should not become captures yet +- idea -- if specific feature ideas emerged during the interview and should not become issues yet - research -- if there are open questions that need investigation - `loaf doctor` -- to verify the setup is healthy @@ -505,18 +509,18 @@ When the interactive interview path is unavailable, bootstrap the operating docu 2. **Always interview** -- even with a rich brief, confirm understanding; when `source: pitch`, gap-fill only 3. **Never overwrite** -- existing documents require explicit confirmation 4. **Draft, then review** -- present documents section-by-section -5. **Extract, don't preserve** -- pull every useful fact from the BRIEF into operating docs (and series-prep seeds change briefs from it once). The BRIEF is archeological after bootstrap; nothing should reference it again. +5. **Extract, don't preserve** -- pull every useful fact from the BRIEF into operating docs (and series-prep seeds issue bodies from it once). The BRIEF is archeological after bootstrap; nothing should reference it again. 6. **Record the session** -- decisions and rationale are preserved 7. **Suggest, don't execute** -- recommend next skills, don't auto-run them; series-prep never auto-shapes or creates branches 8. **Interview structured** -- one question at a time, with a recommendation, using your harness's structured question tool if it has one -9. **Series-prep is not roadmap planning** -- coarse `target_release` + prose sequencing only; no dates, priorities, or dependency fields +9. **Series-prep is not roadmap planning** -- advisory bucket labels (never bindings) + prose sequencing only; no dates, priorities, or dependency fields --- ## Related Skills -- **pitch** -- Authors a project-scale `docs/BRIEF.md` with `source: pitch` (or a change-scale brief); bootstrap consumes the pitched BRIEF with gap-only interview and series-prep -- **shape** -- Bound a captured change into a contract (promotes brief-only folders; often follows series-prep) +- **pitch** -- Authors a project-scale `docs/BRIEF.md` with `source: pitch` (or an issue-scale problem narrative); bootstrap consumes the pitched BRIEF with gap-only interview and series-prep +- **shape** -- Bound a backlog issue into a contract (often follows series-prep) - **explore** -- Agent technique when a concept that emerges during bootstrap is still undecided (not a user front door; prefer pitch for human problem discovery) - **research** -- Investigate topics and open questions - **idea** -- Quick-capture feature ideas that emerge during bootstrap diff --git a/plugins/loaf/skills/bootstrap/references/interview-guide.md b/plugins/loaf/skills/bootstrap/references/interview-guide.md index f3fc0c1ef..83f583396 100644 --- a/plugins/loaf/skills/bootstrap/references/interview-guide.md +++ b/plugins/loaf/skills/bootstrap/references/interview-guide.md @@ -358,33 +358,32 @@ Expect 6-10 questions total, mostly in Excavation and Sharpening. Grounding is l ## Series-Prep Granularity -Series-prep (bootstrap Finalization) turns the project BRIEF's scoped concepts into captured change folders. This section resolves when a concept earns a folder versus staying a BRIEF line, spark, or Intent. Full procedure: bootstrap SKILL.md Finalization → Series-Prep. +Series-prep (bootstrap Finalization) turns the project BRIEF's scoped concepts into backlog issues. This section resolves when a concept earns a row versus staying a BRIEF line or spark. Full procedure: bootstrap SKILL.md Finalization → Series-Prep. -### Earns its own captured change when both are true +### Earns its own backlog issue when both are true -1. **Independently shippable** — the concept could land as a meaningful release unit without waiting on the other scoped concepts to ship in the same commit or PR. Sharing a coarse `target_release` cohort is fine; hard entanglement is not. -2. **Specifiability (mint test)** — a concept mints only if its problem can be **stated precisely now** (not answered now): a cold reader can name the friction, who has it, the current alternative, and the value for *this* concept alone, without needing sibling folders or inventing what was meant. Everything vaguer stays a BRIEF line, spark, or Intent — never pre-sliced into a fake capture. +1. **Independently shippable** — the concept could land as a meaningful release unit without waiting on the other scoped concepts to ship in the same commit or PR. Sharing an advisory bucket is fine (buckets are labels, never bindings); hard entanglement is not. +2. **Specifiability (mint test)** — a concept mints only if its problem can be **stated precisely now** (not answered now): a cold reader can name the friction, who has it, the current alternative, and the value for *this* concept alone, without needing sibling issues or inventing what was meant. Everything vaguer stays a BRIEF line or spark — never pre-sliced into a fake row. -When both hold and the builder will bind a coarse `target_release`, mint: `loaf change init <slug> --brief`, seed problem-space-only, stamp the target, pre-landing check + `change.json` read-back, one docs-only commit on the default branch. +When both hold, mint: `loaf issue new "<title>" --body "<problem narrative>" --status backlog`, seed problem-space-only, optionally `loaf issue bucket <ref> now|next|later` (buckets are labels, never bindings). Run `loaf issue check <ref>` only when the capture is shaped enough to check; a backlog issue with a problem body needs no ceremony. No folders, no docs-only commits — rows, not files. -### Stays a BRIEF line, spark, or Intent when any is true +### Stays a BRIEF line or spark when any is true - The concept only makes sense as a clause of a larger problem (it cannot be stated alone). - Its problem cannot yet be stated precisely — coarse intent only; minting would invent precision. - It is a solution-space detail, implementation task, or "nice-to-have" flavor — not a shippable problem boundary. -- The builder will not bind even a coarse `target_release` (untargeted series members are not promise carriers on main; keep them as sparks/Intents or leave them as prose in the BRIEF until a target exists). -- Splitting would create two folders that always ship as one unit — keep one folder and name the sub-concerns in Sequencing prose. +- Splitting would create two issues that always ship as one unit — keep one issue and name the sub-concerns in Sequencing prose. ### Interview moves during series-prep -- Recommend a decomposition first (list candidate folders + what stays in the BRIEF), then confirm each mint one at a time. +- Recommend a decomposition first (list candidate issues + what stays in the BRIEF), then confirm each mint one at a time. - Challenge over-splitting: "Can this problem be stated precisely now without that one?" If no, merge candidates or leave as BRIEF lines. -- Challenge under-splitting: "Is this independently shippable, or are we smuggling two products into one capture?" -- Never invent priority ranks, dates, or machine dependency fields — sequencing is prose; cohort is `target_release`. +- Challenge under-splitting: "Is this independently shippable, or are we smuggling two products into one issue?" +- Never invent priority ranks, dates, or machine dependency fields — sequencing is prose; buckets are labels, never bindings. ### Standalone cold-read test (H3) -Each minted brief must name its problem without reference to the bootstrap session or to `docs/BRIEF.md`. If a draft says "as discussed" or "per the project brief," rewrite until the problem stands alone. +Each minted issue body must name its problem without reference to the bootstrap session or to `docs/BRIEF.md`. If a draft says "as discussed" or "per the project brief," rewrite until the problem stands alone. --- diff --git a/plugins/loaf/skills/bootstrap/templates/brief.md b/plugins/loaf/skills/bootstrap/templates/brief.md index d7b58d0db..5b550a307 100644 --- a/plugins/loaf/skills/bootstrap/templates/brief.md +++ b/plugins/loaf/skills/bootstrap/templates/brief.md @@ -39,7 +39,7 @@ archived: true # Always true -- BRIEF is a historical snapshot, not a w ## Sequencing and Relationships -[How the initial arc hangs together — which concepts belong as early changes, what depends on what, release cohort stated as prose. No machine relation fields; narrative order only.] +[How the initial arc hangs together — which problems become early backlog issues, what depends on what, sequencing stated as prose. No machine relation fields; narrative order only.] ## Sources and Research Links diff --git a/plugins/loaf/skills/bootstrap/templates/journal.md b/plugins/loaf/skills/bootstrap/templates/journal.md index caf7409fd..9a0a61f8f 100644 --- a/plugins/loaf/skills/bootstrap/templates/journal.md +++ b/plugins/loaf/skills/bootstrap/templates/journal.md @@ -13,7 +13,7 @@ not create or edit journal markdown as the source of truth — use ## Entries -[YYYY-MM-DD HH:MM] skill(implement): implementing TASK-042 +[YYYY-MM-DD HH:MM] skill(implement): implementing LOAF-42 [YYYY-MM-DD HH:MM] decision(scope): description of decision [YYYY-MM-DD HH:MM] discover(scope): something learned [YYYY-MM-DD HH:MM] block(scope): what is blocked diff --git a/plugins/loaf/skills/breakdown/SKILL.md b/plugins/loaf/skills/breakdown/SKILL.md deleted file mode 100644 index 55ff7d943..000000000 --- a/plugins/loaf/skills/breakdown/SKILL.md +++ /dev/null @@ -1,332 +0,0 @@ ---- -name: breakdown -description: >- - Decomposes specifications into atomic tasks with dependencies and priorities. - Use when the user asks "break this down" or "create tasks for this spec." - Produces task files with estimates, dependencies, and acceptance criteria. Not - for shaping ideas (use shape) or implementation work (use implement). -user-invocable: true -argument-hint: '[spec-file or topic]' -version: 0.2.21 ---- - -# Breakdown - -Decompose specifications into atomic, implementable tasks. - -## Contents -- Critical Rules -- Verification -- Quick Reference -- Task Breakdown Philosophy -- Task Backend Detection -- Process -- Linear-Native Mode -- Local-Tasks Mode -- Priority Levels -- Guardrails -- Related Skills - -**Input:** $ARGUMENTS - ---- - -## Critical Rules - -- **One concern per task** -- never mix unrelated layers (backend + frontend) in a single task -- **Every task includes its own verification** -- no separate "verify" tasks; each task must have an observable done condition -- **Own the decisions** -- decide granularity and priorities autonomously; only ask the user when two equally valid orderings have genuinely different trade-offs -- **Keep tests with the code they test** -- never split implementation and tests into separate tasks -- **Update spec status** -- mark the spec as `implementing` after tasks are created -- **One backend only** -- in Linear-native mode create Linear issues and NO local `TASK-NNN.md`; in local mode create local tasks and make NO Linear calls -- **Spec file is always local** -- in both modes, the spec stays in `.agents/specs/`. The Linear parent issue, when present, is a rollup pointing to the spec, not a re-host of it -- **Log outcome** -- log breakdown to the project journal: `loaf journal log "decision(breakdown): SPEC-NNN → N tasks created"` - ---- - -## Verification - -- Each created task has a clear title, priority, file hints, verification command, and observable done condition -- The dependency graph has no cycles and reflects actual implementation order -- Spec status has been updated to `implementing` -- **Linear-native mode only:** parent issue exists, labeled `spec`, with description pointing to the local spec file; N sub-issues have `parentId` set; zero local task rows or `TASK-NNN.md` files were created; spec frontmatter has `linear_parent` and `linear_parent_url` populated -- **Local-tasks mode only:** N local tasks exist in `loaf task list` with compatibility `.md` files when configured; no Linear calls were made - ---- - -## Quick Reference - -### Priority Levels - -| Priority | Loaf | Linear Priority | -|----------|------|-----------------| -| P0 | Urgent/blocking -- drop everything | Urgent (1) | -| P1 | High -- work next | High (2) | -| P2 | Normal -- scheduled work (default) | Normal (3) | -| P3 | Low -- when time permits | Low (4) | - -### Right-Sizing Rules - -| Rule | Guideline | -|------|-----------| -| **One agent type** | Completable by a single implementer (after skills narrowing) | -| **One concern** | Touches one layer, service, or component | -| **Context-appropriate** | Fits in model context with room for exploration | -| **Not over-fragmented** | Don't split what naturally belongs together | - -### Mode Selection - -| `integrations.linear.enabled` in `.agents/loaf.json` | Mode | See | -|------------------------------------------------------|------|-----| -| `true` | Linear-native | [Linear-Native Mode](#linear-native-mode) | -| `false` or absent | Local-tasks | [Local-Tasks Mode](#local-tasks-mode) | - ---- - -## Task Breakdown Philosophy - -**Primary principle: separation of concerns.** - -### The Right Size Test - -1. Can a single implementer complete this? If no, split by concern -2. Does it touch multiple unrelated concerns? If yes, split by concern -3. Will the agent need too much context? If yes, split into smaller coherent units -4. Am I splitting just to have more tasks? If yes, merge back - -### Anti-Patterns - -| Don't | Do Instead | -|-------|------------| -| Split backend + tests into separate tasks | Keep tests with the code they test | -| Create a task per file | Group files by concern | -| Separate "implement" and "verify" tasks | Every task includes its own verification | -| Copy the full spec text into the Linear parent issue | Summarize + link to the local spec file | -| Create both local `TASK-NNN.md` and Linear sub-issues | Pick one backend; never mix | - ---- - -## Task Backend Detection - -Read `.agents/loaf.json`: - -```json -{ - "integrations": { - "linear": { "enabled": true } - } -} -``` - -If `integrations.linear.enabled` is `true`, proceed in **Linear-native mode**. -Otherwise, proceed in **Local-tasks mode**. - -If `.agents/loaf.json` is missing, default to local-tasks and note the -assumption in the project journal. - ---- - -## Process - -### Step 1: Parse Input - -`$ARGUMENTS` should reference a spec (e.g., "SPEC-001"). If unclear, list available specs. - -### Step 2: Read the Spec - -Extract: test conditions, scope, implementation notes, priority ordering, complexity size. - -### Step 3: Identify Task Boundaries - -Break down by concern (data layer, backend, frontend, infrastructure, etc.). One concern per task. Explicit dependencies for sequential tasks. - -### Step 4: Decide Priorities and Granularity - -Own the granularity and priority decisions. Apply the Right Size Test, assign priorities -based on dependencies, priority order, and go/no-go gates, and do a self-review pass. Do not -defer these decisions to the user — they trust agent judgment here. - -If genuinely uncertain (e.g., two equally valid orderings with different trade-offs), -ask. Otherwise, decide and move on. - -### Step 5: Draft Task List - -Draft tasks following [task template](templates/task.md). Each task needs: clear title, priority, file hints, verification command, observable done condition, labels (if routing by team). - -### Step 6: Present the Plan - -Show the dependency graph and task summary for awareness before creating anything. -Present it as "here's what I'm creating" not "which option do you prefer?" The user -can still adjust after creation, but the default is to proceed. - -### Step 7: Create Tasks (mode-specific) - -Detect the mode (see [Task Backend Detection](#task-backend-detection)) and follow the -matching section below. Do NOT mix modes. - -- Linear enabled → [Linear-Native Mode](#linear-native-mode) -- Linear disabled or missing → [Local-Tasks Mode](#local-tasks-mode) - -### Step 8: Update Spec and Announce - -Set spec status to `implementing`. In Linear-native mode, also write -`linear_parent` and `linear_parent_url` into the spec's frontmatter. Announce -created tasks and next steps. - ---- - -## Linear-Native Mode - -Spec files stay local and canonical in `.agents/specs/`. Tasks live in Linear -as sub-issues of a parent rollup issue representing the spec. No local -task rows or `TASK-NNN.md` files are created. - -### 7a. Ensure the `spec` label exists - -The `spec` label groups all spec-parent rollup issues so Linear users can -filter for them. - -1. Call `list_issue_labels` to check whether a label named `spec` exists. -2. If missing, create it via `create_issue_label`: - - `name`: `spec` - - `color`: `#5e6ad2` (Linear-ish indigo; implementer may adjust) - - `description`: `Parent rollup issue representing a design spec tracked in the repo at .agents/specs/` - - Prefer workspace-scoped so all teams can filter uniformly. If the MCP - only supports team-scoped labels, create on the default team. -3. Log whether the label was created this run or already existed. This - matters for first-time Loaf setup on a Linear workspace. - -### 7b. Resolve team, project, and state - -Read from `.agents/loaf.json`: - -- **Team:** `linear.default_team` (name) — resolve to team ID via - `list_teams` if not already cached in `known_teams`. -- **Project:** `linear.project.id`. -- **State:** call `list_issue_statuses` for the team, pick the - `unstarted`-type state (typically "Backlog" or "To-Do"). States are - **team-scoped**, not workspace-scoped — always pass the team. - -### 7c. Create the parent issue - -Use `create_issue` with: - -| Field | Value | -|-------|-------| -| `title` | `[SPEC-NNN] <spec title>` | -| `teamId` | from 7b | -| `projectId` | from 7b | -| `stateId` | unstarted state from 7b | -| `priority` | mapped from spec (default High = 2 if unspecified) | -| `labels` | `["spec"]` | -| `description` | Summary synthesized from the spec's Problem Statement + Solution Direction (1–3 paragraphs), ending with: `See .agents/specs/SPEC-NNN-<slug>.md for full text, council references, and strategic tensions.` | - -**Do NOT** copy the full spec body into the description. The local file is canonical. - -### 7d. Check label-group conflicts (pre-flight per sub-issue) - -Linear labels can belong to exclusive groups (e.g., a `type` group where -`feature`, `testing`, `docs`, `bug`, `refactor` are mutually exclusive). -Before creating each sub-issue: - -1. Inspect proposed labels against known group membership (from - `list_issue_labels` group metadata). -2. If a task has more than one label from the same exclusive group, pick the - most appropriate and drop the others. Warn the user about the drop. -3. Log the resolution so the user can override if desired. - -### 7e. Create sub-issues - -For each task, use `create_issue` with: - -| Field | Value | -|-------|-------| -| `parentId` | parent issue ID from 7c | -| `title` | task title | -| `description` | task description + acceptance criteria | -| `teamId` | routed from `team_keywords` or falling back to `default_team` | -| `projectId` | same as parent unless task explicitly belongs elsewhere | -| `stateId` | unstarted state for the target team | -| `priority` | mapped from task priority (see Priority Levels table) | -| `labels` | task labels after conflict resolution (7d) | - -Express dependencies from the spec's Priority Order / dependency graph via -`blockedBy` referencing sibling sub-issue IDs. Create in dependency order so -predecessors exist when referenced. - -### 7f. Do NOT create local task files - -Skip `loaf task create` entirely. Linear issue IDs are the task record. No -local task rows or `TASK-NNN.md` files for this spec's tasks. - -### 7g. Update spec frontmatter - -Add to the spec file's YAML frontmatter: - -```yaml -linear_parent: ENG-198 -linear_parent_url: https://linear.app/<workspace>/issue/ENG-198 -``` - -Use the actual parent issue identifier and URL returned from 7c. - ---- - -## Local-Tasks Mode - -Spec files and task files both live locally. No Linear calls. - -Use `loaf task create --spec SPEC-XXX --title "Task title" --priority P1` -for each task. In SQLite-backed projects, the CLI creates the operational state -row and any compatibility Markdown/index artifacts needed by the current -project. Then edit the `.md` body content (description, acceptance criteria) -only when an authored task prose artifact exists. - -Dependencies are expressed through CLI flags such as `--depends-on`, not by -hand-editing the compatibility index. Priority Order from the spec maps directly -to task `priority` fields. - -See [local-tasks reference](../orchestration/references/local-tasks.md) for -the full local-task model. - ---- - -## Priority Mapping (reference) - -| Loaf | Linear API value | Linear label | -|------|------------------|--------------| -| P0 | `1` | Urgent | -| P1 | `2` | High | -| P2 | `3` | Normal | -| P3 | `4` | Low | - ---- - -## Guardrails - -1. **One concern per task** -- don't mix backend + frontend -2. **Clear verification** -- how to prove it works -3. **Observable done condition** -- not subjective -4. **File hints** -- help session know where to look -5. **Own the decisions** -- decide granularity and priorities, don't defer -6. **Update spec status** -- mark as implementing -7. **One backend only** -- Linear-native creates Linear issues and no local tasks; local-tasks mode creates local tasks and no Linear calls -8. **Summary not copy** -- the Linear parent description summarizes + links; it does not re-host the spec - ---- - -## Suggests Next - -After breakdown completes, suggest implement to start working on the tasks. - -## Related Skills - -- **shape** -- Create specs that get broken down -- **implement** -- Start session for a task or coordinate multiple tasks - -## Topics - -| Topic | Reference | Use When | -|-------|-----------|----------| -| Linear Integration | `orchestration/references/linear.md` | Working out Linear issue structure, labels, parent/child | -| Local Task Model | `orchestration/references/local-tasks.md` | Local-tasks mode details and CLI flags | diff --git a/plugins/loaf/skills/breakdown/templates/task.md b/plugins/loaf/skills/breakdown/templates/task.md deleted file mode 100644 index fe74a794f..000000000 --- a/plugins/loaf/skills/breakdown/templates/task.md +++ /dev/null @@ -1,28 +0,0 @@ -# Task Template - -**Create with:** `loaf task create --title "..." --spec SPEC-XXX` - -```yaml ---- -id: TASK-XXX -title: [Clear action] -spec: SPEC-001 -status: todo -priority: P2 -files: - - [likely file 1] - - [likely file 2] -verify: [command to verify] -done: [observable outcome] ---- - -## Description -[What needs to be done] - -## Acceptance Criteria -- [ ] [Criterion 1] -- [ ] [Criterion 2] - -## Context -See SPEC-001 for full context. -``` diff --git a/plugins/loaf/skills/council/SKILL.md b/plugins/loaf/skills/council/SKILL.md index f2d355c04..eda19fe35 100644 --- a/plugins/loaf/skills/council/SKILL.md +++ b/plugins/loaf/skills/council/SKILL.md @@ -79,13 +79,13 @@ Councils stay **local**. Even when the workspace uses Linear-native mode, council files live in `.agents/councils/` — they are deliberation artifacts, not executable work, and belong with specs in git. -When a council resolves a spec's open questions: +When a council resolves an issue's open questions: -- Include the spec ID in council frontmatter (e.g., `spec: SPEC-024`). This +- Include the issue ID in council frontmatter (e.g., `issue: LOAF-42`). This is already the common pattern. -- If the spec's `linear_parent` has been populated by breakdown, also - include `linear_parent: ENG-198` in council frontmatter so a reader on - Linear can trace back to the deliberation. +- If the issue is tracked in Linear (tracker authority), also include the + tracker key (e.g., `linear_parent: ENG-198`) in council frontmatter so a + reader on Linear can trace back to the deliberation. - Do not post council content to the Linear parent issue. A brief one-line reference ("Resolved via council 2026-04-21 — see .agents/councils/…") in a sub-issue comment is sufficient if the council drove a specific task diff --git a/plugins/loaf/skills/documentation-standards/SKILL.md b/plugins/loaf/skills/documentation-standards/SKILL.md index ee6a8c172..528ad4226 100644 --- a/plugins/loaf/skills/documentation-standards/SKILL.md +++ b/plugins/loaf/skills/documentation-standards/SKILL.md @@ -51,7 +51,7 @@ Standards for ADRs, API docs, changelogs, and diagrams. - Internal spec/task IDs - Verbatim commit or PR-title dumps - **Good examples:** - - "Add `loaf release --post-merge` guardrails for tagged GitHub releases" + - "Add `loaf release suggest` and `loaf release cut` for retroactive releases" - "Fix journal context routing when hook payloads are empty" - "Document worktree-aware `.agents/` storage for linked checkouts" - **Version protection:** diff --git a/plugins/loaf/skills/explore/SKILL.md b/plugins/loaf/skills/explore/SKILL.md index f612029ee..ade021109 100644 --- a/plugins/loaf/skills/explore/SKILL.md +++ b/plugins/loaf/skills/explore/SKILL.md @@ -2,16 +2,16 @@ name: explore description: >- Conducts divergent inquiry as a durable Exploration with portable checkpoints, - conversation provenance, and Intent capture that survive compaction and - harness changes. Agent technique — not a user entry point: route "explore - this" and similar user asks to pitch; use this technique from inside pitch or - other agent work when the direction is genuinely undecided, or when resuming a - named Exploration. Produces Exploration records, portable checkpoints, and - tracked or deferred Intents; Exploration machinery and the four-field - checkpoint contract stay intact. Not for evidence gathering on a known - question (use research), continuing implementation (use implement), processing - the intake queue (use triage), shaping a bounded Change (use shape), problem - discovery (use pitch), or quick capture (use idea). + conversation provenance, and backlog-issue dispositions that survive + compaction and harness changes. Agent technique — not a user entry point: + route "explore this" and similar user asks to pitch; use this technique from + inside pitch or other agent work when the direction is genuinely undecided, or + when resuming a named Exploration. Produces Exploration records, portable + checkpoints, and backlog issues for crystallized directions; Exploration + machinery and the four-field checkpoint contract stay intact. Not for evidence + gathering on a known question (use research), continuing implementation (use + implement), processing the intake queue (use triage), shaping a bounded issue + (use shape), problem discovery (use pitch), or quick capture (use idea). user-invocable: false argument-hint: '[topic or exploration ref]' version: 0.2.21 @@ -32,6 +32,7 @@ Divergent inquiry with durable continuity. An Exploration is a relational identi - Process - Checkpoint Discipline - Resumption +- Parking a direction - Techniques - Related Skills @@ -41,37 +42,39 @@ Divergent inquiry with durable continuity. An Exploration is a relational identi - You choose what an Exploration means and when to checkpoint; the CLI validates and performs the operation you request. Never expect the CLI to classify or decide for you. - Checkpoint before the context window gets hostile: every checkpoint must carry all four portable fields — purpose, conclusions, unresolved, next action — each self-sufficient without this conversation. - A conversation handle or log path is provenance, never context. Presence of handles does not make an Exploration resumable; only a portable checkpoint does. -- Capture crystallized directions as Intent (`loaf intent create`), deferred bodies with `--disposition deferred`; never leave a substantial direction only in prose. -- Never create Git artifacts, branches, worktrees, or Changes from Explore; when a direction is ready for problem discovery hand it to pitch, and when it is ready for bounded delivery hand it to shape. +- Capture crystallized directions as backlog issues (`loaf issue new "<title>" --status backlog`); park remaining unsharp questions on that issue with `--fog`. Never leave a substantial direction only in prose. +- Never create Git artifacts, branches, or worktrees from Explore; when a direction is ready for problem discovery hand it to pitch, and when it is ready for bounded delivery hand it to shape (issue preparation). - Never store transcripts, prompts, or tool output in checkpoints or items; curate semantic context instead. -- Not a user slash front door — human "explore this" / "where do I start" intent routes to pitch. +- Not a user slash front door — human "explore this" / "where do I start" routes to pitch. ## Verification - The Exploration exists with `portable_context_present: true` after the first checkpoint (`loaf exploration list`). - `loaf exploration context <ref> --json` returns the four-field core whole, and a fresh reader could identify the next action from it alone. -- Crystallized directions exist as Intents with derived dispositions (`loaf intent list`). +- Crystallized directions exist as backlog issues (`loaf issue list --status backlog`); issue aliases named in the checkpoint match those rows. - Conversation provenance, when recorded, carries harness and locality facts without any transcript content. ## Quick Reference | Operation | Command | |-----------|---------| -| Start an inquiry | `loaf exploration create --title <title> [--from <intent-or-source>]...` | +| Start an inquiry | `loaf exploration create --title <title> [--from <source>]...` | | Checkpoint | `loaf exploration checkpoint <ref> --purpose <p> --conclusions <c> --unresolved <u> --next <n> [--item candidate:<text>]... [--operation-id <key>]` | | Resume elsewhere | `loaf exploration context <ref> --json` | -| Track a direction | `loaf intent create --title <t> --body <b> --from <source>...` | -| Defer a direction | `loaf intent create --title <t> --body <b> --disposition deferred --why <w> --boundary <bd> --trigger <tr> --operation-id <key> [--from <source>]` | +| File a direction | `loaf issue new "<title>" --status backlog [--parent <ref>] [--kind delivery\|decision] [--fog <text>] [--body <text>]` | +| Optional bucket | `loaf issue bucket <ref> now\|next\|later\|none` | | Record provenance | `loaf conversation create --title <label>` then `loaf conversation handle add <id> --harness <h> --handle <opaque-id> [--locality <scope>] [--log-ref <path>]` | | Associate conversation | `loaf exploration conversation add <exploration> <conversation-id>` | +`--from` on create accepts journal entries, handoffs, reports, and findings. It does not accept issue, spark, or idea refs — name those in the checkpoint and in the issue body instead. Buckets are labels only and are never read as a constraint. `fog` is writeable only at create. + ## Process 1. **Orient.** If the input names an existing Exploration, run `loaf exploration context <ref>` and continue from its recommended next action. Otherwise check `loaf exploration list` before creating a duplicate inquiry. -2. **Create or resume.** New inquiries get `loaf exploration create` with `--from` links to the Intents, journal entries, reports, or findings that motivated them. +2. **Create or resume.** New inquiries get `loaf exploration create` with `--from` links to the journal entries, reports, findings, or handoffs that motivated them. 3. **Diverge.** Expand the option space before judging it. Use the brainstorm stance (below), research, scouting, prototypes, or spikes as the question demands. -4. **Capture as you go.** Incidental thoughts become sparks; explicit propositions become ideas; deliberately tracked directions become Intents with their sources linked. -5. **Checkpoint.** At every meaningful plateau — and always before ending a session — append a checkpoint with the four portable fields and optional `candidate:`/`evidence:` items. +4. **Capture as you go.** Incidental thoughts become sparks (`loaf spark capture --scope <scope> --text <text>`); explicit propositions become ideas (`loaf idea capture --title "..."`); directions worth keeping become backlog issues. Resolve the capture against the issue so the direction appears once: `loaf spark resolve <ref> --by <issue-ref>` or `loaf idea resolve <ref> --by <issue-ref>`. +5. **Checkpoint.** At every meaningful plateau — and always before ending a session — append a checkpoint with the four portable fields and optional `candidate:`/`evidence:` items. Name any filed issue aliases in conclusions or next. 6. **Record provenance when useful.** Machine-local conversation handles and log locators help forensic navigation later; add them explicitly, and never infer identity from the current session. ## Checkpoint Discipline @@ -83,17 +86,17 @@ The four fields are the portable contract; each is capped at 4096 UTF-8 bytes an - **unresolved** — the open question or decision the inquiry currently turns on. - **next** — the recommended next action, concrete enough for a fresh agent to execute without this conversation. -Larger detail belongs in ordered `--item candidate:` and `--item evidence:` entries or in related reports, not crammed into the core fields. +Larger detail belongs in ordered `--item candidate:` and `--item evidence:` entries or in related reports, not crammed into the core fields. When filing an issue, copy still-unsharp questions into `--fog`; they will not be editable on the issue after create. ## Resumption -A new conversation, harness, or machine resumes with `loaf exploration context <ref> --json`: the portable core returns whole, and each optional layer (items, intents, evidence, conversations) reports counts, truncation, and its exact expansion command. Source handles appear with their last observed availability; treat unavailable ones as lost without ceremony — the checkpoint is the context. If `portable_context_present` is false, the Exploration was never checkpointed: rebuild understanding from linked sources, then write the missing checkpoint first. +A new conversation, harness, or machine resumes with `loaf exploration context <ref> --json`: the portable core returns whole, and each optional layer reports counts, truncation, and its exact expansion command. Source handles appear with their last observed availability; treat unavailable ones as lost without ceremony — the checkpoint is the context. If `portable_context_present` is false, the Exploration was never checkpointed: rebuild understanding from linked sources, then write the missing checkpoint first. -Before continuing, inspect the linked Intents in the context. If an Intent this inquiry was developing has since been resolved, do not silently reopen it: acknowledge the resolution, and if the checkpoint's next action still matters, create a successor Intent, record why in its body, and relate the lineage with `loaf link create --from <new-intent-ref> --to <resolved-intent-ref> --type derived-from`. Continued evidence gathering that serves no unresolved Intent should say so in its next checkpoint. +Before continuing, inspect issue aliases named in the checkpoint. If an issue this inquiry was developing is now done, cancelled, or duplicate, do not silently reopen it: acknowledge the resolution, and if the checkpoint's next action still matters, file a successor backlog issue and record why in its body. Continued evidence gathering that serves no open issue should say so in its next checkpoint. -## Deferring +## Parking a direction -An Exploration is never deferred, paused, or closed — it has no lifecycle to transition. "Defer this exploration" means two concrete acts: checkpoint the current state honestly, then defer the direction it was developing as an Intent — `loaf intent defer` on the linked Intent, or `loaf intent create --disposition deferred` for a new one followed by `loaf link create --from <exploration-ref> --to <intent-ref> --type explores`. The deferred Intent carries the revisit trigger; the Exploration simply waits, resumable from its checkpoint whenever the Intent is resumed. +An Exploration is never paused or closed — it has no lifecycle to transition. When the user wants to park or set aside the inquiry, do two concrete acts: checkpoint the current state honestly, then file the direction it was developing as a backlog issue (`loaf issue new "<title>" --status backlog`, optional `--parent`, `--fog` for remaining unsharp questions, optional `loaf issue bucket <ref> later`). The issue is the revisit surface; the Exploration simply waits, resumable from its checkpoint. ## Techniques @@ -101,8 +104,8 @@ Brainstorm's full divergent stance lives inside Explore: generate options before ## Related Skills -- **pitch** — human problem-discovery front door; route user entry intent here; reach for explore from inside pitch when still undecided -- **triage** — processes the intake queue and may disposition items toward pitch, shape, or agent-side explore -- **shape** — narrows one well-understood direction into a bounded Change +- **pitch** — human problem-discovery front door; route user entry here; reach for explore from inside pitch when still undecided +- **triage** — processes the intake queue and may disposition items toward a backlog issue, pitch, shape, or agent-side explore +- **shape** — prepares a well-understood direction as a bounded issue - **research** — evidence gathering for a known question, usable inside an Exploration - **idea** — quick capture without inquiry diff --git a/plugins/loaf/skills/foundations/references/code-review.md b/plugins/loaf/skills/foundations/references/code-review.md index 2c37d2f0b..37c8a2e10 100644 --- a/plugins/loaf/skills/foundations/references/code-review.md +++ b/plugins/loaf/skills/foundations/references/code-review.md @@ -86,5 +86,5 @@ Project code review conventions and workflow. | Command | Code Review Role | |---------|-----------------| | implement | Self-review before marking complete | -| breakdown | Review task scope and approach | +| shape | Review issue scope and DoD before implementation | | reflect | Note review feedback patterns | diff --git a/plugins/loaf/skills/foundations/references/tdd.md b/plugins/loaf/skills/foundations/references/tdd.md index 28c883709..1ea6fe7c9 100644 --- a/plugins/loaf/skills/foundations/references/tdd.md +++ b/plugins/loaf/skills/foundations/references/tdd.md @@ -54,6 +54,6 @@ If the failing test points to a non-obvious root cause, or if your first fix att | Phase | TDD Role | |-------|----------| | shape | Test conditions become TDD test cases | -| breakdown | Each task should have clear test targets | +| shape | Each promoted issue should have clear test targets in its DoD | | implement | Follow TDD cycle for each task | | reflect | Note TDD friction points for improvement | diff --git a/plugins/loaf/skills/foundations/references/verification.md b/plugins/loaf/skills/foundations/references/verification.md index d3cf84e1f..142aa7dfc 100644 --- a/plugins/loaf/skills/foundations/references/verification.md +++ b/plugins/loaf/skills/foundations/references/verification.md @@ -123,7 +123,7 @@ npm run lint # Check: No errors or warnings | Command | Verification Point | |---------|-------------------| | implement | Before marking session complete | -| breakdown | Each task has verification criteria | +| shape | Each issue has verification criteria (V-tier DoD) | | shape | Test conditions define verification | | reflect | Note verification gaps discovered | diff --git a/plugins/loaf/skills/git-workflow/SKILL.md b/plugins/loaf/skills/git-workflow/SKILL.md index fda197149..2a7541bb4 100644 --- a/plugins/loaf/skills/git-workflow/SKILL.md +++ b/plugins/loaf/skills/git-workflow/SKILL.md @@ -26,7 +26,7 @@ Git conventions for branching, commits, PRs, and merge workflow. - Use Conventional Commits format for all commit messages - Commit complete units of work -- don't commit partial or in-progress changes - Squash merge feature branches -- never merge commits directly -- One branch per spec/feature; branch name format: `feat/{slug}` +- One branch per issue; `loaf issue start` creates `issue/<alias-or-id>` (or use `feat/{slug}` / `fix/{slug}` when not starting from an issue) - Never force-push to `main` or shared branches - Never push without explicit user confirmation @@ -40,7 +40,7 @@ Git conventions for branching, commits, PRs, and merge workflow. | Action | Command/Pattern | |--------|----------------| -| Branch naming | `feat/{slug}`, `fix/{slug}`, `chore/{slug}` | +| Branch naming | `issue/<alias-or-id>` from `loaf issue start`; else `feat/{slug}`, `fix/{slug}`, `chore/{slug}` | | Commit format | `type: description` | | Squash merge | `gh pr merge --squash` | | PR creation | `gh pr create --title "..." --body "..."` | diff --git a/plugins/loaf/skills/git-workflow/references/commits.md b/plugins/loaf/skills/git-workflow/references/commits.md index a879b8709..3e9e3c00d 100644 --- a/plugins/loaf/skills/git-workflow/references/commits.md +++ b/plugins/loaf/skills/git-workflow/references/commits.md @@ -110,13 +110,14 @@ Closes BACK-123 ## Branch Naming ``` +issue/<alias-or-id> <type>/<description> -<type>/TASK-123-description ``` ### Types -- `feat/` - New features (e.g., `feat/spec-010-task-management-cli`) +- `issue/` - Started from `loaf issue start` (`issue/loaf-42`) +- `feat/` - New features (e.g., `feat/thermal-rating-cli`) - `fix/` - Bug fixes - `hotfix/` - Critical production fixes - `release/` - Release preparation @@ -126,7 +127,7 @@ Closes BACK-123 - Lowercase with hyphens (kebab-case) - Short but descriptive (max 50 chars) -- Include spec or task slug when applicable (e.g., `feat/spec-010-task-management-cli`) +- Prefer the started worktree branch from `loaf issue start` when implementing an issue ## Pull Request Format @@ -140,26 +141,10 @@ feat: add thermal rating calculation ### Description -Focus on **review context** — what changed, why, and how to test. Do not include squash merge commit text in the PR body. +The PR body is `loaf issue render <ref>` output — paste-ready, no manual editing. Definition-of-done criteria in the render are the review checklist. Do not include squash merge commit text in the PR body. -```markdown -## Summary - -Brief description of what this PR adds/changes and why. - -- Bullet points covering key changes -- Focus on what a reviewer needs to know - -## Test plan - -- [ ] Unit tests added/updated -- [ ] Integration tests pass -- [ ] Manual testing performed - -## Related Issues - -Closes BACK-123 -Refs BACK-124 +``` +gh pr create --title "type: summary" --body "$(loaf issue render <ref>)" ``` ### Merge Strategy @@ -182,10 +167,10 @@ published release notes read as user-facing prose, not an internal worklog. Internal terms that have no meaning outside the team's working context: -- Spec IDs and task IDs (`SPEC-024`, `TASK-042`) +- Internal work-unit numbering that is not the issue ID (issue IDs like `LOAF-42` belong in commits — release attribution reads them) - Session, sprint, or branch references - Internal terminology from skills/docs that isn't part of the user's mental model — e.g. `Q1`/`Q2`/`Q3` question numbers from a Triage Gate, internal gate-logic notation like `(Q1 OR Q2) AND Q3`, hook IDs that aren't user-facing -- "How the work got done" framing — interview steps, breakdown steps, review gates +- "How the work got done" framing — interview steps, decomposition steps, review gates ### Keep @@ -204,7 +189,7 @@ Internal terms that have no meaning outside the team's working context: ### Auto-generated Entries -When `loaf release` auto-generates the `[Unreleased]` section from commit history, those entries inherit any internal terms present in the commit messages. Treat the generated output as a draft: rewrite it under the curated path before bumping. The release skill preserves curated content when it's already in `[Unreleased]` — curate first, bump second. +`loaf release suggest` drafts notes from landed issues; `loaf release cut` prepends them into `CHANGELOG.md`. Treat drafted notes as a draft: rewrite internal terms before cutting. Curate `[Unreleased]` as PRs land so the later cut reads as user-facing prose. Before approving a release bump, compare `[Unreleased]` against the actual release range and remove scaffolding language introduced by specs, reviews, tasks, or session triage. If an entry only explains why the work was discovered or how the work was organized, it does not belong in the changelog. @@ -228,14 +213,15 @@ Before approving a release bump, compare `[Unreleased]` against the actual relea - Add agent attribution - Mix unrelated changes - Commit secrets or sensitive data -- Put SPEC or TASK IDs in commit subject (use human-readable names) +- Put work-unit IDs in the commit subject (use human-readable names). Issue aliases belong in the body so `loaf release suggest` can attribute the commit. ### ID References - **IDs belong in footer, not subject line** - - Bad: `feat: implement SPEC-002 invisible sessions` - - Good: `feat: implement invisible sessions and task board` + - Bad: `feat: implement LOAF-42 invisible sessions` + - Good: `feat: implement invisible sessions` - Use descriptive names that are understandable without looking up IDs +- Issue aliases (`LOAF-42`) go in the body so release attribution can find them - Linear issue IDs go in footer only (e.g., `Closes BACK-123`) ## Semantic Versioning @@ -277,6 +263,6 @@ BREAKING CHANGE: Description of breaking change. **Convention:** - Use standard SemVer pre-release identifiers (`alpha`, `beta`, or `rc`) when publishing pre-release versions. -- `loaf release` handles all bump types: `prerelease`, `release`, `major`, `minor`, `patch` +- `loaf release cut --bump` handles all bump types: `prerelease`, `release`, `major`, `minor`, `patch` **Not required** — projects using simple `MAJOR.MINOR.PATCH` versioning can ignore pre-release suffixes entirely. This convention is for projects publishing staged pre-releases before stable releases. diff --git a/plugins/loaf/skills/housekeeping/SKILL.md b/plugins/loaf/skills/housekeeping/SKILL.md index d221f6d98..ee5ecf3c2 100644 --- a/plugins/loaf/skills/housekeeping/SKILL.md +++ b/plugins/loaf/skills/housekeeping/SKILL.md @@ -1,12 +1,12 @@ --- name: housekeeping description: >- - Reviews and maintains agent artifacts in .agents/ — specs, plans, drafts, - handoffs, councils, and reports. Use when the user asks "housekeeping," "clean - up," or "tidy up .agents/." Provides hygiene recommendations, archives - completed work, and ensures extracted knowledge is preserved. Not for - strategic reflection (use reflect) or knowledge management (use - knowledge-base). + Reviews and maintains agent artifacts in .agents/ plus issue hygiene — + reports, handoffs, councils, archived issues, and stale started worktrees. Use + when the user asks "housekeeping," "clean up," or "tidy up .agents/." Provides + hygiene recommendations, archives completed work, and ensures extracted + knowledge is preserved. Not for strategic reflection (use reflect) or + knowledge management (use knowledge-base). user-invocable: true argument-hint: '[sessions|specs|plans|drafts|handoffs]' version: 0.2.21 @@ -19,40 +19,43 @@ version: 0.2.21 - Verification - Quick Reference - Mode-Aware Checks -- Process -- Guardrails -- Related Skills +- Suggests Next +- Topics +- Artifact Naming -Systematic review and archival of all `.agents/` artifacts with Linear-aware checks. +Systematic review of `.agents/` artifacts and issue workspaces. ## Critical Rules **Always** - Log invocation as the first action: `loaf journal log "skill(housekeeping): <scope or trigger>"` - Review EVERY file individually — never sample or average -- Check Linear issue status before archiving linked specs +- Check Loaf issue status (and Linear overlay, if enabled) before archiving linked artifacts - Extract lessons learned and decisions before archiving -- Use CLI (`loaf housekeeping`, `loaf task archive`, `loaf spec archive`) — never raw `mv` +- Use CLI (`loaf housekeeping`, `loaf report archive`, `loaf issue status` / `loaf issue stop`) — never raw `mv` - Treat `.agents/handoffs/` as first-class but disposable: keep active/final handoffs, delete only after confirmed deprecated status -- Check report `status` is `processed` before archiving reports (see [templates/report.md](templates/report.md)) -- In SQLite-backed projects, verify lifecycle changes through `loaf task list --json`, `loaf spec list --json`, and `loaf report list --json`; use `loaf task sync` only for Markdown compatibility repair +- Check report `status` is `done` (or `final`) before archiving reports (see [templates/report.md](templates/report.md)) +- In SQLite-backed projects, verify lifecycle through `loaf issue list --json`, `loaf issue list --started`, `loaf issue list --archived`, and `loaf report list --json` - When delegated subagents are available, use the `librarian` profile for - `.agents/`-scoped durable artifact tending: report/spec/handoff hygiene, + `.agents/`-scoped durable artifact tending: report/handoff hygiene, staleness notes, and lifecycle-safe cleanup recommendations. Housekeeping still owns user confirmation and final archive decisions. -- Log outcome to the project journal: `loaf journal log "decision(housekeeping): archived N specs, M reports"` +- Log outcome to the project journal: `loaf journal log "decision(housekeeping): archived N reports; stopped M stale worktrees"` **Never** - Auto-archive without user confirmation for each artifact - Skip spark extraction before deleting brainstorm drafts - Leave `archived_at` or `archived_by` fields empty in archived files +- Run `loaf issue stop` from inside the started worktree +- Dispatch cleanup agents into a live started worktree another agent occupies ## Verification After work completes, verify: -- Tasks archived via `loaf task archive` -- Specs archived via `loaf spec archive` -- SQLite-backed task/spec/report state reflects lifecycle changes when initialized +- Reports archived via `loaf report archive` after processing +- Archived issues reviewed via `loaf issue list --archived` (`cancelled` / `duplicate` archive through `loaf issue status`) +- Stale started worktrees reviewed via `loaf issue list --started` (a `(missing)` marker means the recorded path is gone) +- SQLite-backed report/issue state reflects lifecycle changes when initialized - Drafts checked for unprocessed sparks before deletion - Handoffs deleted only after explicit deprecation is confirmed - Summary table presented showing all actions taken @@ -64,11 +67,18 @@ After work completes, verify: ```bash loaf housekeeping --dry-run # Preview recommendations loaf housekeeping # Run artifact scanner -loaf task archive TASK-XXX # Archive single task -loaf spec archive SPEC-XXX # Archive single spec -loaf task sync # Compatibility diagnostic in SQLite-backed projects +loaf issue list --started # Started worktrees (alias, title, branch, path) +loaf issue list --archived # cancelled / duplicate rows +loaf issue stop <ref> # Remove worktree; keeps branch; does not change status +loaf issue status <ref> cancelled # Archive an abandoned issue +loaf issue status <ref> duplicate --duplicate-of <surviving> +loaf report archive <report> # Archive a processed report ``` +`loaf housekeeping` still prints leftover `specs` / `tasks` sections when those +SQLite tables have rows — compatibility scan only. Do not create new records +there. The `loaf task` / `loaf spec` CLI is legacy. + The project journal is append-only and never archived — it is not a housekeeping target. It is the canonical record housekeeping reads when extracting decisions before archiving other artifacts. @@ -77,19 +87,12 @@ before archiving other artifacts. | Artifact | Active Location | Archive | Action | |----------|-----------------|---------|--------| -| Tasks (local mode only) | SQLite state | SQLite archived status | `loaf task archive` | -| Specs | SQLite state + `.agents/specs/` authored prose | `archive/` | `loaf spec archive` | +| Issues | SQLite (`loaf issue list`) | `cancelled` / `duplicate` via `loaf issue status` | Confirm, then status; `done` is ship, not housekeeping | +| Started worktrees | `loaf issue list --started` | `loaf issue stop <ref>` | Stop stale or `(missing)` trees after confirmation | | Drafts / brainstorms | SQLite state | SQLite resolved/archived status | User decision (spark extraction first) | | Handoffs | `.agents/handoffs/` | delete | Delete after status is confirmed `deprecated` | | Reports | SQLite state + generated/authored report Markdown | `archive/` | `loaf report archive` after processing | -**Linear-native mode** (when `integrations.linear.enabled` is `true` in -`.agents/loaf.json`): local `TASK-NNN.md` files do not exist for new specs — -Linear issues are the task record. The "Tasks" row above is inert unless the -project has pre-Linear local tasks lingering (see [Mode-Aware Checks](#mode-aware-checks)). -Specs still archive locally — they are the canonical deliberation artifact in -every mode. - ## Cross-Branch Reconciliation If a stale branch reintroduces `.agents/{tasks,ideas,sparks,sessions,brainstorms,drafts}/` @@ -99,35 +102,30 @@ or `.agents/TASKS.json`, keep the deletion from the cutover branch and rerun ## Mode-Aware Checks -When `integrations.linear.enabled` is `true` in `.agents/loaf.json`, apply -these additional checks: +### Started worktrees -### Spec / Linear parent reconciliation +For each row from `loaf issue list --started`: -For each spec file (active and archive) with a `linear_parent:` frontmatter key: +1. If `(missing)`, flag as **stale started workspace** — the row still records a path that is gone. Offer `loaf issue stop <ref>` after confirmation. Stop does not mark the issue `done`. +2. If the path exists but the issue is `done` / `cancelled` / `duplicate`, flag as **worktree outlived the issue** — same offer. +3. If the path exists and status is `active`, leave it unless the user asks to stop. -1. Call `get_issue` with the issue identifier. If it 404s or returns - archived/deleted, flag as **orphaned linear_parent** — the local spec - references a Linear issue that no longer exists. -2. If the spec's local status is `done` (or legacy `complete`) or `archived`, - verify the Linear parent issue is in a `completed`-type state. If not - (e.g., still "In Progress"), flag as **status mismatch** — "Spec marked - complete locally but Linear parent ENG-198 is still 'In Progress'." -3. If the spec's local status is `in_progress` and the Linear parent is - already `completed`, flag the inverse — spec likely needs to be moved to - `done` and archived. +Treat these as **warnings**, not auto-fixes. -Treat all three as **warnings**, not auto-fixes. The user decides resolution. +### Linear overlay -### Pre-Linear local task detection +When `integrations.linear.enabled` is `true` in `.agents/loaf.json`, the tracker +adapter is not shipped. If a report or journal entry names a Linear id next to +a Loaf alias, you may `get_issue` and flag an obvious mismatch (Linear Done vs +Loaf still `active`, or the reverse). Warnings only. Do not drive Loaf status +from Linear. -If Linear is enabled but local task records exist in SQLite, -surface them with context: "Pre-Linear local tasks detected. These aren't -auto-migrated. Either continue using them, run a manual migration, or -archive if superseded by Linear issues." +### Leftover board rows -Do NOT auto-migrate. Migration is user-initiated and out of scope for -housekeeping. +If `loaf housekeeping --dry-run` still reports `tasks` or `specs` cleanup +candidates, surface them: "Legacy board rows are still in SQLite. They are not +the work unit. Archive only if the user confirms they are superseded by Loaf +issues." Do NOT auto-migrate. ## Suggests Next @@ -138,9 +136,9 @@ After housekeeping, suggest reflect if the session produced key decisions or lea | Topic | Reference | Use When | |-------|-----------|----------| | Report Template | [templates/report.md](templates/report.md) | Creating cleanup reports | -| Linear Integration | `orchestration/references/linear.md` | Checking external issue status | +| Linear Integration | `orchestration/references/linear.md` | Checking external tracker overlay | | Journal Continuity | `orchestration/references/journal.md` | Understanding the project journal model | ## Artifact Naming -Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or Change already records that provenance. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. +Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or the issue already records that provenance. Put the source in a front-matter field (`source: LOAF-42`), not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. diff --git a/plugins/loaf/skills/housekeeping/templates/journal.md b/plugins/loaf/skills/housekeeping/templates/journal.md index caf7409fd..9a0a61f8f 100644 --- a/plugins/loaf/skills/housekeeping/templates/journal.md +++ b/plugins/loaf/skills/housekeeping/templates/journal.md @@ -13,7 +13,7 @@ not create or edit journal markdown as the source of truth — use ## Entries -[YYYY-MM-DD HH:MM] skill(implement): implementing TASK-042 +[YYYY-MM-DD HH:MM] skill(implement): implementing LOAF-42 [YYYY-MM-DD HH:MM] decision(scope): description of decision [YYYY-MM-DD HH:MM] discover(scope): something learned [YYYY-MM-DD HH:MM] block(scope): what is blocked diff --git a/plugins/loaf/skills/housekeeping/templates/report.md b/plugins/loaf/skills/housekeeping/templates/report.md index d0894e593..ca201e869 100644 --- a/plugins/loaf/skills/housekeeping/templates/report.md +++ b/plugins/loaf/skills/housekeeping/templates/report.md @@ -8,7 +8,7 @@ title: "Report: [Topic]" type: research | audit | analysis | council created: YYYY-MM-DDTHH:MM:SSZ status: draft | done | archived -source: SPEC-XXX | TASK-XXX | ad-hoc +source: LOAF-42 | ad-hoc finalized_at: YYYY-MM-DDTHH:MM:SSZ # Set when status → done archived_at: YYYY-MM-DDTHH:MM:SSZ # Set when status → archived archived_by: orchestrator diff --git a/plugins/loaf/skills/idea/SKILL.md b/plugins/loaf/skills/idea/SKILL.md index 2f3aa44c9..e9d085318 100644 --- a/plugins/loaf/skills/idea/SKILL.md +++ b/plugins/loaf/skills/idea/SKILL.md @@ -3,11 +3,12 @@ name: idea description: >- Captures ideas into structured nuggets for later evaluation. Use when the user says "I have an idea" or "note this down." Also activate when a specific - actionable concept crystallizes during conversation. Ideas and sparks are - capture primitives routed through triage, which chooses dispositions such as - tracking an Intent or handing to pitch. Not for problem discovery (use pitch), - processing the intake queue (use triage), shaping (use shape), or agent-side - divergent inquiry when direction is undecided (use explore as a technique). + actionable concept crystallizes during conversation. Ideas and sparks stay + capture primitives routed through triage, which files worth-keeping items as + backlog issues or hands them to pitch or shape. Not for problem discovery (use + pitch), processing the intake queue (use triage), shaping a bounded issue (use + shape), or agent-side divergent inquiry when direction is undecided (use + explore as a technique). user-invocable: true argument-hint: '[idea description]' version: 0.2.21 @@ -27,7 +28,6 @@ Capture ideas quickly with minimal friction. - Quick Reference - Purpose - Process -- Idea Lifecycle - Guardrails - Related Skills @@ -37,7 +37,7 @@ Capture ideas quickly with minimal friction. - 2-3 questions maximum -- don't turn capture into an interview - Infer metadata automatically -- don't ask for tags, title, or links - One idea per captured row/artifact -- keep them atomic -- No shaping or pitching here -- problem discovery is pitch; bounding is shape +- No shaping, pitching, or filing issues here -- problem discovery is pitch; bounding is shape; dispositions are triage - Capture through `loaf idea capture --title ...` when SQLite state is initialized; log notable context with `loaf journal log` @@ -59,7 +59,7 @@ Capture ideas quickly with minimal friction. ## Purpose -Ideas are raw nuggets — unprocessed, unshaped, but worth remembering. The goal is **speed of capture**, not thoroughness. An idea is retained material, nothing more: tracking it as an Intent, handing it to pitch for problem discovery, handing it to shape when already well-understood, or archiving it are triage dispositions chosen later by the user. +Ideas are raw nuggets — unprocessed, unshaped, but worth remembering. The goal is **speed of capture**, not thoroughness. An idea is retained material, nothing more: filing it as a backlog issue, handing it to pitch for problem discovery, handing it to shape when already well-understood, or archiving it are triage dispositions chosen later by the user. --- @@ -83,7 +83,7 @@ Ideas are raw nuggets — unprocessed, unshaped, but worth remembering. The goal ## Related Skills -- **triage** — process the intake queue and choose dispositions (including hand to pitch or shape) +- **triage** — process the intake queue and choose dispositions (file as backlog issue, hand to pitch, or hand to shape) - **pitch** — problem-discovery ceremony when a captured idea needs a brief before shaping -- **shape** — develop a well-understood direction into a bounded Change +- **shape** — develop a well-understood direction into a bounded issue - **explore** — agent technique when direction is genuinely undecided (not a user front door) diff --git a/plugins/loaf/skills/idea/templates/idea.md b/plugins/loaf/skills/idea/templates/idea.md index b070494d3..b631ca832 100644 --- a/plugins/loaf/skills/idea/templates/idea.md +++ b/plugins/loaf/skills/idea/templates/idea.md @@ -11,7 +11,7 @@ title: "[Idea Title]" captured: YYYY-MM-DDTHH:MM:SSZ status: raw tags: [] -related: [] # Optional: spec IDs, idea filenames, or other references +related: [] # Optional: issue aliases, idea filenames, or other references origin: # Optional: draft filename this spark came from (e.g. drafts/YYYYMMDD-brainstorm-slug.md) --- @@ -31,5 +31,5 @@ origin: # Optional: draft filename this spark came from (e.g. dra --- -*Captured via idea -- shape with shape when ready* +*Captured via idea — triage later (backlog issue, pitch, or shape)* ``` diff --git a/plugins/loaf/skills/implement/SKILL.md b/plugins/loaf/skills/implement/SKILL.md index e4e45bf0c..40ad41557 100644 --- a/plugins/loaf/skills/implement/SKILL.md +++ b/plugins/loaf/skills/implement/SKILL.md @@ -1,20 +1,21 @@ --- name: implement description: >- - Orchestrates implementation work through agent delegation and batch execution. - Use for all implementation work — features, bug fixes, refactors, and code - changes. Picks Change task files when present and flips checkboxes in - delivering commits. Logs to the project journal and produces agent spawn plans - and progress tracking. Not for shaping (use shape), breakdown (use breakdown), - research, or review. + Orchestrates implementation work through agent delegation and batch execution + against Loaf issues. Use for all implementation work — features, bug fixes, + refactors, and code changes. Picks the next issue from loaf issue frontier, + delegates one agent per started worktree, and treats definition-of-done + criteria as the completion contract. Logs to the project journal and produces + agent spawn plans and progress tracking. Not for shaping or decomposition (use + shape), research, or review. user-invocable: true -argument-hint: '[TASK-XXX | SPEC-XXX | TASK-XXX..YYY | TASK-XXX,YYY | description]' +argument-hint: '[LOAF-42 | next | description]' version: 0.2.21 --- # Implement -You are the coordinator. Start by understanding the task: +You are the coordinator. Work units are issues. ## Contents - Critical Rules @@ -22,7 +23,7 @@ You are the coordinator. Start by understanding the task: - Quick Reference - Step 0: Context Check - Input Detection -- Linear-Native Routing +- Pick-up and Dispatch - Agent Spawning - Journal First - Guardrails @@ -40,27 +41,32 @@ You are the coordinator. Start by understanding the task: **You are the ORCHESTRATOR, not the implementer.** -- Log `loaf journal log "skill(implement): <task/spec/context>"` as the first action. -- **Change-first task packets:** prefer `docs/changes/<folder>/tasks/TASK-NNN-*.md` as the delegation brief. Flip checkboxes `- [ ]`→`- [x]` in the same commit that delivers the work (outside `docs/changes/` paths must land with the flip for provenance). Use `loaf change tasks --json` for the index. -- Commit task packets unchecked before executing them — a packet that first lands already-checked induces no flip transition, and the evidence trail never exists. -- Compatibility: existing `TASK-XXX` / `SPEC-XXX` SQLite records remain supported until converted; they are not the default for new work. +- Log `loaf journal log "skill(implement): LOAF-42 — <what>"` as the first action. Substitute the real alias (or opaque id) and a short intent. +- **Pick-up-next is `loaf issue frontier`.** That view is open (`triage` / `backlog` / `todo`), unblocked, and unclaimed (not `active`, no started worktree). Derived at read time. +- **The delegation brief is the issue row** — `loaf issue show <ref>` / `loaf issue render <ref>`: body, definition-of-done criteria, children. There is no other packet. +- **One agent, one worktree.** `loaf issue start <ref>` creates the branch and worktree and moves status to `active`. Before dispatch, run `loaf issue list --started`. Never send two agents into the same worktree. +- **Definition of done is the completion contract.** `loaf issue verify <ref>` runs V-tier criteria from the repository root and writes nothing. H-tier is reviewed by a human or this orchestrator. Completion is the work landing plus `loaf issue status <ref> done`. Do not flip checkboxes. Provenance is the delivering commits and the PR whose body is `loaf issue render <ref>`. +- Shape prepares issues. If `loaf issue check <ref>` does not report the delivery issue shaped (or the decision issue ready), stop and send the work to shape. Do not mint a new issue from this skill. ### Orchestrator Can Do Directly - Log journal entries, read journal context, create council files -- Use your harness's task/todo tracking surface; **if `integrations.linear.enabled` is `true` in `.agents/loaf.json`**, use Linear MCP tools when helpful +- Use your harness's todo tracking surface; **if `integrations.linear.enabled` is `true` in `.agents/loaf.json`**, Linear MCP is an overlay only — Loaf issues remain the work unit and Linear never drives Loaf status - Read any file for context - Ask clarifying questions +- Run `loaf issue` read commands, `loaf issue start` / `stop`, `loaf issue status`, and open a PR whose body is `loaf issue render` output ### Orchestrator MUST Delegate (via agent spawn) -**ALL code changes, documentation edits, and implementation work** to specialized agents. **No exceptions**, even for "trivial" 1-line fixes. +**ALL code changes, documentation edits, and implementation work** to specialized agents. **No exceptions**, even for "trivial" 1-line fixes. Spawn each agent into that issue's started worktree. ## Verification - The invocation is logged to the project journal before implementation work begins — no session start step, no "active session" precondition - All code changes delegated via your harness's agent-spawn mechanism -- no direct edits by orchestrator - The journal is continuously updated with spawns, progress, and decisions as work happens -- Spec artifacts closed out on branch before PR creation -- **Linear-native mode:** `blockedBy` of the target sub-issue is fully `completed` before work begins; starting a sub-issue also promotes an unstarted parent rollup to active; parent rollup is auto-closed only when all sub-issues are `completed` +- Each in-flight issue has exactly one started worktree; `loaf issue list --started` was checked before every spawn +- V-tier criteria pass `loaf issue verify <ref>` (writes nothing); H-tier criteria were reviewed by a human or this orchestrator +- The PR body is `loaf issue render <ref>` with no manual editing; checkboxes stay unchecked until status is `done` +- Completion is landing plus `loaf issue status <ref> done` (usually via ship) ## Quick Reference @@ -75,6 +81,15 @@ You are the coordinator. Start by understanding the task: | Code review/audit | reviewer | relevant domain skills | | Research/comparison | researcher | relevant domain skills | +| Moment | Command | +|--------|---------| +| Pick next | `loaf issue frontier` | +| Brief | `loaf issue show <ref>` / `loaf issue render <ref>` | +| Claim workspace | `loaf issue start <ref>` | +| Occupied trees | `loaf issue list --started` | +| V-tier gate | `loaf issue verify <ref>` | +| Landed | `loaf issue status <ref> done` | + --- ## Step 0: Context Check @@ -85,152 +100,51 @@ Before starting, evaluate context suitability. |---------|--------| | New command/skill added this conversation | **Restart required** (skills loaded at start) | | Conversation > 30 exchanges | Suggest restart | -| Just completed a different task/spec | Suggest clear | +| Just completed a different issue | Suggest clear | | About to start multi-file implementation | Check depth | If restart needed: log current state with `loaf journal log`, then ask the user to restart. A supported startup adapter may reconstruct continuity from the journal in the next conversation; when the exact current target mode is candidate or unsupported, explicitly run `loaf journal context` after restarting. ## Input Detection -Parse `$ARGUMENTS` to determine the work type: +Parse `$ARGUMENTS` to determine the work: | Input Pattern | Type | Action | |---------------|------|--------| -| `TASK-XXX` | Local task | Load via `loaf task show`, log the task coupling | -| `SPEC-XXX` | Spec orchestration | If spec frontmatter has `linear_parent`, resolve to that Linear parent and follow Linear-Native Routing. Otherwise resolve local tasks and build dependency-ready rounds | -| `TASK-XXX..YYY` | Task range | Expand range, build dependency-ready rounds | -| `TASK-XXX,YYY,ZZZ` | Task list | Parse list, build dependency-ready rounds | -| `PLT-123`, `ENG-198`, `PROJ-123` | Linear issue | **If `integrations.linear.enabled` is `true`:** fetch via `get_issue`, then branch on parent vs sub-issue — see [Linear-Native Routing](#linear-native-routing). **Otherwise:** treat as label text or create local task | -| Description text | Ad-hoc | Auto-create local task from description, then fall through to task-coupled flow | - -### Task-Coupled Work - -When starting from `TASK-XXX`: - -1. Load task metadata via `loaf task show TASK-XXX --json`; do not recreate `.agents/TASKS.json` after the SQLite cutover -2. Log the task coupling: `loaf journal log "decision(implement): implementing TASK-XXX"` -3. Load parent spec if task has `spec:` field - -### Ad-hoc Task Auto-Creation - -When input is free-text description (not matching any known pattern): - -1. **Parse the description:** - - Single sentence → use entire text as task title - - Multi-sentence → first sentence = title, remainder = acceptance criteria - - Split on `. ` followed by uppercase letter only (conservative — avoids false positives from URLs, abbreviations) -2. **Create the task:** `loaf task create --title "<parsed title>"` -3. **Write criteria** (if multi-sentence): edit the task `.md` file body to add the remaining sentences as acceptance criteria -4. **Fall through** to the task-coupled flow above — the result is a `TASK-XXX` ID that enters the existing planning pipeline unchanged - -**No user interaction required.** The description IS the task; invoking implement already expressed intent. +| `LOAF-42` or opaque id | Single issue | Load via `loaf issue show <ref>`; fall through to Pick-up and Dispatch | +| Parent ref with children | Tree | `loaf issue tree <ref>`; build rounds from children and `blocks` / `blocked_by` edges (see [batch-orchestration.md](references/batch-orchestration.md)) | +| Multiple refs | Batch | Same round construction across the named set | +| Empty / "next" | Frontier | `loaf issue frontier`; if one row, pick it; if several, ask (structured question tool if the harness has one); if none, stop | +| Description text | Ad-hoc | Match frontier by title. Do not mint. If nothing matches, stop and send to shape | +| Decision kind | Question | Not implementation. Surface the question; do not `loaf issue start` unless the user points at a delivery issue that records the decided answer | -### Non-Existent Task ID Error +### Missing ref -If input matches `TASK-XXX` pattern but `loaf task show` cannot resolve it: +If input looks like an issue ref but `loaf issue show` cannot resolve it: -1. Show error: `"TASK-XXX not found in local task state"` -2. Ask the user: `"Did you mean to create a new task? You can re-run with the description as free text."` -3. **Do not silently create** — the user likely has a typo +1. Show error: `"<ref> not found"` +2. Ask whether they meant a different alias, or to shape a new issue +3. **Do not silently create** --- -## Linear-Native Routing - -Applies when `integrations.linear.enabled` is `true` AND `$ARGUMENTS` -resolves to a Linear issue (direct Linear ID, or a `SPEC-XXX` whose -frontmatter has `linear_parent`). - -Fetch the issue once via `get_issue` and branch on its shape: - -### Parent rollup issue (has `spec` label) - -The issue represents a spec. Do **not** implement it directly — spec-level -"work" is always done via sub-issues. - -1. List sub-issues via `list_issues` with `parent: <parent-id>`. -2. Classify each by state: - - `in_progress` — active work - - `unstarted` + no open `blockedBy` — ready to start - - `unstarted` + open `blockedBy` — blocked - - `completed` — done, skip -3. Select the next work item: - - If one or more sub-issues are `in_progress`, pick the **lowest-ID** - in-progress sub-issue. Resume that. - - Else, if one unblocked `unstarted` sub-issue exists, pick it. - - Else, if multiple unblocked `unstarted` sub-issues exist, use - your harness's structured question tool (if it has one) to let the user choose: pick one, or delegate N in - parallel via parallel agents. List each sub-issue's title + ID. - - Else (all remaining sub-issues are blocked), refuse with a summary: - "All remaining sub-issues under <parent-id> are blocked. Blockers: - <list>." -4. Once a sub-issue is selected, recurse into the sub-issue flow below - with that ID. The parent itself is never the implementation target. - -### Sub-issue (has `parentId`, no `spec` label) - -The issue is an actual task. Implement it directly — with a pre-flight gate. - -1. **Pre-flight: verify `blockedBy` is clear.** For each issue in the - sub-issue's `blockedBy` field, call `get_issue` and confirm its state is - `completed`-type. If any blocker is not Done: - - **Refuse to start.** Do not begin work. Do not move the issue. - - Show the blockers: `"Cannot start <sub-issue-id>. Blocked by: <list - with IDs, titles, and current states>."` - - Suggest: `"Complete the blocker(s) first, or ask to override if the - blockedBy link is stale."` -2. If blockers are clear: - - Start the sub-issue as one logical Linear operation. This moves - the sub-issue to the team's `started`/In Progress state and, when the - parent rollup is still `backlog` or `unstarted`, promotes the parent to - the same `started`/In Progress state. - - If the parent is already active, leave it unchanged. If the parent is - `completed`, `canceled`, or archived, refuse to start unless the user - explicitly asks to override the protected parent state. - - If the child update succeeds but parent promotion fails, report a - reconciliation error naming the parent issue before continuing. - - Resolve branch name from the sub-issue's `branchName` field (Linear - auto-generates one) — see - [branch-and-completion.md](references/branch-and-completion.md). - - Log the task coupling, then continue with the standard Startup Checklist. - -### Completion (after implementer + reviewer finish cleanly) - -When the sub-issue's implementation passes review and tests: - -1. Move the sub-issue to the team's `completed`-type state via - `update_issue` (look up via `list_issue_statuses`, filter - `type: "completed"`). -2. Query the parent's sub-issues again: - - If **all** sub-issues are now `completed`-type, move the parent - rollup to `completed` as well. Also mark the local spec as - `complete` (see [Then Execute → AFTER](#then-execute)). - - If **some** remain, list them as "next available" for the user, - applying the same classification as step 2 of the parent flow above. - Offer to continue with the next one in this session, or stop here. -3. **Do not** close the parent while any sub-issue is open — not even if - only `blocked` ones remain. Blocked sub-issues are still in-flight - work from the spec's perspective. - -### Status flow summary - -| Moment | Sub-issue state | Parent state | -|--------|----------------|--------------| -| Implementation starts | `started` / In Progress | promoted to `started` / In Progress if still `backlog` or `unstarted` | -| Implementation + review pass | `completed` | check: close only if all sibs completed | -| Blocker discovered mid-work | `in_progress` + blocker comment | unchanged | - -### What Linear-native routing does NOT do - -- Does not pull down the full spec text. The parent's description already - links to `.agents/specs/SPEC-NNN-*.md`. Read the local file for shape, - rabbit holes, and strategic tensions. -- Does not create or rewrite sub-issues. That's breakdown's job. If - implementation reveals a missing task, surface it to the user; they - decide whether to run breakdown again or add an ad-hoc sub-issue. -- Does not sync in-progress state bidirectionally. Source of truth at any - moment: Linear for issue state, local files for spec content, the project - journal for current handoff. +## Pick-up and Dispatch + +1. **Confirm the issue is implementable.** `loaf issue check <ref>` must report a delivery issue shaped (or, if the user explicitly asked to resolve a decision issue, that it is ready). Unshaped work goes to shape. +2. **Honor the frontier.** An issue that is blocked does not appear on `loaf issue frontier`. `loaf issue link A blocks B` means A blocks B; B waits until A is `done`, `cancelled`, or `duplicate`. Do not start a blocked successor. Parent/child structure from `loaf issue tree` is not a sequencing edge — only `blocks` / `blocked_by` are. Use the tree to know who belongs in the batch; use the edges to order rounds. +3. **Parents with children are not the implementation target.** Dispatch leaf delivery children that are on the frontier. A parent executes through claimed child criteria, not by starting the parent worktree. +4. **Inspect occupied worktrees:** + ```bash + loaf issue list --started + ``` + Columns: alias, title, `started_branch`, `started_worktree`, optional `(missing)`. If this ref is already started, resume in that worktree with one agent. If the path is occupied by another issue, refuse. A `(missing)` marker means the recorded path is gone — `loaf issue stop <ref>` (not from inside the tree) before starting again. +5. **Start the workspace** (skip if already started and the path exists): + ```bash + loaf issue start <ref> + ``` + Creates branch `issue/<alias-or-id>` in lowercase (`issue/loaf-42`, id suffix when that name is claimed), a sibling worktree, and sets status to `active`. Base is the nearest started ancestor's branch, else the repository default branch. Start refuses archived rows and terminal statuses (`done`, `cancelled`, `duplicate`). +6. **Hand the agent the brief** from `loaf issue show <ref>` (body, criteria, children) and, when opening a PR, `loaf issue render <ref>`. Tell the agent to work only in `started_worktree`. +7. **Batch rounds.** When input is a parent or a set of refs, group unblocked delivery children into dependency-ready rounds from `blocked_by` edges and parent/child structure. Parallel only within a round, max 3, and only when each agent has its own worktree. See [batch-orchestration.md](references/batch-orchestration.md) for the round loop, `--dry-run` / `--parallel` / `--continue` / `--skip <ref>` / `--abort`, and blocked-state recovery. --- @@ -249,7 +163,7 @@ Spawn specialized agents with the appropriate profile: | Code review/audit | reviewer | relevant domain skills | | Research/comparison | researcher | relevant domain skills | -**Rules:** Be specific in prompts. One concern per agent. Include context. Parallel when independent, sequential when dependent. +**Rules:** Be specific in prompts. One concern per agent. Include the issue ref, `started_worktree`, body, and definition of done. Parallel when independent (separate worktrees), sequential when a `blocks` edge says so. --- @@ -258,15 +172,14 @@ Spawn specialized agents with the appropriate profile: There is no session to start — journaling is continuous. Your first action is to log the invocation: ```bash -loaf journal log "skill(implement): <task/spec/context>" +loaf journal log "skill(implement): LOAF-42 — <what>" ``` Entries are project-scoped and tagged with this conversation's harness id automatically. Continuity from prior conversations may arrive through a supported startup adapter; when the exact current target mode is candidate or unsupported, pull it explicitly with `loaf journal context`. Use `loaf journal recent` when you need a narrower timeline. -Suggest renaming the harness conversation with a meaningful name derived from context (use your harness's rename surface if it has one): -- From spec: `SPEC-027-session-stability` -- From task: `TASK-042-login-fix` -- From ad-hoc: `{short-slug-from-description}` +Suggest renaming the harness conversation with a meaningful name derived from the issue (use your harness's rename surface if it has one): +- From issue: `LOAF-42-login-fix` +- From ad-hoc match: `{alias}-{short-slug}` --- @@ -280,12 +193,14 @@ Suggest renaming the harness conversation with a meaningful name derived from co 6. **Journal continuously** -- log spawns, progress, blockers, and decisions with `loaf journal log` as they happen 7. **Clean up** -- no ephemeral files; write an optional `wrap` entry only when there's synthesis worth saving 8. **When in doubt, ask the user** +9. **Never `loaf issue stop` from inside the started worktree** -- stop does not change status; `--force` removes a dirty tree +10. **Do not tick definition-of-done boxes** -- `loaf issue verify` writes nothing; render checks a box only when status is already `done` ## Decision Tree ``` Is this a code/config/doc change? -+-- YES -> Spawn appropriate agent ++-- YES -> Spawn appropriate agent into the issue worktree +-- NO -> Is this a planning/coordination decision? +-- YES with clear path -> Proceed, log the decision +-- YES but ambiguous -> Ask user @@ -298,18 +213,16 @@ When multiple valid approaches exist: spawn council (5-7 agents, odd), present r ## Startup Checklist -1. [ ] Log the invocation: `loaf journal log "skill(implement): <context>"` -2. [ ] Parse input (task, Linear ID, or description) -3. [ ] If TASK-XXX: load task via `loaf task show TASK-XXX`, log task coupling, load parent spec -4. [ ] If Linear ID (or `SPEC-XXX` with `linear_parent`): follow [Linear-Native Routing](#linear-native-routing). Parent → walk sub-issues and select next. Sub-issue → verify `blockedBy` is clear, then start it as one logical Linear operation so the parent is promoted when needed -5. [ ] If description: auto-create task (see Ad-hoc Task Auto-Creation above) -6. [ ] Create dedicated branch (see [branch-and-completion.md](references/branch-and-completion.md)) -7. [ ] Suggest team based on task context -8. [ ] Log initial context and references with `loaf journal log` -9. [ ] Break down work using your harness's task/todo tracking surface -10. [ ] Identify needed specialized agents -11. [ ] Log next steps before spawning -12. [ ] **Get user approval** before spawning +1. [ ] Log the invocation: `loaf journal log "skill(implement): LOAF-42 — <what>"` +2. [ ] Parse input (issue ref, parent, set, frontier, or description) +3. [ ] Load `loaf issue show <ref>`; if children, `loaf issue tree <ref>` +4. [ ] `loaf issue check <ref>` — shaped/ready, or stop and send to shape +5. [ ] Confirm the ref is on `loaf issue frontier` (or already started for resume) +6. [ ] `loaf issue list --started` — one agent per worktree +7. [ ] `loaf issue start <ref>` unless already started +8. [ ] Suggest conversation rename (`LOAF-42-login-fix`) +9. [ ] Identify specialized agents; log next steps +10. [ ] **Get user approval** before spawning --- @@ -317,32 +230,28 @@ When multiple valid approaches exist: spawn council (5-7 agents, odd), present r ### BEFORE (Planning) 1. Log the invocation with `loaf journal log` -2. Set task status: `loaf task update TASK-XXX --status in_progress` -3. Break down work into agent-sized tasks -4. Identify spawn order (respect dependencies) +2. `loaf issue start <ref>` (status becomes `active` through start) +3. Slice work into agent-sized units that still belong to this one issue +4. Identify spawn order (respect `blocked_by` edges and parent/child rounds) 5. Get user approval ### DURING (Execution) -1. Spawn specialized agents via your harness's agent-spawn mechanism -2. Log each spawn with `loaf journal log "todo(agent): spawned <agent> for <task>"` -3. Update Linear with progress (no emoji, no file paths) -4. Keep journal entries handoff-ready -5. After each agent completes: log outcome, spawn next +1. Spawn specialized agents into `started_worktree` via your harness's agent-spawn mechanism +2. Log each spawn with `loaf journal log "todo(agent): spawned <agent> for <ref>"` +3. Keep journal entries handoff-ready +4. After each agent completes: log outcome, spawn next +5. If Linear overlay is enabled, you may comment there — Loaf status stays on `loaf issue` ### AFTER (Completion) 1. Code review pass (spawn `reviewer` agent) 2. Spawn implementer (with foundations + language skill) for final testing -3. **Close out spec artifacts on the branch** (included in the squash merge): - - **Local-tasks mode:** `loaf task update TASK-XXX --status done` (per task), then `loaf task archive --spec SPEC-XXX` - - **Linear-native mode:** `update_issue` the sub-issue to `completed`-type state. Then query the parent's sub-issues; if all are `completed`, also close the parent. If some remain, list them for the user (see [Linear-Native Routing → Completion](#completion-after-implementer--reviewer-finish-cleanly)) - - Mark spec complete and archive: `loaf spec archive SPEC-XXX` (both modes) - - Write a `wrap(scope)` journal entry if the work produced synthesis worth saving (next steps, abandoned paths); otherwise skip it - - Commit: `chore: close SPEC-XXX — archive tasks and spec` -4. If on a feature branch: push and create PR (`gh pr create`). Follow PR format and squash merge conventions in [commits reference](../git-workflow/references/commits.md). -5. After PR is created and approved, use ship to review, verify, and land the PR. Use release later when a coherent batch of landed work is ready to publish. -6. **Suggest reflection:** Check the journal for extractable learnings before closing out: +3. Run `loaf issue verify <ref>` (V-tier, writes nothing). Review every H-tier row yourself or with the user — a skip from verify is not a pass +4. Open or update the PR with body `loaf issue render <ref>` — no manual editing. Follow PR format and squash merge conventions in [commits reference](../git-workflow/references/commits.md) +5. After the PR is created, use ship to review, verify, land, mark `loaf issue status <ref> done`, and `loaf issue stop <ref>`. Use release later when a coherent batch of landed work is ready to publish +6. Write a `wrap(scope)` journal entry if the work produced synthesis worth saving; otherwise skip it +7. **Suggest reflection:** Check the journal for extractable learnings before closing out: - `decision(...)` entries are present - - ADRs, report verdicts, or spec changelog entries were recorded + - ADRs or report verdicts were recorded If any signal is present, suggest: *"This produced key decisions. Consider running reflect to update strategic docs."* If none are present, stay silent. --- @@ -351,18 +260,18 @@ When multiple valid approaches exist: spawn council (5-7 agents, odd), present r | Topic | Reference | Use When | |-------|-----------|----------| -| Batch Orchestration | [batch-orchestration.md](references/batch-orchestration.md) | Running specs, task ranges, or task lists with dependency-ready rounds | -| Branch and Completion | [branch-and-completion.md](references/branch-and-completion.md) | Branch management, team routing, diagrams, Linear sync, journaling, task completion | +| Batch Orchestration | [batch-orchestration.md](references/batch-orchestration.md) | Running a parent or a set of issue refs with dependency-ready rounds | +| Branch and Completion | [branch-and-completion.md](references/branch-and-completion.md) | Team routing, diagrams, exploration, journaling alongside `loaf issue start` / `stop` | +| Working issues locally | [../orchestration/references/local-tasks.md](../orchestration/references/local-tasks.md) | Frontier, started worktrees, status vocabulary, definition of done | --- ## Suggests Next -After all tasks are complete, suggest ship to land the PR. Suggest release only when the landed work forms a coherent release batch. +After the PR exists, suggest ship to land it. Suggest release only when the landed work forms a coherent release batch. ## Related Skills -- **shape** - Spec format and lifecycle -- **breakdown** - Turning specs into tasks -- **orchestration/local-tasks** - Task file format and lifecycle -- **orchestration/journal** - Project journal continuity model +- **shape** — Issue preparation and decomposition +- **orchestration/journal** — Project journal continuity model +- **orchestration/local-tasks** — Frontier, started worktrees, status, definition of done diff --git a/plugins/loaf/skills/implement/references/batch-orchestration.md b/plugins/loaf/skills/implement/references/batch-orchestration.md index 7ab48f755..7902f28f1 100644 --- a/plugins/loaf/skills/implement/references/batch-orchestration.md +++ b/plugins/loaf/skills/implement/references/batch-orchestration.md @@ -7,63 +7,64 @@ - Batch Execution Model - Blocked-State Recovery -Detailed reference for running specs, task ranges, or task lists with dependency-ready scheduling. +Detailed reference for running a parent issue or a set of issue refs with dependency-ready scheduling. ## Orchestration Options | Option | Behavior | |--------|----------| | `--dry-run` | Show dependency-ready execution plan, do not run agents | -| `--parallel` | Run tasks in the same dependency-ready group concurrently (max 3 at once) | -| `--continue` | Resume a blocked orchestration from the recorded task/group | -| `--skip TASK-XXX` | Mark one blocked task as skipped and continue | +| `--parallel` | Run issues in the same dependency-ready group concurrently (max 3 at once) | +| `--continue` | Resume a blocked orchestration from the recorded issue/group | +| `--skip <ref>` | Skip one blocked issue and continue | | `--abort` | Mark orchestration as aborted and stop remaining work | ## Batch Resolution and Dependency-Ready Scheduling -For `SPEC-XXX`, `TASK-XXX..YYY`, and `TASK-XXX,YYY,ZZZ`: +For a parent ref (`loaf issue tree <ref>`) or a named set of refs: -1. Resolve selected tasks and validate each task file exists. -2. Extract `depends_on` from each task and build a dependency graph. -3. Group tasks into dependency-ready rounds: - - First round: tasks with no unresolved dependencies - - Each subsequent round: tasks whose dependencies are completed in earlier rounds -4. If `--parallel` is set, allow parallel execution only within a dependency-ready round and only for non-conflicting tasks. -5. Present execution plan (tasks, dependency-ready rounds, mode, total count) and ask for confirmation unless `--dry-run`. -6. Track progress in the journal and in task statuses: log round boundaries and the current task with `loaf journal log`, and drive each task's status with `loaf task update`. The journal plus task statuses are the durable record of where the batch is. +1. Resolve the selected refs and validate each issue exists (`loaf issue show <ref>`). +2. Read `blocks` / `blocked_by` edges and parent/child structure. Parent/child is not a sequencing edge — only `blocks` / `blocked_by` are. +3. Group unblocked delivery children into dependency-ready rounds: + - First round: issues with no unresolved predecessors + - Each subsequent round: issues whose predecessors are `done`, `cancelled`, or `duplicate` +4. If `--parallel` is set, allow parallel execution only within a dependency-ready round, max 3, and only when each agent has its own started worktree. +5. Present execution plan (issues, dependency-ready rounds, mode, total count) and ask for confirmation unless `--dry-run`. +6. Track progress in the journal: log round boundaries and the current ref with `loaf journal log`. Status moves through `loaf issue start` (to `active`) and, after landing, `loaf issue status <ref> done`. The journal plus issue statuses are the durable record of where the batch is. + +Parents with children are not the implementation target. Dispatch leaf delivery children that are on `loaf issue frontier`. ## Option Handling (`--continue`, `--skip`, `--abort`) -1. Recover batch progress from the journal: `loaf journal recent --since-last-wrap` (or `loaf journal context`) plus `loaf task list --json` to see which tasks are still open. -2. If `--continue`: resume from the last logged dependency-ready round and task. -3. If `--skip TASK-XXX`: mark that task `skipped` via `loaf task update`, log the reason with `loaf journal log`, continue the same dependency-ready round. +1. Recover batch progress from the journal: `loaf journal recent --since-last-wrap` (or `loaf journal context`) plus `loaf issue list --json` and `loaf issue list --started` to see which issues are still open or claimed. +2. If `--continue`: resume from the last logged dependency-ready round and issue. +3. If `--skip <ref>`: log the reason with `loaf journal log`, continue the same dependency-ready round. Do not mark the skipped issue `done`. 4. If `--abort`: log `block(orchestration): aborted`, print a summary, and stop. 5. If no in-flight batch is evident from the journal, report that and ask for fresh selection input. ## Batch Execution Model -When input resolves to multiple tasks, run a dependency-ready round loop: +When input resolves to multiple issues, run a dependency-ready round loop: 1. Set orchestration mode (`sequential` by default, `parallel` only with `--parallel`). 2. For each dependency-ready round: - Log the round start with `loaf journal log` - - Run each task (sequentially, or concurrently within safety limits) - - For each task: set `in_progress` -> spawn agent -> run task verification -> mark `done`/`failed` via `loaf task update` -3. If any task fails verification, stop immediately and log `block(orchestration): <task> failed <reason>`. -4. Consider a round complete only when all its tasks are `done` or skipped. + - For each issue: `loaf issue list --started`, then `loaf issue start <ref>` unless already started, spawn one agent into `started_worktree`, run `loaf issue verify <ref>` (V-tier; writes nothing) +3. If any issue fails verification, stop immediately and log `block(orchestration): <ref> failed <reason>`. +4. Consider a round complete only when all its issues have landed (`loaf issue status <ref> done` via ship) or were skipped. 5. Continue until all rounds complete, then log a closing entry summarizing the batch. ## Blocked-State Recovery When blocked, always print: -- Failed task ID and title +- Failed issue ref and title - Dependency-ready round and current progress - Failure reason + key error output - Recovery options: Re-invoke the implement workflow with: -- `--continue` — after fixes are applied, retry from the blocked task -- `--skip TASK-XXX` — skip only the specified task and continue remaining tasks in the current dependency-ready round +- `--continue` — after fixes are applied, retry from the blocked issue +- `--skip <ref>` — skip only the specified issue and continue remaining issues in the current dependency-ready round - `--abort` — finalize the orchestration as aborted with no further execution diff --git a/plugins/loaf/skills/implement/references/branch-and-completion.md b/plugins/loaf/skills/implement/references/branch-and-completion.md index e4bea8b5d..5c89cdf02 100644 --- a/plugins/loaf/skills/implement/references/branch-and-completion.md +++ b/plugins/loaf/skills/implement/references/branch-and-completion.md @@ -8,7 +8,7 @@ - Linear Status Management - Handoff Readiness - Timestamps for User Context -- Task Completion +- Issue Completion Detailed reference for branch setup, Linear routing, and completion during implementation. @@ -18,28 +18,18 @@ Detailed reference for branch setup, Linear routing, and completion during imple ### Getting Branch Name -1. **If Linear issue exists**: Use the `branchName` field from `get_issue` response - - Linear auto-generates branch names like `username/plt-123-issue-title` - - These are pre-formatted and consistent with team conventions +`loaf issue start <ref>` is the claim. It creates `issue/<alias-or-id>` in lowercase (`issue/loaf-42`, id suffix when that name is claimed), a sibling worktree, and moves status to `active`. -2. **If no Linear issue**: Create branch name from the work description - - Format: `feature/<description>` or `fix/<description>` - - Use kebab-case, keep it concise +Do not `git checkout -b` as a substitute for start. Check `loaf issue list --started` first. Never send two agents into the same worktree. Do not run `loaf issue stop` from inside that worktree. ### Branch Workflow ```bash -# 1. Check current branch status -git status - -# 2. Create and checkout the branch (use Linear's branchName if available) -git checkout -b <branch-name> - -# 3. Confirm branch creation -git branch --show-current +loaf issue list --started +loaf issue start <ref> ``` -**Important:** All implementation agents will work on this branch. The branch should be ready for PR when work completes. Journal entries are tagged with the observed branch automatically, so continuity stays branch-scoped. +Work only in `started_worktree`. The branch should be ready for PR when work completes. Journal entries are tagged with the observed branch automatically. --- @@ -53,7 +43,7 @@ When creating Linear issues, suggest the appropriate team: > "This task seems best suited for the **Security** team (matched: 'auth', 'vulnerability'). > Security hasn't been used in this project yet. Add this team?" 4. **If user confirms**, add team to `known_teams` in config -5. **Create issue** with suggested team +5. **Create via `loaf issue new`** so identity can be delegated; do not create in Linear MCP and forget `loaf issue pull` ### Team Suggestion Example @@ -75,7 +65,7 @@ Use Linear MCP's `list_teams` (if configured) to get all workspace teams for val ## Diagram Consideration -For multi-file or multi-service changes, consider adding architecture diagrams to the linked spec, report, ADR, or implementation notes. +For multi-file or multi-service changes, consider adding architecture diagrams to the issue, a report, ADR, or implementation notes. ### When to Create Diagrams @@ -94,7 +84,7 @@ Ask yourself: 2. Is there a data flow that needs to be understood? 3. Would a visual help communicate the approach? -If yes to any, capture the diagram in a durable artifact such as a spec, report, ADR, or implementation note, and log the reference with `loaf journal log`. +If yes to any, capture the diagram in a durable artifact such as a report, ADR, or implementation note, and log the reference with `loaf journal log`. ### Diagram Template @@ -146,36 +136,23 @@ For complex tasks, explore before implementing: ## Linear Status Management -**Keep Linear status synchronized with actual work state:** - -| Work State | Linear Status (sub-issue) | -|------------|---------------------------| -| Work begun | In Progress | -| Blocked/waiting for user | In Progress (add blocker comment) | -| Work completed | Done (or In Review if PR pending) | +**Keep Loaf status synchronized with actual work state.** Linear is an overlay (`loaf issue pull` / `push` / `reconcile`); never drive Loaf status from Linear MCP tools. -### Parent rollup auto-close +| Work State | Loaf status | +|------------|-------------| +| Work begun | `active` via `loaf issue start` | +| Blocked/waiting | Stay `active`; log `block(scope)` and leave a Linear comment if the overlay is on | +| Work landed | `done` via `loaf issue status <ref> done` (usually ship), then `loaf issue stop <ref>` | -In Linear-native mode, the **parent** rollup issue (labeled `spec`) is not -moved manually during sub-issue work. It flips to Done automatically when -the last sub-issue flips to Done, and only then. Procedure: +### Parent vs children -1. After moving a sub-issue to a `completed`-type state, call - `list_issues` with `parent: <parent-id>`. -2. If every sub-issue is in a `completed`-type state, move the parent to - `completed` via `update_issue`. -3. If any sub-issue is still in an open state (including `blocked`), the - parent stays where it is — the spec is not done. +Parents with children are not the implementation target. Dispatch leaf delivery children on `loaf issue frontier`. A parent is not marked `done` because a child landed. -Never set the parent to In Progress manually — a parent in Linear-native -mode reflects a rollup of its sub-issues, not its own work. +`loaf issue link A blocks B` is the sequencing edge. An issue with an open predecessor does not appear on the frontier. Do not start a blocked successor. -### BlockedBy pre-flight +### Blocked-by pre-flight -Before moving a sub-issue to In Progress, confirm every issue in its -`blockedBy` field is in a `completed`-type state. If not, refuse to start -and report the blockers. This is a hard gate in Linear-native mode — -never implement through open `blockedBy`. +Before `loaf issue start`, confirm the ref is on `loaf issue frontier`. If it is blocked, refuse and report the predecessors. Never implement through an open `blocks` edge. --- @@ -184,7 +161,7 @@ never implement through open `blockedBy`. **The journal must ALWAYS be handoff-ready.** After every significant action: 1. Log what just happened with `loaf journal log` -2. Reference task/spec/report/commit IDs rather than duplicating long prose +2. Reference issue/report/commit IDs rather than duplicating long prose 3. Log completed agent work with outcomes 4. Ensure anyone could pick up the work immediately from `loaf journal recent` @@ -205,32 +182,18 @@ Generate with: `date -u +"%Y-%m-%d %H:%M UTC"` --- -## Task Completion +## Issue Completion -When a task-coupled unit of work completes: +When an issue-coupled unit of work completes: -1. **Update task status** (local file or Linear sub-issue) -2. **Check spec progress:** - - Local-tasks mode: list all tasks for the spec; if all done → mark - spec `complete`, else spec stays `implementing` - - Linear-native mode: query the parent rollup's sub-issues via - `list_issues` with `parent: <parent-id>`; if all are `completed`-type, - close the parent and mark the local spec `complete`, else both stay - in flight -3. **Write a `wrap` journal entry** if the conversation holds synthesis worth - saving (next steps, abandoned paths); skip it otherwise — nothing is - "closed," a conversation that ends without a wrap leaves a valid journal - -### Spec Completion Check +1. **Open or update the PR** with body `loaf issue render <ref>` — no manual editing +2. **Land via ship** — review definition of done, `loaf issue verify <ref>`, squash merge, then `loaf issue status <ref> done` and `loaf issue stop <ref>` +3. **Write a `wrap` journal entry** if the conversation holds synthesis worth saving (next steps, abandoned paths); skip it otherwise — nothing is "closed," a conversation that ends without a wrap leaves a valid journal ```bash -# Local-tasks mode: any open tasks for this spec? -loaf task list --spec SPEC-001 --status open --json - -# Linear-native mode: query the Linear parent's sub-issues -# (via get_issue + list_issues with parent filter) -# The parent itself only flips to Done when every sub-issue is Done. +loaf issue show <ref> +loaf issue tree <ref> +loaf issue list --started ``` -Never mark the local spec `complete` while its Linear parent still has -open sub-issues — the two sources of truth should agree on "done." +Do not mark a parent `done` while delivery children are still open. Do not flip Loaf status from Linear MCP tools; use `loaf issue reconcile` if the overlay has drifted. diff --git a/plugins/loaf/skills/implement/templates/journal.md b/plugins/loaf/skills/implement/templates/journal.md index caf7409fd..9a0a61f8f 100644 --- a/plugins/loaf/skills/implement/templates/journal.md +++ b/plugins/loaf/skills/implement/templates/journal.md @@ -13,7 +13,7 @@ not create or edit journal markdown as the source of truth — use ## Entries -[YYYY-MM-DD HH:MM] skill(implement): implementing TASK-042 +[YYYY-MM-DD HH:MM] skill(implement): implementing LOAF-42 [YYYY-MM-DD HH:MM] decision(scope): description of decision [YYYY-MM-DD HH:MM] discover(scope): something learned [YYYY-MM-DD HH:MM] block(scope): what is blocked diff --git a/plugins/loaf/skills/loaf-reference/SKILL.md b/plugins/loaf/skills/loaf-reference/SKILL.md index 53f1b357d..40d9a7163 100644 --- a/plugins/loaf/skills/loaf-reference/SKILL.md +++ b/plugins/loaf/skills/loaf-reference/SKILL.md @@ -26,7 +26,7 @@ The Loaf operating manual for agents: how to discover commands, diagnose project ## Operating Rules - Get exact, current syntax live: `loaf --help` lists every command, `loaf <command> --help` details one. This index is a map, not the contract. -- Prefer `--json` surfaces when diagnosing: `loaf config check --json`, `loaf state doctor --json`, `loaf change check --json`. Parse the structured output instead of scraping human-readable text. +- Prefer `--json` surfaces when diagnosing: `loaf config check --json`, `loaf state doctor --json`. Parse the structured output instead of scraping human-readable text. - Run the deterministic CLI command before hand-editing anything it manages; the command owns its files. - Use `--fix` only for safe, mechanical repairs, and review what it changed. - Ask the user for project-owned choices — GitHub account, tracker or integration election, which harnesses to install — never guess them. @@ -65,17 +65,16 @@ Names and one-line purposes only. Run `loaf <command> --help` for options, argum | `loaf config` | Validate and refresh project Loaf config | check | | `loaf hooks` | Inspect and set which Loaf hooks project into an installed harness's hooks file | list, enable, disable | | `loaf init` | Initialize a project with Loaf structure | — | -| `loaf release` | Create a new release with changelog, version bump, and tag | — | +| `loaf release` | Cut a retroactive release from already-landed work | suggest, cut | | `loaf search` | Search SQLite artifact bodies, journal entries, and indexed docs | — | | `loaf docs` | Manage docs/ indexing | index | -| `loaf change` | Shape-first Change artifacts: git-canonical work context under docs/changes/ | init, check, list | | `loaf render` | Maintain committed durable Markdown renders | sweep | | `loaf state` | Manage native SQLite state | path, status, init, doctor, repair legacy-project-database, repair relationship-origin, repair journal-search, migrate markdown, migrate storage-home, migrate schema, migrate deferrals, migrate lifecycle-statuses, migrate alias-orphans, migrate journal-duplicates, migrate journal-first, backup, backup verify, backup restore, restore-ephemerals, verify-ephemerals, export, export all, export triage, export spec, export release-readiness | | `loaf journal` | Record and read the project-scoped journal (the durable record across all conversations) | log, recent, search, show, context, export, defer | | `loaf project` | Manage durable project identity | list, show, identity, rename, move, delete | | `loaf migrate` | Run native migration workflows | markdown, storage-home, schema, lifecycle-statuses, journal-first, worktree-storage | -| `loaf task` | Manage project tasks | list, show, status, create, update, archive, refresh, sync | -| `loaf spec` | Manage project specs | new, edit, list, show, status, render, finalize, archive, delete | +| `loaf task` | Manage project tasks; superseded by loaf issue for new work | list, show, status, create, update, archive, refresh, sync | +| `loaf issue` | Manage issues in native SQLite state | new, show, list, tree, frontier, start, stop, edit, status, dod, dod add, dod list, dod remove, dod claim, dod unclaim, promote, check, verify, bucket, link, render, export, pull, push, reconcile | | `loaf report` | Manage durable reports (research, audits, investigations) | list, show, render, generate, create, edit, finalize, archive | | `loaf finding` | Manage report findings and verdicts in native SQLite state | list, show, create, verdict, import-json | | `loaf run` | Manage provenance runs for generated findings and reports | list, show, create, complete | @@ -89,7 +88,7 @@ Names and one-line purposes only. Run `loaf <command> --help` for options, argum | `loaf trace` | Trace relationships for one state entity | — | | `loaf brainstorm` | Manage brainstorms in native SQLite state | capture, list, show, promote, archive | | `loaf idea` | Manage ideas in native SQLite state | list, show, capture, promote, resolve, archive | -| `loaf intent` | Manage tracked Intent in native SQLite state; disposition is derived from append-only facts | create, defer, resume, resolve, show, list | +| `loaf intent` | Manage tracked Intent in native SQLite state; disposition is derived from append-only facts; superseded by loaf issue for new work | create, defer, resume, resolve, show, list | | `loaf intake` | Read the deterministic local intake projection; triage judgment stays with humans and Skills | list | | `loaf exploration` | Manage relational Exploration continuity: immutable portable checkpoints, no lifecycle status, no current pointer | create, checkpoint, list, context, conversation | | `loaf conversation` | Manage logical conversations and machine-local provenance handles; handles never imply portable context | create, show, list, handle, observe | diff --git a/plugins/loaf/skills/loaf-reference/references/command-routing.md b/plugins/loaf/skills/loaf-reference/references/command-routing.md index e6856fd65..5f9d30bbb 100644 --- a/plugins/loaf/skills/loaf-reference/references/command-routing.md +++ b/plugins/loaf/skills/loaf-reference/references/command-routing.md @@ -6,15 +6,15 @@ Which command a task needs. For exact flags, run `loaf <command> --help`. | Intent | Route | |--------|-------| -| Shape new bounded work | `loaf change init <slug>`, then `loaf change check` | -| Start implementing new bounded work | the implement workflow after shaping and validating its Change | -| Continue an existing task or spec record | `loaf task` and `loaf spec` remain supported for existing records | +| Shape new bounded work | `loaf issue new <title>`, then `loaf issue dod add` and `loaf issue check <ref>` | +| Start implementing new bounded work | the implement workflow: pick from `loaf issue frontier`, then `loaf issue start <ref>` | +| Continue an existing task or spec record | `loaf task` and `loaf spec` remain readable for legacy records; new work is issues | | Continue after a restart | `loaf journal context` | | Skills or content changed | `loaf build && loaf install --to <target>` | -| See what is in progress | `loaf task list --active` | -| Archive completed work | `loaf task archive` | +| See what is in progress | `loaf issue list --status active` and `loaf issue list --started` | +| Remove finished-with work | `loaf issue status <ref> cancelled` or `duplicate --duplicate-of <ref>` (archives; record survives) | | Check knowledge freshness | `loaf kb check` | -| Validate a Change is structurally executable, not implementation-complete | `loaf change check --require-executable` | +| Validate an issue is shaped, covered, and contained | `loaf issue check <ref>` (non-zero exit names each failure) | | Import legacy `.agents` Markdown into SQLite | `loaf migrate markdown --dry-run` then `--apply` (see markdown-migration reference) | ## JSON diagnosis surfaces @@ -24,10 +24,10 @@ scraping human-readable text: - `loaf config check --json` — config file and installed hook config validity - `loaf state doctor --json` / `loaf state status --json` — SQLite health and readiness -- `loaf change check --json` — Change violations and derived executability +- `loaf issue check <ref> --json` — derived readiness, coverage, and containment - `loaf check --hook <id> --json` — one enforcement hook's result - `loaf kb check --json` — knowledge staleness against git history -- `loaf task list --json` / `loaf journal recent --json` — current work and timeline +- `loaf issue list --json` / `loaf journal recent --json` — current work and timeline - `loaf migrate markdown --dry-run --json` — `mode` (`simulation`/`inventory`) plus `import_report` when simulated Choosing between the `doctor` commands and `LOAF_DB` isolation are covered in diff --git a/plugins/loaf/skills/orchestration/SKILL.md b/plugins/loaf/skills/orchestration/SKILL.md index 8c4648acc..823a13d79 100644 --- a/plugins/loaf/skills/orchestration/SKILL.md +++ b/plugins/loaf/skills/orchestration/SKILL.md @@ -44,9 +44,9 @@ Comprehensive patterns for orchestration: coordinating multi-agent work, keeping - Outcome-focused, self-contained, no local file references - Magic words in commit body, not subject -**If `integrations.linear.enabled` is `true` in `.agents/loaf.json`:** use Linear MCP workflows and [references/linear.md](references/linear.md) for issue updates and status. +**If `integrations.linear.enabled` is `true` in `.agents/loaf.json`:** Linear is an identity adapter — `loaf issue pull` / `push` / `reconcile`, not a second work unit. See [references/linear.md](references/linear.md). Linear MCP is an overlay; Loaf issues remain the work unit and Linear never drives Loaf status. -**Otherwise:** coordinate with the project journal and `loaf task` / file-based tracking only; do not assume Linear MCP tools are available. +**Otherwise:** coordinate with the project journal and `loaf issue` only; do not assume Linear MCP tools or identity delegation are available. ### Planning (Shape Up) - Complexity-based sizing (small / medium / large) @@ -73,15 +73,15 @@ Comprehensive patterns for orchestration: coordinating multi-agent work, keeping | Pre-compaction | On an exact target mode with supported compaction delivery, hooks may nudge a journal flush and emit the digest afterward; otherwise flush manually and run `loaf journal context` after compaction | | Durable artifact handling | Delegate `.agents/`-scoped report/spec/handoff/knowledge tending to `librarian` | | Low-priority work | Spawn background-runner (see Background Agents) | -| New feature workflow | Research -> Architecture -> Shape -> Breakdown -> Implement | +| New feature workflow | Pitch -> Shape -> Implement -> Ship -> Release | ## Topics | Topic | Reference | Use When | |-------|-----------|----------| -| Shaping Specs | [../shape/SKILL.md](../shape/SKILL.md) | Creating specs, shaping work, defining test conditions | -| Breaking Work Into Tasks | [../breakdown/SKILL.md](../breakdown/SKILL.md) | Turning shaped specs into implementation tasks | -| Local Tasks | [references/local-tasks.md](references/local-tasks.md) | Managing tasks locally or with Linear backend | +| Shaping Issues | [../shape/SKILL.md](../shape/SKILL.md) | Preparing issues: body, definition of done, out of scope | +| Decomposition | [../shape/SKILL.md](../shape/SKILL.md) | Promoting a criterion that earns its own DoD (`loaf issue promote`) | +| Working Issues | [references/local-tasks.md](references/local-tasks.md) | Frontier, started worktrees, status, definition of done | | Agent Delegation | [references/delegation.md](references/delegation.md) | Choosing agents, spawning subagents, decision trees | | Parallel Agents | [references/parallel-agents.md](references/parallel-agents.md) | Dispatching independent work concurrently | | Subagent Development | [references/subagent-development.md](references/subagent-development.md) | Delegating to specialized agents | @@ -98,7 +98,7 @@ Comprehensive patterns for orchestration: coordinating multi-agent work, keeping The orchestrator: 1. Creates issues and logs the orchestration intent for tracking -2. Breaks down work into delegable tasks +2. Picks from `loaf issue frontier` and starts one worktree per issue 3. Spawns specialized agents for implementation 4. Coordinates outcomes and updates external systems 5. Never implements code, tests, or documentation directly @@ -128,16 +128,16 @@ This skill uses paths from `.agents/loaf.json`: | Councils | `.agents/councils/` | `.agents/councils/archive/` | `YYYYMMDD-HHMMSS-topic.md` | | Handoffs | `.agents/handoffs/` | delete after deprecated | Created by handoff | | Reports | `.agents/reports/` | N/A | `YYYYMMDD-HHMMSS-subject.md` | -| Tasks | SQLite (`loaf task show/list`) | N/A | Per task manager conventions | +| Issues | SQLite (`loaf issue show/list`) | `cancelled` / `duplicate` via `loaf issue status` | Alias or opaque id | **Rule:** Agents write artifacts to disk, orchestrator reasons over artifacts, users retrieve from disk. ## Workflow by Lifecycle ### BEFORE (Planning) -- Create/check external issue (Linear, GitHub) +- Shape prepares issues; implement works the frontier. Decomposition is `loaf issue promote` inside shape. - Log the orchestration intent with `loaf journal log` -- Break down into tasks, identify agents, get user approval +- `loaf issue check <ref>` must report shaped (delivery) or ready (decision); identify agents; get user approval ### DURING (Execution) - Spawn specialized agents (never implement directly) @@ -146,6 +146,6 @@ This skill uses paths from `.agents/loaf.json`: ### AFTER (Completion) - Code review + QA testing -- Update external issue to Done +- Land via ship: `loaf issue status <ref> done`, then `loaf issue stop <ref>` - Ensure knowledge captured in permanent locations - Write an optional `wrap` journal entry if the conversation holds synthesis worth saving diff --git a/plugins/loaf/skills/orchestration/references/background-agents.md b/plugins/loaf/skills/orchestration/references/background-agents.md index 6aa2b3c61..95b4b3b54 100644 --- a/plugins/loaf/skills/orchestration/references/background-agents.md +++ b/plugins/loaf/skills/orchestration/references/background-agents.md @@ -43,7 +43,7 @@ Task( - src/services/ Write report to: .agents/reports/YYYYMMDD-HHMMSS-security-audit.md - Reference: TASK-123, SPEC-045 if relevant + Reference: LOAF-123 if relevant """, run_in_background=True ) @@ -51,12 +51,12 @@ Task( ### Cursor -Background agents are configured via the `is_background: true` YAML property. When spawning, specify the report destination and any task/spec IDs: +Background agents are configured via the `is_background: true` YAML property. When spawning, specify the report destination and any issue refs: ``` @background-runner Run security audit on backend codebase. Write report to .agents/reports/. -Reference TASK-123 if relevant. +Reference LOAF-123 if relevant. ``` The background agent's journal entries are tagged with its own harness id automatically — there is no session alias to pass. @@ -72,7 +72,7 @@ Track background work with durable references: 1. Log the spawn with `loaf journal log "todo(background): started <id> for <task>"`. 2. Ask the background agent to write a report under `.agents/reports/`. 3. When complete, log `discover(background): <id> wrote <report>`. -4. Process findings into tasks, specs, ADRs, or report verdicts as appropriate. +4. Process findings into issues, ADRs, or report verdicts as appropriate. Use a stable ID such as `bg-YYYYMMDD-HHMMSS-description` in the prompt and journal entries. @@ -85,7 +85,7 @@ Background agents write results to `.agents/reports/` with enough metadata to id 1. Orchestrator identifies non-blocking security audit work. 2. Orchestrator logs the background spawn to the journal. 3. Background agent writes `.agents/reports/YYYYMMDD-HHMMSS-auth-security.md`. -4. Orchestrator reviews the report, creates follow-up tasks, and logs the outcome. +4. Orchestrator reviews the report, creates follow-up issues, and logs the outcome. 5. Report state is finalized or archived through the report lifecycle. ## Anti-Patterns @@ -94,7 +94,7 @@ Background agents write results to `.agents/reports/` with enough metadata to id |-------|------------| | Use for blocking work | Keep blocking work in foreground | | Spawn without tracking | Log the spawn and require a report path | -| Ignore completed results | Process reports into tasks, findings, or decisions | +| Ignore completed results | Process reports into issues, findings, or decisions | | Use for interactive tasks | Reserve for autonomous work | | Spawn many concurrent background agents | Limit concurrency to avoid resource contention | | Skip result location in prompt | Always specify where output belongs | diff --git a/plugins/loaf/skills/orchestration/references/context-management.md b/plugins/loaf/skills/orchestration/references/context-management.md index 11ce34ac7..49151a167 100644 --- a/plugins/loaf/skills/orchestration/references/context-management.md +++ b/plugins/loaf/skills/orchestration/references/context-management.md @@ -19,28 +19,28 @@ Patterns for keeping long work resumable while using the project journal as exte Compaction is normal in long workflows. Design work that spans many exchanges so important state is already outside chat context. 1. **The journal is external memory.** Record decisions, discoveries, blockers, and next actions with `loaf journal log`. -2. **Artifacts carry detail.** Changes, task-board records, reports, ADRs, and commits hold rich detail; journal entries point to them. +2. **Artifacts carry detail.** Issues, reports, ADRs, and commits hold rich detail; journal entries point to them. 3. **Delegated work absorbs exploration.** Use delegated agents for broad investigation and return concise findings to the main context. -4. **`wrap` captures synthesis.** When meaningful work holds intentions or abandoned paths worth saving, write an optional `wrap` journal entry. +4. **`wrap` captures synthesis.** When meaningful work holds abandoned paths worth saving, write an optional `wrap` journal entry. ## Continuity Digest (contract v2) -`loaf journal context` is the contract-v2 active-truth digest and supersedes the retired three-part summary. Read its named layers and diagnostics; an absent item and an unavailable source are different states. +`loaf journal context` is the contract-v2 active-truth digest. Read its named layers and diagnostics; an absent item and an unavailable source are different states. Layer *names* below are the live CLI identifiers. | Layer | Truth and precedence | |-------|----------------------| | `project-synthesis` | The latest `wrap(project)` synthesis. This is the only wrap that represents project-wide synthesis. | | `scoped-checkpoint` | The latest non-project wrap, only when `project-synthesis` has no item. It is labeled as a fallback, not promoted to project synthesis. | -| `active-lineage` | Journal evidence associated with the active Change lineage. | +| `active-lineage` | Journal evidence associated with the active work lineage. | | `unresolved-blockers` | Blocks without a later exact-scope unblock. | -| `deferred-intent` | Open deferred-intent decision and spark pairs. | -| `active-changes` | Git-derived active Change evidence and worktree state. | +| `deferred-intent` | CLI layer for open deferred decision and spark pairs. | +| `active-changes` | Git-derived active worktree evidence. | | `branch-recency` | Recent branch entries after entries already surfaced as active truth are removed. | -| `transitional-tasks` | Open task-board records retained for compatibility. | +| `transitional-tasks` | Leftover board records retained for compatibility — not the work unit. Prefer `loaf issue frontier` / `loaf issue list --started`. | -Each returned layer includes `source_available`, `available_count`, `shown_count`, `truncated`, and `expand_command`; paginated layers also include a cursor. Treat `source_available: false` as an explicit unavailable source, never as “nothing is active.” If Change discovery is unavailable, `active-changes` and `active-lineage` are unavailable and the digest carries a diagnostic. +Each returned layer includes `source_available`, `available_count`, `shown_count`, `truncated`, and `expand_command`; paginated layers also include a cursor. Treat `source_available: false` as an explicit unavailable source, never as “nothing is active.” If git-derived discovery is unavailable, `active-changes` and `active-lineage` are unavailable and the digest carries a diagnostic. -Use `--branch` to select `branch-recency` scope and bind state cursors. It does not override active Change provenance or reasons, which always use the actual Git branch. Use `loaf journal context --layer <name>` to inspect one layer. `--limit` accepts 1 through 100 and requires `--layer`; `--cursor` also requires `--layer` and cannot be used with the intrinsic one-item `project-synthesis` or `scoped-checkpoint` layers. Follow the returned `expand_command` exactly: a cursor is bound to its layer, project, branch, snapshot, and limit. Use `--json` for automation; the human view preserves availability, counts, truncation, diagnostics, and expansion commands. +Use `--branch` to select `branch-recency` scope and bind state cursors. It does not override git provenance or reasons, which always use the actual Git branch. Use `loaf journal context --layer <name>` to inspect one layer. `--limit` accepts 1 through 100 and requires `--layer`; `--cursor` also requires `--layer` and cannot be used with the intrinsic one-item `project-synthesis` or `scoped-checkpoint` layers. Follow the returned `expand_command` exactly: a cursor is bound to its layer, project, branch, snapshot, and limit. Use `--json` for automation; the human view preserves availability, counts, truncation, diagnostics, and expansion commands. ## Context Commands @@ -53,21 +53,21 @@ Use `--branch` to select `branch-recency` scope and bind state cursors. It does ## When to Clear Context -Clear the conversation when starting a completely new task, after the previous task is complete, when debugging noise crowds out the current objective, or when switching between unrelated codebases. Avoid clearing mid-task until enough state is logged for recovery. +Clear the conversation when starting a completely new issue, after the previous issue is complete, when debugging noise crowds out the current objective, or when switching between unrelated codebases. Avoid clearing mid-issue until enough state is logged for recovery. ## Compaction Lifecycle PreCompact: 1. Flush unrecorded decisions, discoveries, blockers, and next actions with `loaf journal log`. -2. Reference Changes, task-board records, reports, commits, and files by stable ID or path. +2. Reference issues, reports, commits, and files by stable ID or path. 3. On an exact target mode with supported PreCompact delivery, let the hook nudge the flush; otherwise flush manually before compacting. PostCompact: 1. On an exact target mode with supported resumption delivery, read the continuity digest emitted by the hook; otherwise run `loaf journal context` explicitly. 2. Expand the named layer that needs more detail, or use `loaf journal recent` and `loaf journal search` for a different query. -3. Continue from the journal and linked artifacts. +3. Continue from the journal and linked artifacts (`loaf issue show <ref>`). This makes compaction survivable without relying on hand-maintained Markdown state. State not logged or captured in a durable artifact can be lost. @@ -79,10 +79,10 @@ Use delegated agents to investigate without filling the main context. |-----------|----------| | Quick file lookup | Direct read or search tool | | Multi-file exploration | Explorer or research agent | -| Implementation work | Implementer or task-focused agent | +| Implementation work | Implementer in the issue's started worktree | | Long audit | Background agent with report output | -Pass stable references to delegated agents: Change IDs, task IDs, branch names, and report paths. The harness ID is attached to journal entries automatically; there is no session alias to pass. +Pass stable references to delegated agents: issue IDs, branch names, worktree paths, and report paths. The harness ID is attached to journal entries automatically; there is no session alias to pass. Never send two agents to the same started worktree. ## Context Budget Guidelines @@ -109,12 +109,12 @@ No special management is usually needed. | Repeating same mistakes | Context pollution | Log current facts, then clear or compact | | Forgetting recent decisions | Overcrowded context | Read `loaf journal context` and expand the relevant layer | | Slow responses | Large context | Delegate exploration | -| Confusion about task | Too many pivots | Re-anchor on Change or task IDs | +| Confusion about the work | Too many pivots | Re-anchor on issue IDs (`loaf issue show`) | ## Best Practices 1. Log durable facts early with `loaf journal log`. 2. Use delegated agents for exploration-heavy work. -3. Clear between unrelated tasks. -4. Compact mid-task when the journal and artifacts are current. +3. Clear between unrelated issues. +4. Compact mid-issue when the journal and artifacts are current. 5. Scope tool calls so context stays focused. diff --git a/plugins/loaf/skills/orchestration/references/delegation.md b/plugins/loaf/skills/orchestration/references/delegation.md index b710244b6..a620c0ba7 100644 --- a/plugins/loaf/skills/orchestration/references/delegation.md +++ b/plugins/loaf/skills/orchestration/references/delegation.md @@ -131,8 +131,8 @@ Use when work is truly independent. Spawn multiple agents in the same turn when 1. **Be specific in prompts** - Include file paths, requirements, constraints 2. **One concern per agent** - Don't ask a backend implementer to also write tests -3. **Include context** - Task/spec IDs, issue ID, previous outcomes -4. **Reference durable artifacts** - Task, spec, and report IDs; the subagent's journal entries are harness-id tagged automatically +3. **Include context** - Issue refs (`LOAF-42` or opaque id), previous outcomes +4. **Reference durable artifacts** - Issue aliases and report IDs; the subagent's journal entries are harness-id tagged automatically 5. **Include skill hints** - Name the skills that should guide the agent's work ### Skill Hints @@ -171,8 +171,7 @@ Files: - src/api/users.py - src/models/user.py -Task: TASK-042 -Linear: BACK-123 +Issue: LOAF-42 ``` ## Anti-Patterns @@ -183,7 +182,7 @@ Linear: BACK-123 | Asking backend implementer for React | Spawn implementer with frontend skills | | Single agent for database + backend + tests | Sequential: implementer (database-design), implementer (language skill), implementer (foundations) | | Parallel spawns with hidden dependencies | Make dependencies explicit, spawn sequentially | -| Spawning without context | Reference task/spec/report IDs in prompts | +| Spawning without context | Reference issue aliases and report IDs in prompts | | Council for simple decisions | Single agent or orchestrator judgment | ## Agent Access Hierarchy diff --git a/plugins/loaf/skills/orchestration/references/journal.md b/plugins/loaf/skills/orchestration/references/journal.md index de2a9de3a..442f2b216 100644 --- a/plugins/loaf/skills/orchestration/references/journal.md +++ b/plugins/loaf/skills/orchestration/references/journal.md @@ -42,20 +42,20 @@ loaf journal log "spark(scope): possible follow-up idea" loaf journal log "todo(scope): concrete follow-up action" ``` -Log durable facts, not thoughts. Reference task IDs, spec IDs, report IDs, and +Log durable facts, not thoughts. Reference issue IDs, report IDs, and commit refs rather than pasting long prose. The journal should let another agent resume without reading the whole conversation. ## Codex Auto Mode -When the user has explicitly enabled Loaf's managed Codex Auto-journal capability, use the exact path-pinned command shown in the Loaf-managed block of `CODEX_HOME/AGENTS.md`; its form is `'<canonical-loaf-path>' journal log --execpolicy-safe "decision(scope): chose X because Y"`. Do not substitute a bare `loaf`, alternate executable, or shell/environment wrapper. The installed Codex rule authorizes only explicitly classified basic Loaf command leaves outside the workspace sandbox, including this hardened journal writer and approved readers; ordinary `journal log`, body/file-consuming leaves, and path-taking `change check` remain operator-gated, and the policy does not authorize a general Loaf data-directory writable root. Other harness adapters are not implied and continue to use their own ordinary surfaces. +When the user has explicitly enabled Loaf's managed Codex Auto-journal capability, use the exact path-pinned command shown in the Loaf-managed block of `CODEX_HOME/AGENTS.md`; its form is `'<canonical-loaf-path>' journal log --execpolicy-safe "decision(scope): chose X because Y"`. Do not substitute a bare `loaf`, alternate executable, or shell/environment wrapper. The installed Codex rule authorizes only explicitly classified basic Loaf command leaves outside the workspace sandbox, including this hardened journal writer and approved readers; ordinary `journal log`, body/file-consuming leaves, and other path-taking operator-gated leaves remain operator-gated, and the policy does not authorize a general Loaf data-directory writable root. Other harness adapters are not implied and continue to use their own ordinary surfaces. Enable the capability once with `loaf install --to codex --codex-basic-commands`. Installation is an explicit trust decision. If the rules are absent, stale, locally modified, or conflict with user-owned `loaf.rules`, Loaf reports the condition instead of overwriting it or asking for full system access. ## Wrap: Optional Checkpoint A `wrap` entry is a voluntary checkpoint, not a lifecycle transition. Write one -only when the conversation holds synthesis worth saving — intentions, abandoned +only when the conversation holds synthesis worth saving — abandoned paths, next steps — the connective narrative that evaporates with the context window. Almost everything else is derivable from raw entries. @@ -70,11 +70,12 @@ perfectly valid journal. A wrap reviews its own conversation's entries first: loaf journal recent --since-last-wrap ``` -See the `wrap` skill for the full checkpoint flow. +See the `wrap` skill for the full checkpoint flow. Loose ends name issues +(`loaf issue frontier`, `loaf issue list --started`), not board leftovers. ## Derived Continuity -Continuity is computed at read time and never persisted. On an exact target mode whose startup delivery capability is supported, the startup adapter may emit a layered digest with the latest project-level wrap, recent entries scoped to the current branch/worktree, and open (`in_progress`/`pending`) tasks. When startup delivery is candidate or unsupported for the current mode, explicitly run `loaf journal context` at conversation start. On demand, reproduce or extend the digest: +Continuity is computed at read time and never persisted. On an exact target mode whose startup delivery capability is supported, the startup adapter may emit a layered digest with the latest project-level wrap, recent entries scoped to the current branch/worktree, and open work. Bind that open work to `loaf issue frontier` and `loaf issue list --started`. When startup delivery is candidate or unsupported for the current mode, explicitly run `loaf journal context` at conversation start. On demand, reproduce or extend the digest: ```bash loaf journal context # the layered continuity digest @@ -82,7 +83,7 @@ loaf journal recent --branch <b> # recent entries for one branch loaf journal search <query> # find prior decisions by topic ``` -Pass task/spec/report references to background and delegated agents. The harness +Pass issue IDs and report references to background and delegated agents. The harness id is attached automatically — there is no session alias to pass along. ## Recovery @@ -91,7 +92,7 @@ After compaction, a branch switch, or a long gap: 1. Read `loaf journal context`; on an exact target mode with supported resumption delivery, the digest emitted by the adapter is equivalent continuity context. 2. Widen with `loaf journal recent` / `loaf journal search` when more is needed. -3. Compare against `git status`, `git log`, and the relevant specs/tasks. +3. Compare against `git status`, `git log`, `loaf issue frontier`, and `loaf issue list --started`. 4. If code and journal have drifted, log the reconciliation: `loaf journal log "decision(recovery): rewound to <commit>; replaying tests"`. @@ -108,7 +109,7 @@ After compaction, a branch switch, or a long gap: | Don't | Do Instead | |-------|------------| | Wait to log everything at the end | Log significant facts as they happen | -| Store decisions only in chat context | Log them and promote durable ones to ADR/spec/report/docs | +| Store decisions only in chat context | Log them and promote durable ones to ADR/issue body/report/docs | | Write a placeholder wrap out of ceremony | Wrap only when there's synthesis worth saving | | Treat a missing wrap as an open loop | A conversation without a wrap is complete and valid | | Pass a session alias to delegated agents | Nothing to pass — the harness id is automatic | diff --git a/plugins/loaf/skills/orchestration/references/linear.md b/plugins/loaf/skills/orchestration/references/linear.md index f488726cf..f00960ebc 100644 --- a/plugins/loaf/skills/orchestration/references/linear.md +++ b/plugins/loaf/skills/orchestration/references/linear.md @@ -7,8 +7,7 @@ Guidelines for writing Linear issue updates, comments, and commit messages with - Configuration - MCP Server Naming - Multi-Workspace Guidance -- Linear-Native Mode (Parent + Sub-Issues) -- The `spec` Label Convention +- Identity Adapter - Progress Update Format - Issue Description Format - Status Conventions @@ -97,96 +96,44 @@ Match the `linear.mcp_server_name` in each project's `.agents/loaf.json` to the name used in that project's `.mcp.json`. That way the Loaf skills invoke the right workspace automatically. -## Linear-Native Mode (Parent + Sub-Issues) +## Identity Adapter -In Linear-native mode (`integrations.linear.enabled: true`), each spec -produces one parent **rollup issue** and N sub-issues under it. +When `issue_identity.authority = linear`, Linear owns identity, title, status, +and assignment. Loaf owns shaping state: body, definition-of-done criteria, +claims, and the started worktree. The Loaf issue is the work unit. Linear MCP +is an overlay — never drive Loaf status from MCP tools. -``` -Agent framework alignment ← parent, label: `change` -├── Split reviewer profile into reviewer/auditor ← sub-issue, label: type/refactor -├── Harden MCP fallback path ← sub-issue, label: type/feature -└── Migrate legacy task references ← sub-issue, label: type/refactor -``` - -### Parent issue — what it is and isn't - -The parent issue is a **dashboard anchor**, not a re-hosting of the spec. - -- **Is:** a short summary (1–3 paragraphs) of the problem and solution - direction + a link to the canonical spec file in the repo. -- **Is not:** a copy of the spec's Scope / Rabbit Holes / Open Questions / - Risks sections. Those live in the local spec file and evolve there. - -### Sample parent description +`loaf issue new` delegates identity: Linear mints the identifier, and that +key becomes the local alias. The local counter is not advanced. If Linear is +offline, refuse — capture via `loaf spark` or `loaf idea`. Do not mint a +local alias as a fallback. -```markdown -## Summary -Align Loaf's agent profiles with the three-role model (implementer, reviewer, -researcher). Consolidate historical profile variants and add tool-boundary -tests so profiles can't drift without a test failing. - -## Context -See the canonical change file in the repository for full text, council -references, rabbit holes, and strategic tensions. +If Linear created an issue but the local bind failed, adopt it: -## Progress -Sub-issues track execution. +```text +loaf issue pull <linear-key> +loaf issue pull <linear-key> --tree ``` -### Sub-issues - -- Each sub-issue has `parentId` set to the parent issue ID. -- Cross-task dependencies use Linear's `blockedBy` field referencing sibling - sub-issue IDs. -- Sub-issue labels describe the task itself (type, team, area), not the - parent — don't label sub-issues with `spec`. -- Starting a sub-issue promotes the parent rollup from `backlog`/`unstarted` - to the team's `started`/In Progress state. Parent promotion is a state - invariant of the start operation, not a separate manual reminder. -- Do not silently reopen protected parents. If the parent is `completed`, - `canceled`, or archived, stop and ask for an explicit override before - starting the child. - -### Spec file remains canonical - -Even with the parent in Linear, the local spec file is the source of truth -for: +`--tree` also adopts the sub-issue tree with parent edges intact. -- Problem statement and solution direction -- Scope / in-scope / out-of-scope / rabbit holes / no-gos -- Risks and open questions -- Council references and strategic tensions +### Commands -When the spec evolves, edit the file and let git track it. The parent -issue's summary is a frozen entry point; only refresh it if the summary -itself (not the rabbit holes or risks) changes meaningfully. - -## The `spec` Label Convention - -Every spec-parent rollup issue carries a Linear label named `spec`. This lets -anyone in Linear filter for "all spec roots" across projects without having to -know which issues happen to be parents. - -| Field | Value | -|-------|-------| -| Name | `spec` | -| Color | `#5e6ad2` (suggested; implementer may adjust) | -| Description | `Parent rollup issue representing a design spec tracked in the repo at .agents/specs/` | -| Scope | Workspace-scoped preferred; fall back to team-scoped if the MCP requires it | - -### Who creates it - -breakdown creates the `spec` label on first Linear-native breakdown in a -workspace that doesn't already have it. Subsequent breakdowns reuse the -existing label. Log whether the label was created this run or already -existed — this matters for first-time setup. +```text +loaf issue pull <linear-key> [--tree] [--json] +loaf issue push <ref> [--json] +loaf issue reconcile [<ref>] [--take-local|--take-tracker] [--json] +``` -### Sub-issues never carry `spec` +| Command | Writes? | What it does | +|---------|---------|----------------| +| `loaf issue pull` | Yes | Adopt an existing Linear issue as a local row. The Linear key becomes the alias | +| `loaf issue push` | Yes | Write `loaf issue render` as the Linear description. Status is written only when the local status event is newer than the tracker. Never renames the Linear issue | +| `loaf issue reconcile` | Yes with a take flag | Compare local and Linear. Title drift updates the local title (tracker wins). Status drift is reported; `--take-local` or `--take-tracker` resolves it. Description drift is reported only | -`spec` applies only to parents. A sub-issue describing a task uses its own -labels (type groups like `feature`/`bug`/`refactor`, team labels, area -labels) — never `spec`. This keeps the "filter for spec roots" query clean. +Do not create records with `loaf task` or `loaf spec`. Parent/child structure +is `loaf issue promote` (or `loaf issue new --parent`), not a `spec`-labeled +Linear rollup. ## Progress Update Format @@ -222,18 +169,9 @@ None currently. ## Issue Description Format -```markdown -## Summary -Brief description of the work and its purpose. - -## Acceptance Criteria -- [ ] Criterion 1 -- [ ] Criterion 2 -- [ ] Criterion 3 +The Linear description is `loaf issue push` output — `loaf issue render`, not a hand-authored summary. Do not paste a competing description over the render. -## Notes -Any relevant context (keep brief). -``` +Comments (not the description) still follow the progress-update format above. **Rules:** - Concise and actionable @@ -243,6 +181,8 @@ Any relevant context (keep brief). ## Status Conventions +Loaf status is `loaf issue status`. Linear status is the tracker's. Resolve drift with `loaf issue reconcile` (`--take-local` or `--take-tracker`). Do not flip Loaf status from Linear MCP tools. + | State | When to Use | |-------|-------------| | **Backlog** | Issue created, not started | @@ -341,6 +281,8 @@ Use `scripts/suggest-team.py "task desc"` to get suggestions. ## When to Create Issues +Create through `loaf issue new` so identity can be delegated. Do not create in Linear MCP and then forget to `loaf issue pull`. + | Action | Create Issue? | |--------|---------------| | Features, bugs, refactoring | Yes | diff --git a/plugins/loaf/skills/orchestration/references/local-tasks.md b/plugins/loaf/skills/orchestration/references/local-tasks.md index 69c89df64..30d617f47 100644 --- a/plugins/loaf/skills/orchestration/references/local-tasks.md +++ b/plugins/loaf/skills/orchestration/references/local-tasks.md @@ -1,259 +1,173 @@ -# Local Task Management +# Working Issues Locally -Break specs into atomic tasks using SQLite-backed Loaf task records when Linear -isn't available. +Orchestration-facing reference for the Loaf issue CLI: pick-up-next, started +worktrees, status, definition of done, and advisory labels. Issue commands +require initialized SQLite state. ## Contents -- Task Abstraction Layer -- Local Task Records -- Task Lifecycle -- Creating Tasks from Specs -- Cutover Reconciliation -- Task ID Generation -- Archiving Tasks -- Journal Integration -- Task Sizing -- Priority Levels -- Listing Tasks -- Work Log Updates -- Verification -- Local vs Linear Comparison - -## Task Abstraction Layer - -Tasks work identically whether backed by Linear or local SQLite state. - -### Configuration - -```yaml -# .agents/loaf.yaml -task_management: - backend: linear # or "local" - - linear: - team: ProjectName - default_labels: [] - - local: - archive_completed: true -``` - -### Abstracted Operations - -| Operation | Linear | Local | -|-----------|--------|-------| -| Create task | Create issue | `loaf task create --spec SPEC-XXX --title "..." --priority P1` | -| Fetch task | Get issue | `loaf task show TASK-XXX` or `loaf task show TASK-XXX --json` | -| Update status | Update issue | `loaf task update TASK-XXX --status in_progress` | -| List tasks | List issues | `loaf task list` (or `loaf task list --json` for machine parsing) | -| Complete | Move to Done | `loaf task update TASK-XXX --status done` | - -## Local Task Records +- Frontier +- Started worktree +- Status vocabulary +- Relationships +- Definition of done +- Buckets +- Command cheat sheet +- LEGACY -SQLite is the operational source of truth for task metadata, status, priority, -dependencies, dates, and relationships. Use `loaf task` CLI commands for all -task mutations. `.agents/tasks/` and `.agents/TASKS.json` were removed by the -SPEC-045 cutover and are rollback material only. - -**Create with:** `loaf task create --title "..." --spec SPEC-XXX` - -## Task Lifecycle +## Frontier +```text +loaf issue frontier [--json] ``` -todo → in_progress → review → done - │ │ │ │ - └────────┴───────────┴────────┘ - can return to earlier states -``` - -| Status | Meaning | -|--------|---------| -| `todo` | Ready to work, not started | -| `in_progress` | Actively being worked | -| `review` | Implementation complete, needs verification | -| `done` | Verified complete, ready for archive | - -## Creating Tasks from Specs - -### Input -- Spec ID (e.g., `SPEC-001`) -- Optional: priority override +Pick-up-next. Derived at read time, never stored. Lists non-archived issues in +`triage`, `backlog`, or `todo` that are not blocked. -### Task Breakdown Rules +| Qualifier | Meaning | +|-----------|---------| +| Open | Status is `triage`, `backlog`, or `todo` — not `active`, `done`, `cancelled`, or `duplicate` | +| Unblocked | No open predecessor via `blocks` / `blocked_by`. A predecessor that is `done`, `cancelled`, or `duplicate` does not block | +| Unclaimed | Not `active` and no started worktree. `loaf issue start` is the claim | -1. **One concern per task** - Don't mix backend + tests + frontend -2. **Clear done condition** - Observable, verifiable outcome -3. **Verification command** - How to prove it works -4. **File hints** - Which files will likely be modified +Archived rows are excluded. Kind is not filtered: a `--kind decision` question +can appear; it is not delivery work. Buckets are not read. Prefer `--json` +when diagnosing rather than scraping the human-readable text. -### Example Breakdown +## Started worktree +```text +loaf issue start <ref> [--json] +loaf issue stop <ref> [--force] [--json] +loaf issue list --started [--json] ``` -SPEC-001: User Authentication with OAuth - ↓ -TASK-001: OAuth Provider Integration - - Google OAuth client setup - - GitHub OAuth client setup - - Token exchange logic - verify: pytest tests/auth/test_oauth.py - -TASK-002: Session Management - - Session cookie handling - - Session storage (Redis/DB) - - Session expiry logic - verify: pytest tests/auth/test_session.py - -TASK-003: Login UI Components - - Login page layout - - Provider buttons - - Error states - verify: npm run test:e2e -- auth -``` - -## Cutover Reconciliation - -If a stale branch reintroduces `.agents/tasks/`, `.agents/sessions/`, other -ephemeral roots, or `.agents/TASKS.json`, keep the deletion side from the -cutover branch and rerun `loaf check --hook ephemeral-provenance`. Use -`loaf state restore-ephemerals <backup-id>` only for an intentional rollback, -then re-import forward. - -## Task ID Generation -Format: `TASK-{number}-{slug}` +**Invariant:** one agent, one worktree. Check `loaf issue list --started` +before dispatch. Never send two agents into the same path. -Task IDs are auto-generated by `loaf task create`. In SQLite-backed projects, -the allocation is recorded in state. +`start` creates branch `issue/<alias-or-id>` in lowercase (`issue/loaf-42`, +disambiguated with an id suffix when that name is already claimed), adds a +sibling worktree, records `started_branch` / `started_worktree` on the row, and +moves status to `active` through the events path. Base is the nearest started +ancestor's branch, else the repository default branch. Start refuses an already +started row, an archived row, and terminal statuses (`done`, `cancelled`, +`duplicate`). Requires a git repository. -## Archiving Tasks +`list --started` prints alias, title, `started_branch`, `started_worktree`, and +`(missing)` when the recorded path is gone. -When a task is done: +`stop` removes the worktree and clears the started workspace on the row. It +keeps the branch and does not change status. `--force` removes a dirty +worktree. Do not run `stop` from inside the started worktree. -1. Mark complete via CLI: `loaf task update TASK-XXX --status done` -2. Archive: `loaf task archive TASK-XXX` +## Status vocabulary -## Journal Integration +Write statuses that update in place: `triage`, `backlog`, `todo`, `active`, +`done`. `cancelled` and `duplicate` archive through the remove path +(`loaf issue status <ref> duplicate --duplicate-of <surviving>`). -When the implement workflow starts on `TASK-001`: - -1. Load task metadata via `loaf task show TASK-001` for context -2. Read linked spec for full picture -3. Log the task coupling as the first action: +```text +loaf issue status <ref> <status> [--duplicate-of <ref>] [--json] +``` -```bash -loaf journal log "decision(implement): implementing TASK-001" +| Status | Meaning | +|--------|---------| +| `triage` | Default at create. Shaped is derived (`loaf issue check`), not a status | +| `backlog` | Filed, worth keeping | +| `todo` | Explicitly ready to work | +| `active` | Started. **Review is a display name for `active`** — there is no `review` write status | +| `done` | Work landed | +| `cancelled` | Archived; abandoned | +| `duplicate` | Archived; `--duplicate-of` required | + +There is **no `blocked` status**. Blocked is a relationship. Title and body stay +mutable at every status. + +```text +loaf issue list [--status <status>] [--kind delivery|decision] [--archived] [--started] [--json] ``` -There is no session to create or couple to. Task progress is read through -`loaf task show/list`; the surrounding decisions and blockers live in the -project journal (`loaf journal recent`, `loaf journal search`). +Archived rows are hidden unless `--archived`. `--status` accepts every value in +the table above. -## Task Sizing +## Relationships -### Separation of Concerns +```text +loaf issue link <from> blocks|relates-to <to> [--json] +loaf issue link <from> remove <type> <to> [--json] +``` -**The primary principle for task breakdown is separation of concerns.** +Stored types are `blocks` and `relates_to`. `loaf issue link A blocks B` means +A blocks B: B is absent from the frontier until A is `done`, `cancelled`, or +`duplicate`. `relates-to` is not a sequencing constraint. -| Rule | Guideline | -|------|-----------| -| **One agent type** | Task completable by ONE subagent (implementer, reviewer, researcher) | -| **One concern** | Task touches one layer, one service, or one component | -| **Context-appropriate** | Fits in model context with room for exploration | -| **Not over-fragmented** | Don't split what naturally belongs together | +Do not encode order in `loaf issue tree`. Parent/child is structure; `blocks` +is the dependency. `loaf issue export [--json]` dumps relationships (and +claims) when you need the graph. -### Right Size Test +## Definition of done -1. Can a single specialized agent complete this? → If no, split by agent type -2. Does it touch multiple unrelated concerns? → If yes, split by concern -3. Will the agent need too much context? → If yes, split into smaller coherent units -4. Am I splitting just to have more tasks? → If yes, merge back +Criteria live on the issue row. `loaf issue show <ref>` prints each as +`position. [V|H] text` with `command=` / `expect=` when present. -### Agent Scope +```text +loaf issue dod add <ref> <text> [--command <cmd>] [--expect <expect>] [--tier V|H] [--serves <parent-position>] [--json] +loaf issue dod list <ref> [--json] +loaf issue dod remove <ref> <position> [--json] +loaf issue dod claim <child> <child-position> <parent-position> [--json] +loaf issue dod unclaim <child> <child-position> <parent-position> [--json] +loaf issue promote <ref> <position> [--json] +loaf issue check <ref> [--json] [--human <reason>] +loaf issue verify <ref> [--json] +``` -| Agent | Typical Task Scope | -|-------|-------------------| -| implementer (backend) | One service/module, its tests, its docs | -| implementer (frontend) | One component/page, its tests, its styles | -| implementer (database) | One migration, related schema changes | -| implementer (testing) | Test suite for one feature/area | -| implementer (infra) | One infrastructure concern (CI, deploy, config) | +| Tier | When | Who checks | +|------|------|------------| +| V | `--command` present, unless `--tier` overrides | `loaf issue verify <ref>` from the repository root. Honors `exit <N>` and `` contains `text` ``. Writes nothing. Non-zero on failure | +| H | No `--command`, unless `--tier` overrides | Human or orchestrator. Verify skips H-tier; that skip is not a pass | -## Priority Levels +Claims: a child criterion serves a parent criterion. `promote` copies the +parent criterion onto a new delivery child and records the claim. +`--serves` claims a newly added child criterion. `claim` / `unclaim` retarget +an existing pair. Positions are 1-based. -| Priority | Meaning | Response | -|----------|---------|----------| -| P0 | Urgent/blocking | Drop everything | -| P1 | High | Work next | -| P2 | Normal | Scheduled work | -| P3 | Low | When time permits | +`check` is readiness (shape's gate): delivery is shaped with a nonempty body, +at least one criterion, and an out-of-scope statement; decision is ready on a +sharp `?`. Children add coverage (every parent criterion claimed — failure) +and containment (every child criterion claims a parent — report). `verify` is +implement's preflight and writes nothing — it does not set status and does not +tick boxes. -## Listing Tasks +`loaf issue render <ref>` emits the paste-ready PR body: title, body, +definition-of-done checkboxes (checked only when status is already `done`), +and children. No manual editing. -### All Active Tasks +## Buckets -```bash -loaf task list +```text +loaf issue bucket <ref> now|next|later|none [--json] ``` -### Tasks for a Spec - -```bash -loaf spec list # Show specs with task counts -loaf task list --json # Machine-parseable output, filter by spec -``` +Advisory Now/Next/Later labels. Never read as a constraint. Frontier, start, +and verify ignore them. `none` clears the label. -## Work Log Updates +## Command cheat sheet -As work progresses, append to the Work Log section: - -```markdown -## Work Log - -### 2026-01-23 14:30 UTC -Started OAuth integration. Set up Google OAuth client credentials. - -### 2026-01-23 15:45 UTC -Google OAuth working. Moving to GitHub integration. - -### 2026-01-23 17:00 UTC -Both providers working. Tests pass. Moving to review. +```text +loaf issue new <title> [--body <text>|--body -|--body-file <path>|--message <text>] + [--kind delivery|decision] [--parent <ref>] [--fog <text>] [--status <status>] [--json] +loaf issue show <ref> [--json] +loaf issue tree [<ref>] [--archived] [--json] +loaf issue edit <ref> [--body-file <path>|--body -|--message <text>] [--json] +loaf issue export [--json] ``` -## Verification +`new` default kind is `delivery`; default status is `triage`. `--status` on +create still records the initial triage event, then writes the requested +write-status. `--fog` exists only on create. `edit` replaces the body; there +is no patch form. -Before marking `done`: - -1. Run the `verify` command from frontmatter -2. Check all acceptance criteria are checked -3. Ensure no regressions in related tests - -```bash -# Run task verification -verify_cmd=$(grep '^verify:' TASK-001-*.md | cut -d: -f2-) -eval "$verify_cmd" -``` +## LEGACY -## Local vs Linear Comparison - -| Feature | Local | Linear | -|---------|-------|--------| -| No external dependency | yes | no | -| Rich UI | no | yes | -| Team collaboration | git-based | native | -| Notifications | none | email/slack | -| Reporting | manual | built-in | -| Offline work | yes | limited | - -**Use local when:** -- Solo project -- No Linear access -- Offline development -- Simple task tracking - -**Use Linear when:** -- Team collaboration needed -- Rich workflow automation -- Integration with other tools -- Reporting requirements +`loaf task` and `loaf spec` remain readable against leftover SQLite rows. They +mint nothing new. Do not create records there. Issues are the work unit. diff --git a/plugins/loaf/skills/orchestration/references/parallel-agents.md b/plugins/loaf/skills/orchestration/references/parallel-agents.md index f4ef48ab2..35a07a7d6 100644 --- a/plugins/loaf/skills/orchestration/references/parallel-agents.md +++ b/plugins/loaf/skills/orchestration/references/parallel-agents.md @@ -117,7 +117,7 @@ When streams complete: | Command | Parallel Opportunity | |---------|---------------------| -| breakdown | Identify parallelizable tasks during decomposition | +| shape | Identify parallelizable child issues during decomposition | | implement | Single task, usually sequential | | implement | Runs dependency-aware orchestration, including parallel-safe tasks | diff --git a/plugins/loaf/skills/orchestration/references/script-surface.md b/plugins/loaf/skills/orchestration/references/script-surface.md index 70206ab63..474b76354 100644 --- a/plugins/loaf/skills/orchestration/references/script-surface.md +++ b/plugins/loaf/skills/orchestration/references/script-surface.md @@ -20,7 +20,7 @@ script surface: - The source currently has 10 orchestration scripts out of 22 skill-local scripts overall. -- Several scripts overlap existing `loaf journal`, `loaf task`, `loaf check`, +- Several scripts overlap existing `loaf journal`, `loaf issue`, `loaf check`, and Linear-aware behavior. - Shell/Python helpers are harder to discover than `loaf <noun> <verb>` and are not consistently covered by CLI tests. diff --git a/plugins/loaf/skills/orchestration/references/subagent-development.md b/plugins/loaf/skills/orchestration/references/subagent-development.md index 2d36b6175..d2c46e2a1 100644 --- a/plugins/loaf/skills/orchestration/references/subagent-development.md +++ b/plugins/loaf/skills/orchestration/references/subagent-development.md @@ -211,7 +211,7 @@ After subagent completes: | Command | Subagent Role | |---------|---------------| -| breakdown | Tasks become subagent assignments | +| shape | Promoted child issues become subagent assignments | | implement | May dispatch subagents for specialized work | | implement | Automatically coordinates single-task and multi-task subagent work | diff --git a/plugins/loaf/skills/orchestration/templates/journal.md b/plugins/loaf/skills/orchestration/templates/journal.md index caf7409fd..9a0a61f8f 100644 --- a/plugins/loaf/skills/orchestration/templates/journal.md +++ b/plugins/loaf/skills/orchestration/templates/journal.md @@ -13,7 +13,7 @@ not create or edit journal markdown as the source of truth — use ## Entries -[YYYY-MM-DD HH:MM] skill(implement): implementing TASK-042 +[YYYY-MM-DD HH:MM] skill(implement): implementing LOAF-42 [YYYY-MM-DD HH:MM] decision(scope): description of decision [YYYY-MM-DD HH:MM] discover(scope): something learned [YYYY-MM-DD HH:MM] block(scope): what is blocked diff --git a/plugins/loaf/skills/pitch/SKILL.md b/plugins/loaf/skills/pitch/SKILL.md index 766418c5d..a459abcee 100644 --- a/plugins/loaf/skills/pitch/SKILL.md +++ b/plugins/loaf/skills/pitch/SKILL.md @@ -1,15 +1,15 @@ --- name: pitch description: >- - Runs the human problem-discovery ceremony at change or project scale: grills - problem, who has it, current alternatives, value proposition, and constraints, - then authors a brief (change brief.md via loaf change init --brief, or project - docs/BRIEF.md with source: pitch). Use when the user invokes pitch, starts - work on a raw concept, or triage dispositions an item as pitch. Produces an - authored problem-space brief and a shape-now or park offer — never shape.md, - tasks, or PRs. Not for solution shaping (use shape), queue processing (use - triage), quick capture (use idea), or open-ended divergent inquiry (use - explore as an agent technique when pitch reveals the direction is undecided). + Runs the human problem-discovery ceremony: grills problem, who has it, current + alternatives, value proposition, and constraints, then hands a sharpened + problem narrative to shape or authors project docs/BRIEF.md. Use when the user + invokes pitch, starts work on a raw concept, or triage dispositions a spark or + idea as pitch. Produces a problem-space narrative and a shape-now or park + offer — never a bounded issue, criteria, or PRs. Not for quick capture (use + idea), solution bounding (use shape), queue processing (use triage), or + open-ended divergent inquiry (use explore as an agent technique when pitch + reveals the direction is undecided). user-invocable: true disable-model-invocation: true argument-hint: '[idea, problem, or intake item]' @@ -19,7 +19,7 @@ version: 0.2.21 # Pitch -Human problem-discovery ceremony. Authors a brief at the matching scale so shape starts from a framed problem and bootstrap can consume a pitched project BRIEF. +Human problem-discovery ceremony. Narrows sparks and ideas into a framed problem so shape can mint an issue, and bootstrap can consume a pitched project BRIEF. ## Contents - Critical Rules @@ -36,61 +36,90 @@ Human problem-discovery ceremony. Authors a brief at the matching scale so shape ## Critical Rules 1. **Agents never initiate a pitch.** This ceremony is human-invoked only. On Claude Code the sidecar sets `disable-model-invocation: true`; on every target this rule binds behaviorally. Agent legwork *inside* a human-opened pitch (competitive scans, file writes the skill directs) is fine — opening one is not. -2. **Log invocation first** — `loaf journal log "skill(pitch): <idea, problem, or intake item>"` before interviewing. -3. **Problem-space only** — grill what, who, and why-valuable. Approach, architecture, decomposition, and verification design belong to shape. A brief that reads like a pseudo-shape is a failure; rewrite before landing. -4. **One question at a time, recommendation-first** — using your harness's structured question tool if it has one (otherwise one inline question per message). Never a multi-field form. Order by impact on the brief. Full mechanics: [references/interview-guide.md](references/interview-guide.md). -5. **Never write `shape.md`, seed `tasks/`, push, or open PRs** — pitch prepares commits and hands off; push and PR stay human. Never auto-run shape or bootstrap. -6. **Landing is validated, then committed once** — every capture landing runs explicit-path `loaf change check <folder> --json` (zero violations, expected captured state) and a direct read-back of that folder's `change.json` confirming intended `target_release` presence or absence, then one docs-only commit per capture. Never batch captures into one commit. -7. **Slug identity is local** — propose a slug that names the concept, never another work unit (no `spec-042`, no task ids). Provenance lives in frontmatter and the change folder. -8. **Log the outcome** — `loaf journal log "decision(pitch): <slug or project> — <shape-now|park-targeted|park-untargeted|handed-to-bootstrap>"`. +2. **Log invocation first** — `loaf journal log "skill(pitch): <idea, problem, spark, or intake item>"` before interviewing. +3. **Problem-space only** — grill what, who, and why-valuable. Approach, architecture, decomposition, and verification design belong to shape. A narrative that reads like a pseudo-shape is a failure; rewrite before landing. +4. **One question at a time, recommendation-first** — using your harness's structured question tool if it has one (otherwise one inline question per message). Never a multi-field form. Order by impact on the narrative. Full mechanics: [references/interview-guide.md](references/interview-guide.md). +5. **Never bound, never ship** — do not add definition-of-done criteria, do not write an out-of-scope statement, do not run `loaf issue check` or `loaf issue promote`, do not push, do not open PRs. Never auto-run shape or bootstrap. +6. **Shape mints on the happy path** — same-session shape-now hands the authored narrative; shape runs `loaf issue new` with that body. Pitch writes an issue body only when parking an unshaped row or when `$ARGUMENTS` already names an issue (`loaf issue edit` replaces the body). +7. **Titles name the concept** — propose a working title, never another work unit's alias. Provenance lives in the issue row, the spark/idea resolution, and frontmatter on `docs/BRIEF.md`. +8. **Log the outcome** — `loaf journal log "decision(pitch): <title, ref, or project> — <shape-now|park-issue|park-idea|handed-to-bootstrap>"`. --- ## Verification -- Change scale: `docs/changes/YYYYMMDD-slug/` holds `change.json` + authored `brief.md`; `loaf change check <folder> --json` reports zero violations and captured state; `change.json` read-back matches the intended target binding -- Project scale: `docs/BRIEF.md` exists with `source: pitch` and the shared problem-space skeleton -- Cold-read: problem, who, alternative, and value nameable in one pass; zero solution-space content -- No `shape.md` or `tasks/` written by this skill; no push; no PR +- Issue-scale: a problem narrative exists against the shared skeleton; it was handed to shape, written into an existing issue body, or minted as an unshaped triage row with that body and no criteria +- Project-scale: `docs/BRIEF.md` exists with `source: pitch` and the shared problem-space skeleton +- Cold-read: problem, who, alternative, and value nameable in one pass; zero solution-space content; no out-of-scope statement and no criteria added by this skill +- Named sparks were promoted to an idea when pitching them; ideas and sparks were resolved against the issue only after a row exists +- No push; no PR; shape and bootstrap were not auto-run - Journal shows skill invocation and outcome entries --- ## Quick Reference +| Harness | Invoke skill | +|---------|----------------| +| Claude Code (plugin) | `/loaf:pitch` | +| OpenCode, Cursor, Codex, Amp | `/pitch` | + ### Scale detection | Signal | Scale | Output | |--------|-------|--------| -| Existing project (git history, source, or Loaf state) + a concept | **Change** | `loaf change init <slug> --brief` → authored `brief.md` | -| Empty or minimal directory / greenfield intent | **Project** | `docs/BRIEF.md` with `source: pitch` → hand to bootstrap | +| Existing project (git history, source, or Loaf state) + a concept | **Issue** | Problem narrative → shape (`loaf issue new --body`) or an unshaped triage row | +| Empty or minimal directory / greenfield product pitch | **Project** | `docs/BRIEF.md` with `source: pitch` → hand to bootstrap | Detect and confirm briefly; let the human correct. When both could apply (repo exists but they want a new product pitch), ask once. -### Landing matrix (Decision 11) +### Landing offers -| Intent | Branch | Commit | Target | -|--------|--------|--------|--------| -| **Shape now** | Create the slug branch (`git switch -c <slug>`), stay there | Hand to shape for in-place promotion — do not park-commit first | Stamp `target_release` when known | -| **Park targeted** | Default branch | One docs-only commit on default (promise-carrier exception) | `target_release` present and confirmed by read-back | -| **Park untargeted** | Slug branch **or** remain intake (Intent/spark) | Docs-only commit on the slug branch if becoming a Change; else no Change folder | No `target_release`; untargeted captures never land on main | +| Offer | When to recommend | What pitch does | +|-------|-------------------|-----------------| +| **Shape now** | Framing is solid; they want to bound next | Hand the narrative; do not mint; do not auto-run shape | +| **Park as issue** | Framed, durable, not bounding yet | `loaf issue new "<title>" --body -` with the narrative only; status stays `triage` | +| **Park as idea** | Too thin to keep as a row, or might discard | `loaf idea capture --title "..."`; journal the gist | +| **Hand to bootstrap** | Project-scale BRIEF authored | Point at bootstrap; do not auto-run it | -Pitch prepares the commit; never pushes; never opens PRs. +Pitch never pushes; never opens PRs. There is nothing to commit at issue scale — the row lives in SQLite. Project-scale may commit `docs/BRIEF.md` if the human wants it durable. -### Pre-landing guard (every capture) +### Spark and idea promotion -```bash -loaf change check <folder> --json # zero violations; state is captured -# then read <folder>/change.json and confirm target_release presence/absence matches intent +| Input | Read | Then | +|-------|------|------| +| Spark | `loaf spark show <ref>` | `loaf idea capture --title "..."` then `loaf spark promote <spark> --to-idea <idea>`; grill from the idea | +| Idea | `loaf idea show <ref>` | Grill; after a row exists, `loaf idea resolve <idea> --by <ref>` | +| Existing issue | `loaf issue show <ref>` | Grill; `loaf issue edit <ref> --body -` writes the narrative (replaces the whole body) | +| Free text | — | Grill; shape-now hands text; park captures an idea or mints an unshaped row | + +Do not invent a pitch from the queue without human selection. When they name an intake item, read it (`loaf intake list` / the item's read command). + +`loaf idea promote --to-spec` is not this path. Resolve ideas against the minted issue. + +### Problem-narrative skeleton + +Author against these sections, problem-space sentences only. This text is what shape puts in `--body` (or what a park-as-issue row stores): + +```markdown +## Problem Statement +## Who Has It +## Current Alternatives +## Value Proposition +## Constraints +## Sequencing and Relationships +## Sources and Research Links +## Open Questions ``` -Bare `loaf change check` resolves by branch and can miss a capture landing elsewhere — always pass the explicit folder path. +Do not add an out-of-scope statement. Shape bounds; pitch frames. ### Defined terms -- **Brief** — the pitch output (problem-space). Superseded by `shape.md` when shaping starts; may accrete parked problem-space sentences until then; freezes when `shape.md` exists. -- **Accretion** — adding problem-space concepts to a parked brief is legal; solution prose is not. -- **Shape now** — slug branch + hand to shape, which promotes the capture in place via ordinary `loaf change init <slug>` (no `--brief`). +- **Problem narrative** — pitch's issue-scale output. Superseded as the working surface once shape mints and bounds the issue; may accrete parked problem-space sentences until then. +- **BRIEF** — project-scale `docs/BRIEF.md`. A project document, not a work container. +- **Accretion** — adding problem-space concepts to a parked narrative is legal; solution prose is not. +- **Shape now** — hand the narrative to shape, which mints via `loaf issue new` and owns bounding. --- @@ -99,81 +128,82 @@ Bare `loaf change check` resolves by branch and can miss a capture landing elsew ### Step 1: Log and parse input ```bash -loaf journal log "skill(pitch): <idea, problem, or intake item>" +loaf journal log "skill(pitch): <idea, problem, spark, or intake item>" ``` -Parse `$ARGUMENTS`: free text, an intake ref the human already chose, or empty (ask what to pitch). Read the named intake item when provided (`loaf intake list` / the item's read command). Do not invent a pitch from the queue without human selection. +Parse `$ARGUMENTS`: free text, a spark, an idea, an issue ref, an intake ref the human already chose, or empty (ask what to pitch). Read the named item when provided. Do not invent a pitch from the queue without human selection. ### Step 2: Detect scale -Apply the Quick Reference table. Confirm: "I'll treat this as a **change-scale** pitch on this repo" or "…as a **project-scale** pitch for a new BRIEF." Adjust if corrected. +Apply the Quick Reference table. Confirm: "I'll treat this as an **issue-scale** pitch on this repo" or "…as a **project-scale** pitch for a new BRIEF." Adjust if corrected. -### Step 3: Problem-discovery interview +### Step 3: Promote sparks; read ideas + +When the named input is a spark, promote it to an idea before grilling so the capture trail is one idea, not a dangling spark: + +```bash +loaf idea capture --title "<working title>" +loaf spark promote <spark> --to-idea <idea> +``` + +When the named input is already an idea, `loaf idea show` and grill. Leave resolution until an issue row exists. + +### Step 4: Problem-discovery interview Run the interview per [references/interview-guide.md](references/interview-guide.md): -- Pin a one-or-two-line **destination** before dimension grilling (fixes brief scope; project scale feeds VISION success criteria, change scale sharpens the eventual Hypothesis) +- Pin a one-or-two-line **destination** before dimension grilling (fixes narrative scope; project scale feeds VISION success criteria; issue scale sharpens what good looks like for the row) - Dimensions: problem, who has it, current alternatives / competitive landscape, value proposition, constraints (plus sequencing and open questions when needed) - Depth: scenario stress-testing, challenge stance, glossary-term hygiene; open questions must pass the specifiability test and carry HITL/AFK tags when precise - Applicability judgment: skip formal competitive analysis and deep personas when the pitch kind does not warrant them (bug fixes, internal chores) -- One question at a time, recommendation-first, ordered by brief impact +- One question at a time, recommendation-first, ordered by narrative impact - Stop on exit criteria or when answers stop changing the framing -If the direction is genuinely undecided mid-interview, offer the **explore** technique (agent-side; Explorations and checkpoints) rather than forcing a false brief. Pitch remains the human front door; explore is not re-opened as a slash alternative by this skill. +If the direction is genuinely undecided mid-interview, offer the **explore** technique (agent-side; Explorations and checkpoints) rather than forcing a false narrative. Pitch remains the human front door; explore is not re-opened as a slash alternative by this skill. -### Step 4: Evidence delegation (when warranted) +### Step 5: Evidence delegation (when warranted) -When competitive or landscape facts would change the brief and are not already known: +When competitive or landscape facts would change the narrative and are not already known: 1. Spawn a **researcher** subagent with a bounded question (competitors, substitutes, prior art — not solution design). 2. Land evidence: - - **Change scale:** files under the change folder's `research/` (create the folder with the change); link from Sources and Research Links - - **Project scale:** inline source links in `docs/BRIEF.md` Sources and Research Links (no change `research/` yet) -3. Resume the interview or brief draft with recommendations informed by the scan. + - **Issue scale:** source links in the narrative's Sources and Research Links. If a longer scan lands on disk, name it for the landscape, never for the work unit, and cite it from Sources. + - **Project scale:** inline source links in `docs/BRIEF.md` Sources and Research Links. +3. Resume the interview or draft with recommendations informed by the scan. Never fabricate competitive claims. If a scan is skipped, say so in Sources ("no external scan; alternative is internal workaround X"). -### Step 5a: Change-scale ceremony - -1. **Propose a slug** — lowercase, digits, single hyphens; names the concept locally. Confirm with the human. -2. **Initialize capture:** - - ```bash - loaf change init <slug> --brief - ``` - - Creates `docs/changes/YYYYMMDD-<slug>/` with `change.json` + `brief.md` scaffold only. -3. **Author `brief.md`** against the shared problem-space skeleton (shape's brief template / the scaffold just written): Problem Statement, Who Has It, Current Alternatives, Value Proposition, Constraints, Sequencing and Relationships, Sources and Research Links, Open Questions. Problem-space sentences only. -4. **Stamp `target_release` when known** — edit `change.json` with canonical `MAJOR.MINOR.PATCH` (no `v`, no prerelease). Omit the field when untargeted. Confirm with the human before stamping. -5. **Accretion note** — tell the human: parked problem-space concepts may accrete into this brief until shaping starts; once `shape.md` exists the brief freezes. -6. **Cold-read** the brief (interview guide test); revise with the human until it passes. -7. **Offer landing** (recommendation-first): +### Step 6a: Issue-scale ceremony - | Offer | When to recommend | - |-------|-------------------| - | **Shape now** | Framing is solid and they want to bound implementation next | - | **Park targeted** | Bound to a release cohort but not shaping yet — docs-only on default branch | - | **Park untargeted** | Worth capturing off-main, or not ready as a Change (stay intake) | +1. **Propose a working title** — names the concept locally. Confirm with the human. This becomes shape's `loaf issue new` title (or the park-as-issue title). +2. **Author the problem narrative** against the skeleton above. Problem-space sentences only. +3. **Accretion note** — tell the human: parked problem-space concepts may accrete until shaping starts; once the issue is minted, the body is the home. +4. **Cold-read** the narrative (interview guide test); revise with the human until it passes. +5. **Offer landing** (recommendation-first) using the Landing offers table. +6. **Execute the chosen landing:** -8. **Execute the chosen landing:** + - **Shape now:** hand the full narrative and any spark/idea refs. Shape runs `loaf issue new "<title>" --body -` (or `--body-file`) with that text. Do not mint, do not add criteria, do not open a PR. After shape mints, resolve intake: `loaf idea resolve <idea> --by <ref>` (and `loaf spark resolve <spark> --by <ref>` only if the spark was never promoted). + - **Park as issue:** mint the unshaped row yourself, then resolve intake against it: - - **Shape now:** `git switch -c <slug>` (from default unless already on a working branch the human prefers), ensure pre-landing guard would pass if they later park, hand to shape with the folder path — shape promotes in place. Do not open a PR. - - **Park targeted:** on the **default branch**, run pre-landing guard on the explicit folder, confirm `target_release` present in `change.json`, then one docs-only commit of the change folder (and any `research/` under it). - - **Park untargeted as Change:** `git switch -c <slug>`, pre-landing guard, confirm `target_release` **absent**, one docs-only commit on the slug branch. - - **Park as intake:** do not leave a half-written change folder; prefer Intent/spark retention and delete or never create the capture if the human backs out. + ```bash + loaf issue new "<title>" --body - + loaf idea resolve <idea> --by <ref> + ``` -9. **Commit message** (when parking): conventional, e.g. `docs(change): capture <slug> brief` — one commit per capture. + Paste the narrative on stdin. Do not add criteria. Do not write out-of-scope. Default status is `triage`. Read back with `loaf issue show <ref>`. + - **Park as idea:** if no idea exists yet, `loaf idea capture --title "<title>"`. Journal the gist (`loaf journal log "discover(pitch): <one-line problem>"`). Do not mint an issue. + - **Existing issue:** `loaf issue edit <ref> --body -` with the full narrative. Edit replaces the body; do not strip a row that is already bounded — if criteria already exist, hand the narrative to the human and let shape merge. -10. **Closing ceremony (required — never trail off).** After the landing is executed (or intake retained), announce completion with a full closing block: +7. **Closing ceremony (required — never trail off).** After the landing is executed, announce completion with a full closing block: - - **Recap the brief** — section-by-section gist (Problem, Who, Alternatives, Value, Constraints, Sequencing, Open Questions). Name the change folder path (`docs/changes/YYYYMMDD-<slug>/`) and what it holds (`change.json` + `brief.md`, plus any `research/`). + - **Recap the narrative** — section-by-section gist (Problem, Who, Alternatives, Value, Constraints, Sequencing, Open Questions). Name where it lives (handed to shape, unshaped issue `<ref>`, idea `<ref>`, or the conversation plus journal gist). - **Restate the landing actually taken** and what it means next: - - **Shape now** — you are on the slug branch; run shape next to promote the capture in place and bound implementation. No park-commit was made. - - **Park targeted** — the capture is a docs-only commit on the default branch with `target_release` stamped; it sits as a promise carrier for that cohort until shape is invoked later. - - **Park untargeted** — the capture lives on the slug branch (or remains intake) without `target_release`; it is off-main until retargeted or shaped. If intake-only, name the Intent/spark and that no change folder was left half-written. + - **Shape now** — run shape next to mint the issue from this narrative and bound implementation. No row was minted here. + - **Park as issue** — `<ref>` holds the problem in its body and is unshaped; run shape later on that ref. + - **Park as idea** — the idea remains open; re-invoke pitch or shape when ready. Name the idea ref. - **Announce completion** in plain language: "Pitch is complete." Do not end on a dangling offer or an unfinished sentence. -### Step 5b: Project-scale ceremony +### Step 6b: Project-scale ceremony 1. **Author `docs/BRIEF.md`** using bootstrap's brief skeleton with frontmatter: @@ -185,31 +215,31 @@ Never fabricate competitive claims. If a scan is skipped, say so in Sources ("no --- ``` - Same problem-space sections as change scale, at project altitude (Sequencing describes the initial arc as prose). + Same problem-space sections as issue scale, at project altitude (Sequencing describes the initial arc as prose). 2. **Cold-read** and revise with the human. 3. Optionally commit `docs/BRIEF.md` if the human wants it durable before bootstrap; still no push unless they ask outside this skill's duties — pitch itself never pushes. 4. **Closing ceremony (required — never trail off).** Announce completion with a full closing block — do not hand off in a half-sentence: - **Recap what was authored** — section-by-section gist of the BRIEF (Problem Statement, Who Has It, Current Alternatives, Value Proposition, Constraints, Sequencing and Relationships, Sources and Research Links, Open Questions). One or two sentences per section is enough; the human should hear what landed without reopening the file. - **Artifact path** — name `docs/BRIEF.md` explicitly, including that frontmatter carries `source: pitch`. - - **Explicit handoff** — state, verbatim in spirit: next, run bootstrap: it reads this BRIEF as discovery-done (`source: pitch`), interviews only on gaps, populates the operating documents (VISION, STRATEGY, ARCHITECTURE, AGENTS), and closes by proposing your initial arc of captured changes. Do not auto-run bootstrap. + - **Explicit handoff** — state, verbatim in spirit: next, run bootstrap: it reads this BRIEF as discovery-done (`source: pitch`), interviews only on gaps, and populates the operating documents (VISION, STRATEGY, ARCHITECTURE, AGENTS). Do not auto-run bootstrap. - **Announce completion** in plain language: "Pitch is complete." The ceremony ends with a period, never a trail-off. -### Step 6: Log the outcome +### Step 7: Log the outcome ```bash -loaf journal log "decision(pitch): <slug or project> — <shape-now|park-targeted|park-untargeted|handed-to-bootstrap>" +loaf journal log "decision(pitch): <title, ref, or project> — <shape-now|park-issue|park-idea|handed-to-bootstrap>" ``` -The journal line is mechanical; the human-facing close is the closing ceremony in Step 5a/5b. Never log-and-stop without that recap and next-step restatement. +The journal line is mechanical; the human-facing close is the closing ceremony in Step 6a/6b. Never log-and-stop without that recap and next-step restatement. --- ## Related Skills -- **shape** — solution-space narrowing from an existing brief (or full narrowing when no brief); promotes capture folders in place -- **bootstrap** — consumes `docs/BRIEF.md` (`source: pitch` → gap interview) and series-preps captured changes -- **triage** — queue dispositions; may hand an item to pitch when problem discovery is needed +- **shape** — solution-space bounding; mints the issue from the problem narrative (`loaf issue new`) and owns criteria, out-of-scope, and decomposition +- **bootstrap** — consumes `docs/BRIEF.md` (`source: pitch` → gap interview) and populates operating documents +- **triage** — queue dispositions; may hand a spark or idea to pitch when problem discovery is needed - **explore** — agent-side technique when pitch finds the direction still undecided - **idea** — quick capture without ceremony; not a substitute for pitch - **research** — patterns the researcher subagent follows for landscape scans @@ -222,4 +252,4 @@ The journal line is mechanical; the human-facing close is the closing ceremony i ## Artifact Naming -Name every artifact for what it is, never for the work unit that produced it. The change folder already records provenance. Put source in frontmatter, not the filename. See the `foundations` skill; `loaf check --hook artifact-names` enforces it at commit. +Name every on-disk artifact for what it is, never for the work unit that produced it. The issue row or `docs/BRIEF.md` already records provenance. Put source in frontmatter, not the filename. See the `foundations` skill; `loaf check --hook artifact-names` enforces it at commit. diff --git a/plugins/loaf/skills/pitch/references/interview-guide.md b/plugins/loaf/skills/pitch/references/interview-guide.md index 8014082fe..65780fb10 100644 --- a/plugins/loaf/skills/pitch/references/interview-guide.md +++ b/plugins/loaf/skills/pitch/references/interview-guide.md @@ -11,7 +11,7 @@ Problem-discovery interview for pitch. Borrows shape's grilling mechanics (one q - Open Questions: Specifiability and HITL/AFK - Exit Criteria - Anti-Patterns -- Brief Cold-Read +- Problem Cold-Read ## How This Guide Works @@ -23,22 +23,22 @@ The interview is adaptive, not a form. Strong answers skip dimensions; weak answ ## Destination Pinning -Before dimension grilling, converge on a **destination**: one or two lines naming the end state this pitch is aiming at — what becomes true in the world if the work succeeds. Pin it early; it fixes the brief's scope for the rest of the interview. +Before dimension grilling, converge on a **destination**: one or two lines naming the end state this pitch is aiming at — what becomes true in the world if the work succeeds. Pin it early; it fixes the narrative's scope for the rest of the interview. | Scale | Destination feeds | |-------|-------------------| | **Project** | VISION success criteria (bootstrap extracts it; pitch keeps it as the project's north star in the BRIEF) | -| **Change** | The eventual Hypothesis when shape promotes the capture — a sharper "what good looks like" than a feature list | +| **Issue** | What good looks like for the work — the problem statement shape will put in the issue body | **How to pin:** offer a recommendation-first draft from the human's opening words ("Destination: operators can ship a release without a manual config audit"). Confirm, tighten, or rewrite until both parties can restate it. Do not start deep dimension probes until the destination is on the table. -If the destination keeps shifting mid-interview, pause and re-pin — a moving destination means the brief's scope is still open. +If the destination keeps shifting mid-interview, pause and re-pin — a moving destination means the narrative's scope is still open. --- ## Problem-Discovery Dimensions -Grill these five dimensions. Order by what would change the brief most — a wrong "who" invalidates value and alternatives; cosmetic naming goes last. +Grill these five dimensions. Order by what would change the narrative most — a wrong "who" invalidates value and alternatives; cosmetic naming goes last. ### 1. Problem @@ -62,17 +62,17 @@ What do they do today? Existing tools, manual workarounds, cobbled scripts, or " Why is solving this worth it? What becomes true for the people who have the problem if this lands? One line: different AND better relative to the alternative — not a feature list, not an architecture sketch. -**Applicability:** always name value, but depth scales. A product pitch needs a crisp UVP; a small change can be "removes the weekly fire-drill so release day is boring." +**Applicability:** always name value, but depth scales. A product pitch needs a crisp UVP; a small internal pitch can be "removes the weekly fire-drill so release day is boring." ### 5. Constraints -Non-negotiable bounds: technical, legal, organizational, philosophical, sequencing. Things that limit the solution space before design begins. Capture as problem-space limits ("must not break the promise-carrier exception"), never as chosen designs ("use Postgres"). +Non-negotiable bounds: technical, legal, organizational, philosophical, sequencing. Things that limit the solution space before design begins. Capture as problem-space limits ("must not add a new human ceremony to the weekly path"), never as chosen designs ("use Postgres"). **Always ask lightly:** at least one real constraint or an explicit "none known yet." ### Secondary (only when signal demands) -- **Sequencing and relationships** — how this hangs with other work, release cohort as prose, series order. No machine relation fields. +- **Sequencing and relationships** — how this hangs with other work, series order. No machine relation fields. - **Open questions** — unresolved problem-space items that pass the [specifiability test](#open-questions-specifiability-and-hitlafk); each tagged HITL or AFK. - **Evidence of pain** — money, time, workarounds (Mom Test lens). When absent and the claim is large, challenge gently. @@ -105,13 +105,13 @@ Every question includes a recommended answer and a short rationale. The human ov Example shape: > **Who has this problem most often?** -> Recommendation: mid-size platform engineers who already run multi-service deploys — they feel config drift weekly. Rationale: your examples all came from that world; broader "all developers" would dilute the brief. +> Recommendation: mid-size platform engineers who already run multi-service deploys — they feel config drift weekly. Rationale: your examples all came from that world; broader "all developers" would dilute the narrative. ### Ordering -Prioritize answers that would rewrite the brief. Typical order: **destination pin** → problem → who → alternatives → value → constraints → sequencing/open questions. Reorder when the human's first utterance already settles an early dimension. +Prioritize answers that would rewrite the narrative. Typical order: **destination pin** → problem → who → alternatives → value → constraints → sequencing/open questions. Reorder when the human's first utterance already settles an early dimension. -Before asking, check whether reading resolves it — journal, prior Change, intake item body, BRIEF. Only ask what reading could not answer. +Before asking, check whether reading resolves it — journal, prior issue, intake item, BRIEF. Only ask what reading could not answer. ### Adaptive depth @@ -120,12 +120,12 @@ Before asking, check whether reading resolves it — journal, prior Change, inta | Crisp, specific answers | Confirm, move on; skip expand-if-needed probes | | Category answers ("developers need better tools") | Ask for a concrete story or last painful moment | | Solution-first ("I want a CLI that…") | Pause; reframe to problem and who | -| Energy dropping | Cut to synthesis; a brief with named gaps beats an exhausted interrogation | -| Direction genuinely undecided | Offer the explore technique from inside pitch; do not force a false brief | +| Energy dropping | Cut to synthesis; a narrative with named gaps beats an exhausted interrogation | +| Direction genuinely undecided | Offer the explore technique from inside pitch; do not force a false narrative | ### Scenario stress-testing -Probe the problem with **concrete lived scenarios**, not abstract categories. Prefer "walk me through the last time this bit you" over "how often does this happen?" Invent edge cases deliberately to force boundary precision: "What if the operator is on-call at 2am with half the logs missing — is that still this problem, or a different one?" Scenarios that collapse the framing expose missing constraints or a second concept that should stay out of this brief. +Probe the problem with **concrete lived scenarios**, not abstract categories. Prefer "walk me through the last time this bit you" over "how often does this happen?" Invent edge cases deliberately to force boundary precision: "What if the operator is on-call at 2am with half the logs missing — is that still this problem, or a different one?" Scenarios that collapse the framing expose missing constraints or a second concept that should stay out of this narrative. ### Challenge stance @@ -133,12 +133,12 @@ Demand specificity over generalization. Probe the rationale behind claims ("why Also enforce **canonical language** during the interview: -- Flag terms that conflict with `docs/knowledge/glossary.md` usage (e.g. calling a Change a "spec," a release cohort a "milestone," a skill a "module"). Offer the glossary term and get the human to accept the swap before it lands in the brief. +- Flag terms that conflict with `docs/knowledge/glossary.md` usage (e.g. calling an issue a "ticket," a skill a "module"). Offer the glossary term and get the human to accept the swap before it lands in the narrative. - Sharpen fuzzy project-local terms to a single canonical choice mid-interview ("you said both 'capture' and 'ticket' — pick one and stick to it"). Ambiguous vocabulary becomes solution fog later. ### Mid-interview evidence -When competitive landscape or external facts would change the brief and the human lacks them, pause the grill, delegate a researcher subagent, land evidence (change-scale: `research/` in the change folder; project-scale: links in Sources), then resume with a recommendation informed by the scan. Evidence supports framing; it does not become solution design. +When competitive landscape or external facts would change the narrative and the human lacks them, pause the grill, delegate a researcher subagent, land evidence (issue-scale: source links in the narrative; project-scale: links in Sources), then resume with a recommendation informed by the scan. Evidence supports framing; it does not become solution design. --- @@ -146,11 +146,11 @@ When competitive landscape or external facts would change the brief and the huma ### Specifiability test -An open question earns a **precise entry** in the brief only if it can be **stated precisely now** — not answered now. The bar is: a later reader (human or agent) could work the question without inventing what was meant. +An open question earns a **precise entry** in the narrative only if it can be **stated precisely now** — not answered now. The bar is: a later reader (human or agent) could work the question without inventing what was meant. | Passes (precise entry) | Fails (coarse note only) | |------------------------|--------------------------| -| "Does the operator need multi-region failover in v1, or is single-region acceptable for the first cohort?" | "Figure out reliability stuff" | +| "Does the operator need multi-region failover in v1, or is single-region acceptable until the first cut?" | "Figure out reliability stuff" | | "Which existing CLI command is the migration source of truth for config paths?" | "TBD on integration" | Everything vaguer stays a **coarse note** in Open Questions or Sequencing prose — never pre-sliced into fake precision. Do not invent enumerated options the human did not surface. @@ -164,7 +164,7 @@ Mark each precise open question with one of: | **HITL** | Needs the human live — judgment, taste, organizational call, or access only they hold | "Will legal accept the data-retention tradeoff?" | | **AFK** | Runnable by an agent without the human in the loop — research, codebase scan, competitive lookup | "What do the top three substitutes charge for the free tier?" | -Briefs carry the tag inline in the Open Questions section, e.g. `- [HITL, blocking] …` or `- [AFK, deferrable] …`. Blocking vs deferrable remains orthogonal: a HITL question can be deferrable; an AFK question can still block shaping if the answer rewrites scope. +Narratives carry the tag inline in the Open Questions section, e.g. `- [HITL, blocking] …` or `- [AFK, deferrable] …`. Blocking vs deferrable remains orthogonal: a HITL question can be deferrable; an AFK question can still block shaping if the answer rewrites scope. --- @@ -180,13 +180,13 @@ Stop interviewing when all of the following hold (or the human explicitly wants 6. **Constraints** are listed or explicitly empty. 7. Answers have stopped changing the framing — the last questions confirmed rather than rewrote. 8. Open questions that remain pass the specifiability test (or are coarse notes) and carry HITL/AFK tags when precise. -9. A cold reader could pass the brief cold-read test below. +9. A cold reader could pass the problem cold-read test below. -Open questions may remain; mark each blocking or deferrable, and HITL or AFK when precise. Blocking problem-space unknowns belong in the brief's Open Questions, not as invented answers. +Open questions may remain; mark each blocking or deferrable, and HITL or AFK when precise. Blocking problem-space unknowns belong in the narrative's Open Questions, not as invented answers. ### The pivot -Do not announce "the interview is over." Shift: "I think I have enough to draft the brief — tell me what I got wrong." Author the brief against the shared skeleton, then section-review with the human before any init or landing step. The **closing ceremony** (recap, path, next step) is owned by SKILL.md after landing — this guide owns interview depth only. +Do not announce "the interview is over." Shift: "I think I have enough to draft the problem narrative — tell me what I got wrong." Author the narrative against the shared skeleton, then section-review with the human before any mint or landing step. The **closing ceremony** (recap, path, next step) is owned by SKILL.md after landing — this guide owns interview depth only. --- @@ -196,7 +196,7 @@ Adopted from bootstrap's interview guide; binding on pitch. **The Form.** Running dimensions mechanically like a survey. If answer 2 covers dimension 4, confirm and skip. -**The 45-Minute Interrogation.** If energy drops, cut to draft. Gaps in the brief are honest; drained enthusiasm is not recoverable in the same session. +**The 45-Minute Interrogation.** If energy drops, cut to draft. Gaps in the narrative are honest; drained enthusiasm is not recoverable in the same session. **The Therapist.** Do not explore the builder's feelings about the product. User emotions (switching forces, pain) matter; builder therapy does not. @@ -212,7 +212,7 @@ Adopted from bootstrap's interview guide; binding on pitch. **Third Interview Idiom.** Do not invent pitch-specific interview machinery. Destination pinning, scenario stress-testing, and challenge stance deepen the same grilling mechanics — they are not a parallel framework. -**Pseudo-Shape in the Brief.** Approach, architecture, task breakdown, or verification design must not enter `brief.md` / `docs/BRIEF.md`. If it appears while drafting, move it out and note it for shape. +**Pseudo-Shape in the Narrative.** Approach, architecture, decomposition, or verification design must not enter the problem narrative or `docs/BRIEF.md`. If it appears while drafting, move it out and note it for shape. **Fake Precision.** Pre-slicing vague unknowns into numbered open questions that cannot yet be stated precisely. Coarse notes beat counterfeit clarity. @@ -220,9 +220,9 @@ Adopted from bootstrap's interview guide; binding on pitch. --- -## Brief Cold-Read +## Problem Cold-Read -Before offering shape-now or park, cold-read the authored brief. A stranger should name, in one pass: +Before offering shape-now or park, cold-read the authored narrative (issue-scale) or `docs/BRIEF.md` (project-scale). A stranger should name, in one pass: 1. The **destination** (or success end-state) 2. The **problem** @@ -230,4 +230,4 @@ Before offering shape-now or park, cold-read the authored brief. A stranger shou 4. The **current alternative** 5. The **value** of solving it -…and find **zero solution-space content** (no approach, stack, API shape, or task list). Precise open questions should carry HITL/AFK tags. If any of the five is missing or solution prose creeps in, revise before landing. +…and find **zero solution-space content** (no approach, stack, API shape, or implementation slices). Precise open questions should carry HITL/AFK tags. If any of the five is missing or solution prose creeps in, revise before landing. diff --git a/plugins/loaf/skills/refactor-deepen/SKILL.md b/plugins/loaf/skills/refactor-deepen/SKILL.md index 5a986ad8e..91180769b 100644 --- a/plugins/loaf/skills/refactor-deepen/SKILL.md +++ b/plugins/loaf/skills/refactor-deepen/SKILL.md @@ -175,7 +175,7 @@ skill terminates by writing a PLAN file using [templates/plan.md](templates/plan > Plan saved to `.agents/plans/<filename>.md`. Workflow handoff is pending > the SPEC/PLAN/TASKS artifact taxonomy spec — for now, decide manually. -Do **not** recommend breakdown or implement as the next step. The +Do **not** recommend implement as the next step. The handoff design is downstream of a deferred taxonomy spec. ### Codex Review (Opt-In, Plugin-Gated) diff --git a/plugins/loaf/skills/refactor-deepen/templates/plan.md b/plugins/loaf/skills/refactor-deepen/templates/plan.md index 8be684590..0fe537186 100644 --- a/plugins/loaf/skills/refactor-deepen/templates/plan.md +++ b/plugins/loaf/skills/refactor-deepen/templates/plan.md @@ -28,8 +28,8 @@ write a new file rather than updating an existing one. | `title` | Yes | One-line description of the deepening, not the candidate name | | `created` | Yes | ISO 8601 UTC, e.g. `2026-05-02T01:30:00Z` (must match the filename timestamp) | | `status` | Yes | `drafting` on first write; this template does not define additional lifecycle states | -| `spec` | Yes | `SPEC-NNN` if the plan is scoped under a spec; `null` otherwise (do not omit the key) | -| `related` | No | List of related artifact IDs (`ADR-*`, `SPEC-*`, idea filenames, other plan filenames) | +| `issue` | Yes | `LOAF-NNN` if the plan is scoped under an issue; `null` otherwise (do not omit the key) | +| `related` | No | List of related artifact IDs (`ADR-*`, `LOAF-*`, idea filenames, other plan filenames) | PLAN files do **not** carry an `id` frontmatter field. The filename is the identity, mirroring councils and ideas. @@ -131,7 +131,7 @@ Filename: `.agents/plans/20260502-013000-deepen-journal-append.md` title: "Deepen journal append into a self-managing module" created: "2026-05-02T01:30:00Z" status: drafting -spec: SPEC-034 +issue: LOAF-34 related: - 20260501-231922-plan-lifecycle-cli-doctor-housekeeping --- diff --git a/plugins/loaf/skills/reflect/SKILL.md b/plugins/loaf/skills/reflect/SKILL.md index e6c06e326..14cead0d3 100644 --- a/plugins/loaf/skills/reflect/SKILL.md +++ b/plugins/loaf/skills/reflect/SKILL.md @@ -7,7 +7,7 @@ description: >- experience. Not for pre-implementation strategy (use strategy) or ADRs (use architecture). user-invocable: true -argument-hint: '[SPEC-ID or topic]' +argument-hint: '[issue ref or topic]' version: 0.2.21 --- @@ -83,12 +83,12 @@ After completing work, reflect extracts learnings and proposes updates to strate ### Step 1: Parse Input -`$ARGUMENTS` can be: a spec ID (`SPEC-001`), a topic ("authentication learnings"), or empty (general reflection on recent work). +`$ARGUMENTS` can be: an issue ref (`LOAF-42`), a topic ("authentication learnings"), or empty (general reflection on recent work). ### Step 2: Gather Evidence Sources: -1. **Completed specs** (`.agents/specs/SPEC-*.md` with status `done`; legacy files may still read `complete`) -- look for "Lessons Learned" +1. **Completed issues** (`loaf issue list` / `loaf issue show <ref>` with status `done`) -- look for lessons in the issue body 2. **Project journal** (`loaf journal recent --json`, `loaf journal search <topic>`) -- insights, surprises, pivots 3. **Recent commits** (`git log --oneline -30`) 4. **Implementation reality** -- what was harder/easier than expected? What assumptions were wrong? diff --git a/plugins/loaf/skills/release/SKILL.md b/plugins/loaf/skills/release/SKILL.md index fdffa0ef2..81bdc8a6d 100644 --- a/plugins/loaf/skills/release/SKILL.md +++ b/plugins/loaf/skills/release/SKILL.md @@ -1,12 +1,11 @@ --- name: release description: >- - Orchestrates standalone releases from already-landed work: release readiness, - version selection, changelog curation, release commit, tag, GitHub Release, - install verification, and post-release follow-up. Use when the user says "cut - a release," "publish a version," "release from main," or asks whether enough - landed work should become a release. Not for reviewing or merging a PR (use - ship). + Cuts a retroactive release from already-landed issues: loaf release suggest + reports the range, loaf release cut records the version. Use when the user + says "cut a release," "publish a version," "release from main," or asks what + landed since the last tag. Produces a recorded release row and members as + facts. Not for reviewing or merging a PR (use ship). user-invocable: true argument-hint: '[version, base, or release intent]' version: 0.2.21 @@ -14,22 +13,17 @@ version: 0.2.21 # Release -Publish a coherent version from work that has already landed. +Cut a version from work that has already landed. ## Contents - Critical Rules - Verification - Quick Reference - Topics -- Context Detection -- Step 1: Release Readiness -- Step 2: Change Collection -- Step 3: Version + Changelog -- Step 4: Release Execution -- Step 5: Release-PR Flow -- Step 6: Publication Verification -- Step 7: Post-Release Follow-Up -- Hook Interaction +- Process +- Attribution +- Bump derivation +- Must-contain convention - Related Skills **Input:** $ARGUMENTS @@ -38,259 +32,227 @@ Publish a coherent version from work that has already landed. ## Critical Rules -- **Release is not merge** -- do not use release to review, approve, or land a feature PR. Use ship for PR correctness and landing. -- **Release from landed work** -- collect changes from the release base branch, normally the repo default branch, since the last release tag. -- **Release-PR flow is the default** -- prepare on a release branch with `loaf release --pre-merge`, squash-merge the release PR, then finalize with `loaf release --post-merge` on the base branch. Direct `--bump` on the base branch is a named exception used only on explicit user request. -- **Batch by intent** -- group release notes by user-facing outcome, `CR-*` change bundle, spec, or related PRs; do not mirror individual commits mechanically. -- **Keep landed and released distinct** -- a PR may be landed without being released; a release may contain multiple landed PRs. -- **Block on release-readiness failure** -- do not publish if build, tests, version files, changelog, tag, or GitHub release state is inconsistent. -- **Never push, tag, or publish without confirmation** -- present the exact actions first. -- **Use your harness's structured question tool (if it has one) for release decisions** -- version bump type, release PR handoff, push/tag/GitHub Release confirmation. -- **Log release** -- after publication, run `loaf journal log "decision(release): vX.Y.Z published from <base> with <summary>"`. +1. **Log invocation first** — `loaf journal log "skill(release): <what is being cut or suggested>"` before doing anything else. +2. **Release is not merge** — do not review, approve, or land a PR here. Verification authority is the ship workflow (PR review and CI at merge). If the user is asking to merge, stop and route to ship. +3. **A release is cut from what landed** — the surface is `loaf release suggest` and `loaf release cut`. Do not run unsubcommmanded `loaf release`, `--pre-merge`, or `--post-merge`; this skill does not own that path. +4. **Suggest writes nothing** — it reads `baseline-tag..HEAD` (or `--base <ref>..HEAD`), attributes commits to issues, rolls up through parents, reports partially-landed parents and unattributed commits as information, derives the bump, reports the advisory bucket delta, and drafts notes. +5. **Cut records facts** — it applies the version, prepends the drafted notes into `CHANGELOG.md`, tags, records the release row plus members, then attempts a GitHub Release draft. A `gh` failure degrades to a warning with a paste-ready retry command; the recorded row stays. +6. **No forward version stamp** — do not bind an issue to a future version. Members are what already landed. Buckets (`loaf issue bucket`) are advisory labels; planned-vs-landed is information only. +7. **No suite, no re-record, no publication stop in this skill** — ship already verified the merged work. Cut's operational refusals (dirty worktree, disagreeing version files, missing version, `--no-tag` without an existing tag) are command errors, not a substitute for ship. +8. **Confirm before cut** — present the suggest report (or `cut --dry-run`) first. Ask one question at a time, with a recommendation, using your harness's structured question tool if it has one. `--dry-run` previews everything and writes nothing. +9. **Log the outcome** — after a successful cut, `loaf journal log "decision(release): vX.Y.Z recorded from <base> with <summary>"`. + +--- ## Verification -- Release base branch is clean, current, and contains the intended landed PRs -- Pre-flight checks pass before versioning or publication -- Changelog entries are curated user-facing prose, not commit or PR-title dumps -- Version files, changelog heading, git tag, and GitHub Release all agree -- Tag points at the released base-branch commit or release commit, not an abandoned feature branch -- Downstream install path is verified when applicable, especially Homebrew for Loaf releases +- Journal contains the `skill(release)` invocation (and a `decision(release)` entry after a real cut) +- The work in the range already landed through ship (PR review and CI at merge); this skill did not re-verify or re-merge it +- `loaf release suggest` (or `cut --dry-run`) was shown: landed issues, partially-landed parents, unattributed commits, advisory buckets, derived bump, drafted notes +- Partially-landed parents, unattributed commits, and bucket drift were reported as information — not treated as a cut refusal +- Mutating `loaf release cut` updated version files, wrote the notes into `CHANGELOG.md`, created or reused tag `v<version>`, and recorded the release row with issue members (plus `--includes` release members when given) +- `cut --dry-run` left version files, changelog, tags, HEAD, and release rows untouched +- GitHub Release is a draft, was skipped with `--no-gh`, or failed with a warning plus a paste-ready `gh release create …` retry — never a silent rollback of the recorded row +- No issue was stamped with a future version + +--- ## Quick Reference -| Step | Gate | Blocking? | -|------|------|-----------| -| Readiness | clean/current base branch, no unresolved release collisions | Yes | -| Change Collection | landed work since last tag grouped into release themes | Yes | -| Version + Changelog | bump selected, notes curated, files updated | Yes | -| Execution | release commit prepared via `--pre-merge`, release PR landed, `--post-merge` finalizes | Yes | -| Verification | release and install paths checked | Yes | -| Follow-Up | reflect/housekeeping suggested when useful | No | +| Harness | Invoke skill | +|---------|----------------| +| Claude Code (plugin) | `/loaf:release` | +| OpenCode, Cursor, Codex, Amp | `/release` | + +### Commands + +```text +loaf release suggest [--base <ref>] [--json] +loaf release cut [--base <ref>] [--bump <type>] [--includes <version|tag>] [--no-tag] [--no-gh] [--dry-run] +loaf issue bucket <ref> now|next|later|none [--json] +loaf issue link <from> blocks|relates-to <to> [--json] +``` + +Both commands need initialized SQLite state (`loaf state init`, or `loaf state migrate markdown --apply`). They are not a git repository's optional extra — without SQLite they refuse. + +| Command | Writes? | What it does | +|---------|---------|----------------| +| `loaf release suggest` | No | Report landed work since the last version tag | +| `loaf release cut` | Yes (unless `--dry-run`) | Cut the retroactive release and record members as facts | +| `loaf release cut --dry-run` | No | Print the plan, including `--includes` rows, and write nothing | + +### `suggest` flags + +| Flag | Meaning | +|------|---------| +| `--base <ref>` | Commits since `<ref>` instead of the last tag | +| `--json` | Machine-readable suggestion | +| `-h`, `--help` | Help | + +`suggest` rejects `--dry-run` (it is already read-only) and rejects cut-only flags (`--bump`, `--includes`, `--no-tag`, `--no-gh`). + +### `cut` flags + +| Flag | Meaning | +|------|---------| +| `--base <ref>` | Commits since `<ref>` instead of the last tag | +| `--bump <type>` | Override the derived bump: `major`, `minor`, `patch`, `prerelease`, `release` | +| `--includes <version\|tag>` | Record a prior release as a member (repeatable). Use this to hang prerelease references on a stable | +| `--no-tag` | Do not create a git tag; tag `v<version>` must already exist | +| `--no-gh` | Skip the GitHub Release draft | +| `--dry-run` | Print the plan and write nothing | +| `-h`, `--help` | Help | + +`cut` rejects `--json`. `--bump prerelease` and `--bump release` only produce a version when the current version already has a prerelease suffix; otherwise cut fails with `could not compute a version to cut`. `--no-tag` is checked before `--dry-run`: the tag must already exist even for a preview. + +### Cut sequence (mutating) + +1. Recompute the same suggestion as `suggest` (then apply `--bump` if given, and redraft notes) +2. Resolve each `--includes` ref to an existing release +3. Require a clean worktree +4. Apply the version to detected version files (they must exist and agree) +5. Prepend drafted notes into `CHANGELOG.md` (after `[Unreleased]`, ahead of prior versions; creates the file if missing) +6. Commit `chore: release vX.Y.Z` +7. Unless `--no-tag`: create annotated tag `vX.Y.Z` (`git tag -a`). Signing follows git config (`tag.gpgSign`); cut never passes `-s` or `--no-sign` +8. Record the release row, issue members, and `--includes` members as facts +9. Unless `--no-gh`: `gh release create <tag> --draft --title <tag> --notes <notes>` (adds `--prerelease` when the version is a prerelease). Switches to the configured GitHub account first. On `gh` missing, account failure, or create failure: print `warning:` plus a POSIX-quoted `retry:` command; do not fail the cut + +Cut does not push the commit or the tag. + +--- ## Topics | Topic | Use When | |-------|----------| -| [Context Detection](#context-detection) | Determining release base, last tag, and current branch | -| [Release-PR Flow](#step-5-release-pr-flow) | Preparing, landing, and finalizing every release | -| [Hook Interaction](#hook-interaction) | Understanding coexistence with git hooks | +| [Process](#process) | Running suggest then cut in this conversation | +| [Attribution](#attribution) | How commits become issue members | +| [Bump derivation](#bump-derivation) | Why suggest picked major, minor, or patch | +| [Must-contain convention](#must-contain-convention) | The rare promise that named issues must land first | --- -## Context Detection - -Before anything, establish the release surface: - -1. Get current branch and repo default branch: - ```bash - git branch --show-current - gh repo view --json defaultBranchRef -q .defaultBranchRef.name - ``` -2. Parse `$ARGUMENTS` for an explicit base, tag, or version. If omitted, use the repo default branch as the release base. -3. Verify the current branch: - - If already on the release base, continue; the release-PR flow in Step 5 branches from here. - - If on a dedicated release branch, resume the release-PR flow at the matching step. - - If on a feature branch, stop and explain that release publishes from landed work. Offer ship if the active PR needs landing first. -4. Find the previous release tag: - ```bash - git describe --tags --abbrev=0 - ``` -5. Gather the candidate release range: - ```bash - git log --oneline <last-tag>..HEAD - git diff --stat <last-tag>..HEAD - ``` +## Process ---- +Parse `$ARGUMENTS` for a base, bump, version, `--includes`, `--no-tag`, `--no-gh`, or `--dry-run`. Default baseline is the last version tag; `--base` overrides. With neither a last tag nor `--base`, the range is all of `HEAD`. -## Step 1: Release Readiness - -Run release pre-flight checks before editing release files: - -1. Ensure worktree is clean: - ```bash - git status --short - ``` -2. Ensure the release base is current: - ```bash - git fetch --tags origin - git status --branch --short - ``` -3. Check for existing tag or GitHub Release collisions for the target version once known: - ```bash - git tag --list vX.Y.Z - gh release view vX.Y.Z - ``` -4. Run project checks: - - Node: `npm run typecheck`, `npm run test`, `npm run build` when scripts exist - - Go: `go vet ./...`, `go test ./...` when `go.mod` exists - - Python: `pytest`, `mypy .`, `ruff check .` when configured - - Rust: `cargo check`, `cargo test` when `Cargo.toml` exists - -If no checks are detected, warn explicitly. If a check fails, stop and fix before release. +### Step 1: Log and route ---- +```bash +loaf journal log "skill(release): <what is being cut or suggested>" +``` -## Step 2: Change Collection - -Collect landed work since the last release and group it for release notes. - -1. Inspect commits: - ```bash - git log --first-parent --oneline <last-tag>..HEAD - git log --oneline <last-tag>..HEAD - ``` -2. Inspect merged PRs when GitHub is available: - ```bash - gh pr list --state merged --base <base> --json number,title,mergedAt,url - ``` -3. Group changes by user-facing outcome: - - `CR-*` change bundle, when referenced - - spec or task family, when public enough to be useful - - feature/fix/documentation/build themes - - operational release work, when it affects users or maintainers -4. Drop noise: - - purely internal task labels - - reverted work that is not present in `HEAD` - - individual commit mechanics that collapse into one user-facing change - -Present the grouped release contents before choosing the bump. +If the user wants a PR reviewed or merged, stop and use ship. If the work is still on a feature branch, explain that a release is cut from landed `HEAD` since the baseline, and offer ship. ---- +### Step 2: Suggest -## Step 3: Version + Changelog - -Choose the bump and curate the changelog from the grouped landed work. - -1. Run a dry run: - ```bash - loaf release --dry-run - ``` - Use `--base <ref>` when the project expects a non-default release base. -2. Present: - - current version - - proposed next version - - detected version files - - release actions the CLI would perform - - draft changelog entries -3. Curate `CHANGELOG.md` before publishing: - - write from the upgrading user's perspective - - group under Common Changelog categories: `Changed`, `Added`, `Removed`, `Fixed` - - use one self-describing line per meaningful change - - include public PR, issue, ADR, release, or commit links when helpful - - avoid dumping commit subjects, task IDs, session mechanics, or internal gate language -4. Confirm the bump type: `prerelease`, `release`, `major`, `minor`, or `patch`. +```bash +loaf release suggest +# or +loaf release suggest --base <ref> +loaf release suggest --json +``` ---- +Present the report as-is: base, suggested bump and version, bump evidence, landed issues with commits, partially-landed parents (missing children), unattributed commits, advisory buckets (planned landed / planned not landed / unplanned landed), drafted notes. -## Step 4: Release Execution +Do not hide partial parents or unattributed commits, and do not refuse the cut because of them unless the operator is using the [must-contain convention](#must-contain-convention) and wants to wait. -Every release routes through the release-PR flow in Step 5: prepare the release commit on a release branch with `loaf release --pre-merge`, land the release PR, then finalize with `loaf release --post-merge` on the base branch. +### Step 3: Confirm -Release preparation should: +Show the exact `loaf release cut …` you would run. Recommend cutting the derived version when the landed set matches what the operator asked for. Use `--bump` only when they override. Use `--dry-run` when they want a preview: -1. Update version files -2. Convert `[Unreleased]` into `## [X.Y.Z] - YYYY-MM-DD` -3. Reinsert a fresh empty `[Unreleased]` section -4. Run configured release artifact commands -5. Create the release commit +```bash +loaf release cut --dry-run +loaf release cut --dry-run --no-gh +loaf release cut --dry-run --includes <version|tag> +``` -After preparation, verify generated artifacts are current: +### Step 4: Cut ```bash -npm run build -git diff --exit-code -- dist plugins content/skills/loaf-reference/SKILL.md +loaf release cut +loaf release cut --bump minor +loaf release cut --includes v1.1.0-alpha.1 +loaf release cut --no-tag --no-gh ``` -Adjust the path list to the project. For Loaf itself, tracked generated outputs under `dist/`, `plugins/`, and native binaries must match the source changes. +On success, report version files updated, changelog written, tag created or reused, release recorded (member count), and GitHub draft created / skipped / warned. If stderr has `retry:`, paste that command; the row is already recorded. -Capability receipts pin artifact SHA-256s, and the release rebuild version-stamps generated artifacts (`dist/opencode/plugins/hooks.ts` embeds `@version`, so every version bump stales the OpenCode receipt; Go changes additionally stale all binary-pinned receipts via `bin/native`). Therefore re-recording runs AFTER `loaf release --pre-merge` completes its artifact rebuild, on the release branch, before pushing the release PR — never before the bump. Verify with `go test ./internal/cli -run TestTargetCapabilityEvidence`. `loaf release` now enforces this mechanically on every mutating path (post-rebuild refusal in apply, guardrail 9 in `--post-merge`) — the rule explains WHY the gate fires; the gate makes skipping it impossible. +```bash +loaf journal log "decision(release): vX.Y.Z recorded from <base> with <summary>" +``` -### Direct Release (Named Exception) +### Step 5: After -`loaf release --bump <type> --yes` on the base branch prepares, commits, tags, and publishes in a single shot. Use it only when the user explicitly requests a direct release; never select it by default. Skipping the release PR means nothing runs the suite against the prepared tree before the tag exists — the v0.2.16 cut took this door and a capability-evidence canary surfaced only in tag CI, after publication. The same day, v0.2.17 re-recorded evidence minutes before the version bump; the release commit staled it, and the tag again published zero assets — ordering, not diligence, is the failure mode. The CLI prints a flow advisory when a mutating release starts on the default branch; treat it as a routing signal, not noise. +Suggest reflect when the cut produced durable product or workflow learnings, and housekeeping when temporary artifacts need cleanup. Capture leftover discoveries as issues or sparks — not as extra changelog lines. --- -## Step 5: Release-PR Flow +## Attribution -The default for every release: PR CI runs the full suite against the prepared tree, so evidence canaries surface before any tag or GitHub Release exists. This holds regardless of repository settings — where branch protection is enabled it is satisfied as a side effect, not the reason for the flow. +`suggest` (and `cut`, which recomputes the same suggestion) attributes each commit in the range to zero or more issues, then rolls up through parents. -1. Create a dedicated release branch from the release base. -2. Run `loaf release --pre-merge` on it: this creates the version/changelog/artifact release commit but no tag and no GitHub Release. -3. Open a release PR with a concise release-focused body. -4. Hand the PR to ship for review and landing; squash-merge it into one `chore: release vX.Y.Z (#PR)` commit carrying the curated changelog. -5. After the release PR lands, run `loaf release --post-merge` on the base branch to tag, publish the GitHub Release, and verify installability. +**Commit → issue**, first match wins: -If guardrail 9 fires on `--post-merge`, the merged tree itself carries stale evidence; recovery is to re-record against the merged tree, land the receipts as a single evidence-only commit on the base branch (the repair commit must not modify the capability registry), and rerun `loaf release --post-merge`. +1. Issue alias (`PREFIX-N`, e.g. `LOAF-42`) in the subject or body (prefix case-sensitive). URLs and code spans are stripped first. +2. Else the merge/branch rung: aliases in a `Merge …` subject, plus any alias anywhere in the body (case-insensitive). Squash subjects like `feat: add auth (#42)` often carry the alias only in the body. No network, no `gh`. An alias that lived only on a deleted branch name is unattributable. +3. Else a unique journal `commit(<hash>)` row whose message contains an alias (scope uniquely matching that commit). -Do not hide this handoff inside release: ship remains the PR correctness and merge gate. +Resolved aliases become **landed** issues (with the commits that named them). Commits that match nothing are **unattributed** — listed, and included under drafted notes as `### Unattributed`. They do not block the cut. ---- +**Parent rollup** (information): -## Step 6: Publication Verification +- For each landed issue that has a parent, if any sibling child is not `done`, the parent is **partially landed** and the missing children are listed. +- A parent is not auto-added to landed unless a commit attributed to it. -After publishing, verify the public release state: +Drafted notes are `## [version] - YYYY-MM-DD`, then one `### ALIAS — title` section per landed issue with commit subjects, then unattributed. -1. Confirm tag location: - ```bash - git show --stat vX.Y.Z - ``` -2. Confirm GitHub Release: - ```bash - gh release view vX.Y.Z - ``` -3. Confirm package or installer availability when applicable: - - npm: `npm view <package> version` - - Homebrew: `brew update && brew info <tap>/<formula>` - - project-specific deploy or artifact registry checks -4. For Loaf/Homebrew, report readiness only after the GitHub release exists, assets are uploaded, the tap formula is updated, and tap CI has passed. +--- -If publication partially completes, do not retag casually. Name the exact state and continue with the smallest repair or patch release path. +## Bump derivation ---- +Derived from the range, in order: -## Step 7: Post-Release Follow-Up +| Condition | Bump | +|-----------|------| +| Breaking marker (`type!:` in the subject, or `BREAKING CHANGE:` / `BREAKING-CHANGE:` in the body) | `major` | +| A **done** parent with **two or more** children, every child `done` and landed, and the parent's done timestamp **after** the baseline tag's committer time | `minor` (closed multi-child parent fully landed) | +| Else a conventional `feat` commit | `minor` | +| Else | `patch` (`fix` / other) | -After verification: +`--bump` on `cut` replaces the derived bump and redrafts notes; the evidence string becomes `overridden by --bump <type>`. -1. Log the release decision to the project journal: - ```bash - loaf journal log "decision(release): vX.Y.Z published from <base> with <summary>" - ``` -2. Suggest reflect when the release produced durable product or workflow learnings. -3. Suggest housekeeping when release branches or temporary reports need cleanup. -4. Keep future-work discoveries out of the release notes; capture them as tasks, ideas, or sparks instead. +Current version comes from agreeing version files, else from a semver last tag. Cut still requires version files on the mutating path. --- -## Hook Interaction +## Must-contain convention -This skill coexists with existing hooks. Git workflow hooks are advisory unless -configured otherwise; security and secret-scanning hooks remain blocking. +Rare. When the operator needs a named set of issues to land before a cut, create a **release-prep** issue and express `blocked_by` edges with issue mechanics: + +```bash +loaf issue new "Release prep for vX.Y.Z" --body "Must contain LOAF-12 and LOAF-15. Out of scope: the cut itself." +loaf issue link LOAF-12 blocks LOAF-99 +loaf issue link LOAF-15 blocks LOAF-99 +``` -| Hook | Type | When release Runs | -|------|------|---------------------| -| `github-account` | Force-switch | Switches to the configured GitHub account before `gh` release operations; blocks only if the switch fails | -| `validate-push` | Advisory | Cross-checks version bump, changelog, and build on push | -| `workflow-pre-pr` | Advisory | Fires when the release PR is opened | -| `workflow-pre-merge` | Advisory | Belongs to ship when a release PR must land | -| `workflow-post-merge` | Advisory | Belongs to ship after PR landing | -| `check-secrets` | Blocking | Always respected before writes or shell actions | +Stored write types are `blocks` and `relates_to`. `loaf issue link <must-land> blocks <release-prep>` is how you record that the prep issue is blocked by those that must land. `loaf issue frontier` and implement honor `blocks`. **`loaf release suggest` and `cut` do not read these edges** — convention, not schema. If the operator wants to wait, wait; if they cut anyway, cut records whatever actually landed. -Do not disable hooks to force a release through. +Buckets stay labels: ---- +```bash +loaf issue bucket LOAF-12 now +loaf issue bucket LOAF-12 none +``` -## Suggests Next +`suggest` prints `bucket:<name> ALIAS — title (landed|not landed)` and `unplanned ALIAS — title (landed)`. Never treat that delta as a constraint. -After a successful release, suggest reflect for durable learnings and housekeeping if temporary release artifacts need attention. +--- ## Related Skills -- **ship** -- Reviews, verifies, and lands a PR before it becomes release input -- **git-workflow** -- Branching, PR, commit, and squash merge conventions -- **documentation-standards** -- Changelog and release-note quality -- **reflect** -- Updates strategy from shipped/released learnings -- **housekeeping** -- Cleans up completed spec, report, and handoff artifacts +- **ship** — Reviews, verifies, and lands a PR. That merge is the verification authority for what this skill may later cut +- **git-workflow** — Branching, PRs, and any later push of the local tag (cut does not push) +- **documentation-standards** — Changelog prose if a human edits notes after the cut +- **reflect** — Durable learnings after a cut +- **housekeeping** — Cleanup of temporary artifacts diff --git a/plugins/loaf/skills/research/SKILL.md b/plugins/loaf/skills/research/SKILL.md index 7b2e02e92..0dd4beddd 100644 --- a/plugins/loaf/skills/research/SKILL.md +++ b/plugins/loaf/skills/research/SKILL.md @@ -96,7 +96,7 @@ Always check project context first. Rate findings: **High** (official/verified), **Trigger:** Empty input, "project state", "catch me up" 1. Read project documents: VISION.md, STRATEGY.md, ARCHITECTURE.md -2. Check ideas with `loaf idea list --json` and specs with `loaf spec list --json` +2. Check ideas with `loaf idea list --json` and issues with `loaf issue list --json` (or `loaf issue export` for the full graph) 3. Review recent journal activity with `loaf journal recent --json` and `loaf journal context` 4. Check recent commits: `git log --oneline -20` 5. Synthesize following [state-assessment template](templates/state-assessment.md) @@ -146,4 +146,4 @@ User-facing entry for a new concept is pitch (problem-discovery brief). Do not t ## Artifact Naming -Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or Change already records that provenance. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. +Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or the issue already records that provenance. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. diff --git a/plugins/loaf/skills/research/templates/report.md b/plugins/loaf/skills/research/templates/report.md index 2ff4c059c..f7cc5d7dd 100644 --- a/plugins/loaf/skills/research/templates/report.md +++ b/plugins/loaf/skills/research/templates/report.md @@ -14,7 +14,7 @@ title: "Report: [Topic]" type: research | audit | analysis | council created: YYYY-MM-DDTHH:MM:SSZ status: draft | done | archived -source: SPEC-XXX | TASK-XXX | ad-hoc +source: LOAF-42 | ad-hoc tags: [] --- diff --git a/plugins/loaf/skills/research/templates/state-assessment.md b/plugins/loaf/skills/research/templates/state-assessment.md index 65e426f3b..359392033 100644 --- a/plugins/loaf/skills/research/templates/state-assessment.md +++ b/plugins/loaf/skills/research/templates/state-assessment.md @@ -24,7 +24,7 @@ tags: [] - **Vision:** [Brief summary] - **Key personas:** [Who we're building for] -- **Current focus:** [Active specs/work] +- **Current focus:** [Active issues] ## Recent Progress @@ -32,10 +32,10 @@ tags: [] ## In Flight -| Spec/Task | Status | Notes | -|-----------|--------|-------| -| SPEC-001 | implementing | [progress] | -| SPEC-002 | approved | [next up] | +| Issue | Status | Notes | +|-------|--------|-------| +| LOAF-1 | active | [progress] | +| LOAF-2 | todo | [next up] | ## Ideas Pipeline diff --git a/plugins/loaf/skills/shape/SKILL.md b/plugins/loaf/skills/shape/SKILL.md index ebc85549d..424c3bccd 100644 --- a/plugins/loaf/skills/shape/SKILL.md +++ b/plugins/loaf/skills/shape/SKILL.md @@ -1,19 +1,15 @@ --- name: shape description: >- - Shapes messy input into a bounded, reviewable Change under - docs/changes/YYYYMMDD-slug/ (change.json + shape.md + tasks/), validated by - loaf change check. Runs a fog-routed narrowing protocol — gather context, - optional blindspot pass, grilling, reaction artifacts — seeds task-file - vertical slices, runs a critique gate, and offers an opt-in draft PR. Use when - the user asks "shape this," "turn this into a Change," or an idea has enough - constraints to bound. Produces role-named narrative (shape.md required; - brief/plan/design optional) plus task packets — never a numbered spec. - Teaches the problem-boundary test (same problem → another task; different - problem → Intent) and vertical-slice discipline. Not for quick capture (use - idea), problem discovery that should author a brief first (use pitch), or - open-ended divergent thinking (agent technique: explore / brainstorm — user - entry intent routes to pitch). + Shapes messy input into a bounded issue — problem body, definition-of-done + criteria, out-of-scope statement, and children when a criterion earns its own + DoD — validated by loaf issue check. Use when the user asks "shape this," + "turn this into an issue," or a diagnosed fix needs a row. Produces a shaped + issue — never a folder or a plan document. Teaches fog graduation (park, + then a decision child) and one-criterion sizing (one fresh context window, + verifiable alone). Not for quick capture (use idea), problem discovery that + should author a brief first (use pitch), or open-ended divergent thinking + (agent technique: explore / brainstorm — user entry routes to pitch). user-invocable: true argument-hint: '[messy input to shape into a Change]' version: 0.2.21 @@ -21,7 +17,7 @@ version: 0.2.21 # Shape -Turn messy input into a bounded, reviewable Change. +Prepare a bounded, reviewable issue. ## Contents - Critical Rules @@ -37,29 +33,30 @@ Turn messy input into a bounded, reviewable Change. ## Critical Rules -1. **Log invocation first** — `loaf journal log "skill(shape): <input being shaped>"` before doing anything else. -2. **Produces a Change, never a spec** — `change.json` + `shape.md` (+ optional `brief.md`/`plan.md`/`design.md`) and `tasks/TASK-NNN-slug.md`. No sequentially-numbered spec file, no status-like fields anywhere. +1. **Log invocation first** — `loaf journal log "skill(shape): shaping <topic> into LOAF-42"` before doing anything else. If no issue exists yet, log `skill(shape): shaping <topic>` and add the alias in the outcome entry. +2. **Produces an issue, never a folder** — the deliverable is the issue row: problem in the body, definition of done as `loaf issue dod` criteria, an explicit out-of-scope statement in the body, children via `loaf issue promote` when a criterion earns its own DoD. No plan document is committed. The PR body, if a PR is opened, is `loaf issue render` output. 3. **The fog register routes, you don't guess** — every named unknown carries a quadrant tag that dispatches it to exactly one technique (see Quick Reference). Technique-by-vibes is the failure mode this replaces. -4. **Blindspot pass precedes grilling, offered not imposed** — run it when the territory is unfamiliar; skip it when the shaper is the domain expert. Never impose it as a mandatory step. -5. **Grill one question at a time, with a recommendation** — using your harness's structured question tool if it has one (otherwise one inline question per message — same semantics). Never a form to fill in one pass. Order by architectural impact: questions whose answer would change the architecture go first, cosmetic questions last. -6. **Techniques return fog entries, not decisions** — blindspot passes, grilling, and reaction artifacts hand back named unknowns and evidence for the human to adjudicate. Reconnaissance, not orders. -7. **Own the decomposition** — decide Implementation Unit boundaries and granularity autonomously (absorbed from the retired breakdown step); ask only when two orderings carry genuinely different trade-offs. -8. **Order units by likelihood-of-change** — data models, interfaces, and user-facing flows lead; mechanical work collapses at the bottom, so review attention lands on what's most likely to need changing. -9. **Surface misalignment, never silently adjust** — when the idea conflicts with strategic docs, prior Changes, or the journal, tell the user and let them decide. Don't quietly reshape their idea. -10. **Critique before finalizing** — run the Critique Gate as the last shaping step, before `loaf change check` and the PR offer. -11. **Get approval before `loaf change init`** — don't scaffold the folder without explicit confirmation of scope. -12. **Log the outcome** — `loaf journal log "decision(shape): <slug> shaped — N units, N open fog entries"`. +4. **Fog graduates instead of evaporating** — a question not yet sharp enough is parked in the issue's `fog` field (`loaf issue new --fog`). When it sharpens it becomes a `--kind decision` child, which is ready when it poses a sharp question (a `?` in the title or body). No plan required. +5. **Blindspot pass precedes grilling, offered not imposed** — run it when the territory is unfamiliar; skip it when the shaper is the domain expert. Never impose it as a mandatory step. +6. **Grill one question at a time, with a recommendation** — using your harness's structured question tool if it has one (otherwise one inline question per message — same semantics). Never a form to fill in one pass. Order by architectural impact: questions whose answer would change the architecture go first, cosmetic questions last. +7. **Techniques return fog entries, not decisions** — blindspot passes, grilling, and reaction artifacts hand back named unknowns and evidence for the human to adjudicate. Reconnaissance, not orders. +8. **Decomposition is the tail** — a parent gets children only when its DoD needs more than one coherent slice. A criterion becomes a child the moment it earns its own DoD, via `loaf issue promote`. Own those boundaries autonomously; ask only when two orderings carry genuinely different trade-offs. +9. **One sizing criterion** — a slice is right-sized when it fits one fresh context window and is verifiable alone. Expand–contract is the named exception for wide mechanical refactors. See [references/decomposition.md](references/decomposition.md). +10. **Surface misalignment, never silently adjust** — when the idea conflicts with strategic docs, prior issues, or the journal, tell the user and let them decide. Don't quietly reshape their idea. +11. **Critique before finalizing** — run the Critique Gate as the last shaping step, before `loaf issue check`. +12. **A diagnosed one-line fix is two commands** — `loaf issue new` with a body that states the problem and `Out of scope: …`, then one `loaf issue dod add`. No problem-space ceremony. Confirm scope with the user before `loaf issue new` on anything larger. +13. **Log the outcome** — `loaf journal log "decision(shape): LOAF-42 shaped — N children, N open fog entries"`. --- ## Verification -- `docs/changes/YYYYMMDD-slug/` has `change.json` + `shape.md` with Product Contract sections non-empty; task packets seeded under `tasks/` when decomposition is known -- Every Open Questions entry carries a quadrant tag (`[KU]`, `[UK]`, or `[UU]`) and a route -- `loaf change check` reports zero violations (no legacy deprecation on new layout); executability gaps were read, not ignored -- Problem-boundary test applied: discovered different problems become Intents, not TASK-007 -- The Critique Gate ran, and its answers changed the documents where they applied -- No status-like fields in `change.json` or task frontmatter +- The issue body states the problem and contains an explicit out-of-scope statement (`out of scope`, case-insensitive — that substring is what `loaf issue check` reads) +- At least one definition-of-done criterion exists; V-tier criteria carry `--command` (and `--expect` when the check is more than exit 0); H-tier otherwise +- Every open unknown is either parked in create-time `fog`, held in the session register until it sharpens, graduated to a `--kind decision` child (or sibling) with a sharp question, or written into the body as a decided answer +- `loaf issue check <ref>` reports the issue shaped (delivery) or ready (decision). When children exist, coverage failures were fixed and containment orphans were filed as sibling backlog issues using the printed remedy +- Problem-boundary test applied: a discovered different problem becomes a new backlog issue, not another criterion on this one +- The Critique Gate ran, and its answers changed the issue where they applied --- @@ -67,34 +64,46 @@ Turn messy input into a bounded, reviewable Change. ### Fog register format -Open Questions entries take one of three forms: +Open unknowns take one of three forms. Keep the register in the session. Park what is still unsharp in `--fog` at create; after create, unsharp entries stay in the session register (edit cannot mutate `fog`). Graduate what is sharp to a decision child or sibling, and write decided answers into the body. ```text -- [KU] <the unknown> → <route: grilling | research spike | owner section> -- [UK] <the recognize-it-when-seen criterion> → reaction artifact in research/ +- [KU] <the unknown> → <route: grilling | research spike | owner> +- [UK] <the recognize-it-when-seen criterion> → reaction artifact - [UU] <the suspected blind area> → blindspot pass over <territory> ``` -An entry resolves by becoming a Decision, a Planning Contract subsection, or a named follow-up — visible in the diff, never silently deleted. A `[UU]` that gets named becomes a `[KU]` or `[UK]` and re-routes through the table below. +An entry resolves by becoming a decision child, a body paragraph, a criterion, or remaining parked in `fog` — visible on `loaf issue show`, never silently deleted. A `[UU]` that gets named becomes a `[KU]` or `[UK]` and re-routes through the table below. ### Quadrant routing | Tag | Meaning | Routes to | |-----|---------|-----------| | `[KU]` known unknown | A question you can state precisely | [Grilling](references/grilling.md) (architecture-changing answers first) or a research spike | -| `[UK]` unknown known | You'd recognize the right answer if you saw it, but can't state it yet | [Reaction artifact](references/reaction-artifact.md) — a variant or mock in `research/`, react and pick | +| `[UK]` unknown known | You'd recognize the right answer if you saw it, but can't state it yet | [Reaction artifact](references/reaction-artifact.md) — a variant or mock, react and pick | | `[UU]` suspected blind spot | Unfamiliar territory; you don't yet know what you don't know | [Blindspot pass](references/blindspot-pass.md) | -No route names a skill invocation. Research re-interviews an already-scoped question and writes to `.agents/reports/`; brainstorm forces a strategic frame onto a Change-local question and sends resolutions to intake. Shape runs all three techniques itself, in-session, and writes evidence into the Change's own `research/` — never `.agents/reports/`. +No route names a skill invocation. Research re-interviews an already-scoped question; brainstorm forces a strategic frame onto an issue-local question and sends resolutions to intake. Shape runs all three techniques itself, in-session, and writes the captured answer onto the issue — never into `.agents/reports/`. ### Defined terms -- **Rabbit holes** — tempting expansions of scope that would consume disproportionate effort for marginal value; name them so nobody wanders in unknowingly. -- **No-gos** — approaches explicitly forbidden for this Change, stated so they aren't silently reconsidered mid-implementation. +- **Rabbit holes** — tempting expansions of scope that would consume disproportionate effort for marginal value; name them in the out-of-scope statement so nobody wanders in unknowingly. +- **No-gos** — approaches explicitly forbidden for this issue, stated so they aren't silently reconsidered mid-implementation. ### Source inputs recognized -Step 1 reads whatever `$ARGUMENTS` names, or asks. Recognized sources: a change `brief.md` (from pitch or capture), a journal entry (cite by ID), a spark, an idea, a brainstorm document, a Linear issue, a PR conversation, a prior Change, or plain conversation with no artifact behind it yet. +Step 1 reads whatever `$ARGUMENTS` names, or asks. Recognized sources: a brief from pitch, a journal entry (cite by ID), a spark, an idea, a brainstorm document, a Linear issue, a PR conversation, a prior issue, or plain conversation with no artifact behind it yet. + +### One-line entry + +A diagnosed fix that already has a problem and a done-check: + +```bash +loaf issue new "Fix missing --json in list help" --body "issue list --help omits --json. Out of scope: rewriting other help pages." +loaf issue dod add LOAF-42 "issue list help names --json" --command "loaf issue list --help" --expect "contains \`--json\`" +loaf issue check LOAF-42 +``` + +Two writes, then the readiness verdict. No grilling, no children, no files. --- @@ -102,53 +111,86 @@ Step 1 reads whatever `$ARGUMENTS` names, or asks. Recognized sources: a change ### Step 1: Gather Context -Parse `$ARGUMENTS` against the source inputs above. When the input names a Change folder that already has `brief.md` (or you find one for this work), treat the brief as primary: restate the problem from it, confirm with the user rather than re-discovering, and keep later grilling on solution-space (how, boundaries, verification) — pitch already framed the problem. When no brief exists, run full narrowing as today; pitch is the recommended front door for raw concepts, never a gate. Read the journal (`loaf journal recent` / `search`) for related history, and check for a prior Change touching the same area. When VISION.md, STRATEGY.md, and ARCHITECTURE.md exist, read them for strategic fit. Most consumer projects don't have them yet — when absent, shape against the journal, recent Changes, and the conversation instead, and say so in the Change's Source Inputs. +Parse `$ARGUMENTS` against the source inputs above. When a brief from pitch already frames the problem, restate it, confirm with the user rather than re-discovering, and keep later grilling on solution-space (how, boundaries, verification). When no brief exists, run full narrowing; pitch is the recommended front door for raw concepts, never a gate. Read the journal (`loaf journal recent` / `search`) for related history, and check for a prior issue touching the same area (`loaf issue list`, `loaf issue tree`). When VISION.md, STRATEGY.md, and ARCHITECTURE.md exist, read them for strategic fit. Most consumer projects don't have them yet — when absent, shape against the journal, recent issues, and the conversation instead, and say so in the issue body. ### Step 2: Evaluate Strategic Fit -When strategic docs exist, check: does this advance the vision, serve the target personas, fit technical constraints, avoid conflicting with in-flight Changes? On misalignment, **surface it to the user — never silently adjust the idea**. The user decides whether to proceed, narrow scope, or defer to reflect after this ships. +When strategic docs exist, check: does this advance the vision, serve the target personas, fit technical constraints, avoid conflicting with in-flight issues? On misalignment, **surface it to the user — never silently adjust the idea**. The user decides whether to proceed, narrow scope, or file the conflicting concern as its own backlog issue. -### Step 3: Name the Change and Initialize +### Step 3: Name the Issue and Write the Row -Once the shape of the work is nameable, confirm scope with the user, then: +Once the work is nameable, confirm scope with the user (skip this confirmation on the one-line path), then create the row. Prefer creating after the first narrowing pass so `--fog` can carry remaining unsharp questions — the CLI writes `fog` only at create. ```bash -loaf change init <slug> +loaf issue new "Rotate auth tokens on a sliding window" \ + --body "Sessions never expire while the tab stays open, so a stolen cookie is valid indefinitely. + +Out of scope: migrating existing sessions; third-party IdP support." \ + --fog "[KU] sliding-window length → grill; [UU] existing session-store conventions → blindspot pass" ``` -On a fresh slug this scaffolds `change.json` + `shape.md` + seeded `tasks/` from the embedded templates (see `templates/shape.md`, `templates/task.md`). On a capture-only folder that already has `change.json` + `brief.md` (from pitch or `init --brief`), the same command promotes in place — preserving brief and metadata verbatim while materializing `shape.md` and `tasks/` — never hand-copy templates into the folder; rely on that promotion path. Use `loaf change init <slug> --brief` only for capture-before-shape (emits `change.json` + `brief.md`). It does not switch branches — `git switch -c <slug>` yourself. Fill `shape.md` Product Contract sections as understanding solidifies; seed `tasks/TASK-NNN-slug.md` as vertical slices (a task is a commit, not a PR). Optional `plan.md`/`design.md` accrete when the how needs prose. See [references/cli-boundary.md](references/cli-boundary.md). +Default kind is `delivery`; default status is `triage`. `--status` accepts `triage`, `backlog`, `todo`, `active`, or `done`. Use `--body -` or `--body-file <path>` for a longer body; `loaf issue edit <ref>` later **replaces** the body, it does not patch it. + +A delivery issue is shaped when the body is nonempty (the problem), at least one criterion exists, and the body contains an explicit out-of-scope statement. Fill those as understanding solidifies — create can carry the first body; criteria come next. + +A discovered different problem is a new backlog issue, not a child of this one: + +```bash +loaf issue new --status backlog "Rewrite the session store" +``` ### Step 4: Narrow the Unknowns -Offer the blindspot pass when the territory is unfamiliar (a new domain, an unfamiliar subsystem, a first collaboration) — skip it when the shaper is the expert. Its fog entries, and any others surfaced in interview, route by quadrant (Quick Reference above). Loop: grill `[KU]` entries, react to `[UK]` entries, run blindspot reconnaissance on `[UU]` entries until each gets a name and re-routes. Stop grilling when no unrouted `[KU]` entries remain or answers stop changing the contract. Entries still open at the end of the session are fine — each names its owner (a section, a spike, a follow-up). +Offer the blindspot pass when the territory is unfamiliar (a new domain, an unfamiliar subsystem, a first collaboration) — skip it when the shaper is the expert. Its fog entries, and any others surfaced in interview, route by quadrant (Quick Reference above). Loop: grill `[KU]` entries, react to `[UK]` entries, run blindspot reconnaissance on `[UU]` entries until each gets a name and re-routes. Stop grilling when no unrouted `[KU]` entries remain or answers stop changing the issue. -### Step 5: Decompose into Implementation Units +When a parked question sharpens, graduate it — after the parent's DoD is written (Step 5). Attaching **any** child, including a decision, turns coverage on. -Absorbed from the retired breakdown step — see [references/decomposition.md](references/decomposition.md) for the Right Size Test and per-unit verification discipline, including the V-tier `Command:` / `Expect:` forms `loaf change verify` parses (commands run from the repository root; H-tier is never gate input). Order units by likelihood-of-change; state real sequencing constraints in prose, never by list order alone. +```bash +loaf issue new --kind decision --parent LOAF-42 "Should tokens live in httpOnly cookies?" +``` + +A decision issue is ready when the title or body contains `?`. It needs no criteria and no out-of-scope statement. A decision child does not claim a parent criterion, so promote (or otherwise claim) the parent's DoD before adding children, or keep the decision as a sibling (`loaf issue new --kind decision --status backlog`, no `--parent`) if the parent stays a leaf. Unsharp questions discovered after create stay in the session register until they graduate — there is no `--fog` on edit. See [references/decomposition.md](references/decomposition.md). + +### Step 5: Write Definition of Done (decomposition tail) + +Add criteria as the interrogation produces observable done-checks. V-tier when a command can disagree with the implementation; H-tier when only a human can tell. + +```bash +loaf issue dod add LOAF-42 "Sliding-window expiry is covered by tests" --command "go test ./internal/auth/..." --expect "exit 0" +loaf issue dod add LOAF-42 "Stolen-cookie writeup is reviewable" --tier H +``` + +`--command` implies V unless `--tier` overrides. `--expect` uses the verify grammar (`exit <N>`, `` contains `text` ``, joined by ` and `). Commands run from the repository root. See [references/cli-boundary.md](references/cli-boundary.md) and [references/decomposition.md](references/decomposition.md). + +A parent gets children only when its DoD needs more than one coherent slice. The moment a criterion earns its own DoD, promote it — the parent criterion stays, the child starts with a copy, and the claim is recorded so coverage holds for that position: -### Step 6: Fill the Planning Contract +```bash +loaf issue promote LOAF-42 1 +``` -Write the free-form `###` subsections the work actually needs (approach, placement, risks, sequencing) inside the Planning Contract container. Its subsection names are yours; the container itself, plus Implementation Units, Verification Contract, and Definition of Done, is what `loaf change check` looks for. Durable Outputs stays forward-looking here — name what a final spec, ADR, or knowledge doc will need to capture, but don't write it now. Durable artifacts get created after implementation proves what's true, not during shaping. +Then shape the child the same way (body, out-of-scope, its own criteria). Order children by likelihood-of-change when presenting them; state real sequencing with `loaf issue link <from> blocks <to>`, never by tree order alone. -### Step 7: Run the Critique Gate +### Step 6: Run the Critique Gate -Before finalizing, challenge the draft — see [references/critique-gate.md](references/critique-gate.md). Is scope still bounded, does every new command or state name its ceremony, is a status field creeping back in under another name, is the CLI/skill boundary drawn correctly, and could this be smaller and still deliver the Hypothesis? +Before finalizing, challenge the draft — see [references/critique-gate.md](references/critique-gate.md). Is scope still bounded, does every new command or state name its ceremony, is a second progress flag creeping into the body, is the CLI/skill boundary drawn correctly, and could this be smaller and still be verifiable in one fresh context window? -### Step 8: Validate +### Step 7: Validate ```bash -loaf change check +loaf issue check LOAF-42 ``` -Read violations (always block — fix them) separately from the executability report (derived, informational unless `--require-executable` is passed — that flag is implement's preflight and CI's non-draft gate, not shape's business). See [references/cli-boundary.md](references/cli-boundary.md). +A delivery issue that passes prints `issue LOAF-42 is shaped`; a decision issue prints `issue LOAF-42 is ready`. Failures always block (missing body, missing criterion, missing out-of-scope, no sharp question, uncovered parent criterion). Containment orphans are reported, not failed: each line includes a ready-to-paste remedy that files the orphan as a sibling backlog issue — run that command, do not invent a different disposition. + +`loaf issue verify <ref>` runs V-tier commands from the repository root and writes nothing. That is implement's preflight, not shape's gate. See [references/cli-boundary.md](references/cli-boundary.md). -### Step 9: Offer the Draft PR +### Step 8: Offer the Review Surface -Offer to push the branch and open a draft PR, using [the PR template](templates/pr.md) — opt-in, never automatic. `loaf change check` (with no `--require-executable`) plus `gh pr list` is the cross-branch index either way. +The issue lives in SQLite. There is no folder to commit and nothing plan-shaped to land. Offer `loaf issue show <ref>` and `loaf issue tree <ref>` as the review surface. If a PR is being opened for the work, its body is `loaf issue render <ref>` — paste-ready, no manual editing. Opt-in, never automatic. -### Step 10: Log the Outcome +### Step 9: Log the Outcome -`loaf journal log "decision(shape): <slug> shaped — N units, N open fog entries"`. +`loaf journal log "decision(shape): LOAF-42 shaped — N children, N open fog entries"`. --- @@ -156,8 +198,8 @@ Offer to push the branch and open a draft PR, using [the PR template](templates/ - **pitch** — Problem-discovery ceremony that authors a brief; preferred front door when the problem is not yet framed - **idea** — Quick capture; feeds into pitch or shape once a concept has enough weight -- **brainstorm** — Agent technique for divergent thinking (route user entry intent to pitch) -- **implement** — Starts execution once a Change is structurally executable; this does not prove implementation completion +- **brainstorm** — Agent technique for divergent thinking (route user entry to pitch) +- **implement** — Starts execution once `loaf issue check` reports the issue shaped; this does not prove implementation completion - **reflect** — Updates strategic docs after the shipped work proves what changed ## Topics @@ -167,10 +209,10 @@ Offer to push the branch and open a draft PR, using [the PR template](templates/ | Blindspot pass | [references/blindspot-pass.md](references/blindspot-pass.md) | Deciding whether to offer reconnaissance, and how to prompt it | | Grilling | [references/grilling.md](references/grilling.md) | Running the one-question-at-a-time interview for `[KU]` entries | | Reaction artifacts | [references/reaction-artifact.md](references/reaction-artifact.md) | Resolving `[UK]` entries with a variant, mock, or prototype | -| Decomposition | [references/decomposition.md](references/decomposition.md) | Sizing and ordering Implementation Units | -| CLI boundary | [references/cli-boundary.md](references/cli-boundary.md) | Reading `loaf change init`/`check`/`verify` output, or explaining `--require-executable` | +| Decomposition | [references/decomposition.md](references/decomposition.md) | Sizing slices, promoting criteria, reading coverage and containment | +| CLI boundary | [references/cli-boundary.md](references/cli-boundary.md) | Reading `loaf issue` output, authoring `--command`/`--expect`, or explaining `loaf issue check` | | Critique Gate | [references/critique-gate.md](references/critique-gate.md) | Self-challenging scope and boundaries before finalizing | ## Artifact Naming -Name every artifact you create for what it is, never for the work unit that produced it: the containing directory or Change already records that provenance. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. +Shape's deliverable is the issue row. If a reaction artifact or spike note lands on disk, name it for what it is, never for the issue that produced it. Put the source in a front-matter field, not the filename. Versions and timestamps are identity and stay. See the `foundations` skill for the full rule; `loaf check --hook artifact-names` enforces it at commit. diff --git a/plugins/loaf/skills/shape/references/blindspot-pass.md b/plugins/loaf/skills/shape/references/blindspot-pass.md index 7bbd329a4..616f544d5 100644 --- a/plugins/loaf/skills/shape/references/blindspot-pass.md +++ b/plugins/loaf/skills/shape/references/blindspot-pass.md @@ -12,7 +12,7 @@ Ask the user whether to run it; skip when they're the domain expert. Do not auto ## Prompt Shape -Ask, against the specific territory named by the Change: +Ask, against the specific territory named by the issue: > What would I not know to ask here — codebase history, domain conventions, prior art, potholes? @@ -29,6 +29,8 @@ A `[UU]` that gets named through this pass becomes: and re-routes through the quadrant table in the main skill body. +Park what is still unsharp in the issue's `fog` field at create (`--fog`). After create, keep unsharp entries in the session register — edit cannot mutate `fog`. When an entry sharpens, graduate it to a `--kind decision` child or sibling. Do not drop a named unknown on the floor. + ## Stopping The pass ends when the shaper (or the user) can name the territory's remaining risks as entries, not vague unease. A pass that keeps surfacing "something might be wrong here" without a nameable entry has run past its useful length — stop and proceed with what's been named. diff --git a/plugins/loaf/skills/shape/references/cli-boundary.md b/plugins/loaf/skills/shape/references/cli-boundary.md index b7ade652b..6ed241493 100644 --- a/plugins/loaf/skills/shape/references/cli-boundary.md +++ b/plugins/loaf/skills/shape/references/cli-boundary.md @@ -1,56 +1,118 @@ # CLI Boundary -Reading `loaf change init` and `loaf change check` — the skill teaches reading the CLI, not wrapping it. For the rest of the Loaf CLI surface, see the `loaf-reference` skill. +Reading `loaf issue` — the skill teaches reading the CLI, not wrapping it. For the rest of the Loaf CLI surface, see the `loaf-reference` skill. Issue commands require initialized SQLite state (`loaf state init`, or `loaf state migrate markdown --apply`). + +## Contents +- `loaf issue new` +- `loaf issue show` / `list` / `tree` / `frontier` +- `loaf issue edit` / `status` +- `loaf issue dod` +- `loaf issue promote` +- `loaf issue check` +- `loaf issue verify` +- `loaf issue link` / `bucket` +- `loaf issue render` +- `loaf release suggest` / `cut` +- What shape does not run + +## `loaf issue new <title> [options]` + +```text +loaf issue new <title> [--body <text>|--body -|--body-file <path>|--message <text>] + [--kind delivery|decision] [--parent <ref>] [--fog <text>] [--status <status>] [--json] +``` + +Creates the issue row. Default kind is `delivery`; default status is `triage`. `--status` accepts the write statuses `triage`, `backlog`, `todo`, `active`, `done` (it still records the initial triage event). `--fog` parks questions not yet sharp enough to be issues; this flag exists only on create — `loaf issue edit` replaces the body and does not mutate `fog`. + +`--body -` reads stdin; `--body-file` reads a UTF-8 file; `--message` is inline body at lower precedence than `--body-file` and `--body -`. A hyphen-leading title is positional after `--`: + +```bash +loaf issue new --parent LOAF-42 --status backlog -- "--help is missing from the man page" +``` -## `loaf change init <slug> [--brief]` +A delivery body must state the problem and, before `loaf issue check` will pass, contain the substring `out of scope` (case-insensitive). A decision issue needs a sharp question (`?` in the title or body), not a body contract. -Scaffolds `docs/changes/<YYYYMMDD>-<slug>/` from the Change template, where `<YYYYMMDD>` is the creation day (not a target date) and the branch is named by the bare slug — no date prefix on the branch. Ordinary init writes `change.json + shape.md + tasks/`; `--brief` is capture mode (`change.json + brief.md` only). The slug uses lowercase letters, digits, and single hyphens. +## `loaf issue show` / `list` / `tree` / `frontier` -**Capture promotion.** Re-running ordinary `loaf change init <slug>` (no `--brief`) against a structurally valid capture-only folder completes it in place: `brief.md` and every `change.json` value are preserved verbatim, and missing `shape.md` plus the seeded `tasks/` are published atomically (temp-write then rename; existing destinations are never overwritten; `shape.md` is the last rename and the promotion marker). A partial promotion that already holds the byte-identical seed task resumes by filling only the gaps. Everything else fails clearly and leaves the folder untouched — repeated `--brief`, `change.json`-only (missing brief), hybrid `change.md` + `change.json`, diverged `tasks/` content, malformed metadata, and fully-materialized folders (duplicate rejection unchanged). +```text +loaf issue show <ref> [--json] +loaf issue list [--status <status>] [--kind delivery|decision] [--archived] [--started] [--json] +loaf issue tree [<ref>] [--archived] [--json] +loaf issue frontier [--json] +``` -## `loaf change check [folder] [--require-executable] [--json]` +`show` prints identity, parent, fog, body, definition of done, and children. `list` hides archived issues unless `--archived`. `--status` filters by `triage`, `backlog`, `todo`, `active`, `done`, `cancelled`, `duplicate`. `tree` prints from a ref, or the whole project when omitted. `frontier` lists non-archived `triage`/`backlog`/`todo` issues that are not blocked — derived at read time, useful when checking whether this work is already covered. -Folder resolution: an explicit `[folder]` argument always wins; otherwise the current git branch is matched against the `branch:` frontmatter across every `docs/changes/*/change.json + shape.md`. Zero or multiple matches is an error naming the candidates found. +Prefer `--json` when diagnosing rather than scraping the human-readable text. -Output splits into two tiers: +## `loaf issue edit` / `status` -- **Violations** — always fail (exit code 2), regardless of flags: status-like frontmatter keys (`readiness`, `status`, `state`) or values matching the canonical change-state vocabulary; frontmatter not opening the file at byte one; malformed `YYYYMMDD-slug` folder naming; identity mismatch between `change:`/`created:` and the folder name; missing Product Contract sections (Problem, Hypothesis, Scope, Observable Workflow, Rabbit Holes and No-Gos). -- **Derived executability** — reported, never gating by default. A Change is executable when Planning Contract, Implementation Units, Verification Contract, and Definition of Done are all present and non-empty (bracket placeholders and HTML comments don't count as content). A Change with open gaps is incomplete and non-executable; the report just says what's still missing. +```text +loaf issue edit <ref> [--body-file <path>|--body -|--message <text>] [--json] +loaf issue status <ref> <status> [--duplicate-of <ref>] [--json] +``` -`--require-executable` turns structural executability into a gate (exit code 1 if not structurally executable); it does not prove implementation completion. This is implement's preflight and CI's non-draft-PR check, not something shape itself passes during shaping. +`edit` **replaces** the body. Rewrite the full problem-plus-out-of-scope text; there is no patch form. `status` write-statuses (`triage`, `backlog`, `todo`, `active`, `done`) update in place; `cancelled` and `duplicate` archive through the remove path (`--duplicate-of` is required when status is `duplicate`). Shape leaves status at `triage` unless the user asks otherwise — shaped is derived, not a status. -A branch/Change mismatch (current branch doesn't match the Change's `branch:` field) is a warning, never a violation. +## `loaf issue dod` -`--json` emits `{command, folder, passed, state, executable, exitCode, findings, warnings, gaps}` (plus optional `layout`, `captured`, `notices`) for scripted reads; prefer it when diagnosing rather than scraping the human-readable text. The landing guard reads `state` from this envelope — e.g. `"captured"` for a brief-only folder and `"shaped"` (or higher) once `shape.md` exists — and must not invent a second state surface. +```text +loaf issue dod add <ref> <text> [--command <cmd>] [--expect <expect>] [--tier V|H] [--serves <parent-position>] [--json] +loaf issue dod list <ref> [--json] +loaf issue dod remove <ref> <position> [--json] +loaf issue dod claim <child> <child-position> <parent-position> [--json] +loaf issue dod unclaim <child> <child-position> <parent-position> [--json] +``` +V-tier is used when `--command` is present, otherwise H, unless `--tier` overrides. `--serves` records that the new child criterion claims that parent position. Positions are 1-based and compact after `remove`. Authoring guidance and the expect grammar live in the Decomposition topic. -## `loaf change report new <slug> --kind <kind>` +## `loaf issue promote <ref> <position> [--json]` -Stamps `reports/YYYYMMDD-HHMMSS-<kind>-<slug>.html` with charset, provenance, and token skeleton; prints design-language guidance. Closed kinds: approval, review, visual, audit, note. +Promotes the criterion at the 1-based position into a child **delivery** issue. The parent criterion stays in place. The child is minted in `triage` with a copy of the criterion and a claim already recorded, so coverage for that parent position holds by construction. -## `loaf change verify [folder]` +## `loaf issue check <ref> [--json] [--human <reason>]` -Runs executable V-tier criteria declared in `shape.md` and writes `receipts/verify.json` (criteria digest, verified commit, cwd, per-criterion evidence). New-layout-only. +Derives readiness from the issue row, not from markdown headings. -Criteria forms (both parse): +- **Delivery** — shaped when the body is nonempty (the problem), at least one criterion exists, and the body contains an explicit out-of-scope statement. Prints `issue <ref> is shaped` when ready. +- **Decision** — ready when the title or body contains `?`. Prints `issue <ref> is ready`. +- **Children present** — coverage is a failure (every parent criterion must be claimed). Containment is a report (every child criterion must claim a parent criterion); each orphan prints a ready-to-paste `loaf issue new --parent … --status backlog -- …` remedy. -```markdown -- **V1.** Prose. Command: `exact command`. Expect: exit 0. -- **V1.** Prose. - - Command: `exact command` - - Expect: exit 0 -``` +`--human <reason>` publishes ready-for-human instead of ready-for-agent when a tracker authority is configured. Shape's own gate is the derived verdict, not the publication. + +`--json` emits `{issue, kind, shaped, covered, ready, failures, orphans, …}`. Exit code 1 when not ready. -`Expect` is enforced, with a deliberately minimal grammar: atoms join with ` and ` — `exit <N>` is the required exit code (omit the atom, or `Expect` entirely, and `exit 0` is enforced) and `` contains `text` `` requires the command's combined stdout+stderr to contain that backtick-delimited text (repeatable). A criterion passes when the command ran, the exit code matched, and every `contains` matched; the receipt records each atom and its outcome. +## `loaf issue verify <ref> [--json]` -```markdown -- **V1.** Prose. Command: `go test ./...`. Expect: exit 0 and contains `ok github.com/acme/pkg`. +Runs the issue's V-tier criteria (`--command` plus `--expect`) from the repository root. Honors `exit <N>` and `` contains `text` ``. Writes nothing; exits non-zero on any failure. H-tier rows are skipped. This is implement's preflight, not shape's gate. + +A criterion passes when the command ran, the exit code matched, and every `contains` matched. Unenforceable expect clauses are warned and recorded as advisory — never quietly decorative. + +## `loaf issue link` / `bucket` + +```text +loaf issue link <from> blocks|relates-to <to> [--json] +loaf issue link <from> remove <type> <to> [--json] +loaf issue bucket <ref> now|next|later|none [--json] ``` -Any other clause is unenforceable: verify prints a warning naming the criterion and the clause, records it on the criterion as advisory, and never lets it affect the result — an expectation is either checked or loudly not. +Stored relationship types are `blocks` and `relates_to`. Use `blocks` for a real sequencing constraint; do not encode order in `loaf issue tree`. Buckets are labels only and are never read as a constraint. + +## `loaf issue render <ref> [--json]` + +Emits markdown suitable to paste as a PR body with no manual editing: title, body, definition-of-done checkboxes (checked only when status is `done`), and children. Nothing plan-shaped is committed; if a PR is opened, this output *is* the body. + +## `loaf release suggest` / `cut` + +Releases are retroactive. Shape does not bind an issue to a version. + +```text +loaf release suggest [--base <ref>] [--json] +loaf release cut [--base <ref>] [--bump <type>] [--includes <version|tag>] [--no-tag] [--no-gh] [--dry-run] +``` -Commands run from the repository root; the receipt records that cwd. H-tier entries (`**H1.** …`) are never gate input. See [decomposition.md](decomposition.md) for authoring guidance. +`suggest` reports landed work since the last version tag and writes nothing. `cut` records a release from landed work. Neither is a shaping step. -## `loaf change tasks` / `show` +## What shape does not run -On-demand projections. See `loaf change --help`. +`loaf issue start` / `stop` create and remove the issue worktree — implement's job, after the issue is shaped. `loaf issue export` dumps the project snapshot. Do not call them from this skill. diff --git a/plugins/loaf/skills/shape/references/critique-gate.md b/plugins/loaf/skills/shape/references/critique-gate.md index 4e131243c..f01109327 100644 --- a/plugins/loaf/skills/shape/references/critique-gate.md +++ b/plugins/loaf/skills/shape/references/critique-gate.md @@ -1,14 +1,15 @@ # Critique Gate -The last shaping step, before `loaf change check` and the PR offer. An agent won't know to interrogate its own scope, boundary placement, or smuggled-in status words unless something makes it stop and ask. Instantiated from the shape-first pilot's own Critique Gate, generalized for any Change rather than that pilot's specific CLI-surface question. +The last shaping step, before `loaf issue check` and any review offer. An agent won't know to interrogate its own scope, boundary placement, or smuggled-in progress words unless something makes it stop and ask. Run through these before finalizing: -- **Is scope still bounded?** Has the draft crept beyond what the Problem and Hypothesis justify? Could this Change be smaller and still deliver the Hypothesis? +- **Is scope still bounded?** Has the draft crept beyond what the problem statement justifies? Could this issue be smaller and still be verifiable in one fresh context window? - **Does every new command, state, or lifecycle verb name its ceremony?** If a command or state can't name the ceremony that exercises it, cut it — don't build it now and hope a use appears. -- **Is a status field creeping back in under another name?** `readiness`, `phase`, `stage`, or anything else that reintroduces a declared progress flag `loaf change check` doesn't already ban by pattern. +- **Is a second progress flag creeping into the body?** `readiness`, `phase`, `stage`, or anything else that reintroduces a declared progress flag. Status lives on the issue row (`loaf issue status`). Shaped, covered, and ready are derived by `loaf issue check`. `loaf issue bucket` is a label only and is never read as a constraint. - **Is the CLI/skill boundary drawn correctly?** Is the skill doing deterministic work that belongs in the CLI, or is the CLI claiming judgment that belongs in the skill? -- **Which Verification Contract criteria are genuinely executable gates, and which are human review dressed up as automatable?** A criterion that can't disagree with the implementation isn't a gate. -- **Are the Rabbit Holes and No-Gos sections doing real work?** Or are they restating the Scope's Out list in different words? +- **Which criteria are genuinely executable gates, and which are human review dressed up as automatable?** A V-tier criterion needs `--command` (and `--expect` when exit 0 is not enough). A criterion that can't disagree with the implementation isn't a gate — make it H, or rewrite it against an independent source of truth. +- **Is the out-of-scope statement doing real work?** Rabbit holes and no-gos belong there as named exclusions. Restating the problem in different words is not an out-of-scope statement. +- **Did fog graduate, or evaporate?** Every named unknown is parked in create-time `fog`, held in the session register, sitting as a decision child (or sibling) with a sharp question, filed as a new backlog issue (a different problem), or written into the body as a decided answer. Silent deletion is the failure. -Answers that change the document go back into it — the Decisions log, the Planning Contract, or the relevant Product Contract section — before moving to `loaf change check`. An answer spoken but not written doesn't count. +Answers that change the issue go back into it — `loaf issue edit` for the body, `loaf issue dod add` / `remove` for criteria, `loaf issue promote` or `loaf issue new` for children — before moving to `loaf issue check`. An answer spoken but not written doesn't count. diff --git a/plugins/loaf/skills/shape/references/decomposition.md b/plugins/loaf/skills/shape/references/decomposition.md index 7deef4b13..b6839482b 100644 --- a/plugins/loaf/skills/shape/references/decomposition.md +++ b/plugins/loaf/skills/shape/references/decomposition.md @@ -1,53 +1,119 @@ # Decomposition -Shaping step absorbed from the retired breakdown skill: dependency awareness, granularity judgment, and acceptance-criteria thinking, now expressed as the Change's Implementation Units and Verification Contract instead of a separate task-minting pass. +Shaping's tail, not a separate ceremony: dependency awareness, granularity judgment, and acceptance-criteria thinking, expressed as definition-of-done criteria and — only when a criterion earns its own DoD — child issues created by `loaf issue promote`. -## What Survives +## Contents +- When to split +- The sizing rule +- Expand–contract +- Promote, don't mint +- Coverage and containment +- Authoring criteria +- Order by likelihood-of-change +- Own the decisions -- **The Right Size Test** — before finalizing a unit boundary, check: Can a single implementer complete this? If no, split by concern. Does it touch multiple unrelated concerns? If yes, split by concern. Will the agent need too much context? If yes, split into smaller coherent units. Are you splitting just to have more units? If yes, merge back. -- **Right-sizing rules** — one agent type per unit (completable by a single implementer), one concern per unit (one layer, service, or component), context-appropriate (fits in model context with room for exploration), not over-fragmented (don't split what naturally belongs together). -- **Per-unit verification discipline** — every unit includes its own observable done condition. Never a separate "verify" unit; keep tests with the code they test. -- **Own the decisions** — decide granularity and unit boundaries autonomously. Ask the user only when two orderings are genuinely equally valid with different trade-offs; otherwise decide and move on. +## When to split -## What Dies +A parent gets children only when its DoD needs more than one coherent slice. One criterion that is already right-sized stays on the parent. A checkbox becomes a sub-issue the moment it earns its own DoD — its own problem statement, its own out-of-scope, its own criteria. -Task-file minting, ID allocation, estimate fields, and ordering-for-execution as the default presentation. Implementation Units are in-document work packets — commit-boundary guides and review anchors — never tracked entities with IDs, statuses, or a persistence layer of their own. +Same problem, another slice → another criterion on this issue, or a promoted child if that slice now has its own DoD. A different problem discovered mid-shaping → a new backlog issue (`loaf issue new --status backlog`), not a child of this one. -## The New Principle: Order by Likelihood-of-Change +## The sizing rule -Breakdown ordered units for execution (dependency graph, then priority). Shaping orders units for review: data models, interfaces, and user-facing flows lead; mechanical refactors and boilerplate collapse at the bottom. The reviewer's attention at the draft-to-ready flip should land on what's most likely to need changing, not on whatever happens to run first. +One test, replacing the old four-question checklist: **a slice is right-sized when it fits one fresh context window and is verifiable alone.** -Sequencing constraints that genuinely exist — this unit must land before that one — are stated in prose within the unit description or a Planning Contract subsection. Never rely on list order alone to imply a dependency; a reviewer skimming units by likelihood-of-change won't read sequencing into position. +- If an implementer cannot pick the issue up in a new conversation and finish it without reading a sibling, split. +- If the done-check cannot run (or be reviewed) without another slice landing first, either split and `loaf issue link <predecessor> blocks <successor>`, or merge — do not leave a criterion that is only true in combination. +- If you are splitting just to have more rows, merge back. -## Authoring the Verification Contract +Per-slice verification stays with the slice. Never a separate "verify" child; keep tests with the code they test. -Split criteria into two groups, mirroring the Change template: +## Expand–contract -- **Executable (V-tier)** — bound to a command and an expected result; machine-checkable by `loaf change verify`. Two equivalent forms: +The named exception for wide mechanical refactors (a rename, a schema migration, an expand-then-remove of an old path). The slice may be wide in files touched and still be one issue, because it is one coherent mechanical motion and one verification: the suite still passes after the motion. - Inline (what the scaffold writes): +Do not use this exception to smuggle a second problem into the parent. A mechanical rename plus a behavior change is two slices. - ```markdown - - **V1.** What must be true. Command: `go test ./...`. Expect: exit 0. - - **V2.** Output-bound. Command: `loaf change check`. Expect: exit 0 and contains `executable`. - ``` +## Promote, don't mint - Or with an authoring checkbox still open: +```bash +loaf issue promote <ref> <position> +``` - ```markdown - - [**V1.** What must be true. Command: `go test ./...`. Expect: exit 0.] - ``` +The criterion at the 1-based position stays on the parent. A child **delivery** issue is created in `triage`, titled from the criterion text, with a copy of that criterion as its first DoD line and a claim already recorded from the copy to the parent. Coverage for that parent position holds by construction. + +Then shape the child: give it a problem body and an out-of-scope statement (`loaf issue edit` replaces the body), add the criteria that make *its* DoD complete, promote again if one of those earns its own DoD. + +`loaf issue promote` always mints a delivery child. Decision children are created separately: + +```bash +loaf issue new --kind decision --parent <ref> "Should the store be append-only?" +``` + +A decision child is ready when the title or body contains `?`. It does not claim a parent criterion. + +Once **any** child exists — delivery or decision — `loaf issue check` requires every parent criterion to be claimed. A leaf parent that grows a decision child without promoted (or otherwise claimed) criteria will fail coverage. Sequence the tail as: write DoD → promote every slice that will not execute on the parent → then add decision children. If the parent stays a leaf, leave remaining unsharp questions in create-time `fog` (there is no `--fog` on edit) or file sharpened ones as sibling decision issues (`loaf issue new --kind decision --status backlog`, no `--parent`). - Sub-bullet: +Manual claims, when a child criterion was added rather than promoted: - ```markdown - - **V1.** What must be true. - - Command: `go test ./...` - - Expect: exit 0 +```bash +loaf issue dod add <child> "Child done-check" --serves 1 +loaf issue dod claim <child> <child-position> <parent-position> +loaf issue dod unclaim <child> <child-position> <parent-position> +``` + +`--serves` claims the newly added child criterion against that parent position. `claim` / `unclaim` retarget an existing pair. + +## Coverage and containment + +`loaf issue check <ref>` runs these only when the issue has children. + +- **Coverage** (failure) — every parent criterion must be claimed by at least one child criterion. An uncovered position is named in the failure list; fix it by promoting that criterion or adding a claiming child criterion. +- **Containment** (report, not a failure) — every child criterion must claim a parent criterion. An orphan is printed with a ready-to-paste remedy that files it as a **sibling** backlog issue: + + ```bash + loaf issue new --parent '<parent>' --status backlog -- '<orphan text>' ``` - `Expect` is optional and enforced when present, with a deliberately minimal grammar: atoms join with ` and ` — `exit <N>` is the required exit code (an absent `Expect`, or an `Expect` with no exit atom, means `exit 0`) and `` contains `text` `` requires the combined stdout+stderr to contain that backtick-delimited text (repeatable). Any other clause is unenforceable: `loaf change verify` warns naming the criterion and the clause and records it as advisory, so an expectation is either checked or loudly not — never quietly decorative. Commands run from the **repository root** (never the change folder). Only V-entries that declare a fenced `Command:` value are gate input. + Run the printed remedy. Do not fold the orphan back onto the parent, and do not treat it as in-scope work that somehow escaped the DoD — it is a new backlog row under the same parent. + +A different problem (not an orphan criterion) is not a sibling of this decomposition. File it as a new backlog issue with no `--parent`. -- **Human review (H-tier)** — what a reviewer confirms that no command can. H-entries are review material and are **never** gate input; `loaf change verify` ignores them. +## Authoring criteria + +```bash +loaf issue dod add <ref> <text> [--command <cmd>] [--expect <expect>] [--tier V|H] [--serves <parent-position>] +``` + +- **V-tier** — used when `--command` is present, unless `--tier` overrides. Machine-checkable by `loaf issue verify <ref>`. Commands run from the **repository root**. +- **H-tier** — default when `--command` is absent. Human review; never gate input. `loaf issue verify` skips H-tier rows. + +`--expect` is optional and enforced when present, with a deliberately minimal grammar: atoms join with ` and ` — `exit <N>` is the required exit code (an absent `--expect`, or one with no exit atom, means `exit 0`) and `` contains `text` `` requires the combined stdout+stderr to contain that backtick-delimited text (repeatable). Any other clause is unenforceable: verify warns naming the criterion and the clause and records it as advisory, so an expectation is either checked or loudly not — never quietly decorative. + +```bash +loaf issue dod add LOAF-42 "Package tests pass" --command "go test ./..." --expect "exit 0" +loaf issue dod add LOAF-42 "Check names the uncovered criterion" --command "loaf issue check LOAF-42" --expect "exit 0 and contains \`uncovered\`" +loaf issue dod add LOAF-42 "The writeup is readable by someone new to the area" --tier H +``` A criterion whose check only restates the implementation (recomputing the expected value the way the code does) is vacuous — it can never disagree with the code under test. Prefer criteria with an independent source of truth. + +`loaf issue dod list <ref>` prints the current lines. `loaf issue dod remove <ref> <position>` removes one (positions then compact). + +## Order by likelihood-of-change + +Present children for review, not for execution order: data models, interfaces, and user-facing flows lead; mechanical refactors and boilerplate collapse at the bottom. The reviewer's attention should land on what's most likely to need changing, not on whatever happens to run first. + +Sequencing constraints that genuinely exist — this child must land before that one — are recorded as relationships: + +```bash +loaf issue link <from> blocks <to> +loaf issue link <from> relates-to <to> +loaf issue link <from> remove blocks <to> +``` + +Never rely on `loaf issue tree` order to imply a dependency; a reviewer skimming by likelihood-of-change won't read sequencing into position. `loaf issue bucket <ref> now|next|later|none` is an advisory label only and is never read as a constraint. + +## Own the decisions + +Decide granularity and slice boundaries autonomously. Ask the user only when two orderings are genuinely equally valid with different trade-offs; otherwise decide and move on. diff --git a/plugins/loaf/skills/shape/references/grilling.md b/plugins/loaf/skills/shape/references/grilling.md index cd4d6a575..4cf6437dd 100644 --- a/plugins/loaf/skills/shape/references/grilling.md +++ b/plugins/loaf/skills/shape/references/grilling.md @@ -1,8 +1,8 @@ # Grilling -The interview technique for `[KU]` entries — known unknowns precise enough to phrase as a question. Imported from the reviewed `grill-me` / `grill-with-docs` pattern (`docs/changes/20260704-shape-first-change-workflow/research/mattpocock-review/`), sharpened with the Field Guide's architectural-impact ordering. +The interview technique for `[KU]` entries — known unknowns precise enough to phrase as a question. Imported from the reviewed `grill-me` / `grill-with-docs` pattern, sharpened with architectural-impact ordering. -This is a distinct, shape-specific technique — not the shared `templates/grilling.md` used by `architecture` and `refactor-deepen`. That template's glossary-mutation duty doesn't fit shape's ambiguity-resolving step (see the deferral rationale it documents); shape's grilling stays scoped to fog entries and never writes to the domain glossary. +This is a distinct, shape-specific technique — not the shared `templates/grilling.md` used by `architecture` and `refactor-deepen`. That template's glossary-mutation duty doesn't fit shape's ambiguity-resolving step (see the rationale it documents); shape's grilling stays scoped to fog entries and never writes to the domain glossary. ## The Mechanic @@ -14,17 +14,21 @@ Every question carries a recommended answer with rationale — never "what do yo Prioritize questions whose answer would change the architecture. Cosmetic questions — naming, ordering, presentation — go last, even when they're easier to answer. An architecture-changing answer received late can invalidate everything decided in between; asking it first avoids that rework. -Before asking, check whether reading resolves the question — an existing ADR, a prior Change, a journal entry. Only ask what reading couldn't answer. +Before asking, check whether reading resolves the question — an existing ADR, a prior issue, a journal entry. Only ask what reading couldn't answer. ## Stop Condition Stop when either holds: - No unrouted `[KU]` entries remain. -- Answers stop changing the contract — the last several questions confirmed direction rather than altering it. +- Answers stop changing the issue — the last several questions confirmed direction rather than altering the body, the criteria, or the children. -Grilling never opens the session. Context gathering and the blindspot-pass offer come first; an interview without that groundwork asks questions the codebase or journal could have answered for free. +Write each accepted answer into the issue as it lands: `loaf issue edit` for the body, `loaf issue dod add` for a new done-check, `loaf issue new --kind decision --parent <ref>` when the answer is itself a sharp question that still needs a later call. Do not leave a resolved `[KU]` only in the conversation. ## Mid-Interview Reroute If a question turns out to need domain fluency the shaper doesn't have — the follow-up can't even be phrased — stop grilling it and route the entry to the blindspot pass instead of guessing at an answer. + +## Opening + +Grilling never opens the session. Context gathering and the blindspot-pass offer come first; an interview without that groundwork asks questions the codebase or journal could have answered for free. diff --git a/plugins/loaf/skills/shape/references/reaction-artifact.md b/plugins/loaf/skills/shape/references/reaction-artifact.md index d3254bed0..ca30f7485 100644 --- a/plugins/loaf/skills/shape/references/reaction-artifact.md +++ b/plugins/loaf/skills/shape/references/reaction-artifact.md @@ -8,11 +8,11 @@ Before building anything, state precisely what this artifact must let the user d ## Build the Smallest Thing That Lets the User React -Construct the minimum artifact that resolves the named unknown — competing layouts, a comparison of approaches, a rendered mock. Write it into the Change's own `research/` folder, never `.agents/reports/`; this is shape's own technique, executed in-session, not a handoff to the research skill. +Construct the minimum artifact that resolves the named unknown — competing layouts, a comparison of approaches, a rendered mock. Keep it in the session or in a throwaway file you will discard. This is shape's own technique, executed in-session, not a handoff to the research skill and not a committed plan document. Do not write it into `.agents/reports/`. ## Capture the Answer, Discard the Shell -Once the user reacts and picks, write the decision — with rationale — back into the Change: the Decisions log, or the relevant Planning Contract subsection. The artifact itself is not the deliverable; the choice it produced is. Discard or archive the shell once the answer is captured. If a piece of logic proved out along the way is worth keeping (a validated approach, not just a throwaway shell), note that explicitly — but don't let scaffolding survive under the pretense that it's production code. +Once the user reacts and picks, write the decision — with rationale — back onto the issue: the body via `loaf issue edit`, or a `--kind decision` child when the pick is itself a sharp question that still needs a later call. The artifact itself is not the deliverable; the choice it produced is. Discard the shell once the answer is captured. If a piece of logic proved out along the way is worth keeping (a validated approach, not just a throwaway shell), note that explicitly in the issue body — but don't let scaffolding survive under the pretense that it's production code. ## Never a Deliverable diff --git a/plugins/loaf/skills/shape/templates/brief.md b/plugins/loaf/skills/shape/templates/brief.md deleted file mode 100644 index 05016c8c8..000000000 --- a/plugins/loaf/skills/shape/templates/brief.md +++ /dev/null @@ -1,42 +0,0 @@ -<!-- brief.md is the optional archeological kickstart — the original unshaped ask. - May accrete parked problem-space concepts while the change is captured; freezes when shape.md exists. - Superseded by shape.md; never mechanically load-bearing. - A brief-only folder is legal and non-executable (captured, not shaped). --> - -# [Brief title] - -## Problem Statement - -[What friction, gap, or unmet need exists? Be specific about the pain — vague problems produce vague solutions. Problem-space only; do not design the approach here.] - -## Who Has It - -[Who experiences this problem? Role, context, and how often the pain shows up. Avoid unqualified "users" or "developers."] - -## Current Alternatives - -[What do they do today? Existing tools, manual workarounds, or "nothing" are all valid. Understanding the status quo clarifies what better means.] - -## Value Proposition - -[Why is solving this worth it? What becomes true for the people who have the problem if this lands? Describe value, not features or architecture.] - -## Constraints - -[Non-negotiable bounds: technical, legal, organizational, or philosophical. Things that limit the solution space before design begins.] - -- [Constraint 1] - -## Sequencing and Relationships - -[How this relates to other work — series order, release cohort, dependencies stated as prose. No machine relation fields; narrative order only.] - -## Sources and Research Links - -[Evidence that informed this framing — competitive scans, research notes, issue links, prior art. Link rather than paste.] - -## Open Questions - -[Unresolved problem-space items. Mark urgency: blocking (must resolve before shaping) or deferrable.] - -- [ ] [Question] — blocking | deferrable diff --git a/plugins/loaf/skills/shape/templates/change.md b/plugins/loaf/skills/shape/templates/change.md deleted file mode 100644 index 6ce5b9725..000000000 --- a/plugins/loaf/skills/shape/templates/change.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -change: [slug] -created: [YYYY-MM-DD] -branch: [slug] ---- - -<!-- Frontmatter must open the file at byte one — parsers depend on it. No status-like frontmatter (readiness/status/state): readiness is derived — a draft PR is shaping; `loaf change check` derives structural executability from the sections below. --> - -# [Change Title] - -## Problem - -[Why this work exists — the friction, gap, or rot being addressed.] - -## Hypothesis - -[The bet: what becomes true if this ships, and why it is worth making.] - -## Scope - -**In** - -- [What this Change delivers.] - -**Out** (deferred, not rejected) - -- [What is explicitly postponed, and where it went.] - -**Cut** (explicitly rejected) - -- [What this Change will not do, ever.] - -## Observable Workflow - -[What someone sees or does once this ships — commands, flows, UX. Concrete over abstract.] - -## Rabbit Holes and No-Gos - -[Boundaries: the ways this work could quietly grow into something it must not become.] - -## Decisions - -Provenance: [how each decision was accepted — interview, review, dogfooding.] - -1. **[Decision.]** [Rationale, and what it forecloses.] - -## Planning Contract - -[The HOW. Free-form `###` subsections named by the work — the container is the contract; the subsection names are yours.] - -### [Approach / Placement / Risks / Sequencing / Spike findings …] - -[...] - -## Implementation Units - -<!-- In-document work packets — commit-boundary guides and review anchors, not tracked entities. --> - -- [**U1 — Unit name.** What it delivers.] - -## Verification Contract - -<!-- Executable (machine-checkable): --> - -- [**V1.** Criterion bound to a command and an expected result.] - -<!-- Human review: --> - -- [**H1.** What a reviewer confirms that no command can.] - -## Definition of Done - -- [Derived from gates and review — never a status flag.] - -## Durable Outputs - -[Specs, ADRs, knowledge docs, or schema updates to create after implementation proves what is now true. A final spec describes reality, not a plan.] - -## Open Questions - -<!-- Fog register: tag entries [KU]/[UK]/[UU] with a route — see the shape skill's quadrant table. Tags are convention, never parsed by check. --> - -- [Known unknowns, each owned by a section, a spike, or a follow-up.] - -## Source Inputs - -- [Where this Change came from: journal entries (cite by ID), sparks, ideas, brainstorms, issues, conversations, prior Changes.] - -<!-- Optional sections, added when they earn their place: Background, Success Metrics (when validation matters), Follow-ups, Critique Gate. --> diff --git a/plugins/loaf/skills/shape/templates/design.md b/plugins/loaf/skills/shape/templates/design.md deleted file mode 100644 index 2244ccdb6..000000000 --- a/plugins/loaf/skills/shape/templates/design.md +++ /dev/null @@ -1,20 +0,0 @@ -<!-- design.md is the optional design surface for UI, protocol, or schema detail - that would crowd shape.md or plan.md. Accretive; not load-bearing for the gate. --> - -# Design — [Change Title] - -## Intent - -[What this design clarifies that shape.md does not.] - -## Surfaces - -[Screens, APIs, schemas, or protocols under design.] - -## Decisions - -1. **[Decision.]** [Rationale.] - -## Open questions - -- [What remains undecided.] diff --git a/plugins/loaf/skills/shape/templates/plan.md b/plugins/loaf/skills/shape/templates/plan.md deleted file mode 100644 index 6d17a7179..000000000 --- a/plugins/loaf/skills/shape/templates/plan.md +++ /dev/null @@ -1,21 +0,0 @@ -<!-- plan.md is the optional technical route (the corpus's plan sense). - Accretive during shaping; approach churn here never expires a cohort receipt. - Criteria live in shape.md — do not relocate Verification Contract here. --> - -# Plan — [Change Title] - -## Approach - -[How the work lands — architecture, sequencing, compatibility.] - -## Placement - -[Where code and docs live; what stays out of scope for this route.] - -## Risks - -[Failure modes and the safe failure direction.] - -## Sequencing - -[Ordered slices that leave main coherent at each landing.] diff --git a/plugins/loaf/skills/shape/templates/pr.md b/plugins/loaf/skills/shape/templates/pr.md deleted file mode 100644 index 91b4b7401..000000000 --- a/plugins/loaf/skills/shape/templates/pr.md +++ /dev/null @@ -1,25 +0,0 @@ -<!-- Draft = still shaping. Ready for review = structurally executable, not proof of implementation completion. --> - -## Change - -<!-- Link the Change folder this PR advances: docs/changes/YYYYMMDD-slug/ The Change artifact lives in this PR's diff — the draft PR is its shaping surface. During coexistence, legacy PRs implementing a numbered spec link the .agents/specs/ path here instead. Delete this section for PRs tied to neither. --> - -## What & Why - -<!-- What this PR does and the problem it solves. For a shaping (draft) PR, summarize the direction; for an implementation PR, summarize the delta. --> - -## Review focus - -<!-- Where reviewer attention pays off: decisions to challenge, boundaries to verify, criteria to confirm. --> - -## Verification - -<!-- What proves this works: gates run (`loaf change check`, `loaf check`), tests, commands a reviewer should re-run locally. On a draft, state what remains open instead. --> - -## Migration / breaking changes - -<!-- If this PR changes user-facing behavior, document the migration step. Otherwise: "None." --> - -## Deferred - -<!-- Intentionally out of scope, and where it went (follow-up Change, open question, issue). Delete if nothing was deferred. --> diff --git a/plugins/loaf/skills/shape/templates/shape.md b/plugins/loaf/skills/shape/templates/shape.md deleted file mode 100644 index 59aa9b89a..000000000 --- a/plugins/loaf/skills/shape/templates/shape.md +++ /dev/null @@ -1,77 +0,0 @@ -<!-- shape.md is the change contract. Identity lives in change.json — no status-like frontmatter. Readiness is derived: a draft PR is shaping; `loaf change check` derives structural executability from the sections below. --> - -# [Change Title] - -## Problem - -[Why this work exists — the friction, gap, or rot being addressed.] - -## Hypothesis - -[The bet: what becomes true if this ships, and why it is worth making.] - -## Scope - -**In** - -- [What this Change delivers.] - -**Out** (deferred, not rejected) - -- [What is explicitly postponed, and where it went.] - -**Cut** (explicitly rejected) - -- [What this Change will not do, ever.] - -## Observable Workflow - -[What someone sees or does once this ships — commands, flows, UX. Concrete over abstract.] - -## Rabbit Holes and No-Gos - -[Boundaries: the ways this work could quietly grow into something it must not become.] - -## Decisions - -Provenance: [how each decision was accepted — interview, review, dogfooding.] - -1. **[Decision.]** [Rationale, and what it forecloses.] - -## Planning Contract - -<!-- The HOW. Prefer plan.md/design.md when the route needs its own file; keep this container. Free-form ### subsections named by the work. --> - -### [Approach / Placement / Risks / Sequencing …] - -[…] - -## Implementation Units - -<!-- Task packets live in tasks/TASK-NNN-slug.md; this section may summarize the decomposition. --> - -- [**TASK-001 — Unit name.** What it delivers.] - -## Verification Contract - -<!-- Executable (machine-checkable): each V-entry declares Command and Expect for loaf change verify. Expect is a grammar, not prose: atoms join with " and " — `exit <N>` is the required exit code (omit the atom, or Expect entirely, for exit 0; a second exit atom is a contradiction and fails the criterion) and contains `text` requires the combined stdout+stderr to contain that backtick-delimited text (repeatable). Example: Expect: exit 0 and contains `all green`. Any other clause is unenforceable: verify warns naming the criterion and clause, records it as advisory, and never checks it. --> - -- [**V1.** What must be true. Command: `exact command`. Expect: exit 0.] - -<!-- Human review (H-tier): review material, never gate input. --> - -- [**H1.** What a reviewer confirms that no command can.] - -## Definition of Done - -- [Derived from gates and review — never a status flag.] - -## Durable Outputs - -[Specs, ADRs, knowledge docs, or schema updates to create after implementation proves what is now true.] - -## Open Questions - -<!-- Fog register: tag entries [KU]/[UK]/[UU] with a route. Tags are convention, never parsed by check. --> - -- [KU] [Known unknown → route to a task or later change] diff --git a/plugins/loaf/skills/shape/templates/task.md b/plugins/loaf/skills/shape/templates/task.md deleted file mode 100644 index 05de15307..000000000 --- a/plugins/loaf/skills/shape/templates/task.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -change: [slug] -id: TASK-NNN -title: [short title] -# Relations (closed set; targets are TASK-NNN within this change only): -# parent: TASK-NNN -# blocks: -# - TASK-NNN -# blocked-by: -# - TASK-NNN -# relates-to: -# - TASK-NNN ---- - -# TASK-NNN — [Title] - -## Objective - -[What this task delivers when its checkboxes are flipped.] - -## Scope boundaries - -**In:** [What this task may touch.] - -**Out:** [What this task must not touch — other tasks, other changes, deferred work.] - -## Context pointers - -- Contract: `shape.md` — [relevant sections] -- Research: [paths under research/ when cited] - -## Acquisition - -```bash -loaf journal log "skill(implement): TASK-NNN — [short intent]" -# [commands or files to load before editing] -``` - -## Steps - -- [ ] [Atomic step — one commit's worth when flipped with the delivering work] -- [ ] [Next step] - -## Verification - -- [Commands or checks that prove this task alone] -- The slug never cites other work units (`TASK-…`, `SPEC-…`, issue keys) — identity is local; provenance is in frontmatter and the change folder. diff --git a/plugins/loaf/skills/ship/SKILL.md b/plugins/loaf/skills/ship/SKILL.md index 39aae312c..124dcd255 100644 --- a/plugins/loaf/skills/ship/SKILL.md +++ b/plugins/loaf/skills/ship/SKILL.md @@ -1,11 +1,14 @@ --- name: ship description: >- - Reviews, verifies, and lands one pull request. Use when the user says "ship - it," "merge this PR," "ready to merge," "land this branch," or asks for a - final merge gate. Produces a reviewed, squash-merged PR and post-merge - cleanup. Not for version bumps, tags, GitHub Releases, or install verification - (use release). + Reviews, verifies, and lands one pull request — the sole quality gate before + work can appear in a later release cut. Use when the user says "ship it," + "merge this PR," "ready to merge," "land this branch," or asks for a final + merge gate. Binds the PR to an issue: the body is `loaf issue render` output, + definition-of-done criteria are the review checklist, and landing marks the + issue done and stops its worktree. Produces a reviewed, squash-merged PR and + post-merge cleanup. Not for version bumps, tags, GitHub Releases, or install + verification (use release). user-invocable: true argument-hint: '[PR number or URL]' version: 0.2.21 @@ -13,7 +16,7 @@ version: 0.2.21 # Ship -Review, verify, and land one PR. Shipping is the PR gate; releasing is the version-publication gate. +Review, verify, and land one PR. Ship's review is the quality gate for everything that will ever ship. Releases are retroactive — they cut a version from already-landed work. Nothing between merge and the next release cut re-checks the diff, the tests, or the issue. A rubber-stamped PR ships in the next cut with no second net. ## Contents - Critical Rules @@ -23,7 +26,7 @@ Review, verify, and land one PR. Shipping is the PR gate; releasing is the versi - Context Detection - Step 1: PR Readiness - Step 1b: Stacked PR Detection -- Step 2: Evidence Review +- Step 2: Definition-of-Done Review - Step 3: Local Verification - Step 4: Squash Merge - Step 5: Post-Merge Cleanup @@ -37,64 +40,94 @@ Review, verify, and land one PR. Shipping is the PR gate; releasing is the versi ## Critical Rules -- **Ship is not release** -- do not bump versions, create tags, publish GitHub Releases, or verify package installation here. -- **Keep PR quality local** -- smaller PRs are welcome, but ship must still verify correctness before merge. -- **Detect-first** -- auto-detect the PR from the current branch before asking for a PR number. -- **Review before merge** -- inspect code, docs, tests, changelog, PR body, and CI state before approval. -- **Never merge without explicit confirmation** -- present the PR, checks, findings, and squash body first. -- **Detect the stack before merging** -- another open PR may use this PR's head branch as its base. Find out before merge, never after, and never delete a head branch while a child PR still points at it. -- **Clean squash body** -- write an intentional squash commit body; never accept the automatic commit dump. -- **Keep landed and released distinct** -- after merge, describe the PR as landed or shipped, not necessarily released. -- **Log shipping** -- after merge, run `loaf journal log "decision(ship): PR #N landed via squash merge"`. +1. **Log invocation first** — `loaf journal log "skill(ship): shipping <ref or PR or current branch>"` before doing anything else. After merge, log `loaf journal log "decision(ship): PR #N landed via squash merge; <ref> done"`. +2. **Rigor is load-bearing** — this review is the only quality gate. Releases cut from landed work; they do not re-check. If the review is thin, the next cut still publishes it. +3. **Ship is not release** — do not bump versions, create tags, publish GitHub Releases, or verify package installation here. Use the release skill for that. +4. **Bind the PR to an issue** — the PR body is `loaf issue render <ref>` (paste-ready, no manual editing). The issue's definition-of-done criteria are the review checklist. `loaf issue verify <ref>` runs the executable (V-tier) rows and writes nothing. Landing means `loaf issue status <ref> done`. Then `loaf issue stop <ref>` removes the started worktree. +5. **Detect-first** — auto-detect the PR from the current branch, and the issue from `$ARGUMENTS` or the started workspace, before asking for a PR number or issue ref. +6. **Review before merge** — inspect code, docs, tests, changelog, the rendered issue body, definition of done, and CI state before approval. +7. **Never merge without explicit confirmation** — present the PR, checks, review notes, and squash body first, using your harness's structured question tool if it has one. +8. **Detect the stack before merging** — another open PR may use this PR's head branch as its base. Find out before merge, never after, and never delete a head branch while a child PR still points at it. +9. **Clean squash body** — write an intentional squash commit body; never accept the automatic commit dump. +10. **Keep landed and released distinct** — after merge, describe the PR as landed or shipped. It is not released until release publishes a version. + +--- ## Verification +- Invocation is logged to the project journal before review work begins - PR identity, base branch, and head branch are confirmed +- The PR is bound to one issue; `loaf issue show <ref>` is the issue surface +- PR body matches `loaf issue render <ref>` with no manual editing +- Every definition-of-done criterion was reviewed against the diff; H-tier by reading, V-tier by `loaf issue verify <ref>` (writes nothing; exit non-zero blocks) - CI status is passing or the user explicitly accepts named non-blocking checks - Relevant local checks pass or failures are fixed before merge - PR body and durable docs do not overclaim relative to the diff - Squash commit title/body are clean, conventional, and user-facing - Child PRs stacked on this PR's head branch are enumerated before merge, and each is retargeted, rebased, and re-verified after it -- Base branch is updated after merge and the feature branch cleanup state is known +- After merge: `loaf issue status <ref> done`, then `loaf issue stop <ref>` if a worktree was started, base branch updated, feature-branch cleanup state known +- A `decision(ship)` journal entry records the landing ## Quick Reference | Step | Gate | Blocking? | |------|------|-----------| +| Context Detection | PR and issue bound | Yes | | PR Readiness | PR exists, target base known, CI state reviewed | Yes | | Stacked PR Detection | child PRs on this head branch are enumerated | Yes | -| Evidence Review | findings resolved or explicitly accepted | Yes | -| Local Verification | relevant checks pass | Yes | +| Definition-of-Done Review | every criterion reviewed; `loaf issue verify` passes or reports no V-tier rows | Yes | +| Local Verification | relevant project checks pass | Yes | | Squash Merge | user approves body text | Yes | -| Cleanup | base pulled, children retargeted and rebased, branch deletion handled | Yes when a child exists | -| Release Suggestion | enough landed work may justify release | No | +| Cleanup | issue marked done, started worktree stopped, base pulled, children retargeted and rebased, branch deletion handled | Yes when a child PR exists; done + stop always | +| Release Suggestion | enough landed work may justify a later cut | No | ## Topics | Topic | Use When | |-------|----------| -| [Context Detection](#context-detection) | Determining current branch and PR state | +| [Context Detection](#context-detection) | Binding the current branch to a PR and an issue | +| [Definition-of-Done Review](#step-2-definition-of-done-review) | Using issue criteria as the merge checklist | | [Hook Interaction](#hook-interaction) | Understanding coexistence with git hooks | --- ## Context Detection -Before anything, detect the PR surface: +Log the invocation, then detect the PR and the issue. + +### PR 1. Get current branch and repo default branch: ```bash git branch --show-current gh repo view --json defaultBranchRef -q .defaultBranchRef.name ``` -2. Parse `$ARGUMENTS`: may be a PR number, PR URL, branch name, or empty. -3. If `$ARGUMENTS` is empty, auto-detect from the current branch: +2. Parse `$ARGUMENTS`: may be an issue ref (`LOAF-42`), a PR number, a PR URL, a branch name, or empty. +3. If `$ARGUMENTS` is empty or is not a PR identity, auto-detect from the current branch: ```bash gh pr view --json number,title,url,headRefName,baseRefName,state,mergeStateStatus,isDraft ``` 4. If no PR exists for the current branch, stop and offer to create one via `git-workflow` rather than silently merging a branch. 5. If already on the default branch, stop. There is no PR to ship from the current branch. -6. Confirm PR identity with the user before merge actions. + +### Issue + +Issue commands require initialized SQLite state. Bind exactly one issue: + +1. If `$ARGUMENTS` (or a remaining token) is an issue ref, load it: + ```bash + loaf issue show <ref> + ``` +2. Otherwise match the PR's `headRefName` to a started workspace: + ```bash + loaf issue list --started + ``` + Columns are alias, title, `started_branch`, `started_worktree`. The started branch from `loaf issue start` is `issue/<alias-or-id>` in lowercase (`issue/loaf-42`), disambiguated with an id suffix when that name is already claimed. +3. Confirm with `loaf issue show <ref>` — `started_branch` / `started_worktree` should match this PR when the issue was started. + +If nothing binds, stop and ask for the issue ref. Do not invent a row during ship. If `loaf issue show` reports the issue archived (`cancelled` or `duplicate`), stop. + +Confirm PR identity and the bound issue with the user before merge actions. --- @@ -106,6 +139,14 @@ Inspect the PR's declared state: gh pr view <N> --json number,title,body,url,headRefName,baseRefName,state,isDraft,mergeStateStatus,reviewDecision,statusCheckRollup ``` +The body must be `loaf issue render <ref>` output — title, issue body, definition-of-done checkboxes (checked only when status is already `done`), and children. No project headers, no hand-edited summary. If the live PR body differs, replace it: + +```bash +gh pr edit <N> --body "$(loaf issue render <ref>)" +``` + +Do not rewrite the markdown by hand. Checkboxes are unchecked until `loaf issue status <ref> done`; do not tick them in the PR body to fake completion. + Block or pause when: - PR is draft @@ -126,20 +167,27 @@ Before merging anything, find out whether another open PR uses this PR's head br gh pr list --state open --base <headRefName> --json number,title,headRefName ``` -Any result is a **child PR**, and this PR is the base of a stack. Record the list now, because after the merge the relationship is harder to see and easier to break. +Any result is a **child PR**, and this PR is the base of a stack. Record the list now, because after the merge the relationship is harder to see and easier to break. Child PRs are GitHub stacking, not child issues. -When a child exists: +When a child PR exists: - **Do not pass `--delete-branch` to the merge.** Removing the head branch while a child still points at it can close the child outright. Delete it only after every child has been retargeted, in Step 5. - Tell the user the stack exists and name the children before asking for merge confirmation. A stack changes what "merge this" means. -When no child exists, say so, and `--delete-branch` is safe. +When no child PR exists, say so, and `--delete-branch` is safe. --- -## Step 2: Evidence Review +## Step 2: Definition-of-Done Review + +The issue's definition of done is the merge checklist. Load it from the issue, not from memory: + +```bash +loaf issue dod list <ref> +loaf issue show <ref> +``` -Review the landing diff and durable prose together: +`show` prints each criterion as `position. [V|H] text` with `command=` / `expect=` when present. Walk every row against the landing diff. 1. Gather diff context: ```bash @@ -147,13 +195,19 @@ Review the landing diff and durable prose together: git diff --stat origin/<baseRefName>...HEAD git diff --name-only origin/<baseRefName>...HEAD ``` -2. Read the PR title/body and changed docs that make behavior claims. -3. Check for drift: - - PR body claims features that are not in the diff - - changelog entries mention unreleased or unrelated behavior +2. For each **H-tier** criterion, read the diff and durable prose and decide whether the text is met. `loaf issue verify` skips H-tier rows — that skip is not a pass; you are the check. +3. Run the **V-tier** rows: + ```bash + loaf issue verify <ref> + ``` + Commands run from the **repository root**. The command honors `exit <N>` and `` contains `text` ``. It writes nothing — it does not tick checkboxes, does not set status, and does not record a run. Non-zero exit blocks merge. `no executable V-tier criteria on <ref>` is not a failure; H-tier review still is. Unenforceable expect clauses print as advisory warnings and are never quietly decorative. +4. Check for drift: + - Rendered issue body claims features that are not in the diff + - changelog entries mention unrelated behavior - docs describe future work as already shipped - - comments or runbooks use stale internal vocabulary -4. Fix blocking drift before merge. For non-blocking polish, name it and let the user decide. + - comments or runbooks use stale vocabulary +5. If `loaf issue show` lists child issues that are not `done`, name them before asking to merge. Do not mark those children done unless this PR is theirs. +6. Fix blocking drift and unmet criteria before merge. For non-blocking polish, name it and let the user decide. For high-risk PRs, use the project's review skill or read-only review flow before proceeding. @@ -161,7 +215,7 @@ For high-risk PRs, use the project's review skill or read-only review flow befor ## Step 3: Local Verification -Run the checks the project supports. Examples: +Run the checks the project supports, in addition to `loaf issue verify`. Examples: - Node: `npm run typecheck`, `npm run test`, `npm run build` - Go: `go vet ./...`, `go test ./...` @@ -180,7 +234,7 @@ Use the repo's documented pre-commit or pre-PR checklist when present. Stop on f ## Step 4: Squash Merge -Draft a clean squash body from the reviewed diff and PR body: +Draft a clean squash body from the reviewed diff and the rendered issue: - One-line summary, then bullet points grouped by feature area - Plain text; use backticks only for code identifiers @@ -203,31 +257,41 @@ Let GitHub default the title from the PR title so the squash subject remains `ty ## Step 5: Post-Merge Cleanup -After a successful merge: +After a successful merge, leave the started worktree before removing it. Do not run `loaf issue stop` from inside that worktree. -1. Switch to the PR base branch: +1. Switch to the PR base branch in the repository checkout: ```bash git checkout <baseRefName> git pull --ff-only origin <baseRefName> ``` -2. Delete the local feature branch when safe: +2. Mark the bound issue done — this is what "done" means; `loaf issue stop` does not change status: + ```bash + loaf issue status <ref> done + ``` + Write statuses (`triage`, `backlog`, `todo`, `active`, `done`) update in place. Do not use `cancelled` or `duplicate` here. +3. Stop the started worktree if one exists. `loaf issue stop` removes the worktree, clears `started_branch` / `started_worktree` on the row, and **keeps the branch**: + ```bash + loaf issue stop <ref> + ``` + If the issue was never started, the command errors with `issue <ref> is not started` — treat that as already clean and continue. If the worktree is dirty, do not pass `--force` without user confirmation. +4. Delete the local feature branch when safe: ```bash git branch -d <headRefName> ``` -3. Confirm the remote branch deletion state from GitHub output or run: +5. Confirm the remote branch deletion state from GitHub output or run: ```bash gh pr view <N> --json headRefName,state ``` -4. Log the landing to the project journal: +6. Log the landing: ```bash - loaf journal log "decision(ship): PR #N landed via squash merge" + loaf journal log "decision(ship): PR #N landed via squash merge; <ref> done" ``` -If cleanup fails, report the exact residual state. Do not force-delete without user confirmation. +If cleanup fails, report the exact residual state (issue status, whether the worktree is still started, which branches remain). Do not force-delete without user confirmation. ### Stacked child PRs -Every child recorded in Step 1b needs three repairs before it can ship, and none of them happen on their own. +Every child PR recorded in Step 1b needs three repairs before it can ship, and none of them happen on their own. **Retarget the base.** GitHub does not reliably move a child's base when its base branch merges. Check, and move it explicitly: @@ -249,7 +313,7 @@ git diff --stat <baseRefName>...HEAD | tail -1 gh pr view <child> --json changedFiles -q .changedFiles ``` -Then force-push with a lease, wait for CI to finish on the **rebased head** rather than trusting the previous run, and only then ship the child. Delete the parent's head branch once no child points at it. +Then force-push with a lease, wait for CI to finish on the **rebased head** rather than trusting the previous run, and only then ship the child. Delete the parent's head branch once no child PR points at it. Report the numbers rather than asserting success: the before and after file counts, the commits the rebase skipped, and the CI conclusion on the new head. @@ -257,11 +321,11 @@ Report the numbers rather than asserting success: the before and after file coun ## Step 6: Release Suggestion -After landing, decide whether to suggest release: +After landing, decide whether to suggest the release skill. That cut will not re-run this review. -- Suggest release when the landed PR completes a coherent batch, user-facing feature, fix train, or release branch. +- Suggest release when the landed PR completes a coherent batch, user-facing feature, or fix train. - Do not suggest release for every small PR by default. -- If multiple related PRs are expected, say the PR is landed and can wait for a later batched release. +- If more related PRs are expected, say this PR is landed and can wait for a later cut. Use language carefully: the PR is **landed** or **shipped**; it is not **released** until release publishes a version. @@ -285,12 +349,12 @@ Do not disable hooks to force a PR through. ## Suggests Next -After a successful ship, suggest release only when the landed work forms a coherent release batch or the user asks to publish. +After a successful ship, suggest release only when the landed work forms a coherent batch or the user asks to publish. Release will not re-check the landed PR. ## Related Skills -- **release** -- Publishes a version from already-landed work -- **git-workflow** -- Branching, PR, commit, and squash merge conventions -- **foundations** -- Verification, code review, and production readiness -- **documentation-standards** -- Changelog, docs, and durable prose quality -- **reflect** -- Updates strategy from significant shipped work +- **release** — Publishes a version from already-landed work; does not re-review those PRs +- **git-workflow** — Branching, PR, commit, and squash merge conventions +- **foundations** — Verification, code review, and production readiness +- **documentation-standards** — Changelog, docs, and durable prose quality +- **reflect** — Updates strategy from significant shipped work diff --git a/plugins/loaf/skills/triage/SKILL.md b/plugins/loaf/skills/triage/SKILL.md index 25bcdec6f..4a6f7ff91 100644 --- a/plugins/loaf/skills/triage/SKILL.md +++ b/plugins/loaf/skills/triage/SKILL.md @@ -2,13 +2,13 @@ name: triage description: >- Processes the local intake queue from loaf intake list: unresolved sparks, - ideas, brainstorms, tracked and deferred Intents, and unmigrated legacy - deferrals. Use when the user asks "triage", "process my backlog", or wants - dispositions chosen across intake items. Produces explicit dispositions: - discard, retain, track as Intent, defer, resume, resolve, explore, hand to - pitch, or hand to shape. Not for reading a single known item (use loaf intent - show or journal directly), capturing new ideas (use idea), problem discovery - (use pitch), or bounding one chosen direction (use shape). + ideas, and brainstorms. Use when the user asks "triage", "process my backlog", + or wants dispositions chosen across intake items. Produces explicit + dispositions: discard, retain as spark/idea, file as backlog issue, resume + exploration, resolve, hand to pitch, or hand to shape (issue preparation). Not + for reading a single known item (use loaf issue show, loaf spark show, loaf + idea show, or journal directly), capturing new ideas (use idea), problem + discovery (use pitch), or bounding one chosen direction (use shape). user-invocable: true version: 0.2.21 --- @@ -27,7 +27,7 @@ Process the intake queue. Triage is the public funnel where captured material me - Quick Reference - Process - Dispositions -- Legacy Deferrals +- Leftover kinds - Guardrails - Related Skills @@ -37,62 +37,67 @@ Process the intake queue. Triage is the public funnel where captured material me - Read the queue with `loaf intake list --json`; it projects every unresolved logical item exactly once with its provenance and exact read command. - Present everything before acting — the user decides each disposition; never auto-promote, auto-discard, or auto-convert. - The CLI never classifies: you and the user interpret each item; commands perform the chosen operation deterministically. -- Capture, Intent, and Exploration are different claims: a spark or idea is retained material, a tracked Intent is deliberately tracked work, a deferral is an Intent disposition with an immutable payload, an Exploration is an inquiry. Do not conflate them to save a step. +- Capture, issue, and Exploration are different claims: a spark or idea is retained material, a backlog issue is deliberately tracked work, an Exploration is an inquiry. Do not conflate them to save a step. - One pass through the queue — don't loop or re-present items. -- **Two doors into a Change:** items needing problem discovery hand to pitch, which owns `loaf change init <slug> --brief` and brief authoring; well-understood directions hand to shape. When capture should precede shaping without a full pitch, run `loaf change init <slug> --brief` and seed `brief.md` with the original ask, then hand to shape. +- **Two doors into issue work:** items needing problem discovery hand to pitch; well-understood directions hand to shape (issue preparation). Worth keeping but not ready for either door files as a backlog issue (`loaf issue new "<title>" --status backlog`, optional `--parent`, optional `loaf issue bucket`). Triage never runs `loaf issue start`, never opens PRs, and never invents Git artifacts. ## Verification - Every presented item has a recorded disposition or an explicit "leave for next triage". -- Tracked and deferred choices exist as Intents with the expected derived disposition (`loaf intent list`). +- Filed directions exist as backlog issues (`loaf issue list --status backlog`) and no longer appear in `loaf intake list` once their captures are resolved or archived. - Discards are resolved or archived through their own commands and no longer appear in `loaf intake list`. -- No Linear or tracker operation was attempted; publication is a later concern outside this Change. +- No Linear or tracker operation was attempted; publication is a later concern outside triage. ## Quick Reference | Item kind | Comes from | Typical dispositions | |-----------|-----------|----------------------| -| spark | `loaf spark capture` moments | discard, promote to idea, track as Intent | -| idea | idea capture | archive, explore, track as Intent, hand to pitch, hand to shape | -| brainstorm | archived divergent sessions | archive, explore, promote, hand to pitch | -| intent (tracked) | `loaf intent create` | keep tracking, defer, resolve, explore, hand to pitch, hand to shape | -| intent (deferred) | `loaf intent defer` or adapter | resume, resolve, leave deferred | -| legacy_deferral | pre-conversion `journal defer` | read, then optionally convert (see Legacy Deferrals) | +| spark | `loaf spark capture --scope <scope> --text <text>` | discard, retain, promote to idea, file as backlog issue, resume exploration, resolve, hand to pitch, hand to shape | +| idea | `loaf idea capture --title "<title>"` | archive, retain, file as backlog issue, resume exploration, resolve, hand to pitch, hand to shape | +| brainstorm | `loaf brainstorm capture` | archive, retain, promote to idea, file as backlog issue, resume exploration, hand to pitch, hand to shape | ## Process 1. **Scan.** Run `loaf intake list --json`. Summarize counts by kind, then list each item with its title, disposition or status, and read command. 2. **Read on demand.** Use each item's `read_command` verbatim when the user wants detail before deciding. If a read command fails, record the exact command and error in the summary as `unreadable`, make no semantic disposition for that item, continue the pass, and offer a factual diagnostic step (`loaf state doctor --json`) afterward. Never persist unreadable as a status. 3. **Decide per item.** Present the applicable dispositions and perform exactly the chosen one. -4. **Summarize.** Report what was discarded, retained, tracked, deferred, resumed, resolved, or handed onward, and journal notable decisions. +4. **Summarize.** Report what was discarded, retained, filed as backlog issues, resumed as explorations, resolved, or handed to pitch or shape, and journal notable decisions. ## Dispositions - **Discard** — ideas and brainstorms: `loaf idea archive <ref> --reason <r>` or `loaf brainstorm archive <ref> --reason <r>`. A spark is resolved against the entity that addressed it (`loaf spark resolve <ref> --by <entity> --reason <r>`); a pure dead-end spark currently has no deterministic discard operation — leave it retained, journal the judgment, and never invent a resolving entity. -- **Retain as capture** — do nothing; open captures resurface next triage. -- **Track as Intent** — two steps: create the Intent with the capture as its source, then close the capture against it so the direction appears once. `loaf intent create --title <t> --body <self-sufficient body> --from <capture-ref>`, then `loaf spark resolve <capture-ref> --by <intent-ref>` or `loaf idea resolve <capture-ref> --by <intent-ref>` (brainstorms: `loaf brainstorm archive <ref> --reason "tracked as <intent-ref>"`). -- **Defer** — an existing Intent: `loaf intent defer <ref> --why <w> --boundary <b> --trigger <t> --operation-id <key>`; a new deferred direction needs the full skeleton: `loaf intent create --title <t> --body <b> --disposition deferred --why <w> --boundary <bd> --trigger <tr> --operation-id <key> [--from <source-ref>]`. -- **Resume** — `loaf intent resume <ref> --reason <why now>`; appends a tracked disposition linked to the deferral it supersedes. -- **Resolve** — `loaf intent resolve <ref> --reason <outcome>`; history is never rewritten. -- **Explore** — agent technique for genuinely undecided directions (Explorations and checkpoints); not a user slash entry — prefer pitch when the human needs problem discovery first, then reach for explore from inside that work if still undecided. -- **Pitch** — items needing problem discovery hand to pitch, which owns init and brief authoring; resolve the promoted item against the created change (`loaf spark resolve` / `loaf idea resolve` / archive brainstorm with the change as the reason). -- **Shape** — when a direction is already well-understood and ready for bounded delivery, hand it to shape; triage never creates fully-materialized Changes, branches, or worktrees (capture-only brief seeding is the Critical Rules exception above). +- **Retain as spark/idea** — do nothing to leave the capture open, or promote into the other capture primitive: capture the idea first (`loaf idea capture --title "..."`), then `loaf spark promote <spark> --to-idea <idea>` or `loaf brainstorm promote <brainstorm> --to-idea <idea>`. Open captures resurface next triage. +- **File as backlog issue** — two steps so the direction appears once. Create the issue, then close the capture against it: -## Legacy Deferrals + ```bash + loaf issue new "<title>" --status backlog [--parent <ref>] [--kind delivery|decision] [--fog <text>] [--body <text>] + loaf issue bucket <issue-ref> now|next|later # optional; labels only, never a constraint + loaf spark resolve <capture-ref> --by <issue-ref> + # or: loaf idea resolve <capture-ref> --by <issue-ref> + # brainstorms: loaf brainstorm archive <ref> --reason "filed as <issue-ref>" + ``` -Items of kind `legacy_deferral` are pre-conversion `journal defer` records. They stay visible and readable until the explicit, backup-first conversion is run; nothing disappears while migration is pending. When the user wants them converged, offer `loaf state migrate deferrals --dry-run` to preview the project-specific manifest and `--apply` only with explicit consent — apply verifies a whole-database backup first and preserves every legacy row. + Use `--kind decision` when filing a sharp question. Copy still-unsharp questions into `--fog` (create-time only). `--parent` nests under an existing issue; omit it for a different problem. +- **Resume exploration** — agent technique for genuinely undecided directions (Explorations and checkpoints); not a user slash entry. Prefer pitch when the human needs problem discovery first, then reach for explore from inside that work if still undecided. Resume with `loaf exploration context <ref>` when a named Exploration already exists. +- **Resolve** — the capture is already represented elsewhere. `loaf spark resolve <ref> --by <entity> --reason <r>` or `loaf idea resolve <ref> --by <entity>`. History is never rewritten. +- **Hand to pitch** — items needing problem discovery hand to pitch. Resolve the capture against the issue once one exists (`loaf spark resolve` / `loaf idea resolve --by <issue-ref>` / archive the brainstorm with that issue as the reason). +- **Hand to shape** — when a direction is already well-understood and ready for bounded delivery, hand it to shape for issue preparation. Triage never writes definition-of-done criteria, never runs `loaf issue check`, and never creates branches or worktrees. + +## Leftover kinds + +`loaf intake list` may still include `intent` and `legacy_deferral` items. Do not create new `intent` rows. Treat leftover directions like any other capture: file a backlog issue if worth keeping, or leave them for a later pass. Do not offer conversion commands that recreate the old tracked/deferred row. ## Guardrails 1. **User decides every disposition** — present, don't decide. 2. **Batch presentation, individual decisions** — show the full queue, then process one item at a time. 3. **Log everything** — no silent discards, promotions, or conversions. -4. **Deferred is not forgotten** — deferred Intents remain active truth in `loaf journal context` until resumed or resolved. +4. **Filed is not forgotten** — backlog issues remain on `loaf issue list` and may appear on `loaf issue frontier` until their status changes. Buckets are labels only. ## Related Skills - **idea** — capture a new idea (fast, minimal friction) - **pitch** — problem-discovery ceremony for items that need framing before shape - **explore** — agent technique for divergent inquiry with portable checkpoints -- **shape** — develop a well-understood direction into a bounded Change +- **shape** — develop a well-understood direction into a bounded issue - **housekeeping** — flags stale artifacts; does not choose dispositions diff --git a/plugins/loaf/skills/wrap/SKILL.md b/plugins/loaf/skills/wrap/SKILL.md index 9e119f625..7246817b2 100644 --- a/plugins/loaf/skills/wrap/SKILL.md +++ b/plugins/loaf/skills/wrap/SKILL.md @@ -137,7 +137,7 @@ After the wrap-up report, suggest housekeeping if it wasn't run this session and ## Report Format -Use backtick formatting for code identifiers, file paths, spec/task IDs, version numbers, status values, and CLI commands. Use uppercase for spec and task IDs (`SPEC-029`, not `spec-029`). +Use backtick formatting for code identifiers, file paths, issue IDs, version numbers, status values, and CLI commands. Use uppercase for issue IDs (`LOAF-29`, not `loaf-29`). ```markdown ## Session Wrap-Up