diff --git a/bin/native/darwin-arm64/loaf b/bin/native/darwin-arm64/loaf index fa7cf5f0a..6cfd82584 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 d2b51e227..2d247467e 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 = 34; +const EXPECTED_SKILL_COUNT = 35; const PRICING = { "claude-opus-4-6": { input: 15, output: 75 }, @@ -51,9 +51,14 @@ const TEST_CASES = { "Bootstrap this repo with the standard project scaffolding", ], brainstorm: [ - "Help me think through options for notifications", + "Apply the brainstorm divergence stance to this exploration", + "What does the brainstorm technique say about capturing sparks during divergence?", + "Use the structured brainstorm questioning stance on this problem", + ], + explore: [ "Explore several approaches before we choose one", - "What are the tradeoffs between these product ideas?", + "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", @@ -64,6 +69,8 @@ const TEST_CASES = { "Which loaf command shows the recent project journal?", "Show me the Loaf CLI command for task status", "What flags does loaf journal recent support?", + "Upgrade Loaf and bring this project current", + "Diagnose why the installed Loaf targets are stale and repair them", ], council: [ "Convene a council on this architecture decision", @@ -226,17 +233,35 @@ const CONFLICT_PROBES = [ prompt: "Go through the unresolved sparks and decide which ones become tasks", }, { - group: "research-brainstorm", - choices: ["research", "brainstorm"], - expected: "brainstorm", + group: "research-explore", + choices: ["research", "explore"], + expected: "explore", prompt: "Let's generate a few possible directions before choosing one", }, { - group: "research-brainstorm", - choices: ["research", "brainstorm"], + group: "research-explore", + choices: ["research", "explore"], expected: "research", prompt: "Survey the current project and external options, then report findings", }, + { + group: "explore-triage", + choices: ["explore", "triage"], + expected: "triage", + prompt: "Go through the deferred intents and decide which to resume", + }, + { + group: "explore-shape", + choices: ["explore", "shape"], + expected: "shape", + prompt: "This direction is settled; turn it into a bounded change we can implement", + }, + { + group: "maintenance-workflow", + choices: ["loaf-reference", "triage"], + expected: "loaf-reference", + prompt: "Bring this project's Loaf installation current after the upgrade", + }, { group: "strategy-reflect", choices: ["strategy", "reflect"], diff --git a/cli/scripts/u8-claude-smoke.mjs b/cli/scripts/u8-claude-smoke.mjs index bb35515b1..4f6d54da0 100644 --- a/cli/scripts/u8-claude-smoke.mjs +++ b/cli/scripts/u8-claude-smoke.mjs @@ -9,8 +9,8 @@ import { spawnSync } from "node:child_process"; const scriptDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(scriptDir, "../.."); const researchDir = join(repoRoot, "docs/changes/20260710-journal-reliability-foundation/research"); -const evidencePath = join(researchDir, "u8-claude-code-2.1.207-candidate-smoke.json"); -const expectedVersion = "2.1.207"; +const evidencePath = join(researchDir, "u8-claude-code-2.1.215-candidate-smoke.json"); +const expectedVersion = "2.1.215"; const platform = `${process.platform}-${process.arch}`; const candidatePluginPath = "plugins/loaf"; @@ -92,7 +92,7 @@ function main() { if (buildClaude.status !== 0) throw new Error("candidate Claude build failed"); if (!existsSync(candidateBinary)) throw new Error("candidate plugin binary is missing"); const version = run("claude", ["--version"], repoRoot); - if (version.status !== 0 || !claudeVersionMatches(version.stdout, expectedVersion)) throw new Error("installed Claude version is not 2.1.207"); + if (version.status !== 0 || !claudeVersionMatches(version.stdout, expectedVersion)) throw new Error("installed Claude version is not 2.1.215"); if (run("git", ["init", "-q"], disposableRepo).status !== 0) throw new Error("disposable Git initialization failed"); const candidateEnv = { LOAF_DB: dbPath }; if (run(candidateBinary, ["state", "init", "--json"], disposableRepo, candidateEnv).status !== 0) throw new Error("isolated Loaf state initialization failed"); @@ -154,7 +154,7 @@ function main() { cleanup(); } writeFileSync(evidencePath, `${JSON.stringify(smoke, null, 2)}\n`); - process.stdout.write(`${JSON.stringify({ evidence_path: "docs/changes/20260710-journal-reliability-foundation/research/u8-claude-code-2.1.207-candidate-smoke.json", exit_code: smoke.exit_code, assistant_marker_match: smoke.assistant_marker_match }, null, 2)}\n`); + process.stdout.write(`${JSON.stringify({ evidence_path: "docs/changes/20260710-journal-reliability-foundation/research/u8-claude-code-2.1.215-candidate-smoke.json", exit_code: smoke.exit_code, assistant_marker_match: smoke.assistant_marker_match }, null, 2)}\n`); if (smoke.exit_code !== 0 || !smoke.assistant_marker_match) process.exitCode = 1; } diff --git a/cli/scripts/u8-claude-smoke.test.mjs b/cli/scripts/u8-claude-smoke.test.mjs index 04a8e8d49..3d25f2248 100644 --- a/cli/scripts/u8-claude-smoke.test.mjs +++ b/cli/scripts/u8-claude-smoke.test.mjs @@ -20,7 +20,7 @@ test("rejects a stream without SessionStart hook response", () => { }); test("requires the exact Claude Code version token", () => { - assert.equal(claudeVersionMatches("2.1.207 (Claude Code)\n", "2.1.207"), true); - assert.equal(claudeVersionMatches("2.1.2070 (Claude Code)", "2.1.207"), false); - assert.equal(claudeVersionMatches("Claude Code 2.1.207", "2.1.207"), false); + assert.equal(claudeVersionMatches("2.1.215 (Claude Code)\n", "2.1.215"), true); + assert.equal(claudeVersionMatches("2.1.2150 (Claude Code)", "2.1.215"), false); + assert.equal(claudeVersionMatches("Claude Code 2.1.215", "2.1.215"), false); }); diff --git a/cli/scripts/u8-codex-smoke.mjs b/cli/scripts/u8-codex-smoke.mjs index ff4a2cfb7..4659842c8 100644 --- a/cli/scripts/u8-codex-smoke.mjs +++ b/cli/scripts/u8-codex-smoke.mjs @@ -10,8 +10,8 @@ import { tmpdir } from "node:os"; const scriptDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(scriptDir, "../.."); const researchDir = join(repoRoot, "docs/changes/20260710-journal-reliability-foundation/research"); -const evidencePath = join(researchDir, "u8-codex-0.144.1-isolated-smoke.json"); -const expectedVersion = "0.144.1"; +const evidencePath = join(researchDir, "u8-codex-0.144.5-isolated-smoke.json"); +const expectedVersion = "0.144.5"; const platform = `${process.platform}-${process.arch}`; const candidateHooksPath = "dist/codex/.codex/hooks.json"; const candidateNativeRoot = join(repoRoot, "bin", "native"); @@ -148,7 +148,7 @@ function main() { if (buildCodex.status !== 0) throw new Error("candidate Codex build failed"); if (!existsSync(candidateBinary)) throw new Error("candidate native binary is missing"); const version = run("codex", ["--version"], repoRoot); - if (version.status !== 0 || !codexVersionMatches(version.stdout, expectedVersion)) throw new Error("installed Codex version is not 0.144.1"); + if (version.status !== 0 || !codexVersionMatches(version.stdout, expectedVersion)) throw new Error("installed Codex version is not 0.144.5"); if (run("git", ["init", "-q"], disposableRepo).status !== 0) throw new Error("disposable Git initialization failed"); const authPath = join(process.env.CODEX_HOME ?? join(process.env.HOME ?? "", ".codex"), "auth.json"); if (!existsSync(authPath)) throw new Error("installed Codex auth.json is unavailable"); @@ -164,7 +164,7 @@ function main() { hooks: group.hooks.map((hook) => ({ ...hook, command: hook.command.replace("{{LOAF_EXECUTABLE}} journal context --from-hook --codex-hook", hookCommand) })), })); writeFileSync(join(codexHome, "hooks.json"), `${JSON.stringify(sourceHooks, null, 2)}\n`, { mode: 0o600 }); - const candidateEnv = { CODEX_HOME: codexHome, LOAF_DB: dbPath }; + const candidateEnv = { CODEX_HOME: codexHome, LOAF_DB: dbPath, RUST_LOG: "off" }; if (run(candidateBinary, ["state", "init", "--json"], disposableRepo, candidateEnv).status !== 0) throw new Error("isolated Loaf state initialization failed"); if (run(candidateBinary, ["journal", "log", `discover(smoke): ${marker}`], disposableRepo, candidateEnv).status !== 0) throw new Error("isolated journal marker write failed"); const codexArgs = ["exec", "--ephemeral", "--ignore-rules", "--dangerously-bypass-hook-trust", "--sandbox", "read-only", "--json", "-C", "", "Return exactly the unique marker supplied by SessionStart context, and nothing else."]; @@ -186,7 +186,7 @@ function main() { adapter: "codex-session-start-v1", mode: "isolated-codex-home", invocation: { command: "codex", args: codexArgs, cwd: "" }, - setup: ["build candidate Go binary and Codex target", "create disposable Git repository", "create isolated CODEX_HOME with hooks enabled", "copy installed auth.json into isolated CODEX_HOME with mode 0600", "initialize absolute disposable LOAF_DB", "write random marker to isolated journal", "observe hook stdout through a mode-0700 disposable wrapper and mode-0600 file"], + setup: ["build candidate Go binary and Codex target", "create disposable Git repository", "create isolated CODEX_HOME with hooks enabled", "copy installed auth.json into isolated CODEX_HOME with mode 0600", "initialize absolute disposable LOAF_DB", "write random marker to isolated journal", "observe hook stdout through a mode-0700 disposable wrapper and mode-0600 file", "disable Codex tracing logs (RUST_LOG=off) so backend transport noise cannot contaminate the stderr cleanliness gate"], exit_code: codex.status, stderr_empty: codex.stderr.length === 0, stderr: sanitizedStderr(codex.stderr), @@ -219,7 +219,7 @@ function main() { cleanup(); } writeFileSync(evidencePath, `${JSON.stringify(smoke, null, 2)}\n`); - process.stdout.write(`${JSON.stringify({ evidence_path: "docs/changes/20260710-journal-reliability-foundation/research/u8-codex-0.144.1-isolated-smoke.json", exit_code: smoke.exit_code, assistant_marker_match: smoke.assistant_marker_match }, null, 2)}\n`); + process.stdout.write(`${JSON.stringify({ evidence_path: "docs/changes/20260710-journal-reliability-foundation/research/u8-codex-0.144.5-isolated-smoke.json", exit_code: smoke.exit_code, assistant_marker_match: smoke.assistant_marker_match }, null, 2)}\n`); if (smoke.exit_code !== 0 || !smoke.assistant_marker_match) process.exitCode = 1; } diff --git a/cli/scripts/u8-codex-smoke.test.mjs b/cli/scripts/u8-codex-smoke.test.mjs index 00ba790f0..282b0a7ba 100644 --- a/cli/scripts/u8-codex-smoke.test.mjs +++ b/cli/scripts/u8-codex-smoke.test.mjs @@ -46,7 +46,7 @@ test("rejects malformed JSONL", () => { }); test("requires the exact Codex CLI version token", () => { - assert.equal(codexVersionMatches("codex-cli 0.144.1\n", "0.144.1"), true); - assert.equal(codexVersionMatches("codex-cli 0.144.10\n", "0.144.1"), false); - assert.equal(codexVersionMatches("other-cli 0.144.1\n", "0.144.1"), false); + assert.equal(codexVersionMatches("codex-cli 0.144.5\n", "0.144.5"), true); + assert.equal(codexVersionMatches("codex-cli 0.144.50\n", "0.144.5"), false); + assert.equal(codexVersionMatches("other-cli 0.144.5\n", "0.144.5"), false); }); diff --git a/cli/scripts/u8-opencode-smoke.mjs b/cli/scripts/u8-opencode-smoke.mjs index 49fe7d4a9..3bf2e9ef6 100644 --- a/cli/scripts/u8-opencode-smoke.mjs +++ b/cli/scripts/u8-opencode-smoke.mjs @@ -10,8 +10,8 @@ import { tmpdir } from "node:os"; const scriptDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(scriptDir, "../.."); const researchDir = join(repoRoot, "docs/changes/20260710-journal-reliability-foundation/research"); -const evidencePath = join(researchDir, "u8-opencode-1.17.18-isolated-smoke.json"); -const expectedVersion = "1.17.18"; +const evidencePath = join(researchDir, "u8-opencode-1.18.3-isolated-smoke.json"); +const expectedVersion = "1.18.3"; const platform = `${process.platform}-${process.arch}`; const candidateHooksPath = "dist/opencode/plugins/hooks.ts"; const candidateNativePath = `bin/native/${platform}/loaf`; @@ -273,7 +273,7 @@ function main() { const pluginURL = pathToFileURL(candidatePlugin).href; const env = disposableEnvironment(tempRoot, dbPath, pluginURL); const version = run(installedOpenCode, ["--version"], repoRoot, env); - if (version.status !== 0 || !opencodeVersionMatches(version.stdout)) throw new Error("installed OpenCode version is not exactly 1.17.18"); + if (version.status !== 0 || !opencodeVersionMatches(version.stdout)) throw new Error("installed OpenCode version is not exactly 1.18.3"); if (run("git", ["init", "-q"], disposableRepo, env).status !== 0) throw new Error("disposable Git initialization failed"); if (run(candidateBinary, ["state", "init", "--json"], disposableRepo, env).status !== 0) throw new Error("isolated Loaf state initialization failed"); if (run(candidateBinary, ["journal", "log", `discover(smoke): ${marker}`], disposableRepo, env).status !== 0) throw new Error("isolated journal marker write failed"); @@ -312,7 +312,7 @@ function main() { smoke.cleanup_succeeded = cleanupSucceeded; } writeFileSync(evidencePath, `${JSON.stringify(smoke, null, 2)}\n`); - process.stdout.write(`${JSON.stringify({ evidence_path: "docs/changes/20260710-journal-reliability-foundation/research/u8-opencode-1.17.18-isolated-smoke.json", exit_code: smoke.exit_code, assistant_marker_match: smoke.assistant_marker_match, plugin_loaded: smoke.plugin_loaded, root_session_lookup_proven: smoke.root_session_lookup_proven, cleanup_succeeded: smoke.cleanup_succeeded }, null, 2)}\n`); + process.stdout.write(`${JSON.stringify({ evidence_path: "docs/changes/20260710-journal-reliability-foundation/research/u8-opencode-1.18.3-isolated-smoke.json", exit_code: smoke.exit_code, assistant_marker_match: smoke.assistant_marker_match, plugin_loaded: smoke.plugin_loaded, root_session_lookup_proven: smoke.root_session_lookup_proven, cleanup_succeeded: smoke.cleanup_succeeded }, null, 2)}\n`); if (smoke.exit_code !== 0 || !smoke.assistant_marker_match || !smoke.plugin_loaded || !smoke.root_session_lookup_proven || !smoke.cleanup_succeeded) process.exitCode = 1; } diff --git a/cli/scripts/u8-opencode-smoke.test.mjs b/cli/scripts/u8-opencode-smoke.test.mjs index 603c6b1fc..80c68d8cf 100644 --- a/cli/scripts/u8-opencode-smoke.test.mjs +++ b/cli/scripts/u8-opencode-smoke.test.mjs @@ -3,10 +3,10 @@ import assert from "node:assert/strict"; import { collectTextValues, modelVisibleProof, opencodeVersionMatches, parseOpenCodeJSONL, sanitizeError, sanitizedStderr } from "./u8-opencode-smoke.mjs"; test("requires the exact OpenCode version token", () => { - assert.equal(opencodeVersionMatches("1.17.18\n"), true); - assert.equal(opencodeVersionMatches("1.17.180"), false); - assert.equal(opencodeVersionMatches("v1.17.18"), false); - assert.equal(opencodeVersionMatches("1.17.18-dev"), false); + assert.equal(opencodeVersionMatches("1.18.3\n"), true); + assert.equal(opencodeVersionMatches("1.18.30"), false); + assert.equal(opencodeVersionMatches("v1.18.3"), false); + assert.equal(opencodeVersionMatches("1.18.3-dev"), false); }); test("recursively extracts only string values at text keys", () => { diff --git a/cmd/loaf/routing_eval_content_test.go b/cmd/loaf/routing_eval_content_test.go index ea61109ba..92e2e8d6b 100644 --- a/cmd/loaf/routing_eval_content_test.go +++ b/cmd/loaf/routing_eval_content_test.go @@ -23,8 +23,8 @@ func TestRoutingEvalDryRunValidatesCurrentSkillSuite(t *testing.T) { } for _, want := range []string{ - "Loaded 34 skills", - "Selected cases: 119", + "Loaded 35 skills", + "Selected cases: 127", "Suite validation passed.", } { if !strings.Contains(output, want) { @@ -52,10 +52,10 @@ func TestRoutingEvalContentHasNoPhantomSkillCases(t *testing.T) { func TestSkillArchitectureCountMatchesCurrentTaxonomy(t *testing.T) { root := repoRoot(t) body := readTextFile(t, filepath.Join(root, "docs", "knowledge", "skill-architecture.md")) - if !strings.Contains(body, "34 skills total: 19 workflow/default-invocable, 15 reference/knowledge") { + if !strings.Contains(body, "35 skills total: 19 workflow/default-invocable, 16 non-invocable") { t.Fatalf("skill architecture doc missing current 34-skill taxonomy") } - if strings.Contains(body, "33 skills") || strings.Contains(body, "35 skills") { + if strings.Contains(body, "33 skills") || strings.Contains(body, "34 skills") { t.Fatalf("skill architecture doc still contains stale skill count") } } diff --git a/config/target-capabilities.json b/config/target-capabilities.json index 4585927e4..32a5b1b28 100644 --- a/config/target-capabilities.json +++ b/config/target-capabilities.json @@ -4,7 +4,7 @@ { "target": "claude-code", "surface": "cli", - "version": "2.1.207", + "version": "2.1.215", "platform": "darwin-arm64", "installed_mode": "plugin-dir", "context": { @@ -20,9 +20,9 @@ "status": "supported", "model_visible": true, "evidence": { - "source": "docs/changes/20260710-journal-reliability-foundation/research/u8-claude-code-2.1.207-candidate-smoke.json", + "source": "docs/changes/20260710-journal-reliability-foundation/research/u8-claude-code-2.1.215-candidate-smoke.json", "level": "installed-smoke", - "summary": "Claude Code 2.1.207 startup smoke returned SessionStart additionalContext and the isolated marker was model-visible exactly." + "summary": "Claude Code 2.1.215 startup smoke returned SessionStart additionalContext and the isolated marker was model-visible exactly." } }, { @@ -243,7 +243,7 @@ { "target": "codex", "surface": "cli", - "version": "0.144.1", + "version": "0.144.5", "platform": "darwin-arm64", "installed_mode": "isolated-codex-home", "context": { @@ -259,9 +259,9 @@ "status": "supported", "model_visible": true, "evidence": { - "source": "docs/changes/20260710-journal-reliability-foundation/research/u8-codex-0.144.1-isolated-smoke.json", + "source": "docs/changes/20260710-journal-reliability-foundation/research/u8-codex-0.144.5-isolated-smoke.json", "level": "installed-smoke", - "summary": "Codex 0.144.1 isolated CODEX_HOME startup smoke observed exact native SessionStart hookSpecificOutput and the random marker was returned by the model." + "summary": "Codex 0.144.5 isolated CODEX_HOME startup smoke observed exact native SessionStart hookSpecificOutput and the random marker was returned by the model." } }, { @@ -324,7 +324,7 @@ { "target": "opencode", "surface": "cli", - "version": "1.17.18", + "version": "1.18.3", "platform": "darwin-arm64", "installed_mode": "isolated-xdg", "context": { @@ -340,9 +340,9 @@ "status": "supported", "model_visible": true, "evidence": { - "source": "docs/changes/20260710-journal-reliability-foundation/research/u8-opencode-1.17.18-isolated-smoke.json", + "source": "docs/changes/20260710-journal-reliability-foundation/research/u8-opencode-1.18.3-isolated-smoke.json", "level": "installed-smoke", - "summary": "OpenCode 1.17.18 isolated-XDG request smoke returned the exact random continuity marker through the model-visible plugin system transformation." + "summary": "OpenCode 1.18.3 isolated-XDG request smoke returned the exact random continuity marker through the model-visible plugin system transformation." } }, { diff --git a/content/skills/brainstorm/SKILL.claude-code.yaml b/content/skills/brainstorm/SKILL.claude-code.yaml index 8af90c153..6eabcff6e 100644 --- a/content/skills/brainstorm/SKILL.claude-code.yaml +++ b/content/skills/brainstorm/SKILL.claude-code.yaml @@ -1,3 +1,3 @@ # Claude Code skill configuration -user-invocable: true +user-invocable: false argument-hint: "[idea or problem]" diff --git a/content/skills/brainstorm/SKILL.md b/content/skills/brainstorm/SKILL.md index 9b4972693..0bb39dcbc 100644 --- a/content/skills/brainstorm/SKILL.md +++ b/content/skills/brainstorm/SKILL.md @@ -1,15 +1,12 @@ --- name: brainstorm description: >- - Conducts structured brainstorming with divergent thinking and trade-off - analysis. Use when the user asks "help me think through this," "what are - the options," or is exploring tradeoffs. Produces docs with sparks. Not - for quick ideas or shaping. + Preserves the structured divergent-thinking stance consumed by the explore workflow: option generation before judgment, trade-off analysis, and spark capture. Explore owns the user-facing inquiry lifecycle; reference this technique from it. Not a primary entry point — route "explore this" or "help me think through options" to explore. --- # Brainstorm -Generative thinking — expanding possibilities before narrowing through structured exploration. +Generative thinking — expanding possibilities before narrowing. This stance is an internal technique consumed by `/explore`, which owns inquiry continuity through Explorations and portable checkpoints; invoke the technique from there rather than as a standalone workflow. ## Critical Rules @@ -24,17 +21,16 @@ Generative thinking — expanding possibilities before narrowing through structu **Never** - Prematurely commit to an option before full exploration -- Delete brainstorm documents — archive them for context preservation -- Process sparks during the main brainstorm — capture only, expand later -- Turn brainstorm into an interview — keep it exploratory +- Create documents, reports, or any Git artifact from this technique — the surrounding Explore workflow owns checkpoints and any durable writes +- Process sparks during the divergent pass — capture only, expand later +- Turn the divergence into an interview — keep it exploratory ## Verification -After work completes, verify: -- Brainstorm captured in SQLite or summarized in an explicitly durable report -- Sparks captured with `loaf spark capture` and optionally summarized in `## Sparks` -- Spark lifecycle documented: unprocessed → promoted/discarded -- Brainstorm references strategic context from VISION/STRATEGY +After a divergent pass, verify: +- Sparks captured with `loaf spark capture` as they arose +- The surrounding Exploration checkpointed the conclusions, discarded options, and open question (`loaf exploration checkpoint`) +- The divergence referenced strategic context from VISION/STRATEGY ## Quick Reference @@ -55,17 +51,14 @@ After work completes, verify: - **Title** -- one-line description ``` -Sparks are: lightweight, byproducts, worth remembering. Mark as `*(promoted)*` or `*(abandoned)*` after processing. - -Brainstorm documents are archived after sparks are processed — never deleted, since the exploration context has lasting value. SQLite spark state is the lifecycle source; draft markdown is a projection or narrative summary. +Sparks are lightweight byproducts worth remembering; their dispositions belong to triage. SQLite spark state is the source; any summary inside a checkpoint item is narrative, not lifecycle. ## Suggests Next -After brainstorming, suggest `/shape` if a clear idea emerged, or `/idea` to capture sparks for later. `/idea` invoked without arguments scans brainstorm docs for unprocessed sparks, bridging the brainstorm → idea pipeline. +After a divergent pass, checkpoint the surrounding Exploration (`loaf exploration checkpoint`), then suggest `/shape` if a clear direction emerged or `/triage` to disposition captured sparks and ideas. ## Topics | Topic | Reference | Use When | |-------|-----------|----------| -| Brainstorm Template | [templates/brainstorm.md](templates/brainstorm.md) | Creating structured brainstorm documents | | Strategic Context | `strategy/references/` | Grounding exploration in project direction | diff --git a/content/skills/explore/SKILL.claude-code.yaml b/content/skills/explore/SKILL.claude-code.yaml new file mode 100644 index 000000000..7a01e4cdb --- /dev/null +++ b/content/skills/explore/SKILL.claude-code.yaml @@ -0,0 +1,3 @@ +# Claude Code skill configuration +user-invocable: true +argument-hint: "[topic or exploration ref]" diff --git a/content/skills/explore/SKILL.md b/content/skills/explore/SKILL.md new file mode 100644 index 000000000..4aaa006af --- /dev/null +++ b/content/skills/explore/SKILL.md @@ -0,0 +1,93 @@ +--- +name: explore +description: >- + Conducts divergent inquiry as a durable Exploration: portable checkpoints, conversation provenance, and Intent capture that survive compaction and harness changes. Use when the direction is genuinely undecided ("explore this", "we don't know which approach yet") or when resuming a named Exploration. Produces Exploration records, portable checkpoints, and tracked or deferred Intents. Not for evidence gathering on a known question (use research), continuing implementation or delivery work (use implement), processing the intake queue (use triage), shaping a bounded Change (use shape), or quick capture (use idea). +--- + +# Explore + +Divergent inquiry with durable continuity. An Exploration is a relational identity over immutable checkpoints and provenance — it has no status, no owner, and no lifecycle to maintain. Resuming means reading portable context and appending new facts, never toggling state. + +**Input:** $ARGUMENTS + +--- + +## Contents +- Critical Rules +- Verification +- Quick Reference +- Process +- Checkpoint Discipline +- Resumption +- Techniques +- Related Skills + +## Critical Rules + +- Log invocation first: `loaf journal log "skill(explore): "` +- 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 bounded delivery, hand it to `/shape`. +- Never store transcripts, prompts, or tool output in checkpoints or items; curate semantic context instead. + +## Verification + +- The Exploration exists with `portable_context_present: true` after the first checkpoint (`loaf exploration list`). +- `loaf exploration context --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`). +- Conversation provenance, when recorded, carries harness and locality facts without any transcript content. + +## Quick Reference + +| Operation | Command | +|-----------|---------| +| Start an inquiry | `loaf exploration create --title [--from <intent-or-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>]` | +| 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>` | + +## 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. +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. +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 + +The four fields are the portable contract; each is capped at 4096 UTF-8 bytes and oversize input is rejected, never truncated: + +- **purpose** — the current framing of the inquiry, restated so a stranger understands what is being explored and why. +- **conclusions** — constraints and conclusions established so far, including rejected options and why they fell. +- **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. + +## 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. + +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. + +## Deferring + +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. + +## Techniques + +Brainstorm's full divergent stance lives inside Explore: generate options before judging, connect to VISION/STRATEGY context, document discarded options, set boundaries on exploration time. Scout, research, prototype, and spike remain subordinate techniques invoked from whatever stage needs them — none of them owns lifecycle. + +## Related Skills + +- **triage** — processes the intake queue and chooses dispositions, including "explore this" +- **shape** — narrows one direction into a bounded Change; the exit door from Explore +- **research** — evidence gathering for a known question, usable inside an Exploration +- **idea** — quick capture without inquiry diff --git a/content/skills/idea/SKILL.md b/content/skills/idea/SKILL.md index de7160e7f..ea3bdacc1 100644 --- a/content/skills/idea/SKILL.md +++ b/content/skills/idea/SKILL.md @@ -3,9 +3,10 @@ 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. For reviewing and - processing the intake queue (sparks + raw ideas), use triage instead. - Not for deep exploration (use brainstorm) or shaping (use shape). + actionable concept crystallizes during conversation. Ideas and sparks are + capture primitives routed through triage, which chooses dispositions such as + tracking an Intent. Not for divergent inquiry (use explore), processing the + intake queue (use triage), or shaping (use shape). --- # Idea @@ -38,83 +39,46 @@ Capture ideas quickly with minimal friction. ## Verification -- Idea appears in `loaf idea list` / `loaf idea show` -- Status is open/raw according to the active backend -- If promoted from a spark, `loaf spark promote` records the relationship +- The idea appears in `loaf idea list` and `loaf idea show <ref>` with status open +- If promoted from a spark, `loaf spark promote` recorded the relationship +- No shaping, status transition, or promotion happened here — dispositions belong to triage ## Quick Reference -| Status | Meaning | -|--------|---------| -| `raw` | Just captured, unprocessed | -| `shaping` | Being developed via /shape or /brainstorm | -| `shaped` | Converted to SPEC, idea file archived | -| `archived` | Decided not to pursue, kept for reference | +| Operation | Command | +|-----------|---------| +| Capture | `loaf idea capture --title "<title>"` | +| Read back | `loaf idea show <ref>` | +| List open | `loaf idea list` | --- ## Purpose -Ideas are raw nuggets -- unprocessed, unshaped, but worth remembering. The goal is **speed of capture**, not thoroughness. Shape later via `/shape`. +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, exploring it, shaping it, or archiving it are triage dispositions chosen later by the user. --- ## Process -### Step 1: Parse Input - -If `$ARGUMENTS` contains the idea, capture directly. - -If `$ARGUMENTS` is empty, ask **at most 2-3 questions**: core idea, problem/opportunity, immediate constraints. - -### Step 2: Capture Idea - -Use the CLI capture path: - -```bash -loaf idea capture --title "..." -``` - -In SQLite-backed mode, the row is stored in SQLite and no `.agents/ideas/` -markdown file is created. The [idea template](templates/idea.md) is retained -only for markdown-only compatibility and historical restore review. - -### Step 3: Create and Announce - -1. Generate timestamp: `date -u +"%Y-%m-%dT%H:%M:%SZ"` -2. Run `loaf idea capture --title "..."` with the inferred title -3. Announce the captured idea alias with next steps - ---- - -## Idea Lifecycle - -``` -raw -> shaping -> shaped (becomes SPEC) -> archived -``` - -| Status | Meaning | -|--------|---------| -| `raw` | Just captured, unprocessed | -| `shaping` | Being developed via /shape or /brainstorm | -| `shaped` | Converted to SPEC, idea file archived | -| `archived` | Decided not to pursue, kept for reference | +1. **Parse input.** If `$ARGUMENTS` contains the idea, capture directly. If empty, ask at most 2-3 questions: core idea, problem/opportunity, immediate constraints. +2. **Capture.** Run `loaf idea capture --title "..."` with the inferred title; log notable context with `loaf journal log`. +3. **Announce.** Report the captured alias and point at `/triage` for disposition. --- ## Guardrails -1. **Speed over completeness** -- capture quickly, shape later -2. **2-3 questions max** -- don't turn this into an interview -3. **Infer, don't ask** -- metadata should be automatic -4. **One idea per captured row/artifact** -- keep them atomic -5. **No shaping here** -- that's what `/shape` is for +1. **Speed over completeness** — capture quickly, disposition later +2. **2-3 questions max** — don't turn this into an interview +3. **Infer, don't ask** — metadata should be automatic +4. **One idea per captured row** — keep them atomic +5. **No lifecycle here** — no status transitions, promotion, or shaping; triage owns dispositions and the CLI performs them --- ## Related Skills -- **triage** -- Review and process the intake queue (sparks + raw ideas) -- **shape** -- Develop an idea into a SPEC -- **brainstorm** -- Deep thinking on an idea or problem space -- **research** -- Investigate before capturing +- **triage** — process the intake queue and choose dispositions +- **explore** — divergent inquiry when an idea needs development before commitment +- **shape** — develop a chosen direction into a bounded Change diff --git a/content/skills/loaf-reference/SKILL.md b/content/skills/loaf-reference/SKILL.md index da4377e14..2761a3974 100644 --- a/content/skills/loaf-reference/SKILL.md +++ b/content/skills/loaf-reference/SKILL.md @@ -1,7 +1,7 @@ --- name: loaf-reference description: >- - Documents how agents operate the Loaf CLI: command discovery via loaf --help, JSON diagnosis surfaces, guided config maintenance, and troubleshooting. Use when unsure which loaf command to invoke or how to validate project state. Not for workflow guidance (workflow skills own their CLI contracts) or build internals. + Documents how agents operate the Loaf CLI: command discovery via loaf --help, JSON diagnosis surfaces, config-aware maintenance, and troubleshooting. Use when unsure which loaf command to invoke, how to validate project state, or when asked to upgrade, diagnose, repair, configure, or bring a Loaf project current. Not for workflow guidance (workflow skills own their CLI contracts) or build internals. --- # Loaf Reference @@ -61,7 +61,7 @@ Names and one-line purposes only. Run `loaf <command> --help` for options, argum | `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 lifecycle-statuses, backup, backup verify, backup restore, restore-ephemerals, verify-ephemerals, export, export all, export triage, export spec, export release-readiness | +| `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, 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, worktree-storage | @@ -80,6 +80,10 @@ 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 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 | | `loaf spark` | Manage sparks in native SQLite state | list, show, capture, resolve, promote | | `loaf tag` | Manage tags in native SQLite state | list, show, add, remove | | `loaf bundle` | Manage bundles in native SQLite state | list, create, update, show, add, remove | @@ -92,5 +96,6 @@ Names and one-line purposes only. Run `loaf <command> --help` for options, argum | Topic | Reference | Use When | |-------|-----------|----------| | Configuration maintenance | [references/configuration.md](references/configuration.md) | Checking whether a project's Loaf config is current and repairing it; wiring project-owned choices | +| Config-aware maintenance protocol | [references/maintenance.md](references/maintenance.md) | Upgrading, diagnosing, repairing, or bringing a project current: diagnose, plan, ask, apply, verify | | Command routing | [references/command-routing.md](references/command-routing.md) | Deciding which command a task needs; locating the JSON diagnosis surfaces | | Troubleshooting | [references/troubleshooting.md](references/troubleshooting.md) | Diagnosing state, config, or alignment failures; isolating a throwaway database | diff --git a/content/skills/loaf-reference/references/maintenance.md b/content/skills/loaf-reference/references/maintenance.md new file mode 100644 index 000000000..db5feed6e --- /dev/null +++ b/content/skills/loaf-reference/references/maintenance.md @@ -0,0 +1,48 @@ +# Config-Aware Loaf Maintenance + +## Contents +- Protocol +- Fact Sources +- Planning Surfaces +- Consent Boundaries +- What Maintenance Never Does + +This protocol serves natural-language requests to upgrade, diagnose, repair, configure, or bring a Loaf project current. It is a hidden operator layer: interpret facts, ask only for missing project-owned choices, sequence approved deterministic operations, and verify convergence. Discover exact current syntax from `loaf <command> --help` instead of memorizing flags. + +## Protocol + +0. **Classify the request.** A diagnose-only request stops after step 1 with facts; a plan request stops after step 2 with the mutation ledger; only an explicit repair, upgrade, or bring-current request proceeds to apply, and then only for the mutation classes the user's request actually named. Never let the protocol's shape carry a diagnosis into a mutation. +1. **Diagnose.** Start with `loaf config check --json` for project intent and installed hook health. Add `loaf version` (running executable), `loaf state status --json` and `loaf state doctor --json` (SQLite readiness, schema version, repair plan), and `loaf doctor --json` (project alignment: symlinks, stale files, fenced-version drift). All four are read-only. +2. **Plan.** For installed-target convergence, use `loaf install --upgrade --dry-run --json`: it reports intended creates, updates, retirements, preserved conflicts, deprecation actions, project-file effects, and whether explicit consent is required, without writing anything. +3. **Ask.** Only for project-owned choices the facts cannot answer (for example integration election in `.agents/loaf.json`, or consent to destructive deprecation cleanup). Machine-observed facts are never questions. Present one complete mutation ledger — every intended operation with its consent requirement — and obtain approval for the ledger as a whole before applying any part of it. +4. **Apply.** Use the existing explicit operations the ledger named: `loaf config check --fix`, `loaf install --upgrade` (with `-y` only after consent), `loaf doctor --fix --force` (the `--force` form is required for non-interactive execution; plain `--fix` prompts and silently skips repairs without a TTY — if a repair genuinely needs interactive judgment, stop and report that it requires an operator), `loaf state migrate schema --apply`, `loaf state migrate deferrals --apply`. A project-owned election is recorded by editing `.agents/loaf.json` in the checkout — a durable, reviewable change — and validating with `loaf config check --json`. Never invent a bypass. +5. **Verify.** Rerun the diagnosis surfaces and confirm they converge; report any check that still fails rather than declaring success, and never loop back into apply without a changed ledger. + +## Fact Sources + +| Fact | Source | Authority | +|------|--------|-----------| +| Team-owned project intent (integrations, knowledge dirs) | `.agents/loaf.json` via `loaf config check --json` | Shared config; never records machine-local install state | +| Running executable version and provenance | `loaf version` | Observed local fact; the package manager owns acquisition | +| SQLite readiness, schema version, repair plan | `loaf state status --json`, `loaf state doctor --json` | Behind-schema state returns the exact backup-first `loaf state migrate schema --apply` action | +| Project alignment (symlinks, stale files, version fences) | `loaf doctor --json` | Read-only; repairs go through the explicit `--fix` contract | +| Installed target ownership and drift | `loaf install --upgrade --dry-run --json` | Content-addressed ownership manifests decide owned versus foreign | + +## Planning Surfaces + +- `loaf doctor --json` never prompts and never repairs; it carries the identical check identities and outcomes as the human output plus repair IDs for planning. +- `loaf install --upgrade --dry-run --json` is deterministic and byte-for-byte non-mutating; applying the reported plan through the existing commands must produce the predicted effects, after which diagnosis reports convergence. +- `loaf state migrate deferrals --dry-run --json` previews the legacy-deferral conversion manifest; apply is backup-first, preserves every legacy row, and is rerunnable. + +## Consent Boundaries + +- Database migrations (`state migrate schema --apply`, `state migrate deferrals --apply`) are backup-first and require the operator's explicit go-ahead on real state. +- Destructive deprecation cleanup during `install --upgrade` requires explicit consent (`-y`); missing consent must surface as a reported requirement, not an assumed yes. +- Locally modified or unowned destination content is preserved and reported, never overwritten. + +## What Maintenance Never Does + +- Never invokes Homebrew, npm, or any package manager, and never claims a newer remote version exists without evidence from the owning package manager. +- Never hardcodes a Cellar, checkout, or binary path; `loaf` resolves on `PATH`. +- Never infers machine-local installed-target intent from Git-tracked config, and never writes machine-specific fields into `.agents/loaf.json`. +- Never applies a database migration automatically as a side effect of diagnosis. diff --git a/content/skills/triage/SKILL.md b/content/skills/triage/SKILL.md index 705b8e123..25e7e54b2 100644 --- a/content/skills/triage/SKILL.md +++ b/content/skills/triage/SKILL.md @@ -1,17 +1,12 @@ --- name: triage description: >- - Surfaces and processes the intake queue: unresolved sparks from the project - journal and brainstorm documents, plus raw ideas awaiting evaluation. - Use when the user asks "what sparks do I have?", "review my ideas", - "triage", or "what's in my backlog?" Produces promoted ideas, archived - discards, and resolve(spark) journal entries. - Not for capturing new ideas (use idea) or shaping (use shape). + 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, or hand to shape. Not for reading a single known item (use loaf intent show or journal directly), capturing new ideas (use idea), divergent inquiry (use explore), or bounding one chosen direction (use shape). --- # Triage -Review and process the intake queue — sparks and raw ideas. +Process the intake queue. Triage is the public funnel where captured material meets judgment: you present facts, the user chooses each disposition, and the CLI performs exactly what was chosen. **Input:** $ARGUMENTS @@ -22,159 +17,70 @@ Review and process the intake queue — sparks and raw ideas. - Verification - Quick Reference - Process -- Resolution Formats +- Dispositions +- Legacy Deferrals - Guardrails - Related Skills ## Critical Rules -- Present everything before acting -- user decides each disposition -- Never auto-promote or auto-discard without confirmation -- Use SQLite-aware CLI commands for lifecycle changes; do not edit idea/spark - frontmatter by hand -- Log or link resolutions through `loaf spark resolve`, `loaf spark promote`, - `loaf idea archive`, and `loaf brainstorm archive` when state is initialized -- One pass through the queue -- don't loop or re-present items +- Log invocation first: `loaf journal log "skill(triage): <trigger or scope>"` +- 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. +- One pass through the queue — don't loop or re-present items. ## Verification -- All presented sparks have a recorded disposition (promoted, discarded, or deferred) -- Promoted sparks have corresponding idea rows visible in `loaf idea list` -- Processed sparks no longer appear in default `loaf spark list` / triage output -- Archived ideas/brainstorms no longer appear in default triage lists -- Markdown source annotations, when present, are compatibility notes rather than - the authoritative state transition +- 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`). +- 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. ## Quick Reference -| Source | Unprocessed Signal | Resolution | -|--------|-------------------|------------| -| Sparks | Open spark rows from `loaf spark list` | `loaf spark promote` or `loaf spark resolve` | -| Brainstorms | Open brainstorm rows from `loaf brainstorm list` | `loaf brainstorm promote` or `loaf brainstorm archive` | -| Ideas | Open idea rows from `loaf idea list` | Shape, promote, or `loaf idea archive` | - ---- +| 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 `/shape` | +| brainstorm | archived divergent sessions | archive, explore, promote | +| intent (tracked) | `loaf intent create` | keep tracking, defer, resolve, explore, 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) | ## Process -### Step 1: Scan Sources - -Scan state-backed queues first, falling back to Markdown compatibility sources -only when SQLite state is not initialized: - -**1. Sparks** -- Run `loaf spark list` or `loaf spark list --json` -- Treat open rows as unresolved intake - -**2. Brainstorms** -- Run `loaf brainstorm list` or `loaf brainstorm list --json` -- Treat open rows as brainstorm intake - -**3. Ideas** -- Run `loaf idea list` or `loaf idea list --json` -- Treat open rows as idea intake - -### Step 2: Present the Queue - -Show a summary table: - -``` -Intake Queue: - Sparks (journal): 3 unresolved - Sparks (brainstorms): 1 unprocessed - Raw ideas: 2 awaiting evaluation - Total: 6 items -``` - -Then list each item with source, date, and description. - -### Step 3: Process Each Item - -For each item, present it and ask for disposition: - -**Sparks → one of:** -- **Promote** → `loaf spark promote <spark> --to-idea <idea>` -- **Discard** → `loaf spark resolve <spark> --reason <reason>` -- **Defer** → skip, resurface next triage - -**Raw ideas → one of:** -- **Shape** → suggest running `/shape` with this idea -- **Brainstorm** → suggest running `/brainstorm` to explore further -- **Archive** → `loaf idea archive <idea> --reason <reason>` - -### Step 4: Summarize - -After processing, show what happened: +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. -``` -Triage complete: - Promoted: 2 sparks → ideas - Discarded: 1 spark - Deferred: 1 spark - Shaped: 1 idea → /shape - Archived: 1 idea -``` +## Dispositions ---- - -## Resolution Formats - -### Sparks - -When promoting: -```bash -loaf spark promote SPARK-slug --to-idea idea-slug -``` - -When discarding: -```bash -loaf spark resolve SPARK-slug --reason "reason" -``` - -When deferring: -Do nothing; open sparks remain visible in the next triage pass. - -### Brainstorms - -When promoting: -```bash -loaf brainstorm promote brainstorm-slug --to-idea idea-slug -``` +- **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** — hand the item to `/explore`, linking it with `--from` when the Exploration is created. +- **Shape** — when a direction is ready for bounded delivery, hand it to `/shape`; triage never creates Changes, branches, or worktrees. -When archiving: -```bash -loaf brainstorm archive brainstorm-slug --reason "reason" -``` +## Legacy Deferrals -### Ideas - -When archiving: -```bash -loaf idea archive idea-slug --reason "reason" -``` - -When shaping, pass the idea to `/shape`; do not hand-edit status frontmatter to -represent lifecycle state. - ---- +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. ## Guardrails -1. **User decides every disposition** -- present, don't decide -2. **Batch presentation, individual decisions** -- show the full queue, then process one at a time -3. **Log everything** -- no silent discards or promotions -4. **Deferred items resurface** -- they'll appear again next `/triage` - ---- - -## Suggests Next - -After triage completes, suggest `/shape` for any ideas promoted to shaping. +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. ## Related Skills -- **idea** -- Capture a new idea (fast, minimal friction) -- **shape** -- Develop an idea into a SPEC -- **brainstorm** -- Deep exploration of a problem space (produces sparks) -- **housekeeping** -- Flags brainstorm drafts with unprocessed sparks before deletion -- **reflect** -- Strategic document updates (separate from triage) +- **idea** — capture a new idea (fast, minimal friction) +- **explore** — divergent inquiry with portable checkpoints +- **shape** — develop a chosen direction into a bounded Change +- **housekeeping** — flags stale artifacts; does not choose dispositions diff --git a/dist/amp/skills/brainstorm/SKILL.md b/dist/amp/skills/brainstorm/SKILL.md index d4c15f81d..f2118bf9e 100644 --- a/dist/amp/skills/brainstorm/SKILL.md +++ b/dist/amp/skills/brainstorm/SKILL.md @@ -1,16 +1,17 @@ --- name: brainstorm description: >- - Conducts structured brainstorming with divergent thinking and trade-off - analysis. Use when the user asks "help me think through this," "what are the - options," or is exploring tradeoffs. Produces docs with sparks. Not for quick - ideas or shaping. + Preserves the structured divergent-thinking stance consumed by the explore + workflow: option generation before judgment, trade-off analysis, and spark + capture. Explore owns the user-facing inquiry lifecycle; reference this + technique from it. Not a primary entry point — route "explore this" or "help + me think through options" to explore. version: 2.0.0-alpha.9 --- # Brainstorm -Generative thinking — expanding possibilities before narrowing through structured exploration. +Generative thinking — expanding possibilities before narrowing. This stance is an internal technique consumed by `/explore`, which owns inquiry continuity through Explorations and portable checkpoints; invoke the technique from there rather than as a standalone workflow. ## Critical Rules @@ -25,17 +26,16 @@ Generative thinking — expanding possibilities before narrowing through structu **Never** - Prematurely commit to an option before full exploration -- Delete brainstorm documents — archive them for context preservation -- Process sparks during the main brainstorm — capture only, expand later -- Turn brainstorm into an interview — keep it exploratory +- Create documents, reports, or any Git artifact from this technique — the surrounding Explore workflow owns checkpoints and any durable writes +- Process sparks during the divergent pass — capture only, expand later +- Turn the divergence into an interview — keep it exploratory ## Verification -After work completes, verify: -- Brainstorm captured in SQLite or summarized in an explicitly durable report -- Sparks captured with `loaf spark capture` and optionally summarized in `## Sparks` -- Spark lifecycle documented: unprocessed → promoted/discarded -- Brainstorm references strategic context from VISION/STRATEGY +After a divergent pass, verify: +- Sparks captured with `loaf spark capture` as they arose +- The surrounding Exploration checkpointed the conclusions, discarded options, and open question (`loaf exploration checkpoint`) +- The divergence referenced strategic context from VISION/STRATEGY ## Quick Reference @@ -56,17 +56,14 @@ After work completes, verify: - **Title** -- one-line description ``` -Sparks are: lightweight, byproducts, worth remembering. Mark as `*(promoted)*` or `*(abandoned)*` after processing. - -Brainstorm documents are archived after sparks are processed — never deleted, since the exploration context has lasting value. SQLite spark state is the lifecycle source; draft markdown is a projection or narrative summary. +Sparks are lightweight byproducts worth remembering; their dispositions belong to triage. SQLite spark state is the source; any summary inside a checkpoint item is narrative, not lifecycle. ## Suggests Next -After brainstorming, suggest `/shape` if a clear idea emerged, or `/idea` to capture sparks for later. `/idea` invoked without arguments scans brainstorm docs for unprocessed sparks, bridging the brainstorm → idea pipeline. +After a divergent pass, checkpoint the surrounding Exploration (`loaf exploration checkpoint`), then suggest `/shape` if a clear direction emerged or `/triage` to disposition captured sparks and ideas. ## Topics | Topic | Reference | Use When | |-------|-----------|----------| -| Brainstorm Template | [templates/brainstorm.md](templates/brainstorm.md) | Creating structured brainstorm documents | | Strategic Context | `strategy/references/` | Grounding exploration in project direction | diff --git a/dist/amp/skills/explore/SKILL.md b/dist/amp/skills/explore/SKILL.md new file mode 100644 index 000000000..3b34a682d --- /dev/null +++ b/dist/amp/skills/explore/SKILL.md @@ -0,0 +1,102 @@ +--- +name: explore +description: >- + Conducts divergent inquiry as a durable Exploration: portable checkpoints, + conversation provenance, and Intent capture that survive compaction and + harness changes. Use when the direction is genuinely undecided ("explore + this", "we don't know which approach yet") or when resuming a named + Exploration. Produces Exploration records, portable checkpoints, and tracked + or deferred Intents. Not for evidence gathering on a known question (use + research), continuing implementation or delivery work (use implement), + processing the intake queue (use triage), shaping a bounded Change (use + shape), or quick capture (use idea). +version: 2.0.0-alpha.9 +--- + +# Explore + +Divergent inquiry with durable continuity. An Exploration is a relational identity over immutable checkpoints and provenance — it has no status, no owner, and no lifecycle to maintain. Resuming means reading portable context and appending new facts, never toggling state. + +**Input:** $ARGUMENTS + +--- + +## Contents +- Critical Rules +- Verification +- Quick Reference +- Process +- Checkpoint Discipline +- Resumption +- Techniques +- Related Skills + +## Critical Rules + +- Log invocation first: `loaf journal log "skill(explore): <topic or exploration ref>"` +- 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 bounded delivery, hand it to `/shape`. +- Never store transcripts, prompts, or tool output in checkpoints or items; curate semantic context instead. + +## 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`). +- 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>]...` | +| 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>]` | +| 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>` | + +## 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. +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. +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 + +The four fields are the portable contract; each is capped at 4096 UTF-8 bytes and oversize input is rejected, never truncated: + +- **purpose** — the current framing of the inquiry, restated so a stranger understands what is being explored and why. +- **conclusions** — constraints and conclusions established so far, including rejected options and why they fell. +- **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. + +## 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. + +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. + +## Deferring + +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. + +## Techniques + +Brainstorm's full divergent stance lives inside Explore: generate options before judging, connect to VISION/STRATEGY context, document discarded options, set boundaries on exploration time. Scout, research, prototype, and spike remain subordinate techniques invoked from whatever stage needs them — none of them owns lifecycle. + +## Related Skills + +- **triage** — processes the intake queue and chooses dispositions, including "explore this" +- **shape** — narrows one direction into a bounded Change; the exit door from Explore +- **research** — evidence gathering for a known question, usable inside an Exploration +- **idea** — quick capture without inquiry diff --git a/dist/amp/skills/idea/SKILL.md b/dist/amp/skills/idea/SKILL.md index ca0dd17c3..a38af73ae 100644 --- a/dist/amp/skills/idea/SKILL.md +++ b/dist/amp/skills/idea/SKILL.md @@ -3,9 +3,10 @@ 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. For reviewing and - processing the intake queue (sparks + raw ideas), use triage instead. Not for - deep exploration (use brainstorm) or shaping (use shape). + actionable concept crystallizes during conversation. Ideas and sparks are + capture primitives routed through triage, which chooses dispositions such as + tracking an Intent. Not for divergent inquiry (use explore), processing the + intake queue (use triage), or shaping (use shape). version: 2.0.0-alpha.9 --- @@ -39,83 +40,46 @@ Capture ideas quickly with minimal friction. ## Verification -- Idea appears in `loaf idea list` / `loaf idea show` -- Status is open/raw according to the active backend -- If promoted from a spark, `loaf spark promote` records the relationship +- The idea appears in `loaf idea list` and `loaf idea show <ref>` with status open +- If promoted from a spark, `loaf spark promote` recorded the relationship +- No shaping, status transition, or promotion happened here — dispositions belong to triage ## Quick Reference -| Status | Meaning | -|--------|---------| -| `raw` | Just captured, unprocessed | -| `shaping` | Being developed via /shape or /brainstorm | -| `shaped` | Converted to SPEC, idea file archived | -| `archived` | Decided not to pursue, kept for reference | +| Operation | Command | +|-----------|---------| +| Capture | `loaf idea capture --title "<title>"` | +| Read back | `loaf idea show <ref>` | +| List open | `loaf idea list` | --- ## Purpose -Ideas are raw nuggets -- unprocessed, unshaped, but worth remembering. The goal is **speed of capture**, not thoroughness. Shape later via `/shape`. +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, exploring it, shaping it, or archiving it are triage dispositions chosen later by the user. --- ## Process -### Step 1: Parse Input - -If `$ARGUMENTS` contains the idea, capture directly. - -If `$ARGUMENTS` is empty, ask **at most 2-3 questions**: core idea, problem/opportunity, immediate constraints. - -### Step 2: Capture Idea - -Use the CLI capture path: - -```bash -loaf idea capture --title "..." -``` - -In SQLite-backed mode, the row is stored in SQLite and no `.agents/ideas/` -markdown file is created. The [idea template](templates/idea.md) is retained -only for markdown-only compatibility and historical restore review. - -### Step 3: Create and Announce - -1. Generate timestamp: `date -u +"%Y-%m-%dT%H:%M:%SZ"` -2. Run `loaf idea capture --title "..."` with the inferred title -3. Announce the captured idea alias with next steps - ---- - -## Idea Lifecycle - -``` -raw -> shaping -> shaped (becomes SPEC) -> archived -``` - -| Status | Meaning | -|--------|---------| -| `raw` | Just captured, unprocessed | -| `shaping` | Being developed via /shape or /brainstorm | -| `shaped` | Converted to SPEC, idea file archived | -| `archived` | Decided not to pursue, kept for reference | +1. **Parse input.** If `$ARGUMENTS` contains the idea, capture directly. If empty, ask at most 2-3 questions: core idea, problem/opportunity, immediate constraints. +2. **Capture.** Run `loaf idea capture --title "..."` with the inferred title; log notable context with `loaf journal log`. +3. **Announce.** Report the captured alias and point at `/triage` for disposition. --- ## Guardrails -1. **Speed over completeness** -- capture quickly, shape later -2. **2-3 questions max** -- don't turn this into an interview -3. **Infer, don't ask** -- metadata should be automatic -4. **One idea per captured row/artifact** -- keep them atomic -5. **No shaping here** -- that's what `/shape` is for +1. **Speed over completeness** — capture quickly, disposition later +2. **2-3 questions max** — don't turn this into an interview +3. **Infer, don't ask** — metadata should be automatic +4. **One idea per captured row** — keep them atomic +5. **No lifecycle here** — no status transitions, promotion, or shaping; triage owns dispositions and the CLI performs them --- ## Related Skills -- **triage** -- Review and process the intake queue (sparks + raw ideas) -- **shape** -- Develop an idea into a SPEC -- **brainstorm** -- Deep thinking on an idea or problem space -- **research** -- Investigate before capturing +- **triage** — process the intake queue and choose dispositions +- **explore** — divergent inquiry when an idea needs development before commitment +- **shape** — develop a chosen direction into a bounded Change diff --git a/dist/amp/skills/loaf-reference/SKILL.md b/dist/amp/skills/loaf-reference/SKILL.md index fa6f7c4ec..97a612be1 100644 --- a/dist/amp/skills/loaf-reference/SKILL.md +++ b/dist/amp/skills/loaf-reference/SKILL.md @@ -2,10 +2,11 @@ name: loaf-reference description: >- Documents how agents operate the Loaf CLI: command discovery via loaf --help, - JSON diagnosis surfaces, guided config maintenance, and troubleshooting. Use - when unsure which loaf command to invoke or how to validate project state. Not - for workflow guidance (workflow skills own their CLI contracts) or build - internals. + JSON diagnosis surfaces, config-aware maintenance, and troubleshooting. Use + when unsure which loaf command to invoke, how to validate project state, or + when asked to upgrade, diagnose, repair, configure, or bring a Loaf project + current. Not for workflow guidance (workflow skills own their CLI contracts) + or build internals. version: 2.0.0-alpha.9 --- @@ -66,7 +67,7 @@ Names and one-line purposes only. Run `loaf <command> --help` for options, argum | `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 lifecycle-statuses, backup, backup verify, backup restore, restore-ephemerals, verify-ephemerals, export, export all, export triage, export spec, export release-readiness | +| `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, 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, worktree-storage | @@ -85,6 +86,10 @@ 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 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 | | `loaf spark` | Manage sparks in native SQLite state | list, show, capture, resolve, promote | | `loaf tag` | Manage tags in native SQLite state | list, show, add, remove | | `loaf bundle` | Manage bundles in native SQLite state | list, create, update, show, add, remove | @@ -97,5 +102,6 @@ Names and one-line purposes only. Run `loaf <command> --help` for options, argum | Topic | Reference | Use When | |-------|-----------|----------| | Configuration maintenance | [references/configuration.md](references/configuration.md) | Checking whether a project's Loaf config is current and repairing it; wiring project-owned choices | +| Config-aware maintenance protocol | [references/maintenance.md](references/maintenance.md) | Upgrading, diagnosing, repairing, or bringing a project current: diagnose, plan, ask, apply, verify | | Command routing | [references/command-routing.md](references/command-routing.md) | Deciding which command a task needs; locating the JSON diagnosis surfaces | | Troubleshooting | [references/troubleshooting.md](references/troubleshooting.md) | Diagnosing state, config, or alignment failures; isolating a throwaway database | diff --git a/dist/amp/skills/loaf-reference/references/maintenance.md b/dist/amp/skills/loaf-reference/references/maintenance.md new file mode 100644 index 000000000..db5feed6e --- /dev/null +++ b/dist/amp/skills/loaf-reference/references/maintenance.md @@ -0,0 +1,48 @@ +# Config-Aware Loaf Maintenance + +## Contents +- Protocol +- Fact Sources +- Planning Surfaces +- Consent Boundaries +- What Maintenance Never Does + +This protocol serves natural-language requests to upgrade, diagnose, repair, configure, or bring a Loaf project current. It is a hidden operator layer: interpret facts, ask only for missing project-owned choices, sequence approved deterministic operations, and verify convergence. Discover exact current syntax from `loaf <command> --help` instead of memorizing flags. + +## Protocol + +0. **Classify the request.** A diagnose-only request stops after step 1 with facts; a plan request stops after step 2 with the mutation ledger; only an explicit repair, upgrade, or bring-current request proceeds to apply, and then only for the mutation classes the user's request actually named. Never let the protocol's shape carry a diagnosis into a mutation. +1. **Diagnose.** Start with `loaf config check --json` for project intent and installed hook health. Add `loaf version` (running executable), `loaf state status --json` and `loaf state doctor --json` (SQLite readiness, schema version, repair plan), and `loaf doctor --json` (project alignment: symlinks, stale files, fenced-version drift). All four are read-only. +2. **Plan.** For installed-target convergence, use `loaf install --upgrade --dry-run --json`: it reports intended creates, updates, retirements, preserved conflicts, deprecation actions, project-file effects, and whether explicit consent is required, without writing anything. +3. **Ask.** Only for project-owned choices the facts cannot answer (for example integration election in `.agents/loaf.json`, or consent to destructive deprecation cleanup). Machine-observed facts are never questions. Present one complete mutation ledger — every intended operation with its consent requirement — and obtain approval for the ledger as a whole before applying any part of it. +4. **Apply.** Use the existing explicit operations the ledger named: `loaf config check --fix`, `loaf install --upgrade` (with `-y` only after consent), `loaf doctor --fix --force` (the `--force` form is required for non-interactive execution; plain `--fix` prompts and silently skips repairs without a TTY — if a repair genuinely needs interactive judgment, stop and report that it requires an operator), `loaf state migrate schema --apply`, `loaf state migrate deferrals --apply`. A project-owned election is recorded by editing `.agents/loaf.json` in the checkout — a durable, reviewable change — and validating with `loaf config check --json`. Never invent a bypass. +5. **Verify.** Rerun the diagnosis surfaces and confirm they converge; report any check that still fails rather than declaring success, and never loop back into apply without a changed ledger. + +## Fact Sources + +| Fact | Source | Authority | +|------|--------|-----------| +| Team-owned project intent (integrations, knowledge dirs) | `.agents/loaf.json` via `loaf config check --json` | Shared config; never records machine-local install state | +| Running executable version and provenance | `loaf version` | Observed local fact; the package manager owns acquisition | +| SQLite readiness, schema version, repair plan | `loaf state status --json`, `loaf state doctor --json` | Behind-schema state returns the exact backup-first `loaf state migrate schema --apply` action | +| Project alignment (symlinks, stale files, version fences) | `loaf doctor --json` | Read-only; repairs go through the explicit `--fix` contract | +| Installed target ownership and drift | `loaf install --upgrade --dry-run --json` | Content-addressed ownership manifests decide owned versus foreign | + +## Planning Surfaces + +- `loaf doctor --json` never prompts and never repairs; it carries the identical check identities and outcomes as the human output plus repair IDs for planning. +- `loaf install --upgrade --dry-run --json` is deterministic and byte-for-byte non-mutating; applying the reported plan through the existing commands must produce the predicted effects, after which diagnosis reports convergence. +- `loaf state migrate deferrals --dry-run --json` previews the legacy-deferral conversion manifest; apply is backup-first, preserves every legacy row, and is rerunnable. + +## Consent Boundaries + +- Database migrations (`state migrate schema --apply`, `state migrate deferrals --apply`) are backup-first and require the operator's explicit go-ahead on real state. +- Destructive deprecation cleanup during `install --upgrade` requires explicit consent (`-y`); missing consent must surface as a reported requirement, not an assumed yes. +- Locally modified or unowned destination content is preserved and reported, never overwritten. + +## What Maintenance Never Does + +- Never invokes Homebrew, npm, or any package manager, and never claims a newer remote version exists without evidence from the owning package manager. +- Never hardcodes a Cellar, checkout, or binary path; `loaf` resolves on `PATH`. +- Never infers machine-local installed-target intent from Git-tracked config, and never writes machine-specific fields into `.agents/loaf.json`. +- Never applies a database migration automatically as a side effect of diagnosis. diff --git a/dist/amp/skills/triage/SKILL.md b/dist/amp/skills/triage/SKILL.md index c2d5b735d..c496fafe3 100644 --- a/dist/amp/skills/triage/SKILL.md +++ b/dist/amp/skills/triage/SKILL.md @@ -1,18 +1,20 @@ --- name: triage description: >- - Surfaces and processes the intake queue: unresolved sparks from the project - journal and brainstorm documents, plus raw ideas awaiting evaluation. Use when - the user asks "what sparks do I have?", "review my ideas", "triage", or - "what's in my backlog?" Produces promoted ideas, archived discards, and - resolve(spark) journal entries. Not for capturing new ideas (use idea) or - shaping (use shape). + 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, or hand to + shape. Not for reading a single known item (use loaf intent show or journal + directly), capturing new ideas (use idea), divergent inquiry (use explore), or + bounding one chosen direction (use shape). version: 2.0.0-alpha.9 --- # Triage -Review and process the intake queue — sparks and raw ideas. +Process the intake queue. Triage is the public funnel where captured material meets judgment: you present facts, the user chooses each disposition, and the CLI performs exactly what was chosen. **Input:** $ARGUMENTS @@ -23,159 +25,70 @@ Review and process the intake queue — sparks and raw ideas. - Verification - Quick Reference - Process -- Resolution Formats +- Dispositions +- Legacy Deferrals - Guardrails - Related Skills ## Critical Rules -- Present everything before acting -- user decides each disposition -- Never auto-promote or auto-discard without confirmation -- Use SQLite-aware CLI commands for lifecycle changes; do not edit idea/spark - frontmatter by hand -- Log or link resolutions through `loaf spark resolve`, `loaf spark promote`, - `loaf idea archive`, and `loaf brainstorm archive` when state is initialized -- One pass through the queue -- don't loop or re-present items +- Log invocation first: `loaf journal log "skill(triage): <trigger or scope>"` +- 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. +- One pass through the queue — don't loop or re-present items. ## Verification -- All presented sparks have a recorded disposition (promoted, discarded, or deferred) -- Promoted sparks have corresponding idea rows visible in `loaf idea list` -- Processed sparks no longer appear in default `loaf spark list` / triage output -- Archived ideas/brainstorms no longer appear in default triage lists -- Markdown source annotations, when present, are compatibility notes rather than - the authoritative state transition +- 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`). +- 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. ## Quick Reference -| Source | Unprocessed Signal | Resolution | -|--------|-------------------|------------| -| Sparks | Open spark rows from `loaf spark list` | `loaf spark promote` or `loaf spark resolve` | -| Brainstorms | Open brainstorm rows from `loaf brainstorm list` | `loaf brainstorm promote` or `loaf brainstorm archive` | -| Ideas | Open idea rows from `loaf idea list` | Shape, promote, or `loaf idea archive` | - ---- +| 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 `/shape` | +| brainstorm | archived divergent sessions | archive, explore, promote | +| intent (tracked) | `loaf intent create` | keep tracking, defer, resolve, explore, 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) | ## Process -### Step 1: Scan Sources - -Scan state-backed queues first, falling back to Markdown compatibility sources -only when SQLite state is not initialized: - -**1. Sparks** -- Run `loaf spark list` or `loaf spark list --json` -- Treat open rows as unresolved intake - -**2. Brainstorms** -- Run `loaf brainstorm list` or `loaf brainstorm list --json` -- Treat open rows as brainstorm intake - -**3. Ideas** -- Run `loaf idea list` or `loaf idea list --json` -- Treat open rows as idea intake - -### Step 2: Present the Queue - -Show a summary table: - -``` -Intake Queue: - Sparks (journal): 3 unresolved - Sparks (brainstorms): 1 unprocessed - Raw ideas: 2 awaiting evaluation - Total: 6 items -``` - -Then list each item with source, date, and description. - -### Step 3: Process Each Item - -For each item, present it and ask for disposition: - -**Sparks → one of:** -- **Promote** → `loaf spark promote <spark> --to-idea <idea>` -- **Discard** → `loaf spark resolve <spark> --reason <reason>` -- **Defer** → skip, resurface next triage - -**Raw ideas → one of:** -- **Shape** → suggest running `/shape` with this idea -- **Brainstorm** → suggest running `/brainstorm` to explore further -- **Archive** → `loaf idea archive <idea> --reason <reason>` - -### Step 4: Summarize - -After processing, show what happened: +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. -``` -Triage complete: - Promoted: 2 sparks → ideas - Discarded: 1 spark - Deferred: 1 spark - Shaped: 1 idea → /shape - Archived: 1 idea -``` +## Dispositions ---- - -## Resolution Formats - -### Sparks - -When promoting: -```bash -loaf spark promote SPARK-slug --to-idea idea-slug -``` - -When discarding: -```bash -loaf spark resolve SPARK-slug --reason "reason" -``` - -When deferring: -Do nothing; open sparks remain visible in the next triage pass. - -### Brainstorms - -When promoting: -```bash -loaf brainstorm promote brainstorm-slug --to-idea idea-slug -``` +- **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** — hand the item to `/explore`, linking it with `--from` when the Exploration is created. +- **Shape** — when a direction is ready for bounded delivery, hand it to `/shape`; triage never creates Changes, branches, or worktrees. -When archiving: -```bash -loaf brainstorm archive brainstorm-slug --reason "reason" -``` +## Legacy Deferrals -### Ideas - -When archiving: -```bash -loaf idea archive idea-slug --reason "reason" -``` - -When shaping, pass the idea to `/shape`; do not hand-edit status frontmatter to -represent lifecycle state. - ---- +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. ## Guardrails -1. **User decides every disposition** -- present, don't decide -2. **Batch presentation, individual decisions** -- show the full queue, then process one at a time -3. **Log everything** -- no silent discards or promotions -4. **Deferred items resurface** -- they'll appear again next `/triage` - ---- - -## Suggests Next - -After triage completes, suggest `/shape` for any ideas promoted to shaping. +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. ## Related Skills -- **idea** -- Capture a new idea (fast, minimal friction) -- **shape** -- Develop an idea into a SPEC -- **brainstorm** -- Deep exploration of a problem space (produces sparks) -- **housekeeping** -- Flags brainstorm drafts with unprocessed sparks before deletion -- **reflect** -- Strategic document updates (separate from triage) +- **idea** — capture a new idea (fast, minimal friction) +- **explore** — divergent inquiry with portable checkpoints +- **shape** — develop a chosen direction into a bounded Change +- **housekeeping** — flags stale artifacts; does not choose dispositions diff --git a/dist/codex/skills/brainstorm/SKILL.md b/dist/codex/skills/brainstorm/SKILL.md index d4c15f81d..f2118bf9e 100644 --- a/dist/codex/skills/brainstorm/SKILL.md +++ b/dist/codex/skills/brainstorm/SKILL.md @@ -1,16 +1,17 @@ --- name: brainstorm description: >- - Conducts structured brainstorming with divergent thinking and trade-off - analysis. Use when the user asks "help me think through this," "what are the - options," or is exploring tradeoffs. Produces docs with sparks. Not for quick - ideas or shaping. + Preserves the structured divergent-thinking stance consumed by the explore + workflow: option generation before judgment, trade-off analysis, and spark + capture. Explore owns the user-facing inquiry lifecycle; reference this + technique from it. Not a primary entry point — route "explore this" or "help + me think through options" to explore. version: 2.0.0-alpha.9 --- # Brainstorm -Generative thinking — expanding possibilities before narrowing through structured exploration. +Generative thinking — expanding possibilities before narrowing. This stance is an internal technique consumed by `/explore`, which owns inquiry continuity through Explorations and portable checkpoints; invoke the technique from there rather than as a standalone workflow. ## Critical Rules @@ -25,17 +26,16 @@ Generative thinking — expanding possibilities before narrowing through structu **Never** - Prematurely commit to an option before full exploration -- Delete brainstorm documents — archive them for context preservation -- Process sparks during the main brainstorm — capture only, expand later -- Turn brainstorm into an interview — keep it exploratory +- Create documents, reports, or any Git artifact from this technique — the surrounding Explore workflow owns checkpoints and any durable writes +- Process sparks during the divergent pass — capture only, expand later +- Turn the divergence into an interview — keep it exploratory ## Verification -After work completes, verify: -- Brainstorm captured in SQLite or summarized in an explicitly durable report -- Sparks captured with `loaf spark capture` and optionally summarized in `## Sparks` -- Spark lifecycle documented: unprocessed → promoted/discarded -- Brainstorm references strategic context from VISION/STRATEGY +After a divergent pass, verify: +- Sparks captured with `loaf spark capture` as they arose +- The surrounding Exploration checkpointed the conclusions, discarded options, and open question (`loaf exploration checkpoint`) +- The divergence referenced strategic context from VISION/STRATEGY ## Quick Reference @@ -56,17 +56,14 @@ After work completes, verify: - **Title** -- one-line description ``` -Sparks are: lightweight, byproducts, worth remembering. Mark as `*(promoted)*` or `*(abandoned)*` after processing. - -Brainstorm documents are archived after sparks are processed — never deleted, since the exploration context has lasting value. SQLite spark state is the lifecycle source; draft markdown is a projection or narrative summary. +Sparks are lightweight byproducts worth remembering; their dispositions belong to triage. SQLite spark state is the source; any summary inside a checkpoint item is narrative, not lifecycle. ## Suggests Next -After brainstorming, suggest `/shape` if a clear idea emerged, or `/idea` to capture sparks for later. `/idea` invoked without arguments scans brainstorm docs for unprocessed sparks, bridging the brainstorm → idea pipeline. +After a divergent pass, checkpoint the surrounding Exploration (`loaf exploration checkpoint`), then suggest `/shape` if a clear direction emerged or `/triage` to disposition captured sparks and ideas. ## Topics | Topic | Reference | Use When | |-------|-----------|----------| -| Brainstorm Template | [templates/brainstorm.md](templates/brainstorm.md) | Creating structured brainstorm documents | | Strategic Context | `strategy/references/` | Grounding exploration in project direction | diff --git a/dist/codex/skills/explore/SKILL.md b/dist/codex/skills/explore/SKILL.md new file mode 100644 index 000000000..3b34a682d --- /dev/null +++ b/dist/codex/skills/explore/SKILL.md @@ -0,0 +1,102 @@ +--- +name: explore +description: >- + Conducts divergent inquiry as a durable Exploration: portable checkpoints, + conversation provenance, and Intent capture that survive compaction and + harness changes. Use when the direction is genuinely undecided ("explore + this", "we don't know which approach yet") or when resuming a named + Exploration. Produces Exploration records, portable checkpoints, and tracked + or deferred Intents. Not for evidence gathering on a known question (use + research), continuing implementation or delivery work (use implement), + processing the intake queue (use triage), shaping a bounded Change (use + shape), or quick capture (use idea). +version: 2.0.0-alpha.9 +--- + +# Explore + +Divergent inquiry with durable continuity. An Exploration is a relational identity over immutable checkpoints and provenance — it has no status, no owner, and no lifecycle to maintain. Resuming means reading portable context and appending new facts, never toggling state. + +**Input:** $ARGUMENTS + +--- + +## Contents +- Critical Rules +- Verification +- Quick Reference +- Process +- Checkpoint Discipline +- Resumption +- Techniques +- Related Skills + +## Critical Rules + +- Log invocation first: `loaf journal log "skill(explore): <topic or exploration ref>"` +- 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 bounded delivery, hand it to `/shape`. +- Never store transcripts, prompts, or tool output in checkpoints or items; curate semantic context instead. + +## 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`). +- 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>]...` | +| 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>]` | +| 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>` | + +## 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. +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. +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 + +The four fields are the portable contract; each is capped at 4096 UTF-8 bytes and oversize input is rejected, never truncated: + +- **purpose** — the current framing of the inquiry, restated so a stranger understands what is being explored and why. +- **conclusions** — constraints and conclusions established so far, including rejected options and why they fell. +- **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. + +## 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. + +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. + +## Deferring + +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. + +## Techniques + +Brainstorm's full divergent stance lives inside Explore: generate options before judging, connect to VISION/STRATEGY context, document discarded options, set boundaries on exploration time. Scout, research, prototype, and spike remain subordinate techniques invoked from whatever stage needs them — none of them owns lifecycle. + +## Related Skills + +- **triage** — processes the intake queue and chooses dispositions, including "explore this" +- **shape** — narrows one direction into a bounded Change; the exit door from Explore +- **research** — evidence gathering for a known question, usable inside an Exploration +- **idea** — quick capture without inquiry diff --git a/dist/codex/skills/idea/SKILL.md b/dist/codex/skills/idea/SKILL.md index ca0dd17c3..a38af73ae 100644 --- a/dist/codex/skills/idea/SKILL.md +++ b/dist/codex/skills/idea/SKILL.md @@ -3,9 +3,10 @@ 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. For reviewing and - processing the intake queue (sparks + raw ideas), use triage instead. Not for - deep exploration (use brainstorm) or shaping (use shape). + actionable concept crystallizes during conversation. Ideas and sparks are + capture primitives routed through triage, which chooses dispositions such as + tracking an Intent. Not for divergent inquiry (use explore), processing the + intake queue (use triage), or shaping (use shape). version: 2.0.0-alpha.9 --- @@ -39,83 +40,46 @@ Capture ideas quickly with minimal friction. ## Verification -- Idea appears in `loaf idea list` / `loaf idea show` -- Status is open/raw according to the active backend -- If promoted from a spark, `loaf spark promote` records the relationship +- The idea appears in `loaf idea list` and `loaf idea show <ref>` with status open +- If promoted from a spark, `loaf spark promote` recorded the relationship +- No shaping, status transition, or promotion happened here — dispositions belong to triage ## Quick Reference -| Status | Meaning | -|--------|---------| -| `raw` | Just captured, unprocessed | -| `shaping` | Being developed via /shape or /brainstorm | -| `shaped` | Converted to SPEC, idea file archived | -| `archived` | Decided not to pursue, kept for reference | +| Operation | Command | +|-----------|---------| +| Capture | `loaf idea capture --title "<title>"` | +| Read back | `loaf idea show <ref>` | +| List open | `loaf idea list` | --- ## Purpose -Ideas are raw nuggets -- unprocessed, unshaped, but worth remembering. The goal is **speed of capture**, not thoroughness. Shape later via `/shape`. +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, exploring it, shaping it, or archiving it are triage dispositions chosen later by the user. --- ## Process -### Step 1: Parse Input - -If `$ARGUMENTS` contains the idea, capture directly. - -If `$ARGUMENTS` is empty, ask **at most 2-3 questions**: core idea, problem/opportunity, immediate constraints. - -### Step 2: Capture Idea - -Use the CLI capture path: - -```bash -loaf idea capture --title "..." -``` - -In SQLite-backed mode, the row is stored in SQLite and no `.agents/ideas/` -markdown file is created. The [idea template](templates/idea.md) is retained -only for markdown-only compatibility and historical restore review. - -### Step 3: Create and Announce - -1. Generate timestamp: `date -u +"%Y-%m-%dT%H:%M:%SZ"` -2. Run `loaf idea capture --title "..."` with the inferred title -3. Announce the captured idea alias with next steps - ---- - -## Idea Lifecycle - -``` -raw -> shaping -> shaped (becomes SPEC) -> archived -``` - -| Status | Meaning | -|--------|---------| -| `raw` | Just captured, unprocessed | -| `shaping` | Being developed via /shape or /brainstorm | -| `shaped` | Converted to SPEC, idea file archived | -| `archived` | Decided not to pursue, kept for reference | +1. **Parse input.** If `$ARGUMENTS` contains the idea, capture directly. If empty, ask at most 2-3 questions: core idea, problem/opportunity, immediate constraints. +2. **Capture.** Run `loaf idea capture --title "..."` with the inferred title; log notable context with `loaf journal log`. +3. **Announce.** Report the captured alias and point at `/triage` for disposition. --- ## Guardrails -1. **Speed over completeness** -- capture quickly, shape later -2. **2-3 questions max** -- don't turn this into an interview -3. **Infer, don't ask** -- metadata should be automatic -4. **One idea per captured row/artifact** -- keep them atomic -5. **No shaping here** -- that's what `/shape` is for +1. **Speed over completeness** — capture quickly, disposition later +2. **2-3 questions max** — don't turn this into an interview +3. **Infer, don't ask** — metadata should be automatic +4. **One idea per captured row** — keep them atomic +5. **No lifecycle here** — no status transitions, promotion, or shaping; triage owns dispositions and the CLI performs them --- ## Related Skills -- **triage** -- Review and process the intake queue (sparks + raw ideas) -- **shape** -- Develop an idea into a SPEC -- **brainstorm** -- Deep thinking on an idea or problem space -- **research** -- Investigate before capturing +- **triage** — process the intake queue and choose dispositions +- **explore** — divergent inquiry when an idea needs development before commitment +- **shape** — develop a chosen direction into a bounded Change diff --git a/dist/codex/skills/loaf-reference/SKILL.md b/dist/codex/skills/loaf-reference/SKILL.md index fa6f7c4ec..97a612be1 100644 --- a/dist/codex/skills/loaf-reference/SKILL.md +++ b/dist/codex/skills/loaf-reference/SKILL.md @@ -2,10 +2,11 @@ name: loaf-reference description: >- Documents how agents operate the Loaf CLI: command discovery via loaf --help, - JSON diagnosis surfaces, guided config maintenance, and troubleshooting. Use - when unsure which loaf command to invoke or how to validate project state. Not - for workflow guidance (workflow skills own their CLI contracts) or build - internals. + JSON diagnosis surfaces, config-aware maintenance, and troubleshooting. Use + when unsure which loaf command to invoke, how to validate project state, or + when asked to upgrade, diagnose, repair, configure, or bring a Loaf project + current. Not for workflow guidance (workflow skills own their CLI contracts) + or build internals. version: 2.0.0-alpha.9 --- @@ -66,7 +67,7 @@ Names and one-line purposes only. Run `loaf <command> --help` for options, argum | `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 lifecycle-statuses, backup, backup verify, backup restore, restore-ephemerals, verify-ephemerals, export, export all, export triage, export spec, export release-readiness | +| `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, 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, worktree-storage | @@ -85,6 +86,10 @@ 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 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 | | `loaf spark` | Manage sparks in native SQLite state | list, show, capture, resolve, promote | | `loaf tag` | Manage tags in native SQLite state | list, show, add, remove | | `loaf bundle` | Manage bundles in native SQLite state | list, create, update, show, add, remove | @@ -97,5 +102,6 @@ Names and one-line purposes only. Run `loaf <command> --help` for options, argum | Topic | Reference | Use When | |-------|-----------|----------| | Configuration maintenance | [references/configuration.md](references/configuration.md) | Checking whether a project's Loaf config is current and repairing it; wiring project-owned choices | +| Config-aware maintenance protocol | [references/maintenance.md](references/maintenance.md) | Upgrading, diagnosing, repairing, or bringing a project current: diagnose, plan, ask, apply, verify | | Command routing | [references/command-routing.md](references/command-routing.md) | Deciding which command a task needs; locating the JSON diagnosis surfaces | | Troubleshooting | [references/troubleshooting.md](references/troubleshooting.md) | Diagnosing state, config, or alignment failures; isolating a throwaway database | diff --git a/dist/codex/skills/loaf-reference/references/maintenance.md b/dist/codex/skills/loaf-reference/references/maintenance.md new file mode 100644 index 000000000..db5feed6e --- /dev/null +++ b/dist/codex/skills/loaf-reference/references/maintenance.md @@ -0,0 +1,48 @@ +# Config-Aware Loaf Maintenance + +## Contents +- Protocol +- Fact Sources +- Planning Surfaces +- Consent Boundaries +- What Maintenance Never Does + +This protocol serves natural-language requests to upgrade, diagnose, repair, configure, or bring a Loaf project current. It is a hidden operator layer: interpret facts, ask only for missing project-owned choices, sequence approved deterministic operations, and verify convergence. Discover exact current syntax from `loaf <command> --help` instead of memorizing flags. + +## Protocol + +0. **Classify the request.** A diagnose-only request stops after step 1 with facts; a plan request stops after step 2 with the mutation ledger; only an explicit repair, upgrade, or bring-current request proceeds to apply, and then only for the mutation classes the user's request actually named. Never let the protocol's shape carry a diagnosis into a mutation. +1. **Diagnose.** Start with `loaf config check --json` for project intent and installed hook health. Add `loaf version` (running executable), `loaf state status --json` and `loaf state doctor --json` (SQLite readiness, schema version, repair plan), and `loaf doctor --json` (project alignment: symlinks, stale files, fenced-version drift). All four are read-only. +2. **Plan.** For installed-target convergence, use `loaf install --upgrade --dry-run --json`: it reports intended creates, updates, retirements, preserved conflicts, deprecation actions, project-file effects, and whether explicit consent is required, without writing anything. +3. **Ask.** Only for project-owned choices the facts cannot answer (for example integration election in `.agents/loaf.json`, or consent to destructive deprecation cleanup). Machine-observed facts are never questions. Present one complete mutation ledger — every intended operation with its consent requirement — and obtain approval for the ledger as a whole before applying any part of it. +4. **Apply.** Use the existing explicit operations the ledger named: `loaf config check --fix`, `loaf install --upgrade` (with `-y` only after consent), `loaf doctor --fix --force` (the `--force` form is required for non-interactive execution; plain `--fix` prompts and silently skips repairs without a TTY — if a repair genuinely needs interactive judgment, stop and report that it requires an operator), `loaf state migrate schema --apply`, `loaf state migrate deferrals --apply`. A project-owned election is recorded by editing `.agents/loaf.json` in the checkout — a durable, reviewable change — and validating with `loaf config check --json`. Never invent a bypass. +5. **Verify.** Rerun the diagnosis surfaces and confirm they converge; report any check that still fails rather than declaring success, and never loop back into apply without a changed ledger. + +## Fact Sources + +| Fact | Source | Authority | +|------|--------|-----------| +| Team-owned project intent (integrations, knowledge dirs) | `.agents/loaf.json` via `loaf config check --json` | Shared config; never records machine-local install state | +| Running executable version and provenance | `loaf version` | Observed local fact; the package manager owns acquisition | +| SQLite readiness, schema version, repair plan | `loaf state status --json`, `loaf state doctor --json` | Behind-schema state returns the exact backup-first `loaf state migrate schema --apply` action | +| Project alignment (symlinks, stale files, version fences) | `loaf doctor --json` | Read-only; repairs go through the explicit `--fix` contract | +| Installed target ownership and drift | `loaf install --upgrade --dry-run --json` | Content-addressed ownership manifests decide owned versus foreign | + +## Planning Surfaces + +- `loaf doctor --json` never prompts and never repairs; it carries the identical check identities and outcomes as the human output plus repair IDs for planning. +- `loaf install --upgrade --dry-run --json` is deterministic and byte-for-byte non-mutating; applying the reported plan through the existing commands must produce the predicted effects, after which diagnosis reports convergence. +- `loaf state migrate deferrals --dry-run --json` previews the legacy-deferral conversion manifest; apply is backup-first, preserves every legacy row, and is rerunnable. + +## Consent Boundaries + +- Database migrations (`state migrate schema --apply`, `state migrate deferrals --apply`) are backup-first and require the operator's explicit go-ahead on real state. +- Destructive deprecation cleanup during `install --upgrade` requires explicit consent (`-y`); missing consent must surface as a reported requirement, not an assumed yes. +- Locally modified or unowned destination content is preserved and reported, never overwritten. + +## What Maintenance Never Does + +- Never invokes Homebrew, npm, or any package manager, and never claims a newer remote version exists without evidence from the owning package manager. +- Never hardcodes a Cellar, checkout, or binary path; `loaf` resolves on `PATH`. +- Never infers machine-local installed-target intent from Git-tracked config, and never writes machine-specific fields into `.agents/loaf.json`. +- Never applies a database migration automatically as a side effect of diagnosis. diff --git a/dist/codex/skills/triage/SKILL.md b/dist/codex/skills/triage/SKILL.md index c2d5b735d..c496fafe3 100644 --- a/dist/codex/skills/triage/SKILL.md +++ b/dist/codex/skills/triage/SKILL.md @@ -1,18 +1,20 @@ --- name: triage description: >- - Surfaces and processes the intake queue: unresolved sparks from the project - journal and brainstorm documents, plus raw ideas awaiting evaluation. Use when - the user asks "what sparks do I have?", "review my ideas", "triage", or - "what's in my backlog?" Produces promoted ideas, archived discards, and - resolve(spark) journal entries. Not for capturing new ideas (use idea) or - shaping (use shape). + 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, or hand to + shape. Not for reading a single known item (use loaf intent show or journal + directly), capturing new ideas (use idea), divergent inquiry (use explore), or + bounding one chosen direction (use shape). version: 2.0.0-alpha.9 --- # Triage -Review and process the intake queue — sparks and raw ideas. +Process the intake queue. Triage is the public funnel where captured material meets judgment: you present facts, the user chooses each disposition, and the CLI performs exactly what was chosen. **Input:** $ARGUMENTS @@ -23,159 +25,70 @@ Review and process the intake queue — sparks and raw ideas. - Verification - Quick Reference - Process -- Resolution Formats +- Dispositions +- Legacy Deferrals - Guardrails - Related Skills ## Critical Rules -- Present everything before acting -- user decides each disposition -- Never auto-promote or auto-discard without confirmation -- Use SQLite-aware CLI commands for lifecycle changes; do not edit idea/spark - frontmatter by hand -- Log or link resolutions through `loaf spark resolve`, `loaf spark promote`, - `loaf idea archive`, and `loaf brainstorm archive` when state is initialized -- One pass through the queue -- don't loop or re-present items +- Log invocation first: `loaf journal log "skill(triage): <trigger or scope>"` +- 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. +- One pass through the queue — don't loop or re-present items. ## Verification -- All presented sparks have a recorded disposition (promoted, discarded, or deferred) -- Promoted sparks have corresponding idea rows visible in `loaf idea list` -- Processed sparks no longer appear in default `loaf spark list` / triage output -- Archived ideas/brainstorms no longer appear in default triage lists -- Markdown source annotations, when present, are compatibility notes rather than - the authoritative state transition +- 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`). +- 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. ## Quick Reference -| Source | Unprocessed Signal | Resolution | -|--------|-------------------|------------| -| Sparks | Open spark rows from `loaf spark list` | `loaf spark promote` or `loaf spark resolve` | -| Brainstorms | Open brainstorm rows from `loaf brainstorm list` | `loaf brainstorm promote` or `loaf brainstorm archive` | -| Ideas | Open idea rows from `loaf idea list` | Shape, promote, or `loaf idea archive` | - ---- +| 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 `/shape` | +| brainstorm | archived divergent sessions | archive, explore, promote | +| intent (tracked) | `loaf intent create` | keep tracking, defer, resolve, explore, 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) | ## Process -### Step 1: Scan Sources - -Scan state-backed queues first, falling back to Markdown compatibility sources -only when SQLite state is not initialized: - -**1. Sparks** -- Run `loaf spark list` or `loaf spark list --json` -- Treat open rows as unresolved intake - -**2. Brainstorms** -- Run `loaf brainstorm list` or `loaf brainstorm list --json` -- Treat open rows as brainstorm intake - -**3. Ideas** -- Run `loaf idea list` or `loaf idea list --json` -- Treat open rows as idea intake - -### Step 2: Present the Queue - -Show a summary table: - -``` -Intake Queue: - Sparks (journal): 3 unresolved - Sparks (brainstorms): 1 unprocessed - Raw ideas: 2 awaiting evaluation - Total: 6 items -``` - -Then list each item with source, date, and description. - -### Step 3: Process Each Item - -For each item, present it and ask for disposition: - -**Sparks → one of:** -- **Promote** → `loaf spark promote <spark> --to-idea <idea>` -- **Discard** → `loaf spark resolve <spark> --reason <reason>` -- **Defer** → skip, resurface next triage - -**Raw ideas → one of:** -- **Shape** → suggest running `/shape` with this idea -- **Brainstorm** → suggest running `/brainstorm` to explore further -- **Archive** → `loaf idea archive <idea> --reason <reason>` - -### Step 4: Summarize - -After processing, show what happened: +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. -``` -Triage complete: - Promoted: 2 sparks → ideas - Discarded: 1 spark - Deferred: 1 spark - Shaped: 1 idea → /shape - Archived: 1 idea -``` +## Dispositions ---- - -## Resolution Formats - -### Sparks - -When promoting: -```bash -loaf spark promote SPARK-slug --to-idea idea-slug -``` - -When discarding: -```bash -loaf spark resolve SPARK-slug --reason "reason" -``` - -When deferring: -Do nothing; open sparks remain visible in the next triage pass. - -### Brainstorms - -When promoting: -```bash -loaf brainstorm promote brainstorm-slug --to-idea idea-slug -``` +- **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** — hand the item to `/explore`, linking it with `--from` when the Exploration is created. +- **Shape** — when a direction is ready for bounded delivery, hand it to `/shape`; triage never creates Changes, branches, or worktrees. -When archiving: -```bash -loaf brainstorm archive brainstorm-slug --reason "reason" -``` +## Legacy Deferrals -### Ideas - -When archiving: -```bash -loaf idea archive idea-slug --reason "reason" -``` - -When shaping, pass the idea to `/shape`; do not hand-edit status frontmatter to -represent lifecycle state. - ---- +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. ## Guardrails -1. **User decides every disposition** -- present, don't decide -2. **Batch presentation, individual decisions** -- show the full queue, then process one at a time -3. **Log everything** -- no silent discards or promotions -4. **Deferred items resurface** -- they'll appear again next `/triage` - ---- - -## Suggests Next - -After triage completes, suggest `/shape` for any ideas promoted to shaping. +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. ## Related Skills -- **idea** -- Capture a new idea (fast, minimal friction) -- **shape** -- Develop an idea into a SPEC -- **brainstorm** -- Deep exploration of a problem space (produces sparks) -- **housekeeping** -- Flags brainstorm drafts with unprocessed sparks before deletion -- **reflect** -- Strategic document updates (separate from triage) +- **idea** — capture a new idea (fast, minimal friction) +- **explore** — divergent inquiry with portable checkpoints +- **shape** — develop a chosen direction into a bounded Change +- **housekeeping** — flags stale artifacts; does not choose dispositions diff --git a/dist/cursor/skills/brainstorm/SKILL.md b/dist/cursor/skills/brainstorm/SKILL.md index d4c15f81d..f2118bf9e 100644 --- a/dist/cursor/skills/brainstorm/SKILL.md +++ b/dist/cursor/skills/brainstorm/SKILL.md @@ -1,16 +1,17 @@ --- name: brainstorm description: >- - Conducts structured brainstorming with divergent thinking and trade-off - analysis. Use when the user asks "help me think through this," "what are the - options," or is exploring tradeoffs. Produces docs with sparks. Not for quick - ideas or shaping. + Preserves the structured divergent-thinking stance consumed by the explore + workflow: option generation before judgment, trade-off analysis, and spark + capture. Explore owns the user-facing inquiry lifecycle; reference this + technique from it. Not a primary entry point — route "explore this" or "help + me think through options" to explore. version: 2.0.0-alpha.9 --- # Brainstorm -Generative thinking — expanding possibilities before narrowing through structured exploration. +Generative thinking — expanding possibilities before narrowing. This stance is an internal technique consumed by `/explore`, which owns inquiry continuity through Explorations and portable checkpoints; invoke the technique from there rather than as a standalone workflow. ## Critical Rules @@ -25,17 +26,16 @@ Generative thinking — expanding possibilities before narrowing through structu **Never** - Prematurely commit to an option before full exploration -- Delete brainstorm documents — archive them for context preservation -- Process sparks during the main brainstorm — capture only, expand later -- Turn brainstorm into an interview — keep it exploratory +- Create documents, reports, or any Git artifact from this technique — the surrounding Explore workflow owns checkpoints and any durable writes +- Process sparks during the divergent pass — capture only, expand later +- Turn the divergence into an interview — keep it exploratory ## Verification -After work completes, verify: -- Brainstorm captured in SQLite or summarized in an explicitly durable report -- Sparks captured with `loaf spark capture` and optionally summarized in `## Sparks` -- Spark lifecycle documented: unprocessed → promoted/discarded -- Brainstorm references strategic context from VISION/STRATEGY +After a divergent pass, verify: +- Sparks captured with `loaf spark capture` as they arose +- The surrounding Exploration checkpointed the conclusions, discarded options, and open question (`loaf exploration checkpoint`) +- The divergence referenced strategic context from VISION/STRATEGY ## Quick Reference @@ -56,17 +56,14 @@ After work completes, verify: - **Title** -- one-line description ``` -Sparks are: lightweight, byproducts, worth remembering. Mark as `*(promoted)*` or `*(abandoned)*` after processing. - -Brainstorm documents are archived after sparks are processed — never deleted, since the exploration context has lasting value. SQLite spark state is the lifecycle source; draft markdown is a projection or narrative summary. +Sparks are lightweight byproducts worth remembering; their dispositions belong to triage. SQLite spark state is the source; any summary inside a checkpoint item is narrative, not lifecycle. ## Suggests Next -After brainstorming, suggest `/shape` if a clear idea emerged, or `/idea` to capture sparks for later. `/idea` invoked without arguments scans brainstorm docs for unprocessed sparks, bridging the brainstorm → idea pipeline. +After a divergent pass, checkpoint the surrounding Exploration (`loaf exploration checkpoint`), then suggest `/shape` if a clear direction emerged or `/triage` to disposition captured sparks and ideas. ## Topics | Topic | Reference | Use When | |-------|-----------|----------| -| Brainstorm Template | [templates/brainstorm.md](templates/brainstorm.md) | Creating structured brainstorm documents | | Strategic Context | `strategy/references/` | Grounding exploration in project direction | diff --git a/dist/cursor/skills/explore/SKILL.md b/dist/cursor/skills/explore/SKILL.md new file mode 100644 index 000000000..3b34a682d --- /dev/null +++ b/dist/cursor/skills/explore/SKILL.md @@ -0,0 +1,102 @@ +--- +name: explore +description: >- + Conducts divergent inquiry as a durable Exploration: portable checkpoints, + conversation provenance, and Intent capture that survive compaction and + harness changes. Use when the direction is genuinely undecided ("explore + this", "we don't know which approach yet") or when resuming a named + Exploration. Produces Exploration records, portable checkpoints, and tracked + or deferred Intents. Not for evidence gathering on a known question (use + research), continuing implementation or delivery work (use implement), + processing the intake queue (use triage), shaping a bounded Change (use + shape), or quick capture (use idea). +version: 2.0.0-alpha.9 +--- + +# Explore + +Divergent inquiry with durable continuity. An Exploration is a relational identity over immutable checkpoints and provenance — it has no status, no owner, and no lifecycle to maintain. Resuming means reading portable context and appending new facts, never toggling state. + +**Input:** $ARGUMENTS + +--- + +## Contents +- Critical Rules +- Verification +- Quick Reference +- Process +- Checkpoint Discipline +- Resumption +- Techniques +- Related Skills + +## Critical Rules + +- Log invocation first: `loaf journal log "skill(explore): <topic or exploration ref>"` +- 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 bounded delivery, hand it to `/shape`. +- Never store transcripts, prompts, or tool output in checkpoints or items; curate semantic context instead. + +## 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`). +- 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>]...` | +| 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>]` | +| 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>` | + +## 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. +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. +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 + +The four fields are the portable contract; each is capped at 4096 UTF-8 bytes and oversize input is rejected, never truncated: + +- **purpose** — the current framing of the inquiry, restated so a stranger understands what is being explored and why. +- **conclusions** — constraints and conclusions established so far, including rejected options and why they fell. +- **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. + +## 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. + +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. + +## Deferring + +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. + +## Techniques + +Brainstorm's full divergent stance lives inside Explore: generate options before judging, connect to VISION/STRATEGY context, document discarded options, set boundaries on exploration time. Scout, research, prototype, and spike remain subordinate techniques invoked from whatever stage needs them — none of them owns lifecycle. + +## Related Skills + +- **triage** — processes the intake queue and chooses dispositions, including "explore this" +- **shape** — narrows one direction into a bounded Change; the exit door from Explore +- **research** — evidence gathering for a known question, usable inside an Exploration +- **idea** — quick capture without inquiry diff --git a/dist/cursor/skills/idea/SKILL.md b/dist/cursor/skills/idea/SKILL.md index ca0dd17c3..a38af73ae 100644 --- a/dist/cursor/skills/idea/SKILL.md +++ b/dist/cursor/skills/idea/SKILL.md @@ -3,9 +3,10 @@ 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. For reviewing and - processing the intake queue (sparks + raw ideas), use triage instead. Not for - deep exploration (use brainstorm) or shaping (use shape). + actionable concept crystallizes during conversation. Ideas and sparks are + capture primitives routed through triage, which chooses dispositions such as + tracking an Intent. Not for divergent inquiry (use explore), processing the + intake queue (use triage), or shaping (use shape). version: 2.0.0-alpha.9 --- @@ -39,83 +40,46 @@ Capture ideas quickly with minimal friction. ## Verification -- Idea appears in `loaf idea list` / `loaf idea show` -- Status is open/raw according to the active backend -- If promoted from a spark, `loaf spark promote` records the relationship +- The idea appears in `loaf idea list` and `loaf idea show <ref>` with status open +- If promoted from a spark, `loaf spark promote` recorded the relationship +- No shaping, status transition, or promotion happened here — dispositions belong to triage ## Quick Reference -| Status | Meaning | -|--------|---------| -| `raw` | Just captured, unprocessed | -| `shaping` | Being developed via /shape or /brainstorm | -| `shaped` | Converted to SPEC, idea file archived | -| `archived` | Decided not to pursue, kept for reference | +| Operation | Command | +|-----------|---------| +| Capture | `loaf idea capture --title "<title>"` | +| Read back | `loaf idea show <ref>` | +| List open | `loaf idea list` | --- ## Purpose -Ideas are raw nuggets -- unprocessed, unshaped, but worth remembering. The goal is **speed of capture**, not thoroughness. Shape later via `/shape`. +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, exploring it, shaping it, or archiving it are triage dispositions chosen later by the user. --- ## Process -### Step 1: Parse Input - -If `$ARGUMENTS` contains the idea, capture directly. - -If `$ARGUMENTS` is empty, ask **at most 2-3 questions**: core idea, problem/opportunity, immediate constraints. - -### Step 2: Capture Idea - -Use the CLI capture path: - -```bash -loaf idea capture --title "..." -``` - -In SQLite-backed mode, the row is stored in SQLite and no `.agents/ideas/` -markdown file is created. The [idea template](templates/idea.md) is retained -only for markdown-only compatibility and historical restore review. - -### Step 3: Create and Announce - -1. Generate timestamp: `date -u +"%Y-%m-%dT%H:%M:%SZ"` -2. Run `loaf idea capture --title "..."` with the inferred title -3. Announce the captured idea alias with next steps - ---- - -## Idea Lifecycle - -``` -raw -> shaping -> shaped (becomes SPEC) -> archived -``` - -| Status | Meaning | -|--------|---------| -| `raw` | Just captured, unprocessed | -| `shaping` | Being developed via /shape or /brainstorm | -| `shaped` | Converted to SPEC, idea file archived | -| `archived` | Decided not to pursue, kept for reference | +1. **Parse input.** If `$ARGUMENTS` contains the idea, capture directly. If empty, ask at most 2-3 questions: core idea, problem/opportunity, immediate constraints. +2. **Capture.** Run `loaf idea capture --title "..."` with the inferred title; log notable context with `loaf journal log`. +3. **Announce.** Report the captured alias and point at `/triage` for disposition. --- ## Guardrails -1. **Speed over completeness** -- capture quickly, shape later -2. **2-3 questions max** -- don't turn this into an interview -3. **Infer, don't ask** -- metadata should be automatic -4. **One idea per captured row/artifact** -- keep them atomic -5. **No shaping here** -- that's what `/shape` is for +1. **Speed over completeness** — capture quickly, disposition later +2. **2-3 questions max** — don't turn this into an interview +3. **Infer, don't ask** — metadata should be automatic +4. **One idea per captured row** — keep them atomic +5. **No lifecycle here** — no status transitions, promotion, or shaping; triage owns dispositions and the CLI performs them --- ## Related Skills -- **triage** -- Review and process the intake queue (sparks + raw ideas) -- **shape** -- Develop an idea into a SPEC -- **brainstorm** -- Deep thinking on an idea or problem space -- **research** -- Investigate before capturing +- **triage** — process the intake queue and choose dispositions +- **explore** — divergent inquiry when an idea needs development before commitment +- **shape** — develop a chosen direction into a bounded Change diff --git a/dist/cursor/skills/loaf-reference/SKILL.md b/dist/cursor/skills/loaf-reference/SKILL.md index fa6f7c4ec..97a612be1 100644 --- a/dist/cursor/skills/loaf-reference/SKILL.md +++ b/dist/cursor/skills/loaf-reference/SKILL.md @@ -2,10 +2,11 @@ name: loaf-reference description: >- Documents how agents operate the Loaf CLI: command discovery via loaf --help, - JSON diagnosis surfaces, guided config maintenance, and troubleshooting. Use - when unsure which loaf command to invoke or how to validate project state. Not - for workflow guidance (workflow skills own their CLI contracts) or build - internals. + JSON diagnosis surfaces, config-aware maintenance, and troubleshooting. Use + when unsure which loaf command to invoke, how to validate project state, or + when asked to upgrade, diagnose, repair, configure, or bring a Loaf project + current. Not for workflow guidance (workflow skills own their CLI contracts) + or build internals. version: 2.0.0-alpha.9 --- @@ -66,7 +67,7 @@ Names and one-line purposes only. Run `loaf <command> --help` for options, argum | `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 lifecycle-statuses, backup, backup verify, backup restore, restore-ephemerals, verify-ephemerals, export, export all, export triage, export spec, export release-readiness | +| `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, 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, worktree-storage | @@ -85,6 +86,10 @@ 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 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 | | `loaf spark` | Manage sparks in native SQLite state | list, show, capture, resolve, promote | | `loaf tag` | Manage tags in native SQLite state | list, show, add, remove | | `loaf bundle` | Manage bundles in native SQLite state | list, create, update, show, add, remove | @@ -97,5 +102,6 @@ Names and one-line purposes only. Run `loaf <command> --help` for options, argum | Topic | Reference | Use When | |-------|-----------|----------| | Configuration maintenance | [references/configuration.md](references/configuration.md) | Checking whether a project's Loaf config is current and repairing it; wiring project-owned choices | +| Config-aware maintenance protocol | [references/maintenance.md](references/maintenance.md) | Upgrading, diagnosing, repairing, or bringing a project current: diagnose, plan, ask, apply, verify | | Command routing | [references/command-routing.md](references/command-routing.md) | Deciding which command a task needs; locating the JSON diagnosis surfaces | | Troubleshooting | [references/troubleshooting.md](references/troubleshooting.md) | Diagnosing state, config, or alignment failures; isolating a throwaway database | diff --git a/dist/cursor/skills/loaf-reference/references/maintenance.md b/dist/cursor/skills/loaf-reference/references/maintenance.md new file mode 100644 index 000000000..db5feed6e --- /dev/null +++ b/dist/cursor/skills/loaf-reference/references/maintenance.md @@ -0,0 +1,48 @@ +# Config-Aware Loaf Maintenance + +## Contents +- Protocol +- Fact Sources +- Planning Surfaces +- Consent Boundaries +- What Maintenance Never Does + +This protocol serves natural-language requests to upgrade, diagnose, repair, configure, or bring a Loaf project current. It is a hidden operator layer: interpret facts, ask only for missing project-owned choices, sequence approved deterministic operations, and verify convergence. Discover exact current syntax from `loaf <command> --help` instead of memorizing flags. + +## Protocol + +0. **Classify the request.** A diagnose-only request stops after step 1 with facts; a plan request stops after step 2 with the mutation ledger; only an explicit repair, upgrade, or bring-current request proceeds to apply, and then only for the mutation classes the user's request actually named. Never let the protocol's shape carry a diagnosis into a mutation. +1. **Diagnose.** Start with `loaf config check --json` for project intent and installed hook health. Add `loaf version` (running executable), `loaf state status --json` and `loaf state doctor --json` (SQLite readiness, schema version, repair plan), and `loaf doctor --json` (project alignment: symlinks, stale files, fenced-version drift). All four are read-only. +2. **Plan.** For installed-target convergence, use `loaf install --upgrade --dry-run --json`: it reports intended creates, updates, retirements, preserved conflicts, deprecation actions, project-file effects, and whether explicit consent is required, without writing anything. +3. **Ask.** Only for project-owned choices the facts cannot answer (for example integration election in `.agents/loaf.json`, or consent to destructive deprecation cleanup). Machine-observed facts are never questions. Present one complete mutation ledger — every intended operation with its consent requirement — and obtain approval for the ledger as a whole before applying any part of it. +4. **Apply.** Use the existing explicit operations the ledger named: `loaf config check --fix`, `loaf install --upgrade` (with `-y` only after consent), `loaf doctor --fix --force` (the `--force` form is required for non-interactive execution; plain `--fix` prompts and silently skips repairs without a TTY — if a repair genuinely needs interactive judgment, stop and report that it requires an operator), `loaf state migrate schema --apply`, `loaf state migrate deferrals --apply`. A project-owned election is recorded by editing `.agents/loaf.json` in the checkout — a durable, reviewable change — and validating with `loaf config check --json`. Never invent a bypass. +5. **Verify.** Rerun the diagnosis surfaces and confirm they converge; report any check that still fails rather than declaring success, and never loop back into apply without a changed ledger. + +## Fact Sources + +| Fact | Source | Authority | +|------|--------|-----------| +| Team-owned project intent (integrations, knowledge dirs) | `.agents/loaf.json` via `loaf config check --json` | Shared config; never records machine-local install state | +| Running executable version and provenance | `loaf version` | Observed local fact; the package manager owns acquisition | +| SQLite readiness, schema version, repair plan | `loaf state status --json`, `loaf state doctor --json` | Behind-schema state returns the exact backup-first `loaf state migrate schema --apply` action | +| Project alignment (symlinks, stale files, version fences) | `loaf doctor --json` | Read-only; repairs go through the explicit `--fix` contract | +| Installed target ownership and drift | `loaf install --upgrade --dry-run --json` | Content-addressed ownership manifests decide owned versus foreign | + +## Planning Surfaces + +- `loaf doctor --json` never prompts and never repairs; it carries the identical check identities and outcomes as the human output plus repair IDs for planning. +- `loaf install --upgrade --dry-run --json` is deterministic and byte-for-byte non-mutating; applying the reported plan through the existing commands must produce the predicted effects, after which diagnosis reports convergence. +- `loaf state migrate deferrals --dry-run --json` previews the legacy-deferral conversion manifest; apply is backup-first, preserves every legacy row, and is rerunnable. + +## Consent Boundaries + +- Database migrations (`state migrate schema --apply`, `state migrate deferrals --apply`) are backup-first and require the operator's explicit go-ahead on real state. +- Destructive deprecation cleanup during `install --upgrade` requires explicit consent (`-y`); missing consent must surface as a reported requirement, not an assumed yes. +- Locally modified or unowned destination content is preserved and reported, never overwritten. + +## What Maintenance Never Does + +- Never invokes Homebrew, npm, or any package manager, and never claims a newer remote version exists without evidence from the owning package manager. +- Never hardcodes a Cellar, checkout, or binary path; `loaf` resolves on `PATH`. +- Never infers machine-local installed-target intent from Git-tracked config, and never writes machine-specific fields into `.agents/loaf.json`. +- Never applies a database migration automatically as a side effect of diagnosis. diff --git a/dist/cursor/skills/triage/SKILL.md b/dist/cursor/skills/triage/SKILL.md index c2d5b735d..c496fafe3 100644 --- a/dist/cursor/skills/triage/SKILL.md +++ b/dist/cursor/skills/triage/SKILL.md @@ -1,18 +1,20 @@ --- name: triage description: >- - Surfaces and processes the intake queue: unresolved sparks from the project - journal and brainstorm documents, plus raw ideas awaiting evaluation. Use when - the user asks "what sparks do I have?", "review my ideas", "triage", or - "what's in my backlog?" Produces promoted ideas, archived discards, and - resolve(spark) journal entries. Not for capturing new ideas (use idea) or - shaping (use shape). + 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, or hand to + shape. Not for reading a single known item (use loaf intent show or journal + directly), capturing new ideas (use idea), divergent inquiry (use explore), or + bounding one chosen direction (use shape). version: 2.0.0-alpha.9 --- # Triage -Review and process the intake queue — sparks and raw ideas. +Process the intake queue. Triage is the public funnel where captured material meets judgment: you present facts, the user chooses each disposition, and the CLI performs exactly what was chosen. **Input:** $ARGUMENTS @@ -23,159 +25,70 @@ Review and process the intake queue — sparks and raw ideas. - Verification - Quick Reference - Process -- Resolution Formats +- Dispositions +- Legacy Deferrals - Guardrails - Related Skills ## Critical Rules -- Present everything before acting -- user decides each disposition -- Never auto-promote or auto-discard without confirmation -- Use SQLite-aware CLI commands for lifecycle changes; do not edit idea/spark - frontmatter by hand -- Log or link resolutions through `loaf spark resolve`, `loaf spark promote`, - `loaf idea archive`, and `loaf brainstorm archive` when state is initialized -- One pass through the queue -- don't loop or re-present items +- Log invocation first: `loaf journal log "skill(triage): <trigger or scope>"` +- 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. +- One pass through the queue — don't loop or re-present items. ## Verification -- All presented sparks have a recorded disposition (promoted, discarded, or deferred) -- Promoted sparks have corresponding idea rows visible in `loaf idea list` -- Processed sparks no longer appear in default `loaf spark list` / triage output -- Archived ideas/brainstorms no longer appear in default triage lists -- Markdown source annotations, when present, are compatibility notes rather than - the authoritative state transition +- 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`). +- 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. ## Quick Reference -| Source | Unprocessed Signal | Resolution | -|--------|-------------------|------------| -| Sparks | Open spark rows from `loaf spark list` | `loaf spark promote` or `loaf spark resolve` | -| Brainstorms | Open brainstorm rows from `loaf brainstorm list` | `loaf brainstorm promote` or `loaf brainstorm archive` | -| Ideas | Open idea rows from `loaf idea list` | Shape, promote, or `loaf idea archive` | - ---- +| 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 `/shape` | +| brainstorm | archived divergent sessions | archive, explore, promote | +| intent (tracked) | `loaf intent create` | keep tracking, defer, resolve, explore, 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) | ## Process -### Step 1: Scan Sources - -Scan state-backed queues first, falling back to Markdown compatibility sources -only when SQLite state is not initialized: - -**1. Sparks** -- Run `loaf spark list` or `loaf spark list --json` -- Treat open rows as unresolved intake - -**2. Brainstorms** -- Run `loaf brainstorm list` or `loaf brainstorm list --json` -- Treat open rows as brainstorm intake - -**3. Ideas** -- Run `loaf idea list` or `loaf idea list --json` -- Treat open rows as idea intake - -### Step 2: Present the Queue - -Show a summary table: - -``` -Intake Queue: - Sparks (journal): 3 unresolved - Sparks (brainstorms): 1 unprocessed - Raw ideas: 2 awaiting evaluation - Total: 6 items -``` - -Then list each item with source, date, and description. - -### Step 3: Process Each Item - -For each item, present it and ask for disposition: - -**Sparks → one of:** -- **Promote** → `loaf spark promote <spark> --to-idea <idea>` -- **Discard** → `loaf spark resolve <spark> --reason <reason>` -- **Defer** → skip, resurface next triage - -**Raw ideas → one of:** -- **Shape** → suggest running `/shape` with this idea -- **Brainstorm** → suggest running `/brainstorm` to explore further -- **Archive** → `loaf idea archive <idea> --reason <reason>` - -### Step 4: Summarize - -After processing, show what happened: +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. -``` -Triage complete: - Promoted: 2 sparks → ideas - Discarded: 1 spark - Deferred: 1 spark - Shaped: 1 idea → /shape - Archived: 1 idea -``` +## Dispositions ---- - -## Resolution Formats - -### Sparks - -When promoting: -```bash -loaf spark promote SPARK-slug --to-idea idea-slug -``` - -When discarding: -```bash -loaf spark resolve SPARK-slug --reason "reason" -``` - -When deferring: -Do nothing; open sparks remain visible in the next triage pass. - -### Brainstorms - -When promoting: -```bash -loaf brainstorm promote brainstorm-slug --to-idea idea-slug -``` +- **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** — hand the item to `/explore`, linking it with `--from` when the Exploration is created. +- **Shape** — when a direction is ready for bounded delivery, hand it to `/shape`; triage never creates Changes, branches, or worktrees. -When archiving: -```bash -loaf brainstorm archive brainstorm-slug --reason "reason" -``` +## Legacy Deferrals -### Ideas - -When archiving: -```bash -loaf idea archive idea-slug --reason "reason" -``` - -When shaping, pass the idea to `/shape`; do not hand-edit status frontmatter to -represent lifecycle state. - ---- +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. ## Guardrails -1. **User decides every disposition** -- present, don't decide -2. **Batch presentation, individual decisions** -- show the full queue, then process one at a time -3. **Log everything** -- no silent discards or promotions -4. **Deferred items resurface** -- they'll appear again next `/triage` - ---- - -## Suggests Next - -After triage completes, suggest `/shape` for any ideas promoted to shaping. +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. ## Related Skills -- **idea** -- Capture a new idea (fast, minimal friction) -- **shape** -- Develop an idea into a SPEC -- **brainstorm** -- Deep exploration of a problem space (produces sparks) -- **housekeeping** -- Flags brainstorm drafts with unprocessed sparks before deletion -- **reflect** -- Strategic document updates (separate from triage) +- **idea** — capture a new idea (fast, minimal friction) +- **explore** — divergent inquiry with portable checkpoints +- **shape** — develop a chosen direction into a bounded Change +- **housekeeping** — flags stale artifacts; does not choose dispositions diff --git a/dist/opencode/commands/brainstorm.md b/dist/opencode/commands/brainstorm.md deleted file mode 100644 index 236119213..000000000 --- a/dist/opencode/commands/brainstorm.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -description: >- - Conducts structured brainstorming with divergent thinking and trade-off - analysis. Use when the user asks "help me think through this," "what are the - options," or is exploring tradeoffs. Produces docs with sparks. Not for quick - ideas or shaping. -subtask: false -version: 2.0.0-alpha.9 ---- - -# Brainstorm - -Generative thinking — expanding possibilities before narrowing through structured exploration. - -## Critical Rules - -**Always** -- Diverge before converging — generate options before judging -- Connect exploration to VISION.md and STRATEGY.md context -- Document discarded options — they hold valuable reasoning -- Log invocation first: `loaf journal log "skill(brainstorm): <topic>"` -- Capture sparks (speculative byproducts) with `loaf spark capture --scope <scope> --text <text>`; brainstorm summaries may render or summarize them -- Set boundaries on exploration time -- Log outcome to the project journal: `loaf journal log "decision(scope): direction chosen and rationale"` - -**Never** -- Prematurely commit to an option before full exploration -- Delete brainstorm documents — archive them for context preservation -- Process sparks during the main brainstorm — capture only, expand later -- Turn brainstorm into an interview — keep it exploratory - -## Verification - -After work completes, verify: -- Brainstorm captured in SQLite or summarized in an explicitly durable report -- Sparks captured with `loaf spark capture` and optionally summarized in `## Sparks` -- Spark lifecycle documented: unprocessed → promoted/discarded -- Brainstorm references strategic context from VISION/STRATEGY - -## Quick Reference - -### Mode Detection - -| Input Pattern | Mode | Output | -|---------------|------|--------| -| Idea file reference | Idea Processing | Deep dive on captured idea | -| Problem/question | Problem Exploration | Exploratory options | -| Empty | Open Brainstorm | "What should we think about?" | - -### Spark Format - -```markdown -## Sparks - -- **Title** -- one-line description -- **Title** -- one-line description -``` - -Sparks are: lightweight, byproducts, worth remembering. Mark as `*(promoted)*` or `*(abandoned)*` after processing. - -Brainstorm documents are archived after sparks are processed — never deleted, since the exploration context has lasting value. SQLite spark state is the lifecycle source; draft markdown is a projection or narrative summary. - -## Suggests Next - -After brainstorming, suggest `/shape` if a clear idea emerged, or `/idea` to capture sparks for later. `/idea` invoked without arguments scans brainstorm docs for unprocessed sparks, bridging the brainstorm → idea pipeline. - -## Topics - -| Topic | Reference | Use When | -|-------|-----------|----------| -| Brainstorm Template | [templates/brainstorm.md](../skills/brainstorm/templates/brainstorm.md) | Creating structured brainstorm documents | -| Strategic Context | `strategy/references/` | Grounding exploration in project direction | diff --git a/dist/opencode/commands/explore.md b/dist/opencode/commands/explore.md new file mode 100644 index 000000000..24f370ddd --- /dev/null +++ b/dist/opencode/commands/explore.md @@ -0,0 +1,101 @@ +--- +description: >- + Conducts divergent inquiry as a durable Exploration: portable checkpoints, + conversation provenance, and Intent capture that survive compaction and + harness changes. Use when the direction is genuinely undecided ("explore + this", "we don't know which approach yet") or when resuming a named + Exploration. Produces Exploration records, portable checkpoints, and tracked + or deferred Intents. Not for evidence gathering on a known question (use + research), continuing implementation or delivery work (use implement), + processing the intake queue (use triage), shaping a bounded Change (use + shape), or quick capture (use idea). +version: 2.0.0-alpha.9 +--- + +# Explore + +Divergent inquiry with durable continuity. An Exploration is a relational identity over immutable checkpoints and provenance — it has no status, no owner, and no lifecycle to maintain. Resuming means reading portable context and appending new facts, never toggling state. + +**Input:** $ARGUMENTS + +--- + +## Contents +- Critical Rules +- Verification +- Quick Reference +- Process +- Checkpoint Discipline +- Resumption +- Techniques +- Related Skills + +## Critical Rules + +- Log invocation first: `loaf journal log "skill(explore): <topic or exploration ref>"` +- 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 bounded delivery, hand it to `/shape`. +- Never store transcripts, prompts, or tool output in checkpoints or items; curate semantic context instead. + +## 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`). +- 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>]...` | +| 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>]` | +| 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>` | + +## 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. +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. +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 + +The four fields are the portable contract; each is capped at 4096 UTF-8 bytes and oversize input is rejected, never truncated: + +- **purpose** — the current framing of the inquiry, restated so a stranger understands what is being explored and why. +- **conclusions** — constraints and conclusions established so far, including rejected options and why they fell. +- **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. + +## 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. + +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. + +## Deferring + +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. + +## Techniques + +Brainstorm's full divergent stance lives inside Explore: generate options before judging, connect to VISION/STRATEGY context, document discarded options, set boundaries on exploration time. Scout, research, prototype, and spike remain subordinate techniques invoked from whatever stage needs them — none of them owns lifecycle. + +## Related Skills + +- **triage** — processes the intake queue and chooses dispositions, including "explore this" +- **shape** — narrows one direction into a bounded Change; the exit door from Explore +- **research** — evidence gathering for a known question, usable inside an Exploration +- **idea** — quick capture without inquiry diff --git a/dist/opencode/commands/idea.md b/dist/opencode/commands/idea.md index 59be9d241..d59ee9bd6 100644 --- a/dist/opencode/commands/idea.md +++ b/dist/opencode/commands/idea.md @@ -2,9 +2,10 @@ 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. For reviewing and - processing the intake queue (sparks + raw ideas), use triage instead. Not for - deep exploration (use brainstorm) or shaping (use shape). + actionable concept crystallizes during conversation. Ideas and sparks are + capture primitives routed through triage, which chooses dispositions such as + tracking an Intent. Not for divergent inquiry (use explore), processing the + intake queue (use triage), or shaping (use shape). subtask: false version: 2.0.0-alpha.9 --- @@ -39,83 +40,46 @@ Capture ideas quickly with minimal friction. ## Verification -- Idea appears in `loaf idea list` / `loaf idea show` -- Status is open/raw according to the active backend -- If promoted from a spark, `loaf spark promote` records the relationship +- The idea appears in `loaf idea list` and `loaf idea show <ref>` with status open +- If promoted from a spark, `loaf spark promote` recorded the relationship +- No shaping, status transition, or promotion happened here — dispositions belong to triage ## Quick Reference -| Status | Meaning | -|--------|---------| -| `raw` | Just captured, unprocessed | -| `shaping` | Being developed via /shape or /brainstorm | -| `shaped` | Converted to SPEC, idea file archived | -| `archived` | Decided not to pursue, kept for reference | +| Operation | Command | +|-----------|---------| +| Capture | `loaf idea capture --title "<title>"` | +| Read back | `loaf idea show <ref>` | +| List open | `loaf idea list` | --- ## Purpose -Ideas are raw nuggets -- unprocessed, unshaped, but worth remembering. The goal is **speed of capture**, not thoroughness. Shape later via `/shape`. +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, exploring it, shaping it, or archiving it are triage dispositions chosen later by the user. --- ## Process -### Step 1: Parse Input - -If `$ARGUMENTS` contains the idea, capture directly. - -If `$ARGUMENTS` is empty, ask **at most 2-3 questions**: core idea, problem/opportunity, immediate constraints. - -### Step 2: Capture Idea - -Use the CLI capture path: - -```bash -loaf idea capture --title "..." -``` - -In SQLite-backed mode, the row is stored in SQLite and no `.agents/ideas/` -markdown file is created. The [idea template](../skills/idea/templates/idea.md) is retained -only for markdown-only compatibility and historical restore review. - -### Step 3: Create and Announce - -1. Generate timestamp: `date -u +"%Y-%m-%dT%H:%M:%SZ"` -2. Run `loaf idea capture --title "..."` with the inferred title -3. Announce the captured idea alias with next steps - ---- - -## Idea Lifecycle - -``` -raw -> shaping -> shaped (becomes SPEC) -> archived -``` - -| Status | Meaning | -|--------|---------| -| `raw` | Just captured, unprocessed | -| `shaping` | Being developed via /shape or /brainstorm | -| `shaped` | Converted to SPEC, idea file archived | -| `archived` | Decided not to pursue, kept for reference | +1. **Parse input.** If `$ARGUMENTS` contains the idea, capture directly. If empty, ask at most 2-3 questions: core idea, problem/opportunity, immediate constraints. +2. **Capture.** Run `loaf idea capture --title "..."` with the inferred title; log notable context with `loaf journal log`. +3. **Announce.** Report the captured alias and point at `/triage` for disposition. --- ## Guardrails -1. **Speed over completeness** -- capture quickly, shape later -2. **2-3 questions max** -- don't turn this into an interview -3. **Infer, don't ask** -- metadata should be automatic -4. **One idea per captured row/artifact** -- keep them atomic -5. **No shaping here** -- that's what `/shape` is for +1. **Speed over completeness** — capture quickly, disposition later +2. **2-3 questions max** — don't turn this into an interview +3. **Infer, don't ask** — metadata should be automatic +4. **One idea per captured row** — keep them atomic +5. **No lifecycle here** — no status transitions, promotion, or shaping; triage owns dispositions and the CLI performs them --- ## Related Skills -- **triage** -- Review and process the intake queue (sparks + raw ideas) -- **shape** -- Develop an idea into a SPEC -- **brainstorm** -- Deep thinking on an idea or problem space -- **research** -- Investigate before capturing +- **triage** — process the intake queue and choose dispositions +- **explore** — divergent inquiry when an idea needs development before commitment +- **shape** — develop a chosen direction into a bounded Change diff --git a/dist/opencode/commands/triage.md b/dist/opencode/commands/triage.md index 6cc4fb0a9..85b0874f3 100644 --- a/dist/opencode/commands/triage.md +++ b/dist/opencode/commands/triage.md @@ -1,18 +1,20 @@ --- description: >- - Surfaces and processes the intake queue: unresolved sparks from the project - journal and brainstorm documents, plus raw ideas awaiting evaluation. Use when - the user asks "what sparks do I have?", "review my ideas", "triage", or - "what's in my backlog?" Produces promoted ideas, archived discards, and - resolve(spark) journal entries. Not for capturing new ideas (use idea) or - shaping (use shape). + 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, or hand to + shape. Not for reading a single known item (use loaf intent show or journal + directly), capturing new ideas (use idea), divergent inquiry (use explore), or + bounding one chosen direction (use shape). user-invocable: true version: 2.0.0-alpha.9 --- # Triage -Review and process the intake queue — sparks and raw ideas. +Process the intake queue. Triage is the public funnel where captured material meets judgment: you present facts, the user chooses each disposition, and the CLI performs exactly what was chosen. **Input:** $ARGUMENTS @@ -23,159 +25,70 @@ Review and process the intake queue — sparks and raw ideas. - Verification - Quick Reference - Process -- Resolution Formats +- Dispositions +- Legacy Deferrals - Guardrails - Related Skills ## Critical Rules -- Present everything before acting -- user decides each disposition -- Never auto-promote or auto-discard without confirmation -- Use SQLite-aware CLI commands for lifecycle changes; do not edit idea/spark - frontmatter by hand -- Log or link resolutions through `loaf spark resolve`, `loaf spark promote`, - `loaf idea archive`, and `loaf brainstorm archive` when state is initialized -- One pass through the queue -- don't loop or re-present items +- Log invocation first: `loaf journal log "skill(triage): <trigger or scope>"` +- 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. +- One pass through the queue — don't loop or re-present items. ## Verification -- All presented sparks have a recorded disposition (promoted, discarded, or deferred) -- Promoted sparks have corresponding idea rows visible in `loaf idea list` -- Processed sparks no longer appear in default `loaf spark list` / triage output -- Archived ideas/brainstorms no longer appear in default triage lists -- Markdown source annotations, when present, are compatibility notes rather than - the authoritative state transition +- 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`). +- 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. ## Quick Reference -| Source | Unprocessed Signal | Resolution | -|--------|-------------------|------------| -| Sparks | Open spark rows from `loaf spark list` | `loaf spark promote` or `loaf spark resolve` | -| Brainstorms | Open brainstorm rows from `loaf brainstorm list` | `loaf brainstorm promote` or `loaf brainstorm archive` | -| Ideas | Open idea rows from `loaf idea list` | Shape, promote, or `loaf idea archive` | - ---- +| 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 `/shape` | +| brainstorm | archived divergent sessions | archive, explore, promote | +| intent (tracked) | `loaf intent create` | keep tracking, defer, resolve, explore, 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) | ## Process -### Step 1: Scan Sources - -Scan state-backed queues first, falling back to Markdown compatibility sources -only when SQLite state is not initialized: - -**1. Sparks** -- Run `loaf spark list` or `loaf spark list --json` -- Treat open rows as unresolved intake - -**2. Brainstorms** -- Run `loaf brainstorm list` or `loaf brainstorm list --json` -- Treat open rows as brainstorm intake - -**3. Ideas** -- Run `loaf idea list` or `loaf idea list --json` -- Treat open rows as idea intake - -### Step 2: Present the Queue - -Show a summary table: - -``` -Intake Queue: - Sparks (journal): 3 unresolved - Sparks (brainstorms): 1 unprocessed - Raw ideas: 2 awaiting evaluation - Total: 6 items -``` - -Then list each item with source, date, and description. - -### Step 3: Process Each Item - -For each item, present it and ask for disposition: - -**Sparks → one of:** -- **Promote** → `loaf spark promote <spark> --to-idea <idea>` -- **Discard** → `loaf spark resolve <spark> --reason <reason>` -- **Defer** → skip, resurface next triage - -**Raw ideas → one of:** -- **Shape** → suggest running `/shape` with this idea -- **Brainstorm** → suggest running `/brainstorm` to explore further -- **Archive** → `loaf idea archive <idea> --reason <reason>` - -### Step 4: Summarize - -After processing, show what happened: +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. -``` -Triage complete: - Promoted: 2 sparks → ideas - Discarded: 1 spark - Deferred: 1 spark - Shaped: 1 idea → /shape - Archived: 1 idea -``` +## Dispositions ---- - -## Resolution Formats - -### Sparks - -When promoting: -```bash -loaf spark promote SPARK-slug --to-idea idea-slug -``` - -When discarding: -```bash -loaf spark resolve SPARK-slug --reason "reason" -``` - -When deferring: -Do nothing; open sparks remain visible in the next triage pass. - -### Brainstorms - -When promoting: -```bash -loaf brainstorm promote brainstorm-slug --to-idea idea-slug -``` +- **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** — hand the item to `/explore`, linking it with `--from` when the Exploration is created. +- **Shape** — when a direction is ready for bounded delivery, hand it to `/shape`; triage never creates Changes, branches, or worktrees. -When archiving: -```bash -loaf brainstorm archive brainstorm-slug --reason "reason" -``` +## Legacy Deferrals -### Ideas - -When archiving: -```bash -loaf idea archive idea-slug --reason "reason" -``` - -When shaping, pass the idea to `/shape`; do not hand-edit status frontmatter to -represent lifecycle state. - ---- +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. ## Guardrails -1. **User decides every disposition** -- present, don't decide -2. **Batch presentation, individual decisions** -- show the full queue, then process one at a time -3. **Log everything** -- no silent discards or promotions -4. **Deferred items resurface** -- they'll appear again next `/triage` - ---- - -## Suggests Next - -After triage completes, suggest `/shape` for any ideas promoted to shaping. +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. ## Related Skills -- **idea** -- Capture a new idea (fast, minimal friction) -- **shape** -- Develop an idea into a SPEC -- **brainstorm** -- Deep exploration of a problem space (produces sparks) -- **housekeeping** -- Flags brainstorm drafts with unprocessed sparks before deletion -- **reflect** -- Strategic document updates (separate from triage) +- **idea** — capture a new idea (fast, minimal friction) +- **explore** — divergent inquiry with portable checkpoints +- **shape** — develop a chosen direction into a bounded Change +- **housekeeping** — flags stale artifacts; does not choose dispositions diff --git a/dist/opencode/skills/brainstorm/SKILL.md b/dist/opencode/skills/brainstorm/SKILL.md index 122fa0b88..60e60ff21 100644 --- a/dist/opencode/skills/brainstorm/SKILL.md +++ b/dist/opencode/skills/brainstorm/SKILL.md @@ -1,17 +1,18 @@ --- name: brainstorm description: >- - Conducts structured brainstorming with divergent thinking and trade-off - analysis. Use when the user asks "help me think through this," "what are the - options," or is exploring tradeoffs. Produces docs with sparks. Not for quick - ideas or shaping. + Preserves the structured divergent-thinking stance consumed by the explore + workflow: option generation before judgment, trade-off analysis, and spark + capture. Explore owns the user-facing inquiry lifecycle; reference this + technique from it. Not a primary entry point — route "explore this" or "help + me think through options" to explore. subtask: false version: 2.0.0-alpha.9 --- # Brainstorm -Generative thinking — expanding possibilities before narrowing through structured exploration. +Generative thinking — expanding possibilities before narrowing. This stance is an internal technique consumed by `/explore`, which owns inquiry continuity through Explorations and portable checkpoints; invoke the technique from there rather than as a standalone workflow. ## Critical Rules @@ -26,17 +27,16 @@ Generative thinking — expanding possibilities before narrowing through structu **Never** - Prematurely commit to an option before full exploration -- Delete brainstorm documents — archive them for context preservation -- Process sparks during the main brainstorm — capture only, expand later -- Turn brainstorm into an interview — keep it exploratory +- Create documents, reports, or any Git artifact from this technique — the surrounding Explore workflow owns checkpoints and any durable writes +- Process sparks during the divergent pass — capture only, expand later +- Turn the divergence into an interview — keep it exploratory ## Verification -After work completes, verify: -- Brainstorm captured in SQLite or summarized in an explicitly durable report -- Sparks captured with `loaf spark capture` and optionally summarized in `## Sparks` -- Spark lifecycle documented: unprocessed → promoted/discarded -- Brainstorm references strategic context from VISION/STRATEGY +After a divergent pass, verify: +- Sparks captured with `loaf spark capture` as they arose +- The surrounding Exploration checkpointed the conclusions, discarded options, and open question (`loaf exploration checkpoint`) +- The divergence referenced strategic context from VISION/STRATEGY ## Quick Reference @@ -57,17 +57,14 @@ After work completes, verify: - **Title** -- one-line description ``` -Sparks are: lightweight, byproducts, worth remembering. Mark as `*(promoted)*` or `*(abandoned)*` after processing. - -Brainstorm documents are archived after sparks are processed — never deleted, since the exploration context has lasting value. SQLite spark state is the lifecycle source; draft markdown is a projection or narrative summary. +Sparks are lightweight byproducts worth remembering; their dispositions belong to triage. SQLite spark state is the source; any summary inside a checkpoint item is narrative, not lifecycle. ## Suggests Next -After brainstorming, suggest `/shape` if a clear idea emerged, or `/idea` to capture sparks for later. `/idea` invoked without arguments scans brainstorm docs for unprocessed sparks, bridging the brainstorm → idea pipeline. +After a divergent pass, checkpoint the surrounding Exploration (`loaf exploration checkpoint`), then suggest `/shape` if a clear direction emerged or `/triage` to disposition captured sparks and ideas. ## Topics | Topic | Reference | Use When | |-------|-----------|----------| -| Brainstorm Template | [templates/brainstorm.md](templates/brainstorm.md) | Creating structured brainstorm documents | | Strategic Context | `strategy/references/` | Grounding exploration in project direction | diff --git a/dist/opencode/skills/explore/SKILL.md b/dist/opencode/skills/explore/SKILL.md new file mode 100644 index 000000000..3b34a682d --- /dev/null +++ b/dist/opencode/skills/explore/SKILL.md @@ -0,0 +1,102 @@ +--- +name: explore +description: >- + Conducts divergent inquiry as a durable Exploration: portable checkpoints, + conversation provenance, and Intent capture that survive compaction and + harness changes. Use when the direction is genuinely undecided ("explore + this", "we don't know which approach yet") or when resuming a named + Exploration. Produces Exploration records, portable checkpoints, and tracked + or deferred Intents. Not for evidence gathering on a known question (use + research), continuing implementation or delivery work (use implement), + processing the intake queue (use triage), shaping a bounded Change (use + shape), or quick capture (use idea). +version: 2.0.0-alpha.9 +--- + +# Explore + +Divergent inquiry with durable continuity. An Exploration is a relational identity over immutable checkpoints and provenance — it has no status, no owner, and no lifecycle to maintain. Resuming means reading portable context and appending new facts, never toggling state. + +**Input:** $ARGUMENTS + +--- + +## Contents +- Critical Rules +- Verification +- Quick Reference +- Process +- Checkpoint Discipline +- Resumption +- Techniques +- Related Skills + +## Critical Rules + +- Log invocation first: `loaf journal log "skill(explore): <topic or exploration ref>"` +- 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 bounded delivery, hand it to `/shape`. +- Never store transcripts, prompts, or tool output in checkpoints or items; curate semantic context instead. + +## 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`). +- 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>]...` | +| 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>]` | +| 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>` | + +## 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. +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. +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 + +The four fields are the portable contract; each is capped at 4096 UTF-8 bytes and oversize input is rejected, never truncated: + +- **purpose** — the current framing of the inquiry, restated so a stranger understands what is being explored and why. +- **conclusions** — constraints and conclusions established so far, including rejected options and why they fell. +- **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. + +## 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. + +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. + +## Deferring + +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. + +## Techniques + +Brainstorm's full divergent stance lives inside Explore: generate options before judging, connect to VISION/STRATEGY context, document discarded options, set boundaries on exploration time. Scout, research, prototype, and spike remain subordinate techniques invoked from whatever stage needs them — none of them owns lifecycle. + +## Related Skills + +- **triage** — processes the intake queue and chooses dispositions, including "explore this" +- **shape** — narrows one direction into a bounded Change; the exit door from Explore +- **research** — evidence gathering for a known question, usable inside an Exploration +- **idea** — quick capture without inquiry diff --git a/dist/opencode/skills/idea/SKILL.md b/dist/opencode/skills/idea/SKILL.md index f84db0be5..873364b88 100644 --- a/dist/opencode/skills/idea/SKILL.md +++ b/dist/opencode/skills/idea/SKILL.md @@ -3,9 +3,10 @@ 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. For reviewing and - processing the intake queue (sparks + raw ideas), use triage instead. Not for - deep exploration (use brainstorm) or shaping (use shape). + actionable concept crystallizes during conversation. Ideas and sparks are + capture primitives routed through triage, which chooses dispositions such as + tracking an Intent. Not for divergent inquiry (use explore), processing the + intake queue (use triage), or shaping (use shape). subtask: false version: 2.0.0-alpha.9 --- @@ -40,83 +41,46 @@ Capture ideas quickly with minimal friction. ## Verification -- Idea appears in `loaf idea list` / `loaf idea show` -- Status is open/raw according to the active backend -- If promoted from a spark, `loaf spark promote` records the relationship +- The idea appears in `loaf idea list` and `loaf idea show <ref>` with status open +- If promoted from a spark, `loaf spark promote` recorded the relationship +- No shaping, status transition, or promotion happened here — dispositions belong to triage ## Quick Reference -| Status | Meaning | -|--------|---------| -| `raw` | Just captured, unprocessed | -| `shaping` | Being developed via /shape or /brainstorm | -| `shaped` | Converted to SPEC, idea file archived | -| `archived` | Decided not to pursue, kept for reference | +| Operation | Command | +|-----------|---------| +| Capture | `loaf idea capture --title "<title>"` | +| Read back | `loaf idea show <ref>` | +| List open | `loaf idea list` | --- ## Purpose -Ideas are raw nuggets -- unprocessed, unshaped, but worth remembering. The goal is **speed of capture**, not thoroughness. Shape later via `/shape`. +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, exploring it, shaping it, or archiving it are triage dispositions chosen later by the user. --- ## Process -### Step 1: Parse Input - -If `$ARGUMENTS` contains the idea, capture directly. - -If `$ARGUMENTS` is empty, ask **at most 2-3 questions**: core idea, problem/opportunity, immediate constraints. - -### Step 2: Capture Idea - -Use the CLI capture path: - -```bash -loaf idea capture --title "..." -``` - -In SQLite-backed mode, the row is stored in SQLite and no `.agents/ideas/` -markdown file is created. The [idea template](templates/idea.md) is retained -only for markdown-only compatibility and historical restore review. - -### Step 3: Create and Announce - -1. Generate timestamp: `date -u +"%Y-%m-%dT%H:%M:%SZ"` -2. Run `loaf idea capture --title "..."` with the inferred title -3. Announce the captured idea alias with next steps - ---- - -## Idea Lifecycle - -``` -raw -> shaping -> shaped (becomes SPEC) -> archived -``` - -| Status | Meaning | -|--------|---------| -| `raw` | Just captured, unprocessed | -| `shaping` | Being developed via /shape or /brainstorm | -| `shaped` | Converted to SPEC, idea file archived | -| `archived` | Decided not to pursue, kept for reference | +1. **Parse input.** If `$ARGUMENTS` contains the idea, capture directly. If empty, ask at most 2-3 questions: core idea, problem/opportunity, immediate constraints. +2. **Capture.** Run `loaf idea capture --title "..."` with the inferred title; log notable context with `loaf journal log`. +3. **Announce.** Report the captured alias and point at `/triage` for disposition. --- ## Guardrails -1. **Speed over completeness** -- capture quickly, shape later -2. **2-3 questions max** -- don't turn this into an interview -3. **Infer, don't ask** -- metadata should be automatic -4. **One idea per captured row/artifact** -- keep them atomic -5. **No shaping here** -- that's what `/shape` is for +1. **Speed over completeness** — capture quickly, disposition later +2. **2-3 questions max** — don't turn this into an interview +3. **Infer, don't ask** — metadata should be automatic +4. **One idea per captured row** — keep them atomic +5. **No lifecycle here** — no status transitions, promotion, or shaping; triage owns dispositions and the CLI performs them --- ## Related Skills -- **triage** -- Review and process the intake queue (sparks + raw ideas) -- **shape** -- Develop an idea into a SPEC -- **brainstorm** -- Deep thinking on an idea or problem space -- **research** -- Investigate before capturing +- **triage** — process the intake queue and choose dispositions +- **explore** — divergent inquiry when an idea needs development before commitment +- **shape** — develop a chosen direction into a bounded Change diff --git a/dist/opencode/skills/loaf-reference/SKILL.md b/dist/opencode/skills/loaf-reference/SKILL.md index fa6f7c4ec..97a612be1 100644 --- a/dist/opencode/skills/loaf-reference/SKILL.md +++ b/dist/opencode/skills/loaf-reference/SKILL.md @@ -2,10 +2,11 @@ name: loaf-reference description: >- Documents how agents operate the Loaf CLI: command discovery via loaf --help, - JSON diagnosis surfaces, guided config maintenance, and troubleshooting. Use - when unsure which loaf command to invoke or how to validate project state. Not - for workflow guidance (workflow skills own their CLI contracts) or build - internals. + JSON diagnosis surfaces, config-aware maintenance, and troubleshooting. Use + when unsure which loaf command to invoke, how to validate project state, or + when asked to upgrade, diagnose, repair, configure, or bring a Loaf project + current. Not for workflow guidance (workflow skills own their CLI contracts) + or build internals. version: 2.0.0-alpha.9 --- @@ -66,7 +67,7 @@ Names and one-line purposes only. Run `loaf <command> --help` for options, argum | `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 lifecycle-statuses, backup, backup verify, backup restore, restore-ephemerals, verify-ephemerals, export, export all, export triage, export spec, export release-readiness | +| `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, 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, worktree-storage | @@ -85,6 +86,10 @@ 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 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 | | `loaf spark` | Manage sparks in native SQLite state | list, show, capture, resolve, promote | | `loaf tag` | Manage tags in native SQLite state | list, show, add, remove | | `loaf bundle` | Manage bundles in native SQLite state | list, create, update, show, add, remove | @@ -97,5 +102,6 @@ Names and one-line purposes only. Run `loaf <command> --help` for options, argum | Topic | Reference | Use When | |-------|-----------|----------| | Configuration maintenance | [references/configuration.md](references/configuration.md) | Checking whether a project's Loaf config is current and repairing it; wiring project-owned choices | +| Config-aware maintenance protocol | [references/maintenance.md](references/maintenance.md) | Upgrading, diagnosing, repairing, or bringing a project current: diagnose, plan, ask, apply, verify | | Command routing | [references/command-routing.md](references/command-routing.md) | Deciding which command a task needs; locating the JSON diagnosis surfaces | | Troubleshooting | [references/troubleshooting.md](references/troubleshooting.md) | Diagnosing state, config, or alignment failures; isolating a throwaway database | diff --git a/dist/opencode/skills/loaf-reference/references/maintenance.md b/dist/opencode/skills/loaf-reference/references/maintenance.md new file mode 100644 index 000000000..db5feed6e --- /dev/null +++ b/dist/opencode/skills/loaf-reference/references/maintenance.md @@ -0,0 +1,48 @@ +# Config-Aware Loaf Maintenance + +## Contents +- Protocol +- Fact Sources +- Planning Surfaces +- Consent Boundaries +- What Maintenance Never Does + +This protocol serves natural-language requests to upgrade, diagnose, repair, configure, or bring a Loaf project current. It is a hidden operator layer: interpret facts, ask only for missing project-owned choices, sequence approved deterministic operations, and verify convergence. Discover exact current syntax from `loaf <command> --help` instead of memorizing flags. + +## Protocol + +0. **Classify the request.** A diagnose-only request stops after step 1 with facts; a plan request stops after step 2 with the mutation ledger; only an explicit repair, upgrade, or bring-current request proceeds to apply, and then only for the mutation classes the user's request actually named. Never let the protocol's shape carry a diagnosis into a mutation. +1. **Diagnose.** Start with `loaf config check --json` for project intent and installed hook health. Add `loaf version` (running executable), `loaf state status --json` and `loaf state doctor --json` (SQLite readiness, schema version, repair plan), and `loaf doctor --json` (project alignment: symlinks, stale files, fenced-version drift). All four are read-only. +2. **Plan.** For installed-target convergence, use `loaf install --upgrade --dry-run --json`: it reports intended creates, updates, retirements, preserved conflicts, deprecation actions, project-file effects, and whether explicit consent is required, without writing anything. +3. **Ask.** Only for project-owned choices the facts cannot answer (for example integration election in `.agents/loaf.json`, or consent to destructive deprecation cleanup). Machine-observed facts are never questions. Present one complete mutation ledger — every intended operation with its consent requirement — and obtain approval for the ledger as a whole before applying any part of it. +4. **Apply.** Use the existing explicit operations the ledger named: `loaf config check --fix`, `loaf install --upgrade` (with `-y` only after consent), `loaf doctor --fix --force` (the `--force` form is required for non-interactive execution; plain `--fix` prompts and silently skips repairs without a TTY — if a repair genuinely needs interactive judgment, stop and report that it requires an operator), `loaf state migrate schema --apply`, `loaf state migrate deferrals --apply`. A project-owned election is recorded by editing `.agents/loaf.json` in the checkout — a durable, reviewable change — and validating with `loaf config check --json`. Never invent a bypass. +5. **Verify.** Rerun the diagnosis surfaces and confirm they converge; report any check that still fails rather than declaring success, and never loop back into apply without a changed ledger. + +## Fact Sources + +| Fact | Source | Authority | +|------|--------|-----------| +| Team-owned project intent (integrations, knowledge dirs) | `.agents/loaf.json` via `loaf config check --json` | Shared config; never records machine-local install state | +| Running executable version and provenance | `loaf version` | Observed local fact; the package manager owns acquisition | +| SQLite readiness, schema version, repair plan | `loaf state status --json`, `loaf state doctor --json` | Behind-schema state returns the exact backup-first `loaf state migrate schema --apply` action | +| Project alignment (symlinks, stale files, version fences) | `loaf doctor --json` | Read-only; repairs go through the explicit `--fix` contract | +| Installed target ownership and drift | `loaf install --upgrade --dry-run --json` | Content-addressed ownership manifests decide owned versus foreign | + +## Planning Surfaces + +- `loaf doctor --json` never prompts and never repairs; it carries the identical check identities and outcomes as the human output plus repair IDs for planning. +- `loaf install --upgrade --dry-run --json` is deterministic and byte-for-byte non-mutating; applying the reported plan through the existing commands must produce the predicted effects, after which diagnosis reports convergence. +- `loaf state migrate deferrals --dry-run --json` previews the legacy-deferral conversion manifest; apply is backup-first, preserves every legacy row, and is rerunnable. + +## Consent Boundaries + +- Database migrations (`state migrate schema --apply`, `state migrate deferrals --apply`) are backup-first and require the operator's explicit go-ahead on real state. +- Destructive deprecation cleanup during `install --upgrade` requires explicit consent (`-y`); missing consent must surface as a reported requirement, not an assumed yes. +- Locally modified or unowned destination content is preserved and reported, never overwritten. + +## What Maintenance Never Does + +- Never invokes Homebrew, npm, or any package manager, and never claims a newer remote version exists without evidence from the owning package manager. +- Never hardcodes a Cellar, checkout, or binary path; `loaf` resolves on `PATH`. +- Never infers machine-local installed-target intent from Git-tracked config, and never writes machine-specific fields into `.agents/loaf.json`. +- Never applies a database migration automatically as a side effect of diagnosis. diff --git a/dist/opencode/skills/triage/SKILL.md b/dist/opencode/skills/triage/SKILL.md index 3cb533893..375b19123 100644 --- a/dist/opencode/skills/triage/SKILL.md +++ b/dist/opencode/skills/triage/SKILL.md @@ -1,19 +1,21 @@ --- name: triage description: >- - Surfaces and processes the intake queue: unresolved sparks from the project - journal and brainstorm documents, plus raw ideas awaiting evaluation. Use when - the user asks "what sparks do I have?", "review my ideas", "triage", or - "what's in my backlog?" Produces promoted ideas, archived discards, and - resolve(spark) journal entries. Not for capturing new ideas (use idea) or - shaping (use shape). + 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, or hand to + shape. Not for reading a single known item (use loaf intent show or journal + directly), capturing new ideas (use idea), divergent inquiry (use explore), or + bounding one chosen direction (use shape). user-invocable: true version: 2.0.0-alpha.9 --- # Triage -Review and process the intake queue — sparks and raw ideas. +Process the intake queue. Triage is the public funnel where captured material meets judgment: you present facts, the user chooses each disposition, and the CLI performs exactly what was chosen. **Input:** $ARGUMENTS @@ -24,159 +26,70 @@ Review and process the intake queue — sparks and raw ideas. - Verification - Quick Reference - Process -- Resolution Formats +- Dispositions +- Legacy Deferrals - Guardrails - Related Skills ## Critical Rules -- Present everything before acting -- user decides each disposition -- Never auto-promote or auto-discard without confirmation -- Use SQLite-aware CLI commands for lifecycle changes; do not edit idea/spark - frontmatter by hand -- Log or link resolutions through `loaf spark resolve`, `loaf spark promote`, - `loaf idea archive`, and `loaf brainstorm archive` when state is initialized -- One pass through the queue -- don't loop or re-present items +- Log invocation first: `loaf journal log "skill(triage): <trigger or scope>"` +- 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. +- One pass through the queue — don't loop or re-present items. ## Verification -- All presented sparks have a recorded disposition (promoted, discarded, or deferred) -- Promoted sparks have corresponding idea rows visible in `loaf idea list` -- Processed sparks no longer appear in default `loaf spark list` / triage output -- Archived ideas/brainstorms no longer appear in default triage lists -- Markdown source annotations, when present, are compatibility notes rather than - the authoritative state transition +- 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`). +- 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. ## Quick Reference -| Source | Unprocessed Signal | Resolution | -|--------|-------------------|------------| -| Sparks | Open spark rows from `loaf spark list` | `loaf spark promote` or `loaf spark resolve` | -| Brainstorms | Open brainstorm rows from `loaf brainstorm list` | `loaf brainstorm promote` or `loaf brainstorm archive` | -| Ideas | Open idea rows from `loaf idea list` | Shape, promote, or `loaf idea archive` | - ---- +| 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 `/shape` | +| brainstorm | archived divergent sessions | archive, explore, promote | +| intent (tracked) | `loaf intent create` | keep tracking, defer, resolve, explore, 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) | ## Process -### Step 1: Scan Sources - -Scan state-backed queues first, falling back to Markdown compatibility sources -only when SQLite state is not initialized: - -**1. Sparks** -- Run `loaf spark list` or `loaf spark list --json` -- Treat open rows as unresolved intake - -**2. Brainstorms** -- Run `loaf brainstorm list` or `loaf brainstorm list --json` -- Treat open rows as brainstorm intake - -**3. Ideas** -- Run `loaf idea list` or `loaf idea list --json` -- Treat open rows as idea intake - -### Step 2: Present the Queue - -Show a summary table: - -``` -Intake Queue: - Sparks (journal): 3 unresolved - Sparks (brainstorms): 1 unprocessed - Raw ideas: 2 awaiting evaluation - Total: 6 items -``` - -Then list each item with source, date, and description. - -### Step 3: Process Each Item - -For each item, present it and ask for disposition: - -**Sparks → one of:** -- **Promote** → `loaf spark promote <spark> --to-idea <idea>` -- **Discard** → `loaf spark resolve <spark> --reason <reason>` -- **Defer** → skip, resurface next triage - -**Raw ideas → one of:** -- **Shape** → suggest running `/shape` with this idea -- **Brainstorm** → suggest running `/brainstorm` to explore further -- **Archive** → `loaf idea archive <idea> --reason <reason>` - -### Step 4: Summarize - -After processing, show what happened: +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. -``` -Triage complete: - Promoted: 2 sparks → ideas - Discarded: 1 spark - Deferred: 1 spark - Shaped: 1 idea → /shape - Archived: 1 idea -``` +## Dispositions ---- - -## Resolution Formats - -### Sparks - -When promoting: -```bash -loaf spark promote SPARK-slug --to-idea idea-slug -``` - -When discarding: -```bash -loaf spark resolve SPARK-slug --reason "reason" -``` - -When deferring: -Do nothing; open sparks remain visible in the next triage pass. - -### Brainstorms - -When promoting: -```bash -loaf brainstorm promote brainstorm-slug --to-idea idea-slug -``` +- **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** — hand the item to `/explore`, linking it with `--from` when the Exploration is created. +- **Shape** — when a direction is ready for bounded delivery, hand it to `/shape`; triage never creates Changes, branches, or worktrees. -When archiving: -```bash -loaf brainstorm archive brainstorm-slug --reason "reason" -``` +## Legacy Deferrals -### Ideas - -When archiving: -```bash -loaf idea archive idea-slug --reason "reason" -``` - -When shaping, pass the idea to `/shape`; do not hand-edit status frontmatter to -represent lifecycle state. - ---- +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. ## Guardrails -1. **User decides every disposition** -- present, don't decide -2. **Batch presentation, individual decisions** -- show the full queue, then process one at a time -3. **Log everything** -- no silent discards or promotions -4. **Deferred items resurface** -- they'll appear again next `/triage` - ---- - -## Suggests Next - -After triage completes, suggest `/shape` for any ideas promoted to shaping. +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. ## Related Skills -- **idea** -- Capture a new idea (fast, minimal friction) -- **shape** -- Develop an idea into a SPEC -- **brainstorm** -- Deep exploration of a problem space (produces sparks) -- **housekeeping** -- Flags brainstorm drafts with unprocessed sparks before deletion -- **reflect** -- Strategic document updates (separate from triage) +- **idea** — capture a new idea (fast, minimal friction) +- **explore** — divergent inquiry with portable checkpoints +- **shape** — develop a chosen direction into a bounded Change +- **housekeeping** — flags stale artifacts; does not choose dispositions diff --git a/dist/skills/brainstorm/SKILL.md b/dist/skills/brainstorm/SKILL.md index 3bb3b8a50..b740370d1 100644 --- a/dist/skills/brainstorm/SKILL.md +++ b/dist/skills/brainstorm/SKILL.md @@ -1,15 +1,16 @@ --- name: brainstorm description: >- - Conducts structured brainstorming with divergent thinking and trade-off - analysis. Use when the user asks "help me think through this," "what are the - options," or is exploring tradeoffs. Produces docs with sparks. Not for quick - ideas or shaping. + Preserves the structured divergent-thinking stance consumed by the explore + workflow: option generation before judgment, trade-off analysis, and spark + capture. Explore owns the user-facing inquiry lifecycle; reference this + technique from it. Not a primary entry point — route "explore this" or "help + me think through options" to explore. --- # Brainstorm -Generative thinking — expanding possibilities before narrowing through structured exploration. +Generative thinking — expanding possibilities before narrowing. This stance is an internal technique consumed by `/explore`, which owns inquiry continuity through Explorations and portable checkpoints; invoke the technique from there rather than as a standalone workflow. ## Critical Rules @@ -24,17 +25,16 @@ Generative thinking — expanding possibilities before narrowing through structu **Never** - Prematurely commit to an option before full exploration -- Delete brainstorm documents — archive them for context preservation -- Process sparks during the main brainstorm — capture only, expand later -- Turn brainstorm into an interview — keep it exploratory +- Create documents, reports, or any Git artifact from this technique — the surrounding Explore workflow owns checkpoints and any durable writes +- Process sparks during the divergent pass — capture only, expand later +- Turn the divergence into an interview — keep it exploratory ## Verification -After work completes, verify: -- Brainstorm captured in SQLite or summarized in an explicitly durable report -- Sparks captured with `loaf spark capture` and optionally summarized in `## Sparks` -- Spark lifecycle documented: unprocessed → promoted/discarded -- Brainstorm references strategic context from VISION/STRATEGY +After a divergent pass, verify: +- Sparks captured with `loaf spark capture` as they arose +- The surrounding Exploration checkpointed the conclusions, discarded options, and open question (`loaf exploration checkpoint`) +- The divergence referenced strategic context from VISION/STRATEGY ## Quick Reference @@ -55,17 +55,14 @@ After work completes, verify: - **Title** -- one-line description ``` -Sparks are: lightweight, byproducts, worth remembering. Mark as `*(promoted)*` or `*(abandoned)*` after processing. - -Brainstorm documents are archived after sparks are processed — never deleted, since the exploration context has lasting value. SQLite spark state is the lifecycle source; draft markdown is a projection or narrative summary. +Sparks are lightweight byproducts worth remembering; their dispositions belong to triage. SQLite spark state is the source; any summary inside a checkpoint item is narrative, not lifecycle. ## Suggests Next -After brainstorming, suggest `/shape` if a clear idea emerged, or `/idea` to capture sparks for later. `/idea` invoked without arguments scans brainstorm docs for unprocessed sparks, bridging the brainstorm → idea pipeline. +After a divergent pass, checkpoint the surrounding Exploration (`loaf exploration checkpoint`), then suggest `/shape` if a clear direction emerged or `/triage` to disposition captured sparks and ideas. ## Topics | Topic | Reference | Use When | |-------|-----------|----------| -| Brainstorm Template | [templates/brainstorm.md](templates/brainstorm.md) | Creating structured brainstorm documents | | Strategic Context | `strategy/references/` | Grounding exploration in project direction | diff --git a/dist/skills/explore/SKILL.md b/dist/skills/explore/SKILL.md new file mode 100644 index 000000000..8fe7ca9d6 --- /dev/null +++ b/dist/skills/explore/SKILL.md @@ -0,0 +1,101 @@ +--- +name: explore +description: >- + Conducts divergent inquiry as a durable Exploration: portable checkpoints, + conversation provenance, and Intent capture that survive compaction and + harness changes. Use when the direction is genuinely undecided ("explore + this", "we don't know which approach yet") or when resuming a named + Exploration. Produces Exploration records, portable checkpoints, and tracked + or deferred Intents. Not for evidence gathering on a known question (use + research), continuing implementation or delivery work (use implement), + processing the intake queue (use triage), shaping a bounded Change (use + shape), or quick capture (use idea). +--- + +# Explore + +Divergent inquiry with durable continuity. An Exploration is a relational identity over immutable checkpoints and provenance — it has no status, no owner, and no lifecycle to maintain. Resuming means reading portable context and appending new facts, never toggling state. + +**Input:** $ARGUMENTS + +--- + +## Contents +- Critical Rules +- Verification +- Quick Reference +- Process +- Checkpoint Discipline +- Resumption +- Techniques +- Related Skills + +## Critical Rules + +- Log invocation first: `loaf journal log "skill(explore): <topic or exploration ref>"` +- 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 bounded delivery, hand it to `/shape`. +- Never store transcripts, prompts, or tool output in checkpoints or items; curate semantic context instead. + +## 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`). +- 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>]...` | +| 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>]` | +| 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>` | + +## 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. +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. +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 + +The four fields are the portable contract; each is capped at 4096 UTF-8 bytes and oversize input is rejected, never truncated: + +- **purpose** — the current framing of the inquiry, restated so a stranger understands what is being explored and why. +- **conclusions** — constraints and conclusions established so far, including rejected options and why they fell. +- **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. + +## 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. + +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. + +## Deferring + +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. + +## Techniques + +Brainstorm's full divergent stance lives inside Explore: generate options before judging, connect to VISION/STRATEGY context, document discarded options, set boundaries on exploration time. Scout, research, prototype, and spike remain subordinate techniques invoked from whatever stage needs them — none of them owns lifecycle. + +## Related Skills + +- **triage** — processes the intake queue and chooses dispositions, including "explore this" +- **shape** — narrows one direction into a bounded Change; the exit door from Explore +- **research** — evidence gathering for a known question, usable inside an Exploration +- **idea** — quick capture without inquiry diff --git a/dist/skills/idea/SKILL.md b/dist/skills/idea/SKILL.md index 96c677932..ea3bdacc1 100644 --- a/dist/skills/idea/SKILL.md +++ b/dist/skills/idea/SKILL.md @@ -3,9 +3,10 @@ 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. For reviewing and - processing the intake queue (sparks + raw ideas), use triage instead. Not for - deep exploration (use brainstorm) or shaping (use shape). + actionable concept crystallizes during conversation. Ideas and sparks are + capture primitives routed through triage, which chooses dispositions such as + tracking an Intent. Not for divergent inquiry (use explore), processing the + intake queue (use triage), or shaping (use shape). --- # Idea @@ -38,83 +39,46 @@ Capture ideas quickly with minimal friction. ## Verification -- Idea appears in `loaf idea list` / `loaf idea show` -- Status is open/raw according to the active backend -- If promoted from a spark, `loaf spark promote` records the relationship +- The idea appears in `loaf idea list` and `loaf idea show <ref>` with status open +- If promoted from a spark, `loaf spark promote` recorded the relationship +- No shaping, status transition, or promotion happened here — dispositions belong to triage ## Quick Reference -| Status | Meaning | -|--------|---------| -| `raw` | Just captured, unprocessed | -| `shaping` | Being developed via /shape or /brainstorm | -| `shaped` | Converted to SPEC, idea file archived | -| `archived` | Decided not to pursue, kept for reference | +| Operation | Command | +|-----------|---------| +| Capture | `loaf idea capture --title "<title>"` | +| Read back | `loaf idea show <ref>` | +| List open | `loaf idea list` | --- ## Purpose -Ideas are raw nuggets -- unprocessed, unshaped, but worth remembering. The goal is **speed of capture**, not thoroughness. Shape later via `/shape`. +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, exploring it, shaping it, or archiving it are triage dispositions chosen later by the user. --- ## Process -### Step 1: Parse Input - -If `$ARGUMENTS` contains the idea, capture directly. - -If `$ARGUMENTS` is empty, ask **at most 2-3 questions**: core idea, problem/opportunity, immediate constraints. - -### Step 2: Capture Idea - -Use the CLI capture path: - -```bash -loaf idea capture --title "..." -``` - -In SQLite-backed mode, the row is stored in SQLite and no `.agents/ideas/` -markdown file is created. The [idea template](templates/idea.md) is retained -only for markdown-only compatibility and historical restore review. - -### Step 3: Create and Announce - -1. Generate timestamp: `date -u +"%Y-%m-%dT%H:%M:%SZ"` -2. Run `loaf idea capture --title "..."` with the inferred title -3. Announce the captured idea alias with next steps - ---- - -## Idea Lifecycle - -``` -raw -> shaping -> shaped (becomes SPEC) -> archived -``` - -| Status | Meaning | -|--------|---------| -| `raw` | Just captured, unprocessed | -| `shaping` | Being developed via /shape or /brainstorm | -| `shaped` | Converted to SPEC, idea file archived | -| `archived` | Decided not to pursue, kept for reference | +1. **Parse input.** If `$ARGUMENTS` contains the idea, capture directly. If empty, ask at most 2-3 questions: core idea, problem/opportunity, immediate constraints. +2. **Capture.** Run `loaf idea capture --title "..."` with the inferred title; log notable context with `loaf journal log`. +3. **Announce.** Report the captured alias and point at `/triage` for disposition. --- ## Guardrails -1. **Speed over completeness** -- capture quickly, shape later -2. **2-3 questions max** -- don't turn this into an interview -3. **Infer, don't ask** -- metadata should be automatic -4. **One idea per captured row/artifact** -- keep them atomic -5. **No shaping here** -- that's what `/shape` is for +1. **Speed over completeness** — capture quickly, disposition later +2. **2-3 questions max** — don't turn this into an interview +3. **Infer, don't ask** — metadata should be automatic +4. **One idea per captured row** — keep them atomic +5. **No lifecycle here** — no status transitions, promotion, or shaping; triage owns dispositions and the CLI performs them --- ## Related Skills -- **triage** -- Review and process the intake queue (sparks + raw ideas) -- **shape** -- Develop an idea into a SPEC -- **brainstorm** -- Deep thinking on an idea or problem space -- **research** -- Investigate before capturing +- **triage** — process the intake queue and choose dispositions +- **explore** — divergent inquiry when an idea needs development before commitment +- **shape** — develop a chosen direction into a bounded Change diff --git a/dist/skills/loaf-reference/SKILL.md b/dist/skills/loaf-reference/SKILL.md index c83d29b41..331f4adbc 100644 --- a/dist/skills/loaf-reference/SKILL.md +++ b/dist/skills/loaf-reference/SKILL.md @@ -2,10 +2,11 @@ name: loaf-reference description: >- Documents how agents operate the Loaf CLI: command discovery via loaf --help, - JSON diagnosis surfaces, guided config maintenance, and troubleshooting. Use - when unsure which loaf command to invoke or how to validate project state. Not - for workflow guidance (workflow skills own their CLI contracts) or build - internals. + JSON diagnosis surfaces, config-aware maintenance, and troubleshooting. Use + when unsure which loaf command to invoke, how to validate project state, or + when asked to upgrade, diagnose, repair, configure, or bring a Loaf project + current. Not for workflow guidance (workflow skills own their CLI contracts) + or build internals. --- # Loaf Reference @@ -65,7 +66,7 @@ Names and one-line purposes only. Run `loaf <command> --help` for options, argum | `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 lifecycle-statuses, backup, backup verify, backup restore, restore-ephemerals, verify-ephemerals, export, export all, export triage, export spec, export release-readiness | +| `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, 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, worktree-storage | @@ -84,6 +85,10 @@ 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 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 | | `loaf spark` | Manage sparks in native SQLite state | list, show, capture, resolve, promote | | `loaf tag` | Manage tags in native SQLite state | list, show, add, remove | | `loaf bundle` | Manage bundles in native SQLite state | list, create, update, show, add, remove | @@ -96,5 +101,6 @@ Names and one-line purposes only. Run `loaf <command> --help` for options, argum | Topic | Reference | Use When | |-------|-----------|----------| | Configuration maintenance | [references/configuration.md](references/configuration.md) | Checking whether a project's Loaf config is current and repairing it; wiring project-owned choices | +| Config-aware maintenance protocol | [references/maintenance.md](references/maintenance.md) | Upgrading, diagnosing, repairing, or bringing a project current: diagnose, plan, ask, apply, verify | | Command routing | [references/command-routing.md](references/command-routing.md) | Deciding which command a task needs; locating the JSON diagnosis surfaces | | Troubleshooting | [references/troubleshooting.md](references/troubleshooting.md) | Diagnosing state, config, or alignment failures; isolating a throwaway database | diff --git a/dist/skills/loaf-reference/references/maintenance.md b/dist/skills/loaf-reference/references/maintenance.md new file mode 100644 index 000000000..db5feed6e --- /dev/null +++ b/dist/skills/loaf-reference/references/maintenance.md @@ -0,0 +1,48 @@ +# Config-Aware Loaf Maintenance + +## Contents +- Protocol +- Fact Sources +- Planning Surfaces +- Consent Boundaries +- What Maintenance Never Does + +This protocol serves natural-language requests to upgrade, diagnose, repair, configure, or bring a Loaf project current. It is a hidden operator layer: interpret facts, ask only for missing project-owned choices, sequence approved deterministic operations, and verify convergence. Discover exact current syntax from `loaf <command> --help` instead of memorizing flags. + +## Protocol + +0. **Classify the request.** A diagnose-only request stops after step 1 with facts; a plan request stops after step 2 with the mutation ledger; only an explicit repair, upgrade, or bring-current request proceeds to apply, and then only for the mutation classes the user's request actually named. Never let the protocol's shape carry a diagnosis into a mutation. +1. **Diagnose.** Start with `loaf config check --json` for project intent and installed hook health. Add `loaf version` (running executable), `loaf state status --json` and `loaf state doctor --json` (SQLite readiness, schema version, repair plan), and `loaf doctor --json` (project alignment: symlinks, stale files, fenced-version drift). All four are read-only. +2. **Plan.** For installed-target convergence, use `loaf install --upgrade --dry-run --json`: it reports intended creates, updates, retirements, preserved conflicts, deprecation actions, project-file effects, and whether explicit consent is required, without writing anything. +3. **Ask.** Only for project-owned choices the facts cannot answer (for example integration election in `.agents/loaf.json`, or consent to destructive deprecation cleanup). Machine-observed facts are never questions. Present one complete mutation ledger — every intended operation with its consent requirement — and obtain approval for the ledger as a whole before applying any part of it. +4. **Apply.** Use the existing explicit operations the ledger named: `loaf config check --fix`, `loaf install --upgrade` (with `-y` only after consent), `loaf doctor --fix --force` (the `--force` form is required for non-interactive execution; plain `--fix` prompts and silently skips repairs without a TTY — if a repair genuinely needs interactive judgment, stop and report that it requires an operator), `loaf state migrate schema --apply`, `loaf state migrate deferrals --apply`. A project-owned election is recorded by editing `.agents/loaf.json` in the checkout — a durable, reviewable change — and validating with `loaf config check --json`. Never invent a bypass. +5. **Verify.** Rerun the diagnosis surfaces and confirm they converge; report any check that still fails rather than declaring success, and never loop back into apply without a changed ledger. + +## Fact Sources + +| Fact | Source | Authority | +|------|--------|-----------| +| Team-owned project intent (integrations, knowledge dirs) | `.agents/loaf.json` via `loaf config check --json` | Shared config; never records machine-local install state | +| Running executable version and provenance | `loaf version` | Observed local fact; the package manager owns acquisition | +| SQLite readiness, schema version, repair plan | `loaf state status --json`, `loaf state doctor --json` | Behind-schema state returns the exact backup-first `loaf state migrate schema --apply` action | +| Project alignment (symlinks, stale files, version fences) | `loaf doctor --json` | Read-only; repairs go through the explicit `--fix` contract | +| Installed target ownership and drift | `loaf install --upgrade --dry-run --json` | Content-addressed ownership manifests decide owned versus foreign | + +## Planning Surfaces + +- `loaf doctor --json` never prompts and never repairs; it carries the identical check identities and outcomes as the human output plus repair IDs for planning. +- `loaf install --upgrade --dry-run --json` is deterministic and byte-for-byte non-mutating; applying the reported plan through the existing commands must produce the predicted effects, after which diagnosis reports convergence. +- `loaf state migrate deferrals --dry-run --json` previews the legacy-deferral conversion manifest; apply is backup-first, preserves every legacy row, and is rerunnable. + +## Consent Boundaries + +- Database migrations (`state migrate schema --apply`, `state migrate deferrals --apply`) are backup-first and require the operator's explicit go-ahead on real state. +- Destructive deprecation cleanup during `install --upgrade` requires explicit consent (`-y`); missing consent must surface as a reported requirement, not an assumed yes. +- Locally modified or unowned destination content is preserved and reported, never overwritten. + +## What Maintenance Never Does + +- Never invokes Homebrew, npm, or any package manager, and never claims a newer remote version exists without evidence from the owning package manager. +- Never hardcodes a Cellar, checkout, or binary path; `loaf` resolves on `PATH`. +- Never infers machine-local installed-target intent from Git-tracked config, and never writes machine-specific fields into `.agents/loaf.json`. +- Never applies a database migration automatically as a side effect of diagnosis. diff --git a/dist/skills/triage/SKILL.md b/dist/skills/triage/SKILL.md index 0dbbfc600..4a6074505 100644 --- a/dist/skills/triage/SKILL.md +++ b/dist/skills/triage/SKILL.md @@ -1,17 +1,19 @@ --- name: triage description: >- - Surfaces and processes the intake queue: unresolved sparks from the project - journal and brainstorm documents, plus raw ideas awaiting evaluation. Use when - the user asks "what sparks do I have?", "review my ideas", "triage", or - "what's in my backlog?" Produces promoted ideas, archived discards, and - resolve(spark) journal entries. Not for capturing new ideas (use idea) or - shaping (use shape). + 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, or hand to + shape. Not for reading a single known item (use loaf intent show or journal + directly), capturing new ideas (use idea), divergent inquiry (use explore), or + bounding one chosen direction (use shape). --- # Triage -Review and process the intake queue — sparks and raw ideas. +Process the intake queue. Triage is the public funnel where captured material meets judgment: you present facts, the user chooses each disposition, and the CLI performs exactly what was chosen. **Input:** $ARGUMENTS @@ -22,159 +24,70 @@ Review and process the intake queue — sparks and raw ideas. - Verification - Quick Reference - Process -- Resolution Formats +- Dispositions +- Legacy Deferrals - Guardrails - Related Skills ## Critical Rules -- Present everything before acting -- user decides each disposition -- Never auto-promote or auto-discard without confirmation -- Use SQLite-aware CLI commands for lifecycle changes; do not edit idea/spark - frontmatter by hand -- Log or link resolutions through `loaf spark resolve`, `loaf spark promote`, - `loaf idea archive`, and `loaf brainstorm archive` when state is initialized -- One pass through the queue -- don't loop or re-present items +- Log invocation first: `loaf journal log "skill(triage): <trigger or scope>"` +- 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. +- One pass through the queue — don't loop or re-present items. ## Verification -- All presented sparks have a recorded disposition (promoted, discarded, or deferred) -- Promoted sparks have corresponding idea rows visible in `loaf idea list` -- Processed sparks no longer appear in default `loaf spark list` / triage output -- Archived ideas/brainstorms no longer appear in default triage lists -- Markdown source annotations, when present, are compatibility notes rather than - the authoritative state transition +- 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`). +- 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. ## Quick Reference -| Source | Unprocessed Signal | Resolution | -|--------|-------------------|------------| -| Sparks | Open spark rows from `loaf spark list` | `loaf spark promote` or `loaf spark resolve` | -| Brainstorms | Open brainstorm rows from `loaf brainstorm list` | `loaf brainstorm promote` or `loaf brainstorm archive` | -| Ideas | Open idea rows from `loaf idea list` | Shape, promote, or `loaf idea archive` | - ---- +| 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 `/shape` | +| brainstorm | archived divergent sessions | archive, explore, promote | +| intent (tracked) | `loaf intent create` | keep tracking, defer, resolve, explore, 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) | ## Process -### Step 1: Scan Sources - -Scan state-backed queues first, falling back to Markdown compatibility sources -only when SQLite state is not initialized: - -**1. Sparks** -- Run `loaf spark list` or `loaf spark list --json` -- Treat open rows as unresolved intake - -**2. Brainstorms** -- Run `loaf brainstorm list` or `loaf brainstorm list --json` -- Treat open rows as brainstorm intake - -**3. Ideas** -- Run `loaf idea list` or `loaf idea list --json` -- Treat open rows as idea intake - -### Step 2: Present the Queue - -Show a summary table: - -``` -Intake Queue: - Sparks (journal): 3 unresolved - Sparks (brainstorms): 1 unprocessed - Raw ideas: 2 awaiting evaluation - Total: 6 items -``` - -Then list each item with source, date, and description. - -### Step 3: Process Each Item - -For each item, present it and ask for disposition: - -**Sparks → one of:** -- **Promote** → `loaf spark promote <spark> --to-idea <idea>` -- **Discard** → `loaf spark resolve <spark> --reason <reason>` -- **Defer** → skip, resurface next triage - -**Raw ideas → one of:** -- **Shape** → suggest running `/shape` with this idea -- **Brainstorm** → suggest running `/brainstorm` to explore further -- **Archive** → `loaf idea archive <idea> --reason <reason>` - -### Step 4: Summarize - -After processing, show what happened: +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. -``` -Triage complete: - Promoted: 2 sparks → ideas - Discarded: 1 spark - Deferred: 1 spark - Shaped: 1 idea → /shape - Archived: 1 idea -``` +## Dispositions ---- - -## Resolution Formats - -### Sparks - -When promoting: -```bash -loaf spark promote SPARK-slug --to-idea idea-slug -``` - -When discarding: -```bash -loaf spark resolve SPARK-slug --reason "reason" -``` - -When deferring: -Do nothing; open sparks remain visible in the next triage pass. - -### Brainstorms - -When promoting: -```bash -loaf brainstorm promote brainstorm-slug --to-idea idea-slug -``` +- **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** — hand the item to `/explore`, linking it with `--from` when the Exploration is created. +- **Shape** — when a direction is ready for bounded delivery, hand it to `/shape`; triage never creates Changes, branches, or worktrees. -When archiving: -```bash -loaf brainstorm archive brainstorm-slug --reason "reason" -``` +## Legacy Deferrals -### Ideas - -When archiving: -```bash -loaf idea archive idea-slug --reason "reason" -``` - -When shaping, pass the idea to `/shape`; do not hand-edit status frontmatter to -represent lifecycle state. - ---- +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. ## Guardrails -1. **User decides every disposition** -- present, don't decide -2. **Batch presentation, individual decisions** -- show the full queue, then process one at a time -3. **Log everything** -- no silent discards or promotions -4. **Deferred items resurface** -- they'll appear again next `/triage` - ---- - -## Suggests Next - -After triage completes, suggest `/shape` for any ideas promoted to shaping. +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. ## Related Skills -- **idea** -- Capture a new idea (fast, minimal friction) -- **shape** -- Develop an idea into a SPEC -- **brainstorm** -- Deep exploration of a problem space (produces sparks) -- **housekeeping** -- Flags brainstorm drafts with unprocessed sparks before deletion -- **reflect** -- Strategic document updates (separate from triage) +- **idea** — capture a new idea (fast, minimal friction) +- **explore** — divergent inquiry with portable checkpoints +- **shape** — develop a chosen direction into a bounded Change +- **housekeeping** — flags stale artifacts; does not choose dispositions diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6ade3e62c..99462314b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -177,6 +177,14 @@ Skills are the only knowledge mechanism that works across all targets (Claude Co Principles that shape how Loaf is designed and operated. Unlike ADRs, these are mutable and evolve via `/reflect` as the project learns. They sit above implementation choices but below VISION (which captures product intent and direction). +### State Authority — Git Authors, SQLite Operates + +Authored durable artifacts — Changes, plans, research, specs, ADRs, knowledge, reports, code, and generated deliverables — live in Git and are edited in place. Operational, queryable state — the journal, Intent, Exploration, checkpoints, conversation provenance, relationships, deferrals, and derived indexes — lives in project-scoped SQLite. Neither store mirrors the other: SQLite never becomes a hidden Markdown repository, and Git never holds per-conversation operational facts. No tracker holds authority over either until a later coordination Change defines publication and reconciliation explicitly. + +The intent-exploration-foundation Change proved the operational side as append-only facts rather than mutable lifecycle state. An Intent's disposition (`tracked`, `deferred`, `resolved`) and an Exploration's latest portable checkpoint are derived from transactionally sequenced immutable records — the row with the greatest committed per-aggregate sequence wins, never a timestamp and never a status column. Compound writes are retry-safe through one canonical per-project operation-key mapping (`intent_operations`), which the transitional `journal defer` adapter and legacy conversion share with the native commands, so no entry point can mint a parallel canonical record. Machine-local conversation handles and log locators are optional provenance with observed availability; portable context is exclusively the checkpoint's four required fields, and their presence is reported honestly (`portable_context_present`) rather than inferred from handles. + +The judgment boundary follows from the storage boundary: humans and Skills interpret, classify, and choose operations; the CLI validates, proves, and performs them deterministically. Skills never branch on backend configuration, call providers directly, or maintain lifecycle flags; the CLI never decides whether input is a Spark, Idea, Intent, Exploration, or Change. + ### Authorship Model — Agents Create, Humans Curate Agents are the primary authors of knowledge files, ADRs, tasks, and specs. Humans review, approve, and curate — they are not the writing surface. The CLI follows from this: it is for management and health checks (`check`, `validate`, `status`, `review`), not for authoring. diff --git a/docs/changes/20260710-journal-reliability-foundation/change.md b/docs/changes/20260710-journal-reliability-foundation/change.md index d9fe00ddb..3936b5e57 100644 --- a/docs/changes/20260710-journal-reliability-foundation/change.md +++ b/docs/changes/20260710-journal-reliability-foundation/change.md @@ -14,29 +14,50 @@ release-after: spec-conversion-and-guidance-sweep - **Lineage:** `change-model-hard-cut`. - **Position:** Root prerequisite; no predecessor. -- **Accepted order:** `journal-reliability-foundation` → `change-native-execution-migration` → `spec-conversion-and-guidance-sweep`. +- **Original accepted order (2026-07-10):** `journal-reliability-foundation` → `change-native-execution-migration` → `spec-conversion-and-guidance-sweep`. This remains the historical decision that governed the shipped foundation. +- **Superseding successor order (2026-07-17):** `journal-reliability-foundation` → `intent-exploration-foundation` → `change-native-execution-migration` → `linear-native-coordination` → `spec-conversion-and-guidance-sweep`. The approved workflow redesign added the relational Intent/Exploration substrate and provider-native coordination before destructive convergence; it does not rewrite the predecessor's shipped implementation claims. - **Release gate:** A stable or normal-semver release whose ancestry contains any materialized node of `change-model-hard-cut` is refused until `spec-conversion-and-guidance-sweep` is also in that ancestry. Alpha/prerelease dogfood may advance an already-prerelease version only through an explicit `loaf release --bump prerelease` (or post-merge finalization of that prerelease). Releases cut from ancestry predating the first merged node are unaffected; `release-after` is immutable dependency metadata, not lifecycle state. -- **Activation rule:** One active Change branch/worktree at a time. The immediate successor remains a packet until this Change lands and its dogfood evidence is reconciled. +- **Original activation rule:** One active Change branch/worktree at a time; the then-immediate successor remained a packet until this Change landed and its dogfood evidence was reconciled. +- **Current activation rule:** One active Change branch/worktree at a time within this lineage. `intent-exploration-foundation` materialized after explicit approval; every later successor remains a self-sufficient packet until its immediate predecessor lands and is freshly verified. - **Canonical lineage seed:** `journal:00f302e40678b0f13b61f28c`, which supersedes the prose-keyed `journal:8255bef3bf2f5cd68a57fd6f` without rewriting history. +- **Successor-order amendment:** `journal:0036cd92bc4412bbe6c0c4c3` supersedes only the forward-looking successor order with the five-node sequence above; it preserves the root's shipped decisions and terminal release gate. - **Parked terminal draft:** Commit `59fbcdcf` is valuable review evidence but not a lineage authority. This Change carries the durable successor packets; after explicit user approval, the parked branch should be pushed as a remote ref without opening a PR or worktree. -### Immediate successor packet — `change-native-execution-migration` +### Immediate successor packet — `intent-exploration-foundation` -- **Purpose:** Add the Change-native execution and per-project migration path while the legacy public surface still exists, so the destructive hard cut consumes proven additive behavior rather than inventing it during removal. -- **Activation gate:** This Change is merged; its Verification Contract is freshly rerun against current main; project identity, journal retrieval, deferral capture, continuity projections, structural lineage checks, and every claimed target adapter pass real dogfood; deviations are reconciled into this packet before materialization. -- **Inherited decisions:** Changes remain git-canonical; no persistent task replacement is introduced; `implement` consumes executable Change slugs/paths; `ship` consumes the Verification Contract and durable-output obligations without auto-promoting ordinary decisions into ADRs; migration is deterministic in the CLI and judgment-guided through `bootstrap`; global snapshots are disaster recovery while rollback of one project's migration is project-scoped replay. -- **Required outputs:** `loaf migrate change-model` dry-run/manifest/apply/replay; additive Change-native `implement` and `ship`; Loaf's explicit legacy-work disposition inventory; the atomic journal-deferral primitive wired into `shape`, `ship`, and `triage`; no removal of public spec/task surfaces yet. -- **No-gos:** No automatic semantic disposition, no interactive CLI wizard, no new workflow entity, no mutation of other projects, no stable or normal-semver release, and no guidance sweep that makes the still-present legacy recovery path undiscoverable. -- **Fog to re-open:** Exact Loaf legacy dispositions, project-scoped replay collision semantics, additive coexistence hazards found by journal dogfood, and any target adapter that remained fallback-only. -- **Sources:** This Change's proven outputs; the bounded packet in parked commit `59fbcdcf`; journal decisions `journal:e76678700d531bdc14eb9836`, `journal:8a409ff660cb7b0a6f436eb5`, and `journal:8b85530c4ef3f60bea492292`. +- **Purpose:** Add first-class backend-neutral Intent and relational Exploration/checkpoint continuity so tracked/deferred work survives conversations and harnesses without recreating mutable sessions or requiring Git/tracker materialization. +- **Activation gate:** This Change is merged and retained in current main; the 2026-07-15–17 workflow exploration is materially synthesized into a self-sufficient Change; current state/Skill/CLI contradictions are audited; user explicitly approves the new target model and deterministic CLI/judgment-bearing Skill boundary. +- **Inherited decisions:** SQLite owns operational/queryable state; Git owns authored durable artifacts; machine-local conversation/session/log handles are optional provenance; portable checkpoints carry resumable context; defer is an Intent disposition; append-only facts replace mutable lifecycle status; `/triage` and `/explore` own judgment while the CLI performs requested deterministic operations. +- **Required outputs:** Additive Intent/Exploration/conversation/checkpoint schema; typed validated relationships; deterministic local CLI/read surfaces; atomic rich deferral and legacy bridge/migration; `/explore` plus narrow Triage/Idea/Spark/Brainstorm convergence; generated/routing parity; cross-context dogfood. +- **No-gos:** No Change worktree/branch/PR creation, no `change.md`/`plan.md` materialization, no tracker fields/synchronization, no Spec/Task hard cut, no mutable Intent/Exploration status, no raw transcripts/provider payloads, and no automatic semantic promotion. +- **Durable source:** `docs/changes/20260717-intent-exploration-foundation/{change.md,plan.md,research/workflow-convergence.md}` plus the accepted journal decisions cited there. + +### Following successor packet — `change-native-execution-migration` + +- **Purpose:** Promote bounded Intent into a Git-canonical Change and add Change-native execution/per-project migration while the legacy public surface still exists, so the destructive hard cut consumes proven additive behavior rather than inventing it during removal. +- **Activation gate:** `intent-exploration-foundation` is merged and dogfooded; its Verification Contract is freshly rerun against current main; one Exploration resumes portably and one deferred Intent survives response-loss/concurrency; deviations are reconciled into this packet before materialization. +- **Inherited decisions:** `change.md` is the Product Contract; `plan.md` owns named units/dependencies/acceptance/verification; Loaf mechanizes one Change-dedicated branch/worktree before the first durable write; relevant Exploration research/reports materialize inside the Change while canonical durable artifacts are edited in place; no persistent task replacement is introduced; migration is deterministic in the CLI and judgment-guided through Skills. +- **Required outputs:** Intent-to-Change promotion; deterministic branch/worktree/Change/plan/draft-PR materialization; affected-artifact path reconciliation; Change-native `/shape`, `/plan`, `/implement`, and `/ship`; per-project legacy execution migration/replay; no public Spec/Task removal yet. +- **No-gos:** No automatic semantic disposition or promotion, no interactive CLI wizard, no tracker synchronization, no direct provider/backend branching in Skills, no numbered Task replacement, no mutation of other projects, no stable or normal-semver release, and no guidance sweep that hides the still-present legacy recovery path. +- **Fog to re-open:** Exact Loaf legacy dispositions, project-scoped replay collision semantics, Change workspace adoption/reuse when a harness already supplied one, draft-PR timing, additive coexistence hazards, and any target adapter that remained fallback-only. +- **Sources:** The Intent/Exploration Change's proven outputs; the bounded packet in parked commit `59fbcdcf`; journal decisions `journal:e76678700d531bdc14eb9836`, `journal:8a409ff660cb7b0a6f436eb5`, `journal:8b85530c4ef3f60bea492292`, and `journal:3274f922f1cc09e24d80cd12`. + +### Tracker successor packet — `linear-native-coordination` + +- **Purpose:** Make Linear the native collaborative authority for published Intent, Change, and meaningful plan-unit workflow state while Git remains authority for durable artifact bodies and SQLite remains operational/provenance state. +- **Activation gate:** `change-native-execution-migration` is merged and its local-only Intent→Change→Plan→Implement→Ship workflow is dogfooded without tracker assumptions. +- **Inherited decisions:** One primary issue per Change; meaningful named plan units may map to subissues; inbound issues may be adopted as Intent; external IDs are mappings; provider adapters, revisions, retries, and conflicts are deterministic CLI/state responsibilities; Skills never branch on provider configuration or call MCP directly. +- **Required outputs:** Publish/adopt/reconcile primitives; revision/content hashes; outbox/retry/conflict handling; issue/Change/PR links; subissue dependencies and acceptance checklists; agent-from-Linear bootstrap; local-mode parity. +- **No-gos:** No tracker-owned durable document bodies, no silent last-writer-wins, no raw provider payload archive, no requirement that local-only projects configure Linear, and no provider-specific workflow duplicated across Skills. +- **Sources:** Journal discoveries `journal:fa885c8d627e27e407ea86cb`, `journal:c3c937f0ff869ef87127807d`, `journal:d655dbd5f1d881a5e4e0c648`, and the tracker packet in `intent-exploration-foundation`. ### Terminal successor stub — `spec-conversion-and-guidance-sweep` - **Purpose:** Hard-remove public spec/task/breakdown workflow, quarantine remaining legacy tables as migration-only input, converge guidance/build/install surfaces, and dogfood Loaf's own migration. -- **Activation gate:** Both predecessors are merged and unreleased; the parked draft is rebased, receives `predecessor: change-native-execution-migration`, replaces assumptions with landed evidence, and passes fresh critique plus `--require-executable`. -- **Inherited decisions:** Hard cut with no compatibility release; old projects migrate when next opened; quarantine is temporary internal input; historical evidence may retain legacy vocabulary; terminal stable release occurs only after live and generated guidance converge. Alpha/prerelease dogfood remains allowed through the explicit prerelease release path during the freeze. ADRs remain rare, narrow records for deep and durable architectural choices: prospective reasoning defaults to the Change/journal, greenfield speculation and single-Change implementation choices do not become ADRs, and an applicable existing ADR is evidence plus an explicit supersession obligation rather than a veto on an intentional refactor. -- **Required guidance convergence:** Align `shape`, `architecture`, `ship`, the Change template, and durable-document guidance around one high ADR threshold: a focused architectural choice, credible alternatives, long-lived/cross-Change consequence, rationale worth preserving, and low expectation of near-term churn. Shaping checks ADR applicability/status, may explicitly challenge one, and carries any proposed creation/deprecation/supersession as a reviewable output of the Change rather than blocking forward motion. -- **No-gos:** No public read-only compatibility commands, no global deletion while any project retains rows, no broad skill-quality audit, no transcript ingestion, no implementation from the stale parked snapshot without revalidation, no ADR-per-decision habit, no speculative greenfield ADRs, no treating an accepted ADR as immutable law, and no silently contradicting an applicable ADR instead of superseding or deprecating it explicitly. +- **Activation gate:** All four predecessors are merged and unreleased; the parked draft is reconciled onto current main, receives `predecessor: linear-native-coordination`, replaces assumptions with landed evidence, and passes fresh critique plus `--require-executable`. +- **Inherited decisions:** Hard cut with no compatibility release; old projects migrate when next opened; quarantine is temporary internal input; historical evidence may retain legacy vocabulary; terminal stable release occurs only after live and generated guidance converge. Alpha/prerelease dogfood remains allowed through the explicit prerelease release path during the freeze. ADRs remain rare, narrow records for deep and durable architectural choices: prospective reasoning defaults to the Change/journal, greenfield speculation and single-Change implementation choices do not become ADRs, and an applicable existing ADR is evidence plus an explicit supersession obligation rather than a veto on an intentional refactor. A Change's Decisions section is the primary ledger for decisions made during that delivery, not a staging area awaiting ADR-ification; the Change goes stale harmlessly after shipping, and only distilled artifacts are durable. The boundary is a reversal test: when undoing a choice is just a code change it stays in the Change; an ADR is warranted only when undoing would require re-understanding why the choice was made and what conceptually breaks. Promotion of a Change decision to an ADR happens at reflect time — when a constraint proves load-bearing after shipping, arises outside any Change, or spans multiple Changes — never at shaping time. A promoted ADR is a stand-alone distillation abstracted from its source Change: it restates the constraint, rationale, and rejected alternatives in its own words, stands on its own like every durable artifact, carries no back-links into the Change, and is never a copy of its Decisions section. ADR weight scales with project maturity — weigh creation very low pre-release — and volatile decisions are never ADR-ified. +- **Required guidance convergence:** Hard-remove public Spec/Task/render/breakdown authority; converge `/triage → /explore → /shape → /plan → /implement → /ship → /release → /reflect`, supporting workflows, templates, hooks, CLI reference, README, architecture/strategy/knowledge, routing evaluations, generated targets, and installed surfaces; supersede ADR-011/013/016 where landed behavior invalidates their authority; align `shape`, `architecture`, `reflect`, `ship`, the Change template, and durable-document guidance around the existing high ADR threshold. +- **No-gos:** No public read-only compatibility commands, no global deletion while any project retains rows, no unrelated broad skill-quality audit, no transcript ingestion, no implementation from the stale parked snapshot without revalidation, no ADR-per-decision habit, no speculative greenfield ADRs, no ADR that duplicates or merely points into a shipped Change's Decisions section, no treating an accepted ADR as immutable law, and no silently contradicting an applicable ADR instead of superseding or deprecating it explicitly. - **Durable source:** The compact contract here plus accepted lineage journal entries. Commit `59fbcdcf` is a detailed working draft to reconcile, not the only copy of intent. ## Problem @@ -79,8 +100,10 @@ If Loaf makes canonical-versus-derived state explicit, refuses unknown project i **Out** (deferred, not rejected) -- Change-native `implement`/`ship`, per-project legacy migration, Loaf's legacy-work dispositions, and integration of deferral capture into the final workflow — owned by `change-native-execution-migration` after this foundation lands. -- Removal of public `loaf spec`, `loaf task`, breakdown, legacy writers, generated guidance, and physical legacy schemas — owned by `spec-conversion-and-guidance-sweep` and the later zero-row schema cleanup. +- First-class Intent, relational Exploration/checkpoints/conversation provenance, append-only deferred dispositions, and the focused Triage/Explore/capture surface — added by the superseding `intent-exploration-foundation` packet after this foundation shipped. +- Change workspace/plan materialization, Change-native `implement`/`ship`, per-project legacy execution migration, and affected-artifact reconciliation — owned by `change-native-execution-migration` after the Intent/Exploration foundation lands. +- Linear publish/adopt/reconcile, one issue per Change, plan-unit subissues, revisions/outbox/conflicts, and agent-from-tracker bootstrap — owned by `linear-native-coordination` after local Change execution proves provider-neutral semantics. +- Removal of public `loaf spec`, `loaf task`, render, breakdown, legacy writers, generated guidance, and physical legacy schemas — owned by `spec-conversion-and-guidance-sweep` and the later zero-row schema cleanup. - Migrating GridSight, mvault, dots, or any other project. This Change may use isolated copies/fixtures but mutates only Loaf's code, generated artifacts, isolated installs, and explicitly approved local dogfood configuration. - Raw transcript ingestion, prompt mirroring, harness-log parsing, or guaranteed navigation into conversation JSONL. A later `journal-source-navigation` Change may use origin pointers if repeated recovery needs justify it. - Automated cloud-provider backup, continuous replication, zero-RPO disaster recovery, cross-device synchronization, or automatic backup pruning/retention. This Change provides explicit external destinations, watermarks, and verified restore rehearsal. @@ -185,13 +208,15 @@ Repeating the operation key returns the same first-written pair even if a retry ```text $ loaf change list --lineage change-model-hard-cut --json root: journal-reliability-foundation +materialized successor: intent-exploration-foundation next packet: change-native-execution-migration -parked materialized node: spec-conversion-and-guidance-sweep -gap: predecessor change-native-execution-migration is not materialized +tracker packet: linear-native-coordination +parked evidence only: spec-conversion-and-guidance-sweep at 59fbcdcf +gap: predecessors change-native-execution-migration and linear-native-coordination are not materialized release after: spec-conversion-and-guidance-sweep $ loaf change check --require-executable docs/changes/20260710-spec-conversion-and-guidance-sweep -error: predecessor change-native-execution-migration is missing from this lineage ancestry +error: predecessor linear-native-coordination is missing from this lineage ancestry ``` Retained Change slug is identity; branch is provenance. The graph is derived from git-visible Change files and structured journal decisions. @@ -211,10 +236,10 @@ Retained Change slug is identity; branch is provenance. The graph is derived fro ## Decisions -Provenance: Decisions 1–5 originate in the accepted 2026-07-10 journal-first and hard-cut conversations. Decisions 6–20 were selected after isolated adversarial scenarios, three independent read-only reviews, current source inspection, and installed Codex capability inspection; they remain reviewable through this Change and the final Claude critique. +Provenance: Decisions 1–5 originate in the accepted 2026-07-10 journal-first and hard-cut conversations. Decisions 6–20 were selected after isolated adversarial scenarios, three independent read-only reviews, current source inspection, and installed Codex capability inspection; they remain reviewable through this Change and the final Claude critique. Decision 21 is a post-ship successor-protocol amendment accepted through the 2026-07-15–17 workflow exploration; it supersedes only the future order in Decision 2, not the historical foundation contract. 1. **Journal reliability is a release gate, not best-effort polish.** The legacy execution model cannot be removed until every guarantee claimed by supported targets has direct evidence or an explicit fallback. -2. **The serial three-Change lineage remains.** One active branch/worktree avoids contradictory plans; the root carries successor packets so deferred nodes survive without premature worktrees or PRs. +2. **The originally accepted delivery was a serial three-Change lineage.** One active branch/worktree avoided contradictory plans; the root carried successor packets so deferred nodes survived without premature worktrees or PRs. Decision 21 later supersedes the successor order after the foundation shipped and new workflow requirements were explored. 3. **No stable release occurs between lineage nodes.** Once release ancestry contains the first materialized lineage node, release preflight freezes stable and normal-semver publication until that ancestry also contains the named terminal node; explicit `--bump prerelease` may advance an already-prerelease version for alpha/prerelease dogfood, and post-merge finalization may complete that prerelease. Ancestry predating the lineage remains unaffected, and `release-after` is immutable dependency metadata, not mutable lineage status. 4. **No persistent task/session/program entity replaces retired state.** Durable truth remains journal + retained Changes + git; CLI views are derived. 5. **Raw transcripts remain outside Loaf state.** Semantic nuggets and bounded origin pointers provide portability without mirroring sensitive/noisy harness logs. @@ -233,6 +258,7 @@ Provenance: Decisions 1–5 originate in the accepted 2026-07-10 journal-first a 18. **Lineage is structurally enforceable without becoming an entity.** Exact-scope journal decisions preserve evolving intent; retained Change frontmatter/ancestry defines materialized order; CLI checks/list/release preflight derive and validate the graph. Structural ancestry does not prove implementation completion, so successor activation separately reruns the predecessor Verification Contract against current main. 19. **Codex basic command policy is explicit and fail-closed.** The central registry classifies explicit basic leaf prefixes and defaults every unclassified or operator command to gated; an opt-in adds one managed user-layer execpolicy prefix per basic leaf for the canonical absolute Loaf executable. `journal log --execpolicy-safe` is the only basic journal leaf, while ordinary journal logging, body/file-consuming creation or import leaves, and path-taking `change check` remain operator-gated. Codex documents `allow` rules as exact command prefixes that run outside the sandbox without prompting, which is narrower than making the global Loaf data directory writable to every sandboxed process. Safe journal mode requires the registered current project, rejects `LOAF_DB` and caller-supplied worktree/branch/session overrides, and retains manual, `--from-hook`, `--detect-linear`, and JSON behavior. The installer pins the trusted executable in rendered prefixes and digest-owned `CODEX_HOME/AGENTS.md` guidance, never allows bare Loaf namespaces, never overwrites unowned or locally modified managed content, retires legacy journal-only ownership before broadening, and reports when executable resolution or installed ownership cannot be trusted. Other harness adapters are absent; the central registry enables future adapters without claiming them now. 20. **Codex lifecycle delivery uses a narrow current-schema adapter.** Codex `0.144.1` rejects Loaf's legacy top-level `version` and flat hook records, so the Codex build emits a current-schema `SessionStart` matcher group with both `command` and `commandWindows` placeholders. Install renders a trusted absolute executable: POSIX installs keep the exact two-field command shape and omit `commandWindows`, while Windows installs render both fields to the same canonical `cmd.exe /C` outer-wrapped command. U8 proves model-visible startup only on `darwin-arm64` in an isolated `CODEX_HOME`; `failClosed`, `blocking`, command conditions, global-home installation, resume, clear, compact, Windows runtime behavior, and completion remain separately unproven or unsupported. Install retires only recognized legacy flat Loaf records, preserves every recognized current-schema matcher group and top-level description, and refuses malformed, unknown, or unsupported user content under Loaf's conservative merge policy. +21. **The successor protocol expands to Intent/Exploration, Change execution, Linear coordination, then terminal convergence.** The later design established that first-class operational Intent/Exploration and provider-native coordination cannot be invented safely inside execution migration or destructive removal. The current five-node serial order preserves one active Change workspace, keeps local-only operation viable, and still requires every node to land before the terminal stable release. ## Planning Contract @@ -373,8 +399,8 @@ Every schema migration is backup-first, idempotent, and tested from the current Executable (machine-checkable): -- **V1.** `loaf change check --require-executable docs/changes/20260710-journal-reliability-foundation` exits zero before implementation begins and after every contract revision; the same command against the parked terminal Change exits non-zero until `change-native-execution-migration` is materialized and present in ancestry. -- **V2.** Lineage fixtures reject duplicate slugs across dates, self-predecessors, two-node and longer cycles, lineage mismatch, missing predecessor under `--require-executable`, and multiple materialized children; `change list --lineage --json` remains deterministic after branches are renamed/deleted; checker output labels ancestry as structural materialization rather than completion; successor activation reruns predecessor verification against current main; release preflight leaves ancestry predating the first lineage node unaffected, refuses stable and normal-semver ancestry containing any nonterminal node without `release-after`, allows explicit prerelease dogfood only from an already-prerelease version, and accepts terminal ancestry. +- **V1.** `loaf change check --require-executable docs/changes/20260710-journal-reliability-foundation` exits zero before implementation begins and after every contract revision; `intent-exploration-foundation` validates as its only materialized child; the parked terminal Change exits non-zero until `change-native-execution-migration` and `linear-native-coordination` are materialized in ancestry and it names the latter as predecessor. +- **V2.** Lineage fixtures reject duplicate slugs across dates, self-predecessors, two-node and longer cycles, lineage mismatch, missing predecessor under `--require-executable`, multiple materialized children, and bypass of either inserted successor; `change list --lineage --json` remains deterministic after branches are renamed/deleted; checker output labels ancestry as structural materialization rather than completion; successor activation reruns predecessor verification against current main; release preflight leaves ancestry predating the first lineage node unaffected, refuses stable and normal-semver ancestry containing any nonterminal node without `release-after`, allows explicit prerelease dogfood only from an already-prerelease version, and accepts terminal ancestry. - **V3.** A moved-checkout fixture proves `journal recent`, `journal search`, `journal context`, and ordinary `journal log` create zero project/path rows and return the exact move/init actions; `project move` preserves the original project ID and entries; simultaneous independent `state init` processes converge on one project and one current path. - **V4.** Loaf-connection pragma tests assert WAL, foreign keys, busy timeout ≥5000 ms, and `synchronous=FULL`. Existing concurrent-writer tests pass; independent-process contention drops no entries; SIGKILL fault injection leaves no partial base/index pairs and `quick_check=ok`. Documentation labels these results transaction/process evidence and makes no stronger hardware claim. - **V5.** FTS fixtures delete, add, and content-modify derived rows. Doctor reports exact divergence; journal search refuses trusted output; backup verification reports `journal_retrieval_ready=false`; dry-run is byte-for-byte non-mutating; apply creates a verified pre-repair backup, rebuilds from canonical rows, proves exact bidirectional parity, and is idempotent. Storage-home migration and all journal bulk paths finish with parity. @@ -396,7 +422,7 @@ Human review: - **H3.** The default digest is compact but not deceptive: active lineage, blockers, deferrals, and Changes are visible; omissions and expansion are obvious; no unrelated scoped wrap masquerades as project synthesis. - **H4.** Deferred work remains understandable from its nugget alone while origin makes deeper investigation possible when source context still exists. - **H5.** Recovery instructions distinguish local rollback, project-scoped replay, and external disaster recovery, and never invite concurrent live replacement of the global database. -- **H6.** The immediate successor packet is sufficient to initialize `change-native-execution-migration` after this conversation/branch is gone; the terminal stub preserves its hard-cut intent without treating parked commit `59fbcdcf` as current truth. +- **H6.** The materialized `intent-exploration-foundation` Change and the following execution/Linear packets are sufficient to continue the amended lineage after this conversation/branch is gone; the terminal stub preserves its hard-cut intent without treating parked commit `59fbcdcf` as current truth. - **H7.** Enabling Codex's basic command policy is an explicit, understandable one-time trust decision for the centrally classified routine state-plane and read-only Loaf leaves; it does not authorize unclassified/operator commands, make the Loaf database generally writable to arbitrary processes, imply other harness adapters, or imply that Auto-review grants filesystem access. ## Definition of Done @@ -439,7 +465,7 @@ Human review: ## Critique Gate - **Did adversarial review change the plan?** Yes. It moved priority from richer journaling to silent-identity and silent-retrieval failures, rejected nominal hook parity, added outcome-aware automation, made context completeness explicit, and turned lineage prose into an executability/release contract. -- **Did independent Claude review change the revision?** Yes. The fresh Opus pass confirmed every source diagnosis and kept the one-Change/three-node shape, then exposed six remaining contract holes: first-run wrap regression, reworded response-loss retries, ambiguous release scope, structural-ancestry overclaim, missing capture-fidelity classification, and unmatched blocker keys. All six are incorporated; the optional extra lineage Change is rejected because U1 can land first inside this unreleased foundation without adding another node. +- **Did independent Claude review change the revision?** Yes. The fresh Opus pass confirmed every source diagnosis and kept the then-current one-Change/three-node shape, then exposed six remaining contract holes: first-run wrap regression, reworded response-loss retries, ambiguous release scope, structural-ancestry overclaim, missing capture-fidelity classification, and unmatched blocker keys. All six were incorporated; the optional U1-only lineage node was correctly rejected because journal reliability could land inside this then-unreleased foundation. - **Did the amendments close the named findings?** Yes. A narrow second Opus pass checked only F1–F6 across Scope, Decisions, Planning, and Verification, marked every finding `CLOSED`, found no direct contradictions, and returned `APPROVE`. - **Is the foundation too broad?** It is large because journal trust is an end-to-end property. Shipping only storage, context, or adapters would still let downstream work consume a false guarantee. Units remain independently reviewable and reversible; a new dependency or unsupported runtime requirement reopens the one-Change decision. - **Is active truth becoming status?** No. Every projection is derived from append-only latest-per-key entries, unresolved spark state already in intake, retained Change files, and git ancestry; no mutable journal/lineage lifecycle is added. @@ -447,10 +473,13 @@ Human review: - **Does origin preservation depend on transcript availability?** No. The nugget is the minimum durable payload; origin is optional evidence and reconstructability is a checked claim. - **Can one noisy branch hide project-wide intent?** Not under the new precedence contract. Dedicated active projections do not share the recency limit, and every layer exposes omitted counts and expansion. - **Does the model survive loss of the parked branch?** Essential lineage order, decisions, no-gos, activation gates, outputs, fog, and citations live in this root Change plus structured journal decisions. The remote-ref recommendation protects the detailed draft but is not the sole continuity mechanism. +- **Does the 2026-07-17 successor amendment rewrite the shipped critique?** No. It preserves the original rejection and foundation evidence, then changes only future materialization after later dogfood exposed two different missing contracts: relational Intent/Exploration continuity and Linear-native coordination. Both have self-sufficient packets and remain outside the shipped foundation scope. ## Source Inputs - `research/adversarial-scenarios.md` — runtime/source scenarios, positive evidence, selected corrections, rejected alternatives, and lineage re-evaluation. +- `docs/changes/20260717-intent-exploration-foundation/{change.md,plan.md,research/workflow-convergence.md}` — approved post-ship successor amendment, portable exploration synthesis, relational foundation contract, and later execution/Linear packets. +- Journal decisions/discoveries `journal:50205f69f32e2aedc14bf6eb`, `journal:56e7af05c430a63df2047ddb`, `journal:e3c0c830df1fcea2d925d52d`, and `journal:b58088cb742a3474c0010b01` — first-class Exploration, backend-neutral Intent/defer disposition, deterministic CLI/Skill boundary, and holistic workflow convergence. - Journal decision `journal:a5a4094d11591a87c29d12db` and discovery `journal:448e39538bbe9e35b67e1e55` — adjudicated the fresh Claude Opus `REVISE` review: accepted six bounded corrections, kept one foundation Change and the three-node lineage, and rejected a new U1-only lineage node. - Journal discovery `journal:537239e4c916a610fbcb1895` and decision `journal:c550982f58d9f1acfe819e94` — narrow Claude fix verification returned `APPROVE` with F1–F6 closed and no contradictions; shaping is executable but implementation remains unstarted pending user review. - Journal decision `journal:d98256fa9de6deee30365ca9` — OS temporary directories are disposable test surfaces only; durable backups, recovery artifacts, lineage packets, and review evidence must use repository/git, journal state, or an explicitly durable non-temporary destination. diff --git a/docs/changes/20260710-journal-reliability-foundation/research/u8-claude-code-2.1.207-candidate-smoke.json b/docs/changes/20260710-journal-reliability-foundation/research/u8-claude-code-2.1.215-candidate-smoke.json similarity index 88% rename from docs/changes/20260710-journal-reliability-foundation/research/u8-claude-code-2.1.207-candidate-smoke.json rename to docs/changes/20260710-journal-reliability-foundation/research/u8-claude-code-2.1.215-candidate-smoke.json index e83f638c7..81dea13a5 100644 --- a/docs/changes/20260710-journal-reliability-foundation/research/u8-claude-code-2.1.207-candidate-smoke.json +++ b/docs/changes/20260710-journal-reliability-foundation/research/u8-claude-code-2.1.215-candidate-smoke.json @@ -1,9 +1,9 @@ { "evidence_version": 2, - "timestamp": "2026-07-18T22:10:06.984Z", + "timestamp": "2026-07-19T14:24:39.196Z", "target": "claude-code", "surface": "cli", - "version": "2.1.207", + "version": "2.1.215", "platform": "darwin-arm64", "installed_mode": "plugin-dir", "context_mode": "startup", @@ -41,7 +41,7 @@ "stderr_empty": true, "model_visible_marker_observed": true, "assistant_marker_match": true, - "marker": "LOAF_U8_SMOKE_9D87567A9298", + "marker": "LOAF_U8_SMOKE_A274C3210AAF", "hook_observation": { "event_name": "SessionStart:startup", "native_json": true, @@ -52,6 +52,6 @@ "hooks_path": "plugins/loaf/hooks/hooks.json", "hooks_sha256": "5c7343a34af37d6c6e930981f71e405b8023975de4146822058f98c15251b7c2", "native_binary_path": "plugins/loaf/bin/native/darwin-arm64/loaf", - "native_binary_sha256": "5b76b90e835834bea3f4208cb8fa4e344951e43ab90f986837f09ac3d6eb2519" + "native_binary_sha256": "85f72b257a0655ff24ac5a46e666e5a5c88094db89c7b1afe9466016fa5f9069" } } diff --git a/docs/changes/20260710-journal-reliability-foundation/research/u8-codex-0.144.1-isolated-smoke.json b/docs/changes/20260710-journal-reliability-foundation/research/u8-codex-0.144.5-isolated-smoke.json similarity index 82% rename from docs/changes/20260710-journal-reliability-foundation/research/u8-codex-0.144.1-isolated-smoke.json rename to docs/changes/20260710-journal-reliability-foundation/research/u8-codex-0.144.5-isolated-smoke.json index 61fb2491a..1be707b64 100644 --- a/docs/changes/20260710-journal-reliability-foundation/research/u8-codex-0.144.1-isolated-smoke.json +++ b/docs/changes/20260710-journal-reliability-foundation/research/u8-codex-0.144.5-isolated-smoke.json @@ -1,9 +1,9 @@ { "evidence_version": 2, - "timestamp": "2026-07-18T22:10:30.596Z", + "timestamp": "2026-07-19T14:30:33.466Z", "target": "codex", "surface": "cli", - "version": "0.144.1", + "version": "0.144.5", "platform": "darwin-arm64", "installed_mode": "isolated-codex-home", "context_mode": "startup", @@ -32,14 +32,15 @@ "copy installed auth.json into isolated CODEX_HOME with mode 0600", "initialize absolute disposable LOAF_DB", "write random marker to isolated journal", - "observe hook stdout through a mode-0700 disposable wrapper and mode-0600 file" + "observe hook stdout through a mode-0700 disposable wrapper and mode-0600 file", + "disable Codex tracing logs (RUST_LOG=off) so backend transport noise cannot contaminate the stderr cleanliness gate" ], "exit_code": 0, "stderr_empty": false, "stderr": "Reading additional input from stdin...", "model_visible_marker_observed": true, "assistant_marker_match": true, - "marker": "LOAF_CODEX_U8_A72DEEEB44A4", + "marker": "LOAF_CODEX_U8_DC6AE6B35C2F", "hook_observation": { "event_name": "SessionStart:startup", "native_json": true, @@ -50,6 +51,6 @@ "hooks_path": "dist/codex/.codex/hooks.json", "hooks_sha256": "0f9e3feb7204c3309a7db6a02224ad881f6801a8cdd126ec6222e6d9a804c33d", "native_binary_path": "bin/native/darwin-arm64/loaf", - "native_binary_sha256": "5b76b90e835834bea3f4208cb8fa4e344951e43ab90f986837f09ac3d6eb2519" + "native_binary_sha256": "85f72b257a0655ff24ac5a46e666e5a5c88094db89c7b1afe9466016fa5f9069" } } diff --git a/docs/changes/20260710-journal-reliability-foundation/research/u8-opencode-1.17.18-isolated-smoke.json b/docs/changes/20260710-journal-reliability-foundation/research/u8-opencode-1.18.3-isolated-smoke.json similarity index 87% rename from docs/changes/20260710-journal-reliability-foundation/research/u8-opencode-1.17.18-isolated-smoke.json rename to docs/changes/20260710-journal-reliability-foundation/research/u8-opencode-1.18.3-isolated-smoke.json index a5b5a16bf..85e68878e 100644 --- a/docs/changes/20260710-journal-reliability-foundation/research/u8-opencode-1.17.18-isolated-smoke.json +++ b/docs/changes/20260710-journal-reliability-foundation/research/u8-opencode-1.18.3-isolated-smoke.json @@ -1,9 +1,9 @@ { "evidence_version": 2, - "timestamp": "2026-07-18T22:10:54.386Z", + "timestamp": "2026-07-19T14:24:54.207Z", "target": "opencode", "surface": "cli", - "version": "1.17.18", + "version": "1.18.3", "platform": "darwin-arm64", "installed_mode": "isolated-xdg", "context_mode": "request", @@ -39,11 +39,11 @@ "root_session_lookup_proven": true, "no_auth_supplied": true, "cleanup_succeeded": true, - "marker": "LOAF_OPENCODE_U8_33FE552B7792", + "marker": "LOAF_OPENCODE_U8_A87277CFB64C", "candidate_artifacts": { "hooks_path": "dist/opencode/plugins/hooks.ts", "hooks_sha256": "e0df59fe6777b3d096e9ad3cde82dbd62bcbaaece274910f36f7c4defd8359c4", "native_binary_path": "bin/native/darwin-arm64/loaf", - "native_binary_sha256": "5b76b90e835834bea3f4208cb8fa4e344951e43ab90f986837f09ac3d6eb2519" + "native_binary_sha256": "85f72b257a0655ff24ac5a46e666e5a5c88094db89c7b1afe9466016fa5f9069" } } diff --git a/docs/changes/20260717-intent-exploration-foundation/change.md b/docs/changes/20260717-intent-exploration-foundation/change.md new file mode 100644 index 000000000..60efcc50d --- /dev/null +++ b/docs/changes/20260717-intent-exploration-foundation/change.md @@ -0,0 +1,276 @@ +--- +change: intent-exploration-foundation +created: 2026-07-17 +branch: intent-exploration-foundation +lineage: change-model-hard-cut +predecessor: journal-reliability-foundation +--- + +# Intent and Exploration Foundation — Portable Context Without Session Lifecycle + +## Lineage + +- **Lineage:** `change-model-hard-cut`. +- **Position:** Immediate successor to `journal-reliability-foundation` and first materialized node of the 2026-07-17 workflow amendment. +- **Current accepted order:** `journal-reliability-foundation` → `intent-exploration-foundation` → `change-native-execution-migration` → `linear-native-coordination` → `spec-conversion-and-guidance-sweep`. +- **Activation evidence:** The predecessor is retained in current `main`; its journal, origin, lineage, and target-delivery foundations have landed; the workflow redesign was explored and approved before this Change was materialized. +- **Release gate:** The root lineage retains `release-after: spec-conversion-and-guidance-sweep`; this Change cannot enable a stable or normal-semver release before that terminal node lands. +- **Workspace rule:** This branch/worktree is the one active Change workspace for this lineage. Explorations and Intents remain project-scoped SQLite records and never allocate branches or worktrees themselves. + +### Immediate successor packet — `change-native-execution-migration` + +- **Purpose:** Promote a bounded Intent into a Git-canonical Change and mechanize the first durable write through one Change-dedicated branch/worktree, `change.md`, `plan.md`, an affected-artifact manifest, and an early draft PR. +- **Activation gate:** This Change is merged; additive migrations and the legacy-deferral bridge pass against current production-shaped fixtures; one Exploration is resumed through a second harness/context using only its portable checkpoint; one Intent is deferred and resumed without lifecycle drift; `/triage` and `/explore` dogfood produces no direct provider calls. +- **Inherited decisions:** `change.md` owns the Product Contract; `plan.md` owns named implementation units, dependencies, acceptance criteria, and verification; durable project artifacts remain Git-canonical; CLI primitives perform deterministic workspace and state operations while humans and Skills decide when to invoke them. +- **Required outputs:** Intent-to-Change promotion; branch/worktree allocation before the first durable write; Change-local materialization of relevant Exploration research/reports; draft-PR linkage; Change-native `/shape`, `/plan`, `/implement`, and `/ship`; path-based affected-artifact reconciliation; per-project migration of remaining legacy execution state. +- **No-gos:** No tracker synchronization, no direct MCP/provider logic in Skills, no numbered Task replacement, no shadow copies of canonical durable artifacts, no broad guidance hard cut, and no automatic semantic promotion of Intents. +- **Implementation evidence (2026-07-19):** The foundation shipped as schema 12 with composite same-project foreign keys, so cross-project references fail before commit. Shipped command surfaces for this packet to build on: `loaf intent create|defer|resume|resolve|show|list`, `loaf exploration create|checkpoint|list|context|conversation add`, `loaf conversation create|show|list|handle add|observe`, `loaf intake list`, `loaf state migrate deferrals --dry-run|--apply`, `loaf doctor --json`, and `loaf install --upgrade --dry-run --json`. Operation keys bind to exactly one aggregate — cross-binding retries are rejected explicitly rather than silently reusing another aggregate's first write — and the `journal defer` adapter materializes legacy projections from the stored canonical packet with an all-or-nothing version 0→1 advance. Journal continuity carries two layers this packet may consume directly: `deferred-intent` (canonical deferred Intents as active truth with legacy dedup through `intent_operations`) and `exploration-checkpoints` (latest portable checkpoint per Exploration with exact resume commands). Promotion should link the created Change to its Intent through the relationship registry's future `materialized-as` extension, which remains deliberately unregistered here. + +### Following successor packet — `linear-native-coordination` + +- **Purpose:** Add Linear as the native collaborative authority for published Intent, Change, and meaningful plan-unit workflow state while preserving Git as authority for durable artifacts and SQLite as the local operational/provenance layer. +- **Activation gate:** `change-native-execution-migration` is merged and its local-only workflow is dogfooded end to end; the provider-neutral Change and plan primitives are stable enough that the Linear adapter does not define their semantics. +- **Inherited decisions:** One primary issue per Change; meaningful named plan units become subissues; inbound issues may be adopted as Intent; tracker IDs are mappings, not intrinsic local identity; Skills request provider-neutral deterministic operations and never branch on `integrations.linear.enabled`. +- **Required outputs:** Publish/adopt/reconcile primitives, revision/content hashes, an outbox with idempotent retries, explicit conflict detection, issue/PR/Change links, subissue dependencies and acceptance checklists, and agent-from-Linear bootstrap into the Git-canonical Change. +- **No-gos:** No tracker-owned durable artifact bodies, no silent last-writer-wins conflict resolution, no provider payload archive, no Linear requirement for local-only projects, and no provider-specific workflow prose duplicated across Skills. + +### Terminal successor packet — `spec-conversion-and-guidance-sweep` + +- **Purpose:** Hard-remove public Spec/Task/render/breakdown authority, migrate Loaf's own remaining legacy records, supersede conflicting storage/workflow guidance, and converge live source, generated targets, installed surfaces, and release documentation around the completed workflow. +- **Activation gate:** Every preceding node is merged and freshly verified; the parked commit `59fbcdcf` is reconciled as historical working evidence rather than applied as current truth; the terminal Change materializes with `predecessor: linear-native-coordination`. +- **Inherited decisions:** Git owns Changes, plans, specs, ADRs, knowledge, reports, and code; SQLite owns operational state, journal, Intents, Explorations, checkpoints, provenance, relationships, handoffs/wraps, mappings, and derived indexes; Linear owns published collaborative work state when configured; `.agents/` remains tracked shared configuration plus explicitly disposable scratch, not a durable artifact home. +- **Required outputs:** Public command and Skill hard cut, deterministic migration/quarantine, ADR supersession where the reversal test warrants it, current-guidance grep gates, generated-target convergence, installed-surface smokes, and the stable-release gate. +- **No-gos:** No indefinite dual authority, no compatibility release that advertises retired commands, no rewriting historical Changes/ADRs to hide the old model, and no broad unrelated Skill-quality rewrite. + +## Problem + +Loaf can capture a Spark, retain an Idea, store a Brainstorm, append journal entries, and atomically preserve a deferred nugget as a journal-decision/Spark pair, but it cannot represent the body of inquiry that connects them. The workflow and artifact redesign behind this Change exists across one long Codex conversation, multiple compactions, scattered journal entries, prior research, and local repository evidence. There is no first-class Exploration to resume, no first-class Intent that means “this is tracked work worth returning to,” and no relational model connecting those concepts to their source conversations, checkpoints, handoffs, wraps, reports, Changes, or other Loaf entities. + +The absence is already causing vocabulary and ownership drift. Spark, Idea, pre-Change tracker issue, deferred work, and Change are forced to impersonate one another. `journal defer` retains only a four-field packet plus a Spark projection; a substantial exploration cannot be preserved as structured context. A scalar `harness_session_id` can correlate some journal entries but cannot express that one Exploration spans multiple conversations, session handles, harnesses, log fragments, or successor Changes. Machine-local IDs may aid forensic navigation but cannot make a fresh checkout or another machine self-sufficient. + +Trying to solve that with mutable `active`, `paused`, or `complete` records would repeat the Session model's failure: concurrent agents and harnesses cannot reliably maintain shared lifecycle flags. Putting authored artifacts back into SQLite would repeat the render/spec authority problem. Letting Skills choose storage, call Linear directly, or hand-roll worktree operations would repeat the current cross-harness inconsistency. The missing foundation is relational, append-only operational state plus small deterministic CLI primitives—not another workflow engine. + +## Hypothesis + +If Loaf introduces project-scoped Intent and Exploration identities, immutable portable checkpoints, normalized many-to-many conversation/log provenance, append-only Intent dispositions, validated typed relationships, and deterministic provider-neutral CLI operations, then an idea can become tracked, an exploration can survive compaction and harness changes, and substantial deferred work can be resumed without recreating a session lifecycle or requiring its original local logs. `/triage` and `/explore` can then become thinner and more predictable, while later Changes can materialize Git workspaces and Linear coordination without reinventing capture, provenance, or deferral. + +## Scope + +**In** + +- Add first-class project-scoped Intent and Exploration identities to SQLite through a strictly additive migration. Neither entity has a mutable lifecycle status. +- Represent an Intent's current disposition as a derived view over append-only events. The initial vocabulary is `tracked`, `deferred`, and `resolved`; resuming appends a new `tracked` disposition linked to the deferral it supersedes. Every append receives a transactionally allocated immutable per-Intent sequence with a uniqueness constraint, so concurrent commits have one total order independent of wall-clock timestamps. +- Preserve every deferral as an immutable self-sufficient snapshot containing the retained proposition/body, why it matters, the boundary that excluded it, the future trigger or review condition, the operation key, and source relationships. +- Add one canonical `intent_operations` mapping shared by `intent create`, `intent defer`, the transitional `journal defer` adapter, and legacy conversion. Each project-scoped operation key resolves to one Intent and stored digest, with optional historical journal/Spark projection IDs and a projection version, so retries and compatibility paths cannot mint parallel canonical records. A canonical-first write records projection version 0 with null legacy IDs; a later `journal defer` deterministically materializes both legacy projections from the stored canonical packet and advances the mapping to version 1 in one transaction. +- Represent Exploration continuity through immutable checkpoints with four required portable fields—purpose/current framing, conclusions or constraints so far, unresolved question or decision, and recommended next action—plus optional typed items such as candidates/evidence and explicit ordering. Each core field is trimmed, non-empty, and capped at 4096 UTF-8 bytes; oversize input fails with a stable field-specific error and is never truncated. Every append receives a transactionally allocated immutable per-Exploration sequence. An early Exploration may exist without a portable checkpoint and must report that honestly; a new conversation continues it by adding a checkpoint or source relationship, not by toggling lifecycle state. +- Normalize logical conversations separately from machine/harness-local handles and log references so one conversation may carry multiple opaque IDs and one Exploration may span many conversations and harnesses. Store locators, hashes, and bounded ranges—not raw transcript bodies. Availability is either probed ephemerally or appended as a timestamped/locality-scoped observation; it is never a mutable property of the handle/log reference. +- Relate the new entities through a closed entity-kind and relationship-type registry with project and endpoint validation. This Change's required matrix is bounded to Intent, Exploration, checkpoint, logical conversation, Spark, Idea, Brainstorm, journal entry (including wrap), handoff, report, and finding; Git Change references and `materialized-as` edges begin in `change-native-execution-migration` when the target exists and its authority is defined. +- Add focused deterministic `loaf intent`, `loaf exploration`, `loaf conversation`, and `loaf intake` primitives to create, inspect, list, relate, defer, resume, resolve, checkpoint, attach provenance, and project portable Exploration context. Commands execute an explicitly requested operation and report facts; they do not decide which concept applies. +- Bound every collection in `exploration context` independently. The portable checkpoint core is always returned whole; optional items, sources, relationships, and evidence report available/shown counts, truncation, a stable next cursor, and the exact layer-expansion command. +- Add a deterministic local intake projection spanning open Sparks, Ideas, Brainstorms, unresolved Intents, and legacy deferrals without semantically promoting any item. +- Replace deferred-Spark-only continuity with bounded derived Intent/Exploration projections: unresolved deferred Intent is active truth, and recent portable Exploration checkpoints are discoverable with counts and exact expansion/context commands rather than silently flooding startup context. +- Make new Intent deferral atomic and retry-safe. Keep `journal defer` as a transitional compatibility adapter that cannot create a new legacy-only deferral after the new model exists. +- Provide a project-scoped dry-run/manifest/apply conversion for existing `journal_deferrals`, protected by a verified whole-database backup and a project-specific manifest; link each converted Intent to its historical decision and Spark, preserve legacy rows, report unparseable records, and make retries idempotent. +- Introduce the user-facing `/explore` workflow, preserve the full Brainstorm stance as an internal technique, and narrowly converge `/triage`, Idea, and Spark guidance on the new concepts. Generated target output and routing evaluations are part of the same vertical change. +- Evolve the non-user-invocable `loaf-reference` Skill into the config-aware maintenance operator for the in-scope rollout. It consumes project intent through `loaf config check --json`, combines that with observed package, installed-target, SQLite, and checkout facts, discovers exact syntax from live CLI help, asks only for missing project-owned choices, sequences approved deterministic operations, and verifies convergence instead of hardcoding a version-specific recipe. +- Add the non-mutating machine surfaces that maintenance requires for this Change: JSON project-alignment diagnosis from `loaf doctor` and dry-run/JSON planning for `loaf install --upgrade`. The plan reports selected installed targets, owned changes, preserved conflicts, deprecation actions, and project-file effects without mutating either the checkout or harness homes; apply remains an explicit existing install operation. +- Document the proven authority boundary in current architecture/schema guidance after implementation: operational Intent/Exploration state in SQLite, authored durable artifacts in Git, and no tracker authority until the later coordination Change. + +**Out** (deferred, not rejected) + +- Intent-to-Change promotion, branch/worktree creation, `change.md`/`plan.md` scaffolding, draft PR creation, artifact-effect reconciliation, and Change-native implementation/ship behavior — owned by `change-native-execution-migration`. +- Linear or GitHub issue adoption/publication, remote IDs and revisions, polling, synchronization, outbox/retry/conflict behavior, tracker-backed intake, issue completion policy, and agent-from-tracker bootstrap — owned by `linear-native-coordination`. +- `/plan`, `/implement`, `/ship`, `/release`, `/reflect`, and `/housekeeping` convergence; public `/breakdown`, Spec, Task, and render removal; broad README/strategy/guidance cleanup — owned by `spec-conversion-and-guidance-sweep` unless an earlier successor must touch a narrow consumer for correctness. +- A top-level `loaf upgrade` coordinator, package-manager mutation/self-update, fleet-wide or all-project upgrade sweeps, and a user-invocable `/maintain` Skill. Dogfood the hidden config-aware `loaf-reference` protocol and deterministic plan surfaces first; a later Change may add a thin public ceremony if repeated use justifies it. +- Cross-worktree activity scanning, automatic sibling-worktree notices, work claiming, portfolio/program views, or a mutable worktree registry. +- Rewriting handoff or wrap semantics. They remain SQLite/journal snapshots and may be related to an Exploration through the generic relationship surface. +- Copying, ingesting, indexing, or synchronizing raw prompts, transcripts, tool output, provider payloads, or arbitrary external documents. +- Cross-device state synchronization or any guarantee that a machine-local conversation/log locator remains navigable elsewhere. + +**Cut** (explicitly rejected) + +- `intent.status`, `exploration.status`, `active_exploration`, pause/complete lifecycle transitions, or a globally current conversation/session/worktree. +- Treating “resume Exploration” as a mutable lifecycle operation; resumption means reading portable context and appending new evidence/checkpoints. +- Automatically converting every Spark, Idea, Brainstorm, issue, or journal entry into an Intent. +- Storing Linear/GitHub fields or backend-selection flags on Intent/Exploration rows before the tracker Change defines synchronization authority. +- Arbitrary polymorphic relationship strings without a closed registry and endpoint validation. +- A second canonical body store for Changes, plans, specs, ADRs, knowledge, reports, or code. +- Direct MCP/provider calls, storage-mode branches, Git/worktree shell recipes, or semantic classification logic inside Skills. +- CLI heuristics that decide whether input is a Spark, Idea, Intent, Exploration, deferral, or Change. The caller supplies the judgment; the CLI validates and performs it. +- Durable authored artifacts under `.agents/`, other than the existing tracked shared Loaf configuration; disposable scratch remains explicitly non-authoritative. + +## Observable Workflow + +### Track a considered direction + +```text +conversation or triage judgment: this is worth tracking + → loaf intent create --title <title> --body <self-sufficient body> --from <source>... + → CLI validates source endpoints, writes one Intent snapshot plus tracked disposition, and returns stable local identity + → no branch, worktree, Change folder, tracker issue, or journal prose is invented +``` + +### Explore across conversations and harnesses + +```text +$ loaf exploration create --title "Workflow and artifact model" --from <intent-or-source>... +$ loaf exploration checkpoint <exploration> --purpose <current framing> --conclusions <constraints or conclusions> --unresolved <question or decision> --next <recommended action> [--item candidate:<...> --item evidence:<...>] +$ loaf conversation create --title <label> --operation-id <key> +$ loaf conversation handle add <conversation> --harness codex --handle <opaque-local-id> [--locality <machine-or-namespace> --log-ref <locator> --hash <digest> --range <range>] +$ loaf exploration conversation add <exploration> <conversation> + +another conversation or harness later: +$ loaf exploration context <exploration> --json + → portable checkpoint present: true + → complete portable core plus bounded summaries of ordered items, linked Intents/entities, journal/wrap/handoff evidence, conversations, and source locators + → every optional layer reports available/shown counts, truncation, a stable next cursor, and the exact `--layer <name> --cursor <cursor> --limit <n>` expansion command + → source handles and log locators shown separately with locality and ephemeral/latest observed availability; their presence never implies portable context +``` + +### Defer substantial work at any stage + +```text +human or Skill decides this coherent body belongs later + → existing Intent: loaf intent defer <intent-ref> --why <why> --boundary <what excluded it> --trigger <when to revisit> --operation-id <key> + → new deferred Intent: loaf intent create --title <title> --body <self-sufficient body> --disposition deferred --why <why> --boundary <what excluded it> --trigger <when to revisit> --operation-id <key> --from <source>... + → one serializable transaction consults/writes the shared operation mapping and writes or returns exactly one Intent snapshot, deferred disposition, immutable deferral payload, and validated source relationships + → response-loss retry with the same key returns the first write and reports digest equality/mismatch + +later: + → loaf intent resume <intent> --reason <why now> + → CLI appends a tracked disposition linked to the prior deferral; history is never overwritten +``` + +### Triage local intake + +```text +$ loaf intake list --json + → deterministic projection of unresolved Sparks, Ideas, Brainstorms, Intents, and legacy deferrals + → provenance, current derived disposition, duplicate/source relationships, and exact follow-up read commands + +/triage chooses: discard, retain as capture, track as Intent, research, explore, resume, resolve, or later publish +``` + +## Rabbit Holes and No-Gos + +- Do not design the eventual Linear state machine in this foundation. Absence of provider fields is deliberate; later mappings bind stable local identity to remote authority. +- Do not turn an Exploration into a notebook/document platform. Portable checkpoints are curated semantic context, not a mirror of every message or artifact. +- Do not make “relates to everything” mean “accepts any string.” Centralized kind/type registration and endpoint validation are required to prevent relationship entropy. +- Do not create mutable convenience fields that can disagree with append-only dispositions or checkpoints. Derived reads may cache only with source hashes/watermarks and self-invalidation. +- Do not let compatibility with `journal defer` keep the journal-decision/Spark pair authoritative. The bridge is temporary, explicit, and covered by the terminal removal gate. +- Do not broaden this Change into Change materialization merely because this Change itself is dogfooding `change.md` plus `plan.md`; that belongs to the next successor. +- Do not rename every workflow Skill now. `/explore` is deliberately provisional; dogfood behavior before the terminal naming/routing sweep. +- Do not repair the known global journal-search divergence as an incidental side effect. New migration/tests use isolated state; repair of real state remains a separately consented operator action. + +## Decisions + +Provenance: Decisions 1–12 were accepted through the 2026-07-15–17 workflow exploration, explicit user adjudication, current source/state inspection, and prior comparative research. Decision 13 and the ordering/portability/availability refinements were accepted through three independent read-only convergence critiques of the shaped packet. Decision 14 was accepted while dogfooding the alpha.8 upgrade/install path and revisiting the existing `loaf-reference` maintenance boundary. The current Change records the accepted delivery-scoped ledger; no new ADR is justified before dogfood proves which constraints remain cross-Change and load-bearing. + +1. **Intent is a first-class backend-neutral concept, distinct from Spark, Idea, Exploration, issue, and Change.** Spark captures an interruption; Idea retains a proposition; Intent means work is deliberately tracked; Exploration develops understanding; an issue may later publish/adopt Intent; Change is bounded delivery. +2. **Deferred is a disposition of Intent, not another entity or workflow stage.** The stage that discovered it is provenance; the retained body, rationale, boundary, and trigger are the portable contract. +3. **Exploration is a relational inquiry aggregate, not a session.** It spans conversations, harnesses, Intents, and evidence through immutable checkpoints and relations, with no active/paused/done status; later materialization may relate it to a Change without changing this model. +4. **Portable context and local provenance are separate claims.** A checkpoint must be sufficient to resume elsewhere; a harness session ID or log locator is optional machine-local evidence and never satisfies portability by itself. +5. **SQLite uses append-only, totally ordered facts to avoid staleness.** Current Intent disposition and latest Exploration context are derived from transactionally sequenced immutable records rather than timestamps; no agent must maintain shared lifecycle flags after every conversation. +6. **The CLI is a deterministic mechanism layer.** It validates, persists, queries, links, migrates, checks, and serves hooks; humans and Skills interpret input, make recommendations, choose the operation, and author prose. +7. **`/triage` is the public intake funnel and `/explore` is the divergent workflow.** Idea and Spark remain capture primitives reachable from natural conversation; Brainstorm's full stance survives internally inside Explore; `scout`, prototype, and spike remain subordinate techniques rather than competing lifecycle owners. +8. **The foundation is local and provider-neutral.** Local tracked Intent is canonical in SQLite until a later tracker Change publishes/adopts it; no Skill contains a Linear-enabled branch or direct provider call. +9. **Relationships are broad but typed and validated.** New entity kinds integrate with one centralized registry used by create/link/trace/export/delete/doctor surfaces; free-form dangling graph edges are rejected. +10. **Legacy deferrals converge without destructive rewriting.** Existing journal decisions and Sparks remain historical evidence; deterministic conversion adds canonical Intent links and migration markers, and the old command becomes a bridge until the terminal cut. +11. **A Change is self-sufficient in a fresh checkout, not detached from the repository.** This Change materializes the relevant exploration synthesis under `research/`; raw local conversation handles remain provenance, while future Changes continue editing canonical durable artifacts in place and record their affected paths. +12. **The hard-cut lineage expands to five nodes.** The original three-node decision was correct for the earlier model; the approved workflow redesign adds this foundation and Linear-native coordination so execution and final convergence do not invent those contracts during migration/removal. +13. **The initial relationship matrix is deliberately bounded.** The registry supports future extension, but this Change proves only the edges required to connect capture, Intent, Exploration, portable checkpoints/conversations, journal/wrap/handoff evidence, and reports/findings. Git Change origin/materialization belongs to the next Change; unsupported legacy pairings remain explicit future extensions rather than an accidental whole-state-plane refactor. +14. **Loaf maintenance is a hidden config-aware Skill over deterministic observed-state plans.** `.agents/loaf.json` records shared project intent, not machine-specific harness installation state. The CLI diagnoses and plans from config plus actual local state; `loaf-reference` interprets those facts, asks for missing choices, invokes approved operations, and proves convergence. This Change strengthens that existing hidden Skill rather than adding `/maintain`, and it does not let Loaf replace the package manager that owns the executable. + +## Planning Contract + +The authoritative implementation route is [plan.md](plan.md). This compatibility section exists because the current `loaf change check` still expects planning inside `change.md`; this Change deliberately dogfoods the accepted separation where `change.md` owns the Product Contract and `plan.md` owns the transition from current to desired state. + +## Implementation Units + +Named units, dependencies, per-unit acceptance criteria, sequencing, rollback, and verification live in [plan.md](plan.md#units-of-work). Units use stable slugs rather than `U1`/`TASK-*` identifiers and are not separately persisted workflow entities. + +## Verification Contract + +Executable: + +- **Schema convergence.** Migration fixtures beginning at schema 9, explicit journal-first schema 10, and current schema 11 converge to the new additive schema without altering prior migration checksums; interrupted/failed application leaves no partial tables or records. +- **No lifecycle status.** Schema and current-source grep gates reject mutable status/current-session/current-exploration fields for the new entities; derived disposition/context tests rely only on ordered immutable facts. +- **Intent atomicity and ordering.** Create/defer/resume/resolve operations validate required content and project ownership; failure injection at every transactional stage leaves no partial snapshot, disposition, deferral, alias, relationship, compatibility projection, or migration marker. Concurrent conflicting appends allocate unique immutable per-Intent sequences, and derived disposition follows the greatest committed sequence rather than timestamp. +- **Retry convergence.** Concurrent identical and reworded deferral calls with one operation key return one first-written Intent/deferral; `intent create --disposition deferred`, `intent defer`, `journal defer`, and legacy conversion all consult the same canonical operation mapping; output exposes stored/input digests and whether they match without duplicating or rejecting response-loss recovery. Tests cover canonical-first, adapter-first, and concurrent mixed entry points. When the canonical write is first, a later adapter call creates the missing journal/Spark pair from stored canonical content, atomically advances projection version 0→1, returns both IDs, and reports whether the adapter input digest matched. +- **Exploration portability, ordering, and boundedness.** Context fixtures span multiple logical conversations, harness handles, log references, journal entries, handoffs, wraps, and Intents; stable output returns the greatest committed per-Exploration checkpoint sequence even when every local handle/log is unavailable. A checkpoint is portable only when all four required fields are non-empty and within their 4096-byte per-field limits; the fixture asserts that `recommended_next_action` survives byte-for-byte with every source locator unavailable, and oversized core input fails without a partial write. Every optional layer is bounded and cursor-expandable while the intrinsically bounded portable core remains whole. +- **Provenance honesty.** A source handle without a valid portable checkpoint reports `portable_context_present=false`; absent harness/session/log fields remain absent rather than fabricated; availability is an ephemeral probe or immutable timestamped observation rather than a mutable handle field; raw transcript/tool/provider bodies never enter state fixtures. +- **Graph integrity.** Every pairing in the bounded initial relationship matrix participates in link, trace, show, export, project deletion, backup/restore, doctor/status, and search/index parity; invalid kinds/types, unsupported pairings, dangling endpoints, duplicates, and cross-project edges fail visibly. +- **Local intake.** Deterministic intake fixtures project each logical unresolved item once, preserve source/duplicate relations, expose legacy deferrals before conversion, and show the converted Intent exactly once afterward without making a semantic disposition. +- **Continuity projection.** Journal context fixtures show unresolved deferred Intent independently of recency, expose bounded recent portable Exploration checkpoints with counts/truncation/expansion commands, deduplicate legacy Spark projections after conversion, and never treat a local handle as resumable context. +- **Legacy bridge.** Dry-run is byte-for-byte non-mutating; apply verifies a whole-database backup, emits a project-specific complete manifest, populates the shared operation mapping, converts parseable historical deferrals idempotently, reports unparseable packets without guessing, preserves old rows, and prevents all new `journal defer` calls from remaining legacy-only. +- **Skill boundary.** Routing and content gates prove `/triage` chooses dispositions, `/explore` creates/resumes Exploration context, Brainstorm remains an internal technique, Idea/Spark remain captures, and no in-scope current or generated Skill contains direct tracker calls, backend selection, worktree recipes, or CLI-owned semantic judgment. +- **Maintenance planning.** `loaf doctor --json` is fact-equivalent to human diagnosis and never mutates; `loaf install --upgrade --dry-run --json` produces a deterministic complete plan and leaves checkout, harness homes, ownership manifests, config, and SQLite byte-for-byte unchanged. Fixtures cover current and behind-schema state, recognized installed targets, absent targets, owned stale content, locally modified/unowned content, deprecation cleanup with and without explicit consent, and the new Intent/Exploration commands/reference metadata. +- **Maintenance Skill.** Routing/content evaluations prove requests to upgrade, diagnose, repair, configure, or bring a project current select the hidden `loaf-reference` protocol; it reads CLI JSON/actions and live help, does not infer machine-local target intent from `.agents/loaf.json`, does not claim a newer remote version without package-manager evidence, and does not introduce a public `/maintain` or hardcoded binary path. +- **Repository gates.** `go test ./...`, `npm run typecheck`, `npm run test`, `npm run build`, `loaf change check --require-executable docs/changes/20260717-intent-exploration-foundation`, relevant explicit hook checks, generated-target verification, schema-doc parity, `git diff --check`, and focused source/generated gates for the in-scope Skills all pass using isolated absolute `LOAF_DB` paths for disposable tests. The full retired-vocabulary allowlist remains exclusively terminal work. + +Human review: + +- A fresh agent can read the Git-tracked Change/research packet and understand why Intent and Exploration exist without access to this conversation or the local SQLite database. +- A fresh harness can resume an Exploration from its portable checkpoint while clearly seeing which optional source handles are unavailable locally; a blind fresh-agent exercise identifies the intended next action without consulting the source conversation. +- Triage distinguishes capture, tracked intent, deferred disposition, inquiry, and bounded delivery without forcing a tracker or creating branches prematurely. +- The CLI surface contains only deterministic operations and factual projections; all semantic classification and scope judgment remains in humans/Skills. +- The Change remains bounded before Change materialization and tracker coordination, and every deferred capability has a self-sufficient successor packet. + +## Definition of Done + +- Intent, append-only dispositions, immutable deferrals, Exploration checkpoints/items, logical conversations, local handles/log references, and validated relationships are implemented as project-scoped SQLite state with documented authority. +- `loaf intent`, `loaf exploration`, `loaf conversation`, and the local intake read surface provide deterministic human/JSON contracts with idempotent writes, bounded reads, and no mutable workflow lifecycle. +- Project continuity surfaces unresolved deferred Intent and discoverable portable Exploration checkpoints without depending on a mutable current-Exploration pointer. +- Existing `journal_deferrals` are discoverable before migration and can be converted through a project-scoped, manifest-driven, rerunnable path protected by a verified whole-database backup without deleting historical evidence. +- `/explore`, `/triage`, Idea, Spark, Brainstorm, config-aware Loaf reference guidance, routing evaluations, generated targets, and installed-surface fixtures agree on one vocabulary and one CLI/Skill boundary; maintenance diagnostics and upgrade plans cover the new commands, migration, and installed artifacts without adding another public Skill. +- Cross-conversation/harness dogfood proves portable context survives loss of local locators; concurrent Intent/deferral dogfood proves append-only state does not go stale. +- All executable and human gates pass; actual durable-artifact effects are reconciled against the final Git diff; successor packets are updated with implementation evidence before Ship. + +## Durable Artifact Changes + +Expected paths are recorded now and reconciled against the actual Git diff at Ship; `add`, `modify`, and `remove` describe intended effects rather than shadow copies. + +- `docs/changes/20260717-intent-exploration-foundation/change.md` — **add** the Product Contract and successor packets. +- `docs/changes/20260717-intent-exploration-foundation/plan.md` — **add** the implementation route, named units, dependencies, and gates. +- `docs/changes/20260717-intent-exploration-foundation/research/workflow-convergence.md` — **add** the portable synthesis of this multi-conversation exploration and current-source audit. +- `docs/changes/20260710-journal-reliability-foundation/change.md` — **modify** only its living successor protocol and forward-looking gates; preserve shipped implementation claims and original decisions as history. +- `internal/state/migrations/0012_intents_and_explorations.sql`, state APIs/tests, schema upgrade/export/delete/status/trace/search surfaces — **add/modify** the relational operational model after implementation proves the physical layout. +- `internal/cli/` command registration, parsers, JSON contracts, help/reference generation, install-upgrade planning, doctor diagnostics, tests, and safe-command classification — **add/modify** deterministic Intent/Exploration/intake/migration and config-aware maintenance primitives. +- `content/skills/{triage,idea,brainstorm,explore,loaf-reference}/` (spark remains a CLI capture primitive carried by idea and triage guidance), including a config-aware maintenance protocol/reference, routing evaluations, shared templates/references, `config/hooks.yaml` only where deterministic provenance capture requires it, and every tracked generated target — **add/modify** the focused workflow and maintenance surface. +- `docs/ARCHITECTURE.md`, `docs/schema/`, and narrowly affected current knowledge — **modify/add** only behavior proven by implementation; broad Spec/Task/render/ADR convergence remains terminal work. +- No canonical spec, ADR, or knowledge body is copied into SQLite or duplicated under this Change. A later reflect pass may distill an ADR only if the implemented constraint passes the reversal test across Changes. + +## Open Questions + +- [KU] Which existing `artifact_bodies`/search primitives can be reused for Intent snapshots and checkpoints without inheriting `artifact_entities.go` lifecycle assumptions? → `establish-relational-foundation` source spike; preserve immutable-record and parity constraints regardless of physical layout. +- [KU] What exact compatibility projection should `journal defer` return after Intent becomes canonical without breaking installed callers or producing a legacy-only write? → `make-intent-operations-atomic` fixture-led adapter design; terminal removal remains mandatory. +- [KU] How should a logical conversation be explicitly associated with multiple harness handles when no target exposes a trustworthy parent/conversation identity? → `make-exploration-portable` accepts explicit association and absent fields; never infer from branch, worktree, or recency. +- [UK] Does `/explore` remain the clearest public name after real dogfood, or would a unique name such as a wayfinding metaphor route better across harnesses? → retain `/explore` provisionally and evaluate in the terminal routing/naming sweep. + +## Critique Gate + +- **Did independent critique change the model?** Yes. It rejected mutable Intent/Exploration statuses, made disposition/checkpoint facts append-only, separated logical conversations from local handles, required a closed relationship registry, and tightened the foundation away from Change workspaces and trackers. +- **Is Intent another task entity?** No. It carries tracked direction and disposition, not execution assignment, estimates, dependencies, or implementation status. Plan units remain Git-canonical in a later Change and tracker mappings remain later still. +- **Is Exploration another session entity?** No. It has no active owner, pause, completion, or automatic current pointer. It is an identity over immutable checkpoints and relationships; concurrent conversations append without coordinating lifecycle flags. +- **Can SQLite still go stale?** Derived views can be rebuilt from append-only facts; compatibility writes and data migration are transactional/idempotent; cached projections require source hashes/watermarks. External tracker drift is deliberately absent until the coordination Change defines reconciliation. +- **Is the CLI deciding workflow?** No. It exposes deterministic verbs and validates explicit caller intent. Triage/Explore/humans decide classification, scope, and disposition. +- **Is config-aware maintenance scope creep?** It would be if this Change added package-manager control, fleet orchestration, a public workflow, or new install ownership semantics. The accepted unit is limited to making the new commands, migration, generated Skills, and project guidance diagnosable and safely previewable through existing ownership/apply contracts. If implementation cannot share the existing doctor/install analysis without a broader installer redesign, that redesign becomes deferred Intent rather than expanding this foundation. +- **Is the Change self-sufficient?** Yes. The Product Contract, plan, research synthesis, source IDs, authority boundary, successor packets, and affected paths live in Git. Local handles enrich provenance but are unnecessary to understand or implement the Change. +- **Could this be smaller?** Removing Intent would leave deferment and tracked work conflated with Spark/Idea; removing Exploration would leave the source continuity problem unsolved; adding workspace or tracker work would exceed the hypothesis. The selected boundary is the smallest relational substrate that supports both local continuity and later promotion. + +## Source Inputs + +- `research/workflow-convergence.md` — portable synthesis of the prior OpenSpec/compound-engineering/Matt Pocock/NotebookLM review, current source/state audit, authority model, Skill simplification, and successor decomposition. +- Codex desktop thread `019f62e6-88d2-7630-96ce-527652fd9a0b` — machine-local source conversation spanning multiple compactions; retained as provenance, not required context. +- `journal:0036cd92bc4412bbe6c0c4c3` and `journal:3ab77620fc36c7100788901b` — accepted five-node lineage amendment and final shaped Intent/Exploration packet. +- `journal:a7f6ccf3c9d2ed8c6b05d9f1` — validation discovery that the current CLI requires explicit `loaf check --hook <id>` leaves rather than the documented bare aggregate command. +- `journal:50205f69f32e2aedc14bf6eb` and `journal:a324913596ab3cd649235344` — accepted first-class Exploration and normalized many-to-many source provenance. +- `journal:7fd96405e8b50b4fdbde57a0`, `journal:8aa7a6e47d958d454fc66b24`, and `journal:28a3a2e2689f8e6e36860499` — Change portability, machine-local handle boundary, and locator/hash/range storage. +- `journal:087d2ce3294618229858f140` — accepted sequence: Exploration continuity and materialization before rich deferral/workflow cleanup. +- `journal:56e7af05c430a63df2047ddb` — backend-neutral Intent with deferred as its disposition. +- `journal:e3c0c830df1fcea2d925d52d` and `journal:b58088cb742a3474c0010b01` — deterministic CLI/judgment-bearing Skill boundary and holistic workflow convergence. +- `journal:612e9b835366823dbd843c23` — Idea/Spark distinction and Triage as the public intake surface. +- `journal:e2ce66d639fbb3c04a7a89b1` and `journal:dcd10ffd88692bdd6b3d0b06` — `change.md`/`plan.md` authority split and direct affected-artifact recording. +- `journal:3274f922f1cc09e24d80cd12` and `journal:fa885c8d627e27e407ea86cb` — later worktree mechanization and Linear/Git authority boundary. +- `docs/changes/20260710-journal-reliability-foundation/change.md` — predecessor reliability contract, lineage authority, journal-origin envelope, atomic deferral evidence, and parked-terminal boundary. +- Current implementation under `internal/state/{migrations/0001_initial.sql,migrations/0011_journal_origins_and_deferrals.sql,journal_defer.go,trace.go,artifact_entities.go,schema.go}`, `internal/cli/{change.go,cli.go,journal.go}`, `content/skills/{triage,idea,brainstorm,shape,implement}/`, `config/hooks.yaml`, and current generated targets. diff --git a/docs/changes/20260717-intent-exploration-foundation/plan.md b/docs/changes/20260717-intent-exploration-foundation/plan.md new file mode 100644 index 000000000..aeb32e49f --- /dev/null +++ b/docs/changes/20260717-intent-exploration-foundation/plan.md @@ -0,0 +1,256 @@ +# Intent and Exploration Foundation Plan + +## Contract + +This plan moves Loaf from scattered capture records and journal/Spark deferrals to the bounded product contract in [change.md](change.md). The Change defines what must become true; this file owns implementation order, dependencies, review boundaries, rollback, and verification. Unit slugs are stable local handles and potential commit/review boundaries, not numbered Tasks or mutable workflow entities. + +## Starting State + +- Schema 11 has Ideas, Sparks, Brainstorms, generic aliases/relationships/bodies, journal origins, and `journal_deferrals`, but no Intent, Exploration, logical conversation, immutable checkpoint, or portable context aggregate. +- `journal defer` atomically writes a journal decision and open Spark keyed by an operation ID. Its four-field packet and retry behavior are useful implementation evidence, but the pair is not the target authority. +- `relationships` accepts polymorphic endpoints but entity-kind support is distributed across fixed switch/list sites; endpoint and relationship semantics can drift. +- Journal rows may carry one opaque `harness_session_id` and normalized origin fields, but Loaf cannot group multiple handles/logs under a logical conversation or attach many conversations to one Exploration. +- `/triage`, Idea, Brainstorm, Shape, Implement, orchestration, and Linear guidance still encode overlapping legacy workflow ownership. This Change updates only the intake/exploration/capture slice needed to consume the new state. +- The global journal search index is known to diverge from canonical rows. Implementation and migration tests use isolated absolute `LOAF_DB` paths; this Change must not repair real global state implicitly. + +## Implementation Invariants + +- New state is additive, project-scoped, and transactionally verified. Explicit project-scoped data conversion requires a project-specific dry-run manifest plus a verified whole-database backup because the existing recovery operation restores the global database as one unit. +- Intent disposition and Exploration continuity are derived from immutable facts with a transactionally allocated per-aggregate sequence and uniqueness constraint; wall-clock time never decides between concurrent appends, and no mutable lifecycle status or global-current pointer is introduced. +- Every retry-safe compound write uses a caller-supplied/generated operation key, returns the first write after response loss, and reports semantic digest drift. +- Portable checkpoint content remains useful after every conversation handle, log locator, branch, worktree, and source artifact disappears. A valid portable checkpoint requires purpose/current framing, conclusions or constraints, unresolved question or decision, and recommended next action. +- Conversation handles and logs are bounded provenance records, not transcript storage and not proof that context is portable. Availability is ephemeral probe output or an immutable timestamped/locality-scoped observation, never a mutable handle property. +- Entity kinds and relationship types are centrally registered; both endpoints must exist in the same project. Git Change path references and materialization edges begin in `change-native-execution-migration`, where their validation and authority can be defined with the target workspace model. +- Skills choose semantic operations. CLI/state code validates and performs them deterministically. Hooks may supply observable provenance but never semantic classification. +- Shared project intent comes from `.agents/loaf.json`; package provenance, installed harness targets, ownership manifests, SQLite readiness, and checkout alignment are observed local facts. The hidden `loaf-reference` Skill may interpret both, but it consumes CLI diagnostics/actions instead of duplicating config parsing, target detection, or mutation logic. +- Legacy compatibility cannot create state invisible to the new model after the cutover unit lands. + +## Units of Work + +| Unit | Delivers | Depends on | +|------|----------|------------| +| `establish-relational-foundation` | Additive schema, registries, immutable fact model, upgrade/parity fixtures | — | +| `make-intent-operations-atomic` | Intent reads/writes, append-only dispositions, rich deferral, legacy command bridge | `establish-relational-foundation` | +| `make-exploration-portable` | Logical conversations, handles/log refs, checkpoints/items, portable context | `establish-relational-foundation` | +| `connect-intake-and-trace` | Validated relationships, trace/export/delete/status integration, local intake projection | `make-intent-operations-atomic`, `make-exploration-portable` | +| `make-loaf-maintenance-config-aware` | Hidden maintenance protocol plus non-mutating doctor/install-upgrade plans covering the new release surface | `connect-intake-and-trace` | +| `converge-explore-and-triage` | `/explore`, Triage/capture vocabulary, deterministic CLI use, generated/routing convergence | `make-loaf-maintenance-config-aware` | +| `migrate-and-dogfood-intent-model` | Manifest-driven legacy conversion, failure recovery, cross-context dogfood, final docs/effect reconciliation | all preceding units | + +### `establish-relational-foundation` + +**Deliverable:** One additive schema migration and one centralized entity/relationship contract that every downstream surface can use without lifecycle abstractions. + +**Implementation:** + +- Add `0012_intents_and_explorations.sql` and register it after schema 11 without changing prior SQL/checksums or the explicit schema-10 journal-first ceremony. +- Represent stable Intent/Exploration identity separately from immutable content/history. The physical layout may reuse generic body/search primitives only if it preserves immutable revisions/checkpoints and does not pass through `artifact_entities.go` status semantics. +- Store append-only Intent snapshots/dispositions and immutable deferral payloads. Enforce one project-scoped operation key per compound mutation and checksums for self-sufficient content. +- Add the canonical `intent_operations` contract with `(project_id, operation_key)` as its unique identity, `intent_id` and `stored_digest` as required canonical results, nullable `journal_entry_id`/`spark_id` historical projection references, `projection_version`, and `created_at`. Projection version 0 requires both legacy IDs to be null; version 1 requires both to be non-null. `intent create`, `intent defer`, `journal defer`, and legacy conversion must consult and populate this same mapping inside their canonical transaction. +- Allocate immutable per-Intent and per-Exploration append sequences transactionally with unique aggregate/sequence constraints. Reads derive “latest” from the greatest committed sequence, not timestamp or lexical ID. +- Store Exploration checkpoints with four required portable fields and ordered typed checkpoint items. Trim each core field and cap it at 4096 UTF-8 bytes; reject overflow with a stable field-specific error and no partial write. The database must distinguish a structurally valid portable checkpoint from an early Exploration with only optional source references. Larger detail belongs in bounded checkpoint items or related evidence/report references, or in a later checkpoint; no read or write path truncates the core. +- Normalize logical conversations, harness/machine-local handles, bounded log references, Exploration↔conversation membership, and journal↔conversation-handle association. Do not persist raw transcript/tool/provider bodies. +- Centralize entity-kind and relationship-type registration used by alias resolution, link creation, trace details, export, deletion, mapping audits, state status/doctor, and test fixtures. Retain existing supported legacy kinds through explicit registration rather than a broad compatibility wildcard. +- Store source availability only as ephemeral probe results or immutable observations carrying observed time, observer/locality, and result; do not update handle/log rows when reachability changes. +- Add constraints/indexes for project scope, sequence allocation, stable ordering, idempotency, duplicate prevention, and common context/intake queries. + +**Acceptance:** + +- Upgrade fixtures from schema 9, explicit schema 10, and schema 11 converge to schema 12 with prior checksums intact. +- Schema inspection finds no mutable Intent/Exploration status/current pointer. +- Invalid type combinations, duplicate operation keys, dangling endpoints, and cross-project joins fail before commit. +- Concurrent conflicting appends receive distinct committed sequences; equal timestamps cannot produce two “latest” records. +- Search/index parity can be checked/rebuilt from canonical new facts independently of the known global journal-search divergence. + +**Rollback:** Before merge, revert the migration and code together against disposable fixtures. After intentional dogfood, use the verified pre-migration backup or forward repair; never edit migration 12 after release. + +### `make-intent-operations-atomic` + +**Deliverable:** A deterministic `loaf intent` resource surface and one canonical compound deferral operation. + +**Implementation:** + +- Implement create/list/show around stable identity, latest immutable snapshot, derived latest disposition, aliases, sources, and relationships. `intent create` defaults to `tracked`; a new self-sufficient deferred direction uses `intent create --disposition deferred --why ... --boundary ... --trigger ... --operation-id ...`. +- Implement `intent defer <intent-ref> --why ... --boundary ... --trigger ... --operation-id ...` only for an existing Intent. Both new-deferred creation and existing-Intent deferral use one serializable canonical operation that consults/writes `intent_operations` and writes or returns the snapshot, deferred disposition, immutable four-field payload, operation/digests, aliases, and source links. +- Implement `resume` by appending a new tracked disposition linked to the deferral it supersedes; implement `resolve` by appending a reasoned terminal disposition. Neither command updates a status column. +- Keep automatic output factual: IDs, created/reused, derived disposition, input/stored digests, sources, and exact read commands. Do not generate a workflow recommendation. +- Convert `journal defer` into a compatibility adapter over the same canonical Intent operation so it cannot create a new journal/Spark-only record. The adapter and canonical command consult/write `intent_operations` in the same transaction; only the adapter must create the legacy journal/Spark factual projections and record their IDs/projection version. If a canonical command won the key first, the adapter locks/reads that mapping, creates both missing projections from the stored canonical packet rather than the retry body, advances projection version 0→1 atomically, and returns the established decision/Spark IDs plus input/stored digest match. If the adapter won first, later canonical calls reuse the mapped Intent without changing those projections. Preserve the installed JSON/human contract as far as possible and label legacy projections. +- Add safe-command classification only for exact read-only/basic leaves that meet the existing fail-closed policy; body-taking mutations remain operator-gated. + +**Acceptance:** + +- Failure injection after each write stage proves all-or-nothing behavior. +- Concurrent identical/reworded retries under one operation key converge on the first write and expose digest mismatch without duplication/default failure. +- Canonical-first→adapter, adapter-first→canonical, and concurrent mixed-entry-point fixtures converge on one Intent and at most one reciprocal journal/Spark pair. Version 0→1 projection backfill is all-or-nothing under failure injection, uses stored canonical content on digest mismatch, and every successful adapter response contains the established legacy IDs. +- Resume preserves the immutable deferred payload and derives `tracked`; resolve preserves all prior history. +- `intent create --disposition deferred`, `intent defer`, `journal defer`, and legacy conversion cannot create two canonical Intents for the same project/operation key; retrying through a different entry point returns the mapped first write and reports digest equality/mismatch. +- Commands reject unknown projects, invalid sources, cross-project endpoints, control characters, unbounded input, and ambiguous aliases with stable JSON errors. + +**Rollback:** The compatibility adapter can be reverted before merge while additive rows remain harmless in isolated fixtures. Real dogfood is backup-first and rolls forward or invokes the existing quiesced whole-database restore procedure; conversion scope is project-local, but restore scope is not. Never delete rows ad hoc. + +### `make-exploration-portable` + +**Deliverable:** A deterministic `loaf exploration` resource surface whose context remains resumable without local session/log access. + +**Implementation:** + +- Implement create/list/show with stable Exploration identity and relationships; no open/active/paused/completed lifecycle. +- Implement immutable checkpoint append with required purpose/current framing, conclusions or constraints, unresolved question or decision, and recommended next action; trim and cap each at 4096 UTF-8 bytes, rejecting overflow without truncation or partial writes. Add optional individually bounded ordered candidates/evidence, source relationships, operation key, digest, and transactionally allocated Exploration sequence. +- Implement the focused deterministic conversation resource: `loaf conversation create`, `loaf conversation show/list`, `loaf conversation handle add`, and `loaf exploration conversation add`. A logical conversation may have multiple harness-local handles; an Exploration may span many logical conversations; no command infers equivalence from current session, branch, worktree, recency, or provider. +- Record handles/log locators with harness, locality/namespace, observation time, and optional hash/range. Reachability checks return ephemeral results or append separate timestamped observer/locality-scoped observations; do not infer equivalence from matching branch, worktree, timestamp, or opaque ID. +- Implement `exploration context` as a stable projection over latest checkpoint, ordered items, linked Intent/entities, related journal/wrap/handoff evidence, source conversations/handles/logs, and next question. Return the four-field portable core whole; bound each optional layer independently and report available/shown counts, truncation, a stable next cursor, and an exact `--layer <name> --cursor <cursor> --limit <n>` expansion command. Report `portable_context_present` separately from source-reference presence/availability. +- Make checkpoint writes concurrency-safe and deterministic without selecting a globally current Exploration. + +**Acceptance:** + +- Context remains complete and stable after every handle/log locator is marked unavailable or removed from the fixture machine. +- A handle-only Exploration reports `portable_context_present=false` and never claims resumability. +- Every optional context layer stays within configured bounds, paginates without omission/duplication under stable state, and returns deterministic cursors/counts; the portable core is never truncated. +- Multiple conversations/harnesses append checkpoints and source handles without overwriting or ordering ambiguity. +- Concurrent checkpoint appends receive distinct committed sequences, and context deterministically selects the greatest sequence even when timestamps are identical. +- A checkpoint missing any of the four portable fields is retained only as non-portable evidence or rejected according to the command contract; it never makes `portable_context_present=true`. With all source locators unavailable, the context fixture preserves the expected `recommended_next_action` byte-for-byte. Oversized core fields produce the documented stable error and leave no checkpoint/item rows. +- Unknown harness fields remain optional/absent; no ID or locator is fabricated. +- Raw transcripts, prompts, tool output, and provider payload bodies are absent from schema/API fixtures. + +**Rollback:** Checkpoints and source records are additive. Before merge, revert against isolated fixtures; after dogfood, preserve facts and roll forward unless the existing quiesced whole-database restore procedure is explicitly selected. + +### `connect-intake-and-trace` + +**Deliverable:** Intent and Exploration behave as first-class relational state rather than isolated command silos. + +**Initial relationship matrix:** + +| From | Relationship | To | Required now | +|------|--------------|----|--------------| +| Spark, Idea, Brainstorm, journal entry | `source-of` | Intent | Preserve capture/provenance without automatic promotion | +| Intent | `derived-from`, `split-from`, `supersedes`, `duplicates` | Intent | Track candidate lineage without a program entity | +| Exploration | `explores`, `informs` | Intent | One inquiry may develop many tracked directions | +| Exploration | `has-conversation` | logical conversation | Associate explicitly without inferring provider identity | +| Exploration | `uses-source` | journal entry, handoff, report, finding | Connect portable context to bounded evidence | +| checkpoint | `belongs-to` | Exploration | Prefer a hard foreign key; expose as a trace edge if useful | +| report, finding, journal entry | `evidence-for` | Exploration or Intent | Retain conclusions without copying bodies | + +Existing legacy relationships remain readable. Git Change paths, `materialized-as`, blocking/dependency semantics, Spec, Task, Plan, Council, Run, Verdict, and arbitrary cross-kind pairings are not required new-write combinations in this Change; future Changes extend the registry deliberately. + +**Implementation:** + +- Extend generic aliases/link/trace/show resolution through the centralized registry, including new checkpoint/conversation kinds where exposing them is useful. +- Implement the closed matrix above and preserve reasons; reject unsupported endpoint/type combinations rather than accepting a Cartesian product. Deferral linkage uses its dedicated foreign-key/event contract and may expose a derived trace edge without adding a free-form relationship row. +- Integrate new facts into export/import or backup/restore coverage, project deletion, state status/doctor, mapping-invariant scans, search/parity checks, and trace JSON. +- Add `loaf intake list` as a deterministic project-local projection of unresolved Sparks, Ideas, Brainstorms, unresolved Intents, deferred Intents, and unmigrated legacy deferrals. Deduplicate by `intent_operations` plus canonical relationships/migration markers without making a semantic decision. +- Update the layered journal continuity projection so unresolved deferred Intents receive active-truth precedence and recent portable Exploration checkpoints appear as a separately bounded/discoverable layer with counts, truncation, and exact context/expansion commands. Deduplicate converted legacy Spark projections. +- Return exact next read commands and source relationships; never rank, promote, publish, or choose a disposition in CLI code. + +**Acceptance:** + +- Link/trace round trips work for every pairing in the initial matrix and fail for unsupported, invalid, dangling, duplicate, or cross-project edges. +- Export/restore and project deletion preserve/remove every new row exactly; state health catches orphaned or parity-divergent records. +- Intake shows one logical item before and after legacy conversion and remains byte-deterministic for equal state. +- Context shows unresolved deferred Intent regardless of newer noise, exposes bounded checkpoint discovery without a current-Exploration pointer, and never equates handle presence with portable context. +- An inbound external tracker concept is absent from the data model/output except as a future generic mapping capability; no provider-specific fields appear. + +**Rollback:** All projections are derived. Reverting a read surface must not mutate canonical facts; schema-backed write rollback follows the prior units' backup/forward-repair contract. + +### `make-loaf-maintenance-config-aware` + +**Deliverable:** One hidden config-aware Loaf maintenance protocol backed by deterministic, non-mutating plans for the project-alignment and installed-target surfaces changed by this release. + +**Implementation:** + +- Keep `loaf-reference` non-user-invocable. Expand its first-sentence routing and maintenance reference for natural-language requests to upgrade, diagnose, repair, configure, or bring a Loaf project current; do not add `/maintain` in this Change. +- Define the protocol as diagnose → plan → ask only for project-owned choices → apply approved operations → rerun diagnostics. It begins with `loaf config check --json`, combines returned project choices/actions with `loaf version`, installed-target ownership, `loaf state status/doctor --json`, and checkout alignment, and discovers exact current syntax through `loaf <command> --help` instead of memorizing command flags. +- Add read-only JSON output to `loaf doctor` with the same checks, repair identities, ownership/version facts, and pass/fail result as human output. JSON diagnosis never prompts or repairs; any later mutation still uses the existing explicit `--fix`/`--force` contract. +- Add `--dry-run` and `--json` to `loaf install --upgrade`. Planning uses the same target detection, ownership/content-digest checks, deprecation manifest, MCP/config analysis, and project-file reconciliation as apply, but records intended creates/updates/removals/preservations/conflicts without writing files, manifests, config, or state. The JSON response returns exact applicable follow-up commands and whether explicit consent is required. +- Reuse the existing doctor/install analyzers and ownership decisions for plan/apply parity. If a correct dry run requires replacing target ownership, config authority, or install architecture rather than extracting shared analysis from current behavior, stop and retain that redesign as deferred Intent; do not widen this unit silently. +- Ensure the maintenance plan understands the in-scope Intent/Exploration schema migration and new command/help/reference metadata without automatically applying a database migration. Behind-schema state continues to return the exact backup-first `loaf state migrate schema --apply` action. +- Keep executable acquisition outside Loaf: report the running version and executable provenance available from local facts, never invoke Homebrew/npm/another package manager, and never claim a newer remote version without evidence from the owning package manager. +- Update source help/reference metadata, `loaf-reference`, focused routing/content evaluations, every generated target, and installed-surface fixtures atomically. No hardcoded Loaf binary path may appear in the new maintenance protocol or generated commands. + +**Acceptance:** + +- Human and JSON doctor fixtures contain the same check identities and outcomes; JSON mode is byte-for-byte non-mutating and never enters prompt/repair code. +- Install-upgrade dry-run is deterministic and byte-for-byte non-mutating across recognized installed targets, absent targets, current/stale owned content, locally modified or unowned content, deprecation actions, project guidance effects, missing explicit cleanup consent, and a managed safe-command block whose retired version-pinned executable no longer exists while current `loaf` resolves on `PATH`. Applying the reported plan through existing commands produces the predicted effects, and rerunning diagnosis reports convergence. +- Routing chooses the hidden `loaf-reference` Skill for maintenance requests while ordinary workflow prompts continue to route to Triage/Explore/Shape/etc.; generated targets expose the same protocol and command contracts. +- `.agents/loaf.json` remains shared project configuration and gains no machine-specific installed-target or package-manager fields. No top-level `loaf upgrade`, package-manager mutation, fleet sweep, public `/maintain`, or duplicate config parser is introduced. + +**Rollback:** Doctor/install planning changes, reference guidance, evaluations, and generated output revert as one unit. Rollback cannot delete or rewrite user files because the new planning paths are non-mutating and the existing apply ownership rules remain authoritative. + +### `converge-explore-and-triage` + +**Deliverable:** A smaller judgment-bearing Skill surface that consumes deterministic primitives consistently across targets. + +**Implementation:** + +- Add user-facing `/explore` guidance for divergent inquiry, checkpoint discipline, portable resumption, optional research/scout/prototype/spike techniques, and Intent/defer capture. +- Preserve the full Brainstorm questioning/divergence stance as an internal technique referenced by Explore. Hide it from the primary user workflow without deleting its behavioral value. +- Rewrite `/triage` around capture versus tracked Intent versus deferred disposition versus Exploration versus Change. It reads `loaf intake list` and chooses operations; it does not contain backend/provider branches. +- Narrow Idea and Spark to capture primitives triggered naturally during any conversation and routed through Triage. Neither owns shaping/promotion or tracker behavior. +- Consume the config-aware Loaf maintenance protocol from the preceding unit and update orchestration text only where the new primitives must be discoverable and accurately bounded; defer Plan/Implement/Ship/Release/Reflect/Housekeeping convergence. +- Add/adjust sidecars, routing prompts, hook instructions only where required, CLI reference/help generation, and all tracked `dist/`/`plugins/` output in the same unit. + +**Acceptance:** + +- Routing evaluation distinguishes “here is an idea,” incidental Spark, “explore this,” “resume this Exploration,” “track this,” “defer this body,” research, Shape, and Change materialization prompts. +- Current and generated in-scope Skills contain no direct provider/MCP calls, `integrations.linear.enabled` branches, branch/worktree shell recipes, or persisted lifecycle status instructions. +- `/explore` produces/checkpoints Exploration; `/triage` chooses disposition; neither writes durable Git artifacts or creates a Change. +- Target builds contain the same concepts and deterministic command contracts, with target-specific invocation metadata only. + +**Rollback:** Skill/generation changes are atomic with the CLI they describe. Never leave source Skills ahead of generated/installed contracts or vice versa. + +### `migrate-and-dogfood-intent-model` + +**Deliverable:** Existing deferrals converge safely, real Loaf usage proves cross-context continuity, and durable documentation/effects match shipped behavior. + +**Implementation:** + +- Add a project-scoped state conversion command with dry-run/manifest/apply. Dry-run parses every current `journal_deferrals` pair, reports exact intended Intent/relationship/`intent_operations` rows and unparseable records in a project-specific manifest, and performs no writes. +- Apply verifies a whole-database backup, uses deterministic IDs and legacy operation keys, populates the same `intent_operations` mapping used by live commands, links each new deferred Intent to its historical journal decision/Spark/origin, records one migration marker, preserves legacy rows, and is rerunnable. +- Ensure pre-migration intake/context still exposes legacy deferrals so no item disappears while migration is pending. +- Dogfood one Exploration across at least two conversation/harness contexts, with multiple handles/log refs and loss/unavailability of one local source; resume solely from the portable checkpoint. +- Dogfood create/defer/retry/resume/resolve on Loaf state after explicit approval for global-state mutation; retain stable IDs and outcomes in journal/Change research rather than raw logs. +- Update architecture/schema/current knowledge from final code, reconcile the affected-artifact manifest with `git diff`, run the full gate matrix, and refresh immediate successor assumptions with observed behavior. + +**Acceptance:** + +- Dry-run is byte-for-byte non-mutating; apply is backup-first and leaves no partial conversion under failure injection. +- Every parseable legacy deferral maps exactly once; unparseable records remain visible and actionable without guessed semantics. +- Cross-context dogfood succeeds without access to the original local log and does not mutate unrelated project rows. +- Final documentation describes only proven behavior; historical artifacts remain historical and current guidance has no contradictory authority claims in scope. +- The full Verification Matrix below passes twice: once before approved real dogfood using isolated fixtures and once after final source/generated reconciliation. + +**Rollback:** Preserve the verified whole-database pre-apply backup and project-specific conversion manifest. Prefer forward repair for additive rows; restore is a quiesced whole-database operator action and requires explicit approval. Never delete historical journal/Spark records to simulate rollback. + +## Sequencing and Parallelism + +`establish-relational-foundation` lands first because every later unit depends on one authoritative schema/registry. Intent and Exploration operations may then be implemented in parallel if they do not edit the same registry/trace files without coordination. Intake/trace waits for both. Config-aware maintenance follows the integrated doctor/status/command surface so its plans cover the actual release, and Skill convergence then consumes those stable JSON/human contracts. Migration/dogfood is last because it must exercise the final integrated system. This sequencing is a dependency contract, not mutable project status. + +## Verification Matrix + +Run focused tests with an absolute temporary `LOAF_DB` during implementation, then run the complete repository gates before review: + +```bash +go test ./... +npm run typecheck +npm run test +npm run build +loaf check --hook artifact-body-write +loaf check --hook check-secrets +loaf check --hook render-drift +loaf change check --require-executable docs/changes/20260717-intent-exploration-foundation +git diff --check +``` + +Run every other registered enforcement hook affected by the final diff explicitly with `loaf check --hook <id>`; the CLI intentionally has no aggregate no-argument `loaf check` operation. Additional falsification suites must cover schema 9/10/11 upgrades, compound-write failure stages, cross-entry-point response-loss retries through `intent_operations`, bounded context pagination, unavailable provenance, cross-project/dangling relationships, export/restore/delete parity, legacy deferral conversion, doctor human/JSON parity, install-upgrade dry-run non-mutation and apply convergence, maintenance routing, and source-versus-generated Skill drift. Any bug fix discovered during implementation receives a regression test that fails when the fix is reverted and passes after restoration. + +## Review Checkpoints + +- Review schema/registry and command JSON contracts before broad Skill writing; these are the highest-likelihood-of-change surfaces. +- Review the `journal defer` compatibility semantics before real-state dogfood; compatibility must not become indefinite authority. +- Review one rendered `exploration context` from a fixture with unavailable local sources, then give it to a fresh agent without source logs and confirm that agent identifies the intended next action before accepting the human portability claim. +- Review a maintenance dry-run from an old installed-project fixture before apply, then confirm the reported plan matches the actual owned changes, preserved conflicts, required consent, state-migration action, and final converged diagnostics. +- Review source and every generated target together before calling the Skill surface converged. +- Review the final migration manifest and backup evidence before any apply against the global Loaf database. + +## Plan Completion + +The plan is complete when every unit's acceptance and rollback contract is implemented, the Change Verification Contract passes, dogfood evidence is retained, actual affected paths match `change.md`, and `change-native-execution-migration` can be materialized without reopening the Intent/Exploration authority model. diff --git a/docs/changes/20260717-intent-exploration-foundation/research/workflow-convergence.md b/docs/changes/20260717-intent-exploration-foundation/research/workflow-convergence.md new file mode 100644 index 000000000..a140053c2 --- /dev/null +++ b/docs/changes/20260717-intent-exploration-foundation/research/workflow-convergence.md @@ -0,0 +1,137 @@ +# Workflow Convergence Research + +## Purpose + +This file materializes the relevant body of the multi-conversation workflow exploration into Git so `intent-exploration-foundation` remains understandable in a fresh checkout. It records the evidence and synthesis that shaped the Change without copying raw transcripts or making machine-local conversation handles authoritative. + +## Source Provenance + +- Primary source conversation: Codex desktop thread `019f62e6-88d2-7630-96ce-527652fd9a0b`, observed locally across multiple compactions. The opaque ID is provenance only; this document carries the portable context. +- Comparative inputs previously reviewed in that conversation: [OpenSpec OPSX](https://github.com/Fission-AI/OpenSpec/blob/main/docs/opsx.md), [Every Compound Engineering Plugin](https://github.com/everyinc/compound-engineering-plugin), [Matt Pocock Skills](https://github.com/mattpocock/skills), and NotebookLM notebook `9723f7a6-263d-426b-91f0-7fad35fd7567` (“Agile Product Management: From Shaping to Implementation”). +- Durable predecessor: `docs/changes/20260710-journal-reliability-foundation/change.md` and its journal-origin/deferral/lineage implementation evidence. +- Current-source audit: SQLite schema/migrations, journal deferral, trace/relationship resolution, Change CLI, workflow Skills, hooks, ADRs, README/strategy/architecture, routing evaluations, and generated target output as of commit `340ea967`. +- Accepted journal decisions and discoveries are cited in `change.md`; they provide stable semantic IDs while this file supplies the coherent body those entries alone cannot express. + +The comparative sources are not normative dependencies. They helped expose useful workflow shapes and naming/routing patterns; Loaf's authority and continuity model is selected from project evidence and user decisions. + +## What the Comparative Review Reinforced + +### Explicit transitions beat artifact accumulation + +OpenSpec/OPSX demonstrates the value of keeping a bounded change proposal and making workflow transitions legible. Loaf keeps that clarity but does not adopt a shadow copy of every durable artifact: the Change branch is the actual delta, canonical project documents are edited in place, and the Change records affected paths plus relevant local research/reports. + +### Explore, Shape, and Plan are materially different kinds of thinking + +The product-management research and dogfood discussion converged on a useful funnel. Explore expands and interrogates possibilities; Shape narrows one direction into a Product Contract; Plan defines the route from current to desired state. Combining all three creates either premature commitment during discovery or implementation detail inside the PRD. Loaf therefore targets separate `/explore`, `/shape`, and `/plan` workflows, even though the current checker still expects planning sections inside `change.md`. + +### Techniques should not become competing lifecycle owners + +The compound-engineering and Matt Pocock skill sets demonstrate the leverage of focused techniques, review loops, and memorable routing names. Loaf keeps Brainstorm, Scout, research, prototype, spike, council, and critique as techniques available where useful, while giving lifecycle ownership to a smaller public workflow. Unique user-facing names may improve routing later; `/explore` remains provisional until dogfood provides evidence. + +### Plans need enough structure for independent execution without task-file bureaucracy + +One-line units are too weak for reliable handoff, while one Markdown file per Task recreates allocation/status ceremony and duplicates tracker identity. The selected middle is `plan.md` with stable named unit slugs, explicit dependencies, acceptance criteria, rollback, and verification. A later Linear adapter may publish meaningful units as subissues without making tracker IDs intrinsic local identity. + +### Learning belongs after delivery, not as speculative durable truth + +Compound/reflection loops are valuable when they distill proven behavior. Change decisions are the delivery-scoped ledger; specs, knowledge, and ADRs describe durable reality after implementation/ship/reflect proves it. A Change may name expected durable effects, but it should not manufacture final documentation before the behavior exists. + +## Current Repository Evidence + +| Surface | Current behavior | Consequence | +|---------|------------------|-------------| +| `internal/state/migrations/0001_initial.sql` | Separate Ideas, Sparks, Brainstorms, Specs, Tasks, and generic relationships | No tracked Intent or resumable Exploration focal entity | +| `internal/state/migrations/0011_journal_origins_and_deferrals.sql` | Optional journal-origin envelope and `journal_deferrals` linking one decision to one Spark | Useful provenance/idempotency seam, but no substantial deferred body or Exploration relation | +| `internal/state/journal_defer.go` | Serializable operation-key write of a four-field journal decision and open Spark | Strong compound-write pattern; wrong long-term authority/projection | +| `internal/state/trace.go` | Entity support distributed through fixed lists/switches; relationships are polymorphic | New kinds require centralized registration and validated endpoints/types to avoid drift | +| `internal/cli/change.go` | `change init` writes `change.md` in the current checkout and prints a branch hint | Worktree/branch/PR mechanization remains a later Change | +| `content/skills/triage/SKILL.md` | Intake covers Sparks/Brainstorms/Ideas and “defer” may mean no operation | Cannot distinguish capture, tracked Intent, and deferred disposition | +| `content/skills/shape/SKILL.md` | Shape owns decomposition and planning inside `change.md` | Conflicts with the accepted Product Contract/`plan.md` separation | +| `content/skills/breakdown/SKILL.md` and Implement guidance | Public Spec/Task execution vocabulary remains | Terminal hard cut and Skill simplification are still required | +| ADR-011/013/016 and current architecture/strategy | Linear mode branching and SQLite/render authority reflect earlier models | Historical evidence remains; current guidance must be superseded/converged after implementation | +| `plugins/` and `dist/` | Tracked generated Skills repeat current source behavior | Every workflow change needs source/generated/install convergence, not source-only edits | + +The known global `journal_search` divergence proves why derived views need parity contracts and why this Change must never infer that “no search result” means “no prior context.” Isolated migration tests and canonical recent reads remain the safe evidence path until separately consented repair. + +## Selected Concept Taxonomy + +| Concept | Meaning | Authority before tracker integration | Creates a worktree? | +|---------|---------|--------------------------------------|---------------------| +| Spark | Incidental thought worth preserving when it cannot be addressed immediately | SQLite | No | +| Idea | Explicit proposition retained for consideration | SQLite | No | +| Intent | Deliberately tracked direction worth revisiting, investigating, or delivering | SQLite; later published/adopted tracker issue when configured | No | +| Deferred | Append-only disposition of an Intent with immutable self-sufficient payload | SQLite | No | +| Exploration | Relational inquiry spanning checkpoints, conversations, evidence, Intents, and later Changes | SQLite, with relevant synthesis materialized into Git on Change promotion | No | +| Research | Evidence gathering for a known question; may support an Exploration or Change | SQLite relationship plus Git report/research when team/delivery relevant | No by itself | +| Change | Bounded approved delivery contract | Git `docs/changes/<date>-<slug>/`; tracker issue later mirrors collaborative work state | Yes, at approved materialization | +| Plan unit | Named self-contained implementation/review packet inside `plan.md` | Git; later meaningful units map to tracker subissues | Reuses the Change worktree | +| Handoff | Immutable continuation bookmark/snapshot | SQLite, related to the relevant Exploration/Intent/Change/unit | No | +| Wrap | Immutable conversation synthesis/report checkpoint | Journal in SQLite, related where useful | No | + +Deferred work may arise during Explore, Shape, Plan, Implement, Reflect, Ship, or Release. Its discovery stage is provenance, not a separate defer mechanism. One Exploration may yield multiple Intent and multiple simultaneously shapeable Changes; choosing one first does not implicitly defer every other candidate. + +## Authority Boundary + +| Information | Local-only mode | Linear-enabled target after the coordination Change | +|-------------|-----------------|-----------------------------------------------------| +| Capture, Exploration, checkpoints, conversations/log refs, journal, wraps, handoffs, mappings/reconciliation evidence | SQLite | SQLite | +| Tracked Intent and deferred disposition | SQLite | Linear owns published collaborative state; SQLite retains local provenance, mappings, checkpoints, and reconciliation history | +| Change/plan/spec/ADR/knowledge/report/code bodies | Git | Git | +| Change and meaningful unit coordination state | Git/SQLite deterministic local view | Linear | +| Review, merge, publication evidence | PR/Git/release surfaces | PR/Git/release surfaces linked from Linear | +| Disposable scratch/cache | `.agents/tmp/` or OS cache when safe | Same | + +SQLite is not a hidden Markdown repository. Git is not the right home for every conversation checkpoint. Linear is not the authority for durable project truth. The boundaries are complementary rather than a single universal source of truth. + +## CLI and Skill Boundary + +The accepted sentence is: **humans and Skills choose; the CLI deterministically proves and performs.** + +The CLI owns validation, reads, explicit mutations, append-only persistence, idempotency, migrations, mappings, provider adapters, worktree/branch/PR mechanization, hooks, machine-readable output, and health checks. It may enforce invariants around an explicitly requested operation; it does not infer that operation from ambiguous prose. + +Skills own interpretation, questions, recommendations, scope judgment, semantic classification, prose authoring, and deciding which CLI operation to request. A Skill should not branch on backend configuration, call provider MCP directly, maintain lifecycle status, or reproduce Git/SQLite/worktree mechanics. + +Hooks are deterministic callers/gates over the same CLI surface. They may attach observable event/provenance data or enforce a machine-checkable invariant; they must not smuggle LLM judgment into a supposedly deterministic gate. + +### Config-aware Loaf maintenance + +Dogfooding the alpha.8 release exposed a related operator boundary. Upgrading the executable, refreshing installed harness adapters, reconciling project guidance/config, and migrating SQLite are separate mechanisms today; an agent needs one coherent way to determine which apply without embedding another version-specific shell recipe in every workflow Skill. + +The shaping session produced direct failure evidence: Homebrew had installed alpha.8 and current `loaf` resolved through `PATH`, but the project-managed Codex safe-command guidance still required the retired alpha.6 Cellar executable. Both mandated journal writes failed before reaching Loaf because that path no longer existed, and the agent correctly refused to substitute an unauthorized bare command. This proves that package upgrade and managed project/harness reconciliation are independent and that stale command ownership must be diagnosed before a maintenance Skill attempts an operation. + +The existing non-user-invocable `loaf-reference` Skill is the right operator layer. `.agents/loaf.json` supplies durable team-owned choices such as integration election, while executable provenance, installed targets, ownership digests, state readiness, and checkout alignment are machine-local observed facts. The Skill combines CLI-provided facts and actions, asks only for missing project choices, and verifies convergence. It does not infer installed harness intent from Git config, call a package manager, or replace deterministic target/config/state mechanics. + +This requires complete non-mutating planning surfaces underneath it. `loaf doctor --json` must expose project-alignment facts without entering repair flow, and `loaf install --upgrade --dry-run --json` must describe target selection, owned effects, preserved conflicts, deprecations, consent requirements, and project-file reconciliation without writing. State diagnostics retain their backup-first migration actions. A top-level `loaf upgrade` and public `/maintain` remain deferred until this hidden protocol is dogfooded; adding another public Skill now would contradict the simplification goal. + +## Target Skill Surface + +Core public flow: + +```text +/triage → /explore → /shape → /plan → /implement → /ship → /release → /reflect +``` + +`/research`, `/handoff`, `/wrap`, `/housekeeping`, and `/council` remain optional/supporting workflows. Idea and Spark are natural-language-triggered capture primitives routed through Triage. Brainstorm preserves its full divergent stance inside Explore. Scout, prototype, and spike are internal techniques usable from the stage that needs them. Defer is an Intent disposition, not a user-facing Skill. Breakdown, render, and merge do not survive the completed hard cut. + +## Successor Decomposition + +```text +journal-reliability-foundation + → intent-exploration-foundation + → change-native-execution-migration + → linear-native-coordination + → spec-conversion-and-guidance-sweep +``` + +The separation is deliberate: + +- This Change establishes local relational memory and deterministic capture/resumption without Git workspace or tracker dependencies. +- Change-native execution can then promote Intent, allocate the worktree before durable writes, create `change.md`/`plan.md`, open a draft PR, and run local implementation/ship semantics. +- Linear coordination can bind stable local concepts to remote collaborative authority without defining the concepts or leaking provider behavior into Skills. +- The terminal sweep can migrate/remove legacy public authority and converge every guidance/generated/install surface using landed evidence rather than speculative compatibility. + +Cross-worktree activity scanning remains separate from this serial chain unless later dogfood proves it blocks correctness. The journal plus explicit workflow-stage scans are sufficient for the current foundation. + +## Research Conclusion + +The workflow does not need more artifact types or more public Skills. It needs a precise relational bridge between fleeting capture and bounded delivery, plus an equally precise separation between judgment and mechanism. Intent and Exploration provide that bridge only if they remain append-only operational facts rather than mutable lifecycle objects. The five-node lineage lets Loaf prove that foundation locally, then mechanize Git delivery, then add Linear collaboration, and only then remove the legacy surfaces comprehensively. diff --git a/docs/knowledge/skill-architecture.md b/docs/knowledge/skill-architecture.md index 3d2ea0b50..b0cfa4616 100644 --- a/docs/knowledge/skill-architecture.md +++ b/docs/knowledge/skill-architecture.md @@ -85,7 +85,7 @@ Skills load into profiles at spawn time. What an agent *can do* is fixed by prof | Reference/Knowledge | `false` | python-development, database-design, foundations, git-workflow, orchestration | | Workflow/Process | `true` (default) | implement, research, shape, breakdown, ship, release, housekeeping, wrap | -34 skills total: 19 workflow/default-invocable, 15 reference/knowledge with `user-invocable: false`. +35 skills total: 19 workflow/default-invocable, 16 non-invocable with `user-invocable: false` (reference/knowledge skills plus the brainstorm technique consumed by explore). ## Cross-References 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 e37024c50..164265221 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 @@ -34,13 +34,17 @@ This audit is generated from the Go-native dispatch in `internal/cli/cli.go`, wi | `change` | Native Go | Go dispatcher only | Shape-first Change artifacts under `docs/changes/`: `init` scaffolds a validated `YYYYMMDD-slug` folder from the embedded template, `check` carries the Verification Contract (V1 violations, V2 derived executability, V3 `--require-executable` gate) with JSON output following the check findings shape. Git-canonical; no SQLite reads or writes. | | `check` | Native Go | Go dispatcher only | All known hook implementations are native: `artifact-body-write`, `check-secrets`, `ephemeral-provenance`, `github-account`, `render-drift`, `validate-commit`, `security-audit`, `workflow-pre-pr`, and `validate-push`. The obsolete TypeScript command source has been removed; CLI reference generation now runs through native Go reference metadata, and former Vitest-only check edge cases now have Go coverage. | | `config` | Native Go | Go dispatcher only | Project config health checks are native: `loaf config check` validates `.agents/loaf.json` defaults and installed Loaf-managed hook registrations, with `--fix` reusing native target installers to refresh stale hook config. | +| `conversation` | Native Go | Go dispatcher only | SQLite-backed logical conversations with machine-local handles, bounded log references, and immutable availability observations; provenance only, never transcript storage. | | `council` | Native Go | Go dispatcher only | SQLite-backed council creation, show, list, and relationship linking use native body storage and relationship tracing. | | `doctor` | Native Go | Go dispatcher only | Top-level project alignment diagnostics are native, including `--fix`, `--verbose`, help, warning-vs-failure exit semantics, and stale/duplicate instruction-file repair. The obsolete TypeScript doctor command source and duplicate TS tests have been removed. | | `docs` | Native Go | Go dispatcher only | Docs indexing is native, including `docs/` Markdown scanning, explicit rebuilds, SQLite FTS maintenance, and JSON count output. | +| `exploration` | Native Go | Go dispatcher only | SQLite-backed Exploration continuity: identity, immutable four-field portable checkpoints with per-exploration sequences, typed ordered items, and the bounded cursor-expandable `context` projection. | | `finding` | Native Go | Go dispatcher only | SQLite-backed finding creation, list/show with `json/csv/markdown/html` formats, verdict recording, row-shaped JSON import, report decomposition reads, and relationship tracing are native. Finding bodies use `artifact_bodies`; verdicts update finding status without storing generated renders in the database. | | `handoff` | Native Go | Go dispatcher only | SQLite-backed handoff creation, show, list, and relationship linking use native body storage and relationship tracing. | | `housekeeping` | Native Go | Go dispatcher only | Native help is state-independent; operational checks summarize SQLite state or markdown artifacts without TypeScript delegation. The TypeScript command source has been removed; CLI reference generation now uses native Go reference metadata for this command. | | `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. | | `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/0012_intents_and_explorations.sql b/docs/schema/0012_intents_and_explorations.sql new file mode 100644 index 000000000..cd41424b2 --- /dev/null +++ b/docs/schema/0012_intents_and_explorations.sql @@ -0,0 +1,251 @@ +-- Intent and Exploration relational foundation. +-- +-- Every table in this migration is additive and append-only operational state: +-- stable identities, immutable content snapshots, transactionally sequenced +-- facts, and normalized conversation provenance. There is no mutable lifecycle +-- status, no current-session pointer, and no raw transcript storage. +-- +-- Like migration 0011, tables that reference journal_entries or sparks +-- deliberately do not foreign-key them: the explicit journal-first migration +-- 0010 may rebuild journal_entries after this migration has run on a schema-9 +-- database. Referential integrity for those optional projections is audited by +-- the owning workflows. Foreign keys to projects and to the new aggregate +-- roots are hard because those tables are never rebuilt. + +CREATE TABLE IF NOT EXISTS intents ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + UNIQUE (project_id, id) +); +CREATE INDEX IF NOT EXISTS idx_intents_project ON intents (project_id, created_at); + +-- Immutable content revisions. The latest snapshot is derived from the +-- greatest committed per-intent sequence, never from timestamps. +CREATE TABLE IF NOT EXISTS intent_snapshots ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + intent_id TEXT NOT NULL, + seq INTEGER NOT NULL CHECK (seq >= 1), + title TEXT NOT NULL CHECK (length(trim(title)) > 0), + body TEXT NOT NULL CHECK (length(trim(body)) > 0), + content_digest TEXT NOT NULL CHECK (length(content_digest) = 64 AND content_digest NOT GLOB '*[^0-9a-fA-F]*'), + created_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (project_id, intent_id) REFERENCES intents(project_id, id), + UNIQUE (intent_id, seq) +); + +-- Immutable self-sufficient deferral payloads. The retained body plus why, +-- boundary, and revisit trigger form the portable contract; per-field byte +-- caps are enforced by the write path before insert. +CREATE TABLE IF NOT EXISTS intent_deferrals ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + intent_id TEXT NOT NULL, + operation_key TEXT NOT NULL CHECK (length(trim(operation_key)) BETWEEN 1 AND 200), + body TEXT NOT NULL CHECK (length(trim(body)) > 0), + why TEXT NOT NULL CHECK (length(trim(why)) > 0), + boundary TEXT NOT NULL CHECK (length(trim(boundary)) > 0), + revisit_trigger TEXT NOT NULL CHECK (length(trim(revisit_trigger)) > 0), + stored_digest TEXT NOT NULL CHECK (length(stored_digest) = 64 AND stored_digest NOT GLOB '*[^0-9a-fA-F]*'), + created_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (project_id, intent_id) REFERENCES intents(project_id, id), + UNIQUE (project_id, operation_key), + UNIQUE (project_id, id), + UNIQUE (intent_id, id) +); +CREATE INDEX IF NOT EXISTS idx_intent_deferrals_intent ON intent_deferrals (intent_id, created_at); + +-- Append-only disposition facts. Current disposition is the row with the +-- greatest committed per-intent sequence; concurrent appends are serialized by +-- the (intent_id, seq) uniqueness constraint rather than wall-clock time. +CREATE TABLE IF NOT EXISTS intent_dispositions ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + intent_id TEXT NOT NULL, + seq INTEGER NOT NULL CHECK (seq >= 1), + disposition TEXT NOT NULL CHECK (disposition IN ('tracked', 'deferred', 'resolved')), + reason TEXT, + deferral_id TEXT, + supersedes_deferral_id TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (project_id, intent_id) REFERENCES intents(project_id, id), + FOREIGN KEY (project_id, deferral_id) REFERENCES intent_deferrals(project_id, id), + FOREIGN KEY (project_id, supersedes_deferral_id) REFERENCES intent_deferrals(project_id, id), + FOREIGN KEY (intent_id, deferral_id) REFERENCES intent_deferrals(intent_id, id), + FOREIGN KEY (intent_id, supersedes_deferral_id) REFERENCES intent_deferrals(intent_id, id), + UNIQUE (intent_id, seq), + CHECK ((disposition = 'deferred') = (deferral_id IS NOT NULL)), + CHECK (supersedes_deferral_id IS NULL OR disposition = 'tracked') +); + +-- One canonical operation mapping shared by intent create, intent defer, the +-- transitional journal defer adapter, and legacy conversion. Projection +-- version 0 records a canonical-first write with no legacy journal/spark +-- projection; version 1 requires both historical projection references. +CREATE TABLE IF NOT EXISTS intent_operations ( + project_id TEXT NOT NULL, + operation_key TEXT NOT NULL CHECK (length(trim(operation_key)) BETWEEN 1 AND 200), + intent_id TEXT NOT NULL, + stored_digest TEXT NOT NULL CHECK (length(stored_digest) = 64 AND stored_digest NOT GLOB '*[^0-9a-fA-F]*'), + journal_entry_id TEXT, + spark_id TEXT, + projection_version INTEGER NOT NULL CHECK (projection_version IN (0, 1)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (project_id, operation_key), + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (project_id, intent_id) REFERENCES intents(project_id, id), + CHECK ((projection_version = 0 AND journal_entry_id IS NULL AND spark_id IS NULL) OR (projection_version = 1 AND journal_entry_id IS NOT NULL AND spark_id IS NOT NULL)) +); +CREATE INDEX IF NOT EXISTS idx_intent_operations_intent ON intent_operations (intent_id); + +CREATE TABLE IF NOT EXISTS explorations ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + title TEXT NOT NULL CHECK (length(trim(title)) > 0), + created_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + UNIQUE (project_id, id) +); +CREATE INDEX IF NOT EXISTS idx_explorations_project ON explorations (project_id, created_at); + +-- Immutable portable checkpoints. A checkpoint is portable only when all four +-- required core fields are present; per-field 4096 UTF-8 byte caps are +-- enforced by the write path, which rejects overflow without truncation. +CREATE TABLE IF NOT EXISTS exploration_checkpoints ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + exploration_id TEXT NOT NULL, + seq INTEGER NOT NULL CHECK (seq >= 1), + purpose TEXT NOT NULL CHECK (length(trim(purpose)) > 0), + conclusions TEXT NOT NULL CHECK (length(trim(conclusions)) > 0), + unresolved TEXT NOT NULL CHECK (length(trim(unresolved)) > 0), + next_action TEXT NOT NULL CHECK (length(trim(next_action)) > 0), + operation_key TEXT CHECK (operation_key IS NULL OR length(trim(operation_key)) BETWEEN 1 AND 200), + content_digest TEXT NOT NULL CHECK (length(content_digest) = 64 AND content_digest NOT GLOB '*[^0-9a-fA-F]*'), + created_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (project_id, exploration_id) REFERENCES explorations(project_id, id), + UNIQUE (exploration_id, seq), + UNIQUE (project_id, id) +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_exploration_checkpoints_operation + ON exploration_checkpoints (project_id, operation_key) + WHERE operation_key IS NOT NULL; + +-- Ordered typed checkpoint items carry bounded optional detail such as +-- candidates and evidence; item types are validated by the central registry. +CREATE TABLE IF NOT EXISTS exploration_checkpoint_items ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + checkpoint_id TEXT NOT NULL, + item_type TEXT NOT NULL CHECK (length(trim(item_type)) > 0), + position INTEGER NOT NULL CHECK (position >= 1), + content TEXT NOT NULL CHECK (length(trim(content)) > 0), + created_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (project_id, checkpoint_id) REFERENCES exploration_checkpoints(project_id, id), + UNIQUE (checkpoint_id, position) +); + +-- A logical conversation groups machine-local handles. It carries no session +-- lifecycle and is never inferred from branch, worktree, or recency. +CREATE TABLE IF NOT EXISTS logical_conversations ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + title TEXT NOT NULL CHECK (length(trim(title)) > 0), + operation_key TEXT CHECK (operation_key IS NULL OR length(trim(operation_key)) BETWEEN 1 AND 200), + created_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + UNIQUE (project_id, id) +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_logical_conversations_operation + ON logical_conversations (project_id, operation_key) + WHERE operation_key IS NOT NULL; + +-- Machine/harness-local conversation handles: opaque IDs plus locality. The +-- presence of a handle never implies portable context or reachability. +CREATE TABLE IF NOT EXISTS conversation_handles ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + conversation_id TEXT NOT NULL, + harness TEXT NOT NULL CHECK (length(trim(harness)) > 0), + handle TEXT NOT NULL CHECK (length(trim(handle)) > 0), + locality TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (project_id, conversation_id) REFERENCES logical_conversations(project_id, id), + UNIQUE (project_id, id) +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_conversation_handles_identity + ON conversation_handles (conversation_id, harness, handle, COALESCE(locality, '')); + +-- Bounded log references: locators, hashes, and ranges only, never transcript +-- bodies. Availability lives in observations, not on the reference row. +CREATE TABLE IF NOT EXISTS conversation_log_refs ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + handle_id TEXT NOT NULL, + locator TEXT NOT NULL CHECK (length(trim(locator)) > 0), + content_hash TEXT CHECK (content_hash IS NULL OR (length(content_hash) = 64 AND content_hash NOT GLOB '*[^0-9a-fA-F]*')), + range_spec TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (project_id, handle_id) REFERENCES conversation_handles(project_id, id) +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_conversation_log_refs_identity + ON conversation_log_refs (handle_id, locator, COALESCE(range_spec, '')); + +-- Exploration <-> logical conversation membership. +CREATE TABLE IF NOT EXISTS exploration_conversations ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + exploration_id TEXT NOT NULL, + conversation_id TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (project_id, exploration_id) REFERENCES explorations(project_id, id), + FOREIGN KEY (project_id, conversation_id) REFERENCES logical_conversations(project_id, id), + UNIQUE (exploration_id, conversation_id) +); +CREATE INDEX IF NOT EXISTS idx_exploration_conversations_conversation + ON exploration_conversations (conversation_id); + +-- Journal <-> conversation-handle association. journal_entry_id is not a +-- foreign key because migration 0010 may rebuild journal_entries later. +CREATE TABLE IF NOT EXISTS journal_conversation_handles ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + journal_entry_id TEXT NOT NULL, + handle_id TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (project_id, handle_id) REFERENCES conversation_handles(project_id, id), + UNIQUE (journal_entry_id, handle_id) +); +CREATE INDEX IF NOT EXISTS idx_journal_conversation_handles_handle + ON journal_conversation_handles (handle_id); + +-- Immutable availability observations for handles and log references. +-- Reachability is observed at a moment from a locality; it is never a mutable +-- property of the observed row. +CREATE TABLE IF NOT EXISTS source_availability_observations ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + subject_kind TEXT NOT NULL CHECK (subject_kind IN ('conversation_handle', 'conversation_log_ref')), + subject_id TEXT NOT NULL, + observed_at TEXT NOT NULL, + observer TEXT, + locality TEXT, + available INTEGER NOT NULL CHECK (available IN (0, 1)), + note TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id) +); +CREATE INDEX IF NOT EXISTS idx_source_availability_subject + ON source_availability_observations (subject_kind, subject_id, observed_at); diff --git a/docs/schema/operational-state.dbml b/docs/schema/operational-state.dbml index 51d4a7fc0..fa1b440bb 100644 --- a/docs/schema/operational-state.dbml +++ b/docs/schema/operational-state.dbml @@ -553,3 +553,199 @@ Table exports { (project_id, source_entity_kind, source_entity_id) } } + +// Intent and Exploration foundation (migration 0012). Every table below is an +// append-only operational fact: stable IDs plus created_at with no updated_at +// column (intent_operations carries updated_at only for its projection-version +// advance). intent_operations.journal_entry_id/spark_id and +// journal_conversation_handles.journal_entry_id intentionally have no FK +// because migration 0010 may rebuild journal_entries after 0012 has applied. + +Table intents { + id text [pk, not null] + project_id text [not null, ref: > projects.id] + created_at text [not null] + indexes { + (project_id, created_at) + } +} + +Table intent_snapshots { + id text [pk, not null] + project_id text [not null, ref: > projects.id] + intent_id text [not null, ref: > intents.id] + seq integer [not null] + title text [not null] + body text [not null] + content_digest text [not null] + created_at text [not null] + indexes { + (intent_id, seq) [unique] + } +} + +Table intent_deferrals { + id text [pk, not null] + project_id text [not null, ref: > projects.id] + intent_id text [not null, ref: > intents.id] + operation_key text [not null] + body text [not null] + why text [not null] + boundary text [not null] + revisit_trigger text [not null] + stored_digest text [not null] + created_at text [not null] + indexes { + (project_id, operation_key) [unique] + (intent_id, created_at) + } +} + +Table intent_dispositions { + id text [pk, not null] + project_id text [not null, ref: > projects.id] + intent_id text [not null, ref: > intents.id] + seq integer [not null] + disposition text [not null] + reason text + deferral_id text [ref: > intent_deferrals.id] + supersedes_deferral_id text [ref: > intent_deferrals.id] + created_at text [not null] + indexes { + (intent_id, seq) [unique] + } +} + +Table intent_operations { + project_id text [not null, ref: > projects.id] + operation_key text [not null] + intent_id text [not null, ref: > intents.id] + stored_digest text [not null] + journal_entry_id text + spark_id text + projection_version integer [not null] + created_at text [not null] + updated_at text [not null] + indexes { + (project_id, operation_key) [pk] + (intent_id) + } +} + +Table explorations { + id text [pk, not null] + project_id text [not null, ref: > projects.id] + title text [not null] + created_at text [not null] + indexes { + (project_id, created_at) + } +} + +Table exploration_checkpoints { + id text [pk, not null] + project_id text [not null, ref: > projects.id] + exploration_id text [not null, ref: > explorations.id] + seq integer [not null] + purpose text [not null] + conclusions text [not null] + unresolved text [not null] + next_action text [not null] + operation_key text + content_digest text [not null] + created_at text [not null] + indexes { + (exploration_id, seq) [unique] + (project_id, operation_key) [unique] + } +} + +Table exploration_checkpoint_items { + id text [pk, not null] + project_id text [not null, ref: > projects.id] + checkpoint_id text [not null, ref: > exploration_checkpoints.id] + item_type text [not null] + position integer [not null] + content text [not null] + created_at text [not null] + indexes { + (checkpoint_id, position) [unique] + } +} + +Table logical_conversations { + id text [pk, not null] + project_id text [not null, ref: > projects.id] + title text [not null] + operation_key text + created_at text [not null] + indexes { + (project_id, operation_key) [unique] + } +} + +Table conversation_handles { + id text [pk, not null] + project_id text [not null, ref: > projects.id] + conversation_id text [not null, ref: > logical_conversations.id] + harness text [not null] + handle text [not null] + locality text + created_at text [not null] + indexes { + (conversation_id, harness, handle) [unique] + } +} + +Table conversation_log_refs { + id text [pk, not null] + project_id text [not null, ref: > projects.id] + handle_id text [not null, ref: > conversation_handles.id] + locator text [not null] + content_hash text + range_spec text + created_at text [not null] + indexes { + (handle_id, locator) [unique] + } +} + +Table exploration_conversations { + id text [pk, not null] + project_id text [not null, ref: > projects.id] + exploration_id text [not null, ref: > explorations.id] + conversation_id text [not null, ref: > logical_conversations.id] + created_at text [not null] + indexes { + (exploration_id, conversation_id) [unique] + (conversation_id) + } +} + +Table journal_conversation_handles { + id text [pk, not null] + project_id text [not null, ref: > projects.id] + journal_entry_id text [not null] + handle_id text [not null, ref: > conversation_handles.id] + created_at text [not null] + indexes { + (journal_entry_id, handle_id) [unique] + (handle_id) + } +} + +Table source_availability_observations { + id text [pk, not null] + project_id text [not null, ref: > projects.id] + subject_kind text [not null] + subject_id text [not null] + observed_at text [not null] + observer text + locality text + available integer [not null] + note text + created_at text [not null] + indexes { + (subject_kind, subject_id, observed_at) + } +} diff --git a/docs/schema/operational-state.mmd b/docs/schema/operational-state.mmd index 6cb2cad02..1e4a7ed13 100644 --- a/docs/schema/operational-state.mmd +++ b/docs/schema/operational-state.mmd @@ -480,3 +480,157 @@ erDiagram sources ||--o{ plans : bodies sources ||--o{ handoffs : bodies sources ||--o{ councils : bodies + intents { + text id PK + text project_id FK + text created_at + } + intent_snapshots { + text id PK + text project_id FK + text intent_id FK + integer seq + text title + text body + text content_digest + text created_at + } + intent_deferrals { + text id PK + text project_id FK + text intent_id FK + text operation_key + text body + text why + text boundary + text revisit_trigger + text stored_digest + text created_at + } + intent_dispositions { + text id PK + text project_id FK + text intent_id FK + integer seq + text disposition + text reason + text deferral_id FK + text supersedes_deferral_id FK + text created_at + } + intent_operations { + text project_id FK + text operation_key + text intent_id FK + text stored_digest + text journal_entry_id + text spark_id + integer projection_version + text created_at + text updated_at + } + explorations { + text id PK + text project_id FK + text title + text created_at + } + exploration_checkpoints { + text id PK + text project_id FK + text exploration_id FK + integer seq + text purpose + text conclusions + text unresolved + text next_action + text operation_key + text content_digest + text created_at + } + exploration_checkpoint_items { + text id PK + text project_id FK + text checkpoint_id FK + text item_type + integer position + text content + text created_at + } + logical_conversations { + text id PK + text project_id FK + text title + text operation_key + text created_at + } + conversation_handles { + text id PK + text project_id FK + text conversation_id FK + text harness + text handle + text locality + text created_at + } + conversation_log_refs { + text id PK + text project_id FK + text handle_id FK + text locator + text content_hash + text range_spec + text created_at + } + exploration_conversations { + text id PK + text project_id FK + text exploration_id FK + text conversation_id FK + text created_at + } + journal_conversation_handles { + text id PK + text project_id FK + text journal_entry_id + text handle_id FK + text created_at + } + source_availability_observations { + text id PK + text project_id FK + text subject_kind + text subject_id + text observed_at + text observer + text locality + integer available + text note + text created_at + } + projects ||--o{ intents : scopes + projects ||--o{ intent_snapshots : scopes + projects ||--o{ intent_deferrals : scopes + projects ||--o{ intent_dispositions : scopes + projects ||--o{ intent_operations : scopes + projects ||--o{ explorations : scopes + projects ||--o{ exploration_checkpoints : scopes + projects ||--o{ exploration_checkpoint_items : scopes + projects ||--o{ logical_conversations : scopes + projects ||--o{ conversation_handles : scopes + projects ||--o{ conversation_log_refs : scopes + projects ||--o{ exploration_conversations : scopes + projects ||--o{ journal_conversation_handles : scopes + projects ||--o{ source_availability_observations : scopes + intents ||--o{ intent_snapshots : revises + intents ||--o{ intent_deferrals : defers + intents ||--o{ intent_dispositions : disposes + intents ||--o{ intent_operations : maps + intent_deferrals ||--o{ intent_dispositions : anchors + explorations ||--o{ exploration_checkpoints : checkpoints + explorations ||--o{ exploration_conversations : spans + exploration_checkpoints ||--o{ exploration_checkpoint_items : contains + logical_conversations ||--o{ conversation_handles : carries + logical_conversations ||--o{ exploration_conversations : joins + conversation_handles ||--o{ conversation_log_refs : locates + conversation_handles ||--o{ journal_conversation_handles : correlates diff --git a/internal/cli/agent_help.go b/internal/cli/agent_help.go index 15cc3fc92..860c1ca76 100644 --- a/internal/cli/agent_help.go +++ b/internal/cli/agent_help.go @@ -62,6 +62,8 @@ func agentHelpCommands() []agentHelpCommand { Options: []agentHelpOption{ {Flags: "--to <target>", Description: "Target to install to, or all"}, {Flags: "--upgrade", Description: "Upgrade already-installed targets"}, + {Flags: "--dry-run", Description: "With --upgrade, report the deterministic non-mutating upgrade plan without writing files, manifests, config, or state"}, + {Flags: "--json", Description: "With --dry-run, emit the plan as one JSON document with exact follow-up commands and consent_required"}, {Flags: "-y, --yes", Description: "Assume yes to safe project-file symlink migrations and destructive deprecation cleanup"}, {Flags: "--no-yes", Description: "Force prompt-style declines in non-interactive mode"}, }, @@ -93,6 +95,7 @@ func agentHelpCommands() []agentHelpCommand { {Name: "migrate markdown", Description: "Import markdown artifacts into native SQLite state", Options: []agentHelpOption{{Flags: "--dry-run", Description: "Preview import work without creating SQLite state"}, {Flags: "--apply", Description: "Initialize SQLite and apply the import"}, {Flags: "--resume", Description: "Resume an interrupted import"}, {Flags: "--backup", Description: "Create SQLite and .agents rollback backups during apply or resume"}, {Flags: "--remove-source", Description: "Remove ephemeral Markdown sources after a rollback backup"}, {Flags: "--rollback <manifest>", Description: "Restore .agents files from a rollback manifest"}, {Flags: "--json", Description: "Output migration contract, scope, project context, counts, and rollback fields as JSON"}}}, {Name: "migrate storage-home", Description: "Copy legacy XDG_STATE_HOME SQLite state into the global XDG_DATA_HOME database", Options: []agentHelpOption{{Flags: "--dry-run", Description: "Preview migration work without copying"}, {Flags: "--apply", Description: "Copy or merge eligible legacy state without deleting the source"}, {Flags: "--json", Description: "Output migration contract, global database paths, action, and project identity when available"}}}, {Name: "migrate schema", Description: "Preview or apply pending SQLite schema upgrades with a verified backup before mutation", Options: []agentHelpOption{{Flags: "--dry-run", Description: "Preview pending schema upgrades without writing"}, {Flags: "--apply", Description: "Apply pending schema upgrades after creating and verifying a backup"}, {Flags: "--json", Description: "Output schema upgrade action, versions, pending migrations, backup, and verification as JSON"}}}, + {Name: "migrate deferrals", Description: "Convert historical journal deferrals into canonical deferred Intents; apply is backup-first, provenance-linking, legacy-preserving, and rerunnable", Options: []agentHelpOption{{Flags: "--dry-run", Description: "Report the project-specific conversion manifest without writing"}, {Flags: "--apply", Description: "Convert after creating and verifying a whole-database backup"}, {Flags: "--json", Description: "Output the conversion manifest, counts, backup, and project identity as JSON"}}}, {Name: "backup", Description: "Create a SQLite database backup with local rollback or operator-selected non-temporary external destination classification", Options: []agentHelpOption{{Flags: "--to <DIRECTORY>", Description: "Operator-selected non-temporary external destination directory; not proof of off-device protection"}, {Flags: "--json", Description: "Output backup verification, classification, readiness, checksum, journal watermark, and current project identity as JSON"}}}, {Name: "backup verify", Description: "Verify an existing SQLite database backup and report retrieval/recovery readiness", Options: []agentHelpOption{{Flags: "--json", Description: "Output schema version, SQLite validity, journal retrieval readiness, recovery readiness, watermark, and captured project identities as JSON"}}}, {Name: "backup restore", Description: "Run an isolated disposable restore rehearsal without activating or replacing the live database", Options: []agentHelpOption{{Flags: "<backup>", Description: "Verified backup path"}, {Flags: "--to <absolute-empty-database-path>", Description: "Required empty disposable restore target; never the live database"}, {Flags: "--json", Description: "Output isolated disposable rehearsal, exact-copy, integrity, retrieval, watermark, and live-database safety evidence; never activates the live database"}}}, @@ -235,7 +238,7 @@ func agentHelpCommands() []agentHelpCommand { {Flags: "--json", Description: "Output hook result, pass/block status, exit code, warnings, errors, and findings as JSON"}, }, }, - {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"}}}, + {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", @@ -279,6 +282,47 @@ func agentHelpCommands() []agentHelpCommand { {Name: "archive", Description: "Archive one or more ideas", Options: []agentHelpOption{{Flags: "--reason <text>", Description: "Archive reason"}, {Flags: "--json", Description: "Output archive result, archived ideas, global database scope, and project identity as JSON"}}}, }, }, + { + Name: "intent", + Description: "Manage tracked Intent; disposition is derived from append-only facts with no mutable lifecycle status", + Subcommands: []agentHelpSubcommand{ + {Name: "create", Description: "Create a tracked or deferred Intent snapshot plus its initial disposition in one transaction", Options: []agentHelpOption{{Flags: "--title <title>", Description: "Bounded single-line title"}, {Flags: "--body <body>", Description: "Self-sufficient body"}, {Flags: "--disposition <disposition>", Description: "tracked (default) or deferred"}, {Flags: "--why <why>", Description: "Why the deferred direction matters"}, {Flags: "--boundary <boundary>", Description: "What excluded it now"}, {Flags: "--trigger <trigger>", Description: "When to revisit"}, {Flags: "--operation-id <key>", Description: "Retry-safe operation key; required when deferred"}, {Flags: "--from <source>", Description: "Source spark, idea, brainstorm, or journal entry; repeatable"}, {Flags: "--reason <reason>", Description: "Optional reason recorded with the initial disposition"}, {Flags: "--json", Description: "Output the created or reused Intent, digests, and project identity as JSON"}}}, + {Name: "defer", Description: "Append an immutable four-field deferral payload to an existing Intent through the shared operation mapping", Options: []agentHelpOption{{Flags: "--why <why>", Description: "Why the direction matters"}, {Flags: "--boundary <boundary>", Description: "What excluded it now"}, {Flags: "--trigger <trigger>", Description: "When to revisit"}, {Flags: "--operation-id <key>", Description: "Retry-safe operation key"}, {Flags: "--json", Description: "Output the deferred Intent, digests, and project identity as JSON"}}}, + {Name: "resume", Description: "Append a tracked disposition linked to the deferral it supersedes; history is never overwritten", Options: []agentHelpOption{{Flags: "--reason <why now>", Description: "Why the Intent is tracked again"}, {Flags: "--json", Description: "Output the resumed Intent and project identity as JSON"}}}, + {Name: "resolve", Description: "Append a reasoned terminal disposition", Options: []agentHelpOption{{Flags: "--reason <outcome>", Description: "Resolution outcome"}, {Flags: "--json", Description: "Output the resolved Intent and project identity as JSON"}}}, + {Name: "show", Description: "Show one Intent with latest snapshot, derived disposition, deferral payload, and sources", Options: []agentHelpOption{{Flags: "--json", Description: "Output Intent detail, sources, and project identity as JSON"}}}, + {Name: "list", Description: "List Intents with derived dispositions in deterministic order", Options: []agentHelpOption{{Flags: "--disposition <disposition>", Description: "Filter by derived disposition: tracked, deferred, or resolved"}, {Flags: "--json", Description: "Output Intents and project identity as JSON"}}}, + }, + }, + { + Name: "intake", + Description: "Read the deterministic local intake projection of unresolved capture and tracked work; the CLI never ranks, promotes, or chooses a disposition", + Subcommands: []agentHelpSubcommand{ + {Name: "list", Description: "Project each unresolved spark, idea, brainstorm, intent, and unmigrated legacy deferral exactly once with provenance and exact read commands", Options: []agentHelpOption{{Flags: "--json", Description: "Output intake items and project identity as JSON"}}}, + }, + }, + { + Name: "exploration", + Description: "Manage relational Exploration continuity through immutable portable checkpoints; no lifecycle status or current pointer exists", + Subcommands: []agentHelpSubcommand{ + {Name: "create", Description: "Create an Exploration identity; sources map to explores or uses-source edges by kind", Options: []agentHelpOption{{Flags: "--title <title>", Description: "Bounded exploration title"}, {Flags: "--from <source>", Description: "Intent, journal entry, handoff, report, or finding reference; repeatable"}, {Flags: "--json", Description: "Output the created Exploration and project identity as JSON"}}}, + {Name: "checkpoint", Description: "Append one immutable checkpoint with the four required portable fields, each capped at 4096 UTF-8 bytes and never truncated", Options: []agentHelpOption{{Flags: "--purpose <text>", Description: "Current framing"}, {Flags: "--conclusions <text>", Description: "Conclusions or constraints so far"}, {Flags: "--unresolved <text>", Description: "Unresolved question or decision"}, {Flags: "--next <text>", Description: "Recommended next action"}, {Flags: "--item <type>:<content>", Description: "Ordered typed item (candidate or evidence); repeatable"}, {Flags: "--operation-id <key>", Description: "Retry-safe operation key"}, {Flags: "--json", Description: "Output the appended checkpoint and project identity as JSON"}}}, + {Name: "list", Description: "List Explorations with checkpoint counts and portable-context presence", Options: []agentHelpOption{{Flags: "--json", Description: "Output Explorations and project identity as JSON"}}}, + {Name: "context", Description: "Project portable context: the four-field core returns whole while every optional layer reports counts, truncation, a stable cursor, and an exact expansion command", Options: []agentHelpOption{{Flags: "--layer <name>", Description: "Select one layer: items, intents, evidence, or conversations"}, {Flags: "--cursor <cursor>", Description: "Continue the selected layer; requires --layer"}, {Flags: "--limit <n>", Description: "Maximum 1..100 items for the selected layer; requires --layer"}, {Flags: "--json", Description: "Output the portable context projection as JSON"}}}, + {Name: "conversation", Description: "Associate a logical conversation explicitly with add; membership is never inferred from branch, worktree, or recency", Options: []agentHelpOption{{Flags: "--json", Description: "Output the membership result as JSON"}}}, + }, + }, + { + Name: "conversation", + Description: "Manage logical conversations and machine-local provenance handles; a handle is optional evidence and never implies portable context", + Subcommands: []agentHelpSubcommand{ + {Name: "create", Description: "Create a logical conversation that may carry multiple harness-local handles", Options: []agentHelpOption{{Flags: "--title <label>", Description: "Conversation label"}, {Flags: "--operation-id <key>", Description: "Retry-safe operation key"}, {Flags: "--json", Description: "Output the created conversation and project identity as JSON"}}}, + {Name: "show", Description: "Show one conversation with handles, log refs, and latest observed availability", Options: []agentHelpOption{{Flags: "--json", Description: "Output the conversation and project identity as JSON"}}}, + {Name: "list", Description: "List logical conversations deterministically", Options: []agentHelpOption{{Flags: "--json", Description: "Output conversations and project identity as JSON"}}}, + {Name: "handle", Description: "Attach a machine-local handle with add: harness, opaque local ID, optional locality, and an optional bounded log locator with hash and range", Options: []agentHelpOption{{Flags: "--harness <harness>", Description: "Harness name, e.g. codex or claude-code"}, {Flags: "--handle <id>", Description: "Opaque machine-local conversation identifier"}, {Flags: "--locality <scope>", Description: "Machine or namespace scope"}, {Flags: "--log-ref <locator>", Description: "Bounded log locator, never transcript content"}, {Flags: "--hash <sha256>", Description: "Optional SHA-256 of the referenced log range"}, {Flags: "--range <range>", Description: "Optional bounded range"}, {Flags: "--json", Description: "Output the handle result and project identity as JSON"}}}, + {Name: "observe", Description: "Append an immutable timestamped availability observation for a handle or log ref; the observed row never mutates", Options: []agentHelpOption{{Flags: "--handle <handle-id>", Description: "Observed conversation handle ID"}, {Flags: "--log-ref <log-ref-id>", Description: "Observed log reference ID"}, {Flags: "--available", Description: "Source was reachable"}, {Flags: "--unavailable", Description: "Source was not reachable"}, {Flags: "--observer <name>", Description: "Observing agent or probe"}, {Flags: "--locality <scope>", Description: "Machine or namespace of the observation"}, {Flags: "--note <text>", Description: "Bounded observation note"}, {Flags: "--json", Description: "Output the observation result and project identity as JSON"}}}, + }, + }, { Name: "spark", Description: "Manage sparks", @@ -286,7 +330,7 @@ func agentHelpCommands() []agentHelpCommand { {Name: "list", Description: "List sparks from SQLite state", Options: []agentHelpOption{{Flags: "--all", Description: "Include done sparks"}, {Flags: "--status <status>", Description: "Filter by status"}, {Flags: "--json", Description: "Output sparks, global database scope, and project identity as JSON"}}}, {Name: "show", Description: "Show one spark from SQLite state", Options: []agentHelpOption{{Flags: "--json", Description: "Output spark details, relationships, global database scope, and project identity as JSON"}}}, {Name: "capture", Description: "Capture a spark in SQLite state", Options: []agentHelpOption{{Flags: "--scope <scope>", Description: "Spark scope"}, {Flags: "--text <text>", Description: "Spark text"}, {Flags: "--json", Description: "Output created spark, event, global database scope, and project identity as JSON"}}}, - {Name: "resolve", Description: "Resolve a spark", Options: []agentHelpOption{{Flags: "--reason <text>", Description: "Resolution reason"}, {Flags: "--json", Description: "Output resolution relationship, event, global database scope, and project identity as JSON"}}}, + {Name: "resolve", Description: "Resolve a spark by linking it to the entity that resolves it", Options: []agentHelpOption{{Flags: "--by <entity>", Description: "Resolving entity reference (required)"}, {Flags: "--reason <text>", Description: "Resolution reason"}, {Flags: "--json", Description: "Output resolution relationship, event, global database scope, and project identity as JSON"}}}, {Name: "promote", Description: "Record spark-to-idea promotion", Options: []agentHelpOption{{Flags: "--to-idea <idea>", Description: "Target idea"}, {Flags: "--json", Description: "Output promotion relationship, global database scope, and project identity as JSON"}}}, }, }, diff --git a/internal/cli/authority.go b/internal/cli/authority.go index d38f255aa..61f4d7cc8 100644 --- a/internal/cli/authority.go +++ b/internal/cli/authority.go @@ -80,6 +80,11 @@ var basicCommandAuthorityPrefixes = []commandAuthorityPrefix{ {tokens: []string{"idea", "promote"}}, {tokens: []string{"idea", "resolve"}}, {tokens: []string{"idea", "show"}}, + {tokens: []string{"intent", "list"}}, + {tokens: []string{"intent", "show"}}, + {tokens: []string{"exploration", "list"}}, + {tokens: []string{"intake", "list"}}, + {tokens: []string{"conversation", "list"}}, {tokens: []string{"spark", "capture"}}, {tokens: []string{"spark", "list"}}, {tokens: []string{"spark", "promote"}}, diff --git a/internal/cli/cli.go b/internal/cli/cli.go index cdac6e73c..da38a5465 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -303,6 +303,14 @@ func (r Runner) Run(args []string) error { dispatchErr = r.runArtifactEntityCommand("council", args[1:], out, runtime) case "idea": dispatchErr = r.runIdea(args[1:], out, runtime) + case "intent": + dispatchErr = r.runIntent(args[1:], out, runtime) + case "intake": + dispatchErr = r.runIntake(args[1:], out, runtime) + case "exploration": + dispatchErr = r.runExploration(args[1:], out, runtime) + case "conversation": + dispatchErr = r.runConversation(args[1:], out, runtime) case "spark": dispatchErr = r.runSpark(args[1:], out, runtime) case "tag": @@ -393,6 +401,10 @@ func writeRootHelp(out io.Writer) { fmt.Fprintln(out, " migrate Run migration workflows") fmt.Fprintln(out, " render Maintain durable markdown renders") fmt.Fprintln(out, " journal Record and read the project journal") + fmt.Fprintln(out, " intent Manage tracked Intent") + fmt.Fprintln(out, " exploration Manage Exploration continuity") + fmt.Fprintln(out, " conversation Manage conversation provenance") + fmt.Fprintln(out, " intake Read the local intake projection") fmt.Fprintln(out, " task Manage tasks") fmt.Fprintln(out, " spec Manage specs") fmt.Fprintln(out, " report Manage reports") @@ -3133,6 +3145,7 @@ func (r Runner) runStateMigrate(args []string, out io.Writer, runtime state.Runt "schema": writeStateMigrateSchemaHelp, "markdown": writeStateMigrateMarkdownHelp, "storage-home": writeStateMigrateStorageHomeHelp, + "deferrals": writeStateMigrateDeferralsHelp, }) { return nil } @@ -3143,6 +3156,8 @@ func (r Runner) runStateMigrate(args []string, out io.Writer, runtime state.Runt return r.runJournalFirstMigration(args[1:], out, runtime, "loaf state migrate journal-first") case "schema": return r.runSchemaUpgrade(args[1:], out, runtime, "loaf state migrate schema") + case "deferrals": + return r.runStateMigrateDeferrals(args[1:], out, runtime) case "markdown": return r.runStateMigrateMarkdown(args[1:], out, runtime) case "storage-home": @@ -6382,7 +6397,7 @@ func writeSparkCaptureHelp(out io.Writer) { } func writeSparkResolveHelp(out io.Writer) { - writeUsageHelp(out, "loaf spark resolve <spark> [--reason <text>] [--json]", "Resolve a spark.", "--reason Resolution reason", "--json Output resolution relationship, event, global database scope, and project identity as JSON") + writeUsageHelp(out, "loaf spark resolve <spark> --by <entity> [--reason <text>] [--json]", "Resolve a spark by linking it to the entity that resolves it.", "--by Resolving entity reference (required)", "--reason Resolution reason", "--json Output resolution relationship, event, global database scope, and project identity as JSON") } func writeSparkPromoteHelp(out io.Writer) { diff --git a/internal/cli/cli_reference.go b/internal/cli/cli_reference.go index 2abc28f2e..0a41dee23 100644 --- a/internal/cli/cli_reference.go +++ b/internal/cli/cli_reference.go @@ -58,6 +58,8 @@ func cliReferenceCommands() []cliReferenceCommand { Options: []cliReferenceOption{ {Flags: "--to <target>", Description: `Target to install to (or "all")`}, {Flags: "--upgrade", Description: "Update installed targets and apply deprecation-manifest cleanup"}, + {Flags: "--dry-run", Description: "With --upgrade, report the deterministic non-mutating upgrade plan: per-artifact actions, preserved conflicts, deprecations, project-file effects, and consent requirements"}, + {Flags: "--json", Description: "With --dry-run, emit the plan as one JSON document with exact follow-up commands and consent_required"}, {Flags: "--codex-basic-commands", Description: "Explicitly install the least-privilege Codex basic command policy (requires --to codex or --to all)"}, {Flags: "-y, --yes", Description: "Assume 'yes' to safe migrations and destructive deprecation cleanup"}, {Flags: "--no-yes", Description: "Force interactive prompts even when stdin is not a TTY (testing)"}, @@ -199,6 +201,11 @@ func cliReferenceCommands() []cliReferenceCommand { {Flags: "--apply", Description: "Apply pending schema upgrades after creating and verifying a backup"}, {Flags: "--json", Description: "Output schema upgrade action, versions, pending migrations, backup, and verification as JSON"}, }}, + {Name: "migrate deferrals", Description: "Convert historical journal deferrals into canonical deferred Intents; apply is backup-first, provenance-linking, legacy-preserving, and rerunnable", Options: []cliReferenceOption{ + {Flags: "--dry-run", Description: "Report the project-specific conversion manifest without writing"}, + {Flags: "--apply", Description: "Convert after creating and verifying a whole-database backup"}, + {Flags: "--json", Description: "Output the conversion manifest, counts, backup, and project identity as JSON"}, + }}, {Name: "migrate lifecycle-statuses", Description: "Normalize legacy lifecycle statuses in SQLite", Options: []cliReferenceOption{ {Flags: "--dry-run", Description: "Preview status normalization on a temporary database copy"}, {Flags: "--apply", Description: "Normalize live SQLite statuses after creating a backup"}, @@ -634,6 +641,111 @@ func cliReferenceCommands() []cliReferenceCommand { }}, }, }, + { + Name: "intent", + Description: "Manage tracked Intent in native SQLite state; disposition is derived from append-only facts", + Subcommands: []cliReferenceSubcommand{ + {Name: "create", Description: "Create a tracked or deferred Intent in one transaction", Options: []cliReferenceOption{ + {Flags: "--title <title>", Description: "Bounded single-line title"}, + {Flags: "--body <body>", Description: "Self-sufficient body"}, + {Flags: "--disposition <disposition>", Description: "tracked (default) or deferred"}, + {Flags: "--why <why>", Description: "Why the deferred direction matters"}, + {Flags: "--boundary <boundary>", Description: "What excluded it now"}, + {Flags: "--trigger <trigger>", Description: "When to revisit"}, + {Flags: "--operation-id <key>", Description: "Retry-safe operation key; required when deferred"}, + {Flags: "--from <source>", Description: "Source spark, idea, brainstorm, or journal entry; repeatable"}, + {Flags: "--reason <reason>", Description: "Optional reason recorded with the initial disposition"}, + {Flags: "--json", Description: "Output the created or reused Intent, digests, and project identity as JSON"}, + }}, + {Name: "defer", Description: "Append an immutable deferral to an existing Intent", Options: []cliReferenceOption{ + {Flags: "--why <why>", Description: "Why the direction matters"}, + {Flags: "--boundary <boundary>", Description: "What excluded it now"}, + {Flags: "--trigger <trigger>", Description: "When to revisit"}, + {Flags: "--operation-id <key>", Description: "Retry-safe operation key"}, + {Flags: "--json", Description: "Output the deferred Intent, digests, and project identity as JSON"}, + }}, + {Name: "resume", Description: "Append a tracked disposition linked to the deferral it supersedes", Options: []cliReferenceOption{ + {Flags: "--reason <why now>", Description: "Why the Intent is tracked again"}, + {Flags: "--json", Description: "Output the resumed Intent and project identity as JSON"}, + }}, + {Name: "resolve", Description: "Append a reasoned terminal disposition without overwriting history", Options: []cliReferenceOption{ + {Flags: "--reason <outcome>", Description: "Resolution outcome"}, + {Flags: "--json", Description: "Output the resolved Intent and project identity as JSON"}, + }}, + {Name: "show", Description: "Show one Intent with latest snapshot, derived disposition, deferral payload, and sources", Options: []cliReferenceOption{{Flags: "--json", Description: "Output Intent detail, sources, and project identity as JSON"}}}, + {Name: "list", Description: "List Intents with derived dispositions in deterministic order", Options: []cliReferenceOption{ + {Flags: "--disposition <disposition>", Description: "Filter by derived disposition: tracked, deferred, or resolved"}, + {Flags: "--json", Description: "Output Intents and project identity as JSON"}, + }}, + }, + }, + { + Name: "intake", + Description: "Read the deterministic local intake projection; triage judgment stays with humans and Skills", + Subcommands: []cliReferenceSubcommand{ + {Name: "list", Description: "Project each unresolved spark, idea, brainstorm, intent, and unmigrated legacy deferral exactly once with provenance and exact read commands", Options: []cliReferenceOption{{Flags: "--json", Description: "Output intake items and project identity as JSON"}}}, + }, + }, + { + Name: "exploration", + Description: "Manage relational Exploration continuity: immutable portable checkpoints, no lifecycle status, no current pointer", + Subcommands: []cliReferenceSubcommand{ + {Name: "create", Description: "Create an Exploration identity; sources map to explores or uses-source edges by kind", Options: []cliReferenceOption{ + {Flags: "--title <title>", Description: "Bounded exploration title"}, + {Flags: "--from <source>", Description: "Intent, journal entry, handoff, report, or finding reference; repeatable"}, + {Flags: "--json", Description: "Output the created Exploration and project identity as JSON"}, + }}, + {Name: "checkpoint", Description: "Append one immutable checkpoint; the four core fields are required, trimmed, and capped at 4096 UTF-8 bytes without truncation", Options: []cliReferenceOption{ + {Flags: "--purpose <text>", Description: "Current framing"}, + {Flags: "--conclusions <text>", Description: "Conclusions or constraints so far"}, + {Flags: "--unresolved <text>", Description: "Unresolved question or decision"}, + {Flags: "--next <text>", Description: "Recommended next action"}, + {Flags: "--item <type>:<content>", Description: "Ordered typed item (candidate or evidence); repeatable"}, + {Flags: "--operation-id <key>", Description: "Retry-safe operation key"}, + {Flags: "--json", Description: "Output the appended checkpoint and project identity as JSON"}, + }}, + {Name: "list", Description: "List Explorations with checkpoint counts and portable-context presence", Options: []cliReferenceOption{{Flags: "--json", Description: "Output Explorations and project identity as JSON"}}}, + {Name: "context", Description: "Project portable context: the four-field core returns whole; every optional layer reports counts, truncation, and an exact expansion command", Options: []cliReferenceOption{ + {Flags: "--layer <name>", Description: "Select one layer: items, intents, evidence, or conversations"}, + {Flags: "--cursor <cursor>", Description: "Continue the selected layer (requires --layer)"}, + {Flags: "--limit <n>", Description: "Maximum 1..100 items for the selected layer (requires --layer)"}, + {Flags: "--json", Description: "Output the portable context projection as JSON"}, + }}, + {Name: "conversation", Description: "Associate a logical conversation explicitly: loaf exploration conversation add <exploration> <conversation-id>", Options: []cliReferenceOption{{Flags: "--json", Description: "Output the membership result as JSON"}}}, + }, + }, + { + Name: "conversation", + Description: "Manage logical conversations and machine-local provenance handles; handles never imply portable context", + Subcommands: []cliReferenceSubcommand{ + {Name: "create", Description: "Create a logical conversation that may carry multiple harness-local handles", Options: []cliReferenceOption{ + {Flags: "--title <label>", Description: "Conversation label"}, + {Flags: "--operation-id <key>", Description: "Retry-safe operation key"}, + {Flags: "--json", Description: "Output the created conversation and project identity as JSON"}, + }}, + {Name: "show", Description: "Show one conversation with handles, log refs, and latest observed availability", Options: []cliReferenceOption{{Flags: "--json", Description: "Output the conversation and project identity as JSON"}}}, + {Name: "list", Description: "List logical conversations deterministically", Options: []cliReferenceOption{{Flags: "--json", Description: "Output conversations and project identity as JSON"}}}, + {Name: "handle", Description: "Attach a machine-local handle: loaf conversation handle add <conversation-id> --harness <h> --handle <id>", Options: []cliReferenceOption{ + {Flags: "--harness <harness>", Description: "Harness name, e.g. codex or claude-code"}, + {Flags: "--handle <id>", Description: "Opaque machine-local conversation identifier"}, + {Flags: "--locality <scope>", Description: "Machine or namespace scope for the handle"}, + {Flags: "--log-ref <locator>", Description: "Bounded log locator, never transcript content"}, + {Flags: "--hash <sha256>", Description: "Optional SHA-256 of the referenced log range"}, + {Flags: "--range <range>", Description: "Optional bounded range within the log"}, + {Flags: "--json", Description: "Output the handle result and project identity as JSON"}, + }}, + {Name: "observe", Description: "Append an immutable timestamped availability observation; the observed row never mutates", Options: []cliReferenceOption{ + {Flags: "--handle <handle-id>", Description: "Observed conversation handle ID"}, + {Flags: "--log-ref <log-ref-id>", Description: "Observed log reference ID"}, + {Flags: "--available", Description: "Record that the source was reachable"}, + {Flags: "--unavailable", Description: "Record that the source was not reachable"}, + {Flags: "--observer <name>", Description: "Observing agent or probe"}, + {Flags: "--locality <scope>", Description: "Machine or namespace of the observation"}, + {Flags: "--note <text>", Description: "Bounded observation note"}, + {Flags: "--json", Description: "Output the observation result and project identity as JSON"}, + }}, + }, + }, { Name: "spark", Description: "Manage sparks in native SQLite state", @@ -649,7 +761,8 @@ func cliReferenceCommands() []cliReferenceCommand { {Flags: "--text <text>", Description: "Spark text"}, {Flags: "--json", Description: "Output created spark, event, global database scope, and project identity as JSON"}, }}, - {Name: "resolve", Description: "Resolve a spark", Options: []cliReferenceOption{ + {Name: "resolve", Description: "Resolve a spark by linking it to the entity that resolves it", Options: []cliReferenceOption{ + {Flags: "--by <entity>", Description: "Resolving entity reference (required)"}, {Flags: "--reason <text>", Description: "Resolution reason"}, {Flags: "--json", Description: "Output resolution relationship, event, global database scope, and project identity as JSON"}, }}, @@ -724,6 +837,7 @@ func cliReferenceCommands() []cliReferenceCommand { {Flags: "--fix", Description: "Offer each safe repair and prompt y/N before applying it"}, {Flags: "--force", Description: "With --fix, apply every offered repair without prompting"}, {Flags: "--verbose", Description: "Print each check name even when passing"}, + {Flags: "--json", Description: "Output the identical check set as read-only JSON; never prompts or repairs"}, }, }, } @@ -733,7 +847,7 @@ func generateCLIReferenceSkill(commands []cliReferenceCommand) string { header := `--- name: loaf-reference description: >- - Documents how agents operate the Loaf CLI: command discovery via loaf --help, JSON diagnosis surfaces, guided config maintenance, and troubleshooting. Use when unsure which loaf command to invoke or how to validate project state. Not for workflow guidance (workflow skills own their CLI contracts) or build internals. + Documents how agents operate the Loaf CLI: command discovery via loaf --help, JSON diagnosis surfaces, config-aware maintenance, and troubleshooting. Use when unsure which loaf command to invoke, how to validate project state, or when asked to upgrade, diagnose, repair, configure, or bring a Loaf project current. Not for workflow guidance (workflow skills own their CLI contracts) or build internals. --- # Loaf Reference @@ -803,6 +917,7 @@ The Loaf operating manual for agents: how to discover commands, diagnose project "| Topic | Reference | Use When |", "|-------|-----------|----------|", "| Configuration maintenance | [references/configuration.md](references/configuration.md) | Checking whether a project's Loaf config is current and repairing it; wiring project-owned choices |", + "| Config-aware maintenance protocol | [references/maintenance.md](references/maintenance.md) | Upgrading, diagnosing, repairing, or bringing a project current: diagnose, plan, ask, apply, verify |", "| Command routing | [references/command-routing.md](references/command-routing.md) | Deciding which command a task needs; locating the JSON diagnosis surfaces |", "| Troubleshooting | [references/troubleshooting.md](references/troubleshooting.md) | Diagnosing state, config, or alignment failures; isolating a throwaway database |", "", diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 774c09ecf..f026fcfb2 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -44,10 +44,38 @@ type doctorCheck struct { } type doctorOptions struct { - fix bool - force bool - verbose bool - help bool + fix bool + force bool + verbose bool + help bool + jsonOutput bool +} + +// doctorJSONCheck mirrors one human check line: identical identity, status, +// and messaging, with repair identity exposed for maintenance planning. +type doctorJSONCheck struct { + Name string `json:"name"` + Description string `json:"description"` + Status string `json:"status"` + Message string `json:"message,omitempty"` + Detail string `json:"detail,omitempty"` + Fixable bool `json:"fixable"` + RepairID string `json:"repair_id,omitempty"` + Repair string `json:"repair,omitempty"` +} + +// doctorJSONOutput is the read-only machine diagnosis: it never prompts and +// never repairs; any mutation still requires the explicit --fix contract. +type doctorJSONOutput struct { + ContractVersion int `json:"contract_version"` + Command string `json:"command"` + CLIVersion string `json:"cli_version,omitempty"` + Checks []doctorJSONCheck `json:"checks"` + Passes int `json:"passes"` + Warnings int `json:"warnings"` + Failures int `json:"failures"` + Skips int `json:"skips"` + Passed bool `json:"passed"` } type doctorReport struct { @@ -77,6 +105,16 @@ func (r Runner) runDoctor(args []string, out io.Writer, runtimeRoot string) erro if distributionRoot, err := r.resolveInstalledDistributionRoot(); err == nil { cliVersion = packageVersion(distributionRoot) } + if options.jsonOutput { + result := runDoctorChecksJSON(doctorContext{projectRoot: runtimeRoot}, cliVersion) + if err := writeJSON(out, result); err != nil { + return err + } + if result.Failures > 0 { + return ExitError{Code: 1} + } + return nil + } report := runDoctorChecks(out, doctorContext{projectRoot: runtimeRoot}, options, cliVersion, r.Stdin) if report.Failures > 0 { return ExitError{Code: 1} @@ -84,6 +122,42 @@ func (r Runner) runDoctor(args []string, out io.Writer, runtimeRoot string) erro return nil } +// runDoctorChecksJSON runs the identical check set with no prompt or repair +// path reachable: it reads project state and reports facts. +func runDoctorChecksJSON(ctx doctorContext, cliVersion string) doctorJSONOutput { + output := doctorJSONOutput{ + ContractVersion: 1, + Command: "doctor", + CLIVersion: cliVersion, + Checks: []doctorJSONCheck{}, + } + for _, check := range doctorChecks(cliVersion) { + result := safeRunDoctorCheck(check, ctx) + output.Checks = append(output.Checks, doctorJSONCheck{ + Name: check.Name, + Description: check.Description, + Status: string(result.Status), + Message: result.Message, + Detail: result.Detail, + Fixable: result.Fixable, + RepairID: check.RepairID, + Repair: check.Repair, + }) + switch result.Status { + case doctorPass: + output.Passes++ + case doctorWarn: + output.Warnings++ + case doctorFail: + output.Failures++ + case doctorSkip: + output.Skips++ + } + } + output.Passed = output.Failures == 0 + return output +} + func parseLoafDoctorArgs(args []string) (doctorOptions, error) { var options doctorOptions for _, arg := range args { @@ -94,6 +168,8 @@ func parseLoafDoctorArgs(args []string) (doctorOptions, error) { options.force = true case "--verbose": options.verbose = true + case "--json": + options.jsonOutput = true case "--help", "-h": options.help = true default: @@ -103,16 +179,20 @@ func parseLoafDoctorArgs(args []string) (doctorOptions, error) { if options.force && !options.fix { return options, fmt.Errorf("--force requires --fix (usage: loaf doctor --fix --force)") } + if options.jsonOutput && options.fix { + return options, fmt.Errorf("--json is read-only diagnosis and cannot be combined with --fix; apply repairs through the explicit `loaf doctor --fix` contract") + } return options, nil } func writeDoctorHelp(out io.Writer) { - fmt.Fprint(out, "Usage: loaf doctor [--fix [--force]] [--verbose]\n\n") + fmt.Fprint(out, "Usage: loaf doctor [--fix [--force]] [--verbose] [--json]\n\n") fmt.Fprint(out, "Diagnose Loaf project alignment (symlinks, stale files, version drift)\n\n") fmt.Fprint(out, "Options:\n") fmt.Fprint(out, " --fix Offer each safe repair and prompt y/N before applying it\n") fmt.Fprint(out, " --force With --fix, apply every offered repair without prompting\n") fmt.Fprint(out, " --verbose Print each check name even when passing\n") + fmt.Fprint(out, " --json Output the identical check set as read-only JSON; never prompts or repairs\n") fmt.Fprint(out, " -h, --help Show help\n") } diff --git a/internal/cli/doctor_json_test.go b/internal/cli/doctor_json_test.go new file mode 100644 index 000000000..df48075a1 --- /dev/null +++ b/internal/cli/doctor_json_test.go @@ -0,0 +1,113 @@ +package cli + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" +) + +func doctorFixtureProject(t *testing.T) string { + t.Helper() + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".agents"), 0o755); err != nil { + t.Fatalf("mkdir .agents: %v", err) + } + // A canonical AGENTS.md plus a stale legacy artifact so the fixture + // produces a mix of pass and non-pass outcomes. + if err := os.WriteFile(filepath.Join(root, "AGENTS.md"), []byte("# Agents\n"), 0o644); err != nil { + t.Fatalf("write AGENTS.md: %v", err) + } + if err := os.WriteFile(filepath.Join(root, ".cursorrules"), []byte("stale\n"), 0o644); err != nil { + t.Fatalf("write legacy file: %v", err) + } + return root +} + +func hashDirectoryTree(t *testing.T, root string) string { + t.Helper() + digest := sha256.New() + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + relative, err := filepath.Rel(root, path) + if err != nil { + return err + } + fmt.Fprintln(digest, relative) + if entry.Type().IsRegular() { + content, err := os.ReadFile(path) + if err != nil { + return err + } + digest.Write(content) + } + return nil + }) + if err != nil { + t.Fatalf("hash fixture tree: %v", err) + } + return hex.EncodeToString(digest.Sum(nil)) +} + +func TestDoctorJSONMatchesHumanChecksAndNeverMutates(t *testing.T) { + root := doctorFixtureProject(t) + ctx := doctorContext{projectRoot: root} + cliVersion := "2.0.0-test" + + before := hashDirectoryTree(t, root) + jsonResult := runDoctorChecksJSON(ctx, cliVersion) + after := hashDirectoryTree(t, root) + if before != after { + t.Fatal("JSON doctor mutated the project tree") + } + + checks := doctorChecks(cliVersion) + if len(jsonResult.Checks) != len(checks) { + t.Fatalf("JSON checks = %d, want %d", len(jsonResult.Checks), len(checks)) + } + tally := map[string]int{} + for index, check := range checks { + human := safeRunDoctorCheck(check, ctx) + got := jsonResult.Checks[index] + if got.Name != check.Name || got.Status != string(human.Status) || got.Message != human.Message || got.Fixable != human.Fixable { + t.Fatalf("check %q JSON = %#v, want status %q message %q fixable %t", check.Name, got, human.Status, human.Message, human.Fixable) + } + tally[string(human.Status)]++ + } + if jsonResult.Passes != tally["pass"] || jsonResult.Warnings != tally["warn"] || jsonResult.Failures != tally["fail"] || jsonResult.Skips != tally["skip"] { + t.Fatalf("JSON tallies = %d/%d/%d/%d, want %v", jsonResult.Passes, jsonResult.Warnings, jsonResult.Failures, jsonResult.Skips, tally) + } + if jsonResult.Passed != (jsonResult.Failures == 0) { + t.Fatalf("Passed = %t with %d failures", jsonResult.Passed, jsonResult.Failures) + } + if jsonResult.ContractVersion != 1 || jsonResult.Command != "doctor" { + t.Fatalf("JSON envelope = %#v, want contract_version 1 command doctor", jsonResult) + } + + // The human path over the same fixture reports the same outcome counts. + var humanOut bytes.Buffer + report := runDoctorChecks(&humanOut, ctx, doctorOptions{}, cliVersion, strings.NewReader("")) + if report.Passes != jsonResult.Passes || report.Warnings != jsonResult.Warnings || report.Failures != jsonResult.Failures || report.Skips != jsonResult.Skips { + t.Fatalf("human report = %+v, JSON = %d/%d/%d/%d; outcomes must match", report, jsonResult.Passes, jsonResult.Warnings, jsonResult.Failures, jsonResult.Skips) + } +} + +func TestDoctorJSONRejectsFixCombination(t *testing.T) { + if _, err := parseLoafDoctorArgs([]string{"--json", "--fix"}); err == nil { + t.Fatal("--json --fix accepted; JSON diagnosis must never reach repair code") + } + options, err := parseLoafDoctorArgs([]string{"--json"}) + if err != nil { + t.Fatalf("parse --json error = %v", err) + } + if !options.jsonOutput { + t.Fatal("--json flag not recorded") + } +} diff --git a/internal/cli/exploration.go b/internal/cli/exploration.go new file mode 100644 index 000000000..1106d8b87 --- /dev/null +++ b/internal/cli/exploration.go @@ -0,0 +1,752 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + + "github.com/levifig/loaf/internal/state" +) + +func (r Runner) runExploration(args []string, out io.Writer, runtime state.Runtime) error { + if len(args) == 0 || isHelpArg(args) { + writeExplorationHelp(out) + return nil + } + if writeNestedHelp(out, args, map[string]func(io.Writer){ + "create": writeExplorationCreateHelp, + "checkpoint": writeExplorationCheckpointHelp, + "list": writeExplorationListHelp, + "context": writeExplorationContextHelp, + "conversation": writeExplorationConversationHelp, + }) { + return nil + } + switch args[0] { + case "create": + return r.runExplorationCreate(args[1:], out, runtime) + case "checkpoint": + return r.runExplorationCheckpoint(args[1:], out, runtime) + case "list": + return r.runExplorationList(args[1:], out, runtime) + case "context": + return r.runExplorationContext(args[1:], out, runtime) + case "conversation": + return r.runExplorationConversation(args[1:], out, runtime) + default: + return unknownSubcommandError("exploration", args[0]) + } +} + +func writeExplorationHelp(out io.Writer) { + writeCommandGroupHelp(out, "loaf exploration <subcommand> [options]", "Manage relational Exploration continuity: immutable portable checkpoints, no lifecycle status, no current pointer.", []subcommandHelpItem{ + {Name: "create", Summary: "Create an Exploration identity"}, + {Name: "checkpoint", Summary: "Append an immutable portable checkpoint"}, + {Name: "list", Summary: "List Explorations with checkpoint counts"}, + {Name: "context", Summary: "Project portable context with bounded layers"}, + {Name: "conversation", Summary: "Associate a logical conversation (add)"}, + }) +} + +func writeExplorationCreateHelp(out io.Writer) { + writeUsageHelp(out, "loaf exploration create --title <title> [--from <intent-or-source>]... [--json]", "Create an Exploration identity; sources map to explores or uses-source edges by kind.", + "--title Bounded exploration title", + "--from Intent, journal entry, handoff, report, or finding reference; repeatable", + "--json Output the created Exploration and project identity as JSON") +} + +func writeExplorationCheckpointHelp(out io.Writer) { + writeUsageHelp(out, "loaf exploration checkpoint <exploration> --purpose <text> --conclusions <text> --unresolved <text> --next <text> [--item <type>:<content>]... [--operation-id <key>] [--json]", "Append one immutable checkpoint; all four core fields are required, trimmed, and capped at 4096 UTF-8 bytes without truncation.", + "--purpose Current framing", + "--conclusions Conclusions or constraints so far", + "--unresolved Unresolved question or decision", + "--next Recommended next action", + "--item Ordered typed item, e.g. candidate:<text> or evidence:<text>; repeatable", + "--operation-id Retry-safe operation key", + "--json Output the appended checkpoint and project identity as JSON") +} + +func writeExplorationListHelp(out io.Writer) { + writeUsageHelp(out, "loaf exploration list [--json]", "List Explorations with checkpoint counts and portable-context presence.", + "--json Output Explorations and project identity as JSON") +} + +func writeExplorationContextHelp(out io.Writer) { + writeUsageHelp(out, "loaf exploration context <exploration> [--layer items|intents|evidence|conversations --cursor <cursor> --limit <n>] [--json]", "Project portable context: the four-field core is returned whole; every optional layer reports counts, truncation, and an exact expansion command.", + "--layer Select one optional layer for expansion", + "--cursor Continue the selected layer (requires --layer)", + "--limit Maximum 1..100 items for the selected layer (requires --layer)", + "--json Output the portable context projection as JSON") +} + +func writeExplorationConversationHelp(out io.Writer) { + writeUsageHelp(out, "loaf exploration conversation add <exploration> <conversation-id> [--json]", "Explicitly associate a logical conversation; membership is never inferred from branch, worktree, or recency.", + "--json Output the membership result as JSON") +} + +func (r Runner) runExplorationCreate(args []string, out io.Writer, runtime state.Runtime) error { + options := state.ExplorationCreateOptions{} + jsonOutput := false + for i := 0; i < len(args); i++ { + switch args[i] { + case "--json": + jsonOutput = true + case "--title": + value, err := consumeFlagValue(args, &i, "--title") + if err != nil { + return err + } + options.Title = value + case "--from": + value, err := consumeFlagValue(args, &i, "--from") + if err != nil { + return err + } + options.Sources = append(options.Sources, value) + default: + return fmt.Errorf("unknown option %q", args[i]) + } + } + if strings.TrimSpace(options.Title) == "" { + return fmt.Errorf("exploration create requires --title") + } + projectRoot, err := r.requireIntentSQLiteState("exploration create", runtime) + if err != nil { + return err + } + result, err := state.CreateExploration(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, options) + if err != nil { + return err + } + if jsonOutput { + return writeJSON(out, result) + } + writeExplorationMutation(out, result) + return nil +} + +func (r Runner) runExplorationCheckpoint(args []string, out io.Writer, runtime state.Runtime) error { + options := state.ExplorationCheckpointOptions{} + jsonOutput := false + for i := 0; i < len(args); i++ { + switch args[i] { + case "--json": + jsonOutput = true + case "--purpose": + value, err := consumeFlagValue(args, &i, "--purpose") + if err != nil { + return err + } + options.Purpose = value + case "--conclusions": + value, err := consumeFlagValue(args, &i, "--conclusions") + if err != nil { + return err + } + options.Conclusions = value + case "--unresolved": + value, err := consumeFlagValue(args, &i, "--unresolved") + if err != nil { + return err + } + options.Unresolved = value + case "--next": + value, err := consumeFlagValue(args, &i, "--next") + if err != nil { + return err + } + options.NextAction = value + case "--operation-id": + value, err := consumeFlagValue(args, &i, "--operation-id") + if err != nil { + return err + } + options.OperationID = value + case "--item": + value, err := consumeFlagValue(args, &i, "--item") + if err != nil { + return err + } + itemType, content, found := strings.Cut(value, ":") + if !found || strings.TrimSpace(itemType) == "" || strings.TrimSpace(content) == "" { + return fmt.Errorf("--item requires <type>:<content>, got %q", value) + } + options.Items = append(options.Items, state.CheckpointItemInput{Type: strings.TrimSpace(itemType), Content: strings.TrimSpace(content)}) + default: + if strings.HasPrefix(args[i], "-") { + return fmt.Errorf("unknown option %q", args[i]) + } + if options.ExplorationRef != "" { + return fmt.Errorf("exploration checkpoint accepts one exploration reference") + } + options.ExplorationRef = args[i] + } + } + if options.ExplorationRef == "" { + return fmt.Errorf("exploration checkpoint requires an exploration reference") + } + projectRoot, err := r.requireIntentSQLiteState("exploration checkpoint", runtime) + if err != nil { + return err + } + result, err := state.AppendExplorationCheckpoint(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, options) + if err != nil { + return err + } + if jsonOutput { + return writeJSON(out, result) + } + writeExplorationMutation(out, result) + return nil +} + +func (r Runner) runExplorationList(args []string, out io.Writer, runtime state.Runtime) error { + jsonOutput := false + for _, arg := range args { + if arg == "--json" { + jsonOutput = true + continue + } + return fmt.Errorf("unknown option %q", arg) + } + projectRoot, err := r.requireIntentSQLiteState("exploration list", runtime) + if err != nil { + return err + } + result, err := state.ListExplorations(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}) + if err != nil { + return err + } + if jsonOutput { + return writeJSON(out, result) + } + if len(result.Explorations) == 0 { + fmt.Fprintln(out, "no explorations found") + } + for _, item := range result.Explorations { + ref := item.Alias + if ref == "" { + ref = item.ID + } + fmt.Fprintf(out, "%s checkpoints=%d portable=%t %s\n", ref, item.CheckpointCount, item.PortableContextPresent, item.Title) + } + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + return nil +} + +func (r Runner) runExplorationContext(args []string, out io.Writer, runtime state.Runtime) error { + options := state.ExplorationContextOptions{} + jsonOutput := false + for i := 0; i < len(args); i++ { + switch args[i] { + case "--json": + jsonOutput = true + case "--layer": + value, err := consumeFlagValue(args, &i, "--layer") + if err != nil { + return err + } + options.Layer = value + case "--cursor": + value, err := consumeFlagValue(args, &i, "--cursor") + if err != nil { + return err + } + options.Cursor = value + case "--limit": + value, err := consumeFlagValue(args, &i, "--limit") + if err != nil { + return err + } + limit, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("--limit requires an integer, got %q", value) + } + options.Limit = limit + default: + if strings.HasPrefix(args[i], "-") { + return fmt.Errorf("unknown option %q", args[i]) + } + if options.ExplorationRef != "" { + return fmt.Errorf("exploration context accepts one exploration reference") + } + options.ExplorationRef = args[i] + } + } + if options.ExplorationRef == "" { + return fmt.Errorf("exploration context requires an exploration reference") + } + projectRoot, err := r.requireIntentSQLiteState("exploration context", runtime) + if err != nil { + return err + } + result, err := state.ExplorationContext(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, options) + if err != nil { + return err + } + if jsonOutput { + return writeJSON(out, result) + } + writeExplorationContextHuman(out, result) + return nil +} + +func (r Runner) runExplorationConversation(args []string, out io.Writer, runtime state.Runtime) error { + if len(args) == 0 || args[0] != "add" { + return fmt.Errorf("usage: loaf exploration conversation add <exploration> <conversation-id> [--json]") + } + args = args[1:] + jsonOutput := false + positional := []string{} + for _, arg := range args { + if arg == "--json" { + jsonOutput = true + continue + } + if strings.HasPrefix(arg, "-") { + return fmt.Errorf("unknown option %q", arg) + } + positional = append(positional, arg) + } + if len(positional) != 2 { + return fmt.Errorf("exploration conversation add requires <exploration> and <conversation-id>") + } + projectRoot, err := r.requireIntentSQLiteState("exploration conversation add", runtime) + if err != nil { + return err + } + result, err := state.AddExplorationConversation(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, positional[0], positional[1]) + if err != nil { + return err + } + if jsonOutput { + return writeJSON(out, result) + } + fmt.Fprintf(out, "exploration: %s\nconversation: %s\ncreated: %t\n", result.ExplorationID, result.Conversation.ID, result.Created) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + return nil +} + +func writeExplorationMutation(out io.Writer, result state.ExplorationMutationResult) { + ref := result.Exploration.Alias + if ref == "" { + ref = result.Exploration.ID + } + if result.Created { + fmt.Fprintln(out, "created") + } else { + fmt.Fprintln(out, "reused (operation key already established)") + } + fmt.Fprintf(out, "exploration: %s\n", ref) + fmt.Fprintf(out, "title: %s\n", result.Exploration.Title) + fmt.Fprintf(out, "portable context present: %t\n", result.Exploration.PortableContextPresent) + if result.Checkpoint != nil { + fmt.Fprintf(out, "checkpoint seq: %d\n", result.Checkpoint.Seq) + fmt.Fprintf(out, "next action: %s\n", result.Checkpoint.NextAction) + } + if result.OperationID != "" { + fmt.Fprintf(out, "operation: %s\n", result.OperationID) + fmt.Fprintf(out, "digest match: %t\n", result.InputDigestMatches) + } + fmt.Fprintf(out, "read: loaf exploration context %s\n", ref) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) +} + +func writeExplorationContextHuman(out io.Writer, result state.ExplorationContextResult) { + ref := result.Exploration.Alias + if ref == "" { + ref = result.Exploration.ID + } + fmt.Fprintf(out, "exploration: %s\n", ref) + fmt.Fprintf(out, "title: %s\n", result.Exploration.Title) + fmt.Fprintf(out, "portable context present: %t\n", result.PortableContextPresent) + if result.Checkpoint != nil { + fmt.Fprintf(out, "checkpoint seq: %d (%s)\n", result.Checkpoint.Seq, result.Checkpoint.CreatedAt) + fmt.Fprintf(out, "purpose: %s\n", result.Checkpoint.Purpose) + fmt.Fprintf(out, "conclusions: %s\n", result.Checkpoint.Conclusions) + fmt.Fprintf(out, "unresolved: %s\n", result.Checkpoint.Unresolved) + fmt.Fprintf(out, "next action: %s\n", result.Checkpoint.NextAction) + } else { + fmt.Fprintln(out, "no portable checkpoint; source handles alone never imply resumable context") + } + for _, layer := range []string{"items", "intents", "evidence", "conversations"} { + built, ok := result.Layers[layer] + if !ok { + continue + } + fmt.Fprintf(out, "%s: showing %d of %d\n", layer, built.Shown, built.Available) + var rendered []map[string]any + if err := json.Unmarshal(built.Items, &rendered); err == nil { + for _, item := range rendered { + fmt.Fprintf(out, " %s\n", compactContextItem(layer, item)) + } + } + if built.Truncated && built.ExpandCommand != "" { + fmt.Fprintf(out, " more: %s\n", built.ExpandCommand) + } + } + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) +} + +func compactContextItem(layer string, item map[string]any) string { + get := func(key string) string { + value, _ := item[key].(string) + return value + } + switch layer { + case "items": + return fmt.Sprintf("%v. %s: %s", item["position"], get("type"), get("content")) + case "intents": + return fmt.Sprintf("%s %s (%s) %s", get("relationship"), firstNonEmpty(get("alias"), get("id")), get("disposition"), get("title")) + case "evidence": + return fmt.Sprintf("%s %s %s %s", get("relationship"), get("kind"), get("id"), get("title")) + case "conversations": + title := get("title") + handles, _ := item["handles"].([]any) + return fmt.Sprintf("%s (%d handles)", title, len(handles)) + default: + return fmt.Sprintf("%v", item) + } +} + +func (r Runner) runConversation(args []string, out io.Writer, runtime state.Runtime) error { + if len(args) == 0 || isHelpArg(args) { + writeConversationHelp(out) + return nil + } + if writeNestedHelp(out, args, map[string]func(io.Writer){ + "create": writeConversationCreateHelp, + "show": writeConversationShowHelp, + "list": writeConversationListHelp, + "handle": writeConversationHandleHelp, + "observe": writeConversationObserveHelp, + }) { + return nil + } + switch args[0] { + case "create": + return r.runConversationCreate(args[1:], out, runtime) + case "show": + return r.runConversationShow(args[1:], out, runtime) + case "list": + return r.runConversationList(args[1:], out, runtime) + case "handle": + return r.runConversationHandle(args[1:], out, runtime) + case "observe": + return r.runConversationObserve(args[1:], out, runtime) + default: + return unknownSubcommandError("conversation", args[0]) + } +} + +func writeConversationHelp(out io.Writer) { + writeCommandGroupHelp(out, "loaf conversation <subcommand> [options]", "Manage logical conversations and machine-local provenance handles. Handles are optional evidence and never imply portable context.", []subcommandHelpItem{ + {Name: "create", Summary: "Create a logical conversation"}, + {Name: "show", Summary: "Show one conversation with handles and log refs"}, + {Name: "list", Summary: "List logical conversations"}, + {Name: "handle", Summary: "Attach a machine-local handle (add)"}, + {Name: "observe", Summary: "Append an immutable availability observation"}, + }) +} + +func writeConversationCreateHelp(out io.Writer) { + writeUsageHelp(out, "loaf conversation create --title <label> [--operation-id <key>] [--json]", "Create a logical conversation that may carry multiple harness-local handles.", + "--title Conversation label", + "--operation-id Retry-safe operation key", + "--json Output the created conversation and project identity as JSON") +} + +func writeConversationShowHelp(out io.Writer) { + writeUsageHelp(out, "loaf conversation show <conversation-id> [--json]", "Show one conversation with handles, log refs, and latest observed availability.", + "--json Output the conversation and project identity as JSON") +} + +func writeConversationListHelp(out io.Writer) { + writeUsageHelp(out, "loaf conversation list [--json]", "List logical conversations deterministically.", + "--json Output conversations and project identity as JSON") +} + +func writeConversationHandleHelp(out io.Writer) { + writeUsageHelp(out, "loaf conversation handle add <conversation-id> --harness <harness> --handle <opaque-local-id> [--locality <machine-or-namespace>] [--log-ref <locator> [--hash <sha256>] [--range <range>]] [--json]", "Attach one machine-local handle and optional bounded log reference; nothing is inferred from the current session.", + "--harness Harness name, e.g. codex or claude-code", + "--handle Opaque machine-local conversation identifier", + "--locality Machine or namespace scope for the handle", + "--log-ref Bounded log locator (never transcript content)", + "--hash Optional SHA-256 of the referenced log range", + "--range Optional bounded range within the log", + "--json Output the handle result and project identity as JSON") +} + +func writeConversationObserveHelp(out io.Writer) { + writeUsageHelp(out, "loaf conversation observe (--handle <handle-id> | --log-ref <log-ref-id>) (--available | --unavailable) [--observer <name>] [--locality <scope>] [--note <text>] [--json]", "Append an immutable timestamped availability observation; the observed row itself never mutates.", + "--handle Observed conversation handle ID", + "--log-ref Observed log reference ID", + "--available Record that the source was reachable", + "--unavailable Record that the source was not reachable", + "--observer Observing agent or probe", + "--locality Machine or namespace of the observation", + "--note Bounded observation note", + "--json Output the observation result and project identity as JSON") +} + +func (r Runner) runConversationCreate(args []string, out io.Writer, runtime state.Runtime) error { + options := state.ConversationCreateOptions{} + jsonOutput := false + for i := 0; i < len(args); i++ { + switch args[i] { + case "--json": + jsonOutput = true + case "--title": + value, err := consumeFlagValue(args, &i, "--title") + if err != nil { + return err + } + options.Title = value + case "--operation-id": + value, err := consumeFlagValue(args, &i, "--operation-id") + if err != nil { + return err + } + options.OperationID = value + default: + return fmt.Errorf("unknown option %q", args[i]) + } + } + projectRoot, err := r.requireIntentSQLiteState("conversation create", runtime) + if err != nil { + return err + } + result, err := state.CreateConversation(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, options) + if err != nil { + return err + } + if jsonOutput { + return writeJSON(out, result) + } + fmt.Fprintf(out, "conversation: %s\ntitle: %s\ncreated: %t\n", result.Conversation.ID, result.Conversation.Title, result.Created) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + return nil +} + +func (r Runner) runConversationShow(args []string, out io.Writer, runtime state.Runtime) error { + ref, jsonOutput, err := parseSingleRefArgs("conversation show", args) + if err != nil { + return err + } + projectRoot, err := r.requireIntentSQLiteState("conversation show", runtime) + if err != nil { + return err + } + result, err := state.ShowConversation(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, ref) + if err != nil { + return err + } + if jsonOutput { + return writeJSON(out, result) + } + writeConversationDetailHuman(out, result.Conversation) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + return nil +} + +func (r Runner) runConversationList(args []string, out io.Writer, runtime state.Runtime) error { + jsonOutput := false + for _, arg := range args { + if arg == "--json" { + jsonOutput = true + continue + } + return fmt.Errorf("unknown option %q", arg) + } + projectRoot, err := r.requireIntentSQLiteState("conversation list", runtime) + if err != nil { + return err + } + result, err := state.ListConversations(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}) + if err != nil { + return err + } + if jsonOutput { + return writeJSON(out, result) + } + if len(result.Conversations) == 0 { + fmt.Fprintln(out, "no conversations found") + } + for _, conversation := range result.Conversations { + fmt.Fprintf(out, "%s handles=%d %s\n", conversation.ID, len(conversation.Handles), conversation.Title) + } + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + return nil +} + +func (r Runner) runConversationHandle(args []string, out io.Writer, runtime state.Runtime) error { + if len(args) == 0 || args[0] != "add" { + return fmt.Errorf("usage: loaf conversation handle add <conversation-id> --harness <harness> --handle <id> [options]") + } + args = args[1:] + options := state.ConversationHandleAddOptions{} + jsonOutput := false + for i := 0; i < len(args); i++ { + switch args[i] { + case "--json": + jsonOutput = true + case "--harness": + value, err := consumeFlagValue(args, &i, "--harness") + if err != nil { + return err + } + options.Harness = value + case "--handle": + value, err := consumeFlagValue(args, &i, "--handle") + if err != nil { + return err + } + options.Handle = value + case "--locality": + value, err := consumeFlagValue(args, &i, "--locality") + if err != nil { + return err + } + options.Locality = value + case "--log-ref": + value, err := consumeFlagValue(args, &i, "--log-ref") + if err != nil { + return err + } + options.LogRef = value + case "--hash": + value, err := consumeFlagValue(args, &i, "--hash") + if err != nil { + return err + } + options.Hash = value + case "--range": + value, err := consumeFlagValue(args, &i, "--range") + if err != nil { + return err + } + options.Range = value + default: + if strings.HasPrefix(args[i], "-") { + return fmt.Errorf("unknown option %q", args[i]) + } + if options.ConversationRef != "" { + return fmt.Errorf("conversation handle add accepts one conversation reference") + } + options.ConversationRef = args[i] + } + } + if options.ConversationRef == "" { + return fmt.Errorf("conversation handle add requires a conversation reference") + } + projectRoot, err := r.requireIntentSQLiteState("conversation handle add", runtime) + if err != nil { + return err + } + result, err := state.AddConversationHandle(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, options) + if err != nil { + return err + } + if jsonOutput { + return writeJSON(out, result) + } + fmt.Fprintf(out, "conversation: %s\nhandle: %s\ncreated: %t\n", result.Conversation.ID, result.HandleID, result.Created) + if result.LogRefID != "" { + fmt.Fprintf(out, "log ref: %s\n", result.LogRefID) + } + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + return nil +} + +func (r Runner) runConversationObserve(args []string, out io.Writer, runtime state.Runtime) error { + options := state.ConversationObserveOptions{} + jsonOutput := false + availabilitySet := false + for i := 0; i < len(args); i++ { + switch args[i] { + case "--json": + jsonOutput = true + case "--handle": + value, err := consumeFlagValue(args, &i, "--handle") + if err != nil { + return err + } + options.SubjectKind = "conversation_handle" + options.SubjectID = value + case "--log-ref": + value, err := consumeFlagValue(args, &i, "--log-ref") + if err != nil { + return err + } + options.SubjectKind = "conversation_log_ref" + options.SubjectID = value + case "--available": + options.Available = true + availabilitySet = true + case "--unavailable": + options.Available = false + availabilitySet = true + case "--observer": + value, err := consumeFlagValue(args, &i, "--observer") + if err != nil { + return err + } + options.Observer = value + case "--locality": + value, err := consumeFlagValue(args, &i, "--locality") + if err != nil { + return err + } + options.Locality = value + case "--note": + value, err := consumeFlagValue(args, &i, "--note") + if err != nil { + return err + } + options.Note = value + default: + return fmt.Errorf("unknown option %q", args[i]) + } + } + if options.SubjectID == "" { + return fmt.Errorf("conversation observe requires --handle or --log-ref") + } + if !availabilitySet { + return fmt.Errorf("conversation observe requires --available or --unavailable") + } + projectRoot, err := r.requireIntentSQLiteState("conversation observe", runtime) + if err != nil { + return err + } + result, err := state.ObserveConversationSource(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, options) + if err != nil { + return err + } + if jsonOutput { + return writeJSON(out, result) + } + fmt.Fprintf(out, "observation: %s\navailable: %t\n", result.ObservationID, options.Available) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + return nil +} + +func writeConversationDetailHuman(out io.Writer, conversation state.ConversationDetail) { + fmt.Fprintf(out, "conversation: %s\ntitle: %s\n", conversation.ID, conversation.Title) + for _, handle := range conversation.Handles { + availability := "unobserved" + if handle.Latest != nil { + if handle.Latest.Available { + availability = "available at " + handle.Latest.ObservedAt + } else { + availability = "unavailable at " + handle.Latest.ObservedAt + } + } + fmt.Fprintf(out, "handle: %s %s (%s) locality=%s availability=%s\n", handle.Harness, handle.Handle, handle.ID, handle.Locality, availability) + for _, logRef := range handle.LogRefs { + fmt.Fprintf(out, " log: %s (%s)\n", logRef.Locator, logRef.ID) + } + } +} diff --git a/internal/cli/install.go b/internal/cli/install.go index 7583266cd..3e9fcbe82 100644 --- a/internal/cli/install.go +++ b/internal/cli/install.go @@ -20,6 +20,8 @@ type installOptions struct { codexBasicCommands bool yes *bool help bool + dryRun bool + json bool } type detectedInstallTool struct { @@ -64,6 +66,10 @@ func (r Runner) runInstall(args []string, out io.Writer, runtimeRoot string) err hasClaudeCode := installCommandExists("claude") assumeYes := installAssumeYes(options) + if options.dryRun { + return r.runInstallDryRun(options, out, loafRoot, projectRoot.Path(), version, distRoot, tools, hasClaudeCode, assumeYes) + } + fmt.Fprintln(out) fmt.Fprintln(out, ansiBold("loaf install")) fmt.Fprintln(out) @@ -189,6 +195,10 @@ func parseInstallArgs(args []string) (installOptions, error) { options.target = args[i] case "--upgrade": options.upgrade = true + case "--dry-run": + options.dryRun = true + case "--json": + options.json = true case "--codex-basic-commands": options.codexBasicCommands = true case "-y", "--yes": @@ -206,6 +216,12 @@ func parseInstallArgs(args []string) (installOptions, error) { if options.codexBasicCommands && options.target != "codex" && options.target != "all" { return installOptions{}, fmt.Errorf("--codex-basic-commands requires --to codex or --to all") } + if options.dryRun && !options.upgrade { + return installOptions{}, fmt.Errorf("--dry-run requires --upgrade") + } + if options.json && !options.dryRun { + return installOptions{}, fmt.Errorf("--json requires --dry-run") + } return options, nil } @@ -218,6 +234,8 @@ func writeInstallHelp(out io.Writer) { "Options:", " --to <target> Target to install to (or \"all\")", " --upgrade Update installed targets and apply deprecation-manifest cleanup", + " --dry-run Report the upgrade plan without writing anything (requires --upgrade)", + " --json Emit the dry-run plan as a single JSON document (requires --dry-run)", " --codex-basic-commands Explicitly install the least-privilege Codex basic command policy (requires --to codex or --to all)", " -y, --yes Assume yes to safe project-file symlink migrations and destructive deprecation cleanup", " --no-yes Force prompt-style declines in non-interactive mode", diff --git a/internal/cli/install_plan.go b/internal/cli/install_plan.go new file mode 100644 index 000000000..b5cb44d18 --- /dev/null +++ b/internal/cli/install_plan.go @@ -0,0 +1,1043 @@ +package cli + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" +) + +// install_plan.go builds a deterministic, byte-for-byte non-mutating plan for +// `loaf install --upgrade --dry-run`. Every function here reuses the same +// read-only ownership, content-digest, deprecation-manifest, MCP, and +// project-file primitives the apply path uses, but records intended +// creates/updates/removals/preservations/conflicts without writing files, +// manifests, config, or state. The apply path is left untouched; a plan/apply +// parity test guards against drift. + +const installPlanContractVersion = 1 + +// Artifact/action verbs shared by the plan surfaces. +const ( + planActionCreate = "create" + planActionUpdate = "update" + planActionPreserve = "preserve" + planActionRetire = "retire" + planActionConflict = "conflict" + planActionNone = "none" +) + +type installDryRunPlan struct { + ContractVersion int `json:"contract_version"` + Command string `json:"command"` + DryRun bool `json:"dry_run"` + Targets []targetDistributionPlan `json:"targets"` + Deprecations []deprecationPlanEntry `json:"deprecations"` + ProjectFiles []projectFilePlanEntry `json:"project_files"` + Mcp []mcpPlanEntry `json:"mcp"` + FollowUpCommands []string `json:"follow_up_commands"` + ConsentRequired bool `json:"consent_required"` +} + +type targetDistributionPlan struct { + Target string `json:"target"` + ConfigDir string `json:"config_dir"` + Installed bool `json:"installed"` + Blocked bool `json:"blocked"` + Note string `json:"note,omitempty"` + Artifacts []artifactPlanDecision `json:"artifacts"` +} + +type artifactPlanDecision struct { + ID string `json:"id"` + Kind string `json:"kind"` + Destination string `json:"destination"` + Action string `json:"action"` + Detail string `json:"detail,omitempty"` +} + +type deprecationPlanEntry struct { + Kind string `json:"kind"` + Name string `json:"name"` + Path string `json:"path"` + Action string `json:"action"` + ConsentRequired bool `json:"consent_required"` + Reason string `json:"reason,omitempty"` + Since string `json:"since,omitempty"` + Window string `json:"window,omitempty"` + Signoff string `json:"signoff,omitempty"` + Source string `json:"source,omitempty"` + Command string `json:"command,omitempty"` +} + +type projectFilePlanEntry struct { + Target string `json:"target,omitempty"` + Path string `json:"path"` + Action string `json:"action"` + Detail string `json:"detail,omitempty"` +} + +type mcpPlanEntry struct { + ID string `json:"id"` + Target string `json:"target"` + Configured bool `json:"configured"` + Scope string `json:"scope,omitempty"` + Action string `json:"action"` +} + +func (r Runner) runInstallDryRun(options installOptions, out io.Writer, loafRoot string, projectRoot string, version string, distRoot string, tools []detectedInstallTool, hasClaudeCode bool, assumeYes bool) error { + plan, err := r.buildInstallDryRunPlan(options, loafRoot, projectRoot, version, distRoot, tools, hasClaudeCode, assumeYes) + if err != nil { + return err + } + if options.json { + return emitInstallDryRunJSON(out, plan) + } + writeInstallDryRunHuman(out, plan) + return nil +} + +func (r Runner) buildInstallDryRunPlan(options installOptions, loafRoot string, projectRoot string, version string, distRoot string, tools []detectedInstallTool, hasClaudeCode bool, assumeYes bool) (installDryRunPlan, error) { + plan := installDryRunPlan{ + ContractVersion: installPlanContractVersion, + Command: "install", + DryRun: true, + Targets: []targetDistributionPlan{}, + Deprecations: []deprecationPlanEntry{}, + ProjectFiles: []projectFilePlanEntry{}, + Mcp: []mcpPlanEntry{}, + } + + selectedTargets, err := r.selectedInstallTargets(options, tools, io.Discard) + if err != nil { + return installDryRunPlan{}, err + } + + // Deprecation cleanup is always analyzed with allowDestructive=false, which + // is guaranteed non-mutating; the destructive branches only surface as + // "confirmation-required". Consent for destructive deprecation cleanup is + // governed solely by explicit --yes in the apply path (never by tty-detected + // assumeYes), so the plan interprets it the same way. + explicitYes := options.yes != nil && *options.yes + deprecations, err := planInstallDeprecations(loafRoot, explicitYes) + if err != nil { + return installDryRunPlan{}, err + } + plan.Deprecations = deprecations + + toolByKey := installToolsByKey(tools) + defaults := defaultInstallConfigDirs() + buildNeeded := false + for _, target := range selectedTargets { + distDir := filepath.Join(distRoot, target) + configDir := defaults[target] + if tool, ok := toolByKey[target]; ok && tool.configDir != "" { + configDir = tool.configDir + } + targetPlan := targetDistributionPlan{ + Target: target, + ConfigDir: configDir, + Installed: containsInstallToolInstalled(tools, target), + Artifacts: []artifactPlanDecision{}, + } + if !dirExistsForInstall(distDir) { + targetPlan.Note = "no build output found; run loaf build first" + buildNeeded = true + plan.Targets = append(plan.Targets, targetPlan) + continue + } + installOpts := targetInstallOptions{ + Target: target, + DistDir: distDir, + ConfigDir: configDir, + Upgrade: options.upgrade, + CodexBasicCommands: options.codexBasicCommands, + Version: version, + HomeDir: installHome(), + CodexHome: os.Getenv("CODEX_HOME"), + ProjectRoot: projectRoot, + } + decisions, err := planTargetDistribution(installOpts) + if err != nil { + return installDryRunPlan{}, err + } + targetPlan.Artifacts = decisions + for _, decision := range decisions { + if decision.Action == planActionConflict { + targetPlan.Blocked = true + } + } + plan.Targets = append(plan.Targets, targetPlan) + } + sort.Slice(plan.Targets, func(i, j int) bool { return plan.Targets[i].Target < plan.Targets[j].Target }) + + // Project files mirror enforceInstallProjectFiles: symlinks first, then the + // managed fenced section for every target that carries a project file. + targetsInScope := append([]string{}, selectedTargets...) + projectFiles := planInstallProjectFiles(projectRoot, targetsInScope, hasClaudeCode, assumeYes, version) + plan.ProjectFiles = projectFiles + + // MCP recommendations do not run during --upgrade; report read-only + // detection so the plan is informative while making it explicit that + // upgrade applies no MCP changes. + mcpTargets := append([]string{}, targetsInScope...) + if hasClaudeCode { + mcpTargets = append(mcpTargets, "claude-code") + } + plan.Mcp = planInstallMcp(projectRoot, mcpTargets) + + plan.ConsentRequired = installPlanConsentRequired(plan) + plan.FollowUpCommands = installPlanFollowUpCommands(options, plan, buildNeeded) + return plan, nil +} + +func containsInstallToolInstalled(tools []detectedInstallTool, target string) bool { + for _, tool := range tools { + if tool.key == target { + return tool.installed + } + } + return false +} + +// planTargetDistribution mirrors installTargetDistribution's branching without +// writing anything. +func planTargetDistribution(options targetInstallOptions) ([]artifactPlanDecision, error) { + var decisions []artifactPlanDecision + skills, err := planManagedSkills(filepath.Join(options.DistDir, "skills"), installSkillsDestination(options)) + if err != nil { + return nil, err + } + decisions = append(decisions, skills...) + + hasAdapterManifest := fileExistsForInstall(filepath.Join(options.DistDir, targetBuildManifestFile)) + if hasAdapterManifest { + adapters, err := planTargetAdapterArtifacts(options) + if err != nil { + return nil, err + } + decisions = append(decisions, adapters...) + } else { + decisions = append(decisions, artifactPlanDecision{ + ID: "hooks", + Kind: "hook-legacy", + Destination: options.ConfigDir, + Action: planActionUpdate, + Detail: "legacy build output without a target adapter manifest; hooks/plugins refreshed on apply", + }) + } + if options.Target == "codex" { + codex, err := planCodexJournalRule(options) + if err != nil { + return nil, err + } + decisions = append(decisions, codex...) + } + sort.SliceStable(decisions, func(i, j int) bool { return decisions[i].ID < decisions[j].ID }) + return decisions, nil +} + +// planManagedSkills mirrors the read-only preflight and classification of +// syncManagedSkillsDirIfExists. +func planManagedSkills(src string, dest string) ([]artifactPlanDecision, error) { + if !dirExistsForInstall(src) { + return nil, nil + } + sourceSkills, err := listInstallSkillDirs(src) + if err != nil { + return nil, err + } + previous, err := readManagedSkillsState(dest) + if err != nil { + return nil, err + } + current := map[string]string{} + for _, skill := range sourceSkills { + digest, err := hashInstallSkillTree(filepath.Join(src, skill)) + if err != nil { + return nil, fmt.Errorf("hash source skill %q: %w", skill, err) + } + current[skill] = digest + } + + conflicts := map[string]string{} + // Modified previously-managed skills (mirror the ownership preflight). + for skill, recordedDigest := range previous.digests { + if previous.legacy { + continue + } + actual, err := hashInstallSkillTree(filepath.Join(dest, skill)) + if err != nil { + if os.IsNotExist(err) { + continue + } + return nil, fmt.Errorf("managed skill %q cannot be verified: %w", skill, err) + } + if actual != recordedDigest && actual != current[skill] { + conflicts[skill] = "managed skill was modified; refusing to overwrite or remove" + } + } + // Unowned destinations that collide with a source skill. + for _, skill := range sourceSkills { + if _, owned := previous.digests[skill]; owned { + continue + } + if _, err := os.Lstat(filepath.Join(dest, skill)); err == nil { + conflicts[skill] = "skill destination already exists and is not managed by Loaf" + } else if !os.IsNotExist(err) { + return nil, err + } + } + + var decisions []artifactPlanDecision + for _, skill := range sourceSkills { + id := "skill:" + skill + destination := filepath.Join(dest, skill) + if reason, bad := conflicts[skill]; bad { + decisions = append(decisions, artifactPlanDecision{ID: id, Kind: "skill", Destination: destination, Action: planActionConflict, Detail: reason}) + continue + } + installedHash, hashErr := hashInstallSkillTree(destination) + switch { + case hashErr == nil && installedHash == current[skill]: + decisions = append(decisions, artifactPlanDecision{ID: id, Kind: "skill", Destination: destination, Action: planActionPreserve}) + case hashErr == nil: + decisions = append(decisions, artifactPlanDecision{ID: id, Kind: "skill", Destination: destination, Action: planActionUpdate}) + case os.IsNotExist(hashErr): + decisions = append(decisions, artifactPlanDecision{ID: id, Kind: "skill", Destination: destination, Action: planActionCreate}) + default: + return nil, fmt.Errorf("verify managed skill %q: %w", skill, hashErr) + } + } + // Retire previously-managed skills that the source no longer ships. + for skill := range previous.digests { + if _, keep := current[skill]; keep { + continue + } + destination := filepath.Join(dest, skill) + if _, err := os.Lstat(destination); os.IsNotExist(err) { + continue + } else if err != nil { + return nil, err + } + action := planActionRetire + detail := "" + if reason, bad := conflicts[skill]; bad { + action = planActionConflict + detail = reason + } + decisions = append(decisions, artifactPlanDecision{ID: "skill:" + skill, Kind: "skill", Destination: destination, Action: action, Detail: detail}) + } + sort.SliceStable(decisions, func(i, j int) bool { return decisions[i].ID < decisions[j].ID }) + return decisions, nil +} + +// planTargetAdapterArtifacts mirrors the read-only analysis of +// syncTargetAdapterManifest. +func planTargetAdapterArtifacts(options targetInstallOptions) ([]artifactPlanDecision, error) { + buildPath := filepath.Join(options.DistDir, targetBuildManifestFile) + desired, err := readTargetAdapterManifest(buildPath) + if err != nil { + return nil, err + } + if desired.Target != options.Target { + return nil, fmt.Errorf("target adapter manifest target %q does not match install target %q", desired.Target, options.Target) + } + installedPath := filepath.Join(options.ConfigDir, targetInstallManifestFile) + installed := targetAdapterManifest{} + if _, err := os.Lstat(installedPath); err == nil { + installed, err = readTargetAdapterManifest(installedPath) + if err != nil { + return nil, err + } + if installed.Target != options.Target { + return nil, fmt.Errorf("installed target adapter manifest target %q does not match %q", installed.Target, options.Target) + } + } else if !os.IsNotExist(err) { + return nil, err + } + desiredByID := targetAdapterArtifactsByID(desired.Artifacts) + installedByID := targetAdapterArtifactsByID(installed.Artifacts) + + var decisions []artifactPlanDecision + desiredDestinations := map[string]bool{} + + for _, artifact := range desired.Artifacts { + if artifact.Kind == "instruction" { + continue + } + if err := verifyTargetAdapterSource(options, artifact); err != nil { + return nil, err + } + path, err := targetAdapterDestination(options, artifact) + if err != nil { + return nil, err + } + desiredDestinations[path] = true + snapshot, err := readTargetAdapterSnapshot(path) + if err != nil { + return nil, err + } + _, owned := installedByID[artifact.ID] + decision := artifactPlanDecision{ID: artifact.ID, Kind: artifact.Kind, Destination: artifact.Destination} + if !snapshot.exists { + decision.Action = planActionCreate + decisions = append(decisions, decision) + continue + } + matchesDesired, err := targetAdapterSnapshotMatchesArtifact(options.Target, artifact, snapshot) + if err != nil { + return nil, fmt.Errorf("inspect target artifact %q: %w", artifact.ID, err) + } + if owned { + matchesInstalled, err := targetAdapterSnapshotMatchesArtifact(options.Target, installedByID[artifact.ID], snapshot) + if err != nil { + return nil, fmt.Errorf("inspect managed target artifact %q: %w", artifact.ID, err) + } + switch { + case !matchesInstalled && !matchesDesired: + decision.Action = planActionConflict + decision.Detail = "managed target artifact was modified; refusing to overwrite or remove" + case matchesDesired: + decision.Action = planActionPreserve + default: + decision.Action = planActionUpdate + } + decisions = append(decisions, decision) + continue + } + // Unowned destination that already exists: mirror the migration checks. + switch { + case matchesDesired: + decision.Action = planActionPreserve + case targetAdapterLegacyOwnership(options.Target, artifact, snapshot.body): + decision.Action = planActionUpdate + decision.Detail = "adopting legacy Loaf-owned content" + case artifact.Kind == "hook-projection" && targetHookProjectionIsEmpty(options.Target, snapshot.body): + decision.Action = planActionUpdate + decision.Detail = "merging managed hooks into user-owned projection" + default: + decision.Action = planActionConflict + decision.Detail = "destination exists and is not managed by Loaf" + } + decisions = append(decisions, decision) + } + + // Retire installed artifacts the desired manifest no longer ships. + for _, artifact := range installed.Artifacts { + if artifact.Kind == "instruction" { + continue + } + if _, keep := desiredByID[artifact.ID]; keep { + continue + } + path, err := targetAdapterDestination(options, artifact) + if err != nil { + return nil, err + } + if desiredDestinations[path] { + continue + } + snapshot, err := readTargetAdapterSnapshot(path) + if err != nil { + return nil, err + } + if !snapshot.exists { + continue + } + decision := artifactPlanDecision{ID: artifact.ID, Kind: artifact.Kind, Destination: artifact.Destination, Action: planActionRetire} + matchesInstalled, err := targetAdapterSnapshotMatchesArtifact(options.Target, artifact, snapshot) + if err != nil { + return nil, fmt.Errorf("inspect retired target artifact %q: %w", artifact.ID, err) + } + if !matchesInstalled && artifact.Kind != "hook-projection" { + decision.Action = planActionConflict + decision.Detail = "managed target artifact was modified; refusing to remove" + } + decisions = append(decisions, decision) + } + sort.SliceStable(decisions, func(i, j int) bool { return decisions[i].ID < decisions[j].ID }) + return decisions, nil +} + +// planCodexJournalRule mirrors the decisions of +// installCodexJournalRuleWithOperations for the managed Codex rule + guidance +// block. It never resolves or writes anything destructive; when convergence is +// needed it uses the same read-only trusted-executable resolution and template +// rendering the apply path uses. +func planCodexJournalRule(options targetInstallOptions) ([]artifactPlanDecision, error) { + codexHome := options.CodexHome + if codexHome == "" { + codexHome = filepath.Join(installHomeDir(options), ".codex") + } + rulesDir := filepath.Join(codexHome, "rules") + ruleDest := filepath.Join(rulesDir, codexJournalRuleRelativePath) + manifestPath := filepath.Join(rulesDir, codexJournalRuleManifest) + guidanceDest := filepath.Join(codexHome, codexJournalGuidanceRelativePath) + templatePath := filepath.Join(options.DistDir, ".codex", "rules", codexJournalRuleTemplateRelativePath) + + manifest, err := readCodexManagedRuleManifest(manifestPath) + if err != nil { + return nil, err + } + ownedRuleSHA, ownedRule := manifest.ownedDigest(codexJournalRuleRelativePath) + ownedGuidanceSHA, ownedGuidance := manifest.ownedDigest(codexJournalGuidanceRelativePath) + legacyCapability, err := detectLegacyCodexJournalCapability(ruleDest, guidanceDest) + if err != nil { + return nil, err + } + + retireDecisions := []artifactPlanDecision{ + {ID: "codex-rule:loaf.rules", Kind: "codex-rule", Destination: ruleDest, Action: planActionRetire}, + {ID: "codex-rule:AGENTS.md", Kind: "codex-guidance", Destination: guidanceDest, Action: planActionRetire}, + } + + if options.Upgrade && !options.CodexBasicCommands && legacyCapability { + if ownedRule || ownedGuidance { + return retireDecisions, nil + } + return []artifactPlanDecision{{ + ID: "codex-rule:loaf.rules", Kind: "codex-rule", Destination: ruleDest, Action: planActionConflict, + Detail: "legacy Codex journal-only capability requires explicit --codex-basic-commands or recorded Loaf ownership before upgrade", + }}, nil + } + + templateExists := fileExistsForInstall(templatePath) + if !templateExists { + if options.CodexBasicCommands { + return []artifactPlanDecision{{ + ID: "codex-rule:loaf.rules", Kind: "codex-rule", Destination: ruleDest, Action: planActionConflict, + Detail: "generated Codex journal rule template is missing", + }}, nil + } + if options.Upgrade && (ownedRule || ownedGuidance) { + return retireDecisions, nil + } + return nil, nil + } + + needsConvergence := options.CodexBasicCommands || (options.Upgrade && (ownedRule || ownedGuidance)) + if !needsConvergence { + return nil, nil + } + + executable, err := trustedCodexJournalExecutable(options.ProjectRoot, options.CodexRuleOperations) + if err != nil { + // A stale owned install can still be retired without resolving the + // executable, matching the apply path's intent. + if options.Upgrade && !options.CodexBasicCommands && (ownedRule || ownedGuidance) { + return retireDecisions, nil + } + return []artifactPlanDecision{{ + ID: "codex-rule:loaf.rules", Kind: "codex-rule", Destination: ruleDest, Action: planActionConflict, Detail: err.Error(), + }}, nil + } + templateBody, err := os.ReadFile(templatePath) + if err != nil { + return nil, fmt.Errorf("read generated Codex journal rule template: %w", err) + } + renderedRule, err := renderCodexJournalRule(string(templateBody), executable) + if err != nil { + return nil, err + } + guidanceBlock := generateCodexJournalGuidance(executable) + newRuleSHA := sha256Bytes([]byte(renderedRule)) + newGuidanceSHA := sha256Bytes([]byte(guidanceBlock)) + + ruleDecision, err := planCodexRuleFile(ruleDest, options, ownedRule, ownedRuleSHA, newRuleSHA) + if err != nil { + return nil, err + } + guidanceDecision, err := planCodexGuidanceFile(guidanceDest, ownedGuidance, ownedGuidanceSHA, guidanceBlock, newGuidanceSHA) + if err != nil { + return nil, err + } + return []artifactPlanDecision{ruleDecision, guidanceDecision}, nil +} + +func planCodexRuleFile(ruleDest string, options targetInstallOptions, ownedRule bool, ownedRuleSHA string, newRuleSHA string) (artifactPlanDecision, error) { + decision := artifactPlanDecision{ID: "codex-rule:loaf.rules", Kind: "codex-rule", Destination: ruleDest} + currentRule, ruleExists, err := readOptionalInstallFile(ruleDest, "installed Codex journal rule") + if err != nil { + return decision, err + } + if !ruleExists { + decision.Action = planActionCreate + return decision, nil + } + currentSHA := sha256Bytes(currentRule) + switch { + case currentSHA == newRuleSHA: + decision.Action = planActionPreserve + case ownedRule && currentSHA == ownedRuleSHA: + decision.Action = planActionUpdate + case !ownedRule && options.CodexBasicCommands: + decision.Action = planActionConflict + decision.Detail = "refusing to overwrite unowned Codex rule" + case ownedRule: + decision.Action = planActionConflict + decision.Detail = "refusing to overwrite modified Loaf-owned Codex rule" + default: + decision.Action = planActionNone + } + return decision, nil +} + +func planCodexGuidanceFile(guidanceDest string, ownedGuidance bool, ownedGuidanceSHA string, guidanceBlock string, newGuidanceSHA string) (artifactPlanDecision, error) { + decision := artifactPlanDecision{ID: "codex-rule:AGENTS.md", Kind: "codex-guidance", Destination: guidanceDest} + guidanceContent, guidanceExists, err := readOptionalInstallFile(guidanceDest, "Codex journal guidance") + if err != nil { + return decision, err + } + guidanceText := string(guidanceContent) + if err := validateCodexJournalGuidanceStructure(guidanceText); err != nil { + decision.Action = planActionConflict + decision.Detail = fmt.Sprintf("inspect Codex journal guidance: %v", err) + return decision, nil + } + guidanceRange, hasGuidance := findCodexJournalGuidance(guidanceText) + currentGuidance := "" + if hasGuidance { + currentGuidance = guidanceText[guidanceRange.start:guidanceRange.end] + } + switch { + case hasGuidance && sha256Bytes([]byte(currentGuidance)) == newGuidanceSHA: + decision.Action = planActionPreserve + case hasGuidance && currentGuidance == guidanceBlock: + decision.Action = planActionPreserve + case ownedGuidance && hasGuidance && sha256Bytes([]byte(currentGuidance)) == ownedGuidanceSHA: + decision.Action = planActionUpdate + case ownedGuidance && hasGuidance: + decision.Action = planActionConflict + decision.Detail = "refusing to overwrite modified Loaf-owned Codex guidance block" + case hasGuidance: + decision.Action = planActionConflict + decision.Detail = "refusing to overwrite unowned Codex guidance block" + case guidanceExists: + decision.Action = planActionUpdate + decision.Detail = "appending managed guidance block" + default: + decision.Action = planActionCreate + } + return decision, nil +} + +// planInstallDeprecations reuses applyInstallDeprecationCleanup with +// allowDestructive=false, which is guaranteed non-mutating, then interprets the +// classified result. explicitYes reflects an explicit --yes; destructive +// deprecation cleanup requires it, matching the apply contract. +func planInstallDeprecations(loafRoot string, explicitYes bool) ([]deprecationPlanEntry, error) { + manifest, found, err := loadInstallDeprecationManifest(loafRoot) + if err != nil { + return nil, err + } + if !found || manifest.isEmpty() { + return []deprecationPlanEntry{}, nil + } + result, err := applyInstallDeprecationCleanup(manifest, installPathContext(), false) + if err != nil { + return nil, err + } + var entries []deprecationPlanEntry + appendEntry := func(action installDeprecationCleanupAction, planAction string, consentRequired bool) { + entries = append(entries, deprecationPlanEntry{ + Kind: action.Kind, + Name: action.Name, + Path: action.Path, + Action: planAction, + ConsentRequired: consentRequired, + Reason: action.Reason, + Since: action.Since, + Window: action.Window, + Signoff: action.Signoff, + Source: action.Source, + Command: action.Command, + }) + } + for _, action := range result.Externalized { + appendEntry(action, "externalized", false) + } + for _, action := range result.Aliases { + appendEntry(action, "alias", false) + } + for _, action := range result.Skipped { + switch action.Action { + case "missing": + appendEntry(action, "absent", false) + case "unmarked": + appendEntry(action, "skip-unmarked", false) + case "confirmation-required": + appendEntry(action, destructiveDeprecationAction(action.Kind), !explicitYes) + } + } + sort.SliceStable(entries, func(i, j int) bool { + if entries[i].Kind != entries[j].Kind { + return entries[i].Kind < entries[j].Kind + } + if entries[i].Name != entries[j].Name { + return entries[i].Name < entries[j].Name + } + return entries[i].Path < entries[j].Path + }) + if entries == nil { + entries = []deprecationPlanEntry{} + } + return entries, nil +} + +func destructiveDeprecationAction(kind string) string { + if kind == "path" { + return "relocate" + } + return "remove" +} + +// planInstallProjectFiles mirrors enforceInstallProjectFiles: project symlinks +// followed by the managed fenced section, all read-only. +func planInstallProjectFiles(projectRoot string, selectedTargets []string, hasClaudeCode bool, assumeYes bool, version string) []projectFilePlanEntry { + entries := planInstallProjectSymlinks(projectRoot, selectedTargets, hasClaudeCode, assumeYes) + fencedTargets := append([]string{}, selectedTargets...) + if hasClaudeCode { + fencedTargets = append([]string{"claude-code"}, fencedTargets...) + } + entries = append(entries, planInstallFencedSections(fencedTargets, projectRoot, version)...) + if entries == nil { + entries = []projectFilePlanEntry{} + } + return entries +} + +func planInstallProjectSymlinks(projectRoot string, selectedTargets []string, hasClaudeCode bool, assumeYes bool) []projectFilePlanEntry { + var entries []projectFilePlanEntry + wantClaude := hasClaudeCode || containsString(selectedTargets, "claude-code") + wantRootAgents := needsRootInstallAgentsFile(selectedTargets) + if !wantClaude && !wantRootAgents { + return entries + } + canonical := filepath.Join(projectRoot, "AGENTS.md") + rootAction, rootDetail, rootErr := planRootInstallAgentsFile(projectRoot, assumeYes) + entries = append(entries, projectFilePlanEntry{Path: "./AGENTS.md", Action: rootAction, Detail: rootDetail}) + if rootErr { + return entries + } + if wantClaude { + linkPath := filepath.Join(projectRoot, ".claude", "CLAUDE.md") + relTarget := relativeInstallLinkTarget(linkPath, canonical) + action, detail := planInstallSymlink(linkPath, relTarget, ".claude/CLAUDE.md", assumeYes) + entries = append(entries, projectFilePlanEntry{Target: "claude-code", Path: ".claude/CLAUDE.md", Action: action, Detail: detail}) + } + return entries +} + +// planRootInstallAgentsFile mirrors the read-only branch decisions of +// ensureRootInstallAgentsFile. Returns (action, detail, isError). +func planRootInstallAgentsFile(projectRoot string, assumeYes bool) (string, string, bool) { + canonical := filepath.Join(projectRoot, "AGENTS.md") + legacy := filepath.Join(projectRoot, ".agents", "AGENTS.md") + legacyExists := installFileExists(legacy) && !installIsSymlink(legacy) + if installIsDirectory(canonical) { + return "error", "./AGENTS.md is a directory; expected a canonical real file", true + } + if installIsSymlink(canonical) && legacyExists && installSymlinkPointsTo(canonical, legacy) { + return "migrated", "Migrate .agents/AGENTS.md to canonical ./AGENTS.md", false + } + if !installPathExists(canonical) { + if legacyExists { + return "migrated", "Migrate .agents/AGENTS.md to canonical ./AGENTS.md", false + } + return "created", "Create canonical ./AGENTS.md", false + } + if installIsSymlink(canonical) { + if !assumeYes { + return "skipped-no-tty", "./AGENTS.md is a symlink; skipped conversion in non-interactive mode", false + } + return "replaced-file", "Back up the ./AGENTS.md symlink and create a canonical real file", false + } + if legacyExists { + if !assumeYes { + return "skipped-no-tty", "Both ./AGENTS.md and .agents/AGENTS.md are real files; skipped merge in non-interactive mode", false + } + return "migrated", "Merge legacy .agents/AGENTS.md into canonical ./AGENTS.md", false + } + return "already-correct", "Canonical ./AGENTS.md already exists", false +} + +// planInstallSymlink mirrors the read-only branch decisions of +// ensureInstallSymlink. +func planInstallSymlink(linkPath string, relativeTarget string, description string, assumeYes bool) (string, string) { + expectedAbs := filepath.Clean(filepath.Join(filepath.Dir(linkPath), relativeTarget)) + if !installPathExists(linkPath) { + return "created", fmt.Sprintf("Create %s -> %s", description, relativeTarget) + } + if installIsSymlink(linkPath) { + if installSymlinkPointsTo(linkPath, expectedAbs) { + return "already-correct", fmt.Sprintf("%s already points to %s", description, relativeTarget) + } + if !assumeYes { + return "skipped-no-tty", fmt.Sprintf("%s points to the wrong target; skipped in non-interactive mode", description) + } + return "relinked", fmt.Sprintf("Relink %s -> %s", description, relativeTarget) + } + if !assumeYes { + return "skipped-no-tty", fmt.Sprintf("%s exists as a real file; skipped in non-interactive mode", description) + } + return "replaced-file", fmt.Sprintf("Back up %s and replace with a symlink -> %s", description, relativeTarget) +} + +// planInstallFencedSections mirrors installFencedSectionsForTargets + +// installFencedSection without writing. +func planInstallFencedSections(targets []string, projectRoot string, version string) []projectFilePlanEntry { + var entries []projectFilePlanEntry + writtenPaths := map[string]string{} + for _, target := range targets { + relPath, ok := fencedTargetFiles[target] + if !ok { + entries = append(entries, projectFilePlanEntry{Target: target, Path: "", Action: "error", Detail: "Unknown target: " + target}) + continue + } + targetFile := filepath.Join(projectRoot, filepath.FromSlash(relPath)) + canonicalBefore := canonicalInstallPath(targetFile) + if _, ok := writtenPaths[canonicalBefore]; ok { + entries = append(entries, projectFilePlanEntry{Target: target, Path: relPath, Action: "skipped", Detail: "shared canonical project file already planned"}) + continue + } + action, detail := planFencedSection(targetFile, version) + entries = append(entries, projectFilePlanEntry{Target: target, Path: relPath, Action: action, Detail: detail}) + writtenPaths[canonicalBefore] = version + } + return entries +} + +func planFencedSection(targetFile string, version string) (string, string) { + canonicalTarget, err := canonicalFenceWritePath(targetFile) + if err != nil { + return "error", err.Error() + } + targetFile = canonicalTarget + if version == "" { + version = "0.0.0" + } + body, err := os.ReadFile(targetFile) + fileExisted := err == nil + if err != nil && !os.IsNotExist(err) { + return "error", err.Error() + } + content := string(body) + if err := validateFencedStructure(content); err != nil { + return "error", err.Error() + } + section, hasSection := findFencedSectionRange(content) + newContent := generateFencedContent(version) + switch { + case hasSection: + if section.malformedHeader { + return "error", "managed Loaf section has a malformed fingerprint; refusing to overwrite" + } + existingBody := content[section.bodyStart:section.end] + if section.fingerprint != "" && section.fingerprint != sha256Hex(existingBody) { + return "error", "managed Loaf section was modified; refusing to overwrite" + } + if section.fingerprint != "" && section.version == version && section.fingerprint == fencedContentFingerprint(newContent) { + return "skipped", "Loaf framework section already current (v" + version + ")" + } + return "updated", "Update Loaf framework section (v" + version + ")" + case fileExisted: + return "appended", "Add Loaf framework section to project file" + default: + return "created", "Create project file with Loaf framework section" + } +} + +func planInstallMcp(projectRoot string, availableTargets []string) []mcpPlanEntry { + targets := uniqueInstallTargets(availableTargets) + entries := []mcpPlanEntry{} + for _, def := range installMcpDefinitions { + for _, target := range targets { + status := detectInstallMcpForTarget(projectRoot, target, def.id) + entries = append(entries, mcpPlanEntry{ + ID: def.id, + Target: target, + Configured: status.configured, + Scope: status.scope, + Action: planActionNone, + }) + } + } + sort.SliceStable(entries, func(i, j int) bool { + if entries[i].ID != entries[j].ID { + return entries[i].ID < entries[j].ID + } + return entries[i].Target < entries[j].Target + }) + return entries +} + +func installPlanConsentRequired(plan installDryRunPlan) bool { + for _, entry := range plan.Deprecations { + if entry.ConsentRequired { + return true + } + } + return false +} + +func installPlanFollowUpCommands(options installOptions, plan installDryRunPlan, buildNeeded bool) []string { + commands := []string{} + if buildNeeded { + commands = append(commands, "loaf build") + } + if installPlanHasChanges(plan) { + commands = append(commands, installPlanApplyCommand(options, plan.ConsentRequired)) + } + commands = dedupeSortedStrings(commands) + return commands +} + +func installPlanHasChanges(plan installDryRunPlan) bool { + for _, target := range plan.Targets { + if target.Note != "" || target.Blocked { + return true + } + for _, artifact := range target.Artifacts { + switch artifact.Action { + case planActionCreate, planActionUpdate, planActionRetire, planActionConflict: + return true + } + } + } + for _, entry := range plan.Deprecations { + switch entry.Action { + case "remove", "relocate": + return true + } + } + for _, entry := range plan.ProjectFiles { + switch entry.Action { + case "already-correct", "skipped", planActionPreserve, planActionNone: + default: + return true + } + } + return false +} + +func installPlanApplyCommand(options installOptions, consentRequired bool) string { + parts := []string{"loaf", "install", "--upgrade"} + if options.target != "" { + parts = append(parts, "--to", options.target) + } + if options.codexBasicCommands { + parts = append(parts, "--codex-basic-commands") + } + if consentRequired { + parts = append(parts, "--yes") + } + return strings.Join(parts, " ") +} + +func dedupeSortedStrings(values []string) []string { + seen := map[string]bool{} + var result []string + for _, value := range values { + if value == "" || seen[value] { + continue + } + seen[value] = true + result = append(result, value) + } + sort.Strings(result) + if result == nil { + result = []string{} + } + return result +} + +func emitInstallDryRunJSON(out io.Writer, plan installDryRunPlan) error { + body, err := json.MarshalIndent(plan, "", " ") + if err != nil { + return err + } + body = append(body, '\n') + _, err = out.Write(body) + return err +} + +func writeInstallDryRunHuman(out io.Writer, plan installDryRunPlan) { + fmt.Fprintln(out) + fmt.Fprintln(out, ansiBold("loaf install --upgrade --dry-run")) + fmt.Fprintf(out, " %s\n\n", ansiGray("Plan only — no files, manifests, config, or state will change.")) + + if len(plan.Targets) == 0 { + fmt.Fprintf(out, " %s\n", ansiGray("No installed targets to upgrade")) + } + for _, target := range plan.Targets { + header := ansiBold(installDisplayName(target.Target)) + if target.Blocked { + header += " " + ansiRed("(blocked)") + } + fmt.Fprintf(out, " %s %s\n", header, ansiGray(target.ConfigDir)) + if target.Note != "" { + fmt.Fprintf(out, " %s %s\n", ansiYellow("⚠"), target.Note) + } + for _, artifact := range target.Artifacts { + fmt.Fprintf(out, " %s %s %s%s\n", planActionGlyph(artifact.Action), artifact.Action, artifact.ID, planDetailSuffix(artifact.Detail)) + } + fmt.Fprintln(out) + } + + if len(plan.Deprecations) > 0 { + fmt.Fprintf(out, " %s\n", ansiBold("Deprecations")) + for _, entry := range plan.Deprecations { + consent := "" + if entry.ConsentRequired { + consent = " " + ansiYellow("(needs --yes)") + } + fmt.Fprintf(out, " %s %s %s %s at %s%s\n", planActionGlyph(entry.Action), entry.Action, entry.Kind, entry.Name, ansiGray(entry.Path), consent) + } + fmt.Fprintln(out) + } + + if len(plan.ProjectFiles) > 0 { + fmt.Fprintf(out, " %s\n", ansiBold("Project files")) + for _, entry := range plan.ProjectFiles { + fmt.Fprintf(out, " %s %s %s%s\n", planActionGlyph(entry.Action), entry.Action, entry.Path, planDetailSuffix(entry.Detail)) + } + fmt.Fprintln(out) + } + + if len(plan.FollowUpCommands) > 0 { + fmt.Fprintf(out, " %s\n", ansiBold("Apply with")) + for _, command := range plan.FollowUpCommands { + fmt.Fprintf(out, " %s %s\n", ansiGray("$"), ansiWhite(command)) + } + fmt.Fprintln(out) + } + if plan.ConsentRequired { + fmt.Fprintf(out, " %s Explicit consent (--yes) is required to apply destructive deprecation cleanup.\n", ansiYellow("⚠")) + } +} + +func planActionGlyph(action string) string { + switch action { + case planActionCreate, planActionUpdate, "created", "appended", "updated", "relinked", "replaced-file", "migrated": + return ansiGreen("+") + case planActionRetire, "remove", "relocate": + return ansiYellow("-") + case planActionConflict, "error": + return ansiRed("✗") + case planActionPreserve, "skipped", "already-correct", planActionNone, "absent", "skip-unmarked": + return ansiGray("○") + default: + return ansiGray("•") + } +} + +func planDetailSuffix(detail string) string { + if detail == "" { + return "" + } + return " " + ansiGray("— "+detail) +} diff --git a/internal/cli/install_plan_test.go b/internal/cli/install_plan_test.go new file mode 100644 index 000000000..4b9ff0e70 --- /dev/null +++ b/internal/cli/install_plan_test.go @@ -0,0 +1,305 @@ +package cli + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +func TestParseInstallDryRunFlagGuards(t *testing.T) { + if _, err := parseInstallArgs([]string{"--dry-run"}); err == nil || !strings.Contains(err.Error(), "requires --upgrade") { + t.Fatalf("parseInstallArgs(--dry-run) error = %v, want requires --upgrade", err) + } + if _, err := parseInstallArgs([]string{"--json", "--upgrade"}); err == nil || !strings.Contains(err.Error(), "requires --dry-run") { + t.Fatalf("parseInstallArgs(--json --upgrade) error = %v, want requires --dry-run", err) + } + options, err := parseInstallArgs([]string{"--upgrade", "--dry-run", "--json"}) + if err != nil { + t.Fatalf("parseInstallArgs(--upgrade --dry-run --json) error = %v", err) + } + if !options.upgrade || !options.dryRun || !options.json { + t.Fatalf("options = %#v, want upgrade/dryRun/json all set", options) + } +} + +// TestRunnerInstallUpgradeDryRunNonMutatingAcrossSurfaces hashes the fixture +// trees before and after a dry-run and requires byte-identical trees across the +// acceptance scenarios. +func TestRunnerInstallUpgradeDryRunNonMutatingAcrossSurfaces(t *testing.T) { + t.Run("installed-target-stale-modified-foreign", func(t *testing.T) { + root, home := setupInstallCommandFixture(t) + writeInstallFile(t, filepath.Join(root, "dist", "cursor", "skills", "foundations", "SKILL.md"), "# Foundations\n") + writeInstallFile(t, filepath.Join(root, "dist", "cursor", "skills", "go-development", "SKILL.md"), "# Go v1\n") + writeInstallFile(t, filepath.Join(home, ".cursor", loafInstallMarkerFile), "old\n") + runInstallFixture(t, root, "install", "--to", "cursor", "--yes") + + sharedSkills := filepath.Join(home, ".agents", "skills") + // Stale owned: dist advances go-development, installed copy stays v1. + writeInstallFile(t, filepath.Join(root, "dist", "cursor", "skills", "go-development", "SKILL.md"), "# Go v2\n") + // Locally modified owned content -> conflict on apply. + writeInstallFile(t, filepath.Join(sharedSkills, "foundations", "SKILL.md"), "# Foundations LOCAL EDIT\n") + // Foreign unowned content that Loaf must never touch. + writeInstallFile(t, filepath.Join(sharedSkills, "foreign", "SKILL.md"), "# Mine\n") + + plan := assertDryRunNonMutating(t, root, home, "install", "--to", "cursor", "--upgrade", "--dry-run", "--json") + cursor := findTargetPlan(t, plan, "cursor") + if got := skillAction(cursor, "go-development"); got != planActionUpdate { + t.Fatalf("go-development action = %q, want update (stale owned)", got) + } + if got := skillAction(cursor, "foundations"); got != planActionConflict { + t.Fatalf("foundations action = %q, want conflict (locally modified)", got) + } + if !cursor.Blocked { + t.Fatalf("cursor plan Blocked = false, want true when a conflict is present") + } + if hasSkillDecision(cursor, "foreign") { + t.Fatalf("plan referenced foreign unowned skill; it must be ignored") + } + }) + + t.Run("no-installed-targets", func(t *testing.T) { + root, home := setupInstallCommandFixture(t) + plan := assertDryRunNonMutating(t, root, home, "install", "--upgrade", "--dry-run", "--json") + if len(plan.Targets) != 0 { + t.Fatalf("targets = %#v, want none for a fixture with no installed targets", plan.Targets) + } + }) + + t.Run("deprecation-without-consent", func(t *testing.T) { + root, home := setupInstallCommandFixture(t) + retired := filepath.Join(home, ".retired-tool") + writeInstallFile(t, filepath.Join(retired, loafInstallMarkerFile), "old\n") + writeInstallFile(t, filepath.Join(retired, "skills", "stale", "SKILL.md"), "stale\n") + writeInstallDeprecationManifest(t, root, `{ + "version": 1, + "retired_targets": [ + { + "target": "retired-tool", + "since": "v9.9.0", + "window": "one-release", + "reason": "retired by test manifest", + "paths": ["${HOME}/.retired-tool"] + } + ], + "retired_skills": [], + "relocations": [], + "aliases": [] +}`) + + plan := assertDryRunNonMutating(t, root, home, "install", "--upgrade", "--dry-run", "--json") + if !plan.ConsentRequired { + t.Fatalf("consent_required = false, want true for a destructive deprecation without --yes") + } + if len(plan.Deprecations) != 1 || plan.Deprecations[0].Action != "remove" || !plan.Deprecations[0].ConsentRequired { + t.Fatalf("deprecations = %#v, want one remove entry needing consent", plan.Deprecations) + } + // The retired path must still be present after the dry-run. + if _, err := os.Stat(retired); err != nil { + t.Fatalf("retired target stat = %v, want still present after dry-run", err) + } + foundApply := false + for _, command := range plan.FollowUpCommands { + if command == "loaf install --upgrade --yes" { + foundApply = true + } + } + if !foundApply { + t.Fatalf("follow_up_commands = %#v, want the exact --yes apply command", plan.FollowUpCommands) + } + }) +} + +// TestRunnerInstallUpgradeDryRunJSONIsDeterministic requires byte-identical JSON +// across two independent runs over the same fixture state. +func TestRunnerInstallUpgradeDryRunJSONIsDeterministic(t *testing.T) { + root, home := setupInstallCommandFixture(t) + writeInstallFile(t, filepath.Join(root, "dist", "cursor", "skills", "foundations", "SKILL.md"), "# Foundations\n") + writeInstallFile(t, filepath.Join(root, "dist", "opencode", "skills", "foundations", "SKILL.md"), "# Foundations\n") + writeInstallFile(t, filepath.Join(home, ".cursor", loafInstallMarkerFile), "old\n") + mkdirAll(t, filepath.Join(home, ".config", "opencode")) + writeInstallFile(t, filepath.Join(home, ".config", "opencode", loafInstallMarkerFile), "old\n") + + first := runInstallCapture(t, root, "install", "--upgrade", "--dry-run", "--json") + second := runInstallCapture(t, root, "install", "--upgrade", "--dry-run", "--json") + if first != second { + t.Fatalf("dry-run JSON is not deterministic:\n--- first ---\n%s\n--- second ---\n%s", first, second) + } + var plan installDryRunPlan + if err := json.Unmarshal([]byte(first), &plan); err != nil { + t.Fatalf("unmarshal dry-run JSON: %v\n%s", err, first) + } + if plan.ContractVersion != installPlanContractVersion || plan.Command != "install" || !plan.DryRun { + t.Fatalf("plan envelope = %#v, want contract %d command install dry_run true", plan, installPlanContractVersion) + } +} + +// TestRunnerInstallUpgradeDryRunSkillPlanMatchesApply verifies plan/apply +// parity: the per-artifact actions predicted by the dry-run are exactly what a +// subsequent apply performs. +func TestRunnerInstallUpgradeDryRunSkillPlanMatchesApply(t *testing.T) { + root, home := setupInstallCommandFixture(t) + distSkills := filepath.Join(root, "dist", "cursor", "skills") + writeInstallFile(t, filepath.Join(distSkills, "foundations", "SKILL.md"), "# Foundations\n") + writeInstallFile(t, filepath.Join(distSkills, "go-development", "SKILL.md"), "# Go v1\n") + writeInstallFile(t, filepath.Join(distSkills, "legacy-skill", "SKILL.md"), "# Legacy\n") + writeInstallFile(t, filepath.Join(home, ".cursor", loafInstallMarkerFile), "old\n") + runInstallFixture(t, root, "install", "--to", "cursor", "--yes") + + sharedSkills := filepath.Join(home, ".agents", "skills") + writeInstallFile(t, filepath.Join(sharedSkills, "foreign", "SKILL.md"), "# Mine\n") + + // Prepare the upgrade: foundations unchanged (preserve), go-development + // advances (update), legacy-skill removed (retire), new-skill added (create). + writeInstallFile(t, filepath.Join(distSkills, "go-development", "SKILL.md"), "# Go v2\n") + if err := os.RemoveAll(filepath.Join(distSkills, "legacy-skill")); err != nil { + t.Fatalf("remove legacy-skill from dist: %v", err) + } + writeInstallFile(t, filepath.Join(distSkills, "new-skill", "SKILL.md"), "# New\n") + + plan := parseInstallPlanJSON(t, runInstallCapture(t, root, "install", "--to", "cursor", "--upgrade", "--dry-run", "--json")) + cursor := findTargetPlan(t, plan, "cursor") + want := map[string]string{ + "foundations": planActionPreserve, + "go-development": planActionUpdate, + "legacy-skill": planActionRetire, + "new-skill": planActionCreate, + } + for skill, action := range want { + if got := skillAction(cursor, skill); got != action { + t.Fatalf("predicted %s action = %q, want %q", skill, got, action) + } + } + + // Apply and confirm the predicted effects actually happened. + runInstallFixture(t, root, "install", "--to", "cursor", "--upgrade", "--yes") + assertInstallFile(t, filepath.Join(sharedSkills, "foundations", "SKILL.md"), "# Foundations\n") + assertInstallFile(t, filepath.Join(sharedSkills, "go-development", "SKILL.md"), "# Go v2\n") + assertInstallFile(t, filepath.Join(sharedSkills, "new-skill", "SKILL.md"), "# New\n") + assertInstallPathMissing(t, filepath.Join(sharedSkills, "legacy-skill")) + assertInstallFile(t, filepath.Join(sharedSkills, "foreign", "SKILL.md"), "# Mine\n") +} + +// --- helpers ------------------------------------------------------------- + +func runInstallFixture(t *testing.T, root string, args ...string) { + t.Helper() + var stdout bytes.Buffer + err := Runner{Stdout: &stdout, WorkingDir: root, Executable: distributionFixtureExecutable(root)}.Run(args) + if err != nil { + t.Fatalf("%v error = %v\n%s", args, err, stdout.String()) + } +} + +func runInstallCapture(t *testing.T, root string, args ...string) string { + t.Helper() + var stdout bytes.Buffer + err := Runner{Stdout: &stdout, WorkingDir: root, Executable: distributionFixtureExecutable(root)}.Run(args) + if err != nil { + t.Fatalf("%v error = %v\n%s", args, err, stdout.String()) + } + return stdout.String() +} + +func assertDryRunNonMutating(t *testing.T, root string, home string, args ...string) installDryRunPlan { + t.Helper() + before := hashInstallFixtureTrees(t, root, home) + output := runInstallCapture(t, root, args...) + after := hashInstallFixtureTrees(t, root, home) + if before != after { + t.Fatalf("dry-run mutated the fixture trees: before=%s after=%s", before, after) + } + return parseInstallPlanJSON(t, output) +} + +func parseInstallPlanJSON(t *testing.T, output string) installDryRunPlan { + t.Helper() + var plan installDryRunPlan + if err := json.Unmarshal([]byte(output), &plan); err != nil { + t.Fatalf("unmarshal dry-run JSON: %v\n%s", err, output) + } + return plan +} + +func findTargetPlan(t *testing.T, plan installDryRunPlan, target string) targetDistributionPlan { + t.Helper() + for _, entry := range plan.Targets { + if entry.Target == target { + return entry + } + } + t.Fatalf("target %q not found in plan %#v", target, plan.Targets) + return targetDistributionPlan{} +} + +func skillAction(target targetDistributionPlan, skill string) string { + for _, artifact := range target.Artifacts { + if artifact.ID == "skill:"+skill { + return artifact.Action + } + } + return "" +} + +func hasSkillDecision(target targetDistributionPlan, skill string) bool { + return skillAction(target, skill) != "" +} + +// hashInstallFixtureTrees hashes the project root and home trees including file +// contents, symlink targets, and directory structure so any mutation the +// dry-run might make is detected. +func hashInstallFixtureTrees(t *testing.T, roots ...string) string { + t.Helper() + digest := sha256.New() + for _, root := range roots { + var lines []string + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + info, err := os.Lstat(path) + if err != nil { + return err + } + switch { + case info.Mode()&fs.ModeSymlink != 0: + target, err := os.Readlink(path) + if err != nil { + return err + } + lines = append(lines, fmt.Sprintf("L\t%s\t%s", rel, target)) + case info.IsDir(): + lines = append(lines, fmt.Sprintf("D\t%s\t%#o", rel, info.Mode().Perm())) + default: + body, err := os.ReadFile(path) + if err != nil { + return err + } + lines = append(lines, fmt.Sprintf("F\t%s\t%#o\t%s", rel, info.Mode().Perm(), hex.EncodeToString(sha256Sum(body)))) + } + return nil + }) + if err != nil { + t.Fatalf("hash fixture tree %s: %v", root, err) + } + sort.Strings(lines) + fmt.Fprintf(digest, "root=%s\n%s\n", filepath.Base(root), strings.Join(lines, "\n")) + } + return hex.EncodeToString(digest.Sum(nil)) +} + +func sha256Sum(body []byte) []byte { + sum := sha256.Sum256(body) + return sum[:] +} diff --git a/internal/cli/intent.go b/internal/cli/intent.go new file mode 100644 index 000000000..21dcea257 --- /dev/null +++ b/internal/cli/intent.go @@ -0,0 +1,596 @@ +package cli + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/levifig/loaf/internal/project" + "github.com/levifig/loaf/internal/state" +) + +func (r Runner) runIntent(args []string, out io.Writer, runtime state.Runtime) error { + if len(args) == 0 || isHelpArg(args) { + writeIntentHelp(out) + return nil + } + if writeNestedHelp(out, args, map[string]func(io.Writer){ + "create": writeIntentCreateHelp, + "defer": writeIntentDeferHelp, + "resume": writeIntentResumeHelp, + "resolve": writeIntentResolveHelp, + "show": writeIntentShowHelp, + "list": writeIntentListHelp, + }) { + 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 "show": + return r.runIntentShow(args[1:], out, runtime) + case "list": + return r.runIntentList(args[1:], out, runtime) + default: + return unknownSubcommandError("intent", args[0]) + } +} + +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{ + {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"}, + {Name: "resolve", Summary: "Append a reasoned terminal disposition"}, + {Name: "show", Summary: "Show one Intent with derived disposition"}, + {Name: "list", Summary: "List Intents with derived dispositions"}, + }) +} + +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.", + "--title Bounded single-line title", + "--body Self-sufficient body", + "--disposition tracked (default) or deferred", + "--why Why the deferred direction matters", + "--boundary What excluded it now", + "--trigger When to revisit", + "--operation-id Retry-safe operation key (required when deferred)", + "--from Source entity reference (spark, idea, brainstorm, or journal entry); repeatable", + "--reason Optional reason recorded with the initial disposition", + "--json Output the created or reused Intent, digests, and project identity as JSON") +} + +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.", + "--why Why the direction matters", + "--boundary What excluded it now", + "--trigger When to revisit", + "--operation-id Retry-safe operation key", + "--json Output the deferred Intent, digests, and project identity as JSON") +} + +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.", + "--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.", + "--reason Resolution outcome", + "--json Output the resolved Intent and project identity as JSON") +} + +func writeIntentShowHelp(out io.Writer) { + writeUsageHelp(out, "loaf intent show <intent> [--json]", "Show one Intent: latest snapshot, derived disposition, deferral payload, and sources.", + "--json Output Intent detail, sources, and project identity as JSON") +} + +func writeIntentListHelp(out io.Writer) { + writeUsageHelp(out, "loaf intent list [--disposition tracked|deferred|resolved] [--json]", "List Intents with derived dispositions in deterministic order.", + "--disposition Filter by derived disposition", + "--json Output Intents and project identity as JSON") +} + +type intentCreateCLIOptions struct { + create state.IntentCreateOptions + jsonOutput bool +} + +func parseIntentCreateArgs(args []string) (intentCreateCLIOptions, error) { + options := intentCreateCLIOptions{} + for i := 0; i < len(args); i++ { + switch args[i] { + case "--json": + options.jsonOutput = true + case "--title": + value, err := consumeFlagValue(args, &i, "--title") + if err != nil { + return intentCreateCLIOptions{}, err + } + options.create.Title = value + case "--body": + value, err := consumeFlagValue(args, &i, "--body") + if err != nil { + return intentCreateCLIOptions{}, err + } + options.create.Body = value + case "--disposition": + value, err := consumeFlagValue(args, &i, "--disposition") + if err != nil { + return intentCreateCLIOptions{}, err + } + options.create.Disposition = value + case "--why": + value, err := consumeFlagValue(args, &i, "--why") + if err != nil { + return intentCreateCLIOptions{}, err + } + options.create.Why = value + case "--boundary": + value, err := consumeFlagValue(args, &i, "--boundary") + if err != nil { + return intentCreateCLIOptions{}, err + } + options.create.Boundary = value + case "--trigger": + value, err := consumeFlagValue(args, &i, "--trigger") + if err != nil { + return intentCreateCLIOptions{}, err + } + options.create.Trigger = value + case "--operation-id": + value, err := consumeFlagValue(args, &i, "--operation-id") + if err != nil { + return intentCreateCLIOptions{}, err + } + options.create.OperationID = value + case "--reason": + value, err := consumeFlagValue(args, &i, "--reason") + if err != nil { + return intentCreateCLIOptions{}, err + } + options.create.Reason = value + case "--from": + value, err := consumeFlagValue(args, &i, "--from") + if err != nil { + return intentCreateCLIOptions{}, err + } + options.create.Sources = append(options.create.Sources, value) + default: + return intentCreateCLIOptions{}, fmt.Errorf("unknown option %q", args[i]) + } + } + if strings.TrimSpace(options.create.Title) == "" { + return intentCreateCLIOptions{}, fmt.Errorf("intent create requires --title") + } + if strings.TrimSpace(options.create.Body) == "" { + return intentCreateCLIOptions{}, fmt.Errorf("intent create requires --body") + } + return options, nil +} + +type intentDeferCLIOptions struct { + defer_ state.IntentDeferOptions + jsonOutput bool +} + +func parseIntentDeferArgs(args []string) (intentDeferCLIOptions, error) { + options := intentDeferCLIOptions{} + for i := 0; i < len(args); i++ { + switch args[i] { + case "--json": + options.jsonOutput = true + case "--why": + value, err := consumeFlagValue(args, &i, "--why") + if err != nil { + return intentDeferCLIOptions{}, err + } + options.defer_.Why = value + case "--boundary": + value, err := consumeFlagValue(args, &i, "--boundary") + if err != nil { + return intentDeferCLIOptions{}, err + } + options.defer_.Boundary = value + case "--trigger": + value, err := consumeFlagValue(args, &i, "--trigger") + if err != nil { + return intentDeferCLIOptions{}, err + } + options.defer_.Trigger = value + case "--operation-id": + value, err := consumeFlagValue(args, &i, "--operation-id") + if err != nil { + return intentDeferCLIOptions{}, err + } + options.defer_.OperationID = value + default: + if strings.HasPrefix(args[i], "-") { + return intentDeferCLIOptions{}, fmt.Errorf("unknown option %q", args[i]) + } + if options.defer_.IntentRef != "" { + return intentDeferCLIOptions{}, fmt.Errorf("intent defer accepts one intent reference") + } + options.defer_.IntentRef = args[i] + } + } + if options.defer_.IntentRef == "" { + return intentDeferCLIOptions{}, fmt.Errorf("intent defer requires an intent reference") + } + return options, nil +} + +type intentDispositionCLIOptions struct { + disposition state.IntentDispositionOptions + jsonOutput bool +} + +func parseIntentDispositionArgs(command string, args []string) (intentDispositionCLIOptions, error) { + options := intentDispositionCLIOptions{} + for i := 0; i < len(args); i++ { + switch args[i] { + case "--json": + options.jsonOutput = true + case "--reason": + value, err := consumeFlagValue(args, &i, "--reason") + if err != nil { + return intentDispositionCLIOptions{}, err + } + options.disposition.Reason = value + default: + if strings.HasPrefix(args[i], "-") { + return intentDispositionCLIOptions{}, fmt.Errorf("unknown option %q", args[i]) + } + if options.disposition.IntentRef != "" { + return intentDispositionCLIOptions{}, fmt.Errorf("%s accepts one intent reference", command) + } + options.disposition.IntentRef = args[i] + } + } + if options.disposition.IntentRef == "" { + return intentDispositionCLIOptions{}, fmt.Errorf("%s requires an intent reference", command) + } + if strings.TrimSpace(options.disposition.Reason) == "" { + return intentDispositionCLIOptions{}, fmt.Errorf("%s requires --reason", command) + } + return options, nil +} + +func (r Runner) requireIntentSQLiteState(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) runIntentCreate(args []string, out io.Writer, runtime state.Runtime) error { + options, err := parseIntentCreateArgs(args) + if err != nil { + return err + } + projectRoot, err := r.requireIntentSQLiteState("intent create", runtime) + if err != nil { + return err + } + result, err := state.CreateIntent(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, options.create) + if err != nil { + return err + } + if options.jsonOutput { + return writeJSON(out, result) + } + writeIntentMutation(out, result) + return nil +} + +func (r Runner) runIntentDefer(args []string, out io.Writer, runtime state.Runtime) error { + options, err := parseIntentDeferArgs(args) + if err != nil { + return err + } + projectRoot, err := r.requireIntentSQLiteState("intent defer", runtime) + if err != nil { + return err + } + result, err := state.DeferIntent(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, options.defer_) + if err != nil { + return err + } + if options.jsonOutput { + return writeJSON(out, result) + } + writeIntentMutation(out, result) + return nil +} + +func (r Runner) runIntentResume(args []string, out io.Writer, runtime state.Runtime) error { + options, err := parseIntentDispositionArgs("intent resume", args) + if err != nil { + return err + } + projectRoot, err := r.requireIntentSQLiteState("intent resume", runtime) + if err != nil { + return err + } + result, err := state.ResumeIntent(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, options.disposition) + if err != nil { + return err + } + if options.jsonOutput { + return writeJSON(out, result) + } + writeIntentMutation(out, result) + return nil +} + +func (r Runner) runIntentResolve(args []string, out io.Writer, runtime state.Runtime) error { + options, err := parseIntentDispositionArgs("intent resolve", args) + if err != nil { + return err + } + projectRoot, err := r.requireIntentSQLiteState("intent resolve", runtime) + if err != nil { + return err + } + result, err := state.ResolveIntent(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, options.disposition) + if err != nil { + return err + } + if options.jsonOutput { + return writeJSON(out, result) + } + writeIntentMutation(out, result) + return nil +} + +func (r Runner) runIntentShow(args []string, out io.Writer, runtime state.Runtime) error { + ref, jsonOutput, err := parseSingleRefArgs("intent show", args) + if err != nil { + return err + } + projectRoot, err := r.requireIntentSQLiteState("intent show", runtime) + if err != nil { + return err + } + result, err := state.ShowIntent(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, ref) + if err != nil { + return err + } + if jsonOutput { + return writeJSON(out, result) + } + writeIntentDetail(out, result.Intent) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + return nil +} + +func (r Runner) runIntentList(args []string, out io.Writer, runtime state.Runtime) error { + dispositionFilter := "" + jsonOutput := false + for i := 0; i < len(args); i++ { + switch args[i] { + case "--json": + jsonOutput = true + case "--disposition": + value, err := consumeFlagValue(args, &i, "--disposition") + if err != nil { + return err + } + dispositionFilter = value + default: + return fmt.Errorf("unknown option %q", args[i]) + } + } + projectRoot, err := r.requireIntentSQLiteState("intent list", runtime) + if err != nil { + return err + } + result, err := state.ListIntents(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, dispositionFilter) + if err != nil { + return err + } + if jsonOutput { + return writeJSON(out, result) + } + if len(result.Intents) == 0 { + fmt.Fprintln(out, "no intents found") + } + for _, item := range result.Intents { + ref := item.Alias + if ref == "" { + ref = item.ID + } + fmt.Fprintf(out, "%s %-9s %s\n", ref, item.Disposition, item.Title) + } + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + return nil +} + +func writeIntentMutation(out io.Writer, result state.IntentMutationResult) { + if result.Created { + fmt.Fprintln(out, "created intent") + } else { + fmt.Fprintln(out, "reused intent (operation key already established)") + } + writeIntentDetail(out, result.Intent) + if result.OperationID != "" { + fmt.Fprintf(out, "operation: %s\n", result.OperationID) + fmt.Fprintf(out, "input digest: %s\n", result.InputDigest) + fmt.Fprintf(out, "stored digest: %s\n", result.StoredDigest) + fmt.Fprintf(out, "digest match: %t\n", result.InputDigestMatches) + if !result.InputDigestMatches { + fmt.Fprintln(out, "warning: retry input differs from the stored first write; the stored content remains canonical") + } + } + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) +} + +func writeIntentDetail(out io.Writer, intent state.IntentDetail) { + ref := intent.Alias + if ref == "" { + ref = intent.ID + } + fmt.Fprintf(out, "intent: %s\n", ref) + fmt.Fprintf(out, "id: %s\n", intent.ID) + fmt.Fprintf(out, "title: %s\n", intent.Title) + fmt.Fprintf(out, "disposition: %s (seq %d)\n", intent.Disposition, intent.DispositionSeq) + if intent.DispositionReason != "" { + fmt.Fprintf(out, "reason: %s\n", intent.DispositionReason) + } + if intent.Deferral != nil { + fmt.Fprintf(out, "deferred why: %s\n", intent.Deferral.Why) + fmt.Fprintf(out, "deferred boundary: %s\n", intent.Deferral.Boundary) + fmt.Fprintf(out, "deferred trigger: %s\n", intent.Deferral.RevisitTrigger) + } + for _, source := range intent.Sources { + sourceRef := source.Entity.Alias + if sourceRef == "" { + sourceRef = source.Entity.ID + } + fmt.Fprintf(out, "source: %s %s\n", source.Entity.Kind, sourceRef) + } + fmt.Fprintf(out, "read: loaf intent show %s\n", ref) +} + +func (r Runner) runIntake(args []string, out io.Writer, runtime state.Runtime) error { + if len(args) == 0 || isHelpArg(args) { + writeIntakeHelp(out) + return nil + } + if writeNestedHelp(out, args, map[string]func(io.Writer){ + "list": writeIntakeListHelp, + }) { + return nil + } + switch args[0] { + case "list": + jsonOutput := false + for _, arg := range args[1:] { + if arg == "--json" { + jsonOutput = true + continue + } + return fmt.Errorf("unknown option %q", arg) + } + projectRoot, err := r.requireIntentSQLiteState("intake list", runtime) + if err != nil { + return err + } + result, err := state.ListIntake(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}) + if err != nil { + return err + } + if jsonOutput { + return writeJSON(out, result) + } + if len(result.Items) == 0 { + fmt.Fprintln(out, "intake is empty") + } + for _, item := range result.Items { + descriptor := item.Status + if item.Disposition != "" { + descriptor = item.Disposition + } + ref := item.Alias + if ref == "" { + ref = item.ID + } + fmt.Fprintf(out, "%-15s %-9s %s\n", item.Kind, descriptor, item.Title) + fmt.Fprintf(out, " read: %s\n", item.ReadCommand) + _ = ref + } + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + return nil + default: + return unknownSubcommandError("intake", args[0]) + } +} + +func writeIntakeHelp(out io.Writer) { + writeCommandGroupHelp(out, "loaf intake <subcommand> [options]", "Read the deterministic local intake projection. The CLI reports facts; triage judgment stays with humans and Skills.", []subcommandHelpItem{ + {Name: "list", Summary: "List unresolved sparks, ideas, brainstorms, intents, and legacy deferrals"}, + }) +} + +func writeIntakeListHelp(out io.Writer) { + writeUsageHelp(out, "loaf intake list [--json]", "Project each unresolved logical item exactly once with provenance and exact read commands; no ranking, promotion, or disposition is chosen.", + "--json Output intake items and project identity as JSON") +} + +func writeStateMigrateDeferralsHelp(out io.Writer) { + writeUsageHelp(out, "loaf state migrate deferrals [--dry-run|--apply] [--json]", "Convert historical journal deferrals into canonical deferred Intents. Dry-run writes nothing; apply verifies a whole-database backup first, preserves every legacy row, links historical decision/spark provenance, and is rerunnable.", + "--dry-run Report the project-specific conversion manifest without writing", + "--apply Convert after creating and verifying a whole-database backup", + "--json Output the conversion manifest, counts, backup, and project identity as JSON") +} + +func (r Runner) runStateMigrateDeferrals(args []string, out io.Writer, runtime state.Runtime) error { + dryRun := false + apply := false + jsonOutput := false + for _, arg := range args { + switch arg { + case "--dry-run": + dryRun = true + case "--apply": + apply = true + case "--json": + jsonOutput = true + default: + return fmt.Errorf("unknown option %q", arg) + } + } + if dryRun == apply { + return fmt.Errorf("state migrate deferrals requires exactly one of --dry-run or --apply") + } + projectRoot, err := r.requireIntentSQLiteState("state migrate deferrals", runtime) + if err != nil { + return err + } + result, err := state.ConvertLegacyDeferrals(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, apply) + if err != nil { + return err + } + if jsonOutput { + return writeJSON(out, result) + } + fmt.Fprintf(out, "action: %s\n", result.Action) + if result.BackupPath != "" { + fmt.Fprintf(out, "backup: %s (verified: %t)\n", result.BackupPath, result.BackupVerified) + } + fmt.Fprintf(out, "convertible: %d\nalready converted: %d\nunparseable: %d\n", result.Convertible, result.AlreadyConverted, result.Unparseable) + for _, row := range result.Rows { + line := fmt.Sprintf("%s %s", row.Action, row.OperationKey) + if row.IntentAlias != "" { + line += " -> " + row.IntentAlias + } else if row.IntentID != "" { + line += " -> " + row.IntentID + } + if row.Reason != "" { + line += " (" + row.Reason + ")" + } + fmt.Fprintln(out, line) + } + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + return nil +} diff --git a/internal/cli/journal.go b/internal/cli/journal.go index 55acf4c76..9469c77b5 100644 --- a/internal/cli/journal.go +++ b/internal/cli/journal.go @@ -109,7 +109,7 @@ func writeJournalDeferHelp(out io.Writer) { func writeJournalContextHelp(out io.Writer) { writeUsageHelp(out, "loaf journal context [options]", "Emit the contract-v2 active-truth continuity digest.", "--branch Branch scope (defaults to the current git branch)", - "--layer Select one layer: project-synthesis, scoped-checkpoint, active-lineage, unresolved-blockers, deferred-intent, active-changes, branch-recency, transitional-tasks", + "--layer Select one layer: project-synthesis, scoped-checkpoint, active-lineage, unresolved-blockers, deferred-intent, exploration-checkpoints, active-changes, branch-recency, transitional-tasks", "--limit Maximum 1..100 items for the selected layer (requires --layer)", "--cursor Continue the selected layer (requires --layer)", "--from-hook Read the harness hook payload on stdin and exit silently for subagent invocations", @@ -821,14 +821,15 @@ func (r Runner) runJournalContext(args []string, out io.Writer, runtime state.Ru } const ( - journalContextLayerProjectSynthesis = "project-synthesis" - journalContextLayerScopedCheckpoint = "scoped-checkpoint" - journalContextLayerActiveLineage = "active-lineage" - journalContextLayerBlockers = "unresolved-blockers" - journalContextLayerDeferredIntent = "deferred-intent" - journalContextLayerBranchRecency = "branch-recency" - journalContextLayerTasks = "transitional-tasks" - defaultCLIJournalContextLimit = 10 + journalContextLayerProjectSynthesis = "project-synthesis" + journalContextLayerScopedCheckpoint = "scoped-checkpoint" + journalContextLayerActiveLineage = "active-lineage" + journalContextLayerBlockers = "unresolved-blockers" + journalContextLayerDeferredIntent = "deferred-intent" + journalContextLayerExplorationCheckpoints = "exploration-checkpoints" + journalContextLayerBranchRecency = "branch-recency" + journalContextLayerTasks = "transitional-tasks" + defaultCLIJournalContextLimit = 10 ) var canonicalJournalContextLayers = []string{ @@ -837,6 +838,7 @@ var canonicalJournalContextLayers = []string{ journalContextLayerActiveLineage, journalContextLayerBlockers, journalContextLayerDeferredIntent, + journalContextLayerExplorationCheckpoints, journalContextLayerActiveChanges, journalContextLayerBranchRecency, journalContextLayerTasks, @@ -857,14 +859,15 @@ type journalContextCLIOptions struct { } type journalContextLayersJSON struct { - ProjectSynthesis *state.JournalContextJournalLayer `json:"project_synthesis,omitempty"` - ScopedCheckpoint *state.JournalContextCheckpointLayer `json:"scoped_checkpoint,omitempty"` - ActiveLineage *state.JournalContextJournalLayer `json:"active_lineage,omitempty"` - UnresolvedBlockers *state.JournalContextBlockerLayer `json:"unresolved_blockers,omitempty"` - DeferredIntent *state.JournalContextDeferralLayer `json:"deferred_intent,omitempty"` - ActiveChanges *activeChangeLayer `json:"active_changes,omitempty"` - BranchRecency *state.JournalContextJournalLayer `json:"branch_recency,omitempty"` - TransitionalTasks *state.JournalContextTaskLayer `json:"transitional_tasks,omitempty"` + ProjectSynthesis *state.JournalContextJournalLayer `json:"project_synthesis,omitempty"` + ScopedCheckpoint *state.JournalContextCheckpointLayer `json:"scoped_checkpoint,omitempty"` + ActiveLineage *state.JournalContextJournalLayer `json:"active_lineage,omitempty"` + UnresolvedBlockers *state.JournalContextBlockerLayer `json:"unresolved_blockers,omitempty"` + DeferredIntent *state.JournalContextDeferralLayer `json:"deferred_intent,omitempty"` + ExplorationCheckpoints *state.JournalContextCheckpointsLayer `json:"exploration_checkpoints,omitempty"` + ActiveChanges *activeChangeLayer `json:"active_changes,omitempty"` + BranchRecency *state.JournalContextJournalLayer `json:"branch_recency,omitempty"` + TransitionalTasks *state.JournalContextTaskLayer `json:"transitional_tasks,omitempty"` } type journalContextCLIResult struct { @@ -931,6 +934,9 @@ func parseJournalContextArgs(args []string) (journalContextCLIOptions, error) { if (options.layer == journalContextLayerProjectSynthesis || options.layer == journalContextLayerScopedCheckpoint) && options.cursor != "" { return journalContextCLIOptions{}, fmt.Errorf("--cursor is not supported for intrinsic one-item layer %s", options.layer) } + if options.layer == journalContextLayerExplorationCheckpoints && options.cursor != "" { + return journalContextCLIOptions{}, fmt.Errorf("--cursor is not supported for layer %s; widen the bound with --limit", options.layer) + } if options.claudeCode && !options.fromHook { return journalContextCLIOptions{}, errors.New("--claude-code requires --from-hook") } @@ -1072,6 +1078,8 @@ func journalContextStateLayer(layer string) string { return state.JournalContextLayerBlockers case journalContextLayerDeferredIntent: return state.JournalContextLayerDeferrals + case journalContextLayerExplorationCheckpoints: + return state.JournalContextLayerCheckpoints case journalContextLayerBranchRecency: return state.JournalContextLayerBranch case journalContextLayerTasks: @@ -1089,6 +1097,8 @@ func setJournalContextStateLimit(options *state.JournalContextOptions, layer str options.BlockerLimit = limit case journalContextLayerDeferredIntent: options.DeferralLimit = limit + case journalContextLayerExplorationCheckpoints: + options.CheckpointsLimit = limit case journalContextLayerBranchRecency: options.BranchLimit = limit case journalContextLayerTasks: @@ -1124,6 +1134,7 @@ func rewriteJournalContextExpandCommands(result *state.JournalContext, active *a result.ActiveLineage.ExpandCommand = journalContextExpandCommand(journalContextLayerActiveLineage, result.ActiveLineage.Cursor, options.branch, limit) result.UnresolvedBlockers.ExpandCommand = journalContextExpandCommand(journalContextLayerBlockers, result.UnresolvedBlockers.Cursor, options.branch, limit) result.DeferredIntent.ExpandCommand = journalContextExpandCommand(journalContextLayerDeferredIntent, result.DeferredIntent.Cursor, options.branch, limit) + result.ExplorationCheckpoints.ExpandCommand = journalContextExpandCommand(journalContextLayerExplorationCheckpoints, "", options.branch, limit) active.ExpandCommand = journalContextExpandCommand(journalContextLayerActiveChanges, active.Cursor, options.branch, limit) result.BranchRecency.ExpandCommand = journalContextExpandCommand(journalContextLayerBranchRecency, result.BranchRecency.Cursor, options.branch, limit) result.TransitionalTasks.ExpandCommand = journalContextExpandCommand(journalContextLayerTasks, result.TransitionalTasks.Cursor, options.branch, limit) @@ -1161,6 +1172,9 @@ func composeJournalContextCLIResult(result state.JournalContext, active activeCh if include(journalContextLayerDeferredIntent) { output.Layers.DeferredIntent = &result.DeferredIntent } + if include(journalContextLayerExplorationCheckpoints) { + output.Layers.ExplorationCheckpoints = &result.ExplorationCheckpoints + } if include(journalContextLayerActiveChanges) { output.Layers.ActiveChanges = &active } @@ -1208,7 +1222,18 @@ func writeJournalContextHuman(out io.Writer, result journalContextCLIResult) { if layer := result.Layers.DeferredIntent; layer != nil { writeJournalLayerHuman(out, journalContextLayerDeferredIntent, layer.Available, layer.AvailableCount, layer.ShownCount, layer.ExpandCommand) for _, item := range layer.Items { - fmt.Fprintf(out, " %s: %s\n", item.OperationKey, item.Spark.Text) + writeDeferredIntentItemHuman(out, item) + } + } + if layer := result.Layers.ExplorationCheckpoints; layer != nil { + writeJournalLayerHuman(out, journalContextLayerExplorationCheckpoints, layer.Available, layer.AvailableCount, layer.ShownCount, layer.ExpandCommand) + for _, item := range layer.Items { + reference := item.Alias + if reference == "" { + reference = item.ExplorationID + } + fmt.Fprintf(out, " %s: %s / Next: %s\n", reference, item.Title, item.NextAction) + fmt.Fprintf(out, " resume: %s\n", item.ContextCommand) } } if layer := result.Layers.ActiveChanges; layer != nil { @@ -1234,6 +1259,23 @@ func writeJournalContextHuman(out io.Writer, result journalContextCLIResult) { } } +// writeDeferredIntentItemHuman renders one deferred-intent line. A canonical +// Intent leads with its alias-or-id, disposition, and the self-sufficient +// packet fields, then an exact read command. A pre-conversion legacy row keeps +// the operation-key packet line so unmigrated deferrals stay legible. +func writeDeferredIntentItemHuman(out io.Writer, item state.JournalContextDeferralItem) { + if item.Intent == nil { + fmt.Fprintf(out, " %s: %s\n", item.OperationKey, item.Spark.Text) + return + } + reference := item.Intent.Alias + if reference == "" { + reference = item.Intent.ID + } + fmt.Fprintf(out, " %s (%s): Intent: %s / Why: %s / Boundary: %s / Trigger: %s\n", reference, item.Intent.Disposition, item.Intent.Body, item.Intent.Why, item.Intent.Boundary, item.Intent.RevisitTrigger) + fmt.Fprintf(out, " read: loaf intent show %s\n", reference) +} + func writeJournalLayerHuman(out io.Writer, name string, available bool, availableCount, shownCount int, expandCommand string) { if !available { fmt.Fprintf(out, " %s: unavailable\n", name) diff --git a/internal/cli/target_capability_contract_test.go b/internal/cli/target_capability_contract_test.go index c905a66d2..2a5e461df 100644 --- a/internal/cli/target_capability_contract_test.go +++ b/internal/cli/target_capability_contract_test.go @@ -22,11 +22,11 @@ func TestTargetCapabilityEvidenceContractLoadsCurrentRecords(t *testing.T) { t.Fatalf("records = %d, want six exact target surface records", len(contract.Records)) } want := map[string]string{ - "claude-code\x00cli": "2.1.207\x00plugin-dir", + "claude-code\x00cli": "2.1.215\x00plugin-dir", "cursor\x00ide": "3.11.19\x00candidate-build", "cursor\x00cursor-agent": "2026.05.09-0afadcc\x00candidate-build", - "codex\x00cli": "0.144.1\x00isolated-codex-home", - "opencode\x00cli": "1.17.18\x00isolated-xdg", + "codex\x00cli": "0.144.5\x00isolated-codex-home", + "opencode\x00cli": "1.18.3\x00isolated-xdg", "amp\x00cli": "0.0.1783873056-g278461\x00candidate-build", } for _, record := range contract.Records { @@ -421,7 +421,7 @@ func TestTargetCapabilityEvidenceLoadRequiresRetainedRegularSources(t *testing.T } func TestInstalledSmokeEvidenceRejectsUnknownVersionsAndHashDrift(t *testing.T) { - data, err := os.ReadFile(filepath.Join(filepath.Dir(filepath.Dir(testTargetCapabilityEvidencePath(t))), "docs/changes/20260710-journal-reliability-foundation/research/u8-claude-code-2.1.207-candidate-smoke.json")) + data, err := os.ReadFile(filepath.Join(filepath.Dir(filepath.Dir(testTargetCapabilityEvidencePath(t))), "docs/changes/20260710-journal-reliability-foundation/research/u8-claude-code-2.1.215-candidate-smoke.json")) if err != nil { t.Fatal(err) } @@ -483,7 +483,7 @@ func TestOpenCodeInstalledSmokeEvidenceAcceptsFixture(t *testing.T) { } func TestOpenCodeInstalledSmokeEvidenceRejectsFalseBooleansIdentityInvocationHashAndCleanup(t *testing.T) { - receiptPath := filepath.Join(testRepositoryRoot(t), "docs/changes/20260710-journal-reliability-foundation/research/u8-opencode-1.17.18-isolated-smoke.json") + receiptPath := filepath.Join(testRepositoryRoot(t), "docs/changes/20260710-journal-reliability-foundation/research/u8-opencode-1.18.3-isolated-smoke.json") data, err := os.ReadFile(receiptPath) if err != nil { t.Fatal(err) @@ -546,7 +546,7 @@ func TestOpenCodeInstalledSmokeEvidenceRejectsFalseBooleansIdentityInvocationHas } func TestClaudeInstalledSmokeEvidenceRejectsPlatformSwappedNativeBinaryPath(t *testing.T) { - receiptPath := filepath.Join(testRepositoryRoot(t), "docs/changes/20260710-journal-reliability-foundation/research/u8-claude-code-2.1.207-candidate-smoke.json") + receiptPath := filepath.Join(testRepositoryRoot(t), "docs/changes/20260710-journal-reliability-foundation/research/u8-claude-code-2.1.215-candidate-smoke.json") raw := readSmokeReceiptRaw(t, receiptPath) artifacts := raw["candidate_artifacts"].(map[string]any) sourceNativePath := artifacts["native_binary_path"].(string) @@ -568,7 +568,7 @@ func TestClaudeInstalledSmokeEvidenceRejectsPlatformSwappedNativeBinaryPath(t *t } func TestCodexInstalledSmokeEvidenceRejectsPlatformSwappedNativeBinaryPath(t *testing.T) { - receiptPath := filepath.Join(testRepositoryRoot(t), "docs/changes/20260710-journal-reliability-foundation/research/u8-codex-0.144.1-isolated-smoke.json") + receiptPath := filepath.Join(testRepositoryRoot(t), "docs/changes/20260710-journal-reliability-foundation/research/u8-codex-0.144.5-isolated-smoke.json") raw := readSmokeReceiptRaw(t, receiptPath) artifacts := raw["candidate_artifacts"].(map[string]any) sourceNativePath := artifacts["native_binary_path"].(string) @@ -601,7 +601,7 @@ func TestInstalledSmokeEvidenceRejectsCrossTargetNativeBinaryPaths(t *testing.T) { name: "claude-receives-codex-path", target: "claude-code", - receipt: "u8-claude-code-2.1.207-candidate-smoke.json", + receipt: "u8-claude-code-2.1.215-candidate-smoke.json", modeName: "startup", wrongPath: "bin/native/darwin-arm64/loaf", validateFn: validateInstalledSmokeEvidence, @@ -609,7 +609,7 @@ func TestInstalledSmokeEvidenceRejectsCrossTargetNativeBinaryPaths(t *testing.T) { name: "codex-receives-claude-path", target: "codex", - receipt: "u8-codex-0.144.1-isolated-smoke.json", + receipt: "u8-codex-0.144.5-isolated-smoke.json", modeName: "startup", wrongPath: "plugins/loaf/bin/native/darwin-arm64/loaf", validateFn: validateCodexInstalledSmokeEvidence, @@ -641,7 +641,7 @@ func TestInstalledSmokeEvidenceRejectsCrossTargetNativeBinaryPaths(t *testing.T) } func TestExactCapabilityVersionGrammar(t *testing.T) { - valid := []string{"2.1.207", "3.11.19", "2026.05.09-0afadcc", "0.144.1", "1.17.18", "0.0.1783873056-g278461", "1.2.3-alpha9"} + valid := []string{"2.1.215", "3.11.19", "2026.05.09-0afadcc", "0.144.5", "1.18.3", "0.0.1783873056-g278461", "1.2.3-alpha9"} for _, version := range valid { if !isExactCapabilityVersion(version) { t.Errorf("isExactCapabilityVersion(%q) = false, want true", version) diff --git a/internal/state/conversation.go b/internal/state/conversation.go new file mode 100644 index 000000000..86190b8f5 --- /dev/null +++ b/internal/state/conversation.go @@ -0,0 +1,1081 @@ +package state + +import ( + "context" + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/levifig/loaf/internal/project" +) + +const ( + conversationTitleMaxBytes = 200 + conversationFieldMaxBytes = 1024 + explorationContextDefaultLimit = 10 + explorationContextMaxLimit = 100 +) + +// ConversationCreateOptions describes a new logical conversation. +type ConversationCreateOptions struct { + Title string + OperationID string +} + +// ConversationHandleAddOptions attaches one machine-local handle, with an +// optional bounded log reference, to a logical conversation. +type ConversationHandleAddOptions struct { + ConversationRef string + Harness string + Handle string + Locality string + LogRef string + Hash string + Range string +} + +// ConversationObserveOptions appends one immutable availability observation. +type ConversationObserveOptions struct { + SubjectKind string + SubjectID string + Available bool + Observer string + Locality string + Note string +} + +// ConversationHandleDetail is one machine-local handle read model. +type ConversationHandleDetail struct { + ID string `json:"id"` + Harness string `json:"harness"` + Handle string `json:"handle"` + Locality string `json:"locality,omitempty"` + LogRefs []ConversationLogRefDetail `json:"log_refs,omitempty"` + Latest *SourceAvailabilityObservation `json:"latest_observation,omitempty"` +} + +// ConversationLogRefDetail is one bounded log locator read model. +type ConversationLogRefDetail struct { + ID string `json:"id"` + Locator string `json:"locator"` + Hash string `json:"hash,omitempty"` + Range string `json:"range,omitempty"` + Latest *SourceAvailabilityObservation `json:"latest_observation,omitempty"` +} + +// SourceAvailabilityObservation is one immutable availability fact. +type SourceAvailabilityObservation struct { + ObservedAt string `json:"observed_at"` + Observer string `json:"observer,omitempty"` + Locality string `json:"locality,omitempty"` + Available bool `json:"available"` + Note string `json:"note,omitempty"` +} + +// ConversationDetail is the logical conversation read model. +type ConversationDetail struct { + ID string `json:"id"` + Title string `json:"title"` + Handles []ConversationHandleDetail `json:"handles"` + CreatedAt string `json:"created_at"` +} + +// ConversationMutationResult is the shared write envelope. +type ConversationMutationResult struct { + ContractVersion int `json:"contract_version"` + 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"` + OperationID string `json:"operation_id,omitempty"` + Created bool `json:"created"` + Conversation ConversationDetail `json:"conversation"` + HandleID string `json:"handle_id,omitempty"` + LogRefID string `json:"log_ref_id,omitempty"` + ExplorationID string `json:"exploration_id,omitempty"` + ObservationID string `json:"observation_id,omitempty"` +} + +func validateConversationField(field, value string, maxBytes int, required bool) (string, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + if required { + return "", fmt.Errorf("conversation %s must be nonempty", field) + } + return "", nil + } + if len(trimmed) > maxBytes { + return "", fmt.Errorf("conversation %s exceeds %d bytes", field, maxBytes) + } + return trimmed, nil +} + +// CreateConversation writes one logical conversation identity. +func CreateConversation(ctx context.Context, root project.Root, resolver PathResolver, options ConversationCreateOptions) (ConversationMutationResult, error) { + store, err := openProjectStoreMutateExisting(ctx, root, resolver) + if err != nil { + return ConversationMutationResult{}, err + } + defer store.Close() + return store.CreateConversation(ctx, root, options) +} + +// CreateConversation writes one logical conversation on an open store. +func (s *Store) CreateConversation(ctx context.Context, root project.Root, options ConversationCreateOptions) (ConversationMutationResult, error) { + title, err := validateConversationField("title", options.Title, conversationTitleMaxBytes, true) + if err != nil { + return ConversationMutationResult{}, err + } + operationID, err := validateConversationField("operation id", options.OperationID, intentOperationMaxBytes, false) + if err != nil { + return ConversationMutationResult{}, err + } + projectID, err := s.projectID(ctx, root) + if err != nil { + return ConversationMutationResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return ConversationMutationResult{}, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return ConversationMutationResult{}, fmt.Errorf("begin conversation create: %w", err) + } + defer tx.Rollback() + + if operationID != "" { + var existingID string + err := tx.QueryRowContext(ctx, ` +SELECT id FROM logical_conversations WHERE project_id = ? AND operation_key = ? +`, projectID, operationID).Scan(&existingID) + switch { + case err == nil: + detail, loadErr := loadConversationDetailTx(ctx, tx, projectID, existingID) + if loadErr != nil { + return ConversationMutationResult{}, loadErr + } + if err := tx.Commit(); err != nil { + return ConversationMutationResult{}, fmt.Errorf("commit conversation retry: %w", err) + } + return conversationMutationResult(identity, operationID, false, detail), nil + case !errors.Is(err, sql.ErrNoRows): + return ConversationMutationResult{}, fmt.Errorf("lookup conversation operation key: %w", err) + } + } + + now := time.Now().UTC().Format(time.RFC3339Nano) + conversationID := "" + if operationID != "" { + conversationID = stableMigrationID("logical-conversation", projectID, operationID) + } else { + conversationID = stableMigrationID("logical-conversation", projectID, title, now) + } + operationValue := sql.NullString{} + if operationID != "" { + operationValue = sql.NullString{String: operationID, Valid: true} + } + if _, err := tx.ExecContext(ctx, ` +INSERT INTO logical_conversations (id, project_id, title, operation_key, created_at) +VALUES (?, ?, ?, ?, ?) +`, conversationID, projectID, title, operationValue, now); err != nil { + return ConversationMutationResult{}, fmt.Errorf("insert logical conversation: %w", err) + } + if err := tx.Commit(); err != nil { + return ConversationMutationResult{}, fmt.Errorf("commit conversation create: %w", err) + } + return conversationMutationResult(identity, operationID, true, ConversationDetail{ + ID: conversationID, + Title: title, + Handles: []ConversationHandleDetail{}, + CreatedAt: now, + }), nil +} + +// AddConversationHandle attaches a machine-local handle and optional log ref. +func AddConversationHandle(ctx context.Context, root project.Root, resolver PathResolver, options ConversationHandleAddOptions) (ConversationMutationResult, error) { + store, err := openProjectStoreMutateExisting(ctx, root, resolver) + if err != nil { + return ConversationMutationResult{}, err + } + defer store.Close() + return store.AddConversationHandle(ctx, root, options) +} + +// AddConversationHandle attaches a handle on an open store. Re-adding an +// identical handle is idempotent and returns the existing row. +func (s *Store) AddConversationHandle(ctx context.Context, root project.Root, options ConversationHandleAddOptions) (ConversationMutationResult, error) { + harness, err := validateConversationField("harness", options.Harness, conversationFieldMaxBytes, true) + if err != nil { + return ConversationMutationResult{}, err + } + handle, err := validateConversationField("handle", options.Handle, conversationFieldMaxBytes, true) + if err != nil { + return ConversationMutationResult{}, err + } + locality, err := validateConversationField("locality", options.Locality, conversationFieldMaxBytes, false) + if err != nil { + return ConversationMutationResult{}, err + } + logRef, err := validateConversationField("log ref", options.LogRef, conversationFieldMaxBytes, false) + if err != nil { + return ConversationMutationResult{}, err + } + rangeSpec, err := validateConversationField("range", options.Range, conversationFieldMaxBytes, false) + if err != nil { + return ConversationMutationResult{}, err + } + hash := strings.TrimSpace(options.Hash) + if hash != "" && (len(hash) != 64 || strings.ContainsFunc(hash, func(r rune) bool { + return !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) + })) { + return ConversationMutationResult{}, fmt.Errorf("conversation log hash must be 64 hex characters") + } + if (hash != "" || rangeSpec != "") && logRef == "" { + return ConversationMutationResult{}, fmt.Errorf("conversation --hash and --range require --log-ref") + } + + projectID, err := s.projectID(ctx, root) + if err != nil { + return ConversationMutationResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return ConversationMutationResult{}, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return ConversationMutationResult{}, fmt.Errorf("begin handle add: %w", err) + } + defer tx.Rollback() + + conversationID, err := resolveConversationRefTx(ctx, tx, projectID, options.ConversationRef) + if err != nil { + return ConversationMutationResult{}, err + } + + now := time.Now().UTC().Format(time.RFC3339Nano) + created := false + var handleID string + err = tx.QueryRowContext(ctx, ` +SELECT id FROM conversation_handles +WHERE conversation_id = ? AND harness = ? AND handle = ? AND COALESCE(locality, '') = ? +`, conversationID, harness, handle, locality).Scan(&handleID) + switch { + case errors.Is(err, sql.ErrNoRows): + handleID = stableMigrationID("conversation-handle", projectID, conversationID, harness, handle, locality) + localityValue := sql.NullString{} + if locality != "" { + localityValue = sql.NullString{String: locality, Valid: true} + } + if _, err := tx.ExecContext(ctx, ` +INSERT INTO conversation_handles (id, project_id, conversation_id, harness, handle, locality, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?) +`, handleID, projectID, conversationID, harness, handle, localityValue, now); err != nil { + return ConversationMutationResult{}, fmt.Errorf("insert conversation handle: %w", err) + } + created = true + case err != nil: + return ConversationMutationResult{}, fmt.Errorf("probe conversation handle: %w", err) + } + + logRefID := "" + if logRef != "" { + err = tx.QueryRowContext(ctx, ` +SELECT id FROM conversation_log_refs +WHERE handle_id = ? AND locator = ? AND COALESCE(range_spec, '') = ? +`, handleID, logRef, rangeSpec).Scan(&logRefID) + switch { + case errors.Is(err, sql.ErrNoRows): + logRefID = stableMigrationID("conversation-log-ref", projectID, handleID, logRef, rangeSpec) + hashValue := sql.NullString{} + if hash != "" { + hashValue = sql.NullString{String: strings.ToLower(hash), Valid: true} + } + rangeValue := sql.NullString{} + if rangeSpec != "" { + rangeValue = sql.NullString{String: rangeSpec, Valid: true} + } + if _, err := tx.ExecContext(ctx, ` +INSERT INTO conversation_log_refs (id, project_id, handle_id, locator, content_hash, range_spec, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?) +`, logRefID, projectID, handleID, logRef, hashValue, rangeValue, now); err != nil { + return ConversationMutationResult{}, fmt.Errorf("insert conversation log ref: %w", err) + } + created = true + case err != nil: + return ConversationMutationResult{}, fmt.Errorf("probe conversation log ref: %w", err) + } + } + + detail, err := loadConversationDetailTx(ctx, tx, projectID, conversationID) + if err != nil { + return ConversationMutationResult{}, err + } + if err := tx.Commit(); err != nil { + return ConversationMutationResult{}, fmt.Errorf("commit handle add: %w", err) + } + result := conversationMutationResult(identity, "", created, detail) + result.HandleID = handleID + result.LogRefID = logRefID + return result, nil +} + +// AddExplorationConversation records exploration membership explicitly. +func AddExplorationConversation(ctx context.Context, root project.Root, resolver PathResolver, explorationRef, conversationRef string) (ConversationMutationResult, error) { + store, err := openProjectStoreMutateExisting(ctx, root, resolver) + if err != nil { + return ConversationMutationResult{}, err + } + defer store.Close() + return store.AddExplorationConversation(ctx, root, explorationRef, conversationRef) +} + +// AddExplorationConversation records membership on an open store. +func (s *Store) AddExplorationConversation(ctx context.Context, root project.Root, explorationRef, conversationRef string) (ConversationMutationResult, error) { + projectID, err := s.projectID(ctx, root) + if err != nil { + return ConversationMutationResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return ConversationMutationResult{}, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return ConversationMutationResult{}, fmt.Errorf("begin exploration conversation add: %w", err) + } + defer tx.Rollback() + + explorationID, _, err := resolveExplorationRefTx(ctx, tx, projectID, explorationRef) + if err != nil { + return ConversationMutationResult{}, err + } + conversationID, err := resolveConversationRefTx(ctx, tx, projectID, conversationRef) + if err != nil { + return ConversationMutationResult{}, err + } + if err := validateRelationshipAgainstRegistry("exploration", "has-conversation", "logical_conversation"); err != nil { + return ConversationMutationResult{}, err + } + + now := time.Now().UTC().Format(time.RFC3339Nano) + created := false + membershipID := stableMigrationID("exploration-conversation", projectID, explorationID, conversationID) + var existing string + err = tx.QueryRowContext(ctx, ` +SELECT id FROM exploration_conversations WHERE exploration_id = ? AND conversation_id = ? +`, explorationID, conversationID).Scan(&existing) + switch { + case errors.Is(err, sql.ErrNoRows): + if _, err := tx.ExecContext(ctx, ` +INSERT INTO exploration_conversations (id, project_id, exploration_id, conversation_id, created_at) +VALUES (?, ?, ?, ?, ?) +`, membershipID, projectID, explorationID, conversationID, now); err != nil { + return ConversationMutationResult{}, fmt.Errorf("insert exploration conversation: %w", err) + } + created = true + case err != nil: + return ConversationMutationResult{}, fmt.Errorf("probe exploration conversation: %w", err) + } + + detail, err := loadConversationDetailTx(ctx, tx, projectID, conversationID) + if err != nil { + return ConversationMutationResult{}, err + } + if err := tx.Commit(); err != nil { + return ConversationMutationResult{}, fmt.Errorf("commit exploration conversation add: %w", err) + } + result := conversationMutationResult(identity, "", created, detail) + result.ExplorationID = explorationID + return result, nil +} + +// ObserveConversationSource appends one immutable availability observation for +// a conversation handle or log ref; it never mutates the observed row. +func ObserveConversationSource(ctx context.Context, root project.Root, resolver PathResolver, options ConversationObserveOptions) (ConversationMutationResult, error) { + store, err := openProjectStoreMutateExisting(ctx, root, resolver) + if err != nil { + return ConversationMutationResult{}, err + } + defer store.Close() + return store.ObserveConversationSource(ctx, root, options) +} + +// ObserveConversationSource appends an observation on an open store. +func (s *Store) ObserveConversationSource(ctx context.Context, root project.Root, options ConversationObserveOptions) (ConversationMutationResult, error) { + subjectKind := strings.TrimSpace(options.SubjectKind) + if subjectKind != "conversation_handle" && subjectKind != "conversation_log_ref" { + return ConversationMutationResult{}, fmt.Errorf("observation subject kind must be conversation_handle or conversation_log_ref") + } + subjectID := strings.TrimSpace(options.SubjectID) + if subjectID == "" { + return ConversationMutationResult{}, fmt.Errorf("observation subject id must be nonempty") + } + projectID, err := s.projectID(ctx, root) + if err != nil { + return ConversationMutationResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return ConversationMutationResult{}, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return ConversationMutationResult{}, fmt.Errorf("begin observation: %w", err) + } + defer tx.Rollback() + + subjectTable := "conversation_handles" + if subjectKind == "conversation_log_ref" { + subjectTable = "conversation_log_refs" + } + var conversationID string + query := `SELECT conversation_id FROM conversation_handles WHERE project_id = ? AND id = ?` + if subjectKind == "conversation_log_ref" { + query = `SELECT h.conversation_id FROM conversation_log_refs AS r JOIN conversation_handles AS h ON h.id = r.handle_id WHERE r.project_id = ? AND r.id = ?` + } + if err := tx.QueryRowContext(ctx, query, projectID, subjectID).Scan(&conversationID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ConversationMutationResult{}, fmt.Errorf("%s %q not found in this project", subjectKind, subjectID) + } + return ConversationMutationResult{}, fmt.Errorf("resolve observation subject in %s: %w", subjectTable, err) + } + + now := time.Now().UTC().Format(time.RFC3339Nano) + observationID := stableMigrationID("source-availability", projectID, subjectKind, subjectID, now) + available := 0 + if options.Available { + available = 1 + } + if _, err := tx.ExecContext(ctx, ` +INSERT INTO source_availability_observations (id, project_id, subject_kind, subject_id, observed_at, observer, locality, available, note, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +`, observationID, projectID, subjectKind, subjectID, now, emptyToNil(strings.TrimSpace(options.Observer)), emptyToNil(strings.TrimSpace(options.Locality)), available, emptyToNil(strings.TrimSpace(options.Note)), now); err != nil { + return ConversationMutationResult{}, fmt.Errorf("insert availability observation: %w", err) + } + detail, err := loadConversationDetailTx(ctx, tx, projectID, conversationID) + if err != nil { + return ConversationMutationResult{}, err + } + if err := tx.Commit(); err != nil { + return ConversationMutationResult{}, fmt.Errorf("commit observation: %w", err) + } + result := conversationMutationResult(identity, "", true, detail) + result.ObservationID = observationID + return result, nil +} + +func conversationMutationResult(identity ProjectIdentity, operationID string, created bool, detail ConversationDetail) ConversationMutationResult { + return ConversationMutationResult{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + OperationID: operationID, + Created: created, + Conversation: detail, + } +} + +func resolveConversationRefTx(ctx context.Context, tx *sql.Tx, projectID, ref string) (string, error) { + trimmed := strings.TrimSpace(ref) + if trimmed == "" { + return "", fmt.Errorf("conversation reference must be nonempty") + } + var existing string + err := tx.QueryRowContext(ctx, `SELECT id FROM logical_conversations WHERE project_id = ? AND id = ?`, projectID, trimmed).Scan(&existing) + if errors.Is(err, sql.ErrNoRows) { + return "", fmt.Errorf("logical conversation %q not found in SQLite state", trimmed) + } + if err != nil { + return "", fmt.Errorf("resolve conversation %q: %w", trimmed, err) + } + return existing, nil +} + +func loadConversationDetailTx(ctx context.Context, tx *sql.Tx, projectID, conversationID string) (ConversationDetail, error) { + detail := ConversationDetail{ID: conversationID, Handles: []ConversationHandleDetail{}} + err := tx.QueryRowContext(ctx, ` +SELECT title, created_at FROM logical_conversations WHERE project_id = ? AND id = ? +`, projectID, conversationID).Scan(&detail.Title, &detail.CreatedAt) + if errors.Is(err, sql.ErrNoRows) { + return ConversationDetail{}, fmt.Errorf("logical conversation %s not found", conversationID) + } + if err != nil { + return ConversationDetail{}, err + } + rows, err := tx.QueryContext(ctx, ` +SELECT id, harness, handle, COALESCE(locality, '') +FROM conversation_handles WHERE conversation_id = ? +ORDER BY harness, handle, COALESCE(locality, '') +`, conversationID) + if err != nil { + return ConversationDetail{}, fmt.Errorf("read conversation handles: %w", err) + } + defer rows.Close() + for rows.Next() { + var handle ConversationHandleDetail + if err := rows.Scan(&handle.ID, &handle.Harness, &handle.Handle, &handle.Locality); err != nil { + return ConversationDetail{}, fmt.Errorf("scan conversation handle: %w", err) + } + detail.Handles = append(detail.Handles, handle) + } + if err := rows.Err(); err != nil { + return ConversationDetail{}, err + } + for index := range detail.Handles { + handle := &detail.Handles[index] + if latest, err := latestObservationTx(ctx, tx, projectID, "conversation_handle", handle.ID); err != nil { + return ConversationDetail{}, err + } else if latest != nil { + handle.Latest = latest + } + logRows, err := tx.QueryContext(ctx, ` +SELECT id, locator, COALESCE(content_hash, ''), COALESCE(range_spec, '') +FROM conversation_log_refs WHERE handle_id = ? +ORDER BY locator, COALESCE(range_spec, '') +`, handle.ID) + if err != nil { + return ConversationDetail{}, fmt.Errorf("read conversation log refs: %w", err) + } + for logRows.Next() { + var logRef ConversationLogRefDetail + if err := logRows.Scan(&logRef.ID, &logRef.Locator, &logRef.Hash, &logRef.Range); err != nil { + logRows.Close() + return ConversationDetail{}, fmt.Errorf("scan conversation log ref: %w", err) + } + handle.LogRefs = append(handle.LogRefs, logRef) + } + if err := logRows.Close(); err != nil { + return ConversationDetail{}, err + } + for logIndex := range handle.LogRefs { + logRef := &handle.LogRefs[logIndex] + if latest, err := latestObservationTx(ctx, tx, projectID, "conversation_log_ref", logRef.ID); err != nil { + return ConversationDetail{}, err + } else if latest != nil { + logRef.Latest = latest + } + } + } + return detail, nil +} + +func latestObservationTx(ctx context.Context, tx *sql.Tx, projectID, subjectKind, subjectID string) (*SourceAvailabilityObservation, error) { + observation := &SourceAvailabilityObservation{} + var available int + var observer, locality, note sql.NullString + err := tx.QueryRowContext(ctx, ` +SELECT observed_at, observer, locality, available, note +FROM source_availability_observations +WHERE project_id = ? AND subject_kind = ? AND subject_id = ? +ORDER BY observed_at DESC, id DESC LIMIT 1 +`, projectID, subjectKind, subjectID).Scan(&observation.ObservedAt, &observer, &locality, &available, ¬e) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("read latest observation: %w", err) + } + observation.Observer = observer.String + observation.Locality = locality.String + observation.Available = available == 1 + observation.Note = note.String + return observation, nil +} + +// --- Exploration context projection --- + +// ExplorationContextLayer is one bounded, cursor-expandable optional layer. +type ExplorationContextLayer struct { + Available int `json:"available_count"` + Shown int `json:"shown_count"` + Truncated bool `json:"truncated"` + Cursor string `json:"cursor,omitempty"` + ExpandCommand string `json:"expand_command,omitempty"` + Items json.RawMessage `json:"items"` +} + +// ExplorationContextResult is the portable context projection: the four-field +// core is always returned whole, every optional layer is bounded. +type ExplorationContextResult struct { + ContractVersion int `json:"contract_version"` + 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"` + Query string `json:"query"` + Exploration ExplorationDetail `json:"exploration"` + PortableContextPresent bool `json:"portable_context_present"` + Checkpoint *CheckpointDetail `json:"checkpoint,omitempty"` + Layers map[string]ExplorationContextLayer `json:"layers"` +} + +// ExplorationContextOptions selects the projection scope. +type ExplorationContextOptions struct { + ExplorationRef string + Layer string + Cursor string + Limit int +} + +type explorationContextCursor struct { + Version int `json:"v"` + Layer string `json:"layer"` + ExplorationID string `json:"exploration"` + Offset int `json:"offset"` +} + +var explorationContextLayers = []string{"items", "intents", "evidence", "conversations"} + +func validExplorationContextLayer(layer string) bool { + for _, known := range explorationContextLayers { + if known == layer { + return true + } + } + return false +} + +func encodeExplorationContextCursor(cursor explorationContextCursor) string { + payload, _ := json.Marshal(cursor) + return base64.RawURLEncoding.EncodeToString(payload) +} + +func decodeExplorationContextCursor(raw, layer, explorationID string) (explorationContextCursor, error) { + payload, err := base64.RawURLEncoding.DecodeString(raw) + if err != nil { + return explorationContextCursor{}, fmt.Errorf("exploration context cursor is malformed") + } + var cursor explorationContextCursor + if err := json.Unmarshal(payload, &cursor); err != nil { + return explorationContextCursor{}, fmt.Errorf("exploration context cursor is malformed") + } + if cursor.Version != 1 || cursor.Layer != layer || cursor.ExplorationID != explorationID || cursor.Offset < 0 { + return explorationContextCursor{}, fmt.Errorf("exploration context cursor does not match this layer and exploration; rerun without --cursor") + } + return cursor, nil +} + +// ExplorationContext builds the portable projection for one Exploration. +func ExplorationContext(ctx context.Context, root project.Root, resolver PathResolver, options ExplorationContextOptions) (ExplorationContextResult, error) { + store, err := openProjectStoreReadExisting(ctx, root, resolver) + if err != nil { + return ExplorationContextResult{}, err + } + defer store.Close() + return store.ExplorationContext(ctx, root, options) +} + +// ExplorationContext builds the projection on an open store. +func (s *Store) ExplorationContext(ctx context.Context, root project.Root, options ExplorationContextOptions) (ExplorationContextResult, error) { + if options.Layer != "" && !validExplorationContextLayer(options.Layer) { + return ExplorationContextResult{}, fmt.Errorf("unknown exploration context layer %q; layers are %s", options.Layer, strings.Join(explorationContextLayers, ", ")) + } + if options.Cursor != "" && options.Layer == "" { + return ExplorationContextResult{}, fmt.Errorf("--cursor requires --layer") + } + limit := options.Limit + if limit == 0 { + limit = explorationContextDefaultLimit + } + if limit < 1 || limit > explorationContextMaxLimit { + return ExplorationContextResult{}, fmt.Errorf("exploration context limit must be between 1 and %d", explorationContextMaxLimit) + } + if options.Limit != 0 && options.Layer == "" { + return ExplorationContextResult{}, fmt.Errorf("--limit requires --layer") + } + + projectID, err := s.projectID(ctx, root) + if err != nil { + return ExplorationContextResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return ExplorationContextResult{}, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return ExplorationContextResult{}, fmt.Errorf("begin exploration context: %w", err) + } + defer tx.Rollback() + + explorationID, _, err := resolveExplorationRefTx(ctx, tx, projectID, options.ExplorationRef) + if err != nil { + return ExplorationContextResult{}, err + } + detail, err := loadExplorationDetailTx(ctx, tx, projectID, explorationID) + if err != nil { + return ExplorationContextResult{}, err + } + result := ExplorationContextResult{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Query: options.ExplorationRef, + Exploration: detail, + PortableContextPresent: detail.PortableContextPresent, + Layers: map[string]ExplorationContextLayer{}, + } + var latestCheckpointID string + if detail.LatestCheckpointSeq > 0 { + if err := tx.QueryRowContext(ctx, ` +SELECT id FROM exploration_checkpoints WHERE exploration_id = ? AND seq = ? +`, explorationID, detail.LatestCheckpointSeq).Scan(&latestCheckpointID); err != nil { + return ExplorationContextResult{}, fmt.Errorf("read latest checkpoint: %w", err) + } + checkpoint, err := loadCheckpointTx(ctx, tx, projectID, latestCheckpointID) + if err != nil { + return ExplorationContextResult{}, fmt.Errorf("load latest checkpoint: %w", err) + } + result.Checkpoint = &checkpoint + } + + layers := explorationContextLayers + if options.Layer != "" { + layers = []string{options.Layer} + } + offset := 0 + if options.Cursor != "" { + cursor, err := decodeExplorationContextCursor(options.Cursor, options.Layer, explorationID) + if err != nil { + return ExplorationContextResult{}, err + } + offset = cursor.Offset + } + ref := detail.Alias + if ref == "" { + ref = explorationID + } + for _, layer := range layers { + built, err := s.buildExplorationContextLayer(ctx, tx, projectID, explorationID, latestCheckpointID, layer, offset, limit, ref) + if err != nil { + return ExplorationContextResult{}, err + } + result.Layers[layer] = built + } + return result, nil +} + +func (s *Store) buildExplorationContextLayer(ctx context.Context, tx *sql.Tx, projectID, explorationID, latestCheckpointID, layer string, offset, limit int, ref string) (ExplorationContextLayer, error) { + var available int + var items any + switch layer { + case "items": + if latestCheckpointID == "" { + items = []CheckpointItemDetail{} + break + } + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM exploration_checkpoint_items WHERE checkpoint_id = ?`, latestCheckpointID).Scan(&available); err != nil { + return ExplorationContextLayer{}, err + } + rows, err := tx.QueryContext(ctx, ` +SELECT item_type, position, content FROM exploration_checkpoint_items +WHERE checkpoint_id = ? ORDER BY position LIMIT ? OFFSET ? +`, latestCheckpointID, limit, offset) + if err != nil { + return ExplorationContextLayer{}, err + } + list := []CheckpointItemDetail{} + for rows.Next() { + var item CheckpointItemDetail + if err := rows.Scan(&item.Type, &item.Position, &item.Content); err != nil { + rows.Close() + return ExplorationContextLayer{}, err + } + list = append(list, item) + } + rows.Close() + if err := rows.Err(); err != nil { + return ExplorationContextLayer{}, err + } + items = list + case "intents": + if err := tx.QueryRowContext(ctx, ` +SELECT COUNT(*) FROM relationships +WHERE project_id = ? AND from_entity_kind = 'exploration' AND from_entity_id = ? + AND relationship_type IN ('explores', 'informs') AND to_entity_kind = 'intent' +`, projectID, explorationID).Scan(&available); err != nil { + return ExplorationContextLayer{}, err + } + rows, err := tx.QueryContext(ctx, ` +SELECT r.relationship_type, r.to_entity_id, + COALESCE((SELECT alias FROM aliases WHERE project_id = r.project_id AND entity_kind = 'intent' AND entity_id = r.to_entity_id ORDER BY namespace, alias LIMIT 1), ''), + COALESCE((SELECT title FROM intent_snapshots WHERE intent_id = r.to_entity_id ORDER BY seq DESC LIMIT 1), ''), + COALESCE((SELECT disposition FROM intent_dispositions WHERE intent_id = r.to_entity_id ORDER BY seq DESC LIMIT 1), '') +FROM relationships AS r +WHERE r.project_id = ? AND r.from_entity_kind = 'exploration' AND r.from_entity_id = ? + AND r.relationship_type IN ('explores', 'informs') AND r.to_entity_kind = 'intent' +ORDER BY r.relationship_type, r.to_entity_id LIMIT ? OFFSET ? +`, projectID, explorationID, limit, offset) + if err != nil { + return ExplorationContextLayer{}, err + } + type linkedIntent struct { + Relationship string `json:"relationship"` + ID string `json:"id"` + Alias string `json:"alias,omitempty"` + Title string `json:"title"` + Disposition string `json:"disposition"` + } + list := []linkedIntent{} + for rows.Next() { + var item linkedIntent + if err := rows.Scan(&item.Relationship, &item.ID, &item.Alias, &item.Title, &item.Disposition); err != nil { + rows.Close() + return ExplorationContextLayer{}, err + } + list = append(list, item) + } + rows.Close() + if err := rows.Err(); err != nil { + return ExplorationContextLayer{}, err + } + items = list + case "evidence": + if err := tx.QueryRowContext(ctx, ` +SELECT COUNT(*) FROM relationships +WHERE project_id = ? AND ( + (from_entity_kind = 'exploration' AND from_entity_id = ? AND relationship_type = 'uses-source') + OR (to_entity_kind = 'exploration' AND to_entity_id = ? AND relationship_type = 'evidence-for') +) +`, projectID, explorationID, explorationID).Scan(&available); err != nil { + return ExplorationContextLayer{}, err + } + rows, err := tx.QueryContext(ctx, ` +SELECT relationship_type, + CASE WHEN from_entity_kind = 'exploration' THEN to_entity_kind ELSE from_entity_kind END, + CASE WHEN from_entity_kind = 'exploration' THEN to_entity_id ELSE from_entity_id END +FROM relationships +WHERE project_id = ? AND ( + (from_entity_kind = 'exploration' AND from_entity_id = ? AND relationship_type = 'uses-source') + OR (to_entity_kind = 'exploration' AND to_entity_id = ? AND relationship_type = 'evidence-for') +) +ORDER BY relationship_type, 2, 3 LIMIT ? OFFSET ? +`, projectID, explorationID, explorationID, limit, offset) + if err != nil { + return ExplorationContextLayer{}, err + } + type evidenceItem struct { + Relationship string `json:"relationship"` + Kind string `json:"kind"` + ID string `json:"id"` + Title string `json:"title,omitempty"` + } + list := []evidenceItem{} + for rows.Next() { + var item evidenceItem + if err := rows.Scan(&item.Relationship, &item.Kind, &item.ID); err != nil { + rows.Close() + return ExplorationContextLayer{}, err + } + list = append(list, item) + } + rows.Close() + if err := rows.Err(); err != nil { + return ExplorationContextLayer{}, err + } + for index := range list { + if entity, err := s.entityDetails(ctx, projectID, list[index].Kind, list[index].ID); err == nil { + list[index].Title = entity.Title + } + } + items = list + case "conversations": + if err := tx.QueryRowContext(ctx, ` +SELECT COUNT(*) FROM exploration_conversations WHERE exploration_id = ? +`, explorationID).Scan(&available); err != nil { + return ExplorationContextLayer{}, err + } + rows, err := tx.QueryContext(ctx, ` +SELECT conversation_id FROM exploration_conversations +WHERE exploration_id = ? ORDER BY conversation_id LIMIT ? OFFSET ? +`, explorationID, limit, offset) + if err != nil { + return ExplorationContextLayer{}, err + } + conversationIDs := []string{} + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + rows.Close() + return ExplorationContextLayer{}, err + } + conversationIDs = append(conversationIDs, id) + } + rows.Close() + if err := rows.Err(); err != nil { + return ExplorationContextLayer{}, err + } + list := []ConversationDetail{} + for _, id := range conversationIDs { + detail, err := loadConversationDetailTx(ctx, tx, projectID, id) + if err != nil { + return ExplorationContextLayer{}, err + } + list = append(list, detail) + } + items = list + default: + return ExplorationContextLayer{}, fmt.Errorf("unknown exploration context layer %q", layer) + } + + encoded, err := json.Marshal(items) + if err != nil { + return ExplorationContextLayer{}, fmt.Errorf("encode %s layer: %w", layer, err) + } + shown := countJSONArray(encoded) + built := ExplorationContextLayer{ + Available: available, + Shown: shown, + Truncated: offset+shown < available, + Items: encoded, + } + if built.Truncated { + cursor := encodeExplorationContextCursor(explorationContextCursor{Version: 1, Layer: layer, ExplorationID: explorationID, Offset: offset + shown}) + built.Cursor = cursor + built.ExpandCommand = fmt.Sprintf("loaf exploration context %s --layer %s --cursor %s --limit %d", ref, layer, cursor, limit) + } + return built, nil +} + +func countJSONArray(encoded []byte) int { + var raw []json.RawMessage + if err := json.Unmarshal(encoded, &raw); err != nil { + return 0 + } + return len(raw) +} + +// ConversationListResult lists logical conversations deterministically. +type ConversationListResult struct { + ContractVersion int `json:"contract_version"` + 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"` + Conversations []ConversationDetail `json:"conversations"` +} + +// ConversationShowResult is the single-conversation read model. +type ConversationShowResult struct { + ContractVersion int `json:"contract_version"` + 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"` + Query string `json:"query"` + Conversation ConversationDetail `json:"conversation"` +} + +// ShowConversation returns one logical conversation with handles and logs. +func ShowConversation(ctx context.Context, root project.Root, resolver PathResolver, ref string) (ConversationShowResult, error) { + store, err := openProjectStoreReadExisting(ctx, root, resolver) + if err != nil { + return ConversationShowResult{}, err + } + defer store.Close() + projectID, err := store.projectID(ctx, root) + if err != nil { + return ConversationShowResult{}, err + } + identity, err := store.projectIdentity(ctx, projectID) + if err != nil { + return ConversationShowResult{}, err + } + tx, err := store.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return ConversationShowResult{}, fmt.Errorf("begin conversation show: %w", err) + } + defer tx.Rollback() + conversationID, err := resolveConversationRefTx(ctx, tx, projectID, ref) + if err != nil { + return ConversationShowResult{}, err + } + detail, err := loadConversationDetailTx(ctx, tx, projectID, conversationID) + if err != nil { + return ConversationShowResult{}, err + } + return ConversationShowResult{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Query: ref, + Conversation: detail, + }, nil +} + +// ListConversations returns the deterministic conversation projection. +func ListConversations(ctx context.Context, root project.Root, resolver PathResolver) (ConversationListResult, error) { + store, err := openProjectStoreReadExisting(ctx, root, resolver) + if err != nil { + return ConversationListResult{}, err + } + defer store.Close() + projectID, err := store.projectID(ctx, root) + if err != nil { + return ConversationListResult{}, err + } + identity, err := store.projectIdentity(ctx, projectID) + if err != nil { + return ConversationListResult{}, err + } + tx, err := store.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return ConversationListResult{}, fmt.Errorf("begin conversation list: %w", err) + } + defer tx.Rollback() + rows, err := tx.QueryContext(ctx, ` +SELECT id FROM logical_conversations WHERE project_id = ? ORDER BY created_at, id +`, projectID) + if err != nil { + return ConversationListResult{}, fmt.Errorf("list conversations: %w", err) + } + ids := []string{} + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + rows.Close() + return ConversationListResult{}, err + } + ids = append(ids, id) + } + rows.Close() + if err := rows.Err(); err != nil { + return ConversationListResult{}, err + } + conversations := []ConversationDetail{} + for _, id := range ids { + detail, err := loadConversationDetailTx(ctx, tx, projectID, id) + if err != nil { + return ConversationListResult{}, err + } + conversations = append(conversations, detail) + } + return ConversationListResult{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Conversations: conversations, + }, nil +} diff --git a/internal/state/entity_registry.go b/internal/state/entity_registry.go new file mode 100644 index 000000000..6bd0ef3e0 --- /dev/null +++ b/internal/state/entity_registry.go @@ -0,0 +1,207 @@ +package state + +import ( + "context" + "database/sql" + "fmt" + "sort" +) + +// entityDescriptor registers one SQLite-backed entity kind. Every surface that +// enumerates kinds — alias resolution, internal-ID resolution, trace details, +// link validation, export, deletion, mapping audits, and test fixtures — +// consults this registry instead of a local switch, so adding a kind is one +// explicit registration rather than a sweep of distributed lists. +type entityDescriptor struct { + // Kind is the canonical entity-kind string stored in aliases, + // relationships, events, and entity_tags rows. + Kind string + // Table is the backing SQLite table. + Table string + // InternalIDResolvable marks kinds whose rows may be resolved directly by + // internal row ID when no alias matches. + InternalIDResolvable bool + // ResolutionTarget marks kinds that may resolve another entity reference + // (the historical validateResolutionTargetKind list). + ResolutionTarget bool +} + +// entityRegistry is the closed, explicitly ordered entity-kind registry. +// Legacy kinds are retained through explicit registration, not a wildcard; +// iteration order matches the historical internal-ID resolution order with +// new kinds appended after it. +var entityRegistry = []entityDescriptor{ + {Kind: "spec", Table: "specs", InternalIDResolvable: true, ResolutionTarget: true}, + {Kind: "task", Table: "tasks", InternalIDResolvable: true, ResolutionTarget: true}, + {Kind: "idea", Table: "ideas", InternalIDResolvable: true, ResolutionTarget: true}, + {Kind: "spark", Table: "sparks", InternalIDResolvable: true}, + {Kind: "brainstorm", Table: "brainstorms", InternalIDResolvable: true, ResolutionTarget: true}, + {Kind: "shaping_draft", Table: "shaping_drafts", InternalIDResolvable: true, ResolutionTarget: true}, + {Kind: "report", Table: "reports", InternalIDResolvable: true, ResolutionTarget: true}, + {Kind: "finding", Table: "findings", InternalIDResolvable: true, ResolutionTarget: true}, + {Kind: "verdict", Table: "verdicts", InternalIDResolvable: true, ResolutionTarget: true}, + {Kind: "run", Table: "runs", InternalIDResolvable: true, ResolutionTarget: true}, + {Kind: "plan", Table: "plans", InternalIDResolvable: true, ResolutionTarget: true}, + {Kind: "handoff", Table: "handoffs", InternalIDResolvable: true, ResolutionTarget: true}, + {Kind: "council", Table: "councils", InternalIDResolvable: true, ResolutionTarget: true}, + {Kind: "journal_entry", Table: "journal_entries", InternalIDResolvable: true}, + {Kind: "intent", Table: "intents", InternalIDResolvable: true}, + {Kind: "exploration", Table: "explorations", InternalIDResolvable: true}, + {Kind: "exploration_checkpoint", Table: "exploration_checkpoints", InternalIDResolvable: true}, + {Kind: "logical_conversation", Table: "logical_conversations", InternalIDResolvable: true}, +} + +func entityDescriptorForKind(kind string) (entityDescriptor, bool) { + for _, descriptor := range entityRegistry { + if descriptor.Kind == kind { + return descriptor, true + } + } + return entityDescriptor{}, false +} + +// registeredEntityTable returns the backing table for a registered kind and "" +// for anything unregistered, preserving the historical traceTable contract. +func registeredEntityTable(kind string) string { + descriptor, ok := entityDescriptorForKind(kind) + if !ok { + return "" + } + return descriptor.Table +} + +func internalIDResolvableKinds() []string { + kinds := make([]string, 0, len(entityRegistry)) + for _, descriptor := range entityRegistry { + if descriptor.InternalIDResolvable { + kinds = append(kinds, descriptor.Kind) + } + } + return kinds +} + +func registeredEntityKinds() []string { + kinds := make([]string, 0, len(entityRegistry)) + for _, descriptor := range entityRegistry { + kinds = append(kinds, descriptor.Kind) + } + return kinds +} + +// relationshipPairing is one supported directed relationship in the closed +// matrix. Anything not registered here — or in the legacy pairing set — is an +// explicit future extension, never an accidental write. +type relationshipPairing struct { + FromKind string + Type string + ToKind string +} + +// intentExplorationRelationshipMatrix is the bounded initial matrix accepted +// by the intent-exploration-foundation Change. Git Change references and +// materialized-as edges deliberately begin in change-native-execution-migration. +var intentExplorationRelationshipMatrix = []relationshipPairing{ + {FromKind: "spark", Type: "source-of", ToKind: "intent"}, + {FromKind: "idea", Type: "source-of", ToKind: "intent"}, + {FromKind: "brainstorm", Type: "source-of", ToKind: "intent"}, + {FromKind: "journal_entry", Type: "source-of", ToKind: "intent"}, + {FromKind: "intent", Type: "derived-from", ToKind: "intent"}, + {FromKind: "intent", Type: "split-from", ToKind: "intent"}, + {FromKind: "intent", Type: "supersedes", ToKind: "intent"}, + {FromKind: "intent", Type: "duplicates", ToKind: "intent"}, + {FromKind: "exploration", Type: "explores", ToKind: "intent"}, + {FromKind: "exploration", Type: "informs", ToKind: "intent"}, + {FromKind: "exploration", Type: "has-conversation", ToKind: "logical_conversation"}, + {FromKind: "exploration", Type: "uses-source", ToKind: "journal_entry"}, + {FromKind: "exploration", Type: "uses-source", ToKind: "handoff"}, + {FromKind: "exploration", Type: "uses-source", ToKind: "report"}, + {FromKind: "exploration", Type: "uses-source", ToKind: "finding"}, + {FromKind: "report", Type: "evidence-for", ToKind: "exploration"}, + {FromKind: "report", Type: "evidence-for", ToKind: "intent"}, + {FromKind: "finding", Type: "evidence-for", ToKind: "exploration"}, + {FromKind: "finding", Type: "evidence-for", ToKind: "intent"}, + {FromKind: "journal_entry", Type: "evidence-for", ToKind: "exploration"}, + {FromKind: "journal_entry", Type: "evidence-for", ToKind: "intent"}, +} + +// newEntityKinds are the kinds introduced by the Intent/Exploration foundation. +// A relationship write that touches any of them must match the closed matrix; +// writes between purely legacy kinds keep their historical behavior. +var newEntityKinds = map[string]bool{ + "intent": true, + "exploration": true, + "exploration_checkpoint": true, + "logical_conversation": true, +} + +func relationshipPairingSupported(fromKind, relationshipType, toKind string) bool { + for _, pairing := range intentExplorationRelationshipMatrix { + if pairing.FromKind == fromKind && pairing.Type == relationshipType && pairing.ToKind == toKind { + return true + } + } + return false +} + +// validateRelationshipAgainstRegistry rejects relationship writes that touch a +// new entity kind unless the exact (from, type, to) pairing is registered. +// Legacy-to-legacy writes are not constrained here; their validation remains +// with the historical link surface. +func validateRelationshipAgainstRegistry(fromKind, relationshipType, toKind string) error { + if !newEntityKinds[fromKind] && !newEntityKinds[toKind] { + return nil + } + if _, ok := entityDescriptorForKind(fromKind); !ok { + return fmt.Errorf("relationship source kind %q is not registered", fromKind) + } + if _, ok := entityDescriptorForKind(toKind); !ok { + return fmt.Errorf("relationship target kind %q is not registered", toKind) + } + if !relationshipPairingSupported(fromKind, relationshipType, toKind) { + return fmt.Errorf("relationship %s -[%s]-> %s is not in the supported matrix; supported pairings are explicit registrations, not a cartesian product", fromKind, relationshipType, toKind) + } + return nil +} + +// supportedRelationshipTypesForNewKinds lists the distinct relationship types +// in the closed matrix, for diagnostics and fixtures. +func supportedRelationshipTypesForNewKinds() []string { + seen := map[string]bool{} + for _, pairing := range intentExplorationRelationshipMatrix { + seen[pairing.Type] = true + } + types := make([]string, 0, len(seen)) + for relationshipType := range seen { + types = append(types, relationshipType) + } + sort.Strings(types) + return types +} + +// checkpointItemTypes is the closed initial vocabulary for typed exploration +// checkpoint items. +var checkpointItemTypes = map[string]bool{ + "candidate": true, + "evidence": true, +} + +func validateCheckpointItemType(itemType string) error { + if !checkpointItemTypes[itemType] { + return fmt.Errorf("checkpoint item type %q is not registered; supported types are candidate and evidence", itemType) + } + return nil +} + +// nextAggregateSeq transactionally allocates the next immutable per-aggregate +// sequence. Callers must run inside a serializable transaction; the UNIQUE +// (aggregate, seq) constraint turns a lost race into a visible conflict +// instead of two rows claiming the same position. Table and column names are +// compile-time constants at every call site, never caller input. +func nextAggregateSeq(ctx context.Context, tx *sql.Tx, table, aggregateColumn, aggregateID string) (int, error) { + var next int + query := fmt.Sprintf(`SELECT COALESCE(MAX(seq), 0) + 1 FROM %s WHERE %s = ?`, table, aggregateColumn) + if err := tx.QueryRowContext(ctx, query, aggregateID).Scan(&next); err != nil { + return 0, fmt.Errorf("allocate %s sequence for %s: %w", table, aggregateID, err) + } + return next, nil +} diff --git a/internal/state/entity_registry_test.go b/internal/state/entity_registry_test.go new file mode 100644 index 000000000..d394519af --- /dev/null +++ b/internal/state/entity_registry_test.go @@ -0,0 +1,176 @@ +package state + +import ( + "context" + "database/sql" + "fmt" + "strings" + "sync" + "testing" +) + +func TestEntityRegistryCoversEveryKindWithBackingTable(t *testing.T) { + schemaSQL := currentSchemaSQL() + seen := map[string]bool{} + for _, descriptor := range entityRegistry { + if descriptor.Kind == "" || descriptor.Table == "" { + t.Fatalf("registry descriptor %#v has empty kind or table", descriptor) + } + if seen[descriptor.Kind] { + t.Fatalf("registry kind %q is registered twice", descriptor.Kind) + } + seen[descriptor.Kind] = true + if got := traceTable(descriptor.Kind); got != descriptor.Table { + t.Fatalf("traceTable(%q) = %q, want %q", descriptor.Kind, got, descriptor.Table) + } + if !strings.Contains(schemaSQL, "CREATE TABLE IF NOT EXISTS "+descriptor.Table+" (") { + t.Fatalf("registry table %q for kind %q is not created by any auto-applied migration", descriptor.Table, descriptor.Kind) + } + } + if traceTable("unregistered") != "" { + t.Fatal("traceTable must return empty for unregistered kinds") + } + if err := validateResolutionTargetKind("intent", "INTENT-x"); err == nil { + t.Fatal("intent must not be a resolution target kind") + } + if err := validateResolutionTargetKind("spec", "SPEC-001"); err != nil { + t.Fatalf("spec resolution target error = %v, want nil", err) + } +} + +func TestRelationshipRegistryRejectsUnsupportedPairings(t *testing.T) { + for _, pairing := range intentExplorationRelationshipMatrix { + if err := validateRelationshipAgainstRegistry(pairing.FromKind, pairing.Type, pairing.ToKind); err != nil { + t.Fatalf("supported pairing %v rejected: %v", pairing, err) + } + } + for _, rejected := range []relationshipPairing{ + {FromKind: "task", Type: "source-of", ToKind: "intent"}, + {FromKind: "intent", Type: "evidence-for", ToKind: "spark"}, + {FromKind: "spark", Type: "explores", ToKind: "intent"}, + {FromKind: "intent", Type: "materialized-as", ToKind: "spec"}, + {FromKind: "exploration", Type: "uses-source", ToKind: "task"}, + {FromKind: "logical_conversation", Type: "has-conversation", ToKind: "exploration"}, + {FromKind: "unregistered", Type: "source-of", ToKind: "intent"}, + } { + if err := validateRelationshipAgainstRegistry(rejected.FromKind, rejected.Type, rejected.ToKind); err == nil { + t.Fatalf("unsupported pairing %v was accepted", rejected) + } + } + // Legacy-to-legacy pairs remain outside the closed matrix. + if err := validateRelationshipAgainstRegistry("spark", "resolved_by", "idea"); err != nil { + t.Fatalf("legacy pairing rejected: %v", err) + } +} + +func TestValidateCheckpointItemType(t *testing.T) { + for _, valid := range []string{"candidate", "evidence"} { + if err := validateCheckpointItemType(valid); err != nil { + t.Fatalf("validateCheckpointItemType(%q) = %v, want nil", valid, err) + } + } + for _, invalid := range []string{"note", "transcript", "", "Candidate"} { + if err := validateCheckpointItemType(invalid); err == nil { + t.Fatalf("validateCheckpointItemType(%q) = nil, want error", invalid) + } + } +} + +// TestNextAggregateSeqAllocatesDistinctSequencesUnderConcurrency proves the +// ordering acceptance: concurrent conflicting appends receive distinct +// committed per-aggregate sequences and the derived latest follows the +// greatest sequence, even when every row shares one timestamp. +func TestNextAggregateSeqAllocatesDistinctSequencesUnderConcurrency(t *testing.T) { + store, projectID := intentSchemaFixture(t) + seedIntent(t, store, projectID, "intent:seq") + + const writers = 10 + var wg sync.WaitGroup + errs := make(chan error, writers) + for writer := 0; writer < writers; writer++ { + wg.Add(1) + go func(writer int) { + defer wg.Done() + ctx := context.Background() + for attempt := 0; attempt < 200; attempt++ { + tx, err := store.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + continue + } + seq, err := nextAggregateSeq(ctx, tx, "intent_dispositions", "intent_id", "intent:seq") + if err != nil { + _ = tx.Rollback() + continue + } + disposition := "tracked" + if seq%2 == 0 { + disposition = "resolved" + } + _, err = tx.ExecContext(ctx, ` +INSERT INTO intent_dispositions (id, project_id, intent_id, seq, disposition, created_at) +VALUES (?, ?, 'intent:seq', ?, ?, '2026-07-19T00:00:00Z') +`, fmt.Sprintf("disp:writer-%d", writer), projectID, seq, disposition) + if err != nil { + _ = tx.Rollback() + continue + } + if err := tx.Commit(); err != nil { + continue + } + errs <- nil + return + } + errs <- fmt.Errorf("writer %d exhausted retries", writer) + }(writer) + } + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + + rows, err := store.db.QueryContext(context.Background(), `SELECT seq FROM intent_dispositions WHERE intent_id = 'intent:seq' ORDER BY seq`) + if err != nil { + t.Fatalf("read sequences: %v", err) + } + defer rows.Close() + var sequences []int + for rows.Next() { + var seq int + if err := rows.Scan(&seq); err != nil { + t.Fatalf("scan sequence: %v", err) + } + sequences = append(sequences, seq) + } + if err := rows.Err(); err != nil { + t.Fatalf("iterate sequences: %v", err) + } + if len(sequences) != writers { + t.Fatalf("committed rows = %d, want %d", len(sequences), writers) + } + for i, seq := range sequences { + if seq != i+1 { + t.Fatalf("sequences = %v, want dense 1..%d", sequences, writers) + } + } + + // Derived latest must follow the greatest committed sequence even though + // every row carries the identical created_at timestamp. + var latest string + if err := store.db.QueryRowContext(context.Background(), ` +SELECT disposition FROM intent_dispositions +WHERE intent_id = 'intent:seq' +ORDER BY seq DESC LIMIT 1 +`).Scan(&latest); err != nil { + t.Fatalf("read derived latest: %v", err) + } + want := "tracked" + if writers%2 == 0 { + want = "resolved" + } + if latest != want { + t.Fatalf("derived latest disposition = %q, want %q (greatest sequence %d)", latest, want, writers) + } +} diff --git a/internal/state/exploration.go b/internal/state/exploration.go new file mode 100644 index 000000000..67c3cad77 --- /dev/null +++ b/internal/state/exploration.go @@ -0,0 +1,611 @@ +package state + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + "unicode" + + "github.com/levifig/loaf/internal/project" +) + +const ( + explorationTitleMaxBytes = 200 + checkpointFieldMaxBytes = 4096 + checkpointItemMaxBytes = 4096 +) + +// ExplorationValidationError identifies malformed exploration input with a +// stable field name; oversize core input is rejected, never truncated. +type ExplorationValidationError struct { + Field string + Err error +} + +func (e *ExplorationValidationError) Error() string { + if e == nil { + return "exploration validation failed" + } + return fmt.Sprintf("exploration validation failed for %s: %v", e.Field, e.Err) +} + +func (e *ExplorationValidationError) Unwrap() error { return e.Err } + +// ExplorationTransactionError identifies the transactional stage that failed. +type ExplorationTransactionError struct { + Stage string + Err error +} + +func (e *ExplorationTransactionError) Error() string { + if e == nil { + return "exploration transaction failed" + } + return fmt.Sprintf("exploration transaction failed at %s: %v", e.Stage, e.Err) +} + +func (e *ExplorationTransactionError) Unwrap() error { return e.Err } + +// ExplorationCreateOptions describes a new Exploration identity. +type ExplorationCreateOptions struct { + Title string + Sources []string +} + +// CheckpointItemInput is one ordered typed optional detail entry. +type CheckpointItemInput struct { + Type string + Content string +} + +// ExplorationCheckpointOptions appends one immutable portable checkpoint. +type ExplorationCheckpointOptions struct { + ExplorationRef string + Purpose string + Conclusions string + Unresolved string + NextAction string + Items []CheckpointItemInput + OperationID string +} + +// CheckpointItemDetail is one stored ordered item. +type CheckpointItemDetail struct { + Type string `json:"type"` + Position int `json:"position"` + Content string `json:"content"` +} + +// CheckpointDetail is one immutable checkpoint read model. +type CheckpointDetail struct { + ID string `json:"id"` + Seq int `json:"seq"` + Purpose string `json:"purpose"` + Conclusions string `json:"conclusions"` + Unresolved string `json:"unresolved"` + NextAction string `json:"next_action"` + ContentDigest string `json:"content_digest"` + CreatedAt string `json:"created_at"` +} + +// ExplorationMutationResult is the shared envelope for exploration writes. +type ExplorationMutationResult struct { + ContractVersion int `json:"contract_version"` + 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"` + OperationID string `json:"operation_id,omitempty"` + Created bool `json:"created"` + InputDigest string `json:"input_digest,omitempty"` + StoredDigest string `json:"stored_digest,omitempty"` + InputDigestMatches bool `json:"input_digest_matches"` + Exploration ExplorationDetail `json:"exploration"` + Checkpoint *CheckpointDetail `json:"checkpoint,omitempty"` + ItemCount int `json:"item_count,omitempty"` +} + +// ExplorationDetail is the compact exploration identity read model. +type ExplorationDetail struct { + ID string `json:"id"` + Alias string `json:"alias,omitempty"` + Title string `json:"title"` + CheckpointCount int `json:"checkpoint_count"` + LatestCheckpointSeq int `json:"latest_checkpoint_seq,omitempty"` + PortableContextPresent bool `json:"portable_context_present"` + CreatedAt string `json:"created_at"` +} + +// ExplorationListResult lists explorations deterministically. +type ExplorationListResult struct { + ContractVersion int `json:"contract_version"` + 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"` + Explorations []ExplorationDetail `json:"explorations"` +} + +func validateCheckpointCoreField(field, value string) (string, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "", &ExplorationValidationError{Field: field, Err: fmt.Errorf("must be nonempty")} + } + if len(trimmed) > checkpointFieldMaxBytes { + return "", &ExplorationValidationError{Field: field, Err: fmt.Errorf("exceeds %d UTF-8 bytes; store detail in checkpoint items or related evidence instead of truncating", checkpointFieldMaxBytes)} + } + for _, r := range trimmed { + if unicode.IsControl(r) && r != '\n' && r != '\t' { + return "", &ExplorationValidationError{Field: field, Err: fmt.Errorf("contains control characters")} + } + } + return trimmed, nil +} + +// CreateExploration writes a new Exploration identity with optional source +// relationships. There is no lifecycle status to initialize. +func CreateExploration(ctx context.Context, root project.Root, resolver PathResolver, options ExplorationCreateOptions) (ExplorationMutationResult, error) { + store, err := openProjectStoreMutateExisting(ctx, root, resolver) + if err != nil { + return ExplorationMutationResult{}, err + } + defer store.Close() + return store.CreateExploration(ctx, root, options) +} + +// CreateExploration writes a new Exploration identity on an open store. +func (s *Store) CreateExploration(ctx context.Context, root project.Root, options ExplorationCreateOptions) (ExplorationMutationResult, error) { + title := strings.TrimSpace(options.Title) + if title == "" { + return ExplorationMutationResult{}, &ExplorationValidationError{Field: "title", Err: fmt.Errorf("must be nonempty")} + } + if len(title) > explorationTitleMaxBytes { + return ExplorationMutationResult{}, &ExplorationValidationError{Field: "title", Err: fmt.Errorf("exceeds %d bytes", explorationTitleMaxBytes)} + } + projectID, err := s.projectID(ctx, root) + if err != nil { + return ExplorationMutationResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return ExplorationMutationResult{}, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return ExplorationMutationResult{}, &ExplorationTransactionError{Stage: "begin", Err: err} + } + defer tx.Rollback() + + now := time.Now().UTC() + timestamp := now.Format(time.RFC3339Nano) + alias, err := s.nextExplorationAlias(ctx, tx, projectID, title, now) + if err != nil { + return ExplorationMutationResult{}, &ExplorationTransactionError{Stage: "alias allocation", Err: err} + } + explorationID := stableMigrationID("exploration", projectID, alias) + if _, err := tx.ExecContext(ctx, ` +INSERT INTO explorations (id, project_id, title, created_at) VALUES (?, ?, ?, ?) +`, explorationID, projectID, title, timestamp); err != nil { + return ExplorationMutationResult{}, &ExplorationTransactionError{Stage: "exploration", Err: err} + } + if err := insertAlias(ctx, tx, projectID, "exploration", explorationID, "exploration", alias, timestamp); err != nil { + return ExplorationMutationResult{}, &ExplorationTransactionError{Stage: "alias", Err: err} + } + if err := writeExplorationSourceRelationshipsTx(ctx, tx, projectID, explorationID, options.Sources, timestamp); err != nil { + return ExplorationMutationResult{}, err + } + if err := tx.Commit(); err != nil { + return ExplorationMutationResult{}, &ExplorationTransactionError{Stage: "commit", Err: err} + } + return ExplorationMutationResult{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Created: true, + InputDigestMatches: true, + Exploration: ExplorationDetail{ + ID: explorationID, + Alias: alias, + Title: title, + CreatedAt: timestamp, + }, + }, nil +} + +// explorationWriteHooks injects failures between checkpoint stages in tests. +type explorationWriteHooks struct { + afterCheckpoint func(*sql.Tx) error + afterItems func(*sql.Tx) error + beforeCommit func(*sql.Tx) error +} + +// AppendExplorationCheckpoint appends one immutable portable checkpoint. +func AppendExplorationCheckpoint(ctx context.Context, root project.Root, resolver PathResolver, options ExplorationCheckpointOptions) (ExplorationMutationResult, error) { + store, err := openProjectStoreMutateExisting(ctx, root, resolver) + if err != nil { + return ExplorationMutationResult{}, err + } + defer store.Close() + return store.AppendExplorationCheckpoint(ctx, root, options) +} + +// AppendExplorationCheckpoint appends one immutable checkpoint on an open store. +func (s *Store) AppendExplorationCheckpoint(ctx context.Context, root project.Root, options ExplorationCheckpointOptions) (ExplorationMutationResult, error) { + return s.appendExplorationCheckpointWithHooks(ctx, root, options, nil) +} + +func (s *Store) appendExplorationCheckpointWithHooks(ctx context.Context, root project.Root, options ExplorationCheckpointOptions, hooks *explorationWriteHooks) (ExplorationMutationResult, error) { + purpose, err := validateCheckpointCoreField("purpose", options.Purpose) + if err != nil { + return ExplorationMutationResult{}, err + } + conclusions, err := validateCheckpointCoreField("conclusions", options.Conclusions) + if err != nil { + return ExplorationMutationResult{}, err + } + unresolved, err := validateCheckpointCoreField("unresolved", options.Unresolved) + if err != nil { + return ExplorationMutationResult{}, err + } + nextAction, err := validateCheckpointCoreField("next_action", options.NextAction) + if err != nil { + return ExplorationMutationResult{}, err + } + operationID := strings.TrimSpace(options.OperationID) + if len(operationID) > intentOperationMaxBytes { + return ExplorationMutationResult{}, &ExplorationValidationError{Field: "operation_id", Err: fmt.Errorf("exceeds %d bytes", intentOperationMaxBytes)} + } + items := make([]CheckpointItemDetail, 0, len(options.Items)) + for index, item := range options.Items { + itemType := strings.TrimSpace(item.Type) + if err := validateCheckpointItemType(itemType); err != nil { + return ExplorationMutationResult{}, &ExplorationValidationError{Field: fmt.Sprintf("item[%d].type", index), Err: err} + } + content := strings.TrimSpace(item.Content) + if content == "" { + return ExplorationMutationResult{}, &ExplorationValidationError{Field: fmt.Sprintf("item[%d].content", index), Err: fmt.Errorf("must be nonempty")} + } + if len(content) > checkpointItemMaxBytes { + return ExplorationMutationResult{}, &ExplorationValidationError{Field: fmt.Sprintf("item[%d].content", index), Err: fmt.Errorf("exceeds %d UTF-8 bytes", checkpointItemMaxBytes)} + } + items = append(items, CheckpointItemDetail{Type: itemType, Position: index + 1, Content: content}) + } + + // The idempotency digest covers the complete requested checkpoint: the + // four-field core plus every ordered typed item, so a retry that differs + // only in items reports a digest mismatch instead of a silent match. + digestParts := []string{purpose, conclusions, unresolved, nextAction} + for _, item := range items { + digestParts = append(digestParts, item.Type+":"+item.Content) + } + inputDigest := intentDigest(strings.Join(digestParts, "\x00")) + + projectID, err := s.projectID(ctx, root) + if err != nil { + return ExplorationMutationResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return ExplorationMutationResult{}, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return ExplorationMutationResult{}, &ExplorationTransactionError{Stage: "begin", Err: err} + } + defer tx.Rollback() + + explorationID, alias, err := resolveExplorationRefTx(ctx, tx, projectID, options.ExplorationRef) + if err != nil { + return ExplorationMutationResult{}, err + } + + if operationID != "" { + var existingID, existingExploration string + var existingSeq int + var existingDigest string + err := tx.QueryRowContext(ctx, ` +SELECT id, exploration_id, seq, content_digest FROM exploration_checkpoints +WHERE project_id = ? AND operation_key = ? +`, projectID, operationID).Scan(&existingID, &existingExploration, &existingSeq, &existingDigest) + switch { + case err == nil: + if existingExploration != explorationID { + return ExplorationMutationResult{}, &ExplorationValidationError{Field: "operation_id", Err: fmt.Errorf("operation key %q is already bound to a checkpoint on exploration %s and cannot append to %s; use a distinct operation key", operationID, existingExploration, explorationID)} + } + checkpoint, loadErr := loadCheckpointTx(ctx, tx, projectID, existingID) + if loadErr != nil { + return ExplorationMutationResult{}, &ExplorationTransactionError{Stage: "load established checkpoint", Err: loadErr} + } + detail, loadErr := loadExplorationDetailTx(ctx, tx, projectID, explorationID) + if loadErr != nil { + return ExplorationMutationResult{}, &ExplorationTransactionError{Stage: "load exploration", Err: loadErr} + } + if err := tx.Commit(); err != nil { + return ExplorationMutationResult{}, &ExplorationTransactionError{Stage: "commit retry", Err: err} + } + return ExplorationMutationResult{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + OperationID: operationID, + Created: false, + InputDigest: inputDigest, + StoredDigest: existingDigest, + InputDigestMatches: inputDigest == existingDigest, + Exploration: detail, + Checkpoint: &checkpoint, + }, nil + case !errors.Is(err, sql.ErrNoRows): + return ExplorationMutationResult{}, &ExplorationTransactionError{Stage: "lookup operation key", Err: err} + } + } + + seq, err := nextAggregateSeq(ctx, tx, "exploration_checkpoints", "exploration_id", explorationID) + if err != nil { + return ExplorationMutationResult{}, &ExplorationTransactionError{Stage: "sequence", Err: err} + } + now := time.Now().UTC().Format(time.RFC3339Nano) + checkpointID := stableMigrationID("exploration-checkpoint", projectID, explorationID, fmt.Sprintf("%d", seq)) + operationValue := sql.NullString{} + if operationID != "" { + operationValue = sql.NullString{String: operationID, Valid: true} + } + if _, err := tx.ExecContext(ctx, ` +INSERT INTO exploration_checkpoints (id, project_id, exploration_id, seq, purpose, conclusions, unresolved, next_action, operation_key, content_digest, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +`, checkpointID, projectID, explorationID, seq, purpose, conclusions, unresolved, nextAction, operationValue, inputDigest, now); err != nil { + return ExplorationMutationResult{}, &ExplorationTransactionError{Stage: "checkpoint", Err: err} + } + if hooks != nil && hooks.afterCheckpoint != nil { + if err := hooks.afterCheckpoint(tx); err != nil { + return ExplorationMutationResult{}, &ExplorationTransactionError{Stage: "after checkpoint", Err: err} + } + } + for _, item := range items { + itemID := stableMigrationID("exploration-checkpoint-item", projectID, checkpointID, fmt.Sprintf("%d", item.Position)) + if _, err := tx.ExecContext(ctx, ` +INSERT INTO exploration_checkpoint_items (id, project_id, checkpoint_id, item_type, position, content, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?) +`, itemID, projectID, checkpointID, item.Type, item.Position, item.Content, now); err != nil { + return ExplorationMutationResult{}, &ExplorationTransactionError{Stage: "items", Err: err} + } + } + if hooks != nil && hooks.afterItems != nil { + if err := hooks.afterItems(tx); err != nil { + return ExplorationMutationResult{}, &ExplorationTransactionError{Stage: "after items", Err: err} + } + } + if hooks != nil && hooks.beforeCommit != nil { + if err := hooks.beforeCommit(tx); err != nil { + return ExplorationMutationResult{}, &ExplorationTransactionError{Stage: "before commit", Err: err} + } + } + detail, err := loadExplorationDetailTx(ctx, tx, projectID, explorationID) + if err != nil { + return ExplorationMutationResult{}, &ExplorationTransactionError{Stage: "load exploration", Err: err} + } + if err := tx.Commit(); err != nil { + return ExplorationMutationResult{}, &ExplorationTransactionError{Stage: "commit", Err: err} + } + _ = alias + return ExplorationMutationResult{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + OperationID: operationID, + Created: true, + InputDigest: inputDigest, + StoredDigest: inputDigest, + InputDigestMatches: true, + Exploration: detail, + Checkpoint: &CheckpointDetail{ + ID: checkpointID, + Seq: seq, + Purpose: purpose, + Conclusions: conclusions, + Unresolved: unresolved, + NextAction: nextAction, + ContentDigest: inputDigest, + CreatedAt: now, + }, + ItemCount: len(items), + }, nil +} + +// ListExplorations returns the deterministic exploration list projection. +func ListExplorations(ctx context.Context, root project.Root, resolver PathResolver) (ExplorationListResult, error) { + store, err := openProjectStoreReadExisting(ctx, root, resolver) + if err != nil { + return ExplorationListResult{}, err + } + defer store.Close() + projectID, err := store.projectID(ctx, root) + if err != nil { + return ExplorationListResult{}, err + } + identity, err := store.projectIdentity(ctx, projectID) + if err != nil { + return ExplorationListResult{}, err + } + rows, err := store.db.QueryContext(ctx, ` +SELECT e.id, + COALESCE((SELECT alias FROM aliases WHERE project_id = e.project_id AND entity_kind = 'exploration' AND entity_id = e.id ORDER BY namespace, alias LIMIT 1), ''), + e.title, + (SELECT COUNT(*) FROM exploration_checkpoints WHERE exploration_id = e.id), + COALESCE((SELECT MAX(seq) FROM exploration_checkpoints WHERE exploration_id = e.id), 0), + e.created_at +FROM explorations AS e +WHERE e.project_id = ? +ORDER BY e.created_at, e.id +`, projectID) + if err != nil { + return ExplorationListResult{}, fmt.Errorf("list explorations: %w", err) + } + defer rows.Close() + items := []ExplorationDetail{} + for rows.Next() { + var detail ExplorationDetail + if err := rows.Scan(&detail.ID, &detail.Alias, &detail.Title, &detail.CheckpointCount, &detail.LatestCheckpointSeq, &detail.CreatedAt); err != nil { + return ExplorationListResult{}, fmt.Errorf("scan exploration: %w", err) + } + detail.PortableContextPresent = detail.CheckpointCount > 0 + items = append(items, detail) + } + if err := rows.Err(); err != nil { + return ExplorationListResult{}, fmt.Errorf("iterate explorations: %w", err) + } + return ExplorationListResult{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Explorations: items, + }, nil +} + +func (s *Store) nextExplorationAlias(ctx context.Context, tx *sql.Tx, projectID string, title string, now time.Time) (string, error) { + slug := normalizeSparkSlug(title) + if slug == "" { + slug = "exploration" + } + prefix := "EXPL-" + now.UTC().Format("20060102") + "-" + slug + for next := 1; ; next++ { + alias := prefix + if next > 1 { + alias = fmt.Sprintf("%s-%d", prefix, next) + } + var existing string + err := tx.QueryRowContext(ctx, `SELECT id FROM aliases WHERE project_id = ? AND namespace = 'exploration' AND alias = ?`, projectID, alias).Scan(&existing) + if errors.Is(err, sql.ErrNoRows) { + return alias, nil + } + if err != nil { + return "", fmt.Errorf("probe exploration alias %s: %w", alias, err) + } + } +} + +func resolveExplorationRefTx(ctx context.Context, tx *sql.Tx, projectID string, ref string) (string, string, error) { + trimmed := strings.TrimSpace(ref) + if trimmed == "" { + return "", "", &ExplorationValidationError{Field: "exploration", 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 alias = ? +ORDER BY namespace LIMIT 1 +`, projectID, trimmed).Scan(&kind, &id, &alias) + switch { + case err == nil: + if kind != "exploration" { + return "", "", fmt.Errorf("%q resolves to %s, not an exploration", trimmed, kind) + } + return id, alias, nil + case !errors.Is(err, sql.ErrNoRows): + return "", "", fmt.Errorf("resolve exploration %q: %w", trimmed, err) + } + var existing string + err = tx.QueryRowContext(ctx, `SELECT id FROM explorations WHERE project_id = ? AND id = ?`, projectID, trimmed).Scan(&existing) + if errors.Is(err, sql.ErrNoRows) { + return "", "", fmt.Errorf("exploration %q not found in SQLite state", trimmed) + } + if err != nil { + return "", "", fmt.Errorf("resolve exploration %q: %w", trimmed, err) + } + return existing, "", nil +} + +func loadExplorationDetailTx(ctx context.Context, tx *sql.Tx, projectID, explorationID string) (ExplorationDetail, error) { + detail := ExplorationDetail{ID: explorationID} + err := tx.QueryRowContext(ctx, ` +SELECT title, created_at, + (SELECT COUNT(*) FROM exploration_checkpoints WHERE exploration_id = explorations.id), + COALESCE((SELECT MAX(seq) FROM exploration_checkpoints WHERE exploration_id = explorations.id), 0) +FROM explorations WHERE project_id = ? AND id = ? +`, projectID, explorationID).Scan(&detail.Title, &detail.CreatedAt, &detail.CheckpointCount, &detail.LatestCheckpointSeq) + if errors.Is(err, sql.ErrNoRows) { + return ExplorationDetail{}, fmt.Errorf("exploration %s not found", explorationID) + } + if err != nil { + return ExplorationDetail{}, err + } + detail.PortableContextPresent = detail.CheckpointCount > 0 + if err := tx.QueryRowContext(ctx, ` +SELECT alias FROM aliases WHERE project_id = ? AND entity_kind = 'exploration' AND entity_id = ? +ORDER BY namespace, alias LIMIT 1 +`, projectID, explorationID).Scan(&detail.Alias); err != nil && !errors.Is(err, sql.ErrNoRows) { + return ExplorationDetail{}, err + } + return detail, nil +} + +func loadCheckpointTx(ctx context.Context, tx *sql.Tx, projectID, checkpointID string) (CheckpointDetail, error) { + var checkpoint CheckpointDetail + err := tx.QueryRowContext(ctx, ` +SELECT id, seq, purpose, conclusions, unresolved, next_action, content_digest, created_at +FROM exploration_checkpoints WHERE project_id = ? AND id = ? +`, projectID, checkpointID).Scan(&checkpoint.ID, &checkpoint.Seq, &checkpoint.Purpose, &checkpoint.Conclusions, &checkpoint.Unresolved, &checkpoint.NextAction, &checkpoint.ContentDigest, &checkpoint.CreatedAt) + if err != nil { + return CheckpointDetail{}, err + } + return checkpoint, nil +} + +// writeExplorationSourceRelationshipsTx maps each source by its kind through +// the closed matrix: intents get explores edges; journal entries, handoffs, +// reports, and findings get uses-source edges. Anything else is rejected. +func writeExplorationSourceRelationshipsTx(ctx context.Context, tx *sql.Tx, projectID, explorationID string, refs []string, now string) error { + seen := map[string]bool{} + for _, ref := range refs { + trimmed := strings.TrimSpace(ref) + if trimmed == "" { + continue + } + entity, err := resolveSourceEntityTx(ctx, tx, projectID, trimmed) + if err != nil { + return &ExplorationTransactionError{Stage: "resolve source", Err: err} + } + relationshipType := "" + switch entity.Kind { + case "intent": + relationshipType = "explores" + case "journal_entry", "handoff", "report", "finding": + relationshipType = "uses-source" + default: + return &ExplorationValidationError{Field: "from", Err: fmt.Errorf("exploration cannot use %s %q as a source; supported sources are intents, journal entries, handoffs, reports, and findings", entity.Kind, trimmed)} + } + if err := validateRelationshipAgainstRegistry("exploration", relationshipType, entity.Kind); err != nil { + return &ExplorationValidationError{Field: "from", Err: err} + } + key := entity.Kind + "\x00" + entity.ID + if seen[key] { + return &ExplorationValidationError{Field: "from", Err: fmt.Errorf("source %q is duplicated", trimmed)} + } + seen[key] = true + relationshipID := stableMigrationID("relationship", projectID, "exploration", explorationID, relationshipType, entity.Kind, entity.ID) + 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 (?, ?, 'exploration', ?, ?, ?, ?, 'recorded by exploration create', 'exploration-create', ?, ?) +`, relationshipID, projectID, explorationID, entity.Kind, entity.ID, relationshipType, now, now); err != nil { + return &ExplorationTransactionError{Stage: "relationship", Err: err} + } + } + return nil +} diff --git a/internal/state/exploration_test.go b/internal/state/exploration_test.go new file mode 100644 index 000000000..bd32041fb --- /dev/null +++ b/internal/state/exploration_test.go @@ -0,0 +1,457 @@ +package state + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "testing" + + "github.com/levifig/loaf/internal/project" +) + +func explorationFixture(t *testing.T) (project.Root, PathResolver, *Store) { + t.Helper() + root := projectRoot(t) + resolver := PathResolver{StateHome: t.TempDir()} + status, err := Initialize(context.Background(), root, resolver) + 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, resolver, store +} + +func seedPortableExploration(t *testing.T, root project.Root, store *Store) ExplorationMutationResult { + t.Helper() + ctx := context.Background() + created, err := store.CreateExploration(ctx, root, ExplorationCreateOptions{Title: "Workflow and artifact model"}) + if err != nil { + t.Fatalf("CreateExploration() error = %v", err) + } + checkpointed, err := store.AppendExplorationCheckpoint(ctx, root, ExplorationCheckpointOptions{ + ExplorationRef: created.Exploration.Alias, + Purpose: "Understand the portable context contract", + Conclusions: "Append-only facts beat lifecycle flags", + Unresolved: "How should availability observations shape triage?", + NextAction: "Implement the context projection and hand it to a fresh agent", + Items: []CheckpointItemInput{ + {Type: "candidate", Content: "bounded layers with cursors"}, + {Type: "evidence", Content: "journal defer convergence tests"}, + }, + }) + if err != nil { + t.Fatalf("AppendExplorationCheckpoint() error = %v", err) + } + return checkpointed +} + +func TestExplorationCheckpointPortableCoreValidation(t *testing.T) { + root, _, store := explorationFixture(t) + ctx := context.Background() + created, err := store.CreateExploration(ctx, root, ExplorationCreateOptions{Title: "Validation target"}) + if err != nil { + t.Fatalf("CreateExploration() error = %v", err) + } + + base := ExplorationCheckpointOptions{ + ExplorationRef: created.Exploration.Alias, + Purpose: "p", Conclusions: "c", Unresolved: "u", NextAction: "n", + } + for field, mutate := range map[string]func(*ExplorationCheckpointOptions){ + "purpose": func(o *ExplorationCheckpointOptions) { o.Purpose = " " }, + "conclusions": func(o *ExplorationCheckpointOptions) { o.Conclusions = "" }, + "unresolved": func(o *ExplorationCheckpointOptions) { o.Unresolved = "\n\t" }, + "next_action": func(o *ExplorationCheckpointOptions) { o.NextAction = " " }, + } { + options := base + mutate(&options) + _, err := store.AppendExplorationCheckpoint(ctx, root, options) + var validation *ExplorationValidationError + if err == nil || !errors.As(err, &validation) || validation.Field != field { + t.Fatalf("missing %s error = %v, want field-specific validation error", field, err) + } + } + + // Oversize core fields fail with the stable field error and leave no rows. + oversize := base + oversize.NextAction = strings.Repeat("x", checkpointFieldMaxBytes+1) + if _, err := store.AppendExplorationCheckpoint(ctx, root, oversize); err == nil || !strings.Contains(err.Error(), "next_action") || !strings.Contains(err.Error(), "4096") { + t.Fatalf("oversize next_action error = %v, want stable byte-cap error", err) + } + // Multibyte input just over the cap is rejected on bytes, not runes. + multibyte := base + multibyte.Purpose = strings.Repeat("é", checkpointFieldMaxBytes/2+1) + if _, err := store.AppendExplorationCheckpoint(ctx, root, multibyte); err == nil || !strings.Contains(err.Error(), "purpose") { + t.Fatalf("multibyte oversize error = %v, want purpose byte-cap error", err) + } + var checkpoints, items int + if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*), (SELECT COUNT(*) FROM exploration_checkpoint_items) FROM exploration_checkpoints`).Scan(&checkpoints, &items); err != nil { + t.Fatalf("count rows: %v", err) + } + if checkpoints != 0 || items != 0 { + t.Fatalf("checkpoints=%d items=%d after rejected writes, want 0/0", checkpoints, items) + } + + // A checkpoint exactly at the cap is accepted whole, never truncated. + exact := base + exact.NextAction = strings.Repeat("y", checkpointFieldMaxBytes) + result, err := store.AppendExplorationCheckpoint(ctx, root, exact) + if err != nil { + t.Fatalf("exact-cap checkpoint error = %v", err) + } + if len(result.Checkpoint.NextAction) != checkpointFieldMaxBytes { + t.Fatalf("stored next_action bytes = %d, want %d untruncated", len(result.Checkpoint.NextAction), checkpointFieldMaxBytes) + } +} + +func TestHandleOnlyExplorationReportsNoPortableContext(t *testing.T) { + root, resolver, store := explorationFixture(t) + ctx := context.Background() + created, err := store.CreateExploration(ctx, root, ExplorationCreateOptions{Title: "Handle-only exploration"}) + if err != nil { + t.Fatalf("CreateExploration() error = %v", err) + } + conversation, err := store.CreateConversation(ctx, root, ConversationCreateOptions{Title: "codex thread", OperationID: "conv-1"}) + if err != nil { + t.Fatalf("CreateConversation() error = %v", err) + } + if _, err := store.AddConversationHandle(ctx, root, ConversationHandleAddOptions{ + ConversationRef: conversation.Conversation.ID, + Harness: "codex", + Handle: "019f62e6-88d2-7630-96ce-527652fd9a0b", + Locality: "machine-a", + }); err != nil { + t.Fatalf("AddConversationHandle() error = %v", err) + } + if _, err := store.AddExplorationConversation(ctx, root, created.Exploration.Alias, conversation.Conversation.ID); err != nil { + t.Fatalf("AddExplorationConversation() error = %v", err) + } + + result, err := ExplorationContext(ctx, root, resolver, ExplorationContextOptions{ExplorationRef: created.Exploration.Alias}) + if err != nil { + t.Fatalf("ExplorationContext() error = %v", err) + } + if result.PortableContextPresent { + t.Fatal("handle-only exploration claims portable context") + } + if result.Checkpoint != nil { + t.Fatalf("handle-only exploration returned checkpoint %#v", result.Checkpoint) + } + conversations := result.Layers["conversations"] + if conversations.Available != 1 || conversations.Shown != 1 { + t.Fatalf("conversations layer = %#v, want the one source handle visible", conversations) + } +} + +func TestExplorationContextSurvivesUnavailableSources(t *testing.T) { + root, resolver, store := explorationFixture(t) + ctx := context.Background() + checkpointed := seedPortableExploration(t, root, store) + explorationRef := checkpointed.Exploration.Alias + wantNext := checkpointed.Checkpoint.NextAction + + conversation, err := store.CreateConversation(ctx, root, ConversationCreateOptions{Title: "primary thread", OperationID: "conv-primary"}) + if err != nil { + t.Fatalf("CreateConversation() error = %v", err) + } + handle, err := store.AddConversationHandle(ctx, root, ConversationHandleAddOptions{ + ConversationRef: conversation.Conversation.ID, + Harness: "codex", + Handle: "local-thread-1", + Locality: "machine-a", + LogRef: "/Users/nobody/.codex/sessions/thread.jsonl", + Hash: strings.Repeat("a", 64), + Range: "1-500", + }) + if err != nil { + t.Fatalf("AddConversationHandle() error = %v", err) + } + if _, err := store.AddExplorationConversation(ctx, root, explorationRef, conversation.Conversation.ID); err != nil { + t.Fatalf("AddExplorationConversation() error = %v", err) + } + // Every local source is observed unavailable. + if _, err := store.ObserveConversationSource(ctx, root, ConversationObserveOptions{ + SubjectKind: "conversation_handle", SubjectID: handle.HandleID, Available: false, Observer: "probe", Locality: "machine-b", Note: "log purged", + }); err != nil { + t.Fatalf("ObserveConversationSource(handle) error = %v", err) + } + if _, err := store.ObserveConversationSource(ctx, root, ConversationObserveOptions{ + SubjectKind: "conversation_log_ref", SubjectID: handle.LogRefID, Available: false, Observer: "probe", Locality: "machine-b", + }); err != nil { + t.Fatalf("ObserveConversationSource(log) error = %v", err) + } + + result, err := ExplorationContext(ctx, root, resolver, ExplorationContextOptions{ExplorationRef: explorationRef}) + if err != nil { + t.Fatalf("ExplorationContext() error = %v", err) + } + if !result.PortableContextPresent || result.Checkpoint == nil { + t.Fatalf("context = %#v, want portable checkpoint present", result) + } + if result.Checkpoint.NextAction != wantNext { + t.Fatalf("next action = %q, want byte-identical %q with every source unavailable", result.Checkpoint.NextAction, wantNext) + } + var conversationsLayer []ConversationDetail + if err := json.Unmarshal(result.Layers["conversations"].Items, &conversationsLayer); err != nil { + t.Fatalf("decode conversations layer: %v", err) + } + if len(conversationsLayer) != 1 || len(conversationsLayer[0].Handles) != 1 { + t.Fatalf("conversations layer = %#v, want one conversation with one handle", conversationsLayer) + } + observed := conversationsLayer[0].Handles[0] + if observed.Latest == nil || observed.Latest.Available || observed.Latest.Locality != "machine-b" { + t.Fatalf("handle observation = %#v, want latest unavailable at machine-b", observed.Latest) + } + if len(observed.LogRefs) != 1 || observed.LogRefs[0].Latest == nil || observed.LogRefs[0].Latest.Available { + t.Fatalf("log ref observation = %#v, want latest unavailable", observed.LogRefs) + } + + // The handle row itself never mutates when availability changes. + var handleColumns int + if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM pragma_table_info('conversation_handles') WHERE name IN ('available', 'availability', 'status', 'updated_at')`).Scan(&handleColumns); err != nil { + t.Fatalf("inspect handle columns: %v", err) + } + if handleColumns != 0 { + t.Fatalf("conversation_handles carries %d mutable availability columns, want 0", handleColumns) + } +} + +func TestExplorationContextLayersPaginateDeterministically(t *testing.T) { + root, resolver, store := explorationFixture(t) + ctx := context.Background() + created, err := store.CreateExploration(ctx, root, ExplorationCreateOptions{Title: "Paginated"}) + if err != nil { + t.Fatalf("CreateExploration() error = %v", err) + } + items := make([]CheckpointItemInput, 25) + for i := range items { + items[i] = CheckpointItemInput{Type: "candidate", Content: fmt.Sprintf("candidate %02d", i+1)} + } + if _, err := store.AppendExplorationCheckpoint(ctx, root, ExplorationCheckpointOptions{ + ExplorationRef: created.Exploration.Alias, + Purpose: "p", Conclusions: "c", Unresolved: "u", NextAction: "n", + Items: items, + }); err != nil { + t.Fatalf("AppendExplorationCheckpoint() error = %v", err) + } + + collected := []CheckpointItemDetail{} + cursor := "" + pages := 0 + for { + result, err := ExplorationContext(ctx, root, resolver, ExplorationContextOptions{ + ExplorationRef: created.Exploration.Alias, + Layer: "items", + Cursor: cursor, + Limit: 10, + }) + if err != nil { + t.Fatalf("ExplorationContext(page %d) error = %v", pages, err) + } + layer := result.Layers["items"] + var page []CheckpointItemDetail + if err := json.Unmarshal(layer.Items, &page); err != nil { + t.Fatalf("decode page %d: %v", pages, err) + } + collected = append(collected, page...) + pages++ + if layer.Available != 25 { + t.Fatalf("page %d available = %d, want 25", pages, layer.Available) + } + if !layer.Truncated { + break + } + if layer.Cursor == "" || !strings.Contains(layer.ExpandCommand, "--layer items --cursor ") { + t.Fatalf("truncated layer without cursor/expand command: %#v", layer) + } + cursor = layer.Cursor + } + if pages != 3 || len(collected) != 25 { + t.Fatalf("pages=%d collected=%d, want 3 pages covering all 25", pages, len(collected)) + } + for i, item := range collected { + if item.Position != i+1 { + t.Fatalf("pagination omitted or duplicated: item %d has position %d", i, item.Position) + } + } + + // Cursors are layer- and exploration-bound. + if _, err := ExplorationContext(ctx, root, resolver, ExplorationContextOptions{ + ExplorationRef: created.Exploration.Alias, + Layer: "conversations", + Cursor: cursor, + Limit: 10, + }); err == nil { + t.Fatal("cursor accepted for the wrong layer") + } + if _, err := ExplorationContext(ctx, root, resolver, ExplorationContextOptions{ + ExplorationRef: created.Exploration.Alias, + Layer: "items", + Cursor: "not-a-cursor", + Limit: 10, + }); err == nil { + t.Fatal("malformed cursor accepted") + } +} + +func TestConcurrentCheckpointAppendsReceiveDistinctSequences(t *testing.T) { + root, resolver, store := explorationFixture(t) + ctx := context.Background() + created, err := store.CreateExploration(ctx, root, ExplorationCreateOptions{Title: "Concurrent"}) + if err != nil { + t.Fatalf("CreateExploration() error = %v", err) + } + + const writers = 8 + var wg sync.WaitGroup + errs := make([]error, writers) + for i := 0; i < writers; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + for attempt := 0; attempt < 50; attempt++ { + _, err := store.AppendExplorationCheckpoint(ctx, root, ExplorationCheckpointOptions{ + ExplorationRef: created.Exploration.Alias, + Purpose: fmt.Sprintf("writer %d", i), + Conclusions: "c", Unresolved: "u", + NextAction: fmt.Sprintf("next from writer %d", i), + }) + if err == nil { + errs[i] = nil + return + } + errs[i] = err + } + }(i) + } + wg.Wait() + for i, err := range errs { + if err != nil { + t.Fatalf("writer %d error = %v", i, err) + } + } + + rows, err := store.db.QueryContext(ctx, `SELECT seq FROM exploration_checkpoints WHERE exploration_id = ? ORDER BY seq`, created.Exploration.ID) + if err != nil { + t.Fatalf("read sequences: %v", err) + } + defer rows.Close() + var sequences []int + for rows.Next() { + var seq int + if err := rows.Scan(&seq); err != nil { + t.Fatalf("scan: %v", err) + } + sequences = append(sequences, seq) + } + if len(sequences) != writers { + t.Fatalf("checkpoints = %d, want %d", len(sequences), writers) + } + for i, seq := range sequences { + if seq != i+1 { + t.Fatalf("sequences = %v, want dense 1..%d", sequences, writers) + } + } + context, err := ExplorationContext(ctx, root, resolver, ExplorationContextOptions{ExplorationRef: created.Exploration.Alias}) + if err != nil { + t.Fatalf("ExplorationContext() error = %v", err) + } + if context.Checkpoint == nil || context.Checkpoint.Seq != writers { + t.Fatalf("context checkpoint = %#v, want greatest committed seq %d", context.Checkpoint, writers) + } +} + +func TestCheckpointOperationKeyRetryReturnsFirstWrite(t *testing.T) { + root, _, store := explorationFixture(t) + ctx := context.Background() + created, err := store.CreateExploration(ctx, root, ExplorationCreateOptions{Title: "Retry"}) + if err != nil { + t.Fatalf("CreateExploration() error = %v", err) + } + first, err := store.AppendExplorationCheckpoint(ctx, root, ExplorationCheckpointOptions{ + ExplorationRef: created.Exploration.Alias, + Purpose: "p", Conclusions: "c", Unresolved: "u", NextAction: "n", + OperationID: "cp-op-1", + }) + if err != nil { + t.Fatalf("first checkpoint error = %v", err) + } + retry, err := store.AppendExplorationCheckpoint(ctx, root, ExplorationCheckpointOptions{ + ExplorationRef: created.Exploration.Alias, + Purpose: "reworded", Conclusions: "c", Unresolved: "u", NextAction: "n", + OperationID: "cp-op-1", + }) + if err != nil { + t.Fatalf("retry checkpoint error = %v", err) + } + if retry.Created || retry.Checkpoint.ID != first.Checkpoint.ID || retry.InputDigestMatches { + t.Fatalf("retry = %#v, want first write with digest mismatch", retry) + } + var count int + if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM exploration_checkpoints`).Scan(&count); err != nil { + t.Fatalf("count checkpoints: %v", err) + } + if count != 1 { + t.Fatalf("checkpoints = %d, want 1", count) + } +} + +func TestExplorationSchemaStoresNoTranscriptBodies(t *testing.T) { + // The provenance tables store locators, hashes, and bounded ranges — no + // column can hold transcript/prompt/tool/provider payload bodies. + migration := SchemaMigrations()[len(SchemaMigrations())-1] + for _, table := range []string{"logical_conversations", "conversation_handles", "conversation_log_refs", "source_availability_observations"} { + body := tableBody(t, migration.SQL, table) + for _, forbidden := range []string{"transcript", "prompt", "payload", "content TEXT", "body TEXT", "message TEXT"} { + if strings.Contains(body, forbidden) { + t.Fatalf("%s declares %q; provenance tables must not store bodies", table, forbidden) + } + } + } +} + +func TestExplorationCheckpointFailureInjectionLeavesNoPartialState(t *testing.T) { + root, _, store := explorationFixture(t) + ctx := context.Background() + created, err := store.CreateExploration(ctx, root, ExplorationCreateOptions{Title: "Failure injection"}) + if err != nil { + t.Fatalf("CreateExploration() error = %v", err) + } + for _, stage := range []string{"after checkpoint", "after items", "before commit"} { + t.Run(strings.ReplaceAll(stage, " ", "-"), func(t *testing.T) { + hooks := &explorationWriteHooks{} + injected := fmt.Errorf("injected failure") + switch stage { + case "after checkpoint": + hooks.afterCheckpoint = func(*sql.Tx) error { return injected } + case "after items": + hooks.afterItems = func(*sql.Tx) error { return injected } + case "before commit": + hooks.beforeCommit = func(*sql.Tx) error { return injected } + } + _, err := store.appendExplorationCheckpointWithHooks(ctx, root, ExplorationCheckpointOptions{ + ExplorationRef: created.Exploration.Alias, + Purpose: "p", Conclusions: "c", Unresolved: "u", NextAction: "n", + Items: []CheckpointItemInput{{Type: "candidate", Content: "x"}}, + }, hooks) + if err == nil { + t.Fatalf("stage %s: error = nil, want injected failure", stage) + } + var checkpoints, items int + if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*), (SELECT COUNT(*) FROM exploration_checkpoint_items) FROM exploration_checkpoints`).Scan(&checkpoints, &items); err != nil { + t.Fatalf("count rows: %v", err) + } + if checkpoints != 0 || items != 0 { + t.Fatalf("stage %s left checkpoints=%d items=%d, want 0/0", stage, checkpoints, items) + } + }) + } +} diff --git a/internal/state/export.go b/internal/state/export.go index 1fb21d9df..454604dc1 100644 --- a/internal/state/export.go +++ b/internal/state/export.go @@ -158,6 +158,20 @@ var exportAllTables = []exportTable{ {Name: "backend_mappings", OrderBy: "id", FilterColumn: "project_id"}, {Name: "hook_events", OrderBy: "id", FilterColumn: "project_id"}, {Name: "exports", OrderBy: "id", FilterColumn: "project_id"}, + {Name: "intents", OrderBy: "id", FilterColumn: "project_id"}, + {Name: "intent_snapshots", OrderBy: "id", FilterColumn: "project_id"}, + {Name: "intent_deferrals", OrderBy: "id", FilterColumn: "project_id"}, + {Name: "intent_dispositions", OrderBy: "id", FilterColumn: "project_id"}, + {Name: "intent_operations", OrderBy: "operation_key", FilterColumn: "project_id"}, + {Name: "explorations", OrderBy: "id", FilterColumn: "project_id"}, + {Name: "exploration_checkpoints", OrderBy: "id", FilterColumn: "project_id"}, + {Name: "exploration_checkpoint_items", OrderBy: "id", FilterColumn: "project_id"}, + {Name: "logical_conversations", OrderBy: "id", FilterColumn: "project_id"}, + {Name: "conversation_handles", OrderBy: "id", FilterColumn: "project_id"}, + {Name: "conversation_log_refs", OrderBy: "id", FilterColumn: "project_id"}, + {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"}, } // ExportAllJSON returns a repository-non-mutating internal snapshot of SQLite state. diff --git a/internal/state/intake.go b/internal/state/intake.go new file mode 100644 index 000000000..501d0cdcd --- /dev/null +++ b/internal/state/intake.go @@ -0,0 +1,225 @@ +package state + +import ( + "context" + "fmt" + "strings" + + "github.com/levifig/loaf/internal/project" +) + +// IntakeItem is one unresolved logical item in the local intake projection. +// The CLI reports facts and exact read commands; it never ranks, promotes, or +// chooses a disposition. +type IntakeItem struct { + Kind string `json:"kind"` + ID string `json:"id"` + Alias string `json:"alias,omitempty"` + Title string `json:"title"` + Status string `json:"status,omitempty"` + Disposition string `json:"disposition,omitempty"` + OperationKey string `json:"operation_key,omitempty"` + ReadCommand string `json:"read_command"` + CreatedAt string `json:"created_at"` +} + +// IntakeListResult is the deterministic project-local intake projection. +type IntakeListResult struct { + ContractVersion int `json:"contract_version"` + 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"` + Items []IntakeItem `json:"items"` +} + +// ListIntake projects unresolved Sparks, Ideas, Brainstorms, Intents, and +// unmigrated legacy deferrals exactly once each. A spark that is a legacy +// deferral projection surfaces as the deferral (pre-conversion) or is +// deduplicated behind its canonical Intent (post-conversion); it never appears +// twice. +func ListIntake(ctx context.Context, root project.Root, resolver PathResolver) (IntakeListResult, error) { + store, err := openProjectStoreReadExisting(ctx, root, resolver) + if err != nil { + return IntakeListResult{}, err + } + defer store.Close() + projectID, err := store.projectID(ctx, root) + if err != nil { + return IntakeListResult{}, err + } + identity, err := store.projectIdentity(ctx, projectID) + if err != nil { + return IntakeListResult{}, err + } + + items := []IntakeItem{} + + // Open sparks that are not legacy deferral projections. + rows, err := store.db.QueryContext(ctx, ` +SELECT s.id, + COALESCE((SELECT alias FROM aliases WHERE project_id = s.project_id AND entity_kind = 'spark' AND entity_id = s.id ORDER BY namespace, alias LIMIT 1), ''), + s.text, s.status, s.created_at +FROM sparks AS s +WHERE s.project_id = ? AND s.status = 'open' + AND NOT EXISTS (SELECT 1 FROM journal_deferrals AS d WHERE d.project_id = s.project_id AND d.spark_id = s.id) +ORDER BY s.created_at, s.id +`, projectID) + if err != nil { + return IntakeListResult{}, fmt.Errorf("intake sparks: %w", err) + } + if err := scanIntakeRows(rows, &items, "spark", func(item *IntakeItem) { + item.ReadCommand = "loaf spark show " + firstNonEmpty(item.Alias, item.ID) + }); err != nil { + return IntakeListResult{}, err + } + + // Open ideas. + rows, err = store.db.QueryContext(ctx, ` +SELECT i.id, + COALESCE((SELECT alias FROM aliases WHERE project_id = i.project_id AND entity_kind = 'idea' AND entity_id = i.id ORDER BY namespace, alias LIMIT 1), ''), + i.title, i.status, i.created_at +FROM ideas AS i +WHERE i.project_id = ? AND i.status = 'open' +ORDER BY i.created_at, i.id +`, projectID) + if err != nil { + return IntakeListResult{}, fmt.Errorf("intake ideas: %w", err) + } + if err := scanIntakeRows(rows, &items, "idea", func(item *IntakeItem) { + item.ReadCommand = "loaf idea show " + firstNonEmpty(item.Alias, item.ID) + }); err != nil { + return IntakeListResult{}, err + } + + // Open brainstorms. + rows, err = store.db.QueryContext(ctx, ` +SELECT b.id, + COALESCE((SELECT alias FROM aliases WHERE project_id = b.project_id AND entity_kind = 'brainstorm' AND entity_id = b.id ORDER BY namespace, alias LIMIT 1), ''), + b.title, b.status, b.created_at +FROM brainstorms AS b +WHERE b.project_id = ? AND b.status = 'open' +ORDER BY b.created_at, b.id +`, projectID) + if err != nil { + return IntakeListResult{}, fmt.Errorf("intake brainstorms: %w", err) + } + if err := scanIntakeRows(rows, &items, "brainstorm", func(item *IntakeItem) { + item.ReadCommand = "loaf brainstorm show " + firstNonEmpty(item.Alias, item.ID) + }); err != nil { + return IntakeListResult{}, err + } + + // Unresolved intents with derived dispositions. + rows, err = store.db.QueryContext(ctx, ` +SELECT i.id, + COALESCE((SELECT alias FROM aliases WHERE project_id = i.project_id AND entity_kind = 'intent' AND entity_id = i.id ORDER BY namespace, alias LIMIT 1), ''), + COALESCE((SELECT title FROM intent_snapshots WHERE intent_id = i.id ORDER BY seq DESC LIMIT 1), ''), + COALESCE((SELECT disposition FROM intent_dispositions WHERE intent_id = i.id ORDER BY seq DESC LIMIT 1), ''), + i.created_at +FROM intents AS i +WHERE i.project_id = ? +ORDER BY i.created_at, i.id +`, projectID) + if err != nil { + return IntakeListResult{}, fmt.Errorf("intake intents: %w", err) + } + intentRows := []IntakeItem{} + for rows.Next() { + var item IntakeItem + if err := rows.Scan(&item.ID, &item.Alias, &item.Title, &item.Disposition, &item.CreatedAt); err != nil { + rows.Close() + return IntakeListResult{}, fmt.Errorf("scan intake intent: %w", err) + } + item.Kind = "intent" + item.ReadCommand = "loaf intent show " + firstNonEmpty(item.Alias, item.ID) + intentRows = append(intentRows, item) + } + rows.Close() + if err := rows.Err(); err != nil { + return IntakeListResult{}, err + } + for _, item := range intentRows { + if item.Disposition == "resolved" { + continue + } + items = append(items, item) + } + + // Legacy deferrals not yet converged into the canonical model. + rows, err = store.db.QueryContext(ctx, ` +SELECT d.operation_key, d.journal_entry_id, s.text, d.created_at +FROM journal_deferrals AS d +JOIN sparks AS s ON s.project_id = d.project_id AND s.id = d.spark_id +WHERE d.project_id = ? AND s.status = 'open' + AND NOT EXISTS (SELECT 1 FROM intent_operations AS o WHERE o.project_id = d.project_id AND o.operation_key = d.operation_key) +ORDER BY d.created_at, d.operation_key +`, projectID) + if err != nil { + return IntakeListResult{}, fmt.Errorf("intake legacy deferrals: %w", err) + } + for rows.Next() { + var item IntakeItem + var decisionID, sparkText string + if err := rows.Scan(&item.OperationKey, &decisionID, &sparkText, &item.CreatedAt); err != nil { + rows.Close() + return IntakeListResult{}, fmt.Errorf("scan intake legacy deferral: %w", err) + } + item.Kind = "legacy_deferral" + item.ID = decisionID + item.Title = legacyDeferralTitle(sparkText) + item.ReadCommand = "loaf journal show " + decisionID + items = append(items, item) + } + rows.Close() + if err := rows.Err(); err != nil { + return IntakeListResult{}, err + } + + return IntakeListResult{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Items: items, + }, nil +} + +type intakeRowScanner interface { + Next() bool + Scan(dest ...any) error + Close() error + Err() error +} + +func scanIntakeRows(rows intakeRowScanner, items *[]IntakeItem, kind string, finish func(*IntakeItem)) error { + for rows.Next() { + var item IntakeItem + if err := rows.Scan(&item.ID, &item.Alias, &item.Title, &item.Status, &item.CreatedAt); err != nil { + rows.Close() + return fmt.Errorf("scan intake %s: %w", kind, err) + } + item.Kind = kind + item.Status = LifecycleStatusForDisplay(kind, item.Status) + finish(&item) + *items = append(*items, item) + } + if err := rows.Close(); err != nil { + return err + } + return rows.Err() +} + +// legacyDeferralTitle extracts the Intent line from a legacy spark packet. +func legacyDeferralTitle(sparkText string) string { + for _, line := range strings.Split(sparkText, "\n") { + if rest, found := strings.CutPrefix(line, "Intent: "); found { + return rest + } + } + first, _, _ := strings.Cut(sparkText, "\n") + return first +} diff --git a/internal/state/intake_test.go b/internal/state/intake_test.go new file mode 100644 index 000000000..a04855474 --- /dev/null +++ b/internal/state/intake_test.go @@ -0,0 +1,169 @@ +package state + +import ( + "context" + "fmt" + "testing" +) + +func TestCreateLinkEnforcesClosedMatrixForNewKinds(t *testing.T) { + root, _, store := intentTestFixture(t) + ctx := context.Background() + spark, err := store.CaptureSpark(ctx, root, SparkCaptureOptions{Text: "seed"}) + if err != nil { + t.Fatalf("CaptureSpark() error = %v", err) + } + intent, err := store.CreateIntent(ctx, root, IntentCreateOptions{Title: "Linked", Body: "b"}) + if err != nil { + t.Fatalf("CreateIntent() error = %v", err) + } + task, err := store.CreateTask(ctx, root, TaskCreateOptions{Title: "legacy task"}) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + // Supported matrix pairing round-trips through link and trace. + link, err := store.CreateLink(ctx, root, LinkMutationOptions{From: spark.Spark.Alias, To: intent.Intent.Alias, Type: "source-of"}) + if err != nil { + t.Fatalf("CreateLink(spark source-of intent) error = %v", err) + } + if link.From.Kind != "spark" || link.To.Kind != "intent" { + t.Fatalf("link = %#v, want spark -> intent", link) + } + trace, err := store.Trace(ctx, root, intent.Intent.Alias) + if err != nil { + t.Fatalf("Trace(intent) error = %v", err) + } + if trace.Entity.Kind != "intent" || len(trace.Relationships) != 1 || trace.Relationships[0].Entity.Kind != "spark" { + t.Fatalf("trace = %#v, want intent with one inbound spark edge", trace) + } + + // Unsupported pairings touching a new kind fail visibly. + if _, err := store.CreateLink(ctx, root, LinkMutationOptions{From: task.Task.Alias, To: intent.Intent.Alias, Type: "source-of"}); err == nil { + t.Fatal("task source-of intent link accepted, want closed-matrix rejection") + } + if _, err := store.CreateLink(ctx, root, LinkMutationOptions{From: intent.Intent.Alias, To: spark.Spark.Alias, Type: "evidence-for"}); err == nil { + t.Fatal("intent evidence-for spark link accepted, want closed-matrix rejection") + } + // Legacy-to-legacy links keep their historical open behavior. + if _, err := store.CreateLink(ctx, root, LinkMutationOptions{From: spark.Spark.Alias, To: task.Task.Alias, Type: "relates_to"}); err != nil { + t.Fatalf("legacy link error = %v, want preserved open behavior", err) + } +} + +func TestIntakeListShowsEachLogicalItemOnceAndIsDeterministic(t *testing.T) { + root, resolver, store := intentTestFixture(t) + ctx := context.Background() + + if _, err := store.CaptureSpark(ctx, root, SparkCaptureOptions{Text: "plain spark"}); err != nil { + t.Fatalf("CaptureSpark() error = %v", err) + } + if _, err := store.CaptureIdea(ctx, root, IdeaCaptureOptions{Title: "an idea"}); err != nil { + t.Fatalf("CaptureIdea() error = %v", err) + } + if _, err := store.CreateIntent(ctx, root, IntentCreateOptions{Title: "Tracked direction", Body: "b"}); err != nil { + t.Fatalf("CreateIntent() error = %v", err) + } + resolved, err := store.CreateIntent(ctx, root, IntentCreateOptions{Title: "Resolved direction", Body: "b"}) + if err != nil { + t.Fatalf("CreateIntent(resolved) error = %v", err) + } + if _, err := ResolveIntent(ctx, root, resolver, IntentDispositionOptions{IntentRef: resolved.Intent.Alias, Reason: "done"}); err != nil { + t.Fatalf("ResolveIntent() error = %v", err) + } + // An adapter-era deferral carries its canonical intent; it must show once + // as an intent, never additionally as a spark or legacy deferral. + if _, err := store.DeferJournal(ctx, root, JournalDeferOptions{ + Intent: "adapter deferral", Why: "w", Boundary: "b", Trigger: "t", OperationID: "intake-op-1", + }); err != nil { + t.Fatalf("DeferJournal() error = %v", err) + } + // A pre-conversion legacy deferral (no canonical mapping) surfaces as one + // legacy item. + projectID := projectIDForTest(t, store, root) + mustExecSchemaSQL(t, store, ` +INSERT INTO journal_entries (id, project_id, entry_type, scope, message, created_at, updated_at) +VALUES ('journal:legacy-intake', ?, 'decision', 'defer/x', 'Intent: legacy body', '2026-07-01T00:00:00Z', '2026-07-01T00:00:00Z') +`, projectID) + mustExecSchemaSQL(t, store, ` +INSERT INTO journal_search (rowid, journal_entry_id, project_id, session_id, entry_type, scope, message) +SELECT rowid, id, project_id, '', 'decision', 'defer/x', 'Intent: legacy body' FROM journal_entries WHERE id = 'journal:legacy-intake' +`) + mustExecSchemaSQL(t, store, ` +INSERT INTO sparks (id, project_id, scope, status, text, created_at, updated_at) +VALUES ('spark:legacy-intake', ?, 'defer/x', 'open', 'Intent: legacy body' || char(10) || 'Decision: journal:legacy-intake', '2026-07-01T00:00:00Z', '2026-07-01T00:00:00Z') +`, projectID) + mustExecSchemaSQL(t, store, ` +INSERT INTO journal_deferrals (project_id, operation_key, journal_entry_id, spark_id, stored_digest, created_at) +VALUES (?, 'legacy-intake-op', 'journal:legacy-intake', 'spark:legacy-intake', ?, '2026-07-01T00:00:00Z') +`, projectID, testDigest) + + first, err := ListIntake(ctx, root, resolver) + if err != nil { + t.Fatalf("ListIntake() error = %v", err) + } + counts := map[string]int{} + for _, item := range first.Items { + counts[item.Kind]++ + if item.ReadCommand == "" { + t.Fatalf("intake item %#v missing read command", item) + } + } + want := map[string]int{"spark": 1, "idea": 1, "intent": 2, "legacy_deferral": 1} + for kind, expected := range want { + if counts[kind] != expected { + t.Fatalf("intake counts = %v, want %v", counts, want) + } + } + for _, item := range first.Items { + if item.Kind == "intent" && item.Title == "Resolved direction" { + t.Fatal("resolved intent appears in intake") + } + if item.Kind == "legacy_deferral" && item.Title != "legacy body" { + t.Fatalf("legacy deferral title = %q, want packet Intent line", item.Title) + } + } + + second, err := ListIntake(ctx, root, resolver) + if err != nil { + t.Fatalf("ListIntake() again error = %v", err) + } + if fmt.Sprintf("%#v", first.Items) != fmt.Sprintf("%#v", second.Items) { + t.Fatal("intake projection is not deterministic for equal state") + } +} + +func TestExportCoversIntentAndExplorationTables(t *testing.T) { + root, resolver, store := intentTestFixture(t) + ctx := context.Background() + if _, err := store.CreateIntent(ctx, root, IntentCreateOptions{ + Title: "Exported", Body: "b", Disposition: "deferred", + Why: "w", Boundary: "b", Trigger: "t", OperationID: "export-op", + }); err != nil { + t.Fatalf("CreateIntent() error = %v", err) + } + exploration, err := store.CreateExploration(ctx, root, ExplorationCreateOptions{Title: "Exported exploration"}) + if err != nil { + t.Fatalf("CreateExploration() error = %v", err) + } + if _, err := store.AppendExplorationCheckpoint(ctx, root, ExplorationCheckpointOptions{ + ExplorationRef: exploration.Exploration.Alias, + Purpose: "p", Conclusions: "c", Unresolved: "u", NextAction: "n", + }); err != nil { + t.Fatalf("AppendExplorationCheckpoint() error = %v", err) + } + + snapshot, err := ExportAllJSON(ctx, root, resolver) + if err != nil { + t.Fatalf("ExportAllJSON() error = %v", err) + } + for _, table := range []string{"intents", "intent_snapshots", "intent_deferrals", "intent_dispositions", "intent_operations", "explorations", "exploration_checkpoints"} { + rows, ok := snapshot.Tables[table] + if !ok { + t.Fatalf("export snapshot missing table %s", table) + } + if len(rows) == 0 { + t.Fatalf("export snapshot table %s is empty, want seeded rows", table) + } + } +} diff --git a/internal/state/intent.go b/internal/state/intent.go new file mode 100644 index 000000000..26d364617 --- /dev/null +++ b/internal/state/intent.go @@ -0,0 +1,1092 @@ +package state + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" + "unicode" + + "github.com/levifig/loaf/internal/project" +) + +const ( + intentTitleMaxBytes = 200 + intentFieldMaxBytes = 4096 + intentOperationMaxBytes = 200 +) + +// IntentValidationError identifies malformed intent input. +type IntentValidationError struct { + Field string + Err error +} + +func (e *IntentValidationError) Error() string { + if e == nil { + return "intent validation failed" + } + return fmt.Sprintf("intent validation failed for %s: %v", e.Field, e.Err) +} + +func (e *IntentValidationError) Unwrap() error { return e.Err } + +// IntentTransactionError identifies the transactional stage that failed. +type IntentTransactionError struct { + Stage string + Err error +} + +func (e *IntentTransactionError) Error() string { + if e == nil { + return "intent transaction failed" + } + return fmt.Sprintf("intent transaction failed at %s: %v", e.Stage, e.Err) +} + +func (e *IntentTransactionError) Unwrap() error { return e.Err } + +// IntentCreateOptions describes a new tracked or deferred Intent. +type IntentCreateOptions struct { + Title string + Body string + Disposition string + Reason string + Why string + Boundary string + Trigger string + OperationID string + Sources []string +} + +// IntentDeferOptions defers an existing Intent with an immutable payload. +type IntentDeferOptions struct { + IntentRef string + Why string + Boundary string + Trigger string + OperationID string +} + +// IntentDispositionOptions appends a reasoned disposition (resume/resolve). +type IntentDispositionOptions struct { + IntentRef string + Reason string +} + +// IntentDeferralDetail is the immutable self-sufficient deferral payload. +type IntentDeferralDetail struct { + ID string `json:"id"` + OperationKey string `json:"operation_key"` + Body string `json:"body"` + Why string `json:"why"` + Boundary string `json:"boundary"` + RevisitTrigger string `json:"revisit_trigger"` + StoredDigest string `json:"stored_digest"` + CreatedAt string `json:"created_at"` +} + +// IntentDetail is the derived read model for one Intent. +type IntentDetail struct { + ID string `json:"id"` + Alias string `json:"alias,omitempty"` + Title string `json:"title"` + Body string `json:"body"` + SnapshotSeq int `json:"snapshot_seq"` + ContentDigest string `json:"content_digest"` + Disposition string `json:"disposition"` + DispositionSeq int `json:"disposition_seq"` + DispositionReason string `json:"disposition_reason,omitempty"` + Deferral *IntentDeferralDetail `json:"deferral,omitempty"` + Sources []TraceRelationship `json:"sources"` + CreatedAt string `json:"created_at"` +} + +// IntentMutationResult is the shared envelope for intent writes. +type IntentMutationResult struct { + ContractVersion int `json:"contract_version"` + 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"` + OperationID string `json:"operation_id,omitempty"` + Created bool `json:"created"` + InputDigest string `json:"input_digest,omitempty"` + StoredDigest string `json:"stored_digest,omitempty"` + InputDigestMatches bool `json:"input_digest_matches"` + Intent IntentDetail `json:"intent"` +} + +// IntentListItem is one row of the deterministic intent list projection. +type IntentListItem struct { + ID string `json:"id"` + Alias string `json:"alias,omitempty"` + Title string `json:"title"` + Disposition string `json:"disposition"` + CreatedAt string `json:"created_at"` +} + +// IntentListResult is the intent list read model. +type IntentListResult struct { + ContractVersion int `json:"contract_version"` + 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"` + Disposition string `json:"disposition,omitempty"` + Intents []IntentListItem `json:"intents"` +} + +// IntentShowResult is the intent show read model. +type IntentShowResult struct { + ContractVersion int `json:"contract_version"` + 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"` + Query string `json:"query"` + Intent IntentDetail `json:"intent"` +} + +// intentWriteHooks injects failures between transactional stages in tests. +type intentWriteHooks struct { + afterIntent func(*sql.Tx) error + afterSnapshot func(*sql.Tx) error + afterDisposition func(*sql.Tx) error + afterDeferral func(*sql.Tx) error + afterOperation func(*sql.Tx) error + afterRelationship func(*sql.Tx) error + beforeCommit func(*sql.Tx) error +} + +func runIntentWriteHook(hooks *intentWriteHooks, stage string, pick func(*intentWriteHooks) func(*sql.Tx) error, tx *sql.Tx) error { + if hooks == nil { + return nil + } + hook := pick(hooks) + if hook == nil { + return nil + } + if err := hook(tx); err != nil { + return &IntentTransactionError{Stage: stage, Err: err} + } + return nil +} + +// intentDeferralPacket composes the canonical deferral packet. The format is +// deliberately identical to the legacy journal defer packet so digests remain +// comparable across every entry point that shares intent_operations. +func intentDeferralPacket(body, why, boundary, trigger string) string { + return fmt.Sprintf("Intent: %s\nWhy: %s\nBoundary: %s\nTrigger: %s", body, why, boundary, trigger) +} + +func intentDigest(content string) string { + sum := sha256.Sum256([]byte(content)) + return hex.EncodeToString(sum[:]) +} + +func validateIntentField(field, value string, maxBytes int, allowMultiline bool) (string, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "", &IntentValidationError{Field: field, Err: fmt.Errorf("must be nonempty")} + } + if len(trimmed) > maxBytes { + return "", &IntentValidationError{Field: field, Err: fmt.Errorf("exceeds %d bytes", maxBytes)} + } + for _, r := range trimmed { + if !unicode.IsControl(r) { + continue + } + if allowMultiline && (r == '\n' || r == '\t') { + continue + } + return "", &IntentValidationError{Field: field, Err: fmt.Errorf("contains control characters")} + } + return trimmed, nil +} + +func validateIntentOperationID(value string, required bool) (string, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + if required { + return "", &IntentValidationError{Field: "operation_id", Err: fmt.Errorf("must be nonempty")} + } + return "", nil + } + return validateIntentField("operation_id", trimmed, intentOperationMaxBytes, false) +} + +// CreateIntent writes one Intent snapshot plus its initial disposition. +func CreateIntent(ctx context.Context, root project.Root, resolver PathResolver, options IntentCreateOptions) (IntentMutationResult, error) { + store, err := openProjectStoreMutateExisting(ctx, root, resolver) + if err != nil { + return IntentMutationResult{}, err + } + defer store.Close() + return store.CreateIntent(ctx, root, options) +} + +// CreateIntent writes one Intent snapshot plus its initial disposition in one +// serializable transaction on an open store. +func (s *Store) CreateIntent(ctx context.Context, root project.Root, options IntentCreateOptions) (IntentMutationResult, error) { + return s.createIntentWithHooks(ctx, root, options, nil) +} + +func (s *Store) createIntentWithHooks(ctx context.Context, root project.Root, options IntentCreateOptions, hooks *intentWriteHooks) (IntentMutationResult, error) { + title, err := validateIntentField("title", options.Title, intentTitleMaxBytes, false) + if err != nil { + return IntentMutationResult{}, err + } + body, err := validateIntentField("body", options.Body, intentFieldMaxBytes, true) + if err != nil { + return IntentMutationResult{}, err + } + disposition := strings.TrimSpace(options.Disposition) + if disposition == "" { + disposition = "tracked" + } + if disposition != "tracked" && disposition != "deferred" { + return IntentMutationResult{}, &IntentValidationError{Field: "disposition", Err: fmt.Errorf("must be tracked or deferred; resolution happens through intent resolve")} + } + var why, boundary, trigger string + if disposition == "deferred" { + if why, err = validateIntentField("why", options.Why, intentFieldMaxBytes, false); err != nil { + return IntentMutationResult{}, err + } + if boundary, err = validateIntentField("boundary", options.Boundary, intentFieldMaxBytes, false); err != nil { + return IntentMutationResult{}, err + } + if trigger, err = validateIntentField("trigger", options.Trigger, intentFieldMaxBytes, false); err != nil { + return IntentMutationResult{}, err + } + } + operationID, err := validateIntentOperationID(options.OperationID, disposition == "deferred") + if err != nil { + return IntentMutationResult{}, err + } + reason := "" + if strings.TrimSpace(options.Reason) != "" { + if reason, err = validateIntentField("reason", options.Reason, intentFieldMaxBytes, false); err != nil { + return IntentMutationResult{}, err + } + } + + var inputDigest string + if disposition == "deferred" { + inputDigest = intentDigest(intentDeferralPacket(body, why, boundary, trigger)) + } else { + inputDigest = intentDigest("intent-create\x00" + title + "\x00" + body) + } + + projectID, err := s.projectID(ctx, root) + if err != nil { + return IntentMutationResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return IntentMutationResult{}, err + } + + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return IntentMutationResult{}, &IntentTransactionError{Stage: "begin", Err: err} + } + defer tx.Rollback() + + if operationID != "" { + established, found, loadErr := loadEstablishedIntentOperationTx(ctx, tx, identity, operationID, inputDigest) + if loadErr != nil { + return IntentMutationResult{}, loadErr + } + if found { + if err := tx.Commit(); err != nil { + return IntentMutationResult{}, &IntentTransactionError{Stage: "commit retry", Err: err} + } + return established, nil + } + if err := rejectLegacyBoundOperationKeyTx(ctx, tx, projectID, operationID); err != nil { + return IntentMutationResult{}, err + } + } + + now := time.Now().UTC() + timestamp := now.Format(time.RFC3339Nano) + alias, err := s.nextIntentAlias(ctx, tx, projectID, title, now) + if err != nil { + return IntentMutationResult{}, &IntentTransactionError{Stage: "alias allocation", Err: err} + } + intentID := stableMigrationID("intent", projectID, alias) + + if _, err := tx.ExecContext(ctx, ` +INSERT INTO intents (id, project_id, created_at) VALUES (?, ?, ?) +`, intentID, projectID, timestamp); err != nil { + return IntentMutationResult{}, &IntentTransactionError{Stage: "intent", Err: err} + } + if err := runIntentWriteHook(hooks, "after intent", func(h *intentWriteHooks) func(*sql.Tx) error { return h.afterIntent }, tx); err != nil { + return IntentMutationResult{}, err + } + + snapshotDigest := intentDigest(title + "\x00" + body) + snapshotID := stableMigrationID("intent-snapshot", projectID, intentID, "1") + if _, err := tx.ExecContext(ctx, ` +INSERT INTO intent_snapshots (id, project_id, intent_id, seq, title, body, content_digest, created_at) +VALUES (?, ?, ?, 1, ?, ?, ?, ?) +`, snapshotID, projectID, intentID, title, body, snapshotDigest, timestamp); err != nil { + return IntentMutationResult{}, &IntentTransactionError{Stage: "snapshot", Err: err} + } + if err := runIntentWriteHook(hooks, "after snapshot", func(h *intentWriteHooks) func(*sql.Tx) error { return h.afterSnapshot }, tx); err != nil { + return IntentMutationResult{}, err + } + + var deferral *IntentDeferralDetail + if disposition == "deferred" { + deferral = &IntentDeferralDetail{ + ID: stableMigrationID("intent-deferral", projectID, operationID), + OperationKey: operationID, + Body: body, + Why: why, + Boundary: boundary, + RevisitTrigger: trigger, + StoredDigest: inputDigest, + CreatedAt: timestamp, + } + if _, err := tx.ExecContext(ctx, ` +INSERT INTO intent_deferrals (id, project_id, intent_id, operation_key, body, why, boundary, revisit_trigger, stored_digest, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +`, deferral.ID, projectID, intentID, operationID, body, why, boundary, trigger, inputDigest, timestamp); err != nil { + return IntentMutationResult{}, &IntentTransactionError{Stage: "deferral", Err: err} + } + if err := runIntentWriteHook(hooks, "after deferral", func(h *intentWriteHooks) func(*sql.Tx) error { return h.afterDeferral }, tx); err != nil { + return IntentMutationResult{}, err + } + } + + dispositionID := stableMigrationID("intent-disposition", projectID, intentID, "1") + deferralID := sql.NullString{} + if deferral != nil { + deferralID = sql.NullString{String: deferral.ID, Valid: true} + } + if _, err := tx.ExecContext(ctx, ` +INSERT INTO intent_dispositions (id, project_id, intent_id, seq, disposition, reason, deferral_id, created_at) +VALUES (?, ?, ?, 1, ?, ?, ?, ?) +`, dispositionID, projectID, intentID, disposition, emptyToNil(reason), deferralID, timestamp); err != nil { + return IntentMutationResult{}, &IntentTransactionError{Stage: "disposition", Err: err} + } + if err := runIntentWriteHook(hooks, "after disposition", func(h *intentWriteHooks) func(*sql.Tx) error { return h.afterDisposition }, tx); err != nil { + return IntentMutationResult{}, err + } + + if err := insertAlias(ctx, tx, projectID, "intent", intentID, "intent", alias, timestamp); err != nil { + return IntentMutationResult{}, &IntentTransactionError{Stage: "alias", Err: err} + } + + sources, err := writeIntentSourceRelationshipsTx(ctx, tx, projectID, intentID, options.Sources, timestamp) + if err != nil { + return IntentMutationResult{}, err + } + if err := runIntentWriteHook(hooks, "after relationships", func(h *intentWriteHooks) func(*sql.Tx) error { return h.afterRelationship }, tx); err != nil { + return IntentMutationResult{}, err + } + + if operationID != "" { + if _, err := tx.ExecContext(ctx, ` +INSERT INTO intent_operations (project_id, operation_key, intent_id, stored_digest, journal_entry_id, spark_id, projection_version, created_at, updated_at) +VALUES (?, ?, ?, ?, NULL, NULL, 0, ?, ?) +`, projectID, operationID, intentID, inputDigest, timestamp, timestamp); err != nil { + return IntentMutationResult{}, &IntentTransactionError{Stage: "operation mapping", Err: err} + } + if err := runIntentWriteHook(hooks, "after operation", func(h *intentWriteHooks) func(*sql.Tx) error { return h.afterOperation }, tx); err != nil { + return IntentMutationResult{}, err + } + } + + if err := runIntentWriteHook(hooks, "before commit", func(h *intentWriteHooks) func(*sql.Tx) error { return h.beforeCommit }, tx); err != nil { + return IntentMutationResult{}, err + } + if err := tx.Commit(); err != nil { + return IntentMutationResult{}, &IntentTransactionError{Stage: "commit", Err: err} + } + + return IntentMutationResult{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + OperationID: operationID, + Created: true, + InputDigest: inputDigest, + StoredDigest: inputDigest, + InputDigestMatches: true, + Intent: IntentDetail{ + ID: intentID, + Alias: alias, + Title: title, + Body: body, + SnapshotSeq: 1, + ContentDigest: snapshotDigest, + Disposition: disposition, + DispositionSeq: 1, + DispositionReason: reason, + Deferral: deferral, + Sources: sources, + CreatedAt: timestamp, + }, + }, nil +} + +// DeferIntent appends an immutable deferral to an existing Intent. +func DeferIntent(ctx context.Context, root project.Root, resolver PathResolver, options IntentDeferOptions) (IntentMutationResult, error) { + store, err := openProjectStoreMutateExisting(ctx, root, resolver) + if err != nil { + return IntentMutationResult{}, err + } + defer store.Close() + return store.DeferIntent(ctx, root, options) +} + +// DeferIntent appends an immutable deferral to an existing Intent in one +// serializable transaction on an open store. +func (s *Store) DeferIntent(ctx context.Context, root project.Root, options IntentDeferOptions) (IntentMutationResult, error) { + return s.deferIntentWithHooks(ctx, root, options, nil) +} + +func (s *Store) deferIntentWithHooks(ctx context.Context, root project.Root, options IntentDeferOptions, hooks *intentWriteHooks) (IntentMutationResult, error) { + why, err := validateIntentField("why", options.Why, intentFieldMaxBytes, false) + if err != nil { + return IntentMutationResult{}, err + } + boundary, err := validateIntentField("boundary", options.Boundary, intentFieldMaxBytes, false) + if err != nil { + return IntentMutationResult{}, err + } + trigger, err := validateIntentField("trigger", options.Trigger, intentFieldMaxBytes, false) + if err != nil { + return IntentMutationResult{}, err + } + operationID, err := validateIntentOperationID(options.OperationID, true) + if err != nil { + return IntentMutationResult{}, err + } + + projectID, err := s.projectID(ctx, root) + if err != nil { + return IntentMutationResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return IntentMutationResult{}, err + } + + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return IntentMutationResult{}, &IntentTransactionError{Stage: "begin", Err: err} + } + defer tx.Rollback() + + intentID, _, err := resolveIntentRefTx(ctx, tx, projectID, options.IntentRef) + if err != nil { + return IntentMutationResult{}, err + } + snapshot, err := latestIntentSnapshotTx(ctx, tx, projectID, intentID) + if err != nil { + return IntentMutationResult{}, &IntentTransactionError{Stage: "read snapshot", Err: err} + } + inputDigest := intentDigest(intentDeferralPacket(snapshot.Body, why, boundary, trigger)) + + established, found, loadErr := loadEstablishedIntentOperationTx(ctx, tx, identity, operationID, inputDigest) + if loadErr != nil { + return IntentMutationResult{}, loadErr + } + if found { + if established.Intent.ID != intentID { + return IntentMutationResult{}, &IntentValidationError{Field: "operation_id", Err: fmt.Errorf("operation key %q is already bound to intent %s and cannot defer %s; use a distinct operation key", operationID, established.Intent.ID, intentID)} + } + if err := tx.Commit(); err != nil { + return IntentMutationResult{}, &IntentTransactionError{Stage: "commit retry", Err: err} + } + return established, nil + } + if err := rejectLegacyBoundOperationKeyTx(ctx, tx, projectID, operationID); err != nil { + return IntentMutationResult{}, err + } + + now := time.Now().UTC().Format(time.RFC3339Nano) + deferral := IntentDeferralDetail{ + ID: stableMigrationID("intent-deferral", projectID, operationID), + OperationKey: operationID, + Body: snapshot.Body, + Why: why, + Boundary: boundary, + RevisitTrigger: trigger, + StoredDigest: inputDigest, + CreatedAt: now, + } + if _, err := tx.ExecContext(ctx, ` +INSERT INTO intent_deferrals (id, project_id, intent_id, operation_key, body, why, boundary, revisit_trigger, stored_digest, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +`, deferral.ID, projectID, intentID, operationID, deferral.Body, why, boundary, trigger, inputDigest, now); err != nil { + return IntentMutationResult{}, &IntentTransactionError{Stage: "deferral", Err: err} + } + if err := runIntentWriteHook(hooks, "after deferral", func(h *intentWriteHooks) func(*sql.Tx) error { return h.afterDeferral }, tx); err != nil { + return IntentMutationResult{}, err + } + + seq, err := nextAggregateSeq(ctx, tx, "intent_dispositions", "intent_id", intentID) + if err != nil { + return IntentMutationResult{}, &IntentTransactionError{Stage: "sequence", Err: err} + } + dispositionID := stableMigrationID("intent-disposition", projectID, intentID, fmt.Sprintf("%d", seq)) + if _, err := tx.ExecContext(ctx, ` +INSERT INTO intent_dispositions (id, project_id, intent_id, seq, disposition, reason, deferral_id, created_at) +VALUES (?, ?, ?, ?, 'deferred', NULL, ?, ?) +`, dispositionID, projectID, intentID, seq, deferral.ID, now); err != nil { + return IntentMutationResult{}, &IntentTransactionError{Stage: "disposition", Err: err} + } + if err := runIntentWriteHook(hooks, "after disposition", func(h *intentWriteHooks) func(*sql.Tx) error { return h.afterDisposition }, tx); err != nil { + return IntentMutationResult{}, err + } + + if _, err := tx.ExecContext(ctx, ` +INSERT INTO intent_operations (project_id, operation_key, intent_id, stored_digest, journal_entry_id, spark_id, projection_version, created_at, updated_at) +VALUES (?, ?, ?, ?, NULL, NULL, 0, ?, ?) +`, projectID, operationID, intentID, inputDigest, now, now); err != nil { + return IntentMutationResult{}, &IntentTransactionError{Stage: "operation mapping", Err: err} + } + if err := runIntentWriteHook(hooks, "after operation", func(h *intentWriteHooks) func(*sql.Tx) error { return h.afterOperation }, tx); err != nil { + return IntentMutationResult{}, err + } + if err := runIntentWriteHook(hooks, "before commit", func(h *intentWriteHooks) func(*sql.Tx) error { return h.beforeCommit }, tx); err != nil { + return IntentMutationResult{}, err + } + + detail, err := loadIntentDetailTx(ctx, tx, projectID, intentID) + if err != nil { + return IntentMutationResult{}, &IntentTransactionError{Stage: "read result", Err: err} + } + if err := tx.Commit(); err != nil { + return IntentMutationResult{}, &IntentTransactionError{Stage: "commit", Err: err} + } + return IntentMutationResult{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + OperationID: operationID, + Created: true, + InputDigest: inputDigest, + StoredDigest: inputDigest, + InputDigestMatches: true, + Intent: detail, + }, nil +} + +// ResumeIntent appends a tracked disposition superseding the current deferral. +func ResumeIntent(ctx context.Context, root project.Root, resolver PathResolver, options IntentDispositionOptions) (IntentMutationResult, error) { + store, err := openProjectStoreMutateExisting(ctx, root, resolver) + if err != nil { + return IntentMutationResult{}, err + } + defer store.Close() + return store.appendIntentDisposition(ctx, root, options, "tracked") +} + +// ResolveIntent appends a reasoned terminal disposition. +func ResolveIntent(ctx context.Context, root project.Root, resolver PathResolver, options IntentDispositionOptions) (IntentMutationResult, error) { + store, err := openProjectStoreMutateExisting(ctx, root, resolver) + if err != nil { + return IntentMutationResult{}, err + } + defer store.Close() + return store.appendIntentDisposition(ctx, root, options, "resolved") +} + +func (s *Store) appendIntentDisposition(ctx context.Context, root project.Root, options IntentDispositionOptions, disposition string) (IntentMutationResult, error) { + reason, err := validateIntentField("reason", options.Reason, intentFieldMaxBytes, false) + if err != nil { + return IntentMutationResult{}, err + } + projectID, err := s.projectID(ctx, root) + if err != nil { + return IntentMutationResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return IntentMutationResult{}, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return IntentMutationResult{}, &IntentTransactionError{Stage: "begin", Err: err} + } + defer tx.Rollback() + + intentID, _, err := resolveIntentRefTx(ctx, tx, projectID, options.IntentRef) + if err != nil { + return IntentMutationResult{}, err + } + current, err := latestIntentDispositionTx(ctx, tx, projectID, intentID) + if err != nil { + return IntentMutationResult{}, &IntentTransactionError{Stage: "read disposition", Err: err} + } + + supersedes := sql.NullString{} + if disposition == "tracked" { + if current.Disposition != "deferred" || !current.DeferralID.Valid { + return IntentMutationResult{}, fmt.Errorf("intent %s is %s, not deferred; resume supersedes an existing deferral", options.IntentRef, current.Disposition) + } + supersedes = current.DeferralID + } + + now := time.Now().UTC().Format(time.RFC3339Nano) + seq, err := nextAggregateSeq(ctx, tx, "intent_dispositions", "intent_id", intentID) + if err != nil { + return IntentMutationResult{}, &IntentTransactionError{Stage: "sequence", Err: err} + } + dispositionID := stableMigrationID("intent-disposition", projectID, intentID, fmt.Sprintf("%d", seq)) + if _, err := tx.ExecContext(ctx, ` +INSERT INTO intent_dispositions (id, project_id, intent_id, seq, disposition, reason, deferral_id, supersedes_deferral_id, created_at) +VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?) +`, dispositionID, projectID, intentID, seq, disposition, reason, supersedes, now); err != nil { + return IntentMutationResult{}, &IntentTransactionError{Stage: "disposition", Err: err} + } + + detail, err := loadIntentDetailTx(ctx, tx, projectID, intentID) + if err != nil { + return IntentMutationResult{}, &IntentTransactionError{Stage: "read result", Err: err} + } + if err := tx.Commit(); err != nil { + return IntentMutationResult{}, &IntentTransactionError{Stage: "commit", Err: err} + } + return IntentMutationResult{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Created: true, + InputDigestMatches: true, + Intent: detail, + }, nil +} + +// ShowIntent returns the derived read model for one Intent. +func ShowIntent(ctx context.Context, root project.Root, resolver PathResolver, ref string) (IntentShowResult, error) { + store, err := openProjectStoreReadExisting(ctx, root, resolver) + if err != nil { + return IntentShowResult{}, err + } + defer store.Close() + projectID, err := store.projectID(ctx, root) + if err != nil { + return IntentShowResult{}, err + } + identity, err := store.projectIdentity(ctx, projectID) + if err != nil { + return IntentShowResult{}, err + } + tx, err := store.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return IntentShowResult{}, fmt.Errorf("begin intent show: %w", err) + } + defer tx.Rollback() + intentID, _, err := resolveIntentRefTx(ctx, tx, projectID, ref) + if err != nil { + return IntentShowResult{}, err + } + detail, err := loadIntentDetailTx(ctx, tx, projectID, intentID) + if err != nil { + return IntentShowResult{}, err + } + return IntentShowResult{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Query: ref, + Intent: detail, + }, nil +} + +// ListIntents returns the deterministic intent list projection. +func ListIntents(ctx context.Context, root project.Root, resolver PathResolver, dispositionFilter string) (IntentListResult, error) { + store, err := openProjectStoreReadExisting(ctx, root, resolver) + if err != nil { + return IntentListResult{}, err + } + defer store.Close() + projectID, err := store.projectID(ctx, root) + if err != nil { + return IntentListResult{}, err + } + identity, err := store.projectIdentity(ctx, projectID) + if err != nil { + return IntentListResult{}, err + } + filter := strings.TrimSpace(dispositionFilter) + if filter != "" && filter != "tracked" && filter != "deferred" && filter != "resolved" { + return IntentListResult{}, &IntentValidationError{Field: "disposition", Err: fmt.Errorf("must be tracked, deferred, or resolved")} + } + rows, err := store.db.QueryContext(ctx, ` +SELECT i.id, + COALESCE((SELECT alias FROM aliases WHERE project_id = i.project_id AND entity_kind = 'intent' AND entity_id = i.id ORDER BY namespace, alias LIMIT 1), ''), + COALESCE((SELECT title FROM intent_snapshots WHERE intent_id = i.id ORDER BY seq DESC LIMIT 1), ''), + COALESCE((SELECT disposition FROM intent_dispositions WHERE intent_id = i.id ORDER BY seq DESC LIMIT 1), ''), + i.created_at +FROM intents AS i +WHERE i.project_id = ? +ORDER BY i.created_at, i.id +`, projectID) + if err != nil { + return IntentListResult{}, fmt.Errorf("list intents: %w", err) + } + defer rows.Close() + items := []IntentListItem{} + for rows.Next() { + var item IntentListItem + if err := rows.Scan(&item.ID, &item.Alias, &item.Title, &item.Disposition, &item.CreatedAt); err != nil { + return IntentListResult{}, fmt.Errorf("scan intent row: %w", err) + } + if filter != "" && item.Disposition != filter { + continue + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return IntentListResult{}, fmt.Errorf("iterate intents: %w", err) + } + return IntentListResult{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Disposition: filter, + Intents: items, + }, nil +} + +func (s *Store) nextIntentAlias(ctx context.Context, tx *sql.Tx, projectID string, title string, now time.Time) (string, error) { + slug := normalizeSparkSlug(title) + if slug == "" { + slug = "intent" + } + prefix := "INTENT-" + now.UTC().Format("20060102") + "-" + slug + for next := 1; ; next++ { + alias := prefix + if next > 1 { + alias = fmt.Sprintf("%s-%d", prefix, next) + } + var existing string + err := tx.QueryRowContext(ctx, `SELECT id FROM aliases WHERE project_id = ? AND namespace = 'intent' AND alias = ?`, projectID, alias).Scan(&existing) + if errors.Is(err, sql.ErrNoRows) { + return alias, nil + } + if err != nil { + return "", fmt.Errorf("probe intent alias %s: %w", alias, err) + } + } +} + +// resolveIntentRefTx resolves an alias or internal ID to an intent row. +func resolveIntentRefTx(ctx context.Context, tx *sql.Tx, projectID string, ref string) (string, string, error) { + trimmed := strings.TrimSpace(ref) + if trimmed == "" { + return "", "", &IntentValidationError{Field: "intent", 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 alias = ? +ORDER BY namespace LIMIT 1 +`, projectID, trimmed).Scan(&kind, &id, &alias) + switch { + case err == nil: + if kind != "intent" { + return "", "", fmt.Errorf("%q resolves to %s, not an intent", trimmed, kind) + } + return id, alias, nil + case !errors.Is(err, sql.ErrNoRows): + return "", "", fmt.Errorf("resolve intent %q: %w", trimmed, err) + } + var existing string + err = tx.QueryRowContext(ctx, `SELECT id FROM intents WHERE project_id = ? AND id = ?`, projectID, trimmed).Scan(&existing) + if errors.Is(err, sql.ErrNoRows) { + return "", "", fmt.Errorf("intent %q not found in SQLite state", trimmed) + } + if err != nil { + return "", "", fmt.Errorf("resolve intent %q: %w", trimmed, err) + } + return existing, "", nil +} + +type intentSnapshotRow struct { + Seq int + Title string + Body string + ContentDigest string +} + +func latestIntentSnapshotTx(ctx context.Context, tx *sql.Tx, projectID, intentID string) (intentSnapshotRow, error) { + var snapshot intentSnapshotRow + err := tx.QueryRowContext(ctx, ` +SELECT seq, title, body, content_digest FROM intent_snapshots +WHERE project_id = ? AND intent_id = ? +ORDER BY seq DESC LIMIT 1 +`, projectID, intentID).Scan(&snapshot.Seq, &snapshot.Title, &snapshot.Body, &snapshot.ContentDigest) + if errors.Is(err, sql.ErrNoRows) { + return intentSnapshotRow{}, fmt.Errorf("intent %s has no snapshot", intentID) + } + if err != nil { + return intentSnapshotRow{}, err + } + return snapshot, nil +} + +type intentDispositionRow struct { + Seq int + Disposition string + Reason sql.NullString + DeferralID sql.NullString +} + +func latestIntentDispositionTx(ctx context.Context, tx *sql.Tx, projectID, intentID string) (intentDispositionRow, error) { + var row intentDispositionRow + err := tx.QueryRowContext(ctx, ` +SELECT seq, disposition, reason, deferral_id FROM intent_dispositions +WHERE project_id = ? AND intent_id = ? +ORDER BY seq DESC LIMIT 1 +`, projectID, intentID).Scan(&row.Seq, &row.Disposition, &row.Reason, &row.DeferralID) + if errors.Is(err, sql.ErrNoRows) { + return intentDispositionRow{}, fmt.Errorf("intent %s has no disposition", intentID) + } + if err != nil { + return intentDispositionRow{}, err + } + return row, nil +} + +func loadIntentDeferralTx(ctx context.Context, tx *sql.Tx, projectID, deferralID string) (*IntentDeferralDetail, error) { + deferral := &IntentDeferralDetail{} + err := tx.QueryRowContext(ctx, ` +SELECT id, operation_key, body, why, boundary, revisit_trigger, stored_digest, created_at +FROM intent_deferrals WHERE project_id = ? AND id = ? +`, projectID, deferralID).Scan(&deferral.ID, &deferral.OperationKey, &deferral.Body, &deferral.Why, &deferral.Boundary, &deferral.RevisitTrigger, &deferral.StoredDigest, &deferral.CreatedAt) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("intent deferral %s not found", deferralID) + } + if err != nil { + return nil, err + } + return deferral, nil +} + +// loadIntentDetailTx builds the derived read model inside a transaction. +func loadIntentDetailTx(ctx context.Context, tx *sql.Tx, projectID, intentID string) (IntentDetail, error) { + var createdAt string + err := tx.QueryRowContext(ctx, `SELECT created_at FROM intents WHERE project_id = ? AND id = ?`, projectID, intentID).Scan(&createdAt) + if errors.Is(err, sql.ErrNoRows) { + return IntentDetail{}, fmt.Errorf("intent %s not found", intentID) + } + if err != nil { + return IntentDetail{}, err + } + snapshot, err := latestIntentSnapshotTx(ctx, tx, projectID, intentID) + if err != nil { + return IntentDetail{}, err + } + disposition, err := latestIntentDispositionTx(ctx, tx, projectID, intentID) + if err != nil { + return IntentDetail{}, err + } + var deferral *IntentDeferralDetail + if disposition.Disposition == "deferred" && disposition.DeferralID.Valid { + deferral, err = loadIntentDeferralTx(ctx, tx, projectID, disposition.DeferralID.String) + if err != nil { + return IntentDetail{}, err + } + } + var alias string + if err := tx.QueryRowContext(ctx, ` +SELECT alias FROM aliases WHERE project_id = ? AND entity_kind = 'intent' AND entity_id = ? +ORDER BY namespace, alias LIMIT 1 +`, projectID, intentID).Scan(&alias); err != nil && !errors.Is(err, sql.ErrNoRows) { + return IntentDetail{}, err + } + sources, err := loadIntentSourcesTx(ctx, tx, projectID, intentID) + if err != nil { + return IntentDetail{}, err + } + return IntentDetail{ + ID: intentID, + Alias: alias, + Title: snapshot.Title, + Body: snapshot.Body, + SnapshotSeq: snapshot.Seq, + ContentDigest: snapshot.ContentDigest, + Disposition: disposition.Disposition, + DispositionSeq: disposition.Seq, + DispositionReason: disposition.Reason.String, + Deferral: deferral, + Sources: sources, + CreatedAt: createdAt, + }, nil +} + +func loadIntentSourcesTx(ctx context.Context, tx *sql.Tx, projectID, intentID string) ([]TraceRelationship, error) { + rows, err := tx.QueryContext(ctx, ` +SELECT relationship_type, from_entity_kind, from_entity_id, COALESCE(reason, '') +FROM relationships +WHERE project_id = ? AND to_entity_kind = 'intent' AND to_entity_id = ? AND relationship_type = 'source-of' +ORDER BY from_entity_kind, from_entity_id +`, projectID, intentID) + if err != nil { + return nil, fmt.Errorf("read intent sources: %w", err) + } + defer rows.Close() + sources := []TraceRelationship{} + for rows.Next() { + var relationshipType, kind, id, reason string + if err := rows.Scan(&relationshipType, &kind, &id, &reason); err != nil { + return nil, fmt.Errorf("scan intent source: %w", err) + } + sources = append(sources, TraceRelationship{ + Direction: "inbound", + Type: relationshipType, + Entity: TraceEntity{Kind: kind, ID: id}, + Reason: reason, + }) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate intent sources: %w", err) + } + return sources, nil +} + +// writeIntentSourceRelationshipsTx validates each source against the closed +// relationship matrix and records the source-of edges. +func writeIntentSourceRelationshipsTx(ctx context.Context, tx *sql.Tx, projectID, intentID string, refs []string, now string) ([]TraceRelationship, error) { + sources := []TraceRelationship{} + seen := map[string]bool{} + for _, ref := range refs { + trimmed := strings.TrimSpace(ref) + if trimmed == "" { + continue + } + entity, err := resolveSourceEntityTx(ctx, tx, projectID, trimmed) + if err != nil { + return nil, &IntentTransactionError{Stage: "resolve source", Err: err} + } + if err := validateRelationshipAgainstRegistry(entity.Kind, "source-of", "intent"); err != nil { + return nil, &IntentValidationError{Field: "from", Err: err} + } + key := entity.Kind + "\x00" + entity.ID + if seen[key] { + return nil, &IntentValidationError{Field: "from", Err: fmt.Errorf("source %q is duplicated", trimmed)} + } + seen[key] = true + relationshipID := stableMigrationID("relationship", projectID, entity.Kind, entity.ID, "source-of", "intent", intentID) + 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 (?, ?, ?, ?, 'intent', ?, 'source-of', 'recorded by intent create', 'intent-create', ?, ?) +`, relationshipID, projectID, entity.Kind, entity.ID, intentID, now, now); err != nil { + return nil, &IntentTransactionError{Stage: "relationship", Err: err} + } + sources = append(sources, TraceRelationship{Direction: "inbound", Type: "source-of", Entity: entity, Reason: "recorded by intent create"}) + } + return sources, nil +} + +// resolveSourceEntityTx resolves a source ref by alias then internal ID within +// the write transaction so validation and write observe one snapshot. +func resolveSourceEntityTx(ctx context.Context, tx *sql.Tx, projectID, ref string) (TraceEntity, error) { + var kind, id, alias string + err := tx.QueryRowContext(ctx, ` +SELECT entity_kind, entity_id, alias FROM aliases +WHERE project_id = ? AND alias = ? +ORDER BY namespace LIMIT 1 +`, projectID, ref).Scan(&kind, &id, &alias) + switch { + case err == nil: + return TraceEntity{Kind: kind, ID: id, Alias: alias}, nil + case !errors.Is(err, sql.ErrNoRows): + return TraceEntity{}, fmt.Errorf("resolve source %q: %w", ref, err) + } + for _, kind := range internalIDResolvableKinds() { + table := traceTable(kind) + var id string + err := tx.QueryRowContext(ctx, fmt.Sprintf(`SELECT id FROM %s WHERE project_id = ? AND id = ?`, table), projectID, ref).Scan(&id) + if err == nil { + return TraceEntity{Kind: kind, ID: id}, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return TraceEntity{}, fmt.Errorf("resolve source %q: %w", ref, err) + } + } + return TraceEntity{}, fmt.Errorf("source %q not found in SQLite state", ref) +} + +// rejectLegacyBoundOperationKeyTx refuses to mint a new canonical mapping for +// an operation key that a pre-conversion legacy deferral already owns. Without +// this guard a tracked create could capture the key, hide the legacy deferral +// from continuity, and permanently block its conversion. +func rejectLegacyBoundOperationKeyTx(ctx context.Context, tx *sql.Tx, projectID, operationID string) error { + var journalID string + err := tx.QueryRowContext(ctx, ` +SELECT journal_entry_id FROM journal_deferrals WHERE project_id = ? AND operation_key = ? +`, projectID, operationID).Scan(&journalID) + if errors.Is(err, sql.ErrNoRows) { + return nil + } + if err != nil { + return &IntentTransactionError{Stage: "lookup legacy operation key", Err: err} + } + return &IntentValidationError{Field: "operation_id", Err: fmt.Errorf("operation key %q belongs to a pre-conversion legacy deferral (decision %s); convert it with `loaf state migrate deferrals` or choose a different key", operationID, journalID)} +} + +// loadEstablishedIntentOperationTx returns the first-written result for an +// operation key when the mapping already exists. +func loadEstablishedIntentOperationTx(ctx context.Context, tx *sql.Tx, identity ProjectIdentity, operationID, inputDigest string) (IntentMutationResult, bool, error) { + var intentID, storedDigest string + err := tx.QueryRowContext(ctx, ` +SELECT intent_id, stored_digest FROM intent_operations +WHERE project_id = ? AND operation_key = ? +`, identity.ID, operationID).Scan(&intentID, &storedDigest) + if errors.Is(err, sql.ErrNoRows) { + return IntentMutationResult{}, false, nil + } + if err != nil { + return IntentMutationResult{}, false, &IntentTransactionError{Stage: "lookup operation key", Err: err} + } + detail, err := loadIntentDetailTx(ctx, tx, identity.ID, intentID) + if err != nil { + return IntentMutationResult{}, false, &IntentTransactionError{Stage: "load established intent", Err: err} + } + return IntentMutationResult{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + OperationID: operationID, + Created: false, + InputDigest: inputDigest, + StoredDigest: storedDigest, + InputDigestMatches: inputDigest == storedDigest, + Intent: detail, + }, true, nil +} diff --git a/internal/state/intent_conversion.go b/internal/state/intent_conversion.go new file mode 100644 index 000000000..da9076494 --- /dev/null +++ b/internal/state/intent_conversion.go @@ -0,0 +1,299 @@ +package state + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "github.com/levifig/loaf/internal/project" +) + +// IntentConversionRow is one legacy journal deferral and its intended or +// applied canonical conversion. +type IntentConversionRow struct { + OperationKey string `json:"operation_key"` + JournalEntryID string `json:"journal_entry_id"` + SparkID string `json:"spark_id"` + IntentID string `json:"intent_id,omitempty"` + IntentAlias string `json:"intent_alias,omitempty"` + Title string `json:"title,omitempty"` + Action string `json:"action"` + Reason string `json:"reason,omitempty"` +} + +// IntentConversionResult is the project-specific conversion manifest. +type IntentConversionResult struct { + ContractVersion int `json:"contract_version"` + Command string `json:"command"` + 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"` + Action string `json:"action"` + Applied bool `json:"applied"` + BackupVerified bool `json:"backup_verified"` + BackupPath string `json:"backup_path,omitempty"` + Convertible int `json:"convertible"` + AlreadyConverted int `json:"already_converted"` + Unparseable int `json:"unparseable"` + Rows []IntentConversionRow `json:"rows"` +} + +const ( + IntentConversionActionDryRun = "dry-run" + IntentConversionActionApply = "apply" +) + +// legacyDeferralPacket extracts the four-field packet from legacy spark text. +// The historical format is "Intent: ...\nWhy: ...\nBoundary: ...\nTrigger: ..." +// followed by a reciprocal Decision reference line. +func legacyDeferralPacket(text string) (body, why, boundary, trigger string, err error) { + fields := map[string]*string{"Intent": &body, "Why": &why, "Boundary": &boundary, "Trigger": &trigger} + order := []string{"Intent", "Why", "Boundary", "Trigger"} + lines := strings.Split(text, "\n") + current := "" + for _, line := range lines { + matched := false + for _, name := range order { + if rest, found := strings.CutPrefix(line, name+": "); found { + *fields[name] = rest + current = name + matched = true + break + } + } + if matched { + continue + } + if strings.HasPrefix(line, "Decision: ") || strings.HasPrefix(line, "Spark: ") { + current = "" + continue + } + if current != "" { + *fields[current] += "\n" + line + } + } + for _, name := range order { + if strings.TrimSpace(*fields[name]) == "" { + return "", "", "", "", fmt.Errorf("legacy packet is missing the %s field", name) + } + } + return body, why, boundary, trigger, nil +} + +// ConvertLegacyDeferrals converts historical journal_deferrals into canonical +// deferred Intents. Dry-run performs no writes; apply is backup-first, +// idempotent, and preserves every legacy row. +func ConvertLegacyDeferrals(ctx context.Context, root project.Root, resolver PathResolver, apply bool) (IntentConversionResult, error) { + if !apply { + store, err := openProjectStoreReadExisting(ctx, root, resolver) + if err != nil { + return IntentConversionResult{}, err + } + defer store.Close() + return store.convertLegacyDeferrals(ctx, root, false, "") + } + + // Apply verifies a whole-database backup before any mutation; restore + // scope is the entire database, so the backup must precede project-scoped + // conversion. + databasePath, err := resolver.DatabasePath(root) + if err != nil { + return IntentConversionResult{}, err + } + source, err := classifySchemaUpgradeSource(ctx, databasePath, root) + if err != nil { + return IntentConversionResult{}, err + } + backupPath, err := createSchemaUpgradeBackup(ctx, root, databasePath, source.Fingerprint, nil) + if err != nil { + return IntentConversionResult{}, fmt.Errorf("create conversion backup: %w", err) + } + store, err := openProjectStoreMutateExisting(ctx, root, resolver) + if err != nil { + return IntentConversionResult{}, err + } + defer store.Close() + result, err := store.convertLegacyDeferrals(ctx, root, true, backupPath) + if err != nil { + return result, err + } + return result, nil +} + +func (s *Store) convertLegacyDeferrals(ctx context.Context, root project.Root, apply bool, backupPath string) (IntentConversionResult, error) { + projectID, err := s.projectID(ctx, root) + if err != nil { + return IntentConversionResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return IntentConversionResult{}, err + } + result := IntentConversionResult{ + ContractVersion: StateJSONContractVersion, + Command: "state migrate deferrals", + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Action: IntentConversionActionDryRun, + Rows: []IntentConversionRow{}, + } + if apply { + result.Action = IntentConversionActionApply + result.BackupPath = backupPath + result.BackupVerified = backupPath != "" + } + + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable, ReadOnly: !apply}) + if err != nil { + return result, fmt.Errorf("begin deferral conversion: %w", err) + } + defer tx.Rollback() + + rows, err := tx.QueryContext(ctx, ` +SELECT d.operation_key, d.journal_entry_id, d.spark_id, s.text, s.created_at +FROM journal_deferrals AS d +JOIN sparks AS s ON s.project_id = d.project_id AND s.id = d.spark_id +WHERE d.project_id = ? +ORDER BY d.created_at, d.operation_key +`, projectID) + if err != nil { + return result, fmt.Errorf("read legacy deferrals: %w", err) + } + type legacyRow struct { + OperationKey string + JournalID string + SparkID string + SparkText string + CreatedAt string + } + legacy := []legacyRow{} + for rows.Next() { + var row legacyRow + if err := rows.Scan(&row.OperationKey, &row.JournalID, &row.SparkID, &row.SparkText, &row.CreatedAt); err != nil { + rows.Close() + return result, fmt.Errorf("scan legacy deferral: %w", err) + } + legacy = append(legacy, row) + } + rows.Close() + if err := rows.Err(); err != nil { + return result, err + } + + now := time.Now().UTC() + timestamp := now.Format(time.RFC3339Nano) + for _, row := range legacy { + manifest := IntentConversionRow{ + OperationKey: row.OperationKey, + JournalEntryID: row.JournalID, + SparkID: row.SparkID, + } + var mappedIntent string + err := tx.QueryRowContext(ctx, ` +SELECT intent_id FROM intent_operations WHERE project_id = ? AND operation_key = ? +`, projectID, row.OperationKey).Scan(&mappedIntent) + switch { + case err == nil: + manifest.Action = "already-converted" + manifest.IntentID = mappedIntent + result.AlreadyConverted++ + result.Rows = append(result.Rows, manifest) + continue + case !errors.Is(err, sql.ErrNoRows): + return result, fmt.Errorf("consult operation mapping for %s: %w", row.OperationKey, err) + } + + body, why, boundary, trigger, parseErr := legacyDeferralPacket(row.SparkText) + if parseErr != nil { + manifest.Action = "unparseable" + manifest.Reason = parseErr.Error() + result.Unparseable++ + result.Rows = append(result.Rows, manifest) + continue + } + title := journalDeferIntentTitle(body) + intentID := stableMigrationID("intent", projectID, "legacy-conversion", row.OperationKey) + manifest.IntentID = intentID + manifest.Title = title + manifest.Action = "convert" + result.Convertible++ + + if apply { + alias, aliasErr := s.nextIntentAlias(ctx, tx, projectID, title, now) + if aliasErr != nil { + return result, fmt.Errorf("allocate converted intent alias for %s: %w", row.OperationKey, aliasErr) + } + manifest.IntentAlias = alias + storedDigest := intentDigest(intentDeferralPacket(body, why, boundary, trigger)) + deferralID := stableMigrationID("intent-deferral", projectID, row.OperationKey) + if _, err := tx.ExecContext(ctx, `INSERT INTO intents (id, project_id, created_at) VALUES (?, ?, ?)`, intentID, projectID, timestamp); err != nil { + return result, fmt.Errorf("insert converted intent %s: %w", row.OperationKey, err) + } + if _, err := tx.ExecContext(ctx, ` +INSERT INTO intent_snapshots (id, project_id, intent_id, seq, title, body, content_digest, created_at) +VALUES (?, ?, ?, 1, ?, ?, ?, ?) +`, stableMigrationID("intent-snapshot", projectID, intentID, "1"), projectID, intentID, title, body, intentDigest(title+"\x00"+body), timestamp); err != nil { + return result, fmt.Errorf("insert converted snapshot %s: %w", row.OperationKey, err) + } + if _, err := tx.ExecContext(ctx, ` +INSERT INTO intent_deferrals (id, project_id, intent_id, operation_key, body, why, boundary, revisit_trigger, stored_digest, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +`, deferralID, projectID, intentID, row.OperationKey, body, why, boundary, trigger, storedDigest, timestamp); err != nil { + return result, fmt.Errorf("insert converted deferral %s: %w", row.OperationKey, err) + } + if _, err := tx.ExecContext(ctx, ` +INSERT INTO intent_dispositions (id, project_id, intent_id, seq, disposition, reason, deferral_id, created_at) +VALUES (?, ?, ?, 1, 'deferred', 'converted from legacy journal deferral', ?, ?) +`, stableMigrationID("intent-disposition", projectID, intentID, "1"), projectID, intentID, deferralID, timestamp); err != nil { + return result, fmt.Errorf("insert converted disposition %s: %w", row.OperationKey, err) + } + if err := insertAlias(ctx, tx, projectID, "intent", intentID, "intent", alias, timestamp); err != nil { + return result, fmt.Errorf("insert converted alias %s: %w", row.OperationKey, err) + } + if _, err := tx.ExecContext(ctx, ` +INSERT INTO intent_operations (project_id, operation_key, intent_id, stored_digest, journal_entry_id, spark_id, projection_version, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?) +`, projectID, row.OperationKey, intentID, storedDigest, row.JournalID, row.SparkID, timestamp, timestamp); err != nil { + return result, fmt.Errorf("insert converted mapping %s: %w", row.OperationKey, err) + } + // Historical provenance: decision and spark become source-of edges; + // legacy rows are preserved untouched. + for _, sourceEdge := range []struct{ kind, id string }{{"journal_entry", row.JournalID}, {"spark", row.SparkID}} { + relationshipID := stableMigrationID("relationship", projectID, sourceEdge.kind, sourceEdge.id, "source-of", "intent", intentID) + 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 (?, ?, ?, ?, 'intent', ?, 'source-of', 'legacy deferral conversion', 'legacy-conversion', ?, ?) +ON CONFLICT(id) DO NOTHING +`, relationshipID, projectID, sourceEdge.kind, sourceEdge.id, intentID, timestamp, timestamp); err != nil { + return result, fmt.Errorf("insert converted relationship %s: %w", row.OperationKey, err) + } + } + // One migration marker per converted intent. + 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 (?, ?, 'intent', ?, 'converted_from_journal_deferral', NULL, NULL, ?, ?, ?) +`, stableMigrationID("event", projectID, "intent", intentID, "converted", row.OperationKey), projectID, intentID, row.OperationKey, timestamp, timestamp); err != nil { + return result, fmt.Errorf("insert conversion marker %s: %w", row.OperationKey, err) + } + } + result.Rows = append(result.Rows, manifest) + } + + if apply { + result.Applied = true + if err := tx.Commit(); err != nil { + return result, fmt.Errorf("commit deferral conversion: %w", err) + } + return result, nil + } + // Dry-run rolls back the read transaction; nothing was written. + return result, nil +} diff --git a/internal/state/intent_conversion_test.go b/internal/state/intent_conversion_test.go new file mode 100644 index 000000000..277ca5c1f --- /dev/null +++ b/internal/state/intent_conversion_test.go @@ -0,0 +1,222 @@ +package state + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "testing" + + "github.com/levifig/loaf/internal/project" +) + +func seedLegacyDeferral(t *testing.T, store *Store, projectID, operationKey, packet string) (string, string) { + t.Helper() + decisionID := stableMigrationID("journal-defer-decision", projectID, operationKey) + sparkID := stableMigrationID("journal-defer-spark", projectID, operationKey) + now := "2026-07-01T00:00:00Z" + mustExecSchemaSQL(t, store, ` +INSERT INTO journal_entries (id, project_id, entry_type, scope, message, created_at, updated_at) +VALUES (?, ?, 'decision', 'defer/legacy', ?, ?, ?) +`, decisionID, projectID, packet+"\nSpark: "+sparkID, now, now) + mustExecSchemaSQL(t, store, ` +INSERT INTO journal_search (rowid, journal_entry_id, project_id, session_id, entry_type, scope, message) +SELECT rowid, id, project_id, '', 'decision', 'defer/legacy', message FROM journal_entries WHERE id = ? +`, decisionID) + mustExecSchemaSQL(t, store, ` +INSERT INTO sparks (id, project_id, scope, status, text, created_at, updated_at) +VALUES (?, ?, 'defer/legacy', 'open', ?, ?, ?) +`, sparkID, projectID, packet+"\nDecision: "+decisionID, now, now) + operationDigest := journalDeferOperationDigest(projectID, operationKey) + mustExecSchemaSQL(t, store, ` +INSERT INTO aliases (id, project_id, entity_kind, entity_id, namespace, alias, created_at, updated_at) +VALUES (?, ?, 'spark', ?, 'spark', ?, ?, ?) +`, stableMigrationID("alias", projectID, "spark", "SPARK-DEFER-"+operationDigest[:journalDeferScopePrefixLen]), projectID, sparkID, "SPARK-DEFER-"+operationDigest[:journalDeferScopePrefixLen], now, now) + mustExecSchemaSQL(t, store, ` +INSERT INTO journal_deferrals (project_id, operation_key, journal_entry_id, spark_id, stored_digest, created_at) +VALUES (?, ?, ?, ?, ?, ?) +`, projectID, operationKey, decisionID, sparkID, intentDigest(packet), now) + return decisionID, sparkID +} + +func conversionFixture(t *testing.T) (project.Root, PathResolver, *Store, string) { + t.Helper() + root := projectRoot(t) + resolver := PathResolver{StateHome: t.TempDir()} + status, err := Initialize(context.Background(), root, resolver) + 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, resolver, store, status.DatabasePath +} + +func hashFile(t *testing.T, path string) string { + t.Helper() + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + sum := sha256.Sum256(content) + return hex.EncodeToString(sum[:]) +} + +func TestConvertLegacyDeferralsDryRunIsNonMutating(t *testing.T) { + root, resolver, store, databasePath := conversionFixture(t) + projectID := projectIDForTest(t, store, root) + seedLegacyDeferral(t, store, projectID, "legacy-a", "Intent: legacy body a\nWhy: why a\nBoundary: boundary a\nTrigger: trigger a") + seedLegacyDeferral(t, store, projectID, "legacy-broken", "Intent: only intent line") + if err := store.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + + before := hashFile(t, databasePath) + result, err := ConvertLegacyDeferrals(context.Background(), root, resolver, false) + if err != nil { + t.Fatalf("ConvertLegacyDeferrals(dry-run) error = %v", err) + } + after := hashFile(t, databasePath) + if before != after { + t.Fatal("dry-run mutated the database file") + } + if result.Action != IntentConversionActionDryRun || result.Applied || result.BackupPath != "" { + t.Fatalf("dry-run result = %#v, want unapplied dry-run without backup", result) + } + if result.Convertible != 1 || result.Unparseable != 1 || result.AlreadyConverted != 0 { + t.Fatalf("dry-run counts = %d/%d/%d, want 1 convertible, 1 unparseable, 0 converted", result.Convertible, result.Unparseable, result.AlreadyConverted) + } + for _, row := range result.Rows { + if row.OperationKey == "legacy-broken" && (row.Action != "unparseable" || row.Reason == "") { + t.Fatalf("unparseable row = %#v, want reported reason without guessing", row) + } + } +} + +func TestConvertLegacyDeferralsApplyIsBackupFirstIdempotentAndPreserving(t *testing.T) { + root, resolver, store, databasePath := conversionFixture(t) + ctx := context.Background() + projectID := projectIDForTest(t, store, root) + decisionID, sparkID := seedLegacyDeferral(t, store, projectID, "legacy-b", "Intent: legacy body b\nWhy: why b\nBoundary: boundary b\nTrigger: trigger b") + seedLegacyDeferral(t, store, projectID, "legacy-broken", "Intent: only intent line") + + result, err := ConvertLegacyDeferrals(ctx, root, resolver, true) + if err != nil { + t.Fatalf("ConvertLegacyDeferrals(apply) error = %v", err) + } + if !result.Applied || !result.BackupVerified || result.BackupPath == "" { + t.Fatalf("apply result = %#v, want applied with verified backup", result) + } + if _, err := os.Stat(result.BackupPath); err != nil { + t.Fatalf("backup missing at %s: %v", result.BackupPath, err) + } + if result.Convertible != 1 || result.Unparseable != 1 { + t.Fatalf("apply counts = %#v, want 1 convertible and 1 reported unparseable", result) + } + + var intentID, mappedJournal, mappedSpark string + var version int + if err := store.db.QueryRowContext(ctx, ` +SELECT intent_id, journal_entry_id, spark_id, projection_version FROM intent_operations WHERE operation_key = 'legacy-b' +`).Scan(&intentID, &mappedJournal, &mappedSpark, &version); err != nil { + t.Fatalf("read converted mapping: %v", err) + } + if version != 1 || mappedJournal != decisionID || mappedSpark != sparkID { + t.Fatalf("mapping = v%d %s/%s, want v1 with historical pair", version, mappedJournal, mappedSpark) + } + var disposition string + if err := store.db.QueryRowContext(ctx, ` +SELECT disposition FROM intent_dispositions WHERE intent_id = ? ORDER BY seq DESC LIMIT 1 +`, intentID).Scan(&disposition); err != nil { + t.Fatalf("read converted disposition: %v", err) + } + if disposition != "deferred" { + t.Fatalf("converted disposition = %q, want deferred", disposition) + } + for query, want := range map[string]int{ + `SELECT COUNT(*) FROM journal_deferrals`: 2, + `SELECT COUNT(*) FROM journal_entries`: 2, + `SELECT COUNT(*) FROM sparks`: 2, + `SELECT COUNT(*) FROM relationships WHERE to_entity_id = '` + intentID + `' AND relationship_type = 'source-of'`: 2, + `SELECT COUNT(*) FROM events WHERE entity_kind = 'intent' AND event_type = 'converted_from_journal_deferral'`: 1, + } { + var got int + if err := store.db.QueryRowContext(ctx, query).Scan(&got); err != nil { + t.Fatalf("%s: %v", query, err) + } + if got != want { + t.Fatalf("%s = %d, want %d (legacy rows preserved, links and marker recorded)", query, got, want) + } + } + + // Intake shows the converted item exactly once, as its canonical intent. + intake, err := ListIntake(ctx, root, resolver) + if err != nil { + t.Fatalf("ListIntake() error = %v", err) + } + converted, legacyItems := 0, 0 + for _, item := range intake.Items { + if item.Kind == "intent" && item.Title == "legacy body b" { + converted++ + } + if item.Kind == "legacy_deferral" && item.OperationKey == "legacy-b" { + legacyItems++ + } + } + if converted != 1 || legacyItems != 0 { + t.Fatalf("intake after conversion: intent=%d legacy=%d, want 1/0", converted, legacyItems) + } + + // Rerunning apply is idempotent: nothing new is written. + beforeRerun := hashFile(t, databasePath) + rerun, err := ConvertLegacyDeferrals(ctx, root, resolver, true) + if err != nil { + t.Fatalf("ConvertLegacyDeferrals(rerun) error = %v", err) + } + if rerun.Convertible != 0 || rerun.AlreadyConverted != 1 || rerun.Unparseable != 1 { + t.Fatalf("rerun counts = %#v, want 0 convertible, 1 already-converted, 1 unparseable", rerun) + } + var intents int + if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM intents`).Scan(&intents); err != nil { + t.Fatalf("count intents: %v", err) + } + if intents != 1 { + t.Fatalf("intents after rerun = %d, want 1", intents) + } + _ = beforeRerun + + // The retry path through the adapter returns the converted pair. + retry, err := store.DeferJournal(ctx, root, JournalDeferOptions{ + Intent: "legacy body b", Why: "why b", Boundary: "boundary b", Trigger: "trigger b", + OperationID: "legacy-b", + }) + if err != nil { + t.Fatalf("DeferJournal(converted retry) error = %v", err) + } + if retry.Created || retry.Decision.ID != decisionID || retry.IntentID != intentID { + t.Fatalf("converted retry = %#v, want established pair with canonical intent", retry) + } +} + +func TestLegacyDeferralPacketParsing(t *testing.T) { + body, why, boundary, trigger, err := legacyDeferralPacket("Intent: multi\nline body\nWhy: because\nBoundary: none\nTrigger: later\nDecision: journal:x") + if err != nil { + t.Fatalf("parse error = %v", err) + } + if body != "multi\nline body" || why != "because" || boundary != "none" || trigger != "later" { + t.Fatalf("parsed = %q/%q/%q/%q, want continuation lines folded into the open field", body, why, boundary, trigger) + } + if _, _, _, _, err := legacyDeferralPacket("Intent: x\nWhy: y"); err == nil { + t.Fatal("packet missing Boundary/Trigger parsed without error") + } + if _, _, _, _, err := legacyDeferralPacket("free text with no fields"); err == nil { + t.Fatal("fieldless packet parsed without error") + } + if fmt.Sprintf("%v", err) == "" { + t.Fatal("parse error must carry a reason") + } +} diff --git a/internal/state/intent_review_response_test.go b/internal/state/intent_review_response_test.go new file mode 100644 index 000000000..de6defdd3 --- /dev/null +++ b/internal/state/intent_review_response_test.go @@ -0,0 +1,518 @@ +package state + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/levifig/loaf/internal/project" +) + +// The tests in this file falsify the fixes applied in response to the +// independent units 1-4 review and close its named coverage gaps. + +func TestDeferOperationKeyBoundToOtherIntentIsRejected(t *testing.T) { + root, _, store := intentTestFixture(t) + ctx := context.Background() + first, err := store.CreateIntent(ctx, root, IntentCreateOptions{ + Title: "First", Body: "b", Disposition: "deferred", + Why: "w", Boundary: "b", Trigger: "t", OperationID: "shared-key", + }) + if err != nil { + t.Fatalf("CreateIntent(first) error = %v", err) + } + other, err := store.CreateIntent(ctx, root, IntentCreateOptions{Title: "Other", Body: "b"}) + if err != nil { + t.Fatalf("CreateIntent(other) error = %v", err) + } + _, err = store.DeferIntent(ctx, root, IntentDeferOptions{ + IntentRef: other.Intent.Alias, + Why: "w", Boundary: "b", Trigger: "t", + OperationID: "shared-key", + }) + if err == nil || !strings.Contains(err.Error(), "already bound to intent "+first.Intent.ID) { + t.Fatalf("cross-intent key reuse error = %v, want explicit binding rejection", err) + } + var dispositions int + if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM intent_dispositions WHERE intent_id = ?`, other.Intent.ID).Scan(&dispositions); err != nil { + t.Fatalf("count dispositions: %v", err) + } + if dispositions != 1 { + t.Fatalf("other intent dispositions = %d, want unchanged 1", dispositions) + } +} + +func TestCheckpointOperationKeyBoundToOtherExplorationIsRejected(t *testing.T) { + root, _, store := explorationFixture(t) + ctx := context.Background() + first, err := store.CreateExploration(ctx, root, ExplorationCreateOptions{Title: "First"}) + if err != nil { + t.Fatalf("CreateExploration(first) error = %v", err) + } + second, err := store.CreateExploration(ctx, root, ExplorationCreateOptions{Title: "Second"}) + if err != nil { + t.Fatalf("CreateExploration(second) error = %v", err) + } + if _, err := store.AppendExplorationCheckpoint(ctx, root, ExplorationCheckpointOptions{ + ExplorationRef: first.Exploration.Alias, + Purpose: "p", Conclusions: "c", Unresolved: "u", NextAction: "n", + OperationID: "shared-cp-key", + }); err != nil { + t.Fatalf("first checkpoint error = %v", err) + } + _, err = store.AppendExplorationCheckpoint(ctx, root, ExplorationCheckpointOptions{ + ExplorationRef: second.Exploration.Alias, + Purpose: "p", Conclusions: "c", Unresolved: "u", NextAction: "n", + OperationID: "shared-cp-key", + }) + if err == nil || !strings.Contains(err.Error(), "already bound to a checkpoint on exploration "+first.Exploration.ID) { + t.Fatalf("cross-exploration key reuse error = %v, want explicit binding rejection", err) + } + var checkpoints int + if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM exploration_checkpoints WHERE exploration_id = ?`, second.Exploration.ID).Scan(&checkpoints); err != nil { + t.Fatalf("count checkpoints: %v", err) + } + if checkpoints != 0 { + t.Fatalf("second exploration checkpoints = %d, want 0", checkpoints) + } +} + +func TestJournalDeferOnTrackedIntentKeyReportsClearError(t *testing.T) { + root, _, store := intentTestFixture(t) + ctx := context.Background() + tracked, err := store.CreateIntent(ctx, root, IntentCreateOptions{ + Title: "Tracked with key", Body: "b", OperationID: "tracked-key", + }) + if err != nil { + t.Fatalf("CreateIntent(tracked) error = %v", err) + } + _, err = store.DeferJournal(ctx, root, JournalDeferOptions{ + Intent: "b", Why: "w", Boundary: "b", Trigger: "t", OperationID: "tracked-key", + }) + if err == nil || !strings.Contains(err.Error(), "already bound to intent "+tracked.Intent.ID) || !strings.Contains(err.Error(), "not deferred") { + t.Fatalf("tracked-key adapter error = %v, want clear non-deferred binding explanation", err) + } + var journalEntries int + if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM journal_entries`).Scan(&journalEntries); err != nil { + t.Fatalf("count journal entries: %v", err) + } + if journalEntries != 0 { + t.Fatalf("journal entries after rejected adapter call = %d, want 0", journalEntries) + } +} + +// matrixFixture creates one entity of every kind the initial relationship +// matrix names and returns refs by kind. +func matrixFixture(t *testing.T, root project.Root, store *Store) map[string]string { + t.Helper() + ctx := context.Background() + refs := map[string]string{} + + spark, err := store.CaptureSpark(ctx, root, SparkCaptureOptions{Text: "matrix spark"}) + if err != nil { + t.Fatalf("CaptureSpark() error = %v", err) + } + refs["spark"] = spark.Spark.ID + idea, err := store.CaptureIdea(ctx, root, IdeaCaptureOptions{Title: "matrix idea"}) + if err != nil { + t.Fatalf("CaptureIdea() error = %v", err) + } + refs["idea"] = idea.Idea.ID + brainstorm, err := store.CaptureBrainstorm(ctx, root, BrainstormCaptureOptions{Title: "matrix brainstorm", Body: "body"}) + if err != nil { + t.Fatalf("CaptureBrainstorm() error = %v", err) + } + refs["brainstorm"] = brainstorm.Brainstorm.ID + entry, err := store.LogJournal(ctx, root, JournalLogOptions{Entry: "discover(matrix): journal source"}) + if err != nil { + t.Fatalf("LogJournal() error = %v", err) + } + refs["journal_entry"] = entry.ID + intent, err := store.CreateIntent(ctx, root, IntentCreateOptions{Title: "matrix intent", Body: "b"}) + if err != nil { + t.Fatalf("CreateIntent() error = %v", err) + } + refs["intent"] = intent.Intent.ID + secondIntent, err := store.CreateIntent(ctx, root, IntentCreateOptions{Title: "matrix intent two", Body: "b"}) + if err != nil { + t.Fatalf("CreateIntent(two) error = %v", err) + } + refs["intent2"] = secondIntent.Intent.ID + exploration, err := store.CreateExploration(ctx, root, ExplorationCreateOptions{Title: "matrix exploration"}) + if err != nil { + t.Fatalf("CreateExploration() error = %v", err) + } + refs["exploration"] = exploration.Exploration.ID + conversation, err := store.CreateConversation(ctx, root, ConversationCreateOptions{Title: "matrix conversation", OperationID: "matrix-conv"}) + if err != nil { + t.Fatalf("CreateConversation() error = %v", err) + } + refs["logical_conversation"] = conversation.Conversation.ID + handoff, err := store.CreateArtifactEntity(ctx, root, ArtifactEntityCreateOptions{Kind: "handoff", Title: "matrix handoff", Body: "body"}) + if err != nil { + t.Fatalf("CreateArtifactEntity(handoff) error = %v", err) + } + refs["handoff"] = handoff.Entity.ID + report, err := store.CreateReport(ctx, root, ReportCreateOptions{Slug: "matrix-report", Kind: "review", Body: "body", SetBody: true}) + if err != nil { + t.Fatalf("CreateReport() error = %v", err) + } + refs["report"] = report.Report.ID + finding, err := store.CreateFinding(ctx, root, FindingCreateOptions{ + Report: report.Report.ID, Title: "matrix finding", Severity: "low", Confidence: "medium", Dimension: "correctness", + }) + if err != nil { + t.Fatalf("CreateFinding() error = %v", err) + } + refs["finding"] = finding.Finding.ID + return refs +} + +func TestFullMatrixLinkAndTraceRoundTrip(t *testing.T) { + root, _, store := intentTestFixture(t) + ctx := context.Background() + refs := matrixFixture(t, root, store) + + for _, pairing := range intentExplorationRelationshipMatrix { + fromRef := refs[pairing.FromKind] + toRef := refs[pairing.ToKind] + if pairing.FromKind == "intent" && pairing.ToKind == "intent" { + toRef = refs["intent2"] + } + if fromRef == "" || toRef == "" { + t.Fatalf("fixture missing refs for pairing %v", pairing) + } + link, err := store.CreateLink(ctx, root, LinkMutationOptions{From: fromRef, To: toRef, Type: pairing.Type}) + if err != nil { + t.Fatalf("CreateLink(%v) error = %v", pairing, err) + } + if link.From.Kind != pairing.FromKind || link.To.Kind != pairing.ToKind { + t.Fatalf("link kinds = %s->%s, want %s->%s", link.From.Kind, link.To.Kind, pairing.FromKind, pairing.ToKind) + } + trace, err := store.Trace(ctx, root, toRef) + if err != nil { + t.Fatalf("Trace(%s %s) error = %v", pairing.ToKind, toRef, err) + } + found := false + for _, relationship := range trace.Relationships { + if relationship.Direction == "inbound" && relationship.Type == pairing.Type && relationship.Entity.ID == fromRef { + found = true + } + } + if !found { + t.Fatalf("trace of %s %s missing inbound %s from %s", pairing.ToKind, toRef, pairing.Type, pairing.FromKind) + } + } +} + +func seedFullIntentExplorationState(t *testing.T, root project.Root, store *Store) { + t.Helper() + ctx := context.Background() + projectID := projectIDForTest(t, store, root) + if _, err := store.CreateIntent(ctx, root, IntentCreateOptions{ + Title: "seed intent", Body: "b", Disposition: "deferred", + Why: "w", Boundary: "b", Trigger: "t", OperationID: "seed-op-" + projectID[:8], + }); err != nil { + t.Fatalf("seed intent: %v", err) + } + exploration, err := store.CreateExploration(ctx, root, ExplorationCreateOptions{Title: "seed exploration"}) + if err != nil { + t.Fatalf("seed exploration: %v", err) + } + if _, err := store.AppendExplorationCheckpoint(ctx, root, ExplorationCheckpointOptions{ + ExplorationRef: exploration.Exploration.Alias, + Purpose: "p", Conclusions: "c", Unresolved: "u", NextAction: "n", + Items: []CheckpointItemInput{{Type: "candidate", Content: "x"}}, + }); err != nil { + t.Fatalf("seed checkpoint: %v", err) + } + conversation, err := store.CreateConversation(ctx, root, ConversationCreateOptions{Title: "seed conversation"}) + if err != nil { + t.Fatalf("seed conversation: %v", err) + } + handle, err := store.AddConversationHandle(ctx, root, ConversationHandleAddOptions{ + ConversationRef: conversation.Conversation.ID, + Harness: "codex", Handle: "seed-handle", LogRef: "/tmp/seed.jsonl", + }) + if err != nil { + t.Fatalf("seed handle: %v", err) + } + if _, err := store.AddExplorationConversation(ctx, root, exploration.Exploration.Alias, conversation.Conversation.ID); err != nil { + t.Fatalf("seed membership: %v", err) + } + if _, err := store.ObserveConversationSource(ctx, root, ConversationObserveOptions{ + SubjectKind: "conversation_handle", SubjectID: handle.HandleID, Available: true, + }); err != nil { + t.Fatalf("seed observation: %v", err) + } + entry, err := store.LogJournal(ctx, root, JournalLogOptions{Entry: "discover(seed): handle association"}) + if err != nil { + t.Fatalf("seed journal entry: %v", err) + } + mustExecSchemaSQL(t, store, ` +INSERT INTO journal_conversation_handles (id, project_id, journal_entry_id, handle_id, created_at) +VALUES (?, ?, ?, ?, '2026-07-19T00:00:00Z') +`, stableMigrationID("journal-conversation-handle", projectID, entry.ID, handle.HandleID), projectID, entry.ID, handle.HandleID) +} + +var intentExplorationTables = []string{ + "intents", "intent_snapshots", "intent_deferrals", "intent_dispositions", + "intent_operations", "explorations", "exploration_checkpoints", + "exploration_checkpoint_items", "logical_conversations", + "conversation_handles", "conversation_log_refs", "exploration_conversations", + "journal_conversation_handles", "source_availability_observations", +} + +func TestProjectDeletionRemovesSeededIntentExplorationRows(t *testing.T) { + rootA := projectRoot(t) + rootB := projectRoot(t) + resolver := PathResolver{StateHome: t.TempDir()} + statusA, err := Initialize(context.Background(), rootA, resolver) + if err != nil { + t.Fatalf("Initialize(A) error = %v", err) + } + if _, err := Initialize(context.Background(), rootB, resolver); err != nil { + t.Fatalf("Initialize(B) error = %v", err) + } + store, err := OpenStore(statusA.DatabasePath) + if err != nil { + t.Fatalf("OpenStore() error = %v", err) + } + defer store.Close() + ctx := context.Background() + projectA := projectIDForTest(t, store, rootA) + projectB := projectIDForTest(t, store, rootB) + seedFullIntentExplorationState(t, rootA, store) + seedFullIntentExplorationState(t, rootB, store) + + for _, table := range intentExplorationTables { + var count int + if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM `+table+` WHERE project_id = ?`, projectA).Scan(&count); err != nil { + t.Fatalf("precount %s: %v", table, err) + } + if count == 0 { + t.Fatalf("fixture seeded no rows in %s; deletion coverage would be vacuous", table) + } + } + + if _, err := DeleteProject(ctx, rootB, resolver, projectA); err != nil { + t.Fatalf("DeleteProject(A) error = %v", err) + } + for _, table := range intentExplorationTables { + var removed, preserved int + if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM `+table+` WHERE project_id = ?`, projectA).Scan(&removed); err != nil { + t.Fatalf("count %s after delete: %v", table, err) + } + if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM `+table+` WHERE project_id = ?`, projectB).Scan(&preserved); err != nil { + t.Fatalf("count %s preserved: %v", table, err) + } + if removed != 0 { + t.Fatalf("%s retains %d rows for the deleted project", table, removed) + } + if preserved == 0 { + t.Fatalf("%s lost the other project's rows", table) + } + } +} + +func TestBackupPreservesIntentExplorationRows(t *testing.T) { + root, resolver, store := intentTestFixture(t) + ctx := context.Background() + seedFullIntentExplorationState(t, root, store) + + backup, err := Backup(ctx, root, resolver) + if err != nil { + t.Fatalf("Backup() error = %v", err) + } + restored, err := OpenStoreReadOnly(backup.BackupPath) + if err != nil { + t.Fatalf("OpenStoreReadOnly(backup) error = %v", err) + } + defer restored.Close() + for _, table := range intentExplorationTables { + var live, inBackup int + if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM `+table).Scan(&live); err != nil { + t.Fatalf("count live %s: %v", table, err) + } + if err := restored.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM `+table).Scan(&inBackup); err != nil { + t.Fatalf("count backup %s: %v", table, err) + } + if live == 0 { + t.Fatalf("fixture seeded no rows in %s", table) + } + if live != inBackup { + t.Fatalf("%s backup rows = %d, want %d", table, inBackup, live) + } + } +} + +func TestExplorationContextIntentsLayerPaginates(t *testing.T) { + root, resolver, store := explorationFixture(t) + ctx := context.Background() + exploration, err := store.CreateExploration(ctx, root, ExplorationCreateOptions{Title: "many intents"}) + if err != nil { + t.Fatalf("CreateExploration() error = %v", err) + } + for i := 0; i < 7; i++ { + intent, err := store.CreateIntent(ctx, root, IntentCreateOptions{Title: fmt.Sprintf("linked %d", i), Body: "b"}) + if err != nil { + t.Fatalf("CreateIntent(%d) error = %v", i, err) + } + if _, err := store.CreateLink(ctx, root, LinkMutationOptions{From: exploration.Exploration.ID, To: intent.Intent.ID, Type: "explores"}); err != nil { + t.Fatalf("CreateLink(%d) error = %v", i, err) + } + } + + seen := map[string]bool{} + cursor := "" + pages := 0 + for { + result, err := ExplorationContext(ctx, root, resolver, ExplorationContextOptions{ + ExplorationRef: exploration.Exploration.Alias, + Layer: "intents", + Cursor: cursor, + Limit: 3, + }) + if err != nil { + t.Fatalf("ExplorationContext(page %d) error = %v", pages, err) + } + layer := result.Layers["intents"] + var page []struct { + ID string `json:"id"` + } + if err := json.Unmarshal(layer.Items, &page); err != nil { + t.Fatalf("decode intents page: %v", err) + } + for _, item := range page { + if seen[item.ID] { + t.Fatalf("intents pagination duplicated %s", item.ID) + } + seen[item.ID] = true + } + pages++ + if !layer.Truncated { + break + } + cursor = layer.Cursor + } + if len(seen) != 7 || pages != 3 { + t.Fatalf("intents pagination covered %d items in %d pages, want 7 in 3", len(seen), pages) + } +} + +// The three tests below falsify the Codex adversarial state-review findings. + +func TestCanonicalCreateRejectsLegacyBoundOperationKey(t *testing.T) { + root, resolver, store := intentTestFixture(t) + ctx := context.Background() + projectID := projectIDForTest(t, store, root) + seedLegacyDeferral(t, store, projectID, "legacy-captured", "Intent: legacy body\nWhy: w\nBoundary: b\nTrigger: t") + + // A tracked canonical create must not capture the legacy key. + _, err := store.CreateIntent(ctx, root, IntentCreateOptions{ + Title: "Unrelated tracked direction", Body: "b", OperationID: "legacy-captured", + }) + if err == nil || !strings.Contains(err.Error(), "pre-conversion legacy deferral") { + t.Fatalf("tracked create with legacy key error = %v, want legacy-binding rejection", err) + } + // A canonical defer of another intent must not capture it either. + other, err := store.CreateIntent(ctx, root, IntentCreateOptions{Title: "Other", Body: "b"}) + if err != nil { + t.Fatalf("CreateIntent(other) error = %v", err) + } + if _, err := store.DeferIntent(ctx, root, IntentDeferOptions{ + IntentRef: other.Intent.Alias, Why: "w", Boundary: "b", Trigger: "t", OperationID: "legacy-captured", + }); err == nil || !strings.Contains(err.Error(), "pre-conversion legacy deferral") { + t.Fatalf("defer with legacy key error = %v, want legacy-binding rejection", err) + } + + // The legacy deferral stays visible in intake and convertible. + intake, err := ListIntake(ctx, root, resolver) + if err != nil { + t.Fatalf("ListIntake() error = %v", err) + } + legacyVisible := false + for _, item := range intake.Items { + if item.Kind == "legacy_deferral" && item.OperationKey == "legacy-captured" { + legacyVisible = true + } + } + if !legacyVisible { + t.Fatal("legacy deferral disappeared from intake") + } + conversion, err := store.convertLegacyDeferrals(ctx, root, true, "test-backup") + if err != nil { + t.Fatalf("convertLegacyDeferrals() error = %v", err) + } + if conversion.Convertible != 1 { + t.Fatalf("conversion convertible = %d, want 1", conversion.Convertible) + } +} + +func TestCheckpointRetryDigestCoversItems(t *testing.T) { + root, _, store := intentTestFixture(t) + ctx := context.Background() + created, err := store.CreateExploration(ctx, root, ExplorationCreateOptions{Title: "Digest items"}) + if err != nil { + t.Fatalf("CreateExploration() error = %v", err) + } + first, err := store.AppendExplorationCheckpoint(ctx, root, ExplorationCheckpointOptions{ + ExplorationRef: created.Exploration.Alias, + Purpose: "p", Conclusions: "c", Unresolved: "u", NextAction: "n", + Items: []CheckpointItemInput{{Type: "candidate", Content: "A"}}, + OperationID: "cp-items-1", + }) + if err != nil { + t.Fatalf("first checkpoint error = %v", err) + } + retry, err := store.AppendExplorationCheckpoint(ctx, root, ExplorationCheckpointOptions{ + ExplorationRef: created.Exploration.Alias, + Purpose: "p", Conclusions: "c", Unresolved: "u", NextAction: "n", + Items: []CheckpointItemInput{{Type: "candidate", Content: "B"}}, + OperationID: "cp-items-1", + }) + if err != nil { + t.Fatalf("retry checkpoint error = %v", err) + } + if retry.Created || retry.Checkpoint.ID != first.Checkpoint.ID { + t.Fatalf("retry = %#v, want stored first write", retry) + } + if retry.InputDigestMatches { + t.Fatal("retry with different items reported digest match, want mismatch") + } + identical, err := store.AppendExplorationCheckpoint(ctx, root, ExplorationCheckpointOptions{ + ExplorationRef: created.Exploration.Alias, + Purpose: "p", Conclusions: "c", Unresolved: "u", NextAction: "n", + Items: []CheckpointItemInput{{Type: "candidate", Content: "A"}}, + OperationID: "cp-items-1", + }) + if err != nil { + t.Fatalf("identical retry error = %v", err) + } + if !identical.InputDigestMatches { + t.Fatal("identical retry reported digest mismatch, want match") + } +} + +func TestDispositionCannotReferenceAnotherIntentsDeferral(t *testing.T) { + root, _, store := intentTestFixture(t) + projectID := projectIDForTest(t, store, root) + seedIntent(t, store, projectID, "intent:a") + seedIntent(t, store, projectID, "intent:b") + seedDeferral(t, store, projectID, "intent:b", "deferral:b", "op-b") + + if err := execSchemaSQL(t, store, ` +INSERT INTO intent_dispositions (id, project_id, intent_id, seq, disposition, deferral_id, created_at) +VALUES ('disp:cross-intent', ?, 'intent:a', 1, 'deferred', 'deferral:b', '2026-07-19T00:00:00Z') +`, projectID); err == nil || !strings.Contains(err.Error(), "FOREIGN KEY") { + t.Fatalf("cross-intent deferral reference error = %v, want FOREIGN KEY violation", err) + } + if err := execSchemaSQL(t, store, ` +INSERT INTO intent_dispositions (id, project_id, intent_id, seq, disposition, supersedes_deferral_id, created_at) +VALUES ('disp:cross-supersede', ?, 'intent:a', 1, 'tracked', 'deferral:b', '2026-07-19T00:00:00Z') +`, projectID); err == nil || !strings.Contains(err.Error(), "FOREIGN KEY") { + t.Fatalf("cross-intent supersedes reference error = %v, want FOREIGN KEY violation", err) + } +} diff --git a/internal/state/intent_test.go b/internal/state/intent_test.go new file mode 100644 index 000000000..01cace286 --- /dev/null +++ b/internal/state/intent_test.go @@ -0,0 +1,510 @@ +package state + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "sync" + "testing" + + "github.com/levifig/loaf/internal/project" +) + +func intentTestFixture(t *testing.T) (project.Root, PathResolver, *Store) { + t.Helper() + root := projectRoot(t) + stateHome := t.TempDir() + resolver := PathResolver{StateHome: stateHome} + status, err := Initialize(context.Background(), root, resolver) + 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, resolver, store +} + +func intentTableCounts(t *testing.T, store *Store) string { + t.Helper() + counts := []string{} + for _, table := range []string{"intents", "intent_snapshots", "intent_deferrals", "intent_dispositions", "intent_operations", "aliases", "relationships"} { + var count int + if err := store.db.QueryRowContext(context.Background(), `SELECT COUNT(*) FROM `+table).Scan(&count); err != nil { + t.Fatalf("count %s: %v", table, err) + } + counts = append(counts, fmt.Sprintf("%s=%d", table, count)) + } + return strings.Join(counts, " ") +} + +func TestCreateIntentTrackedWritesSnapshotDispositionAndAlias(t *testing.T) { + root, _, store := intentTestFixture(t) + ctx := context.Background() + + result, err := store.CreateIntent(ctx, root, IntentCreateOptions{ + Title: "Track journal-source navigation", + Body: "Origin pointers exist; decide whether navigation is worth building.", + }) + if err != nil { + t.Fatalf("CreateIntent() error = %v", err) + } + if !result.Created || result.Intent.Disposition != "tracked" || result.Intent.SnapshotSeq != 1 || result.Intent.DispositionSeq != 1 { + t.Fatalf("result = %#v, want created tracked seq 1/1", result) + } + if !strings.HasPrefix(result.Intent.Alias, "INTENT-") { + t.Fatalf("alias = %q, want INTENT- prefix", result.Intent.Alias) + } + if result.Intent.Deferral != nil { + t.Fatalf("tracked create carries deferral %#v", result.Intent.Deferral) + } + + show, err := store2Show(t, root, store, result.Intent.Alias) + if err != nil { + t.Fatalf("ShowIntent() error = %v", err) + } + if show.Intent.ID != result.Intent.ID || show.Intent.Disposition != "tracked" || show.Intent.Body != result.Intent.Body { + t.Fatalf("show = %#v, want same intent tracked", show.Intent) + } +} + +// store2Show resolves ShowIntent against the same fixture database. +func store2Show(t *testing.T, root project.Root, store *Store, ref string) (IntentShowResult, error) { + t.Helper() + ctx := context.Background() + projectID, err := store.projectID(ctx, root) + if err != nil { + return IntentShowResult{}, err + } + tx, err := store.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return IntentShowResult{}, err + } + defer tx.Rollback() + intentID, _, err := resolveIntentRefTx(ctx, tx, projectID, ref) + if err != nil { + return IntentShowResult{}, err + } + detail, err := loadIntentDetailTx(ctx, tx, projectID, intentID) + if err != nil { + return IntentShowResult{}, err + } + return IntentShowResult{Query: ref, Intent: detail}, nil +} + +func TestCreateIntentDeferredWritesImmutablePayloadAndMapping(t *testing.T) { + root, _, store := intentTestFixture(t) + ctx := context.Background() + + result, err := store.CreateIntent(ctx, root, IntentCreateOptions{ + Title: "Defer FTS artifact redaction", + Body: "Ingest-time secret exclusion for artifact_search remains unimplemented.", + Disposition: "deferred", + Why: "Not required for the current foundation", + Boundary: "Excluded by the intent-exploration-foundation scope", + Trigger: "Revisit when search work resumes", + OperationID: "defer-fts-redaction", + }) + if err != nil { + t.Fatalf("CreateIntent(deferred) error = %v", err) + } + if !result.Created || result.Intent.Disposition != "deferred" || result.Intent.Deferral == nil { + t.Fatalf("result = %#v, want created deferred with payload", result) + } + if result.Intent.Deferral.Body != result.Intent.Body { + t.Fatalf("deferral body %q != intent body %q", result.Intent.Deferral.Body, result.Intent.Body) + } + + var version int + var journalID, sparkID sql.NullString + if err := store.db.QueryRowContext(ctx, ` +SELECT projection_version, journal_entry_id, spark_id FROM intent_operations +WHERE operation_key = 'defer-fts-redaction' +`).Scan(&version, &journalID, &sparkID); err != nil { + t.Fatalf("read operation mapping: %v", err) + } + if version != 0 || journalID.Valid || sparkID.Valid { + t.Fatalf("mapping = v%d journal=%v spark=%v, want canonical-first v0 with null legacy IDs", version, journalID, sparkID) + } +} + +func TestIntentOperationRetryConvergesAcrossEntryPoints(t *testing.T) { + root, _, store := intentTestFixture(t) + ctx := context.Background() + + first, err := store.CreateIntent(ctx, root, IntentCreateOptions{ + Title: "Converge deferral entry points", + Body: "One canonical operation mapping.", + Disposition: "deferred", + Why: "why", Boundary: "boundary", Trigger: "trigger", + OperationID: "op-converge", + }) + if err != nil { + t.Fatalf("first CreateIntent error = %v", err) + } + + // Identical retry through the same entry point returns the first write. + retry, err := store.CreateIntent(ctx, root, IntentCreateOptions{ + Title: "Converge deferral entry points", + Body: "One canonical operation mapping.", + Disposition: "deferred", + Why: "why", Boundary: "boundary", Trigger: "trigger", + OperationID: "op-converge", + }) + if err != nil { + t.Fatalf("retry CreateIntent error = %v", err) + } + if retry.Created || retry.Intent.ID != first.Intent.ID || !retry.InputDigestMatches { + t.Fatalf("retry = %#v, want reused first intent with matching digest", retry) + } + + // A reworded retry through the OTHER entry point converges on the mapped + // intent and reports the digest mismatch instead of duplicating. + reworded, err := store.DeferIntent(ctx, root, IntentDeferOptions{ + IntentRef: first.Intent.Alias, + Why: "different why", Boundary: "boundary", Trigger: "trigger", + OperationID: "op-converge", + }) + if err != nil { + t.Fatalf("reworded DeferIntent error = %v", err) + } + if reworded.Created || reworded.Intent.ID != first.Intent.ID { + t.Fatalf("reworded = %#v, want reused first intent", reworded) + } + if reworded.InputDigestMatches { + t.Fatal("reworded retry reports digest match, want mismatch") + } + + var intents int + if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM intents`).Scan(&intents); err != nil { + t.Fatalf("count intents: %v", err) + } + if intents != 1 { + t.Fatalf("intents = %d, want exactly one canonical intent", intents) + } +} + +func TestConcurrentIntentDeferralsWithOneKeyConvergeOnFirstWrite(t *testing.T) { + root, _, store := intentTestFixture(t) + ctx := context.Background() + + const writers = 8 + results := make([]IntentMutationResult, writers) + errs := make([]error, writers) + var wg sync.WaitGroup + for i := 0; i < writers; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + results[i], errs[i] = store.CreateIntent(ctx, root, IntentCreateOptions{ + Title: "Concurrent deferral", + Body: fmt.Sprintf("Body wording variant %d.", i), + Disposition: "deferred", + Why: "why", Boundary: "boundary", Trigger: "trigger", + OperationID: "op-concurrent", + }) + }(i) + } + wg.Wait() + + created := 0 + var canonicalID string + for i := 0; i < writers; i++ { + if errs[i] != nil { + t.Fatalf("writer %d error = %v", i, errs[i]) + } + if results[i].Created { + created++ + canonicalID = results[i].Intent.ID + } + } + if created != 1 { + t.Fatalf("created count = %d, want exactly 1 first write", created) + } + for i := 0; i < writers; i++ { + if results[i].Intent.ID != canonicalID { + t.Fatalf("writer %d intent %s, want canonical %s", i, results[i].Intent.ID, canonicalID) + } + } + var intents, deferrals int + if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*), (SELECT COUNT(*) FROM intent_deferrals) FROM intents`).Scan(&intents, &deferrals); err != nil { + t.Fatalf("count rows: %v", err) + } + if intents != 1 || deferrals != 1 { + t.Fatalf("intents=%d deferrals=%d, want 1/1", intents, deferrals) + } +} + +func TestIntentCreateFailureInjectionLeavesNoPartialState(t *testing.T) { + stages := []struct { + name string + hook func(*intentWriteHooks, error) + }{ + {"after intent", func(h *intentWriteHooks, err error) { h.afterIntent = func(*sql.Tx) error { return err } }}, + {"after snapshot", func(h *intentWriteHooks, err error) { h.afterSnapshot = func(*sql.Tx) error { return err } }}, + {"after deferral", func(h *intentWriteHooks, err error) { h.afterDeferral = func(*sql.Tx) error { return err } }}, + {"after disposition", func(h *intentWriteHooks, err error) { h.afterDisposition = func(*sql.Tx) error { return err } }}, + {"after relationships", func(h *intentWriteHooks, err error) { h.afterRelationship = func(*sql.Tx) error { return err } }}, + {"after operation", func(h *intentWriteHooks, err error) { h.afterOperation = func(*sql.Tx) error { return err } }}, + {"before commit", func(h *intentWriteHooks, err error) { h.beforeCommit = func(*sql.Tx) error { return err } }}, + } + for _, stage := range stages { + t.Run(strings.ReplaceAll(stage.name, " ", "-"), func(t *testing.T) { + root, _, store := intentTestFixture(t) + ctx := context.Background() + spark, err := store.CaptureSpark(ctx, root, SparkCaptureOptions{Text: "seed spark"}) + if err != nil { + t.Fatalf("CaptureSpark() error = %v", err) + } + before := intentTableCounts(t, store) + + hooks := &intentWriteHooks{} + stage.hook(hooks, errors.New("injected failure")) + _, err = store.createIntentWithHooks(ctx, root, IntentCreateOptions{ + Title: "Failure injection", + Body: "Body.", + Disposition: "deferred", + Why: "why", Boundary: "boundary", Trigger: "trigger", + OperationID: "op-failure-" + stage.name, + Sources: []string{spark.Spark.Alias}, + }, hooks) + if err == nil { + t.Fatalf("stage %s: error = nil, want injected failure", stage.name) + } + var stageErr *IntentTransactionError + if !errors.As(err, &stageErr) || stageErr.Stage != stage.name { + t.Fatalf("stage %s: error = %v, want IntentTransactionError at that stage", stage.name, err) + } + after := intentTableCounts(t, store) + if before != after { + t.Fatalf("stage %s left partial state:\nbefore: %s\nafter: %s", stage.name, before, after) + } + }) + } +} + +func TestIntentDeferFailureInjectionLeavesNoPartialState(t *testing.T) { + root, _, store := intentTestFixture(t) + ctx := context.Background() + created, err := store.CreateIntent(ctx, root, IntentCreateOptions{Title: "Defer target", Body: "Body."}) + if err != nil { + t.Fatalf("CreateIntent() error = %v", err) + } + for _, stage := range []string{"after deferral", "after disposition", "after operation", "before commit"} { + t.Run(strings.ReplaceAll(stage, " ", "-"), func(t *testing.T) { + before := intentTableCounts(t, store) + hooks := &intentWriteHooks{} + injected := errors.New("injected failure") + switch stage { + case "after deferral": + hooks.afterDeferral = func(*sql.Tx) error { return injected } + case "after disposition": + hooks.afterDisposition = func(*sql.Tx) error { return injected } + case "after operation": + hooks.afterOperation = func(*sql.Tx) error { return injected } + case "before commit": + hooks.beforeCommit = func(*sql.Tx) error { return injected } + } + _, err := store.deferIntentWithHooks(ctx, root, IntentDeferOptions{ + IntentRef: created.Intent.Alias, + Why: "why", Boundary: "boundary", Trigger: "trigger", + OperationID: "op-defer-fail-" + stage, + }, hooks) + if err == nil { + t.Fatalf("stage %s: error = nil, want injected failure", stage) + } + if after := intentTableCounts(t, store); after != before { + t.Fatalf("stage %s left partial state:\nbefore: %s\nafter: %s", stage, before, after) + } + }) + } +} + +func TestIntentResumePreservesDeferralAndDerivesTracked(t *testing.T) { + root, resolver, store := intentTestFixture(t) + ctx := context.Background() + created, err := store.CreateIntent(ctx, root, IntentCreateOptions{ + Title: "Resumable direction", + Body: "Body.", + Disposition: "deferred", + Why: "why", Boundary: "boundary", Trigger: "trigger", + OperationID: "op-resume", + }) + if err != nil { + t.Fatalf("CreateIntent(deferred) error = %v", err) + } + + resumed, err := ResumeIntent(ctx, root, resolver, IntentDispositionOptions{ + IntentRef: created.Intent.Alias, + Reason: "capacity is available now", + }) + if err != nil { + t.Fatalf("ResumeIntent() error = %v", err) + } + if resumed.Intent.Disposition != "tracked" || resumed.Intent.DispositionSeq != 2 { + t.Fatalf("resumed = %#v, want tracked seq 2", resumed.Intent) + } + + // The immutable deferral payload and the deferred disposition history + // survive; the resume links the deferral it supersedes. + var payloadCount int + if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM intent_deferrals WHERE intent_id = ?`, created.Intent.ID).Scan(&payloadCount); err != nil { + t.Fatalf("count deferrals: %v", err) + } + if payloadCount != 1 { + t.Fatalf("deferral payloads = %d, want preserved 1", payloadCount) + } + var supersedes string + if err := store.db.QueryRowContext(ctx, ` +SELECT supersedes_deferral_id FROM intent_dispositions WHERE intent_id = ? AND seq = 2 +`, created.Intent.ID).Scan(&supersedes); err != nil { + t.Fatalf("read resume disposition: %v", err) + } + if supersedes != created.Intent.Deferral.ID { + t.Fatalf("supersedes = %q, want %q", supersedes, created.Intent.Deferral.ID) + } + + // Resuming a non-deferred intent is rejected. + if _, err := ResumeIntent(ctx, root, resolver, IntentDispositionOptions{IntentRef: created.Intent.Alias, Reason: "again"}); err == nil { + t.Fatal("second resume succeeded, want rejection of non-deferred intent") + } + + resolved, err := ResolveIntent(ctx, root, resolver, IntentDispositionOptions{ + IntentRef: created.Intent.Alias, + Reason: "shipped in the foundation change", + }) + if err != nil { + t.Fatalf("ResolveIntent() error = %v", err) + } + if resolved.Intent.Disposition != "resolved" || resolved.Intent.DispositionSeq != 3 { + t.Fatalf("resolved = %#v, want resolved seq 3", resolved.Intent) + } + var history int + if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM intent_dispositions WHERE intent_id = ?`, created.Intent.ID).Scan(&history); err != nil { + t.Fatalf("count dispositions: %v", err) + } + if history != 3 { + t.Fatalf("disposition history = %d rows, want all 3 preserved", history) + } +} + +func TestIntentCreateValidatesSourcesAndInput(t *testing.T) { + root, _, store := intentTestFixture(t) + ctx := context.Background() + + // A task is not a supported source-of pairing for intent. + taskResult, err := store.CreateTask(ctx, root, TaskCreateOptions{Title: "unsupported source"}) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + if _, err := store.CreateIntent(ctx, root, IntentCreateOptions{ + Title: "Invalid source", + Body: "Body.", + Sources: []string{taskResult.Task.Alias}, + }); err == nil || !strings.Contains(err.Error(), "not in the supported matrix") { + t.Fatalf("task source error = %v, want unsupported matrix rejection", err) + } + + // Unknown sources fail visibly. + if _, err := store.CreateIntent(ctx, root, IntentCreateOptions{ + Title: "Dangling source", + Body: "Body.", + Sources: []string{"SPARK-does-not-exist"}, + }); err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("dangling source error = %v, want not-found rejection", err) + } + + // Control characters and oversize fields are rejected without writes. + if _, err := store.CreateIntent(ctx, root, IntentCreateOptions{Title: "bad\x00title", Body: "Body."}); err == nil { + t.Fatal("control-character title accepted") + } + if _, err := store.CreateIntent(ctx, root, IntentCreateOptions{Title: "ok", Body: strings.Repeat("x", intentFieldMaxBytes+1)}); err == nil { + t.Fatal("oversize body accepted") + } + if _, err := store.CreateIntent(ctx, root, IntentCreateOptions{Title: "ok", Body: "Body.", Disposition: "paused"}); err == nil { + t.Fatal("unknown disposition accepted") + } + + var intents int + if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM intents`).Scan(&intents); err != nil { + t.Fatalf("count intents: %v", err) + } + if intents != 0 { + t.Fatalf("intents = %d after rejected writes, want 0", intents) + } +} + +func TestIntentSourcesRecordSupportedPairings(t *testing.T) { + root, _, store := intentTestFixture(t) + ctx := context.Background() + spark, err := store.CaptureSpark(ctx, root, SparkCaptureOptions{Text: "spark source"}) + if err != nil { + t.Fatalf("CaptureSpark() error = %v", err) + } + + result, err := store.CreateIntent(ctx, root, IntentCreateOptions{ + Title: "Sourced intent", + Body: "Body.", + Sources: []string{spark.Spark.Alias}, + }) + if err != nil { + t.Fatalf("CreateIntent() error = %v", err) + } + if len(result.Intent.Sources) != 1 || result.Intent.Sources[0].Entity.Kind != "spark" || result.Intent.Sources[0].Type != "source-of" { + t.Fatalf("sources = %#v, want one spark source-of edge", result.Intent.Sources) + } + var count int + if err := store.db.QueryRowContext(ctx, ` +SELECT COUNT(*) FROM relationships +WHERE from_entity_kind = 'spark' AND to_entity_kind = 'intent' AND relationship_type = 'source-of' +`).Scan(&count); err != nil { + t.Fatalf("count relationships: %v", err) + } + if count != 1 { + t.Fatalf("relationship rows = %d, want 1", count) + } +} + +func TestListIntentsIsDeterministicAndFilters(t *testing.T) { + root, resolver, store := intentTestFixture(t) + ctx := context.Background() + if _, err := store.CreateIntent(ctx, root, IntentCreateOptions{Title: "Alpha", Body: "Body."}); err != nil { + t.Fatalf("create alpha: %v", err) + } + if _, err := store.CreateIntent(ctx, root, IntentCreateOptions{ + Title: "Beta", Body: "Body.", Disposition: "deferred", + Why: "why", Boundary: "boundary", Trigger: "trigger", OperationID: "op-beta", + }); err != nil { + t.Fatalf("create beta: %v", err) + } + + all, err := ListIntents(ctx, root, resolver, "") + if err != nil { + t.Fatalf("ListIntents() error = %v", err) + } + if len(all.Intents) != 2 { + t.Fatalf("intents = %d, want 2", len(all.Intents)) + } + deferred, err := ListIntents(ctx, root, resolver, "deferred") + if err != nil { + t.Fatalf("ListIntents(deferred) error = %v", err) + } + if len(deferred.Intents) != 1 || deferred.Intents[0].Title != "Beta" { + t.Fatalf("deferred list = %#v, want only Beta", deferred.Intents) + } + if _, err := ListIntents(ctx, root, resolver, "paused"); err == nil { + t.Fatal("unknown disposition filter accepted") + } + + again, err := ListIntents(ctx, root, resolver, "") + if err != nil { + t.Fatalf("ListIntents() again error = %v", err) + } + if fmt.Sprintf("%#v", again.Intents) != fmt.Sprintf("%#v", all.Intents) { + t.Fatal("list projection is not deterministic for equal state") + } +} diff --git a/internal/state/intents_explorations_schema_test.go b/internal/state/intents_explorations_schema_test.go new file mode 100644 index 000000000..0aa49ed0b --- /dev/null +++ b/internal/state/intents_explorations_schema_test.go @@ -0,0 +1,365 @@ +package state + +import ( + "context" + "strings" + "testing" +) + +// intentSchemaFixture initializes an isolated store and returns it with the +// registered project ID. Raw SQL is used deliberately: these tests falsify the +// migration 0012 constraints themselves, beneath any Go write path. +func intentSchemaFixture(t *testing.T) (*Store, string) { + t.Helper() + root := projectRoot(t) + stateHome := t.TempDir() + status, err := Initialize(context.Background(), root, PathResolver{StateHome: stateHome}) + 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 store, projectIDForTest(t, store, root) +} + +func execSchemaSQL(t *testing.T, store *Store, query string, args ...any) error { + t.Helper() + _, err := store.db.ExecContext(context.Background(), query, args...) + return err +} + +func mustExecSchemaSQL(t *testing.T, store *Store, query string, args ...any) { + t.Helper() + if err := execSchemaSQL(t, store, query, args...); err != nil { + compact := strings.Join(strings.Fields(query), " ") + if len(compact) > 60 { + compact = compact[:60] + } + t.Fatalf("exec %q: %v", compact, err) + } +} + +const testDigest = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +func seedIntent(t *testing.T, store *Store, projectID, intentID string) { + t.Helper() + mustExecSchemaSQL(t, store, `INSERT INTO intents (id, project_id, created_at) VALUES (?, ?, '2026-07-19T00:00:00Z')`, intentID, projectID) +} + +func seedDeferral(t *testing.T, store *Store, projectID, intentID, deferralID, operationKey string) { + t.Helper() + mustExecSchemaSQL(t, store, ` +INSERT INTO intent_deferrals (id, project_id, intent_id, operation_key, body, why, boundary, revisit_trigger, stored_digest, created_at) +VALUES (?, ?, ?, ?, 'body', 'why', 'boundary', 'trigger', ?, '2026-07-19T00:00:00Z') +`, deferralID, projectID, intentID, operationKey, testDigest) +} + +func TestIntentSequenceUniquenessRejectsConcurrentDuplicates(t *testing.T) { + store, projectID := intentSchemaFixture(t) + seedIntent(t, store, projectID, "intent:a") + + mustExecSchemaSQL(t, store, ` +INSERT INTO intent_dispositions (id, project_id, intent_id, seq, disposition, created_at) +VALUES ('disp:1', ?, 'intent:a', 1, 'tracked', '2026-07-19T00:00:00Z') +`, projectID) + err := execSchemaSQL(t, store, ` +INSERT INTO intent_dispositions (id, project_id, intent_id, seq, disposition, created_at) +VALUES ('disp:2', ?, 'intent:a', 1, 'resolved', '2026-07-19T00:00:00Z') +`, projectID) + if err == nil || !strings.Contains(err.Error(), "UNIQUE") { + t.Fatalf("duplicate (intent_id, seq) error = %v, want UNIQUE violation", err) + } + + mustExecSchemaSQL(t, store, ` +INSERT INTO intent_snapshots (id, project_id, intent_id, seq, title, body, content_digest, created_at) +VALUES ('snap:1', ?, 'intent:a', 1, 'title', 'body', ?, '2026-07-19T00:00:00Z') +`, projectID, testDigest) + err = execSchemaSQL(t, store, ` +INSERT INTO intent_snapshots (id, project_id, intent_id, seq, title, body, content_digest, created_at) +VALUES ('snap:2', ?, 'intent:a', 1, 'other', 'body', ?, '2026-07-19T00:00:00Z') +`, projectID, testDigest) + if err == nil || !strings.Contains(err.Error(), "UNIQUE") { + t.Fatalf("duplicate snapshot (intent_id, seq) error = %v, want UNIQUE violation", err) + } +} + +func TestIntentDispositionVocabularyAndDeferralPairingAreEnforced(t *testing.T) { + store, projectID := intentSchemaFixture(t) + seedIntent(t, store, projectID, "intent:a") + seedDeferral(t, store, projectID, "intent:a", "deferral:1", "op-1") + + if err := execSchemaSQL(t, store, ` +INSERT INTO intent_dispositions (id, project_id, intent_id, seq, disposition, created_at) +VALUES ('disp:bad-vocab', ?, 'intent:a', 1, 'paused', '2026-07-19T00:00:00Z') +`, projectID); err == nil || !strings.Contains(err.Error(), "CHECK") { + t.Fatalf("unknown disposition error = %v, want CHECK violation", err) + } + if err := execSchemaSQL(t, store, ` +INSERT INTO intent_dispositions (id, project_id, intent_id, seq, disposition, created_at) +VALUES ('disp:bad-deferred', ?, 'intent:a', 1, 'deferred', '2026-07-19T00:00:00Z') +`, projectID); err == nil || !strings.Contains(err.Error(), "CHECK") { + t.Fatalf("deferred without deferral_id error = %v, want CHECK violation", err) + } + if err := execSchemaSQL(t, store, ` +INSERT INTO intent_dispositions (id, project_id, intent_id, seq, disposition, deferral_id, created_at) +VALUES ('disp:bad-tracked', ?, 'intent:a', 1, 'tracked', 'deferral:1', '2026-07-19T00:00:00Z') +`, projectID); err == nil || !strings.Contains(err.Error(), "CHECK") { + t.Fatalf("tracked with deferral_id error = %v, want CHECK violation", err) + } + if err := execSchemaSQL(t, store, ` +INSERT INTO intent_dispositions (id, project_id, intent_id, seq, disposition, supersedes_deferral_id, created_at) +VALUES ('disp:bad-supersede', ?, 'intent:a', 1, 'resolved', 'deferral:1', '2026-07-19T00:00:00Z') +`, projectID); err == nil || !strings.Contains(err.Error(), "CHECK") { + t.Fatalf("resolved with supersedes_deferral_id error = %v, want CHECK violation", err) + } + mustExecSchemaSQL(t, store, ` +INSERT INTO intent_dispositions (id, project_id, intent_id, seq, disposition, deferral_id, created_at) +VALUES ('disp:good-deferred', ?, 'intent:a', 1, 'deferred', 'deferral:1', '2026-07-19T00:00:00Z') +`, projectID) + mustExecSchemaSQL(t, store, ` +INSERT INTO intent_dispositions (id, project_id, intent_id, seq, disposition, reason, supersedes_deferral_id, created_at) +VALUES ('disp:good-resume', ?, 'intent:a', 2, 'tracked', 'resumed', 'deferral:1', '2026-07-19T00:00:00Z') +`, projectID) +} + +func TestIntentOperationsEnforceKeyUniquenessAndProjectionPairing(t *testing.T) { + store, projectID := intentSchemaFixture(t) + seedIntent(t, store, projectID, "intent:a") + + mustExecSchemaSQL(t, store, ` +INSERT INTO intent_operations (project_id, operation_key, intent_id, stored_digest, projection_version, created_at, updated_at) +VALUES (?, 'op-1', 'intent:a', ?, 0, '2026-07-19T00:00:00Z', '2026-07-19T00:00:00Z') +`, projectID, testDigest) + if err := execSchemaSQL(t, store, ` +INSERT INTO intent_operations (project_id, operation_key, intent_id, stored_digest, projection_version, created_at, updated_at) +VALUES (?, 'op-1', 'intent:a', ?, 0, '2026-07-19T00:00:00Z', '2026-07-19T00:00:00Z') +`, projectID, testDigest); err == nil { + t.Fatal("duplicate (project_id, operation_key) insert succeeded, want primary key violation") + } + if err := execSchemaSQL(t, store, ` +INSERT INTO intent_operations (project_id, operation_key, intent_id, stored_digest, journal_entry_id, spark_id, projection_version, created_at, updated_at) +VALUES (?, 'op-bad-v0', 'intent:a', ?, 'journal:x', 'spark:x', 0, '2026-07-19T00:00:00Z', '2026-07-19T00:00:00Z') +`, projectID, testDigest); err == nil || !strings.Contains(err.Error(), "CHECK") { + t.Fatalf("projection version 0 with legacy IDs error = %v, want CHECK violation", err) + } + if err := execSchemaSQL(t, store, ` +INSERT INTO intent_operations (project_id, operation_key, intent_id, stored_digest, projection_version, created_at, updated_at) +VALUES (?, 'op-bad-v1', 'intent:a', ?, 1, '2026-07-19T00:00:00Z', '2026-07-19T00:00:00Z') +`, projectID, testDigest); err == nil || !strings.Contains(err.Error(), "CHECK") { + t.Fatalf("projection version 1 without legacy IDs error = %v, want CHECK violation", err) + } + if err := execSchemaSQL(t, store, ` +INSERT INTO intent_operations (project_id, operation_key, intent_id, stored_digest, projection_version, created_at, updated_at) +VALUES (?, 'op-dangling', 'intent:missing', ?, 0, '2026-07-19T00:00:00Z', '2026-07-19T00:00:00Z') +`, projectID, testDigest); err == nil || !strings.Contains(err.Error(), "FOREIGN KEY") { + t.Fatalf("dangling intent reference error = %v, want FOREIGN KEY violation", err) + } +} + +func TestIntentDeferralOperationKeyIsProjectScoped(t *testing.T) { + store, projectID := intentSchemaFixture(t) + seedIntent(t, store, projectID, "intent:a") + seedIntent(t, store, projectID, "intent:b") + seedDeferral(t, store, projectID, "intent:a", "deferral:1", "op-1") + if err := execSchemaSQL(t, store, ` +INSERT INTO intent_deferrals (id, project_id, intent_id, operation_key, body, why, boundary, revisit_trigger, stored_digest, created_at) +VALUES ('deferral:2', ?, 'intent:b', 'op-1', 'body', 'why', 'boundary', 'trigger', ?, '2026-07-19T00:00:00Z') +`, projectID, testDigest); err == nil || !strings.Contains(err.Error(), "UNIQUE") { + t.Fatalf("duplicate deferral operation key error = %v, want UNIQUE violation", err) + } + if err := execSchemaSQL(t, store, ` +INSERT INTO intent_deferrals (id, project_id, intent_id, operation_key, body, why, boundary, revisit_trigger, stored_digest, created_at) +VALUES ('deferral:blank', ?, 'intent:a', 'op-2', ' ', 'why', 'boundary', 'trigger', ?, '2026-07-19T00:00:00Z') +`, projectID, testDigest); err == nil || !strings.Contains(err.Error(), "CHECK") { + t.Fatalf("blank deferral body error = %v, want CHECK violation", err) + } +} + +func TestExplorationCheckpointConstraints(t *testing.T) { + store, projectID := intentSchemaFixture(t) + mustExecSchemaSQL(t, store, `INSERT INTO explorations (id, project_id, title, created_at) VALUES ('expl:a', ?, 'inquiry', '2026-07-19T00:00:00Z')`, projectID) + + mustExecSchemaSQL(t, store, ` +INSERT INTO exploration_checkpoints (id, project_id, exploration_id, seq, purpose, conclusions, unresolved, next_action, operation_key, content_digest, created_at) +VALUES ('cp:1', ?, 'expl:a', 1, 'p', 'c', 'u', 'n', 'op-cp-1', ?, '2026-07-19T00:00:00Z') +`, projectID, testDigest) + if err := execSchemaSQL(t, store, ` +INSERT INTO exploration_checkpoints (id, project_id, exploration_id, seq, purpose, conclusions, unresolved, next_action, content_digest, created_at) +VALUES ('cp:dup-seq', ?, 'expl:a', 1, 'p', 'c', 'u', 'n', ?, '2026-07-19T00:00:00Z') +`, projectID, testDigest); err == nil || !strings.Contains(err.Error(), "UNIQUE") { + t.Fatalf("duplicate checkpoint seq error = %v, want UNIQUE violation", err) + } + if err := execSchemaSQL(t, store, ` +INSERT INTO exploration_checkpoints (id, project_id, exploration_id, seq, purpose, conclusions, unresolved, next_action, operation_key, content_digest, created_at) +VALUES ('cp:dup-op', ?, 'expl:a', 2, 'p', 'c', 'u', 'n', 'op-cp-1', ?, '2026-07-19T00:00:00Z') +`, projectID, testDigest); err == nil || !strings.Contains(err.Error(), "UNIQUE") { + t.Fatalf("duplicate checkpoint operation key error = %v, want UNIQUE violation", err) + } + // NULL operation keys never collide with one another. + mustExecSchemaSQL(t, store, ` +INSERT INTO exploration_checkpoints (id, project_id, exploration_id, seq, purpose, conclusions, unresolved, next_action, content_digest, created_at) +VALUES ('cp:2', ?, 'expl:a', 2, 'p', 'c', 'u', 'n', ?, '2026-07-19T00:00:00Z') +`, projectID, testDigest) + mustExecSchemaSQL(t, store, ` +INSERT INTO exploration_checkpoints (id, project_id, exploration_id, seq, purpose, conclusions, unresolved, next_action, content_digest, created_at) +VALUES ('cp:3', ?, 'expl:a', 3, 'p', 'c', 'u', 'n', ?, '2026-07-19T00:00:00Z') +`, projectID, testDigest) + if err := execSchemaSQL(t, store, ` +INSERT INTO exploration_checkpoints (id, project_id, exploration_id, seq, purpose, conclusions, unresolved, next_action, content_digest, created_at) +VALUES ('cp:blank', ?, 'expl:a', 4, 'p', 'c', 'u', ' ', ?, '2026-07-19T00:00:00Z') +`, projectID, testDigest); err == nil || !strings.Contains(err.Error(), "CHECK") { + t.Fatalf("blank next_action error = %v, want CHECK violation", err) + } + + mustExecSchemaSQL(t, store, ` +INSERT INTO exploration_checkpoint_items (id, project_id, checkpoint_id, item_type, position, content, created_at) +VALUES ('item:1', ?, 'cp:1', 'candidate', 1, 'first', '2026-07-19T00:00:00Z') +`, projectID) + if err := execSchemaSQL(t, store, ` +INSERT INTO exploration_checkpoint_items (id, project_id, checkpoint_id, item_type, position, content, created_at) +VALUES ('item:dup', ?, 'cp:1', 'evidence', 1, 'second', '2026-07-19T00:00:00Z') +`, projectID); err == nil || !strings.Contains(err.Error(), "UNIQUE") { + t.Fatalf("duplicate item position error = %v, want UNIQUE violation", err) + } +} + +func TestConversationIdentityConstraints(t *testing.T) { + store, projectID := intentSchemaFixture(t) + mustExecSchemaSQL(t, store, `INSERT INTO logical_conversations (id, project_id, title, operation_key, created_at) VALUES ('conv:a', ?, 'thread', 'op-conv-1', '2026-07-19T00:00:00Z')`, projectID) + if err := execSchemaSQL(t, store, `INSERT INTO logical_conversations (id, project_id, title, operation_key, created_at) VALUES ('conv:dup', ?, 'other', 'op-conv-1', '2026-07-19T00:00:00Z')`, projectID); err == nil || !strings.Contains(err.Error(), "UNIQUE") { + t.Fatalf("duplicate conversation operation key error = %v, want UNIQUE violation", err) + } + + mustExecSchemaSQL(t, store, ` +INSERT INTO conversation_handles (id, project_id, conversation_id, harness, handle, created_at) +VALUES ('handle:1', ?, 'conv:a', 'codex', 'opaque-1', '2026-07-19T00:00:00Z') +`, projectID) + // NULL locality must still deduplicate through the COALESCE identity index. + if err := execSchemaSQL(t, store, ` +INSERT INTO conversation_handles (id, project_id, conversation_id, harness, handle, created_at) +VALUES ('handle:dup', ?, 'conv:a', 'codex', 'opaque-1', '2026-07-19T00:00:00Z') +`, projectID); err == nil || !strings.Contains(err.Error(), "UNIQUE") { + t.Fatalf("duplicate handle with NULL locality error = %v, want UNIQUE violation", err) + } + mustExecSchemaSQL(t, store, ` +INSERT INTO conversation_handles (id, project_id, conversation_id, harness, handle, locality, created_at) +VALUES ('handle:2', ?, 'conv:a', 'codex', 'opaque-1', 'machine-b', '2026-07-19T00:00:00Z') +`, projectID) + + mustExecSchemaSQL(t, store, ` +INSERT INTO conversation_log_refs (id, project_id, handle_id, locator, created_at) +VALUES ('log:1', ?, 'handle:1', '/logs/session.jsonl', '2026-07-19T00:00:00Z') +`, projectID) + if err := execSchemaSQL(t, store, ` +INSERT INTO conversation_log_refs (id, project_id, handle_id, locator, created_at) +VALUES ('log:dup', ?, 'handle:1', '/logs/session.jsonl', '2026-07-19T00:00:00Z') +`, projectID); err == nil || !strings.Contains(err.Error(), "UNIQUE") { + t.Fatalf("duplicate log ref error = %v, want UNIQUE violation", err) + } + if err := execSchemaSQL(t, store, ` +INSERT INTO conversation_log_refs (id, project_id, handle_id, locator, content_hash, created_at) +VALUES ('log:badhash', ?, 'handle:1', '/logs/other.jsonl', 'nothex', '2026-07-19T00:00:00Z') +`, projectID); err == nil || !strings.Contains(err.Error(), "CHECK") { + t.Fatalf("invalid content hash error = %v, want CHECK violation", err) + } + + if err := execSchemaSQL(t, store, ` +INSERT INTO source_availability_observations (id, project_id, subject_kind, subject_id, observed_at, available, created_at) +VALUES ('obs:bad', ?, 'session', 'handle:1', '2026-07-19T00:00:00Z', 1, '2026-07-19T00:00:00Z') +`, projectID); err == nil || !strings.Contains(err.Error(), "CHECK") { + t.Fatalf("unknown observation subject kind error = %v, want CHECK violation", err) + } + mustExecSchemaSQL(t, store, ` +INSERT INTO source_availability_observations (id, project_id, subject_kind, subject_id, observed_at, observer, locality, available, created_at) +VALUES ('obs:1', ?, 'conversation_handle', 'handle:1', '2026-07-19T00:00:00Z', 'probe', 'machine-a', 0, '2026-07-19T00:00:00Z') +`, projectID) +} + +// TestCrossProjectReferencesFailBeforeCommit proves the same-project pairing +// acceptance at the schema level: composite (project_id, ref) foreign keys +// reject a row in one project that references an aggregate owned by another. +func TestCrossProjectReferencesFailBeforeCommit(t *testing.T) { + rootA := projectRoot(t) + rootB := projectRoot(t) + stateHome := t.TempDir() + resolver := PathResolver{StateHome: stateHome} + status, err := Initialize(context.Background(), rootA, resolver) + if err != nil { + t.Fatalf("Initialize(rootA) error = %v", err) + } + if _, err := Initialize(context.Background(), rootB, resolver); err != nil { + t.Fatalf("Initialize(rootB) error = %v", err) + } + store, err := OpenStore(status.DatabasePath) + if err != nil { + t.Fatalf("OpenStore() error = %v", err) + } + defer store.Close() + projectA := projectIDForTest(t, store, rootA) + projectB := projectIDForTest(t, store, rootB) + if projectA == projectB { + t.Fatal("fixture projects must be distinct") + } + + seedIntent(t, store, projectA, "intent:a") + if err := execSchemaSQL(t, store, ` +INSERT INTO intent_deferrals (id, project_id, intent_id, operation_key, body, why, boundary, revisit_trigger, stored_digest, created_at) +VALUES ('deferral:cross', ?, 'intent:a', 'op-cross', 'body', 'why', 'boundary', 'trigger', ?, '2026-07-19T00:00:00Z') +`, projectB, testDigest); err == nil || !strings.Contains(err.Error(), "FOREIGN KEY") { + t.Fatalf("cross-project deferral error = %v, want FOREIGN KEY violation", err) + } + if err := execSchemaSQL(t, store, ` +INSERT INTO intent_dispositions (id, project_id, intent_id, seq, disposition, created_at) +VALUES ('disp:cross', ?, 'intent:a', 1, 'tracked', '2026-07-19T00:00:00Z') +`, projectB); err == nil || !strings.Contains(err.Error(), "FOREIGN KEY") { + t.Fatalf("cross-project disposition error = %v, want FOREIGN KEY violation", err) + } + + mustExecSchemaSQL(t, store, `INSERT INTO explorations (id, project_id, title, created_at) VALUES ('expl:a', ?, 'inquiry', '2026-07-19T00:00:00Z')`, projectA) + mustExecSchemaSQL(t, store, `INSERT INTO logical_conversations (id, project_id, title, created_at) VALUES ('conv:b', ?, 'thread', '2026-07-19T00:00:00Z')`, projectB) + if err := execSchemaSQL(t, store, ` +INSERT INTO exploration_conversations (id, project_id, exploration_id, conversation_id, created_at) +VALUES ('membership:cross', ?, 'expl:a', 'conv:b', '2026-07-19T00:00:00Z') +`, projectA); err == nil || !strings.Contains(err.Error(), "FOREIGN KEY") { + t.Fatalf("cross-project membership error = %v, want FOREIGN KEY violation", err) + } +} + +// TestIntentExplorationSchemaHasNoLifecycleState is the schema-inspection gate +// from the Change Verification Contract: the new tables must never grow a +// mutable status column or a current-session/current-exploration pointer. +func TestIntentExplorationSchemaHasNoLifecycleState(t *testing.T) { + migration := SchemaMigrations()[len(SchemaMigrations())-1] + if migration.Name != "intents_and_explorations" { + t.Fatalf("last migration = %q, want intents_and_explorations", migration.Name) + } + for _, table := range []string{ + "intents", "intent_snapshots", "intent_deferrals", "intent_dispositions", + "intent_operations", "explorations", "exploration_checkpoints", + "exploration_checkpoint_items", "logical_conversations", + "conversation_handles", "conversation_log_refs", + "exploration_conversations", "journal_conversation_handles", + "source_availability_observations", + } { + body := tableBody(t, migration.SQL, table) + for _, line := range strings.Split(body, "\n") { + trimmed := strings.TrimSpace(line) + fields := strings.Fields(trimmed) + if len(fields) == 0 { + continue + } + column := strings.ToLower(fields[0]) + switch { + case column == "status" || strings.HasSuffix(column, "_status"): + t.Fatalf("%s declares lifecycle column %q", table, column) + case strings.HasPrefix(column, "current_") || column == "active" || strings.HasSuffix(column, "_active"): + t.Fatalf("%s declares current-pointer column %q", table, column) + case column == "updated_at" && table != "intent_operations": + t.Fatalf("%s declares updated_at on an append-only fact table", table) + } + } + } +} diff --git a/internal/state/journal_context.go b/internal/state/journal_context.go index be082deca..df5189d83 100644 --- a/internal/state/journal_context.go +++ b/internal/state/journal_context.go @@ -16,20 +16,22 @@ import ( const ( JournalContextContractVersion = 2 - JournalContextLayerSynthesis = "project_synthesis" - JournalContextLayerCheckpoint = "latest_checkpoint" - JournalContextLayerLineage = "active_lineage" - JournalContextLayerBlockers = "unresolved_blockers" - JournalContextLayerDeferrals = "deferred_intent" - JournalContextLayerBranch = "branch_recency" - JournalContextLayerTasks = "transitional_tasks" - - defaultJournalContextLineageLimit = 10 - defaultJournalContextBlockerLimit = 10 - defaultJournalContextDeferralLimit = 10 - defaultJournalContextBranchLimit = 10 - defaultJournalContextTaskLimit = 10 - journalContextSynthesisLimit = 1 + JournalContextLayerSynthesis = "project_synthesis" + JournalContextLayerCheckpoint = "latest_checkpoint" + JournalContextLayerLineage = "active_lineage" + JournalContextLayerBlockers = "unresolved_blockers" + JournalContextLayerDeferrals = "deferred_intent" + JournalContextLayerCheckpoints = "exploration_checkpoints" + JournalContextLayerBranch = "branch_recency" + JournalContextLayerTasks = "transitional_tasks" + + defaultJournalContextLineageLimit = 10 + defaultJournalContextBlockerLimit = 10 + defaultJournalContextDeferralLimit = 10 + defaultJournalContextCheckpointsLimit = 5 + defaultJournalContextBranchLimit = 10 + defaultJournalContextTaskLimit = 10 + journalContextSynthesisLimit = 1 ) // JournalContextOptions describes a continuity digest request. Limits are @@ -37,15 +39,16 @@ const ( // when supplied, prevents a cursor generated for one layer being used as // another layer's expansion token. type JournalContextOptions struct { - Branch string - LineageKeys []string - LineageLimit int - BlockerLimit int - DeferralLimit int - BranchLimit int - TaskLimit int - Cursor string - CursorLayer string + Branch string + LineageKeys []string + LineageLimit int + BlockerLimit int + DeferralLimit int + CheckpointsLimit int + BranchLimit int + TaskLimit int + Cursor string + CursorLayer string } // JournalContext is the contract-v2 active-truth read model. Every layer is @@ -60,14 +63,15 @@ type JournalContext struct { Branch string `json:"branch,omitempty"` JournalWatermark int64 `json:"journal_watermark"` - ProjectSynthesis JournalContextJournalLayer `json:"project_synthesis"` - LatestCheckpoint JournalContextCheckpointLayer `json:"latest_checkpoint"` - ActiveLineage JournalContextJournalLayer `json:"active_lineage"` - UnresolvedBlockers JournalContextBlockerLayer `json:"unresolved_blockers"` - DeferredIntent JournalContextDeferralLayer `json:"deferred_intent"` - BranchRecency JournalContextJournalLayer `json:"branch_recency"` - TransitionalTasks JournalContextTaskLayer `json:"transitional_tasks"` - Diagnostics []JournalContextDiagnostic `json:"diagnostics"` + ProjectSynthesis JournalContextJournalLayer `json:"project_synthesis"` + LatestCheckpoint JournalContextCheckpointLayer `json:"latest_checkpoint"` + ActiveLineage JournalContextJournalLayer `json:"active_lineage"` + UnresolvedBlockers JournalContextBlockerLayer `json:"unresolved_blockers"` + DeferredIntent JournalContextDeferralLayer `json:"deferred_intent"` + ExplorationCheckpoints JournalContextCheckpointsLayer `json:"exploration_checkpoints"` + BranchRecency JournalContextJournalLayer `json:"branch_recency"` + TransitionalTasks JournalContextTaskLayer `json:"transitional_tasks"` + Diagnostics []JournalContextDiagnostic `json:"diagnostics"` // Deprecated compatibility fields. The v2 CLI should consume the named // layers above; these keep existing callers source-compatible during U6. @@ -130,10 +134,26 @@ type JournalContextDeferralLayer struct { } type JournalContextDeferralItem struct { - OperationKey string `json:"operation_key"` - Decision JournalEntryRecord `json:"decision"` - Spark JournalContextDeferredSpark `json:"spark"` - Origin *JournalContextOriginSummary `json:"origin,omitempty"` + OperationKey string `json:"operation_key"` + Decision JournalEntryRecord `json:"decision"` + Spark JournalContextDeferredSpark `json:"spark"` + Origin *JournalContextOriginSummary `json:"origin,omitempty"` + Intent *JournalContextDeferredIntent `json:"intent,omitempty"` +} + +// JournalContextDeferredIntent is the canonical Intent behind a deferred item. +// When present it is active truth: the item surfaces regardless of journal +// recency, and the legacy Decision/Spark fields carry mapped v1 projections +// only when those historical rows exist. +type JournalContextDeferredIntent struct { + ID string `json:"id"` + Alias string `json:"alias,omitempty"` + Title string `json:"title"` + Disposition string `json:"disposition"` + Body string `json:"body"` + Why string `json:"why"` + Boundary string `json:"boundary"` + RevisitTrigger string `json:"revisit_trigger"` } type JournalContextDeferredSpark struct { @@ -157,6 +177,33 @@ type JournalContextOriginSummary struct { Supported bool `json:"supported"` } +// JournalContextCheckpointsLayer is the separately bounded, discoverable layer +// of recent portable Exploration checkpoints. It never exposes a +// current-Exploration pointer: every item carries its own read command, and an +// Exploration with no checkpoint (a handle-only inquiry) is absent because a +// handle never implies portable context. +type JournalContextCheckpointsLayer struct { + Available bool `json:"source_available"` + AvailableCount int `json:"available_count"` + ShownCount int `json:"shown_count"` + Truncated bool `json:"truncated"` + Cursor string `json:"cursor,omitempty"` + ExpandCommand string `json:"expand_command"` + Items []JournalContextExplorationCheckpointItem `json:"items"` +} + +// JournalContextExplorationCheckpointItem is the latest portable checkpoint of +// one Exploration. +type JournalContextExplorationCheckpointItem struct { + ExplorationID string `json:"exploration_id"` + Alias string `json:"alias,omitempty"` + Title string `json:"title"` + Seq int `json:"seq"` + NextAction string `json:"next_action"` + CreatedAt string `json:"created_at"` + ContextCommand string `json:"context_command"` +} + type JournalContextTaskLayer struct { Available bool `json:"source_available"` AvailableCount int `json:"available_count"` @@ -198,6 +245,14 @@ type journalContextDeferralCandidate struct { Item JournalContextDeferralItem CreatedAt string RowID int64 + // Canonical marks a candidate derived from a canonical Intent. Canonical + // candidates are active truth: they precede legacy-only items and never + // depend on the journal watermark for inclusion. + Canonical bool +} + +type journalContextCheckpointCandidate struct { + Item JournalContextExplorationCheckpointItem } // JournalContextForRoot computes the continuity digest from registered state. @@ -287,7 +342,8 @@ func (s *Store) JournalContext(ctx context.Context, root project.Root, options J return JournalContext{}, err } // Deferrals have a journal endpoint but are mutable through their spark and - // deferral rows. Fingerprint the complete current candidate set, including + // deferral rows, and canonical Intents are active truth independent of the + // watermark. Fingerprint the complete current candidate set, including // deferrals created after a continuation cursor's journal watermark. currentDeferralCandidates := deferralCandidates if currentWatermark != watermark { @@ -296,6 +352,10 @@ func (s *Store) JournalContext(ctx context.Context, root project.Root, options J return JournalContext{}, err } } + checkpointItemCandidates, err := queryExplorationCheckpointCandidates(ctx, tx, identity.ID) + if err != nil { + return JournalContext{}, err + } activeIDs := make(map[string]struct{}, len(synthesisCandidates)+len(checkpointCandidates)+len(lineageCandidates)+len(blockerCandidates)+len(deferralCandidates)) for _, candidates := range [][]journalContextJournalCandidate{synthesisCandidates, checkpointCandidates, lineageCandidates} { @@ -307,7 +367,9 @@ func (s *Store) JournalContext(ctx context.Context, root project.Root, options J activeIDs[candidate.Item.Block.ID] = struct{}{} } for _, candidate := range deferralCandidates { - activeIDs[candidate.Item.Decision.ID] = struct{}{} + if candidate.Item.Decision.ID != "" { + activeIDs[candidate.Item.Decision.ID] = struct{}{} + } } branchCandidates := []journalContextJournalCandidate{} @@ -359,6 +421,7 @@ func (s *Store) JournalContext(ctx context.Context, root project.Root, options J if err != nil { return JournalContext{}, err } + checkpointsLayer := journalContextCheckpointsPage(checkpointItemCandidates, contextLimit(options.CheckpointsLimit, defaultJournalContextCheckpointsLimit), branch) branchLayer, err := journalContextJournalPageChecked(branchCandidates, contextLimit(options.BranchLimit, defaultJournalContextBranchLimit), cursorForLayer(cursor, JournalContextLayerBranch), JournalContextLayerBranch, identity.ID, branch, watermark, "") if err != nil { return JournalContext{}, err @@ -372,24 +435,25 @@ func (s *Store) JournalContext(ctx context.Context, root project.Root, options J } result := JournalContext{ - ContractVersion: JournalContextContractVersion, - DatabaseScope: identity.DatabaseScope, - DatabasePath: identity.DatabasePath, - ProjectID: identity.ID, - ProjectName: identity.FriendlyName, - ProjectCurrentPath: identity.CurrentPath, - Branch: branch, - JournalWatermark: watermark, - ProjectSynthesis: synthesisLayer, - LatestCheckpoint: checkpointLayer, - ActiveLineage: lineageLayer, - UnresolvedBlockers: blockerLayer, - DeferredIntent: deferralLayer, - BranchRecency: branchLayer, - TransitionalTasks: taskLayer, - Diagnostics: diagnostics, - BranchEntries: append([]JournalEntryRecord(nil), branchLayer.Items...), - OpenTasks: append([]JournalContextTask(nil), taskLayer.Items...), + ContractVersion: JournalContextContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Branch: branch, + JournalWatermark: watermark, + ProjectSynthesis: synthesisLayer, + LatestCheckpoint: checkpointLayer, + ActiveLineage: lineageLayer, + UnresolvedBlockers: blockerLayer, + DeferredIntent: deferralLayer, + ExplorationCheckpoints: checkpointsLayer, + BranchRecency: branchLayer, + TransitionalTasks: taskLayer, + Diagnostics: diagnostics, + BranchEntries: append([]JournalEntryRecord(nil), branchLayer.Items...), + OpenTasks: append([]JournalContextTask(nil), taskLayer.Items...), } if len(synthesisLayer.Items) > 0 { entry := synthesisLayer.Items[0] @@ -521,7 +585,113 @@ func queryBlockerCandidates(ctx context.Context, tx *sql.Tx, projectID string, w return candidates, diagnostics, nil } +// queryDeferralCandidates builds the deferred-intent candidate set. Canonical +// Intents whose latest disposition is 'deferred' come first as active truth +// (never bounded by the journal watermark), followed by pre-conversion legacy +// journal_deferrals rows that have no intent_operations mapping. A converted +// legacy row is represented by its canonical Intent item, not a second entry. func queryDeferralCandidates(ctx context.Context, tx *sql.Tx, projectID string, watermark int64) ([]journalContextDeferralCandidate, error) { + canonical, err := queryCanonicalDeferralCandidates(ctx, tx, projectID) + if err != nil { + return nil, err + } + legacy, err := queryLegacyDeferralCandidates(ctx, tx, projectID, watermark) + if err != nil { + return nil, err + } + return append(canonical, legacy...), nil +} + +// queryCanonicalDeferralCandidates returns every canonical Intent whose latest +// disposition is 'deferred'. When the operation key that produced the current +// deferral carries a version-1 projection, the mapped decision/spark/origin +// rows enrich the item; version-0 (or unmapped) Intents leave those legacy +// fields zero-valued and rely on the intent sub-object alone. +func queryCanonicalDeferralCandidates(ctx context.Context, tx *sql.Tx, projectID string) ([]journalContextDeferralCandidate, error) { + rows, err := tx.QueryContext(ctx, ` +SELECT i.id, i.created_at, COALESCE(ia.alias, ''), + COALESCE(sn.title, ''), d.disposition, + df.operation_key, df.body, df.why, df.boundary, df.revisit_trigger, + COALESCE(op.journal_entry_id, ''), COALESCE(op.spark_id, ''), + COALESCE(j.id, ''), COALESCE(j.entry_type, ''), COALESCE(j.scope, ''), COALESCE(j.message, ''), + COALESCE(j.observed_branch, ''), COALESCE(j.observed_worktree, ''), COALESCE(j.harness_session_id, ''), + COALESCE(j.created_at, ''), COALESCE(j.rowid, 0), + COALESCE(s.id, ''), COALESCE(sa.alias, ''), COALESCE(s.text, ''), COALESCE(s.scope, ''), + COALESCE(s.status, ''), COALESCE(s.created_at, ''), COALESCE(s.updated_at, ''), + o.envelope_version, o.capture_mechanism, o.branch, o.worktree, o.head, o.change_path, o.dirty, o.reconstructable +FROM intents i +JOIN intent_dispositions d ON d.project_id = i.project_id AND d.intent_id = i.id + AND d.seq = (SELECT MAX(seq) FROM intent_dispositions WHERE intent_id = i.id) +JOIN intent_deferrals df ON df.project_id = i.project_id AND df.id = d.deferral_id +LEFT JOIN intent_snapshots sn ON sn.project_id = i.project_id AND sn.intent_id = i.id + AND sn.seq = (SELECT MAX(seq) FROM intent_snapshots WHERE intent_id = i.id) +LEFT JOIN ( + SELECT project_id, entity_id, MIN(alias) AS alias + FROM aliases + WHERE entity_kind = 'intent' AND namespace = 'intent' + GROUP BY project_id, entity_id +) ia ON ia.project_id = i.project_id AND ia.entity_id = i.id +LEFT JOIN intent_operations op ON op.project_id = i.project_id AND op.operation_key = df.operation_key +LEFT JOIN journal_entries j ON j.project_id = i.project_id AND j.id = op.journal_entry_id +LEFT JOIN sparks s ON s.project_id = i.project_id AND s.id = op.spark_id +LEFT JOIN ( + SELECT project_id, entity_id, MIN(alias) AS alias + FROM aliases + WHERE entity_kind = 'spark' AND namespace = 'spark' + GROUP BY project_id, entity_id +) sa ON sa.project_id = s.project_id AND sa.entity_id = s.id +LEFT JOIN journal_origins o ON o.project_id = i.project_id AND o.journal_entry_id = op.journal_entry_id +WHERE i.project_id = ? AND d.disposition = 'deferred' +ORDER BY i.created_at DESC, i.id DESC +`, projectID) + if err != nil { + return nil, fmt.Errorf("query journal context canonical deferrals: %w", err) + } + defer rows.Close() + candidates := []journalContextDeferralCandidate{} + for rows.Next() { + var candidate journalContextDeferralCandidate + var intent JournalContextDeferredIntent + var journalID, sparkID string + var envelopeVersion sql.NullInt64 + var mechanism, branch, worktree, head, changePath sql.NullString + var dirty, reconstructable sql.NullBool + if err := rows.Scan( + &intent.ID, &candidate.CreatedAt, &intent.Alias, + &intent.Title, &intent.Disposition, + &candidate.Item.OperationKey, &intent.Body, &intent.Why, &intent.Boundary, &intent.RevisitTrigger, + &journalID, &sparkID, + &candidate.Item.Decision.ID, &candidate.Item.Decision.EntryType, &candidate.Item.Decision.Scope, &candidate.Item.Decision.Message, + &candidate.Item.Decision.ObservedBranch, &candidate.Item.Decision.ObservedWorktree, &candidate.Item.Decision.HarnessSessionID, + &candidate.Item.Decision.CreatedAt, &candidate.RowID, + &candidate.Item.Spark.ID, &candidate.Item.Spark.Alias, &candidate.Item.Spark.Text, &candidate.Item.Spark.Scope, + &candidate.Item.Spark.Status, &candidate.Item.Spark.CreatedAt, &candidate.Item.Spark.UpdatedAt, + &envelopeVersion, &mechanism, &branch, &worktree, &head, &changePath, &dirty, &reconstructable, + ); err != nil { + return nil, fmt.Errorf("scan journal context canonical deferral: %w", err) + } + candidate.Canonical = true + candidate.Item.Intent = &intent + // Without a version-1 projection there is no legacy decision/spark to + // surface; never fabricate one from the canonical rows. + if journalID == "" || sparkID == "" { + candidate.Item.Decision = JournalEntryRecord{} + candidate.Item.Spark = JournalContextDeferredSpark{} + } else if mechanism.Valid { + candidate.Item.Origin = buildJournalContextOrigin(envelopeVersion, mechanism, branch, worktree, head, changePath, dirty, reconstructable) + } + candidates = append(candidates, candidate) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate journal context canonical deferrals: %w", err) + } + return candidates, nil +} + +// queryLegacyDeferralCandidates returns pre-conversion legacy journal_deferrals +// rows (open spark) that have no intent_operations mapping. A row with a +// mapping is deduplicated: its canonical Intent item represents it instead. +func queryLegacyDeferralCandidates(ctx context.Context, tx *sql.Tx, projectID string, watermark int64) ([]journalContextDeferralCandidate, error) { rows, err := tx.QueryContext(ctx, ` SELECT d.operation_key, j.id, j.entry_type, COALESCE(j.scope, ''), j.message, COALESCE(j.observed_branch, ''), @@ -539,10 +709,14 @@ LEFT JOIN ( ) a ON a.project_id = s.project_id AND a.entity_id = s.id LEFT JOIN journal_origins o ON o.project_id = d.project_id AND o.journal_entry_id = j.id WHERE d.project_id = ? + AND NOT EXISTS ( + SELECT 1 FROM intent_operations op + WHERE op.project_id = d.project_id AND op.operation_key = d.operation_key + ) ORDER BY j.created_at DESC, j.rowid DESC, d.operation_key `, watermark, projectID) if err != nil { - return nil, fmt.Errorf("query journal context deferrals: %w", err) + return nil, fmt.Errorf("query journal context legacy deferrals: %w", err) } defer rows.Close() candidates := []journalContextDeferralCandidate{} @@ -560,25 +734,71 @@ ORDER BY j.created_at DESC, j.rowid DESC, d.operation_key &candidate.Item.Spark.Status, &candidate.Item.Spark.CreatedAt, &candidate.Item.Spark.UpdatedAt, &envelopeVersion, &mechanism, &branch, &worktree, &head, &changePath, &dirty, &reconstructable, ); err != nil { - return nil, fmt.Errorf("scan journal context deferral: %w", err) + return nil, fmt.Errorf("scan journal context legacy deferral: %w", err) } candidate.CreatedAt = candidate.Item.Decision.CreatedAt if mechanism.Valid { - origin := &JournalContextOriginSummary{CaptureMechanism: mechanism.String, Branch: branch.String, Worktree: worktree.String, Head: head.String, ChangePath: changePath.String, Supported: envelopeVersion.Valid && envelopeVersion.Int64 <= JournalOriginEnvelopeVersion} - if dirty.Valid { - value := dirty.Bool - origin.Dirty = &value - } - if reconstructable.Valid { - value := reconstructable.Bool - origin.Reconstructable = &value - } - candidate.Item.Origin = origin + candidate.Item.Origin = buildJournalContextOrigin(envelopeVersion, mechanism, branch, worktree, head, changePath, dirty, reconstructable) + } + candidates = append(candidates, candidate) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate journal context legacy deferrals: %w", err) + } + return candidates, nil +} + +func buildJournalContextOrigin(envelopeVersion sql.NullInt64, mechanism, branch, worktree, head, changePath sql.NullString, dirty, reconstructable sql.NullBool) *JournalContextOriginSummary { + origin := &JournalContextOriginSummary{CaptureMechanism: mechanism.String, Branch: branch.String, Worktree: worktree.String, Head: head.String, ChangePath: changePath.String, Supported: envelopeVersion.Valid && envelopeVersion.Int64 <= JournalOriginEnvelopeVersion} + if dirty.Valid { + value := dirty.Bool + origin.Dirty = &value + } + if reconstructable.Valid { + value := reconstructable.Bool + origin.Reconstructable = &value + } + return origin +} + +// queryExplorationCheckpointCandidates returns the latest checkpoint of every +// Exploration that has at least one, newest checkpoint first. An Exploration +// with no checkpoint is absent: a conversation handle never implies portable +// context, so it must not surface here. +func queryExplorationCheckpointCandidates(ctx context.Context, tx *sql.Tx, projectID string) ([]journalContextCheckpointCandidate, error) { + rows, err := tx.QueryContext(ctx, ` +SELECT e.id, COALESCE(ea.alias, ''), e.title, c.seq, c.next_action, c.created_at +FROM explorations e +JOIN exploration_checkpoints c ON c.project_id = e.project_id AND c.exploration_id = e.id + AND c.seq = (SELECT MAX(seq) FROM exploration_checkpoints WHERE exploration_id = e.id) +LEFT JOIN ( + SELECT project_id, entity_id, MIN(alias) AS alias + FROM aliases + WHERE entity_kind = 'exploration' AND namespace = 'exploration' + GROUP BY project_id, entity_id +) ea ON ea.project_id = e.project_id AND ea.entity_id = e.id +WHERE e.project_id = ? +ORDER BY c.created_at DESC, e.id +`, projectID) + if err != nil { + return nil, fmt.Errorf("query journal context exploration checkpoints: %w", err) + } + defer rows.Close() + candidates := []journalContextCheckpointCandidate{} + for rows.Next() { + var candidate journalContextCheckpointCandidate + if err := rows.Scan(&candidate.Item.ExplorationID, &candidate.Item.Alias, &candidate.Item.Title, &candidate.Item.Seq, &candidate.Item.NextAction, &candidate.Item.CreatedAt); err != nil { + return nil, fmt.Errorf("scan journal context exploration checkpoint: %w", err) } + reference := candidate.Item.Alias + if reference == "" { + reference = candidate.Item.ExplorationID + } + candidate.Item.ContextCommand = "loaf exploration context " + reference candidates = append(candidates, candidate) } if err := rows.Err(); err != nil { - return nil, fmt.Errorf("iterate journal context deferrals: %w", err) + return nil, fmt.Errorf("iterate journal context exploration checkpoints: %w", err) } return candidates, nil } @@ -683,6 +903,7 @@ func journalContextFingerprintDeferrals(candidates []journalContextDeferralCandi item := candidate.Item parts = append(parts, journalContextDeferralKey(candidate), + fmt.Sprintf("canonical:%t", candidate.Canonical), item.OperationKey, item.Decision.ID, item.Decision.EntryType, @@ -700,6 +921,21 @@ func journalContextFingerprintDeferrals(candidates []journalContextDeferralCandi item.Spark.CreatedAt, item.Spark.UpdatedAt, ) + if item.Intent == nil { + parts = append(parts, "intent:nil") + } else { + parts = append(parts, + "intent:present", + item.Intent.ID, + item.Intent.Alias, + item.Intent.Title, + item.Intent.Disposition, + item.Intent.Body, + item.Intent.Why, + item.Intent.Boundary, + item.Intent.RevisitTrigger, + ) + } if item.Origin == nil { parts = append(parts, "origin:nil") continue diff --git a/internal/state/journal_context_cursor.go b/internal/state/journal_context_cursor.go index 514fdd82c..34619c411 100644 --- a/internal/state/journal_context_cursor.go +++ b/internal/state/journal_context_cursor.go @@ -129,6 +129,8 @@ func journalContextLayerOption(layer string) string { return "unresolved-blockers" case JournalContextLayerDeferrals: return "deferred-intent" + case JournalContextLayerCheckpoints: + return "exploration-checkpoints" case JournalContextLayerBranch: return "branch-recency" case JournalContextLayerTasks: @@ -174,6 +176,26 @@ func journalContextCheckpointPage(candidates []journalContextJournalCandidate, b return JournalContextCheckpointLayer{Available: true, AvailableCount: len(items), ShownCount: len(items), Truncated: false, ExpandCommand: journalContextExpandCommand(JournalContextLayerCheckpoint, journalContextSynthesisLimit, branch, ""), Items: items} } +// journalContextCheckpointsPage bounds the recent portable checkpoints layer. +// It is discoverable through a --limit expansion command rather than a cursor: +// the layer selects the greatest-sequence checkpoint per Exploration, so there +// is no per-item journal watermark to freeze into a continuation token. +func journalContextCheckpointsPage(candidates []journalContextCheckpointCandidate, limit int, branch string) JournalContextCheckpointsLayer { + end := min(limit, len(candidates)) + items := make([]JournalContextExplorationCheckpointItem, 0, end) + for _, candidate := range candidates[:end] { + items = append(items, candidate.Item) + } + return JournalContextCheckpointsLayer{ + Available: true, + AvailableCount: len(candidates), + ShownCount: len(items), + Truncated: end < len(candidates), + ExpandCommand: journalContextExpandCommand(JournalContextLayerCheckpoints, limit, branch, ""), + Items: items, + } +} + func journalContextBlockerPage(candidates []journalContextBlockerCandidate, limit int, cursor *journalContextCursor, projectID, branch string, watermark int64) (JournalContextBlockerLayer, error) { start := 0 if cursor != nil { diff --git a/internal/state/journal_context_test.go b/internal/state/journal_context_test.go index a50629362..feacbe9fc 100644 --- a/internal/state/journal_context_test.go +++ b/internal/state/journal_context_test.go @@ -357,9 +357,11 @@ func TestJournalContextV2ScopedCheckpointFallbackAndCursorErrors(t *testing.T) { t.Fatal("wrong-project cursor succeeded") } otherStore.Close() - if _, err := store.db.ExecContext(ctx, `UPDATE sparks SET status = 'done' WHERE project_id = ? AND id = ?`, projectID, deferred[0].Spark.ID); err != nil { + // Disposition is canonical: resolving the Intent (not marking the legacy + // spark done) is what removes a deferred item from active truth. + if _, err := store.appendIntentDisposition(ctx, root, IntentDispositionOptions{IntentRef: deferred[0].IntentAlias, Reason: "resolved in test"}, "resolved"); err != nil { store.Close() - t.Fatalf("resolve deferred spark: %v", err) + t.Fatalf("resolve deferred intent: %v", err) } if _, err := store.JournalContext(ctx, root, JournalContextOptions{Branch: "main", DeferralLimit: 1, Cursor: result.DeferredIntent.Cursor, CursorLayer: JournalContextLayerDeferrals}); err == nil { store.Close() @@ -663,6 +665,274 @@ func assertJournalContextSourceAvailabilityJSON(t *testing.T, layer any, want bo } } +// seedRawLegacyDeferral inserts a pre-conversion journal_deferrals row (a +// decision, an open spark, and the deferral pair) with no intent_operations +// mapping and caller-controlled branch/text/timestamp, mirroring state written +// before the canonical Intent model existed. +func seedRawLegacyDeferral(t *testing.T, store *Store, projectID, operationKey, intentText, sparkText, branch, createdAt string) (string, string) { + t.Helper() + decisionID := stableMigrationID("test-legacy-decision", projectID, operationKey) + sparkID := stableMigrationID("test-legacy-spark", projectID, operationKey) + scope := "defer/" + operationKey + ctx := context.Background() + if _, err := store.db.ExecContext(ctx, ` +INSERT INTO journal_entries ( + id, project_id, entry_type, scope, message, + observed_branch, observed_worktree, harness_session_id, + session_id, spec_id, task_id, created_at, updated_at +) VALUES (?, ?, 'decision', ?, ?, ?, NULL, NULL, NULL, NULL, NULL, ?, ?) +`, decisionID, projectID, scope, "Intent: "+intentText, emptyToNil(branch), createdAt, createdAt); err != nil { + t.Fatalf("seed legacy decision: %v", err) + } + if _, err := store.db.ExecContext(ctx, ` +INSERT INTO sparks (id, project_id, scope, status, text, source_id, created_at, updated_at) +VALUES (?, ?, ?, 'open', ?, NULL, ?, ?) +`, sparkID, projectID, scope, sparkText, createdAt, createdAt); err != nil { + t.Fatalf("seed legacy spark: %v", err) + } + if _, err := store.db.ExecContext(ctx, ` +INSERT INTO journal_deferrals (project_id, operation_key, journal_entry_id, spark_id, stored_digest, created_at) +VALUES (?, ?, ?, ?, ?, ?) +`, projectID, operationKey, decisionID, sparkID, strings.Repeat("b", 64), createdAt); err != nil { + t.Fatalf("seed legacy deferral: %v", err) + } + return decisionID, sparkID +} + +// seedExplorationCheckpoint inserts one immutable checkpoint with a controlled +// sequence and timestamp so tests can assert greatest-sequence latest selection +// and deterministic recency ordering independently of wall-clock append time. +func seedExplorationCheckpoint(t *testing.T, store *Store, projectID, explorationID string, seq int, nextAction, createdAt string) { + t.Helper() + id := stableMigrationID("test-checkpoint", projectID, explorationID, fmt.Sprintf("%d", seq)) + if _, err := store.db.ExecContext(context.Background(), ` +INSERT INTO exploration_checkpoints (id, project_id, exploration_id, seq, purpose, conclusions, unresolved, next_action, content_digest, created_at) +VALUES (?, ?, ?, ?, 'purpose', 'conclusions', 'unresolved', ?, ?, ?) +`, id, projectID, explorationID, seq, nextAction, strings.Repeat("a", 64), createdAt); err != nil { + t.Fatalf("seed exploration checkpoint: %v", err) + } +} + +func TestJournalContextCanonicalDeferredIntentIsActiveTruthAheadOfLegacy(t *testing.T) { + ctx := context.Background() + root := projectRoot(t) + stateHome := t.TempDir() + if _, err := Initialize(ctx, root, PathResolver{StateHome: stateHome}); err != nil { + t.Fatalf("Initialize() error = %v", err) + } + store := openTestStore(t, root, stateHome) + defer store.Close() + + canonical, err := store.CreateIntent(ctx, root, IntentCreateOptions{ + Title: "Rework retry budget", Body: "revisit the retry budget model", + Disposition: "deferred", Why: "needs data first", Boundary: "do not touch prod", Trigger: "when metrics land", + OperationID: "canonical-active-truth", + }) + if err != nil { + t.Fatalf("CreateIntent(deferred) error = %v", err) + } + + projectID := projectIDForTest(t, store, root) + // A pre-conversion legacy deferral whose decision is much newer than the + // canonical Intent, plus a flood of newer journal noise. + legacyDecision, legacySpark := seedRawLegacyDeferral(t, store, projectID, "legacy-active-truth", "legacy direction", "legacy spark text", "", "2026-07-09T12:00:00Z") + for i := 0; i < 40; i++ { + seedJournalEntry(t, store, projectID, "discover", "noise", fmt.Sprintf("noise-%02d", i), "main", fmt.Sprintf("2026-07-10T%02d:00:00Z", i%24)) + } + + result, err := store.JournalContext(ctx, root, JournalContextOptions{Branch: "main"}) + if err != nil { + t.Fatalf("JournalContext() error = %v", err) + } + if result.DeferredIntent.AvailableCount != 2 || len(result.DeferredIntent.Items) != 2 { + t.Fatalf("deferred intent count = %d, want 2 (canonical + legacy)", result.DeferredIntent.AvailableCount) + } + first := result.DeferredIntent.Items[0] + if first.Intent == nil || first.Intent.Alias != canonical.Intent.Alias { + t.Fatalf("first deferred item = %#v, want canonical intent %s ahead of newer legacy", first, canonical.Intent.Alias) + } + if first.Intent.Body != "revisit the retry budget model" || first.Intent.Why != "needs data first" || first.Intent.Boundary != "do not touch prod" || first.Intent.RevisitTrigger != "when metrics land" { + t.Fatalf("canonical intent packet = %#v, want the self-sufficient deferral fields", first.Intent) + } + if first.Intent.Disposition != "deferred" { + t.Fatalf("canonical disposition = %q, want deferred", first.Intent.Disposition) + } + // A version-0 canonical Intent has no legacy projection: never fabricate one. + if first.Decision.ID != "" || first.Spark.ID != "" { + t.Fatalf("canonical item fabricated a legacy projection: decision=%q spark=%q", first.Decision.ID, first.Spark.ID) + } + second := result.DeferredIntent.Items[1] + if second.Intent != nil || second.OperationKey != "legacy-active-truth" { + t.Fatalf("second deferred item = %#v, want the legacy-only deferral", second) + } + if second.Decision.ID != legacyDecision || second.Spark.ID != legacySpark { + t.Fatalf("legacy item projection = decision %q spark %q, want %q/%q", second.Decision.ID, second.Spark.ID, legacyDecision, legacySpark) + } +} + +func TestJournalContextDeferJournalAdapterAppearsOnceWithEnrichment(t *testing.T) { + ctx := context.Background() + root := projectRoot(t) + stateHome := t.TempDir() + if _, err := Initialize(ctx, root, PathResolver{StateHome: stateHome}); err != nil { + t.Fatalf("Initialize() error = %v", err) + } + store := openTestStore(t, root, stateHome) + defer store.Close() + deferred, err := store.DeferJournal(ctx, root, JournalDeferOptions{ + Intent: "adapter deferral", Why: "later", Boundary: "not now", Trigger: "on resume", OperationID: "adapter-once", + Origin: &JournalOriginInput{EnvelopeVersion: 1, CaptureMechanism: JournalOriginMechanismSkill, Branch: "feat/x"}, + }) + if err != nil { + t.Fatalf("DeferJournal() error = %v", err) + } + result, err := store.JournalContext(ctx, root, JournalContextOptions{Branch: "feat/x"}) + if err != nil { + t.Fatalf("JournalContext() error = %v", err) + } + if result.DeferredIntent.AvailableCount != 1 || len(result.DeferredIntent.Items) != 1 { + t.Fatalf("adapter deferral surfaced %d items, want exactly one canonical item", result.DeferredIntent.AvailableCount) + } + item := result.DeferredIntent.Items[0] + if item.Intent == nil || item.Intent.Alias != deferred.IntentAlias { + t.Fatalf("deduplicated item = %#v, want canonical intent %s", item, deferred.IntentAlias) + } + if item.Decision.ID != deferred.Decision.ID || item.Spark.ID != deferred.Spark.ID { + t.Fatalf("version-1 enrichment = decision %q spark %q, want %q/%q", item.Decision.ID, item.Spark.ID, deferred.Decision.ID, deferred.Spark.ID) + } + if item.Origin == nil || item.Origin.Branch != "feat/x" { + t.Fatalf("origin enrichment = %#v, want the mapped decision origin", item.Origin) + } +} + +func TestJournalContextPreConversionLegacyDeferralAppears(t *testing.T) { + ctx := context.Background() + root := projectRoot(t) + stateHome := t.TempDir() + if _, err := Initialize(ctx, root, PathResolver{StateHome: stateHome}); err != nil { + t.Fatalf("Initialize() error = %v", err) + } + store := openTestStore(t, root, stateHome) + defer store.Close() + projectID := projectIDForTest(t, store, root) + decisionID, sparkID := seedRawLegacyDeferral(t, store, projectID, "pre-conversion", "unmigrated direction", "unmigrated spark", "", "2026-07-01T09:00:00Z") + result, err := store.JournalContext(ctx, root, JournalContextOptions{Branch: "main"}) + if err != nil { + t.Fatalf("JournalContext() error = %v", err) + } + if result.DeferredIntent.AvailableCount != 1 || len(result.DeferredIntent.Items) != 1 { + t.Fatalf("legacy deferral count = %d, want 1", result.DeferredIntent.AvailableCount) + } + item := result.DeferredIntent.Items[0] + if item.Intent != nil { + t.Fatalf("legacy item carried an intent sub-object: %#v", item.Intent) + } + if item.OperationKey != "pre-conversion" || item.Decision.ID != decisionID || item.Spark.ID != sparkID { + t.Fatalf("legacy item = %#v, want operation pre-conversion with mapped decision/spark", item) + } +} + +func TestJournalContextResolvedAndResumedIntentsDisappear(t *testing.T) { + ctx := context.Background() + root := projectRoot(t) + stateHome := t.TempDir() + if _, err := Initialize(ctx, root, PathResolver{StateHome: stateHome}); err != nil { + t.Fatalf("Initialize() error = %v", err) + } + store := openTestStore(t, root, stateHome) + defer store.Close() + deferrals := []struct{ op, title string }{{"defer-resolve", "resolve me"}, {"defer-resume", "resume me"}, {"defer-keep", "keep me"}} + created := map[string]IntentMutationResult{} + for _, spec := range deferrals { + res, err := store.CreateIntent(ctx, root, IntentCreateOptions{Title: spec.title, Body: spec.title + " body", Disposition: "deferred", Why: "w", Boundary: "b", Trigger: "t", OperationID: spec.op}) + if err != nil { + t.Fatalf("CreateIntent(%s) error = %v", spec.op, err) + } + created[spec.op] = res + } + if _, err := store.appendIntentDisposition(ctx, root, IntentDispositionOptions{IntentRef: created["defer-resolve"].Intent.Alias, Reason: "done exploring"}, "resolved"); err != nil { + t.Fatalf("resolve error = %v", err) + } + if _, err := store.appendIntentDisposition(ctx, root, IntentDispositionOptions{IntentRef: created["defer-resume"].Intent.Alias, Reason: "back on it"}, "tracked"); err != nil { + t.Fatalf("resume error = %v", err) + } + result, err := store.JournalContext(ctx, root, JournalContextOptions{Branch: "main"}) + if err != nil { + t.Fatalf("JournalContext() error = %v", err) + } + if result.DeferredIntent.AvailableCount != 1 || len(result.DeferredIntent.Items) != 1 { + t.Fatalf("deferred count = %d, want only the still-deferred intent", result.DeferredIntent.AvailableCount) + } + if got := result.DeferredIntent.Items[0].Intent; got == nil || got.Alias != created["defer-keep"].Intent.Alias { + t.Fatalf("surviving deferred item = %#v, want %s", got, created["defer-keep"].Intent.Alias) + } +} + +func TestJournalContextExplorationCheckpointsLayerBoundsLatestPerExploration(t *testing.T) { + ctx := context.Background() + root := projectRoot(t) + stateHome := t.TempDir() + if _, err := Initialize(ctx, root, PathResolver{StateHome: stateHome}); err != nil { + t.Fatalf("Initialize() error = %v", err) + } + store := openTestStore(t, root, stateHome) + defer store.Close() + projectID := projectIDForTest(t, store, root) + + older, err := store.CreateExploration(ctx, root, ExplorationCreateOptions{Title: "Older inquiry"}) + if err != nil { + t.Fatalf("CreateExploration(older) error = %v", err) + } + newer, err := store.CreateExploration(ctx, root, ExplorationCreateOptions{Title: "Newer inquiry"}) + if err != nil { + t.Fatalf("CreateExploration(newer) error = %v", err) + } + // A handle-only Exploration with no checkpoint must never surface: a handle + // is never proof of portable context. + if _, err := store.CreateExploration(ctx, root, ExplorationCreateOptions{Title: "Handle only"}); err != nil { + t.Fatalf("CreateExploration(handle only) error = %v", err) + } + + // The greater sequence is the latest even when its timestamp is older. + seedExplorationCheckpoint(t, store, projectID, older.Exploration.ID, 1, "older superseded action", "2026-07-01T08:00:00Z") + seedExplorationCheckpoint(t, store, projectID, older.Exploration.ID, 2, "older latest action", "2026-07-01T07:00:00Z") + seedExplorationCheckpoint(t, store, projectID, newer.Exploration.ID, 1, "newer latest action", "2026-07-02T09:00:00Z") + + result, err := store.JournalContext(ctx, root, JournalContextOptions{Branch: "main"}) + if err != nil { + t.Fatalf("JournalContext() error = %v", err) + } + layer := result.ExplorationCheckpoints + if !layer.Available || layer.AvailableCount != 2 || layer.ShownCount != 2 || layer.Truncated { + t.Fatalf("checkpoints layer = %#v, want two checkpointed explorations, none truncated", layer) + } + if layer.Items[0].ExplorationID != newer.Exploration.ID || layer.Items[1].ExplorationID != older.Exploration.ID { + t.Fatalf("checkpoint order = %#v, want newest checkpoint first", layer.Items) + } + if layer.Items[1].Seq != 2 || layer.Items[1].NextAction != "older latest action" { + t.Fatalf("older latest checkpoint = %#v, want the greatest-sequence row", layer.Items[1]) + } + if want := "loaf exploration context " + newer.Exploration.Alias; layer.Items[0].ContextCommand != want { + t.Fatalf("context command = %q, want %q", layer.Items[0].ContextCommand, want) + } + assertJournalContextSourceAvailabilityJSON(t, layer, true) + + bounded, err := store.JournalContext(ctx, root, JournalContextOptions{Branch: "main", CheckpointsLimit: 1}) + if err != nil { + t.Fatalf("JournalContext(bounded) error = %v", err) + } + checkpoints := bounded.ExplorationCheckpoints + if checkpoints.AvailableCount != 2 || checkpoints.ShownCount != 1 || !checkpoints.Truncated { + t.Fatalf("bounded checkpoints = %#v, want 1 of 2 truncated", checkpoints) + } + if want := "loaf journal context --layer exploration-checkpoints --limit 1 --branch 'main'"; checkpoints.ExpandCommand != want { + t.Fatalf("expand command = %q, want %q", checkpoints.ExpandCommand, want) + } + if checkpoints.Cursor != "" { + t.Fatalf("checkpoints layer must not expose a cursor, got %q", checkpoints.Cursor) + } +} + func assertJournalContextEntryIDs(t *testing.T, entries []JournalEntryRecord, expected map[string]bool) { t.Helper() seen := make(map[string]bool, len(entries)) diff --git a/internal/state/journal_defer.go b/internal/state/journal_defer.go index 649476a84..6e141af4b 100644 --- a/internal/state/journal_defer.go +++ b/internal/state/journal_defer.go @@ -32,7 +32,10 @@ type JournalDeferOptions struct { // JournalDeferResult describes the reciprocal journal decision and spark // created by one operation key. A retry returns the original pair with Created -// false and reports whether its packet digest matched the first write. +// false and reports whether its packet digest matched the first write. Since +// the Intent model became canonical this command is a compatibility adapter: +// IntentID/IntentAlias identify the canonical Intent behind the operation key, +// and the decision/spark pair is a labeled legacy projection. type JournalDeferResult struct { ContractVersion int `json:"contract_version,omitempty"` DatabaseScope string `json:"database_scope,omitempty"` @@ -47,6 +50,8 @@ type JournalDeferResult struct { InputDigest string `json:"input_digest"` StoredDigest string `json:"stored_digest"` InputDigestMatches bool `json:"input_digest_matches"` + IntentID string `json:"intent_id,omitempty"` + IntentAlias string `json:"intent_alias,omitempty"` Origin *JournalOrigin `json:"origin,omitempty"` } @@ -97,13 +102,14 @@ func (s *Store) DeferJournal(ctx context.Context, root project.Root, options Jou } type journalDeferHooks struct { - afterDecision func(*sql.Tx) error - afterFTS func(*sql.Tx) error - afterSpark func(*sql.Tx) error - afterAliasEvent func(*sql.Tx) error - afterOrigin func(*sql.Tx) error - afterDeferral func(*sql.Tx) error - beforeCommit func(*sql.Tx) error + afterDecision func(*sql.Tx) error + afterFTS func(*sql.Tx) error + afterSpark func(*sql.Tx) error + afterAliasEvent func(*sql.Tx) error + afterOrigin func(*sql.Tx) error + afterDeferral func(*sql.Tx) error + afterCanonicalIntent func(*sql.Tx) error + beforeCommit func(*sql.Tx) error } func (s *Store) deferJournalWithHooks(ctx context.Context, root project.Root, options JournalDeferOptions, hooks *journalDeferHooks) (JournalDeferResult, error) { @@ -145,12 +151,39 @@ WHERE project_id = ? AND operation_key = ? return JournalDeferResult{}, &JournalDeferTransactionError{Stage: "lookup operation key", Err: err} } + // Canonical-first recovery: a canonical intent command may have won this + // operation key without legacy projections (projection version 0). The + // adapter then materializes both projections from the STORED canonical + // packet — never from the retry body — and advances the mapping to + // version 1 in this same transaction. + var mappedIntentID, mappedDigest string + var mappedVersion int + err = tx.QueryRowContext(ctx, ` +SELECT intent_id, stored_digest, projection_version +FROM intent_operations +WHERE project_id = ? AND operation_key = ? +`, projectID, normalized.OperationID).Scan(&mappedIntentID, &mappedDigest, &mappedVersion) + switch { + case err == nil: + result, backfillErr := s.backfillJournalDeferProjectionsTx(ctx, tx, identity, normalized, inputDigest, mappedIntentID, mappedDigest, mappedVersion, hooks) + if backfillErr != nil { + return JournalDeferResult{}, backfillErr + } + if err := tx.Commit(); err != nil { + return JournalDeferResult{}, &JournalDeferTransactionError{Stage: "commit backfill", Err: err} + } + return result, nil + case !errors.Is(err, sql.ErrNoRows): + return JournalDeferResult{}, &JournalDeferTransactionError{Stage: "lookup canonical operation", Err: err} + } + operationDigest := journalDeferOperationDigest(projectID, normalized.OperationID) decisionID := stableMigrationID("journal-defer-decision", projectID, normalized.OperationID) sparkID := stableMigrationID("journal-defer-spark", projectID, normalized.OperationID) alias := "SPARK-DEFER-" + operationDigest[:journalDeferScopePrefixLen] scope := "defer/" + operationDigest[:journalDeferScopePrefixLen] - now := time.Now().UTC().Format(time.RFC3339Nano) + nowTime := time.Now().UTC() + now := nowTime.Format(time.RFC3339Nano) decisionMessage := packet + "\nSpark: " + sparkID sparkText := packet + "\nDecision: " + decisionID @@ -211,9 +244,23 @@ VALUES (?, ?, ?, ?, ?, ?) if err := runJournalDeferHook(hooks, "after deferral", func(h *journalDeferHooks) func(*sql.Tx) error { return h.afterDeferral }, tx); err != nil { return JournalDeferResult{}, err } + // The adapter can no longer create a legacy-only deferral: every write + // also records the canonical Intent, its immutable deferral payload, its + // deferred disposition, and the shared operation mapping at projection + // version 1 in this same transaction. + intentID, intentAlias, err := s.insertCanonicalDeferredIntentTx(ctx, tx, projectID, normalized, inputDigest, decisionID, sparkID, nowTime) + if err != nil { + return JournalDeferResult{}, err + } + if err := runJournalDeferHook(hooks, "after canonical intent", func(h *journalDeferHooks) func(*sql.Tx) error { return h.afterCanonicalIntent }, tx); err != nil { + return JournalDeferResult{}, err + } if err := verifyJournalDeferralTx(ctx, tx, projectID, normalized.OperationID, decisionID, sparkID); err != nil { return JournalDeferResult{}, &JournalDeferTransactionError{Stage: "verify", Err: err} } + if err := verifyIntentOperationProjectionTx(ctx, tx, projectID, normalized.OperationID, intentID, decisionID, sparkID); err != nil { + return JournalDeferResult{}, &JournalDeferTransactionError{Stage: "verify", Err: err} + } if err := runJournalDeferHook(hooks, "before commit", func(h *journalDeferHooks) func(*sql.Tx) error { return h.beforeCommit }, tx); err != nil { return JournalDeferResult{}, err } @@ -222,12 +269,236 @@ VALUES (?, ?, ?, ?, ?, ?) return JournalDeferResult{}, &JournalDeferTransactionError{Stage: "read origin", Err: err} } result := buildJournalDeferResult(identity, normalized.OperationID, true, inputDigest, inputDigest, true, decisionID, projectID, scope, decisionMessage, sparkID, alias, sparkText, now, origin) + result.IntentID = intentID + result.IntentAlias = intentAlias if err := tx.Commit(); err != nil { return JournalDeferResult{}, &JournalDeferTransactionError{Stage: "commit", Err: err} } return result, nil } +// journalDeferIntentTitle derives a bounded single-line canonical title from +// the legacy Intent field, cutting on a rune boundary. +func journalDeferIntentTitle(intentText string) string { + title := strings.TrimSpace(intentText) + if len(title) <= intentTitleMaxBytes { + return title + } + cut := intentTitleMaxBytes + for cut > 0 && (title[cut]&0xC0) == 0x80 { + cut-- + } + return strings.TrimSpace(title[:cut]) +} + +// insertCanonicalDeferredIntentTx writes the canonical Intent rows for one +// adapter-first journal defer: identity, first snapshot, immutable deferral +// payload, deferred disposition, intent alias, and the shared operation +// mapping at projection version 1 carrying the legacy projection IDs. +func (s *Store) insertCanonicalDeferredIntentTx(ctx context.Context, tx *sql.Tx, projectID string, normalized JournalDeferOptions, inputDigest, decisionID, sparkID string, nowTime time.Time) (string, string, error) { + timestamp := nowTime.Format(time.RFC3339Nano) + title := journalDeferIntentTitle(normalized.Intent) + intentAlias, err := s.nextIntentAlias(ctx, tx, projectID, title, nowTime) + if err != nil { + return "", "", &JournalDeferTransactionError{Stage: "canonical alias", Err: err} + } + intentID := stableMigrationID("intent", projectID, intentAlias) + if _, err := tx.ExecContext(ctx, ` +INSERT INTO intents (id, project_id, created_at) VALUES (?, ?, ?) +`, intentID, projectID, timestamp); err != nil { + return "", "", &JournalDeferTransactionError{Stage: "canonical intent", Err: err} + } + snapshotID := stableMigrationID("intent-snapshot", projectID, intentID, "1") + if _, err := tx.ExecContext(ctx, ` +INSERT INTO intent_snapshots (id, project_id, intent_id, seq, title, body, content_digest, created_at) +VALUES (?, ?, ?, 1, ?, ?, ?, ?) +`, snapshotID, projectID, intentID, title, normalized.Intent, intentDigest(title+"\x00"+normalized.Intent), timestamp); err != nil { + return "", "", &JournalDeferTransactionError{Stage: "canonical snapshot", Err: err} + } + deferralID := stableMigrationID("intent-deferral", projectID, normalized.OperationID) + if _, err := tx.ExecContext(ctx, ` +INSERT INTO intent_deferrals (id, project_id, intent_id, operation_key, body, why, boundary, revisit_trigger, stored_digest, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +`, deferralID, projectID, intentID, normalized.OperationID, normalized.Intent, normalized.Why, normalized.Boundary, normalized.Trigger, inputDigest, timestamp); err != nil { + return "", "", &JournalDeferTransactionError{Stage: "canonical deferral", Err: err} + } + dispositionID := stableMigrationID("intent-disposition", projectID, intentID, "1") + if _, err := tx.ExecContext(ctx, ` +INSERT INTO intent_dispositions (id, project_id, intent_id, seq, disposition, reason, deferral_id, created_at) +VALUES (?, ?, ?, 1, 'deferred', NULL, ?, ?) +`, dispositionID, projectID, intentID, deferralID, timestamp); err != nil { + return "", "", &JournalDeferTransactionError{Stage: "canonical disposition", Err: err} + } + if err := insertAlias(ctx, tx, projectID, "intent", intentID, "intent", intentAlias, timestamp); err != nil { + return "", "", &JournalDeferTransactionError{Stage: "canonical intent alias", Err: err} + } + if _, err := tx.ExecContext(ctx, ` +INSERT INTO intent_operations (project_id, operation_key, intent_id, stored_digest, journal_entry_id, spark_id, projection_version, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?) +`, projectID, normalized.OperationID, intentID, inputDigest, decisionID, sparkID, timestamp, timestamp); err != nil { + return "", "", &JournalDeferTransactionError{Stage: "canonical operation", Err: err} + } + return intentID, intentAlias, nil +} + +// verifyIntentOperationProjectionTx confirms the shared mapping row carries +// exactly this intent and legacy projection pair at version 1. +func verifyIntentOperationProjectionTx(ctx context.Context, tx *sql.Tx, projectID, operationID, intentID, decisionID, sparkID string) error { + var count int + if err := tx.QueryRowContext(ctx, ` +SELECT COUNT(*) FROM intent_operations +WHERE project_id = ? AND operation_key = ? AND intent_id = ? + AND journal_entry_id = ? AND spark_id = ? AND projection_version = 1 +`, projectID, operationID, intentID, decisionID, sparkID).Scan(&count); err != nil { + return fmt.Errorf("verify canonical operation mapping: %w", err) + } + if count != 1 { + return fmt.Errorf("verify canonical operation mapping: expected one version-1 row, got %d", count) + } + return nil +} + +// backfillJournalDeferProjectionsTx materializes the legacy decision/spark +// pair for an operation key that a canonical intent command won first. Content +// comes from the stored canonical deferral packet, never the retry input. +func (s *Store) backfillJournalDeferProjectionsTx(ctx context.Context, tx *sql.Tx, identity ProjectIdentity, normalized JournalDeferOptions, inputDigest, intentID, storedDigest string, mappedVersion int, hooks *journalDeferHooks) (JournalDeferResult, error) { + projectID := identity.ID + if mappedVersion == 1 { + // Both projections already exist (for example through conversion); + // return the established pair without writing anything. + var journalID, sparkID string + if err := tx.QueryRowContext(ctx, ` +SELECT journal_entry_id, spark_id FROM intent_operations +WHERE project_id = ? AND operation_key = ? +`, projectID, normalized.OperationID).Scan(&journalID, &sparkID); err != nil { + return JournalDeferResult{}, &JournalDeferTransactionError{Stage: "load projected operation", Err: err} + } + result, err := loadExistingJournalDeferralTx(ctx, tx, identity, normalized.OperationID, inputDigest, journalID, sparkID, storedDigest) + if err != nil { + return JournalDeferResult{}, &JournalDeferTransactionError{Stage: "load projected operation", Err: err} + } + return result, nil + } + + var body, why, boundary, trigger string + err := tx.QueryRowContext(ctx, ` +SELECT body, why, boundary, revisit_trigger FROM intent_deferrals +WHERE project_id = ? AND operation_key = ? +`, projectID, normalized.OperationID).Scan(&body, &why, &boundary, &trigger) + if errors.Is(err, sql.ErrNoRows) { + return JournalDeferResult{}, &JournalDeferValidationError{Field: "operation_id", Err: fmt.Errorf("operation key %q is already bound to intent %s without a deferral (its disposition is not deferred); use a distinct operation key or defer that intent explicitly", normalized.OperationID, intentID)} + } + if err != nil { + return JournalDeferResult{}, &JournalDeferTransactionError{Stage: "read canonical packet", Err: err} + } + storedPacket := intentDeferralPacket(body, why, boundary, trigger) + + operationDigest := journalDeferOperationDigest(projectID, normalized.OperationID) + decisionID := stableMigrationID("journal-defer-decision", projectID, normalized.OperationID) + sparkID := stableMigrationID("journal-defer-spark", projectID, normalized.OperationID) + alias := "SPARK-DEFER-" + operationDigest[:journalDeferScopePrefixLen] + scope := "defer/" + operationDigest[:journalDeferScopePrefixLen] + now := time.Now().UTC().Format(time.RFC3339Nano) + decisionMessage := storedPacket + "\nSpark: " + sparkID + sparkText := storedPacket + "\nDecision: " + decisionID + + if _, err := tx.ExecContext(ctx, ` +INSERT INTO journal_entries ( + id, project_id, entry_type, scope, message, + observed_branch, observed_worktree, harness_session_id, + spec_id, task_id, created_at, updated_at +) VALUES (?, ?, 'decision', ?, ?, NULL, NULL, NULL, NULL, NULL, ?, ?) +`, decisionID, projectID, scope, decisionMessage, now, now); err != nil { + return JournalDeferResult{}, &JournalDeferTransactionError{Stage: "decision", Err: err} + } + if err := runJournalDeferHook(hooks, "after decision", func(h *journalDeferHooks) func(*sql.Tx) error { return h.afterDecision }, tx); err != nil { + return JournalDeferResult{}, err + } + if err := insertJournalSearchTx(ctx, tx, projectID, decisionID, "", "decision", scope, decisionMessage); err != nil { + return JournalDeferResult{}, &JournalDeferTransactionError{Stage: "fts", Err: err} + } + if err := runJournalDeferHook(hooks, "after FTS", func(h *journalDeferHooks) func(*sql.Tx) error { return h.afterFTS }, tx); err != nil { + return JournalDeferResult{}, err + } + if _, err := tx.ExecContext(ctx, ` +INSERT INTO sparks (id, project_id, scope, status, text, source_id, created_at, updated_at) +VALUES (?, ?, ?, 'open', ?, NULL, ?, ?) +`, sparkID, projectID, scope, sparkText, now, now); err != nil { + return JournalDeferResult{}, &JournalDeferTransactionError{Stage: "spark", Err: err} + } + if err := runJournalDeferHook(hooks, "after spark", func(h *journalDeferHooks) func(*sql.Tx) error { return h.afterSpark }, tx); err != nil { + return JournalDeferResult{}, err + } + if err := insertAlias(ctx, tx, projectID, "spark", sparkID, "spark", alias, now); err != nil { + return JournalDeferResult{}, &JournalDeferTransactionError{Stage: "alias", Err: err} + } + eventID := stableMigrationID("event", projectID, "spark", sparkID, "created", "open") + 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 (?, ?, 'spark', ?, 'status_changed', NULL, 'open', 'recorded by journal defer', ?, ?) +`, eventID, projectID, sparkID, now, now); err != nil { + return JournalDeferResult{}, &JournalDeferTransactionError{Stage: "event", Err: err} + } + if err := runJournalDeferHook(hooks, "after alias/event", func(h *journalDeferHooks) func(*sql.Tx) error { return h.afterAliasEvent }, tx); err != nil { + return JournalDeferResult{}, err + } + if normalized.Origin != nil { + if err := insertJournalOriginTx(ctx, tx, decisionID, *normalized.Origin); err != nil { + return JournalDeferResult{}, &JournalDeferTransactionError{Stage: "origin", Err: err} + } + } + if err := runJournalDeferHook(hooks, "after origin", func(h *journalDeferHooks) func(*sql.Tx) error { return h.afterOrigin }, tx); err != nil { + return JournalDeferResult{}, err + } + if _, err := tx.ExecContext(ctx, ` +INSERT INTO journal_deferrals (project_id, operation_key, journal_entry_id, spark_id, stored_digest, created_at) +VALUES (?, ?, ?, ?, ?, ?) +`, projectID, normalized.OperationID, decisionID, sparkID, storedDigest, now); err != nil { + return JournalDeferResult{}, &JournalDeferTransactionError{Stage: "deferral", Err: err} + } + if err := runJournalDeferHook(hooks, "after deferral", func(h *journalDeferHooks) func(*sql.Tx) error { return h.afterDeferral }, tx); err != nil { + return JournalDeferResult{}, err + } + if _, err := tx.ExecContext(ctx, ` +UPDATE intent_operations +SET journal_entry_id = ?, spark_id = ?, projection_version = 1, updated_at = ? +WHERE project_id = ? AND operation_key = ? AND projection_version = 0 +`, decisionID, sparkID, now, projectID, normalized.OperationID); err != nil { + return JournalDeferResult{}, &JournalDeferTransactionError{Stage: "advance projection", Err: err} + } + if err := runJournalDeferHook(hooks, "after canonical intent", func(h *journalDeferHooks) func(*sql.Tx) error { return h.afterCanonicalIntent }, tx); err != nil { + return JournalDeferResult{}, err + } + if err := verifyJournalDeferralTx(ctx, tx, projectID, normalized.OperationID, decisionID, sparkID); err != nil { + return JournalDeferResult{}, &JournalDeferTransactionError{Stage: "verify", Err: err} + } + if err := verifyIntentOperationProjectionTx(ctx, tx, projectID, normalized.OperationID, intentID, decisionID, sparkID); err != nil { + return JournalDeferResult{}, &JournalDeferTransactionError{Stage: "verify", Err: err} + } + if err := runJournalDeferHook(hooks, "before commit", func(h *journalDeferHooks) func(*sql.Tx) error { return h.beforeCommit }, tx); err != nil { + return JournalDeferResult{}, err + } + origin, err := loadJournalOrigin(ctx, tx, projectID, decisionID) + if err != nil { + return JournalDeferResult{}, &JournalDeferTransactionError{Stage: "read origin", Err: err} + } + result := buildJournalDeferResult(identity, normalized.OperationID, false, inputDigest, storedDigest, inputDigest == storedDigest, decisionID, projectID, scope, decisionMessage, sparkID, alias, sparkText, now, origin) + result.IntentID = intentID + if alias, aliasErr := intentAliasTx(ctx, tx, projectID, intentID); aliasErr == nil { + result.IntentAlias = alias + } + return result, nil +} + +func intentAliasTx(ctx context.Context, tx *sql.Tx, projectID, intentID string) (string, error) { + var alias string + err := tx.QueryRowContext(ctx, ` +SELECT alias FROM aliases WHERE project_id = ? AND entity_kind = 'intent' AND entity_id = ? +ORDER BY namespace, alias LIMIT 1 +`, projectID, intentID).Scan(&alias) + return alias, err +} + func journalDeferOperationDigest(projectID, operationID string) string { digest := sha256.Sum256([]byte(projectID + "\x00" + operationID)) return hex.EncodeToString(digest[:]) @@ -311,7 +582,7 @@ func loadExistingJournalDeferralTx(ctx context.Context, tx *sql.Tx, identity Pro if err != nil { return JournalDeferResult{}, err } - return JournalDeferResult{ + result := JournalDeferResult{ ContractVersion: StateJSONContractVersion, DatabaseScope: identity.DatabaseScope, DatabasePath: identity.DatabasePath, @@ -326,7 +597,23 @@ func loadExistingJournalDeferralTx(ctx context.Context, tx *sql.Tx, identity Pro StoredDigest: storedDigest, InputDigestMatches: inputDigest == storedDigest, Origin: origin, - }, nil + } + // Attach the canonical mapping when the operation key is already bound to + // an Intent; legacy-only rows awaiting conversion carry no intent fields. + var mappedIntentID string + err = tx.QueryRowContext(ctx, ` +SELECT intent_id FROM intent_operations WHERE project_id = ? AND operation_key = ? +`, identity.ID, operationID).Scan(&mappedIntentID) + switch { + case err == nil: + result.IntentID = mappedIntentID + if alias, aliasErr := intentAliasTx(ctx, tx, identity.ID, mappedIntentID); aliasErr == nil { + result.IntentAlias = alias + } + case !errors.Is(err, sql.ErrNoRows): + return JournalDeferResult{}, fmt.Errorf("read canonical mapping for %s: %w", operationID, err) + } + return result, nil } func loadDeferredDecisionTx(ctx context.Context, tx *sql.Tx, projectID, journalID string) (JournalEntryRecord, error) { diff --git a/internal/state/journal_defer_adapter_test.go b/internal/state/journal_defer_adapter_test.go new file mode 100644 index 000000000..d097726a2 --- /dev/null +++ b/internal/state/journal_defer_adapter_test.go @@ -0,0 +1,344 @@ +package state + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "sync" + "testing" + + "github.com/levifig/loaf/internal/project" +) + +func adapterFixture(t *testing.T) (project.Root, *Store, string) { + t.Helper() + root := projectRoot(t) + resolver := PathResolver{StateHome: t.TempDir()} + status, err := Initialize(context.Background(), root, resolver) + 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, status.DatabasePath +} + +func TestJournalDeferAfterCanonicalFirstBackfillsProjections(t *testing.T) { + root, store, path := adapterFixture(t) + ctx := context.Background() + + canonical, err := store.CreateIntent(ctx, root, IntentCreateOptions{ + Title: "Canonical-first deferral", + Body: "canonical body", + Disposition: "deferred", + Why: "canonical why", Boundary: "canonical boundary", Trigger: "canonical trigger", + OperationID: "op-canonical-first", + }) + if err != nil { + t.Fatalf("CreateIntent(deferred) error = %v", err) + } + if got := rawCount(t, path, `SELECT COUNT(*) FROM journal_entries`); got != 0 { + t.Fatalf("journal entries before adapter = %d, want 0", got) + } + + // The adapter retry uses DIFFERENT wording; projections must come from + // the stored canonical packet, not this retry body. + result, err := store.DeferJournal(ctx, root, JournalDeferOptions{ + Intent: "retry wording", Why: "retry why", Boundary: "retry boundary", Trigger: "retry trigger", + OperationID: "op-canonical-first", + }) + if err != nil { + t.Fatalf("DeferJournal(backfill) error = %v", err) + } + if result.Created { + t.Fatal("backfill reported Created = true, want reuse of the canonical first write") + } + if result.IntentID != canonical.Intent.ID || result.IntentAlias != canonical.Intent.Alias { + t.Fatalf("backfill intent = %q/%q, want %q/%q", result.IntentID, result.IntentAlias, canonical.Intent.ID, canonical.Intent.Alias) + } + if result.Decision.ID == "" || result.Spark.ID == "" { + t.Fatalf("backfill result = %#v, want established legacy IDs", result) + } + if result.InputDigestMatches { + t.Fatal("backfill digest match = true, want mismatch for reworded retry") + } + wantPacket := "Intent: canonical body\nWhy: canonical why\nBoundary: canonical boundary\nTrigger: canonical trigger" + if !strings.HasPrefix(result.Decision.Message, wantPacket) { + t.Fatalf("decision message %q does not carry the stored canonical packet", result.Decision.Message) + } + if strings.Contains(result.Decision.Message, "retry why") { + t.Fatal("decision message carries retry wording, want stored canonical content only") + } + + var version int + var journalID, sparkID string + if err := store.db.QueryRowContext(ctx, ` +SELECT projection_version, journal_entry_id, spark_id FROM intent_operations WHERE operation_key = 'op-canonical-first' +`).Scan(&version, &journalID, &sparkID); err != nil { + t.Fatalf("read mapping: %v", err) + } + if version != 1 || journalID != result.Decision.ID || sparkID != result.Spark.ID { + t.Fatalf("mapping = v%d %q/%q, want v1 with established pair", version, journalID, sparkID) + } + if got := rawCount(t, path, `SELECT COUNT(*) FROM intents`); got != 1 { + t.Fatalf("intents = %d, want the single canonical intent", got) + } + + // A second adapter retry now takes the established-pair path. + again, err := store.DeferJournal(ctx, root, JournalDeferOptions{ + Intent: "canonical body", Why: "canonical why", Boundary: "canonical boundary", Trigger: "canonical trigger", + OperationID: "op-canonical-first", + }) + if err != nil { + t.Fatalf("DeferJournal(second retry) error = %v", err) + } + if again.Created || again.Decision.ID != result.Decision.ID || again.Spark.ID != result.Spark.ID || !again.InputDigestMatches { + t.Fatalf("second retry = %#v, want established pair with digest match", again) + } + if got := rawCount(t, path, `SELECT COUNT(*) FROM journal_entries`); got != 1 { + t.Fatalf("journal entries after second retry = %d, want 1", got) + } +} + +func TestCanonicalAfterAdapterFirstReusesMappedIntent(t *testing.T) { + root, store, path := adapterFixture(t) + ctx := context.Background() + + adapter, err := store.DeferJournal(ctx, root, JournalDeferOptions{ + Intent: "adapter-first body", Why: "why", Boundary: "boundary", Trigger: "trigger", + OperationID: "op-adapter-first", + }) + if err != nil { + t.Fatalf("DeferJournal(adapter-first) error = %v", err) + } + if !adapter.Created || adapter.IntentID == "" || adapter.IntentAlias == "" { + t.Fatalf("adapter-first = %#v, want created with canonical intent identity", adapter) + } + + // A later canonical call with the same key reuses the mapped intent and + // leaves the existing projections untouched. + canonical, err := store.CreateIntent(ctx, root, IntentCreateOptions{ + Title: "different title", + Body: "different body", + Disposition: "deferred", + Why: "different why", Boundary: "different boundary", Trigger: "different trigger", + OperationID: "op-adapter-first", + }) + if err != nil { + t.Fatalf("CreateIntent(retry) error = %v", err) + } + if canonical.Created || canonical.Intent.ID != adapter.IntentID { + t.Fatalf("canonical retry = %#v, want reuse of adapter-first intent %q", canonical, adapter.IntentID) + } + if canonical.InputDigestMatches { + t.Fatal("canonical reworded retry digest match = true, want mismatch") + } + + var version int + var journalID, sparkID string + if err := store.db.QueryRowContext(ctx, ` +SELECT projection_version, journal_entry_id, spark_id FROM intent_operations WHERE operation_key = 'op-adapter-first' +`).Scan(&version, &journalID, &sparkID); err != nil { + t.Fatalf("read mapping: %v", err) + } + if version != 1 || journalID != adapter.Decision.ID || sparkID != adapter.Spark.ID { + t.Fatalf("mapping = v%d %q/%q, want unchanged adapter projections", version, journalID, sparkID) + } + for query, want := range map[string]int{ + `SELECT COUNT(*) FROM intents`: 1, + `SELECT COUNT(*) FROM journal_entries`: 1, + `SELECT COUNT(*) FROM sparks`: 1, + `SELECT COUNT(*) FROM intent_deferrals`: 1, + } { + if got := rawCount(t, path, query); got != want { + t.Fatalf("%s = %d, want %d", query, got, want) + } + } +} + +func TestConcurrentMixedEntryPointsConvergeOnOneIntentAndOnePair(t *testing.T) { + root, store, path := adapterFixture(t) + ctx := context.Background() + + const writers = 8 + var wg sync.WaitGroup + intentIDs := make([]string, writers) + errs := make([]error, writers) + for i := 0; i < writers; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + if i%2 == 0 { + result, err := store.CreateIntent(ctx, root, IntentCreateOptions{ + Title: "Mixed entry", + Body: "mixed body", + Disposition: "deferred", + Why: "why", Boundary: "boundary", Trigger: "trigger", + OperationID: "op-mixed", + }) + intentIDs[i], errs[i] = result.Intent.ID, err + return + } + result, err := store.DeferJournal(ctx, root, JournalDeferOptions{ + Intent: "mixed body", Why: "why", Boundary: "boundary", Trigger: "trigger", + OperationID: "op-mixed", + }) + intentIDs[i], errs[i] = result.IntentID, err + }(i) + } + wg.Wait() + + for i := 0; i < writers; i++ { + if errs[i] != nil { + t.Fatalf("writer %d error = %v", i, errs[i]) + } + } + canonical := intentIDs[0] + for i := 1; i < writers; i++ { + if intentIDs[i] != canonical { + t.Fatalf("writer %d intent %q, want converged %q", i, intentIDs[i], canonical) + } + } + for query, want := range map[string]int{ + `SELECT COUNT(*) FROM intents`: 1, + `SELECT COUNT(*) FROM intent_deferrals`: 1, + `SELECT COUNT(*) FROM intent_operations`: 1, + `SELECT COUNT(*) FROM intent_operations WHERE projection_version = 1`: 1, + `SELECT COUNT(*) FROM journal_entries`: 1, + `SELECT COUNT(*) FROM sparks`: 1, + `SELECT COUNT(*) FROM journal_deferrals`: 1, + } { + if got := rawCount(t, path, query); got != want { + t.Fatalf("%s = %d, want %d", query, got, want) + } + } +} + +func TestBackfillProjectionAdvanceIsAllOrNothing(t *testing.T) { + stages := []struct { + name string + set func(*journalDeferHooks, error) + }{ + {"after decision", func(h *journalDeferHooks, err error) { h.afterDecision = func(*sql.Tx) error { return err } }}, + {"after FTS", func(h *journalDeferHooks, err error) { h.afterFTS = func(*sql.Tx) error { return err } }}, + {"after spark", func(h *journalDeferHooks, err error) { h.afterSpark = func(*sql.Tx) error { return err } }}, + {"after alias/event", func(h *journalDeferHooks, err error) { h.afterAliasEvent = func(*sql.Tx) error { return err } }}, + {"after deferral", func(h *journalDeferHooks, err error) { h.afterDeferral = func(*sql.Tx) error { return err } }}, + {"after canonical intent", func(h *journalDeferHooks, err error) { h.afterCanonicalIntent = func(*sql.Tx) error { return err } }}, + {"before commit", func(h *journalDeferHooks, err error) { h.beforeCommit = func(*sql.Tx) error { return err } }}, + } + for _, stage := range stages { + t.Run(strings.ReplaceAll(stage.name, " ", "-"), func(t *testing.T) { + root, store, path := adapterFixture(t) + ctx := context.Background() + if _, err := store.CreateIntent(ctx, root, IntentCreateOptions{ + Title: "Backfill target", + Body: "body", + Disposition: "deferred", + Why: "why", Boundary: "boundary", Trigger: "trigger", + OperationID: "op-backfill", + }); err != nil { + t.Fatalf("CreateIntent(deferred) error = %v", err) + } + + countState := func() string { + parts := []string{} + for _, query := range []string{ + `SELECT COUNT(*) FROM journal_entries`, + `SELECT COUNT(*) FROM sparks`, + `SELECT COUNT(*) FROM journal_deferrals`, + `SELECT COUNT(*) FROM intent_operations WHERE projection_version = 1`, + `SELECT COUNT(*) FROM intent_operations WHERE projection_version = 0`, + } { + parts = append(parts, fmt.Sprintf("%d", rawCount(t, path, query))) + } + return strings.Join(parts, "/") + } + before := countState() + if before != "0/0/0/0/1" { + t.Fatalf("precondition = %s, want 0/0/0/0/1", before) + } + + hooks := &journalDeferHooks{} + stage.set(hooks, errors.New("injected failure")) + _, err := store.deferJournalWithHooks(ctx, root, JournalDeferOptions{ + Intent: "body", Why: "why", Boundary: "boundary", Trigger: "trigger", + OperationID: "op-backfill", + }, hooks) + if err == nil { + t.Fatalf("stage %s: error = nil, want injected failure", stage.name) + } + if after := countState(); after != before { + t.Fatalf("stage %s left partial backfill: before=%s after=%s", stage.name, before, after) + } + + // After the failed backfill, a clean retry completes 0→1 exactly once. + result, err := store.DeferJournal(ctx, root, JournalDeferOptions{ + Intent: "body", Why: "why", Boundary: "boundary", Trigger: "trigger", + OperationID: "op-backfill", + }) + if err != nil { + t.Fatalf("clean retry error = %v", err) + } + if result.Created || result.Decision.ID == "" || result.Spark.ID == "" || !result.InputDigestMatches { + t.Fatalf("clean retry = %#v, want established projections with digest match", result) + } + if got := countState(); got != "1/1/1/1/0" { + t.Fatalf("state after clean retry = %s, want 1/1/1/1/0", got) + } + }) + } +} + +func TestLegacyOnlyDeferralRetryStaysLegacyUntilConversion(t *testing.T) { + root, store, path := adapterFixture(t) + ctx := context.Background() + projectID := projectIDForTest(t, store, root) + + // Simulate a pre-migration legacy deferral: journal_deferrals pair with no + // canonical mapping, as real databases hold before explicit conversion. + packet := "Intent: legacy body\nWhy: legacy why\nBoundary: legacy boundary\nTrigger: legacy trigger" + digest := intentDigest(packet) + decisionID := stableMigrationID("journal-defer-decision", projectID, "op-legacy") + sparkID := stableMigrationID("journal-defer-spark", projectID, "op-legacy") + now := "2026-07-01T00:00:00Z" + mustExecSchemaSQL(t, store, ` +INSERT INTO journal_entries (id, project_id, entry_type, scope, message, created_at, updated_at) +VALUES (?, ?, 'decision', 'defer/legacy', ?, ?, ?) +`, decisionID, projectID, packet+"\nSpark: "+sparkID, now, now) + mustExecSchemaSQL(t, store, ` +INSERT INTO journal_search (rowid, journal_entry_id, project_id, session_id, entry_type, scope, message) +SELECT rowid, id, project_id, '', 'decision', 'defer/legacy', ? FROM journal_entries WHERE id = ? +`, packet+"\nSpark: "+sparkID, decisionID) + mustExecSchemaSQL(t, store, ` +INSERT INTO sparks (id, project_id, scope, status, text, created_at, updated_at) +VALUES (?, ?, 'defer/legacy', 'open', ?, ?, ?) +`, sparkID, projectID, packet+"\nDecision: "+decisionID, now, now) + mustExecSchemaSQL(t, store, `INSERT INTO aliases (id, project_id, entity_kind, entity_id, namespace, alias, created_at, updated_at) VALUES ('alias:legacy', ?, 'spark', ?, 'spark', 'SPARK-DEFER-legacy', ?, ?)`, projectID, sparkID, now, now) + mustExecSchemaSQL(t, store, ` +INSERT INTO journal_deferrals (project_id, operation_key, journal_entry_id, spark_id, stored_digest, created_at) +VALUES (?, 'op-legacy', ?, ?, ?, ?) +`, projectID, decisionID, sparkID, digest, now) + + retry, err := store.DeferJournal(ctx, root, JournalDeferOptions{ + Intent: "legacy body", Why: "legacy why", Boundary: "legacy boundary", Trigger: "legacy trigger", + OperationID: "op-legacy", + }) + if err != nil { + t.Fatalf("DeferJournal(legacy retry) error = %v", err) + } + if retry.Created || retry.Decision.ID != decisionID || retry.Spark.ID != sparkID || !retry.InputDigestMatches { + t.Fatalf("legacy retry = %#v, want established legacy pair", retry) + } + if retry.IntentID != "" { + t.Fatalf("legacy retry intent = %q, want empty until explicit conversion", retry.IntentID) + } + if got := rawCount(t, path, `SELECT COUNT(*) FROM intents`); got != 0 { + t.Fatalf("intents after legacy retry = %d, want 0 (no implicit conversion)", got) + } +} diff --git a/internal/state/journal_defer_test.go b/internal/state/journal_defer_test.go index 2f3ab3d7f..91fca231e 100644 --- a/internal/state/journal_defer_test.go +++ b/internal/state/journal_defer_test.go @@ -396,11 +396,28 @@ func assertNoDeferredRows(t *testing.T, databasePath string) { func assertDeferredCounts(t *testing.T, databasePath string, journal, search, sparks, aliases, events, deferrals int) { t.Helper() - for table, want := range map[string]int{"journal_entries": journal, "journal_search": search, "sparks": sparks, "aliases": aliases, "events": events, "journal_deferrals": deferrals} { + for table, want := range map[string]int{"journal_entries": journal, "journal_search": search, "sparks": sparks, "events": events, "journal_deferrals": deferrals} { if got := rawCount(t, databasePath, `SELECT COUNT(*) FROM `+table); got != want { t.Fatalf("%s rows = %d, want %d", table, got, want) } } + if got := rawCount(t, databasePath, `SELECT COUNT(*) FROM aliases WHERE namespace = 'spark'`); got != aliases { + t.Fatalf("spark alias rows = %d, want %d", got, aliases) + } + // The adapter can no longer create a legacy-only deferral: every legacy + // pair must be backed by one canonical Intent, one immutable deferral + // payload, and one version-1 operation mapping carrying the pair IDs. + for query, want := range map[string]int{ + `SELECT COUNT(*) FROM intents`: deferrals, + `SELECT COUNT(*) FROM intent_deferrals`: deferrals, + `SELECT COUNT(*) FROM intent_operations WHERE projection_version = 1`: deferrals, + `SELECT COUNT(*) FROM intent_operations WHERE journal_entry_id IS NULL`: 0, + `SELECT COUNT(*) FROM aliases WHERE namespace = 'intent'`: deferrals, + } { + if got := rawCount(t, databasePath, query); got != want { + t.Fatalf("%s = %d, want %d", query, got, want) + } + } } func statusDatabasePath(t *testing.T, root project.Root, resolver PathResolver) string { diff --git a/internal/state/journal_first_migration.go b/internal/state/journal_first_migration.go index c742fd289..da94717b6 100644 --- a/internal/state/journal_first_migration.go +++ b/internal/state/journal_first_migration.go @@ -381,9 +381,9 @@ func runJournalFirstMigrationWithHooksAndSource(ctx context.Context, store *Stor } } // Apply migrations 1-9 plus migration 10 in order, then prune optional - // provenance and apply migration 11 if absent. All destructive work, - // migration records, pruning, and parity verification remain uncommitted - // until every pre-commit check succeeds. + // provenance and apply every canonical migration above 10 if absent. All + // destructive work, migration records, pruning, and parity verification + // remain uncommitted until every pre-commit check succeeds. preJournalMigrations := make([]SchemaMigration, 0, len(SchemaMigrations())+1) for _, migration := range SchemaMigrations() { if migration.Version < journalFirstMigrationVersion { @@ -412,8 +412,13 @@ func runJournalFirstMigrationWithHooksAndSource(ctx context.Context, store *Stor return fmt.Errorf("journal-first after-prune seam: %w", err) } } - if err := applyMigration(ctx, tx, journalOriginsMigration()); err != nil { - return fmt.Errorf("apply journal origins migration after journal-first migration: %w", err) + for _, migration := range SchemaMigrations() { + if migration.Version <= journalFirstMigrationVersion { + continue + } + if err := applyMigration(ctx, tx, migration); err != nil { + return fmt.Errorf("apply post-journal-first migration %d: %w", migration.Version, err) + } } if _, err := rebuildAndVerifyJournalSearch(ctx, tx); err != nil { return fmt.Errorf("rebuild journal search after journal-first migration: %w", err) diff --git a/internal/state/journal_first_migration_test.go b/internal/state/journal_first_migration_test.go index 9354bbde6..f2d379099 100644 --- a/internal/state/journal_first_migration_test.go +++ b/internal/state/journal_first_migration_test.go @@ -537,16 +537,16 @@ func TestBackupVerifiesMigratedJournalFirstDatabase(t *testing.T) { } func TestJournalFirstMigrationExcludedFromAutoApply(t *testing.T) { - if CurrentSchemaVersion() != 11 { - t.Fatalf("CurrentSchemaVersion() = %d, want 11 (migration 10 must not auto-apply on store open)", CurrentSchemaVersion()) + if CurrentSchemaVersion() != 12 { + t.Fatalf("CurrentSchemaVersion() = %d, want 12 (migration 10 must not auto-apply on store open)", CurrentSchemaVersion()) } for _, m := range SchemaMigrations() { if m.Version == journalFirstMigrationVersion { t.Fatalf("journal-first migration %d must be excluded from SchemaMigrations()", journalFirstMigrationVersion) } } - if got := SchemaMigrations()[len(SchemaMigrations())-1].Version; got != journalOriginsMigrationVersion { - t.Fatalf("last auto-applied migration = %d, want %d", got, journalOriginsMigrationVersion) + if got := SchemaMigrations()[len(SchemaMigrations())-1].Version; got <= journalOriginsMigrationVersion { + t.Fatalf("last auto-applied migration = %d, want a version above %d", got, journalOriginsMigrationVersion) } m := JournalFirstMigration() if m.Version != 10 || m.Name != "journal_first" { diff --git a/internal/state/journal_origins_migration_test.go b/internal/state/journal_origins_migration_test.go index 0f81cc87b..4f361e90f 100644 --- a/internal/state/journal_origins_migration_test.go +++ b/internal/state/journal_origins_migration_test.go @@ -83,11 +83,17 @@ func TestJournalOriginsMigrationAppliesAfterExplicitSchema10(t *testing.T) { if got, _ := store.SchemaVersion(ctx); got != journalFirstMigrationVersion { t.Fatalf("schema version after explicit 10 = %d, want %d", got, journalFirstMigrationVersion) } - if err := ApplyMigrations(ctx, store.db, []SchemaMigration{journalOriginsMigration()}); err != nil { - t.Fatalf("ApplyMigrations(10->11) error = %v", err) + postJournalFirst := []SchemaMigration{} + for _, migration := range SchemaMigrations() { + if migration.Version > journalFirstMigrationVersion { + postJournalFirst = append(postJournalFirst, migration) + } + } + if err := ApplyMigrations(ctx, store.db, postJournalFirst); err != nil { + t.Fatalf("ApplyMigrations(10->current) error = %v", err) } if got, _ := store.SchemaVersion(ctx); got != CurrentSchemaVersion() { - t.Fatalf("schema version after 10->11 = %d, want %d", got, CurrentSchemaVersion()) + t.Fatalf("schema version after 10->current = %d, want %d", got, CurrentSchemaVersion()) } var mechanism, branch, worktree, harness string if err := store.db.QueryRowContext(ctx, `SELECT capture_mechanism, branch, worktree, harness_session_id FROM journal_origins WHERE journal_entry_id = 'journal-ten'`).Scan(&mechanism, &branch, &worktree, &harness); err != nil { diff --git a/internal/state/link.go b/internal/state/link.go index cc06de09e..cc4a041cf 100644 --- a/internal/state/link.go +++ b/internal/state/link.go @@ -215,6 +215,11 @@ func (s *Store) resolveLinkOptions(ctx context.Context, projectID string, option if err != nil { return TraceEntity{}, TraceEntity{}, "", "", err } + // Relationships touching the Intent/Exploration kinds must match the + // closed registry matrix; legacy-to-legacy links keep their behavior. + if err := validateRelationshipAgainstRegistry(from.Kind, relationshipType, to.Kind); err != nil { + return TraceEntity{}, TraceEntity{}, "", "", err + } reason := strings.TrimSpace(options.Reason) if reason == "" { reason = "recorded by link create" diff --git a/internal/state/migrations/0012_intents_and_explorations.sql b/internal/state/migrations/0012_intents_and_explorations.sql new file mode 100644 index 000000000..cd41424b2 --- /dev/null +++ b/internal/state/migrations/0012_intents_and_explorations.sql @@ -0,0 +1,251 @@ +-- Intent and Exploration relational foundation. +-- +-- Every table in this migration is additive and append-only operational state: +-- stable identities, immutable content snapshots, transactionally sequenced +-- facts, and normalized conversation provenance. There is no mutable lifecycle +-- status, no current-session pointer, and no raw transcript storage. +-- +-- Like migration 0011, tables that reference journal_entries or sparks +-- deliberately do not foreign-key them: the explicit journal-first migration +-- 0010 may rebuild journal_entries after this migration has run on a schema-9 +-- database. Referential integrity for those optional projections is audited by +-- the owning workflows. Foreign keys to projects and to the new aggregate +-- roots are hard because those tables are never rebuilt. + +CREATE TABLE IF NOT EXISTS intents ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + UNIQUE (project_id, id) +); +CREATE INDEX IF NOT EXISTS idx_intents_project ON intents (project_id, created_at); + +-- Immutable content revisions. The latest snapshot is derived from the +-- greatest committed per-intent sequence, never from timestamps. +CREATE TABLE IF NOT EXISTS intent_snapshots ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + intent_id TEXT NOT NULL, + seq INTEGER NOT NULL CHECK (seq >= 1), + title TEXT NOT NULL CHECK (length(trim(title)) > 0), + body TEXT NOT NULL CHECK (length(trim(body)) > 0), + content_digest TEXT NOT NULL CHECK (length(content_digest) = 64 AND content_digest NOT GLOB '*[^0-9a-fA-F]*'), + created_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (project_id, intent_id) REFERENCES intents(project_id, id), + UNIQUE (intent_id, seq) +); + +-- Immutable self-sufficient deferral payloads. The retained body plus why, +-- boundary, and revisit trigger form the portable contract; per-field byte +-- caps are enforced by the write path before insert. +CREATE TABLE IF NOT EXISTS intent_deferrals ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + intent_id TEXT NOT NULL, + operation_key TEXT NOT NULL CHECK (length(trim(operation_key)) BETWEEN 1 AND 200), + body TEXT NOT NULL CHECK (length(trim(body)) > 0), + why TEXT NOT NULL CHECK (length(trim(why)) > 0), + boundary TEXT NOT NULL CHECK (length(trim(boundary)) > 0), + revisit_trigger TEXT NOT NULL CHECK (length(trim(revisit_trigger)) > 0), + stored_digest TEXT NOT NULL CHECK (length(stored_digest) = 64 AND stored_digest NOT GLOB '*[^0-9a-fA-F]*'), + created_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (project_id, intent_id) REFERENCES intents(project_id, id), + UNIQUE (project_id, operation_key), + UNIQUE (project_id, id), + UNIQUE (intent_id, id) +); +CREATE INDEX IF NOT EXISTS idx_intent_deferrals_intent ON intent_deferrals (intent_id, created_at); + +-- Append-only disposition facts. Current disposition is the row with the +-- greatest committed per-intent sequence; concurrent appends are serialized by +-- the (intent_id, seq) uniqueness constraint rather than wall-clock time. +CREATE TABLE IF NOT EXISTS intent_dispositions ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + intent_id TEXT NOT NULL, + seq INTEGER NOT NULL CHECK (seq >= 1), + disposition TEXT NOT NULL CHECK (disposition IN ('tracked', 'deferred', 'resolved')), + reason TEXT, + deferral_id TEXT, + supersedes_deferral_id TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (project_id, intent_id) REFERENCES intents(project_id, id), + FOREIGN KEY (project_id, deferral_id) REFERENCES intent_deferrals(project_id, id), + FOREIGN KEY (project_id, supersedes_deferral_id) REFERENCES intent_deferrals(project_id, id), + FOREIGN KEY (intent_id, deferral_id) REFERENCES intent_deferrals(intent_id, id), + FOREIGN KEY (intent_id, supersedes_deferral_id) REFERENCES intent_deferrals(intent_id, id), + UNIQUE (intent_id, seq), + CHECK ((disposition = 'deferred') = (deferral_id IS NOT NULL)), + CHECK (supersedes_deferral_id IS NULL OR disposition = 'tracked') +); + +-- One canonical operation mapping shared by intent create, intent defer, the +-- transitional journal defer adapter, and legacy conversion. Projection +-- version 0 records a canonical-first write with no legacy journal/spark +-- projection; version 1 requires both historical projection references. +CREATE TABLE IF NOT EXISTS intent_operations ( + project_id TEXT NOT NULL, + operation_key TEXT NOT NULL CHECK (length(trim(operation_key)) BETWEEN 1 AND 200), + intent_id TEXT NOT NULL, + stored_digest TEXT NOT NULL CHECK (length(stored_digest) = 64 AND stored_digest NOT GLOB '*[^0-9a-fA-F]*'), + journal_entry_id TEXT, + spark_id TEXT, + projection_version INTEGER NOT NULL CHECK (projection_version IN (0, 1)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (project_id, operation_key), + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (project_id, intent_id) REFERENCES intents(project_id, id), + CHECK ((projection_version = 0 AND journal_entry_id IS NULL AND spark_id IS NULL) OR (projection_version = 1 AND journal_entry_id IS NOT NULL AND spark_id IS NOT NULL)) +); +CREATE INDEX IF NOT EXISTS idx_intent_operations_intent ON intent_operations (intent_id); + +CREATE TABLE IF NOT EXISTS explorations ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + title TEXT NOT NULL CHECK (length(trim(title)) > 0), + created_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + UNIQUE (project_id, id) +); +CREATE INDEX IF NOT EXISTS idx_explorations_project ON explorations (project_id, created_at); + +-- Immutable portable checkpoints. A checkpoint is portable only when all four +-- required core fields are present; per-field 4096 UTF-8 byte caps are +-- enforced by the write path, which rejects overflow without truncation. +CREATE TABLE IF NOT EXISTS exploration_checkpoints ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + exploration_id TEXT NOT NULL, + seq INTEGER NOT NULL CHECK (seq >= 1), + purpose TEXT NOT NULL CHECK (length(trim(purpose)) > 0), + conclusions TEXT NOT NULL CHECK (length(trim(conclusions)) > 0), + unresolved TEXT NOT NULL CHECK (length(trim(unresolved)) > 0), + next_action TEXT NOT NULL CHECK (length(trim(next_action)) > 0), + operation_key TEXT CHECK (operation_key IS NULL OR length(trim(operation_key)) BETWEEN 1 AND 200), + content_digest TEXT NOT NULL CHECK (length(content_digest) = 64 AND content_digest NOT GLOB '*[^0-9a-fA-F]*'), + created_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (project_id, exploration_id) REFERENCES explorations(project_id, id), + UNIQUE (exploration_id, seq), + UNIQUE (project_id, id) +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_exploration_checkpoints_operation + ON exploration_checkpoints (project_id, operation_key) + WHERE operation_key IS NOT NULL; + +-- Ordered typed checkpoint items carry bounded optional detail such as +-- candidates and evidence; item types are validated by the central registry. +CREATE TABLE IF NOT EXISTS exploration_checkpoint_items ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + checkpoint_id TEXT NOT NULL, + item_type TEXT NOT NULL CHECK (length(trim(item_type)) > 0), + position INTEGER NOT NULL CHECK (position >= 1), + content TEXT NOT NULL CHECK (length(trim(content)) > 0), + created_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (project_id, checkpoint_id) REFERENCES exploration_checkpoints(project_id, id), + UNIQUE (checkpoint_id, position) +); + +-- A logical conversation groups machine-local handles. It carries no session +-- lifecycle and is never inferred from branch, worktree, or recency. +CREATE TABLE IF NOT EXISTS logical_conversations ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + title TEXT NOT NULL CHECK (length(trim(title)) > 0), + operation_key TEXT CHECK (operation_key IS NULL OR length(trim(operation_key)) BETWEEN 1 AND 200), + created_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + UNIQUE (project_id, id) +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_logical_conversations_operation + ON logical_conversations (project_id, operation_key) + WHERE operation_key IS NOT NULL; + +-- Machine/harness-local conversation handles: opaque IDs plus locality. The +-- presence of a handle never implies portable context or reachability. +CREATE TABLE IF NOT EXISTS conversation_handles ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + conversation_id TEXT NOT NULL, + harness TEXT NOT NULL CHECK (length(trim(harness)) > 0), + handle TEXT NOT NULL CHECK (length(trim(handle)) > 0), + locality TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (project_id, conversation_id) REFERENCES logical_conversations(project_id, id), + UNIQUE (project_id, id) +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_conversation_handles_identity + ON conversation_handles (conversation_id, harness, handle, COALESCE(locality, '')); + +-- Bounded log references: locators, hashes, and ranges only, never transcript +-- bodies. Availability lives in observations, not on the reference row. +CREATE TABLE IF NOT EXISTS conversation_log_refs ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + handle_id TEXT NOT NULL, + locator TEXT NOT NULL CHECK (length(trim(locator)) > 0), + content_hash TEXT CHECK (content_hash IS NULL OR (length(content_hash) = 64 AND content_hash NOT GLOB '*[^0-9a-fA-F]*')), + range_spec TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (project_id, handle_id) REFERENCES conversation_handles(project_id, id) +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_conversation_log_refs_identity + ON conversation_log_refs (handle_id, locator, COALESCE(range_spec, '')); + +-- Exploration <-> logical conversation membership. +CREATE TABLE IF NOT EXISTS exploration_conversations ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + exploration_id TEXT NOT NULL, + conversation_id TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (project_id, exploration_id) REFERENCES explorations(project_id, id), + FOREIGN KEY (project_id, conversation_id) REFERENCES logical_conversations(project_id, id), + UNIQUE (exploration_id, conversation_id) +); +CREATE INDEX IF NOT EXISTS idx_exploration_conversations_conversation + ON exploration_conversations (conversation_id); + +-- Journal <-> conversation-handle association. journal_entry_id is not a +-- foreign key because migration 0010 may rebuild journal_entries later. +CREATE TABLE IF NOT EXISTS journal_conversation_handles ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + journal_entry_id TEXT NOT NULL, + handle_id TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (project_id, handle_id) REFERENCES conversation_handles(project_id, id), + UNIQUE (journal_entry_id, handle_id) +); +CREATE INDEX IF NOT EXISTS idx_journal_conversation_handles_handle + ON journal_conversation_handles (handle_id); + +-- Immutable availability observations for handles and log references. +-- Reachability is observed at a moment from a locality; it is never a mutable +-- property of the observed row. +CREATE TABLE IF NOT EXISTS source_availability_observations ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + subject_kind TEXT NOT NULL CHECK (subject_kind IN ('conversation_handle', 'conversation_log_ref')), + subject_id TEXT NOT NULL, + observed_at TEXT NOT NULL, + observer TEXT, + locality TEXT, + available INTEGER NOT NULL CHECK (available IN (0, 1)), + note TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id) +); +CREATE INDEX IF NOT EXISTS idx_source_availability_subject + ON source_availability_observations (subject_kind, subject_id, observed_at); diff --git a/internal/state/project_delete.go b/internal/state/project_delete.go index 4030008e3..c976f3fae 100644 --- a/internal/state/project_delete.go +++ b/internal/state/project_delete.go @@ -25,6 +25,20 @@ type ProjectDeleteResult struct { // be cleared when a project is removed. artifact_bodies and docs_index are handled // separately because they back FTS5 indexes. var projectScopedDeleteTables = []string{ + "source_availability_observations", + "journal_conversation_handles", + "conversation_log_refs", + "conversation_handles", + "exploration_conversations", + "logical_conversations", + "exploration_checkpoint_items", + "exploration_checkpoints", + "explorations", + "intent_operations", + "intent_dispositions", + "intent_deferrals", + "intent_snapshots", + "intents", "journal_deferrals", "journal_origins", "verdicts", diff --git a/internal/state/schema.go b/internal/state/schema.go index 2506cd273..4a0a5839d 100644 --- a/internal/state/schema.go +++ b/internal/state/schema.go @@ -40,6 +40,9 @@ var journalFirstSQL string //go:embed migrations/0011_journal_origins_and_deferrals.sql var journalOriginsAndDeferralsSQL string +//go:embed migrations/0012_intents_and_explorations.sql +var intentsAndExplorationsSQL string + const schemaMigrationsDDL = `CREATE TABLE IF NOT EXISTS schema_migrations ( version INTEGER PRIMARY KEY NOT NULL, name TEXT NOT NULL, @@ -108,6 +111,11 @@ func SchemaMigrations() []SchemaMigration { Name: "journal_origins_and_deferrals", SQL: normalizeMigrationSQL(journalOriginsAndDeferralsSQL), }, + { + Version: 12, + Name: "intents_and_explorations", + SQL: normalizeMigrationSQL(intentsAndExplorationsSQL), + }, } } diff --git a/internal/state/schema_test.go b/internal/state/schema_test.go index f198cd1b3..ba697cc54 100644 --- a/internal/state/schema_test.go +++ b/internal/state/schema_test.go @@ -45,16 +45,30 @@ var requiredInitialTables = []string{ "councils", "journal_origins", "journal_deferrals", + "intents", + "intent_snapshots", + "intent_deferrals", + "intent_dispositions", + "intent_operations", + "explorations", + "exploration_checkpoints", + "exploration_checkpoint_items", + "logical_conversations", + "conversation_handles", + "conversation_log_refs", + "exploration_conversations", + "journal_conversation_handles", + "source_availability_observations", "schema_migrations", } func TestSchemaMigrationsAreOrderedAndChecksummed(t *testing.T) { migrations := SchemaMigrations() - if len(migrations) != 10 { - t.Fatalf("len(SchemaMigrations()) = %d, want 10", len(migrations)) + if len(migrations) != 11 { + t.Fatalf("len(SchemaMigrations()) = %d, want 11", len(migrations)) } - wantVersions := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 11} + wantVersions := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12} for i, migration := range migrations { if migration.Version != wantVersions[i] { t.Fatalf("migration[%d].Version = %d, want %d", i, migration.Version, wantVersions[i]) @@ -124,19 +138,39 @@ func TestInitialSchemaContainsRequiredTableSet(t *testing.T) { } func TestOperationalTablesHaveStableIDsAndTimestamps(t *testing.T) { + // Append-only fact tables are immutable after insert: they carry stable + // IDs and created_at but deliberately no updated_at column. + appendOnly := map[string]bool{ + "intents": true, + "intent_snapshots": true, + "intent_deferrals": true, + "intent_dispositions": true, + "explorations": true, + "exploration_checkpoints": true, + "exploration_checkpoint_items": true, + "logical_conversations": true, + "conversation_handles": true, + "conversation_log_refs": true, + "exploration_conversations": true, + "journal_conversation_handles": true, + "source_availability_observations": true, + } sql := currentSchemaSQL() for _, table := range requiredInitialTables { - if table == "schema_migrations" || table == "journal_origins" || table == "journal_deferrals" { + if table == "schema_migrations" || table == "journal_origins" || table == "journal_deferrals" || table == "intent_operations" { continue } body := tableBody(t, sql, table) - for _, required := range []string{ + required := []string{ "id TEXT PRIMARY KEY NOT NULL", "created_at TEXT NOT NULL", - "updated_at TEXT NOT NULL", - } { - if !strings.Contains(body, required) { - t.Fatalf("%s is missing %q in:\n%s", table, required, body) + } + if !appendOnly[table] { + required = append(required, "updated_at TEXT NOT NULL") + } + for _, column := range required { + if !strings.Contains(body, column) { + t.Fatalf("%s is missing %q in:\n%s", table, column, body) } } } @@ -277,6 +311,10 @@ func TestSchemaDocumentationMirrorsExecutableMigration(t *testing.T) { if sqlDoc != SchemaMigrations()[9].SQL { t.Fatal("docs/schema/0011_journal_origins_and_deferrals.sql must match embedded migration 0011 exactly") } + sqlDoc = readRepoFile(t, "docs", "schema", "0012_intents_and_explorations.sql") + if sqlDoc != SchemaMigrations()[10].SQL { + t.Fatal("docs/schema/0012_intents_and_explorations.sql must match embedded migration 0012 exactly") + } dbmlDoc := readRepoFile(t, "docs", "schema", "operational-state.dbml") mermaidDoc := readRepoFile(t, "docs", "schema", "operational-state.mmd") @@ -301,25 +339,50 @@ func TestSchemaDocumentationMirrorsExecutableMigration(t *testing.T) { wantMermaidRelationships := []string{ "bundles ||--o{ bundle_members : contains", + "conversation_handles ||--o{ conversation_log_refs : locates", + "conversation_handles ||--o{ journal_conversation_handles : correlates", + "exploration_checkpoints ||--o{ exploration_checkpoint_items : contains", + "explorations ||--o{ exploration_checkpoints : checkpoints", + "explorations ||--o{ exploration_conversations : spans", "findings ||--o{ verdicts : adjudicates", + "intent_deferrals ||--o{ intent_dispositions : anchors", + "intents ||--o{ intent_deferrals : defers", + "intents ||--o{ intent_dispositions : disposes", + "intents ||--o{ intent_operations : maps", + "intents ||--o{ intent_snapshots : revises", + "logical_conversations ||--o{ conversation_handles : carries", + "logical_conversations ||--o{ exploration_conversations : joins", "projects ||--o{ aliases : scopes", "projects ||--o{ artifact_bodies : scopes", "projects ||--o{ backend_mappings : scopes", "projects ||--o{ brainstorms : scopes", "projects ||--o{ bundle_members : scopes", "projects ||--o{ bundles : scopes", + "projects ||--o{ conversation_handles : scopes", + "projects ||--o{ conversation_log_refs : scopes", "projects ||--o{ councils : scopes", "projects ||--o{ docs_index : scopes", "projects ||--o{ entity_tags : scopes", "projects ||--o{ events : scopes", + "projects ||--o{ exploration_checkpoint_items : scopes", + "projects ||--o{ exploration_checkpoints : scopes", + "projects ||--o{ exploration_conversations : scopes", + "projects ||--o{ explorations : scopes", "projects ||--o{ exports : scopes", "projects ||--o{ findings : scopes", "projects ||--o{ handoffs : scopes", "projects ||--o{ hook_events : scopes", "projects ||--o{ ideas : scopes", + "projects ||--o{ intent_deferrals : scopes", + "projects ||--o{ intent_dispositions : scopes", + "projects ||--o{ intent_operations : scopes", + "projects ||--o{ intent_snapshots : scopes", + "projects ||--o{ intents : scopes", + "projects ||--o{ journal_conversation_handles : scopes", "projects ||--o{ journal_deferrals : scopes", "projects ||--o{ journal_entries : scopes", "projects ||--o{ journal_origins : scopes", + "projects ||--o{ logical_conversations : scopes", "projects ||--o{ plans : scopes", "projects ||--o{ project_paths : locates", "projects ||--o{ relationships : scopes", @@ -328,6 +391,7 @@ func TestSchemaDocumentationMirrorsExecutableMigration(t *testing.T) { "projects ||--o{ session_state_snapshots : scopes", "projects ||--o{ sessions : scopes", "projects ||--o{ shaping_drafts : scopes", + "projects ||--o{ source_availability_observations : scopes", "projects ||--o{ sources : scopes", "projects ||--o{ sparks : scopes", "projects ||--o{ specs : scopes", diff --git a/internal/state/schema_upgrade_test.go b/internal/state/schema_upgrade_test.go index 8d6cc7df7..d74f0b904 100644 --- a/internal/state/schema_upgrade_test.go +++ b/internal/state/schema_upgrade_test.go @@ -46,8 +46,8 @@ func TestSchemaUpgradeSchema9PreviewAndApply(t *testing.T) { if err != nil { t.Fatalf("PreviewSchemaUpgrade() error = %v", err) } - if preview.CurrentVersion != 9 || len(preview.PendingVersions) != 1 || preview.PendingVersions[0] != 11 || preview.BackupPath != "" { - t.Fatalf("preview = %#v, want schema9 pending only 11 without backup", preview) + if preview.CurrentVersion != 9 || len(preview.PendingVersions) != 2 || preview.PendingVersions[0] != 11 || preview.PendingVersions[1] != 12 || preview.BackupPath != "" { + t.Fatalf("preview = %#v, want schema9 pending [11 12] without backup", preview) } result, err := ApplySchemaUpgrade(ctx, root, resolver) if err != nil { @@ -125,7 +125,7 @@ func TestSchemaUpgradeFailureSeamsPreserveVerifiedBackupAndRollback(t *testing.T t.Fatalf("apply failure result=%#v err=%v, want error and verified backup", result, err) } backup, verifyErr := classifySchemaUpgradeSource(ctx, result.BackupPath, root) - if verifyErr != nil || backup.Fingerprint.Version != 9 || len(backup.Pending) != 1 || backup.Pending[0] != 11 { + if verifyErr != nil || backup.Fingerprint.Version != 9 || len(backup.Pending) != 2 || backup.Pending[0] != 11 || backup.Pending[1] != 12 { t.Fatalf("backup source classification=%#v err=%v, want verified schema9 source", backup, verifyErr) } after := schemaVersionAndMigrationCount(t, databasePath) @@ -178,8 +178,8 @@ func TestSchemaUpgradeSchema10BackupPreservesPreOriginShape(t *testing.T) { if exists, err := sqliteTableExists(ctx, backup.db, "journal_origins"); err != nil || exists { t.Fatalf("schema10 backup journal_origins exists=%t err=%v, want absent", exists, err) } - if got := schemaVersionAndMigrationCount(t, databasePath); got != "11/11" { - t.Fatalf("live schema after schema10 upgrade = %s, want 11/11", got) + if got := schemaVersionAndMigrationCount(t, databasePath); got != "12/12" { + t.Fatalf("live schema after schema10 upgrade = %s, want 12/12", got) } } @@ -268,8 +268,8 @@ func TestSchema10OrdinaryWritesRequireUpgradeWithoutMutation(t *testing.T) { before := schema10MutableCounts(t, databasePath) err := tc.run(root, resolver) var required *SchemaUpgradeRequiredError - if !errors.As(err, &required) || required.Code != SchemaUpgradeRequiredCode || len(required.PendingVersions) != 1 || required.PendingVersions[0] != 11 { - t.Fatalf("%s error=%v required=%#v, want schema-upgrade-required pending [11]", tc.name, err, required) + if !errors.As(err, &required) || required.Code != SchemaUpgradeRequiredCode || len(required.PendingVersions) != 2 || required.PendingVersions[0] != 11 || required.PendingVersions[1] != 12 { + t.Fatalf("%s error=%v required=%#v, want schema-upgrade-required pending [11 12]", tc.name, err, required) } after := schema10MutableCounts(t, databasePath) if before != after { diff --git a/internal/state/status.go b/internal/state/status.go index e0c5b7bfd..e96db80e2 100644 --- a/internal/state/status.go +++ b/internal/state/status.go @@ -884,6 +884,32 @@ func inspectBackendMappingInvariants(ctx context.Context, store *Store) ([]Diagn diagnostics := []Diagnostic{} valid := true + // Migration 0012 tables may be absent on behind-schema databases that this + // scan classifies before an upgrade; their clauses join conditionally. + var intentTableCount int + if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN ('intents', 'explorations', 'exploration_checkpoints', 'logical_conversations')`).Scan(&intentTableCount); err != nil { + return nil, false, fmt.Errorf("inspect intent table presence: %w", err) + } + intentTablesPresent := intentTableCount == 4 + intentKindList := "" + intentOrphanKindList := "" + intentEntityCTE := "" + if intentTablesPresent { + // One kind→table source builds every conditional clause so the three + // scan sites cannot drift apart. + intentKinds := [][2]string{ + {"intent", "intents"}, + {"exploration", "explorations"}, + {"exploration_checkpoint", "exploration_checkpoints"}, + {"logical_conversation", "logical_conversations"}, + } + for _, kind := range intentKinds { + intentKindList += ",\n '" + kind[0] + "'" + intentOrphanKindList += ",\n '" + kind[0] + "'" + intentEntityCTE += "\n UNION ALL SELECT '" + kind[0] + "', project_id, id FROM " + kind[1] + } + } + blankRows, err := store.db.QueryContext(ctx, ` SELECT field, COUNT(*) FROM ( @@ -994,7 +1020,7 @@ WHERE entity_kind NOT IN ( 'bundle_member', 'source', 'hook_event', - 'export' + 'export'`+intentKindList+` ) GROUP BY entity_kind ORDER BY entity_kind @@ -1084,7 +1110,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 + UNION ALL SELECT 'export', project_id, id FROM exports`+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 @@ -1116,7 +1142,7 @@ WHERE local_entities.entity_id IS NULL 'bundle_member', 'source', 'hook_event', - 'export' + 'export'`+intentOrphanKindList+` ) ORDER BY backend_mappings.id `) diff --git a/internal/state/trace.go b/internal/state/trace.go index 1a965fd9c..8ed7fb707 100644 --- a/internal/state/trace.go +++ b/internal/state/trace.go @@ -48,12 +48,10 @@ type TraceRelationship struct { } func validateResolutionTargetKind(kind string, ref string) error { - switch kind { - case "spec", "task", "idea", "brainstorm", "shaping_draft", "report", "finding", "verdict", "run", "plan", "handoff", "council": + if descriptor, ok := entityDescriptorForKind(kind); ok && descriptor.ResolutionTarget { return nil - default: - return fmt.Errorf("%q resolves to %s, which cannot resolve another entity", ref, kind) } + return fmt.Errorf("%q resolves to %s, which cannot resolve another entity", ref, kind) } // Trace resolves a human-facing alias or internal row ID from initialized SQLite state. @@ -134,7 +132,7 @@ LIMIT 1 } func (s *Store) resolveEntityByInternalID(ctx context.Context, projectID string, ref string) (string, string, error) { - for _, kind := range []string{"spec", "task", "idea", "spark", "brainstorm", "shaping_draft", "report", "finding", "verdict", "run", "plan", "handoff", "council", "journal_entry"} { + for _, kind := range internalIDResolvableKinds() { table := traceTable(kind) var id string err := s.db.QueryRowContext(ctx, fmt.Sprintf(`SELECT id FROM %s WHERE project_id = ? AND id = ?`, table), projectID, ref).Scan(&id) @@ -221,6 +219,57 @@ func (s *Store) entityDetails(ctx context.Context, projectID string, kind string } else { entity.Title = fmt.Sprintf("%s: %s", entryType.String, message.String) } + case "intent": + // Title is the latest immutable snapshot; Status is the disposition + // derived from the greatest committed sequence, never a stored column. + var exists string + err := s.db.QueryRowContext(ctx, `SELECT id FROM intents WHERE project_id = ? AND id = ?`, projectID, id).Scan(&exists) + if errors.Is(err, sql.ErrNoRows) { + return entityWithAliasFallback(ctx, s, projectID, entity) + } + if err != nil { + return TraceEntity{}, fmt.Errorf("read intent %s: %w", id, err) + } + var title sql.NullString + if err := s.db.QueryRowContext(ctx, `SELECT title FROM intent_snapshots WHERE project_id = ? AND intent_id = ? ORDER BY seq DESC LIMIT 1`, projectID, id).Scan(&title); err != nil && !errors.Is(err, sql.ErrNoRows) { + return TraceEntity{}, fmt.Errorf("read intent snapshot %s: %w", id, err) + } + var disposition sql.NullString + if err := s.db.QueryRowContext(ctx, `SELECT disposition FROM intent_dispositions WHERE project_id = ? AND intent_id = ? ORDER BY seq DESC LIMIT 1`, projectID, id).Scan(&disposition); err != nil && !errors.Is(err, sql.ErrNoRows) { + return TraceEntity{}, fmt.Errorf("read intent disposition %s: %w", id, err) + } + entity.Title = title.String + entity.Status = disposition.String + case "exploration": + var title sql.NullString + err := s.db.QueryRowContext(ctx, `SELECT title FROM explorations WHERE project_id = ? AND id = ?`, projectID, id).Scan(&title) + if errors.Is(err, sql.ErrNoRows) { + return entityWithAliasFallback(ctx, s, projectID, entity) + } + if err != nil { + return TraceEntity{}, fmt.Errorf("read exploration %s: %w", id, err) + } + entity.Title = title.String + case "exploration_checkpoint": + var purpose sql.NullString + err := s.db.QueryRowContext(ctx, `SELECT purpose FROM exploration_checkpoints WHERE project_id = ? AND id = ?`, projectID, id).Scan(&purpose) + if errors.Is(err, sql.ErrNoRows) { + return entityWithAliasFallback(ctx, s, projectID, entity) + } + if err != nil { + return TraceEntity{}, fmt.Errorf("read exploration checkpoint %s: %w", id, err) + } + entity.Title = purpose.String + case "logical_conversation": + var title sql.NullString + err := s.db.QueryRowContext(ctx, `SELECT title FROM logical_conversations WHERE project_id = ? AND id = ?`, projectID, id).Scan(&title) + if errors.Is(err, sql.ErrNoRows) { + return entityWithAliasFallback(ctx, s, projectID, entity) + } + if err != nil { + return TraceEntity{}, fmt.Errorf("read logical conversation %s: %w", id, err) + } + entity.Title = title.String default: return TraceEntity{}, fmt.Errorf("unsupported trace entity kind %q", kind) } @@ -354,36 +403,5 @@ LIMIT 1 } func traceTable(kind string) string { - switch kind { - case "spec": - return "specs" - case "task": - return "tasks" - case "idea": - return "ideas" - case "spark": - return "sparks" - case "brainstorm": - return "brainstorms" - case "shaping_draft": - return "shaping_drafts" - case "report": - return "reports" - case "finding": - return "findings" - case "verdict": - return "verdicts" - case "run": - return "runs" - case "plan": - return "plans" - case "handoff": - return "handoffs" - case "council": - return "councils" - case "journal_entry": - return "journal_entries" - default: - return "" - } + return registeredEntityTable(kind) } diff --git a/plugins/loaf/bin/native/darwin-arm64/loaf b/plugins/loaf/bin/native/darwin-arm64/loaf index fa7cf5f0a..6cfd82584 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/skills/bootstrap/SKILL.md b/plugins/loaf/skills/bootstrap/SKILL.md index 7f724206a..395fb9ab2 100644 --- a/plugins/loaf/skills/bootstrap/SKILL.md +++ b/plugins/loaf/skills/bootstrap/SKILL.md @@ -389,7 +389,7 @@ format reference; do not hand-author journal markdown as the source of truth. Suggest relevant next steps based on what was learned: -- `/loaf:brainstorm` -- if the idea needs more exploration +- `/brainstorm` -- if the idea needs more exploration - `/loaf:idea` -- if specific feature ideas emerged during the interview - `/loaf:research` -- if there are open questions that need investigation - `/loaf:shape` -- if a specific feature is ready to be bounded into a spec diff --git a/plugins/loaf/skills/bootstrap/references/interview-guide.md b/plugins/loaf/skills/bootstrap/references/interview-guide.md index 5fe97cab2..9e859e93e 100644 --- a/plugins/loaf/skills/bootstrap/references/interview-guide.md +++ b/plugins/loaf/skills/bootstrap/references/interview-guide.md @@ -402,7 +402,7 @@ Each document gets section-by-section review. Don't dump 3 documents at once. ### When the Builder Wants to Keep Talking If the builder is energized and wants to explore further after the core documents are drafted, suggest: -- `/loaf:brainstorm` for divergent exploration +- `/brainstorm` for divergent exploration - `/loaf:research` for topic investigation - `/loaf:strategy` for deep persona/market work - `/loaf:shape` for bounding a specific feature diff --git a/plugins/loaf/skills/brainstorm/SKILL.md b/plugins/loaf/skills/brainstorm/SKILL.md index 1b328448b..5518090e8 100644 --- a/plugins/loaf/skills/brainstorm/SKILL.md +++ b/plugins/loaf/skills/brainstorm/SKILL.md @@ -1,18 +1,18 @@ --- name: brainstorm description: >- - Conducts structured brainstorming with divergent thinking and trade-off - analysis. Use when the user asks "help me think through this," "what are the - options," or is exploring tradeoffs. Produces docs with sparks. Not for quick - ideas or shaping. -user-invocable: true + Preserves the structured divergent-thinking stance consumed by the explore + workflow: option generation before judgment, trade-off analysis, and spark + capture. Explore owns the user-facing inquiry lifecycle; reference this + technique from it. Not a ... +user-invocable: false argument-hint: '[idea or problem]' version: 2.0.0-alpha.9 --- # Brainstorm -Generative thinking — expanding possibilities before narrowing through structured exploration. +Generative thinking — expanding possibilities before narrowing. This stance is an internal technique consumed by `/loaf:explore`, which owns inquiry continuity through Explorations and portable checkpoints; invoke the technique from there rather than as a standalone workflow. ## Critical Rules @@ -27,17 +27,16 @@ Generative thinking — expanding possibilities before narrowing through structu **Never** - Prematurely commit to an option before full exploration -- Delete brainstorm documents — archive them for context preservation -- Process sparks during the main brainstorm — capture only, expand later -- Turn brainstorm into an interview — keep it exploratory +- Create documents, reports, or any Git artifact from this technique — the surrounding Explore workflow owns checkpoints and any durable writes +- Process sparks during the divergent pass — capture only, expand later +- Turn the divergence into an interview — keep it exploratory ## Verification -After work completes, verify: -- Brainstorm captured in SQLite or summarized in an explicitly durable report -- Sparks captured with `loaf spark capture` and optionally summarized in `## Sparks` -- Spark lifecycle documented: unprocessed → promoted/discarded -- Brainstorm references strategic context from VISION/STRATEGY +After a divergent pass, verify: +- Sparks captured with `loaf spark capture` as they arose +- The surrounding Exploration checkpointed the conclusions, discarded options, and open question (`loaf exploration checkpoint`) +- The divergence referenced strategic context from VISION/STRATEGY ## Quick Reference @@ -58,17 +57,14 @@ After work completes, verify: - **Title** -- one-line description ``` -Sparks are: lightweight, byproducts, worth remembering. Mark as `*(promoted)*` or `*(abandoned)*` after processing. - -Brainstorm documents are archived after sparks are processed — never deleted, since the exploration context has lasting value. SQLite spark state is the lifecycle source; draft markdown is a projection or narrative summary. +Sparks are lightweight byproducts worth remembering; their dispositions belong to triage. SQLite spark state is the source; any summary inside a checkpoint item is narrative, not lifecycle. ## Suggests Next -After brainstorming, suggest `/loaf:shape` if a clear idea emerged, or `/loaf:idea` to capture sparks for later. `/loaf:idea` invoked without arguments scans brainstorm docs for unprocessed sparks, bridging the brainstorm → idea pipeline. +After a divergent pass, checkpoint the surrounding Exploration (`loaf exploration checkpoint`), then suggest `/loaf:shape` if a clear direction emerged or `/loaf:triage` to disposition captured sparks and ideas. ## Topics | Topic | Reference | Use When | |-------|-----------|----------| -| Brainstorm Template | [templates/brainstorm.md](templates/brainstorm.md) | Creating structured brainstorm documents | | Strategic Context | `strategy/references/` | Grounding exploration in project direction | diff --git a/plugins/loaf/skills/explore/SKILL.md b/plugins/loaf/skills/explore/SKILL.md new file mode 100644 index 000000000..36382cbf1 --- /dev/null +++ b/plugins/loaf/skills/explore/SKILL.md @@ -0,0 +1,99 @@ +--- +name: explore +description: >- + Conducts divergent inquiry as a durable Exploration: portable checkpoints, + conversation provenance, and Intent capture that survive compaction and + harness changes. Use when the direction is genuinely undecided ("explore + this", "we don't know which... +user-invocable: true +argument-hint: '[topic or exploration ref]' +version: 2.0.0-alpha.9 +--- + +# Explore + +Divergent inquiry with durable continuity. An Exploration is a relational identity over immutable checkpoints and provenance — it has no status, no owner, and no lifecycle to maintain. Resuming means reading portable context and appending new facts, never toggling state. + +**Input:** $ARGUMENTS + +--- + +## Contents +- Critical Rules +- Verification +- Quick Reference +- Process +- Checkpoint Discipline +- Resumption +- Techniques +- Related Skills + +## Critical Rules + +- Log invocation first: `loaf journal log "skill(explore): <topic or exploration ref>"` +- 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 bounded delivery, hand it to `/loaf:shape`. +- Never store transcripts, prompts, or tool output in checkpoints or items; curate semantic context instead. + +## 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`). +- 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>]...` | +| 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>]` | +| 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>` | + +## 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. +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. +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 + +The four fields are the portable contract; each is capped at 4096 UTF-8 bytes and oversize input is rejected, never truncated: + +- **purpose** — the current framing of the inquiry, restated so a stranger understands what is being explored and why. +- **conclusions** — constraints and conclusions established so far, including rejected options and why they fell. +- **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. + +## 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. + +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. + +## Deferring + +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. + +## Techniques + +Brainstorm's full divergent stance lives inside Explore: generate options before judging, connect to VISION/STRATEGY context, document discarded options, set boundaries on exploration time. Scout, research, prototype, and spike remain subordinate techniques invoked from whatever stage needs them — none of them owns lifecycle. + +## Related Skills + +- **triage** — processes the intake queue and chooses dispositions, including "explore this" +- **shape** — narrows one direction into a bounded Change; the exit door from Explore +- **research** — evidence gathering for a known question, usable inside an Exploration +- **idea** — quick capture without inquiry diff --git a/plugins/loaf/skills/foundations/references/tdd.md b/plugins/loaf/skills/foundations/references/tdd.md index 0079356a3..2323d5a4e 100644 --- a/plugins/loaf/skills/foundations/references/tdd.md +++ b/plugins/loaf/skills/foundations/references/tdd.md @@ -33,7 +33,7 @@ Project TDD conventions and workflow. | Symptom | Likely Cause | Solution | |---------|--------------|----------| -| Can't write test first | Don't understand requirements | Clarify with `/loaf:brainstorm` or `/loaf:shape` | +| Can't write test first | Don't understand requirements | Clarify with `/brainstorm` or `/loaf:shape` | | Test is too complex | Testing too much at once | Break into smaller behaviors | | Implementation explodes | Test scope too large | Smaller test, smaller implementation | | Refactor breaks tests | Tests coupled to implementation | Test behavior, not structure | diff --git a/plugins/loaf/skills/idea/SKILL.md b/plugins/loaf/skills/idea/SKILL.md index 0bbf2ad9b..6b1123a8e 100644 --- a/plugins/loaf/skills/idea/SKILL.md +++ b/plugins/loaf/skills/idea/SKILL.md @@ -3,8 +3,8 @@ 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. For reviewing and - processing the intake qu... + actionable concept crystallizes during conversation. Ideas and sparks are + capture primitives ro... user-invocable: true argument-hint: '[idea description]' version: 2.0.0-alpha.9 @@ -40,83 +40,46 @@ Capture ideas quickly with minimal friction. ## Verification -- Idea appears in `loaf idea list` / `loaf idea show` -- Status is open/raw according to the active backend -- If promoted from a spark, `loaf spark promote` records the relationship +- The idea appears in `loaf idea list` and `loaf idea show <ref>` with status open +- If promoted from a spark, `loaf spark promote` recorded the relationship +- No shaping, status transition, or promotion happened here — dispositions belong to triage ## Quick Reference -| Status | Meaning | -|--------|---------| -| `raw` | Just captured, unprocessed | -| `shaping` | Being developed via /loaf:shape or /loaf:brainstorm | -| `shaped` | Converted to SPEC, idea file archived | -| `archived` | Decided not to pursue, kept for reference | +| Operation | Command | +|-----------|---------| +| Capture | `loaf idea capture --title "<title>"` | +| Read back | `loaf idea show <ref>` | +| List open | `loaf idea list` | --- ## Purpose -Ideas are raw nuggets -- unprocessed, unshaped, but worth remembering. The goal is **speed of capture**, not thoroughness. Shape later via `/loaf:shape`. +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, exploring it, shaping it, or archiving it are triage dispositions chosen later by the user. --- ## Process -### Step 1: Parse Input - -If `$ARGUMENTS` contains the idea, capture directly. - -If `$ARGUMENTS` is empty, ask **at most 2-3 questions**: core idea, problem/opportunity, immediate constraints. - -### Step 2: Capture Idea - -Use the CLI capture path: - -```bash -loaf idea capture --title "..." -``` - -In SQLite-backed mode, the row is stored in SQLite and no `.agents/ideas/` -markdown file is created. The [idea template](templates/idea.md) is retained -only for markdown-only compatibility and historical restore review. - -### Step 3: Create and Announce - -1. Generate timestamp: `date -u +"%Y-%m-%dT%H:%M:%SZ"` -2. Run `loaf idea capture --title "..."` with the inferred title -3. Announce the captured idea alias with next steps - ---- - -## Idea Lifecycle - -``` -raw -> shaping -> shaped (becomes SPEC) -> archived -``` - -| Status | Meaning | -|--------|---------| -| `raw` | Just captured, unprocessed | -| `shaping` | Being developed via /loaf:shape or /loaf:brainstorm | -| `shaped` | Converted to SPEC, idea file archived | -| `archived` | Decided not to pursue, kept for reference | +1. **Parse input.** If `$ARGUMENTS` contains the idea, capture directly. If empty, ask at most 2-3 questions: core idea, problem/opportunity, immediate constraints. +2. **Capture.** Run `loaf idea capture --title "..."` with the inferred title; log notable context with `loaf journal log`. +3. **Announce.** Report the captured alias and point at `/loaf:triage` for disposition. --- ## Guardrails -1. **Speed over completeness** -- capture quickly, shape later -2. **2-3 questions max** -- don't turn this into an interview -3. **Infer, don't ask** -- metadata should be automatic -4. **One idea per captured row/artifact** -- keep them atomic -5. **No shaping here** -- that's what `/loaf:shape` is for +1. **Speed over completeness** — capture quickly, disposition later +2. **2-3 questions max** — don't turn this into an interview +3. **Infer, don't ask** — metadata should be automatic +4. **One idea per captured row** — keep them atomic +5. **No lifecycle here** — no status transitions, promotion, or shaping; triage owns dispositions and the CLI performs them --- ## Related Skills -- **triage** -- Review and process the intake queue (sparks + raw ideas) -- **shape** -- Develop an idea into a SPEC -- **brainstorm** -- Deep thinking on an idea or problem space -- **research** -- Investigate before capturing +- **triage** — process the intake queue and choose dispositions +- **explore** — divergent inquiry when an idea needs development before commitment +- **shape** — develop a chosen direction into a bounded Change diff --git a/plugins/loaf/skills/loaf-reference/SKILL.md b/plugins/loaf/skills/loaf-reference/SKILL.md index b19165a14..9adedaa90 100644 --- a/plugins/loaf/skills/loaf-reference/SKILL.md +++ b/plugins/loaf/skills/loaf-reference/SKILL.md @@ -2,9 +2,9 @@ name: loaf-reference description: >- Documents how agents operate the Loaf CLI: command discovery via loaf --help, - JSON diagnosis surfaces, guided config maintenance, and troubleshooting. Use - when unsure which loaf command to invoke or how to validate project state. Not - for workflow ... + JSON diagnosis surfaces, config-aware maintenance, and troubleshooting. Use + when unsure which loaf command to invoke, how to validate project state, or + when asked to upg... user-invocable: false version: 2.0.0-alpha.9 --- @@ -66,7 +66,7 @@ Names and one-line purposes only. Run `loaf <command> --help` for options, argum | `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 lifecycle-statuses, backup, backup verify, backup restore, restore-ephemerals, verify-ephemerals, export, export all, export triage, export spec, export release-readiness | +| `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, 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, worktree-storage | @@ -85,6 +85,10 @@ 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 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 | | `loaf spark` | Manage sparks in native SQLite state | list, show, capture, resolve, promote | | `loaf tag` | Manage tags in native SQLite state | list, show, add, remove | | `loaf bundle` | Manage bundles in native SQLite state | list, create, update, show, add, remove | @@ -97,5 +101,6 @@ Names and one-line purposes only. Run `loaf <command> --help` for options, argum | Topic | Reference | Use When | |-------|-----------|----------| | Configuration maintenance | [references/configuration.md](references/configuration.md) | Checking whether a project's Loaf config is current and repairing it; wiring project-owned choices | +| Config-aware maintenance protocol | [references/maintenance.md](references/maintenance.md) | Upgrading, diagnosing, repairing, or bringing a project current: diagnose, plan, ask, apply, verify | | Command routing | [references/command-routing.md](references/command-routing.md) | Deciding which command a task needs; locating the JSON diagnosis surfaces | | Troubleshooting | [references/troubleshooting.md](references/troubleshooting.md) | Diagnosing state, config, or alignment failures; isolating a throwaway database | diff --git a/plugins/loaf/skills/loaf-reference/references/maintenance.md b/plugins/loaf/skills/loaf-reference/references/maintenance.md new file mode 100644 index 000000000..db5feed6e --- /dev/null +++ b/plugins/loaf/skills/loaf-reference/references/maintenance.md @@ -0,0 +1,48 @@ +# Config-Aware Loaf Maintenance + +## Contents +- Protocol +- Fact Sources +- Planning Surfaces +- Consent Boundaries +- What Maintenance Never Does + +This protocol serves natural-language requests to upgrade, diagnose, repair, configure, or bring a Loaf project current. It is a hidden operator layer: interpret facts, ask only for missing project-owned choices, sequence approved deterministic operations, and verify convergence. Discover exact current syntax from `loaf <command> --help` instead of memorizing flags. + +## Protocol + +0. **Classify the request.** A diagnose-only request stops after step 1 with facts; a plan request stops after step 2 with the mutation ledger; only an explicit repair, upgrade, or bring-current request proceeds to apply, and then only for the mutation classes the user's request actually named. Never let the protocol's shape carry a diagnosis into a mutation. +1. **Diagnose.** Start with `loaf config check --json` for project intent and installed hook health. Add `loaf version` (running executable), `loaf state status --json` and `loaf state doctor --json` (SQLite readiness, schema version, repair plan), and `loaf doctor --json` (project alignment: symlinks, stale files, fenced-version drift). All four are read-only. +2. **Plan.** For installed-target convergence, use `loaf install --upgrade --dry-run --json`: it reports intended creates, updates, retirements, preserved conflicts, deprecation actions, project-file effects, and whether explicit consent is required, without writing anything. +3. **Ask.** Only for project-owned choices the facts cannot answer (for example integration election in `.agents/loaf.json`, or consent to destructive deprecation cleanup). Machine-observed facts are never questions. Present one complete mutation ledger — every intended operation with its consent requirement — and obtain approval for the ledger as a whole before applying any part of it. +4. **Apply.** Use the existing explicit operations the ledger named: `loaf config check --fix`, `loaf install --upgrade` (with `-y` only after consent), `loaf doctor --fix --force` (the `--force` form is required for non-interactive execution; plain `--fix` prompts and silently skips repairs without a TTY — if a repair genuinely needs interactive judgment, stop and report that it requires an operator), `loaf state migrate schema --apply`, `loaf state migrate deferrals --apply`. A project-owned election is recorded by editing `.agents/loaf.json` in the checkout — a durable, reviewable change — and validating with `loaf config check --json`. Never invent a bypass. +5. **Verify.** Rerun the diagnosis surfaces and confirm they converge; report any check that still fails rather than declaring success, and never loop back into apply without a changed ledger. + +## Fact Sources + +| Fact | Source | Authority | +|------|--------|-----------| +| Team-owned project intent (integrations, knowledge dirs) | `.agents/loaf.json` via `loaf config check --json` | Shared config; never records machine-local install state | +| Running executable version and provenance | `loaf version` | Observed local fact; the package manager owns acquisition | +| SQLite readiness, schema version, repair plan | `loaf state status --json`, `loaf state doctor --json` | Behind-schema state returns the exact backup-first `loaf state migrate schema --apply` action | +| Project alignment (symlinks, stale files, version fences) | `loaf doctor --json` | Read-only; repairs go through the explicit `--fix` contract | +| Installed target ownership and drift | `loaf install --upgrade --dry-run --json` | Content-addressed ownership manifests decide owned versus foreign | + +## Planning Surfaces + +- `loaf doctor --json` never prompts and never repairs; it carries the identical check identities and outcomes as the human output plus repair IDs for planning. +- `loaf install --upgrade --dry-run --json` is deterministic and byte-for-byte non-mutating; applying the reported plan through the existing commands must produce the predicted effects, after which diagnosis reports convergence. +- `loaf state migrate deferrals --dry-run --json` previews the legacy-deferral conversion manifest; apply is backup-first, preserves every legacy row, and is rerunnable. + +## Consent Boundaries + +- Database migrations (`state migrate schema --apply`, `state migrate deferrals --apply`) are backup-first and require the operator's explicit go-ahead on real state. +- Destructive deprecation cleanup during `install --upgrade` requires explicit consent (`-y`); missing consent must surface as a reported requirement, not an assumed yes. +- Locally modified or unowned destination content is preserved and reported, never overwritten. + +## What Maintenance Never Does + +- Never invokes Homebrew, npm, or any package manager, and never claims a newer remote version exists without evidence from the owning package manager. +- Never hardcodes a Cellar, checkout, or binary path; `loaf` resolves on `PATH`. +- Never infers machine-local installed-target intent from Git-tracked config, and never writes machine-specific fields into `.agents/loaf.json`. +- Never applies a database migration automatically as a side effect of diagnosis. diff --git a/plugins/loaf/skills/triage/SKILL.md b/plugins/loaf/skills/triage/SKILL.md index d04d33e38..d6842fc05 100644 --- a/plugins/loaf/skills/triage/SKILL.md +++ b/plugins/loaf/skills/triage/SKILL.md @@ -1,17 +1,17 @@ --- name: triage description: >- - Surfaces and processes the intake queue: unresolved sparks from the project - journal and brainstorm documents, plus raw ideas awaiting evaluation. Use when - the user asks "what sparks do I have?", "review my ideas", "triage", or - "what's in my backlo... + 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 acros... user-invocable: true version: 2.0.0-alpha.9 --- # Triage -Review and process the intake queue — sparks and raw ideas. +Process the intake queue. Triage is the public funnel where captured material meets judgment: you present facts, the user chooses each disposition, and the CLI performs exactly what was chosen. **Input:** $ARGUMENTS @@ -22,159 +22,70 @@ Review and process the intake queue — sparks and raw ideas. - Verification - Quick Reference - Process -- Resolution Formats +- Dispositions +- Legacy Deferrals - Guardrails - Related Skills ## Critical Rules -- Present everything before acting -- user decides each disposition -- Never auto-promote or auto-discard without confirmation -- Use SQLite-aware CLI commands for lifecycle changes; do not edit idea/spark - frontmatter by hand -- Log or link resolutions through `loaf spark resolve`, `loaf spark promote`, - `loaf idea archive`, and `loaf brainstorm archive` when state is initialized -- One pass through the queue -- don't loop or re-present items +- Log invocation first: `loaf journal log "skill(triage): <trigger or scope>"` +- 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. +- One pass through the queue — don't loop or re-present items. ## Verification -- All presented sparks have a recorded disposition (promoted, discarded, or deferred) -- Promoted sparks have corresponding idea rows visible in `loaf idea list` -- Processed sparks no longer appear in default `loaf spark list` / triage output -- Archived ideas/brainstorms no longer appear in default triage lists -- Markdown source annotations, when present, are compatibility notes rather than - the authoritative state transition +- 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`). +- 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. ## Quick Reference -| Source | Unprocessed Signal | Resolution | -|--------|-------------------|------------| -| Sparks | Open spark rows from `loaf spark list` | `loaf spark promote` or `loaf spark resolve` | -| Brainstorms | Open brainstorm rows from `loaf brainstorm list` | `loaf brainstorm promote` or `loaf brainstorm archive` | -| Ideas | Open idea rows from `loaf idea list` | Shape, promote, or `loaf idea archive` | - ---- +| Item kind | Comes from | Typical dispositions | +|-----------|-----------|----------------------| +| spark | `loaf spark capture` moments | discard, promote to idea, track as Intent | +| idea | `/loaf:idea` capture | archive, explore, track as Intent, hand to `/loaf:shape` | +| brainstorm | archived divergent sessions | archive, explore, promote | +| intent (tracked) | `loaf intent create` | keep tracking, defer, resolve, explore, hand to `/loaf: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) | ## Process -### Step 1: Scan Sources - -Scan state-backed queues first, falling back to Markdown compatibility sources -only when SQLite state is not initialized: - -**1. Sparks** -- Run `loaf spark list` or `loaf spark list --json` -- Treat open rows as unresolved intake - -**2. Brainstorms** -- Run `loaf brainstorm list` or `loaf brainstorm list --json` -- Treat open rows as brainstorm intake - -**3. Ideas** -- Run `loaf idea list` or `loaf idea list --json` -- Treat open rows as idea intake - -### Step 2: Present the Queue - -Show a summary table: - -``` -Intake Queue: - Sparks (journal): 3 unresolved - Sparks (brainstorms): 1 unprocessed - Raw ideas: 2 awaiting evaluation - Total: 6 items -``` - -Then list each item with source, date, and description. - -### Step 3: Process Each Item - -For each item, present it and ask for disposition: - -**Sparks → one of:** -- **Promote** → `loaf spark promote <spark> --to-idea <idea>` -- **Discard** → `loaf spark resolve <spark> --reason <reason>` -- **Defer** → skip, resurface next triage - -**Raw ideas → one of:** -- **Shape** → suggest running `/loaf:shape` with this idea -- **Brainstorm** → suggest running `/loaf:brainstorm` to explore further -- **Archive** → `loaf idea archive <idea> --reason <reason>` - -### Step 4: Summarize - -After processing, show what happened: +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. -``` -Triage complete: - Promoted: 2 sparks → ideas - Discarded: 1 spark - Deferred: 1 spark - Shaped: 1 idea → /loaf:shape - Archived: 1 idea -``` +## Dispositions ---- - -## Resolution Formats - -### Sparks - -When promoting: -```bash -loaf spark promote SPARK-slug --to-idea idea-slug -``` - -When discarding: -```bash -loaf spark resolve SPARK-slug --reason "reason" -``` - -When deferring: -Do nothing; open sparks remain visible in the next triage pass. - -### Brainstorms - -When promoting: -```bash -loaf brainstorm promote brainstorm-slug --to-idea idea-slug -``` +- **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** — hand the item to `/loaf:explore`, linking it with `--from` when the Exploration is created. +- **Shape** — when a direction is ready for bounded delivery, hand it to `/loaf:shape`; triage never creates Changes, branches, or worktrees. -When archiving: -```bash -loaf brainstorm archive brainstorm-slug --reason "reason" -``` +## Legacy Deferrals -### Ideas - -When archiving: -```bash -loaf idea archive idea-slug --reason "reason" -``` - -When shaping, pass the idea to `/loaf:shape`; do not hand-edit status frontmatter to -represent lifecycle state. - ---- +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. ## Guardrails -1. **User decides every disposition** -- present, don't decide -2. **Batch presentation, individual decisions** -- show the full queue, then process one at a time -3. **Log everything** -- no silent discards or promotions -4. **Deferred items resurface** -- they'll appear again next `/loaf:triage` - ---- - -## Suggests Next - -After triage completes, suggest `/loaf:shape` for any ideas promoted to shaping. +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. ## Related Skills -- **idea** -- Capture a new idea (fast, minimal friction) -- **shape** -- Develop an idea into a SPEC -- **brainstorm** -- Deep exploration of a problem space (produces sparks) -- **housekeeping** -- Flags brainstorm drafts with unprocessed sparks before deletion -- **reflect** -- Strategic document updates (separate from triage) +- **idea** — capture a new idea (fast, minimal friction) +- **explore** — divergent inquiry with portable checkpoints +- **shape** — develop a chosen direction into a bounded Change +- **housekeeping** — flags stale artifacts; does not choose dispositions