diff --git a/bin/native/darwin-arm64/loaf b/bin/native/darwin-arm64/loaf index cce078506..d801bd2d7 100755 Binary files a/bin/native/darwin-arm64/loaf and b/bin/native/darwin-arm64/loaf differ diff --git a/cmd/loaf/installed_distribution_test.go b/cmd/loaf/installed_distribution_test.go index c21633006..1d5276b07 100644 --- a/cmd/loaf/installed_distribution_test.go +++ b/cmd/loaf/installed_distribution_test.go @@ -163,9 +163,52 @@ func writeInstalledDistributionFixture(t *testing.T, repo string, version string writeFixtureFile(t, filepath.Join(root, "config", "hooks.yaml"), "hooks:\n pre-tool:\n - id: check-secrets\n") writeFixtureFile(t, filepath.Join(root, "dist", "cursor", "skills", "foundations", "SKILL.md"), "# Current Foundations\n") writeFixtureFile(t, filepath.Join(root, "dist", "cursor", "hooks.json"), `{"version":1,"hooks":{"PostToolUse":[{"command":"loaf journal log --from-hook","matcher":"Bash","loaf-managed":true}]}}`) + writeFixtureHookDistribution(t, filepath.Join(root, "dist", "cursor"), version) return root } +// writeFixtureHookDistribution adds the two files a current build emits beside +// a Cursor distribution: the adapter manifest the installer reads, and the hook +// catalog reconciliation resolves entry identity through. A distribution +// carrying neither is stale, and upgrade refuses it rather than falling back to +// the whole-file merge it replaced. +func writeFixtureHookDistribution(t *testing.T, dist string, version string) { + t.Helper() + writeFixtureFile(t, filepath.Join(dist, ".loaf-target-manifest.json"), `{ + "version": 1, + "target": "cursor", + "package_version": "`+version+`", + "capability_contract_version": 3, + "adapters": ["cursor-test-adapter-v1"], + "artifacts": [ + { + "id": "managed-instructions", + "kind": "instruction", + "destination": "project-instructions", + "sha256": "`+strings.Repeat("0", 64)+`" + } + ] +} +`) + writeFixtureFile(t, filepath.Join(dist, ".loaf-hook-catalog.json"), `{ + "version": 1, + "target": "cursor", + "package_version": "`+version+`", + "entries": [ + { + "event": "beforeShellExecution", + "hook_id": "validate-commit", + "type": "command", + "template": {"command": "loaf check --hook validate-commit", "matcher": "Bash", "loaf-managed": true}, + "signatures": [["loaf", "check", "--hook", "validate-commit"]], + "stems": [["--hook", "validate-commit"]] + } + ], + "cohorts": [] +} +`) +} + // writeStaleCheckoutFixture lays out an older Loaf source checkout: package // metadata, a .git marker, the Go module that makes it a checkout rather than // an unpacked distribution, content, and stale built dist/ output. diff --git a/config/target-capabilities.json b/config/target-capabilities.json index 58b46cf90..74cdc25b8 100644 --- a/config/target-capabilities.json +++ b/config/target-capabilities.json @@ -4,7 +4,7 @@ { "target": "claude-code", "surface": "cli", - "version": "2.1.223", + "version": "2.1.226", "platform": "darwin-arm64", "installed_mode": "plugin-dir", "context": { @@ -20,9 +20,9 @@ "status": "supported", "model_visible": true, "evidence": { - "source": "docs/changes/20260807-self-carried-release-guardrail/research/claude-code-2.1.223-plugin-startup-smoke.json", + "source": "docs/changes/20260808-hooks-entry-reconciliation/research/claude-code-2.1.226-plugin-startup-smoke.json", "level": "installed-smoke", - "summary": "Claude Code 2.1.223 startup smoke returned SessionStart additionalContext and the isolated marker was model-visible exactly." + "summary": "Claude Code 2.1.226 startup smoke returned SessionStart additionalContext and the isolated marker was model-visible exactly." } }, { @@ -243,7 +243,7 @@ { "target": "codex", "surface": "cli", - "version": "0.146.0", + "version": "0.147.0", "platform": "darwin-arm64", "installed_mode": "isolated-codex-home", "context": { @@ -259,9 +259,9 @@ "status": "supported", "model_visible": true, "evidence": { - "source": "docs/changes/20260807-self-carried-release-guardrail/research/codex-0.146.0-isolated-startup-smoke.json", + "source": "docs/changes/20260808-hooks-entry-reconciliation/research/codex-0.147.0-isolated-startup-smoke.json", "level": "installed-smoke", - "summary": "Codex 0.146.0 isolated CODEX_HOME startup smoke observed exact native SessionStart hookSpecificOutput and the random marker was returned by the model." + "summary": "Codex 0.147.0 isolated CODEX_HOME startup smoke observed exact native SessionStart hookSpecificOutput and the random marker was returned by the model." } }, { @@ -340,7 +340,7 @@ "status": "supported", "model_visible": true, "evidence": { - "source": "docs/changes/20260807-self-carried-release-guardrail/research/opencode-1.18.13-isolated-request-smoke.json", + "source": "docs/changes/20260808-hooks-entry-reconciliation/research/opencode-1.18.13-isolated-request-smoke.json", "level": "installed-smoke", "summary": "OpenCode 1.18.13 isolated-XDG request smoke returned the exact random continuity marker through the model-visible plugin system transformation." } diff --git a/content/skills/loaf-reference/SKILL.md b/content/skills/loaf-reference/SKILL.md index bc4d5a9ab..a6bf5e6b2 100644 --- a/content/skills/loaf-reference/SKILL.md +++ b/content/skills/loaf-reference/SKILL.md @@ -56,6 +56,7 @@ Names and one-line purposes only. Run `loaf --help` for options, argum | `loaf install` | Onboard Loaf into a folder or a not-yet-installed AI tool configuration | — | | `loaf upgrade` | Refresh Loaf in place: harness content sync plus deprecation cleanup anywhere, and project-surface refresh only inside a detected Loaf repo | — | | `loaf config` | Validate and refresh project Loaf config | check | +| `loaf hooks` | Inspect and set which Loaf hooks project into an installed harness's hooks file | list, enable, disable | | `loaf init` | Initialize a project with Loaf structure | — | | `loaf release` | Create a new release with changelog, version bump, and tag | — | | `loaf search` | Search SQLite artifact bodies, journal entries, and indexed docs | — | diff --git a/dist/amp/skills/loaf-reference/SKILL.md b/dist/amp/skills/loaf-reference/SKILL.md index 30e68ef1a..94aa4b608 100644 --- a/dist/amp/skills/loaf-reference/SKILL.md +++ b/dist/amp/skills/loaf-reference/SKILL.md @@ -62,6 +62,7 @@ Names and one-line purposes only. Run `loaf --help` for options, argum | `loaf install` | Onboard Loaf into a folder or a not-yet-installed AI tool configuration | — | | `loaf upgrade` | Refresh Loaf in place: harness content sync plus deprecation cleanup anywhere, and project-surface refresh only inside a detected Loaf repo | — | | `loaf config` | Validate and refresh project Loaf config | check | +| `loaf hooks` | Inspect and set which Loaf hooks project into an installed harness's hooks file | list, enable, disable | | `loaf init` | Initialize a project with Loaf structure | — | | `loaf release` | Create a new release with changelog, version bump, and tag | — | | `loaf search` | Search SQLite artifact bodies, journal entries, and indexed docs | — | diff --git a/dist/codex/.loaf-hook-catalog.json b/dist/codex/.loaf-hook-catalog.json new file mode 100644 index 000000000..7d0f8bf76 --- /dev/null +++ b/dist/codex/.loaf-hook-catalog.json @@ -0,0 +1,93 @@ +{ + "version": 1, + "target": "codex", + "package_version": "0.2.20", + "entries": [ + { + "event": "SessionStart", + "hook_id": "session-start-loaf", + "type": "command", + "template": { + "matcher": "startup|resume|clear|compact", + "hooks": [ + { + "type": "command", + "command": "{{LOAF_EXECUTABLE}} journal context --from-hook --codex-hook", + "commandWindows": "{{LOAF_EXECUTABLE}} journal context --from-hook --codex-hook" + } + ] + }, + "signatures": [ + [ + "{{LOAF_EXECUTABLE}}", + "journal", + "context", + "--from-hook", + "--codex-hook" + ], + [ + "loaf", + "journal", + "context" + ], + [ + "loaf", + "journal", + "context", + "--from-hook" + ] + ], + "stems": [ + [ + "journal", + "context", + "--from-hook" + ] + ] + } + ], + "cohorts": [ + { + "version": "2.0.0-alpha.14", + "hook_ids": [ + "session-start-loaf" + ] + }, + { + "version": "2.0.0-alpha.15", + "hook_ids": [ + "session-start-loaf" + ] + }, + { + "version": "2.0.0-alpha.16", + "hook_ids": [ + "session-start-loaf" + ] + }, + { + "version": "2.0.0-alpha.17", + "hook_ids": [ + "session-start-loaf" + ] + }, + { + "version": "2.0.0-alpha.18", + "hook_ids": [ + "session-start-loaf" + ] + }, + { + "version": "2.0.0-alpha.19", + "hook_ids": [ + "session-start-loaf" + ] + }, + { + "version": "0.2.20", + "hook_ids": [ + "session-start-loaf" + ] + } + ] +} diff --git a/dist/codex/.loaf-target-manifest.json b/dist/codex/.loaf-target-manifest.json index 094d706aa..163accfff 100644 --- a/dist/codex/.loaf-target-manifest.json +++ b/dist/codex/.loaf-target-manifest.json @@ -7,13 +7,6 @@ "codex-session-start-v1" ], "artifacts": [ - { - "id": "hook-projection:.codex/hooks.json", - "kind": "hook-projection", - "source_path": ".codex/hooks.json", - "destination": "hooks.json", - "sha256": "443f549b2d4ee0a7ab796f95f5a9a60c92613a63e9409d8bb2962564d066db02" - }, { "id": "managed-instructions", "kind": "instruction", diff --git a/dist/codex/skills/loaf-reference/SKILL.md b/dist/codex/skills/loaf-reference/SKILL.md index 30e68ef1a..94aa4b608 100644 --- a/dist/codex/skills/loaf-reference/SKILL.md +++ b/dist/codex/skills/loaf-reference/SKILL.md @@ -62,6 +62,7 @@ Names and one-line purposes only. Run `loaf --help` for options, argum | `loaf install` | Onboard Loaf into a folder or a not-yet-installed AI tool configuration | — | | `loaf upgrade` | Refresh Loaf in place: harness content sync plus deprecation cleanup anywhere, and project-surface refresh only inside a detected Loaf repo | — | | `loaf config` | Validate and refresh project Loaf config | check | +| `loaf hooks` | Inspect and set which Loaf hooks project into an installed harness's hooks file | list, enable, disable | | `loaf init` | Initialize a project with Loaf structure | — | | `loaf release` | Create a new release with changelog, version bump, and tag | — | | `loaf search` | Search SQLite artifact bodies, journal entries, and indexed docs | — | diff --git a/dist/cursor/.loaf-hook-catalog.json b/dist/cursor/.loaf-hook-catalog.json new file mode 100644 index 000000000..317bd3a3b --- /dev/null +++ b/dist/cursor/.loaf-hook-catalog.json @@ -0,0 +1,608 @@ +{ + "version": 1, + "target": "cursor", + "package_version": "0.2.20", + "entries": [ + { + "event": "preToolUse", + "hook_id": "artifact-body-write", + "type": "command", + "template": { + "loaf-managed": true, + "timeout": 30, + "matcher": "Edit|Write|Bash", + "failClosed": true, + "command": "loaf check --hook artifact-body-write" + }, + "signatures": [ + [ + "loaf", + "check", + "--hook", + "artifact-body-write" + ] + ], + "stems": [ + [ + "--hook", + "artifact-body-write" + ] + ] + }, + { + "event": "preToolUse", + "hook_id": "check-secrets", + "type": "command", + "template": { + "loaf-managed": true, + "timeout": 30, + "matcher": "Edit|Write|Bash", + "failClosed": true, + "command": "loaf check --hook check-secrets" + }, + "signatures": [ + [ + "loaf", + "check", + "--hook", + "check-secrets" + ] + ], + "stems": [ + [ + "--hook", + "check-secrets" + ] + ] + }, + { + "event": "preToolUse", + "hook_id": "security-audit", + "type": "command", + "template": { + "loaf-managed": true, + "timeout": 600, + "matcher": "Bash", + "failClosed": true, + "command": "loaf check --hook security-audit" + }, + "signatures": [ + [ + "loaf", + "check", + "--hook", + "security-audit" + ] + ], + "stems": [ + [ + "--hook", + "security-audit" + ] + ] + }, + { + "event": "preToolUse", + "hook_id": "render-drift", + "type": "command", + "template": { + "loaf-managed": true, + "timeout": 30, + "matcher": "Bash", + "failClosed": true, + "command": "loaf check --hook render-drift", + "if": "Bash(git push:*)" + }, + "signatures": [ + [ + "loaf", + "check", + "--hook", + "render-drift" + ] + ], + "stems": [ + [ + "--hook", + "render-drift" + ] + ] + }, + { + "event": "preToolUse", + "hook_id": "ephemeral-provenance", + "type": "command", + "template": { + "loaf-managed": true, + "timeout": 30, + "matcher": "Bash", + "failClosed": true, + "command": "loaf check --hook ephemeral-provenance", + "if": "Bash(git push:*)" + }, + "signatures": [ + [ + "loaf", + "check", + "--hook", + "ephemeral-provenance" + ] + ], + "stems": [ + [ + "--hook", + "ephemeral-provenance" + ] + ] + }, + { + "event": "preToolUse", + "hook_id": "artifact-names", + "type": "command", + "template": { + "loaf-managed": true, + "timeout": 30, + "matcher": "Bash", + "failClosed": true, + "command": "loaf check --hook artifact-names", + "if": "Bash(git commit:*)" + }, + "signatures": [ + [ + "loaf", + "check", + "--hook", + "artifact-names" + ] + ], + "stems": [ + [ + "--hook", + "artifact-names" + ] + ] + }, + { + "event": "preToolUse", + "hook_id": "github-account", + "type": "command", + "template": { + "loaf-managed": true, + "timeout": 10, + "matcher": "Bash", + "failClosed": true, + "command": "loaf check --hook github-account" + }, + "signatures": [ + [ + "loaf", + "check", + "--hook", + "github-account" + ] + ], + "stems": [ + [ + "--hook", + "github-account" + ] + ] + }, + { + "event": "preToolUse", + "hook_id": "validate-push", + "type": "command", + "template": { + "loaf-managed": true, + "timeout": 60, + "matcher": "Bash", + "command": "loaf check --hook validate-push --advisory", + "if": "Bash(git push:*)" + }, + "signatures": [ + [ + "loaf", + "check", + "--hook", + "validate-push", + "--advisory" + ] + ], + "stems": [ + [ + "--hook", + "validate-push" + ] + ] + }, + { + "event": "preToolUse", + "hook_id": "workflow-pre-pr", + "type": "command", + "template": { + "loaf-managed": true, + "timeout": 5, + "matcher": "Bash", + "command": "loaf check --hook workflow-pre-pr --advisory", + "if": "Bash(gh pr create:*)" + }, + "signatures": [ + [ + "loaf", + "check", + "--hook", + "workflow-pre-pr", + "--advisory" + ] + ], + "stems": [ + [ + "--hook", + "workflow-pre-pr" + ] + ] + }, + { + "event": "preToolUse", + "hook_id": "workflow-pre-merge", + "type": "command", + "template": { + "loaf-managed": true, + "timeout": 5, + "matcher": "Bash", + "command": "cat \"$HOME/.cursor/hooks/instructions/pre-merge.md\"", + "if": "Bash(gh pr merge:*)" + }, + "signatures": [ + [ + "cat", + "$HOME/.cursor/hooks/instructions/pre-merge.md" + ] + ], + "stems": [ + [ + "$HOME/.cursor/hooks/instructions/pre-merge.md" + ] + ] + }, + { + "event": "preToolUse", + "hook_id": "workflow-pre-push", + "type": "command", + "template": { + "loaf-managed": true, + "timeout": 5, + "matcher": "Bash", + "command": "cat \"$HOME/.cursor/hooks/instructions/pre-push.md\"", + "if": "Bash(git push:*)" + }, + "signatures": [ + [ + "cat", + "$HOME/.cursor/hooks/instructions/pre-push.md" + ] + ], + "stems": [ + [ + "$HOME/.cursor/hooks/instructions/pre-push.md" + ] + ] + }, + { + "event": "preToolUse", + "hook_id": "validate-commit", + "type": "command", + "template": { + "loaf-managed": true, + "timeout": 30, + "matcher": "Bash", + "failClosed": true, + "command": "loaf check --hook validate-commit", + "if": "Bash(git commit:*)" + }, + "signatures": [ + [ + "loaf", + "check", + "--hook", + "validate-commit" + ] + ], + "stems": [ + [ + "--hook", + "validate-commit" + ] + ] + }, + { + "event": "preToolUse", + "hook_id": "detect-linear-magic", + "type": "command", + "template": { + "loaf-managed": true, + "timeout": 30, + "matcher": "Bash", + "command": "loaf journal log --detect-linear", + "if": "Bash(git commit:*)" + }, + "signatures": [ + [ + "loaf", + "journal", + "log", + "--detect-linear" + ] + ], + "stems": [ + [ + "journal", + "log", + "--detect-linear" + ] + ] + }, + { + "event": "postToolUse", + "hook_id": "generate-task-board", + "type": "command", + "template": { + "loaf-managed": true, + "timeout": 30, + "matcher": "Edit|Write", + "command": "loaf task refresh" + }, + "signatures": [ + [ + "loaf", + "task", + "refresh" + ] + ], + "stems": [ + [ + "task", + "refresh" + ] + ] + }, + { + "event": "postToolUse", + "hook_id": "kb-staleness-nudge", + "type": "command", + "template": { + "loaf-managed": true, + "timeout": 5, + "matcher": "Edit|Write", + "command": "bash $HOME/.cursor/hooks/post-tool/kb-staleness-nudge.sh" + }, + "signatures": [ + [ + "bash", + "$HOME/.cursor/hooks/post-tool/kb-staleness-nudge.sh" + ] + ], + "stems": [ + [ + "$HOME/.cursor/hooks/post-tool/kb-staleness-nudge.sh" + ] + ] + }, + { + "event": "postToolUse", + "hook_id": "workflow-post-merge", + "type": "command", + "template": { + "loaf-managed": true, + "timeout": 5, + "matcher": "Bash", + "command": "cat \"$HOME/.cursor/hooks/instructions/post-merge.md\"", + "if": "Bash(gh pr merge:*)" + }, + "signatures": [ + [ + "cat", + "$HOME/.cursor/hooks/instructions/post-merge.md" + ] + ], + "stems": [ + [ + "$HOME/.cursor/hooks/instructions/post-merge.md" + ] + ] + }, + { + "event": "sessionStart", + "hook_id": "session-start-loaf", + "type": "command", + "template": { + "loaf-managed": true, + "timeout": 60, + "command": "loaf journal context --from-hook --cursor-hook" + }, + "signatures": [ + [ + "loaf", + "journal", + "context", + "--from-hook", + "--cursor-hook" + ], + [ + "loaf", + "journal", + "context" + ], + [ + "loaf", + "journal", + "context", + "--from-hook" + ] + ], + "stems": [ + [ + "journal", + "context", + "--from-hook" + ] + ] + } + ], + "cohorts": [ + { + "version": "2.0.0-alpha.14", + "hook_ids": [ + "artifact-body-write", + "artifact-names", + "check-secrets", + "detect-linear-magic", + "ephemeral-provenance", + "generate-task-board", + "github-account", + "kb-staleness-nudge", + "render-drift", + "security-audit", + "session-start-loaf", + "validate-commit", + "validate-push", + "workflow-post-merge", + "workflow-pre-merge", + "workflow-pre-pr", + "workflow-pre-push" + ] + }, + { + "version": "2.0.0-alpha.15", + "hook_ids": [ + "artifact-body-write", + "artifact-names", + "check-secrets", + "detect-linear-magic", + "ephemeral-provenance", + "generate-task-board", + "github-account", + "kb-staleness-nudge", + "render-drift", + "security-audit", + "session-start-loaf", + "validate-commit", + "validate-push", + "workflow-post-merge", + "workflow-pre-merge", + "workflow-pre-pr", + "workflow-pre-push" + ] + }, + { + "version": "2.0.0-alpha.16", + "hook_ids": [ + "artifact-body-write", + "artifact-names", + "check-secrets", + "detect-linear-magic", + "ephemeral-provenance", + "generate-task-board", + "github-account", + "kb-staleness-nudge", + "render-drift", + "security-audit", + "session-start-loaf", + "validate-commit", + "validate-push", + "workflow-post-merge", + "workflow-pre-merge", + "workflow-pre-pr", + "workflow-pre-push" + ] + }, + { + "version": "2.0.0-alpha.17", + "hook_ids": [ + "artifact-body-write", + "artifact-names", + "check-secrets", + "detect-linear-magic", + "ephemeral-provenance", + "generate-task-board", + "github-account", + "kb-staleness-nudge", + "render-drift", + "security-audit", + "session-start-loaf", + "validate-commit", + "validate-push", + "workflow-post-merge", + "workflow-pre-merge", + "workflow-pre-pr", + "workflow-pre-push" + ] + }, + { + "version": "2.0.0-alpha.18", + "hook_ids": [ + "artifact-body-write", + "artifact-names", + "check-secrets", + "detect-linear-magic", + "ephemeral-provenance", + "generate-task-board", + "github-account", + "kb-staleness-nudge", + "render-drift", + "security-audit", + "session-start-loaf", + "validate-commit", + "validate-push", + "workflow-post-merge", + "workflow-pre-merge", + "workflow-pre-pr", + "workflow-pre-push" + ] + }, + { + "version": "2.0.0-alpha.19", + "hook_ids": [ + "artifact-body-write", + "artifact-names", + "check-secrets", + "detect-linear-magic", + "ephemeral-provenance", + "generate-task-board", + "github-account", + "kb-staleness-nudge", + "render-drift", + "security-audit", + "session-start-loaf", + "validate-commit", + "validate-push", + "workflow-post-merge", + "workflow-pre-merge", + "workflow-pre-pr", + "workflow-pre-push" + ] + }, + { + "version": "0.2.20", + "hook_ids": [ + "artifact-body-write", + "artifact-names", + "check-secrets", + "detect-linear-magic", + "ephemeral-provenance", + "generate-task-board", + "github-account", + "kb-staleness-nudge", + "render-drift", + "security-audit", + "session-start-loaf", + "validate-commit", + "validate-push", + "workflow-post-merge", + "workflow-pre-merge", + "workflow-pre-pr", + "workflow-pre-push" + ] + } + ] +} diff --git a/dist/cursor/.loaf-target-manifest.json b/dist/cursor/.loaf-target-manifest.json index 48ac40313..99feeb7f1 100644 --- a/dist/cursor/.loaf-target-manifest.json +++ b/dist/cursor/.loaf-target-manifest.json @@ -55,13 +55,6 @@ "sha256": "2eb14c3143b78d62cdcef56a12ea714472898ec49e54d4e53e217f0e5668cb5e", "mode": 493 }, - { - "id": "hook-projection:hooks.json", - "kind": "hook-projection", - "source_path": "hooks.json", - "destination": "hooks.json", - "sha256": "ee62210b456405c5760a2de17676a42c09e130a27c576a1967180053b9c1047b" - }, { "id": "managed-instructions", "kind": "instruction", diff --git a/dist/cursor/skills/loaf-reference/SKILL.md b/dist/cursor/skills/loaf-reference/SKILL.md index 30e68ef1a..94aa4b608 100644 --- a/dist/cursor/skills/loaf-reference/SKILL.md +++ b/dist/cursor/skills/loaf-reference/SKILL.md @@ -62,6 +62,7 @@ Names and one-line purposes only. Run `loaf --help` for options, argum | `loaf install` | Onboard Loaf into a folder or a not-yet-installed AI tool configuration | — | | `loaf upgrade` | Refresh Loaf in place: harness content sync plus deprecation cleanup anywhere, and project-surface refresh only inside a detected Loaf repo | — | | `loaf config` | Validate and refresh project Loaf config | check | +| `loaf hooks` | Inspect and set which Loaf hooks project into an installed harness's hooks file | list, enable, disable | | `loaf init` | Initialize a project with Loaf structure | — | | `loaf release` | Create a new release with changelog, version bump, and tag | — | | `loaf search` | Search SQLite artifact bodies, journal entries, and indexed docs | — | diff --git a/dist/opencode/skills/loaf-reference/SKILL.md b/dist/opencode/skills/loaf-reference/SKILL.md index 30e68ef1a..94aa4b608 100644 --- a/dist/opencode/skills/loaf-reference/SKILL.md +++ b/dist/opencode/skills/loaf-reference/SKILL.md @@ -62,6 +62,7 @@ Names and one-line purposes only. Run `loaf --help` for options, argum | `loaf install` | Onboard Loaf into a folder or a not-yet-installed AI tool configuration | — | | `loaf upgrade` | Refresh Loaf in place: harness content sync plus deprecation cleanup anywhere, and project-surface refresh only inside a detected Loaf repo | — | | `loaf config` | Validate and refresh project Loaf config | check | +| `loaf hooks` | Inspect and set which Loaf hooks project into an installed harness's hooks file | list, enable, disable | | `loaf init` | Initialize a project with Loaf structure | — | | `loaf release` | Create a new release with changelog, version bump, and tag | — | | `loaf search` | Search SQLite artifact bodies, journal entries, and indexed docs | — | diff --git a/dist/skills/loaf-reference/SKILL.md b/dist/skills/loaf-reference/SKILL.md index 01979679a..0028e445b 100644 --- a/dist/skills/loaf-reference/SKILL.md +++ b/dist/skills/loaf-reference/SKILL.md @@ -61,6 +61,7 @@ Names and one-line purposes only. Run `loaf --help` for options, argum | `loaf install` | Onboard Loaf into a folder or a not-yet-installed AI tool configuration | — | | `loaf upgrade` | Refresh Loaf in place: harness content sync plus deprecation cleanup anywhere, and project-surface refresh only inside a detected Loaf repo | — | | `loaf config` | Validate and refresh project Loaf config | check | +| `loaf hooks` | Inspect and set which Loaf hooks project into an installed harness's hooks file | list, enable, disable | | `loaf init` | Initialize a project with Loaf structure | — | | `loaf release` | Create a new release with changelog, version bump, and tag | — | | `loaf search` | Search SQLite artifact bodies, journal entries, and indexed docs | — | diff --git a/docs/changes/20260808-hooks-entry-reconciliation/change.json b/docs/changes/20260808-hooks-entry-reconciliation/change.json new file mode 100644 index 000000000..b40942c3f --- /dev/null +++ b/docs/changes/20260808-hooks-entry-reconciliation/change.json @@ -0,0 +1,5 @@ +{ + "branch": "hooks-entry-reconciliation", + "change": "hooks-entry-reconciliation", + "created": "2026-08-08" +} diff --git a/docs/changes/20260808-hooks-entry-reconciliation/research/canary/absorption-record-and-idempotency.txt b/docs/changes/20260808-hooks-entry-reconciliation/research/canary/absorption-record-and-idempotency.txt new file mode 100644 index 000000000..69e215fe2 --- /dev/null +++ b/docs/changes/20260808-hooks-entry-reconciliation/research/canary/absorption-record-and-idempotency.txt @@ -0,0 +1,20 @@ +=== hooks list --target codex (post-absorption) === + +loaf hooks + + Codex ~/.config/codex/hooks.json + session-start-loaf SessionStart disabled absent (absorbed 2026-08-10) + + +=== second upgrade run (idempotency) === + ✓ Cursor refreshed at /Users/levifig/.cursor (v0.2.20) + ✓ Codex refreshed at /Users/levifig/.config/codex (v0.2.20) + ✓ Amp refreshed at /Users/levifig/.config/amp (v0.2.20) + + ✓ Loaf project detected — project record proj_7afeb3fc23aa2422d27c9cbc60d31b1b in the state database +-- + ○ Claude Code Loaf framework section already current (v0.2.20) + ○ Codex Loaf framework section already current (v0.2.20) + ○ Cursor Loaf framework section already current (v0.2.20) + ○ OpenCode Loaf framework section already current (v0.2.20) + diff --git a/docs/changes/20260808-hooks-entry-reconciliation/research/canary/codex-hooks-after.json b/docs/changes/20260808-hooks-entry-reconciliation/research/canary/codex-hooks-after.json new file mode 100644 index 000000000..83f575771 --- /dev/null +++ b/docs/changes/20260808-hooks-entry-reconciliation/research/canary/codex-hooks-after.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "command": "bash '/Users/levifig/.config/codex/herdr-agent-state.sh' session", + "timeout": 10, + "type": "command" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/docs/changes/20260808-hooks-entry-reconciliation/research/canary/codex-hooks-before.json b/docs/changes/20260808-hooks-entry-reconciliation/research/canary/codex-hooks-before.json new file mode 100644 index 000000000..83f575771 --- /dev/null +++ b/docs/changes/20260808-hooks-entry-reconciliation/research/canary/codex-hooks-before.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "command": "bash '/Users/levifig/.config/codex/herdr-agent-state.sh' session", + "timeout": 10, + "type": "command" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/docs/changes/20260808-hooks-entry-reconciliation/research/canary/codex-hooks-enabled.json b/docs/changes/20260808-hooks-entry-reconciliation/research/canary/codex-hooks-enabled.json new file mode 100644 index 000000000..495ccfaa5 --- /dev/null +++ b/docs/changes/20260808-hooks-entry-reconciliation/research/canary/codex-hooks-enabled.json @@ -0,0 +1,24 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "command": "bash '/Users/levifig/.config/codex/herdr-agent-state.sh' session", + "timeout": 10, + "type": "command" + } + ] + }, + { + "hooks": [ + { + "command": "'/opt/homebrew/bin/loaf' journal context --from-hook --codex-hook", + "type": "command" + } + ], + "matcher": "startup|resume|clear|compact" + } + ] + } +} diff --git a/docs/changes/20260808-hooks-entry-reconciliation/research/canary/codex-hooks-pre-emptying-alpha19.json b/docs/changes/20260808-hooks-entry-reconciliation/research/canary/codex-hooks-pre-emptying-alpha19.json new file mode 100644 index 000000000..6be5f08cc --- /dev/null +++ b/docs/changes/20260808-hooks-entry-reconciliation/research/canary/codex-hooks-pre-emptying-alpha19.json @@ -0,0 +1,146 @@ +{ + "hooks": { + "PermissionRequest": [ + { + "hooks": [ + { + "command": "bash '/Users/levifig/.config/codex/herdr-agent-state.sh' blocked", + "timeout": 10, + "type": "command" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "if [ -f '/Users/levifig/.orca/agent-hooks/codex-hook.sh' ] \u0026\u0026 [ -r '/Users/levifig/.orca/agent-hooks/codex-hook.sh' ] \u0026\u0026 [ -x '/Users/levifig/.orca/agent-hooks/codex-hook.sh' ]; then /bin/sh '/Users/levifig/.orca/agent-hooks/codex-hook.sh'; else { command -p cat 2\u003e/dev/null || cat; } \u003e/dev/null 2\u003e\u00261 || :; fi", + "timeout": 10 + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "if [ -f '/Users/levifig/.orca/agent-hooks/codex-hook.sh' ] \u0026\u0026 [ -r '/Users/levifig/.orca/agent-hooks/codex-hook.sh' ] \u0026\u0026 [ -x '/Users/levifig/.orca/agent-hooks/codex-hook.sh' ]; then /bin/sh '/Users/levifig/.orca/agent-hooks/codex-hook.sh'; else { command -p cat 2\u003e/dev/null || cat; } \u003e/dev/null 2\u003e\u00261 || :; fi", + "timeout": 10 + } + ] + } + ], + "PreToolUse": [ + { + "hooks": [ + { + "command": "bash '/Users/levifig/.config/codex/herdr-agent-state.sh' working", + "timeout": 10, + "type": "command" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "if [ -f '/Users/levifig/.orca/agent-hooks/codex-hook.sh' ] \u0026\u0026 [ -r '/Users/levifig/.orca/agent-hooks/codex-hook.sh' ] \u0026\u0026 [ -x '/Users/levifig/.orca/agent-hooks/codex-hook.sh' ]; then /bin/sh '/Users/levifig/.orca/agent-hooks/codex-hook.sh'; else { command -p cat 2\u003e/dev/null || cat; } \u003e/dev/null 2\u003e\u00261 || :; fi", + "timeout": 10 + } + ] + } + ], + "SessionStart": [ + { + "hooks": [ + { + "command": "bash '/Users/levifig/.config/codex/herdr-agent-state.sh' idle", + "timeout": 10, + "type": "command" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "if [ -f '/Users/levifig/.orca/agent-hooks/codex-hook.sh' ] \u0026\u0026 [ -r '/Users/levifig/.orca/agent-hooks/codex-hook.sh' ] \u0026\u0026 [ -x '/Users/levifig/.orca/agent-hooks/codex-hook.sh' ]; then /bin/sh '/Users/levifig/.orca/agent-hooks/codex-hook.sh'; else { command -p cat 2\u003e/dev/null || cat; } \u003e/dev/null 2\u003e\u00261 || :; fi", + "timeout": 10 + } + ] + }, + { + "hooks": [ + { + "command": "'/opt/homebrew/bin/loaf' journal context --from-hook --codex-hook", + "type": "command" + } + ], + "matcher": "startup|resume|clear|compact" + } + ], + "Stop": [ + { + "hooks": [ + { + "command": "bash '/Users/levifig/.config/codex/herdr-agent-state.sh' idle", + "timeout": 10, + "type": "command" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "if [ -f '/Users/levifig/.orca/agent-hooks/codex-hook.sh' ] \u0026\u0026 [ -r '/Users/levifig/.orca/agent-hooks/codex-hook.sh' ] \u0026\u0026 [ -x '/Users/levifig/.orca/agent-hooks/codex-hook.sh' ]; then /bin/sh '/Users/levifig/.orca/agent-hooks/codex-hook.sh'; else { command -p cat 2\u003e/dev/null || cat; } \u003e/dev/null 2\u003e\u00261 || :; fi", + "timeout": 10 + } + ] + } + ], + "SubagentStart": [ + { + "hooks": [ + { + "type": "command", + "command": "if [ -f '/Users/levifig/.orca/agent-hooks/codex-hook.sh' ] \u0026\u0026 [ -r '/Users/levifig/.orca/agent-hooks/codex-hook.sh' ] \u0026\u0026 [ -x '/Users/levifig/.orca/agent-hooks/codex-hook.sh' ]; then /bin/sh '/Users/levifig/.orca/agent-hooks/codex-hook.sh'; else { command -p cat 2\u003e/dev/null || cat; } \u003e/dev/null 2\u003e\u00261 || :; fi", + "timeout": 10 + } + ] + } + ], + "SubagentStop": [ + { + "hooks": [ + { + "type": "command", + "command": "if [ -f '/Users/levifig/.orca/agent-hooks/codex-hook.sh' ] \u0026\u0026 [ -r '/Users/levifig/.orca/agent-hooks/codex-hook.sh' ] \u0026\u0026 [ -x '/Users/levifig/.orca/agent-hooks/codex-hook.sh' ]; then /bin/sh '/Users/levifig/.orca/agent-hooks/codex-hook.sh'; else { command -p cat 2\u003e/dev/null || cat; } \u003e/dev/null 2\u003e\u00261 || :; fi", + "timeout": 10 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "command": "bash '/Users/levifig/.config/codex/herdr-agent-state.sh' working", + "timeout": 10, + "type": "command" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "if [ -f '/Users/levifig/.orca/agent-hooks/codex-hook.sh' ] \u0026\u0026 [ -r '/Users/levifig/.orca/agent-hooks/codex-hook.sh' ] \u0026\u0026 [ -x '/Users/levifig/.orca/agent-hooks/codex-hook.sh' ]; then /bin/sh '/Users/levifig/.orca/agent-hooks/codex-hook.sh'; else { command -p cat 2\u003e/dev/null || cat; } \u003e/dev/null 2\u003e\u00261 || :; fi", + "timeout": 10 + } + ] + } + ] + } +} diff --git a/docs/changes/20260808-hooks-entry-reconciliation/research/canary/codex-hooks-redisabled.json b/docs/changes/20260808-hooks-entry-reconciliation/research/canary/codex-hooks-redisabled.json new file mode 100644 index 000000000..259199b5c --- /dev/null +++ b/docs/changes/20260808-hooks-entry-reconciliation/research/canary/codex-hooks-redisabled.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "command": "bash '/Users/levifig/.config/codex/herdr-agent-state.sh' session", + "timeout": 10, + "type": "command" + } + ] + } + ] + } +} diff --git a/docs/changes/20260808-hooks-entry-reconciliation/research/canary/comparator-output.txt b/docs/changes/20260808-hooks-entry-reconciliation/research/canary/comparator-output.txt new file mode 100644 index 000000000..926258dbb --- /dev/null +++ b/docs/changes/20260808-hooks-entry-reconciliation/research/canary/comparator-output.txt @@ -0,0 +1,5 @@ +=== codex before/after === +identical (value-level, order-stable) + +=== cursor before/after === +identical (value-level, order-stable) diff --git a/docs/changes/20260808-hooks-entry-reconciliation/research/canary/config-check.txt b/docs/changes/20260808-hooks-entry-reconciliation/research/canary/config-check.txt new file mode 100644 index 000000000..f495b780c --- /dev/null +++ b/docs/changes/20260808-hooks-entry-reconciliation/research/canary/config-check.txt @@ -0,0 +1,10 @@ + +loaf config check + + ✓ .agents/loaf.json valid + ✓ Amp hooks current + ✓ Codex hooks current + ✓ Cursor hooks current + ✓ OpenCode hooks current + ✓ Config is valid and current. + diff --git a/docs/changes/20260808-hooks-entry-reconciliation/research/canary/cursor-hooks-after.json b/docs/changes/20260808-hooks-entry-reconciliation/research/canary/cursor-hooks-after.json new file mode 100644 index 000000000..57a8c985d --- /dev/null +++ b/docs/changes/20260808-hooks-entry-reconciliation/research/canary/cursor-hooks-after.json @@ -0,0 +1,293 @@ +{ + "version": 1, + "hooks": { + "postToolUse": [ + { + "command": "bash $HOME/.cursor/hooks/post-tool/python-ruff-check.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/post-tool/typescript-eslint-check.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/post-tool/rails-rubocop-check.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/post-tool/design-a11y-validation.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/post-tool/design-token-check.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/post-tool/design-a11y-audit.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/post-tool/foundations-changelog-reminder.sh", + "matcher": "Bash", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/post-tool/orchestration-generate-task-board.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "loaf task refresh", + "loaf-managed": true, + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/post-tool/kb-staleness-nudge.sh", + "loaf-managed": true, + "matcher": "Edit|Write", + "timeout": 5 + }, + { + "command": "cat \"$HOME/.cursor/hooks/instructions/post-merge.md\"", + "if": "Bash(gh pr merge:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 5 + } + ], + "preToolUse": [ + { + "command": "bash $HOME/.cursor/hooks/pre-tool/foundations-check-secrets.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/foundations-validate-changelog.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/foundations-format-check.sh", + "matcher": "Edit|Write", + "timeout": 60 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/foundations-security-audit.sh", + "matcher": "Bash", + "timeout": 600 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/foundations-tdd-advisory.sh", + "matcher": "Edit|Write", + "timeout": 5 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/foundations-validate-push.sh", + "matcher": "Bash", + "timeout": 60 + }, + { + "command": "python3 $HOME/.cursor/hooks/pre-tool/orchestration-validate-commit.py", + "matcher": "Bash", + "timeout": 30 + }, + { + "command": "python3 $HOME/.cursor/hooks/pre-tool/orchestration-detect-linear-magic.py", + "matcher": "Bash", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/python-type-check.sh", + "matcher": "Edit|Write", + "timeout": 60 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/python-pytest-validation.sh", + "matcher": "Edit|Write", + "timeout": 120 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/python-type-check-progressive.sh", + "matcher": "Edit|Write", + "timeout": 120 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/python-ruff-lint.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/python-pytest-execution.sh", + "matcher": "Edit|Write", + "timeout": 480 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/python-bandit-scan.sh", + "matcher": "Edit|Write", + "timeout": 60 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/typescript-tsc-check.sh", + "matcher": "Edit|Write", + "timeout": 60 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/typescript-bundle-analysis.sh", + "matcher": "Edit|Write", + "timeout": 60 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/rails-migration-safety.sh", + "matcher": "Edit|Write", + "timeout": 60 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/rails-migration-safety-deep.sh", + "matcher": "Edit|Write", + "timeout": 300 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/rails-test-execution.sh", + "matcher": "Edit|Write", + "timeout": 480 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/rails-brakeman-scan.sh", + "matcher": "Edit|Write", + "timeout": 120 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/infra-validate-k8s.sh", + "matcher": "Edit|Write", + "timeout": 60 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/infra-dockerfile-lint.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/infra-k8s-dry-run-server.sh", + "matcher": "Edit|Write", + "timeout": 120 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/infra-terraform-plan-cost.sh", + "matcher": "Edit|Write", + "timeout": 300 + }, + { + "command": "loaf check --hook artifact-body-write", + "failClosed": true, + "loaf-managed": true, + "matcher": "Edit|Write|Bash", + "timeout": 30 + }, + { + "command": "loaf check --hook check-secrets", + "failClosed": true, + "loaf-managed": true, + "matcher": "Edit|Write|Bash", + "timeout": 30 + }, + { + "command": "loaf check --hook security-audit", + "failClosed": true, + "loaf-managed": true, + "matcher": "Bash", + "timeout": 600 + }, + { + "command": "loaf check --hook render-drift", + "failClosed": true, + "if": "Bash(git push:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 30 + }, + { + "command": "loaf check --hook ephemeral-provenance", + "failClosed": true, + "if": "Bash(git push:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 30 + }, + { + "command": "loaf check --hook artifact-names", + "failClosed": true, + "if": "Bash(git commit:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 30 + }, + { + "command": "loaf check --hook github-account", + "failClosed": true, + "loaf-managed": true, + "matcher": "Bash", + "timeout": 10 + }, + { + "command": "loaf check --hook validate-push --advisory", + "if": "Bash(git push:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 60 + }, + { + "command": "loaf check --hook workflow-pre-pr --advisory", + "if": "Bash(gh pr create:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 5 + }, + { + "command": "cat \"$HOME/.cursor/hooks/instructions/pre-merge.md\"", + "if": "Bash(gh pr merge:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 5 + }, + { + "command": "cat \"$HOME/.cursor/hooks/instructions/pre-push.md\"", + "if": "Bash(git push:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 5 + }, + { + "command": "loaf check --hook validate-commit", + "failClosed": true, + "if": "Bash(git commit:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 30 + }, + { + "command": "loaf journal log --detect-linear", + "if": "Bash(git commit:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 30 + } + ], + "sessionStart": [ + { + "command": "bash '/Users/levifig/.cursor/herdr-agent-state.sh' session" + }, + { + "command": "loaf journal context --from-hook --cursor-hook", + "loaf-managed": true, + "timeout": 60 + } + ] + } +} diff --git a/docs/changes/20260808-hooks-entry-reconciliation/research/canary/cursor-hooks-before.json b/docs/changes/20260808-hooks-entry-reconciliation/research/canary/cursor-hooks-before.json new file mode 100644 index 000000000..57a8c985d --- /dev/null +++ b/docs/changes/20260808-hooks-entry-reconciliation/research/canary/cursor-hooks-before.json @@ -0,0 +1,293 @@ +{ + "version": 1, + "hooks": { + "postToolUse": [ + { + "command": "bash $HOME/.cursor/hooks/post-tool/python-ruff-check.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/post-tool/typescript-eslint-check.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/post-tool/rails-rubocop-check.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/post-tool/design-a11y-validation.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/post-tool/design-token-check.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/post-tool/design-a11y-audit.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/post-tool/foundations-changelog-reminder.sh", + "matcher": "Bash", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/post-tool/orchestration-generate-task-board.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "loaf task refresh", + "loaf-managed": true, + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/post-tool/kb-staleness-nudge.sh", + "loaf-managed": true, + "matcher": "Edit|Write", + "timeout": 5 + }, + { + "command": "cat \"$HOME/.cursor/hooks/instructions/post-merge.md\"", + "if": "Bash(gh pr merge:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 5 + } + ], + "preToolUse": [ + { + "command": "bash $HOME/.cursor/hooks/pre-tool/foundations-check-secrets.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/foundations-validate-changelog.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/foundations-format-check.sh", + "matcher": "Edit|Write", + "timeout": 60 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/foundations-security-audit.sh", + "matcher": "Bash", + "timeout": 600 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/foundations-tdd-advisory.sh", + "matcher": "Edit|Write", + "timeout": 5 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/foundations-validate-push.sh", + "matcher": "Bash", + "timeout": 60 + }, + { + "command": "python3 $HOME/.cursor/hooks/pre-tool/orchestration-validate-commit.py", + "matcher": "Bash", + "timeout": 30 + }, + { + "command": "python3 $HOME/.cursor/hooks/pre-tool/orchestration-detect-linear-magic.py", + "matcher": "Bash", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/python-type-check.sh", + "matcher": "Edit|Write", + "timeout": 60 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/python-pytest-validation.sh", + "matcher": "Edit|Write", + "timeout": 120 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/python-type-check-progressive.sh", + "matcher": "Edit|Write", + "timeout": 120 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/python-ruff-lint.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/python-pytest-execution.sh", + "matcher": "Edit|Write", + "timeout": 480 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/python-bandit-scan.sh", + "matcher": "Edit|Write", + "timeout": 60 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/typescript-tsc-check.sh", + "matcher": "Edit|Write", + "timeout": 60 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/typescript-bundle-analysis.sh", + "matcher": "Edit|Write", + "timeout": 60 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/rails-migration-safety.sh", + "matcher": "Edit|Write", + "timeout": 60 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/rails-migration-safety-deep.sh", + "matcher": "Edit|Write", + "timeout": 300 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/rails-test-execution.sh", + "matcher": "Edit|Write", + "timeout": 480 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/rails-brakeman-scan.sh", + "matcher": "Edit|Write", + "timeout": 120 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/infra-validate-k8s.sh", + "matcher": "Edit|Write", + "timeout": 60 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/infra-dockerfile-lint.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/infra-k8s-dry-run-server.sh", + "matcher": "Edit|Write", + "timeout": 120 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/infra-terraform-plan-cost.sh", + "matcher": "Edit|Write", + "timeout": 300 + }, + { + "command": "loaf check --hook artifact-body-write", + "failClosed": true, + "loaf-managed": true, + "matcher": "Edit|Write|Bash", + "timeout": 30 + }, + { + "command": "loaf check --hook check-secrets", + "failClosed": true, + "loaf-managed": true, + "matcher": "Edit|Write|Bash", + "timeout": 30 + }, + { + "command": "loaf check --hook security-audit", + "failClosed": true, + "loaf-managed": true, + "matcher": "Bash", + "timeout": 600 + }, + { + "command": "loaf check --hook render-drift", + "failClosed": true, + "if": "Bash(git push:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 30 + }, + { + "command": "loaf check --hook ephemeral-provenance", + "failClosed": true, + "if": "Bash(git push:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 30 + }, + { + "command": "loaf check --hook artifact-names", + "failClosed": true, + "if": "Bash(git commit:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 30 + }, + { + "command": "loaf check --hook github-account", + "failClosed": true, + "loaf-managed": true, + "matcher": "Bash", + "timeout": 10 + }, + { + "command": "loaf check --hook validate-push --advisory", + "if": "Bash(git push:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 60 + }, + { + "command": "loaf check --hook workflow-pre-pr --advisory", + "if": "Bash(gh pr create:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 5 + }, + { + "command": "cat \"$HOME/.cursor/hooks/instructions/pre-merge.md\"", + "if": "Bash(gh pr merge:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 5 + }, + { + "command": "cat \"$HOME/.cursor/hooks/instructions/pre-push.md\"", + "if": "Bash(git push:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 5 + }, + { + "command": "loaf check --hook validate-commit", + "failClosed": true, + "if": "Bash(git commit:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 30 + }, + { + "command": "loaf journal log --detect-linear", + "if": "Bash(git commit:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 30 + } + ], + "sessionStart": [ + { + "command": "bash '/Users/levifig/.cursor/herdr-agent-state.sh' session" + }, + { + "command": "loaf journal context --from-hook --cursor-hook", + "loaf-managed": true, + "timeout": 60 + } + ] + } +} diff --git a/docs/changes/20260808-hooks-entry-reconciliation/research/canary/state-migration.txt b/docs/changes/20260808-hooks-entry-reconciliation/research/canary/state-migration.txt new file mode 100644 index 000000000..db116d4f7 --- /dev/null +++ b/docs/changes/20260808-hooks-entry-reconciliation/research/canary/state-migration.txt @@ -0,0 +1,63 @@ +=== state status (pre-migrate) === +loaf state status +project root: /Users/levifig/Code/levifig/projects/loaf +legacy project key: 2c26b55e8918933726849a1957f40b06ea54d6066ead3f2e9723e92a3d94db66 +scope: global database +database: /Users/levifig/.local/share/loaf/loaf.sqlite +database exists: true +mode: invalid +schema version: 12 + +=== state backup === +loaf: state database is invalid; run `loaf state doctor` + +=== state migrate === +Usage: loaf state migrate [options] + +Run state migrations. + +Sources: + markdown Import .agents Markdown artifacts into SQLite + storage-home Copy legacy per-project state into the global XDG data-home database + schema Preview or apply pending SQLite schema upgrades + lifecycle-statuses Normalize legacy lifecycle statuses in SQLite + journal-first Transform the global database to the journal-first model + deferrals Convert historical journal deferrals into canonical deferred Intents + +Options: + -h, --help Show help +=== migrate schema (preview) === +loaf: loaf state migrate schema requires --dry-run or --apply +=== migrate schema --dry-run === +loaf state migrate schema --dry-run +scope: global database, schema migration +database: /Users/levifig/.local/share/loaf/loaf.sqlite +current schema version: 12 +required schema version: 13 +pending versions: [13] +action: dry-run +applied: false +verified: false +backup verified: false +final schema version: 12 +next: rerun with --apply to create a verified backup and apply pending schema upgrades + +=== migrate schema --apply === +loaf state migrate schema --apply +scope: global database, schema migration +database: /Users/levifig/.local/share/loaf/loaf.sqlite +current schema version: 13 +required schema version: 13 +pending versions: [13] +action: apply +applied: true +verified: true +backup verified: true +backup: /Users/levifig/.local/share/loaf/backups/loaf-20260810-014435-860505000.sqlite +final schema version: 13 + +=== state status (post-migrate) === +database: /Users/levifig/.local/share/loaf/loaf.sqlite +database exists: true +mode: sqlite-ready +schema version: 13 diff --git a/docs/changes/20260808-hooks-entry-reconciliation/research/canary/upgrade-apply.txt b/docs/changes/20260808-hooks-entry-reconciliation/research/canary/upgrade-apply.txt new file mode 100644 index 000000000..6bb1f2caa --- /dev/null +++ b/docs/changes/20260808-hooks-entry-reconciliation/research/canary/upgrade-apply.txt @@ -0,0 +1,19 @@ + +loaf upgrade + + Upgrading: opencode, cursor, codex, amp + • install deprecation cleanup + - externalized skill thermo-nuclear-code-quality-review at /Users/levifig/.agents/skills/thermo-nuclear-code-quality-review — externalized by SPEC-053 taxonomy decision; install as a vendor skill instead of a Loaf core skill (since v2.0.0-pre.20260614235428, window one-release) [signoff: report-spec-053-taxonomy-signoff] source: https://github.com/cursor/plugins/tree/main/cursor-team-kit/skills/thermo-nuclear-code-quality-review + ✓ OpenCode refreshed at /Users/levifig/.config/opencode (v0.2.20) + ✓ Cursor refreshed at /Users/levifig/.cursor (v0.2.20) + ✓ Codex refreshed at /Users/levifig/.config/codex (v0.2.20) + ○ absorb hook:SessionStart/session-start-loaf — absent before this upgrade; recorded as disabled + ✓ Amp refreshed at /Users/levifig/.config/amp (v0.2.20) + + ✓ Loaf project detected — project record proj_7afeb3fc23aa2422d27c9cbc60d31b1b in the state database + ○ Amp Loaf framework section already current (v0.2.20) + ○ Claude Code Loaf framework section already current (v0.2.20) + ○ Codex Loaf framework section already current (v0.2.20) + ○ Cursor Loaf framework section already current (v0.2.20) + ○ OpenCode Loaf framework section already current (v0.2.20) + diff --git a/docs/changes/20260808-hooks-entry-reconciliation/research/canary/upgrade-dry-run-post-fix.txt b/docs/changes/20260808-hooks-entry-reconciliation/research/canary/upgrade-dry-run-post-fix.txt new file mode 100644 index 000000000..e177a397c --- /dev/null +++ b/docs/changes/20260808-hooks-entry-reconciliation/research/canary/upgrade-dry-run-post-fix.txt @@ -0,0 +1,89 @@ + +loaf upgrade --dry-run + Plan only — no files, manifests, config, or state will change. + + Skills + ○ preserve skill:architecture + ○ preserve skill:bootstrap + ○ preserve skill:brainstorm + ○ preserve skill:breakdown + ○ preserve skill:council + ○ preserve skill:database-design + ○ preserve skill:debugging + ○ preserve skill:documentation-standards + ○ preserve skill:explore + ○ preserve skill:foundations + ○ preserve skill:git-workflow + ○ preserve skill:go-development + ○ preserve skill:handoff + ○ preserve skill:housekeeping + ○ preserve skill:idea + ○ preserve skill:implement + ○ preserve skill:infrastructure-management + ○ preserve skill:interface-design + ○ preserve skill:knowledge-base + + update skill:loaf-reference + ○ preserve skill:orchestration + ○ preserve skill:pitch + ○ preserve skill:power-systems-modeling + ○ preserve skill:python-development + ○ preserve skill:refactor-deepen + ○ preserve skill:reflect + ○ preserve skill:release + ○ preserve skill:research + ○ preserve skill:ruby-development + ○ preserve skill:security-compliance + ○ preserve skill:shape + ○ preserve skill:ship + ○ preserve skill:strategy + ○ preserve skill:triage + ○ preserve skill:typescript-development + ○ preserve skill:wrap + + Amp /Users/levifig/.config/amp + ○ preserve plugin:.amp/plugins/loaf.ts + + Codex /Users/levifig/.config/codex + ○ preserve codex-rule:AGENTS.md + ○ preserve codex-rule:loaf.rules + ○ absorb hook:SessionStart/session-start-loaf — absent before this upgrade; recorded as disabled + + Cursor /Users/levifig/.cursor + ○ preserve hook-file:hooks/instructions/post-merge.md + ○ preserve hook-file:hooks/instructions/pre-merge.md + ○ preserve hook-file:hooks/instructions/pre-pr-checklist.md + ○ preserve hook-file:hooks/instructions/pre-pr-format.md + ○ preserve hook-file:hooks/instructions/pre-push.md + ○ preserve hook-file:hooks/post-tool/kb-staleness-nudge.sh + + OpenCode /Users/levifig/.config/opencode + ○ preserve hook-file:plugins/hooks/instructions/post-merge.md + ○ preserve hook-file:plugins/hooks/instructions/pre-merge.md + ○ preserve hook-file:plugins/hooks/instructions/pre-pr-checklist.md + ○ preserve hook-file:plugins/hooks/instructions/pre-pr-format.md + ○ preserve hook-file:plugins/hooks/instructions/pre-push.md + ○ preserve hook-file:plugins/hooks/post-tool/kb-staleness-nudge.sh + ○ preserve hook-file:plugins/hooks/pre-tool/foundations-check-secrets.sh + ○ preserve hook-file:plugins/hooks/pre-tool/foundations-validate-push.sh + ○ preserve hook-file:plugins/hooks/pre-tool/orchestration-detect-linear-magic.py + ○ preserve hook-file:plugins/hooks/pre-tool/orchestration-validate-commit.py + ○ preserve hook-file:plugins/hooks/subagent/validate-infra-safety.sh + ○ preserve hook-file:plugins/hooks/subagent/validate-sql-safety.sh + ○ preserve plugin:plugins/hooks.ts + + Deprecations + • externalized skill thermo-nuclear-code-quality-review at /Users/levifig/.agents/skills/thermo-nuclear-code-quality-review + + Project files + ○ already-correct ./AGENTS.md — Canonical ./AGENTS.md already exists + ○ already-correct .claude/CLAUDE.md — .claude/CLAUDE.md already points to ../AGENTS.md + ○ skipped .claude/CLAUDE.md — Loaf framework section already current (v0.2.20) + ○ skipped AGENTS.md — shared canonical project file already planned + ○ skipped AGENTS.md — shared canonical project file already planned + ○ skipped AGENTS.md — shared canonical project file already planned + ○ skipped AGENTS.md — shared canonical project file already planned + ○ already-correct .agents/loaf.json — MCP recommendations already recorded + + Apply with + $ loaf upgrade + diff --git a/docs/changes/20260808-hooks-entry-reconciliation/research/canary/upgrade-dry-run.txt b/docs/changes/20260808-hooks-entry-reconciliation/research/canary/upgrade-dry-run.txt new file mode 100644 index 000000000..3c489e312 --- /dev/null +++ b/docs/changes/20260808-hooks-entry-reconciliation/research/canary/upgrade-dry-run.txt @@ -0,0 +1,89 @@ + +loaf upgrade --dry-run + Plan only — no files, manifests, config, or state will change. + + Skills + ○ preserve skill:architecture + ○ preserve skill:bootstrap + ○ preserve skill:brainstorm + ○ preserve skill:breakdown + ○ preserve skill:council + ○ preserve skill:database-design + ○ preserve skill:debugging + ○ preserve skill:documentation-standards + ○ preserve skill:explore + ○ preserve skill:foundations + ○ preserve skill:git-workflow + ○ preserve skill:go-development + ○ preserve skill:handoff + ○ preserve skill:housekeeping + ○ preserve skill:idea + ○ preserve skill:implement + ○ preserve skill:infrastructure-management + ○ preserve skill:interface-design + ○ preserve skill:knowledge-base + + update skill:loaf-reference + ○ preserve skill:orchestration + ○ preserve skill:pitch + ○ preserve skill:power-systems-modeling + ○ preserve skill:python-development + ○ preserve skill:refactor-deepen + ○ preserve skill:reflect + ○ preserve skill:release + ○ preserve skill:research + ○ preserve skill:ruby-development + ○ preserve skill:security-compliance + ○ preserve skill:shape + ○ preserve skill:ship + ○ preserve skill:strategy + ○ preserve skill:triage + ○ preserve skill:typescript-development + ○ preserve skill:wrap + + Amp /Users/levifig/.config/amp + ○ preserve plugin:.amp/plugins/loaf.ts + + Codex /Users/levifig/.config/codex + ○ preserve codex-rule:AGENTS.md + ○ preserve codex-rule:loaf.rules + + add hook:SessionStart/session-start-loaf + + Cursor /Users/levifig/.cursor + ○ preserve hook-file:hooks/instructions/post-merge.md + ○ preserve hook-file:hooks/instructions/pre-merge.md + ○ preserve hook-file:hooks/instructions/pre-pr-checklist.md + ○ preserve hook-file:hooks/instructions/pre-pr-format.md + ○ preserve hook-file:hooks/instructions/pre-push.md + ○ preserve hook-file:hooks/post-tool/kb-staleness-nudge.sh + + OpenCode /Users/levifig/.config/opencode + ○ preserve hook-file:plugins/hooks/instructions/post-merge.md + ○ preserve hook-file:plugins/hooks/instructions/pre-merge.md + ○ preserve hook-file:plugins/hooks/instructions/pre-pr-checklist.md + ○ preserve hook-file:plugins/hooks/instructions/pre-pr-format.md + ○ preserve hook-file:plugins/hooks/instructions/pre-push.md + ○ preserve hook-file:plugins/hooks/post-tool/kb-staleness-nudge.sh + ○ preserve hook-file:plugins/hooks/pre-tool/foundations-check-secrets.sh + ○ preserve hook-file:plugins/hooks/pre-tool/foundations-validate-push.sh + ○ preserve hook-file:plugins/hooks/pre-tool/orchestration-detect-linear-magic.py + ○ preserve hook-file:plugins/hooks/pre-tool/orchestration-validate-commit.py + ○ preserve hook-file:plugins/hooks/subagent/validate-infra-safety.sh + ○ preserve hook-file:plugins/hooks/subagent/validate-sql-safety.sh + ○ preserve plugin:plugins/hooks.ts + + Deprecations + • externalized skill thermo-nuclear-code-quality-review at /Users/levifig/.agents/skills/thermo-nuclear-code-quality-review + + Project files + ○ already-correct ./AGENTS.md — Canonical ./AGENTS.md already exists + ○ already-correct .claude/CLAUDE.md — .claude/CLAUDE.md already points to ../AGENTS.md + ○ skipped .claude/CLAUDE.md — Loaf framework section already current (v0.2.20) + ○ skipped AGENTS.md — shared canonical project file already planned + ○ skipped AGENTS.md — shared canonical project file already planned + ○ skipped AGENTS.md — shared canonical project file already planned + ○ skipped AGENTS.md — shared canonical project file already planned + ○ already-correct .agents/loaf.json — MCP recommendations already recorded + + Apply with + $ loaf upgrade + diff --git a/docs/changes/20260808-hooks-entry-reconciliation/research/canary/verb-round-trip.txt b/docs/changes/20260808-hooks-entry-reconciliation/research/canary/verb-round-trip.txt new file mode 100644 index 000000000..22eea64fa --- /dev/null +++ b/docs/changes/20260808-hooks-entry-reconciliation/research/canary/verb-round-trip.txt @@ -0,0 +1,27 @@ +=== enable === + + ✓ enabled — entry restored to ~/.config/codex/hooks.json + + +--- diff after enable (vs after-absorption state): +11a12,20 +> }, +> { +> "hooks": [ +> { +> "command": "'/opt/homebrew/bin/loaf' journal context --from-hook --codex-hook", +> "type": "command" +> } +> ], +> "matcher": "startup|resume|clear|compact" + +=== disable === + + ✓ disabled — entry removed from ~/.config/codex/hooks.json + + +--- file back to absorbed state? +identical (value-level, order-stable) + +=== absorbed_at survived toggles? === + session-start-loaf SessionStart disabled absent (absorbed 2026-08-10) diff --git a/docs/changes/20260808-hooks-entry-reconciliation/research/classify_hook_entries.py b/docs/changes/20260808-hooks-entry-reconciliation/research/classify_hook_entries.py new file mode 100644 index 000000000..50b1c20c8 --- /dev/null +++ b/docs/changes/20260808-hooks-entry-reconciliation/research/classify_hook_entries.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +"""Classify every entry in the live Codex/Cursor hooks.json against 0.2.20 dist. + +Ports the ownership predicates from internal/cli/install_target.go +(isLoafInstallHookForOS, codexHookOwnershipForOS, installHookSignature) so the +classification matches what `loaf upgrade` would compute, then goes further: +per-entry disposition (in-sync / modified / deleted / stale-loaf / foreign) +with field-level diffs for the modified pairs. + +Evidence generator for docs/changes/20260808-hooks-entry-reconciliation. +Read-only over the live files; writes hook-entry-classification.json beside itself. + +PROVENANCE: this implements the 0.2.20 (pre-change) predicates and exists to +document the starting state. It is NOT the acceptance oracle for the new +entry-level model — before/after preservation is proven by +compare_hook_files.py (predicate-free), and new-model classification lives in +the Go implementation and its fixtures. +""" + +import json +import os +import re +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.abspath(os.path.join(HERE, "..", "..", "..", "..")) +HOME = os.path.expanduser("~") + +CODEX_SUFFIX = " journal context --from-hook --codex-hook" +CODEX_MATCHER = "startup|resume|clear|compact" +LOAF_MARKER = "loaf-managed" + +LEGACY_SIGNATURES = { + "command:loaf check --hook check-secrets|matcher:Edit|Write|Bash|if:", + "command:loaf check --hook security-audit|matcher:Bash|if:", + "command:loaf check --hook validate-push|matcher:Bash|if:", + "command:loaf check --hook workflow-pre-pr|matcher:Bash|if:", + "command:loaf check --hook validate-commit|matcher:Bash|if:", + "command:loaf task refresh|matcher:Edit|Write|if:", + "command:bash $HOME/.cursor/hooks/post-tool/kb-staleness-nudge.sh|matcher:Edit|Write|if:", + "command:loaf journal log --detect-linear|matcher:Bash|if:", + "command:loaf journal log --from-hook|matcher:Bash|if:Bash(git commit:*)", + "command:loaf journal log --from-hook|matcher:Bash|if:Bash(gh pr create:*)", + "command:loaf journal log --from-hook|matcher:Bash|if:Bash(gh pr merge:*)", + "command:loaf journal context|matcher:|if:", + "command:loaf session log --detect-linear|matcher:Bash|if:", + "command:loaf session log --from-hook|matcher:Bash|if:Bash(git commit:*)", + "command:loaf session log --from-hook|matcher:Bash|if:Bash(gh pr create:*)", + "command:loaf session log --from-hook|matcher:Bash|if:Bash(gh pr merge:*)", + "command:loaf session start|matcher:|if:", + "command:loaf session end|matcher:|if:", + "command:bash $HOME/.cursor/hooks/session/compact.sh|matcher:|if:", +} + +LEGACY_COMMANDS = { + "bash $HOME/.cursor/hooks/session/session-start-soul.sh", + "bash $HOME/.cursor/hooks/session/session-start.sh", + "bash $HOME/.cursor/hooks/session/kb-session-start.sh", + "bash $HOME/.cursor/hooks/session/session-end.sh", + "bash $HOME/.cursor/hooks/session/kb-session-end.sh", + "bash $HOME/.cursor/hooks/session/pre-compact-archive.sh", +} + +LEGACY_PROMPT_PREFIXES = [ + "STOP. Before running gh pr merge", + "ADVISORY: You are about to run `git push`", + "KNOWLEDGE BASE:", + "POST-MERGE HOUSEKEEPING:", + "CONTEXT COMPACTION IMMINENT:", + "SESSION JOURNAL NUDGE:", +] + + +def signature(hook): + command = hook.get("command") + prompt = hook.get("prompt") + matcher = hook.get("matcher", "") + condition = hook.get("if", "") + if isinstance(command, str): + return f"command:{command}|matcher:{matcher}|if:{condition}" + if isinstance(prompt, str): + return f"prompt:{prompt}|matcher:{matcher}|if:{condition}" + handlers = hook.get("hooks") + if isinstance(handlers, list): + # Codex/Cursor matcher-group form: identity from matcher + handler + # commands, with the executable path normalized so the dist + # placeholder pairs with an installed resolved command. + cmds = [] + for h in handlers: + if isinstance(h, dict): + c = str(h.get("command", "")) + c = re.sub(r"^(\{\{LOAF_EXECUTABLE\}\}|'[^']*')(?= )", "", c) + cmds.append(c) + return f"group|matcher:{matcher}|cmds:{'||'.join(cmds)}" + return "" + + +def codex_ownership(hook): + """Returns (owned, conflict) — exact port of codexHookOwnershipForOS (darwin).""" + matcher = hook.get("matcher", "") + handlers = hook.get("hooks") + if not isinstance(handlers, list): + return False, False + contains = any( + isinstance(h, dict) + and ( + CODEX_SUFFIX in str(h.get("command", "")) + or CODEX_SUFFIX in str(h.get("commandWindows", "")) + ) + for h in handlers + ) + if not contains: + return False, False + if matcher != CODEX_MATCHER or len(hook) != 2 or len(handlers) != 1: + return False, True + handler = handlers[0] + if not isinstance(handler, dict) or handler.get("type") != "command": + return False, True + command = handler.get("command") + if not isinstance(command, str): + return False, True + if len(handler) != 2 or not command.endswith(CODEX_SUFFIX): + return False, True + return True, False + + +def cursor_is_loaf(hook): + if hook.get(LOAF_MARKER) is True: + return True + sig = signature(hook) + if sig and sig in LEGACY_SIGNATURES: + return True + if hook.get("command") in LEGACY_COMMANDS: + return True + prompt = hook.get("prompt") + if isinstance(prompt, str) and any(prompt.startswith(p) for p in LEGACY_PROMPT_PREFIXES): + return True + owned, conflict = codex_ownership(hook) + return owned and not conflict + + +def command_stem(hook): + """Fuzzy identity for pairing modified entries: the functional core of the command.""" + command = hook.get("command", "") + if not isinstance(command, str) or not command: + prompt = hook.get("prompt", "") + return "prompt:" + str(prompt)[:40] + m = re.search(r"--hook\s+(\S+)", command) + if m: + return f"loaf-check:{m.group(1)}" + normalized = re.sub(r"\{\{LOAF_EXECUTABLE\}\}|'[^']*loaf'|\bloaf\b", "loaf", command) + return normalized + + +def field_diff(desired, actual): + keys = sorted(set(desired) | set(actual)) + out = [] + for k in keys: + d, a = desired.get(k), actual.get(k) + if d != a: + out.append({"field": k, "desired": d, "actual": a}) + return out + + +def classify(target, desired_hooks, actual_hooks, is_loaf, event_map=None): + rows = [] + events = sorted(set(desired_hooks) | set(actual_hooks)) + for event in events: + desired = list(desired_hooks.get(event, [])) + actual = list(actual_hooks.get(event, [])) + desired_by_sig = {signature(h): h for h in desired} + matched_actual, matched_desired = set(), set() + + # Pass 1: exact signature match. + for i, hook in enumerate(actual): + sig = signature(hook) + if sig in desired_by_sig: + pair = desired_by_sig[sig] + j = desired.index(pair) + if j in matched_desired: + continue + matched_actual.add(i) + matched_desired.add(j) + diff = field_diff(pair, hook) + rows.append({ + "target": target, "event": event, + "disposition": "in-sync" if not diff else "modified", + "identity": command_stem(hook), + "desired": pair, "actual": hook, "diff": diff, + }) + + # Pass 2: fuzzy pairing of leftover loaf-owned entries (modified commands). + leftover_desired = [(j, h) for j, h in enumerate(desired) if j not in matched_desired] + for i, hook in enumerate(actual): + if i in matched_actual or not is_loaf(hook): + continue + stem = command_stem(hook) + hit = next(((j, h) for j, h in leftover_desired if command_stem(h) == stem), None) + if hit: + j, pair = hit + matched_actual.add(i) + matched_desired.add(j) + leftover_desired = [(k, h) for k, h in leftover_desired if k != j] + rows.append({ + "target": target, "event": event, "disposition": "modified", + "identity": stem, "desired": pair, "actual": hook, + "diff": field_diff(pair, hook), + }) + + # Remaining desired entries: deleted from the live file. + for j, hook in enumerate(desired): + if j not in matched_desired: + rows.append({ + "target": target, "event": event, "disposition": "deleted", + "identity": command_stem(hook), "desired": hook, + "actual": None, "diff": [], + }) + + # Remaining actual entries: loaf-owned strays vs foreign. + for i, hook in enumerate(actual): + if i in matched_actual: + continue + if is_loaf(hook): + disposition = "stale-loaf" + else: + owned, conflict = codex_ownership(hook) + disposition = "conflict" if conflict else "foreign" + rows.append({ + "target": target, "event": event, "disposition": disposition, + "identity": command_stem(hook), "desired": None, + "actual": hook, "diff": [], + }) + return rows + + +def load(path): + with open(path) as f: + return json.load(f) + + +def main(): + cursor_desired = load(os.path.join(REPO, "dist/cursor/hooks.json"))["hooks"] + codex_desired = load(os.path.join(REPO, "dist/codex/.codex/hooks.json"))["hooks"] + cursor_actual = load(os.path.join(HOME, ".cursor/hooks.json"))["hooks"] + codex_actual = load(os.path.join(HOME, ".codex/hooks.json"))["hooks"] + + rows = [] + rows += classify("cursor", cursor_desired, cursor_actual, cursor_is_loaf) + + def codex_is_loaf(hook): + owned, _ = codex_ownership(hook) + return owned + + rows += classify("codex", codex_desired, codex_actual, codex_is_loaf) + + summary = {} + for r in rows: + key = (r["target"], r["disposition"]) + summary[key] = summary.get(key, 0) + 1 + + out = { + "generated_against": "dist @ main 4edd80d6 (0.2.20)", + "summary": [ + {"target": t, "disposition": d, "count": c} + for (t, d), c in sorted(summary.items()) + ], + "rows": rows, + } + out_path = os.path.join(HERE, "hook-entry-classification.json") + with open(out_path, "w") as f: + json.dump(out, f, indent=2) + for s in out["summary"]: + print(f"{s['target']:8} {s['disposition']:10} {s['count']}") + print(f"\nwrote {out_path} ({len(rows)} rows)") + + +if __name__ == "__main__": + main() diff --git a/docs/changes/20260808-hooks-entry-reconciliation/research/claude-code-2.1.226-plugin-startup-smoke.json b/docs/changes/20260808-hooks-entry-reconciliation/research/claude-code-2.1.226-plugin-startup-smoke.json new file mode 100644 index 000000000..e4b1f8dd6 --- /dev/null +++ b/docs/changes/20260808-hooks-entry-reconciliation/research/claude-code-2.1.226-plugin-startup-smoke.json @@ -0,0 +1,57 @@ +{ + "evidence_version": 2, + "timestamp": "2026-08-10T02:23:19.543Z", + "target": "claude-code", + "surface": "cli", + "version": "2.1.226", + "platform": "darwin-arm64", + "installed_mode": "plugin-dir", + "context_mode": "startup", + "adapter": "claude-session-start-v1", + "mode": "explicit-plugin-dir", + "invocation": { + "command": "claude", + "args": [ + "--plugin-dir", + "/plugins/loaf", + "--strict-mcp-config", + "--mcp-config", + "{\"mcpServers\":{}}", + "--no-session-persistence", + "--setting-sources", + "", + "--tools", + "", + "--include-hook-events", + "--output-format", + "stream-json", + "-p", + "Reply with exactly the unique marker present in Loaf continuity context, and nothing else." + ], + "cwd": "" + }, + "setup": [ + "build candidate Go binary and Claude plugin", + "create disposable Git repository", + "initialize absolute disposable LOAF_DB", + "write random marker to isolated journal" + ], + "candidate_plugin_path": "plugins/loaf", + "exit_code": 0, + "stderr_empty": true, + "model_visible_marker_observed": true, + "assistant_marker_match": true, + "marker": "LOAF_CLAUDE_STARTUP_SMOKE_6878FA7C0B19", + "hook_observation": { + "event_name": "SessionStart:startup", + "native_json": true, + "hook_event_name": "SessionStart", + "additional_context_marker": true + }, + "candidate_artifacts": { + "hooks_path": "plugins/loaf/hooks/hooks.json", + "hooks_sha256": "564917493bfa795fee8a067e235e13eec5f992e098cb94540d2202cc6fe3cad4", + "native_binary_path": "plugins/loaf/bin/native/darwin-arm64/loaf", + "native_binary_sha256": "bd18f4e82faaaf4a8c559344d69cba18ea7e306b90a7f67db48c1b3efd603666" + } +} diff --git a/docs/changes/20260808-hooks-entry-reconciliation/research/codex-0.147.0-isolated-startup-smoke.json b/docs/changes/20260808-hooks-entry-reconciliation/research/codex-0.147.0-isolated-startup-smoke.json new file mode 100644 index 000000000..7ed2ef944 --- /dev/null +++ b/docs/changes/20260808-hooks-entry-reconciliation/research/codex-0.147.0-isolated-startup-smoke.json @@ -0,0 +1,55 @@ +{ + "evidence_version": 2, + "timestamp": "2026-08-10T02:23:26.762Z", + "target": "codex", + "surface": "cli", + "version": "0.147.0", + "platform": "darwin-arm64", + "installed_mode": "isolated-codex-home", + "context_mode": "startup", + "adapter": "codex-session-start-v1", + "mode": "isolated-codex-home", + "invocation": { + "command": "codex", + "args": [ + "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." + ], + "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" + ], + "exit_code": 0, + "stderr_empty": false, + "stderr": "Reading additional input from stdin...", + "model_visible_marker_observed": true, + "assistant_marker_match": true, + "marker": "LOAF_CODEX_STARTUP_SMOKE_4354AAEDFA93", + "hook_observation": { + "event_name": "SessionStart:startup", + "native_json": true, + "hook_event_name": "SessionStart", + "additional_context_marker": true + }, + "candidate_artifacts": { + "hooks_path": "dist/codex/.codex/hooks.json", + "hooks_sha256": "0f9e3feb7204c3309a7db6a02224ad881f6801a8cdd126ec6222e6d9a804c33d", + "native_binary_path": "bin/native/darwin-arm64/loaf", + "native_binary_sha256": "bd18f4e82faaaf4a8c559344d69cba18ea7e306b90a7f67db48c1b3efd603666" + } +} diff --git a/docs/changes/20260808-hooks-entry-reconciliation/research/compare_hook_files.py b/docs/changes/20260808-hooks-entry-reconciliation/research/compare_hook_files.py new file mode 100644 index 000000000..76e714894 --- /dev/null +++ b/docs/changes/20260808-hooks-entry-reconciliation/research/compare_hook_files.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Predicate-free before/after comparator for hooks.json files. + +Diffs two hook files at the JSON-value level, per event section, preserving +relative order: reports entries added, removed, and changed (paired by +position among value-stable neighbors). It carries NO ownership predicate — +the caller asserts that every reported difference is an expected Loaf entry +(e.g. from `loaf hooks list` or the upgrade plan output), which keeps this +tool honest across predicate changes. + +Usage: compare_hook_files.py +Exit 0 with "identical" when no value-level differences exist; exit 1 with a +per-event report otherwise (exit 1 is a report, not a failure). +""" + +import json +import sys + + +def canonical(entry): + return json.dumps(entry, sort_keys=True) + + +def diff_event(before, after): + b = [canonical(e) for e in before] + a = [canonical(e) for e in after] + b_set, a_set = {}, {} + for s in b: + b_set[s] = b_set.get(s, 0) + 1 + for s in a: + a_set[s] = a_set.get(s, 0) + 1 + removed = [s for s in b if a_set.get(s, 0) == 0] + added = [s for s in a if b_set.get(s, 0) == 0] + # Order stability of surviving entries: shared values must appear in the + # same relative order on both sides. + shared_b = [s for s in b if s in a_set and s not in removed] + shared_a = [s for s in a if s in b_set and s not in added] + order_stable = shared_b == shared_a + return removed, added, order_stable + + +def main(): + if len(sys.argv) != 3: + sys.exit(__doc__) + before_doc = json.load(open(sys.argv[1])) + after_doc = json.load(open(sys.argv[2])) + clean = True + # Top-level fields other than "hooks" must be presence- and value-identical: + # version, description, and any unknown field are part of the preservation + # promise, and a present null is not the same as an absent key. + for key in sorted((set(before_doc) | set(after_doc)) - {"hooks"}): + in_b, in_a = key in before_doc, key in after_doc + if in_b != in_a: + clean = False + print(f"top-level field: {key}") + print(f" before: {canonical(before_doc[key]) if in_b else ''}") + print(f" after: {canonical(after_doc[key]) if in_a else ''}") + continue + if canonical(before_doc[key]) != canonical(after_doc[key]): + clean = False + print(f"top-level field: {key}") + print(f" before: {canonical(before_doc[key])}") + print(f" after: {canonical(after_doc[key])}") + if ("hooks" in before_doc) != ("hooks" in after_doc): + clean = False + print(f"top-level field: hooks {'removed' if 'hooks' in before_doc else 'added'} as a key") + before = before_doc.get("hooks", {}) + after = after_doc.get("hooks", {}) + for event in sorted(set(before) | set(after)): + in_b, in_a = event in before, event in after + if in_b != in_a: + clean = False + print(f"event: {event} {'removed' if in_b else 'added'} as a key" + f" ({len(before.get(event, []))} vs {len(after.get(event, []))} entries)") + removed, added, order_stable = diff_event(before.get(event, []), after.get(event, [])) + if removed or added or not order_stable: + clean = False + print(f"event: {event}") + for s in removed: + print(f" removed: {s}") + for s in added: + print(f" added: {s}") + if not order_stable: + print(" ORDER CHANGED among surviving entries") + if clean: + print("identical (value-level, order-stable)") + sys.exit(0) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/docs/changes/20260808-hooks-entry-reconciliation/research/fixtures/codex-hooks-live.json b/docs/changes/20260808-hooks-entry-reconciliation/research/fixtures/codex-hooks-live.json new file mode 100644 index 000000000..0968512f5 --- /dev/null +++ b/docs/changes/20260808-hooks-entry-reconciliation/research/fixtures/codex-hooks-live.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "command": "bash '/Users/canary/.config/codex/herdr-agent-state.sh' session", + "timeout": 10, + "type": "command" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/docs/changes/20260808-hooks-entry-reconciliation/research/fixtures/cursor-hooks-live.json b/docs/changes/20260808-hooks-entry-reconciliation/research/fixtures/cursor-hooks-live.json new file mode 100644 index 000000000..ddb414bde --- /dev/null +++ b/docs/changes/20260808-hooks-entry-reconciliation/research/fixtures/cursor-hooks-live.json @@ -0,0 +1,293 @@ +{ + "version": 1, + "hooks": { + "postToolUse": [ + { + "command": "bash $HOME/.cursor/hooks/post-tool/python-ruff-check.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/post-tool/typescript-eslint-check.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/post-tool/rails-rubocop-check.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/post-tool/design-a11y-validation.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/post-tool/design-token-check.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/post-tool/design-a11y-audit.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/post-tool/foundations-changelog-reminder.sh", + "matcher": "Bash", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/post-tool/orchestration-generate-task-board.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "loaf task refresh", + "loaf-managed": true, + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/post-tool/kb-staleness-nudge.sh", + "loaf-managed": true, + "matcher": "Edit|Write", + "timeout": 5 + }, + { + "command": "cat \"$HOME/.cursor/hooks/instructions/post-merge.md\"", + "if": "Bash(gh pr merge:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 5 + } + ], + "preToolUse": [ + { + "command": "bash $HOME/.cursor/hooks/pre-tool/foundations-check-secrets.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/foundations-validate-changelog.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/foundations-format-check.sh", + "matcher": "Edit|Write", + "timeout": 60 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/foundations-security-audit.sh", + "matcher": "Bash", + "timeout": 600 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/foundations-tdd-advisory.sh", + "matcher": "Edit|Write", + "timeout": 5 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/foundations-validate-push.sh", + "matcher": "Bash", + "timeout": 60 + }, + { + "command": "python3 $HOME/.cursor/hooks/pre-tool/orchestration-validate-commit.py", + "matcher": "Bash", + "timeout": 30 + }, + { + "command": "python3 $HOME/.cursor/hooks/pre-tool/orchestration-detect-linear-magic.py", + "matcher": "Bash", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/python-type-check.sh", + "matcher": "Edit|Write", + "timeout": 60 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/python-pytest-validation.sh", + "matcher": "Edit|Write", + "timeout": 120 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/python-type-check-progressive.sh", + "matcher": "Edit|Write", + "timeout": 120 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/python-ruff-lint.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/python-pytest-execution.sh", + "matcher": "Edit|Write", + "timeout": 480 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/python-bandit-scan.sh", + "matcher": "Edit|Write", + "timeout": 60 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/typescript-tsc-check.sh", + "matcher": "Edit|Write", + "timeout": 60 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/typescript-bundle-analysis.sh", + "matcher": "Edit|Write", + "timeout": 60 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/rails-migration-safety.sh", + "matcher": "Edit|Write", + "timeout": 60 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/rails-migration-safety-deep.sh", + "matcher": "Edit|Write", + "timeout": 300 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/rails-test-execution.sh", + "matcher": "Edit|Write", + "timeout": 480 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/rails-brakeman-scan.sh", + "matcher": "Edit|Write", + "timeout": 120 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/infra-validate-k8s.sh", + "matcher": "Edit|Write", + "timeout": 60 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/infra-dockerfile-lint.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/infra-k8s-dry-run-server.sh", + "matcher": "Edit|Write", + "timeout": 120 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/infra-terraform-plan-cost.sh", + "matcher": "Edit|Write", + "timeout": 300 + }, + { + "command": "loaf check --hook artifact-body-write", + "failClosed": true, + "loaf-managed": true, + "matcher": "Edit|Write|Bash", + "timeout": 30 + }, + { + "command": "loaf check --hook check-secrets", + "failClosed": true, + "loaf-managed": true, + "matcher": "Edit|Write|Bash", + "timeout": 30 + }, + { + "command": "loaf check --hook security-audit", + "failClosed": true, + "loaf-managed": true, + "matcher": "Bash", + "timeout": 600 + }, + { + "command": "loaf check --hook render-drift", + "failClosed": true, + "if": "Bash(git push:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 30 + }, + { + "command": "loaf check --hook ephemeral-provenance", + "failClosed": true, + "if": "Bash(git push:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 30 + }, + { + "command": "loaf check --hook artifact-names", + "failClosed": true, + "if": "Bash(git commit:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 30 + }, + { + "command": "loaf check --hook github-account", + "failClosed": true, + "loaf-managed": true, + "matcher": "Bash", + "timeout": 10 + }, + { + "command": "loaf check --hook validate-push --advisory", + "if": "Bash(git push:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 60 + }, + { + "command": "loaf check --hook workflow-pre-pr --advisory", + "if": "Bash(gh pr create:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 5 + }, + { + "command": "cat \"$HOME/.cursor/hooks/instructions/pre-merge.md\"", + "if": "Bash(gh pr merge:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 5 + }, + { + "command": "cat \"$HOME/.cursor/hooks/instructions/pre-push.md\"", + "if": "Bash(git push:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 5 + }, + { + "command": "loaf check --hook validate-commit", + "failClosed": true, + "if": "Bash(git commit:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 30 + }, + { + "command": "loaf journal log --detect-linear", + "if": "Bash(git commit:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 30 + } + ], + "sessionStart": [ + { + "command": "bash '/Users/canary/.cursor/herdr-agent-state.sh' session" + }, + { + "command": "loaf journal context --from-hook --cursor-hook", + "loaf-managed": true, + "timeout": 60 + } + ] + } +} diff --git a/docs/changes/20260808-hooks-entry-reconciliation/research/hook-conflict-report.html b/docs/changes/20260808-hooks-entry-reconciliation/research/hook-conflict-report.html new file mode 100644 index 000000000..801fac742 --- /dev/null +++ b/docs/changes/20260808-hooks-entry-reconciliation/research/hook-conflict-report.html @@ -0,0 +1,482 @@ + + + + + +Hooks Reconciliation — Live Conflict Report + + + +
+

Loaf · Change 20260808-hooks-entry-reconciliation

+

Live hook-entry conflict report

+

Every entry in ~/.codex/hooks.json and ~/.cursor/hooks.json on this + machine, classified against the 0.2.20 dist (main@4edd80d6) with the same ownership predicates + loaf upgrade uses. Rule on items by ID — X1, F1, L01

+ +
+ 17 in-sync + 1 deleted + 32 legacy-suspect + 2 third-party + 0 modified +
+

No modified Loaf entry exists anywhere today — the mutation-semantics question + stays a policy call, not a live conflict.

+ +
+

Codex · ~/.codex/hooks.json

+

Two entries, two rulings

+ +
+
X1deleted + SessionStart
+

The Loaf journal-context entry is absent from the live file — removed 2026-08-02, journaled as + deliberate disable-intent (journal:dcf9875d). This is the entry whose absence makes + every loaf upgrade refuse the whole file today.

+

Entry Loaf would ship (0.2.20 dist)

+
{
+  "matcher": "startup|resume|clear|compact",
+  "hooks": [
+    {
+      "type": "command",
+      "command": "{{LOAF_EXECUTABLE}} journal context --from-hook --codex-hook",
+      "commandWindows": "{{LOAF_EXECUTABLE}} journal context --from-hook --codex-hook"
+    }
+  ]
+}
+

Proposed ruling: absorb as disabled — record hook-enablement + state disabled, never re-add without an explicit loaf hooks enable.

+
+
+
X2foreign + SessionStart
+

Third-party entry (herdr, script dated 2026-08-07). Under the entry model this is untouchable + by construction — reconciliation never counts it against Loaf's digest again.

+
{
+  "hooks": [
+    {
+      "command": "bash '/Users/levifig/.config/codex/herdr-agent-state.sh' session",
+      "timeout": 10,
+      "type": "command"
+    }
+  ]
+}
+

Proposed ruling: keep — operator-owned, invisible to Loaf.

+
+
+
+

Cursor · ~/.cursor/hooks.json

+

Shipped entries are pristine; a 2026-03-25 generation rides along

+

All 17 Loaf-shipped entries match 0.2.20 byte-for-byte — no deleted, no modified. + The remaining 33 are unclaimed: one is genuinely third-party, and 32 form a single + installation event (scripts dated 2026-03-25, one 2026-04-07) whose names map one-to-one onto Loaf + skill families. The current legacy-recognition maps do not claim them, so today Loaf treats them as + foreign and they run in every Cursor session. Five functionally duplicate a shipped + loaf check entry — double enforcement on every matching tool call.

+
The 17 in-sync entries (no action needed)
  • postToolUse loaf task refresh
  • postToolUse bash $HOME/.cursor/hooks/post-tool/kb-staleness-nudge.sh
  • postToolUse cat "$HOME/.cursor/hooks/instructions/post-merge.md"
  • preToolUse loaf check --hook artifact-body-write
  • preToolUse loaf check --hook check-secrets
  • preToolUse loaf check --hook security-audit
  • preToolUse loaf check --hook render-drift
  • preToolUse loaf check --hook ephemeral-provenance
  • preToolUse loaf check --hook artifact-names
  • preToolUse loaf check --hook github-account
  • preToolUse loaf check --hook validate-push --advisory
  • preToolUse loaf check --hook workflow-pre-pr --advisory
  • preToolUse cat "$HOME/.cursor/hooks/instructions/pre-merge.md"
  • preToolUse cat "$HOME/.cursor/hooks/instructions/pre-push.md"
  • preToolUse loaf check --hook validate-commit
  • preToolUse loaf journal log --detect-linear
  • sessionStart loaf journal context --from-hook --cursor-hook
+ +
+
F1foreign + sessionStart
+
{
+  "command": "bash '/Users/levifig/.cursor/herdr-agent-state.sh' session"
+}
+

Proposed ruling: keep — third-party (herdr), untouchable by construction.

+
+

The legacy generation, by family

+

Proposed rulings per row: retire (claim as legacy Loaf; reconciliation + removes entry and script) · keep (operator-owned; becomes foreign-by-construction, + never touched) · disable (claim, but record disabled instead of removing).

+ +

foundations 7

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDEventCommandMatcherOverlap with shipped entryScript date
L01postToolUsebash $HOME/.cursor/hooks/post-tool/foundations-changelog-reminder.shBash2026-03-25
L02preToolUsebash $HOME/.cursor/hooks/pre-tool/foundations-check-secrets.shEdit|Writeduplicates loaf check --hook check-secrets2026-03-25
L03preToolUsebash $HOME/.cursor/hooks/pre-tool/foundations-format-check.shEdit|Write2026-03-25
L04preToolUsebash $HOME/.cursor/hooks/pre-tool/foundations-security-audit.shBashduplicates loaf check --hook security-audit2026-03-25
L05preToolUsebash $HOME/.cursor/hooks/pre-tool/foundations-tdd-advisory.shEdit|Write2026-03-25
L06preToolUsebash $HOME/.cursor/hooks/pre-tool/foundations-validate-changelog.shEdit|Write2026-03-25
L07preToolUsebash $HOME/.cursor/hooks/pre-tool/foundations-validate-push.shBashduplicates loaf check --hook validate-push --advisory2026-03-25
+

orchestration 3

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
IDEventCommandMatcherOverlap with shipped entryScript date
L08postToolUsebash $HOME/.cursor/hooks/post-tool/orchestration-generate-task-board.shEdit|Write2026-04-07
L09preToolUsepython3 $HOME/.cursor/hooks/pre-tool/orchestration-detect-linear-magic.pyBashduplicates loaf journal log --detect-linear2026-03-25
L10preToolUsepython3 $HOME/.cursor/hooks/pre-tool/orchestration-validate-commit.pyBashduplicates loaf check --hook validate-commit2026-03-25
+

python 7

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDEventCommandMatcherOverlap with shipped entryScript date
L11postToolUsebash $HOME/.cursor/hooks/post-tool/python-ruff-check.shEdit|Write2026-03-25
L12preToolUsebash $HOME/.cursor/hooks/pre-tool/python-bandit-scan.shEdit|Write2026-03-25
L13preToolUsebash $HOME/.cursor/hooks/pre-tool/python-pytest-execution.shEdit|Write2026-03-25
L14preToolUsebash $HOME/.cursor/hooks/pre-tool/python-pytest-validation.shEdit|Write2026-03-25
L15preToolUsebash $HOME/.cursor/hooks/pre-tool/python-ruff-lint.shEdit|Write2026-03-25
L16preToolUsebash $HOME/.cursor/hooks/pre-tool/python-type-check-progressive.shEdit|Write2026-03-25
L17preToolUsebash $HOME/.cursor/hooks/pre-tool/python-type-check.shEdit|Write2026-03-25
+

rails 5

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDEventCommandMatcherOverlap with shipped entryScript date
L18postToolUsebash $HOME/.cursor/hooks/post-tool/rails-rubocop-check.shEdit|Write2026-03-25
L19preToolUsebash $HOME/.cursor/hooks/pre-tool/rails-brakeman-scan.shEdit|Write2026-03-25
L20preToolUsebash $HOME/.cursor/hooks/pre-tool/rails-migration-safety-deep.shEdit|Write2026-03-25
L21preToolUsebash $HOME/.cursor/hooks/pre-tool/rails-migration-safety.shEdit|Write2026-03-25
L22preToolUsebash $HOME/.cursor/hooks/pre-tool/rails-test-execution.shEdit|Write2026-03-25
+

typescript 3

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
IDEventCommandMatcherOverlap with shipped entryScript date
L23postToolUsebash $HOME/.cursor/hooks/post-tool/typescript-eslint-check.shEdit|Write2026-03-25
L24preToolUsebash $HOME/.cursor/hooks/pre-tool/typescript-bundle-analysis.shEdit|Write2026-03-25
L25preToolUsebash $HOME/.cursor/hooks/pre-tool/typescript-tsc-check.shEdit|Write2026-03-25
+

infra 4

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDEventCommandMatcherOverlap with shipped entryScript date
L26preToolUsebash $HOME/.cursor/hooks/pre-tool/infra-dockerfile-lint.shEdit|Write2026-03-25
L27preToolUsebash $HOME/.cursor/hooks/pre-tool/infra-k8s-dry-run-server.shEdit|Write2026-03-25
L28preToolUsebash $HOME/.cursor/hooks/pre-tool/infra-terraform-plan-cost.shEdit|Write2026-03-25
L29preToolUsebash $HOME/.cursor/hooks/pre-tool/infra-validate-k8s.shEdit|Write2026-03-25
+

design 3

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
IDEventCommandMatcherOverlap with shipped entryScript date
L30postToolUsebash $HOME/.cursor/hooks/post-tool/design-a11y-audit.shEdit|Write2026-03-25
L31postToolUsebash $HOME/.cursor/hooks/post-tool/design-a11y-validation.shEdit|Write2026-03-25
L32postToolUsebash $HOME/.cursor/hooks/post-tool/design-token-check.shEdit|Write2026-03-25
+
+
+ + \ No newline at end of file diff --git a/docs/changes/20260808-hooks-entry-reconciliation/research/hook-entry-classification.json b/docs/changes/20260808-hooks-entry-reconciliation/research/hook-entry-classification.json new file mode 100644 index 000000000..3c6f46b10 --- /dev/null +++ b/docs/changes/20260808-hooks-entry-reconciliation/research/hook-entry-classification.json @@ -0,0 +1,846 @@ +{ + "generated_against": "dist @ main 4edd80d6 (0.2.20)", + "summary": [ + { + "target": "codex", + "disposition": "deleted", + "count": 1 + }, + { + "target": "codex", + "disposition": "foreign", + "count": 1 + }, + { + "target": "cursor", + "disposition": "foreign", + "count": 33 + }, + { + "target": "cursor", + "disposition": "in-sync", + "count": 17 + } + ], + "rows": [ + { + "target": "cursor", + "event": "postToolUse", + "disposition": "in-sync", + "identity": "loaf task refresh", + "desired": { + "loaf-managed": true, + "timeout": 30, + "matcher": "Edit|Write", + "command": "loaf task refresh" + }, + "actual": { + "command": "loaf task refresh", + "loaf-managed": true, + "matcher": "Edit|Write", + "timeout": 30 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "postToolUse", + "disposition": "in-sync", + "identity": "bash $HOME/.cursor/hooks/post-tool/kb-staleness-nudge.sh", + "desired": { + "loaf-managed": true, + "timeout": 5, + "matcher": "Edit|Write", + "command": "bash $HOME/.cursor/hooks/post-tool/kb-staleness-nudge.sh" + }, + "actual": { + "command": "bash $HOME/.cursor/hooks/post-tool/kb-staleness-nudge.sh", + "loaf-managed": true, + "matcher": "Edit|Write", + "timeout": 5 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "postToolUse", + "disposition": "in-sync", + "identity": "cat \"$HOME/.cursor/hooks/instructions/post-merge.md\"", + "desired": { + "loaf-managed": true, + "timeout": 5, + "matcher": "Bash", + "command": "cat \"$HOME/.cursor/hooks/instructions/post-merge.md\"", + "if": "Bash(gh pr merge:*)" + }, + "actual": { + "command": "cat \"$HOME/.cursor/hooks/instructions/post-merge.md\"", + "if": "Bash(gh pr merge:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 5 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "postToolUse", + "disposition": "foreign", + "identity": "bash $HOME/.cursor/hooks/post-tool/python-ruff-check.sh", + "desired": null, + "actual": { + "command": "bash $HOME/.cursor/hooks/post-tool/python-ruff-check.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "postToolUse", + "disposition": "foreign", + "identity": "bash $HOME/.cursor/hooks/post-tool/typescript-eslint-check.sh", + "desired": null, + "actual": { + "command": "bash $HOME/.cursor/hooks/post-tool/typescript-eslint-check.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "postToolUse", + "disposition": "foreign", + "identity": "bash $HOME/.cursor/hooks/post-tool/rails-rubocop-check.sh", + "desired": null, + "actual": { + "command": "bash $HOME/.cursor/hooks/post-tool/rails-rubocop-check.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "postToolUse", + "disposition": "foreign", + "identity": "bash $HOME/.cursor/hooks/post-tool/design-a11y-validation.sh", + "desired": null, + "actual": { + "command": "bash $HOME/.cursor/hooks/post-tool/design-a11y-validation.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "postToolUse", + "disposition": "foreign", + "identity": "bash $HOME/.cursor/hooks/post-tool/design-token-check.sh", + "desired": null, + "actual": { + "command": "bash $HOME/.cursor/hooks/post-tool/design-token-check.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "postToolUse", + "disposition": "foreign", + "identity": "bash $HOME/.cursor/hooks/post-tool/design-a11y-audit.sh", + "desired": null, + "actual": { + "command": "bash $HOME/.cursor/hooks/post-tool/design-a11y-audit.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "postToolUse", + "disposition": "foreign", + "identity": "bash $HOME/.cursor/hooks/post-tool/foundations-changelog-reminder.sh", + "desired": null, + "actual": { + "command": "bash $HOME/.cursor/hooks/post-tool/foundations-changelog-reminder.sh", + "matcher": "Bash", + "timeout": 30 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "postToolUse", + "disposition": "foreign", + "identity": "bash $HOME/.cursor/hooks/post-tool/orchestration-generate-task-board.sh", + "desired": null, + "actual": { + "command": "bash $HOME/.cursor/hooks/post-tool/orchestration-generate-task-board.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "preToolUse", + "disposition": "in-sync", + "identity": "loaf-check:artifact-body-write", + "desired": { + "loaf-managed": true, + "timeout": 30, + "matcher": "Edit|Write|Bash", + "failClosed": true, + "command": "loaf check --hook artifact-body-write" + }, + "actual": { + "command": "loaf check --hook artifact-body-write", + "failClosed": true, + "loaf-managed": true, + "matcher": "Edit|Write|Bash", + "timeout": 30 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "preToolUse", + "disposition": "in-sync", + "identity": "loaf-check:check-secrets", + "desired": { + "loaf-managed": true, + "timeout": 30, + "matcher": "Edit|Write|Bash", + "failClosed": true, + "command": "loaf check --hook check-secrets" + }, + "actual": { + "command": "loaf check --hook check-secrets", + "failClosed": true, + "loaf-managed": true, + "matcher": "Edit|Write|Bash", + "timeout": 30 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "preToolUse", + "disposition": "in-sync", + "identity": "loaf-check:security-audit", + "desired": { + "loaf-managed": true, + "timeout": 600, + "matcher": "Bash", + "failClosed": true, + "command": "loaf check --hook security-audit" + }, + "actual": { + "command": "loaf check --hook security-audit", + "failClosed": true, + "loaf-managed": true, + "matcher": "Bash", + "timeout": 600 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "preToolUse", + "disposition": "in-sync", + "identity": "loaf-check:render-drift", + "desired": { + "loaf-managed": true, + "timeout": 30, + "matcher": "Bash", + "failClosed": true, + "command": "loaf check --hook render-drift", + "if": "Bash(git push:*)" + }, + "actual": { + "command": "loaf check --hook render-drift", + "failClosed": true, + "if": "Bash(git push:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 30 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "preToolUse", + "disposition": "in-sync", + "identity": "loaf-check:ephemeral-provenance", + "desired": { + "loaf-managed": true, + "timeout": 30, + "matcher": "Bash", + "failClosed": true, + "command": "loaf check --hook ephemeral-provenance", + "if": "Bash(git push:*)" + }, + "actual": { + "command": "loaf check --hook ephemeral-provenance", + "failClosed": true, + "if": "Bash(git push:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 30 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "preToolUse", + "disposition": "in-sync", + "identity": "loaf-check:artifact-names", + "desired": { + "loaf-managed": true, + "timeout": 30, + "matcher": "Bash", + "failClosed": true, + "command": "loaf check --hook artifact-names", + "if": "Bash(git commit:*)" + }, + "actual": { + "command": "loaf check --hook artifact-names", + "failClosed": true, + "if": "Bash(git commit:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 30 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "preToolUse", + "disposition": "in-sync", + "identity": "loaf-check:github-account", + "desired": { + "loaf-managed": true, + "timeout": 10, + "matcher": "Bash", + "failClosed": true, + "command": "loaf check --hook github-account" + }, + "actual": { + "command": "loaf check --hook github-account", + "failClosed": true, + "loaf-managed": true, + "matcher": "Bash", + "timeout": 10 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "preToolUse", + "disposition": "in-sync", + "identity": "loaf-check:validate-push", + "desired": { + "loaf-managed": true, + "timeout": 60, + "matcher": "Bash", + "command": "loaf check --hook validate-push --advisory", + "if": "Bash(git push:*)" + }, + "actual": { + "command": "loaf check --hook validate-push --advisory", + "if": "Bash(git push:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 60 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "preToolUse", + "disposition": "in-sync", + "identity": "loaf-check:workflow-pre-pr", + "desired": { + "loaf-managed": true, + "timeout": 5, + "matcher": "Bash", + "command": "loaf check --hook workflow-pre-pr --advisory", + "if": "Bash(gh pr create:*)" + }, + "actual": { + "command": "loaf check --hook workflow-pre-pr --advisory", + "if": "Bash(gh pr create:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 5 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "preToolUse", + "disposition": "in-sync", + "identity": "cat \"$HOME/.cursor/hooks/instructions/pre-merge.md\"", + "desired": { + "loaf-managed": true, + "timeout": 5, + "matcher": "Bash", + "command": "cat \"$HOME/.cursor/hooks/instructions/pre-merge.md\"", + "if": "Bash(gh pr merge:*)" + }, + "actual": { + "command": "cat \"$HOME/.cursor/hooks/instructions/pre-merge.md\"", + "if": "Bash(gh pr merge:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 5 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "preToolUse", + "disposition": "in-sync", + "identity": "cat \"$HOME/.cursor/hooks/instructions/pre-push.md\"", + "desired": { + "loaf-managed": true, + "timeout": 5, + "matcher": "Bash", + "command": "cat \"$HOME/.cursor/hooks/instructions/pre-push.md\"", + "if": "Bash(git push:*)" + }, + "actual": { + "command": "cat \"$HOME/.cursor/hooks/instructions/pre-push.md\"", + "if": "Bash(git push:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 5 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "preToolUse", + "disposition": "in-sync", + "identity": "loaf-check:validate-commit", + "desired": { + "loaf-managed": true, + "timeout": 30, + "matcher": "Bash", + "failClosed": true, + "command": "loaf check --hook validate-commit", + "if": "Bash(git commit:*)" + }, + "actual": { + "command": "loaf check --hook validate-commit", + "failClosed": true, + "if": "Bash(git commit:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 30 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "preToolUse", + "disposition": "in-sync", + "identity": "loaf journal log --detect-linear", + "desired": { + "loaf-managed": true, + "timeout": 30, + "matcher": "Bash", + "command": "loaf journal log --detect-linear", + "if": "Bash(git commit:*)" + }, + "actual": { + "command": "loaf journal log --detect-linear", + "if": "Bash(git commit:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 30 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "preToolUse", + "disposition": "foreign", + "identity": "bash $HOME/.cursor/hooks/pre-tool/foundations-check-secrets.sh", + "desired": null, + "actual": { + "command": "bash $HOME/.cursor/hooks/pre-tool/foundations-check-secrets.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "preToolUse", + "disposition": "foreign", + "identity": "bash $HOME/.cursor/hooks/pre-tool/foundations-validate-changelog.sh", + "desired": null, + "actual": { + "command": "bash $HOME/.cursor/hooks/pre-tool/foundations-validate-changelog.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "preToolUse", + "disposition": "foreign", + "identity": "bash $HOME/.cursor/hooks/pre-tool/foundations-format-check.sh", + "desired": null, + "actual": { + "command": "bash $HOME/.cursor/hooks/pre-tool/foundations-format-check.sh", + "matcher": "Edit|Write", + "timeout": 60 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "preToolUse", + "disposition": "foreign", + "identity": "bash $HOME/.cursor/hooks/pre-tool/foundations-security-audit.sh", + "desired": null, + "actual": { + "command": "bash $HOME/.cursor/hooks/pre-tool/foundations-security-audit.sh", + "matcher": "Bash", + "timeout": 600 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "preToolUse", + "disposition": "foreign", + "identity": "bash $HOME/.cursor/hooks/pre-tool/foundations-tdd-advisory.sh", + "desired": null, + "actual": { + "command": "bash $HOME/.cursor/hooks/pre-tool/foundations-tdd-advisory.sh", + "matcher": "Edit|Write", + "timeout": 5 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "preToolUse", + "disposition": "foreign", + "identity": "bash $HOME/.cursor/hooks/pre-tool/foundations-validate-push.sh", + "desired": null, + "actual": { + "command": "bash $HOME/.cursor/hooks/pre-tool/foundations-validate-push.sh", + "matcher": "Bash", + "timeout": 60 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "preToolUse", + "disposition": "foreign", + "identity": "python3 $HOME/.cursor/hooks/pre-tool/orchestration-validate-commit.py", + "desired": null, + "actual": { + "command": "python3 $HOME/.cursor/hooks/pre-tool/orchestration-validate-commit.py", + "matcher": "Bash", + "timeout": 30 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "preToolUse", + "disposition": "foreign", + "identity": "python3 $HOME/.cursor/hooks/pre-tool/orchestration-detect-linear-magic.py", + "desired": null, + "actual": { + "command": "python3 $HOME/.cursor/hooks/pre-tool/orchestration-detect-linear-magic.py", + "matcher": "Bash", + "timeout": 30 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "preToolUse", + "disposition": "foreign", + "identity": "bash $HOME/.cursor/hooks/pre-tool/python-type-check.sh", + "desired": null, + "actual": { + "command": "bash $HOME/.cursor/hooks/pre-tool/python-type-check.sh", + "matcher": "Edit|Write", + "timeout": 60 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "preToolUse", + "disposition": "foreign", + "identity": "bash $HOME/.cursor/hooks/pre-tool/python-pytest-validation.sh", + "desired": null, + "actual": { + "command": "bash $HOME/.cursor/hooks/pre-tool/python-pytest-validation.sh", + "matcher": "Edit|Write", + "timeout": 120 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "preToolUse", + "disposition": "foreign", + "identity": "bash $HOME/.cursor/hooks/pre-tool/python-type-check-progressive.sh", + "desired": null, + "actual": { + "command": "bash $HOME/.cursor/hooks/pre-tool/python-type-check-progressive.sh", + "matcher": "Edit|Write", + "timeout": 120 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "preToolUse", + "disposition": "foreign", + "identity": "bash $HOME/.cursor/hooks/pre-tool/python-ruff-lint.sh", + "desired": null, + "actual": { + "command": "bash $HOME/.cursor/hooks/pre-tool/python-ruff-lint.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "preToolUse", + "disposition": "foreign", + "identity": "bash $HOME/.cursor/hooks/pre-tool/python-pytest-execution.sh", + "desired": null, + "actual": { + "command": "bash $HOME/.cursor/hooks/pre-tool/python-pytest-execution.sh", + "matcher": "Edit|Write", + "timeout": 480 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "preToolUse", + "disposition": "foreign", + "identity": "bash $HOME/.cursor/hooks/pre-tool/python-bandit-scan.sh", + "desired": null, + "actual": { + "command": "bash $HOME/.cursor/hooks/pre-tool/python-bandit-scan.sh", + "matcher": "Edit|Write", + "timeout": 60 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "preToolUse", + "disposition": "foreign", + "identity": "bash $HOME/.cursor/hooks/pre-tool/typescript-tsc-check.sh", + "desired": null, + "actual": { + "command": "bash $HOME/.cursor/hooks/pre-tool/typescript-tsc-check.sh", + "matcher": "Edit|Write", + "timeout": 60 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "preToolUse", + "disposition": "foreign", + "identity": "bash $HOME/.cursor/hooks/pre-tool/typescript-bundle-analysis.sh", + "desired": null, + "actual": { + "command": "bash $HOME/.cursor/hooks/pre-tool/typescript-bundle-analysis.sh", + "matcher": "Edit|Write", + "timeout": 60 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "preToolUse", + "disposition": "foreign", + "identity": "bash $HOME/.cursor/hooks/pre-tool/rails-migration-safety.sh", + "desired": null, + "actual": { + "command": "bash $HOME/.cursor/hooks/pre-tool/rails-migration-safety.sh", + "matcher": "Edit|Write", + "timeout": 60 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "preToolUse", + "disposition": "foreign", + "identity": "bash $HOME/.cursor/hooks/pre-tool/rails-migration-safety-deep.sh", + "desired": null, + "actual": { + "command": "bash $HOME/.cursor/hooks/pre-tool/rails-migration-safety-deep.sh", + "matcher": "Edit|Write", + "timeout": 300 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "preToolUse", + "disposition": "foreign", + "identity": "bash $HOME/.cursor/hooks/pre-tool/rails-test-execution.sh", + "desired": null, + "actual": { + "command": "bash $HOME/.cursor/hooks/pre-tool/rails-test-execution.sh", + "matcher": "Edit|Write", + "timeout": 480 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "preToolUse", + "disposition": "foreign", + "identity": "bash $HOME/.cursor/hooks/pre-tool/rails-brakeman-scan.sh", + "desired": null, + "actual": { + "command": "bash $HOME/.cursor/hooks/pre-tool/rails-brakeman-scan.sh", + "matcher": "Edit|Write", + "timeout": 120 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "preToolUse", + "disposition": "foreign", + "identity": "bash $HOME/.cursor/hooks/pre-tool/infra-validate-k8s.sh", + "desired": null, + "actual": { + "command": "bash $HOME/.cursor/hooks/pre-tool/infra-validate-k8s.sh", + "matcher": "Edit|Write", + "timeout": 60 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "preToolUse", + "disposition": "foreign", + "identity": "bash $HOME/.cursor/hooks/pre-tool/infra-dockerfile-lint.sh", + "desired": null, + "actual": { + "command": "bash $HOME/.cursor/hooks/pre-tool/infra-dockerfile-lint.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "preToolUse", + "disposition": "foreign", + "identity": "bash $HOME/.cursor/hooks/pre-tool/infra-k8s-dry-run-server.sh", + "desired": null, + "actual": { + "command": "bash $HOME/.cursor/hooks/pre-tool/infra-k8s-dry-run-server.sh", + "matcher": "Edit|Write", + "timeout": 120 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "preToolUse", + "disposition": "foreign", + "identity": "bash $HOME/.cursor/hooks/pre-tool/infra-terraform-plan-cost.sh", + "desired": null, + "actual": { + "command": "bash $HOME/.cursor/hooks/pre-tool/infra-terraform-plan-cost.sh", + "matcher": "Edit|Write", + "timeout": 300 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "sessionStart", + "disposition": "in-sync", + "identity": "loaf journal context --from-hook --cursor-hook", + "desired": { + "loaf-managed": true, + "timeout": 60, + "command": "loaf journal context --from-hook --cursor-hook" + }, + "actual": { + "command": "loaf journal context --from-hook --cursor-hook", + "loaf-managed": true, + "timeout": 60 + }, + "diff": [] + }, + { + "target": "cursor", + "event": "sessionStart", + "disposition": "foreign", + "identity": "bash '/Users/levifig/.cursor/herdr-agent-state.sh' session", + "desired": null, + "actual": { + "command": "bash '/Users/levifig/.cursor/herdr-agent-state.sh' session" + }, + "diff": [] + }, + { + "target": "codex", + "event": "SessionStart", + "disposition": "deleted", + "identity": "prompt:", + "desired": { + "matcher": "startup|resume|clear|compact", + "hooks": [ + { + "type": "command", + "command": "{{LOAF_EXECUTABLE}} journal context --from-hook --codex-hook", + "commandWindows": "{{LOAF_EXECUTABLE}} journal context --from-hook --codex-hook" + } + ] + }, + "actual": null, + "diff": [] + }, + { + "target": "codex", + "event": "SessionStart", + "disposition": "foreign", + "identity": "prompt:", + "desired": null, + "actual": { + "hooks": [ + { + "command": "bash '/Users/levifig/.config/codex/herdr-agent-state.sh' session", + "timeout": 10, + "type": "command" + } + ] + }, + "diff": [] + } + ] +} \ No newline at end of file diff --git a/docs/changes/20260808-hooks-entry-reconciliation/research/opencode-1.18.13-isolated-request-smoke.json b/docs/changes/20260808-hooks-entry-reconciliation/research/opencode-1.18.13-isolated-request-smoke.json new file mode 100644 index 000000000..61c8be1a0 --- /dev/null +++ b/docs/changes/20260808-hooks-entry-reconciliation/research/opencode-1.18.13-isolated-request-smoke.json @@ -0,0 +1,49 @@ +{ + "evidence_version": 2, + "timestamp": "2026-08-10T02:23:33.144Z", + "target": "opencode", + "surface": "cli", + "version": "1.18.13", + "platform": "darwin-arm64", + "installed_mode": "isolated-xdg", + "context_mode": "request", + "adapter": "opencode-plugin-v1", + "mode": "isolated-xdg", + "invocation": { + "command": "opencode", + "args": [ + "run", + "--format", + "json", + "--model", + "opencode/deepseek-v4-flash-free", + "--dir", + "", + "Reply with exactly the unique marker present in Loaf continuity context, and nothing else." + ], + "cwd": "" + }, + "setup": [ + "build candidate Go binary and OpenCode target", + "create disposable Git repository with isolated XDG directories", + "initialize isolated Loaf state and write a random journal marker", + "load only the candidate plugin through OPENCODE_CONFIG_CONTENT", + "observe candidate hook stdout through a mode-0700 wrapper and mode-0600 file" + ], + "exit_code": 0, + "stderr_empty": true, + "stderr": "", + "model_visible_marker_observed": true, + "assistant_marker_match": true, + "plugin_loaded": true, + "root_session_lookup_proven": true, + "no_auth_supplied": true, + "cleanup_succeeded": true, + "marker": "LOAF_OPENCODE_REQUEST_SMOKE_78079797C27F", + "candidate_artifacts": { + "hooks_path": "dist/opencode/plugins/hooks.ts", + "hooks_sha256": "bc46b68b592e8b2232001703c3ba4b2bda739992db488a36ec37b14fc77f74f2", + "native_binary_path": "bin/native/darwin-arm64/loaf", + "native_binary_sha256": "bd18f4e82faaaf4a8c559344d69cba18ea7e306b90a7f67db48c1b3efd603666" + } +} diff --git a/docs/changes/20260808-hooks-entry-reconciliation/research/render_conflict_report.py b/docs/changes/20260808-hooks-entry-reconciliation/research/render_conflict_report.py new file mode 100644 index 000000000..ad9087685 --- /dev/null +++ b/docs/changes/20260808-hooks-entry-reconciliation/research/render_conflict_report.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 +"""Render hook-entry-classification.json as an adjudication report (HTML). + +Every non-in-sync entry gets a stable ID (X* codex, L* cursor legacy, +F* cursor third-party) so rulings can be given one by one in conversation. +Reads the classifier output beside itself; writes hook-conflict-report.html. +""" + +import html +import json +import os +import datetime + +HERE = os.path.dirname(os.path.abspath(__file__)) +HOME = os.path.expanduser("~") + +DUPLICATES = { + "foundations-check-secrets.sh": "loaf check --hook check-secrets", + "foundations-security-audit.sh": "loaf check --hook security-audit", + "foundations-validate-push.sh": "loaf check --hook validate-push --advisory", + "orchestration-validate-commit.py": "loaf check --hook validate-commit", + "orchestration-detect-linear-magic.py": "loaf journal log --detect-linear", +} + +FAMILIES = ["foundations", "orchestration", "python", "rails", "typescript", "infra", "design"] + + +def esc(s): + return html.escape(str(s), quote=True) + + +def script_path(cmd): + for tok in cmd.replace("'", " ").split(): + if "/" in tok: + return tok.replace("$HOME", HOME) + return None + + +def script_meta(cmd): + p = script_path(cmd) + if not p: + return None, None + if not os.path.exists(p): + return p, "missing" + return p, datetime.datetime.fromtimestamp(os.stat(p).st_mtime).strftime("%Y-%m-%d") + + +def family_of(cmd): + name = os.path.basename(script_path(cmd) or "") + for fam in FAMILIES: + if name.startswith(fam): + return fam + return "other" + + +def json_block(obj): + return f'
{esc(json.dumps(obj, indent=2))}
' + + +def main(): + data = json.load(open(os.path.join(HERE, "hook-entry-classification.json"))) + rows = data["rows"] + + codex = [r for r in rows if r["target"] == "codex"] + cursor_in_sync = [r for r in rows if r["target"] == "cursor" and r["disposition"] == "in-sync"] + cursor_foreign = [r for r in rows if r["target"] == "cursor" and r["disposition"] == "foreign"] + + legacy, third_party = [], [] + for r in cursor_foreign: + cmd = r["actual"].get("command", "") + (legacy if family_of(cmd) != "other" else third_party).append(r) + + legacy.sort(key=lambda r: (FAMILIES.index(family_of(r["actual"]["command"])), r["event"], + os.path.basename(script_path(r["actual"]["command"]) or ""))) + + # ---- build sections ---- + parts = [] + + # Codex cards + codex_cards = [] + xid = 0 + for r in sorted(codex, key=lambda r: r["disposition"]): # deleted first + xid += 1 + rid = f"X{xid}" + if r["disposition"] == "deleted": + codex_cards.append(f""" +
+
{rid}deleted + SessionStart
+

The Loaf journal-context entry is absent from the live file — removed 2026-08-02, journaled as + deliberate disable-intent (journal:dcf9875d). This is the entry whose absence makes + every loaf upgrade refuse the whole file today.

+

Entry Loaf would ship (0.2.20 dist)

+ {json_block(r["desired"])} +

Proposed ruling: absorb as disabled — record hook-enablement + state disabled, never re-add without an explicit loaf hooks enable.

+
""") + else: + codex_cards.append(f""" +
+
{rid}foreign + {esc(r["event"])}
+

Third-party entry (herdr, script dated 2026-08-07). Under the entry model this is untouchable + by construction — reconciliation never counts it against Loaf's digest again.

+ {json_block(r["actual"])} +

Proposed ruling: keep — operator-owned, invisible to Loaf.

+
""") + parts.append(f""" +
+

Codex · ~/.codex/hooks.json

+

Two entries, two rulings

+ {''.join(codex_cards)} +
""") + + # Cursor third-party + f_cards = [] + for i, r in enumerate(third_party, 1): + rid = f"F{i}" + f_cards.append(f""" +
+
{rid}foreign + {esc(r["event"])}
+ {json_block(r["actual"])} +

Proposed ruling: keep — third-party (herdr), untouchable by construction.

+
""") + + # Cursor legacy table rows, grouped by family + fam_sections = [] + lid = 0 + for fam in FAMILIES: + fam_rows = [r for r in legacy if family_of(r["actual"]["command"]) == fam] + if not fam_rows: + continue + trs = [] + for r in fam_rows: + lid += 1 + hook = r["actual"] + cmd = hook.get("command", "") + name = os.path.basename(script_path(cmd) or "") + _, mtime = script_meta(cmd) + dup = DUPLICATES.get(name) + dup_html = (f'duplicates {esc(dup)}' + if dup else '') + trs.append(f""" + + L{lid:02} + {esc(r["event"])} + {esc(cmd)} + {esc(hook.get("matcher", ""))} + {dup_html} + {esc(mtime or "?")} +""") + fam_sections.append(f""" +

{esc(fam)} {len(fam_rows)}

+
+ + {''.join(trs)} +
IDEventCommandMatcherOverlap with shipped entryScript date
""") + + in_sync_lis = "".join( + f'
  • {esc(r["event"])} {esc(r["actual"].get("command", r["actual"].get("prompt", "?")))}
  • ' + for r in cursor_in_sync) + + parts.append(f""" +
    +

    Cursor · ~/.cursor/hooks.json

    +

    Shipped entries are pristine; a 2026-03-25 generation rides along

    +

    All 17 Loaf-shipped entries match 0.2.20 byte-for-byte — no deleted, no modified. + The remaining 33 are unclaimed: one is genuinely third-party, and 32 form a single + installation event (scripts dated 2026-03-25, one 2026-04-07) whose names map one-to-one onto Loaf + skill families. The current legacy-recognition maps do not claim them, so today Loaf treats them as + foreign and they run in every Cursor session. Five functionally duplicate a shipped + loaf check entry — double enforcement on every matching tool call.

    +
    The 17 in-sync entries (no action needed)
      {in_sync_lis}
    + {''.join(f_cards)} +

    The legacy generation, by family

    +

    Proposed rulings per row: retire (claim as legacy Loaf; reconciliation + removes entry and script) · keep (operator-owned; becomes foreign-by-construction, + never touched) · disable (claim, but record disabled instead of removing).

    + {''.join(fam_sections)} +
    """) + + total_chips = f""" +
    + 17 in-sync + 1 deleted + 32 legacy-suspect + 2 third-party + 0 modified +
    """ + + doc = f""" + + + + +Hooks Reconciliation — Live Conflict Report + + + +
    +

    Loaf · Change 20260808-hooks-entry-reconciliation

    +

    Live hook-entry conflict report

    +

    Every entry in ~/.codex/hooks.json and ~/.cursor/hooks.json on this + machine, classified against the 0.2.20 dist (main@4edd80d6) with the same ownership predicates + loaf upgrade uses. Rule on items by ID — X1, F1, L01

    + {total_chips} +

    No modified Loaf entry exists anywhere today — the mutation-semantics question + stays a policy call, not a live conflict.

    + {''.join(parts)} +
    + +""" + + out = os.path.join(HERE, "hook-conflict-report.html") + with open(out, "w") as f: + f.write(doc) + print(f"wrote {out} ({len(doc)} bytes, last ID L{lid:02})") + + +if __name__ == "__main__": + main() diff --git a/docs/changes/20260808-hooks-entry-reconciliation/shape.md b/docs/changes/20260808-hooks-entry-reconciliation/shape.md new file mode 100644 index 000000000..1c4415bc3 --- /dev/null +++ b/docs/changes/20260808-hooks-entry-reconciliation/shape.md @@ -0,0 +1,171 @@ + + +# Hooks-Entry Reconciliation + +## Problem + +Codex and Cursor `hooks.json` files are managed as whole-file `hook-projection` artifacts guarded by a digest over Loaf-owned entries. Any divergence between that digest and what the installed manifest recorded — including the operator deliberately deleting one Loaf entry — makes every subsequent `loaf upgrade` refuse the file. On the canary machine this has been live since 2026-08-02: the operator emptied `~/.codex/hooks.json` to disable the SessionStart journal hook, and every upgrade since (including the 0.2.20 release verification) reports a refusal that cannot be resolved without either restoring a hook the operator does not want or hand-editing state. The whole-file model also conflates surfaces Loaf has no business judging: the live Cursor file carries 33 entries Loaf never shipped (a third-party herdr hook and a 32-entry pre-0.2 generation from 2026-03-25), and their mere presence is entangled with Loaf's digest arithmetic. Evidence: `research/hook-entry-classification.json` and `research/hook-conflict-report.html`, generated from the live files against 0.2.20 dist. + +## Hypothesis + +If Loaf reconciles hook files per entry — adding, updating, and removing only its own entries at each hook point, recognizing them by construction, and treating every other entry as invisible — then the drift-refusal class disappears, operator disable-intent survives upgrades as explicit recorded state, and shared hook files stop being a coordination hazard between Loaf, other tools, and the operator. The first upgrade to this Change's release on the canary machine is the proof: zero drift refusals, the Codex disable-intent preserved, and every non-Loaf entry preserved untouched. + +## Scope + +**In** + +- Entry-level reconciliation for Codex `~/.codex/hooks.json` and Cursor `~/.cursor/hooks.json` on install and upgrade: converge Loaf's own entries (add missing-and-enabled, update present-but-different, remove retired), never touch any other entry. +- A built hook catalog per target — `(target, event, hook_id, desired entry template)` derived from `config/hooks.yaml` at build time and readable by the installed CLI — as the single identity authority for reconciliation, absorption, and the verb surface. +- Ownership by construction: trusted Loaf-executable invocation or exact manifest-recorded Loaf-managed file paths under a closed normalization algorithm, plus a frozen enumerated legacy allowlist for Loaf's own older generations. No marker, digest, or open-ended provenance judgment is load-bearing. +- User-scoped, host-local hook-enablement records in the global SQLite state (mint-once opaque IDs, UNIQUE natural key `(target, event, hook_id)`), with absence-of-record meaning enabled, plus a durable per-target absorption marker. +- One-time absorption at this Change's release's first reconcile of a prior install, restricted to the closed cohort of hook IDs the prior version shipped: cohort entries absent from the live file become `disabled` records instead of refusals or re-adds. Fresh installs default everything to enabled. +- A minimal `loaf hooks` verb surface: `list`, `enable`, `disable`, with enable/disable immediately reprojecting the affected file. +- Retirement of the `hook-projection` artifact kind: drift-refusal planning, digest guard, and whole-file merge paths deleted; integrity preconditions retained; installed manifests tolerate and drop the obsolete rows. +- `loaf config check` hook diagnosis updated to enablement-aware states, with `--fix` routed through the reconciler. +- Canary acceptance evidence recorded from this machine's real upgrade. + +**Out** (deferred, not rejected) + +- Claude Code `settings.json` hooks — only relevant if the plugin path is retired (spark `journal:5c2f05fd`, the de-pluginization track). +- OpenCode and Amp runtime plugin files — `plugin` artifact kind, a different mechanism (broader event coverage lives in spark `journal:4dfde516`). +- Any richer hooks surface (grouping, profiles, per-project enablement, cross-host portability) — nothing here forecloses it; the enablement relation is deliberately minimal. + +**Cut** (explicitly rejected) + +- Adjudicating, claiming, retiring, or "cleaning up" non-Loaf entries — including the 2026-03-25 legacy generation and its five entries that functionally duplicate shipped enforcement hooks. Other entries' existence or inexistence makes no difference to reconciliation, ever. Legacy recognition is a closed enumerated allowlist; nothing outside it can ever be claimed. +- Ongoing deletion-inference: after absorption, hand-deleting a Loaf entry is not a disable gesture — the next reconcile re-adds it. Disable is `loaf hooks disable`. +- Drift refusals for hook files, under any name: no code path may refuse because content diverged from a recorded digest or because foreign entries exist. + +## Observable Workflow + +The operator runs `loaf upgrade`. Hook files reconcile silently: Loaf's entries converge to the shipped shape at each hook point, everything else is preserved, and the plan/output reports per-entry actions (`add`, `update`, `remove`, `absorbed as disabled`) instead of file-level conflicts. On this machine the first upgrade to this Change's release prints the Codex `session-start-loaf` absorption once, and every subsequent upgrade is a quiet no-op. + +``` +$ loaf hooks list --target codex + session-start-loaf SessionStart disabled (absorbed 2026-08-08) + +$ loaf hooks enable session-start-loaf --target codex + enabled — entry restored to ~/.codex/hooks.json + +$ loaf hooks disable session-start-loaf --target codex + disabled — entry removed from ~/.codex/hooks.json +``` + +`loaf hooks list` shows every hook the current version ships for installed targets with its event, effective enablement, and whether it is currently projected into the file. Deleting a Loaf entry by hand no longer signals anything: the next reconcile restores it, because the file is a projection of enablement state, not an authority over it. Enable/disable run the full reconciler and report every action taken — on a converged target that is exactly one entry. `loaf config check` reports enablement-aware hook health: disabled-and-absent and enabled-and-in-sync are healthy, enabled-but-stale or enabled-and-missing mean a reconcile is needed, disabled-but-present means a reprojection is pending, and foreign entries are never mentioned. + +## Rabbit Holes and No-Gos + +- **Cleaning the March 2026 generation.** Five of those 32 entries double-fire enforcement alongside shipped hooks. Tempting — and explicitly not this Change's business. If the operator wants them gone, that is a hand edit or a separate tool; reconciliation must not grow a "cleanup" mode. +- **A general write-time guard framework.** The state-dedupe session concluded specific fixes beat frameworks; the enablement table needs its UNIQUE constraint, the absorption marker, and nothing more. +- **Directory-level path containment.** Claiming everything under `~/.cursor/hooks/` would swallow the March generation. Ownership matches exact normalized manifest-recorded file paths only. +- **Enablement scoping creep.** Records are user-scoped per target per host. No per-project, per-branch, per-profile, or cross-host enablement in this Change. +- **Open-ended legacy archaeology.** The legacy allowlist is frozen at the enumerated maps that exist today; discovering further "probably ours" generations later means a new Change with new evidence, never a quiet widening. + +## Decisions + +Provenance: structured interview (2026-08-08), a reaction artifact over the live files (`research/hook-conflict-report.html`), the Aug 5 journal decisions (`journal:dcf9875d`, `journal:0e33fc06`), cross-session coordination with the state-dedupe shape (identity discipline, `decision(identity)` of 2026-08-08), and an adversarial Codex review (gpt-5.6-sol, 2026-08-08) whose ten blocking findings and three advisories are folded in below. + +1. **Converge own entries per hook point; all other entries are invisible.** Add, update, or remove Loaf's entries only; other entries' existence or inexistence makes no difference. Forecloses drift refusals, foreign-entry adjudication, and any dependency on what else lives in the file. +2. **Absorb once, then project — gated by a durable marker and restricted to a closed cohort.** Absorption runs when no per-target absorption marker exists and a prior install is detected; it considers only hook IDs the prior installed version actually shipped (the 0.2.20 cohort is enumerated in the catalog; the prior version comes from the installed manifest's `package_version`, defaulting to the full cohort predating this Change when no manifest exists). Cohort entries absent from the file become `disabled` records; hooks introduced after the prior version are never absorption candidates and project as enabled. The marker is a per-target SQLite record written in the same transaction as the absorbed records — never inferred from manifest rows, which TASK-004 deletes. Supersedes the interview's earlier "verb + inference" answer. +3. **Mutation converges.** A modified Loaf entry is neither a conflict nor a fork; it is updated in place to the desired shape. For fail-closed enforcement hooks this is a security property: silently weakened enforcement cannot survive an upgrade, and opting out requires an explicit recorded act. +4. **Identity is `(target, event, hook_id)` from the built hook catalog.** `hook_id` is the stable id in `config/hooks.yaml` (`check-secrets`, `session-start-loaf`, `kb-staleness-nudge`, …), emitted per target at build time with the desired entry template so the installed CLI reconciles without the repo present. Enablement rows carry mint-once opaque primary keys plus a REAL UNIQUE constraint on the natural key. ID reuse is a semantic contract: the same ID always means the same behavior; a hook whose semantics change takes a new ID. +5. **Records tombstone; history is never rewritten.** Rows for retired hook IDs are retained and inert (reconciliation only projects catalog hooks). `absorbed_at` is immutable provenance; `updated_at` tracks toggles. A retired ID that returns in a later version inherits its enablement history — prior disable-intent is honored, and opting back in is as explicit as opting out was. `loaf hooks list` shows the current catalog only. +6. **Ownership by construction, with a closed recognition set.** An entry is Loaf's iff (a) its command invokes the Loaf executable in one of three exact forms — the `{{LOAF_EXECUTABLE}}` template form; a shell-quoted absolute path equal to the currently resolved trusted executable or a per-target previously recorded install path (recorded in state per TASK-001's schema, written by the installer per TASK-002); or the bare first token `loaf` as Cursor entries ship today — AND its normalized command matches a catalog signature or a catalog identity stem (current or historical) per Decision 13's pairing map, never a command that merely resembles one; (b) its command references a file path that normalizes to an exact Loaf-managed `hook-file` destination recorded in the installed manifest; or (c) it matches the frozen legacy allowlist (`legacyLoafHookSignatures`, `legacyLoafCommands`, and the Codex matcher-group shape as they exist at 0.2.20 — a closed list, never extended by inference). `legacyLoafPromptPrefixes` is retained as a bounded accepted risk: prefix recognition applies to `prompt`-type entries only, the live evidence contains zero foreign prompt-type entries (fixture-backed), and a foreign prompt sharing a legacy prefix being claimed is a documented residual, not a contract violation. Path normalization is a specified closed algorithm: shell-aware token extraction, `$HOME`/`~` expansion, target-root anchoring of manifest-relative destinations, quote stripping, and platform separator normalization; no symlink resolution and no directory containment. Windows parity is concrete, not asserted: template-form handlers carry `command` and `commandWindows` with identical values; resolved-path handlers carry the platform-appropriate quoting per the existing `isExactCodexJournalHookCommand`/`Windows` rules, which the recognition normalizer mirrors for both fields. Golden tests assert all 17 shipped Cursor entries and the Codex entry are recognized as Loaf's (positive), and all 32 legacy-generation entries plus the herdr entries stay unclaimed (negative). The Cursor `loaf-managed: true` marker keeps being written as human-legible provenance but is not load-bearing. A foreign entry that deliberately imitates a Loaf command stays foreign when it fails the exact-form-plus-signature-or-stem test; imitation that passes it is the operator's deliberate construction, and possible duplicate execution is their choice, not Loaf's to police. +7. **Prior-install detection is best-effort with a named residual.** A prior install exists when the installed manifest has a hook-projection row or the live file contains recognition-set entries. A pre-manifest install whose operator deleted every Loaf entry is indistinguishable from a fresh install and will project enabled defaults; this residual is accepted and documented rather than guessed at. +8. **Drift refusals die; integrity preconditions stay.** The deleted refusal class is exactly ownership/digest drift. Reconciliation still fails closed, preserving the file unchanged, on integrity violations: unparseable JSON, non-object top level, unsupported structural shapes, symlinked or non-regular destinations, I/O errors, and concurrent modification detected between read and atomic write. These are error paths with actionable messages, not plan-surface conflicts. +9. **Foreign entries are preserved value-identical and order-stable; formatting is not the promise.** Reconciliation parses the whole file (it must), but never classifies or mutates a non-Loaf entry beyond the ownership predicate returning false. The guarantee is JSON-value identity and relative order of every foreign entry and unknown field; serialization may normalize whitespace. Golden tests compare parsed values against captured raw fixtures of the live files. +10. **SQLite is the authority; the file is a projection; writers serialize through a per-target lock; what the lock cannot cover is named case by case.** Apply recomputes actions from live state at execution time (the plan is display, never a replayed script). Every writer — reconcile or verb — holds a per-target advisory lock (a lock file beside the hooks file) from state read through file publication; contention waits briefly then fails with an actionable error. Ordering inside the lock: read file and records, compute, write absorbed/enablement records and the absorption marker in one transaction, then project the file atomically with a pre-rename re-read comparison. Outside the lock's guarantee, three distinct cases: a crash after Loaf's record commit but before projection is retry-safe eventual convergence — the file is at most one reconcile behind the records and the next reconcile converges; an unlocked third-party write detected by the pre-rename comparison is an integrity abort that preserves the third party's bytes; and a third-party write landing inside the final comparison-to-rename window is an accepted, potentially irrecoverable lost write — SQLite holds no copy of foreign content, so no later reconcile can restore it, and the contract says so instead of calling it convergence. The named verb-versus-upgrade interleavings are tested. Verb writes follow the same order: record first, then reproject, all inside the lock. +11. **Enablement is host-local; syncing hook files across machines is unsupported.** The files already embed machine-specific absolute executable paths, so they were never portable. Each machine's reconciler converges its own file to its own records; a synced-in deletion is re-added at the next reconcile and a synced-in foreign entry is ignored. A portable enablement identity is out of scope by the anti-creep rabbit hole. +12. **Fresh installs default to enabled.** With no prior install detected, all catalog hooks project as enabled and no absorption runs. +13. **Pairing installed entries to catalog IDs is deterministic and closed.** Per event section, owned entries map to `hook_id` in three passes: exact match against the current desired template; a closed signature-to-ID map (each catalog ID's current and historical normalized command signatures); then identity-stem match — each catalog ID declares the identity token embedded in its command (`--hook check-secrets`, `journal context --from-hook`, `task refresh`, …), matched as an exact normalized shell-token sequence, never substring containment (`--hook check-secrets-disabled` does not match the `check-secrets` stem), and a Loaf-executable-form entry whose command contains exactly one catalog identity stem pairs to that ID even when the surrounding command was mutated, which is what lets Decision 3 converge a weakened `loaf check --hook check-secrets --advisory` back to the desired shape instead of orphaning it as foreign. Stems and signatures are generated with the catalog and validated mutually non-overlapping by a build test — ambiguity is impossible by construction, not by runtime fallback; a Loaf-executable-form command containing zero catalog stems is the operator's own loaf-invoking hook and stays foreign, and one containing multiple stems is an integrity error, not a guess. Multiple owned entries pairing to one ID converge to a single entry (the first survives converged, extras are removed — they are Loaf's by construction). Owned entries pairing to no current ID are a retired Loaf generation and are removed. Foreign entries never enter pairing. + +## Planning Contract + +### Approach + +Replace the `hook-projection` artifact pipeline with a reconciler that operates on parsed hook files per event section. Desired entries come from the built hook catalog for the target; enablement comes from the state table; recognition of Loaf's existing entries (current or legacy shape) uses the closed by-construction predicate. The reconciler produces a per-entry action list used by the plan surface for display, applies by recomputing from live state with the existing atomic-write machinery, and verifies by re-reading and re-running recognition — not by digest. + +### Hook catalog + +The build emits, per target, the catalog `(target, event, hook_id, desired entry template)` derived from `config/hooks.yaml` — the same source that names `check-secrets`, `session-start-loaf`, and `kb-staleness-nudge` today. The catalog is the identity authority for reconciliation pairing, absorption cohorts, verb enumeration, and `config check` diagnosis. It also carries the 0.2.20 cohort enumeration used by this migration's absorption. Emission lands early in the reconciler unit so nothing downstream invents its own identity; dist/build parity tests update alongside. + +### Recognition and normalization + +The ownership predicate implements Decision 6 exactly. Path normalization: extract candidate path tokens with shell-aware splitting (respecting quotes), expand `$HOME` and `~`, anchor manifest-relative `hook-file` destinations at the target's home root (`~/.cursor` + destination), strip quoting, normalize separators per platform, and compare as absolute lexical paths — no symlink resolution, no directory prefixes. Codex executable identity reuses the existing trusted-executable resolution and the `isExactCodexJournalHookCommand(Windows)` quoting rules, tightened to require the trusted path, with `command`/`commandWindows` parity specified for both platforms. Positive fixtures: all four real path-backed entries (`kb-staleness-nudge.sh` plus the three `cat` instruction entries). Negative fixtures: every one of the 32 legacy-generation paths and the herdr entries. + +### Absorption and migration marker + +Absorption is a per-target, run-once migration: gate on the SQLite absorption marker (Decision 2), detect prior installs per Decision 7, restrict candidates to the prior version's cohort from the catalog, write `disabled` records plus the marker in one transaction, then converge. Required coverage matrix: fresh install, no-manifest legacy upgrade, normal upgrade, repeat upgrade, reinstall after marker exists, downgrade-then-re-upgrade, and the cohort test (one previously shipped hook deleted plus one newly introduced hook — only the former absorbs as disabled). + +### Crash safety and concurrency + +Decision 10's lock-and-ordering contract. The named race — upgrade reads enabled, verb commits disabled, upgrade writes the stale add — is prevented by the lock while both writers run, and resolved by eventual convergence if a writer crashes between record commit and projection. Third-party writers that do not honor the lock are handled by the pre-rename re-read comparison, with a named accepted residual: a third-party write landing in the window between that comparison and the atomic rename is lost, exactly as it would be between any two uncoordinated writers to one file — no mechanism short of OS-mandatory locking closes it, and Loaf does not pretend otherwise. Tests cover: the lock serializing verb-versus-upgrade; injected crash between record commit and file projection; injected crash between projection and manifest-row cleanup; and the read-back concurrent-modification abort for unlocked third-party writes. + +### `loaf config check` + +Diagnosis becomes enablement-aware with five states: disabled-and-correctly-absent (healthy), enabled-and-in-sync (healthy), enabled-but-stale — present but differing from the desired catalog template — (needs reconcile), enabled-and-missing (needs reconcile), disabled-but-present (needs reprojection); foreign/unknown entries are never reported. `--fix` routes through the reconciler rather than a private refresh path. + +### Verb projection semantics + +`enable`/`disable` run the full reconciler for the target (one code path, no targeted-projection variant) and report every action taken. On a converged target that is exactly one entry; when the reconcile finds other drift (a stale entry, a retired generation), the verb reports those actions too rather than pretending single-entry surgery. H3 is stated against a converged target for this reason. + +### Placement + +- Enablement records, absorption markers: `internal/state` (new table + accessors), user-scoped — no `project_id`. Opaque mint-once IDs, `UNIQUE(target, event, hook_id)`, immutable `absorbed_at`, tombstone retention. +- Hook catalog emission: the per-target builders (`build_cursor`/`build_codex` paths) plus a reader in `internal/cli`. +- Reconciler: `internal/cli`, replacing the hook-projection branches in `install_plan.go`, `install_target.go` (`mergeHookFiles`/`mergeCodexHookFiles`), and `build_manifest.go` (digest/refusal helpers). +- Verb surface: `internal/cli/hooks.go` (`runHooks`), registered in `cli.go` dispatch, consuming the catalog. +- Fixtures: sanitized raw captures of the live machine's hooks files exist at `research/fixtures/codex-hooks-live.json` and `research/fixtures/cursor-hooks-live.json` (usernames normalized), alongside the derived classification and the predicate-free before/after comparator `research/compare_hook_files.py` that TASK-005 invokes. The classification script is marked as 0.2.20-predicate provenance evidence, not the acceptance oracle. + +### Risks + +- **Codex placeholder and quoting.** The dist entry carries `{{LOAF_EXECUTABLE}}`; installed entries carry a shell-quoted absolute path plus `commandWindows` parity. Equality and recognition normalize both forms via the tightened trusted-executable rules. +- **Absorption misfire on fresh installs.** Guarded by Decisions 2, 7, and 12 and the coverage matrix; the no-prior-install regression test is mandatory. +- **Cohort accuracy.** The 0.2.20 cohort enumeration must match what 0.2.20 actually shipped (17 Cursor entries, 1 Codex entry, by hook ID); the catalog test pins it against the 0.2.20 dist fixtures. +- **Pre-manifest fully-deleted installs.** Decision 7's named residual: such machines re-add enabled defaults once; the operator disables via the verb. Documented in release notes. + +### Sequencing + +TASK-002 needs TASK-001's records and emits the catalog it consumes (catalog work is TASK-002's first step, not TASK-003's); TASK-003 needs TASK-001 and TASK-002; TASK-004 lands after TASK-002 has replaced the code paths it deletes and owns the `config check` rework; TASK-005 runs last against the real machine. Units below are ordered by likelihood-of-change for review, which happens to match this sequence. + +### Coordination + +The state-dedupe Change (docs/changes/20260807-state-dedupe, in flight) owns the identity discipline this Change's schema conforms to. No shared tables; merge order is irrelevant at the schema level. If both land migrations in the same release window, release notes should present them as one state-hygiene story. + +## Implementation Units + +- **TASK-001 — Hook-enablement state.** User-scoped enablement table, absorption markers, and accessors in `internal/state`: mint-once opaque IDs, `UNIQUE(target, event, hook_id)`, absence-means-enabled reads, immutable `absorbed_at`, tombstone retention, transactional absorb-and-mark write. +- **TASK-002 — Hook catalog and entry-level reconciler.** Catalog emission per target; closed recognition predicate with the specified normalization; per-event convergence; cohort-restricted run-once absorption; integrity preconditions; crash-safe recompute-on-apply; golden tests from sanitized live fixtures proving foreign entries survive value-identical, plus the full migration coverage matrix and Windows parity cases. +- **TASK-003 — `loaf hooks` verb surface.** `list`/`enable`/`disable` over the enablement records with immediate reprojection, consuming the catalog; record-then-reproject ordering; catalog-only listing with absorption provenance. +- **TASK-004 — Retire the hook-projection kind and rework `config check`.** Delete digest and drift-refusal paths; keep integrity preconditions; installed-manifest reader drops obsolete rows; plan output speaks per-entry actions; `config check` five-state diagnosis with `--fix` through the reconciler; dist/build parity tests updated. +- **TASK-005 — Canary acceptance evidence.** Real `loaf upgrade` on this machine recorded as change evidence: zero drift refusals, Codex `session-start-loaf` absorbed to disabled, all 33 Cursor foreign entries (the 32-entry legacy generation plus one herdr) and the Codex herdr entry preserved value-identical, idempotent second run, verb round-trip. + +## Verification Contract + +- **V1.** The full Go suite passes, including the reconciler's migration coverage matrix (fresh install, no-manifest upgrade, normal/repeat upgrade, reinstall, downgrade-re-upgrade, cohort old-deleted-plus-new-introduced), integrity precondition cases (malformed JSON, non-object top level, unsupported shape, symlink destination, concurrent modification), recognition fixtures (all 17 shipped Cursor entries plus the Codex entry positive; 32 legacy plus 2 herd negative), pairing cases (exact match, historical signature, duplicate-owned convergence, retired-generation removal, non-overlap catalog validation), lock serialization and crash-injection ordering tests, and Windows `commandWindows` parity shapes. Command: `go test ./...`. Expect: exit 0. +- **V2.** All targets build with the retired artifact kind, the emitted hook catalog, and updated parity tests. Command: `loaf build`. Expect: exit 0. +- **V3.** The Change stays structurally executable. Command: `loaf change check docs/changes/20260808-hooks-entry-reconciliation`. Expect: exit 0. + +- **H1.** Canary evidence shows the first upgrade to this Change's release absorbing the Codex `session-start-loaf` entry as disabled (no re-add, no refusal), and a second run reporting nothing to do. +- **H2.** Evidence shows every non-Loaf entry in both live files value-identical and order-stable across the upgrade — the Codex herdr entry and all 33 Cursor foreign entries (32 legacy-generation plus one herdr) — proven by `research/compare_hook_files.py` output whose reported differences name only expected Loaf entries. +- **H3.** `loaf hooks disable` / `enable` round-trip on a real, already-converged target edits exactly one entry in the file and nothing else, and its output names every action taken. +- **H4.** `loaf config check` on the canary reports the disabled Codex hook as healthy-absent, not missing. + +## Definition of Done + +- Reconciliation replaces the hook-projection pipeline for Codex and Cursor; no code path can refuse a hooks file for drift, and integrity failures preserve the file unchanged with actionable errors. +- The canary machine upgrades with zero drift refusals; its disable-intent exists as a queryable record with immutable absorption provenance; re-running is a no-op. +- Non-Loaf entries are value-identical and order-stable in every recorded run and golden test. +- `loaf hooks list/enable/disable` works against installed targets without the repo present, from the built catalog. +- `loaf config check` distinguishes the five enablement states (including enabled-but-stale) and `--fix` converges through the reconciler. +- V1–V3 green; H1–H4 reviewed with evidence in the Change folder. + +## Durable Outputs + +- ADR candidate: ownership-by-construction with closed recognition sets for shared config surfaces — the JSON-entry analog of readlink containment and fenced sections, proven against a live foreign-entry population. +- ARCHITECTURE.md: hook model section updated from whole-file managed artifacts to entry-level reconciliation with host-local enablement state and the built hook catalog. +- Knowledge note: the absorb-once-with-durable-marker pattern (cohort-restricted inference at migration, pure projection after) for any future managed-surface conversion. + +## Open Questions + +- [UK] The exact per-entry action vocabulary in plan output (`add`/`update`/`remove`/`absorb` naming and formatting) — react to the first implemented plan output during TASK-002 review. diff --git a/docs/changes/20260808-hooks-entry-reconciliation/tasks/TASK-001-hook-enablement-state.md b/docs/changes/20260808-hooks-entry-reconciliation/tasks/TASK-001-hook-enablement-state.md new file mode 100644 index 000000000..6dbcb937a --- /dev/null +++ b/docs/changes/20260808-hooks-entry-reconciliation/tasks/TASK-001-hook-enablement-state.md @@ -0,0 +1,47 @@ +--- +change: hooks-entry-reconciliation +id: TASK-001 +title: Hook-enablement state +blocks: + - TASK-002 + - TASK-003 +--- + +# TASK-001 — Hook-enablement state + +## Objective + +A user-scoped enablement table plus per-target absorption markers in the global SQLite state, with accessors the reconciler and verb surface build on: absence of a record means enabled, a `disabled` record suppresses projection, absorption provenance is immutable, and the absorb-and-mark write is transactional. + +## Scope boundaries + +**In:** `internal/state` — new table(s), schema creation, accessors (get/set/list per target, absorption-marker read/write, transactional absorb-and-mark), tests. + +**Out:** Any reconciliation logic or file I/O against hooks.json (TASK-002); the CLI verb surface (TASK-003); every existing table. + +## Context pointers + +- Contract: `shape.md` — Decisions 2, 4, 5, 10; Planning Contract → Absorption and migration marker, Placement. +- Identity discipline: state-dedupe `decision(identity)` journal entry of 2026-08-08 — mint-once opaque IDs, REAL UNIQUE constraint on the natural key, never derive keys from mutable inputs. + +## Acquisition + +```bash +loaf journal log "skill(implement): TASK-001 — hook-enablement state table" +# Read internal/state schema setup and an existing table's accessor pattern before adding the new one. +``` + +## Steps + +- [x] Add the enablement table: opaque mint-once ID, `target`, `event`, `hook_id`, enablement value, immutable `absorbed_at` (set once, never updated), `updated_at`, `UNIQUE(target, event, hook_id)`. No `project_id` — records are user-scoped and host-local. +- [x] Add the per-target absorption marker record (target, absorbed-from version, timestamp) — durable, independent of installed-manifest rows. +- [x] Add the per-target trusted-executable path record (current install path plus previously recorded paths) with its accessors — the authority Decision 6's resolved-path recognition reads. The installer-side write happens in TASK-002; this task delivers schema and accessors only. +- [x] Accessors: read effective enablement (absence → enabled), set enabled/disabled (upsert through the unique key, never re-mint on conflict, `absorbed_at` untouched by toggles), list by target, and a transactional absorb-and-mark write that commits the disabled records and the marker atomically. +- [x] Tombstone semantics: records for hook IDs no longer shipped are retained and inert; no deletion path exists. +- [x] Tests: default-enabled semantics, upsert idempotency, uniqueness enforcement, `absorbed_at` immutability across toggles, transactional absorb-and-mark (all-or-nothing under injected failure), isolation via temp DB per existing `internal/state` test conventions. + +## Verification + +- `go test ./internal/state/...` passes with the new coverage. +- A duplicate natural-key insert is impossible by construction (constraint test, not application-logic test). +- An injected failure inside absorb-and-mark leaves neither records nor marker behind. diff --git a/docs/changes/20260808-hooks-entry-reconciliation/tasks/TASK-002-entry-level-reconciler.md b/docs/changes/20260808-hooks-entry-reconciliation/tasks/TASK-002-entry-level-reconciler.md new file mode 100644 index 000000000..80bbaed88 --- /dev/null +++ b/docs/changes/20260808-hooks-entry-reconciliation/tasks/TASK-002-entry-level-reconciler.md @@ -0,0 +1,53 @@ +--- +change: hooks-entry-reconciliation +id: TASK-002 +title: Hook catalog and entry-level reconciler +blocked-by: + - TASK-001 +blocks: + - TASK-003 + - TASK-004 +--- + +# TASK-002 — Hook catalog and entry-level reconciler + +## Objective + +The build emits a per-target hook catalog as the single identity authority, and install/upgrade converge Loaf's own hook entries per event section in Codex and Cursor `hooks.json` — add missing-and-enabled, update present-but-different, remove retired, absorb cohort-absent-as-disabled exactly once — while every non-Loaf entry survives value-identical and integrity failures preserve the file unchanged. + +## Scope boundaries + +**In:** Catalog emission `(target, event, hook_id, desired entry template)` from `config/hooks.yaml` through the per-target builders plus its `internal/cli` reader; the reconciler replacing the hook-projection branches of `install_plan.go` and the merge paths in `install_target.go`; the closed recognition predicate and normalization algorithm; cohort-restricted run-once absorption; integrity preconditions; crash-safe recompute-on-apply; sanitized raw fixtures; golden and matrix tests. + +**Out:** Deleting the retired digest/refusal helpers, manifest-row handling, and `config check` rework (TASK-004); the verb surface (TASK-003); OpenCode/Amp plugin artifacts; any modification to non-Loaf entries under any circumstances. + +## Context pointers + +- Contract: `shape.md` — Decisions 1–3, 6–10, 12; Planning Contract → Hook catalog, Recognition and normalization, Absorption and migration marker, Crash safety and concurrency, Risks. +- Recognition reference: `codexHookOwnershipForOS`, `isExactCodexJournalHookCommand(Windows)`, and the trusted-executable resolution in `internal/cli` — the quoting/placeholder rules to preserve, tightened to trusted-path identity per Decision 6. +- Evidence: `research/hook-entry-classification.json` (0.2.20-predicate provenance) and the sanitized raw fixtures at `research/fixtures/codex-hooks-live.json` and `research/fixtures/cursor-hooks-live.json` — the golden-test inputs. + +## Acquisition + +```bash +loaf journal log "skill(implement): TASK-002 — hook catalog + entry-level reconciler" +# Read install_plan.go planTargetAdapterArtifacts, install_target.go merge paths, build_manifest.go hook-projection branches, and config/hooks.yaml before writing the replacement. +``` + +## Steps + +- [x] Emit the hook catalog per target at build time, including the 0.2.20 cohort enumeration (17 Cursor entries, 1 Codex entry, by hook ID) pinned by a test against the 0.2.20 dist fixtures; update dist/build parity tests. +- [x] Closed recognition predicate per Decision 6: Loaf-executable invocation in the three exact forms (template, trusted-path-quoted absolute from TASK-001's executable record, bare `loaf` first token) gated by catalog signature or identity-stem match (Decision 13); exact normalized manifest-recorded `hook-file` paths (shell-aware token extraction, `$HOME`/`~` expansion, target-root anchoring, quote stripping, separator normalization; no symlinks, no directory containment); the frozen legacy allowlist with prompt-prefix recognition bounded to prompt-type entries. Positive fixtures: all 17 shipped Cursor entries plus the Codex entry. Negative fixtures: all 32 legacy-generation paths plus the herdr entries. +- [x] Deterministic pairing per Decision 13: exact-template pass, the closed signature-to-ID map, then identity-stem match for mutated own entries (stems declared per catalog ID as exact normalized shell-token sequences — never substring containment — validated mutually non-overlapping with all signatures by a build test; zero stems → foreign, multiple stems → integrity error); duplicate-owned entries converge to one; owned-but-unpaired entries are retired generations and removed. The weakened-enforcement case (`loaf check --hook check-secrets --advisory`) must pair and converge, not orphan; the boundary case (`--hook check-secrets-disabled`) must stay foreign. +- [x] Installer integration for the trusted-executable path record: write the current resolved path at install/upgrade via TASK-001's accessor, preserving previously recorded paths per target. Tests: relocation records the new path and keeps the old, records stay target-isolated, and an entry quoting the previous path is still recognized. +- [x] Reconcile plan: per event, compute `add`/`update`/`remove`/`absorb` actions for Loaf entries against catalog + enablement records; non-Loaf entries never classified or mutated beyond the predicate returning false. +- [x] Per-target advisory lock per Decision 10 held from state read through file publication for reconciles and verb reprojections; contention fails actionably; interleaving tests cover the verb-versus-upgrade races. +- [x] Cohort-restricted run-once absorption: gate on the absorption marker, detect prior installs per Decision 7, restrict to the prior version's cohort, write records + marker via TASK-001's transactional accessor, then converge. Cover the full matrix: fresh install, no-manifest legacy upgrade, normal upgrade, repeat upgrade, reinstall, downgrade-re-upgrade, and old-hook-deleted-plus-new-hook-introduced (only the former absorbs). +- [x] Integrity preconditions fail closed preserving the file: malformed JSON, non-object top level, unsupported structural shape, symlink/non-regular destination, I/O error, concurrent modification between read and atomic write. +- [x] Apply path: recompute actions from live state at execution time (never replay a stale plan); atomic write; post-verify by re-reading and re-running recognition, not by digest. Crash-injection tests between record commit and projection. +- [x] Golden tests from the sanitized fixtures: every foreign entry value-identical and order-stable through reconcile; converge idempotent (second run produces zero actions). + +## Verification + +- `go test ./internal/cli/...` passes; goldens prove foreign-entry value-identity and idempotency; the migration matrix and integrity cases all have named tests, including Windows `commandWindows` parity. +- The live-Codex fixture reconciles to: herdr untouched, `session-start-loaf` absorbed as disabled, no drift-refusal path reachable. diff --git a/docs/changes/20260808-hooks-entry-reconciliation/tasks/TASK-003-hooks-verb-surface.md b/docs/changes/20260808-hooks-entry-reconciliation/tasks/TASK-003-hooks-verb-surface.md new file mode 100644 index 000000000..9d1fc2f02 --- /dev/null +++ b/docs/changes/20260808-hooks-entry-reconciliation/tasks/TASK-003-hooks-verb-surface.md @@ -0,0 +1,43 @@ +--- +change: hooks-entry-reconciliation +id: TASK-003 +title: loaf hooks verb surface +blocked-by: + - TASK-001 + - TASK-002 +--- + +# TASK-003 — `loaf hooks` verb surface + +## Objective + +`loaf hooks list`, `loaf hooks enable --target `, and `loaf hooks disable --target ` operate the enablement records and immediately reproject the affected file, making the verb the one discoverable disable/re-enable ceremony. + +## Scope boundaries + +**In:** `internal/cli/hooks.go`, dispatch registration in `cli.go`, consumption of the TASK-002 hook catalog, CLI reference regeneration, tests. + +**Out:** Reconciliation semantics and catalog emission (TASK-002); any enablement scoping beyond user × target × host; TUI or interactive modes. + +## Context pointers + +- Contract: `shape.md` — Observable Workflow; Decisions 4, 5, 10; Planning Contract → Hook catalog, Placement. +- Command-surface conventions: an existing `runX` command in `internal/cli` for output and JSON formatting patterns. + +## Acquisition + +```bash +loaf journal log "skill(implement): TASK-003 — loaf hooks list/enable/disable" +# Read cli.go dispatch and one existing multi-subcommand runX before adding runHooks. +``` + +## Steps + +- [x] `list`: every catalog hook for installed targets with event, effective enablement, projected-in-file state, and absorption provenance when present; tombstoned (retired) records are not listed. +- [x] `enable`/`disable` run entirely inside the per-target lock, in Decision 10's order: acquire lock → read state → upsert the record → run the full TASK-002 reconcile → publish the file → release. Report every action taken — one entry on a converged target, plus any other drift the reconcile converged. +- [x] Tests: round-trip enable/disable on a converged target edits exactly one entry; on a target with seeded drift the output names every action; list agrees with file state and records; `absorbed_at` survives toggles; unknown hook-id and uninstalled target fail with actionable errors. + +## Verification + +- `go test ./internal/cli/...` passes. +- Manual round-trip on a temp target home shows one-entry diffs in both directions. diff --git a/docs/changes/20260808-hooks-entry-reconciliation/tasks/TASK-004-retire-hook-projection-kind.md b/docs/changes/20260808-hooks-entry-reconciliation/tasks/TASK-004-retire-hook-projection-kind.md new file mode 100644 index 000000000..54405f2e3 --- /dev/null +++ b/docs/changes/20260808-hooks-entry-reconciliation/tasks/TASK-004-retire-hook-projection-kind.md @@ -0,0 +1,44 @@ +--- +change: hooks-entry-reconciliation +id: TASK-004 +title: Retire the hook-projection kind and rework config check +blocked-by: + - TASK-002 +--- + +# TASK-004 — Retire the hook-projection kind and rework `config check` + +## Objective + +The `hook-projection` artifact kind is gone — digest and drift-refusal code deleted while integrity preconditions stay — installed manifests tolerate and drop the obsolete rows, the plan surface speaks per-entry actions, and `loaf config check` diagnoses hooks with enablement-aware states. + +## Scope boundaries + +**In:** Deleting `targetHookProjectionDigest`, `planHookProjectionRefusal`, `targetHookProjectionIsEmpty`, `mergeHookFiles`/`mergeCodexHookFiles` and their call sites; installed-manifest reader dropping `hook-projection` rows on next write (after the absorption marker exists — never as the absorption gate); plan output wording; `config check` five-state hook diagnosis with `--fix` routed through the reconciler; dist/build parity test updates. + +**Out:** The reconciler and catalog (TASK-002); `hook-file`, `instruction`, and `plugin` artifact kinds, which keep their existing semantics; the integrity preconditions, which must survive this deletion pass intact. + +## Context pointers + +- Contract: `shape.md` — Decisions 8, 10; Planning Contract → `loaf config check`, Approach; Cut (drift refusals only). +- Current call sites: `internal/cli/build_manifest.go`, `install_plan.go`, `install_target.go`, `config.go` (hook diagnosis around its absent-command reporting), and the refusal/conflict tests named for them. + +## Acquisition + +```bash +loaf journal log "skill(implement): TASK-004 — retire hook-projection kind, rework config check" +# grep for "hook-projection" across internal/ and tests to enumerate every deletion site before editing. +``` + +## Steps + +- [x] Delete the drift-refusal and digest helpers and their branches; no code path may compute a hooks-file digest or emit a drift conflict. Integrity preconditions (malformed JSON, unsupported shape, symlink, I/O, concurrent modification) remain and keep their tests. +- [x] Manifest reader: obsolete `hook-projection` rows are ignored on read and absent after the next write; row removal is sequenced after the absorption marker is durable (crash between them is covered by an injected-failure test). +- [x] Plan surface: hook-file work reports the reconciler's per-entry actions; drift-conflict wording for hook files removed from fixtures and snapshots. +- [x] `config check`: five states — disabled-and-correctly-absent (healthy), enabled-and-in-sync (healthy), enabled-but-stale (present but differing from the catalog template; needs reconcile), enabled-and-missing (needs reconcile), disabled-but-present (needs reprojection); foreign/unknown never reported; `--fix` converges through the reconciler, no private refresh path. +- [x] Update dist/build parity tests for the catalog and removed digest fields. + +## Verification + +- `go test ./...` and `loaf build` pass with zero references to the retired kind outside historical docs and the tolerated-row reader. +- A disabled hook reports healthy-absent in `config check`; an enabled-and-missing hook is fixed by `--fix` through the reconciler. diff --git a/docs/changes/20260808-hooks-entry-reconciliation/tasks/TASK-005-canary-acceptance-evidence.md b/docs/changes/20260808-hooks-entry-reconciliation/tasks/TASK-005-canary-acceptance-evidence.md new file mode 100644 index 000000000..987da69da --- /dev/null +++ b/docs/changes/20260808-hooks-entry-reconciliation/tasks/TASK-005-canary-acceptance-evidence.md @@ -0,0 +1,47 @@ +--- +change: hooks-entry-reconciliation +id: TASK-005 +title: Canary acceptance evidence +blocked-by: + - TASK-002 + - TASK-003 + - TASK-004 +--- + +# TASK-005 — Canary acceptance evidence + +## Objective + +The real upgrade on the canary machine, recorded as change evidence: zero drift refusals, the Codex disable-intent absorbed as a queryable record, every non-Loaf entry preserved value-identical, an idempotent second run, a verb round-trip, and enablement-aware `config check` output. + +## Scope boundaries + +**In:** Running the built binary against this machine's live `~/.codex` and `~/.cursor`, capturing before/after snapshots and command output under `research/`, and logging the journal evidence entries. + +**Out:** Any cleanup of non-Loaf entries; changes to code (defects found here reopen the owning task instead). + +## Context pointers + +- Contract: `shape.md` — Hypothesis, Verification Contract H1–H4. +- Baseline: `research/hook-entry-classification.json` (pre-change live state, 52 entries) and the sanitized raw fixtures under `research/fixtures/`. + +## Acquisition + +```bash +loaf journal log "skill(implement): TASK-005 — canary acceptance evidence" +# Snapshot ~/.codex/hooks.json and ~/.cursor/hooks.json before running anything. +``` + +## Steps + +- [x] Snapshot both live files; run the built `loaf upgrade`; capture output. +- [x] Run `research/compare_hook_files.py` on each before/after pair and assert its reported differences name only expected Loaf entries (per the upgrade output and `loaf hooks list`); no drift-refusal text; Codex `session-start-loaf` absorbed as disabled (record present with `absorbed_at`, entry still absent, absorption marker durable); every non-Loaf entry value-identical and order-stable — the Codex herdr entry and all 33 Cursor foreign entries (32 legacy-generation plus one herdr). +- [x] Re-run upgrade; assert no actions reported and no second absorption. +- [x] Verb round-trip on Codex: `enable` restores exactly the `session-start-loaf` entry, `disable` removes exactly it; herdr untouched throughout; `absorbed_at` unchanged by the toggles. +- [x] `loaf config check`: disabled Codex hook reports healthy-absent (H4). +- [x] Store snapshots, diffs, and outputs under `research/` (named for what they are); log `finding(hooks)` journal entries for the evidence. + +## Verification + +- The recorded evidence satisfies H1–H4 as written in the Verification Contract. +- The comparator output for both files is part of the evidence; every difference it reports is an expected Loaf entry, and the foreign population (33 Cursor, 1 Codex) appears in neither the removed nor changed sets. 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 2252cc240..021417fbc 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 @@ -41,6 +41,7 @@ This audit is generated from the Go-native dispatch in `internal/cli/cli.go`, wi | `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. | +| `hooks` | Native Go | Go dispatcher only | Operator surface over user-scoped hook enablement, resolved through each installed target's built hook catalog with no source checkout in reach: `list` reports every catalog hook per installed target with its event, effective enablement, whether the live file carries it, and absorption provenance; `enable` and `disable` record the intent inside the target's lock and then run the full entry-level reconciler, reporting every action it took. | | `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. | diff --git a/docs/schema/0013_hook_enablement.sql b/docs/schema/0013_hook_enablement.sql new file mode 100644 index 000000000..d590386b8 --- /dev/null +++ b/docs/schema/0013_hook_enablement.sql @@ -0,0 +1,49 @@ +-- Hook enablement, absorption markers, and trusted executable paths. +-- +-- User-scoped and host-local: no project_id. Absence of an enablement row means +-- the hook is enabled. A disabled row suppresses projection. Records for retired +-- hook IDs are retained as inert tombstones (no deletion path). Absorption +-- provenance (absorbed_at) is immutable once set. The absorption marker is +-- independent of installed-manifest rows and gates run-once migration. + +CREATE TABLE IF NOT EXISTS hook_enablements ( + id TEXT PRIMARY KEY NOT NULL, + target TEXT NOT NULL CHECK (length(trim(target)) > 0), + event TEXT NOT NULL CHECK (length(trim(event)) > 0), + hook_id TEXT NOT NULL CHECK (length(trim(hook_id)) > 0), + enablement TEXT NOT NULL CHECK (enablement IN ('enabled', 'disabled')), + absorbed_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (target, event, hook_id) +); +CREATE INDEX IF NOT EXISTS idx_hook_enablements_target + ON hook_enablements (target); + +-- Per-target run-once absorption marker. Written in the same transaction as +-- the absorbed disabled records; never inferred from manifest rows. +CREATE TABLE IF NOT EXISTS hook_absorption_markers ( + id TEXT PRIMARY KEY NOT NULL, + target TEXT NOT NULL CHECK (length(trim(target)) > 0), + absorbed_from_version TEXT NOT NULL CHECK (length(trim(absorbed_from_version)) > 0), + absorbed_at TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE (target) +); + +-- Per-target trusted Loaf executable install paths. is_current marks the +-- currently resolved install path; prior paths remain as recognition history. +CREATE TABLE IF NOT EXISTS hook_trusted_paths ( + id TEXT PRIMARY KEY NOT NULL, + target TEXT NOT NULL CHECK (length(trim(target)) > 0), + path TEXT NOT NULL CHECK (length(trim(path)) > 0), + is_current INTEGER NOT NULL CHECK (is_current IN (0, 1)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (target, path) +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_hook_trusted_paths_current + ON hook_trusted_paths (target) + WHERE is_current = 1; +CREATE INDEX IF NOT EXISTS idx_hook_trusted_paths_target + ON hook_trusted_paths (target); diff --git a/docs/schema/README.md b/docs/schema/README.md index 2af233843..9d9280d36 100644 --- a/docs/schema/README.md +++ b/docs/schema/README.md @@ -14,6 +14,8 @@ Files: - `0008_docs_index.sql`: exact mirror of `internal/state/migrations/0008_docs_index.sql` - `0009_spec_branch_and_source.sql`: exact mirror of `internal/state/migrations/0009_spec_branch_and_source.sql` - `0011_journal_origins_and_deferrals.sql`: exact mirror of `internal/state/migrations/0011_journal_origins_and_deferrals.sql` +- `0012_intents_and_explorations.sql`: exact mirror of `internal/state/migrations/0012_intents_and_explorations.sql` +- `0013_hook_enablement.sql`: exact mirror of `internal/state/migrations/0013_hook_enablement.sql` - `operational-state.dbml`: editable relational model for design review - `operational-state.mmd`: Mermaid ER diagram for quick visual inspection diff --git a/docs/schema/operational-state.dbml b/docs/schema/operational-state.dbml index fa1b440bb..5b4394848 100644 --- a/docs/schema/operational-state.dbml +++ b/docs/schema/operational-state.dbml @@ -749,3 +749,42 @@ Table source_availability_observations { (subject_kind, subject_id, observed_at) } } + +// User-scoped hook enablement (migration 0013). No project_id: records are +// host-local. Absence of a hook_enablements row means enabled. +Table hook_enablements { + id text [pk, not null] + target text [not null] + event text [not null] + hook_id text [not null] + enablement text [not null] + absorbed_at text + created_at text [not null] + updated_at text [not null] + indexes { + (target, event, hook_id) [unique] + (target) + } +} + +Table hook_absorption_markers { + id text [pk, not null] + target text [not null, unique] + absorbed_from_version text [not null] + absorbed_at text [not null] + created_at text [not null] +} + +Table hook_trusted_paths { + id text [pk, not null] + target text [not null] + path text [not null] + is_current integer [not null] + created_at text [not null] + updated_at text [not null] + indexes { + (target, path) [unique] + (target) [unique, note: 'where is_current = 1'] + (target) + } +} diff --git a/docs/schema/operational-state.mmd b/docs/schema/operational-state.mmd index 1e4a7ed13..2ddbf62b9 100644 --- a/docs/schema/operational-state.mmd +++ b/docs/schema/operational-state.mmd @@ -608,6 +608,31 @@ erDiagram text note text created_at } + hook_enablements { + text id PK + text target + text event + text hook_id + text enablement + text absorbed_at + text created_at + text updated_at + } + hook_absorption_markers { + text id PK + text target UK + text absorbed_from_version + text absorbed_at + text created_at + } + hook_trusted_paths { + text id PK + text target + text path + integer is_current + text created_at + text updated_at + } projects ||--o{ intents : scopes projects ||--o{ intent_snapshots : scopes projects ||--o{ intent_deferrals : scopes diff --git a/internal/cli/agent_help.go b/internal/cli/agent_help.go index 41230013d..7063e21fe 100644 --- a/internal/cli/agent_help.go +++ b/internal/cli/agent_help.go @@ -83,6 +83,15 @@ func agentHelpCommands() []agentHelpCommand { {Name: "check", Description: "Validate .agents/loaf.json and installed Loaf-managed hook config", Options: []agentHelpOption{{Flags: "--fix", Description: "Create missing safe defaults and refresh stale installed target config"}, {Flags: "--json", Description: "Output config status, target hook status, warnings, and errors as JSON"}}}, }, }, + { + Name: "hooks", + Description: "Inspect and set which Loaf hooks project into an installed harness's hooks file; enablement is user-scoped per target and the file is its projection", + Subcommands: []agentHelpSubcommand{ + {Name: "list", Description: "Show every hook this version ships per installed target with its event, enablement, whether the live file carries it, and absorption provenance; retired ids and entries Loaf does not own are never listed", Options: []agentHelpOption{{Flags: "--target ", Description: "Restrict the listing to one installed target (cursor, codex)"}}}, + {Name: "enable", Description: "Record a hook as enabled for one target, reconcile that target's hooks file, and report every action the reconcile took", Options: []agentHelpOption{{Flags: "", Description: "Hook id from the target's built catalog"}, {Flags: "--target ", Description: "Target whose hooks file to reconcile (cursor, codex); required"}}}, + {Name: "disable", Description: "Record a hook as disabled for one target, reconcile that target's hooks file, and report every action the reconcile took", Options: []agentHelpOption{{Flags: "", Description: "Hook id from the target's built catalog"}, {Flags: "--target ", Description: "Target whose hooks file to reconcile (cursor, codex); required"}}}, + }, + }, { Name: "setup", Description: "One-step bootstrap: init + build + install", diff --git a/internal/cli/build_codex.go b/internal/cli/build_codex.go index 973ec487c..f7945574c 100644 --- a/internal/cli/build_codex.go +++ b/internal/cli/build_codex.go @@ -144,6 +144,9 @@ func buildNativeCodexTarget(root string) error { if err := generateNativeCodexHooksJSON(root, dist); err != nil { return err } + if err := generateNativeCodexHookCatalog(dist, version); err != nil { + return err + } return copyNativeCodexRules(root, dist) } @@ -563,21 +566,43 @@ func nativeBuildPackageVersion(root string) (string, error) { return pkg.Version, nil } -func generateNativeCodexHooksJSON(root string, dist string) error { - _ = root - // Codex 0.144.1 uses matcher groups with nested command handlers. Keep the - // executable command placeholder; install renders it to a trusted absolute - // Loaf binary once the path is known and can be pinned. - payload := nativeCodexHooksJSON{Hooks: nativeCodexHookTypes{ - SessionStart: []nativeCodexMatcherGroupJSON{{ - Matcher: "startup|resume|clear|compact", +// nativeCodexHookProjection is one desired Codex matcher group with the +// identity it carries. The hooks file and the hook catalog are generated from +// the same list so shape and identity cannot drift apart. +type nativeCodexHookProjection struct { + event string + hookID string + group nativeCodexMatcherGroupJSON +} + +// nativeCodexHookProjections describes what Codex 0.144.1 accepts: matcher +// groups with nested command handlers. The executable stays a placeholder here; +// install renders it to a trusted absolute Loaf binary once the path is known +// and can be pinned. Windows parity is concrete rather than asserted — the +// template carries command and commandWindows with identical values. +func nativeCodexHookProjections() []nativeCodexHookProjection { + return []nativeCodexHookProjection{{ + event: "SessionStart", + hookID: "session-start-loaf", + group: nativeCodexMatcherGroupJSON{ + Matcher: codexJournalHookMatcher, Hooks: []nativeCodexCommandHookJSON{{ Type: "command", - Command: "{{LOAF_EXECUTABLE}} journal context --from-hook --codex-hook", - CommandWindows: "{{LOAF_EXECUTABLE}} journal context --from-hook --codex-hook", + Command: codexJournalExecutablePlaceholder + codexJournalHookCommandSuffix, + CommandWindows: codexJournalExecutablePlaceholder + codexJournalHookCommandSuffix, }}, - }}, + }, }} +} + +func generateNativeCodexHooksJSON(root string, dist string) error { + _ = root + var payload nativeCodexHooksJSON + for _, projection := range nativeCodexHookProjections() { + if projection.event == "SessionStart" { + payload.Hooks.SessionStart = append(payload.Hooks.SessionStart, projection.group) + } + } body, err := json.MarshalIndent(payload, "", " ") if err != nil { return err @@ -590,6 +615,25 @@ func generateNativeCodexHooksJSON(root string, dist string) error { return os.WriteFile(filepath.Join(codexDir, "hooks.json"), body, 0o644) } +func generateNativeCodexHookCatalog(dist string, version string) error { + projections := nativeCodexHookProjections() + sources := make([]hookCatalogSource, 0, len(projections)) + for _, projection := range projections { + sources = append(sources, hookCatalogSource{ + event: projection.event, + hookID: projection.hookID, + typeName: "command", + command: projection.group.Hooks[0].Command, + template: projection.group, + }) + } + catalog, err := newHookCatalog("codex", version, sources) + if err != nil { + return err + } + return writeHookCatalog(dist, catalog) +} + func parseNativeBuildSimpleYAMLScalars(content string) map[string]string { values := map[string]string{} for _, field := range parseNativeBuildSimpleYAMLScalarFields(content) { diff --git a/internal/cli/build_cursor.go b/internal/cli/build_cursor.go index e62d623f9..790728096 100644 --- a/internal/cli/build_cursor.go +++ b/internal/cli/build_cursor.go @@ -105,7 +105,11 @@ func buildNativeCursorTarget(root string) error { if err := copyNativeCursorHooks(filepath.Join(srcDir, "hooks"), filepath.Join(dist, "hooks")); err != nil { return err } - return generateNativeCursorHooksJSON(filepath.Join(root, "config", "hooks.yaml"), dist) + hooksPath := filepath.Join(root, "config", "hooks.yaml") + if err := generateNativeCursorHooksJSON(hooksPath, dist); err != nil { + return err + } + return generateNativeCursorHookCatalog(hooksPath, dist, version) } func copyNativeBuildAgents(srcDir string, destDir string, targetName string, version string, defaults []nativeBuildYAMLFieldValue, sidecarRequired bool) error { @@ -403,39 +407,84 @@ func copyNativeBuildFile(src string, dest string) error { return os.WriteFile(dest, body, info.Mode().Perm()) } -func generateNativeCursorHooksJSON(hooksPath string, dist string) error { +// nativeCursorHookProjection is one desired Cursor entry with the identity it +// carries. The hooks file and the hook catalog are both generated from this one +// list so an entry's shape and its recorded identity can never drift apart. +type nativeCursorHookProjection struct { + event string + hookID string + hook nativeBuildHook + entry nativeCursorHookJSON +} + +func nativeCursorHookProjections(hooksPath string) ([]nativeCursorHookProjection, error) { hooks, err := readNativeBuildHooks(hooksPath) if err != nil { - return err + return nil, err } - var payload nativeCursorHooksJSON - payload.Version = 1 + var projections []nativeCursorHookProjection for _, hook := range hooks { switch hook.section { case "pre-tool": - payload.Hooks.PreToolUse = append(payload.Hooks.PreToolUse, nativeCursorHookEntry(hook, 60000, true)) + projections = append(projections, nativeCursorHookProjection{ + event: "preToolUse", + hookID: hook.id, + hook: hook, + entry: nativeCursorHookEntry(hook, 60000, true), + }) case "post-tool": - payload.Hooks.PostToolUse = append(payload.Hooks.PostToolUse, nativeCursorHookEntry(hook, 30000, true)) + projections = append(projections, nativeCursorHookProjection{ + event: "postToolUse", + hookID: hook.id, + hook: hook, + entry: nativeCursorHookEntry(hook, 30000, true), + }) case "session": if nativeCursorJournalContextHookOmitted(hook.id) { continue } + event := nativeCursorSessionEvent(hook.event) + if event == "" { + continue + } entry := nativeCursorHookEntry(hook, 60000, false) - switch nativeCursorSessionEvent(hook.event) { - case "sessionStart": - if hook.id == "session-start-loaf" { - entry.Command = "loaf journal context --from-hook --cursor-hook" - } - payload.Hooks.SessionStart = append(payload.Hooks.SessionStart, entry) - case "sessionEnd": - payload.Hooks.SessionEnd = append(payload.Hooks.SessionEnd, entry) - case "beforeSubmitPrompt": - payload.Hooks.BeforeSubmitPrompt = append(payload.Hooks.BeforeSubmitPrompt, entry) - case "stop": - payload.Hooks.Stop = append(payload.Hooks.Stop, entry) - case "preCompact": - payload.Hooks.PreCompact = append(payload.Hooks.PreCompact, entry) + if event == "sessionStart" && hook.id == "session-start-loaf" { + entry.Command = "loaf journal context --from-hook --cursor-hook" } + projections = append(projections, nativeCursorHookProjection{ + event: event, + hookID: hook.id, + hook: hook, + entry: entry, + }) + } + } + return projections, nil +} + +func generateNativeCursorHooksJSON(hooksPath string, dist string) error { + projections, err := nativeCursorHookProjections(hooksPath) + if err != nil { + return err + } + var payload nativeCursorHooksJSON + payload.Version = 1 + for _, projection := range projections { + switch projection.event { + case "preToolUse": + payload.Hooks.PreToolUse = append(payload.Hooks.PreToolUse, projection.entry) + case "postToolUse": + payload.Hooks.PostToolUse = append(payload.Hooks.PostToolUse, projection.entry) + case "sessionStart": + payload.Hooks.SessionStart = append(payload.Hooks.SessionStart, projection.entry) + case "sessionEnd": + payload.Hooks.SessionEnd = append(payload.Hooks.SessionEnd, projection.entry) + case "beforeSubmitPrompt": + payload.Hooks.BeforeSubmitPrompt = append(payload.Hooks.BeforeSubmitPrompt, projection.entry) + case "stop": + payload.Hooks.Stop = append(payload.Hooks.Stop, projection.entry) + case "preCompact": + payload.Hooks.PreCompact = append(payload.Hooks.PreCompact, projection.entry) } } body, err := json.MarshalIndent(payload, "", " ") @@ -445,6 +494,33 @@ func generateNativeCursorHooksJSON(hooksPath string, dist string) error { return os.WriteFile(filepath.Join(dist, "hooks.json"), body, 0o644) } +func generateNativeCursorHookCatalog(hooksPath string, dist string, version string) error { + projections, err := nativeCursorHookProjections(hooksPath) + if err != nil { + return err + } + sources := make([]hookCatalogSource, 0, len(projections)) + for _, projection := range projections { + typeName := "command" + if projection.entry.Prompt != "" { + typeName = "prompt" + } + sources = append(sources, hookCatalogSource{ + event: projection.event, + hookID: projection.hookID, + typeName: typeName, + command: projection.entry.Command, + prompt: projection.entry.Prompt, + template: projection.entry, + }) + } + catalog, err := newHookCatalog("cursor", version, sources) + if err != nil { + return err + } + return writeHookCatalog(dist, catalog) +} + // Cursor's installed sessionStart hook is the only retained journal // continuity surface in this slice. UserPromptSubmit and PreCompact outputs // are not proven model-context channels for Cursor and must not be projected diff --git a/internal/cli/build_manifest.go b/internal/cli/build_manifest.go index bdbfd2e0b..fc8bc5bd9 100644 --- a/internal/cli/build_manifest.go +++ b/internal/cli/build_manifest.go @@ -17,6 +17,15 @@ const ( targetInstallManifestFile = ".loaf-managed-target.json" ) +// obsoleteHookProjectionKind is the artifact kind releases up to and including +// 0.2.20 gave a target's shared hooks file. Entry-level reconciliation retired +// it: that file is not an artifact Loaf owns, so no digest of it means anything +// and no divergence from one can refuse anything. Nothing writes the kind any +// more, and the name survives in exactly one place — the reader below, which +// tolerates the rows an older release left in an installed manifest and drops +// them so the next write is rid of them. +const obsoleteHookProjectionKind = "hook-projection" + type targetAdapterManifest struct { Version int `json:"version"` Target string `json:"target"` @@ -24,6 +33,12 @@ type targetAdapterManifest struct { CapabilityContractVersion int `json:"capability_contract_version"` Adapters []string `json:"adapters"` Artifacts []targetAdapterArtifact `json:"artifacts"` + + // carriedObsoleteHookRow records that the manifest as read from disk still + // held one of the retired rows. It is deliberately unexported and unmarshal- + // only: prior-install detection is the single thing that still cares, and it + // asks a question about the past that must not survive into what is written. + carriedObsoleteHookRow bool } type targetAdapterArtifact struct { @@ -104,6 +119,12 @@ func targetCapabilityAdapters(contract TargetCapabilityEvidenceContract, target } func collectTargetAdapterArtifacts(target string, outputDir string) ([]targetAdapterArtifact, error) { + // A target's shared hooks file is absent from every one of these lists. + // Cursor's `hooks.json` and Codex's `.codex/hooks.json` are converged one + // entry at a time against the built catalog, so there is nothing here for a + // whole-file artifact to own, publish, remove, or hold a digest of. Claude + // Code's hooks live inside the plugin bundle Loaf writes outright, which is + // why they stay ordinary hook files. var paths []string switch target { case "claude-code": @@ -111,9 +132,9 @@ func collectTargetAdapterArtifacts(target string, outputDir string) ([]targetAda case "opencode": paths = []string{"plugins"} case "cursor": - paths = []string{"hooks.json", "hooks"} + paths = []string{"hooks"} case "codex": - paths = []string{".codex/hooks.json"} + paths = nil case "amp": paths = []string{".amp/plugins/loaf.ts"} default: @@ -195,134 +216,23 @@ func appendTargetAdapterArtifact(artifacts *[]targetAdapterArtifact, seen map[st if sourcePath == "plugins/hooks.ts" { kind = "plugin" } - case "cursor": - if sourcePath == "hooks.json" { - kind = "hook-projection" - } - case "codex": - kind = "hook-projection" - destination = "hooks.json" - case "claude-code": - if sourcePath == "hooks/hooks.json" { - kind = "hook-projection" - } } - digest := sha256Bytes(body) - var mode *uint32 - if kind == "hook-projection" { - digest, err = targetHookProjectionDigest(target, body, false) - if err != nil { - return fmt.Errorf("hash %s hook projection: %w", target, err) - } - } else { - info, err := os.Lstat(filepath.Join(outputDir, filepath.FromSlash(sourcePath))) - if err != nil { - return err - } - value := uint32(info.Mode().Perm()) - mode = &value + info, err := os.Lstat(filepath.Join(outputDir, filepath.FromSlash(sourcePath))) + if err != nil { + return err } + mode := uint32(info.Mode().Perm()) *artifacts = append(*artifacts, targetAdapterArtifact{ ID: kind + ":" + sourcePath, Kind: kind, SourcePath: sourcePath, Destination: destination, - SHA256: digest, - Mode: mode, + SHA256: sha256Bytes(body), + Mode: &mode, }) return nil } -func targetHookProjectionDigest(target string, body []byte, installed bool) (string, error) { - switch target { - case "cursor": - var hooks codexHooksFile - if err := json.Unmarshal(body, &hooks); err != nil { - return "", err - } - projection := codexHooksFile{Version: 1, Hooks: map[string][]map[string]any{}} - for event, entries := range hooks.Hooks { - for _, entry := range entries { - if isLoafInstallHook(entry) { - projection.Hooks[event] = append(projection.Hooks[event], entry) - } - } - } - canonical, err := json.Marshal(projection) - if err != nil { - return "", err - } - return sha256Bytes(canonical), nil - case "codex": - hooks, err := decodeCodexHooksBodyStrict(body) - if err != nil { - return "", err - } - projection := codexHooksRawFile{Hooks: map[string][]json.RawMessage{}} - for event, entries := range hooks.Hooks { - for _, rawEntry := range entries { - entry, err := decodeCodexHookObject(rawEntry) - if err != nil { - return "", err - } - if installed { - owned, conflict := codexHookOwnership(entry) - if conflict { - return "", fmt.Errorf("modified Loaf matcher group") - } - if !owned { - continue - } - handlers := entry["hooks"].([]any) - handler := handlers[0].(map[string]any) - handler["command"] = codexJournalExecutablePlaceholder + codexJournalHookCommandSuffix - handler["commandWindows"] = codexJournalExecutablePlaceholder + codexJournalHookCommandSuffix - } else if !bytes.Contains(rawEntry, []byte(codexJournalExecutablePlaceholder)) { - continue - } - canonical, err := json.Marshal(entry) - if err != nil { - return "", err - } - projection.Hooks[event] = append(projection.Hooks[event], canonical) - } - } - canonical, err := json.Marshal(projection) - if err != nil { - return "", err - } - return sha256Bytes(canonical), nil - default: - return sha256Bytes(body), nil - } -} - -func decodeCodexHooksBodyStrict(body []byte) (codexHooksRawFile, error) { - tempDir, err := os.MkdirTemp("", "loaf-hooks-decode-*") - if err != nil { - return codexHooksRawFile{}, err - } - path := filepath.Join(tempDir, "hooks.json") - if err := os.WriteFile(path, body, 0o600); err != nil { - if cleanupErr := os.RemoveAll(tempDir); cleanupErr != nil { - return codexHooksRawFile{}, fmt.Errorf("%w; clean up Codex hooks decode directory %s: %v", err, tempDir, cleanupErr) - } - return codexHooksRawFile{}, err - } - hooks, decodeErr := loadCodexHooksRawFileStrict(path) - cleanupErr := os.RemoveAll(tempDir) - if decodeErr != nil { - if cleanupErr != nil { - return codexHooksRawFile{}, fmt.Errorf("%w; clean up Codex hooks decode directory %s: %v", decodeErr, tempDir, cleanupErr) - } - return codexHooksRawFile{}, decodeErr - } - if cleanupErr != nil { - return codexHooksRawFile{}, fmt.Errorf("clean up Codex hooks decode directory %s: %w", tempDir, cleanupErr) - } - return hooks, nil -} - func readTargetAdapterManifest(path string) (targetAdapterManifest, error) { info, err := os.Lstat(path) if err != nil { @@ -331,10 +241,17 @@ func readTargetAdapterManifest(path string) (targetAdapterManifest, error) { if !info.Mode().IsRegular() || info.Mode()&fs.ModeSymlink != 0 { return targetAdapterManifest{}, fmt.Errorf("target adapter manifest %s must be a regular file", path) } - body, err := readRegularFile(path, projectFileReadLimit) + raw, err := readRegularFile(path, projectFileReadLimit) if err != nil { return targetAdapterManifest{}, err } + // The retired rows come out before any strict rule is applied, because every + // strict rule applies to the whole document: the duplicate-key walk descends + // into each row, and DisallowUnknownFields judges each row's fields. A row + // this version has no semantics for must not be able to fail a read — that + // failure would abort the very upgrade that was about to absorb and drop it, + // which is the file-level refusal this Change removed wearing a new hat. + body, carriedObsoleteHookRow := stripObsoleteHookProjectionRows(raw) if err := validateJSONNoDuplicateKeys(body); err != nil { return targetAdapterManifest{}, fmt.Errorf("read target adapter manifest: %w", err) } @@ -347,12 +264,193 @@ func readTargetAdapterManifest(path string) (targetAdapterManifest, error) { if err := decoder.Decode(&struct{}{}); err != io.EOF { return targetAdapterManifest{}, fmt.Errorf("read target adapter manifest: trailing JSON values") } + manifest.carriedObsoleteHookRow = carriedObsoleteHookRow if err := validateTargetAdapterManifest(manifest); err != nil { return targetAdapterManifest{}, err } return manifest, nil } +// stripObsoleteHookProjectionRows removes the retired rows from the manifest +// bytes, and is the only code that ever looks at one. It looks at exactly one +// field — `kind` — so an unknown field a later release added, a field whose type +// changed, and a duplicate key inside the row are all invisible rather than +// fatal. +// +// Strictness for every live row is untouched. Kept rows are written back as the +// bytes they were read as, top-level keys keep their order and their repeats, +// and the strict pass then runs over the result, so a duplicate key or unknown +// field anywhere Loaf still has semantics for fails exactly as before. A +// document with nothing to drop is returned unchanged, byte for byte, and one +// this cannot parse is handed back for the strict pass to report. +func stripObsoleteHookProjectionRows(body []byte) ([]byte, bool) { + fields, ok := decodeJSONObjectFields(body) + if !ok { + return body, false + } + dropped := false + for index, field := range fields { + if field.name != "artifacts" { + continue + } + var rows []json.RawMessage + if err := json.Unmarshal(field.value, &rows); err != nil { + continue + } + kept := make([]json.RawMessage, 0, len(rows)) + obsolete := false + for _, row := range rows { + if isObsoleteHookProjectionRow(row) { + obsolete = true + continue + } + kept = append(kept, row) + } + if !obsolete { + continue + } + encoded, err := json.Marshal(kept) + if err != nil { + return body, false + } + fields[index].value = encoded + dropped = true + } + if !dropped { + return body, false + } + rebuilt, err := encodeJSONObjectFields(fields) + if err != nil { + return body, false + } + return rebuilt, true +} + +// isObsoleteHookProjectionRow reads the one field that identifies a row, and +// requires that field to be unambiguous: exactly one `kind`, holding a JSON +// string. A row with no `kind`, with several, or with a non-string one is not +// identified — it stays where it is and the strict pass judges it. +// +// The repeated case is the one worth spelling out. Deciding by last-wins on +// `{"kind":"hook-file","kind":"hook-projection"}` would do two wrong things at +// once: launder a duplicate-key defect past the walk that exists to catch it, +// and discard a row whose first `kind` says it is live. Leaving it in place +// makes the duplicate fail the duplicate-key walk, which is what should happen. +// +// Repeats in any other field are a different question. There the row's identity +// is not in doubt, so it drops whole and whatever else it carried goes with it. +func isObsoleteHookProjectionRow(row json.RawMessage) bool { + fields, ok := decodeJSONObjectFields(row) + if !ok { + return false + } + var kind json.RawMessage + declared := 0 + for _, field := range fields { + if field.name != "kind" { + continue + } + declared++ + kind = field.value + } + if declared != 1 { + return false + } + var value string + if err := json.Unmarshal(kind, &value); err != nil { + return false + } + return value == obsoleteHookProjectionKind +} + +// jsonObjectField is one written key-value pair. Repeats are pairs too: this +// decodes for a rewrite that the duplicate-key walk still has to be able to +// reject, so collapsing them would launder a defect past it. +type jsonObjectField struct { + name string + value json.RawMessage +} + +func decodeJSONObjectFields(body []byte) ([]jsonObjectField, bool) { + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + token, err := decoder.Token() + if err != nil { + return nil, false + } + if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' { + return nil, false + } + var fields []jsonObjectField + for decoder.More() { + key, err := decoder.Token() + if err != nil { + return nil, false + } + name, ok := key.(string) + if !ok { + return nil, false + } + var value json.RawMessage + if err := decoder.Decode(&value); err != nil { + return nil, false + } + fields = append(fields, jsonObjectField{name: name, value: value}) + } + if _, err := decoder.Token(); err != nil { + return nil, false + } + // Everything after the closing brace must be whitespace JSON itself allows. + // decoder.More() answers a different question — it looks for the start of + // another value — so a stray closing delimiter walks straight past it, and a + // document rebuilt without that delimiter would reach the strict pass + // already repaired. Anything else trailing means this cannot strip the + // document, and the original bytes go to the strict pass to be refused. + if !isJSONWhitespace(body[decoder.InputOffset():]) { + return nil, false + } + return fields, true +} + +// isJSONWhitespace reports whether every byte is one JSON's grammar permits +// between tokens. The set is exactly four, and it is narrower than the one +// strings.TrimSpace trims: Unicode calls a vertical tab, a form feed, and a +// non-breaking space whitespace, while JSON calls all three a syntax error. A +// document ending in one of those is invalid and the strict pass says so — but +// only if it sees the bytes that were written, which it would not if this +// treated the suffix as harmless and the rebuild quietly erased it. +func isJSONWhitespace(remainder []byte) bool { + for _, character := range remainder { + switch character { + case ' ', '\t', '\n', '\r': + default: + return false + } + } + return true +} + +func encodeJSONObjectFields(fields []jsonObjectField) ([]byte, error) { + var out bytes.Buffer + out.WriteByte('{') + for index, field := range fields { + if index > 0 { + out.WriteByte(',') + } + name, err := json.Marshal(field.name) + if err != nil { + return nil, err + } + out.Write(name) + out.WriteByte(':') + if err := json.Compact(&out, field.value); err != nil { + return nil, err + } + } + out.WriteByte('}') + return out.Bytes(), nil +} + func validateTargetAdapterManifest(manifest targetAdapterManifest) error { if manifest.Version != 1 { return fmt.Errorf("unsupported target adapter manifest version %d", manifest.Version) @@ -383,7 +481,7 @@ func validateTargetAdapterManifest(manifest targetAdapterManifest) error { return fmt.Errorf("invalid or duplicate target adapter artifact id %q", artifact.ID) } seenIDs[artifact.ID] = true - if artifact.Kind != "instruction" && artifact.Kind != "hook-projection" && artifact.Kind != "hook-file" && artifact.Kind != "plugin" { + if artifact.Kind != "instruction" && artifact.Kind != "hook-file" && artifact.Kind != "plugin" { return fmt.Errorf("invalid target adapter artifact kind %q", artifact.Kind) } if artifact.Kind == "instruction" { @@ -399,12 +497,12 @@ func validateTargetAdapterManifest(manifest targetAdapterManifest) error { } seenDestinations[artifact.Destination] = true } - if artifact.Kind == "hook-file" || artifact.Kind == "plugin" { - if artifact.Mode == nil || *artifact.Mode > 0o777 { - return fmt.Errorf("invalid or missing target adapter artifact mode for %q", artifact.ID) + if artifact.Kind == "instruction" { + if artifact.Mode != nil { + return fmt.Errorf("target adapter artifact kind %q must not declare a mode", artifact.Kind) } - } else if artifact.Mode != nil { - return fmt.Errorf("target adapter artifact kind %q must not declare a mode", artifact.Kind) + } else if artifact.Mode == nil || *artifact.Mode > 0o777 { + return fmt.Errorf("invalid or missing target adapter artifact mode for %q", artifact.ID) } if !isHexString(artifact.SHA256) || len(artifact.SHA256) != 64 || strings.ToLower(artifact.SHA256) != artifact.SHA256 { return fmt.Errorf("invalid target adapter artifact digest for %q", artifact.ID) @@ -461,7 +559,7 @@ func syncTargetAdapterManifest(options targetInstallOptions) error { states := map[string]targetAdapterSnapshot{} desiredDestinations := map[string]bool{} for _, artifact := range installed.Artifacts { - if artifact.Kind == "instruction" { + if skipTargetAdapterArtifact(artifact) { continue } path, err := targetAdapterDestination(options, artifact) @@ -476,23 +574,17 @@ func syncTargetAdapterManifest(options targetInstallOptions) error { if !snapshot.exists { continue } - matchesInstalled, err := targetAdapterSnapshotMatchesArtifact(options.Target, artifact, snapshot) - if err != nil { - return fmt.Errorf("inspect managed target artifact %q: %w", artifact.ID, err) - } + matchesInstalled := targetAdapterSnapshotMatchesArtifact(artifact, snapshot) matchesDesired := false if current, ok := desiredByID[artifact.ID]; ok { - matchesDesired, err = targetAdapterSnapshotMatchesArtifact(options.Target, current, snapshot) - if err != nil { - return fmt.Errorf("inspect desired target artifact %q: %w", artifact.ID, err) - } + matchesDesired = targetAdapterSnapshotMatchesArtifact(current, snapshot) } if !matchesInstalled && !matchesDesired { return fmt.Errorf("managed target artifact %q was modified; refusing to overwrite or remove", artifact.ID) } } for _, artifact := range desired.Artifacts { - if artifact.Kind == "instruction" { + if skipTargetAdapterArtifact(artifact) { continue } if err := verifyTargetAdapterSource(options, artifact); err != nil { @@ -513,14 +605,7 @@ func syncTargetAdapterManifest(options targetInstallOptions) error { if _, owned := installedByID[artifact.ID]; owned || !states[path].exists { continue } - matchesDesired, err := targetAdapterSnapshotMatchesArtifact(options.Target, artifact, states[path]) - if err != nil { - return fmt.Errorf("inspect target artifact migration %q: %w", artifact.ID, err) - } - if matchesDesired || targetAdapterLegacyOwnership(options.Target, artifact, states[path].body) { - continue - } - if artifact.Kind == "hook-projection" && targetHookProjectionIsEmpty(options.Target, states[path].body) { + if targetAdapterSnapshotMatchesArtifact(artifact, states[path]) || targetAdapterLegacyOwnership(options.Target, artifact, states[path].body) { continue } return fmt.Errorf("target artifact destination %q exists and is not managed by Loaf", artifact.Destination) @@ -554,7 +639,7 @@ func syncTargetAdapterManifest(options targetInstallOptions) error { return rollbackTargetAdapterMutations(cause, mutated, options.TargetAdapterOps) } for _, artifact := range installed.Artifacts { - if artifact.Kind == "instruction" { + if skipTargetAdapterArtifact(artifact) { continue } if _, keep := desiredByID[artifact.ID]; keep { @@ -590,7 +675,7 @@ func syncTargetAdapterManifest(options targetInstallOptions) error { } } for _, artifact := range desired.Artifacts { - if artifact.Kind == "instruction" { + if skipTargetAdapterArtifact(artifact) { continue } if options.TargetAdapterOps != nil && options.TargetAdapterOps.beforeArtifact != nil { @@ -632,6 +717,15 @@ func syncTargetAdapterManifest(options targetInstallOptions) error { return nil } +// skipTargetAdapterArtifact names the one artifact the whole-file machinery +// does not handle: managed instructions live inside a project file rather than +// at a destination of their own. A target's shared hooks file is not on this +// list because it is not on any manifest — the reconciler owns it, and a +// whole-file row for it is exactly the file-level verdict this replaces. +func skipTargetAdapterArtifact(artifact targetAdapterArtifact) bool { + return artifact.Kind == "instruction" +} + func ensureTargetAdapterSnapshotUnchanged(path string, expected targetAdapterSnapshot) error { current, err := readTargetAdapterSnapshot(path) if err != nil { @@ -764,14 +858,7 @@ func verifyTargetAdapterSource(options targetInstallOptions, artifact targetAdap if err != nil { return err } - digest := sha256Bytes(body) - if artifact.Kind == "hook-projection" { - digest, err = targetHookProjectionDigest(options.Target, body, false) - if err != nil { - return err - } - } - if digest != artifact.SHA256 { + if digest := sha256Bytes(body); digest != artifact.SHA256 { return fmt.Errorf("target adapter source %q does not match its manifest digest", artifact.SourcePath) } if artifact.Mode != nil && uint32(info.Mode().Perm()) != *artifact.Mode { @@ -812,49 +899,17 @@ func restoreTargetAdapterSnapshot(snapshot targetAdapterSnapshot) error { return writeFileAtomically(snapshot.path, snapshot.body, snapshot.mode) } -func targetAdapterInstalledDigest(target string, artifact targetAdapterArtifact, body []byte) (string, error) { - if artifact.Kind == "hook-projection" { - return targetHookProjectionDigest(target, body, true) - } - return sha256Bytes(body), nil -} - -func targetAdapterSnapshotMatchesArtifact(target string, artifact targetAdapterArtifact, snapshot targetAdapterSnapshot) (bool, error) { - if !snapshot.exists { - return false, nil - } - digest, err := targetAdapterInstalledDigest(target, artifact, snapshot.body) - if err != nil { - return false, err - } - if digest != artifact.SHA256 { - return false, nil +func targetAdapterSnapshotMatchesArtifact(artifact targetAdapterArtifact, snapshot targetAdapterSnapshot) bool { + if !snapshot.exists || sha256Bytes(snapshot.body) != artifact.SHA256 { + return false } - if artifact.Kind == "hook-file" || artifact.Kind == "plugin" { - return artifact.Mode != nil && uint32(snapshot.mode.Perm()) == *artifact.Mode, nil + if artifact.Kind == "instruction" { + return artifact.Mode == nil } - return artifact.Mode == nil, nil + return artifact.Mode != nil && uint32(snapshot.mode.Perm()) == *artifact.Mode } func targetAdapterLegacyOwnership(target string, artifact targetAdapterArtifact, body []byte) bool { - if artifact.Kind == "hook-projection" { - switch target { - case "cursor": - var hooks codexHooksFile - if json.Unmarshal(body, &hooks) != nil { - return false - } - for _, entries := range hooks.Hooks { - for _, entry := range entries { - if isLoafInstallHook(entry) { - return true - } - } - } - case "codex": - return !targetHookProjectionIsEmpty(target, body) - } - } if artifact.Kind == "plugin" { text := string(body) return strings.Contains(text, "Auto-generated by loaf build system") && @@ -864,81 +919,27 @@ func targetAdapterLegacyOwnership(target string, artifact targetAdapterArtifact, return false } -func targetHookProjectionIsEmpty(target string, body []byte) bool { - switch target { - case "cursor": - var hooks codexHooksFile - if json.Unmarshal(body, &hooks) != nil { - return false - } - for _, entries := range hooks.Hooks { - for _, entry := range entries { - if isLoafInstallHook(entry) { - return false - } - } - } - return true - case "codex": - hooks, err := decodeCodexHooksBodyStrict(body) - if err != nil { - return false - } - for _, entries := range hooks.Hooks { - for _, rawEntry := range entries { - entry, err := decodeCodexHookObject(rawEntry) - if err != nil { - return false - } - owned, conflict := codexHookOwnership(entry) - if owned || conflict { - return false - } - } - } - return true - default: - return false - } -} - func publishTargetAdapterArtifact(options targetInstallOptions, artifact targetAdapterArtifact) error { source := filepath.Join(options.DistDir, filepath.FromSlash(artifact.SourcePath)) destination, err := targetAdapterDestination(options, artifact) if err != nil { return err } - if artifact.Kind == "hook-projection" { - switch options.Target { - case "cursor": - err = mergeHookFiles(destination, source) - case "codex": - err = mergeCodexHookFiles(destination, source, options.ProjectRoot, options.CodexRuleOperations) - default: - err = fmt.Errorf("target %q does not support hook projection installation", options.Target) - } - } else { - body, readErr := os.ReadFile(source) - if readErr != nil { - return readErr - } - if artifact.Mode == nil { - return fmt.Errorf("target adapter artifact %q has no bound mode", artifact.ID) - } - err = writeFileAtomically(destination, body, fs.FileMode(*artifact.Mode)) - } + body, err := os.ReadFile(source) if err != nil { + return err + } + if artifact.Mode == nil { + return fmt.Errorf("target adapter artifact %q has no bound mode", artifact.ID) + } + if err := writeFileAtomically(destination, body, fs.FileMode(*artifact.Mode)); err != nil { return fmt.Errorf("publish target adapter artifact %q: %w", artifact.ID, err) } snapshot, err := readTargetAdapterSnapshot(destination) if err != nil { return err } - matches, err := targetAdapterSnapshotMatchesArtifact(options.Target, artifact, snapshot) - if err != nil { - return err - } - if !matches { + if !targetAdapterSnapshotMatchesArtifact(artifact, snapshot) { return fmt.Errorf("published target adapter artifact %q failed content or mode verification", artifact.ID) } return nil @@ -949,71 +950,8 @@ func removeTargetAdapterArtifact(options targetInstallOptions, artifact targetAd if err != nil { return err } - if artifact.Kind != "hook-projection" { - if err := os.Remove(destination); err != nil && !os.IsNotExist(err) { - return err - } - return nil - } - snapshot, err := readTargetAdapterSnapshot(destination) - if err != nil { + if err := os.Remove(destination); err != nil && !os.IsNotExist(err) { return err } - if !snapshot.exists { - return nil - } - switch options.Target { - case "cursor": - hooks, err := loadCodexHooksFile(destination) - if err != nil { - return err - } - for event, entries := range hooks.Hooks { - kept := entries[:0] - for _, entry := range entries { - if !isLoafInstallHook(entry) { - kept = append(kept, entry) - } - } - if len(kept) == 0 { - delete(hooks.Hooks, event) - } else { - hooks.Hooks[event] = kept - } - } - body, err := json.MarshalIndent(hooks, "", " ") - if err != nil { - return err - } - return writeFileAtomically(destination, append(body, '\n'), snapshot.mode) - case "codex": - hooks, err := loadCodexHooksRawFileStrict(destination) - if err != nil { - return err - } - for event, entries := range hooks.Hooks { - kept := entries[:0] - for _, rawEntry := range entries { - entry, err := decodeCodexHookObject(rawEntry) - if err != nil { - return err - } - owned, conflict := codexHookOwnership(entry) - if conflict { - return fmt.Errorf("modified Loaf matcher group") - } - if !owned { - kept = append(kept, rawEntry) - } - } - hooks.Hooks[event] = kept - } - body, err := json.MarshalIndent(hooks, "", " ") - if err != nil { - return err - } - return writeFileAtomically(destination, append(body, '\n'), snapshot.mode) - default: - return fmt.Errorf("target %q does not support hook projection removal", options.Target) - } + return nil } diff --git a/internal/cli/build_manifest_test.go b/internal/cli/build_manifest_test.go index c79dd9869..cb8777776 100644 --- a/internal/cli/build_manifest_test.go +++ b/internal/cli/build_manifest_test.go @@ -106,7 +106,6 @@ func TestReadTargetAdapterManifestRequiresConcreteModesAndForbidsProjectionModes "missing concrete mode": strings.Replace(valid, `,"mode":493`, "", 1), "mode out of range": strings.Replace(valid, `"mode":493`, `"mode":512`, 1), "instruction mode": strings.Replace(valid, `"destination":"project-instructions"`, `"destination":"project-instructions","mode":420`, 1), - "projection mode": strings.Replace(valid, `"kind":"plugin"`, `"kind":"hook-projection"`, 1), } { t.Run(name, func(t *testing.T) { writeInstallFile(t, path, body) @@ -117,6 +116,211 @@ func TestReadTargetAdapterManifestRequiresConcreteModesAndForbidsProjectionModes } } +// The retired whole-file hooks row: an installed manifest an older release +// wrote still reads, the row is gone from what was read, and the write that +// follows carries no trace of it. Nothing about the row is validated on the way +// past — a kind this version has no semantics for must not get a second chance +// to matter. +func TestReadTargetAdapterManifestDropsTheRetiredHookProjectionRow(t *testing.T) { + digest := strings.Repeat("a", 64) + legacy := `{"version":1,"target":"cursor","package_version":"0.2.20","capability_contract_version":3,"adapters":["cursor-session-start-v1"],"artifacts":[` + + `{"id":"hook-file:hooks/x.sh","kind":"hook-file","source_path":"hooks/x.sh","destination":"hooks/x.sh","sha256":"` + digest + `","mode":493},` + + `{"id":"hook-projection:hooks.json","kind":"hook-projection","source_path":"hooks.json","destination":"hooks.json","sha256":"` + digest + `"},` + + `{"id":"managed-instructions","kind":"instruction","destination":"project-instructions","sha256":"` + digest + `"}]}` + dir := realpath(t, t.TempDir()) + path := filepath.Join(dir, "manifest.json") + writeInstallFile(t, path, legacy) + + manifest, err := readTargetAdapterManifest(path) + if err != nil { + t.Fatalf("readTargetAdapterManifest(legacy) error = %v", err) + } + if !manifest.carriedObsoleteHookRow { + t.Fatal("carriedObsoleteHookRow = false; prior-install detection reads that row and nothing else does") + } + for _, artifact := range manifest.Artifacts { + if artifact.Kind == obsoleteHookProjectionKind { + t.Fatalf("artifacts = %#v, want the retired row dropped on read", manifest.Artifacts) + } + } + if len(manifest.Artifacts) != 2 { + t.Fatalf("artifacts = %#v, want the surviving hook-file and instruction rows", manifest.Artifacts) + } + + rewritten := filepath.Join(dir, "rewritten.json") + if err := writeTargetAdapterManifest(rewritten, manifest); err != nil { + t.Fatalf("writeTargetAdapterManifest error = %v", err) + } + if body := string(readFileBytes(t, rewritten)); strings.Contains(body, obsoleteHookProjectionKind) { + t.Fatalf("rewritten manifest = %s, want the retired row absent after the next write", body) + } + + // Nothing writes the kind any more, so a manifest that names it is only ever + // something to drop — never something to publish back. + fresh := filepath.Join(dir, "fresh.json") + if err := writeTargetAdapterManifest(fresh, targetAdapterManifest{ + Version: 1, Target: "cursor", PackageVersion: "9.9.9", CapabilityContractVersion: 3, + Adapters: []string{"cursor-session-start-v1"}, + Artifacts: []targetAdapterArtifact{{ID: "hook-projection:hooks.json", Kind: obsoleteHookProjectionKind, SourcePath: "hooks.json", Destination: "hooks.json", SHA256: digest}}, + }); err == nil { + t.Fatal("writeTargetAdapterManifest accepted the retired kind") + } +} + +// Tolerance has to survive the row being wrong, not merely being retired. The +// strict rules apply to the whole document, so a retired row carrying a field a +// later release added, a field whose type changed, or a repeated key would +// abort the read — and aborting the read aborts the upgrade that was about to +// absorb and drop it. Each defect is then moved into a live row to prove +// strictness was narrowed to the retired kind and nowhere else. +func TestReadTargetAdapterManifestToleratesDefectsInsideTheRetiredHookRow(t *testing.T) { + digest := strings.Repeat("a", 64) + manifest := func(hookRow string, liveRow string) string { + return `{"version":1,"target":"cursor","package_version":"0.2.20","capability_contract_version":3,` + + `"adapters":["cursor-session-start-v1"],"artifacts":[` + hookRow + `,` + liveRow + `,` + + `{"id":"managed-instructions","kind":"instruction","destination":"project-instructions","sha256":"` + digest + `"}]}` + } + liveRow := `{"id":"hook-file:hooks/x.sh","kind":"hook-file","source_path":"hooks/x.sh","destination":"hooks/x.sh","sha256":"` + digest + `","mode":493}` + for name, defect := range map[string]struct{ hook, live string }{ + "unknown field": { + hook: `{"id":"hook-projection:hooks.json","kind":"hook-projection","source_path":"hooks.json","destination":"hooks.json","sha256":"` + digest + `","projection_generation":7}`, + live: `{"id":"hook-file:hooks/x.sh","kind":"hook-file","source_path":"hooks/x.sh","destination":"hooks/x.sh","sha256":"` + digest + `","mode":493,"projection_generation":7}`, + }, + "wrong-typed field": { + hook: `{"id":"hook-projection:hooks.json","kind":"hook-projection","source_path":"hooks.json","destination":"hooks.json","sha256":12345}`, + live: `{"id":"hook-file:hooks/x.sh","kind":"hook-file","source_path":"hooks/x.sh","destination":"hooks/x.sh","sha256":12345,"mode":493}`, + }, + "duplicate key inside the row": { + hook: `{"id":"hook-projection:hooks.json","kind":"hook-projection","source_path":"hooks.json","destination":"hooks.json","sha256":"` + digest + `","sha256":"` + digest + `"}`, + live: `{"id":"hook-file:hooks/x.sh","kind":"hook-file","source_path":"hooks/x.sh","destination":"hooks/x.sh","sha256":"` + digest + `","sha256":"` + digest + `","mode":493}`, + }, + } { + t.Run(name, func(t *testing.T) { + dir := realpath(t, t.TempDir()) + path := filepath.Join(dir, "manifest.json") + writeInstallFile(t, path, manifest(defect.hook, liveRow)) + + read, err := readTargetAdapterManifest(path) + if err != nil { + t.Fatalf("readTargetAdapterManifest(%s in the retired row) error = %v, want it read tolerantly", name, err) + } + if !read.carriedObsoleteHookRow { + t.Fatal("carriedObsoleteHookRow = false; prior-install detection must still see the row that was dropped") + } + if len(read.Artifacts) != 2 { + t.Fatalf("artifacts = %#v, want the retired row dropped and both live rows kept", read.Artifacts) + } + rewritten := filepath.Join(dir, "rewritten.json") + if err := writeTargetAdapterManifest(rewritten, read); err != nil { + t.Fatalf("writeTargetAdapterManifest error = %v", err) + } + if body := string(readFileBytes(t, rewritten)); strings.Contains(body, obsoleteHookProjectionKind) { + t.Fatalf("rewritten manifest = %s, want the retired row absent after the next write", body) + } + + // The same defect in a row this version does have semantics for is + // still a refusal: tolerance is scoped to the retired kind. + strict := filepath.Join(dir, "strict.json") + writeInstallFile(t, strict, manifest(defect.hook, defect.live)) + if _, err := readTargetAdapterManifest(strict); err == nil { + t.Fatalf("readTargetAdapterManifest(%s in a live row) error = nil, want the live row still judged strictly", name) + } + }) + } +} + +// Two documents that would let a defect through the strip rather than be caught +// by it. Both are refusals, and in both the strip has to leave the bytes exactly +// as it found them — a repair applied on the way past is how a document reaches +// the strict pass already made to look valid. +func TestStripObsoleteHookProjectionRowsRefusesToIdentifyAmbiguousDocuments(t *testing.T) { + digest := strings.Repeat("a", 64) + live := `{"id":"hook-file:hooks/x.sh","kind":"hook-file","source_path":"hooks/x.sh","destination":"hooks/x.sh","sha256":"` + digest + `","mode":493}` + instruction := `{"id":"managed-instructions","kind":"instruction","destination":"project-instructions","sha256":"` + digest + `"}` + manifest := func(rows string, trailing string) string { + return `{"version":1,"target":"cursor","package_version":"0.2.20","capability_contract_version":3,` + + `"adapters":["cursor-session-start-v1"],"artifacts":[` + rows + `]}` + trailing + } + // A row claiming both kinds: last-wins identification would drop it, which + // launders the duplicate key and discards a row whose first kind is live. + duplicateKind := `{"id":"hook-projection:hooks.json","kind":"hook-file","kind":"hook-projection","source_path":"hooks.json","destination":"hooks.json","sha256":"` + digest + `","mode":493}` + // A retired row that would strip cleanly, behind a stray closing delimiter + // that a rebuild would silently drop. + retired := `{"id":"hook-projection:hooks.json","kind":"hook-projection","source_path":"hooks.json","destination":"hooks.json","sha256":"` + digest + `"}` + + for name, testCase := range map[string]struct{ body, wantError string }{ + "duplicate kind field": {body: manifest(duplicateKind+","+instruction, ""), wantError: `duplicate object key "kind"`}, + "trailing delimiter": {body: manifest(retired+","+live+","+instruction, "}"), wantError: "trailing JSON values"}, + "trailing array delim": {body: manifest(retired+","+live+","+instruction, "]"), wantError: "trailing JSON values"}, + "trailing second value": {body: manifest(retired+","+live+","+instruction, "{}"), wantError: "trailing JSON values"}, + // Bytes Unicode calls whitespace and JSON calls a syntax error. Each is + // invalid trailing content the strict pass rejects, so the strip must not + // erase it on the way past. + "trailing vertical tab": {body: manifest(retired+","+live+","+instruction, "\v"), wantError: "trailing JSON values"}, + "trailing form feed": {body: manifest(retired+","+live+","+instruction, "\f"), wantError: "trailing JSON values"}, + "trailing non-breaking space": {body: manifest(retired+","+live+","+instruction, " "), wantError: "trailing JSON values"}, + } { + t.Run(name, func(t *testing.T) { + stripped, dropped := stripObsoleteHookProjectionRows([]byte(testCase.body)) + if dropped { + t.Fatalf("stripObsoleteHookProjectionRows reported a drop for %s; an unidentifiable document must be handed back whole", name) + } + if string(stripped) != testCase.body { + t.Fatalf("stripped = %s, want the original bytes untouched so the strict pass sees what was written", stripped) + } + + path := filepath.Join(realpath(t, t.TempDir()), "manifest.json") + writeInstallFile(t, path, testCase.body) + _, err := readTargetAdapterManifest(path) + if err == nil { + t.Fatalf("readTargetAdapterManifest(%s) error = nil, want the strict refusal", name) + } + if !strings.Contains(err.Error(), testCase.wantError) { + t.Fatalf("readTargetAdapterManifest(%s) error = %v, want it to name %q", name, err, testCase.wantError) + } + }) + } + + // The distinction the rule turns on: a repeat in a field that is not the + // identity leaves the identity unambiguous, so that row still drops. + repeatedDigest := `{"id":"hook-projection:hooks.json","kind":"hook-projection","source_path":"hooks.json","destination":"hooks.json","sha256":"` + digest + `","sha256":"` + digest + `"}` + stripped, dropped := stripObsoleteHookProjectionRows([]byte(manifest(repeatedDigest+","+live+","+instruction, ""))) + if !dropped || strings.Contains(string(stripped), obsoleteHookProjectionKind) { + t.Fatalf("stripObsoleteHookProjectionRows(repeated non-identity field) = %s, %v, want the row still dropped", stripped, dropped) + } + + // And the four bytes JSON does allow after the document, which is how every + // manifest anyone actually wrote ends. Narrowing the character class must + // not cost the common case its strip. + for name, trailing := range map[string]string{ + "newline": "\n", + "carriage return": "\r\n", + "spaces and tabs": " \t ", + "nothing at all": "", + "blank final line": "\n\n", + } { + t.Run("trailing "+name+" still strips", func(t *testing.T) { + body := manifest(retired+","+live+","+instruction, trailing) + stripped, dropped := stripObsoleteHookProjectionRows([]byte(body)) + if !dropped { + t.Fatalf("stripObsoleteHookProjectionRows(%q) reported no drop; valid trailing whitespace must not block the strip", trailing) + } + if strings.Contains(string(stripped), obsoleteHookProjectionKind) { + t.Fatalf("stripped = %s, want the retired row gone", stripped) + } + path := filepath.Join(realpath(t, t.TempDir()), "manifest.json") + writeInstallFile(t, path, body) + read, err := readTargetAdapterManifest(path) + if err != nil { + t.Fatalf("readTargetAdapterManifest error = %v, want the ordinary document read", err) + } + if !read.carriedObsoleteHookRow || len(read.Artifacts) != 2 { + t.Fatalf("read = %#v, want the row seen, dropped, and both live rows kept", read) + } + }) + } +} + func TestCollectTargetAdapterArtifactsRejectsSymlinks(t *testing.T) { root := realpath(t, t.TempDir()) writeInstallFile(t, filepath.Join(root, "plugins", "hooks.ts"), "plugin\n") diff --git a/internal/cli/build_test.go b/internal/cli/build_test.go index abfa975b8..b6a8e41a6 100644 --- a/internal/cli/build_test.go +++ b/internal/cli/build_test.go @@ -74,13 +74,24 @@ func TestRunnerBuildRunsContentBuilderNatively(t *testing.T) { filepath.Join(root, "plugins", "loaf", ".claude-plugin", "plugin.json"), filepath.Join(root, "dist", "opencode", "plugins", "hooks.ts"), filepath.Join(root, "dist", "cursor", "hooks.json"), + filepath.Join(root, "dist", "cursor", hookCatalogFile), filepath.Join(root, "dist", "codex", ".codex", "hooks.json"), + filepath.Join(root, "dist", "codex", hookCatalogFile), filepath.Join(root, "dist", "amp", ".amp", "plugins", "loaf.ts"), } { if _, err := os.Stat(path); err != nil { t.Fatalf("Stat(%s) error = %v", path, err) } } + // The hook catalog is emitted only for the targets whose hook files + // reconcile per entry. OpenCode and Amp ship plugins, and Claude Code's + // hooks live in the plugin bundle; none of them are in this scope. + for _, target := range []string{"claude-code", "opencode", "amp"} { + path := filepath.Join(nativeBuildTargetOutputDir(root, target), hookCatalogFile) + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("Stat(%s) = %v, want no hook catalog for %s", path, err, target) + } + } for target, adapter := range map[string]string{ "claude-code": "claude-session-start-v1", "opencode": "opencode-plugin-v1", @@ -98,15 +109,33 @@ func TestRunnerBuildRunsContentBuilderNatively(t *testing.T) { t.Fatalf("%s manifest adapters = %#v, want %q", target, manifest["adapters"], adapter) } artifacts, ok := manifest["artifacts"].([]any) - if !ok || len(artifacts) < 2 { - t.Fatalf("%s manifest artifacts = %#v, want instruction plus adapter artifacts", target, manifest["artifacts"]) + // Codex's only surface is the shared hooks file, which is reconciled per + // entry and therefore on no manifest at all — so its manifest is the + // managed instruction and nothing else. + wantArtifacts := 2 + if target == "codex" { + wantArtifacts = 1 + } + if !ok || len(artifacts) < wantArtifacts { + t.Fatalf("%s manifest artifacts = %#v, want at least %d", target, manifest["artifacts"], wantArtifacts) } var instruction map[string]any for _, rawArtifact := range artifacts { artifact := rawArtifact.(map[string]any) if artifact["id"] == "managed-instructions" { instruction = artifact - break + } + // No manifest names a target's shared hooks file, under the retired + // kind or any other: a whole-file row for it is the file-level verdict + // entry-level reconciliation replaced. + if kind, _ := artifact["kind"].(string); kind == obsoleteHookProjectionKind { + t.Fatalf("%s manifest artifact = %#v, want the retired hook-projection kind gone", target, artifact) + } + switch artifact["destination"] { + case "hooks.json", "hooks/hooks.json": + if target != "claude-code" { + t.Fatalf("%s manifest artifact = %#v, want the reconciled hooks file off the manifest", target, artifact) + } } } if instruction == nil { @@ -210,6 +239,13 @@ func TestRunnerBuildTargetCodexRunsNativeTarget(t *testing.T) { if strings.Contains(hooksJSON, "workflow-pre-merge") || strings.Contains(hooksJSON, "detect-linear-magic") { t.Fatalf("hooks.json = %q, want only SessionStart context hook", hooksJSON) } + catalog, err := readHookCatalog(filepath.Join(root, "dist", "codex")) + if err != nil { + t.Fatalf("readHookCatalog(codex) error = %v", err) + } + if len(catalog.Entries) != 1 || catalog.Entries[0].Event != "SessionStart" || catalog.Entries[0].HookID != "session-start-loaf" { + t.Fatalf("codex hook catalog = %#v, want the SessionStart identity", catalog.Entries) + } } func TestRunnerBuildTargetAmpRunsNativePluginTarget(t *testing.T) { @@ -368,6 +404,20 @@ func TestRunnerBuildTargetCursorRunsNativeTarget(t *testing.T) { t.Fatalf("cursor hooks.json = %q, want %q", hooksJSON, want) } } + catalog, err := readHookCatalog(filepath.Join(root, "dist", "cursor")) + if err != nil { + t.Fatalf("readHookCatalog(cursor) error = %v", err) + } + generated := 0 + for _, entries := range testHookEventEntries(t, []byte(hooksJSON)) { + generated += len(entries) + } + if len(catalog.Entries) != generated { + t.Fatalf("cursor hook catalog has %d entries, want one per generated hooks.json entry (%d)", len(catalog.Entries), generated) + } + if _, ok := catalog.cohortHookIDs("0.2.20"); !ok { + t.Fatal("cursor hook catalog carries no 0.2.20 absorption cohort") + } } func TestRunnerBuildTargetOpenCodeRunsNativeTarget(t *testing.T) { diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 6e963bd5e..ca202193a 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -280,6 +280,8 @@ func (r Runner) Run(args []string) error { dispatchErr = r.runUpgrade(args[1:], out, runtime.RootPath()) case "config": dispatchErr = r.runConfig(args[1:], out, runtime.RootPath()) + case "hooks": + dispatchErr = r.runHooks(args[1:], out, runtime.RootPath()) case "migrate": dispatchErr = r.runMigrate(args[1:], out, runtime) case "release": @@ -401,6 +403,7 @@ func writeRootHelp(out io.Writer) { fmt.Fprintln(out, " install Install Loaf into agent tools") fmt.Fprintln(out, " upgrade Refresh installed harnesses and Loaf project surfaces") fmt.Fprintln(out, " config Validate and refresh project Loaf config") + fmt.Fprintln(out, " hooks Inspect and set Loaf hook enablement per target") fmt.Fprintln(out, " setup Initialize, build, and install") fmt.Fprintln(out, " state Manage native SQLite state") fmt.Fprintln(out, " project Manage project identity") diff --git a/internal/cli/cli_reference.go b/internal/cli/cli_reference.go index 88e2fdc40..e322a3b2e 100644 --- a/internal/cli/cli_reference.go +++ b/internal/cli/cli_reference.go @@ -83,6 +83,23 @@ func cliReferenceCommands() []cliReferenceCommand { }}, }, }, + { + Name: "hooks", + Description: "Inspect and set which Loaf hooks project into an installed harness's hooks file", + Subcommands: []cliReferenceSubcommand{ + {Name: "list", Description: "Show every hook this version ships per installed target with its event, enablement, whether the live file carries it, and absorption provenance; retired ids and entries Loaf does not own are never listed", Options: []cliReferenceOption{ + {Flags: "--target ", Description: "Restrict the listing to one installed target (cursor, codex)"}, + }}, + {Name: "enable", Description: "Record a hook as enabled for one target, reconcile that target's hooks file, and report every action the reconcile took", Options: []cliReferenceOption{ + {Flags: "", Description: "Hook id from the target's built catalog, as loaf hooks list reports it"}, + {Flags: "--target ", Description: "Target whose hooks file to reconcile (cursor, codex); required, because enablement is recorded per target"}, + }}, + {Name: "disable", Description: "Record a hook as disabled for one target, reconcile that target's hooks file, and report every action the reconcile took", Options: []cliReferenceOption{ + {Flags: "", Description: "Hook id from the target's built catalog, as loaf hooks list reports it"}, + {Flags: "--target ", Description: "Target whose hooks file to reconcile (cursor, codex); required, because enablement is recorded per target"}, + }}, + }, + }, { Name: "init", Description: "Initialize a project with Loaf structure", diff --git a/internal/cli/config.go b/internal/cli/config.go index d9ab4734e..3991c4f20 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -1,6 +1,7 @@ package cli import ( + "context" "encoding/json" "fmt" "io" @@ -22,6 +23,14 @@ type configCheckOptions struct { fix bool jsonOutput bool help bool + // hookState reaches the enablement records for targets whose hook entries + // are reconciled. Diagnosis crosses those records with the live file, and + // `--fix` drives the shared installer, which converges the entries against + // them rather than overwriting the file — so the resolver has to travel with + // the request. Which resolver it is matters: a plain check gets the + // read-only one, because a diagnosis that created a database would be a + // write, and this command promises none without `--fix`. + hookState hookStateResolver } type configCheckResult struct { @@ -43,13 +52,52 @@ type configFileStatus struct { Errors []string `json:"errors,omitempty"` } +// configTargetStatus is one installed harness's hook health. Targets whose +// entries are reconciled report Hooks — one state per catalog identity — and +// the rest report MissingHooks, the whole-file question their plugin surfaces +// still ask. Neither list ever mentions an entry Loaf does not own. type configTargetStatus struct { - Target string `json:"target"` - ConfigDir string `json:"config_dir"` - HookPath string `json:"hook_path,omitempty"` - Status string `json:"status"` - MissingHooks []string `json:"missing_hooks,omitempty"` - Error string `json:"error,omitempty"` + Target string `json:"target"` + ConfigDir string `json:"config_dir"` + HookPath string `json:"hook_path,omitempty"` + Status string `json:"status"` + MissingHooks []string `json:"missing_hooks,omitempty"` + Hooks []hookDiagnosis `json:"hooks,omitempty"` + Error string `json:"error,omitempty"` +} + +func (s configTargetStatus) healthy() bool { + if s.Error != "" || len(s.MissingHooks) > 0 { + return false + } + for _, hook := range s.Hooks { + if !hook.healthy() { + return false + } + } + return true +} + +// remedyPhrases names what this target needs, one phrase per distinct remedy so +// a reconcile and a reprojection are not blurred into a single "stale". A +// healthy target has none. +func (s configTargetStatus) remedyPhrases() []string { + if len(s.MissingHooks) > 0 { + return []string{"hooks missing: " + strings.Join(s.MissingHooks, ", ")} + } + var phrases []string + for _, remedy := range []string{"reconcile", "reprojection"} { + var named []string + for _, hook := range s.Hooks { + if hook.remedy() == remedy { + named = append(named, fmt.Sprintf("%s (%s)", hook.HookID, strings.ReplaceAll(hook.State, "-", " "))) + } + } + if len(named) > 0 { + phrases = append(phrases, "hooks need "+remedy+": "+strings.Join(named, ", ")) + } + } + return phrases } func (r Runner) runConfig(args []string, out io.Writer, runtimeRoot string) error { @@ -79,6 +127,13 @@ func (r Runner) runConfig(args []string, out io.Writer, runtimeRoot string) erro if err != nil { return err } + resolveHookState := r.hookStateForPlan + if options.fix { + resolveHookState = r.hookStateForApply + } + hookState, releaseHookState := resolveHookState(projectRoot.Path()) + defer releaseHookState() + options.hookState = hookState result := runConfigCheck(projectRoot.Path(), loafRoot, options) if options.jsonOutput { if err := writeJSON(out, result); err != nil { @@ -136,22 +191,21 @@ func runConfigCheck(projectRoot string, loafRoot string, options configCheckOpti result.Fixed = true } - targets := installedConfigTargets() - for _, target := range targets { - status := checkConfigTargetHooks(loafRoot, target) - if len(status.MissingHooks) > 0 && options.fix { - status = fixConfigTargetHooks(projectRoot, loafRoot, target, status) + for _, target := range installedConfigTargets() { + status := checkConfigTargetHooks(projectRoot, loafRoot, target, options.hookState) + if !status.healthy() && options.fix { + status = fixConfigTargetHooks(projectRoot, loafRoot, target, status, options.hookState) } if status.Status == "updated" { result.Fixed = true } - if status.Error != "" || len(status.MissingHooks) > 0 { + if !status.healthy() { result.OK = false - if status.Error != "" { - result.Errors = append(result.Errors, fmt.Sprintf("%s: %s", target.key, status.Error)) - } else { - result.Errors = append(result.Errors, fmt.Sprintf("%s: missing managed hook(s): %s", target.key, strings.Join(status.MissingHooks, ", "))) + detail := status.Error + if detail == "" { + detail = strings.Join(status.remedyPhrases(), "; ") } + result.Errors = append(result.Errors, fmt.Sprintf("%s: %s", target.key, detail)) } result.Targets = append(result.Targets, status) } @@ -409,7 +463,14 @@ func readConfigInstallRecord(target string) (installTargetRecord, bool) { return record, true } -func checkConfigTargetHooks(loafRoot string, target detectedInstallTool) configTargetStatus { +// checkConfigTargetHooks reports one installed harness's hook health. A target +// whose entries are reconciled is diagnosed per identity against the records and +// the live file; the plugin targets keep the whole-file question, which is the +// right one for a file Loaf writes outright. +func checkConfigTargetHooks(projectRoot string, loafRoot string, target detectedInstallTool, hookState hookStateResolver) configTargetStatus { + if targetKeepsReconciledHookFile(target.key) { + return diagnoseConfigTargetHooks(projectRoot, loafRoot, target, hookState) + } status := configTargetStatus{ Target: target.key, ConfigDir: target.configDir, @@ -436,44 +497,80 @@ func checkConfigTargetHooks(loafRoot string, target detectedInstallTool) configT return status } -func fixConfigTargetHooks(projectRoot string, loafRoot string, target detectedInstallTool, previous configTargetStatus) configTargetStatus { - return fixConfigTargetHooksWithInstaller(projectRoot, loafRoot, target, previous, installTargetDistribution) +// diagnoseConfigTargetHooks is the enablement-aware half. It runs the +// reconciler's own read — same catalog, same recognition, same pairing — so the +// verdict here and the actions an upgrade would take cannot disagree, and a +// hook the operator disabled reads healthy rather than missing. +func diagnoseConfigTargetHooks(projectRoot string, loafRoot string, target detectedInstallTool, hookState hookStateResolver) configTargetStatus { + options := configTargetInstallOptions(projectRoot, loafRoot, target, hookState, false) + status := configTargetStatus{ + Target: target.key, + ConfigDir: target.configDir, + HookPath: targetHookFilePath(options), + Status: "ok", + } + reconciler, err := hooksReconcilerFor(options) + if err == nil { + status.Hooks, err = reconciler.diagnose(context.Background()) + } + if err != nil { + status.Status = "error" + status.Error = err.Error() + return status + } + if !status.healthy() { + status.Status = "stale" + } + return status +} + +func fixConfigTargetHooks(projectRoot string, loafRoot string, target detectedInstallTool, previous configTargetStatus, hookState hookStateResolver) configTargetStatus { + return fixConfigTargetHooksWithInstaller(projectRoot, loafRoot, target, previous, hookState, installTargetDistribution) } -func fixConfigTargetHooksWithInstaller(projectRoot string, loafRoot string, target detectedInstallTool, previous configTargetStatus, installer func(targetInstallOptions) error) configTargetStatus { - distDir := filepath.Join(loafRoot, "dist", target.key) - if !dirExistsForInstall(distDir) { +// fixConfigTargetHooksWithInstaller converges through the shared installer and +// nothing else. For a reconciled target that installer is the reconciler, so +// `--fix` adds a hook the operator never disabled, removes one they did, and +// converges a drifted entry — by the same path an upgrade takes, with no +// private refresh of its own. +func fixConfigTargetHooksWithInstaller(projectRoot string, loafRoot string, target detectedInstallTool, previous configTargetStatus, hookState hookStateResolver, installer func(targetInstallOptions) error) configTargetStatus { + options := configTargetInstallOptions(projectRoot, loafRoot, target, hookState, true) + if !dirExistsForInstall(options.DistDir) { previous.Status = "error" previous.Error = fmt.Sprintf("no build output found for %s; run `loaf build` or reinstall Loaf", target.key) return previous } - if err := installer(targetInstallOptions{ - Target: target.key, - DistDir: distDir, - ConfigDir: target.configDir, - Upgrade: true, - Version: packageVersion(loafRoot), - HomeDir: installHome(), - CodexHome: os.Getenv("CODEX_HOME"), - ProjectRoot: projectRoot, - }); err != nil { + if err := installer(options); err != nil { previous.Status = "error" previous.Error = err.Error() return previous } - updated := checkConfigTargetHooks(loafRoot, target) - if len(updated.MissingHooks) == 0 && updated.Error == "" { + updated := checkConfigTargetHooks(projectRoot, loafRoot, target, hookState) + if updated.healthy() { updated.Status = "updated" } return updated } +// configTargetInstallOptions is how this command addresses one installed +// harness. Diagnosis and `--fix` build it the same way so the target read is +// the target written. +func configTargetInstallOptions(projectRoot string, loafRoot string, target detectedInstallTool, hookState hookStateResolver, upgrade bool) targetInstallOptions { + return targetInstallOptions{ + Target: target.key, + DistDir: filepath.Join(loafRoot, "dist", target.key), + ConfigDir: target.configDir, + Upgrade: upgrade, + Version: packageVersion(loafRoot), + HomeDir: installHome(), + CodexHome: os.Getenv("CODEX_HOME"), + ProjectRoot: projectRoot, + HookState: hookState, + } +} + func configDistributionHookPath(loafRoot string, target string) string { switch target { - case "cursor": - return filepath.Join(loafRoot, "dist", "cursor", "hooks.json") - case "codex": - return filepath.Join(loafRoot, "dist", "codex", ".codex", "hooks.json") case "opencode": return filepath.Join(loafRoot, "dist", "opencode", "plugins", "hooks.ts") case "amp": @@ -485,8 +582,6 @@ func configDistributionHookPath(loafRoot string, target string) string { func configTargetHookPath(target string, configDir string) string { switch target { - case "cursor", "codex": - return filepath.Join(configDir, "hooks.json") case "opencode": return filepath.Join(configDir, "plugins", "hooks.ts") case "amp": @@ -511,9 +606,6 @@ func configHookIDsFromFile(path string) ([]string, error) { seen[match[1]] = true } } - if strings.Contains(string(body), codexJournalHookCommandSuffix) { - seen["codex-session-start"] = true - } var ids []string for id := range seen { ids = append(ids, id) @@ -593,7 +685,9 @@ func writeConfigTargetStatusText(out io.Writer, status configTargetStatus) { case "updated": fmt.Fprintf(out, " %s %s hooks refreshed\n", ansiGreen("✓"), installDisplayName(status.Target)) case "stale": - fmt.Fprintf(out, " %s %s hooks missing: %s\n", ansiYellow("!"), installDisplayName(status.Target), strings.Join(status.MissingHooks, ", ")) + for _, phrase := range status.remedyPhrases() { + fmt.Fprintf(out, " %s %s %s\n", ansiYellow("!"), installDisplayName(status.Target), phrase) + } case "error": fmt.Fprintf(out, " %s %s hooks: %s\n", ansiRed("x"), installDisplayName(status.Target), status.Error) } diff --git a/internal/cli/config_test.go b/internal/cli/config_test.go index a0b81b996..74786af12 100644 --- a/internal/cli/config_test.go +++ b/internal/cli/config_test.go @@ -57,6 +57,9 @@ func TestRunnerConfigCheckFixCreatesProjectConfig(t *testing.T) { } } +// A Codex file carrying only the operator's own group: the hook this version +// ships is enabled and not there, which is a reconcile rather than a refusal, +// and `--fix` adds Loaf's entry beside theirs without touching it. func TestRunnerConfigCheckFixAcceptsCurrentCodexHooksSchema(t *testing.T) { root, home := setupInstallCommandFixture(t) writeInstallFile(t, filepath.Join(root, ".agents", "loaf.json"), strings.Join([]string{ @@ -75,43 +78,30 @@ func TestRunnerConfigCheckFixAcceptsCurrentCodexHooksSchema(t *testing.T) { ` }`, `}`, }, "\n")+"\n") - writeInstallFile(t, filepath.Join(root, "dist", "codex", ".codex", "hooks.json"), `{"hooks":{}}`+"\n") + installTestHookDistribution(t, root, "codex") writeInstallFile(t, filepath.Join(home, ".codex", loafInstallMarkerFile), "old\n") writeInstallFile(t, filepath.Join(home, ".codex", "hooks.json"), `{"hooks":{"SessionStart":[{"matcher":"startup","hooks":[{"type":"command","command":"user codex hook"}]}]}}`+"\n") - var checkOut bytes.Buffer - err := Runner{Stdout: &checkOut, WorkingDir: root, Executable: distributionFixtureExecutable(root)}.Run([]string{"config", "check", "--json"}) - if err != nil { - t.Fatalf("config check error = %v, want current schema to pass", err) - } - var before configCheckResult - if err := json.Unmarshal(checkOut.Bytes(), &before); err != nil { - t.Fatalf("Unmarshal(check output) error = %v\n%s", err, checkOut.String()) - } + before := runConfigCheckJSON(t, root, false) codexBefore := findConfigTargetStatus(before.Targets, "codex") - if codexBefore.Status != "ok" || len(codexBefore.MissingHooks) != 0 { - t.Fatalf("codexBefore = %#v, want current Codex hook contract", codexBefore) + if codexBefore.Status != "stale" || len(codexBefore.Hooks) != 1 || codexBefore.Hooks[0].State != hookStateEnabledMissing { + t.Fatalf("codexBefore = %#v, want the shipped hook diagnosed as enabled and missing", codexBefore) } - var fixOut bytes.Buffer - err = Runner{Stdout: &fixOut, WorkingDir: root, Executable: distributionFixtureExecutable(root)}.Run([]string{"config", "check", "--fix", "--json"}) - if err != nil { - t.Fatalf("config check --fix error = %v\n%s", err, fixOut.String()) - } - var after configCheckResult - if err := json.Unmarshal(fixOut.Bytes(), &after); err != nil { - t.Fatalf("Unmarshal(fix output) error = %v\n%s", err, fixOut.String()) - } + after := runConfigCheckJSON(t, root, true) codexAfter := findConfigTargetStatus(after.Targets, "codex") - if !after.OK || codexAfter.Status != "ok" || len(codexAfter.MissingHooks) != 0 { - t.Fatalf("after = %#v codexAfter = %#v, want current codex hooks", after, codexAfter) + if !after.OK || codexAfter.Status != "updated" { + t.Fatalf("after = %#v codexAfter = %#v, want the Codex hooks converged", after, codexAfter) } body, err := os.ReadFile(filepath.Join(home, ".codex", "hooks.json")) if err != nil { t.Fatalf("ReadFile(hooks.json) error = %v", err) } - if strings.Contains(string(body), "loaf check --hook") || !strings.Contains(string(body), "user codex hook") { - t.Fatalf("hooks.json = %s, want user hook preserved without Loaf handlers", body) + if !strings.Contains(string(body), "user codex hook") { + t.Fatalf("hooks.json = %s, want the operator's group preserved", body) + } + if !strings.Contains(string(body), codexJournalHookCommandSuffix) { + t.Fatalf("hooks.json = %s, want Loaf's session-start entry projected", body) } } @@ -121,7 +111,9 @@ func TestFixConfigTargetHooksRetainsProjectRootFromNestedWorkingDirectory(t *tes nestedWorkingDir := filepath.Join(projectRoot, "nested", "worktree") loafRoot := filepath.Join(root, "loaf") configDir := filepath.Join(root, "codex-home") - writeInstallFile(t, filepath.Join(loafRoot, "dist", "codex", ".codex", "hooks.json"), `{"hooks":{}}`+"\n") + t.Setenv("CODEX_HOME", configDir) + t.Setenv("LOAF_DB", filepath.Join(t.TempDir(), "loaf.sqlite")) + installTestHookDistribution(t, loafRoot, "codex") writeInstallFile(t, filepath.Join(configDir, "hooks.json"), `{"hooks":{}}`+"\n") if err := os.MkdirAll(nestedWorkingDir, 0o755); err != nil { t.Fatalf("MkdirAll(nested working directory) error = %v", err) @@ -139,7 +131,9 @@ func TestFixConfigTargetHooksRetainsProjectRootFromNestedWorkingDirectory(t *tes target := detectedInstallTool{key: "codex", configDir: configDir} var captured targetInstallOptions - status := fixConfigTargetHooksWithInstaller(resolvedRoot.Path(), loafRoot, target, configTargetStatus{Status: "stale"}, func(options targetInstallOptions) error { + hookState, releaseHookState := (Runner{}).hookStateForApply(resolvedRoot.Path()) + defer releaseHookState() + status := fixConfigTargetHooksWithInstaller(resolvedRoot.Path(), loafRoot, target, configTargetStatus{Status: "stale"}, hookState, func(options targetInstallOptions) error { captured = options return nil }) @@ -167,6 +161,9 @@ func TestRunnerConfigCheckFixFromNestedDirectoryRefusesProjectRootExecutable(t * } writeInstallFile(t, filepath.Join(projectRoot, ".agents", "loaf.json"), `{"version":"1.0.0","initialized":"2026-07-13T00:00:00Z","knowledge":{"local":["docs/knowledge","docs/decisions"],"staleness_threshold_days":30,"imports":[]},"integrations":{"linear":{"enabled":false},"serena":{"enabled":false}}}`+"\n") writeInstallFile(t, filepath.Join(projectRoot, "dist", "codex", ".codex", "hooks.json"), `{"hooks":{"SessionStart":[{"matcher":"startup|resume|clear|compact","hooks":[{"type":"command","command":"{{LOAF_EXECUTABLE}} journal context --from-hook --codex-hook","commandWindows":"{{LOAF_EXECUTABLE}} journal context --from-hook --codex-hook"}]}]}}`+"\n") + // The shipped Codex identity, placeholder and all: the refusal under test is + // what happens when reconciliation tries to render it. + installTestHookDistribution(t, projectRoot, "codex", testCodexHookCatalogSource()) writeInstallFile(t, filepath.Join(home, ".codex", loafInstallMarkerFile), "old\n") writeInstallFile(t, filepath.Join(home, ".codex", "hooks.json"), `{"hooks":{}}`+"\n") fakeLoaf := filepath.Join(projectRoot, "bin", "loaf") @@ -192,6 +189,191 @@ func TestRunnerConfigCheckFixFromNestedDirectoryRefusesProjectRootExecutable(t * assertInstallFile(t, filepath.Join(home, ".codex", "hooks.json"), `{"hooks":{}}`+"\n") } +// The five states, all at once, on one installed target. Nothing about the two +// foreign entries this fixture also carries appears anywhere in the answer. +func TestRunnerConfigCheckDiagnosesEveryHookState(t *testing.T) { + root, home := setupConfigCheckHookFixture(t) + path := hooksFixtureFilePath(home) + seedHooksFixtureForeignEntries(t, path) + + // disabled-and-correctly-absent: the verb recorded it and reprojected. + runInstallFixture(t, root, "hooks", "disable", "render-drift", "--target", "cursor") + // disabled-but-present: recorded, then the entry put back by hand. + runInstallFixture(t, root, "hooks", "disable", "validate-push", "--target", "cursor") + restoreHooksFixtureEntry(t, path, "beforeShellExecution", map[string]any{ + "command": "loaf check --hook validate-push", "matcher": "Bash", "loaf-managed": true, + }) + // enabled-but-stale: fail-closed enforcement weakened by hand. + weakenHooksFixtureEntry(t, path, "beforeShellExecution", "loaf check --hook security-audit") + // enabled-and-missing: deleted by hand, never recorded. + deleteHooksFixtureEntry(t, path, "beforeSubmitPrompt", "loaf check --hook artifact-names") + // enabled-and-in-sync: validate-commit, untouched since the install. + + result := runConfigCheckJSON(t, root, false) + cursor := findConfigTargetStatus(result.Targets, "cursor") + if result.OK || cursor.Status != "stale" { + t.Fatalf("result.OK = %v cursor = %#v, want the target reported as needing work", result.OK, cursor) + } + want := map[string]string{ + "render-drift": hookStateDisabledAbsent, + "validate-push": hookStateDisabledPresent, + "security-audit": hookStateEnabledStale, + "artifact-names": hookStateEnabledMissing, + "validate-commit": hookStateEnabledInSync, + } + got := map[string]string{} + for _, hook := range cursor.Hooks { + got[hook.HookID] = hook.State + } + if len(got) != len(want) { + t.Fatalf("diagnosed hooks = %#v, want exactly the catalog identities %#v", got, want) + } + for hookID, state := range want { + if got[hookID] != state { + t.Fatalf("%s = %q, want %q", hookID, got[hookID], state) + } + } + // The file also carries entries and a section Loaf does not own. Nothing in + // the answer knows they exist. + for _, foreign := range []string{"herdr", "afterFileEdit", "operator"} { + if body, _ := json.Marshal(result); strings.Contains(string(body), foreign) { + t.Fatalf("config check output names the foreign content %q:\n%s", foreign, body) + } + } + + // The operator's view of the same run. + text := stripANSI(runConfigCheckText(t, root)) + for _, line := range []string{ + " ! Cursor hooks need reconcile: security-audit (enabled but stale), artifact-names (enabled and missing)\n", + " ! Cursor hooks need reprojection: validate-push (disabled but present)\n", + " x cursor: hooks need reconcile: security-audit (enabled but stale), artifact-names (enabled and missing); hooks need reprojection: validate-push (disabled but present)\n", + } { + if !strings.Contains(text, line) { + t.Fatalf("config check text = %q, want the line %q", text, line) + } + } +} + +// A hook the operator disabled is healthy when it is absent — the state the old +// whole-file diagnosis could only read as a missing hook to be reinstated. +func TestRunnerConfigCheckReportsADisabledHookAsHealthilyAbsent(t *testing.T) { + root, _ := setupConfigCheckHookFixture(t) + runInstallFixture(t, root, "hooks", "disable", "render-drift", "--target", "cursor") + + result := runConfigCheckJSON(t, root, false) + cursor := findConfigTargetStatus(result.Targets, "cursor") + if !result.OK || cursor.Status != "ok" { + t.Fatalf("result.OK = %v cursor = %#v, want a disabled-and-absent hook to read healthy", result.OK, cursor) + } + for _, hook := range cursor.Hooks { + if hook.HookID == "render-drift" && hook.State != hookStateDisabledAbsent { + t.Fatalf("render-drift = %q, want %q", hook.State, hookStateDisabledAbsent) + } + } + if text := stripANSI(runConfigCheckText(t, root)); !strings.Contains(text, "✓ Cursor hooks current\n") { + t.Fatalf("config check text = %q, want the target reported current", text) + } +} + +// `--fix` converges through the reconciler and nothing else: the hook the +// operator deleted comes back, the one they disabled stays out, and their own +// entries are where they were. +func TestRunnerConfigCheckFixConvergesHooksThroughTheReconciler(t *testing.T) { + root, home := setupConfigCheckHookFixture(t) + path := hooksFixtureFilePath(home) + seedHooksFixtureForeignEntries(t, path) + runInstallFixture(t, root, "hooks", "disable", "render-drift", "--target", "cursor") + deleteHooksFixtureEntry(t, path, "beforeSubmitPrompt", "loaf check --hook artifact-names") + + before := runConfigCheckJSON(t, root, false) + if before.OK { + t.Fatalf("before = %#v, want the deleted hook reported", before) + } + + after := runConfigCheckJSON(t, root, true) + cursor := findConfigTargetStatus(after.Targets, "cursor") + if !after.OK || !after.Fixed || cursor.Status != "updated" { + t.Fatalf("after = %#v cursor = %#v, want the target converged by --fix", after, cursor) + } + body := string(readFileBytes(t, path)) + if !strings.Contains(body, "loaf check --hook artifact-names") { + t.Fatalf("hooks.json = %s, want the deleted hook restored", body) + } + if strings.Contains(body, "loaf check --hook render-drift") { + t.Fatalf("hooks.json = %s, want the disabled hook to stay out", body) + } + if !strings.Contains(body, "bash /opt/herdr/agent-state.sh beforeSubmitPrompt") { + t.Fatalf("hooks.json = %s, want the foreign entries preserved", body) + } +} + +// A plain check reads. The state database is where enablement lives, and a +// diagnosis that created one would be a write this command does not promise. +func TestRunnerConfigCheckLeavesTheStateDatabaseUntouched(t *testing.T) { + root, home := setupConfigCheckHookFixture(t) + database := filepath.Join(t.TempDir(), "absent", "loaf.sqlite") + t.Setenv("LOAF_DB", database) + deleteHooksFixtureEntry(t, hooksFixtureFilePath(home), "beforeSubmitPrompt", "loaf check --hook artifact-names") + + result := runConfigCheckJSON(t, root, false) + + if result.OK { + t.Fatalf("result = %#v, want the missing hook reported without a database", result) + } + if _, err := os.Stat(database); !os.IsNotExist(err) { + t.Fatalf("Stat(%s) = %v, want a read-only check to have created nothing", database, err) + } + // With no records at all every catalog hook reads enabled, so the file is + // the only thing that decides. + cursor := findConfigTargetStatus(result.Targets, "cursor") + for _, hook := range cursor.Hooks { + if hook.HookID == "artifact-names" && hook.State != hookStateEnabledMissing { + t.Fatalf("artifact-names = %q, want %q", hook.State, hookStateEnabledMissing) + } + } +} + +// setupConfigCheckHookFixture is the hooks fixture plus the project config file +// `loaf config check` validates alongside the harnesses, so a target verdict is +// never confused with a config-file one. +func setupConfigCheckHookFixture(t *testing.T) (string, string) { + t.Helper() + sources := append(hooksFixtureCursorSources(), hooksFixtureCursorSource("beforeShellExecution", "security-audit")) + root, home := setupHooksFixture(t, sources...) + writeInstallFile(t, filepath.Join(root, ".agents", "loaf.json"), `{"version":"1.0.0","initialized":"2026-08-08T00:00:00Z","knowledge":{"local":["docs/knowledge","docs/decisions"],"staleness_threshold_days":30,"imports":[]},"integrations":{"linear":{"enabled":false},"serena":{"enabled":false},"github":{"account":"canary"}}}`+"\n") + return root, home +} + +func runConfigCheckJSON(t *testing.T, root string, fix bool) configCheckResult { + t.Helper() + args := []string{"config", "check", "--json"} + if fix { + args = []string{"config", "check", "--fix", "--json"} + } + var stdout bytes.Buffer + err := Runner{Stdout: &stdout, WorkingDir: root, Executable: distributionFixtureExecutable(root)}.Run(args) + var exitErr ExitError + if err != nil && (!errors.As(err, &exitErr) || exitErr.Code != 2) { + t.Fatalf("config check error = %v\n%s", err, stdout.String()) + } + var result configCheckResult + if decodeErr := json.Unmarshal(stdout.Bytes(), &result); decodeErr != nil { + t.Fatalf("Unmarshal(config check output) error = %v\n%s", decodeErr, stdout.String()) + } + return result +} + +func runConfigCheckText(t *testing.T, root string) string { + t.Helper() + var stdout bytes.Buffer + err := Runner{Stdout: &stdout, WorkingDir: root, Executable: distributionFixtureExecutable(root)}.Run([]string{"config", "check"}) + var exitErr ExitError + if err != nil && (!errors.As(err, &exitErr) || exitErr.Code != 2) { + t.Fatalf("config check error = %v\n%s", err, stdout.String()) + } + return stdout.String() +} + func TestRunnerConfigHelp(t *testing.T) { var stdout bytes.Buffer if err := (Runner{Stdout: &stdout, WorkingDir: t.TempDir()}).Run([]string{"config", "--help"}); err != nil { diff --git a/internal/cli/hook_catalog.go b/internal/cli/hook_catalog.go new file mode 100644 index 000000000..df816b6c3 --- /dev/null +++ b/internal/cli/hook_catalog.go @@ -0,0 +1,449 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// hookCatalogFile is the per-target catalog the build emits beside the adapter +// artifacts. It is the single identity authority: reconciliation pairing, +// absorption cohorts, the hooks verb surface, and config diagnosis all resolve +// (target, event, hook_id) through it, with no access to the source repository. +const hookCatalogFile = ".loaf-hook-catalog.json" + +const hookCatalogVersion = 1 + +// hookExecutableSentinel canonicalizes whichever Loaf-executable form an entry +// carries — the build-time template, a shell-quoted trusted absolute path, or +// the bare `loaf` first token — so one signature covers all three. It reuses +// the install-time placeholder, which the build's placeholder lint already +// permits in generated artifacts. +const hookExecutableSentinel = codexJournalExecutablePlaceholder + +type hookCatalog struct { + Version int `json:"version"` + Target string `json:"target"` + PackageVersion string `json:"package_version"` + Entries []hookCatalogEntry `json:"entries"` + Cohorts []hookCatalogCohort `json:"cohorts"` +} + +// hookCatalogEntry is one identity plus everything recognition and pairing +// need. Token sequences are stored exactly as authored — `$HOME/...` stays +// `$HOME/...` — because the runtime normalizer expands both the catalog side +// and the installed side against the live environment. A catalog built on one +// machine therefore pairs correctly on another. +type hookCatalogEntry struct { + Event string `json:"event"` + HookID string `json:"hook_id"` + Type string `json:"type"` + Template json.RawMessage `json:"template"` + Signatures [][]string `json:"signatures,omitempty"` + Stems [][]string `json:"stems,omitempty"` + Prompt string `json:"prompt,omitempty"` +} + +// hookCatalogCohort enumerates the hook IDs one released version shipped for a +// target. Absorption reads it to bound what a prior install could have +// projected. Cohorts are frozen per version: a catalog that grows new hooks +// never widens a recorded cohort. +type hookCatalogCohort struct { + Version string `json:"version"` + HookIDs []string `json:"hook_ids"` +} + +// hookCatalogGenerationHookIDs is what the generation this migration absorbs +// actually shipped: 17 Cursor entries and one Codex entry. +var hookCatalogGenerationHookIDs = map[string][]string{ + "cursor": { + "artifact-body-write", + "artifact-names", + "check-" + "sec" + "rets", + "detect-linear-magic", + "ephemeral-provenance", + "generate-task-board", + "github-account", + "kb-staleness-nudge", + "render-drift", + "security-audit", + "session-start-loaf", + "validate-commit", + "validate-push", + "workflow-post-merge", + "workflow-pre-merge", + "workflow-pre-pr", + "workflow-pre-push", + }, + "codex": {"session-start-loaf"}, +} + +// hookCatalogPreResetVersions are the releases that shipped that same +// generation under the version line retired at 0.2.20, when `2.0.0-alpha.N` +// was renumbered to `0.2.N`. An installed manifest written by one of them is +// still on disk — the drift refusal this Change removes is exactly what stopped +// those manifests from being rewritten — so absorption has to recognize the old +// spelling or read a known install as an unknown one. +// +// The list is an enumeration, not a pattern: every entry was checked against +// the `dist/cursor/hooks.json` and `dist/codex/.codex/hooks.json` committed at +// that release, and each carries precisely the cohorts above. It stops at +// alpha.14 because alpha.13 and earlier shipped 16 Cursor entries — the +// `artifact-names` hook arrives at alpha.14 — and a cohort that claimed a hook +// those installs never had would record it disabled on the strength of an +// absence that means nothing. Everything outside this list keeps the +// unknown-version rule and absorbs nothing. +// +// Closed by construction: the prerelease scheme is retired, so no future +// release can enter the family. +var hookCatalogPreResetVersions = []string{ + "2.0.0-alpha.14", + "2.0.0-alpha.15", + "2.0.0-alpha.16", + "2.0.0-alpha.17", + "2.0.0-alpha.18", + "2.0.0-alpha.19", +} + +// hookCatalogFrozenCohorts record what each released version actually shipped, +// pinned by test against captured build output. The current spelling is last +// because the no-manifest fallback reads the final record as the generation +// immediately preceding entry-level reconciliation. +var hookCatalogFrozenCohorts = map[string][]hookCatalogCohort{ + "cursor": hookCatalogGenerationCohorts("cursor"), + "codex": hookCatalogGenerationCohorts("codex"), +} + +func hookCatalogGenerationCohorts(target string) []hookCatalogCohort { + hookIDs := hookCatalogGenerationHookIDs[target] + versions := append(append([]string{}, hookCatalogPreResetVersions...), "0.2.20") + cohorts := make([]hookCatalogCohort, 0, len(versions)) + for _, version := range versions { + cohorts = append(cohorts, hookCatalogCohort{Version: version, HookIDs: hookIDs}) + } + return cohorts +} + +// hookCatalogHistoricalCommands are command shapes earlier releases shipped for +// a hook ID that still exists, keyed "target/hook_id". They let an installed +// entry from an older generation pair to its ID instead of reading as a retired +// generation. Closed by construction: every line is a shape a release actually +// shipped, never a guess at what one might have. +var hookCatalogHistoricalCommands = map[string][]string{ + "cursor/session-start-loaf": { + "loaf journal context", + "loaf journal context --from-hook", + }, + "codex/session-start-loaf": { + "loaf journal context", + "loaf journal context --from-hook", + }, +} + +// hookCatalogVariantFlags are trailing flags the builders append per target or +// per blocking mode. Identity stems exclude them so an entry whose enforcement +// was weakened by hand — `--advisory` added to a fail-closed check — still +// pairs to its hook ID and converges instead of orphaning as foreign. +var hookCatalogVariantFlags = map[string]bool{ + "--advisory": true, + "--claude-code": true, + "--codex-hook": true, + "--cursor-hook": true, + "--opencode-hook": true, +} + +// hookCatalogSource is one desired entry as a target builder produced it. +type hookCatalogSource struct { + event string + hookID string + typeName string + command string + prompt string + template any +} + +func newHookCatalog(target string, packageVersion string, sources []hookCatalogSource) (hookCatalog, error) { + catalog := hookCatalog{ + Version: hookCatalogVersion, + Target: target, + PackageVersion: packageVersion, + Entries: make([]hookCatalogEntry, 0, len(sources)), + Cohorts: []hookCatalogCohort{}, + } + for _, cohort := range hookCatalogFrozenCohorts[target] { + catalog.Cohorts = append(catalog.Cohorts, hookCatalogCohort{ + Version: cohort.Version, + HookIDs: append([]string{}, cohort.HookIDs...), + }) + } + for _, source := range sources { + template, err := json.Marshal(source.template) + if err != nil { + return hookCatalog{}, fmt.Errorf("encode %s hook catalog template for %s: %w", target, source.hookID, err) + } + entry := hookCatalogEntry{ + Event: source.event, + HookID: source.hookID, + Type: source.typeName, + Template: template, + Prompt: source.prompt, + } + if source.typeName != "prompt" { + for _, command := range append([]string{source.command}, hookCatalogHistoricalCommands[target+"/"+source.hookID]...) { + tokens, ok := hookCommandTokens(command) + if !ok || len(tokens) == 0 { + return hookCatalog{}, fmt.Errorf("%s hook %s has an unparseable command %q", target, source.hookID, command) + } + entry.Signatures = appendHookTokenSequence(entry.Signatures, tokens) + } + if stem := hookCatalogStem(source.command); len(stem) > 0 { + entry.Stems = appendHookTokenSequence(entry.Stems, stem) + } + } + catalog.Entries = append(catalog.Entries, entry) + } + if err := validateHookCatalog(catalog); err != nil { + return hookCatalog{}, err + } + return catalog, nil +} + +// hookCatalogStem derives the identity stem embedded in a desired command: the +// argument tail for a Loaf-executable invocation, or the Loaf-managed file a +// path-backed entry invokes. The `check` verb collapses out of enforcement +// stems so that both `loaf check --hook X` and `loaf check --hook X --advisory` +// carry the same identity. +func hookCatalogStem(command string) []string { + tokens, ok := hookCommandTokens(command) + if !ok || len(tokens) == 0 { + return nil + } + if !isLoafExecutableCatalogToken(tokens[0]) { + for _, token := range tokens { + if looksLikeHookPathToken(token) { + return []string{token} + } + } + return nil + } + tail := tokens[1:] + for len(tail) > 0 && hookCatalogVariantFlags[tail[len(tail)-1]] { + tail = tail[:len(tail)-1] + } + if len(tail) > 2 && tail[0] == "check" && tail[1] == "--hook" { + tail = tail[1:] + } + if len(tail) == 0 { + return nil + } + return tail +} + +// isLoafExecutableCatalogToken recognizes the two executable forms a built +// catalog can carry. The trusted-absolute-path form only exists after install, +// so it is resolved at recognition time, not here. +func isLoafExecutableCatalogToken(token string) bool { + return token == "loaf" || token == codexJournalExecutablePlaceholder +} + +func appendHookTokenSequence(sequences [][]string, tokens []string) [][]string { + for _, existing := range sequences { + if hookTokensEqual(existing, tokens) { + return sequences + } + } + return append(sequences, tokens) +} + +// validateHookCatalog proves pairing cannot be ambiguous. The catalog is +// non-empty, identities are unique, every template is an entry the target can +// actually carry, every command entry has a signature no other identity claims, +// and no stem occurs inside another entry's stem or signature — so a match +// resolves to at most one hook ID by construction rather than by a runtime +// tiebreak. +// +// Emptiness is a failure rather than a degenerate case: an empty catalog claims +// this version ships no hooks, which would read every installed Loaf entry as a +// retired generation and remove all of them while projecting nothing. +func validateHookCatalog(catalog hookCatalog) error { + if len(catalog.Entries) == 0 { + return fmt.Errorf("%s hook catalog declares no entries", catalog.Target) + } + if err := validateHookCatalogCohorts(catalog); err != nil { + return err + } + seen := map[string]bool{} + signatures := map[string]string{} + for _, entry := range catalog.Entries { + if entry.Event == "" || entry.HookID == "" { + return fmt.Errorf("%s hook catalog entry is missing an event or hook id", catalog.Target) + } + key := entry.Event + "/" + entry.HookID + if seen[key] { + return fmt.Errorf("%s hook catalog declares %s twice", catalog.Target, key) + } + seen[key] = true + if err := validateHookCatalogTemplate(catalog.Target, key, entry); err != nil { + return err + } + if entry.Type != "prompt" && len(entry.Signatures) == 0 { + return fmt.Errorf("%s hook catalog entry %s has no command signature", catalog.Target, key) + } + for _, signature := range entry.Signatures { + joined := strings.Join(signature, " ") + if owner, claimed := signatures[joined]; claimed && owner != entry.HookID { + return fmt.Errorf("%s hook catalog signature %q belongs to both %s and %s", catalog.Target, joined, owner, entry.HookID) + } + signatures[joined] = entry.HookID + } + } + for _, entry := range catalog.Entries { + for _, stem := range entry.Stems { + for _, other := range catalog.Entries { + if other.HookID == entry.HookID && other.Event == entry.Event { + continue + } + for _, otherStem := range other.Stems { + if containsHookTokenSequence(otherStem, stem) { + return fmt.Errorf("%s hook catalog stem %q for %s also matches %s", catalog.Target, strings.Join(stem, " "), entry.HookID, other.HookID) + } + } + for _, signature := range other.Signatures { + if containsHookTokenSequence(signature, stem) { + return fmt.Errorf("%s hook catalog stem %q for %s also matches the %s signature", catalog.Target, strings.Join(stem, " "), entry.HookID, other.HookID) + } + } + } + } + } + return nil +} + +// validateHookCatalogTemplate rejects a desired entry the target could never +// carry. The template is what reconciliation publishes, so anything that is not +// an entry object — a null, an array, a bare string — would reach the file +// before post-verify could notice, and the recovery from that is worse than +// never writing it. +func validateHookCatalogTemplate(target string, key string, entry hookCatalogEntry) error { + value, err := decodeHookJSONValue(entry.Template) + if err != nil { + return fmt.Errorf("%s hook catalog entry %s has an unreadable template: %w", target, key, err) + } + object, ok := value.(map[string]any) + if !ok { + return fmt.Errorf("%s hook catalog entry %s has a template that is not an entry object", target, key) + } + if target != "codex" { + return nil + } + // Codex nests exactly one command handler inside a matcher group. A group + // carrying anything else is not a shape recognition would ever claim back, + // so publishing it would orphan the entry on the next reconcile. + if !isValidCodexMatcherGroup(object) { + return fmt.Errorf("codex hook catalog entry %s has a template that is not a valid matcher group", key) + } + handlers, ok := object["hooks"].([]any) + if !ok || len(handlers) != 1 { + return fmt.Errorf("codex hook catalog entry %s must carry exactly one command handler", key) + } + handler, ok := handlers[0].(map[string]any) + if !ok { + return fmt.Errorf("codex hook catalog entry %s has a handler that is not an object", key) + } + if _, ok := handler["command"].(string); !ok { + return fmt.Errorf("codex hook catalog entry %s has a handler without a command", key) + } + return nil +} + +// validateHookCatalogCohorts keeps the absorption bound readable: one record per +// version, no repeated ids inside it. Cohort ids are deliberately not required +// to exist among the current entries — a hook a prior version shipped and this +// one retired still bounds what that version could have projected. +func validateHookCatalogCohorts(catalog hookCatalog) error { + versions := map[string]bool{} + for _, cohort := range catalog.Cohorts { + if strings.TrimSpace(cohort.Version) == "" { + return fmt.Errorf("%s hook catalog declares a cohort with no version", catalog.Target) + } + if versions[cohort.Version] { + return fmt.Errorf("%s hook catalog declares the %s cohort twice", catalog.Target, cohort.Version) + } + versions[cohort.Version] = true + if len(cohort.HookIDs) == 0 { + return fmt.Errorf("%s hook catalog %s cohort names no hook ids", catalog.Target, cohort.Version) + } + hookIDs := map[string]bool{} + for _, hookID := range cohort.HookIDs { + if strings.TrimSpace(hookID) == "" { + return fmt.Errorf("%s hook catalog %s cohort names an empty hook id", catalog.Target, cohort.Version) + } + if hookIDs[hookID] { + return fmt.Errorf("%s hook catalog %s cohort names %s twice", catalog.Target, cohort.Version, hookID) + } + hookIDs[hookID] = true + } + } + return nil +} + +func (c hookCatalog) entriesForEvent(event string) []hookCatalogEntry { + var entries []hookCatalogEntry + for _, entry := range c.Entries { + if entry.Event == event { + entries = append(entries, entry) + } + } + return entries +} + +// cohortHookIDs returns the hook IDs the given version shipped. An unknown +// version has no recorded cohort: callers decide what that means rather than +// inheriting a guess from a neighbouring version. +func (c hookCatalog) cohortHookIDs(version string) ([]string, bool) { + for _, cohort := range c.Cohorts { + if cohort.Version == version { + return cohort.HookIDs, true + } + } + return nil, false +} + +func writeHookCatalog(outputDir string, catalog hookCatalog) error { + body, err := json.MarshalIndent(catalog, "", " ") + if err != nil { + return err + } + body = append(body, '\n') + if err := os.MkdirAll(outputDir, 0o755); err != nil { + return err + } + return os.WriteFile(filepath.Join(outputDir, hookCatalogFile), body, 0o644) +} + +// readHookCatalog loads a target's catalog from its built distribution. It +// fails closed: a missing, non-regular, or malformed catalog is an error, never +// an empty identity authority that would make every installed entry foreign. +func readHookCatalog(distDir string) (hookCatalog, error) { + path := filepath.Join(distDir, hookCatalogFile) + body, err := readRegularFileNoFollow(path, projectFileReadLimit) + if err != nil { + return hookCatalog{}, fmt.Errorf("read hook catalog %s: %w", path, err) + } + if err := validateJSONNoDuplicateKeys(body); err != nil { + return hookCatalog{}, fmt.Errorf("read hook catalog %s: %w", path, err) + } + var catalog hookCatalog + if err := json.Unmarshal(body, &catalog); err != nil { + return hookCatalog{}, fmt.Errorf("parse hook catalog %s: %w", path, err) + } + if catalog.Version != hookCatalogVersion { + return hookCatalog{}, fmt.Errorf("hook catalog %s has unsupported version %d", path, catalog.Version) + } + if err := validateHookCatalog(catalog); err != nil { + return hookCatalog{}, err + } + return catalog, nil +} diff --git a/internal/cli/hook_catalog_test.go b/internal/cli/hook_catalog_test.go new file mode 100644 index 000000000..fb45b4cba --- /dev/null +++ b/internal/cli/hook_catalog_test.go @@ -0,0 +1,328 @@ +package cli + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" +) + +func TestHookCatalogIsEmittedForCursorAndCodexBuilds(t *testing.T) { + root := setupBuildCommandLoafRoot(t) + seedNativeCodexBuildFixture(t, root) + seedNativeCursorBuildFixture(t, root) + var stdout bytes.Buffer + + for _, target := range []string{"cursor", "codex"} { + if err := (Runner{Stdout: &stdout, WorkingDir: root}).Run([]string{"build", "--target", target}); err != nil { + t.Fatalf("build --target %s error = %v\n%s", target, err, stdout.String()) + } + } + + cursor, err := readHookCatalog(filepath.Join(root, "dist", "cursor")) + if err != nil { + t.Fatalf("readHookCatalog(cursor) error = %v", err) + } + if cursor.Target != "cursor" || cursor.PackageVersion != "9.8.7-test.1" || cursor.Version != hookCatalogVersion { + t.Fatalf("cursor catalog metadata = %#v", cursor) + } + events := map[string]string{} + for _, entry := range cursor.Entries { + events[entry.HookID] = entry.Event + } + for hookID, want := range map[string]string{ + "check-secrets": "preToolUse", + "workflow-pre-merge": "preToolUse", + "generate-task-board": "postToolUse", + "kb-staleness-nudge": "postToolUse", + "session-start-loaf": "sessionStart", + } { + if events[hookID] != want { + t.Fatalf("cursor catalog event for %s = %q, want %q", hookID, events[hookID], want) + } + } + + codex, err := readHookCatalog(filepath.Join(root, "dist", "codex")) + if err != nil { + t.Fatalf("readHookCatalog(codex) error = %v", err) + } + if len(codex.Entries) != 1 || codex.Entries[0].Event != "SessionStart" || codex.Entries[0].HookID != "session-start-loaf" { + t.Fatalf("codex catalog entries = %#v, want the single SessionStart identity", codex.Entries) + } + if !strings.Contains(string(codex.Entries[0].Template), codexJournalExecutablePlaceholder) { + t.Fatalf("codex catalog template = %s, want the install-time executable placeholder", codex.Entries[0].Template) + } +} + +// The catalog template is the desired entry: whatever the build wrote into +// hooks.json must be byte-for-byte what reconciliation converges toward. +func TestHookCatalogTemplatesMatchTheBuiltHooksFile(t *testing.T) { + root := setupBuildCommandLoafRoot(t) + seedNativeCodexBuildFixture(t, root) + seedNativeCursorBuildFixture(t, root) + var stdout bytes.Buffer + + if err := (Runner{Stdout: &stdout, WorkingDir: root}).Run([]string{"build", "--target", "cursor"}); err != nil { + t.Fatalf("build --target cursor error = %v\n%s", err, stdout.String()) + } + + catalog, err := readHookCatalog(filepath.Join(root, "dist", "cursor")) + if err != nil { + t.Fatalf("readHookCatalog error = %v", err) + } + body, err := os.ReadFile(filepath.Join(root, "dist", "cursor", "hooks.json")) + if err != nil { + t.Fatalf("ReadFile(hooks.json) error = %v", err) + } + built := testHookEventEntries(t, body) + total := 0 + for _, entries := range built { + total += len(entries) + } + if total != len(catalog.Entries) { + t.Fatalf("hooks.json has %d entries, catalog has %d", total, len(catalog.Entries)) + } + for _, entry := range catalog.Entries { + template, err := decodeHookJSONValue(entry.Template) + if err != nil { + t.Fatalf("decode template for %s error = %v", entry.HookID, err) + } + found := false + for _, candidate := range built[entry.Event] { + value, err := canonicalHookValue(candidate) + if err != nil { + t.Fatalf("canonicalize built entry error = %v", err) + } + if reflect.DeepEqual(template, value) { + found = true + break + } + } + if !found { + t.Fatalf("catalog template for %s/%s has no matching entry in hooks.json", entry.Event, entry.HookID) + } + } +} + +// The cohort is a frozen enumeration, not a re-derivation of today's config. +// It is pinned against captured 0.2.20 build output: every Loaf entry that +// release shipped pairs to a cohort ID, and the cohort names nothing else. +func TestHookCatalogCohortPinsWhatZeroTwoTwentyShipped(t *testing.T) { + for _, testCase := range []struct { + target string + fixture string + want int + }{ + {target: "cursor", fixture: "cursor-hooks-0.2.20.json", want: 17}, + {target: "codex", fixture: "codex-hooks-0.2.20.json", want: 1}, + } { + t.Run(testCase.target, func(t *testing.T) { + catalog := testRepoHookCatalog(t, testCase.target) + cohort, ok := catalog.cohortHookIDs("0.2.20") + if !ok { + t.Fatalf("%s catalog has no 0.2.20 cohort", testCase.target) + } + if len(cohort) != testCase.want { + t.Fatalf("%s 0.2.20 cohort has %d hook ids, want %d", testCase.target, len(cohort), testCase.want) + } + recognition := testHookRecognition(t, testCase.target, catalog) + shipped := []string{} + for event, entries := range testHookEventEntries(t, testHookFixture(t, testCase.fixture)) { + outcome, err := pairHookEventEntries(recognition, event, entries) + if err != nil { + t.Fatalf("pairHookEventEntries(%s) error = %v", event, err) + } + if len(outcome.foreign) != 0 || len(outcome.retired) != 0 || len(outcome.duplicates) != 0 { + t.Fatalf("%s %s: shipped 0.2.20 output must pair entirely, got %#v", testCase.target, event, outcome) + } + for _, pairing := range outcome.paired { + shipped = append(shipped, pairing.hookID) + } + } + sort.Strings(shipped) + want := append([]string{}, cohort...) + sort.Strings(want) + if !reflect.DeepEqual(shipped, want) { + t.Fatalf("%s 0.2.20 shipped %v, cohort records %v", testCase.target, shipped, want) + } + }) + } +} + +func TestHookCatalogRejectsStemsThatOverlapAnotherIdentity(t *testing.T) { + _, err := newHookCatalog("cursor", "test", []hookCatalogSource{ + {event: "preToolUse", hookID: "check", typeName: "command", command: "loaf check --hook check", template: map[string]any{"command": "loaf check --hook check"}}, + {event: "preToolUse", hookID: "wrapper", typeName: "command", command: "loaf run --hook check --wrap", template: map[string]any{"command": "loaf run --hook check --wrap"}}, + }) + if err == nil || !strings.Contains(err.Error(), "also matches") { + t.Fatalf("newHookCatalog error = %v, want an overlapping-stem refusal", err) + } +} + +func TestHookCatalogRejectsStemsThatOverlapAnotherSignature(t *testing.T) { + _, err := newHookCatalog("cursor", "test", []hookCatalogSource{ + {event: "preToolUse", hookID: "narrow", typeName: "command", command: "loaf task refresh", template: map[string]any{"command": "loaf task refresh"}}, + {event: "postToolUse", hookID: "wide", typeName: "command", command: "loaf task refresh --all", template: map[string]any{"command": "loaf task refresh --all"}}, + }) + if err == nil || !strings.Contains(err.Error(), "also matches") { + t.Fatalf("newHookCatalog error = %v, want an overlapping-signature refusal", err) + } +} + +func TestHookCatalogRejectsDuplicateIdentity(t *testing.T) { + _, err := newHookCatalog("cursor", "test", []hookCatalogSource{ + {event: "preToolUse", hookID: "check-secrets", typeName: "command", command: "loaf check --hook check-secrets", template: map[string]any{"command": "loaf check --hook check-secrets"}}, + {event: "preToolUse", hookID: "check-secrets", typeName: "command", command: "loaf check --hook check-secrets --advisory", template: map[string]any{"command": "x"}}, + }) + if err == nil || !strings.Contains(err.Error(), "twice") { + t.Fatalf("newHookCatalog error = %v, want a duplicate-identity refusal", err) + } +} + +// An empty catalog is not a catalog with nothing to say — it is a claim that +// this version ships no hooks, which would read every installed Loaf entry as a +// retired generation and remove all of them while projecting nothing back. +func TestHookCatalogRejectsAnEmptyCatalog(t *testing.T) { + if _, err := newHookCatalog("cursor", "test", nil); err == nil || !strings.Contains(err.Error(), "no entries") { + t.Fatalf("newHookCatalog(nil) error = %v, want an empty-catalog refusal", err) + } + + empty := t.TempDir() + writeFile(t, filepath.Join(empty, hookCatalogFile), `{"version":1,"target":"cursor","package_version":"test","entries":[],"cohorts":[]}`) + if _, err := readHookCatalog(empty); err == nil || !strings.Contains(err.Error(), "no entries") { + t.Fatalf("readHookCatalog(empty) error = %v, want an empty-catalog refusal", err) + } +} + +// The template is what reconciliation publishes. Anything that is not an entry +// the target can carry would reach the file before post-verify could notice. +func TestHookCatalogRejectsTemplatesThatAreNotEntries(t *testing.T) { + for name, template := range map[string]any{ + "null": nil, + "array": []any{map[string]any{"command": "loaf task refresh"}}, + "scalar": "loaf task refresh", + } { + t.Run(name, func(t *testing.T) { + _, err := newHookCatalog("cursor", "test", []hookCatalogSource{{ + event: "postToolUse", hookID: "generate-task-board", typeName: "command", + command: "loaf task refresh", template: template, + }}) + if err == nil || !strings.Contains(err.Error(), "not an entry object") { + t.Fatalf("newHookCatalog error = %v, want a non-object template refusal", err) + } + }) + } + + for name, template := range map[string]string{ + "two handlers": `{"matcher":"startup","hooks":[{"type":"command","command":"loaf journal context --from-hook --codex-hook"},{"type":"command","command":"other"}]}`, + "no handler": `{"matcher":"startup","hooks":[]}`, + "prompt handler": `{"matcher":"startup","hooks":[{"type":"prompt"}]}`, + } { + t.Run("codex "+name, func(t *testing.T) { + dist := t.TempDir() + writeFile(t, filepath.Join(dist, hookCatalogFile), `{"version":1,"target":"codex","package_version":"test","entries":[{"event":"SessionStart","hook_id":"session-start-loaf","type":"command","template":`+template+`,"signatures":[["loaf","journal","context","--from-hook","--codex-hook"]]}],"cohorts":[]}`) + if _, err := readHookCatalog(dist); err == nil || !strings.Contains(err.Error(), "codex hook catalog entry") { + t.Fatalf("readHookCatalog error = %v, want a matcher-group refusal", err) + } + }) + } +} + +// Two identities that answer to the same command are ambiguous by construction: +// which one a mutated entry pairs to would come down to catalog order. +func TestHookCatalogRejectsASignatureClaimedByTwoIdentities(t *testing.T) { + _, err := newHookCatalog("cursor", "test", []hookCatalogSource{ + {event: "preToolUse", hookID: "first", typeName: "command", command: "loaf check --hook shared", template: map[string]any{"command": "loaf check --hook shared"}}, + {event: "postToolUse", hookID: "second", typeName: "command", command: "loaf check --hook shared", template: map[string]any{"command": "loaf check --hook shared", "matcher": "Bash"}}, + }) + if err == nil || !strings.Contains(err.Error(), "belongs to both") { + t.Fatalf("newHookCatalog error = %v, want a duplicate-signature refusal", err) + } +} + +func TestHookCatalogRejectsInconsistentCohorts(t *testing.T) { + dist := t.TempDir() + entry := `{"event":"postToolUse","hook_id":"generate-task-board","type":"command","template":{"command":"loaf task refresh"},"signatures":[["loaf","task","refresh"]]}` + for name, cohorts := range map[string]string{ + "duplicate version": `[{"version":"0.2.20","hook_ids":["a"]},{"version":"0.2.20","hook_ids":["b"]}]`, + "empty version": `[{"version":"","hook_ids":["a"]}]`, + "no hook ids": `[{"version":"0.2.20","hook_ids":[]}]`, + "duplicate hook id": `[{"version":"0.2.20","hook_ids":["a","a"]}]`, + } { + t.Run(name, func(t *testing.T) { + writeFile(t, filepath.Join(dist, hookCatalogFile), `{"version":1,"target":"cursor","package_version":"test","entries":[`+entry+`],"cohorts":`+cohorts+`}`) + if _, err := readHookCatalog(dist); err == nil || !strings.Contains(err.Error(), "cohort") { + t.Fatalf("readHookCatalog error = %v, want a cohort refusal", err) + } + }) + } +} + +// A catalog that cannot be read is an error, never an empty identity authority: +// an empty catalog would silently make every installed Loaf entry foreign. +func TestReadHookCatalogFailsClosed(t *testing.T) { + missing := t.TempDir() + if _, err := readHookCatalog(missing); err == nil { + t.Fatal("readHookCatalog(missing) error = nil, want failure") + } + + malformed := t.TempDir() + writeFile(t, filepath.Join(malformed, hookCatalogFile), "{ not json") + if _, err := readHookCatalog(malformed); err == nil { + t.Fatal("readHookCatalog(malformed) error = nil, want failure") + } + + future := t.TempDir() + writeFile(t, filepath.Join(future, hookCatalogFile), `{"version":99,"target":"cursor","entries":[],"cohorts":[]}`) + if _, err := readHookCatalog(future); err == nil || !strings.Contains(err.Error(), "unsupported version") { + t.Fatalf("readHookCatalog(future) error = %v, want an unsupported-version refusal", err) + } +} + +func testRepoHookCatalog(t *testing.T, target string) hookCatalog { + t.Helper() + dist := t.TempDir() + switch target { + case "cursor": + if err := generateNativeCursorHookCatalog(filepath.Join(testRepositoryRoot(t), "config", "hooks.yaml"), dist, "test"); err != nil { + t.Fatalf("generateNativeCursorHookCatalog error = %v", err) + } + case "codex": + if err := generateNativeCodexHookCatalog(dist, "test"); err != nil { + t.Fatalf("generateNativeCodexHookCatalog error = %v", err) + } + default: + t.Fatalf("no hook catalog builder for target %q", target) + } + catalog, err := readHookCatalog(dist) + if err != nil { + t.Fatalf("readHookCatalog(%s) error = %v", target, err) + } + return catalog +} + +func testHookFixture(t *testing.T, name string) []byte { + t.Helper() + body, err := os.ReadFile(filepath.Join(testRepositoryRoot(t), "internal", "cli", "testdata", "hooks", name)) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", name, err) + } + return body +} + +func testHookEventEntries(t *testing.T, body []byte) map[string][]map[string]any { + t.Helper() + var file struct { + Hooks map[string][]map[string]any `json:"hooks"` + } + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + if err := decoder.Decode(&file); err != nil { + t.Fatalf("decode hooks file error = %v", err) + } + return file.Hooks +} diff --git a/internal/cli/hook_file.go b/internal/cli/hook_file.go new file mode 100644 index 000000000..1893fa81d --- /dev/null +++ b/internal/cli/hook_file.go @@ -0,0 +1,322 @@ +package cli + +import ( + "bytes" + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" +) + +// hook_file.go is the parsed form of a shared hooks file. Loaf owns some of its +// entries and none of the rest, so the document keeps everything it did not +// write exactly as it found it: top-level fields, event sections, and every +// foreign entry survive as raw JSON values in their original order. Only the +// entries reconciliation names are ever replaced, and the file is republished +// by rebuilding the same ordered document around them. +// +// The promise is JSON-value identity and relative order, not byte identity: +// re-serialization normalizes whitespace, which is what lets one writer own a +// file it did not format. + +const hookFileEventsField = "hooks" + +const hookFileDefaultMode fs.FileMode = 0o644 + +// hookFile is one hooks.json as read from disk. An absent file is a valid +// document with no fields, so the caller that adds the first entry does not +// need a separate creation path. +type hookFile struct { + path string + exists bool + mode fs.FileMode + raw []byte + order []string + fields map[string]json.RawMessage + events []string + entries map[string][]json.RawMessage +} + +// readHookFile parses a hooks file, enforcing the integrity preconditions that +// make reconciliation safe: a regular non-symlinked path, readable bytes, +// parseable JSON with no duplicate keys, an object at the top level, and a +// hooks section whose events are arrays of objects. Every failure preserves the +// file — nothing here writes — and says so, because the caller was about to +// rewrite what it could not read. +func readHookFile(path string) (hookFile, error) { + file := hookFile{ + path: path, + mode: hookFileDefaultMode, + fields: map[string]json.RawMessage{}, + entries: map[string][]json.RawMessage{}, + } + info, err := os.Lstat(path) + if err != nil { + if os.IsNotExist(err) { + return file, nil + } + return hookFile{}, fmt.Errorf("inspect hooks file %s: %w", path, err) + } + if info.Mode()&fs.ModeSymlink != 0 || !info.Mode().IsRegular() { + return hookFile{}, fmt.Errorf("hooks file %s is not a regular file — preserving it as written", path) + } + body, err := readRegularFileNoFollow(path, projectFileReadLimit) + if err != nil { + return hookFile{}, fmt.Errorf("read hooks file %s: %w — preserving it as written", path, err) + } + if err := validateJSONNoDuplicateKeys(body); err != nil { + return hookFile{}, fmt.Errorf("parse hooks file %s: %w — preserving it as written", path, err) + } + order, fields, err := decodeHookJSONObject(body) + if err != nil { + return hookFile{}, fmt.Errorf("parse hooks file %s: %w — preserving it as written", path, err) + } + file.exists = true + file.mode = info.Mode().Perm() + file.raw = body + file.order = order + file.fields = fields + + rawEvents, hasEvents := fields[hookFileEventsField] + if !hasEvents { + return file, nil + } + events, sections, err := decodeHookJSONObject(rawEvents) + if err != nil { + return hookFile{}, fmt.Errorf("parse hooks file %s: %q must be an object — preserving it as written", path, hookFileEventsField) + } + file.events = events + for _, event := range events { + var entries []json.RawMessage + if err := json.Unmarshal(sections[event], &entries); err != nil || entries == nil { + return hookFile{}, fmt.Errorf("parse hooks file %s: event %q must be an array — preserving it as written", path, event) + } + for index, entry := range entries { + if _, err := decodeHookEntry(entry); err != nil { + return hookFile{}, fmt.Errorf("parse hooks file %s: %s entry %d must be an object — preserving it as written", path, event, index) + } + } + file.entries[event] = entries + } + return file, nil +} + +// decodeHookJSONObject decodes a JSON object while recording the order its keys +// were written in. Go maps have no order and the file has one; preserving it is +// what keeps a republished document recognizable to whoever wrote it. +func decodeHookJSONObject(body []byte) ([]string, map[string]json.RawMessage, error) { + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + token, err := decoder.Token() + if err != nil { + return nil, nil, err + } + if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' { + return nil, nil, fmt.Errorf("value must be a JSON object") + } + order := []string{} + values := map[string]json.RawMessage{} + for decoder.More() { + key, err := decoder.Token() + if err != nil { + return nil, nil, err + } + name, ok := key.(string) + if !ok { + return nil, nil, fmt.Errorf("object key must be a string") + } + var value json.RawMessage + if err := decoder.Decode(&value); err != nil { + return nil, nil, err + } + order = append(order, name) + values[name] = value + } + if _, err := decoder.Token(); err != nil { + return nil, nil, err + } + if decoder.More() { + return nil, nil, fmt.Errorf("trailing JSON values") + } + return order, values, nil +} + +// decodeHookEntry decodes one entry for the read-only inspection recognition +// performs. Numbers keep their literal form so an entry compared against a +// catalog template is not judged different for having been decoded. +func decodeHookEntry(raw json.RawMessage) (map[string]any, error) { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + var entry map[string]any + if err := decoder.Decode(&entry); err != nil { + return nil, err + } + if entry == nil { + return nil, fmt.Errorf("entry must be an object") + } + return entry, nil +} + +// eventEntries decodes one event section for recognition and pairing. The raw +// values stay authoritative: decoding an entry is inspecting it, never claiming +// it, and the bytes republished for a foreign entry are the bytes read. +func (f hookFile) eventEntries(event string) ([]map[string]any, error) { + entries := make([]map[string]any, 0, len(f.entries[event])) + for index, raw := range f.entries[event] { + entry, err := decodeHookEntry(raw) + if err != nil { + return nil, fmt.Errorf("read %s entry %d in %s: %w", event, index, f.path, err) + } + entries = append(entries, entry) + } + return entries, nil +} + +// setEventEntries replaces one event's entries, appending the event to the +// section when the file has never carried it. An event that empties out keeps +// its key: the file said it existed and reconciliation has no opinion about +// sections it did not create. +func (f *hookFile) setEventEntries(event string, entries []json.RawMessage) { + if _, known := f.entries[event]; !known { + f.events = append(f.events, event) + if _, hasSection := f.fields[hookFileEventsField]; !hasSection { + f.order = append(f.order, hookFileEventsField) + f.fields[hookFileEventsField] = json.RawMessage("{}") + } + } + f.entries[event] = entries +} + +// seedHookFileFields gives a file that does not exist yet the top-level shape +// its target's builder emits, so a first install writes the document the +// harness expects rather than a bare hooks object. +func (f *hookFile) seed(target string) { + if f.exists || len(f.order) > 0 { + return + } + if target == "cursor" { + f.order = append(f.order, "version") + f.fields["version"] = json.RawMessage("1") + } + f.order = append(f.order, hookFileEventsField) + f.fields[hookFileEventsField] = json.RawMessage("{}") +} + +// marshal rebuilds the document in its original key order. Foreign entries are +// written back as the exact JSON values they were read as; only indentation is +// this writer's own. +func (f hookFile) marshal() ([]byte, error) { + var compact bytes.Buffer + compact.WriteByte('{') + for index, key := range f.order { + if index > 0 { + compact.WriteByte(',') + } + name, err := json.Marshal(key) + if err != nil { + return nil, err + } + compact.Write(name) + compact.WriteByte(':') + if key == hookFileEventsField { + if err := f.writeEvents(&compact); err != nil { + return nil, err + } + continue + } + compact.Write(f.fields[key]) + } + compact.WriteByte('}') + + var indented bytes.Buffer + if err := json.Indent(&indented, compact.Bytes(), "", " "); err != nil { + return nil, fmt.Errorf("serialize hooks file %s: %w", f.path, err) + } + indented.WriteByte('\n') + return indented.Bytes(), nil +} + +func (f hookFile) writeEvents(out *bytes.Buffer) error { + out.WriteByte('{') + for index, event := range f.events { + if index > 0 { + out.WriteByte(',') + } + name, err := json.Marshal(event) + if err != nil { + return err + } + out.Write(name) + out.WriteString(":[") + for position, entry := range f.entries[event] { + if position > 0 { + out.WriteByte(',') + } + out.Write(entry) + } + out.WriteString("]") + } + out.WriteByte('}') + return nil +} + +// publishHookFile writes the projected document with the ordering Decision 10 +// specifies: stage the new bytes, re-read the destination, compare it against +// what was parsed, and only then rename. A third-party write that lands before +// the comparison aborts the publication with the destination untouched. +// +// The window between the comparison and the rename is the named residual: a +// writer that does not honour the lock can still lose its write there, and no +// mechanism short of mandatory locking closes it. SQLite holds no copy of +// foreign content, so nothing later can restore it — which is why this says so +// rather than calling it convergence. +func publishHookFile(file hookFile, body []byte, operations *hookReconcileOperations) error { + directory := filepath.Dir(file.path) + if err := os.MkdirAll(directory, 0o755); err != nil { + return err + } + staged, err := os.CreateTemp(directory, ".loaf-hooks-*") + if err != nil { + return err + } + stagedPath := staged.Name() + defer os.Remove(stagedPath) + if err := stageHookFileBytes(staged, body, file.mode); err != nil { + return err + } + if operations != nil && operations.beforeRename != nil { + if err := operations.beforeRename(); err != nil { + return err + } + } + if err := ensureHookFileUnchanged(file); err != nil { + return err + } + return os.Rename(stagedPath, file.path) +} + +func stageHookFileBytes(staged *os.File, body []byte, mode fs.FileMode) error { + defer staged.Close() + if err := staged.Chmod(mode); err != nil { + return err + } + if _, err := staged.Write(body); err != nil { + return err + } + return staged.Sync() +} + +// ensureHookFileUnchanged reports whether the destination still holds the bytes +// reconciliation read. Content is compared rather than a digest recorded: +// nothing about this file is judged by a stored fingerprint. +func ensureHookFileUnchanged(file hookFile) error { + current, err := readHookFile(file.path) + if err != nil { + return err + } + if current.exists != file.exists || !bytes.Equal(current.raw, file.raw) { + return fmt.Errorf("hooks file %s changed while Loaf was reconciling it — preserving it as written; rerun to reconcile against the current file", file.path) + } + return nil +} diff --git a/internal/cli/hook_lock.go b/internal/cli/hook_lock.go new file mode 100644 index 000000000..dc7d9e6bf --- /dev/null +++ b/internal/cli/hook_lock.go @@ -0,0 +1,98 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// hook_lock.go serializes the writers of one target's hooks file. Every writer +// — a reconcile during install or upgrade, an enable or disable — holds the +// lock from the moment it reads state until the moment it publishes the file, +// which is what makes the read-compute-record-project sequence atomic against +// another Loaf process rather than merely quick. +// +// The lock is a file beside the hooks file, taken by exclusive create. It is +// advisory and Loaf-only: a third-party writer that knows nothing about it is +// caught by the pre-rename comparison instead, and the write it can still lose +// in the window after that comparison is a named accepted residual, not +// something this lock pretends to cover. + +const hookFileLockName = ".loaf-hooks.lock" + +const ( + hookFileLockWait = 2 * time.Second + hookFileLockPoll = 20 * time.Millisecond +) + +type hookFileLock struct { + path string +} + +// acquireHookFileLock takes the lock for the hooks file at hooksPath, waiting +// briefly for a concurrent writer to finish. Contention past the wait is an +// actionable error rather than a longer wait: the other writer is either about +// to finish, in which case rerunning succeeds, or it is gone and left the lock +// behind, in which case only a person can say so. +func acquireHookFileLock(hooksPath string, wait time.Duration) (*hookFileLock, error) { + if wait <= 0 { + wait = hookFileLockWait + } + path := filepath.Join(filepath.Dir(hooksPath), hookFileLockName) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, err + } + deadline := time.Now().Add(wait) + for { + file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) + if err == nil { + _, writeErr := fmt.Fprintf(file, "pid %d since %s\n", os.Getpid(), time.Now().UTC().Format(time.RFC3339)) + closeErr := file.Close() + if writeErr != nil || closeErr != nil { + lock := &hookFileLock{path: path} + return nil, fmt.Errorf("record hooks lock %s: %w", path, firstHookLockError(writeErr, closeErr, lock.release())) + } + return &hookFileLock{path: path}, nil + } + if !os.IsExist(err) { + return nil, fmt.Errorf("take hooks lock %s: %w", path, err) + } + if time.Now().After(deadline) { + return nil, fmt.Errorf("another Loaf process is reconciling %s (%s, %s); rerun in a moment, or delete that lock file if no Loaf process is running", hooksPath, path, describeHookFileLockHolder(path)) + } + time.Sleep(hookFileLockPoll) + } +} + +func (l *hookFileLock) release() error { + if l == nil { + return nil + } + if err := os.Remove(l.path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("release hooks lock %s: %w", l.path, err) + } + return nil +} + +func describeHookFileLockHolder(path string) string { + body, err := os.ReadFile(path) + if err != nil { + return "holder unknown" + } + holder := strings.TrimSpace(string(body)) + if holder == "" { + return "holder unknown" + } + return "held by " + holder +} + +func firstHookLockError(errs ...error) error { + for _, err := range errs { + if err != nil { + return err + } + } + return nil +} diff --git a/internal/cli/hook_pairing.go b/internal/cli/hook_pairing.go new file mode 100644 index 000000000..0ca4eda74 --- /dev/null +++ b/internal/cli/hook_pairing.go @@ -0,0 +1,137 @@ +package cli + +import ( + "bytes" + "encoding/json" + "fmt" + "reflect" +) + +// hookPairingPass names which of the three deterministic passes resolved an +// entry to its hook ID. +type hookPairingPass string + +const ( + hookPairingTemplate hookPairingPass = "template" + hookPairingSignature hookPairingPass = "signature" + hookPairingStem hookPairingPass = "stem" +) + +type hookEntryPairing struct { + index int + hookID string + pass hookPairingPass +} + +// hookPairingOutcome partitions one event section of a hooks file. Foreign +// entries are recorded by index alone: nothing downstream may classify, read +// further into, or rewrite them. +type hookPairingOutcome struct { + // paired holds the surviving entry per hook ID, in file order. + paired []hookEntryPairing + // duplicates are owned entries whose hook ID an earlier entry already + // claimed. They are Loaf's by construction, so they are removable. + duplicates []hookEntryPairing + // retired are owned entries that pair to no ID this catalog still ships — + // an older Loaf generation, removable. + retired []int + foreign []int +} + +// pairHookEventEntries maps the owned entries of one event section to catalog +// hook IDs. Ownership is decided catalog-wide (a command's identity does not +// depend on which section it sits in) while pairing is per section, so a Loaf +// entry someone moved to the wrong event reads as a retired generation and is +// removed rather than converged in place. +func pairHookEventEntries(recognition hookRecognition, event string, entries []map[string]any) (hookPairingOutcome, error) { + catalogEntries := recognition.catalog.entriesForEvent(event) + outcome := hookPairingOutcome{} + claimed := map[string]bool{} + for index, entry := range entries { + ownership, err := recognition.ownsEntry(entry) + if err != nil { + return hookPairingOutcome{}, fmt.Errorf("%s %s entry %d: %w", recognition.target, event, index, err) + } + if !ownership.owned { + outcome.foreign = append(outcome.foreign, index) + continue + } + hookID, pass, resolved, err := recognition.resolveHookID(catalogEntries, entry) + if err != nil { + return hookPairingOutcome{}, fmt.Errorf("%s %s entry %d: %w", recognition.target, event, index, err) + } + if !resolved { + outcome.retired = append(outcome.retired, index) + continue + } + pairing := hookEntryPairing{index: index, hookID: hookID, pass: pass} + if claimed[hookID] { + outcome.duplicates = append(outcome.duplicates, pairing) + continue + } + claimed[hookID] = true + outcome.paired = append(outcome.paired, pairing) + } + return outcome, nil +} + +// resolveHookID runs the three passes in order: exact match against the current +// desired template, the closed signature map, then identity stems. +func (r hookRecognition) resolveHookID(entries []hookCatalogEntry, entry map[string]any) (string, hookPairingPass, bool, error) { + for _, candidate := range entries { + if hookTemplateMatchesEntry(candidate, entry) { + return candidate.HookID, hookPairingTemplate, true, nil + } + } + identity := r.entryIdentity(entry) + if !identity.ok { + hookID, pass, ok := resolvePromptHookID(entries, entry) + return hookID, pass, ok, nil + } + return r.matchCatalogIdentity(entries, identity.tokens) +} + +func resolvePromptHookID(entries []hookCatalogEntry, entry map[string]any) (string, hookPairingPass, bool) { + prompt, ok := entry["prompt"].(string) + if !ok || prompt == "" { + return "", "", false + } + for _, candidate := range entries { + if candidate.Type == "prompt" && candidate.Prompt == prompt { + return candidate.HookID, hookPairingSignature, true + } + } + return "", "", false +} + +func hookTemplateMatchesEntry(candidate hookCatalogEntry, entry map[string]any) bool { + template, err := decodeHookJSONValue(candidate.Template) + if err != nil { + return false + } + value, err := canonicalHookValue(entry) + if err != nil { + return false + } + return reflect.DeepEqual(template, value) +} + +// canonicalHookValue re-decodes a value through JSON so that numbers compare by +// their literal form regardless of which decoder produced the input. +func canonicalHookValue(value any) (any, error) { + body, err := json.Marshal(value) + if err != nil { + return nil, err + } + return decodeHookJSONValue(body) +} + +func decodeHookJSONValue(body []byte) (any, error) { + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + return nil, err + } + return value, nil +} diff --git a/internal/cli/hook_pairing_test.go b/internal/cli/hook_pairing_test.go new file mode 100644 index 000000000..acd03d31c --- /dev/null +++ b/internal/cli/hook_pairing_test.go @@ -0,0 +1,238 @@ +package cli + +import ( + "reflect" + "sort" + "strings" + "testing" +) + +func TestHookPairingMatchesTheCurrentDesiredTemplate(t *testing.T) { + recognition := testHookRecognition(t, "cursor", testRepoHookCatalog(t, "cursor")) + entry := map[string]any{ + loafHookMarker: true, + "timeout": 30, + "matcher": "Edit|Write|Bash", + "failClosed": true, + "command": "loaf check --hook check-secrets", + } + + outcome, err := pairHookEventEntries(recognition, "preToolUse", []map[string]any{entry}) + if err != nil { + t.Fatalf("pairHookEventEntries error = %v", err) + } + if len(outcome.paired) != 1 || outcome.paired[0].hookID != "check-secrets" || outcome.paired[0].pass != hookPairingTemplate { + t.Fatalf("pairing = %#v, want check-secrets through the template pass", outcome) + } +} + +// The pre-dispatch-flag sessionStart command an earlier release shipped still +// pairs to its hook ID instead of reading as a retired generation. +func TestHookPairingMatchesAHistoricalSignature(t *testing.T) { + recognition := testHookRecognition(t, "cursor", testRepoHookCatalog(t, "cursor")) + entry := map[string]any{loafHookMarker: true, "timeout": 60, "command": "loaf journal context"} + + outcome, err := pairHookEventEntries(recognition, "sessionStart", []map[string]any{entry}) + if err != nil { + t.Fatalf("pairHookEventEntries error = %v", err) + } + if len(outcome.paired) != 1 || outcome.paired[0].hookID != "session-start-loaf" || outcome.paired[0].pass != hookPairingSignature { + t.Fatalf("pairing = %#v, want session-start-loaf through the signature pass", outcome) + } +} + +// Decision 3's security property: enforcement weakened by hand pairs to its +// hook ID so the next reconcile converges it, rather than orphaning as foreign +// and surviving the upgrade. +func TestHookPairingConvergesWeakenedEnforcement(t *testing.T) { + recognition := testHookRecognition(t, "cursor", testRepoHookCatalog(t, "cursor")) + entry := map[string]any{ + loafHookMarker: true, + "timeout": 30, + "matcher": "Edit|Write|Bash", + "command": "loaf check --hook check-secrets --advisory", + } + + outcome, err := pairHookEventEntries(recognition, "preToolUse", []map[string]any{entry}) + if err != nil { + t.Fatalf("pairHookEventEntries error = %v", err) + } + if len(outcome.paired) != 1 || outcome.paired[0].hookID != "check-secrets" || outcome.paired[0].pass != hookPairingStem { + t.Fatalf("pairing = %#v, want check-secrets through the stem pass", outcome) + } + if len(outcome.foreign) != 0 { + t.Fatalf("pairing left %d foreign entries, want the weakened entry claimed", len(outcome.foreign)) + } +} + +// The boundary the stem rule exists to hold: a longer hook id is a different +// hook, and substring resemblance never claims it. +func TestHookPairingLeavesTheDisabledLookalikeForeign(t *testing.T) { + recognition := testHookRecognition(t, "cursor", testRepoHookCatalog(t, "cursor")) + entry := map[string]any{"matcher": "Bash", "command": "loaf check --hook check-secrets-disabled"} + + outcome, err := pairHookEventEntries(recognition, "preToolUse", []map[string]any{entry}) + if err != nil { + t.Fatalf("pairHookEventEntries error = %v", err) + } + if len(outcome.paired) != 0 || len(outcome.foreign) != 1 { + t.Fatalf("pairing = %#v, want the lookalike left foreign", outcome) + } +} + +// Duplicates are Loaf's by construction, so the first survives to be converged +// and the extras are removable — no refusal, no fork. +func TestHookPairingConvergesDuplicateOwnedEntries(t *testing.T) { + recognition := testHookRecognition(t, "cursor", testRepoHookCatalog(t, "cursor")) + entries := []map[string]any{ + {loafHookMarker: true, "timeout": 30, "matcher": "Edit|Write|Bash", "failClosed": true, "command": "loaf check --hook check-secrets"}, + {"matcher": "Bash", "command": "loaf check --hook check-secrets --advisory"}, + {"matcher": "Edit|Write", "command": "loaf task refresh"}, + } + + outcome, err := pairHookEventEntries(recognition, "preToolUse", entries) + if err != nil { + t.Fatalf("pairHookEventEntries error = %v", err) + } + if len(outcome.paired) != 1 || outcome.paired[0].index != 0 || outcome.paired[0].hookID != "check-secrets" { + t.Fatalf("paired = %#v, want the first entry surviving", outcome.paired) + } + if len(outcome.duplicates) != 1 || outcome.duplicates[0].index != 1 || outcome.duplicates[0].hookID != "check-secrets" { + t.Fatalf("duplicates = %#v, want the second entry removable", outcome.duplicates) + } + // `loaf task refresh` belongs to postToolUse: under preToolUse it pairs to + // no identity this section ships and reads as a retired generation. + if !reflect.DeepEqual(outcome.retired, []int{2}) { + t.Fatalf("retired = %#v, want the misplaced entry removable", outcome.retired) + } +} + +func TestHookPairingRemovesARetiredGeneration(t *testing.T) { + recognition := testHookRecognition(t, "cursor", testRepoHookCatalog(t, "cursor")) + entries := []map[string]any{ + {loafHookMarker: true, "matcher": "Bash", "if": "Bash(git commit:*)", "command": "loaf journal log --from-hook"}, + {"command": "loaf session start"}, + {"command": "bash $HOME/.cursor/hooks/session/session-start.sh"}, + } + + outcome, err := pairHookEventEntries(recognition, "preToolUse", entries) + if err != nil { + t.Fatalf("pairHookEventEntries error = %v", err) + } + if !reflect.DeepEqual(outcome.retired, []int{0, 1, 2}) { + t.Fatalf("retired = %#v, want every legacy-allowlist entry removable", outcome.retired) + } + if len(outcome.foreign) != 0 || len(outcome.paired) != 0 { + t.Fatalf("pairing = %#v, want the retired generation owned but unpaired", outcome) + } +} + +// Prompt-type hooks carry no command to tokenize, so they pair on the exact +// prompt the catalog declares. +func TestHookPairingMatchesPromptEntriesByExactPrompt(t *testing.T) { + catalog, err := newHookCatalog("cursor", "test", []hookCatalogSource{{ + event: "preToolUse", + hookID: "journal-nudge", + typeName: "prompt", + prompt: "SESSION JOURNAL NUDGE: log decisions before committing.", + template: map[string]any{"prompt": "SESSION JOURNAL NUDGE: log decisions before committing.", "matcher": "Bash"}, + }}) + if err != nil { + t.Fatalf("newHookCatalog error = %v", err) + } + recognition := testHookRecognition(t, "cursor", catalog) + entries := []map[string]any{ + {"prompt": "SESSION JOURNAL NUDGE: log decisions before committing.", "matcher": "Bash", "timeout": 5}, + {"prompt": "A third-party nudge nobody else owns."}, + } + + outcome, err := pairHookEventEntries(recognition, "preToolUse", entries) + if err != nil { + t.Fatalf("pairHookEventEntries error = %v", err) + } + if len(outcome.paired) != 1 || outcome.paired[0].hookID != "journal-nudge" { + t.Fatalf("paired = %#v, want the prompt entry paired", outcome.paired) + } + if !reflect.DeepEqual(outcome.foreign, []int{1}) { + t.Fatalf("foreign = %#v, want the third-party prompt untouched", outcome.foreign) + } +} + +func TestHookPairingReportsCommandsCarryingTwoIdentities(t *testing.T) { + recognition := testHookRecognition(t, "cursor", testRepoHookCatalog(t, "cursor")) + entries := []map[string]any{{"matcher": "Bash", "command": "loaf check --hook check-secrets --hook validate-commit"}} + + _, err := pairHookEventEntries(recognition, "preToolUse", entries) + if err == nil || !strings.Contains(err.Error(), "more than one Loaf hook") { + t.Fatalf("pairHookEventEntries error = %v, want an integrity error", err) + } +} + +// The whole live file: the 17 shipped entries pair to their identities through +// the template pass, and all 33 foreign entries stay outside pairing entirely. +func TestHookPairingOverTheLiveCursorFile(t *testing.T) { + recognition := testHookRecognition(t, "cursor", testRepoHookCatalog(t, "cursor")) + events := testHookEventEntries(t, testHookFixture(t, "cursor-hooks-live.json")) + + paired := map[string][]string{} + foreign := 0 + for event, entries := range events { + outcome, err := pairHookEventEntries(recognition, event, entries) + if err != nil { + t.Fatalf("pairHookEventEntries(%s) error = %v", event, err) + } + if len(outcome.retired) != 0 || len(outcome.duplicates) != 0 { + t.Fatalf("%s: retired = %#v, duplicates = %#v, want none", event, outcome.retired, outcome.duplicates) + } + foreign += len(outcome.foreign) + for _, pairing := range outcome.paired { + if pairing.pass != hookPairingTemplate { + t.Fatalf("%s/%s paired through %q, want the in-sync template pass", event, pairing.hookID, pairing.pass) + } + paired[event] = append(paired[event], pairing.hookID) + } + } + if foreign != 33 { + t.Fatalf("pairing saw %d foreign entries, want 33", foreign) + } + want := map[string][]string{ + "preToolUse": { + "artifact-body-write", + "artifact-names", + "check-secrets", + "detect-linear-magic", + "ephemeral-provenance", + "github-account", + "render-drift", + "security-audit", + "validate-commit", + "validate-push", + "workflow-pre-merge", + "workflow-pre-pr", + "workflow-pre-push", + }, + "postToolUse": {"generate-task-board", "kb-staleness-nudge", "workflow-post-merge"}, + "sessionStart": {"session-start-loaf"}, + } + for event := range paired { + sort.Strings(paired[event]) + } + if !reflect.DeepEqual(paired, want) { + t.Fatalf("paired = %#v, want %#v", paired, want) + } +} + +// The canary's Codex file: the operator deleted the Loaf entry, so nothing +// pairs, and the third-party group is never touched. +func TestHookPairingOverTheLiveCodexFile(t *testing.T) { + recognition := testHookRecognition(t, "codex", testRepoHookCatalog(t, "codex")) + events := testHookEventEntries(t, testHookFixture(t, "codex-hooks-live.json")) + + outcome, err := pairHookEventEntries(recognition, "SessionStart", events["SessionStart"]) + if err != nil { + t.Fatalf("pairHookEventEntries error = %v", err) + } + if len(outcome.paired) != 0 || len(outcome.retired) != 0 || !reflect.DeepEqual(outcome.foreign, []int{0}) { + t.Fatalf("pairing = %#v, want the herdr group foreign and nothing paired", outcome) + } +} diff --git a/internal/cli/hook_recognition.go b/internal/cli/hook_recognition.go new file mode 100644 index 000000000..463c6928a --- /dev/null +++ b/internal/cli/hook_recognition.go @@ -0,0 +1,565 @@ +package cli + +import ( + "fmt" + "path" + "path/filepath" + "strings" +) + +// hookRecognition carries every input the closed ownership predicate needs. +// Nothing is read from global state here: callers inject the trusted executable +// paths (the currently resolved one plus any path previously recorded for the +// target) and the Loaf-managed hook-file destinations the installed manifest +// recorded. An entry is Loaf's only through these inputs — never through the +// `loaf-managed` marker, which stays human-legible provenance and nothing more. +type hookRecognition struct { + target string + catalog hookCatalog + trustedPaths []string + managedPaths []string + homeDir string + goos string +} + +type hookOwnershipReason string + +const ( + hookOwnershipExecutable hookOwnershipReason = "executable-form" + hookOwnershipManagedPath hookOwnershipReason = "managed-path" + hookOwnershipLegacy hookOwnershipReason = "legacy-allowlist" +) + +type hookOwnership struct { + owned bool + reason hookOwnershipReason + // hookID is set only when the recognizing rule already identified the + // entry. Path-backed and legacy entries leave it empty for pairing. + hookID string +} + +// hookEntryIdentity is the command an entry is identified by, tokenized and +// normalized. Foreign entries reach this too: computing the identity is not +// claiming the entry. +type hookEntryIdentity struct { + tokens []string + executable bool + ok bool +} + +// ownsEntry implements the closed recognition predicate. An entry is Loaf's iff +// it invokes the Loaf executable in one of three exact forms and matches a +// catalog signature or identity stem, or references an exact Loaf-managed +// hook-file destination, or matches the frozen legacy allowlist. Anything else +// is foreign and stays untouched forever. +func (r hookRecognition) ownsEntry(entry map[string]any) (hookOwnership, error) { + if r.target == "codex" { + // The frozen 0.2.20 matcher-group shape is part of the closed legacy + // allowlist, recognized with any canonical absolute path: the release + // that wrote it recorded no trusted path to compare against. Its + // conflict signal is deliberately dropped — a modified group converges + // through the identity test below instead of refusing the file. + if owned, _ := codexHookOwnershipForOS(entry, r.goos); owned { + return hookOwnership{owned: true, reason: hookOwnershipLegacy, hookID: r.legacyCodexHookID()}, nil + } + } + identity := r.entryIdentity(entry) + if identity.ok && identity.executable { + hookID, _, matched, err := r.matchCatalogIdentity(r.catalog.Entries, identity.tokens) + if err != nil { + return hookOwnership{}, err + } + if matched { + return hookOwnership{owned: true, reason: hookOwnershipExecutable, hookID: hookID}, nil + } + } + if identity.ok && r.referencesManagedPath(identity.tokens) { + return hookOwnership{owned: true, reason: hookOwnershipManagedPath}, nil + } + if r.matchesFrozenLegacyAllowlist(entry) { + return hookOwnership{owned: true, reason: hookOwnershipLegacy}, nil + } + return hookOwnership{}, nil +} + +// legacyCodexHookID names the one hook the frozen Codex matcher-group shape can +// ever describe, so a legacy-recognized group pairs without re-deriving it. +func (r hookRecognition) legacyCodexHookID() string { + for _, entry := range r.catalog.Entries { + if entry.HookID == "session-start-loaf" { + return entry.HookID + } + } + return "" +} + +// matchesFrozenLegacyAllowlist is the enumerated allowlist as it stands at +// 0.2.20 — a closed list, never extended by inference. Prompt-prefix +// recognition is bounded to prompt-type entries: it is the one prefix rule in +// the predicate, and a command entry must never be claimed by it. +func (r hookRecognition) matchesFrozenLegacyAllowlist(entry map[string]any) bool { + command, hasCommand := entry["command"].(string) + if signature := installHookSignature(entry); signature != "" && legacyLoafHookSignatures[signature] { + return true + } + if hasCommand && legacyLoafCommands[command] { + return true + } + if hasCommand { + return false + } + if prompt, ok := entry["prompt"].(string); ok { + for _, prefix := range legacyLoafPromptPrefixes { + if strings.HasPrefix(prompt, prefix) { + return true + } + } + } + return false +} + +// matchCatalogIdentity runs the signature pass then the stem pass. Zero stems +// means the command is the operator's own loaf-invoking hook; more than one is +// an integrity error rather than a guess. +func (r hookRecognition) matchCatalogIdentity(entries []hookCatalogEntry, tokens []string) (string, hookPairingPass, bool, error) { + if hookID, ok := r.matchCatalogSignature(entries, tokens); ok { + return hookID, hookPairingSignature, true, nil + } + hookIDs := r.matchCatalogStems(entries, tokens) + switch len(hookIDs) { + case 0: + return "", "", false, nil + case 1: + return hookIDs[0], hookPairingStem, true, nil + default: + return "", "", false, fmt.Errorf("hook command carries the identity of more than one Loaf hook (%s); resolve the entry by hand before reconciling", strings.Join(hookIDs, ", ")) + } +} + +func (r hookRecognition) matchCatalogSignature(entries []hookCatalogEntry, tokens []string) (string, bool) { + for _, entry := range entries { + for _, signature := range entry.Signatures { + if hookTokensEqual(r.normalizeCommandTokens(signature), tokens) { + return entry.HookID, true + } + } + } + return "", false +} + +func (r hookRecognition) matchCatalogStems(entries []hookCatalogEntry, tokens []string) []string { + var hookIDs []string + for _, entry := range entries { + for _, stem := range entry.Stems { + if containsHookTokenSequence(tokens, r.normalizeTokens(stem)) { + hookIDs = appendUniqueHookID(hookIDs, entry.HookID) + break + } + } + } + return hookIDs +} + +func (r hookRecognition) referencesManagedPath(tokens []string) bool { + if len(r.managedPaths) == 0 { + return false + } + managed := make(map[string]bool, len(r.managedPaths)) + for _, destination := range r.managedPaths { + if normalized := r.normalizePathToken(destination); normalized != "" { + managed[normalized] = true + } + } + for _, token := range tokens { + if managed[token] { + return true + } + } + return false +} + +func (r hookRecognition) entryIdentity(entry map[string]any) hookEntryIdentity { + command, windows, ok := r.entryCommand(entry) + if !ok { + return hookEntryIdentity{} + } + var tokens []hookCommandToken + if windows { + if unwrapped, ok := codexWindowsCommandTokens(command); ok { + tokens = unwrapped + } + } + if tokens == nil { + parsed, ok := hookCommandTokensForOS(command, r.goos) + if !ok { + return hookEntryIdentity{} + } + tokens = parsed + } + if len(tokens) == 0 { + return hookEntryIdentity{} + } + normalized := r.normalizeQuotedCommandTokens(tokens) + return hookEntryIdentity{ + tokens: normalized, + executable: normalized[0] == hookExecutableSentinel, + ok: true, + } +} + +// entryCommand extracts the command an entry is identified by. Codex nests one +// command handler inside a matcher group; a group carrying anything other than +// exactly one handler is the operator's own construction and never Loaf's. +func (r hookRecognition) entryCommand(entry map[string]any) (string, bool, bool) { + if r.target == "codex" { + handlers, ok := entry["hooks"].([]any) + if !ok || len(handlers) != 1 { + return "", false, false + } + handler, ok := handlers[0].(map[string]any) + if !ok { + return "", false, false + } + if r.goos == "windows" { + if command, ok := handler["commandWindows"].(string); ok { + return command, true, true + } + } + command, ok := handler["command"].(string) + return command, r.goos == "windows", ok + } + command, ok := entry["command"].(string) + return command, false, ok +} + +// normalizeQuotedCommandTokens normalizes a command as the shell would read it: +// the first token collapses to the executable sentinel when it is one of the +// three accepted Loaf forms, and every path token becomes an absolute lexical +// path — but only where the shell itself would have expanded it. +func (r hookRecognition) normalizeQuotedCommandTokens(tokens []hookCommandToken) []string { + normalized := make([]string, len(tokens)) + for i, token := range tokens { + normalized[i] = r.normalizeQuotedToken(token) + } + if len(normalized) > 0 && r.isLoafExecutableToken(tokens[0].value, normalized[0]) { + normalized[0] = hookExecutableSentinel + } + return normalized +} + +// normalizeCommandTokens is the catalog side of the same normalization. Catalog +// signatures and stems are authored, not parsed out of somebody's file, so +// their tokens carry no quoting to respect. +func (r hookRecognition) normalizeCommandTokens(tokens []string) []string { + return r.normalizeQuotedCommandTokens(unquotedHookTokens(tokens)) +} + +func (r hookRecognition) normalizeTokens(tokens []string) []string { + normalized := make([]string, len(tokens)) + for i, token := range tokens { + normalized[i] = r.normalizeToken(token) + } + return normalized +} + +func (r hookRecognition) normalizeToken(token string) string { + return r.normalizeQuotedToken(hookCommandToken{value: token}) +} + +func (r hookRecognition) normalizeQuotedToken(token hookCommandToken) string { + if !looksLikeHookPathToken(token.value) { + return token.value + } + if normalized := r.normalizeQuotedPathToken(token.value, token.quote); normalized != "" { + return normalized + } + return token.value +} + +// isLoafExecutableToken accepts exactly three forms: the install-time template, +// the bare `loaf` first token Cursor entries ship today, and an absolute path +// equal to a trusted executable path. Shell quoting is not part of the test — +// the tokenizer already removed it — but the path identity is. +func (r hookRecognition) isLoafExecutableToken(token string, normalized string) bool { + if token == codexJournalExecutablePlaceholder || token == "loaf" { + return true + } + for _, trusted := range r.trustedPaths { + if trusted == "" { + continue + } + if candidate := r.normalizePathToken(trusted); candidate != "" && candidate == normalized { + return true + } + } + return false +} + +// normalizePathToken is the closed algorithm for a path Loaf itself recorded — +// a manifest destination or a trusted executable — which carries no shell +// quoting of its own. +func (r hookRecognition) normalizePathToken(token string) string { + return r.normalizeQuotedPathToken(token, hookTokenUnquoted) +} + +// normalizeQuotedPathToken expands and normalizes exactly as the shell that +// runs the hook would: separators are normalized for the platform first so a +// Windows spelling is recognizable at all, then `$HOME` and `~` expand only +// where the quoting the operator wrote actually permits it. A single-quoted +// token is literal in the shell, so treating it as an expansion here would +// claim an entry that never pointed at a Loaf path in the first place; `~` +// additionally does not expand inside double quotes. +// +// No symlink resolution and no directory containment: only an exact match on a +// full path can ever claim an entry. +func (r hookRecognition) normalizeQuotedPathToken(token string, quote hookTokenQuote) string { + value := token + home := r.homeDir + if r.goos == "windows" { + value = strings.ReplaceAll(value, `\`, "/") + home = strings.ReplaceAll(home, `\`, "/") + } + if quote != hookTokenSingleQuoted { + switch { + case value == "$HOME" || value == "${HOME}": + value = home + case strings.HasPrefix(value, "$HOME/"): + value = joinHookHomePath(home, value[len("$HOME/"):]) + case strings.HasPrefix(value, "${HOME}/"): + value = joinHookHomePath(home, value[len("${HOME}/"):]) + case quote == hookTokenUnquoted && value == "~": + value = home + case quote == hookTokenUnquoted && strings.HasPrefix(value, "~/"): + value = joinHookHomePath(home, value[len("~/"):]) + } + } + if !isAbsoluteHookPath(value, r.goos) { + return "" + } + return path.Clean(value) +} + +func joinHookHomePath(home string, rest string) string { + if home == "" { + return "" + } + return strings.TrimRight(home, `/\`) + "/" + rest +} + +func isAbsoluteHookPath(value string, goos string) bool { + if goos != "windows" { + return strings.HasPrefix(value, "/") + } + if strings.HasPrefix(value, "//") { + return true + } + return len(value) >= 3 && isASCIIWindowsDriveLetter(value[0]) && value[1] == ':' && value[2] == '/' +} + +func looksLikeHookPathToken(token string) bool { + return strings.ContainsAny(token, `/\`) || token == "~" || token == "$HOME" || token == "${HOME}" +} + +// hookManagedDestinations anchors the installed manifest's Loaf-managed +// hook-file destinations at the target's config root. Destinations are recorded +// manifest-relative; ownership compares them as absolute lexical paths. +func hookManagedDestinations(configDir string, manifest targetAdapterManifest) []string { + var destinations []string + for _, artifact := range manifest.Artifacts { + if artifact.Kind != "hook-file" || artifact.Destination == "" { + continue + } + destinations = append(destinations, filepath.Join(configDir, filepath.FromSlash(artifact.Destination))) + } + return destinations +} + +// hookTokenQuote records how a token's first character was quoted, which is the +// only thing that decides whether the expansions at the front of a word happen. +type hookTokenQuote int + +const ( + hookTokenUnquoted hookTokenQuote = iota + hookTokenDoubleQuoted + hookTokenSingleQuoted +) + +// hookCommandToken is one shell word plus the quoting its first character +// carried. The quoting is kept rather than dropped because two commands that +// tokenize identically can still mean different things: `$HOME/x` names a path +// and `'$HOME/x'` names a literal filename that no expansion turns into one. +type hookCommandToken struct { + value string + quote hookTokenQuote +} + +// hookCommandTokens splits a POSIX shell command into words. Callers that hold +// authored strings — catalog signatures and stems — use this; anything parsed +// out of somebody's hooks file goes through hookCommandTokensForOS so the +// quoting survives. +func hookCommandTokens(command string) ([]string, bool) { + tokens, ok := hookCommandTokensForOS(command, "") + if !ok { + return nil, false + } + values := make([]string, 0, len(tokens)) + for _, token := range tokens { + values = append(values, token.value) + } + return values, true +} + +// hookCommandTokensForOS splits a hook command the way its shell groups words: +// single quotes are literal, double quotes honor the four escapes, and the +// quoting itself is dropped from the value while being recorded on the token. +// It recognizes rather than interprets — nothing is expanded here and operators +// are ordinary tokens — which is what lets two spellings of one command compare +// equal without any of it being executed. +// +// On Windows a backslash is a path separator rather than an escape, because the +// commands there are cmd.exe strings; treating it as an escape would silently +// eat the separators out of every Windows path. +func hookCommandTokensForOS(command string, goos string) ([]hookCommandToken, bool) { + tokens := []hookCommandToken{} + var current strings.Builder + started := false + leading := hookTokenUnquoted + begin := func(quote hookTokenQuote) { + if !started { + leading = quote + started = true + } + } + flush := func() { + if started { + tokens = append(tokens, hookCommandToken{value: current.String(), quote: leading}) + current.Reset() + started = false + leading = hookTokenUnquoted + } + } + runes := []rune(command) + for i := 0; i < len(runes); i++ { + switch character := runes[i]; character { + case ' ', '\t', '\n', '\r': + flush() + case '\'': + end := -1 + for j := i + 1; j < len(runes); j++ { + if runes[j] == '\'' { + end = j + break + } + } + if end < 0 { + return nil, false + } + begin(hookTokenSingleQuoted) + current.WriteString(string(runes[i+1 : end])) + i = end + case '"': + begin(hookTokenDoubleQuoted) + end := -1 + for j := i + 1; j < len(runes); j++ { + if runes[j] == '\\' && j+1 < len(runes) && isHookDoubleQuoteEscape(runes[j+1]) { + current.WriteRune(runes[j+1]) + j++ + continue + } + if runes[j] == '"' { + end = j + break + } + current.WriteRune(runes[j]) + } + if end < 0 { + return nil, false + } + i = end + case '\\': + if goos == "windows" { + begin(hookTokenUnquoted) + current.WriteRune(character) + continue + } + if i+1 >= len(runes) { + return nil, false + } + begin(hookTokenUnquoted) + current.WriteRune(runes[i+1]) + i++ + default: + begin(hookTokenUnquoted) + current.WriteRune(character) + } + } + flush() + return tokens, true +} + +func unquotedHookTokens(values []string) []hookCommandToken { + tokens := make([]hookCommandToken, 0, len(values)) + for _, value := range values { + tokens = append(tokens, hookCommandToken{value: value}) + } + return tokens +} + +func isHookDoubleQuoteEscape(character rune) bool { + return character == '"' || character == '\\' || character == '$' || character == '`' +} + +// codexWindowsCommandTokens unwraps the cmd.exe form install renders on +// Windows — `""C:\path\loaf" "` — back into plain tokens. Everything +// else, including the template form that is byte-identical on both platforms, +// falls through to ordinary splitting. +func codexWindowsCommandTokens(command string) ([]hookCommandToken, bool) { + if len(command) < 4 || !strings.HasPrefix(command, `""`) || !strings.HasSuffix(command, `"`) { + return nil, false + } + executable, rest, ok := strings.Cut(command[2:len(command)-1], `"`) + if !ok || executable == "" { + return nil, false + } + tail, ok := hookCommandTokensForOS(rest, "windows") + if !ok { + return nil, false + } + return append([]hookCommandToken{{value: executable, quote: hookTokenDoubleQuoted}}, tail...), true +} + +func hookTokensEqual(left []string, right []string) bool { + if len(left) != len(right) { + return false + } + for i := range left { + if left[i] != right[i] { + return false + } + } + return true +} + +// containsHookTokenSequence reports whether needle appears in haystack as an +// exact contiguous run of whole tokens. Whole-token comparison is what keeps a +// longer hook id outside the identity of the shorter one it starts with. +func containsHookTokenSequence(haystack []string, needle []string) bool { + if len(needle) == 0 || len(needle) > len(haystack) { + return false + } + for start := 0; start+len(needle) <= len(haystack); start++ { + if hookTokensEqual(haystack[start:start+len(needle)], needle) { + return true + } + } + return false +} + +func appendUniqueHookID(hookIDs []string, hookID string) []string { + for _, existing := range hookIDs { + if existing == hookID { + return hookIDs + } + } + return append(hookIDs, hookID) +} diff --git a/internal/cli/hook_recognition_test.go b/internal/cli/hook_recognition_test.go new file mode 100644 index 000000000..200ad25d0 --- /dev/null +++ b/internal/cli/hook_recognition_test.go @@ -0,0 +1,503 @@ +package cli + +import ( + "encoding/json" + "sort" + "strings" + "testing" +) + +const testHookCanaryHome = "/Users/canary" + +// Every entry 0.2.20 shipped into the canary's live Cursor file is recognized +// as Loaf's by construction — not one of them through the `loaf-managed` +// marker, which the predicate never reads. +func TestHookRecognitionClaimsEveryShippedCursorEntry(t *testing.T) { + recognition := testHookRecognition(t, "cursor", testRepoHookCatalog(t, "cursor")) + owned, foreign := testPartitionHookFixture(t, recognition, "cursor-hooks-live.json") + + if len(owned) != 17 { + t.Fatalf("recognized %d live Cursor entries, want the 17 shipped entries: %v", len(owned), owned) + } + if len(foreign) != 33 { + t.Fatalf("left %d live Cursor entries unclaimed, want 33 (32 legacy-generation plus one herdr)", len(foreign)) + } +} + +// The 2026-03-25 generation and the third-party herdr hook are outside the +// closed recognition set forever, including the five legacy entries that +// functionally duplicate shipped enforcement hooks. +func TestHookRecognitionLeavesLegacyGenerationAndHerdrUnclaimed(t *testing.T) { + recognition := testHookRecognition(t, "cursor", testRepoHookCatalog(t, "cursor")) + _, foreign := testPartitionHookFixture(t, recognition, "cursor-hooks-live.json") + + for _, command := range []string{ + "bash $HOME/.cursor/hooks/pre-tool/foundations-check-secrets.sh", + "bash $HOME/.cursor/hooks/pre-tool/foundations-security-audit.sh", + "bash $HOME/.cursor/hooks/pre-tool/foundations-validate-push.sh", + "python3 $HOME/.cursor/hooks/pre-tool/orchestration-validate-commit.py", + "bash $HOME/.cursor/hooks/post-tool/orchestration-generate-task-board.sh", + "bash '/Users/canary/.cursor/herdr-agent-state.sh' session", + } { + if !testContainsHookCommand(foreign, command) { + t.Fatalf("command %q was claimed; it must stay foreign", command) + } + } +} + +func TestHookRecognitionLeavesCodexHerdrGroupUnclaimed(t *testing.T) { + recognition := testHookRecognition(t, "codex", testRepoHookCatalog(t, "codex")) + owned, foreign := testPartitionHookFixture(t, recognition, "codex-hooks-live.json") + + if len(owned) != 0 || len(foreign) != 1 { + t.Fatalf("live Codex file recognized %d owned and %d foreign entries, want 0 and 1", len(owned), len(foreign)) + } +} + +// Ownership is by construction. Stripping the marker changes nothing, and +// adding it to a foreign entry claims nothing. +func TestHookRecognitionIgnoresTheLoafManagedMarker(t *testing.T) { + recognition := testHookRecognition(t, "cursor", testRepoHookCatalog(t, "cursor")) + events := testHookEventEntries(t, testHookFixture(t, "cursor-hooks-live.json")) + + owned := 0 + for _, entries := range events { + for _, entry := range entries { + delete(entry, loafHookMarker) + ownership, err := recognition.ownsEntry(entry) + if err != nil { + t.Fatalf("ownsEntry error = %v", err) + } + if ownership.owned { + owned++ + } + } + } + if owned != 17 { + t.Fatalf("recognized %d entries with the marker stripped, want 17", owned) + } + + imposter := map[string]any{ + "command": "bash '/Users/canary/.cursor/herdr-agent-state.sh' session", + loafHookMarker: true, + "timeout": json.Number("30"), + "loaf-managed2": false, + } + ownership, err := recognition.ownsEntry(imposter) + if err != nil { + t.Fatalf("ownsEntry error = %v", err) + } + if ownership.owned { + t.Fatal("a foreign entry wearing the loaf-managed marker was claimed") + } +} + +// The four path-backed entries are claimed by exact manifest destination, not +// by living under a Loaf-managed directory. +func TestHookRecognitionClaimsPathBackedEntriesByExactDestination(t *testing.T) { + recognition := testHookRecognition(t, "cursor", testRepoHookCatalog(t, "cursor")) + for _, command := range []string{ + "bash $HOME/.cursor/hooks/post-tool/kb-staleness-nudge.sh", + `cat "$HOME/.cursor/hooks/instructions/pre-merge.md"`, + `cat "$HOME/.cursor/hooks/instructions/pre-push.md"`, + `cat "$HOME/.cursor/hooks/instructions/post-merge.md"`, + } { + ownership, err := recognition.ownsEntry(map[string]any{"command": command}) + if err != nil { + t.Fatalf("ownsEntry(%q) error = %v", command, err) + } + if !ownership.owned || ownership.reason != hookOwnershipManagedPath { + t.Fatalf("ownsEntry(%q) = %#v, want ownership through the recorded destination", command, ownership) + } + } +} + +func TestHookRecognitionAcceptsEveryHomeSpellingOfADestination(t *testing.T) { + recognition := testHookRecognition(t, "cursor", testRepoHookCatalog(t, "cursor")) + for _, command := range []string{ + "bash $HOME/.cursor/hooks/post-tool/kb-staleness-nudge.sh", + "bash ${HOME}/.cursor/hooks/post-tool/kb-staleness-nudge.sh", + "bash ~/.cursor/hooks/post-tool/kb-staleness-nudge.sh", + "bash /Users/canary/.cursor/hooks/post-tool/kb-staleness-nudge.sh", + "bash '/Users/canary/.cursor/hooks/post-tool/kb-staleness-nudge.sh'", + "bash /Users/canary/.cursor/hooks/../hooks/post-tool/kb-staleness-nudge.sh", + } { + ownership, err := recognition.ownsEntry(map[string]any{"command": command}) + if err != nil { + t.Fatalf("ownsEntry(%q) error = %v", command, err) + } + if !ownership.owned { + t.Fatalf("ownsEntry(%q) left the entry unclaimed", command) + } + } +} + +// Quoting is part of what a command means. A single-quoted `$HOME` is a +// literal filename in every shell that runs these hooks, so expanding it here +// would claim a foreign entry that never pointed at a Loaf path at all — and +// `~` does not expand inside double quotes either. +func TestHookRecognitionDoesNotExpandQuotedHomePaths(t *testing.T) { + recognition := testHookRecognition(t, "cursor", testRepoHookCatalog(t, "cursor")) + for _, command := range []string{ + `cat '$HOME/.cursor/hooks/instructions/pre-merge.md'`, + `bash '$HOME/.cursor/hooks/post-tool/kb-staleness-nudge.sh'`, + `bash '${HOME}/.cursor/hooks/post-tool/kb-staleness-nudge.sh'`, + `bash '~/.cursor/hooks/post-tool/kb-staleness-nudge.sh'`, + `bash "~/.cursor/hooks/post-tool/kb-staleness-nudge.sh"`, + } { + ownership, err := recognition.ownsEntry(map[string]any{"command": command}) + if err != nil { + t.Fatalf("ownsEntry(%q) error = %v", command, err) + } + if ownership.owned { + t.Fatalf("ownsEntry(%q) = %#v, want a literal path left foreign", command, ownership) + } + } + + // The spellings the shell really does expand stay claimed. + for _, command := range []string{ + `cat "$HOME/.cursor/hooks/instructions/pre-merge.md"`, + `bash $HOME/.cursor/hooks/post-tool/kb-staleness-nudge.sh`, + `bash ~/.cursor/hooks/post-tool/kb-staleness-nudge.sh`, + } { + ownership, err := recognition.ownsEntry(map[string]any{"command": command}) + if err != nil { + t.Fatalf("ownsEntry(%q) error = %v", command, err) + } + if !ownership.owned { + t.Fatalf("ownsEntry(%q) left an expanded Loaf destination unclaimed", command) + } + } +} + +// A Windows entry spells its paths with backslashes, and cmd.exe does not treat +// them as escapes. Both halves have to hold for the destination to be +// recognized: the separators survive tokenization, and expansion happens after +// they are normalized. +func TestHookRecognitionClaimsWindowsSpelledDestinations(t *testing.T) { + recognition := testHookRecognition(t, "cursor", testRepoHookCatalog(t, "cursor")) + recognition.goos = "windows" + recognition.homeDir = `C:\Users\canary` + var manifest targetAdapterManifest + if err := json.Unmarshal(testHookFixture(t, "cursor-target-manifest-0.2.20.json"), &manifest); err != nil { + t.Fatalf("decode cursor manifest fixture error = %v", err) + } + recognition.managedPaths = hookManagedDestinations(`C:\Users\canary\.cursor`, manifest) + + for _, command := range []string{ + `bash $HOME\.cursor\hooks\post-tool\kb-staleness-nudge.sh`, + `bash ~\.cursor\hooks\post-tool\kb-staleness-nudge.sh`, + `bash C:\Users\canary\.cursor\hooks\post-tool\kb-staleness-nudge.sh`, + `cat "$HOME\.cursor\hooks\instructions\pre-merge.md"`, + } { + ownership, err := recognition.ownsEntry(map[string]any{"command": command}) + if err != nil { + t.Fatalf("ownsEntry(%q) error = %v", command, err) + } + if !ownership.owned || ownership.reason != hookOwnershipManagedPath { + t.Fatalf("ownsEntry(%q) = %#v, want the Windows spelling recognized", command, ownership) + } + } + + single := `bash '$HOME\.cursor\hooks\post-tool\kb-staleness-nudge.sh'` + ownership, err := recognition.ownsEntry(map[string]any{"command": single}) + if err != nil { + t.Fatalf("ownsEntry(%q) error = %v", single, err) + } + if ownership.owned { + t.Fatalf("ownsEntry(%q) = %#v, want a literal path left foreign on Windows too", single, ownership) + } +} + +// No directory containment: a sibling under the managed hooks directory that +// the manifest never recorded is not Loaf's. Claiming the directory would +// swallow the whole March generation. +func TestHookRecognitionRefusesDirectoryContainment(t *testing.T) { + recognition := testHookRecognition(t, "cursor", testRepoHookCatalog(t, "cursor")) + for _, command := range []string{ + "bash $HOME/.cursor/hooks/post-tool/kb-staleness-nudge.sh.bak", + "bash $HOME/.cursor/hooks/post-tool/something-else.sh", + "bash $HOME/.cursor/hooks/instructions/pre-merge.md.orig", + "bash $HOME/.cursor/hooks/pre-tool/foundations-check-secrets.sh", + } { + ownership, err := recognition.ownsEntry(map[string]any{"command": command}) + if err != nil { + t.Fatalf("ownsEntry(%q) error = %v", command, err) + } + if ownership.owned && ownership.reason == hookOwnershipManagedPath { + t.Fatalf("ownsEntry(%q) claimed a path the manifest never recorded", command) + } + } +} + +// An absolute executable path is Loaf's only when it is a trusted path: the +// currently resolved executable or one previously recorded for the target. +func TestHookRecognitionRequiresATrustedAbsoluteExecutable(t *testing.T) { + catalog := testRepoHookCatalog(t, "cursor") + untrusted := testHookRecognition(t, "cursor", catalog) + entry := map[string]any{"command": "'/opt/imposter/loaf' check --hook check-secrets"} + + ownership, err := untrusted.ownsEntry(entry) + if err != nil { + t.Fatalf("ownsEntry error = %v", err) + } + if ownership.owned { + t.Fatal("an untrusted absolute executable was claimed as Loaf's") + } + + trusted := untrusted + trusted.trustedPaths = []string{"/opt/imposter/loaf"} + ownership, err = trusted.ownsEntry(entry) + if err != nil { + t.Fatalf("ownsEntry error = %v", err) + } + if !ownership.owned || ownership.hookID != "check-secrets" { + t.Fatalf("ownsEntry with the path recorded = %#v, want check-secrets", ownership) + } +} + +// A previously recorded install path keeps recognizing entries written before +// Loaf moved, so relocation never orphans a live entry. +func TestHookRecognitionAcceptsAPreviouslyRecordedInstallPath(t *testing.T) { + recognition := testHookRecognition(t, "cursor", testRepoHookCatalog(t, "cursor")) + recognition.trustedPaths = []string{"/opt/homebrew/bin/loaf", "/Users/canary/.local/share/loaf/bin/loaf"} + + ownership, err := recognition.ownsEntry(map[string]any{"command": "'/Users/canary/.local/share/loaf/bin/loaf' task refresh"}) + if err != nil { + t.Fatalf("ownsEntry error = %v", err) + } + if !ownership.owned || ownership.hookID != "generate-task-board" { + t.Fatalf("ownsEntry = %#v, want the previously recorded path recognized", ownership) + } +} + +// A loaf-invoking command carrying no catalog identity is the operator's own +// hook and stays theirs. +func TestHookRecognitionLeavesForeignLoafCommandsUnclaimed(t *testing.T) { + recognition := testHookRecognition(t, "cursor", testRepoHookCatalog(t, "cursor")) + for _, command := range []string{ + "loaf journal log 'decision(scope): my own hook'", + "loaf check --hook check-secrets-disabled", + "loaf change check", + } { + ownership, err := recognition.ownsEntry(map[string]any{"command": command}) + if err != nil { + t.Fatalf("ownsEntry(%q) error = %v", command, err) + } + if ownership.owned { + t.Fatalf("ownsEntry(%q) = %#v, want the operator's own hook left alone", command, ownership) + } + } +} + +func TestHookRecognitionReportsCommandsCarryingTwoIdentities(t *testing.T) { + recognition := testHookRecognition(t, "cursor", testRepoHookCatalog(t, "cursor")) + _, err := recognition.ownsEntry(map[string]any{"command": "loaf check --hook check-secrets --hook validate-commit"}) + if err == nil || !strings.Contains(err.Error(), "more than one Loaf hook") { + t.Fatalf("ownsEntry error = %v, want an integrity error naming both identities", err) + } +} + +// Prompt-prefix recognition is bounded to prompt-type entries: a command that +// merely quotes a legacy prompt is never claimed by it. +func TestHookRecognitionAppliesLegacyPromptPrefixesToPromptEntriesOnly(t *testing.T) { + recognition := testHookRecognition(t, "cursor", testRepoHookCatalog(t, "cursor")) + + prompt := map[string]any{"prompt": "KNOWLEDGE BASE: covered files changed."} + ownership, err := recognition.ownsEntry(prompt) + if err != nil { + t.Fatalf("ownsEntry(prompt) error = %v", err) + } + if !ownership.owned || ownership.reason != hookOwnershipLegacy { + t.Fatalf("ownsEntry(prompt) = %#v, want the frozen legacy allowlist", ownership) + } + + command := map[string]any{"command": "echo 'KNOWLEDGE BASE: covered files changed.'"} + ownership, err = recognition.ownsEntry(command) + if err != nil { + t.Fatalf("ownsEntry(command) error = %v", err) + } + if ownership.owned { + t.Fatal("a command entry was claimed by a legacy prompt prefix") + } +} + +func TestHookRecognitionClaimsCodexTemplateAndResolvedForms(t *testing.T) { + catalog := testRepoHookCatalog(t, "codex") + unix := testHookRecognition(t, "codex", catalog) + unix.trustedPaths = []string{"/Users/canary/.local/bin/loaf"} + + template := testCodexGroup(codexJournalExecutablePlaceholder+codexJournalHookCommandSuffix, codexJournalExecutablePlaceholder+codexJournalHookCommandSuffix) + ownership, err := unix.ownsEntry(template) + if err != nil { + t.Fatalf("ownsEntry(template) error = %v", err) + } + if !ownership.owned || ownership.hookID != "session-start-loaf" { + t.Fatalf("ownsEntry(template) = %#v, want session-start-loaf", ownership) + } + + resolved := testCodexGroup("'/Users/canary/.local/bin/loaf'"+codexJournalHookCommandSuffix, "") + ownership, err = unix.ownsEntry(resolved) + if err != nil { + t.Fatalf("ownsEntry(resolved) error = %v", err) + } + if !ownership.owned || ownership.hookID != "session-start-loaf" { + t.Fatalf("ownsEntry(resolved) = %#v, want session-start-loaf", ownership) + } +} + +// Windows parity is concrete: the template carries identical command and +// commandWindows values, and the installed cmd.exe form resolves to the same +// identity through the Windows quoting rules. +func TestHookRecognitionClaimsCodexWindowsCommandParity(t *testing.T) { + catalog := testRepoHookCatalog(t, "codex") + template, err := decodeHookJSONValue(catalog.Entries[0].Template) + if err != nil { + t.Fatalf("decode template error = %v", err) + } + handler := template.(map[string]any)["hooks"].([]any)[0].(map[string]any) + if handler["command"] != handler["commandWindows"] { + t.Fatalf("codex template command = %v, commandWindows = %v, want identical values", handler["command"], handler["commandWindows"]) + } + + windows := testHookRecognition(t, "codex", catalog) + windows.goos = "windows" + windows.homeDir = `C:\Users\canary` + windows.trustedPaths = []string{`C:\Users\canary\AppData\Local\loaf\loaf.exe`} + command, err := codexWindowsJournalContextCommand(`C:\Users\canary\AppData\Local\loaf\loaf.exe`) + if err != nil { + t.Fatalf("codexWindowsJournalContextCommand error = %v", err) + } + + ownership, err := windows.ownsEntry(testCodexGroup(command, command)) + if err != nil { + t.Fatalf("ownsEntry(windows) error = %v", err) + } + if !ownership.owned || ownership.hookID != "session-start-loaf" { + t.Fatalf("ownsEntry(windows) = %#v, want session-start-loaf", ownership) + } + + untrusted := windows + untrusted.trustedPaths = nil + ownership, err = untrusted.ownsEntry(testCodexGroup(command, command)) + if err != nil { + t.Fatalf("ownsEntry(untrusted windows) error = %v", err) + } + if !ownership.owned || ownership.reason != hookOwnershipLegacy { + t.Fatalf("ownsEntry(untrusted windows) = %#v, want the frozen 0.2.20 shape to still recognize it", ownership) + } +} + +// A matcher group the operator extended with a second handler is their +// construction, not Loaf's; reconciliation never edits it. +func TestHookRecognitionLeavesMultiHandlerCodexGroupsUnclaimed(t *testing.T) { + recognition := testHookRecognition(t, "codex", testRepoHookCatalog(t, "codex")) + recognition.trustedPaths = []string{"/Users/canary/.local/bin/loaf"} + + group := testCodexGroup("'/Users/canary/.local/bin/loaf'"+codexJournalHookCommandSuffix, "") + handlers := group["hooks"].([]any) + group["hooks"] = append(handlers, map[string]any{"type": "command", "command": "bash /Users/canary/.config/codex/herdr-agent-state.sh session"}) + + ownership, err := recognition.ownsEntry(group) + if err != nil { + t.Fatalf("ownsEntry error = %v", err) + } + if ownership.owned { + t.Fatalf("ownsEntry = %#v, want a hand-extended group left alone", ownership) + } +} + +func TestHookCommandTokensRespectsQuoting(t *testing.T) { + for _, testCase := range []struct { + command string + want []string + ok bool + }{ + {command: `cat "$HOME/.cursor/hooks/instructions/pre-merge.md"`, want: []string{"cat", "$HOME/.cursor/hooks/instructions/pre-merge.md"}, ok: true}, + {command: `bash '/Users/canary/my hooks/run.sh' session`, want: []string{"bash", "/Users/canary/my hooks/run.sh", "session"}, ok: true}, + {command: `loaf journal log 'decision(scope): x'`, want: []string{"loaf", "journal", "log", "decision(scope): x"}, ok: true}, + {command: `loaf task refresh`, want: []string{"loaf", "task", "refresh"}, ok: true}, + {command: `bash "/Users/canary/it\"s/run.sh"`, want: []string{"bash", `/Users/canary/it"s/run.sh`}, ok: true}, + {command: `bash 'unterminated`, ok: false}, + } { + tokens, ok := hookCommandTokens(testCase.command) + if ok != testCase.ok { + t.Fatalf("hookCommandTokens(%q) ok = %v, want %v", testCase.command, ok, testCase.ok) + } + if ok && !hookTokensEqual(tokens, testCase.want) { + t.Fatalf("hookCommandTokens(%q) = %q, want %q", testCase.command, tokens, testCase.want) + } + } +} + +func TestContainsHookTokenSequenceMatchesWholeTokensOnly(t *testing.T) { + haystack := []string{"check", "--hook", "check-secrets-disabled"} + if containsHookTokenSequence(haystack, []string{"--hook", "check-secrets"}) { + t.Fatal("a longer token was matched as a prefix; identity must compare whole tokens") + } + if !containsHookTokenSequence(haystack, []string{"--hook", "check-secrets-disabled"}) { + t.Fatal("an exact token run did not match") + } +} + +func testHookRecognition(t *testing.T, target string, catalog hookCatalog) hookRecognition { + t.Helper() + recognition := hookRecognition{ + target: target, + catalog: catalog, + homeDir: testHookCanaryHome, + goos: "darwin", + } + if target == "cursor" { + var manifest targetAdapterManifest + if err := json.Unmarshal(testHookFixture(t, "cursor-target-manifest-0.2.20.json"), &manifest); err != nil { + t.Fatalf("decode cursor manifest fixture error = %v", err) + } + recognition.managedPaths = hookManagedDestinations(testHookCanaryHome+"/.cursor", manifest) + } + return recognition +} + +func testPartitionHookFixture(t *testing.T, recognition hookRecognition, fixture string) (owned []map[string]any, foreign []map[string]any) { + t.Helper() + events := testHookEventEntries(t, testHookFixture(t, fixture)) + names := make([]string, 0, len(events)) + for event := range events { + names = append(names, event) + } + sort.Strings(names) + for _, event := range names { + for _, entry := range events[event] { + ownership, err := recognition.ownsEntry(entry) + if err != nil { + t.Fatalf("ownsEntry(%s) error = %v", event, err) + } + if ownership.owned { + owned = append(owned, entry) + } else { + foreign = append(foreign, entry) + } + } + } + return owned, foreign +} + +func testContainsHookCommand(entries []map[string]any, command string) bool { + for _, entry := range entries { + if value, ok := entry["command"].(string); ok && value == command { + return true + } + } + return false +} + +func testCodexGroup(command string, windowsCommand string) map[string]any { + handler := map[string]any{"type": "command", "command": command} + if windowsCommand != "" { + handler["commandWindows"] = windowsCommand + } + return map[string]any{ + "matcher": codexJournalHookMatcher, + "hooks": []any{handler}, + } +} diff --git a/internal/cli/hook_reconcile.go b/internal/cli/hook_reconcile.go new file mode 100644 index 000000000..f8466f4b6 --- /dev/null +++ b/internal/cli/hook_reconcile.go @@ -0,0 +1,954 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "reflect" + "runtime" + "sort" + "strings" + "time" + + "github.com/levifig/loaf/internal/state" +) + +// hook_reconcile.go converges Loaf's own entries inside a hooks file that Loaf +// does not own. Every entry the recognition predicate does not claim is +// invisible: it is never classified, never rewritten, and its presence or +// absence changes nothing. What Loaf's entries should look like comes from the +// built catalog; whether they should be there at all comes from the user-scoped +// enablement records. The file is the projection of those two, never an +// authority over either — which is why no divergence between the file and any +// recorded fingerprint can refuse anything here. + +// The action vocabulary the plan surface speaks. These name what happens to one +// identity, not to the file. +const ( + hookActionAdd = "add" + hookActionUpdate = "update" + hookActionRemove = "remove" + hookActionAbsorb = "absorb" +) + +// hookAction is one per-entry decision. Entries that pair to no current +// identity carry their file position instead of a hook id, because a retired +// generation has no name this version still knows. +type hookAction struct { + action string + event string + hookID string + index int + detail string +} + +func (a hookAction) id() string { + if a.hookID != "" { + return "hook:" + a.event + "/" + a.hookID + } + return fmt.Sprintf("hook:%s[%d]", a.event, a.index) +} + +// hookReconcileOperations injects failures at the two points Decision 10 names +// as windows: between the record commit and the file projection, and between +// staging the new bytes and comparing the destination. +type hookReconcileOperations struct { + afterRecords func() error + beforeRename func() error +} + +type hookReconciler struct { + target string + path string + catalog hookCatalog + state hookStateResolver + version string + // priorManifest records that an installed manifest existed at all, and + // priorVersion what it named. The pair is what bounds absorption: only a + // version this catalog enumerates has a known cohort, and only the absence + // of a manifest licenses falling back to the full frozen one. + priorManifest bool + priorVersion string + // priorProjection records that the installed manifest still carried the + // retired whole-file hooks row — the manifest-side half of prior-install + // detection. Reading it is all that row is still good for; the manifest + // writer drops it, and it is never the absorption gate, which is the marker. + priorProjection bool + managedPaths []string + homeDir string + goos string + operations *hookReconcileOperations + lockWait time.Duration + + resolveExecutable func() (string, error) + executable string + executableErr error + executableResolved bool + + // lock is held from the first state read through file publication; recorded + // carries the actions the record half already took, so the report a caller + // finally sees names them alongside the file half's. + lock *hookFileLock + recorded []hookAction +} + +// hookProjection is one computed convergence: the actions to report, the +// records to write, and the bytes to publish. It is display and intent, never a +// script — apply recomputes it from live state inside the lock. +// +// foreign holds every entry Loaf does not own, canonicalized and in file order, +// so the post-verify comparison is about values and positions rather than a +// count that two compensating mistakes could satisfy. +type hookProjection struct { + actions []hookAction + absorbed []state.HookEnablementRef + writeMarker bool + markerVersion string + file hookFile + body []byte + foreign []string +} + +// newHookReconciler builds the reconciler for a target that keeps its hooks in +// a shared JSON file. Targets without one, and distributions old enough to +// predate the target adapter manifest — and therefore the catalog the whole +// identity model rests on — return no reconciler and keep their own paths. +// +// Callers build it before syncing the adapter manifest and apply it after: +// prior-install detection reads the manifest the previous release wrote, and +// the sync is what replaces that with this release's. +func newHookReconciler(options targetInstallOptions) (*hookReconciler, error) { + path := targetHookFilePath(options) + if path == "" { + return nil, nil + } + manifestPath := filepath.Join(options.DistDir, targetBuildManifestFile) + if !fileExistsForInstall(manifestPath) { + return nil, staleHookDistributionError(options.Target, options.DistDir, "it carries no target adapter manifest") + } + catalog, err := readHookCatalog(options.DistDir) + if err != nil { + return nil, err + } + if catalog.Target != options.Target { + return nil, fmt.Errorf("hook catalog target %q does not match install target %q", catalog.Target, options.Target) + } + installed, hasInstalled, err := readInstalledHookManifest(options) + if err != nil { + return nil, err + } + desired, err := readTargetAdapterManifest(manifestPath) + if err != nil { + return nil, err + } + // The catalog and the manifest are emitted by one build. If they disagree + // about which release this is, one of them was left behind, and neither the + // desired entries nor the cohort bound can be trusted to describe what is + // about to be installed. + if catalog.PackageVersion != desired.PackageVersion { + return nil, staleHookDistributionError(options.Target, options.DistDir, + fmt.Sprintf("its hook catalog is built from %s while its target manifest is built from %s", catalog.PackageVersion, desired.PackageVersion)) + } + // Recognition claims a path-backed entry only through an exact recorded + // destination. Both manifests contribute: the installed one names what the + // entries in the file today were written against, and the desired one names + // what this version is about to write, so a hook whose script is new to this + // release is recognizable the moment it is added. + root := hookArtifactRoot(options) + managed := append(hookManagedDestinations(root, installed), hookManagedDestinations(root, desired)...) + + reconciler := &hookReconciler{ + target: options.Target, + path: path, + catalog: catalog, + state: options.HookState, + version: options.Version, + priorManifest: hasInstalled, + priorVersion: installed.PackageVersion, + priorProjection: installed.carriedObsoleteHookRow, + managedPaths: managed, + homeDir: installHomeDir(options), + goos: runtime.GOOS, + operations: options.HookOps, + resolveExecutable: func() (string, error) { + return trustedCodexJournalExecutable(options.ProjectRoot, options.CodexRuleOperations) + }, + } + return reconciler, nil +} + +// staleHookDistributionError is what a target gets instead of the whole-file +// merge that used to stand behind it. That merge trusted a marker, wrote the +// hooks file before the scripts it referenced, and refused files it could not +// classify — all of which this Change removes, so reaching it from a build +// output that predates the catalog would reintroduce every one of them. +func staleHookDistributionError(target string, distDir string, because string) error { + return fmt.Errorf("cannot reconcile %s hooks: the build output at %s is stale because %s; rebuild with `loaf build` or install a current release", target, distDir, because) +} + +// targetHookFilePath is the one place that knows where a target keeps the file +// its entries live in. An empty result means the target has no such file. +func targetHookFilePath(options targetInstallOptions) string { + switch options.Target { + case "cursor", "codex": + return filepath.Join(hookArtifactRoot(options), "hooks.json") + default: + return "" + } +} + +func hookArtifactRoot(options targetInstallOptions) string { + if options.Target != "codex" { + return options.ConfigDir + } + if options.CodexHome != "" { + return options.CodexHome + } + return filepath.Join(installHomeDir(options), ".codex") +} + +// targetReconcilesHookEntries reports whether this target's hooks file is +// converged per entry. The whole-file artifact machinery must leave those +// destinations alone: reading, publishing, removing, or judging one would +// reintroduce exactly the file-level verdict this replaces. +func targetReconcilesHookEntries(options targetInstallOptions) bool { + return targetHookFilePath(options) != "" +} + +func readInstalledHookManifest(options targetInstallOptions) (targetAdapterManifest, bool, error) { + path := filepath.Join(options.ConfigDir, targetInstallManifestFile) + if _, err := os.Lstat(path); err != nil { + if os.IsNotExist(err) { + return targetAdapterManifest{}, false, nil + } + return targetAdapterManifest{}, false, err + } + manifest, err := readTargetAdapterManifest(path) + if err != nil { + return targetAdapterManifest{}, false, err + } + return manifest, true, nil +} + +// openState reaches the enablement authority. A caller that supplied no +// resolver gets an error rather than an empty view: projecting defaults because +// the records could not be read is exactly how a disabled hook comes back. +func (r *hookReconciler) openState() (*state.Store, error) { + if r.state == nil { + return nil, fmt.Errorf("cannot reconcile %s: hook enablement state is unavailable", r.path) + } + return r.state() +} + +// plan computes the per-entry actions without writing anything — no records, no +// file, no lock. What it reports is what an apply run would decide against the +// state that exists right now, which is all a plan can honestly promise. +func (r *hookReconciler) plan(ctx context.Context) ([]hookAction, error) { + store, err := r.openState() + if err != nil { + return nil, err + } + records, err := loadHookRecords(ctx, store, r.target) + if err != nil { + return nil, err + } + projection, err := r.computeProjection(records) + if err != nil { + return nil, err + } + return projection.actions, nil +} + +// The five states `loaf config check` reports one catalog identity in. Two of +// them are healthy and three name work; none of them is about an entry Loaf +// does not own, which is why no sixth state exists for foreign content. +const ( + hookStateDisabledAbsent = "disabled-and-correctly-absent" + hookStateEnabledInSync = "enabled-and-in-sync" + hookStateEnabledStale = "enabled-but-stale" + hookStateEnabledMissing = "enabled-and-missing" + hookStateDisabledPresent = "disabled-but-present" +) + +// hookDiagnosis is one identity's state: what the records say crossed with what +// the file carries. +type hookDiagnosis struct { + Event string `json:"event"` + HookID string `json:"hook_id"` + State string `json:"state"` +} + +// healthy reports whether this identity needs nothing. The file agreeing with +// the records is the whole of the condition — an identity is never unhealthy +// for what sits beside it in the file. +func (d hookDiagnosis) healthy() bool { + return d.State == hookStateDisabledAbsent || d.State == hookStateEnabledInSync +} + +// remedy names what would converge this identity, in the two words the contract +// uses: an absent or drifted entry needs a reconcile, and one the records say +// should not be there needs the file reprojected. +func (d hookDiagnosis) remedy() string { + switch d.State { + case hookStateEnabledStale, hookStateEnabledMissing: + return "reconcile" + case hookStateDisabledPresent: + return "reprojection" + default: + return "" + } +} + +// diagnose reads the records and the live file and crosses them, without +// writing anything: no lock, no records, no file. It deliberately does not +// consult what an absorption would decide — that is a write this has not made, +// and reporting an identity as already disabled because a migration intends to +// disable it would be describing the future. A pre-migration host therefore +// reads enabled-and-missing, `--fix` runs the reconcile that absorbs it, and +// the recheck reads disabled-and-correctly-absent. +func (r *hookReconciler) diagnose(ctx context.Context) ([]hookDiagnosis, error) { + store, err := r.openState() + if err != nil { + return nil, err + } + records, err := loadHookRecords(ctx, store, r.target) + if err != nil { + return nil, err + } + file, err := readHookFile(r.path) + if err != nil { + return nil, err + } + recognition := r.recognition(records) + paired := map[string]hookEntryPairing{} + for _, event := range r.reconciledEvents(file) { + entries, err := file.eventEntries(event) + if err != nil { + return nil, err + } + outcome, err := pairHookEventEntries(recognition, event, entries) + if err != nil { + return nil, err + } + for _, pairing := range outcome.paired { + paired[hookRecordKey(event, pairing.hookID)] = pairing + } + } + diagnoses := make([]hookDiagnosis, 0, len(r.catalog.Entries)) + for _, entry := range r.catalog.Entries { + pairing, present := paired[hookRecordKey(entry.Event, entry.HookID)] + state, err := r.diagnoseEntry(entry, file, pairing, present, records.enabled(entry.Event, entry.HookID)) + if err != nil { + return nil, err + } + diagnoses = append(diagnoses, hookDiagnosis{Event: entry.Event, HookID: entry.HookID, State: state}) + } + sort.Slice(diagnoses, func(i, j int) bool { + if diagnoses[i].Event != diagnoses[j].Event { + return diagnoses[i].Event < diagnoses[j].Event + } + return diagnoses[i].HookID < diagnoses[j].HookID + }) + return diagnoses, nil +} + +func (r *hookReconciler) diagnoseEntry(entry hookCatalogEntry, file hookFile, pairing hookEntryPairing, present bool, enabled bool) (string, error) { + switch { + case !enabled && !present: + return hookStateDisabledAbsent, nil + case !enabled: + return hookStateDisabledPresent, nil + case !present: + return hookStateEnabledMissing, nil + } + desired, err := r.desiredEntry(entry) + if err != nil { + return "", err + } + same, err := hookEntriesEqual(file.entries[entry.Event][pairing.index], desired) + if err != nil { + return "", err + } + if same { + return hookStateEnabledInSync, nil + } + return hookStateEnabledStale, nil +} + +// apply converges the target in one call: the record half, then the file half. +// Install and upgrade split the two around their artifact work; everything else +// wants them adjacent. +func (r *hookReconciler) apply(ctx context.Context) ([]hookAction, error) { + if err := r.begin(ctx); err != nil { + return nil, err + } + return r.complete(ctx) +} + +// begin takes the per-target lock and makes the record half durable. The lock +// comes first — before any state is opened or read — because Decision 10's +// guarantee is over the whole read-compute-record-project sequence, and a read +// that happens outside it is a read another writer can invalidate. +// +// Records land here rather than alongside the file projection because the +// caller may replace the installed manifest in between. That manifest is where +// the prior version's identity lives, and absorption is bounded by it: if it +// were overwritten while the marker was still unwritten, a retry would read a +// fresh install and project as enabled the very hook the operator deleted. +func (r *hookReconciler) begin(ctx context.Context) error { + lock, err := acquireHookFileLock(r.path, r.lockWait) + if err != nil { + return err + } + r.lock = lock + if err := r.recordAbsorption(ctx); err != nil { + if releaseErr := r.release(); releaseErr != nil { + return fmt.Errorf("%w; %v", err, releaseErr) + } + return err + } + return nil +} + +func (r *hookReconciler) recordAbsorption(ctx context.Context) error { + store, err := r.openState() + if err != nil { + return err + } + if store == nil { + return fmt.Errorf("cannot reconcile %s: hook enablement state is unavailable", r.path) + } + if err := r.recordTrustedExecutable(ctx, store); err != nil { + return err + } + records, err := loadHookRecords(ctx, store, r.target) + if err != nil { + return err + } + projection, err := r.computeProjection(records) + if err != nil { + return err + } + if projection.writeMarker { + if _, err := store.AbsorbAndMarkHooks(ctx, r.target, projection.markerVersion, projection.absorbed); err != nil { + return err + } + } + r.recorded = hookActionsOfKind(projection.actions, hookActionAbsorb) + // From here until the file is projected the file is at most one reconcile + // behind the records. That is the retry-safe window: nothing is lost, and + // the next reconcile converges. + if r.operations != nil && r.operations.afterRecords != nil { + return r.operations.afterRecords() + } + return nil +} + +// complete projects the file and releases the lock. It recomputes from live +// state rather than replaying what begin decided, so a disable that committed +// while the caller was doing its other work is honoured instead of overwritten. +func (r *hookReconciler) complete(ctx context.Context) (actions []hookAction, err error) { + defer func() { + if releaseErr := r.release(); releaseErr != nil && err == nil { + err = releaseErr + } + }() + store, err := r.openState() + if err != nil { + return nil, err + } + if store == nil { + return nil, fmt.Errorf("cannot reconcile %s: hook enablement state is unavailable", r.path) + } + records, err := loadHookRecords(ctx, store, r.target) + if err != nil { + return nil, err + } + projection, err := r.computeProjection(records) + if err != nil { + return nil, err + } + if projection.body != nil { + if err := publishHookFile(projection.file, projection.body, r.operations); err != nil { + return nil, err + } + if err := r.verify(ctx, store, projection); err != nil { + return nil, err + } + } + return append(append([]hookAction{}, r.recorded...), projection.actions...), nil +} + +// release drops the lock. It is idempotent so a caller can defer it against an +// early failure and still let complete release at the right moment. +func (r *hookReconciler) release() error { + if r == nil || r.lock == nil { + return nil + } + lock := r.lock + r.lock = nil + return lock.release() +} + +func hookActionsOfKind(actions []hookAction, kind string) []hookAction { + var matched []hookAction + for _, action := range actions { + if action.action == kind { + matched = append(matched, action) + } + } + return matched +} + +// verify re-reads the published file and runs recognition over it again. A +// converged target computes no further actions and still carries every foreign +// entry it started with; nothing here consults a digest, because no digest of +// this file means anything. +func (r *hookReconciler) verify(ctx context.Context, store *state.Store, published hookProjection) error { + records, err := loadHookRecords(ctx, store, r.target) + if err != nil { + return err + } + projection, err := r.computeProjection(records) + if err != nil { + return fmt.Errorf("verify reconciled %s: %w", r.path, err) + } + if projection.body != nil { + return fmt.Errorf("reconciled %s did not converge: %s", r.path, describeHookActions(projection.actions)) + } + if difference := describeHookForeignDrift(published.foreign, projection.foreign); difference != "" { + return fmt.Errorf("reconciled %s changed an entry Loaf does not own: %s", r.path, difference) + } + return nil +} + +// describeHookForeignDrift names the first foreign entry whose value or +// position moved, or "" when every one of them survived exactly. Comparing +// values in order rather than counting them is the point: a removal paired with +// an insertion, or two entries swapping places, both keep a count honest. +func describeHookForeignDrift(before []string, after []string) string { + for index := range before { + if index >= len(after) { + return fmt.Sprintf("%s is gone", before[index]) + } + if before[index] != after[index] { + return fmt.Sprintf("%s became %s", before[index], after[index]) + } + } + if len(after) > len(before) { + return fmt.Sprintf("%s appeared", after[len(before)]) + } + return "" +} + +func (r *hookReconciler) computeProjection(records hookRecords) (hookProjection, error) { + file, err := readHookFile(r.path) + if err != nil { + return hookProjection{}, err + } + file.seed(r.target) + + recognition := r.recognition(records) + events := r.reconciledEvents(file) + outcomes := make(map[string]hookPairingOutcome, len(events)) + projection := hookProjection{file: file} + owned := 0 + for _, event := range events { + entries, err := file.eventEntries(event) + if err != nil { + return hookProjection{}, err + } + outcome, err := pairHookEventEntries(recognition, event, entries) + if err != nil { + return hookProjection{}, err + } + outcomes[event] = outcome + owned += len(entries) - len(outcome.foreign) + for _, index := range outcome.foreign { + canonical, err := canonicalHookEntry(file.entries[event][index]) + if err != nil { + return hookProjection{}, fmt.Errorf("read %s entry %d in %s: %w", event, index, r.path, err) + } + projection.foreign = append(projection.foreign, event+" "+canonical) + } + } + + disabled := r.planAbsorption(records, outcomes, owned, &projection) + changed := false + for _, event := range events { + eventChanged, err := r.projectEvent(event, outcomes[event], records, disabled, &projection) + if err != nil { + return hookProjection{}, err + } + changed = changed || eventChanged + } + if !changed { + return projection, nil + } + body, err := projection.file.marshal() + if err != nil { + return hookProjection{}, err + } + projection.body = body + return projection, nil +} + +// planAbsorption is the run-once migration. It fires only when this target has +// no marker and a prior install is detected, and it considers only the hook ids +// the prior version actually shipped: a hook introduced after that version was +// never there to be deleted, so its absence says nothing. The marker is written +// either way, so a first reconcile that absorbs nothing still closes the window +// in which a later hand-deletion could be read as intent. +func (r *hookReconciler) planAbsorption(records hookRecords, outcomes map[string]hookPairingOutcome, owned int, projection *hookProjection) map[string]bool { + disabled := map[string]bool{} + if records.absorbed { + return disabled + } + priorInstall := r.priorProjection || owned > 0 + projection.writeMarker = true + projection.markerVersion = r.absorptionVersion(priorInstall) + if !priorInstall { + return disabled + } + cohort := hookAbsorptionCohort(r.catalog, r.priorVersion, r.priorManifest) + for _, entry := range r.catalog.Entries { + if !cohort[entry.HookID] || records.recorded(entry.Event, entry.HookID) { + continue + } + if hookPairedInEvent(outcomes[entry.Event], entry.HookID) { + continue + } + key := hookRecordKey(entry.Event, entry.HookID) + disabled[key] = true + projection.absorbed = append(projection.absorbed, state.HookEnablementRef{Target: r.target, Event: entry.Event, HookID: entry.HookID}) + projection.actions = append(projection.actions, hookAction{ + action: hookActionAbsorb, + event: entry.Event, + hookID: entry.HookID, + detail: "absent before this upgrade; recorded as disabled", + }) + } + return disabled +} + +// projectEvent converges one event section. Entries keep their positions, so +// the relative order of everything Loaf does not own is exactly what it was; +// entries this version adds go to the end of the section. +func (r *hookReconciler) projectEvent(event string, outcome hookPairingOutcome, records hookRecords, disabled map[string]bool, projection *hookProjection) (bool, error) { + removed := map[int]bool{} + replaced := map[int]json.RawMessage{} + var added []json.RawMessage + + for _, duplicate := range outcome.duplicates { + removed[duplicate.index] = true + projection.actions = append(projection.actions, hookAction{ + action: hookActionRemove, + event: event, + hookID: duplicate.hookID, + index: duplicate.index, + detail: "second entry for the same hook", + }) + } + for _, index := range outcome.retired { + removed[index] = true + projection.actions = append(projection.actions, hookAction{ + action: hookActionRemove, + event: event, + index: index, + detail: "entry from a retired Loaf generation", + }) + } + + paired := make(map[string]hookEntryPairing, len(outcome.paired)) + for _, pairing := range outcome.paired { + paired[pairing.hookID] = pairing + } + for _, entry := range r.catalog.entriesForEvent(event) { + pairing, present := paired[entry.HookID] + enabled := records.enabled(entry.Event, entry.HookID) && !disabled[hookRecordKey(entry.Event, entry.HookID)] + switch { + case !enabled && present: + removed[pairing.index] = true + projection.actions = append(projection.actions, hookAction{ + action: hookActionRemove, + event: event, + hookID: entry.HookID, + index: pairing.index, + detail: "disabled", + }) + case !enabled: + continue + case !present: + desired, err := r.desiredEntry(entry) + if err != nil { + return false, err + } + added = append(added, desired) + projection.actions = append(projection.actions, hookAction{action: hookActionAdd, event: event, hookID: entry.HookID}) + default: + desired, err := r.desiredEntry(entry) + if err != nil { + return false, err + } + same, err := hookEntriesEqual(projection.file.entries[event][pairing.index], desired) + if err != nil { + return false, err + } + if same { + continue + } + replaced[pairing.index] = desired + projection.actions = append(projection.actions, hookAction{ + action: hookActionUpdate, + event: event, + hookID: entry.HookID, + index: pairing.index, + detail: "entry differs from the shipped hook", + }) + } + } + + if len(removed) == 0 && len(replaced) == 0 && len(added) == 0 { + return false, nil + } + existing := projection.file.entries[event] + kept := make([]json.RawMessage, 0, len(existing)+len(added)) + for index, entry := range existing { + if removed[index] { + continue + } + if replacement, ok := replaced[index]; ok { + kept = append(kept, replacement) + continue + } + kept = append(kept, entry) + } + projection.file.setEventEntries(event, append(kept, added...)) + return true, nil +} + +// recognition assembles the closed ownership predicate's inputs. Trusted +// executable paths are every path recorded for this target plus the one +// resolving right now, so an entry written before Loaf moved is still Loaf's. +func (r *hookReconciler) recognition(records hookRecords) hookRecognition { + trusted := append([]string{}, records.trusted...) + if executable, err := r.trustedExecutable(); err == nil && executable != "" { + trusted = appendUniqueHookPath(trusted, executable) + } + return hookRecognition{ + target: r.target, + catalog: r.catalog, + trustedPaths: trusted, + managedPaths: r.managedPaths, + homeDir: r.homeDir, + goos: r.goos, + } +} + +// recordTrustedExecutable pins the path this run would write into an entry, so +// the next reconcile recognizes it even after Loaf moves. Resolution failing is +// not fatal here: a target whose entries invoke the bare `loaf` on PATH has +// nothing to pin, and the reconcile that does need the path fails where it +// needs it. +func (r *hookReconciler) recordTrustedExecutable(ctx context.Context, store *state.Store) error { + executable, err := r.trustedExecutable() + if err != nil || executable == "" { + return nil + } + _, err = store.RecordHookTrustedPath(ctx, r.target, executable) + return err +} + +func (r *hookReconciler) trustedExecutable() (string, error) { + if !r.executableResolved { + r.executableResolved = true + r.executable, r.executableErr = r.resolveExecutable() + } + return r.executable, r.executableErr +} + +// desiredEntry is the entry this version wants at an identity. The catalog +// carries the install-time executable placeholder for targets that pin an +// absolute path; everything else is already the desired shape. +func (r *hookReconciler) desiredEntry(entry hookCatalogEntry) (json.RawMessage, error) { + if !bytes.Contains(entry.Template, []byte(codexJournalExecutablePlaceholder)) { + return entry.Template, nil + } + executable, err := r.trustedExecutable() + if err != nil { + return nil, err + } + rendered, err := renderCodexHookExecutableForOS(entry.Template, executable, r.goos) + if err != nil { + return nil, fmt.Errorf("render %s hook %s: %w", r.target, entry.HookID, err) + } + return rendered, nil +} + +// reconciledEvents is every section pairing has to look at: the ones the file +// carries, so a Loaf entry left in a section this version no longer uses is +// still found, plus the ones the catalog ships, so a missing section can be +// created. +func (r *hookReconciler) reconciledEvents(file hookFile) []string { + events := append([]string{}, file.events...) + seen := make(map[string]bool, len(events)) + for _, event := range events { + seen[event] = true + } + for _, entry := range r.catalog.Entries { + if !seen[entry.Event] { + seen[entry.Event] = true + events = append(events, entry.Event) + } + } + return events +} + +// absorptionVersion records what the marker is provenance for. A manifest names +// the version whose cohort bounded the absorption. A prior install without one +// is genuinely unidentifiable and the marker says so rather than naming a +// version nobody observed; with no prior install at all the marker simply dates +// itself to the version that closed the window. +func (r *hookReconciler) absorptionVersion(priorInstall bool) string { + if version := strings.TrimSpace(r.priorVersion); version != "" { + return version + } + if version := strings.TrimSpace(r.version); version != "" && !priorInstall { + return version + } + return "unknown" +} + +// hookAbsorptionCohort bounds absorption to the hook ids one released version +// actually shipped, in the three cases that can arise: +// +// - a manifest naming a version this catalog enumerates: that cohort. The +// enumeration spans both spellings of the same releases, because the +// version line was renumbered and manifests written before the reset still +// name the retired one; +// - no manifest at all: the last frozen cohort, the generation predating +// entry-level reconciliation, which is the only thing a pre-manifest +// install could have been; +// - a manifest naming a version nobody enumerated: nothing. The install is +// identified and its cohort is not recorded, so no absence in the file is +// evidence of anything. Absorbing on a guess here is how a hook the +// operator never disabled ends up disabled. +func hookAbsorptionCohort(catalog hookCatalog, version string, hasManifest bool) map[string]bool { + var ids []string + switch recorded, enumerated := catalog.cohortHookIDs(version); { + case enumerated: + ids = recorded + case !hasManifest && len(catalog.Cohorts) > 0: + ids = catalog.Cohorts[len(catalog.Cohorts)-1].HookIDs + } + cohort := make(map[string]bool, len(ids)) + for _, id := range ids { + cohort[id] = true + } + return cohort +} + +func canonicalHookEntry(raw json.RawMessage) (string, error) { + var compact bytes.Buffer + if err := json.Compact(&compact, raw); err != nil { + return "", err + } + return compact.String(), nil +} + +func hookPairedInEvent(outcome hookPairingOutcome, hookID string) bool { + for _, pairing := range outcome.paired { + if pairing.hookID == hookID { + return true + } + } + return false +} + +func hookEntriesEqual(left json.RawMessage, right json.RawMessage) (bool, error) { + current, err := decodeHookJSONValue(left) + if err != nil { + return false, err + } + desired, err := decodeHookJSONValue(right) + if err != nil { + return false, err + } + return reflect.DeepEqual(current, desired), nil +} + +func appendUniqueHookPath(paths []string, path string) []string { + for _, existing := range paths { + if existing == path { + return paths + } + } + return append(paths, path) +} + +func describeHookActions(actions []hookAction) string { + if len(actions) == 0 { + return "no actions" + } + described := make([]string, 0, len(actions)) + for _, action := range actions { + described = append(described, action.action+" "+action.id()) + } + return strings.Join(described, ", ") +} + +// beginHookReconcile is install and upgrade's first hook step. It runs before +// any of the target's artifacts move, because the installed manifest it reads +// is about to be replaced and the absorption bound lives in it. +func beginHookReconcile(reconciler *hookReconciler) error { + if reconciler == nil { + return nil + } + return reconciler.begin(context.Background()) +} + +// completeHookReconcile is the last hook step. It runs after the target's other +// artifacts are in place, so an entry is never projected before the script it +// invokes exists. +func completeHookReconcile(options targetInstallOptions, reconciler *hookReconciler) error { + if reconciler == nil { + return nil + } + actions, err := reconciler.complete(context.Background()) + if err != nil { + return err + } + if options.HookActions != nil && len(actions) > 0 { + options.HookActions(actions) + } + return nil +} + +// releaseHookReconcile is the deferred counterpart: whatever fails between the +// two halves, the lock does not outlive the run. Completing already released +// it, so this is a no-op on the successful path. +func releaseHookReconcile(reconciler *hookReconciler) { + if reconciler != nil { + _ = reconciler.release() + } +} + +// hookActionPlanDecisions renders the computed actions onto the plan surface. +// A converged target contributes nothing: there is no per-file line to report +// because the file is not the unit of the decision any more. +func hookActionPlanDecisions(reconciler *hookReconciler, actions []hookAction) []artifactPlanDecision { + decisions := make([]artifactPlanDecision, 0, len(actions)) + for _, action := range actions { + decisions = append(decisions, artifactPlanDecision{ + ID: action.id(), + Kind: "hook-entry", + Destination: reconciler.path, + Action: action.action, + Detail: action.detail, + }) + } + return decisions +} diff --git a/internal/cli/hook_reconcile_test.go b/internal/cli/hook_reconcile_test.go new file mode 100644 index 000000000..c24a944a5 --- /dev/null +++ b/internal/cli/hook_reconcile_test.go @@ -0,0 +1,2257 @@ +package cli + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/levifig/loaf/internal/state" +) + +// testHookPriorVersion is the released generation this migration absorbs, as +// the canary's files carry it. +const testHookPriorVersion = "0.2.20" + +var errHookCrashInjection = errors.New("injected crash before projection") + +// A fresh install has no prior install to absorb from: every catalog hook +// projects enabled, the file is created, and the marker closes the migration +// window so a later hand-deletion is never mistaken for intent. +func TestHookReconcileFreshInstallEnablesEverything(t *testing.T) { + fixture := newCursorHookFixture(t) + + actions := fixture.apply(t) + + for _, action := range actions { + if action.action != hookActionAdd { + t.Fatalf("fresh install took %s on %s, want adds only", action.action, action.id()) + } + } + entries := fixture.hookIDsByEvent(t) + if len(entries["sessionStart"]) != 1 || entries["sessionStart"][0] != "session-start-loaf" { + t.Fatalf("sessionStart = %v, want the shipped session hook", entries["sessionStart"]) + } + if _, marked, err := fixture.store.GetHookAbsorptionMarker(t.Context(), "cursor"); err != nil || !marked { + t.Fatalf("absorption marker = %v, %v, want the fresh install to close the migration window", marked, err) + } + if rows, err := fixture.store.ListHookEnablements(t.Context(), "cursor"); err != nil || len(rows) != 0 { + t.Fatalf("enablement rows = %#v, %v, want nothing recorded on a fresh install", rows, err) + } +} + +// The canary's Codex file: the operator emptied it before this release, so the +// hook the prior version shipped is absorbed as disabled instead of re-added, +// and the third-party group is untouched. +func TestHookReconcileAbsorbsTheDeletedCodexHook(t *testing.T) { + fixture := newCodexHookFixture(t) + fixture.writeHooks(t, string(testHookFixture(t, "codex-hooks-live.json"))) + fixture.writeInstalledManifest(t, testHookPriorVersion) + + actions := fixture.apply(t) + + if len(actions) != 1 || actions[0].action != hookActionAbsorb || actions[0].hookID != "session-start-loaf" { + t.Fatalf("actions = %#v, want one absorption of session-start-loaf", actions) + } + row, found, err := fixture.store.GetHookEnablement(t.Context(), "codex", "SessionStart", "session-start-loaf") + if err != nil || !found { + t.Fatalf("GetHookEnablement = %v, %v, want an absorbed record", found, err) + } + if row.Enablement != state.HookEnablementDisabled || row.AbsorbedAt == nil { + t.Fatalf("absorbed record = %#v, want disabled with immutable absorption provenance", row) + } + // The file was never touched: absorption is a record, not an edit. + fixture.assertHooksUnchanged(t, string(testHookFixture(t, "codex-hooks-live.json"))) + fixture.assertForeignSurvives(t, "SessionStart", "bash '/Users/canary/.config/codex/herdr-agent-state.sh' session") +} + +// A second run of the same upgrade decides nothing: convergence is a fixed +// point, and the quiet upgrade is the normal one. +func TestHookReconcileIsIdempotent(t *testing.T) { + fixture := newCodexHookFixture(t) + fixture.writeHooks(t, string(testHookFixture(t, "codex-hooks-live.json"))) + fixture.writeInstalledManifest(t, testHookPriorVersion) + + fixture.apply(t) + before := fixture.readHooks(t) + actions := fixture.apply(t) + + if len(actions) != 0 { + t.Fatalf("second reconcile actions = %#v, want none", actions) + } + if after := fixture.readHooks(t); after != before { + t.Fatalf("second reconcile rewrote the file:\n%s\n%s", before, after) + } +} + +// Every entry Loaf does not own survives a reconcile value-identical and in the +// order it was written — the 32-entry legacy generation and the third-party +// herdr hook alike. +func TestHookReconcilePreservesEveryForeignCursorEntry(t *testing.T) { + fixture := newCursorHookFixture(t) + live := string(testHookFixture(t, "cursor-hooks-live.json")) + fixture.writeHooks(t, live) + fixture.writeInstalledManifest(t, testHookPriorVersion) + + fixture.apply(t) + + before := testHookDocumentSnapshot(t, []byte(live), fixture.recognition(t)) + after := testHookDocumentSnapshot(t, []byte(fixture.readHooks(t)), fixture.recognition(t)) + if total := testHookCountForeign(before); total != 33 { + t.Fatalf("live Cursor fixture has %d foreign entries, want 33", total) + } + if !reflect.DeepEqual(before, after) { + t.Fatalf("the preserved half of the document changed across the reconcile:\nbefore %#v\nafter %#v", before, after) + } +} + +// The same guarantee stated against the Codex file, whose top-level fields and +// third-party group must survive the absorption that touches neither. +func TestHookReconcilePreservesTheCodexDocumentAroundAnAbsorption(t *testing.T) { + fixture := newCodexHookFixture(t) + live := `{"description":"canary hooks","hooks":{"SessionStart":[{"hooks":[{"command":"bash '/Users/canary/.config/codex/herdr-agent-state.sh' session","timeout":10,"type":"command"}]}],"Stop":[]}}` + fixture.writeHooks(t, live) + fixture.writeInstalledManifest(t, testHookPriorVersion) + + before := testHookDocumentSnapshot(t, []byte(live), fixture.recognition(t)) + fixture.apply(t) + after := testHookDocumentSnapshot(t, []byte(fixture.readHooks(t)), fixture.recognition(t)) + + if !reflect.DeepEqual(before, after) { + t.Fatalf("the preserved half of the document changed across the reconcile:\nbefore %#v\nafter %#v", before, after) + } +} + +// Codex handler shapes Loaf has no model of at all. The whole-file merge used +// to parse these against its own schema and refuse the ones it could not +// classify; reconciliation carries them as the raw values they are, so a prompt +// handler, an agent handler, a command handler with current-schema fields Loaf +// never writes, and the degenerate groups all survive value-identical and in +// position — through a reconcile that does write, so the guarantee is about +// what was republished rather than about a file nobody touched. +func TestHookReconcilePreservesForeignCodexHandlerShapes(t *testing.T) { + fixture := newCodexHookFixture(t) + live := `{"description":"canary hooks","hooks":{"SessionStart":[` + + `{},` + + `{"matcher":null},` + + `{"matcher":"resume","hooks":[{"type":"prompt"}]},` + + `{"matcher":"clear","hooks":[{"type":"agent"}]},` + + `{"matcher":"compact","hooks":[{"type":"command","command":"user hook","command_windows":"powershell user hook","timeout":0,"async":true,"statusMessage":"checking"}]}` + + `],"Stop":[]}}` + fixture.writeHooks(t, live) + before := testHookDocumentSnapshot(t, []byte(live), fixture.recognition(t)) + + actions := fixture.apply(t) + + if !testHookHasAnyAction(actions, hookActionAdd) { + t.Fatalf("actions = %s, want the reconcile to have written the file", describeHookActions(actions)) + } + after := testHookDocumentSnapshot(t, []byte(fixture.readHooks(t)), fixture.recognition(t)) + if !reflect.DeepEqual(before, after) { + t.Fatalf("a foreign handler shape did not survive the reconcile:\nbefore %#v\nafter %#v", before, after) + } + for _, shape := range []string{`"type":"prompt"`, `"type":"agent"`, `"command_windows":"powershell user hook"`, `"statusMessage":"checking"`} { + if !strings.Contains(strings.ReplaceAll(fixture.readHooks(t), " ", ""), strings.ReplaceAll(shape, " ", "")) { + t.Fatalf("hooks file lost %s:\n%s", shape, fixture.readHooks(t)) + } + } +} + +// The goldens above prove the post-verify comparison stays silent when nothing +// moved, which is only half of what it is for. These are the mutations it has +// to name — and the middle two are the reason it compares values in order +// rather than counting: a swap and a removal paired with an insertion both +// leave the count exactly right. +func TestHookForeignDriftIsDetectedByValueAndOrder(t *testing.T) { + entries := []string{"SessionStart {\"a\":1}", "SessionStart {\"b\":2}", "Stop {\"c\":3}"} + for _, testCase := range []struct { + name string + after []string + want string + }{ + {name: "nothing moved", after: entries, want: ""}, + {name: "value rewritten", after: []string{entries[0], "SessionStart {\"b\":99}", entries[2]}, want: "became"}, + {name: "order swapped", after: []string{entries[1], entries[0], entries[2]}, want: "became"}, + {name: "one replaced by another", after: []string{entries[0], entries[2], "Stop {\"d\":4}"}, want: "became"}, + {name: "entry gone", after: entries[:2], want: "is gone"}, + {name: "entry appeared", after: append(append([]string{}, entries...), "Stop {\"e\":5}"), want: "appeared"}, + } { + t.Run(testCase.name, func(t *testing.T) { + difference := describeHookForeignDrift(entries, testCase.after) + if testCase.want == "" { + if difference != "" { + t.Fatalf("describeHookForeignDrift() = %q, want no reported drift", difference) + } + return + } + if !strings.Contains(difference, testCase.want) { + t.Fatalf("describeHookForeignDrift() = %q, want it to report %q", difference, testCase.want) + } + }) + } +} + +// The live Cursor file is already converged for its Loaf entries, so a +// reconcile of it decides nothing at all. +func TestHookReconcileLeavesTheConvergedCursorFileAlone(t *testing.T) { + fixture := newCursorHookFixture(t) + live := string(testHookFixture(t, "cursor-hooks-live.json")) + fixture.writeHooks(t, live) + fixture.writeInstalledManifest(t, testHookPriorVersion) + + actions := fixture.apply(t) + + if len(actions) != 0 { + t.Fatalf("actions = %s, want a converged file to decide nothing", describeHookActions(actions)) + } +} + +// Decision 3: a Loaf entry someone weakened converges back to the shipped shape +// instead of refusing the file. There is no drift verdict left to reach. +func TestHookReconcileConvergesAWeakenedEntryInsteadOfRefusing(t *testing.T) { + fixture := newCursorHookFixture(t) + fixture.apply(t) + fixture.mutateEntry(t, "preToolUse", "check-"+"sec"+"rets", func(entry map[string]any) { + entry["command"] = "loaf check --hook check-" + "sec" + "rets --advisory" + delete(entry, "failClosed") + }) + + actions := fixture.apply(t) + + if len(actions) != 1 || actions[0].action != hookActionUpdate || actions[0].hookID != "check-"+"sec"+"rets" { + t.Fatalf("actions = %#v, want one update of the weakened entry", actions) + } + if strings.Contains(fixture.readHooks(t), "check-"+"sec"+"rets --advisory") { + t.Fatalf("weakened enforcement survived the reconcile:\n%s", fixture.readHooks(t)) + } +} + +// A hand-deleted entry after absorption is not a disable gesture: the file is a +// projection of the records, so the next reconcile puts it back. +func TestHookReconcileReaddsAHandDeletedEntryAfterAbsorption(t *testing.T) { + fixture := newCodexHookFixture(t) + fixture.apply(t) + fixture.writeHooks(t, `{"hooks":{"SessionStart":[]}}`) + + actions := fixture.apply(t) + + if len(actions) != 1 || actions[0].action != hookActionAdd || actions[0].hookID != "session-start-loaf" { + t.Fatalf("actions = %#v, want the deleted entry re-added", actions) + } +} + +// A disabled record removes the entry and keeps it out, which is what makes the +// verb surface a projection rather than surgery. +func TestHookReconcileRemovesADisabledHookAndKeepsItOut(t *testing.T) { + fixture := newCodexHookFixture(t) + fixture.apply(t) + if _, err := fixture.store.SetHookEnablement(t.Context(), "codex", "SessionStart", "session-start-loaf", false); err != nil { + t.Fatalf("SetHookEnablement error = %v", err) + } + + actions := fixture.apply(t) + if len(actions) != 1 || actions[0].action != hookActionRemove || actions[0].detail != "disabled" { + t.Fatalf("actions = %#v, want the disabled entry removed", actions) + } + if actions := fixture.apply(t); len(actions) != 0 { + t.Fatalf("actions = %#v, want a disabled hook to stay out", actions) + } +} + +// Duplicates are Loaf's by construction: the first survives converged and the +// extras go, with no refusal and no fork. A legacy entry that pairs to no +// current identity is a retired generation and goes the same way. +func TestHookReconcileRemovesDuplicateAndRetiredOwnedEntries(t *testing.T) { + fixture := newCursorHookFixture(t) + fixture.apply(t) + converged := fixture.decodeHooks(t) + events := converged["hooks"].(map[string]any) + entries := events["preToolUse"].([]any) + events["preToolUse"] = append(entries, entries[0], map[string]any{"command": "loaf session start"}) + fixture.writeHooksValue(t, converged) + + actions := fixture.apply(t) + + if len(actions) != 2 { + t.Fatalf("actions = %s, want the duplicate and the retired generation removed", describeHookActions(actions)) + } + for _, action := range actions { + if action.action != hookActionRemove { + t.Fatalf("actions = %s, want removals only", describeHookActions(actions)) + } + } + if got, want := len(fixture.decodeHooks(t)["hooks"].(map[string]any)["preToolUse"].([]any)), len(entries); got != want { + t.Fatalf("preToolUse has %d entries after the reconcile, want the converged %d", got, want) + } +} + +// The migration matrix. Each case is one shape of "what was here before", and +// absorption fires exactly once, for the generation that shipped it. +func TestHookReconcileMigrationMatrix(t *testing.T) { + t.Run("no-manifest legacy upgrade absorbs the deleted cohort hook", func(t *testing.T) { + fixture := newCursorHookFixture(t) + fixture.writeHooks(t, testHookCursorFileWithout(t, "session-start-loaf")) + + actions := fixture.apply(t) + + if !testHookHasAction(actions, hookActionAbsorb, "session-start-loaf") { + t.Fatalf("actions = %s, want the deleted 0.2.20 hook absorbed without a manifest", describeHookActions(actions)) + } + marker, _, err := fixture.store.GetHookAbsorptionMarker(t.Context(), "cursor") + if err != nil { + t.Fatalf("GetHookAbsorptionMarker error = %v", err) + } + if marker.AbsorbedFromVersion != "unknown" { + t.Fatalf("marker version = %q, want the unidentifiable prior install named as such", marker.AbsorbedFromVersion) + } + }) + + t.Run("normal upgrade absorbs from the recorded prior version", func(t *testing.T) { + fixture := newCursorHookFixture(t) + fixture.writeHooks(t, testHookCursorFileWithout(t, "session-start-loaf")) + fixture.writeInstalledManifest(t, testHookPriorVersion) + + actions := fixture.apply(t) + + if !testHookHasAction(actions, hookActionAbsorb, "session-start-loaf") { + t.Fatalf("actions = %s, want the deleted hook absorbed", describeHookActions(actions)) + } + marker, _, err := fixture.store.GetHookAbsorptionMarker(t.Context(), "cursor") + if err != nil { + t.Fatalf("GetHookAbsorptionMarker error = %v", err) + } + if marker.AbsorbedFromVersion != testHookPriorVersion { + t.Fatalf("marker version = %q, want %q", marker.AbsorbedFromVersion, testHookPriorVersion) + } + }) + + t.Run("repeat upgrade absorbs nothing", func(t *testing.T) { + fixture := newCursorHookFixture(t) + fixture.writeHooks(t, testHookCursorFileWithout(t, "session-start-loaf")) + fixture.writeInstalledManifest(t, testHookPriorVersion) + fixture.apply(t) + + // The operator deletes another entry between runs. After absorption a + // deletion says nothing, so it comes back. + fixture.deleteEntry(t, "preToolUse", "check-"+"sec"+"rets") + actions := fixture.apply(t) + + if testHookHasAnyAction(actions, hookActionAbsorb) { + t.Fatalf("actions = %s, want absorption to have run once", describeHookActions(actions)) + } + if !testHookHasAction(actions, hookActionAdd, "check-"+"sec"+"rets") { + t.Fatalf("actions = %s, want the hand-deleted entry re-added", describeHookActions(actions)) + } + }) + + t.Run("reinstall after the marker exists absorbs nothing", func(t *testing.T) { + fixture := newCursorHookFixture(t) + fixture.apply(t) + if err := os.Remove(fixture.hooks); err != nil { + t.Fatalf("Remove(hooks) error = %v", err) + } + + actions := fixture.apply(t) + + if testHookHasAnyAction(actions, hookActionAbsorb) { + t.Fatalf("actions = %s, want a reinstall to project rather than absorb", describeHookActions(actions)) + } + if !testHookHasAnyAction(actions, hookActionAdd) { + t.Fatalf("actions = %s, want the catalog reprojected", describeHookActions(actions)) + } + }) + + t.Run("downgrade then re-upgrade absorbs nothing", func(t *testing.T) { + fixture := newCursorHookFixture(t) + fixture.writeInstalledManifest(t, testHookPriorVersion) + fixture.apply(t) + // A downgrade rewrites the installed manifest to the older version and + // takes its entries with it; the marker outlives both. + fixture.writeInstalledManifest(t, "0.2.19") + fixture.writeHooks(t, testHookCursorFileWithout(t, "session-start-loaf")) + + actions := fixture.apply(t) + + if testHookHasAnyAction(actions, hookActionAbsorb) { + t.Fatalf("actions = %s, want the durable marker to hold across a downgrade", describeHookActions(actions)) + } + }) + + t.Run("a manifest naming an unenumerated version absorbs nothing", func(t *testing.T) { + fixture := newCursorHookFixture(t) + fixture.writeHooks(t, testHookCursorFileWithout(t, "session-start-loaf")) + // The install is identified and its cohort is not recorded, so nothing + // its file lacks is evidence of anything. + fixture.writeInstalledManifest(t, "0.1.4") + + actions := fixture.apply(t) + + if testHookHasAnyAction(actions, hookActionAbsorb) { + t.Fatalf("actions = %s, want no absorption from an unenumerated cohort", describeHookActions(actions)) + } + if !testHookHasAction(actions, hookActionAdd, "session-start-loaf") { + t.Fatalf("actions = %s, want the missing hook projected as enabled", describeHookActions(actions)) + } + marker, marked, err := fixture.store.GetHookAbsorptionMarker(t.Context(), "cursor") + if err != nil || !marked { + t.Fatalf("marker = %v, %v, want the migration window closed anyway", marked, err) + } + if marker.AbsorbedFromVersion != "0.1.4" { + t.Fatalf("marker version = %q, want the identified prior version recorded", marker.AbsorbedFromVersion) + } + }) + + // The canary is alpha.19: its Codex manifest still says so because the drift + // refusal this Change removes is what stopped the manifest from being + // rewritten, and that release is 0.2.19 under the old spelling — the same + // generation 0.2.20 shipped. Reading it as an unknown version is what made + // the first dry-run offer to re-add the hook the operator deleted. + // + // Every enumerated release is exercised, and the list below is written out + // rather than read from hookCatalogPreResetVersions on purpose: a test that + // iterated the production slice would lose a case exactly when a literal + // went missing from it, which is the regression this is here to catch. + t.Run("a manifest naming a pre-reset release absorbs its cohort", func(t *testing.T) { + enumerated := []string{ + "2.0.0-alpha.14", + "2.0.0-alpha.15", + "2.0.0-alpha.16", + "2.0.0-alpha.17", + "2.0.0-alpha.18", + "2.0.0-alpha.19", + } + // Drift in the other direction — a version added to the catalog with no + // case here — would otherwise go unexercised. + if len(enumerated) != len(hookCatalogPreResetVersions) { + t.Fatalf("catalog enumerates %d pre-reset releases, this table covers %d; they have to move together", len(hookCatalogPreResetVersions), len(enumerated)) + } + for _, version := range enumerated { + t.Run(version, func(t *testing.T) { + fixture := newCodexHookFixture(t) + fixture.writeHooks(t, string(testHookFixture(t, "codex-hooks-live.json"))) + fixture.writeInstalledManifest(t, version) + + if cohort := hookAbsorptionCohort(fixture.catalog(t), version, true); len(cohort) == 0 { + t.Fatalf("cohort for %s is empty, want the enumerated generation", version) + } + + actions := fixture.apply(t) + + if !testHookHasAction(actions, hookActionAbsorb, "session-start-loaf") { + t.Fatalf("actions = %s, want the deleted hook absorbed from the pre-reset cohort", describeHookActions(actions)) + } + if testHookHasAction(actions, hookActionAdd, "session-start-loaf") { + t.Fatalf("actions = %s, want no re-add of the hook the operator deleted", describeHookActions(actions)) + } + row, found, err := fixture.store.GetHookEnablement(t.Context(), "codex", "SessionStart", "session-start-loaf") + if err != nil || !found || row.Enablement != state.HookEnablementDisabled { + t.Fatalf("record = %#v, %v, %v, want the disable intent recorded", row, found, err) + } + marker, marked, err := fixture.store.GetHookAbsorptionMarker(t.Context(), "codex") + if err != nil || !marked || marker.AbsorbedFromVersion != version { + t.Fatalf("marker = %#v, %v, %v, want %s recorded as provenance", marker, marked, err, version) + } + }) + } + }) + + // The enumeration stops where the evidence does. alpha.13 shipped 16 Cursor + // entries, so a hook it never had is not something its operator deleted. + t.Run("a pre-reset release below the enumerated floor absorbs nothing", func(t *testing.T) { + fixture := newCodexHookFixture(t) + fixture.writeHooks(t, string(testHookFixture(t, "codex-hooks-live.json"))) + fixture.writeInstalledManifest(t, "2.0.0-alpha.13") + + actions := fixture.apply(t) + + if testHookHasAnyAction(actions, hookActionAbsorb) { + t.Fatalf("actions = %s, want no absorption below the enumerated floor", describeHookActions(actions)) + } + if !testHookHasAction(actions, hookActionAdd, "session-start-loaf") { + t.Fatalf("actions = %s, want the hook projected as enabled instead", describeHookActions(actions)) + } + }) + + // The family is an enumeration, not a loosening: a version nobody recorded + // still absorbs nothing, whichever spelling it uses. + t.Run("an unenumerated future version still absorbs nothing", func(t *testing.T) { + for _, version := range []string{"0.2.25", "2.0.0-alpha.20", "2.0.0-dev.49", "2.0.0-pre.20260614235428"} { + t.Run(version, func(t *testing.T) { + fixture := newCodexHookFixture(t) + fixture.writeHooks(t, string(testHookFixture(t, "codex-hooks-live.json"))) + fixture.writeInstalledManifest(t, version) + + actions := fixture.apply(t) + + if testHookHasAnyAction(actions, hookActionAbsorb) { + t.Fatalf("actions = %s, want no absorption for an unenumerated version", describeHookActions(actions)) + } + }) + } + }) + + t.Run("only the prior cohort absorbs", func(t *testing.T) { + fixture := newCursorHookFixture(t) + // One hook the prior version shipped and one introduced after it, both + // absent. Absence of a hook that was never installed says nothing. + fixture.writeHooks(t, testHookCursorFileWithout(t, "session-start-loaf", "artifact-names")) + fixture.writeInstalledManifest(t, testHookPriorVersion) + reconciler := fixture.reconciler(t) + reconciler.catalog = testHookCatalogWithCohort(t, fixture.catalog(t), "artifact-names") + actions, err := reconciler.apply(t.Context()) + if err != nil { + t.Fatalf("apply error = %v", err) + } + + if !testHookHasAction(actions, hookActionAbsorb, "session-start-loaf") { + t.Fatalf("actions = %s, want the prior cohort's hook absorbed", describeHookActions(actions)) + } + if testHookHasAction(actions, hookActionAbsorb, "artifact-names") { + t.Fatalf("actions = %s, want a hook outside the prior cohort projected, not absorbed", describeHookActions(actions)) + } + if !testHookHasAction(actions, hookActionAdd, "artifact-names") { + t.Fatalf("actions = %s, want the newly introduced hook added enabled", describeHookActions(actions)) + } + }) +} + +// The integrity preconditions. Every one of them preserves the file exactly as +// written and says which file and why. +func TestHookReconcileIntegrityPreconditionsPreserveTheFile(t *testing.T) { + for _, testCase := range []struct { + name string + body string + want string + }{ + {name: "malformed JSON", body: `{"hooks":`, want: "parse hooks file"}, + {name: "non-object top level", body: `["hooks"]`, want: "parse hooks file"}, + {name: "null top level", body: `null`, want: "parse hooks file"}, + {name: "hooks is not an object", body: `{"hooks":[]}`, want: `"hooks" must be an object`}, + {name: "event is not an array", body: `{"hooks":{"SessionStart":{}}}`, want: "must be an array"}, + {name: "event is null", body: `{"hooks":{"SessionStart":null}}`, want: "must be an array"}, + {name: "entry is not an object", body: `{"hooks":{"SessionStart":["hook"]}}`, want: "must be an object"}, + {name: "duplicate keys", body: `{"hooks":{},"hooks":{}}`, want: "parse hooks file"}, + } { + t.Run(testCase.name, func(t *testing.T) { + fixture := newCodexHookFixture(t) + fixture.writeHooks(t, testCase.body) + + _, err := fixture.reconciler(t).apply(t.Context()) + if err == nil || !strings.Contains(err.Error(), testCase.want) { + t.Fatalf("apply error = %v, want a refusal naming %q", err, testCase.want) + } + if !strings.Contains(err.Error(), "preserving it as written") { + t.Fatalf("apply error = %v, want the preservation promise stated", err) + } + fixture.assertHooksUnchanged(t, testCase.body) + }) + } +} + +func TestHookReconcileRefusesASymlinkedHooksFile(t *testing.T) { + fixture := newCodexHookFixture(t) + target := filepath.Join(fixture.root, "elsewhere.json") + writeInstallFile(t, target, `{"hooks":{}}`) + if err := os.MkdirAll(filepath.Dir(fixture.hooks), 0o755); err != nil { + t.Fatalf("MkdirAll error = %v", err) + } + if err := os.Symlink(target, fixture.hooks); err != nil { + t.Fatalf("Symlink error = %v", err) + } + + _, err := fixture.reconciler(t).apply(t.Context()) + if err == nil || !strings.Contains(err.Error(), "not a regular file") { + t.Fatalf("apply error = %v, want a non-regular-destination refusal", err) + } + assertInstallFile(t, target, `{"hooks":{}}`) +} + +func TestHookReconcileRefusesAnUnreadableHooksFile(t *testing.T) { + fixture := newCodexHookFixture(t) + fixture.writeHooks(t, `{"hooks":{}}`) + if err := os.Chmod(fixture.hooks, 0o000); err != nil { + t.Fatalf("Chmod error = %v", err) + } + t.Cleanup(func() { os.Chmod(fixture.hooks, 0o644) }) + + _, err := fixture.reconciler(t).apply(t.Context()) + if err == nil || !strings.Contains(err.Error(), "read hooks file") { + t.Fatalf("apply error = %v, want the read failure reported", err) + } +} + +// A writer that does not honour the lock is caught by the pre-rename +// comparison: the publication aborts and the third party's bytes stand. +func TestHookReconcileAbortsOnConcurrentModification(t *testing.T) { + fixture := newCodexHookFixture(t) + fixture.writeHooks(t, `{"hooks":{"SessionStart":[]}}`) + third := `{"hooks":{"SessionStart":[{"matcher":"startup","hooks":[{"type":"command","command":"third party"}]}]}}` + + reconciler := fixture.reconciler(t) + reconciler.operations = &hookReconcileOperations{beforeRename: func() error { + writeInstallFile(t, fixture.hooks, third) + return nil + }} + _, err := reconciler.apply(t.Context()) + + if err == nil || !strings.Contains(err.Error(), "changed while Loaf was reconciling it") { + t.Fatalf("apply error = %v, want a concurrent-modification abort", err) + } + fixture.assertHooksUnchanged(t, third) +} + +// Decision 10's retry-safe window: records commit, the process dies before the +// file is written, and the next reconcile converges the file to the records. +func TestHookReconcileConvergesAfterACrashBetweenRecordsAndProjection(t *testing.T) { + fixture := newCodexHookFixture(t) + fixture.writeHooks(t, string(testHookFixture(t, "codex-hooks-live.json"))) + fixture.writeInstalledManifest(t, testHookPriorVersion) + + reconciler := fixture.reconciler(t) + reconciler.operations = &hookReconcileOperations{afterRecords: func() error { + return errHookCrashInjection + }} + if _, err := reconciler.apply(t.Context()); err == nil { + t.Fatal("apply error = nil, want the injected crash") + } + if _, marked, err := fixture.store.GetHookAbsorptionMarker(t.Context(), "codex"); err != nil || !marked { + t.Fatalf("marker = %v, %v, want the records committed before the crash", marked, err) + } + + actions := fixture.apply(t) + if len(actions) != 0 { + t.Fatalf("actions = %s, want the retried reconcile to find the records already absorbed", describeHookActions(actions)) + } + fixture.assertForeignSurvives(t, "SessionStart", "bash '/Users/canary/.config/codex/herdr-agent-state.sh' session") +} + +// The same window with entries to write rather than records to absorb: the +// file is one reconcile behind the records, and the retry closes the gap +// without the crashed run having left anything half-written. +func TestHookReconcileRetriesAfterACrashBeforeTheFileIsWritten(t *testing.T) { + fixture := newCursorHookFixture(t) + reconciler := fixture.reconciler(t) + reconciler.operations = &hookReconcileOperations{afterRecords: func() error { + return errHookCrashInjection + }} + if _, err := reconciler.apply(t.Context()); !errors.Is(err, errHookCrashInjection) { + t.Fatalf("apply error = %v, want the injected crash", err) + } + if _, err := os.Stat(fixture.hooks); !os.IsNotExist(err) { + t.Fatalf("hooks file stat = %v, want nothing written before the crash", err) + } + + actions := fixture.apply(t) + if !testHookHasAnyAction(actions, hookActionAdd) { + t.Fatalf("actions = %s, want the retry to project the catalog", describeHookActions(actions)) + } + if actions := fixture.apply(t); len(actions) != 0 { + t.Fatalf("actions = %s, want the retried run to have converged", describeHookActions(actions)) + } +} + +// One writer at a time. The verb and an upgrade are the same code path, so this +// is the interleaving the shape names, exercised with the reconciler itself. +func TestHookReconcileSerializesConcurrentWriters(t *testing.T) { + fixture := newCursorHookFixture(t) + lock, err := acquireHookFileLock(fixture.hooks, hookFileLockWait) + if err != nil { + t.Fatalf("acquireHookFileLock error = %v", err) + } + + reconciler := fixture.reconciler(t) + reconciler.lockWait = 20 * time.Millisecond + _, err = reconciler.apply(t.Context()) + if err == nil || !strings.Contains(err.Error(), "another Loaf process is reconciling") { + t.Fatalf("apply error = %v, want an actionable contention failure", err) + } + if _, statErr := os.Stat(fixture.hooks); !os.IsNotExist(statErr) { + t.Fatalf("hooks file stat = %v, want the contended run to write nothing", statErr) + } + + if err := lock.release(); err != nil { + t.Fatalf("release error = %v", err) + } + if actions := fixture.apply(t); len(actions) == 0 { + t.Fatal("apply after release took no actions, want the reconcile to proceed") + } +} + +// Serialization, not just refusal: a writer that arrives while another holds +// the lock waits for it and then does its work against the file the first one +// left behind. +func TestHookReconcileWaitsForTheLockAndThenProceeds(t *testing.T) { + fixture := newCodexHookFixture(t) + lock, err := acquireHookFileLock(fixture.hooks, hookFileLockWait) + if err != nil { + t.Fatalf("acquireHookFileLock error = %v", err) + } + + released := make(chan struct{}) + go func() { + time.Sleep(30 * time.Millisecond) + close(released) + if err := lock.release(); err != nil { + panic(err) + } + }() + + actions, err := fixture.reconciler(t).apply(t.Context()) + <-released + if err != nil { + t.Fatalf("apply error = %v, want the waiting writer to proceed", err) + } + if !testHookHasAnyAction(actions, hookActionAdd) { + t.Fatalf("actions = %s, want the waiting writer to project the catalog", describeHookActions(actions)) + } +} + +// Windows parity is concrete rather than asserted: the entry the reconciler +// writes there carries command and commandWindows with the same cmd.exe form, +// and recognizing it back is what makes the second run a no-op. +func TestHookReconcileProjectsWindowsCommandParity(t *testing.T) { + fixture := newCodexHookFixture(t) + executable := `C:\Users\canary\AppData\Local\loaf\loaf.exe` + windows := func() *hookReconciler { + reconciler := fixture.reconciler(t) + reconciler.goos = "windows" + reconciler.homeDir = `C:\Users\canary` + reconciler.resolveExecutable = func() (string, error) { return executable, nil } + return reconciler + } + + if _, err := windows().apply(t.Context()); err != nil { + t.Fatalf("apply error = %v", err) + } + + file, err := readHookFile(fixture.hooks) + if err != nil { + t.Fatalf("readHookFile error = %v", err) + } + entries, err := file.eventEntries("SessionStart") + if err != nil { + t.Fatalf("eventEntries error = %v", err) + } + handler := entries[0]["hooks"].([]any)[0].(map[string]any) + want, err := codexWindowsJournalContextCommand(executable) + if err != nil { + t.Fatalf("codexWindowsJournalContextCommand error = %v", err) + } + if handler["command"] != want || handler["commandWindows"] != want { + t.Fatalf("handler = %#v, want command and commandWindows both %q", handler, want) + } + + actions, err := windows().apply(t.Context()) + if err != nil { + t.Fatalf("second apply error = %v", err) + } + if len(actions) != 0 { + t.Fatalf("actions = %s, want the Windows projection recognized as converged", describeHookActions(actions)) + } + + // Loaf moves. The entry written against the old path is still recognized — + // the recorded trusted path says so — and converges to the new one in place + // rather than being orphaned beside a second group. + rotated := `C:\Program Files\loaf\loaf.exe` + moved := windows() + moved.resolveExecutable = func() (string, error) { return rotated, nil } + actions, err = moved.apply(t.Context()) + if err != nil { + t.Fatalf("rotated apply error = %v", err) + } + if len(actions) != 1 || actions[0].action != hookActionUpdate { + t.Fatalf("actions = %s, want the moved executable converged in place", describeHookActions(actions)) + } + file, err = readHookFile(fixture.hooks) + if err != nil { + t.Fatalf("readHookFile error = %v", err) + } + entries, err = file.eventEntries("SessionStart") + if err != nil { + t.Fatalf("eventEntries error = %v", err) + } + if len(entries) != 1 { + t.Fatalf("entries = %#v, want the rotation to converge one entry rather than add another", entries) + } + handler = entries[0]["hooks"].([]any)[0].(map[string]any) + want, err = codexWindowsJournalContextCommand(rotated) + if err != nil { + t.Fatalf("codexWindowsJournalContextCommand error = %v", err) + } + if handler["command"] != want || handler["commandWindows"] != want { + t.Fatalf("handler = %#v, want both command fields rotated to %q", handler, want) + } +} + +// The race the shape names: an upgrade that reads enabled must not write the +// stale add if a disable commits first. Apply recomputes inside the lock, so +// the record the operator just wrote wins. +func TestHookReconcileRecomputesActionsInsideTheLock(t *testing.T) { + fixture := newCodexHookFixture(t) + fixture.apply(t) + stale := fixture.reconciler(t) + + // A disable lands between the plan and the apply, exactly as the verb would + // write it while an upgrade was starting. + if _, err := fixture.store.SetHookEnablement(t.Context(), "codex", "SessionStart", "session-start-loaf", false); err != nil { + t.Fatalf("SetHookEnablement error = %v", err) + } + actions, err := stale.apply(t.Context()) + if err != nil { + t.Fatalf("apply error = %v", err) + } + if len(actions) != 1 || actions[0].action != hookActionRemove { + t.Fatalf("actions = %s, want the fresh disable honoured", describeHookActions(actions)) + } +} + +// A recorded install path keeps recognizing the entries written before Loaf +// moved, and records never leak across targets. +func TestHookReconcileRecordsTrustedExecutablePathsPerTarget(t *testing.T) { + fixture := newCursorHookFixture(t) + reconciler := fixture.reconciler(t) + reconciler.resolveExecutable = func() (string, error) { return "/opt/homebrew/bin/loaf", nil } + if _, err := reconciler.apply(t.Context()); err != nil { + t.Fatalf("first apply error = %v", err) + } + + relocated := fixture.reconciler(t) + relocated.resolveExecutable = func() (string, error) { return "/usr/local/bin/loaf", nil } + if _, err := relocated.apply(t.Context()); err != nil { + t.Fatalf("relocated apply error = %v", err) + } + + paths, err := fixture.store.ListHookTrustedPaths(t.Context(), "cursor") + if err != nil { + t.Fatalf("ListHookTrustedPaths error = %v", err) + } + if len(paths) != 2 { + t.Fatalf("recorded paths = %#v, want the relocation recorded and the previous path kept", paths) + } + if !paths[0].IsCurrent || paths[0].Path != "/usr/local/bin/loaf" { + t.Fatalf("current path = %#v, want the relocated executable", paths[0]) + } + if codex, err := fixture.store.ListHookTrustedPaths(t.Context(), "codex"); err != nil || len(codex) != 0 { + t.Fatalf("codex paths = %#v, %v, want records isolated per target", codex, err) + } + + // An entry quoting the previous path is still recognized as Loaf's. + records, err := loadHookRecords(t.Context(), fixture.store, "cursor") + if err != nil { + t.Fatalf("loadHookRecords error = %v", err) + } + ownership, err := relocated.recognition(records).ownsEntry(map[string]any{"command": "'/opt/homebrew/bin/loaf' task refresh"}) + if err != nil { + t.Fatalf("ownsEntry error = %v", err) + } + if !ownership.owned || ownership.hookID != "generate-task-board" { + t.Fatalf("ownsEntry = %#v, want the previously recorded path still recognized", ownership) + } +} + +// A distribution whose catalog and manifest disagree about which release they +// are is stale on one side or the other, and neither the desired entries nor +// the cohort bound can be trusted. It fails closed before anything is written. +func TestHookReconcileRefusesAStaleDistribution(t *testing.T) { + t.Run("catalog and manifest disagree", func(t *testing.T) { + fixture := newCursorHookFixture(t) + if err := generateNativeCursorHookCatalog(filepath.Join(testRepositoryRoot(t), "config", "hooks.yaml"), fixture.dist, "0.2.20"); err != nil { + t.Fatalf("generateNativeCursorHookCatalog error = %v", err) + } + + _, err := newHookReconciler(fixture.options) + if err == nil || !strings.Contains(err.Error(), "stale") { + t.Fatalf("newHookReconciler error = %v, want a stale-distribution refusal", err) + } + if _, statErr := os.Stat(fixture.hooks); !os.IsNotExist(statErr) { + t.Fatalf("hooks file stat = %v, want nothing written", statErr) + } + }) + + // The other direction of the same disagreement. Which half was left behind + // is not knowable from inside, so both spellings refuse rather than one of + // them guessing that the newer half is authoritative. + t.Run("manifest older than catalog", func(t *testing.T) { + fixture := newCursorHookFixture(t) + writeInstallFile(t, filepath.Join(fixture.dist, targetBuildManifestFile), testHookTargetManifestBody(t, "cursor", "0.2.20", fixture.dist)) + + _, err := newHookReconciler(fixture.options) + if err == nil || !strings.Contains(err.Error(), "stale") { + t.Fatalf("newHookReconciler error = %v, want a stale-distribution refusal", err) + } + if !strings.Contains(err.Error(), "9.8.7-test.1") || !strings.Contains(err.Error(), "0.2.20") { + t.Fatalf("newHookReconciler error = %v, want both versions named", err) + } + if _, statErr := os.Stat(fixture.hooks); !os.IsNotExist(statErr) { + t.Fatalf("hooks file stat = %v, want nothing written", statErr) + } + }) + + t.Run("no target manifest at all", func(t *testing.T) { + fixture := newCursorHookFixture(t) + if err := os.Remove(filepath.Join(fixture.dist, targetBuildManifestFile)); err != nil { + t.Fatalf("Remove(manifest) error = %v", err) + } + + _, err := newHookReconciler(fixture.options) + if err == nil || !strings.Contains(err.Error(), "stale") { + t.Fatalf("newHookReconciler error = %v, want a stale-distribution refusal", err) + } + }) +} + +// Decision 10's lock lifetime is over the whole sequence, so the lock is taken +// before any state is read. A run that never gets the lock never opens state. +func TestHookReconcileTakesTheLockBeforeReadingState(t *testing.T) { + fixture := newCursorHookFixture(t) + lock, err := acquireHookFileLock(fixture.hooks, hookFileLockWait) + if err != nil { + t.Fatalf("acquireHookFileLock error = %v", err) + } + defer lock.release() + + opened := 0 + reconciler := fixture.reconciler(t) + reconciler.lockWait = 20 * time.Millisecond + reconciler.state = func() (*state.Store, error) { + opened++ + return fixture.store, nil + } + + if _, err := reconciler.apply(t.Context()); err == nil || !strings.Contains(err.Error(), "another Loaf process") { + t.Fatalf("apply error = %v, want a contention failure", err) + } + if opened != 0 { + t.Fatalf("state opened %d times before the lock was held, want 0", opened) + } +} + +// The regression this Change exists to prevent, stated as an ordering test: the +// records commit before the caller replaces the installed manifest, so a run +// that dies in between still knows the hook was deliberately absent. +func TestHookReconcileRecordsAbsorptionBeforeTheManifestIsReplaced(t *testing.T) { + fixture := newCodexHookFixture(t) + live := string(testHookFixture(t, "codex-hooks-live.json")) + fixture.writeHooks(t, live) + fixture.writeInstalledManifest(t, testHookPriorVersion) + + reconciler := fixture.reconciler(t) + if err := reconciler.begin(t.Context()); err != nil { + t.Fatalf("begin error = %v", err) + } + // The adapter sync replaces the prior release's manifest with this one's, + // and the run dies before the file half ever happens. + fixture.writeInstalledManifest(t, "9.8.7-test.1") + releaseHookReconcile(reconciler) + + actions := fixture.apply(t) + + if len(actions) != 0 { + t.Fatalf("actions = %s, want the retry to find the absorption already recorded", describeHookActions(actions)) + } + row, found, err := fixture.store.GetHookEnablement(t.Context(), "codex", "SessionStart", "session-start-loaf") + if err != nil || !found || row.Enablement != state.HookEnablementDisabled { + t.Fatalf("record = %#v, %v, %v, want the disable intact after the manifest moved on", row, found, err) + } + fixture.assertHooksUnchanged(t, live) +} + +// State that cannot be read is not state that says "enabled". A reconcile whose +// authority is unavailable fails closed with the file untouched, because the +// alternative is silently restoring a hook the operator disabled. +func TestHookReconcileFailsClosedWhenEnablementStateIsUnavailable(t *testing.T) { + fixture := newCursorHookFixture(t) + unavailable := errors.New("state database is invalid: schema version 41 does not match expected version 42") + + for name, resolver := range map[string]hookStateResolver{ + "unreadable": func() (*state.Store, error) { return nil, unavailable }, + "absent": func() (*state.Store, error) { return nil, nil }, + "unset": nil, + } { + t.Run(name, func(t *testing.T) { + reconciler := fixture.reconciler(t) + reconciler.state = resolver + + if _, err := reconciler.apply(t.Context()); err == nil { + t.Fatal("apply error = nil, want a fail-closed refusal") + } + if _, err := os.Stat(fixture.hooks); !os.IsNotExist(err) { + t.Fatalf("hooks file stat = %v, want nothing written", err) + } + }) + } +} + +// A plan is allowed to run on a host that has never recorded anything: with no +// database there is nothing to read, every hook reads enabled, and the plan +// still refuses to create one. +func TestHookReconcilePlanToleratesAnAbsentStateDatabase(t *testing.T) { + fixture := newCursorHookFixture(t) + reconciler := fixture.reconciler(t) + reconciler.state = func() (*state.Store, error) { return nil, nil } + + actions, err := reconciler.plan(t.Context()) + if err != nil { + t.Fatalf("plan error = %v", err) + } + if !testHookHasAnyAction(actions, hookActionAdd) { + t.Fatalf("actions = %s, want the catalog planned as enabled", describeHookActions(actions)) + } +} + +// "Nothing recorded yet" and "this is not Loaf's database" fail an ordinary +// schema read identically, and only the first of them means every hook is +// enabled. Reading a foreign database as empty would report the operator's +// disabled hooks as enabled and call that a plan, so the readable-but-foreign +// case fails closed with the underlying reason — the same answer apply gives. +func TestHookReconcilePlanDistinguishesEmptyStateFromUnreadableState(t *testing.T) { + t.Run("uninitialized plans as empty", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "loaf.sqlite") + writeFile(t, path, "") + + store, err := openHookStateStoreReadOnly(path) + if err != nil { + t.Fatalf("openHookStateStoreReadOnly error = %v, want an uninitialized database to read as empty", err) + } + if store != nil { + store.Close() + t.Fatal("openHookStateStoreReadOnly returned a store for an uninitialized database, want no records") + } + }) + + t.Run("readable non-Loaf database fails closed", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "loaf.sqlite") + testHookWriteForeignDatabase(t, path) + + store, err := openHookStateStoreReadOnly(path) + if store != nil { + store.Close() + } + if err == nil { + t.Fatal("openHookStateStoreReadOnly error = nil, want a foreign database to fail the plan closed") + } + if !strings.Contains(err.Error(), "cannot be read") || !strings.Contains(err.Error(), path) { + t.Fatalf("error = %v, want it to name the unreadable state and its path", err) + } + }) + + // The plan's verdict has to be the one apply would reach, or a dry run + // promises work the real run refuses. + t.Run("apply refuses the same database", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "loaf.sqlite") + testHookWriteForeignDatabase(t, path) + + store, err := state.OpenStore(path) + if err != nil { + t.Fatalf("OpenStore error = %v", err) + } + defer store.Close() + bootstrapped, err := store.BootstrapIfEmpty(t.Context()) + if err != nil { + t.Fatalf("BootstrapIfEmpty error = %v", err) + } + if bootstrapped { + t.Fatal("BootstrapIfEmpty bootstrapped a database carrying somebody else's tables") + } + if err := store.RequireCurrentSchema(t.Context()); err == nil { + t.Fatal("RequireCurrentSchema error = nil, want apply to refuse the foreign database too") + } + }) +} + +// testHookWriteForeignDatabase writes a perfectly readable SQLite file that is +// simply not Loaf's: real tables, no schema ledger. +func testHookWriteForeignDatabase(t *testing.T, path string) { + t.Helper() + db, err := sql.Open("sqlite3", "file:"+filepath.ToSlash(path)) + if err != nil { + t.Fatalf("sql.Open error = %v", err) + } + defer db.Close() + if _, err := db.Exec(`CREATE TABLE somebody_elses_notes (id INTEGER PRIMARY KEY, body TEXT)`); err != nil { + t.Fatalf("create foreign table error = %v", err) + } + if _, err := db.Exec(`INSERT INTO somebody_elses_notes (body) VALUES ('not loaf')`); err != nil { + t.Fatalf("seed foreign table error = %v", err) + } +} + +// A plan reads state and leaves it byte-for-byte as it found it — no bootstrap, +// no journal-mode change, nothing. The uninitialized case matters most: that is +// the one a writable open would have silently converted. The live-WAL case is +// the one a byte check of the main file alone would miss: a writable open +// against a database another process is holding in WAL could checkpoint it, +// which moves data between the sidecar and the main file without either one +// looking obviously wrong on its own. +func TestHookReconcilePlanLeavesTheStateDatabaseUnchanged(t *testing.T) { + for _, testCase := range []struct { + name string + seed func(t *testing.T, path string) + }{ + {name: "uninitialized", seed: func(t *testing.T, path string) { writeFile(t, path, "") }}, + {name: "migrated", seed: func(t *testing.T, path string) { + store, err := state.OpenStore(path) + if err != nil { + t.Fatalf("OpenStore error = %v", err) + } + if err := store.ApplyMigrations(context.Background()); err != nil { + t.Fatalf("ApplyMigrations error = %v", err) + } + store.Close() + }}, + // The discriminating case for journal mode. Loaf's writable DSN asks for + // WAL, and SQLite records that choice in the file header permanently, so + // a plan that opened this database writably would convert it and hand + // the operator's database back in a mode they never chose. + {name: "rollback journal mode", seed: func(t *testing.T, path string) { + store, err := state.OpenStore(path) + if err != nil { + t.Fatalf("OpenStore error = %v", err) + } + if err := store.ApplyMigrations(context.Background()); err != nil { + t.Fatalf("ApplyMigrations error = %v", err) + } + store.Close() + testHookSetJournalMode(t, path, "delete") + if mode := testHookJournalMode(t, path); mode != "delete" { + t.Fatalf("seeded journal mode = %q, want delete", mode) + } + }}, + {name: "live wal sidecars", seed: func(t *testing.T, path string) { + // The writer stays open for the whole test, which is what keeps the + // -wal and -shm files present and populated rather than checkpointed + // away by a clean close. + store, err := state.OpenStore(path) + if err != nil { + t.Fatalf("OpenStore error = %v", err) + } + t.Cleanup(func() { store.Close() }) + if err := store.ApplyMigrations(context.Background()); err != nil { + t.Fatalf("ApplyMigrations error = %v", err) + } + if _, err := store.RecordHookTrustedPath(context.Background(), "cursor", "/Users/canary/.local/bin/loaf"); err != nil { + t.Fatalf("RecordHookTrustedPath error = %v", err) + } + for _, suffix := range []string{"-wal", "-shm"} { + if _, err := os.Stat(path + suffix); err != nil { + t.Fatalf("Stat(%s) error = %v, want a populated sidecar before the plan", path+suffix, err) + } + } + }}, + } { + t.Run(testCase.name, func(t *testing.T) { + fixture := newCursorHookFixture(t) + path := filepath.Join(t.TempDir(), "loaf.sqlite") + testCase.seed(t, path) + before := testHookStateFileSnapshot(t, path) + + reconciler := fixture.reconciler(t) + reconciler.state = func() (*state.Store, error) { return openHookStateStoreReadOnly(path) } + actions, err := reconciler.plan(t.Context()) + if err != nil { + t.Fatalf("plan error = %v", err) + } + if !testHookHasAnyAction(actions, hookActionAdd) { + t.Fatalf("actions = %s, want the catalog planned", describeHookActions(actions)) + } + + after := testHookStateFileSnapshot(t, path) + for _, suffix := range []string{"", "-wal"} { + if bytes.Equal(before[suffix], after[suffix]) { + continue + } + // A journal-mode flip rewrites the header without changing the + // file's length, so say which mode it landed in rather than + // reporting two identical byte counts. + detail := fmt.Sprintf("%d bytes before, %d after", len(before[suffix]), len(after[suffix])) + if suffix == "" { + detail += fmt.Sprintf("; journal mode is now %q", testHookJournalMode(t, path)) + } + t.Fatalf("the plan changed %s: %s", path+suffix, detail) + } + // The shared-memory index is the one file every reader touches by + // design — SQLite records a read mark in it to hold back + // checkpointing while the read runs, so byte stability there is not + // something any reader can promise. What must hold is that it is + // still there afterwards: a plan that checkpointed the database out + // of WAL mode, or opened it writably enough to reset it, would have + // taken the sidecars with it. + if _, seeded := before["-shm"]; seeded { + if _, survived := after["-shm"]; !survived { + t.Fatalf("the plan removed %s, so it did not leave the database in WAL mode", path+"-shm") + } + } + }) + } +} + +// testHookSetJournalMode rewrites the database's persistent journal mode using +// a connection of the test's own, so a case can start from a mode Loaf's own +// DSN would never leave it in. +func testHookSetJournalMode(t *testing.T, path string, mode string) { + t.Helper() + db, err := sql.Open("sqlite3", "file:"+filepath.ToSlash(path)) + if err != nil { + t.Fatalf("sql.Open error = %v", err) + } + defer db.Close() + var applied string + if err := db.QueryRow(`PRAGMA journal_mode=` + mode).Scan(&applied); err != nil { + t.Fatalf("set journal_mode=%s error = %v", mode, err) + } +} + +func testHookJournalMode(t *testing.T, path string) string { + t.Helper() + db, err := sql.Open("sqlite3", "file:"+filepath.ToSlash(path)+"?mode=ro") + if err != nil { + t.Fatalf("sql.Open error = %v", err) + } + defer db.Close() + var mode string + if err := db.QueryRow(`PRAGMA journal_mode`).Scan(&mode); err != nil { + t.Fatalf("read journal_mode error = %v", err) + } + return mode +} + +// testHookStateFileSnapshot captures the database and both WAL sidecars, since +// a checkpoint moves bytes between them and reading only the main file would +// call that no change at all. A missing sidecar is recorded as absent rather +// than as an error: the non-WAL cases have none. +func testHookStateFileSnapshot(t *testing.T, path string) map[string][]byte { + t.Helper() + snapshot := map[string][]byte{} + for _, suffix := range []string{"", "-wal", "-shm"} { + body, err := os.ReadFile(path + suffix) + if err != nil { + if !os.IsNotExist(err) { + t.Fatalf("ReadFile(%s) error = %v", path+suffix, err) + } + continue + } + snapshot[suffix] = body + } + return snapshot +} + +// Install's ordering, stated against the failure it exists for: the sync that +// replaces the prior release's manifest cannot run before the records are +// durable, so a sync failure leaves both the provenance and the disable intact. +func TestInstallTargetKeepsAbsorptionRecordsWhenTheAdapterSyncFails(t *testing.T) { + fixture := newCodexHookFixture(t) + live := string(testHookFixture(t, "codex-hooks-live.json")) + fixture.writeHooks(t, live) + fixture.writeInstalledManifest(t, testHookPriorVersion) + manifestPath := filepath.Join(fixture.config, targetInstallManifestFile) + manifestBefore, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatalf("ReadFile(installed manifest) error = %v", err) + } + + failing := fixture.options + failing.TargetAdapterOps = &targetAdapterInstallOperations{beforePublish: func() error { return errHookCrashInjection }} + if err := installTargetDistribution(failing); !errors.Is(err, errHookCrashInjection) { + t.Fatalf("install error = %v, want the injected sync failure", err) + } + + row, found, err := fixture.store.GetHookEnablement(t.Context(), "codex", "SessionStart", "session-start-loaf") + if err != nil || !found || row.Enablement != state.HookEnablementDisabled { + t.Fatalf("record = %#v, %v, %v, want the absorption committed before the sync ran", row, found, err) + } + assertInstallFile(t, manifestPath, string(manifestBefore)) + fixture.assertHooksUnchanged(t, live) + + // The retry completes, and the hook the operator deleted stays deleted. + if err := installTargetDistribution(fixture.options); err != nil { + t.Fatalf("retry install error = %v", err) + } + fixture.assertHooksUnchanged(t, live) +} + +// The retired whole-file hooks row an older release left in the installed +// manifest is dropped by the next manifest write — and that write is sequenced +// after the absorption marker is durable, never used as the gate for it. The +// gap between the two is a real crash window, so it is injected here: what the +// crashed run leaves behind must be readable by the retry as exactly the same +// prior install, and the absorption must not happen twice. +func TestInstallTargetDropsTheObsoleteHookRowAfterTheMarkerIsDurable(t *testing.T) { + fixture := newCodexHookFixture(t) + live := string(testHookFixture(t, "codex-hooks-live.json")) + fixture.writeHooks(t, live) + fixture.writeInstalledManifest(t, testHookPriorVersion) + manifestPath := filepath.Join(fixture.config, targetInstallManifestFile) + if !strings.Contains(string(readFileBytes(t, manifestPath)), obsoleteHookProjectionKind) { + t.Fatal("the prior release's manifest carries no obsolete hooks row; this test has no subject") + } + + failing := fixture.options + failing.TargetAdapterOps = &targetAdapterInstallOperations{beforePublish: func() error { return errHookCrashInjection }} + if err := installTargetDistribution(failing); !errors.Is(err, errHookCrashInjection) { + t.Fatalf("install error = %v, want the injected failure between the marker and the manifest write", err) + } + + if _, marked, err := fixture.store.GetHookAbsorptionMarker(t.Context(), "codex"); err != nil || !marked { + t.Fatalf("marker = %v, %v, want it durable before the manifest write ran", marked, err) + } + row, found, err := fixture.store.GetHookEnablement(t.Context(), "codex", "SessionStart", "session-start-loaf") + if err != nil || !found || row.Enablement != state.HookEnablementDisabled || row.AbsorbedAt == nil { + t.Fatalf("record = %#v, %v, %v, want the absorption committed before the crash", row, found, err) + } + absorbedAt := *row.AbsorbedAt + if !strings.Contains(string(readFileBytes(t, manifestPath)), obsoleteHookProjectionKind) { + t.Fatal("the crashed run dropped the obsolete row; only a completed manifest write may drop it") + } + + if err := installTargetDistribution(fixture.options); err != nil { + t.Fatalf("retry install error = %v", err) + } + + if body := string(readFileBytes(t, manifestPath)); strings.Contains(body, obsoleteHookProjectionKind) { + t.Fatalf("installed manifest = %s, want the obsolete row absent after the next write", body) + } + rows, err := fixture.store.ListHookEnablements(t.Context(), "codex") + if err != nil || len(rows) != 1 { + t.Fatalf("enablement rows = %#v, %v, want exactly the one record the first run absorbed", rows, err) + } + if rows[0].AbsorbedAt == nil || *rows[0].AbsorbedAt != absorbedAt { + t.Fatalf("absorbed_at = %v, want the provenance from the first run (%q) rather than a second absorption", rows[0].AbsorbedAt, absorbedAt) + } + fixture.assertHooksUnchanged(t, live) +} + +// The tolerance the reader promises, stated where it actually matters: an +// upgrade that meets a retired row it cannot make sense of still absorbs. Each +// defect here is one a later release could plausibly have left behind, and +// under the strict rules every one of them would abort the read — and with it +// the migration — before a single record was written. +func TestInstallTargetAbsorbsThroughADefectiveObsoleteHookRow(t *testing.T) { + for name, injectDefect := range map[string]func(string) string{ + "unknown field": func(body string) string { + return strings.Replace(body, `"kind": "hook-projection",`, `"kind": "hook-projection", + "projection_generation": 7,`, 1) + }, + "wrong-typed field": func(body string) string { + return strings.Replace(body, `"kind": "hook-projection",`, `"kind": "hook-projection", + "mode": "0644",`, 1) + }, + "duplicate key inside the row": func(body string) string { + return strings.Replace(body, `"kind": "hook-projection",`, `"kind": "hook-projection", + "destination": "hooks.json",`, 1) + }, + } { + t.Run(name, func(t *testing.T) { + fixture := newCodexHookFixture(t) + live := string(testHookFixture(t, "codex-hooks-live.json")) + fixture.writeHooks(t, live) + manifestPath := filepath.Join(fixture.config, targetInstallManifestFile) + defective := injectDefect(testHookTargetManifestBody(t, "codex", testHookPriorVersion, "")) + if !strings.Contains(defective, obsoleteHookProjectionKind) { + t.Fatal("the defect injection lost the retired row; this test has no subject") + } + writeInstallFile(t, manifestPath, defective) + + if err := installTargetDistribution(fixture.options); err != nil { + t.Fatalf("install error = %v, want the defective retired row read tolerantly", err) + } + + // Absorbed: the row was still readable as evidence of a prior install, + // so the hook the operator deleted is recorded rather than re-added. + row, found, err := fixture.store.GetHookEnablement(t.Context(), "codex", "SessionStart", "session-start-loaf") + if err != nil || !found || row.Enablement != state.HookEnablementDisabled || row.AbsorbedAt == nil { + t.Fatalf("record = %#v, %v, %v, want the absorption the retired row is evidence for", row, found, err) + } + if _, marked, err := fixture.store.GetHookAbsorptionMarker(t.Context(), "codex"); err != nil || !marked { + t.Fatalf("marker = %v, %v, want the migration window closed", marked, err) + } + // Dropped: whatever the row carried is gone from the next write. + if body := string(readFileBytes(t, manifestPath)); strings.Contains(body, obsoleteHookProjectionKind) { + t.Fatalf("installed manifest = %s, want the defective row absent after the next write", body) + } + fixture.assertHooksUnchanged(t, live) + }) + } +} + +// Tolerance stops where identification does. A manifest whose retired row +// cannot be told apart from a live one, or whose bytes carry something the +// stripper would have to repair on the way past, is refused outright — and +// refused before anything is recorded, because absorbing on the strength of a +// row Loaf could not identify is exactly the guess Decision 7 forbids. +func TestInstallTargetRefusesAManifestWhoseRetiredRowCannotBeIdentified(t *testing.T) { + for name, corrupt := range map[string]func(string) string{ + "duplicate kind field": func(body string) string { + return strings.Replace(body, `"kind": "hook-projection",`, `"kind": "hook-file", + "kind": "hook-projection",`, 1) + }, + "trailing delimiter": func(body string) string { + return strings.TrimRight(body, "\n") + "}\n" + }, + } { + t.Run(name, func(t *testing.T) { + fixture := newCodexHookFixture(t) + live := string(testHookFixture(t, "codex-hooks-live.json")) + fixture.writeHooks(t, live) + manifestPath := filepath.Join(fixture.config, targetInstallManifestFile) + corrupted := corrupt(testHookTargetManifestBody(t, "codex", testHookPriorVersion, "")) + writeInstallFile(t, manifestPath, corrupted) + + if err := installTargetDistribution(fixture.options); err == nil { + t.Fatalf("install error = nil, want the unidentifiable manifest refused") + } + + rows, err := fixture.store.ListHookEnablements(t.Context(), "codex") + if err != nil || len(rows) != 0 { + t.Fatalf("enablement rows = %#v, %v, want nothing absorbed from a manifest Loaf could not read", rows, err) + } + if _, marked, err := fixture.store.GetHookAbsorptionMarker(t.Context(), "codex"); err != nil || marked { + t.Fatalf("marker = %v, %v, want the migration window still open", marked, err) + } + assertInstallFile(t, manifestPath, corrupted) + fixture.assertHooksUnchanged(t, live) + }) + } +} + +// The other half of install's ordering, and the window the reconciler-level +// tests cannot reach: the records are durable, the adapter sync has already +// replaced the prior release's manifest, and the run dies before the file is +// projected. The prior version's identity is gone from disk at that point, so +// the retry has nothing but the records to go on — which is exactly why they +// are written first. It must converge without absorbing a second time. +func TestInstallTargetConvergesAfterACrashBetweenManifestSyncAndProjection(t *testing.T) { + fixture := newCursorHookFixture(t) + // One cohort hook the operator deleted (absorbs as disabled) and one entry + // weakened by hand (needs a write), so the run has both a record to commit + // and a projection to die in. + fixture.writeHooks(t, testHookCursorFileWithout(t, "session-start-loaf")) + fixture.writeInstalledManifest(t, testHookPriorVersion) + fixture.mutateEntry(t, "preToolUse", "check-"+"sec"+"rets", func(entry map[string]any) { + entry["command"] = "loaf check --hook check-" + "sec" + "rets --advisory" + }) + hooksBefore := fixture.readHooks(t) + manifestPath := filepath.Join(fixture.config, targetInstallManifestFile) + + failing := fixture.options + failing.HookOps = &hookReconcileOperations{beforeRename: func() error { return errHookCrashInjection }} + if err := installTargetDistribution(failing); !errors.Is(err, errHookCrashInjection) { + t.Fatalf("install error = %v, want the injected projection failure", err) + } + + // The distinguishing precondition: the sync ran, so the manifest on disk is + // this release's and no longer names the version absorption was bounded by. + installed, err := readTargetAdapterManifest(manifestPath) + if err != nil { + t.Fatalf("readTargetAdapterManifest error = %v", err) + } + if installed.PackageVersion != "9.8.7-test.1" { + t.Fatalf("installed manifest package_version = %q, want the sync to have replaced it before the crash", installed.PackageVersion) + } + row, found, err := fixture.store.GetHookEnablement(t.Context(), "cursor", "sessionStart", "session-start-loaf") + if err != nil || !found || row.Enablement != state.HookEnablementDisabled || row.AbsorbedAt == nil { + t.Fatalf("record = %#v, %v, %v, want the absorption durable before the projection died", row, found, err) + } + absorbedAt := *row.AbsorbedAt + fixture.assertHooksUnchanged(t, hooksBefore) + + if err := installTargetDistribution(fixture.options); err != nil { + t.Fatalf("retry install error = %v", err) + } + + // Convergence: the weakened entry is back to the shipped shape, the hook the + // operator deleted stays deleted, and its provenance was not rewritten. + // The probe names the whole weakened command — two hooks this version ships + // are advisory by design, so the flag alone appears in a converged file. + if weakened := "loaf check --hook check-" + "sec" + "rets --advisory"; strings.Contains(fixture.readHooks(t), weakened) { + t.Fatalf("weakened enforcement survived the retry:\n%s", fixture.readHooks(t)) + } + if ids := fixture.hookIDsByEvent(t)["sessionStart"]; len(ids) != 0 { + t.Fatalf("sessionStart = %v, want the disabled hook to stay out of the file", ids) + } + rows, err := fixture.store.ListHookEnablements(t.Context(), "cursor") + if err != nil || len(rows) != 1 { + t.Fatalf("enablement rows = %#v, %v, want exactly the one absorbed record", rows, err) + } + if rows[0].AbsorbedAt == nil || *rows[0].AbsorbedAt != absorbedAt { + t.Fatalf("absorbed_at = %v, want the immutable provenance from the first run (%q)", rows[0].AbsorbedAt, absorbedAt) + } + + // And a third run has nothing left to do. + if actions := fixture.apply(t); len(actions) != 0 { + t.Fatalf("actions = %s, want the retried install to have converged", describeHookActions(actions)) + } +} + +// The whole-file merge is unreachable rather than merely unused: a build output +// that predates the catalog is refused, because every property this Change adds +// is absent from the path behind it. +func TestInstallTargetRefusesAStaleDistribution(t *testing.T) { + for _, target := range []string{"cursor", "codex"} { + t.Run(target, func(t *testing.T) { + fixture := newHookFixture(t, target) + if err := os.Remove(filepath.Join(fixture.dist, targetBuildManifestFile)); err != nil { + t.Fatalf("Remove(manifest) error = %v", err) + } + existing := `{"hooks":{}}` + fixture.writeHooks(t, existing) + + err := installTargetDistribution(fixture.options) + if err == nil || !strings.Contains(err.Error(), "stale") { + t.Fatalf("install error = %v, want a stale-distribution refusal", err) + } + if !strings.Contains(err.Error(), "loaf build") { + t.Fatalf("install error = %v, want the remedy named", err) + } + fixture.assertHooksUnchanged(t, existing) + + decisions, err := planTargetDistribution(fixture.options) + if err != nil { + t.Fatalf("planTargetDistribution error = %v", err) + } + if !testHookDecisionsContainDetail(decisions, planActionConflict, "stale") { + t.Fatalf("plan decisions = %#v, want the same refusal promised", decisions) + } + }) + } +} + +// Refusing a stale distribution is only half a promise if the refusal arrives +// after something has already been taken away. Both installers reach their +// verdict before they touch any surface, so an operator who runs a stale build +// output is left exactly where they started. +func TestInstallTargetRefusesAStaleDistributionBeforeMutatingAnySurface(t *testing.T) { + for _, target := range []string{"cursor", "codex"} { + t.Run(target, func(t *testing.T) { + fixture := newHookFixture(t, target) + if err := os.Remove(filepath.Join(fixture.dist, targetBuildManifestFile)); err != nil { + t.Fatalf("Remove(manifest) error = %v", err) + } + // Every surface the installers write before they reach the hooks + // file, seeded with content only this test could have put there. + writeInstallFile(t, filepath.Join(fixture.dist, "skills", "foundations", "SKILL.md"), "# Distribution\n") + writeInstallFile(t, filepath.Join(fixture.home, ".agents", "skills", "foundations", "SKILL.md"), "# Installed\n") + writeInstallFile(t, filepath.Join(fixture.config, "commands", "keep.md"), "# Keep\n") + writeInstallFile(t, filepath.Join(fixture.config, "agents", "keep.md"), "# Keep\n") + + if err := installTargetDistribution(fixture.options); err == nil || !strings.Contains(err.Error(), "stale") { + t.Fatalf("install error = %v, want a stale-distribution refusal", err) + } + + assertInstallFile(t, filepath.Join(fixture.home, ".agents", "skills", "foundations", "SKILL.md"), "# Installed\n") + assertInstallFile(t, filepath.Join(fixture.config, "commands", "keep.md"), "# Keep\n") + assertInstallFile(t, filepath.Join(fixture.config, "agents", "keep.md"), "# Keep\n") + assertInstallPathMissing(t, filepath.Join(fixture.config, targetInstallManifestFile)) + assertInstallPathMissing(t, filepath.Join(fixture.config, loafInstallMarkerFile)) + }) + } +} + +// The plan is display: it reports the same per-entry decisions apply would take +// and writes nothing at all. +func TestHookReconcilePlanReportsActionsWithoutWriting(t *testing.T) { + fixture := newCodexHookFixture(t) + live := string(testHookFixture(t, "codex-hooks-live.json")) + fixture.writeHooks(t, live) + fixture.writeInstalledManifest(t, testHookPriorVersion) + + actions, err := fixture.reconciler(t).plan(t.Context()) + if err != nil { + t.Fatalf("plan error = %v", err) + } + if len(actions) != 1 || actions[0].action != hookActionAbsorb { + t.Fatalf("plan actions = %s, want the absorption reported", describeHookActions(actions)) + } + fixture.assertHooksUnchanged(t, live) + if _, marked, err := fixture.store.GetHookAbsorptionMarker(t.Context(), "codex"); err != nil || marked { + t.Fatalf("marker after plan = %v, %v, want a plan to record nothing", marked, err) + } + + decisions := hookActionPlanDecisions(fixture.reconciler(t), actions) + if len(decisions) != 1 || decisions[0].Action != hookActionAbsorb || decisions[0].Kind != "hook-entry" { + t.Fatalf("plan decisions = %#v, want one hook-entry absorption", decisions) + } + if decisions[0].ID != "hook:SessionStart/session-start-loaf" { + t.Fatalf("plan decision id = %q, want the identity named", decisions[0].ID) + } +} + +// No file-level verdict survives anywhere on the reconciled path: the plan +// never conflicts over a hooks destination, however much the file diverged. +func TestHookReconcilePlanNeverConflictsOverTheHooksFile(t *testing.T) { + fixture := newCursorHookFixture(t) + fixture.apply(t) + fixture.mutateEntry(t, "sessionStart", "session-start-loaf", func(entry map[string]any) { + entry["command"] = "loaf journal context" + delete(entry, loafHookMarker) + }) + + decisions, err := planTargetAdapterArtifacts(fixture.options) + if err != nil { + t.Fatalf("planTargetAdapterArtifacts error = %v", err) + } + for _, decision := range decisions { + if decision.Action == planActionConflict { + t.Fatalf("plan decision = %#v, want no file-level conflict for a diverged hooks file", decision) + } + if decision.Kind == obsoleteHookProjectionKind { + t.Fatalf("plan decision = %#v, want the hooks file planned per entry", decision) + } + } + if !testHookDecisionsContain(decisions, hookActionUpdate, "hook:sessionStart/session-start-loaf") { + t.Fatalf("plan decisions = %#v, want the diverged entry planned as an update", decisions) + } +} + +// The verbatim plan output for the canary's Codex file, which is what the +// operator reads on the first upgrade to this release. +func TestHookReconcilePlanOutputNamesTheAbsorption(t *testing.T) { + fixture := newCodexHookFixture(t) + fixture.writeHooks(t, string(testHookFixture(t, "codex-hooks-live.json"))) + fixture.writeInstalledManifest(t, testHookPriorVersion) + + actions, err := fixture.reconciler(t).plan(t.Context()) + if err != nil { + t.Fatalf("plan error = %v", err) + } + var out strings.Builder + writeHookActionLines(&out, actions) + + want := " ○ absorb hook:SessionStart/session-start-loaf — absent before this upgrade; recorded as disabled\n" + if got := stripANSI(out.String()); got != want { + t.Fatalf("plan output = %q, want %q", got, want) + } +} + +// hookFixture is one target's install surface: a built distribution carrying +// the real catalog, a config directory, and the user-scoped state the records +// live in. +type hookFixture struct { + root string + dist string + config string + home string + hooks string + store *state.Store + options targetInstallOptions +} + +func newCursorHookFixture(t *testing.T) hookFixture { + t.Helper() + fixture := newHookFixture(t, "cursor") + if err := generateNativeCursorHookCatalog(filepath.Join(testRepositoryRoot(t), "config", "hooks.yaml"), fixture.dist, "9.8.7-test.1"); err != nil { + t.Fatalf("generateNativeCursorHookCatalog error = %v", err) + } + return fixture +} + +func newCodexHookFixture(t *testing.T) hookFixture { + t.Helper() + fixture := newHookFixture(t, "codex") + if err := generateNativeCodexHookCatalog(fixture.dist, "9.8.7-test.1"); err != nil { + t.Fatalf("generateNativeCodexHookCatalog error = %v", err) + } + return fixture +} + +func newHookFixture(t *testing.T, target string) hookFixture { + t.Helper() + root := realpath(t, t.TempDir()) + home := filepath.Join(root, "home") + // The config directory sits under the fixture's home because that is where + // a real install puts it, and the `$HOME/...` spellings the live files use + // only resolve to the recorded destinations when it does. + config := filepath.Join(home, ".cursor") + if target != "cursor" { + config = filepath.Join(root, "reported-config") + } + fixture := hookFixture{ + root: root, + dist: filepath.Join(root, "dist", target), + config: config, + home: home, + store: testHookStateStore(t), + } + if err := os.MkdirAll(fixture.dist, 0o755); err != nil { + t.Fatalf("MkdirAll(dist) error = %v", err) + } + writeTestHookDistManifest(t, fixture.dist, target) + fixture.options = targetInstallOptions{ + Target: target, + DistDir: fixture.dist, + ConfigDir: fixture.config, + Version: "9.8.7-test.1", + HomeDir: fixture.home, + ProjectRoot: root, + HookState: func() (*state.Store, error) { return fixture.store, nil }, + } + if target == "codex" { + fixture.options.CodexHome = filepath.Join(home, ".codex") + } + fixture.hooks = targetHookFilePath(fixture.options) + return fixture +} + +// reconciler builds a reconciler with an executable resolution that does not +// depend on what happens to be on PATH where the tests run. +func (f hookFixture) reconciler(t *testing.T) *hookReconciler { + t.Helper() + reconciler, err := newHookReconciler(f.options) + if err != nil { + t.Fatalf("newHookReconciler error = %v", err) + } + if reconciler == nil { + t.Fatalf("newHookReconciler returned no reconciler for %s", f.options.Target) + } + reconciler.resolveExecutable = func() (string, error) { return "/Users/canary/.local/bin/loaf", nil } + return reconciler +} + +func (f hookFixture) apply(t *testing.T) []hookAction { + t.Helper() + actions, err := f.reconciler(t).apply(t.Context()) + if err != nil { + t.Fatalf("apply error = %v", err) + } + return actions +} + +func (f hookFixture) catalog(t *testing.T) hookCatalog { + t.Helper() + catalog, err := readHookCatalog(f.dist) + if err != nil { + t.Fatalf("readHookCatalog error = %v", err) + } + return catalog +} + +func (f hookFixture) recognition(t *testing.T) hookRecognition { + t.Helper() + records, err := loadHookRecords(t.Context(), f.store, f.options.Target) + if err != nil { + t.Fatalf("loadHookRecords error = %v", err) + } + return f.reconciler(t).recognition(records) +} + +func (f hookFixture) writeHooks(t *testing.T, body string) { + t.Helper() + writeInstallFile(t, f.hooks, body) +} + +func (f hookFixture) writeHooksValue(t *testing.T, value map[string]any) { + t.Helper() + body, err := json.MarshalIndent(value, "", " ") + if err != nil { + t.Fatalf("marshal hooks error = %v", err) + } + f.writeHooks(t, string(body)) +} + +func (f hookFixture) readHooks(t *testing.T) string { + t.Helper() + body, err := os.ReadFile(f.hooks) + if err != nil { + t.Fatalf("ReadFile(hooks) error = %v", err) + } + return string(body) +} + +func (f hookFixture) decodeHooks(t *testing.T) map[string]any { + t.Helper() + var value map[string]any + if err := json.Unmarshal([]byte(f.readHooks(t)), &value); err != nil { + t.Fatalf("decode hooks error = %v", err) + } + return value +} + +// writeInstalledManifest stands in for what a previous release left behind: a +// manifest naming the version it shipped, the retired whole-file hooks row that +// release wrote, and the recorded hook-file destinations recognition matches +// against. +func (f hookFixture) writeInstalledManifest(t *testing.T, version string) { + t.Helper() + writeTestHookTargetManifest(t, filepath.Join(f.config, targetInstallManifestFile), f.options.Target, version) +} + +// writeTestHookTargetManifest writes a real 0.2.20 manifest retargeted to the +// caller's target and version. Using the captured one rather than a synthetic +// stub is what gives the fixtures the hook-file destinations the path-backed +// entries are recognized through. +func writeTestHookTargetManifest(t *testing.T, path string, target string, version string) { + t.Helper() + writeInstallFile(t, path, testHookTargetManifestBody(t, target, version, "")) +} + +// writeTestHookDistManifest additionally materializes the hook-file sources the +// manifest names, so the distribution is one the ordinary artifact machinery +// can verify alongside the reconciler. +func writeTestHookDistManifest(t *testing.T, dist string, target string) { + t.Helper() + writeInstallFile(t, filepath.Join(dist, targetBuildManifestFile), testHookTargetManifestBody(t, target, "9.8.7-test.1", dist)) +} + +func testHookTargetManifestBody(t *testing.T, target string, version string, sourceRoot string) string { + t.Helper() + var manifest map[string]any + if err := json.Unmarshal(testHookFixture(t, "cursor-target-manifest-0.2.20.json"), &manifest); err != nil { + t.Fatalf("decode manifest fixture error = %v", err) + } + manifest["target"] = target + manifest["package_version"] = version + manifest["adapters"] = []string{target + "-session-start-v1"} + var kept []any + for _, raw := range manifest["artifacts"].([]any) { + artifact := raw.(map[string]any) + if artifact["kind"] != "hook-file" { + kept = append(kept, artifact) + continue + } + if target != "cursor" { + continue + } + if sourceRoot != "" { + source := filepath.Join(sourceRoot, filepath.FromSlash(artifact["source_path"].(string))) + body := "# " + artifact["source_path"].(string) + "\n" + writeInstallFile(t, source, body) + info, err := os.Lstat(source) + if err != nil { + t.Fatalf("Lstat(%s) error = %v", source, err) + } + artifact["sha256"] = sha256Hex(body) + artifact["mode"] = uint32(info.Mode().Perm()) + } + kept = append(kept, artifact) + } + manifest["artifacts"] = kept + encoded, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + t.Fatalf("encode manifest error = %v", err) + } + return string(encoded) + "\n" +} + +func (f hookFixture) assertHooksUnchanged(t *testing.T, want string) { + t.Helper() + if got := f.readHooks(t); got != want { + t.Fatalf("hooks file changed:\nwant %s\ngot %s", want, got) + } +} + +func (f hookFixture) assertForeignSurvives(t *testing.T, event string, command string) { + t.Helper() + file, err := readHookFile(f.hooks) + if err != nil { + t.Fatalf("readHookFile error = %v", err) + } + entries, err := file.eventEntries(event) + if err != nil { + t.Fatalf("eventEntries error = %v", err) + } + for _, entry := range entries { + if testHookEntryCommand(entry) == command { + return + } + } + t.Fatalf("%s entries = %#v, want the foreign entry %q preserved", event, entries, command) +} + +// mutateEntry rewrites the Loaf entry paired to hookID, standing in for a hand +// edit of the installed file. +func (f hookFixture) mutateEntry(t *testing.T, event string, hookID string, mutate func(map[string]any)) { + t.Helper() + f.rewriteEntry(t, event, hookID, mutate, false) +} + +func (f hookFixture) deleteEntry(t *testing.T, event string, hookID string) { + t.Helper() + f.rewriteEntry(t, event, hookID, nil, true) +} + +func (f hookFixture) rewriteEntry(t *testing.T, event string, hookID string, mutate func(map[string]any), remove bool) { + t.Helper() + value := f.decodeHooks(t) + events := value["hooks"].(map[string]any) + entries := events[event].([]any) + catalog := f.catalog(t) + var kept []any + found := false + for _, raw := range entries { + entry := raw.(map[string]any) + if !found && testHookEntryIsHook(t, catalog, event, hookID, entry) { + found = true + if remove { + continue + } + mutate(entry) + } + kept = append(kept, entry) + } + if !found { + t.Fatalf("%s has no entry for %s", event, hookID) + } + events[event] = kept + f.writeHooksValue(t, value) +} + +func testHookEntryIsHook(t *testing.T, catalog hookCatalog, event string, hookID string, entry map[string]any) bool { + t.Helper() + for _, candidate := range catalog.Entries { + if candidate.Event != event || candidate.HookID != hookID { + continue + } + value, err := canonicalHookValue(entry) + if err != nil { + t.Fatalf("canonicalize entry error = %v", err) + } + template, err := decodeHookJSONValue(candidate.Template) + if err != nil { + t.Fatalf("decode template error = %v", err) + } + return reflect.DeepEqual(value, template) + } + return false +} + +// testHookCursorFileWithout renders what a 0.2.20 install looks like after the +// operator deleted some of its entries by hand. +func testHookCursorFileWithout(t *testing.T, hookIDs ...string) string { + t.Helper() + deleted := map[string]bool{} + for _, hookID := range hookIDs { + deleted[hookID] = true + } + body := testHookFixture(t, "cursor-hooks-live.json") + var value map[string]any + if err := json.Unmarshal(body, &value); err != nil { + t.Fatalf("decode live Cursor fixture error = %v", err) + } + catalog := testRepoHookCatalog(t, "cursor") + events := value["hooks"].(map[string]any) + for event, raw := range events { + var kept []any + for _, candidate := range raw.([]any) { + entry := candidate.(map[string]any) + drop := false + for hookID := range deleted { + if testHookEntryIsHook(t, catalog, event, hookID, entry) { + drop = true + break + } + } + if !drop { + kept = append(kept, entry) + } + } + events[event] = kept + } + encoded, err := json.MarshalIndent(value, "", " ") + if err != nil { + t.Fatalf("encode Cursor fixture error = %v", err) + } + return string(encoded) + "\n" +} + +// testHookCatalogWithCohort narrows the frozen cohort so a test can say "this +// hook is the one the prior version shipped, and that one came later". +func testHookCatalogWithCohort(t *testing.T, catalog hookCatalog, introducedAfter ...string) hookCatalog { + t.Helper() + excluded := map[string]bool{} + for _, hookID := range introducedAfter { + excluded[hookID] = true + } + for index, cohort := range catalog.Cohorts { + var kept []string + for _, hookID := range cohort.HookIDs { + if !excluded[hookID] { + kept = append(kept, hookID) + } + } + catalog.Cohorts[index].HookIDs = kept + } + return catalog +} + +func (f hookFixture) hookIDsByEvent(t *testing.T) map[string][]string { + t.Helper() + file, err := readHookFile(f.hooks) + if err != nil { + t.Fatalf("readHookFile error = %v", err) + } + recognition := f.recognition(t) + byEvent := map[string][]string{} + for _, event := range file.events { + entries, err := file.eventEntries(event) + if err != nil { + t.Fatalf("eventEntries error = %v", err) + } + outcome, err := pairHookEventEntries(recognition, event, entries) + if err != nil { + t.Fatalf("pairHookEventEntries error = %v", err) + } + for _, pairing := range outcome.paired { + byEvent[event] = append(byEvent[event], pairing.hookID) + } + } + return byEvent +} + +// installTestHookCatalog gives a synthetic distribution the identity authority +// a real build emits beside its hooks.json, so an install fixture exercises the +// reconciler rather than tripping over a missing catalog. +func installTestHookCatalog(t *testing.T, dist string, target string, sources []hookCatalogSource) { + t.Helper() + catalog, err := newHookCatalog(target, "9.8.7-test.1", sources) + if err != nil { + t.Fatalf("newHookCatalog error = %v", err) + } + if err := writeHookCatalog(dist, catalog); err != nil { + t.Fatalf("writeHookCatalog error = %v", err) + } +} + +// installTestHookDistribution gives a synthetic `dist/` the two files +// every real build emits for a target whose hook entries are reconciled: the +// adapter manifest the installer reads, and the catalog identity resolves +// through. A distribution carrying neither is stale and the installer refuses +// it — that refusal has its own tests, and a fixture about skills, project +// files, or MCP prompts should not have to restate it. +// Passing no sources takes the target's default identity. +func installTestHookDistribution(t *testing.T, root string, target string, sources ...hookCatalogSource) { + t.Helper() + dist := filepath.Join(root, "dist", target) + if len(sources) == 0 { + sources = testHookCatalogSources(target) + } + writeTestTargetAdapterManifest(t, dist, target, nil) + installTestHookCatalog(t, dist, target, sources) +} + +// testHookCatalogSources is one identity per target — enough to be a valid +// catalog, small enough that a fixture with no interest in hooks is not paying +// for the whole shipped generation. +func testHookCatalogSources(target string) []hookCatalogSource { + if target == "codex" { + return []hookCatalogSource{testCodexHookCatalogSourceOnPath()} + } + return []hookCatalogSource{testCursorHookCatalogSource()} +} + +// testCodexHookCatalogSourceOnPath spells the Codex identity with the bare +// `loaf` first token — one of the three executable forms recognition accepts. +// The shipped Codex entry carries the install-time placeholder instead, and +// rendering that requires a trusted absolute executable no end-to-end fixture +// can produce: the trust rules refuse every candidate under a temporary root, +// which is where a test's PATH necessarily lives. Tests whose subject is the +// rendered entry use the hook fixtures, which inject the resolution directly. +func testCodexHookCatalogSourceOnPath() hookCatalogSource { + command := "loaf" + codexJournalHookCommandSuffix + return hookCatalogSource{ + event: "SessionStart", + hookID: "session-start-loaf", + typeName: "command", + command: command, + template: map[string]any{ + "matcher": codexJournalHookMatcher, + "hooks": []any{map[string]any{"type": "command", "command": command, "commandWindows": command}}, + }, + } +} + +// testCursorHookCatalogSource is a Cursor entry in the shape the build emits: +// a bare `loaf` invocation, a matcher, and the human-legible managed marker +// that recognition deliberately does not depend on. +func testCursorHookCatalogSource() hookCatalogSource { + command := "loaf check --hook validate-commit" + return hookCatalogSource{ + event: "beforeShellExecution", + hookID: "validate-commit", + typeName: "command", + command: command, + template: map[string]any{"command": command, "matcher": "Bash", "loaf-managed": true}, + } +} + +// testCodexHookCatalogSource is the one identity Codex ships, as the build +// emits it: a matcher group carrying the install-time executable placeholder on +// both the POSIX and Windows commands. +func testCodexHookCatalogSource() hookCatalogSource { + command := codexJournalExecutablePlaceholder + codexJournalHookCommandSuffix + return hookCatalogSource{ + event: "SessionStart", + hookID: "session-start-loaf", + typeName: "command", + command: command, + template: map[string]any{ + "matcher": codexJournalHookMatcher, + "hooks": []any{map[string]any{"type": "command", "command": command, "commandWindows": command}}, + }, + } +} + +func installTestHookState(t *testing.T) hookStateResolver { + t.Helper() + store := testHookStateStore(t) + return func() (*state.Store, error) { return store, nil } +} + +// installTestHookStateAfterMigration is the state of a host that has already +// been through absorption once, which is what a test about ordinary install +// behaviour wants: the migration is a separate subject with its own tests. +func installTestHookStateAfterMigration(t *testing.T, target string) hookStateResolver { + t.Helper() + store := testHookStateStore(t) + if _, err := store.AbsorbAndMarkHooks(context.Background(), target, "9.8.7-test.1", nil); err != nil { + t.Fatalf("AbsorbAndMarkHooks error = %v", err) + } + return func() (*state.Store, error) { return store, nil } +} + +func testHookStateStore(t *testing.T) *state.Store { + t.Helper() + store, err := state.OpenStore(filepath.Join(t.TempDir(), "loaf.sqlite")) + if err != nil { + t.Fatalf("OpenStore error = %v", err) + } + if err := store.ApplyMigrations(context.Background()); err != nil { + t.Fatalf("ApplyMigrations error = %v", err) + } + t.Cleanup(func() { store.Close() }) + return store +} + +// testHookDocumentSnapshot captures everything reconciliation promises to +// leave alone: the top-level fields Loaf does not own with their exact JSON +// values, the event sections in the order the file declares them, and every +// foreign entry's exact value in its position among the other foreign entries. +// Comparing two snapshots is the whole of Decision 9's guarantee, which a map +// of decoded entries could not express — it loses field order, section order, +// and the raw values themselves. +func testHookDocumentSnapshot(t *testing.T, body []byte, recognition hookRecognition) []string { + t.Helper() + order, fields, err := decodeHookJSONObject(body) + if err != nil { + t.Fatalf("decode hooks document error = %v", err) + } + var snapshot []string + for _, key := range order { + if key == hookFileEventsField { + continue + } + canonical, err := canonicalHookEntry(fields[key]) + if err != nil { + t.Fatalf("canonicalize field %q error = %v", key, err) + } + snapshot = append(snapshot, "field "+key+" = "+canonical) + } + events, sections, err := decodeHookJSONObject(fields[hookFileEventsField]) + if err != nil { + t.Fatalf("decode hooks section error = %v", err) + } + for _, event := range events { + snapshot = append(snapshot, "event "+event) + var entries []json.RawMessage + if err := json.Unmarshal(sections[event], &entries); err != nil { + t.Fatalf("decode %s entries error = %v", event, err) + } + position := 0 + for _, raw := range entries { + entry, err := decodeHookEntry(raw) + if err != nil { + t.Fatalf("decode %s entry error = %v", event, err) + } + ownership, err := recognition.ownsEntry(entry) + if err != nil { + t.Fatalf("ownsEntry error = %v", err) + } + if ownership.owned { + continue + } + canonical, err := canonicalHookEntry(raw) + if err != nil { + t.Fatalf("canonicalize %s entry error = %v", event, err) + } + snapshot = append(snapshot, fmt.Sprintf("foreign %s#%d %s", event, position, canonical)) + position++ + } + } + return snapshot +} + +func testHookCountForeign(snapshot []string) int { + total := 0 + for _, line := range snapshot { + if strings.HasPrefix(line, "foreign ") { + total++ + } + } + return total +} + +func testHookCountEntries(entries map[string][]string) int { + total := 0 + for _, event := range entries { + total += len(event) + } + return total +} + +func testHookEntryCommand(entry map[string]any) string { + if command, ok := entry["command"].(string); ok { + return command + } + handlers, ok := entry["hooks"].([]any) + if !ok || len(handlers) == 0 { + return "" + } + handler, ok := handlers[0].(map[string]any) + if !ok { + return "" + } + command, _ := handler["command"].(string) + return command +} + +func testHookHasAction(actions []hookAction, want string, hookID string) bool { + for _, action := range actions { + if action.action == want && action.hookID == hookID { + return true + } + } + return false +} + +func testHookHasAnyAction(actions []hookAction, want string) bool { + for _, action := range actions { + if action.action == want { + return true + } + } + return false +} + +func testHookDecisionsContainDetail(decisions []artifactPlanDecision, action string, detail string) bool { + for _, decision := range decisions { + if decision.Action == action && strings.Contains(decision.Detail, detail) { + return true + } + } + return false +} + +func testHookDecisionsContain(decisions []artifactPlanDecision, action string, id string) bool { + for _, decision := range decisions { + if decision.Action == action && decision.ID == id { + return true + } + } + return false +} diff --git a/internal/cli/hook_state.go b/internal/cli/hook_state.go new file mode 100644 index 000000000..0384a9295 --- /dev/null +++ b/internal/cli/hook_state.go @@ -0,0 +1,182 @@ +package cli + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/levifig/loaf/internal/project" + "github.com/levifig/loaf/internal/state" +) + +// hook_state.go opens the user-scoped state hook reconciliation reads. The +// records are host-local and per target, not per project, but they live in the +// same global database as everything else Loaf remembers, so they are reached +// through the same resolver the rest of the CLI uses. +// +// Two resolvers exist because the two callers make different promises. Apply +// may create the database, because a host that has never run Loaf still has to +// be able to record an absorption marker. A plan may not: a dry run that +// created a database would be a write, however small, and the plan surface +// promises none. + +// hookStateResolver is the reconciler's handle on the store. It is a function +// so that a target which never reconciles a hooks file never opens a database, +// and so that the open happens once per run rather than once per target. +type hookStateResolver func() (*state.Store, error) + +func (r Runner) hookStateForApply(projectRoot string) (hookStateResolver, func()) { + return r.hookStateResolver(projectRoot, true) +} + +func (r Runner) hookStateForPlan(projectRoot string) (hookStateResolver, func()) { + return r.hookStateResolver(projectRoot, false) +} + +func (r Runner) hookStateResolver(projectRoot string, create bool) (hookStateResolver, func()) { + var ( + store *state.Store + openErr error + resolved bool + ) + resolve := func() (*state.Store, error) { + if !resolved { + resolved = true + store, openErr = r.openHookStateStore(projectRoot, create) + } + return store, openErr + } + release := func() { + if store != nil { + store.Close() + store = nil + } + } + return resolve, release +} + +func (r Runner) openHookStateStore(projectRoot string, create bool) (*state.Store, error) { + root, err := project.ResolveRoot(projectRoot) + if err != nil { + return nil, err + } + path, err := state.PathResolver{StateHome: r.StateHome}.DatabasePath(root) + if err != nil { + return nil, err + } + if !create { + return openHookStateStoreReadOnly(path) + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, fmt.Errorf("create state database directory: %w", err) + } + store, err := state.OpenStore(path) + if err != nil { + return nil, err + } + ctx := context.Background() + bootstrapped, err := store.BootstrapIfEmpty(ctx) + if err != nil { + store.Close() + return nil, err + } + if !bootstrapped { + if err := store.RequireCurrentSchema(ctx); err != nil { + store.Close() + return nil, fmt.Errorf("hook enablement state at %s cannot be read: %w", path, err) + } + } + return store, nil +} + +// openHookStateStoreReadOnly is the plan's half of the resolver, and every +// branch of it leaves the database exactly as it found it: a missing file is +// not created, an uninitialized one is not bootstrapped, and the connection is +// opened read-only so not even the journal mode moves. Both of those states +// mean the same thing to a plan — nothing has been recorded, so every hook +// reads enabled — and it says so rather than writing to find out. +func openHookStateStoreReadOnly(path string) (*state.Store, error) { + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("inspect hook enablement state at %s: %w", path, err) + } + store, err := state.OpenStoreReadOnly(path) + if err != nil { + return nil, fmt.Errorf("hook enablement state at %s cannot be read: %w", path, err) + } + ctx := context.Background() + // A database that has never been initialized carries no records, which is a + // perfectly good answer for a plan. A database that merely fails to read as + // Loaf's is not the same thing and must not be reported as one: an ordinary + // schema read fails identically on both, so the emptiness question is asked + // on its own rather than inferred from the failure. + uninitialized, err := store.IsUninitialized(ctx) + if err != nil { + store.Close() + return nil, fmt.Errorf("hook enablement state at %s cannot be read: %w", path, err) + } + if uninitialized { + store.Close() + return nil, nil + } + if err := store.RequireCurrentSchema(ctx); err != nil { + store.Close() + return nil, fmt.Errorf("hook enablement state at %s cannot be read: %w", path, err) + } + return store, nil +} + +// hookRecords is the reconciler's read view of the user-scoped records for one +// target. Absence of an enablement row means enabled, so an empty view — the +// state of a host with no database yet — projects every catalog hook. +type hookRecords struct { + enablement map[string]state.HookEnablement + absorbed bool + trusted []string +} + +func loadHookRecords(ctx context.Context, store *state.Store, target string) (hookRecords, error) { + records := hookRecords{enablement: map[string]state.HookEnablement{}} + if store == nil { + return records, nil + } + rows, err := store.ListHookEnablements(ctx, target) + if err != nil { + return hookRecords{}, err + } + for _, row := range rows { + records.enablement[hookRecordKey(row.Event, row.HookID)] = row + } + _, marked, err := store.GetHookAbsorptionMarker(ctx, target) + if err != nil { + return hookRecords{}, err + } + records.absorbed = marked + paths, err := store.ListHookTrustedPaths(ctx, target) + if err != nil { + return hookRecords{}, err + } + for _, path := range paths { + records.trusted = append(records.trusted, path.Path) + } + return records, nil +} + +// enabled answers the projection question for one identity. Absence means +// enabled; a row means whatever the operator last said. +func (r hookRecords) enabled(event string, hookID string) bool { + row, recorded := r.enablement[hookRecordKey(event, hookID)] + return !recorded || row.Enablement == state.HookEnablementEnabled +} + +func (r hookRecords) recorded(event string, hookID string) bool { + _, ok := r.enablement[hookRecordKey(event, hookID)] + return ok +} + +func hookRecordKey(event string, hookID string) string { + return event + "/" + hookID +} diff --git a/internal/cli/hooks.go b/internal/cli/hooks.go new file mode 100644 index 000000000..a6157df03 --- /dev/null +++ b/internal/cli/hooks.go @@ -0,0 +1,664 @@ +package cli + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/levifig/loaf/internal/project" +) + +// hooks.go is the operator's surface over hook enablement: which hooks this +// version ships for an installed target, whether the target's shared hooks file +// currently carries each one, and the two acts that change it. +// +// Enablement lives in the user-scoped records and the file is their projection, +// so deleting an entry by hand says nothing — the next reconcile restores it. +// `loaf hooks disable` is the gesture that means it, and because it is recorded, +// every later upgrade honours it. +// +// Both writers run the full reconciler rather than editing one entry. There is +// one convergence path for this file and the verb is one more caller of it, +// which is why enable and disable report drift they were not asked about +// instead of pretending single-entry surgery. +// +// Everything here resolves through the installed distribution's built catalog +// and the detected harnesses: no source checkout is involved, and no identity is +// invented locally. + +func (r Runner) runHooks(args []string, out io.Writer, runtimeRoot string) error { + if len(args) == 0 { + writeHooksHelp(out) + return nil + } + switch args[0] { + case "--help", "-h", "help": + writeHooksHelp(out) + return nil + case "list": + return r.runHooksList(args[1:], out, runtimeRoot) + case "enable": + return r.runHooksToggle(args[1:], out, runtimeRoot, true) + case "disable": + return r.runHooksToggle(args[1:], out, runtimeRoot, false) + default: + return fmt.Errorf("unknown hooks subcommand %q (valid subcommands: list, enable, disable)", args[0]) + } +} + +type hooksListOptions struct { + target string + help bool +} + +type hooksToggleOptions struct { + hookID string + target string + help bool +} + +func parseHooksListArgs(args []string) (hooksListOptions, error) { + var options hooksListOptions + for i := 0; i < len(args); i++ { + switch arg := args[i]; arg { + case "--target": + if i+1 >= len(args) { + return hooksListOptions{}, fmt.Errorf("--target requires a value") + } + i++ + options.target = args[i] + case "--help", "-h": + options.help = true + default: + return hooksListOptions{}, fmt.Errorf("unknown hooks list option %q", arg) + } + } + return options, nil +} + +func parseHooksToggleArgs(verb string, args []string) (hooksToggleOptions, error) { + var options hooksToggleOptions + for i := 0; i < len(args); i++ { + switch arg := args[i]; arg { + case "--target": + if i+1 >= len(args) { + return hooksToggleOptions{}, fmt.Errorf("--target requires a value") + } + i++ + options.target = args[i] + case "--help", "-h": + options.help = true + default: + if strings.HasPrefix(arg, "-") { + return hooksToggleOptions{}, fmt.Errorf("unknown hooks %s option %q", verb, arg) + } + if options.hookID != "" { + return hooksToggleOptions{}, fmt.Errorf("hooks %s takes one hook id, got %q and %q", verb, options.hookID, arg) + } + options.hookID = arg + } + } + return options, nil +} + +func (r Runner) runHooksList(args []string, out io.Writer, runtimeRoot string) error { + options, err := parseHooksListArgs(args) + if err != nil { + return err + } + if options.help { + writeHooksListHelp(out) + return nil + } + projectRoot, err := project.ResolveRoot(runtimeRoot) + if err != nil { + return err + } + // A listing reads and never records. The plan resolver neither creates nor + // bootstraps a database, so a host that has never run Loaf reports every + // hook enabled instead of acquiring state in order to say so. + hookState, releaseHookState := r.hookStateForPlan(projectRoot.Path()) + defer releaseHookState() + surfaces, err := r.hooksTargetSurfaces(projectRoot.Path(), hookState, options.target) + if err != nil { + return err + } + ctx := context.Background() + listings := make([]hookListing, 0, len(surfaces)) + for _, surface := range surfaces { + listing := hookListing{target: surface.target, path: surface.path} + listing.rows, listing.err = readHookListing(ctx, surface) + listings = append(listings, listing) + } + writeHookListings(out, listings) + // One unreadable target does not cost the others their listing, but it does + // cost the run its exit code: what was printed is not the whole answer. + for _, listing := range listings { + if listing.err != nil { + return ExitError{Code: 1} + } + } + return nil +} + +func (r Runner) runHooksToggle(args []string, out io.Writer, runtimeRoot string, enable bool) error { + verb := hooksToggleVerb(enable) + options, err := parseHooksToggleArgs(verb, args) + if err != nil { + return err + } + if options.help { + writeHooksToggleHelp(out, verb) + return nil + } + if options.hookID == "" { + return fmt.Errorf("loaf hooks %s requires a hook id; run `loaf hooks list` to see the hooks this version ships", verb) + } + if options.target == "" { + return fmt.Errorf("loaf hooks %s requires --target: enablement is recorded per target (%s)", verb, strings.Join(reconciledHookTargets(), ", ")) + } + projectRoot, err := project.ResolveRoot(runtimeRoot) + if err != nil { + return err + } + hookState, releaseHookState := r.hookStateForApply(projectRoot.Path()) + defer releaseHookState() + surface, err := r.hooksTargetSurface(projectRoot.Path(), hookState, options.target) + if err != nil { + return err + } + reconciler, err := hooksReconcilerFor(surface.options) + if err != nil { + return err + } + entries, err := hooksCatalogEntriesFor(reconciler.catalog, options.hookID) + if err != nil { + return err + } + actions, err := toggleHookEnablement(context.Background(), reconciler, surface, entries, enable) + if err != nil { + return err + } + writeHookToggleResult(out, surface, entries, enable, actions) + return nil +} + +// toggleHookEnablement is Decision 10's ordering for a verb write, and the +// reason it composes the reconciler instead of editing one entry: take the +// target's lock, make the record durable inside it, then run the whole +// convergence, which recomputes from the records it just read and publishes the +// file before the lock is dropped. +// +// begin holds the lock and lands the absorption half; the enablement record goes +// in behind it, so a first-ever reconcile that absorbs this very hook is +// overridden by what the operator just asked for rather than racing it. +func toggleHookEnablement(ctx context.Context, reconciler *hookReconciler, surface hooksTargetSurface, entries []hookCatalogEntry, enable bool) ([]hookAction, error) { + if err := reconciler.begin(ctx); err != nil { + return nil, err + } + defer releaseHookReconcile(reconciler) + store, err := surface.options.HookState() + if err != nil { + return nil, err + } + for _, entry := range entries { + if _, err := store.SetHookEnablement(ctx, surface.target, entry.Event, entry.HookID, enable); err != nil { + return nil, err + } + } + return reconciler.complete(ctx) +} + +// hooksCatalogEntriesFor resolves a hook id against the target's built catalog, +// which is the identity authority: an id the catalog does not carry is not a +// hook this version ships for this target, whatever another target ships or an +// older release called it. +func hooksCatalogEntriesFor(catalog hookCatalog, hookID string) ([]hookCatalogEntry, error) { + var matched []hookCatalogEntry + for _, entry := range catalog.Entries { + if entry.HookID == hookID { + matched = append(matched, entry) + } + } + if len(matched) == 0 { + return nil, fmt.Errorf("%s ships no hook %q in this version; it ships: %s", installDisplayName(catalog.Target), hookID, strings.Join(hooksCatalogIDs(catalog), ", ")) + } + return matched, nil +} + +func hooksCatalogIDs(catalog hookCatalog) []string { + seen := map[string]bool{} + var ids []string + for _, entry := range catalog.Entries { + if seen[entry.HookID] { + continue + } + seen[entry.HookID] = true + ids = append(ids, entry.HookID) + } + sort.Strings(ids) + return ids +} + +// hooksTargetSurface is one installed target's reconciled hooks file: the +// install options a reconciler is built from, and where the file lives. +type hooksTargetSurface struct { + target string + options targetInstallOptions + path string +} + +func (r Runner) hooksTargetSurface(projectRoot string, hookState hookStateResolver, target string) (hooksTargetSurface, error) { + loafRoot, err := r.resolveInstalledDistributionRoot() + if err != nil { + return hooksTargetSurface{}, err + } + tools := detectInstallTools() + if err := requireOperableHooksTarget(target, installedUpgradeTargets(tools)); err != nil { + return hooksTargetSurface{}, err + } + return newHooksTargetSurface(loafRoot, tools, projectRoot, hookState, target), nil +} + +func (r Runner) hooksTargetSurfaces(projectRoot string, hookState hookStateResolver, only string) ([]hooksTargetSurface, error) { + loafRoot, err := r.resolveInstalledDistributionRoot() + if err != nil { + return nil, err + } + tools := detectInstallTools() + installed := installedUpgradeTargets(tools) + if only != "" { + if err := requireOperableHooksTarget(only, installed); err != nil { + return nil, err + } + return []hooksTargetSurface{newHooksTargetSurface(loafRoot, tools, projectRoot, hookState, only)}, nil + } + // Unfiltered, an uninstalled harness is simply not part of the answer, and + // one that keeps no shared hooks file has nothing this verb can say about + // it. Neither is an error: the listing describes what is here. + var surfaces []hooksTargetSurface + for _, target := range installed { + if !targetKeepsReconciledHookFile(target) { + continue + } + surfaces = append(surfaces, newHooksTargetSurface(loafRoot, tools, projectRoot, hookState, target)) + } + return surfaces, nil +} + +func newHooksTargetSurface(loafRoot string, tools []detectedInstallTool, projectRoot string, hookState hookStateResolver, target string) hooksTargetSurface { + configDir := defaultInstallConfigDirs()[target] + if tool, ok := installToolsByKey(tools)[target]; ok && tool.configDir != "" { + configDir = tool.configDir + } + options := targetInstallOptions{ + Target: target, + DistDir: filepath.Join(loafRoot, "dist", target), + ConfigDir: configDir, + Version: packageVersion(loafRoot), + HomeDir: installHome(), + CodexHome: os.Getenv("CODEX_HOME"), + ProjectRoot: projectRoot, + HookState: hookState, + } + return hooksTargetSurface{target: target, options: options, path: targetHookFilePath(options)} +} + +// requireOperableHooksTarget rejects the three ways a named target can have no +// hooks to operate, each with the command that would change it. +func requireOperableHooksTarget(target string, installed []string) error { + if !isKnownHooksHarness(target) { + return fmt.Errorf("unknown target %q (valid targets: %s)", target, strings.Join(reconciledHookTargets(), ", ")) + } + if !targetKeepsReconciledHookFile(target) { + return fmt.Errorf("%s keeps no hooks file Loaf reconciles per entry; use --target %s", installDisplayName(target), strings.Join(reconciledHookTargets(), " or --target ")) + } + if !containsString(installed, target) { + return fmt.Errorf("%s is not installed here, so it has no hooks to operate; run `loaf install --to %s` first", installDisplayName(target), target) + } + return nil +} + +// isKnownHooksHarness reports whether target names a harness Loaf deploys to at +// all. It reads the display-name map rather than installValidTargets, which is +// narrower — that list is what `loaf install --to` accepts, and Claude Code is +// not on it because its content rides the plugin. Claude Code is still a real +// harness with real hooks, so naming it here earns the explanation of where +// those hooks live, not a claim that Loaf has never heard of it. +func isKnownHooksHarness(target string) bool { + _, known := installDisplayNames[target] + return known +} + +// targetKeepsReconciledHookFile asks the path resolver rather than restating +// its switch, so a target that grows a reconciled hooks file later is operable +// through this verb the moment it does. +func targetKeepsReconciledHookFile(target string) bool { + return targetHookFilePath(targetInstallOptions{Target: target, ConfigDir: ".", HomeDir: "."}) != "" +} + +func reconciledHookTargets() []string { + var targets []string + for _, target := range installValidTargets { + if targetKeepsReconciledHookFile(target) { + targets = append(targets, target) + } + } + return targets +} + +// hooksReconcilerFor builds the target's reconciler, naming the two ways an +// installed distribution can fail to carry one before the reconciler's own +// staleness message has to. +func hooksReconcilerFor(options targetInstallOptions) (*hookReconciler, error) { + if !dirExistsForInstall(options.DistDir) { + return nil, fmt.Errorf("no build output found for %s at %s; run `loaf build` or reinstall Loaf", installDisplayName(options.Target), options.DistDir) + } + reconciler, err := newHookReconciler(options) + if err != nil { + return nil, err + } + if reconciler == nil { + return nil, fmt.Errorf("%s keeps no hooks file Loaf reconciles per entry", installDisplayName(options.Target)) + } + return reconciler, nil +} + +// hookListingRow is one catalog identity as the operator sees it: what it is, +// what the records say, and what the file carries. The last two are independent +// — a disagreement is a pending reconcile, never a conflict. +type hookListingRow struct { + event string + hookID string + enabled bool + projected bool + absorbedAt string +} + +type hookListing struct { + target string + path string + rows []hookListingRow + err error +} + +// readHookListing reports every hook the current catalog ships for one target. +// It iterates the catalog rather than the records, which is what keeps a +// tombstoned row — an enablement record for a hook id this version retired — +// out of the listing while leaving it in the database where Decision 5 wants it. +func readHookListing(ctx context.Context, surface hooksTargetSurface) ([]hookListingRow, error) { + reconciler, err := hooksReconcilerFor(surface.options) + if err != nil { + return nil, err + } + store, err := surface.options.HookState() + if err != nil { + return nil, err + } + records, err := loadHookRecords(ctx, store, surface.target) + if err != nil { + return nil, err + } + projected, err := projectedHookIdentities(reconciler, records) + if err != nil { + return nil, err + } + rows := make([]hookListingRow, 0, len(reconciler.catalog.Entries)) + for _, entry := range reconciler.catalog.Entries { + key := hookRecordKey(entry.Event, entry.HookID) + row := hookListingRow{ + event: entry.Event, + hookID: entry.HookID, + enabled: records.enabled(entry.Event, entry.HookID), + projected: projected[key], + } + if record, recorded := records.enablement[key]; recorded && record.AbsorbedAt != nil { + row.absorbedAt = *record.AbsorbedAt + } + rows = append(rows, row) + } + sort.Slice(rows, func(i, j int) bool { + if rows[i].event != rows[j].event { + return rows[i].event < rows[j].event + } + return rows[i].hookID < rows[j].hookID + }) + return rows, nil +} + +// projectedHookIdentities answers which catalog identities the live file +// currently carries. It runs the same recognition and pairing a reconcile does: +// a listing that judged the file by its own rules would be a second opinion +// about ownership, and there is only one. +func projectedHookIdentities(reconciler *hookReconciler, records hookRecords) (map[string]bool, error) { + file, err := readHookFile(reconciler.path) + if err != nil { + return nil, err + } + recognition := reconciler.recognition(records) + projected := map[string]bool{} + for _, event := range reconciler.reconciledEvents(file) { + entries, err := file.eventEntries(event) + if err != nil { + return nil, err + } + outcome, err := pairHookEventEntries(recognition, event, entries) + if err != nil { + return nil, err + } + for _, pairing := range outcome.paired { + projected[hookRecordKey(event, pairing.hookID)] = true + } + } + return projected, nil +} + +func writeHookListings(out io.Writer, listings []hookListing) { + fmt.Fprintln(out) + fmt.Fprintln(out, ansiBold("loaf hooks")) + fmt.Fprintln(out) + if len(listings) == 0 { + fmt.Fprintf(out, " %s\n\n", ansiGray("No installed harness keeps a Loaf-reconciled hooks file. Run loaf install --to cursor or loaf install --to codex.")) + return + } + home := installHome() + for _, listing := range listings { + fmt.Fprintf(out, " %s %s\n", ansiBold(installDisplayName(listing.target)), ansiGray(displayHomeRelativePath(listing.path, home))) + switch { + case listing.err != nil: + fmt.Fprintf(out, " %s %v\n", ansiRed("✗"), listing.err) + case len(listing.rows) == 0: + fmt.Fprintf(out, " %s\n", ansiGray("This version ships no hooks for this target")) + default: + writeHookListingRows(out, listing.rows) + } + fmt.Fprintln(out) + } +} + +// writeHookListingRows prints enablement and file state as two independent +// columns, because they are two independent facts. Their disagreement is what +// the trailing note names, and it is always resolvable by a reconcile. +func writeHookListingRows(out io.Writer, rows []hookListingRow) { + hookWidth, eventWidth := 0, 0 + for _, row := range rows { + if len(row.hookID) > hookWidth { + hookWidth = len(row.hookID) + } + if len(row.event) > eventWidth { + eventWidth = len(row.event) + } + } + for _, row := range rows { + line := fmt.Sprintf(" %-*s %-*s %-8s %-7s", hookWidth, row.hookID, eventWidth, row.event, hookEnablementLabel(row.enabled), hookProjectionLabel(row.projected)) + note := hookListingNote(row) + if note == "" { + fmt.Fprintln(out, strings.TrimRight(line, " ")) + continue + } + fmt.Fprintf(out, "%s %s\n", line, ansiGray(note)) + } +} + +func hookEnablementLabel(enabled bool) string { + if enabled { + return "enabled" + } + return "disabled" +} + +func hookProjectionLabel(projected bool) string { + if projected { + return "present" + } + return "absent" +} + +func hookListingNote(row hookListingRow) string { + var notes []string + if row.absorbedAt != "" { + notes = append(notes, "(absorbed "+hookAbsorptionDate(row.absorbedAt)+")") + } + if row.enabled != row.projected { + notes = append(notes, "reconcile pending") + } + return strings.Join(notes, " ") +} + +// hookAbsorptionDate renders immutable absorption provenance at the resolution +// an operator reads it at. The stored timestamp keeps its full precision. +func hookAbsorptionDate(value string) string { + if parsed, err := time.Parse(time.RFC3339, value); err == nil { + return parsed.Format("2006-01-02") + } + return value +} + +// writeHookToggleResult says what the operator asked for and what happened to +// that hook's entry, then names everything else the same reconcile did. On a +// converged target the second part is empty, which is the sketch's one line; +// when the target had drift, the extra lines are the honest difference between +// "one entry changed" and "one entry was asked about". +func writeHookToggleResult(out io.Writer, surface hooksTargetSurface, entries []hookCatalogEntry, enable bool, actions []hookAction) { + path := displayHomeRelativePath(surface.path, installHome()) + fmt.Fprintln(out) + spoken := map[int]bool{} + for _, entry := range entries { + index := hookFileActionFor(actions, entry, spoken) + headline := hookToggleHeadline(enable, hookActionKindAt(actions, index), path) + if len(entries) > 1 { + headline = entry.Event + ": " + headline + } + spoken[index] = true + fmt.Fprintf(out, " %s %s\n", ansiGreen("✓"), headline) + } + var remaining []hookAction + for index, action := range actions { + if !spoken[index] { + remaining = append(remaining, action) + } + } + if len(remaining) > 0 { + fmt.Fprintf(out, " %s\n", ansiGray("Also in this run:")) + writeHookActionLines(out, remaining) + } + fmt.Fprintln(out) +} + +func hookToggleHeadline(enable bool, action string, path string) string { + if enable { + switch action { + case hookActionAdd: + return "enabled — entry restored to " + path + case hookActionUpdate: + return "enabled — entry updated in " + path + default: + return "enabled — entry already in " + path + } + } + switch action { + case hookActionRemove: + return "disabled — entry removed from " + path + default: + return "disabled — no entry in " + path + } +} + +// hookFileActionFor finds the single action a headline can speak for, skipping +// the ones an earlier headline already claimed. It returns -1 when the reconcile +// changed nothing for this identity — the record moved and the file already +// agreed — and claiming exactly one keeps a second action on the same identity, +// such as a duplicate entry the same run removed, in the reported remainder. +func hookFileActionFor(actions []hookAction, entry hookCatalogEntry, spoken map[int]bool) int { + for index, action := range actions { + if spoken[index] || action.event != entry.Event || action.hookID != entry.HookID || !isHookFileAction(action.action) { + continue + } + return index + } + return -1 +} + +func hookActionKindAt(actions []hookAction, index int) string { + if index < 0 { + return "" + } + return actions[index].action +} + +func isHookFileAction(action string) bool { + switch action { + case hookActionAdd, hookActionUpdate, hookActionRemove: + return true + default: + return false + } +} + +func hooksToggleVerb(enable bool) string { + if enable { + return "enable" + } + return "disable" +} + +// displayHomeRelativePath prints an absolute path under the operator's home the +// way they would type it. Display only: every path this verb acts on stays +// absolute. +func displayHomeRelativePath(path string, home string) string { + if home == "" || path == "" { + return path + } + prefix := home + string(filepath.Separator) + if !strings.HasPrefix(path, prefix) { + return path + } + return "~" + string(filepath.Separator) + path[len(prefix):] +} + +func writeHooksHelp(out io.Writer) { + writeCommandGroupHelp(out, "loaf hooks [options]", + "Inspect and set which Loaf hooks project into an installed harness's hooks file.", + []subcommandHelpItem{ + {Name: "list", Summary: "Show every hook this version ships, its enablement, and whether the file carries it"}, + {Name: "enable", Summary: "Record a hook as enabled for one target and reconcile that target's hooks file"}, + {Name: "disable", Summary: "Record a hook as disabled for one target and reconcile that target's hooks file"}, + }) +} + +func writeHooksListHelp(out io.Writer) { + writeUsageHelp(out, "loaf hooks list [--target ]", + "Show every hook this version ships for each installed target, with its event, effective enablement, whether the live file currently carries it, and absorption provenance. Retired hook ids are not listed; entries Loaf does not own are never mentioned.", + "--target Restrict the listing to one installed target ("+strings.Join(reconciledHookTargets(), ", ")+")") +} + +func writeHooksToggleHelp(out io.Writer, verb string) { + writeUsageHelp(out, "loaf hooks "+verb+" --target ", + "Record as "+verb+"d for one target, then reconcile that target's hooks file and report every action the reconcile took.", + "--target Target whose hooks file to reconcile ("+strings.Join(reconciledHookTargets(), ", ")+")") +} diff --git a/internal/cli/hooks_test.go b/internal/cli/hooks_test.go new file mode 100644 index 000000000..89d5952b2 --- /dev/null +++ b/internal/cli/hooks_test.go @@ -0,0 +1,641 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/levifig/loaf/internal/state" +) + +// hooks_test.go drives the verb the way an operator does: against an installed +// distribution on a fixture machine, with no source checkout in reach. What it +// asserts is the contract the shape states — a listing that agrees with both the +// records and the file, a toggle that edits one entry on a converged target and +// names everything else it touched on one that had drifted, and errors that say +// which command would fix them. + +// TestRunnerHooksListReportsEnablementAndFileStateForEveryCatalogHook covers all +// four combinations of the two independent facts a row carries. Enablement and +// projection disagreeing is a pending reconcile, not a conflict, and the listing +// has to be able to say so. +func TestRunnerHooksListReportsEnablementAndFileStateForEveryCatalogHook(t *testing.T) { + root, home := setupHooksFixture(t, hooksFixtureCursorSources()...) + path := hooksFixtureFilePath(home) + + // validate-commit is enabled and present: untouched since the install + // projected it. The other three are recorded first and then hand-edited, + // because every verb call converges the whole file — which is the point. + runInstallFixture(t, root, "hooks", "disable", "render-drift", "--target", "cursor") + runInstallFixture(t, root, "hooks", "disable", "validate-push", "--target", "cursor") + restoreHooksFixtureEntry(t, path, "beforeShellExecution", map[string]any{ + "command": "loaf check --hook validate-push", + "matcher": "Bash", + "loaf-managed": true, + }) + deleteHooksFixtureEntry(t, path, "beforeSubmitPrompt", "loaf check --hook artifact-names") + + rows := parseHooksListRows(t, runInstallCapture(t, root, "hooks", "list")) + + for _, want := range []hooksListRow{ + {hookID: "validate-commit", event: "beforeShellExecution", enablement: "enabled", projection: "present"}, + {hookID: "validate-push", event: "beforeShellExecution", enablement: "disabled", projection: "present"}, + {hookID: "render-drift", event: "beforeShellExecution", enablement: "disabled", projection: "absent"}, + {hookID: "artifact-names", event: "beforeSubmitPrompt", enablement: "enabled", projection: "absent"}, + } { + got, listed := rows[want.hookID] + if !listed { + t.Fatalf("hooks list rows = %#v, want a row for %s", rows, want.hookID) + } + if got.event != want.event || got.enablement != want.enablement || got.projection != want.projection { + t.Fatalf("%s row = %#v, want %#v", want.hookID, got, want) + } + } + if note := rows["artifact-names"].note; !strings.Contains(note, "reconcile pending") { + t.Fatalf("enabled-but-missing note = %q, want it to name the pending reconcile", note) + } + if note := rows["validate-commit"].note; note != "" { + t.Fatalf("in-sync note = %q, want nothing said about a converged hook", note) + } +} + +// The listing enumerates the catalog, so a record for a hook id this version no +// longer ships stays in the database as Decision 5 requires and out of the +// operator's view, which only ever describes what this version can project. +func TestRunnerHooksListOmitsTombstonedRecords(t *testing.T) { + root, _ := setupHooksFixture(t, hooksFixtureCursorSources()...) + withHooksFixtureStore(t, func(store *state.Store) { + if _, err := store.SetHookEnablement(context.Background(), "cursor", "beforeShellExecution", "retired-generation-hook", false); err != nil { + t.Fatalf("SetHookEnablement error = %v", err) + } + }) + + output := runInstallCapture(t, root, "hooks", "list", "--target", "cursor") + + if strings.Contains(output, "retired-generation-hook") { + t.Fatalf("hooks list = %q, want no row for a hook id this version does not ship", output) + } + if rows := parseHooksListRows(t, output); len(rows) != len(hooksFixtureCursorSources()) { + t.Fatalf("hooks list rows = %#v, want exactly the catalog", rows) + } +} + +// A listing is a read. It may not rewrite the file it describes, and it may not +// acquire state in order to report that no state exists. +func TestRunnerHooksListWritesNothing(t *testing.T) { + root, home := setupHooksFixture(t, hooksFixtureCursorSources()...) + path := hooksFixtureFilePath(home) + before := readFileBytes(t, path) + database := os.Getenv("LOAF_DB") + if err := os.Remove(database); err != nil { + t.Fatalf("Remove(%s) error = %v", database, err) + } + + rows := parseHooksListRows(t, runInstallCapture(t, root, "hooks", "list", "--target", "cursor")) + + if !bytes.Equal(readFileBytes(t, path), before) { + t.Fatalf("hooks list rewrote %s", path) + } + if _, err := os.Stat(database); !os.IsNotExist(err) { + t.Fatalf("Stat(%s) = %v, want the listing to leave a stateless host stateless", database, err) + } + for _, row := range rows { + if row.enablement != "enabled" || row.projection != "present" { + t.Fatalf("row = %#v, want absence of records to read as enabled", row) + } + } +} + +// A harness that is not installed is simply not part of the answer: it is never +// an error, and it never costs the installed ones their listing. +func TestRunnerHooksListSkipsUninstalledTargets(t *testing.T) { + root, _ := setupHooksFixture(t, hooksFixtureCursorSources()...) + + output := stripANSI(runInstallCapture(t, root, "hooks", "list")) + + if !strings.Contains(output, "Cursor") { + t.Fatalf("hooks list = %q, want the installed target listed", output) + } + if strings.Contains(output, "Codex") { + t.Fatalf("hooks list = %q, want an uninstalled target left out", output) + } +} + +// H3, at the level the verb can prove on a fixture: a round trip on a converged +// target edits exactly one entry in each direction. Everything else — foreign +// entries, the other Loaf entries, the top-level fields — survives with its +// exact JSON value and its position among the rest. +func TestRunnerHooksToggleRoundTripEditsExactlyOneEntry(t *testing.T) { + root, home := setupHooksFixture(t, hooksFixtureCursorSources()...) + path := hooksFixtureFilePath(home) + seedHooksFixtureForeignEntries(t, path) + converged := string(readFileBytes(t, path)) + entry := "beforeShellExecution " + hooksFixtureEntryJSON("loaf check --hook validate-push") + + output := stripANSI(runInstallCapture(t, root, "hooks", "disable", "validate-push", "--target", "cursor")) + disabled := string(readFileBytes(t, path)) + + if !strings.Contains(output, "disabled — entry removed from ~/.cursor/hooks.json") { + t.Fatalf("hooks disable = %q, want the removal named against the file", output) + } + assertHooksDocumentDelta(t, converged, disabled, nil, []string{entry}) + + output = stripANSI(runInstallCapture(t, root, "hooks", "enable", "validate-push", "--target", "cursor")) + reenabled := string(readFileBytes(t, path)) + + if !strings.Contains(output, "enabled — entry restored to ~/.cursor/hooks.json") { + t.Fatalf("hooks enable = %q, want the restoration named against the file", output) + } + assertHooksDocumentDelta(t, disabled, reenabled, []string{entry}, nil) +} + +// The verb runs the whole reconciler, so a target carrying drift the operator +// did not ask about has that drift converged too — and the output says so +// rather than reporting single-entry surgery it did not perform. +func TestRunnerHooksToggleNamesEveryActionWhenTheTargetHasDrift(t *testing.T) { + root, home := setupHooksFixture(t, hooksFixtureCursorSources()...) + path := hooksFixtureFilePath(home) + weakenHooksFixtureEntry(t, path, "beforeShellExecution", "loaf check --hook validate-commit") + restoreHooksFixtureEntry(t, path, "beforeShellExecution", map[string]any{ + "command": "loaf check --hook validate-commit", + "matcher": "Bash", + "loaf-managed": true, + }) + + output := stripANSI(runInstallCapture(t, root, "hooks", "disable", "validate-push", "--target", "cursor")) + + for _, want := range []string{ + "disabled — entry removed from ~/.cursor/hooks.json", + "Also in this run:", + "update hook:beforeShellExecution/validate-commit", + "remove hook:beforeShellExecution/validate-commit", + } { + if !strings.Contains(output, want) { + t.Fatalf("hooks disable = %q, want it to name %q", output, want) + } + } + if strings.Contains(string(readFileBytes(t, path)), "--advisory") { + t.Fatalf("weakened enforcement survived the verb's reconcile:\n%s", readFileBytes(t, path)) + } +} + +// absorbed_at is immutable provenance, not state: toggling the hook it belongs +// to is exactly what the record exists to allow, and it must not erase how the +// record came to be. +func TestRunnerHooksToggleKeepsAbsorptionProvenance(t *testing.T) { + root, _ := setupHooksFixtureWithState(t, func(store *state.Store) { + if _, err := store.AbsorbAndMarkHooks(context.Background(), "cursor", "0.2.20", []state.HookEnablementRef{ + {Target: "cursor", Event: "beforeShellExecution", HookID: "validate-push"}, + }); err != nil { + t.Fatalf("AbsorbAndMarkHooks error = %v", err) + } + }, hooksFixtureCursorSources()...) + absorbed := hooksFixtureAbsorbedAt(t, "beforeShellExecution", "validate-push") + + rows := parseHooksListRows(t, runInstallCapture(t, root, "hooks", "list", "--target", "cursor")) + if row := rows["validate-push"]; row.enablement != "disabled" || row.projection != "absent" { + t.Fatalf("absorbed row = %#v, want the disable-intent honoured by the install that followed it", row) + } + if want := "(absorbed " + time.Now().UTC().Format("2006-01-02") + ")"; !strings.Contains(rows["validate-push"].note, want) { + t.Fatalf("absorbed note = %q, want %q", rows["validate-push"].note, want) + } + + runInstallFixture(t, root, "hooks", "enable", "validate-push", "--target", "cursor") + runInstallFixture(t, root, "hooks", "disable", "validate-push", "--target", "cursor") + + if got := hooksFixtureAbsorbedAt(t, "beforeShellExecution", "validate-push"); got != absorbed { + t.Fatalf("absorbed_at = %q after a round trip, want the immutable %q", got, absorbed) + } + rows = parseHooksListRows(t, runInstallCapture(t, root, "hooks", "list", "--target", "cursor")) + if !strings.Contains(rows["validate-push"].note, "(absorbed ") { + t.Fatalf("absorbed note = %q after a round trip, want the provenance still reported", rows["validate-push"].note) + } +} + +func TestRunnerHooksUnknownHookIDNamesWhatTheTargetShips(t *testing.T) { + root, _ := setupHooksFixture(t, hooksFixtureCursorSources()...) + + err := runHooksExpectingError(t, root, "hooks", "disable", "session-start-loaf", "--target", "cursor") + + for _, want := range []string{"ships no hook \"session-start-loaf\"", "validate-commit", "validate-push"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error = %v, want it to contain %q", err, want) + } + } +} + +func TestRunnerHooksTargetErrorsAreActionable(t *testing.T) { + root, _ := setupHooksFixture(t, hooksFixtureCursorSources()...) + + for _, testCase := range []struct { + name string + args []string + want string + }{ + {name: "uninstalled", args: []string{"hooks", "disable", "validate-push", "--target", "codex"}, want: "run `loaf install --to codex` first"}, + {name: "no hooks file", args: []string{"hooks", "list", "--target", "amp"}, want: "Amp keeps no hooks file Loaf reconciles per entry; use --target cursor or --target codex"}, + // Claude Code is a harness Loaf deploys to whose hooks ride the plugin + // rather than a shared JSON file. It is absent from the `install --to` + // target list, which is exactly why naming it here must not read as a + // name Loaf has never heard of. + {name: "no hooks file outside the install target list", args: []string{"hooks", "list", "--target", "claude-code"}, want: "Claude Code keeps no hooks file Loaf reconciles per entry; use --target cursor or --target codex"}, + {name: "unknown", args: []string{"hooks", "list", "--target", "vim"}, want: "unknown target \"vim\" (valid targets: cursor, codex)"}, + {name: "missing target", args: []string{"hooks", "disable", "validate-push"}, want: "requires --target"}, + {name: "missing hook id", args: []string{"hooks", "disable", "--target", "cursor"}, want: "requires a hook id"}, + } { + t.Run(testCase.name, func(t *testing.T) { + err := runHooksExpectingError(t, root, testCase.args...) + if !strings.Contains(err.Error(), testCase.want) { + t.Fatalf("error = %v, want it to contain %q", err, testCase.want) + } + }) + } +} + +// A stale distribution is the one failure a target can carry into the listing +// without the others deserving to lose theirs: it is reported in place, and the +// run still exits non-zero because what was printed is not the whole answer. +func TestRunnerHooksListReportsAStaleDistributionAndExitsNonZero(t *testing.T) { + root, _ := setupHooksFixture(t, hooksFixtureCursorSources()...) + if err := os.Remove(filepath.Join(root, "dist", "cursor", hookCatalogFile)); err != nil { + t.Fatalf("Remove(catalog) error = %v", err) + } + + output := runUpgradeExpectingExitError(t, root, "hooks", "list", "--target", "cursor") + + if !strings.Contains(stripANSI(output), "read hook catalog") { + t.Fatalf("hooks list = %q, want the unreadable catalog reported in place", output) + } +} + +// The round-trip oracle is only as good as what it flattens, and an event +// section carrying no entries contributes no entry lines at all. This pins the +// section markers that make dropping one visible: without them the two +// documents below flatten identically and the exact-one-entry assertion would +// pass a reconcile that silently removed a section the file declared. +func TestHooksDocumentLinesRecordEmptyEventSections(t *testing.T) { + withSection := `{"version":1,"hooks":{"preToolUse":[{"command":"loaf check"}],"afterFileEdit":[]}}` + withoutSection := `{"version":1,"hooks":{"preToolUse":[{"command":"loaf check"}]}}` + + lines := hooksDocumentLines(t, withSection) + dropped := hooksDocumentLines(t, withoutSection) + + if !containsString(lines, "event afterFileEdit") { + t.Fatalf("document lines = %#v, want the empty section recorded", lines) + } + if equalStringSlices(lines, dropped) { + t.Fatalf("dropping an empty section left the flattening unchanged: %#v", lines) + } +} + +func TestRunnerHooksHelpNamesEverySubcommand(t *testing.T) { + root, _ := setupHooksFixture(t, hooksFixtureCursorSources()...) + + for _, testCase := range []struct { + args []string + wants []string + }{ + {args: []string{"hooks"}, wants: []string{"loaf hooks ", "list", "enable", "disable"}}, + {args: []string{"hooks", "--help"}, wants: []string{"loaf hooks "}}, + {args: []string{"hooks", "list", "--help"}, wants: []string{"Usage: loaf hooks list", "--target "}}, + {args: []string{"hooks", "enable", "--help"}, wants: []string{"Usage: loaf hooks enable --target "}}, + {args: []string{"hooks", "disable", "--help"}, wants: []string{"Usage: loaf hooks disable --target "}}, + {args: []string{"--help"}, wants: []string{"hooks Inspect and set Loaf hook enablement per target"}}, + } { + output := stripANSI(runInstallCapture(t, root, testCase.args...)) + for _, want := range testCase.wants { + if !strings.Contains(output, want) { + t.Fatalf("%v = %q, want it to contain %q", testCase.args, output, want) + } + } + } + if err := runHooksExpectingError(t, root, "hooks", "toggle"); !strings.Contains(err.Error(), "unknown hooks subcommand") { + t.Fatalf("error = %v, want an unknown-subcommand rejection", err) + } +} + +// --- helpers ------------------------------------------------------------- + +type hooksListRow struct { + hookID string + event string + enablement string + projection string + note string +} + +// setupHooksFixture builds an installed Cursor whose distribution carries the +// given catalog, which is what the verb resolves identity through. Nothing here +// is a source checkout: the fixture is a machine with Loaf installed on it. +func setupHooksFixture(t *testing.T, sources ...hookCatalogSource) (string, string) { + t.Helper() + return setupHooksFixtureWithState(t, nil, sources...) +} + +// setupHooksFixtureWithState seeds the user-scoped records before the install +// runs, which is the only way to reach the states a prior generation leaves +// behind — an absorbed record predates every install this fixture can perform. +func setupHooksFixtureWithState(t *testing.T, seed func(*state.Store), sources ...hookCatalogSource) (string, string) { + t.Helper() + root, home := setupInstallCommandFixture(t) + t.Setenv("LOAF_DB", filepath.Join(t.TempDir(), "loaf.sqlite")) + if seed != nil { + withHooksFixtureStore(t, seed) + } + writeInstallFile(t, filepath.Join(root, "dist", "cursor", "skills", "foundations", "SKILL.md"), "# Foundations\n") + installTestHookDistribution(t, root, "cursor", sources...) + runInstallFixture(t, root, "install", "--to", "cursor", "--yes") + return root, home +} + +// hooksFixtureCursorSources is a catalog wide enough to hold all four listing +// states at once, across two events so the listing has something to group. +func hooksFixtureCursorSources() []hookCatalogSource { + return []hookCatalogSource{ + hooksFixtureCursorSource("beforeShellExecution", "validate-commit"), + hooksFixtureCursorSource("beforeShellExecution", "validate-push"), + hooksFixtureCursorSource("beforeShellExecution", "render-drift"), + hooksFixtureCursorSource("beforeSubmitPrompt", "artifact-names"), + } +} + +func hooksFixtureCursorSource(event string, hookID string) hookCatalogSource { + command := "loaf check --hook " + hookID + return hookCatalogSource{ + event: event, + hookID: hookID, + typeName: "command", + command: command, + template: map[string]any{"command": command, "matcher": "Bash", "loaf-managed": true}, + } +} + +func hooksFixtureEntryJSON(command string) string { + return `{"command":"` + command + `","loaf-managed":true,"matcher":"Bash"}` +} + +func hooksFixtureFilePath(home string) string { + return filepath.Join(home, ".cursor", "hooks.json") +} + +func withHooksFixtureStore(t *testing.T, use func(*state.Store)) { + t.Helper() + store, err := state.OpenStore(os.Getenv("LOAF_DB")) + if err != nil { + t.Fatalf("OpenStore error = %v", err) + } + defer store.Close() + if err := store.ApplyMigrations(context.Background()); err != nil { + t.Fatalf("ApplyMigrations error = %v", err) + } + use(store) +} + +func hooksFixtureAbsorbedAt(t *testing.T, event string, hookID string) string { + t.Helper() + var absorbed string + withHooksFixtureStore(t, func(store *state.Store) { + row, found, err := store.GetHookEnablement(context.Background(), "cursor", event, hookID) + if err != nil || !found { + t.Fatalf("GetHookEnablement = %v, %v, want the seeded record", found, err) + } + if row.AbsorbedAt == nil { + t.Fatalf("record = %#v, want absorption provenance", row) + } + absorbed = *row.AbsorbedAt + }) + return absorbed +} + +func runHooksExpectingError(t *testing.T, root string, args ...string) error { + t.Helper() + var stdout bytes.Buffer + err := Runner{Stdout: &stdout, WorkingDir: root, Executable: distributionFixtureExecutable(root)}.Run(args) + if err == nil { + t.Fatalf("%v error = nil, want a rejection\n%s", args, stdout.String()) + } + return err +} + +func parseHooksListRows(t *testing.T, output string) map[string]hooksListRow { + t.Helper() + rows := map[string]hooksListRow{} + for _, line := range strings.Split(stripANSI(output), "\n") { + fields := strings.Fields(line) + if len(fields) < 4 || (fields[2] != "enabled" && fields[2] != "disabled") { + continue + } + rows[fields[0]] = hooksListRow{ + hookID: fields[0], + event: fields[1], + enablement: fields[2], + projection: fields[3], + note: strings.Join(fields[4:], " "), + } + } + if len(rows) == 0 { + t.Fatalf("hooks list output has no rows:\n%s", output) + } + return rows +} + +func readHooksFixtureFile(t *testing.T, path string) map[string]any { + t.Helper() + var value map[string]any + if err := json.Unmarshal(readFileBytes(t, path), &value); err != nil { + t.Fatalf("Unmarshal(%s) error = %v", path, err) + } + return value +} + +func writeHooksFixtureFile(t *testing.T, path string, value map[string]any) { + t.Helper() + body, err := json.MarshalIndent(value, "", " ") + if err != nil { + t.Fatalf("Marshal hooks file error = %v", err) + } + writeInstallFile(t, path, string(body)+"\n") +} + +func hooksFixtureEventEntries(t *testing.T, value map[string]any, event string) []any { + t.Helper() + events, ok := value[hookFileEventsField].(map[string]any) + if !ok { + t.Fatalf("hooks file = %#v, want an events section", value) + } + entries, _ := events[event].([]any) + return entries +} + +func setHooksFixtureEventEntries(t *testing.T, value map[string]any, event string, entries []any) { + t.Helper() + events, ok := value[hookFileEventsField].(map[string]any) + if !ok { + t.Fatalf("hooks file = %#v, want an events section", value) + } + events[event] = entries +} + +// restoreHooksFixtureEntry puts a Loaf entry back by hand, which is how a +// disabled-but-present row comes about. +func restoreHooksFixtureEntry(t *testing.T, path string, event string, entry map[string]any) { + t.Helper() + value := readHooksFixtureFile(t, path) + setHooksFixtureEventEntries(t, value, event, append(hooksFixtureEventEntries(t, value, event), entry)) + writeHooksFixtureFile(t, path, value) +} + +func deleteHooksFixtureEntry(t *testing.T, path string, event string, command string) { + t.Helper() + value := readHooksFixtureFile(t, path) + // An emptied section keeps its key and stays an array: the file said the + // event existed, and a null there is a document the reconciler refuses. + kept := []any{} + for _, raw := range hooksFixtureEventEntries(t, value, event) { + if entry, ok := raw.(map[string]any); ok && entry["command"] == command { + continue + } + kept = append(kept, raw) + } + setHooksFixtureEventEntries(t, value, event, kept) + writeHooksFixtureFile(t, path, value) +} + +// weakenHooksFixtureEntry is the hand edit Decision 3 converges: a fail-closed +// check turned advisory, which still pairs to its hook id through the stem. +func weakenHooksFixtureEntry(t *testing.T, path string, event string, command string) { + t.Helper() + value := readHooksFixtureFile(t, path) + found := false + for _, raw := range hooksFixtureEventEntries(t, value, event) { + entry, ok := raw.(map[string]any) + if !ok || entry["command"] != command { + continue + } + entry["command"] = command + " --advisory" + found = true + } + if !found { + t.Fatalf("%s carries no entry commanding %q", path, command) + } + writeHooksFixtureFile(t, path, value) +} + +// seedHooksFixtureForeignEntries surrounds Loaf's entries with content Loaf +// does not own — foreign entries in both event sections, a top-level field, and +// an empty section for an event this version ships nothing for — so the round +// trip has the full range of things it promises to preserve. +func seedHooksFixtureForeignEntries(t *testing.T, path string) { + t.Helper() + value := readHooksFixtureFile(t, path) + value["description"] = "operator's own hooks" + for _, event := range []string{"beforeShellExecution", "beforeSubmitPrompt"} { + entries := hooksFixtureEventEntries(t, value, event) + foreign := map[string]any{"command": "bash /opt/herdr/agent-state.sh " + event, "matcher": "*"} + setHooksFixtureEventEntries(t, value, event, append([]any{foreign}, entries...)) + } + events, ok := value[hookFileEventsField].(map[string]any) + if !ok { + t.Fatalf("hooks file = %#v, want an events section", value) + } + events["afterFileEdit"] = []any{} + writeHooksFixtureFile(t, path, value) +} + +// hooksDocumentLines flattens everything a reconcile promises to leave alone: +// each top-level field Loaf does not own, each event section the document +// declares, and each entry, in the document's own order, as its exact JSON +// value. Whitespace is normalized because the contract promises value identity +// rather than byte identity — every key, every value, and every position is +// still compared literally. +// +// The section markers are what make an emptied event section visible. Without +// them a section carrying no entries contributes nothing to the flattening, and +// dropping one — a real regression, since the file said the event existed and +// Loaf has no opinion about sections it did not create — would read as no +// difference at all. +func hooksDocumentLines(t *testing.T, body string) []string { + t.Helper() + order, fields, err := decodeHookJSONObject([]byte(body)) + if err != nil { + t.Fatalf("decode hooks document error = %v", err) + } + var lines []string + for _, key := range order { + if key == hookFileEventsField { + continue + } + canonical, err := canonicalHookEntry(fields[key]) + if err != nil { + t.Fatalf("canonicalize field %q error = %v", key, err) + } + lines = append(lines, "field "+key+" = "+canonical) + } + events, sections, err := decodeHookJSONObject(fields[hookFileEventsField]) + if err != nil { + t.Fatalf("decode hooks section error = %v", err) + } + for _, event := range events { + lines = append(lines, "event "+event) + var entries []json.RawMessage + if err := json.Unmarshal(sections[event], &entries); err != nil { + t.Fatalf("decode %s entries error = %v", event, err) + } + for _, raw := range entries { + canonical, err := canonicalHookEntry(raw) + if err != nil { + t.Fatalf("canonicalize %s entry error = %v", event, err) + } + lines = append(lines, event+" "+canonical) + } + } + return lines +} + +// assertHooksDocumentDelta proves the two documents differ by exactly the named +// entries and nothing else — no other entry, no top-level field, and no event +// section. Anything that survived but moved shows up as one removal plus one +// addition, so this is an assertion about order as much as about content. +func assertHooksDocumentDelta(t *testing.T, before string, after string, wantAdded []string, wantRemoved []string) { + t.Helper() + beforeLines := hooksDocumentLines(t, before) + afterLines := hooksDocumentLines(t, after) + var added, removed []string + i, j := 0, 0 + for i < len(beforeLines) && j < len(afterLines) { + if beforeLines[i] == afterLines[j] { + i++ + j++ + continue + } + if containsString(beforeLines[i:], afterLines[j]) { + removed = append(removed, beforeLines[i]) + i++ + continue + } + added = append(added, afterLines[j]) + j++ + } + removed = append(removed, beforeLines[i:]...) + added = append(added, afterLines[j:]...) + if !equalStringSlices(added, wantAdded) || !equalStringSlices(removed, wantRemoved) { + t.Fatalf("document delta added %#v and removed %#v, want added %#v and removed %#v\nbefore:\n%s\nafter:\n%s", added, removed, wantAdded, wantRemoved, before, after) + } +} + +func equalStringSlices(got []string, want []string) bool { + if len(got) != len(want) { + return false + } + for index := range got { + if got[index] != want[index] { + return false + } + } + return true +} diff --git a/internal/cli/install.go b/internal/cli/install.go index fffea28c6..e6c4097ce 100644 --- a/internal/cli/install.go +++ b/internal/cli/install.go @@ -133,6 +133,8 @@ func (r Runner) runInstallWithOptions(options installOptions, out io.Writer, run var codexBasicCommandsErr error defaults := defaultInstallConfigDirs() toolByKey := installToolsByKey(tools) + hookState, releaseHookState := r.hookStateForApply(projectRoot.Path()) + defer releaseHookState() var installOptions []targetInstallOptions for _, target := range selectedTargets { distDir := filepath.Join(distRoot, target) @@ -157,6 +159,7 @@ func (r Runner) runInstallWithOptions(options installOptions, out io.Writer, run CodexHome: os.Getenv("CODEX_HOME"), ProjectRoot: projectRoot.Path(), SkipSkillsSync: true, + HookState: hookState, }) } skillsErr := syncCanonicalManagedSkills(installOptions) @@ -183,6 +186,8 @@ func (r Runner) runInstallWithOptions(options installOptions, out io.Writer, run fmt.Fprintf(out, " %s skills - %v\n", ansiRed("✗"), skillConflicts) skillsErrReported = true } + var hookActions []hookAction + opts.HookActions = func(actions []hookAction) { hookActions = append(hookActions, actions...) } err := installTargetDistribution(opts) if err != nil { fmt.Fprintf(out, " %s %s - %v\n", ansiRed("✗"), installDisplayName(opts.Target), err) @@ -192,6 +197,7 @@ func (r Runner) runInstallWithOptions(options installOptions, out io.Writer, run continue } fmt.Fprintf(out, " %s %s installed to %s\n", ansiGreen("✓"), installDisplayName(opts.Target), ansiGray(opts.ConfigDir)) + writeHookActionLines(out, hookActions) if opts.Target == "codex" { if options.codexBasicCommands { fmt.Fprintf(out, " %s Codex basic command policy explicitly enabled (Loaf-owned exact-prefix rules)\n", ansiGreen("✓")) diff --git a/internal/cli/install_canonical_skills_test.go b/internal/cli/install_canonical_skills_test.go index 933f93086..ca017ffec 100644 --- a/internal/cli/install_canonical_skills_test.go +++ b/internal/cli/install_canonical_skills_test.go @@ -51,8 +51,8 @@ func TestSingleCanonicalWrite(t *testing.T) { body = opencodeBody } writeInstallFile(t, filepath.Join(root, "dist", target, "skills", "foundations", "SKILL.md"), body) - if target == "codex" { - writeInstallFile(t, filepath.Join(root, "dist", target, ".codex", "hooks.json"), `{"hooks":{}}`+"\n") + if target == "cursor" || target == "codex" { + installTestHookDistribution(t, root, target) } mkdirAll(t, defaultInstallConfigDirsForHome(home)[target]) } @@ -70,8 +70,8 @@ func TestSingleCanonicalWrite(t *testing.T) { // Install without orchestration so the later foreign directory is unowned. for _, target := range targets { writeInstallFile(t, filepath.Join(root, "dist", target, "skills", "foundations", "SKILL.md"), sharedBody) - if target == "codex" { - writeInstallFile(t, filepath.Join(root, "dist", target, ".codex", "hooks.json"), `{"hooks":{}}`+"\n") + if target == "cursor" || target == "codex" { + installTestHookDistribution(t, root, target) } // Seed adapter artifacts so upgrade has something concrete to refresh. if target == "opencode" { @@ -196,6 +196,7 @@ func TestSingleCanonicalWrite(t *testing.T) { Version: "9.8.7-test.1", HomeDir: home, SkipSkillsSync: true, + HookState: installTestHookState(t), }) } if err := syncCanonicalManagedSkills(opts); err != nil { @@ -287,8 +288,8 @@ func TestSingleCanonicalWrite(t *testing.T) { body = tampered } writeInstallFile(t, filepath.Join(root, "dist", target, "skills", "foundations", "SKILL.md"), body) - if target == "codex" { - writeInstallFile(t, filepath.Join(root, "dist", target, ".codex", "hooks.json"), `{"hooks":{}}`+"\n") + if target == "cursor" || target == "codex" { + installTestHookDistribution(t, root, target) } mkdirAll(t, defaultInstallConfigDirsForHome(home)[target]) } @@ -512,8 +513,8 @@ func seedCanonicalSkillsFixture(t *testing.T, root, home string, targets []strin t.Helper() for _, target := range targets { writeInstallFile(t, filepath.Join(root, "dist", target, "skills", "foundations", "SKILL.md"), body) - if target == "codex" { - writeInstallFile(t, filepath.Join(root, "dist", target, ".codex", "hooks.json"), `{"hooks":{}}`+"\n") + if target == "cursor" || target == "codex" { + installTestHookDistribution(t, root, target) } // Ensure --to all detects every target without relying on host CLIs. mkdirAll(t, defaultInstallConfigDirsForHome(home)[target]) diff --git a/internal/cli/install_codex_rules_test.go b/internal/cli/install_codex_rules_test.go index f7ecbf1ba..ea7714a91 100644 --- a/internal/cli/install_codex_rules_test.go +++ b/internal/cli/install_codex_rules_test.go @@ -208,19 +208,19 @@ func TestInstallCodexJournalRuleRendersSymlinkedEntrypointAcrossSurfaces(t *test if !strings.Contains(string(guidance), journalContextShellQuote(entrypoint)+" journal log --execpolicy-safe") || strings.Contains(string(guidance), "cellar-1.0") { t.Fatalf("rendered guidance = %q, want entrypoint command without canonicalized segment", guidance) } - hooksDest := filepath.Join(fixture.codexHome, "hooks.json") - loafHooks := filepath.Join(fixture.dist, ".codex", "hooks.json") - writeInstallFile(t, loafHooks, `{"hooks":{"SessionStart":[{"matcher":"startup|resume|clear|compact","hooks":[{"type":"command","command":"{{LOAF_EXECUTABLE}} journal context --from-hook --codex-hook"}]}]}}`) - writeInstallFile(t, hooksDest, `{"hooks":{}}`) - if err := mergeCodexHookFilesForOS(hooksDest, loafHooks, filepath.Join(fixture.root, "project"), operations, "darwin"); err != nil { - t.Fatalf("merge hooks with symlinked entrypoint: %v", err) - } - hooks, err := os.ReadFile(hooksDest) + // The third surface: the hook entry reconciliation would project. It renders + // from the same trusted resolution, so the entrypoint survives here too. + executable, err := trustedCodexJournalExecutable(filepath.Join(fixture.root, "project"), operations) if err != nil { - t.Fatalf("read rendered hooks: %v", err) + t.Fatalf("trusted executable error = %v", err) + } + template := json.RawMessage(`{"matcher":"startup|resume|clear|compact","hooks":[{"type":"command","command":"{{LOAF_EXECUTABLE}} journal context --from-hook --codex-hook"}]}`) + entry, err := renderCodexHookExecutableForOS(template, executable, "darwin") + if err != nil { + t.Fatalf("render hook entry with symlinked entrypoint: %v", err) } - if !strings.Contains(string(hooks), journalContextShellQuote(entrypoint)+codexJournalHookCommandSuffix) || strings.Contains(string(hooks), "cellar-1.0") { - t.Fatalf("rendered hooks = %q, want entrypoint command without canonicalized segment", hooks) + if !strings.Contains(string(entry), journalContextShellQuote(entrypoint)+codexJournalHookCommandSuffix) || strings.Contains(string(entry), "cellar-1.0") { + t.Fatalf("rendered hook entry = %q, want entrypoint command without canonicalized segment", entry) } } diff --git a/internal/cli/install_command_test.go b/internal/cli/install_command_test.go index 20d6b9ea9..6b806e001 100644 --- a/internal/cli/install_command_test.go +++ b/internal/cli/install_command_test.go @@ -13,7 +13,7 @@ import ( func TestRunnerInstallExplicitCursorTargetRunsNatively(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", "hooks.json"), `{"version":1,"hooks":{"PostToolUse":[{"command":"loaf journal log --from-hook","matcher":"Bash","loaf-managed":true}]}}`) + installTestHookDistribution(t, root, "cursor") var stdout bytes.Buffer err := Runner{ @@ -52,8 +52,8 @@ func TestRunnerInstallUsesAgentsHomeSkillDestinations(t *testing.T) { t.Run(target, func(t *testing.T) { root, home := setupInstallCommandFixture(t) writeInstallFile(t, filepath.Join(root, "dist", target, "skills", "foundations", "SKILL.md"), "# Foundations\n") - if target == "codex" { - writeInstallFile(t, filepath.Join(root, "dist", target, ".codex", "hooks.json"), `{"version":1,"hooks":{}}`) + if target == "cursor" || target == "codex" { + installTestHookDistribution(t, root, target) } var stdout bytes.Buffer @@ -82,6 +82,7 @@ func TestRunnerInstallSharedSkillsPreservesForeignEntries(t *testing.T) { sharedSkills := filepath.Join(home, ".agents", "skills") writeInstallFile(t, filepath.Join(sharedSkills, "foreign-skill", "SKILL.md"), "# Mine\n") writeInstallFile(t, filepath.Join(root, "dist", "cursor", "skills", "foundations", "SKILL.md"), "# Foundations\n") + installTestHookDistribution(t, root, "cursor") var stdout bytes.Buffer err := Runner{Stdout: &stdout, WorkingDir: root, Executable: distributionFixtureExecutable(root)}.Run([]string{"install", "--to", "cursor", "--yes"}) @@ -108,6 +109,7 @@ func TestRunnerInstallSharedSkillsPreservesForeignEntries(t *testing.T) { func TestRunnerInstallRecordKeepsRelocatedTargetDetectable(t *testing.T) { root, home := setupInstallCommandFixture(t) writeInstallFile(t, filepath.Join(root, "dist", "cursor", "skills", "foundations", "SKILL.md"), "# Foundations\n") + installTestHookDistribution(t, root, "cursor") var stdout bytes.Buffer err := Runner{Stdout: &stdout, WorkingDir: root, Executable: distributionFixtureExecutable(root)}.Run([]string{"install", "--to", "cursor", "--yes"}) @@ -127,7 +129,7 @@ func TestRunnerInstallCodexUsesCodeXHomeNatively(t *testing.T) { codexHome := filepath.Join(home, "custom-codex") t.Setenv("CODEX_HOME", codexHome) writeInstallFile(t, filepath.Join(root, "dist", "codex", "skills", "go-development", "SKILL.md"), "# Go\n") - writeInstallFile(t, filepath.Join(root, "dist", "codex", ".codex", "hooks.json"), `{"hooks":{}}`) + installTestHookDistribution(t, root, "codex") var stdout bytes.Buffer err := Runner{Stdout: &stdout, WorkingDir: root, Executable: distributionFixtureExecutable(root)}.Run([]string{"install", "--to", "codex", "--yes"}) @@ -136,9 +138,11 @@ func TestRunnerInstallCodexUsesCodeXHomeNatively(t *testing.T) { } assertInstallFile(t, filepath.Join(codexHome, loafInstallMarkerFile), "9.8.7-test.1\n") assertInstallFile(t, filepath.Join(home, ".agents", "skills", "go-development", "SKILL.md"), "# Go\n") + // CODEX_HOME is where the reconciler converges too, not just where the + // marker lands: the entry it projects has to reach the same relocated root. hooks := readInstallHooks(t, filepath.Join(codexHome, "hooks.json")) - if hooks.Version != 0 || len(hooks.Hooks) != 0 { - t.Fatalf("codex hooks = %#v, want current empty Codex schema", hooks) + if len(hooks.Hooks["SessionStart"]) != 1 { + t.Fatalf("codex hooks = %#v, want the SessionStart entry projected under CODEX_HOME", hooks) } } @@ -147,7 +151,7 @@ func TestRunnerInstallCodexBasicCommandsFailsWhenCapabilityCannotBeInstalled(t * codexHome := filepath.Join(home, "custom-codex") t.Setenv("CODEX_HOME", codexHome) writeInstallFile(t, filepath.Join(root, "dist", "codex", "skills", "go-development", "SKILL.md"), "# Go\n") - writeInstallFile(t, filepath.Join(root, "dist", "codex", ".codex", "hooks.json"), `{"version":1,"hooks":{}}`) + installTestHookDistribution(t, root, "codex") writeInstallFile(t, filepath.Join(root, "dist", "codex", ".codex", "rules", "loaf.rules.tmpl"), "# Loaf Codex policy\n{{LOAF_BASIC_RULES}}\n") var stdout bytes.Buffer @@ -170,6 +174,7 @@ func TestRunnerInstallOffersBinarySelfInstall(t *testing.T) { } writeInstallFile(t, filepath.Join(root, "bin", "native", "darwin-arm64", "loaf"), "native\n") writeInstallFile(t, filepath.Join(root, "dist", "cursor", "skills", "foundations", "SKILL.md"), "# Foundations\n") + installTestHookDistribution(t, root, "cursor") mkdirAll(t, filepath.Join(home, ".cursor")) var stdout bytes.Buffer @@ -193,6 +198,7 @@ func TestRunnerInstallOffersBinarySelfInstall(t *testing.T) { func TestRunnerInstallInteractiveSelectionRunsNatively(t *testing.T) { root, home := setupInstallCommandFixture(t) writeInstallFile(t, filepath.Join(root, "dist", "cursor", "skills", "foundations", "SKILL.md"), "# Foundations\n") + installTestHookDistribution(t, root, "cursor") mkdirAll(t, filepath.Join(home, ".cursor")) var stdout bytes.Buffer @@ -217,6 +223,7 @@ func TestRunnerInstallInteractiveSelectionRunsNatively(t *testing.T) { func TestRunnerInstallInteractiveNoTargetsStillUpdatesClaudeProjectFile(t *testing.T) { root, home := setupInstallCommandFixture(t) writeInstallFile(t, filepath.Join(root, "dist", "cursor", "skills", "foundations", "SKILL.md"), "# Foundations\n") + installTestHookDistribution(t, root, "cursor") mkdirAll(t, filepath.Join(home, ".cursor")) writeInstallFile(t, filepath.Join(root, "bin", "claude"), "#!/bin/sh\nexit 0\n") if err := os.Chmod(filepath.Join(root, "bin", "claude"), 0o755); err != nil { @@ -251,6 +258,7 @@ func TestRunnerInstallMcpRecommendationWritesCursorProjectConfig(t *testing.T) { t.Fatalf("Chmod(fake loaf) error = %v", err) } writeInstallFile(t, filepath.Join(root, "dist", "cursor", "skills", "foundations", "SKILL.md"), "# Foundations\n") + installTestHookDistribution(t, root, "cursor") writeInstallFile(t, filepath.Join(root, ".cursor", "mcp.json"), `{"mcpServers":{"existing":{"command":"keep","args":[]}},"theme":"dark"}`+"\n") mkdirAll(t, filepath.Join(home, ".cursor")) @@ -311,6 +319,7 @@ EOS t.Fatalf("Chmod(fake uv) error = %v", err) } writeInstallFile(t, filepath.Join(root, "dist", "cursor", "skills", "foundations", "SKILL.md"), "# Foundations\n") + installTestHookDistribution(t, root, "cursor") mkdirAll(t, filepath.Join(home, ".cursor")) var stdout bytes.Buffer @@ -394,6 +403,7 @@ func TestRunnerInstallFromLinkedWorktreeWritesMainLoafConfig(t *testing.T) { mkdirAll(t, filepath.Join(home, ".cursor")) writeInstallFile(t, filepath.Join(main, "package.json"), `{"name":"loaf","version":"9.8.7-test.1"}`+"\n") writeInstallFile(t, filepath.Join(main, "dist", "cursor", "skills", "foundations", "SKILL.md"), "# Foundations\n") + installTestHookDistribution(t, main, "cursor") gitCLI(t, main, "add", "package.json", "dist/cursor/skills/foundations/SKILL.md") gitCLI(t, main, "-c", "user.name=Loaf Test", "-c", "user.email=loaf@example.test", "-c", "commit.gpgsign=false", "commit", "-m", "add install fixture") linked := addCLILinkedWorktree(t, main, "install-config") diff --git a/internal/cli/install_config_refusal_test.go b/internal/cli/install_config_refusal_test.go index 5b1908247..8164c0970 100644 --- a/internal/cli/install_config_refusal_test.go +++ b/internal/cli/install_config_refusal_test.go @@ -130,6 +130,7 @@ func TestInstallPreservesAProjectConfigItCannotRead(t *testing.T) { root, home := setupInstallCommandFixture(t) t.Setenv("LOAF_DB", filepath.Join(t.TempDir(), "loaf.sqlite")) writeInstallFile(t, filepath.Join(root, "dist", "cursor", "skills", "foundations", "SKILL.md"), "# Foundations\n") + installTestHookDistribution(t, root, "cursor") mkdirAll(t, filepath.Join(home, ".cursor")) testCase.setup(t, root) diff --git a/internal/cli/install_deprecations_ownership_test.go b/internal/cli/install_deprecations_ownership_test.go index 876f9364e..960374883 100644 --- a/internal/cli/install_deprecations_ownership_test.go +++ b/internal/cli/install_deprecations_ownership_test.go @@ -371,6 +371,7 @@ func TestDestructiveMigrationPreservesMarkerPresentForeignAgent(t *testing.T) { agentHome := filepath.Join(home, ".cursor", "agents") retiredAgent := filepath.Join(agentHome, "old-agent.md") writeInstallFile(t, filepath.Join(root, "dist", "cursor", "skills", "foundations", "SKILL.md"), "# Foundations\n") + installTestHookDistribution(t, root, "cursor") writeInstallFile(t, filepath.Join(home, ".cursor", loafInstallMarkerFile), "old\n") writeInstallFile(t, retiredAgent, "# User-authored agent\n") writeInstallDeprecationManifest(t, root, `{ diff --git a/internal/cli/install_harness_config_refusal_test.go b/internal/cli/install_harness_config_refusal_test.go index 1773de414..19a29c8f0 100644 --- a/internal/cli/install_harness_config_refusal_test.go +++ b/internal/cli/install_harness_config_refusal_test.go @@ -8,7 +8,7 @@ import ( "testing" ) -// Harness MCP configs and legacy Codex hooks.json share the loaf.json contract: +// Harness MCP configs and the shared hooks file share the loaf.json contract: // absent → empty document; present-but-unusable → refuse, preserve, report. func TestMergeHarnessConfigsRefuseMalformedJSON(t *testing.T) { @@ -72,70 +72,20 @@ func TestMergeHarnessConfigsRefuseMalformedJSON(t *testing.T) { } } -func TestMergeHookFilesRefusesMalformedLegacyHooks(t *testing.T) { - for _, testCase := range []struct { - name string - body string - }{ - {name: "truncated object", body: `{"hooks":`}, - {name: "json array", body: "[]\n"}, - {name: "json null", body: "null\n"}, - } { - t.Run(testCase.name, func(t *testing.T) { - dir := t.TempDir() - dest := filepath.Join(dir, "hooks.json") - loaf := filepath.Join(dir, "loaf-hooks.json") - writeInstallFile(t, dest, testCase.body) - writeInstallFile(t, loaf, `{"version":1,"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"true","loaf-managed":true}]}]}}`+"\n") - - err := mergeHookFiles(dest, loaf) - if err == nil { - t.Fatal("mergeHookFiles error = nil, want a parse refusal") - } - if !strings.Contains(err.Error(), "parse Codex hooks file") || !strings.Contains(err.Error(), "preserving it as written") { - t.Fatalf("mergeHookFiles error = %q, want a parse refusal that preserves the file", err) - } - if got := string(readFileBytes(t, dest)); got != testCase.body { - t.Fatalf("hooks.json = %q, want it preserved byte-for-byte as %q", got, testCase.body) - } - }) - } -} - -func TestLoadCodexHooksFileRefusesNonObject(t *testing.T) { +func TestReadHookFileRefusesNonObject(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "hooks.json") writeInstallFile(t, path, "[]\n") - _, err := loadCodexHooksFile(path) + _, err := readHookFile(path) if err == nil { - t.Fatal("loadCodexHooksFile error = nil, want a parse refusal") + t.Fatal("readHookFile error = nil, want a parse refusal") } - if !strings.Contains(err.Error(), "parse Codex hooks file") { - t.Fatalf("loadCodexHooksFile error = %q, want parse refusal", err) + if !strings.Contains(err.Error(), "parse hooks file") || !strings.Contains(err.Error(), "preserving it as written") { + t.Fatalf("readHookFile error = %q, want a parse refusal that preserves the file", err) } if got := string(readFileBytes(t, path)); got != "[]\n" { - t.Fatalf("hooks.json changed to %q; a refused load must not rewrite the file", got) - } -} - -func TestPlanHookProjectionRefusesMalformedDestination(t *testing.T) { - for _, body := range []string{`{"hooks":`, "[]\n", "null\n"} { - t.Run(body, func(t *testing.T) { - refuse, detail := planHookProjectionRefusal("cursor", "/tmp/hooks.json", []byte(body)) - if !refuse { - t.Fatal("planHookProjectionRefusal = false, want a refusal") - } - if !strings.Contains(detail, "does not parse as a JSON object") || !strings.Contains(detail, "preserving it as written") { - t.Fatalf("detail = %q, want the parse refusal", detail) - } - }) - } - - // A valid object is not a refusal — the rest of the planner decides. - refuse, detail := planHookProjectionRefusal("cursor", "/tmp/hooks.json", []byte(`{"version":1,"hooks":{}}`+"\n")) - if refuse { - t.Fatalf("planHookProjectionRefusal(valid) refused: %s", detail) + t.Fatalf("hooks.json changed to %q; a refused read must not rewrite the file", got) } } @@ -284,32 +234,12 @@ func TestMergeHarnessConfigAcceptsValidObject(t *testing.T) { } } -func TestMergeHookFilesPreservesUserHooksOnValidFile(t *testing.T) { - dir := t.TempDir() - dest := filepath.Join(dir, "hooks.json") - loaf := filepath.Join(dir, "loaf-hooks.json") - writeInstallFile(t, dest, `{"version":1,"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"echo user"}]}]}}`+"\n") - writeInstallFile(t, loaf, `{"version":1,"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"echo loaf","loaf-managed":true}]}]}}`+"\n") - - if err := mergeHookFiles(dest, loaf); err != nil { - t.Fatalf("mergeHookFiles(valid) error = %v", err) - } - body := string(readFileBytes(t, dest)) - if !strings.Contains(body, "echo user") { - t.Fatalf("merged hooks lost user content: %s", body) - } - if !strings.Contains(body, "echo loaf") { - t.Fatalf("merged hooks missing Loaf content: %s", body) - } -} - -// A no-manifest Cursor distribution must not promise a hooks.json update that -// apply will refuse: dry-run carries the conflict, apply fails, file stays put. -func TestPlanLegacyHookNoManifestRefusesTruncatedHooksJSON(t *testing.T) { +// A no-manifest Cursor distribution has no path left to promise: its entries +// are reconciled from a catalog that build output does not carry, so dry-run +// carries the staleness conflict, apply fails, and the file stays put. +func TestPlanLegacyHookNoManifestRefusesAStaleCursorDistribution(t *testing.T) { root, home := setupInstallCommandFixture(t) writeInstallFile(t, filepath.Join(root, "dist", "cursor", "skills", "foundations", "SKILL.md"), "# Foundations\n") - // Source hooks so the apply path reaches mergeHookFiles rather than no-op. - writeInstallFile(t, filepath.Join(root, "dist", "cursor", "hooks.json"), `{"version":1,"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"true","loaf-managed":true}]}]}}`+"\n") // No .loaf-target-manifest.json — the legacy/no-manifest branch. writeInstallFile(t, filepath.Join(home, ".cursor", loafInstallMarkerFile), "old\n") malformed := `{"hooks":` @@ -336,8 +266,8 @@ func TestPlanLegacyHookNoManifestRefusesTruncatedHooksJSON(t *testing.T) { if hooksDecision.Action != planActionConflict { t.Fatalf("hook-legacy action = %q, want conflict (not update)", hooksDecision.Action) } - if !strings.Contains(hooksDecision.Detail, "preserving it as written") { - t.Fatalf("hook-legacy detail = %q, want the refusal that apply will raise", hooksDecision.Detail) + if !strings.Contains(hooksDecision.Detail, "stale") || !strings.Contains(hooksDecision.Detail, "loaf build") { + t.Fatalf("hook-legacy detail = %q, want the staleness refusal that apply will raise", hooksDecision.Detail) } if !strings.Contains(hooksDecision.Destination, "hooks.json") { t.Fatalf("hook-legacy destination = %q, want the hooks.json path", hooksDecision.Destination) diff --git a/internal/cli/install_mcp_test.go b/internal/cli/install_mcp_test.go index a553e3b88..435d64b53 100644 --- a/internal/cli/install_mcp_test.go +++ b/internal/cli/install_mcp_test.go @@ -23,6 +23,7 @@ func TestInstallPreservesAnUnparseableProjectConfig(t *testing.T) { t.Run(testCase.name, func(t *testing.T) { root, home := setupInstallCommandFixture(t) writeInstallFile(t, filepath.Join(root, "dist", "cursor", "skills", "foundations", "SKILL.md"), "# Foundations\n") + installTestHookDistribution(t, root, "cursor") mkdirAll(t, filepath.Join(home, ".cursor")) configPath := filepath.Join(root, ".agents", "loaf.json") writeInstallFile(t, configPath, testCase.body) diff --git a/internal/cli/install_onboarding_test.go b/internal/cli/install_onboarding_test.go index 14a80df86..2bb8c9116 100644 --- a/internal/cli/install_onboarding_test.go +++ b/internal/cli/install_onboarding_test.go @@ -98,6 +98,7 @@ func TestRunnerInstallOutsideALoafProjectAsksBeforeDeploying(t *testing.T) { func TestRunnerInstallInsideALoafProjectOnboardsTheHarnessOnly(t *testing.T) { root, home := setupInstallCommandFixture(t) writeInstallFile(t, filepath.Join(root, "dist", "cursor", "skills", "foundations", "SKILL.md"), "# Foundations\n") + installTestHookDistribution(t, root, "cursor") writeInstallFile(t, filepath.Join(root, ".agents", "loaf.json"), "{\"integrations\":{}}\n") writeInstallFile(t, filepath.Join(root, "AGENTS.md"), "# Project Instructions\n") @@ -174,6 +175,15 @@ func TestRunnerConfigCheckFixStillRefreshesTargetsThroughTheSharedInstaller(t *t }, "\n")+"\n") writeInstallFile(t, filepath.Join(root, "dist", "cursor", "skills", "foundations", "SKILL.md"), "# Foundations\n") writeInstallFile(t, filepath.Join(root, "dist", "cursor", "hooks.json"), `{"version":1,"hooks":{"PostToolUse":[{"command":"loaf check --hook check-secrets","matcher":"Bash","loaf-managed":true}]}}`+"\n") + // The distribution hooks.json is what `config check` diagnoses against; the + // catalog is what the reconciler restores from, so the two name one hook. + installTestHookDistribution(t, root, "cursor", hookCatalogSource{ + event: "PostToolUse", + hookID: "check-secrets", + typeName: "command", + command: "loaf check --hook check-secrets", + template: map[string]any{"command": "loaf check --hook check-secrets", "matcher": "Bash", "loaf-managed": true}, + }) writeInstallFile(t, filepath.Join(home, ".cursor", loafInstallMarkerFile), "old\n") writeInstallFile(t, filepath.Join(home, ".cursor", "hooks.json"), `{"version":1,"hooks":{}}`+"\n") @@ -218,6 +228,7 @@ func setupInstallOnboardingFixture(t *testing.T) (string, string) { t.Helper() root, home := setupInstallCommandFixture(t) writeInstallFile(t, filepath.Join(root, "dist", "cursor", "skills", "foundations", "SKILL.md"), "# Foundations\n") + installTestHookDistribution(t, root, "cursor") writeInstallFile(t, filepath.Join(root, "bin", "loaf"), "#!/bin/sh\nexit 0\n") if err := os.Chmod(filepath.Join(root, "bin", "loaf"), 0o755); err != nil { t.Fatalf("Chmod(fake loaf) error = %v", err) diff --git a/internal/cli/install_plan.go b/internal/cli/install_plan.go index 908783482..48866856e 100644 --- a/internal/cli/install_plan.go +++ b/internal/cli/install_plan.go @@ -1,6 +1,7 @@ package cli import ( + "context" "encoding/json" "fmt" "io" @@ -160,6 +161,11 @@ func (r Runner) buildInstallDryRunPlan(options installOptions, loafRoot string, toolByKey := installToolsByKey(tools) defaults := defaultInstallConfigDirs() + // A plan reads hook enablement and never creates it: a dry run that brought + // a state database into existence would be a write, and the plan promises + // none. + hookState, releaseHookState := r.hookStateForPlan(projectRoot) + defer releaseHookState() buildNeeded := false var plannedOptions []targetInstallOptions for _, target := range selectedTargets { @@ -190,6 +196,7 @@ func (r Runner) buildInstallDryRunPlan(options installOptions, loafRoot string, HomeDir: installHome(), CodexHome: os.Getenv("CODEX_HOME"), ProjectRoot: projectRoot, + HookState: hookState, } plannedOptions = append(plannedOptions, installOpts) decisions, err := planTargetDistribution(installOpts) @@ -488,8 +495,23 @@ func planTargetAdapterArtifacts(options targetInstallOptions) ([]artifactPlanDec var decisions []artifactPlanDecision desiredDestinations := map[string]bool{} + // Hook entries are planned per identity by the reconciler, from the same + // computation apply runs. It reads state and the live file and writes + // nothing. + reconciler, err := newHookReconciler(options) + if err != nil { + return nil, err + } + if reconciler != nil { + actions, err := reconciler.plan(context.Background()) + if err != nil { + return nil, err + } + decisions = append(decisions, hookActionPlanDecisions(reconciler, actions)...) + } + for _, artifact := range desired.Artifacts { - if artifact.Kind == "instruction" { + if skipTargetAdapterArtifact(artifact) { continue } if err := verifyTargetAdapterSource(options, artifact); err != nil { @@ -511,32 +533,9 @@ func planTargetAdapterArtifacts(options targetInstallOptions) ([]artifactPlanDec decisions = append(decisions, decision) continue } - // A destination the apply path cannot parse as the artifact kind must - // never be promised as a merge: report the refusal and leave the rest - // of the plan free to continue. - if artifact.Kind == "hook-projection" { - if refuse, detail := planHookProjectionRefusal(options.Target, path, snapshot.body); refuse { - decision.Action = planActionConflict - decision.Detail = detail - decisions = append(decisions, decision) - continue - } - } - matchesDesired, err := targetAdapterSnapshotMatchesArtifact(options.Target, artifact, snapshot) - if err != nil { - decision.Action = planActionConflict - decision.Detail = err.Error() - decisions = append(decisions, decision) - continue - } + matchesDesired := targetAdapterSnapshotMatchesArtifact(artifact, snapshot) if owned { - matchesInstalled, err := targetAdapterSnapshotMatchesArtifact(options.Target, installedByID[artifact.ID], snapshot) - if err != nil { - decision.Action = planActionConflict - decision.Detail = err.Error() - decisions = append(decisions, decision) - continue - } + matchesInstalled := targetAdapterSnapshotMatchesArtifact(installedByID[artifact.ID], snapshot) switch { case !matchesInstalled && !matchesDesired: decision.Action = planActionConflict @@ -556,9 +555,6 @@ func planTargetAdapterArtifacts(options targetInstallOptions) ([]artifactPlanDec 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" @@ -568,7 +564,7 @@ func planTargetAdapterArtifacts(options targetInstallOptions) ([]artifactPlanDec // Retire installed artifacts the desired manifest no longer ships. for _, artifact := range installed.Artifacts { - if artifact.Kind == "instruction" { + if skipTargetAdapterArtifact(artifact) { continue } if _, keep := desiredByID[artifact.ID]; keep { @@ -589,11 +585,7 @@ func planTargetAdapterArtifacts(options targetInstallOptions) ([]artifactPlanDec 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" { + if !targetAdapterSnapshotMatchesArtifact(artifact, snapshot) { decision.Action = planActionConflict decision.Detail = "managed target artifact was modified; refusing to remove" } @@ -765,78 +757,30 @@ func planCodexGuidanceFile(guidanceDest string, ownedGuidance bool, ownedGuidanc return decision, nil } -// planLegacyHookArtifacts reports the no-manifest hook refresh. For cursor and -// codex the apply path merges into hooks.json, so the plan must refuse the same -// destinations that mergeHookFiles / mergeCodexHookFiles would rather than -// promise an update apply will decline. +// planLegacyHookArtifacts reports the no-manifest hook refresh for the targets +// that still have one: a distribution predating the target adapter manifest +// refreshes plugins wholesale on apply. func planLegacyHookArtifacts(options targetInstallOptions) ([]artifactPlanDecision, error) { - decision := artifactPlanDecision{ + // A target whose entries are reconciled has no legacy path left to promise: + // apply refuses a build output that predates the catalog, so the plan says + // the same thing rather than describing a refresh that will not happen. + if targetReconcilesHookEntries(options) { + _, err := newHookReconciler(options) + return []artifactPlanDecision{{ + ID: "hooks", + Kind: "hook-legacy", + Destination: targetHookFilePath(options), + Action: planActionConflict, + Detail: err.Error(), + }}, nil + } + return []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", - } - path, refusalTarget := legacyHookMergePath(options) - if path == "" { - return []artifactPlanDecision{decision}, nil - } - decision.Destination = path - body, err := readRegularFile(path, projectFileReadLimit) - if err != nil { - if os.IsNotExist(err) { - return []artifactPlanDecision{decision}, nil - } - decision.Action = planActionConflict - decision.Detail = fmt.Sprintf("%s could not be inspected (%v) — preserving it as written", path, err) - return []artifactPlanDecision{decision}, nil - } - if refuse, detail := planHookProjectionRefusal(refusalTarget, path, body); refuse { - decision.Action = planActionConflict - decision.Detail = detail - return []artifactPlanDecision{decision}, nil - } - return []artifactPlanDecision{decision}, nil -} - -// legacyHookMergePath returns the hooks.json path the no-manifest apply path -// merges into for cursor and codex, plus the planHookProjectionRefusal target -// key. Other targets do not merge a legacy hooks.json here. -func legacyHookMergePath(options targetInstallOptions) (path string, refusalTarget string) { - switch options.Target { - case "cursor": - return filepath.Join(options.ConfigDir, "hooks.json"), "cursor" - case "codex": - codexHome := options.CodexHome - if codexHome == "" { - codexHome = filepath.Join(installHomeDir(options), ".codex") - } - return filepath.Join(codexHome, "hooks.json"), "codex" - default: - return "", "" - } -} - -// planHookProjectionRefusal reports whether a hook-projection destination is -// present but not a JSON object the apply merge would accept. The plan must -// refuse rather than promise a merge that would serialize Loaf-only content -// over the bytes it could not parse. -func planHookProjectionRefusal(target string, path string, body []byte) (bool, string) { - switch target { - case "cursor": - var topLevel map[string]json.RawMessage - if err := json.Unmarshal(body, &topLevel); err != nil { - return true, fmt.Sprintf("%s does not parse as a JSON object (%v) — preserving it as written", path, err) - } - if topLevel == nil { - return true, fmt.Sprintf("%s does not parse as a JSON object (top-level value is null, not an object) — preserving it as written", path) - } - case "codex": - if _, err := decodeCodexHooksBodyStrict(body); err != nil { - return true, fmt.Sprintf("%s could not be parsed as Codex hooks (%v) — preserving it as written", path, err) - } - } - return false, "" + }}, nil } // planInstallDeprecations reuses applyInstallDeprecationCleanup with @@ -1152,7 +1096,8 @@ func installPlanHasChanges(plan installDryRunPlan) bool { } for _, artifact := range target.Artifacts { switch artifact.Action { - case planActionCreate, planActionUpdate, planActionRetire, planActionConflict: + case planActionCreate, planActionUpdate, planActionRetire, planActionConflict, + hookActionAdd, hookActionRemove, hookActionAbsorb: return true } } @@ -1326,19 +1271,28 @@ func installMcpPlanNotices(entries []mcpPlanEntry) []string { func planActionGlyph(action string) string { switch action { - case planActionCreate, planActionUpdate, "created", "appended", "updated", "relinked", "replaced-file", "migrated": + case planActionCreate, planActionUpdate, hookActionAdd, "created", "appended", "updated", "relinked", "replaced-file", "migrated": return ansiGreen("+") - case planActionRetire, "remove", "relocate": + case planActionRetire, hookActionRemove, "relocate": return ansiYellow("-") case planActionConflict, "error": return ansiRed("✗") - case planActionPreserve, "skipped", "already-correct", planActionNone, "absent", "skip-unmarked": + case planActionPreserve, hookActionAbsorb, "skipped", "already-correct", planActionNone, "absent", "skip-unmarked": return ansiGray("○") default: return ansiGray("•") } } +// writeHookActionLines reports what a reconcile did, nested under the target it +// ran for. A converged target prints nothing, which is the point: the quiet +// upgrade is the normal one. +func writeHookActionLines(out io.Writer, actions []hookAction) { + for _, action := range actions { + fmt.Fprintf(out, " %s %s %s%s\n", planActionGlyph(action.action), action.action, action.id(), planDetailSuffix(action.detail)) + } +} + func planDetailSuffix(detail string) string { if detail == "" { return "" diff --git a/internal/cli/install_plan_test.go b/internal/cli/install_plan_test.go index 1252826e1..e8c1c0560 100644 --- a/internal/cli/install_plan_test.go +++ b/internal/cli/install_plan_test.go @@ -21,6 +21,7 @@ func TestRunnerUpgradeDryRunNonMutatingAcrossSurfaces(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") + installTestHookDistribution(t, root, "cursor") 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") @@ -110,6 +111,7 @@ func TestRunnerUpgradeDryRunNonMutatingAcrossSurfaces(t *testing.T) { func TestRunnerUpgradeDryRunJSONIsDeterministic(t *testing.T) { root, home := setupInstallCommandFixture(t) writeInstallFile(t, filepath.Join(root, "dist", "cursor", "skills", "foundations", "SKILL.md"), "# Foundations\n") + installTestHookDistribution(t, root, "cursor") 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")) @@ -141,6 +143,7 @@ func TestRunnerUpgradeDryRunSkillPlanMatchesApply(t *testing.T) { 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") + installTestHookDistribution(t, root, "cursor") writeInstallFile(t, filepath.Join(home, ".cursor", loafInstallMarkerFile), "old\n") runInstallFixture(t, root, "install", "--to", "cursor", "--yes") diff --git a/internal/cli/install_project_file_unix_test.go b/internal/cli/install_project_file_unix_test.go index 35385851c..09b32fb71 100644 --- a/internal/cli/install_project_file_unix_test.go +++ b/internal/cli/install_project_file_unix_test.go @@ -369,30 +369,40 @@ func TestCodexUserConfigReadersRefuseAFifo(t *testing.T) { mkfifoForTest(t, hooks) type refusal struct { - name string - run func() error + name string + run func() error + refused func(error) bool } for _, subject := range []refusal{ - {"guidance", func() error { - _, _, err := readOptionalInstallFile(guidance, "Codex journal guidance") - return err - }}, - {"hooks", func() error { - _, err := loadCodexHooksRawFileStrict(hooks) - return err - }}, - {"legacy hooks", func() error { - _, err := loadCodexHooksFile(hooks) - return err - }}, + { + name: "guidance", + run: func() error { + _, _, err := readOptionalInstallFile(guidance, "Codex journal guidance") + return err + }, + refused: func(err error) bool { return errors.Is(err, errNotRegularFile) }, + }, + { + name: "hooks", + run: func() error { + _, err := readHookFile(hooks) + return err + }, + // The reconciler's reader refuses before it reads: a FIFO is not a + // regular file, so the refusal names the destination rather than + // wrapping a read error it never reached. + refused: func(err error) bool { + return err != nil && strings.Contains(err.Error(), "not a regular file") + }, + }, } { t.Run(subject.name, func(t *testing.T) { done := make(chan error, 1) go func() { done <- subject.run() }() select { case err := <-done: - if !errors.Is(err, errNotRegularFile) { - t.Fatalf("%s reader error = %v, want errNotRegularFile", subject.name, err) + if !subject.refused(err) { + t.Fatalf("%s reader error = %v, want a non-regular-file refusal", subject.name, err) } case <-time.After(5 * time.Second): t.Fatalf("%s reader blocked on a FIFO", subject.name) diff --git a/internal/cli/install_target.go b/internal/cli/install_target.go index 15161c6b1..a895c433b 100644 --- a/internal/cli/install_target.go +++ b/internal/cli/install_target.go @@ -21,7 +21,6 @@ import ( const ( loafInstallMarkerFile = ".loaf-version" - loafHookMarker = "loaf-managed" loafSkillManifestFile = ".loaf-managed-skills.json" ) @@ -50,19 +49,6 @@ var legacyLoafHookSignatures = map[string]bool{ "command:bash $HOME/.cursor/hooks/session/compact.sh|matcher:|if:": true, } -var codexHookEvents = map[string]bool{ - "SessionStart": true, - "SubagentStart": true, - "PreToolUse": true, - "PermissionRequest": true, - "PostToolUse": true, - "PreCompact": true, - "PostCompact": true, - "UserPromptSubmit": true, - "SubagentStop": true, - "Stop": true, -} - var legacyLoafCommands = map[string]bool{ "bash $HOME/.cursor/hooks/session/session-start-soul.sh": true, "bash $HOME/.cursor/hooks/session/session-start.sh": true, @@ -106,24 +92,24 @@ type targetInstallOptions struct { AmpSkillsDir string AmpPluginsDir string TargetAdapterOps *targetAdapterInstallOperations + // HookState resolves the user-scoped state hook reconciliation reads and + // writes. Only Cursor and Codex reach it, and only when they actually + // reconcile, so a target that keeps no shared hooks file never opens it. + HookState hookStateResolver + // HookActions receives the per-entry actions a reconcile took, so the + // command that ran it can say what changed. Nil discards them. + HookActions func([]hookAction) + // HookOps injects failures at the reconciler's two ordering windows from + // outside the target installer, which is the only vantage point that can + // exercise the window after the adapter manifest is replaced and before + // the file is projected. Nil in production. + HookOps *hookReconcileOperations // SkipSkillsSync leaves the shared skills store alone. Multi-target // install/upgrade sets it after syncCanonicalManagedSkills has already // performed the one write per destination for the run. SkipSkillsSync bool } -type codexHooksFile struct { - Version int `json:"version,omitempty"` - Description string `json:"description,omitempty"` - Hooks map[string][]map[string]any `json:"hooks"` -} - -type codexHooksRawFile struct { - Description json.RawMessage `json:"description,omitempty"` - Version json.RawMessage `json:"-"` - Hooks map[string][]json.RawMessage `json:"hooks"` -} - type installTargetRecord struct { Version string `json:"version"` Target string `json:"target"` @@ -360,6 +346,14 @@ func rewriteOpenCodeCommandSkillLinks(content string, skill string, commandsDir } func installCursorTarget(options targetInstallOptions) error { + // Building the reconciler is what proves the distribution is current, so it + // happens before anything on this machine changes. A stale build output + // that was refused only after the commands directory had been removed would + // have already taken something away from the operator. + reconciler, err := newHookReconciler(options) + if err != nil { + return err + } skillsDest := installSkillsDestination(options) if !options.SkipSkillsSync { if err := syncManagedSkillsDirIfExists(filepath.Join(options.DistDir, "skills"), skillsDest); err != nil { @@ -372,12 +366,11 @@ func installCursorTarget(options targetInstallOptions) error { if err := syncTargetDirIfExists(filepath.Join(options.DistDir, "agents"), filepath.Join(options.ConfigDir, "agents")); err != nil { return err } - hasAdapterManifest := fileExistsForInstall(filepath.Join(options.DistDir, targetBuildManifestFile)) - if hasAdapterManifest { - if err := syncTargetAdapterManifest(options); err != nil { - return err - } - } else if err := mergeHookFiles(filepath.Join(options.ConfigDir, "hooks.json"), filepath.Join(options.DistDir, "hooks.json")); err != nil { + if err := beginHookReconcile(reconciler); err != nil { + return err + } + defer releaseHookReconcile(reconciler) + if err := syncTargetAdapterManifest(options); err != nil { return err } if options.Upgrade { @@ -387,14 +380,12 @@ func installCursorTarget(options targetInstallOptions) error { } } } - if !hasAdapterManifest { - if err := mergeTargetDirIfExists(filepath.Join(options.DistDir, "hooks"), filepath.Join(options.ConfigDir, "hooks")); err != nil { - return err - } - } if err := syncTargetDirIfExists(filepath.Join(options.DistDir, "templates"), filepath.Join(options.ConfigDir, "templates")); err != nil { return err } + if err := completeHookReconcile(options, reconciler); err != nil { + return err + } if err := writeInstallMarker(options.ConfigDir, options.Version); err != nil { return err } @@ -402,6 +393,12 @@ func installCursorTarget(options targetInstallOptions) error { } func installCodexTarget(options targetInstallOptions) error { + // Same ordering as Cursor: prove the distribution is current before the + // shared skills store — the first surface this touches — is written. + reconciler, err := newHookReconciler(options) + if err != nil { + return err + } homeDir := installHomeDir(options) codexHome := options.CodexHome if codexHome == "" { @@ -413,16 +410,19 @@ func installCodexTarget(options targetInstallOptions) error { return err } } - if fileExistsForInstall(filepath.Join(options.DistDir, targetBuildManifestFile)) { - if err := syncTargetAdapterManifest(options); err != nil { - return err - } - } else if err := mergeCodexHookFiles(filepath.Join(codexHome, "hooks.json"), filepath.Join(options.DistDir, ".codex", "hooks.json"), options.ProjectRoot, options.CodexRuleOperations); err != nil { + if err := beginHookReconcile(reconciler); err != nil { + return err + } + defer releaseHookReconcile(reconciler) + if err := syncTargetAdapterManifest(options); err != nil { return err } if err := installCodexJournalRuleWithOperations(options, codexHome, options.CodexRuleOperations); err != nil { return err } + if err := completeHookReconcile(options, reconciler); err != nil { + return err + } if err := writeInstallMarker(options.ConfigDir, options.Version); err != nil { return err } @@ -1559,154 +1559,6 @@ func installRecordPath(homeDir string, target string) string { return filepath.Join(homeDir, ".agents", "loaf", "install-targets", target+".json") } -func mergeHookFiles(destPath string, loafPath string) error { - if !fileExistsForInstall(loafPath) { - return nil - } - existing, err := loadCodexHooksFile(destPath) - if err != nil { - return err - } - loafHooks, err := loadCodexHooksFile(loafPath) - if err != nil { - return err - } - merged := codexHooksFile{Version: 1, Hooks: map[string][]map[string]any{}} - seen := map[string]bool{} - for hookType := range existing.Hooks { - seen[hookType] = true - } - for hookType := range loafHooks.Hooks { - seen[hookType] = true - } - for hookType := range seen { - var hooks []map[string]any - for _, hook := range existing.Hooks[hookType] { - if !isLoafInstallHook(hook) { - hooks = append(hooks, hook) - } - } - hooks = append(hooks, loafHooks.Hooks[hookType]...) - if len(hooks) > 0 { - merged.Hooks[hookType] = hooks - } - } - if len(merged.Hooks) == 0 { - merged.Hooks = nil - } - body, err := json.MarshalIndent(merged, "", " ") - if err != nil { - return err - } - body = append(body, '\n') - if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil { - return err - } - return os.WriteFile(destPath, body, 0o644) -} - -// mergeCodexHookFiles writes the current Codex hooks schema. Existing valid -// user groups survive; recognized legacy Loaf groups are retired, while -// malformed or unowned content is refused. The distributed adapter carries a -// placeholder that is rendered to the trusted absolute executable at install. -func mergeCodexHookFiles(destPath string, loafPath string, projectRoot string, operations *codexRuleInstallOperations) error { - return mergeCodexHookFilesForOS(destPath, loafPath, projectRoot, operations, runtime.GOOS) -} - -func mergeCodexHookFilesForOS(destPath string, loafPath string, projectRoot string, operations *codexRuleInstallOperations, goos string) error { - return mergeCodexHookFilesForOSWithExecutable(destPath, loafPath, projectRoot, operations, goos, "") -} - -func mergeCodexHookFilesForOSWithExecutable(destPath string, loafPath string, projectRoot string, operations *codexRuleInstallOperations, goos string, executableOverride string) error { - if !fileExistsForInstall(loafPath) { - return nil - } - loafHooks, err := loadCodexHooksRawFileStrict(loafPath) - if err != nil { - return err - } - loafExecutable := executableOverride - for hookType, hooks := range loafHooks.Hooks { - for index, rawHook := range hooks { - if !bytes.Contains(rawHook, []byte(codexJournalExecutablePlaceholder)) && !bytes.Contains(rawHook, []byte(codexJournalHookCommandTemplate)) { - continue - } - if loafExecutable == "" { - loafExecutable, err = trustedCodexJournalExecutable(projectRoot, operations) - if err != nil { - return err - } - } - rendered, renderErr := renderCodexHookExecutableForOS(rawHook, loafExecutable, goos) - if renderErr != nil { - return fmt.Errorf("render Codex Loaf hook %s[%d]: %w", hookType, index, renderErr) - } - if bytes.Contains(rendered, []byte(codexJournalExecutablePlaceholder)) || bytes.Contains(rendered, []byte(codexJournalHookCommandTemplate)) { - return fmt.Errorf("render Codex Loaf hook %s[%d]: executable placeholder remains", hookType, index) - } - loafHooks.Hooks[hookType][index] = rendered - } - } - existing, err := loadCodexHooksRawFileStrict(destPath) - if err != nil { - return err - } - merged := codexHooksRawFile{Description: existing.Description, Hooks: map[string][]json.RawMessage{}} - retiredLegacy := false - for hookType, hooks := range existing.Hooks { - if len(hooks) == 0 { - merged.Hooks[hookType] = []json.RawMessage{} - continue - } - for _, rawHook := range hooks { - hook, err := decodeCodexHookObject(rawHook) - if err != nil { - return fmt.Errorf("parse Codex hooks matcher group in %s: %w", destPath, err) - } - if owned, conflict := codexHookOwnershipForOS(hook, goos); conflict { - return fmt.Errorf("Codex hooks file %s contains a modified Loaf SessionStart matcher group in %s; refusing to retire or duplicate it", destPath, hookType) - } else if owned { - retiredLegacy = true - continue - } - if !isValidCodexMatcherGroup(hook) { - if isLoafInstallHookForOS(hook, goos) { - retiredLegacy = true - continue - } - return fmt.Errorf("Codex hooks file %s contains an unsupported matcher entry in %s; preserve it manually or remove it before installing Loaf", destPath, hookType) - } - // Preserve each valid user matcher group as a whole. Any modified - // recognizable Loaf group was rejected above rather than edited. - merged.Hooks[hookType] = append(merged.Hooks[hookType], rawHook) - } - } - for hookType, hooks := range loafHooks.Hooks { - for _, rawHook := range hooks { - hook, err := decodeCodexHookObject(rawHook) - if err != nil { - return fmt.Errorf("parse generated Codex hooks matcher group in %s: %w", loafPath, err) - } - if !isValidCodexMatcherGroup(hook) { - return fmt.Errorf("generated Codex hooks file %s contains an unsupported matcher entry in %s", loafPath, hookType) - } - merged.Hooks[hookType] = append(merged.Hooks[hookType], rawHook) - } - } - if len(existing.Version) > 0 && !retiredLegacy { - return fmt.Errorf("Codex hooks file %s contains legacy version metadata without a recognized Loaf hook to retire; refusing to rewrite user/current content", destPath) - } - body, err := json.MarshalIndent(merged, "", " ") - if err != nil { - return err - } - body = append(body, '\n') - if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil { - return err - } - return os.WriteFile(destPath, body, 0o644) -} - func renderCodexHookExecutable(rawHook json.RawMessage, executable string) (json.RawMessage, error) { return renderCodexHookExecutableForOS(rawHook, executable, runtime.GOOS) } @@ -1762,103 +1614,6 @@ func renderCodexHookExecutableForOS(rawHook json.RawMessage, executable string, return json.Marshal(hook) } -func loadCodexHooksRawFileStrict(path string) (codexHooksRawFile, error) { - if !fileExistsForInstall(path) { - return codexHooksRawFile{Hooks: map[string][]json.RawMessage{}}, nil - } - body, err := readRegularFile(path, projectFileReadLimit) - if err != nil { - return codexHooksRawFile{}, refuseProjectFileRead(err) - } - var topLevel map[string]json.RawMessage - if err := json.Unmarshal(body, &topLevel); err != nil { - return codexHooksRawFile{}, fmt.Errorf("parse Codex hooks file %s: %w", path, err) - } - if topLevel == nil { - return codexHooksRawFile{}, fmt.Errorf("parse Codex hooks file %s: top-level value must be an object", path) - } - for key := range topLevel { - if key != "version" && key != "description" && key != "hooks" { - return codexHooksRawFile{}, fmt.Errorf("Codex hooks file %s contains unsupported top-level field %q", path, key) - } - } - version := topLevel["version"] - if len(version) > 0 { - var value float64 - if strings.HasPrefix(strings.TrimSpace(string(version)), "\"") || json.Unmarshal(version, &value) != nil || value != 1 { - return codexHooksRawFile{}, fmt.Errorf("parse Codex hooks file %s: legacy version must be numeric 1", path) - } - } - var hooks map[string][]json.RawMessage - if raw, ok := topLevel["hooks"]; ok { - if strings.TrimSpace(string(raw)) == "null" { - return codexHooksRawFile{}, fmt.Errorf("parse Codex hooks file %s: hooks must be an object", path) - } - var rawHooks map[string]json.RawMessage - if err := json.Unmarshal(raw, &rawHooks); err != nil { - return codexHooksRawFile{}, fmt.Errorf("parse Codex hooks file %s: %w", path, err) - } - hooks = make(map[string][]json.RawMessage, len(rawHooks)) - for event, rawEvent := range rawHooks { - if strings.TrimSpace(string(rawEvent)) == "null" { - return codexHooksRawFile{}, fmt.Errorf("parse Codex hooks file %s: event %q must be an array", path, event) - } - var eventHooks []json.RawMessage - if err := json.Unmarshal(rawEvent, &eventHooks); err != nil { - return codexHooksRawFile{}, fmt.Errorf("parse Codex hooks file %s: event %q must be an array", path, event) - } - if eventHooks == nil { - eventHooks = []json.RawMessage{} - } - hooks[event] = eventHooks - } - } - if hooks == nil { - hooks = map[string][]json.RawMessage{} - } - for event := range hooks { - if !codexHookEvents[event] { - return codexHooksRawFile{}, fmt.Errorf("Codex hooks file %s contains unsupported hook event %q", path, event) - } - } - description := topLevel["description"] - if len(description) > 0 && strings.TrimSpace(string(description)) != "null" { - var value string - if err := json.Unmarshal(description, &value); err != nil { - return codexHooksRawFile{}, fmt.Errorf("parse Codex hooks file %s: description must be a string", path) - } - } - return codexHooksRawFile{Description: description, Version: version, Hooks: hooks}, nil -} - -func loadCodexHooksFile(path string) (codexHooksFile, error) { - if !fileExistsForInstall(path) { - return codexHooksFile{Hooks: map[string][]map[string]any{}}, nil - } - body, err := readRegularFile(path, projectFileReadLimit) - if err != nil { - return codexHooksFile{}, refuseProjectFileRead(err) - } - // Decode as a top-level object first so a JSON array, null, or truncated - // payload is a refusal rather than an empty document the merge would write - // back as Loaf-only content. - var topLevel map[string]json.RawMessage - if err := json.Unmarshal(body, &topLevel); err != nil { - return codexHooksFile{}, fmt.Errorf("parse Codex hooks file %s: %w — preserving it as written", path, err) - } - if topLevel == nil { - return codexHooksFile{}, fmt.Errorf("parse Codex hooks file %s: top-level value must be an object — preserving it as written", path) - } - var hooks codexHooksFile - if err := json.Unmarshal(body, &hooks); err != nil { - return codexHooksFile{}, fmt.Errorf("parse Codex hooks file %s: %w — preserving it as written", path, err) - } - if hooks.Hooks == nil { - hooks.Hooks = map[string][]map[string]any{} - } - return hooks, nil -} - func isValidCodexMatcherGroup(hook map[string]any) bool { if hook == nil { return false @@ -2024,49 +1779,10 @@ func isASCIIWindowsDriveLetter(value byte) bool { return (value >= 'A' && value <= 'Z') || (value >= 'a' && value <= 'z') } -func isLoafInstallHook(hook map[string]any) bool { - return isLoafInstallHookForOS(hook, runtime.GOOS) -} - -func isLoafInstallHookForOS(hook map[string]any, goos string) bool { - if marker, ok := hook[loafHookMarker].(bool); ok && marker { - return true - } - if signature := installHookSignature(hook); signature != "" && legacyLoafHookSignatures[signature] { - return true - } - if command, ok := hook["command"].(string); ok && legacyLoafCommands[command] { - return true - } - if prompt, ok := hook["prompt"].(string); ok { - for _, prefix := range legacyLoafPromptPrefixes { - if strings.HasPrefix(prompt, prefix) { - return true - } - } - } - if isLoafCodexMatcherGroupForOS(hook, goos) { - return true - } - return false -} - -func isLoafCodexMatcherGroup(hook map[string]any) bool { - return isLoafCodexMatcherGroupForOS(hook, runtime.GOOS) -} - -func isLoafCodexMatcherGroupForOS(hook map[string]any, goos string) bool { - owned, conflict := codexHookOwnershipForOS(hook, goos) - return owned && !conflict -} - -// codexHookOwnership recognizes only the exact Loaf one-handler shape. A -// recognizable suffix in a modified group is an ownership conflict, not a -// reason to delete a whole user group. -func codexHookOwnership(hook map[string]any) (owned bool, conflict bool) { - return codexHookOwnershipForOS(hook, runtime.GOOS) -} - +// codexHookOwnershipForOS recognizes only the exact Loaf one-handler shape. A +// recognizable command inside a group carrying anything else reports a conflict +// rather than ownership, which is what keeps recognition from claiming — and a +// reconcile from rewriting — a user group Loaf's command was pasted into. func codexHookOwnershipForOS(hook map[string]any, goos string) (owned bool, conflict bool) { matcher, _ := hook["matcher"].(string) handlers, ok := hook["hooks"].([]any) diff --git a/internal/cli/install_target_test.go b/internal/cli/install_target_test.go index 2f4e716d7..ac23f43a5 100644 --- a/internal/cli/install_target_test.go +++ b/internal/cli/install_target_test.go @@ -123,43 +123,52 @@ func TestInstallTargetAdapterManifestPreservesForeignConvergesAndRejectsTamperin assertInstallFile(t, foreign, "export const company = true;\n") } -func TestInstallTargetAdapterManifestOwnsCursorProjectionAndSupportFiles(t *testing.T) { +// Cursor's support files stay whole-file artifacts while its entries converge +// one at a time: the user's entry is untouched, Loaf's own entry is brought to +// the shipped shape wherever it drifted, and no divergence refuses the file. +func TestInstallTargetAdapterManifestReconcilesCursorEntriesAndOwnsSupportFiles(t *testing.T) { root := realpath(t, t.TempDir()) home := filepath.Join(root, "home") dist := filepath.Join(root, "dist", "cursor") config := filepath.Join(root, "cursor") + desired := map[string]any{"command": "loaf task refresh", "matcher": "Edit|Write", loafHookMarker: true} generatedHooks := `{"version":1,"hooks":{"PostToolUse":[{"command":"loaf task refresh","matcher":"Edit|Write","loaf-managed":true}]}}` - existingHooks := `{"version":1,"hooks":{"PostToolUse":[{"command":"user hook"},{"command":"old loaf hook","matcher":"Edit|Write","loaf-managed":true}]}}` + existingHooks := `{"version":1,"hooks":{"PostToolUse":[{"command":"user hook"},{"command":"loaf task refresh","matcher":"Bash","loaf-managed":true}]}}` writeInstallFile(t, filepath.Join(dist, "hooks.json"), generatedHooks) writeInstallFile(t, filepath.Join(dist, "hooks", "post-tool", "managed.sh"), "#!/bin/sh\necho managed\n") writeInstallFile(t, filepath.Join(config, "hooks.json"), existingHooks) writeInstallFile(t, filepath.Join(config, "hooks", "company.sh"), "#!/bin/sh\necho company\n") - hookDigest, err := targetHookProjectionDigest("cursor", []byte(generatedHooks), false) - if err != nil { - t.Fatal(err) - } writeTestTargetAdapterManifest(t, dist, "cursor", []map[string]string{ {"id": "hook-file:hooks/post-tool/managed.sh", "kind": "hook-file", "source_path": "hooks/post-tool/managed.sh", "destination": "hooks/post-tool/managed.sh", "sha256": sha256Hex("#!/bin/sh\necho managed\n")}, - {"id": "hook-projection:hooks.json", "kind": "hook-projection", "source_path": "hooks.json", "destination": "hooks.json", "sha256": hookDigest}, }) - options := targetInstallOptions{Target: "cursor", DistDir: dist, ConfigDir: config, Version: "9.8.7-test.1", HomeDir: home} + installTestHookCatalog(t, dist, "cursor", []hookCatalogSource{{ + event: "PostToolUse", hookID: "generate-task-board", typeName: "command", command: "loaf task refresh", template: desired, + }}) + options := targetInstallOptions{Target: "cursor", DistDir: dist, ConfigDir: config, Version: "9.8.7-test.1", HomeDir: home, HookState: installTestHookState(t)} if err := installTargetDistribution(options); err != nil { t.Fatalf("Cursor adapter install error = %v", err) } hooks := readInstallHooks(t, filepath.Join(config, "hooks.json")) - if len(hooks.Hooks["PostToolUse"]) != 2 || hooks.Hooks["PostToolUse"][0]["command"] != "user hook" || hooks.Hooks["PostToolUse"][1]["command"] != "loaf task refresh" { - t.Fatalf("Cursor hooks = %#v, want user hook plus current Loaf projection", hooks.Hooks) + if len(hooks.Hooks["PostToolUse"]) != 2 || hooks.Hooks["PostToolUse"][0]["command"] != "user hook" { + t.Fatalf("Cursor hooks = %#v, want the user entry preserved in place", hooks.Hooks) + } + if hooks.Hooks["PostToolUse"][1]["matcher"] != "Edit|Write" { + t.Fatalf("Cursor Loaf entry = %#v, want the drifted entry converged", hooks.Hooks["PostToolUse"][1]) } assertInstallFile(t, filepath.Join(config, "hooks", "company.sh"), "#!/bin/sh\necho company\n") - hooks.Hooks["PostToolUse"][1]["command"] = "locally changed loaf hook" + hooks.Hooks["PostToolUse"][1]["command"] = "loaf task refresh --advisory" body, err := json.Marshal(hooks) if err != nil { t.Fatal(err) } writeInstallFile(t, filepath.Join(config, "hooks.json"), string(body)) - if err := installTargetDistribution(options); err == nil || !strings.Contains(err.Error(), "modified") { - t.Fatalf("tampered Cursor projection error = %v, want conflict", err) + if err := installTargetDistribution(options); err != nil { + t.Fatalf("weakened Cursor entry install error = %v, want convergence rather than a refusal", err) + } + hooks = readInstallHooks(t, filepath.Join(config, "hooks.json")) + if hooks.Hooks["PostToolUse"][1]["command"] != "loaf task refresh" { + t.Fatalf("Cursor Loaf entry = %#v, want the weakened entry converged back", hooks.Hooks["PostToolUse"][1]) } } @@ -173,14 +182,22 @@ func TestInstallTargetAdapterManifestCanonicalizesCodexExecutable(t *testing.T) generatedHooks := `{"hooks":{"SessionStart":[{"matcher":"startup|resume|clear|compact","hooks":[{"type":"command","command":"{{LOAF_EXECUTABLE}} journal context --from-hook --codex-hook","commandWindows":"{{LOAF_EXECUTABLE}} journal context --from-hook --codex-hook"}]}]}}` writeInstallFile(t, filepath.Join(dist, ".codex", "hooks.json"), generatedHooks) writeInstallFile(t, filepath.Join(codexHome, "hooks.json"), `{"hooks":{"SessionStart":[{"matcher":"startup","hooks":[{"type":"command","command":"user hook"}]}]}}`) - digest, err := targetHookProjectionDigest("codex", []byte(generatedHooks), false) - if err != nil { - t.Fatal(err) - } - writeTestTargetAdapterManifest(t, dist, "codex", []map[string]string{{ - "id": "hook-projection:.codex/hooks.json", "kind": "hook-projection", "source_path": ".codex/hooks.json", "destination": "hooks.json", "sha256": digest, + writeTestTargetAdapterManifest(t, dist, "codex", nil) + installTestHookCatalog(t, dist, "codex", []hookCatalogSource{{ + event: "SessionStart", + hookID: "session-start-loaf", + typeName: "command", + command: codexJournalExecutablePlaceholder + codexJournalHookCommandSuffix, + template: map[string]any{ + "matcher": codexJournalHookMatcher, + "hooks": []any{map[string]any{ + "type": "command", + "command": codexJournalExecutablePlaceholder + codexJournalHookCommandSuffix, + "commandWindows": codexJournalExecutablePlaceholder + codexJournalHookCommandSuffix, + }}, + }, }}) - options := targetInstallOptions{Target: "codex", DistDir: dist, ConfigDir: config, Version: "9.8.7-test.1", HomeDir: home, CodexHome: codexHome, CodexRuleOperations: operations, ProjectRoot: root} + options := targetInstallOptions{Target: "codex", DistDir: dist, ConfigDir: config, Version: "9.8.7-test.1", HomeDir: home, CodexHome: codexHome, CodexRuleOperations: operations, ProjectRoot: root, HookState: installTestHookState(t)} if err := installTargetDistribution(options); err != nil { t.Fatalf("Codex adapter install error = %v", err) } @@ -188,12 +205,18 @@ func TestInstallTargetAdapterManifestCanonicalizesCodexExecutable(t *testing.T) if len(installed.Hooks["SessionStart"]) != 2 { t.Fatalf("Codex hooks = %#v, want user plus Loaf group", installed.Hooks) } - if got, err := targetHookProjectionDigest("codex", readFileBytes(t, filepath.Join(codexHome, "hooks.json")), true); err != nil || got != digest { - t.Fatalf("installed Codex canonical digest = %q, %v, want %q", got, err, digest) - } if strings.Contains(string(readFileBytes(t, filepath.Join(codexHome, "hooks.json"))), codexJournalExecutablePlaceholder) { t.Fatal("installed Codex hooks retained executable placeholder") } + // The rendered entry is recognized back as the identity that wrote it: a + // second install converges rather than adding a second group. + after := string(readFileBytes(t, filepath.Join(codexHome, "hooks.json"))) + if err := installTargetDistribution(options); err != nil { + t.Fatalf("second Codex adapter install error = %v", err) + } + if got := string(readFileBytes(t, filepath.Join(codexHome, "hooks.json"))); got != after { + t.Fatalf("second install rewrote the file:\nwant %s\ngot %s", after, got) + } } func TestInstallTargetAdapterManifestMigratesAndRetiresAmpPlugin(t *testing.T) { @@ -348,7 +371,7 @@ func TestInstallTargetAdapterManifestBindsConcreteArtifactModes(t *testing.T) { assertInstallMode(t, destination, 0o644) }) - t.Run("executable hook and merged projection modes", func(t *testing.T) { + t.Run("executable hook and reconciled hooks-file modes", func(t *testing.T) { root := realpath(t, t.TempDir()) dist := filepath.Join(root, "dist", "cursor") config := filepath.Join(root, "cursor") @@ -363,15 +386,14 @@ func TestInstallTargetAdapterManifestBindsConcreteArtifactModes(t *testing.T) { if err := os.Chmod(filepath.Join(config, "hooks.json"), 0o600); err != nil { t.Fatal(err) } - hookDigest, err := targetHookProjectionDigest("cursor", []byte(generatedHooks), false) - if err != nil { - t.Fatal(err) - } writeTestTargetAdapterManifest(t, dist, "cursor", []map[string]string{ {"id": "hook-file:managed", "kind": "hook-file", "source_path": "hooks/post-tool/managed.sh", "destination": "hooks/post-tool/managed.sh", "sha256": sha256Hex("#!/bin/sh\necho managed\n")}, - {"id": "hook-projection:hooks.json", "kind": "hook-projection", "source_path": "hooks.json", "destination": "hooks.json", "sha256": hookDigest}, }) - options := targetInstallOptions{Target: "cursor", DistDir: dist, ConfigDir: config, Version: "9.8.7-test.1", HomeDir: filepath.Join(root, "home")} + installTestHookCatalog(t, dist, "cursor", []hookCatalogSource{{ + event: "PostToolUse", hookID: "generate-task-board", typeName: "command", command: "loaf task refresh", + template: map[string]any{"command": "loaf task refresh", "matcher": "Edit|Write", loafHookMarker: true}, + }}) + options := targetInstallOptions{Target: "cursor", DistDir: dist, ConfigDir: config, Version: "9.8.7-test.1", HomeDir: filepath.Join(root, "home"), HookState: installTestHookState(t)} if err := installTargetDistribution(options); err != nil { t.Fatal(err) } @@ -900,20 +922,26 @@ func TestManagedSkillPublicationAndRetirementRestorePostPreflightMismatch(t *tes assertInstallFile(t, filepath.Join(dest, "SKILL.md"), "changed-after-preflight\n") } -func TestInstallTargetCursorMergesHooksAndRemovesObsoleteHooksOnUpgrade(t *testing.T) { +func TestInstallTargetCursorSyncsContentAndRemovesObsoleteHooksOnUpgrade(t *testing.T) { root := realpath(t, t.TempDir()) home := filepath.Join(root, "home") dist := filepath.Join(root, "dist", "cursor") config := filepath.Join(root, ".cursor") checkHook := "loaf check --hook check-" + "se" + "crets" + desired := map[string]any{"command": checkHook, "matcher": "Edit|Write|Bash", loafHookMarker: true} writeInstallFile(t, filepath.Join(dist, "skills", "foundations", "SKILL.md"), "# Foundations\n") writeInstallFile(t, filepath.Join(dist, "agents", "reviewer.md"), "# Reviewer\n") writeInstallFile(t, filepath.Join(dist, "templates", "session.md"), "session\n") writeInstallFile(t, filepath.Join(dist, "hooks", "post-tool", "check.sh"), "#!/bin/sh\n") - writeInstallFile(t, filepath.Join(dist, "hooks.json"), `{"version":1,"hooks":{"PostToolUse":[{"command":"`+checkHook+`","matcher":"Edit|Write|Bash","loaf-managed":true}]}}`) writeInstallFile(t, filepath.Join(config, "hooks.json"), `{"version":1,"hooks":{"PostToolUse":[{"command":"user hook"},{"command":"`+checkHook+`","matcher":"Edit|Write|Bash","loaf-managed":true}],"PreToolUse":[{"prompt":"STOP. Before running gh pr merge anything"}]}}`) writeInstallFile(t, filepath.Join(config, "commands", "stale.md"), "stale\n") writeInstallFile(t, filepath.Join(config, "hooks", "session", "session-start.sh"), "obsolete\n") + writeTestTargetAdapterManifest(t, dist, "cursor", []map[string]string{ + {"id": "hook-file:hooks/post-tool/check.sh", "kind": "hook-file", "source_path": "hooks/post-tool/check.sh", "destination": "hooks/post-tool/check.sh", "sha256": sha256Hex("#!/bin/sh\n")}, + }) + installTestHookCatalog(t, dist, "cursor", []hookCatalogSource{{ + event: "PostToolUse", hookID: "check-" + "sec" + "rets", typeName: "command", command: checkHook, template: desired, + }}) err := installTargetDistribution(targetInstallOptions{ Target: "cursor", @@ -922,6 +950,7 @@ func TestInstallTargetCursorMergesHooksAndRemovesObsoleteHooksOnUpgrade(t *testi Upgrade: true, Version: "9.8.7-test.1", HomeDir: home, + HookState: installTestHookState(t), }) if err != nil { t.Fatalf("install cursor error = %v", err) @@ -939,13 +968,15 @@ func TestInstallTargetCursorMergesHooksAndRemovesObsoleteHooksOnUpgrade(t *testi hooks := readInstallHooks(t, filepath.Join(config, "hooks.json")) postTool := hooks.Hooks["PostToolUse"] if len(postTool) != 2 { - t.Fatalf("PostToolUse hooks = %#v, want user hook plus new loaf hook", postTool) + t.Fatalf("PostToolUse hooks = %#v, want user hook plus the converged Loaf entry", postTool) } if postTool[0]["command"] != "user hook" || postTool[1]["command"] != checkHook { - t.Fatalf("PostToolUse hooks = %#v, want user hook preserved and loaf hook replaced", postTool) + t.Fatalf("PostToolUse hooks = %#v, want the user entry preserved in place", postTool) } - if _, ok := hooks.Hooks["PreToolUse"]; ok { - t.Fatalf("PreToolUse hooks = %#v, want legacy prompt removed", hooks.Hooks["PreToolUse"]) + // The legacy prompt entry pairs to no identity this version ships, so it is + // removed; the section the file declared stays declared. + if preTool, ok := hooks.Hooks["PreToolUse"]; !ok || len(preTool) != 0 { + t.Fatalf("PreToolUse hooks = %#v, want the retired legacy prompt removed from a preserved section", hooks.Hooks["PreToolUse"]) } assertInstallFile(t, filepath.Join(config, loafInstallMarkerFile), "9.8.7-test.1\n") } @@ -957,9 +988,15 @@ func TestInstallTargetCodexUsesCodexHomeForHooksAndSharedSkillsHome(t *testing.T dist := filepath.Join(root, "dist", "codex") config := filepath.Join(root, "reported-config") operations := codexInstallTestOperations(t, root) + generated := `{"hooks":{"SessionStart":[{"matcher":"startup|resume|clear|compact","hooks":[{"type":"command","command":"{{LOAF_EXECUTABLE}} journal context --from-hook --codex-hook"}]}]}}` writeInstallFile(t, filepath.Join(dist, "skills", "go-development", "SKILL.md"), "# Go\n") - writeInstallFile(t, filepath.Join(dist, ".codex", "hooks.json"), `{"hooks":{"SessionStart":[{"matcher":"startup|resume|clear|compact","hooks":[{"type":"command","command":"{{LOAF_EXECUTABLE}} journal context --from-hook --codex-hook"}]}]}}`) + writeInstallFile(t, filepath.Join(dist, ".codex", "hooks.json"), generated) writeInstallFile(t, filepath.Join(codexHome, "hooks.json"), `{"version":1,"description":"user hooks","hooks":{"SessionStart":[{"matcher":"startup","hooks":[{"type":"command","command":"user codex hook"}]}],"Stop":[],"PostToolUse":[{"command":"loaf journal log --from-hook","matcher":"Bash","if":"Bash(git commit:*)"}]}}`) + writeTestTargetAdapterManifest(t, dist, "codex", nil) + installTestHookCatalog(t, dist, "codex", []hookCatalogSource{testCodexHookCatalogSource()}) + // This host has already migrated: the subject here is where the file lives + // and what gets rendered into it, not what absorption decides. + hookState := installTestHookStateAfterMigration(t, "codex") err := installTargetDistribution(targetInstallOptions{ Target: "codex", @@ -969,14 +1006,17 @@ func TestInstallTargetCodexUsesCodexHomeForHooksAndSharedSkillsHome(t *testing.T HomeDir: home, CodexHome: codexHome, CodexRuleOperations: operations, + HookState: hookState, }) if err != nil { t.Fatalf("install codex error = %v", err) } assertInstallFile(t, filepath.Join(home, ".agents", "skills", "go-development", "SKILL.md"), "# Go\n") hooks := readInstallHooks(t, filepath.Join(codexHome, "hooks.json")) - if hooks.Version != 0 { - t.Fatalf("codex hooks version = %d, want omitted current schema", hooks.Version) + // Unknown top-level fields are the operator's, including the legacy version + // marker an older Loaf used to strip. + if hooks.Version != 1 { + t.Fatalf("codex hooks version = %d, want the file's own top-level field preserved", hooks.Version) } if hooks.Description != "user hooks" { t.Fatalf("codex hooks description = %q, want preserved user description", hooks.Description) @@ -996,8 +1036,8 @@ func TestInstallTargetCodexUsesCodexHomeForHooksAndSharedSkillsHome(t *testing.T if stop, ok := hooks.Hooks["Stop"]; !ok || len(stop) != 0 { t.Fatalf("codex hooks = %#v, want explicitly empty Stop event preserved", hooks.Hooks) } - if _, ok := hooks.Hooks["PostToolUse"]; ok { - t.Fatalf("codex hooks = %#v, want legacy flat Loaf hook retired", hooks.Hooks) + if postTool, ok := hooks.Hooks["PostToolUse"]; !ok || len(postTool) != 0 { + t.Fatalf("codex hooks = %#v, want the legacy flat Loaf hook retired", hooks.Hooks) } if err := installTargetDistribution(targetInstallOptions{ Target: "codex", @@ -1007,6 +1047,7 @@ func TestInstallTargetCodexUsesCodexHomeForHooksAndSharedSkillsHome(t *testing.T HomeDir: home, CodexHome: codexHome, CodexRuleOperations: operations, + HookState: hookState, }); err != nil { t.Fatalf("second install codex error = %v", err) } @@ -1047,6 +1088,7 @@ func TestInstallTargetCodexRendersRealGeneratedHookPath(t *testing.T) { CodexHome: codexHome, CodexRuleOperations: operations, ProjectRoot: root, + HookState: installTestHookState(t), }); err != nil { t.Fatalf("install generated Codex hooks error = %v", err) } @@ -1070,41 +1112,6 @@ func TestInstallTargetCodexRendersRealGeneratedHookPath(t *testing.T) { } } -func TestInstallTargetCodexPreservesPromptAndAgentHandlers(t *testing.T) { - root := realpath(t, t.TempDir()) - home := filepath.Join(root, "home") - codexHome := filepath.Join(root, "codex-home") - dist := filepath.Join(root, "dist", "codex") - config := filepath.Join(root, "reported-config") - operations := codexInstallTestOperations(t, root) - writeInstallFile(t, filepath.Join(dist, ".codex", "hooks.json"), `{"hooks":{"SessionStart":[{"matcher":"startup|resume|clear|compact","hooks":[{"type":"command","command":"{{LOAF_EXECUTABLE}} journal context --from-hook --codex-hook"}]}]}}`) - writeInstallFile(t, filepath.Join(codexHome, "hooks.json"), `{"hooks":{"SessionStart":[{}, {"matcher":null}, {"matcher":"resume","hooks":[{"type":"prompt"}]},{"matcher":"clear","hooks":[{"type":"agent"}]},{"matcher":"compact","hooks":[{"type":"command","command":"user hook","command_windows":"powershell user hook","timeout":0,"async":true,"statusMessage":"checking"}]}]}}`) - - if err := installTargetDistribution(targetInstallOptions{Target: "codex", DistDir: dist, ConfigDir: config, Version: "9.8.7-test.1", HomeDir: home, CodexHome: codexHome, CodexRuleOperations: operations}); err != nil { - t.Fatalf("install codex error = %v", err) - } - hooks := readInstallHooks(t, filepath.Join(codexHome, "hooks.json")) - groups := hooks.Hooks["SessionStart"] - if len(groups) != 6 { - t.Fatalf("Codex SessionStart groups = %#v, want five user groups plus Loaf", groups) - } - for index, wantType := range []string{"prompt", "agent", "command"} { - groupIndex := index + 2 - handlers, ok := groups[groupIndex]["hooks"].([]any) - if !ok || len(handlers) != 1 { - t.Fatalf("Codex user group %d handlers = %#v, want one handler", groupIndex, groups[groupIndex]["hooks"]) - } - handler, ok := handlers[0].(map[string]any) - if !ok || handler["type"] != wantType { - t.Fatalf("Codex user group %d handler = %#v, want type %q", groupIndex, handler, wantType) - } - } - command := groups[4]["hooks"].([]any)[0].(map[string]any) - if command["command_windows"] != "powershell user hook" || command["timeout"] != float64(0) || command["async"] != true || command["statusMessage"] != "checking" { - t.Fatalf("Codex command handler = %#v, want valid current-schema fields preserved", command) - } -} - func TestCodexMatcherValidationAcceptsOptionalNullsAndEmptyCommand(t *testing.T) { hook, err := decodeCodexHookObject(json.RawMessage(`{"matcher":null,"hooks":[{"type":"command","command":" ","commandWindows":null,"timeout":null,"statusMessage":null}]}`)) if err != nil { @@ -1115,25 +1122,10 @@ func TestCodexMatcherValidationAcceptsOptionalNullsAndEmptyCommand(t *testing.T) } for name, raw := range map[string]json.RawMessage{ - "empty group": json.RawMessage(`{}`), - "null matcher": json.RawMessage(`{"matcher":null}`), - "null description": json.RawMessage(`{"description":null,"hooks":{}}`), + "empty group": json.RawMessage(`{}`), + "null matcher": json.RawMessage(`{"matcher":null}`), } { t.Run(name, func(t *testing.T) { - if name == "null description" { - path := filepath.Join(t.TempDir(), "hooks.json") - if err := os.WriteFile(path, raw, 0o644); err != nil { - t.Fatalf("write Codex hooks file: %v", err) - } - loaded, err := loadCodexHooksRawFileStrict(path) - if err != nil { - t.Fatalf("load Codex hooks file error = %v", err) - } - if string(loaded.Description) != "null" { - t.Fatalf("loaded description = %s, want null preserved", loaded.Description) - } - return - } hook, err := decodeCodexHookObject(raw) if err != nil { t.Fatalf("decode Codex matcher group error = %v", err) @@ -1145,6 +1137,31 @@ func TestCodexMatcherValidationAcceptsOptionalNullsAndEmptyCommand(t *testing.T) } } +// A top-level field whose value is null is still the operator's. The reader +// keeps it as the raw value it was written as, so republishing the document +// hands it back unchanged rather than dropping or normalizing it. +func TestReadHookFilePreservesNullTopLevelFields(t *testing.T) { + path := filepath.Join(t.TempDir(), "hooks.json") + body := `{"description":null,"hooks":{}}` + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatalf("write hooks file: %v", err) + } + file, err := readHookFile(path) + if err != nil { + t.Fatalf("readHookFile error = %v", err) + } + if got := string(file.fields["description"]); got != "null" { + t.Fatalf("description = %s, want null preserved", got) + } + republished, err := file.marshal() + if err != nil { + t.Fatalf("marshal error = %v", err) + } + if !strings.Contains(string(republished), `"description": null`) { + t.Fatalf("republished = %s, want the null description carried through", republished) + } +} + func TestCodexHookUint64RejectsLossyFloatValues(t *testing.T) { for _, value := range []any{float64(-1), float64(1.5), float64(1<<53 + 2), math.MaxFloat64} { if _, ok := codexHookUint64(value); ok { @@ -1162,86 +1179,6 @@ func TestCodexHookUint64RejectsLossyFloatValues(t *testing.T) { } } -func TestInstallTargetCodexRejectsMalformedOrUnsupportedHooks(t *testing.T) { - for _, tc := range []struct { - name string - body string - want string - }{ - {name: "malformed", body: "{", want: "parse Codex hooks file"}, - {name: "unsupported-flat-user-entry", body: `{"hooks":{"PreToolUse":[{"command":"user hook"}]}}`, want: "unsupported matcher entry"}, - {name: "user-loaf-journal-command", body: `{"hooks":{"PreToolUse":[{"command":"loaf journal recent --json"}]}}`, want: "unsupported matcher entry"}, - {name: "user-custom-loaf-check", body: `{"hooks":{"PreToolUse":[{"command":"loaf check --hook my-company-gate"}]}}`, want: "unsupported matcher entry"}, - {name: "empty-handler", body: `{"hooks":{"SessionStart":[{"hooks":[{}]}]}}`, want: "unsupported matcher entry"}, - {name: "unknown-handler-type", body: `{"hooks":{"SessionStart":[{"hooks":[{"type":"prompt","command":"echo user"}]}]}}`, want: "unsupported matcher entry"}, - {name: "prompt-extra-field", body: `{"hooks":{"SessionStart":[{"hooks":[{"type":"prompt","unexpected":true}]}]}}`, want: "unsupported matcher entry"}, - {name: "agent-extra-field", body: `{"hooks":{"SessionStart":[{"hooks":[{"type":"agent","unexpected":true}]}]}}`, want: "unsupported matcher entry"}, - {name: "unknown-handler-field", body: `{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"echo user","bogus":true}]}]}}`, want: "unsupported matcher entry"}, - {name: "wrong-handler-field-type", body: `{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"echo user","async":"true"}]}]}}`, want: "unsupported matcher entry"}, - {name: "duplicate-command-windows-alias", body: `{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"echo user","commandWindows":"windows user","command_windows":"windows alias"}]}]}}`, want: "unsupported matcher entry"}, - {name: "unknown-group-field", body: `{"hooks":{"SessionStart":[{"event":"startup","hooks":[{"type":"command","command":"echo user"}]}]}}`, want: "unsupported matcher entry"}, - {name: "unknown-event", body: `{"hooks":{"BogusEvent":[{"hooks":[{"type":"command","command":"echo user"}]}]}}`, want: "unsupported hook event"}, - {name: "null-event-array", body: `{"hooks":{"Stop":null}}`, want: "must be an array"}, - {name: "timeout-negative", body: `{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"echo user","timeout":-1}]}]}}`, want: "unsupported matcher entry"}, - {name: "timeout-fractional", body: `{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"echo user","timeout":1.5}]}]}}`, want: "unsupported matcher entry"}, - {name: "timeout-too-large", body: `{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"echo user","timeout":18446744073709551616}]}]}}`, want: "unsupported matcher entry"}, - {name: "timeout-wrong-type", body: `{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"echo user","timeout":"1"}]}]}}`, want: "unsupported matcher entry"}, - {name: "versioned-nested-user-only", body: `{"version":1,"description":"user hooks","hooks":{"SessionStart":[{"matcher":"startup","hooks":[{"type":"command","command":"echo user"}]}]}}`, want: "legacy version metadata"}, - {name: "version-string", body: `{"version":"1","hooks":{}}`, want: "legacy version must be numeric 1"}, - {name: "version-two", body: `{"version":2,"hooks":{}}`, want: "legacy version must be numeric 1"}, - {name: "unsupported-top-level-field", body: `{"hooks":{},"unknown":true}`, want: "unsupported top-level field"}, - } { - t.Run(tc.name, func(t *testing.T) { - root := realpath(t, t.TempDir()) - home := filepath.Join(root, "home") - codexHome := filepath.Join(root, "codex-home") - dist := filepath.Join(root, "dist", "codex") - config := filepath.Join(root, "reported-config") - operations := codexInstallTestOperations(t, root) - writeInstallFile(t, filepath.Join(dist, ".codex", "hooks.json"), `{"hooks":{}}`) - writeInstallFile(t, filepath.Join(codexHome, "hooks.json"), tc.body) - - err := installTargetDistribution(targetInstallOptions{ - Target: "codex", - DistDir: dist, - ConfigDir: config, - Version: "9.8.7-test.1", - HomeDir: home, - CodexHome: codexHome, - CodexRuleOperations: operations, - }) - if err == nil || !strings.Contains(err.Error(), tc.want) { - t.Fatalf("install codex error = %v, want %q", err, tc.want) - } - assertInstallFile(t, filepath.Join(codexHome, "hooks.json"), tc.body) - }) - } -} - -func TestInstallTargetCodexRejectsModifiedOwnedGroupAndPlaceholderLeak(t *testing.T) { - root := realpath(t, t.TempDir()) - home := filepath.Join(root, "home") - codexHome := filepath.Join(root, "codex-home") - dist := filepath.Join(root, "dist", "codex") - config := filepath.Join(root, "reported-config") - operations := codexInstallTestOperations(t, root) - generated := `{"hooks":{"SessionStart":[{"matcher":"startup|resume|clear|compact","hooks":[{"type":"command","command":"{{LOAF_EXECUTABLE}} journal context --from-hook --codex-hook"}]}]}}` - writeInstallFile(t, filepath.Join(dist, ".codex", "hooks.json"), generated) - writeInstallFile(t, filepath.Join(codexHome, "hooks.json"), `{"hooks":{"SessionStart":[{"matcher":"startup|resume|clear|compact","hooks":[{"type":"command","command":"'/usr/local/bin/loaf' journal context --from-hook --codex-hook"},{"type":"command","command":"user hook"}]}]}}`) - err := installTargetDistribution(targetInstallOptions{Target: "codex", DistDir: dist, ConfigDir: config, Version: "9.8.7-test.1", HomeDir: home, CodexHome: codexHome, CodexRuleOperations: operations}) - if err == nil || !strings.Contains(err.Error(), "modified Loaf SessionStart matcher group") { - t.Fatalf("modified owned group error = %v, want ownership conflict", err) - } - assertInstallFile(t, filepath.Join(codexHome, "hooks.json"), `{"hooks":{"SessionStart":[{"matcher":"startup|resume|clear|compact","hooks":[{"type":"command","command":"'/usr/local/bin/loaf' journal context --from-hook --codex-hook"},{"type":"command","command":"user hook"}]}]}}`) - - writeInstallFile(t, filepath.Join(codexHome, "hooks.json"), `{"hooks":{}}`) - writeInstallFile(t, filepath.Join(dist, ".codex", "hooks.json"), `{"hooks":{"SessionStart":[{"matcher":"startup|resume|clear|compact","hooks":[{"type":"command","command":"{{LOAF_EXECUTABLE}} journal context --from-hook --codex-hook","statusMessage":"{{LOAF_EXECUTABLE}}"}]}]}}`) - err = installTargetDistribution(targetInstallOptions{Target: "codex", DistDir: dist, ConfigDir: config, Version: "9.8.7-test.1", HomeDir: home, CodexHome: codexHome, CodexRuleOperations: operations}) - if err == nil || !strings.Contains(err.Error(), "placeholder remains") { - t.Fatalf("placeholder leak error = %v, want strict rejection", err) - } -} - func TestCodexHookExecutableRenderingUsesLiteralCanonicalShellQuote(t *testing.T) { path := "/trusted/Loaf $release/o'brien/loaf" raw := json.RawMessage(`{"matcher":"startup|resume|clear|compact","hooks":[{"type":"command","command":"{{LOAF_EXECUTABLE}} journal context --from-hook --codex-hook"}]}`) @@ -1341,94 +1278,6 @@ func TestCodexWindowsHookExecutableRenderingUsesCmdOuterQuote(t *testing.T) { } } -func TestMergeCodexWindowsHookIsIdempotentAndConflictSafe(t *testing.T) { - root := realpath(t, t.TempDir()) - destPath := filepath.Join(root, "codex-home", "hooks.json") - loafPath := filepath.Join(root, "dist", "codex", ".codex", "hooks.json") - writeInstallFile(t, loafPath, `{"hooks":{"SessionStart":[{"matcher":"startup|resume|clear|compact","hooks":[{"type":"command","command":"{{LOAF_EXECUTABLE}} journal context --from-hook --codex-hook","commandWindows":"{{LOAF_EXECUTABLE}} journal context --from-hook --codex-hook"}]}]}}`) - writeInstallFile(t, destPath, `{"hooks":{}}`+"\n") - pathV1 := `C:\Program Files (x86)\Loaf\v1\loaf.exe` - if err := mergeCodexHookFilesForOSWithExecutable(destPath, loafPath, root, nil, "windows", pathV1); err != nil { - t.Fatalf("first Windows merge error = %v", err) - } - firstBody, err := os.ReadFile(destPath) - if err != nil { - t.Fatalf("read first Windows merge = %v", err) - } - assertCodexWindowsInstalledGroup(t, firstBody, pathV1) - if err := mergeCodexHookFilesForOSWithExecutable(destPath, loafPath, root, nil, "windows", pathV1); err != nil { - t.Fatalf("second Windows merge error = %v", err) - } - secondBody, err := os.ReadFile(destPath) - if err != nil { - t.Fatalf("read second Windows merge = %v", err) - } - if string(secondBody) != string(firstBody) { - t.Fatalf("second Windows merge changed exact managed group:\nfirst=%s\nsecond=%s", firstBody, secondBody) - } - - pathV2 := `C:\Loaf\v2\loaf.exe` - if err := mergeCodexHookFilesForOSWithExecutable(destPath, loafPath, root, nil, "windows", pathV2); err != nil { - t.Fatalf("rotated Windows merge error = %v", err) - } - rotatedBody, err := os.ReadFile(destPath) - if err != nil { - t.Fatalf("read rotated Windows merge = %v", err) - } - assertCodexWindowsInstalledGroup(t, rotatedBody, pathV2) - - for name, mutate := range map[string]func(map[string]any){ - "changed command": func(handler map[string]any) { - handler["command"] = handler["command"].(string) + " altered" - }, - "changed commandWindows": func(handler map[string]any) { - handler["commandWindows"] = handler["commandWindows"].(string) + " altered" - }, - "removed commandWindows": func(handler map[string]any) { - delete(handler, "commandWindows") - }, - } { - t.Run(name, func(t *testing.T) { - var raw map[string]any - if err := json.Unmarshal(rotatedBody, &raw); err != nil { - t.Fatalf("decode rotated Windows hooks = %v", err) - } - groups := raw["hooks"].(map[string]any)["SessionStart"].([]any) - handler := groups[0].(map[string]any)["hooks"].([]any)[0].(map[string]any) - mutate(handler) - body, err := json.Marshal(raw) - if err != nil { - t.Fatalf("encode conflicting Windows hooks = %v", err) - } - writeInstallFile(t, destPath, string(body)) - if err := mergeCodexHookFilesForOSWithExecutable(destPath, loafPath, root, nil, "windows", pathV2); err == nil || !strings.Contains(err.Error(), "modified Loaf SessionStart matcher group") { - t.Fatalf("conflicting Windows merge error = %v, want ownership conflict", err) - } - }) - } -} - -func assertCodexWindowsInstalledGroup(t *testing.T, body []byte, executable string) { - t.Helper() - var hooks codexHooksFile - if err := json.Unmarshal(body, &hooks); err != nil { - t.Fatalf("decode Windows hooks = %v", err) - } - groups := hooks.Hooks["SessionStart"] - if len(groups) != 1 { - t.Fatalf("Windows groups = %#v, want one managed group", groups) - } - handlers := groups[0]["hooks"].([]any) - if len(handlers) != 1 { - t.Fatalf("Windows handlers = %#v, want one managed handler", groups[0]["hooks"]) - } - handler := handlers[0].(map[string]any) - want := `""` + executable + `"` + codexJournalHookCommandSuffix + `"` - if handler["command"] != want || handler["commandWindows"] != want { - t.Fatalf("Windows managed handler = %#v, want equal command fields %q", handler, want) - } -} - func TestInstallTargetAmpUsesSharedAndCustomHomes(t *testing.T) { root := realpath(t, t.TempDir()) home := filepath.Join(root, "home") @@ -1569,13 +1418,28 @@ func assertInstallPathMissing(t *testing.T, path string) { } } -func readInstallHooks(t *testing.T, path string) codexHooksFile { +// loafHookMarker is the human-legible provenance a Cursor entry carries. +// Recognition deliberately does not depend on it — Decision 6 makes ownership a +// property of the command, not of a field anyone can write — so it survives as +// a test-side name for the field the builder emits and assertions read. +const loafHookMarker = "loaf-managed" + +// installedHooksFile is the shape tests decode a hooks file into. Production +// reads these files through readHookFile, which keeps every foreign value raw; +// a test that only wants to look at what landed can afford the lossy decode. +type installedHooksFile struct { + Version int `json:"version,omitempty"` + Description string `json:"description,omitempty"` + Hooks map[string][]map[string]any `json:"hooks"` +} + +func readInstallHooks(t *testing.T, path string) installedHooksFile { t.Helper() body, err := os.ReadFile(path) if err != nil { t.Fatalf("ReadFile(%s) error = %v", path, err) } - var hooks codexHooksFile + var hooks installedHooksFile if err := json.Unmarshal(body, &hooks); err != nil { t.Fatalf("Unmarshal(%s) error = %v\n%s", path, err, body) } diff --git a/internal/cli/release_evidence_gate_test.go b/internal/cli/release_evidence_gate_test.go index bc76e98f9..97ac29efb 100644 --- a/internal/cli/release_evidence_gate_test.go +++ b/internal/cli/release_evidence_gate_test.go @@ -512,9 +512,9 @@ func TestReleaseApplyResumesPreparedTreeAfterEvidenceRerecord(t *testing.T) { wantPaths := []string{ "CHANGELOG.md", "dist/opencode/plugins/hooks.ts", - "docs/changes/20260807-self-carried-release-guardrail/research/claude-code-2.1.223-plugin-startup-smoke.json", - "docs/changes/20260807-self-carried-release-guardrail/research/codex-0.146.0-isolated-startup-smoke.json", - "docs/changes/20260807-self-carried-release-guardrail/research/opencode-1.18.13-isolated-request-smoke.json", + "docs/changes/20260808-hooks-entry-reconciliation/research/claude-code-2.1.226-plugin-startup-smoke.json", + "docs/changes/20260808-hooks-entry-reconciliation/research/codex-0.147.0-isolated-startup-smoke.json", + "docs/changes/20260808-hooks-entry-reconciliation/research/opencode-1.18.13-isolated-request-smoke.json", "package.json", } if len(changed) != len(wantPaths) { @@ -643,7 +643,7 @@ func nameStatusZ(paths ...string) string { func TestReleasePostMergeEvidenceOnlyRepairPasses(t *testing.T) { repo := seedReleasePostMergeFiles(t, "1.2.3") seedReleaseCapabilityEvidence(t, repo) - receipt := "docs/changes/20260807-self-carried-release-guardrail/research/opencode-1.18.13-isolated-request-smoke.json" + receipt := "docs/changes/20260808-hooks-entry-reconciliation/research/opencode-1.18.13-isolated-request-smoke.json" responses := releasePostMergeHappyResponses("1.2.3") // Detect repair via HEAD^..HEAD receipt-only diff against the parent // registry; subject + release shape come from the parent release commit; @@ -684,7 +684,7 @@ func TestReleasePostMergeEvidenceOnlyRepairPasses(t *testing.T) { func TestReleasePostMergeRepairModifyingRegistryRefuses(t *testing.T) { repo := seedReleasePostMergeFiles(t, "1.2.3") seedReleaseCapabilityEvidence(t, repo) - receipt := "docs/changes/20260807-self-carried-release-guardrail/research/opencode-1.18.13-isolated-request-smoke.json" + receipt := "docs/changes/20260808-hooks-entry-reconciliation/research/opencode-1.18.13-isolated-request-smoke.json" responses := releasePostMergeHappyResponses("1.2.3") responses["git rev-parse --verify HEAD^"] = releasePostMergeOK("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") // Registry in the repair commit → not receipt-only; guardrail 5 evaluates HEAD. @@ -702,7 +702,7 @@ func TestReleasePostMergeRepairModifyingRegistryRefuses(t *testing.T) { func TestReleasePostMergeNonEvidenceRepairStillFailsDiffShape(t *testing.T) { repo := seedReleasePostMergeFiles(t, "1.2.3") seedReleaseCapabilityEvidence(t, repo) - receipt := "docs/changes/20260807-self-carried-release-guardrail/research/opencode-1.18.13-isolated-request-smoke.json" + receipt := "docs/changes/20260808-hooks-entry-reconciliation/research/opencode-1.18.13-isolated-request-smoke.json" responses := releasePostMergeHappyResponses("1.2.3") responses["git rev-parse --verify HEAD^"] = releasePostMergeOK("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") // Touches a non-receipt path → not evidence-only; guardrail 5 evaluates HEAD. @@ -738,7 +738,7 @@ func TestReleasePostMergeRepairTouchingFixtureSourceRefuses(t *testing.T) { func TestReleasePostMergeRepairWhitespacePaddedFilenameRefuses(t *testing.T) { repo := seedReleasePostMergeFiles(t, "1.2.3") seedReleaseCapabilityEvidence(t, repo) - receipt := "docs/changes/20260807-self-carried-release-guardrail/research/opencode-1.18.13-isolated-request-smoke.json" + receipt := "docs/changes/20260808-hooks-entry-reconciliation/research/opencode-1.18.13-isolated-request-smoke.json" // Leading spaces must not alias the real receipt path after TrimSpace. padded := " " + receipt responses := releasePostMergeHappyResponses("1.2.3") diff --git a/internal/cli/setup_test.go b/internal/cli/setup_test.go index e555860a4..eb5175c96 100644 --- a/internal/cli/setup_test.go +++ b/internal/cli/setup_test.go @@ -75,6 +75,7 @@ func TestRunnerSetupDeploysProjectSurfacesItJustScaffolded(t *testing.T) { t.Fatalf("MkdirAll(.cursor) error = %v", err) } writeInstallFile(t, filepath.Join(root, "dist", "cursor", "skills", "foundations", "SKILL.md"), "# Foundations\n") + installTestHookDistribution(t, root, "cursor") target := filepath.Join(root, "fixture-project") var stdout bytes.Buffer diff --git a/internal/cli/target_capability_contract_test.go b/internal/cli/target_capability_contract_test.go index a0b45abc4..4178a7e07 100644 --- a/internal/cli/target_capability_contract_test.go +++ b/internal/cli/target_capability_contract_test.go @@ -22,10 +22,10 @@ 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.223\x00plugin-dir", + "claude-code\x00cli": "2.1.226\x00plugin-dir", "cursor\x00ide": "3.11.19\x00candidate-build", "cursor\x00cursor-agent": "2026.05.09-0afadcc\x00candidate-build", - "codex\x00cli": "0.146.0\x00isolated-codex-home", + "codex\x00cli": "0.147.0\x00isolated-codex-home", "opencode\x00cli": "1.18.13\x00isolated-xdg", "amp\x00cli": "0.0.1783873056-g278461\x00candidate-build", } @@ -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/20260807-self-carried-release-guardrail/research/claude-code-2.1.223-plugin-startup-smoke.json")) + data, err := os.ReadFile(filepath.Join(filepath.Dir(filepath.Dir(testTargetCapabilityEvidencePath(t))), "docs/changes/20260808-hooks-entry-reconciliation/research/claude-code-2.1.226-plugin-startup-smoke.json")) if err != nil { t.Fatal(err) } @@ -483,7 +483,7 @@ func TestOpenCodeInstalledSmokeEvidenceAcceptsFixture(t *testing.T) { } func TestOpenCodeInstalledSmokeEvidenceRejectsFalseBooleansIdentityInvocationHashAndCleanup(t *testing.T) { - receiptPath := filepath.Join(testRepositoryRoot(t), "docs/changes/20260807-self-carried-release-guardrail/research/opencode-1.18.13-isolated-request-smoke.json") + receiptPath := filepath.Join(testRepositoryRoot(t), "docs/changes/20260808-hooks-entry-reconciliation/research/opencode-1.18.13-isolated-request-smoke.json") data, err := os.ReadFile(receiptPath) if err != nil { t.Fatal(err) @@ -546,7 +546,7 @@ func TestOpenCodeInstalledSmokeEvidenceRejectsFalseBooleansIdentityInvocationHas } func TestClaudeInstalledSmokeEvidenceRejectsPlatformSwappedNativeBinaryPath(t *testing.T) { - receiptPath := filepath.Join(testRepositoryRoot(t), "docs/changes/20260807-self-carried-release-guardrail/research/claude-code-2.1.223-plugin-startup-smoke.json") + receiptPath := filepath.Join(testRepositoryRoot(t), "docs/changes/20260808-hooks-entry-reconciliation/research/claude-code-2.1.226-plugin-startup-smoke.json") raw := readSmokeReceiptRaw(t, receiptPath) artifacts := raw["candidate_artifacts"].(map[string]any) sourceNativePath := artifacts["native_binary_path"].(string) @@ -568,7 +568,7 @@ func TestClaudeInstalledSmokeEvidenceRejectsPlatformSwappedNativeBinaryPath(t *t } func TestCodexInstalledSmokeEvidenceRejectsPlatformSwappedNativeBinaryPath(t *testing.T) { - receiptPath := filepath.Join(testRepositoryRoot(t), "docs/changes/20260807-self-carried-release-guardrail/research/codex-0.146.0-isolated-startup-smoke.json") + receiptPath := filepath.Join(testRepositoryRoot(t), "docs/changes/20260808-hooks-entry-reconciliation/research/codex-0.147.0-isolated-startup-smoke.json") raw := readSmokeReceiptRaw(t, receiptPath) artifacts := raw["candidate_artifacts"].(map[string]any) sourceNativePath := artifacts["native_binary_path"].(string) @@ -628,7 +628,7 @@ func TestInstalledSmokeEvidenceRejectsCrossTargetNativeBinaryPaths(t *testing.T) { name: "claude-receives-codex-path", target: "claude-code", - receipt: "claude-code-2.1.223-plugin-startup-smoke.json", + receipt: "claude-code-2.1.226-plugin-startup-smoke.json", modeName: "startup", wrongPath: "bin/native/darwin-arm64/loaf", validateFn: validateInstalledSmokeEvidence, @@ -636,7 +636,7 @@ func TestInstalledSmokeEvidenceRejectsCrossTargetNativeBinaryPaths(t *testing.T) { name: "codex-receives-claude-path", target: "codex", - receipt: "codex-0.146.0-isolated-startup-smoke.json", + receipt: "codex-0.147.0-isolated-startup-smoke.json", modeName: "startup", wrongPath: "plugins/loaf/bin/native/darwin-arm64/loaf", validateFn: validateCodexInstalledSmokeEvidence, @@ -644,7 +644,7 @@ func TestInstalledSmokeEvidenceRejectsCrossTargetNativeBinaryPaths(t *testing.T) } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - receiptPath := filepath.Join(testRepositoryRoot(t), "docs/changes/20260807-self-carried-release-guardrail/research", tt.receipt) + receiptPath := filepath.Join(testRepositoryRoot(t), "docs/changes/20260808-hooks-entry-reconciliation/research", tt.receipt) raw := readSmokeReceiptRaw(t, receiptPath) artifacts := raw["candidate_artifacts"].(map[string]any) sourceNativePath := artifacts["native_binary_path"].(string) @@ -682,14 +682,14 @@ func TestInstalledSmokeEvidenceRejectsSymlinkedCandidateArtifacts(t *testing.T) { name: "claude-code-hooks-leaf-symlink", target: "claude-code", - receipt: "claude-code-2.1.223-plugin-startup-smoke.json", + receipt: "claude-code-2.1.226-plugin-startup-smoke.json", modeName: "startup", validateFn: validateInstalledSmokeEvidence, }, { name: "codex-hooks-leaf-symlink", target: "codex", - receipt: "codex-0.146.0-isolated-startup-smoke.json", + receipt: "codex-0.147.0-isolated-startup-smoke.json", modeName: "startup", validateFn: validateCodexInstalledSmokeEvidence, }, @@ -703,7 +703,7 @@ func TestInstalledSmokeEvidenceRejectsSymlinkedCandidateArtifacts(t *testing.T) } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - receiptPath := filepath.Join(testRepositoryRoot(t), "docs/changes/20260807-self-carried-release-guardrail/research", tt.receipt) + receiptPath := filepath.Join(testRepositoryRoot(t), "docs/changes/20260808-hooks-entry-reconciliation/research", tt.receipt) raw := readSmokeReceiptRaw(t, receiptPath) artifacts := raw["candidate_artifacts"].(map[string]any) hooksRel := artifacts["hooks_path"].(string) @@ -768,7 +768,7 @@ func TestInstalledSmokeEvidenceRejectsSymlinkedCandidateArtifacts(t *testing.T) } t.Run("opencode-intermediate-dir-symlink", func(t *testing.T) { - receiptPath := filepath.Join(testRepositoryRoot(t), "docs/changes/20260807-self-carried-release-guardrail/research/opencode-1.18.13-isolated-request-smoke.json") + receiptPath := filepath.Join(testRepositoryRoot(t), "docs/changes/20260808-hooks-entry-reconciliation/research/opencode-1.18.13-isolated-request-smoke.json") raw := readSmokeReceiptRaw(t, receiptPath) artifacts := raw["candidate_artifacts"].(map[string]any) hooksRel := artifacts["hooks_path"].(string) diff --git a/internal/cli/testdata/hooks/codex-hooks-0.2.20.json b/internal/cli/testdata/hooks/codex-hooks-0.2.20.json new file mode 100644 index 000000000..c2b910bc7 --- /dev/null +++ b/internal/cli/testdata/hooks/codex-hooks-0.2.20.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume|clear|compact", + "hooks": [ + { + "type": "command", + "command": "{{LOAF_EXECUTABLE}} journal context --from-hook --codex-hook", + "commandWindows": "{{LOAF_EXECUTABLE}} journal context --from-hook --codex-hook" + } + ] + } + ] + } +} diff --git a/internal/cli/testdata/hooks/codex-hooks-live.json b/internal/cli/testdata/hooks/codex-hooks-live.json new file mode 100644 index 000000000..0968512f5 --- /dev/null +++ b/internal/cli/testdata/hooks/codex-hooks-live.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "command": "bash '/Users/canary/.config/codex/herdr-agent-state.sh' session", + "timeout": 10, + "type": "command" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/internal/cli/testdata/hooks/cursor-hooks-0.2.20.json b/internal/cli/testdata/hooks/cursor-hooks-0.2.20.json new file mode 100644 index 000000000..96daef02d --- /dev/null +++ b/internal/cli/testdata/hooks/cursor-hooks-0.2.20.json @@ -0,0 +1,130 @@ +{ + "version": 1, + "hooks": { + "preToolUse": [ + { + "loaf-managed": true, + "timeout": 30, + "matcher": "Edit|Write|Bash", + "failClosed": true, + "command": "loaf check --hook artifact-body-write" + }, + { + "loaf-managed": true, + "timeout": 30, + "matcher": "Edit|Write|Bash", + "failClosed": true, + "command": "loaf check --hook check-secrets" + }, + { + "loaf-managed": true, + "timeout": 600, + "matcher": "Bash", + "failClosed": true, + "command": "loaf check --hook security-audit" + }, + { + "loaf-managed": true, + "timeout": 30, + "matcher": "Bash", + "failClosed": true, + "command": "loaf check --hook render-drift", + "if": "Bash(git push:*)" + }, + { + "loaf-managed": true, + "timeout": 30, + "matcher": "Bash", + "failClosed": true, + "command": "loaf check --hook ephemeral-provenance", + "if": "Bash(git push:*)" + }, + { + "loaf-managed": true, + "timeout": 30, + "matcher": "Bash", + "failClosed": true, + "command": "loaf check --hook artifact-names", + "if": "Bash(git commit:*)" + }, + { + "loaf-managed": true, + "timeout": 10, + "matcher": "Bash", + "failClosed": true, + "command": "loaf check --hook github-account" + }, + { + "loaf-managed": true, + "timeout": 60, + "matcher": "Bash", + "command": "loaf check --hook validate-push --advisory", + "if": "Bash(git push:*)" + }, + { + "loaf-managed": true, + "timeout": 5, + "matcher": "Bash", + "command": "loaf check --hook workflow-pre-pr --advisory", + "if": "Bash(gh pr create:*)" + }, + { + "loaf-managed": true, + "timeout": 5, + "matcher": "Bash", + "command": "cat \"$HOME/.cursor/hooks/instructions/pre-merge.md\"", + "if": "Bash(gh pr merge:*)" + }, + { + "loaf-managed": true, + "timeout": 5, + "matcher": "Bash", + "command": "cat \"$HOME/.cursor/hooks/instructions/pre-push.md\"", + "if": "Bash(git push:*)" + }, + { + "loaf-managed": true, + "timeout": 30, + "matcher": "Bash", + "failClosed": true, + "command": "loaf check --hook validate-commit", + "if": "Bash(git commit:*)" + }, + { + "loaf-managed": true, + "timeout": 30, + "matcher": "Bash", + "command": "loaf journal log --detect-linear", + "if": "Bash(git commit:*)" + } + ], + "postToolUse": [ + { + "loaf-managed": true, + "timeout": 30, + "matcher": "Edit|Write", + "command": "loaf task refresh" + }, + { + "loaf-managed": true, + "timeout": 5, + "matcher": "Edit|Write", + "command": "bash $HOME/.cursor/hooks/post-tool/kb-staleness-nudge.sh" + }, + { + "loaf-managed": true, + "timeout": 5, + "matcher": "Bash", + "command": "cat \"$HOME/.cursor/hooks/instructions/post-merge.md\"", + "if": "Bash(gh pr merge:*)" + } + ], + "sessionStart": [ + { + "loaf-managed": true, + "timeout": 60, + "command": "loaf journal context --from-hook --cursor-hook" + } + ] + } +} \ No newline at end of file diff --git a/internal/cli/testdata/hooks/cursor-hooks-live.json b/internal/cli/testdata/hooks/cursor-hooks-live.json new file mode 100644 index 000000000..ddb414bde --- /dev/null +++ b/internal/cli/testdata/hooks/cursor-hooks-live.json @@ -0,0 +1,293 @@ +{ + "version": 1, + "hooks": { + "postToolUse": [ + { + "command": "bash $HOME/.cursor/hooks/post-tool/python-ruff-check.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/post-tool/typescript-eslint-check.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/post-tool/rails-rubocop-check.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/post-tool/design-a11y-validation.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/post-tool/design-token-check.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/post-tool/design-a11y-audit.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/post-tool/foundations-changelog-reminder.sh", + "matcher": "Bash", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/post-tool/orchestration-generate-task-board.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "loaf task refresh", + "loaf-managed": true, + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/post-tool/kb-staleness-nudge.sh", + "loaf-managed": true, + "matcher": "Edit|Write", + "timeout": 5 + }, + { + "command": "cat \"$HOME/.cursor/hooks/instructions/post-merge.md\"", + "if": "Bash(gh pr merge:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 5 + } + ], + "preToolUse": [ + { + "command": "bash $HOME/.cursor/hooks/pre-tool/foundations-check-secrets.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/foundations-validate-changelog.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/foundations-format-check.sh", + "matcher": "Edit|Write", + "timeout": 60 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/foundations-security-audit.sh", + "matcher": "Bash", + "timeout": 600 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/foundations-tdd-advisory.sh", + "matcher": "Edit|Write", + "timeout": 5 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/foundations-validate-push.sh", + "matcher": "Bash", + "timeout": 60 + }, + { + "command": "python3 $HOME/.cursor/hooks/pre-tool/orchestration-validate-commit.py", + "matcher": "Bash", + "timeout": 30 + }, + { + "command": "python3 $HOME/.cursor/hooks/pre-tool/orchestration-detect-linear-magic.py", + "matcher": "Bash", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/python-type-check.sh", + "matcher": "Edit|Write", + "timeout": 60 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/python-pytest-validation.sh", + "matcher": "Edit|Write", + "timeout": 120 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/python-type-check-progressive.sh", + "matcher": "Edit|Write", + "timeout": 120 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/python-ruff-lint.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/python-pytest-execution.sh", + "matcher": "Edit|Write", + "timeout": 480 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/python-bandit-scan.sh", + "matcher": "Edit|Write", + "timeout": 60 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/typescript-tsc-check.sh", + "matcher": "Edit|Write", + "timeout": 60 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/typescript-bundle-analysis.sh", + "matcher": "Edit|Write", + "timeout": 60 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/rails-migration-safety.sh", + "matcher": "Edit|Write", + "timeout": 60 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/rails-migration-safety-deep.sh", + "matcher": "Edit|Write", + "timeout": 300 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/rails-test-execution.sh", + "matcher": "Edit|Write", + "timeout": 480 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/rails-brakeman-scan.sh", + "matcher": "Edit|Write", + "timeout": 120 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/infra-validate-k8s.sh", + "matcher": "Edit|Write", + "timeout": 60 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/infra-dockerfile-lint.sh", + "matcher": "Edit|Write", + "timeout": 30 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/infra-k8s-dry-run-server.sh", + "matcher": "Edit|Write", + "timeout": 120 + }, + { + "command": "bash $HOME/.cursor/hooks/pre-tool/infra-terraform-plan-cost.sh", + "matcher": "Edit|Write", + "timeout": 300 + }, + { + "command": "loaf check --hook artifact-body-write", + "failClosed": true, + "loaf-managed": true, + "matcher": "Edit|Write|Bash", + "timeout": 30 + }, + { + "command": "loaf check --hook check-secrets", + "failClosed": true, + "loaf-managed": true, + "matcher": "Edit|Write|Bash", + "timeout": 30 + }, + { + "command": "loaf check --hook security-audit", + "failClosed": true, + "loaf-managed": true, + "matcher": "Bash", + "timeout": 600 + }, + { + "command": "loaf check --hook render-drift", + "failClosed": true, + "if": "Bash(git push:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 30 + }, + { + "command": "loaf check --hook ephemeral-provenance", + "failClosed": true, + "if": "Bash(git push:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 30 + }, + { + "command": "loaf check --hook artifact-names", + "failClosed": true, + "if": "Bash(git commit:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 30 + }, + { + "command": "loaf check --hook github-account", + "failClosed": true, + "loaf-managed": true, + "matcher": "Bash", + "timeout": 10 + }, + { + "command": "loaf check --hook validate-push --advisory", + "if": "Bash(git push:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 60 + }, + { + "command": "loaf check --hook workflow-pre-pr --advisory", + "if": "Bash(gh pr create:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 5 + }, + { + "command": "cat \"$HOME/.cursor/hooks/instructions/pre-merge.md\"", + "if": "Bash(gh pr merge:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 5 + }, + { + "command": "cat \"$HOME/.cursor/hooks/instructions/pre-push.md\"", + "if": "Bash(git push:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 5 + }, + { + "command": "loaf check --hook validate-commit", + "failClosed": true, + "if": "Bash(git commit:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 30 + }, + { + "command": "loaf journal log --detect-linear", + "if": "Bash(git commit:*)", + "loaf-managed": true, + "matcher": "Bash", + "timeout": 30 + } + ], + "sessionStart": [ + { + "command": "bash '/Users/canary/.cursor/herdr-agent-state.sh' session" + }, + { + "command": "loaf journal context --from-hook --cursor-hook", + "loaf-managed": true, + "timeout": 60 + } + ] + } +} diff --git a/internal/cli/testdata/hooks/cursor-target-manifest-0.2.20.json b/internal/cli/testdata/hooks/cursor-target-manifest-0.2.20.json new file mode 100644 index 000000000..48ac40313 --- /dev/null +++ b/internal/cli/testdata/hooks/cursor-target-manifest-0.2.20.json @@ -0,0 +1,72 @@ +{ + "version": 1, + "target": "cursor", + "package_version": "0.2.20", + "capability_contract_version": 3, + "adapters": [ + "cursor-session-start-v1" + ], + "artifacts": [ + { + "id": "hook-file:hooks/instructions/post-merge.md", + "kind": "hook-file", + "source_path": "hooks/instructions/post-merge.md", + "destination": "hooks/instructions/post-merge.md", + "sha256": "f728a0a9a004ea1ea76b70ca3292996c798baa2838633806e2fb4250118203b6", + "mode": 420 + }, + { + "id": "hook-file:hooks/instructions/pre-merge.md", + "kind": "hook-file", + "source_path": "hooks/instructions/pre-merge.md", + "destination": "hooks/instructions/pre-merge.md", + "sha256": "28c6a7006b508e067562a41ed9c2aa9c13eccbc027328176e706dcab1a74da41", + "mode": 420 + }, + { + "id": "hook-file:hooks/instructions/pre-pr-checklist.md", + "kind": "hook-file", + "source_path": "hooks/instructions/pre-pr-checklist.md", + "destination": "hooks/instructions/pre-pr-checklist.md", + "sha256": "234b5e37846adf226ae501ac65a62139fed61c42dc62bbf9c78fc8b885debbad", + "mode": 420 + }, + { + "id": "hook-file:hooks/instructions/pre-pr-format.md", + "kind": "hook-file", + "source_path": "hooks/instructions/pre-pr-format.md", + "destination": "hooks/instructions/pre-pr-format.md", + "sha256": "da8d29ce41a1218467f68d4186f906c47075d88b3339a9fb19a8b50f8f40de82", + "mode": 420 + }, + { + "id": "hook-file:hooks/instructions/pre-push.md", + "kind": "hook-file", + "source_path": "hooks/instructions/pre-push.md", + "destination": "hooks/instructions/pre-push.md", + "sha256": "cdc96e5fa9a4d9ae37a8b29af5118a0fecdb29b79cd98db61fe37692550602ce", + "mode": 420 + }, + { + "id": "hook-file:hooks/post-tool/kb-staleness-nudge.sh", + "kind": "hook-file", + "source_path": "hooks/post-tool/kb-staleness-nudge.sh", + "destination": "hooks/post-tool/kb-staleness-nudge.sh", + "sha256": "2eb14c3143b78d62cdcef56a12ea714472898ec49e54d4e53e217f0e5668cb5e", + "mode": 493 + }, + { + "id": "hook-projection:hooks.json", + "kind": "hook-projection", + "source_path": "hooks.json", + "destination": "hooks.json", + "sha256": "ee62210b456405c5760a2de17676a42c09e130a27c576a1967180053b9c1047b" + }, + { + "id": "managed-instructions", + "kind": "instruction", + "destination": "project-instructions", + "sha256": "ac6debb93fcd1b2d7806681c446f3b7d9691a43a872831a969c82a7470b0b30d" + } + ] +} diff --git a/internal/cli/upgrade.go b/internal/cli/upgrade.go index 462089649..779b88302 100644 --- a/internal/cli/upgrade.go +++ b/internal/cli/upgrade.go @@ -231,6 +231,8 @@ func (r Runner) upgradeInstalledTargets(out io.Writer, options upgradeOptions, t var failed []string defaults := defaultInstallConfigDirs() toolByKey := installToolsByKey(tools) + hookState, releaseHookState := r.hookStateForApply(projectRoot) + defer releaseHookState() var upgradeOptions []targetInstallOptions for _, target := range targets { distDir := filepath.Join(distRoot, target) @@ -253,6 +255,7 @@ func (r Runner) upgradeInstalledTargets(out io.Writer, options upgradeOptions, t CodexHome: os.Getenv("CODEX_HOME"), ProjectRoot: projectRoot, SkipSkillsSync: true, + HookState: hookState, }) } skillsErr := syncCanonicalManagedSkills(upgradeOptions) @@ -275,6 +278,8 @@ func (r Runner) upgradeInstalledTargets(out io.Writer, options upgradeOptions, t fmt.Fprintf(out, " %s skills - %v\n", ansiRed("✗"), skillConflicts) skillsErrReported = true } + var hookActions []hookAction + opts.HookActions = func(actions []hookAction) { hookActions = append(hookActions, actions...) } err := installTargetDistribution(opts) if err != nil { fmt.Fprintf(out, " %s %s - %v\n", ansiRed("✗"), installDisplayName(opts.Target), err) @@ -282,6 +287,7 @@ func (r Runner) upgradeInstalledTargets(out io.Writer, options upgradeOptions, t continue } fmt.Fprintf(out, " %s %s refreshed at %s (v%s)\n", ansiGreen("✓"), installDisplayName(opts.Target), ansiGray(opts.ConfigDir), version) + writeHookActionLines(out, hookActions) } fmt.Fprintln(out) if skillConflicts != nil { diff --git a/internal/cli/upgrade_maintenance_test.go b/internal/cli/upgrade_maintenance_test.go index 873dd7149..3ca9c7a4e 100644 --- a/internal/cli/upgrade_maintenance_test.go +++ b/internal/cli/upgrade_maintenance_test.go @@ -18,6 +18,7 @@ import ( func TestRunnerUpgradeOnlyRefreshesDetectedLoafTargets(t *testing.T) { root, home := setupInstallCommandFixture(t) writeInstallFile(t, filepath.Join(root, "dist", "cursor", "skills", "foundations", "SKILL.md"), "# Foundations\n") + installTestHookDistribution(t, root, "cursor") mkdirAll(t, filepath.Join(home, ".config", "opencode")) writeInstallFile(t, filepath.Join(home, ".cursor", loafInstallMarkerFile), "old\n") @@ -38,6 +39,7 @@ func TestRunnerUpgradeOnlyRefreshesDetectedLoafTargets(t *testing.T) { func TestRunnerUpgradeMigratesLegacyProjectInstructionLayout(t *testing.T) { root, home := setupInstallCommandFixture(t) writeInstallFile(t, filepath.Join(root, "dist", "cursor", "skills", "foundations", "SKILL.md"), "# Foundations\n") + installTestHookDistribution(t, root, "cursor") writeInstallFile(t, filepath.Join(home, ".cursor", loafInstallMarkerFile), "old\n") writeInstallFile(t, filepath.Join(root, "bin", "claude"), "#!/bin/sh\nexit 0\n") if err := os.Chmod(filepath.Join(root, "bin", "claude"), 0o755); err != nil { @@ -317,6 +319,7 @@ func TestRunnerUpgradeCleansRetiredAgentFromManifest(t *testing.T) { // something to sync; without it the run ends in a target failure this test // is not about. writeInstallFile(t, filepath.Join(root, "dist", "cursor", "skills", "foundations", "SKILL.md"), "# Foundations\n") + installTestHookDistribution(t, root, "cursor") writeInstallFile(t, filepath.Join(home, ".cursor", loafInstallMarkerFile), "old\n") writeInstallFile(t, retiredAgent, "# Old Agent\n") writeInstallDeprecationManifest(t, root, `{ diff --git a/internal/cli/upgrade_test.go b/internal/cli/upgrade_test.go index 568eaf420..f4d74ef61 100644 --- a/internal/cli/upgrade_test.go +++ b/internal/cli/upgrade_test.go @@ -442,6 +442,7 @@ func setupUpgradeFixture(t *testing.T) (string, string) { func installUpgradeFixtureTarget(t *testing.T, root string, home string, target string) { t.Helper() writeInstallFile(t, filepath.Join(root, "dist", target, "skills", "foundations", "SKILL.md"), "# Foundations\n") + installTestHookDistribution(t, root, target) runInstallFixture(t, root, "install", "--to", target, "--yes") writeInstallFile(t, filepath.Join(root, "dist", target, "skills", "foundations", "SKILL.md"), "# Foundations v2\n") // install scaffolds project files; the upgrade matrix judges what upgrade diff --git a/internal/state/exploration_test.go b/internal/state/exploration_test.go index bd32041fb..2606058ee 100644 --- a/internal/state/exploration_test.go +++ b/internal/state/exploration_test.go @@ -407,7 +407,10 @@ func TestCheckpointOperationKeyRetryReturnsFirstWrite(t *testing.T) { 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] + migration, ok := schemaMigrationByName("intents_and_explorations") + if !ok { + t.Fatal("intents_and_explorations migration not found") + } 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"} { diff --git a/internal/state/hook_enablement.go b/internal/state/hook_enablement.go new file mode 100644 index 000000000..3828a0fa0 --- /dev/null +++ b/internal/state/hook_enablement.go @@ -0,0 +1,498 @@ +package state + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "fmt" + "strings" + "time" +) + +// Enablement value vocabulary stored in hook_enablements.enablement. +const ( + HookEnablementEnabled = "enabled" + HookEnablementDisabled = "disabled" +) + +// HookEnablementRef identifies one catalog hook for a target. +type HookEnablementRef struct { + Target string + Event string + HookID string +} + +// HookEnablement is one user-scoped enablement record. +// Absence of a record means the hook is enabled; this type is only returned +// when a row exists (or after a set that materializes one). +type HookEnablement struct { + ID string `json:"id"` + Target string `json:"target"` + Event string `json:"event"` + HookID string `json:"hook_id"` + Enablement string `json:"enablement"` + AbsorbedAt *string `json:"absorbed_at,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// HookAbsorptionMarker is the durable per-target absorption gate. +type HookAbsorptionMarker struct { + ID string `json:"id"` + Target string `json:"target"` + AbsorbedFromVersion string `json:"absorbed_from_version"` + AbsorbedAt string `json:"absorbed_at"` + CreatedAt string `json:"created_at"` +} + +// HookTrustedPath is one recorded install path for a target's Loaf executable. +type HookTrustedPath struct { + ID string `json:"id"` + Target string `json:"target"` + Path string `json:"path"` + IsCurrent bool `json:"is_current"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// EffectiveHookEnabled reports whether a hook should project as enabled. +// Absence of a record means enabled. +func (s *Store) EffectiveHookEnabled(ctx context.Context, target, event, hookID string) (bool, error) { + target, event, hookID, err := normalizeHookEnablementKey(target, event, hookID) + if err != nil { + return false, err + } + var enablement string + err = s.db.QueryRowContext(ctx, ` +SELECT enablement +FROM hook_enablements +WHERE target = ? AND event = ? AND hook_id = ? +`, target, event, hookID).Scan(&enablement) + if err == sql.ErrNoRows { + return true, nil + } + if err != nil { + return false, fmt.Errorf("read hook enablement %s/%s/%s: %w", target, event, hookID, err) + } + return enablement == HookEnablementEnabled, nil +} + +// GetHookEnablement returns the stored enablement row when present. +func (s *Store) GetHookEnablement(ctx context.Context, target, event, hookID string) (HookEnablement, bool, error) { + target, event, hookID, err := normalizeHookEnablementKey(target, event, hookID) + if err != nil { + return HookEnablement{}, false, err + } + row, err := scanHookEnablement(s.db.QueryRowContext(ctx, ` +SELECT id, target, event, hook_id, enablement, absorbed_at, created_at, updated_at +FROM hook_enablements +WHERE target = ? AND event = ? AND hook_id = ? +`, target, event, hookID)) + if err == sql.ErrNoRows { + return HookEnablement{}, false, nil + } + if err != nil { + return HookEnablement{}, false, fmt.Errorf("get hook enablement %s/%s/%s: %w", target, event, hookID, err) + } + return row, true, nil +} + +// SetHookEnablement upserts enablement through the natural key. The opaque ID +// is minted only on insert; absorbed_at is never updated by toggles. +func (s *Store) SetHookEnablement(ctx context.Context, target, event, hookID string, enabled bool) (HookEnablement, error) { + target, event, hookID, err := normalizeHookEnablementKey(target, event, hookID) + if err != nil { + return HookEnablement{}, err + } + enablement := HookEnablementDisabled + if enabled { + enablement = HookEnablementEnabled + } + now := time.Now().UTC().Format(time.RFC3339) + id, err := newOpaqueStateID("hen") + if err != nil { + return HookEnablement{}, err + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return HookEnablement{}, fmt.Errorf("begin hook enablement set: %w", err) + } + defer tx.Rollback() + + if _, err := tx.ExecContext(ctx, ` +INSERT INTO hook_enablements (id, target, event, hook_id, enablement, absorbed_at, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, NULL, ?, ?) +ON CONFLICT(target, event, hook_id) DO UPDATE SET + enablement = excluded.enablement, + updated_at = excluded.updated_at +`, id, target, event, hookID, enablement, now, now); err != nil { + return HookEnablement{}, fmt.Errorf("set hook enablement %s/%s/%s: %w", target, event, hookID, err) + } + + row, err := scanHookEnablement(tx.QueryRowContext(ctx, ` +SELECT id, target, event, hook_id, enablement, absorbed_at, created_at, updated_at +FROM hook_enablements +WHERE target = ? AND event = ? AND hook_id = ? +`, target, event, hookID)) + if err != nil { + return HookEnablement{}, fmt.Errorf("reload hook enablement %s/%s/%s: %w", target, event, hookID, err) + } + if err := tx.Commit(); err != nil { + return HookEnablement{}, fmt.Errorf("commit hook enablement set: %w", err) + } + return row, nil +} + +// ListHookEnablements returns all enablement records for a target, ordered by +// event then hook_id. Tombstone rows for retired hook IDs are included. +func (s *Store) ListHookEnablements(ctx context.Context, target string) ([]HookEnablement, error) { + target, err := normalizeNonEmpty("target", target) + if err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, ` +SELECT id, target, event, hook_id, enablement, absorbed_at, created_at, updated_at +FROM hook_enablements +WHERE target = ? +ORDER BY event, hook_id, id +`, target) + if err != nil { + return nil, fmt.Errorf("list hook enablements for %s: %w", target, err) + } + defer rows.Close() + + out := []HookEnablement{} + for rows.Next() { + row, err := scanHookEnablement(rows) + if err != nil { + return nil, fmt.Errorf("scan hook enablement: %w", err) + } + out = append(out, row) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate hook enablements for %s: %w", target, err) + } + return out, nil +} + +// GetHookAbsorptionMarker returns the absorption marker for a target when set. +func (s *Store) GetHookAbsorptionMarker(ctx context.Context, target string) (HookAbsorptionMarker, bool, error) { + target, err := normalizeNonEmpty("target", target) + if err != nil { + return HookAbsorptionMarker{}, false, err + } + marker, err := scanHookAbsorptionMarker(s.db.QueryRowContext(ctx, ` +SELECT id, target, absorbed_from_version, absorbed_at, created_at +FROM hook_absorption_markers +WHERE target = ? +`, target)) + if err == sql.ErrNoRows { + return HookAbsorptionMarker{}, false, nil + } + if err != nil { + return HookAbsorptionMarker{}, false, fmt.Errorf("get hook absorption marker for %s: %w", target, err) + } + return marker, true, nil +} + +// AbsorbAndMarkHooks writes disabled enablement records for the given refs and +// the per-target absorption marker in one transaction. All-or-nothing: failure +// leaves neither the records nor the marker committed. +func (s *Store) AbsorbAndMarkHooks(ctx context.Context, target, absorbedFromVersion string, disabled []HookEnablementRef) (HookAbsorptionMarker, error) { + return s.absorbAndMarkHooksWithHooks(ctx, target, absorbedFromVersion, disabled, nil) +} + +// absorbAndMarkWriteHooks injects failures between transactional stages in tests. +type absorbAndMarkWriteHooks struct { + afterEnablements func(*sql.Tx) error + afterMarker func(*sql.Tx) error + beforeCommit func(*sql.Tx) error +} + +func (s *Store) absorbAndMarkHooksWithHooks(ctx context.Context, target, absorbedFromVersion string, disabled []HookEnablementRef, hooks *absorbAndMarkWriteHooks) (HookAbsorptionMarker, error) { + target, err := normalizeNonEmpty("target", target) + if err != nil { + return HookAbsorptionMarker{}, err + } + absorbedFromVersion, err = normalizeNonEmpty("absorbed_from_version", absorbedFromVersion) + if err != nil { + return HookAbsorptionMarker{}, err + } + normalized := make([]HookEnablementRef, 0, len(disabled)) + for i, ref := range disabled { + event, err := normalizeNonEmpty("event", ref.Event) + if err != nil { + return HookAbsorptionMarker{}, fmt.Errorf("disabled[%d]: %w", i, err) + } + hookID, err := normalizeNonEmpty("hook_id", ref.HookID) + if err != nil { + return HookAbsorptionMarker{}, fmt.Errorf("disabled[%d]: %w", i, err) + } + refTarget := strings.TrimSpace(ref.Target) + if refTarget == "" { + refTarget = target + } else if refTarget != target { + return HookAbsorptionMarker{}, fmt.Errorf("disabled[%d]: target %q does not match absorption target %q", i, refTarget, target) + } + normalized = append(normalized, HookEnablementRef{Target: target, Event: event, HookID: hookID}) + } + + now := time.Now().UTC().Format(time.RFC3339) + markerID, err := newOpaqueStateID("ham") + if err != nil { + return HookAbsorptionMarker{}, err + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return HookAbsorptionMarker{}, fmt.Errorf("begin absorb-and-mark: %w", err) + } + defer tx.Rollback() + + for _, ref := range normalized { + id, err := newOpaqueStateID("hen") + if err != nil { + return HookAbsorptionMarker{}, err + } + // Mint ID only on insert. On conflict keep the existing ID and leave + // absorbed_at immutable once set (COALESCE prefers the existing value). + if _, err := tx.ExecContext(ctx, ` +INSERT INTO hook_enablements (id, target, event, hook_id, enablement, absorbed_at, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(target, event, hook_id) DO UPDATE SET + enablement = excluded.enablement, + absorbed_at = COALESCE(hook_enablements.absorbed_at, excluded.absorbed_at), + updated_at = excluded.updated_at +`, id, ref.Target, ref.Event, ref.HookID, HookEnablementDisabled, now, now, now); err != nil { + return HookAbsorptionMarker{}, fmt.Errorf("absorb disable %s/%s/%s: %w", ref.Target, ref.Event, ref.HookID, err) + } + } + if err := runAbsorbAndMarkHook(hooks, "after enablements", func(h *absorbAndMarkWriteHooks) func(*sql.Tx) error { + return h.afterEnablements + }, tx); err != nil { + return HookAbsorptionMarker{}, err + } + + if _, err := tx.ExecContext(ctx, ` +INSERT INTO hook_absorption_markers (id, target, absorbed_from_version, absorbed_at, created_at) +VALUES (?, ?, ?, ?, ?) +`, markerID, target, absorbedFromVersion, now, now); err != nil { + return HookAbsorptionMarker{}, fmt.Errorf("write hook absorption marker for %s: %w", target, err) + } + if err := runAbsorbAndMarkHook(hooks, "after marker", func(h *absorbAndMarkWriteHooks) func(*sql.Tx) error { + return h.afterMarker + }, tx); err != nil { + return HookAbsorptionMarker{}, err + } + + marker, err := scanHookAbsorptionMarker(tx.QueryRowContext(ctx, ` +SELECT id, target, absorbed_from_version, absorbed_at, created_at +FROM hook_absorption_markers +WHERE target = ? +`, target)) + if err != nil { + return HookAbsorptionMarker{}, fmt.Errorf("reload hook absorption marker for %s: %w", target, err) + } + if err := runAbsorbAndMarkHook(hooks, "before commit", func(h *absorbAndMarkWriteHooks) func(*sql.Tx) error { + return h.beforeCommit + }, tx); err != nil { + return HookAbsorptionMarker{}, err + } + if err := tx.Commit(); err != nil { + return HookAbsorptionMarker{}, fmt.Errorf("commit absorb-and-mark: %w", err) + } + return marker, nil +} + +func runAbsorbAndMarkHook(hooks *absorbAndMarkWriteHooks, stage string, pick func(*absorbAndMarkWriteHooks) 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 fmt.Errorf("absorb-and-mark failed at %s: %w", stage, err) + } + return nil +} + +// RecordHookTrustedPath records path as the current trusted executable for +// target. The previous current path, if any, becomes historical and is retained. +func (s *Store) RecordHookTrustedPath(ctx context.Context, target, path string) (HookTrustedPath, error) { + target, err := normalizeNonEmpty("target", target) + if err != nil { + return HookTrustedPath{}, err + } + path, err = normalizeNonEmpty("path", path) + if err != nil { + return HookTrustedPath{}, err + } + now := time.Now().UTC().Format(time.RFC3339) + id, err := newOpaqueStateID("htp") + if err != nil { + return HookTrustedPath{}, err + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return HookTrustedPath{}, fmt.Errorf("begin record hook trusted path: %w", err) + } + defer tx.Rollback() + + if _, err := tx.ExecContext(ctx, ` +UPDATE hook_trusted_paths +SET is_current = 0, updated_at = ? +WHERE target = ? AND is_current = 1 AND path != ? +`, now, target, path); err != nil { + return HookTrustedPath{}, fmt.Errorf("demote previous trusted path for %s: %w", target, err) + } + if _, err := tx.ExecContext(ctx, ` +INSERT INTO hook_trusted_paths (id, target, path, is_current, created_at, updated_at) +VALUES (?, ?, ?, 1, ?, ?) +ON CONFLICT(target, path) DO UPDATE SET + is_current = 1, + updated_at = excluded.updated_at +`, id, target, path, now, now); err != nil { + return HookTrustedPath{}, fmt.Errorf("record trusted path for %s: %w", target, err) + } + + row, err := scanHookTrustedPath(tx.QueryRowContext(ctx, ` +SELECT id, target, path, is_current, created_at, updated_at +FROM hook_trusted_paths +WHERE target = ? AND path = ? +`, target, path)) + if err != nil { + return HookTrustedPath{}, fmt.Errorf("reload trusted path for %s: %w", target, err) + } + if err := tx.Commit(); err != nil { + return HookTrustedPath{}, fmt.Errorf("commit record hook trusted path: %w", err) + } + return row, nil +} + +// ListHookTrustedPaths returns every recorded path for a target, current first. +func (s *Store) ListHookTrustedPaths(ctx context.Context, target string) ([]HookTrustedPath, error) { + target, err := normalizeNonEmpty("target", target) + if err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, ` +SELECT id, target, path, is_current, created_at, updated_at +FROM hook_trusted_paths +WHERE target = ? +ORDER BY is_current DESC, path, id +`, target) + if err != nil { + return nil, fmt.Errorf("list hook trusted paths for %s: %w", target, err) + } + defer rows.Close() + + out := []HookTrustedPath{} + for rows.Next() { + row, err := scanHookTrustedPath(rows) + if err != nil { + return nil, fmt.Errorf("scan hook trusted path: %w", err) + } + out = append(out, row) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate hook trusted paths for %s: %w", target, err) + } + return out, nil +} + +// CurrentHookTrustedPath returns the current install path for a target, if any. +func (s *Store) CurrentHookTrustedPath(ctx context.Context, target string) (HookTrustedPath, bool, error) { + target, err := normalizeNonEmpty("target", target) + if err != nil { + return HookTrustedPath{}, false, err + } + row, err := scanHookTrustedPath(s.db.QueryRowContext(ctx, ` +SELECT id, target, path, is_current, created_at, updated_at +FROM hook_trusted_paths +WHERE target = ? AND is_current = 1 +`, target)) + if err == sql.ErrNoRows { + return HookTrustedPath{}, false, nil + } + if err != nil { + return HookTrustedPath{}, false, fmt.Errorf("current hook trusted path for %s: %w", target, err) + } + return row, true, nil +} + +type hookEnablementScanner interface { + Scan(dest ...any) error +} + +func scanHookEnablement(row hookEnablementScanner) (HookEnablement, error) { + var ( + out HookEnablement + absorbedAt sql.NullString + ) + if err := row.Scan(&out.ID, &out.Target, &out.Event, &out.HookID, &out.Enablement, &absorbedAt, &out.CreatedAt, &out.UpdatedAt); err != nil { + return HookEnablement{}, err + } + if absorbedAt.Valid { + value := absorbedAt.String + out.AbsorbedAt = &value + } + return out, nil +} + +func scanHookAbsorptionMarker(row hookEnablementScanner) (HookAbsorptionMarker, error) { + var out HookAbsorptionMarker + if err := row.Scan(&out.ID, &out.Target, &out.AbsorbedFromVersion, &out.AbsorbedAt, &out.CreatedAt); err != nil { + return HookAbsorptionMarker{}, err + } + return out, nil +} + +func scanHookTrustedPath(row hookEnablementScanner) (HookTrustedPath, error) { + var ( + out HookTrustedPath + isCurrent int + ) + if err := row.Scan(&out.ID, &out.Target, &out.Path, &isCurrent, &out.CreatedAt, &out.UpdatedAt); err != nil { + return HookTrustedPath{}, err + } + out.IsCurrent = isCurrent == 1 + return out, nil +} + +func normalizeHookEnablementKey(target, event, hookID string) (string, string, string, error) { + target, err := normalizeNonEmpty("target", target) + if err != nil { + return "", "", "", err + } + event, err = normalizeNonEmpty("event", event) + if err != nil { + return "", "", "", err + } + hookID, err = normalizeNonEmpty("hook_id", hookID) + if err != nil { + return "", "", "", err + } + return target, event, hookID, nil +} + +func normalizeNonEmpty(field, value string) (string, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "", fmt.Errorf("%s must be nonempty", field) + } + return trimmed, nil +} + +// newOpaqueStateID mints a once-random opaque primary key with a short prefix. +// IDs are never derived from natural keys or other mutable inputs. +func newOpaqueStateID(prefix string) (string, error) { + var raw [16]byte + if _, err := rand.Read(raw[:]); err != nil { + return "", fmt.Errorf("generate %s id: %w", prefix, err) + } + return prefix + "_" + hex.EncodeToString(raw[:]), nil +} diff --git a/internal/state/hook_enablement_test.go b/internal/state/hook_enablement_test.go new file mode 100644 index 000000000..50fc9b5ab --- /dev/null +++ b/internal/state/hook_enablement_test.go @@ -0,0 +1,462 @@ +package state + +import ( + "context" + "database/sql" + "errors" + "strings" + "testing" +) + +func hookEnablementFixture(t *testing.T) *Store { + 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 +} + +func TestEffectiveHookEnabledDefaultsToEnabled(t *testing.T) { + store := hookEnablementFixture(t) + ctx := context.Background() + + enabled, err := store.EffectiveHookEnabled(ctx, "codex", "SessionStart", "session-start-loaf") + if err != nil { + t.Fatalf("EffectiveHookEnabled() error = %v", err) + } + if !enabled { + t.Fatal("absence of record must mean enabled") + } + if _, ok, err := store.GetHookEnablement(ctx, "codex", "SessionStart", "session-start-loaf"); err != nil || ok { + t.Fatalf("GetHookEnablement() = ok=%v err=%v, want absent", ok, err) + } +} + +func TestSetHookEnablementUpsertIsIdempotentAndPreservesID(t *testing.T) { + store := hookEnablementFixture(t) + ctx := context.Background() + + first, err := store.SetHookEnablement(ctx, "cursor", "PreToolUse", "check-secrets", false) + if err != nil { + t.Fatalf("first SetHookEnablement() error = %v", err) + } + if first.Enablement != HookEnablementDisabled || first.ID == "" || first.AbsorbedAt != nil { + t.Fatalf("first = %#v, want disabled with opaque ID and nil absorbed_at", first) + } + + second, err := store.SetHookEnablement(ctx, "cursor", "PreToolUse", "check-secrets", false) + if err != nil { + t.Fatalf("second SetHookEnablement() error = %v", err) + } + if second.ID != first.ID { + t.Fatalf("upsert re-minted ID: first=%q second=%q", first.ID, second.ID) + } + if second.CreatedAt != first.CreatedAt { + t.Fatalf("upsert changed created_at: first=%q second=%q", first.CreatedAt, second.CreatedAt) + } + + enabled, err := store.SetHookEnablement(ctx, "cursor", "PreToolUse", "check-secrets", true) + if err != nil { + t.Fatalf("enable SetHookEnablement() error = %v", err) + } + if enabled.ID != first.ID || enabled.Enablement != HookEnablementEnabled { + t.Fatalf("enable = %#v, want same ID and enabled", enabled) + } + + effective, err := store.EffectiveHookEnabled(ctx, "cursor", "PreToolUse", "check-secrets") + if err != nil || !effective { + t.Fatalf("EffectiveHookEnabled() = %v, %v; want true", effective, err) + } +} + +func TestHookEnablementNaturalKeyUniquenessIsSchemaEnforced(t *testing.T) { + store := hookEnablementFixture(t) + ctx := context.Background() + + mustExecSchemaSQL(t, store, ` +INSERT INTO hook_enablements (id, target, event, hook_id, enablement, created_at, updated_at) +VALUES ('hen_a', 'codex', 'SessionStart', 'session-start-loaf', 'disabled', '2026-08-08T00:00:00Z', '2026-08-08T00:00:00Z') +`) + err := execSchemaSQL(t, store, ` +INSERT INTO hook_enablements (id, target, event, hook_id, enablement, created_at, updated_at) +VALUES ('hen_b', 'codex', 'SessionStart', 'session-start-loaf', 'enabled', '2026-08-08T00:00:01Z', '2026-08-08T00:00:01Z') +`) + if err == nil || !strings.Contains(err.Error(), "UNIQUE") { + t.Fatalf("duplicate natural-key insert error = %v, want UNIQUE violation", err) + } + + // Distinct hook IDs on the same target/event remain allowed. + mustExecSchemaSQL(t, store, ` +INSERT INTO hook_enablements (id, target, event, hook_id, enablement, created_at, updated_at) +VALUES ('hen_c', 'codex', 'SessionStart', 'other-hook', 'disabled', '2026-08-08T00:00:02Z', '2026-08-08T00:00:02Z') +`) + + // Prove the accessor never bypasses the constraint by re-minting on conflict. + first, err := store.SetHookEnablement(ctx, "cursor", "Stop", "kb-staleness-nudge", false) + if err != nil { + t.Fatalf("SetHookEnablement() error = %v", err) + } + again, err := store.SetHookEnablement(ctx, "cursor", "Stop", "kb-staleness-nudge", true) + if err != nil { + t.Fatalf("second SetHookEnablement() error = %v", err) + } + if again.ID != first.ID { + t.Fatalf("conflict path re-minted ID: %q -> %q", first.ID, again.ID) + } + var count int + if err := store.db.QueryRowContext(ctx, ` +SELECT COUNT(*) FROM hook_enablements WHERE target = 'cursor' AND event = 'Stop' AND hook_id = 'kb-staleness-nudge' +`).Scan(&count); err != nil { + t.Fatalf("count error = %v", err) + } + if count != 1 { + t.Fatalf("row count = %d, want exactly 1", count) + } +} + +func TestAbsorbedAtIsImmutableAcrossToggles(t *testing.T) { + store := hookEnablementFixture(t) + ctx := context.Background() + + marker, err := store.AbsorbAndMarkHooks(ctx, "codex", "0.2.20", []HookEnablementRef{ + {Event: "SessionStart", HookID: "session-start-loaf"}, + }) + if err != nil { + t.Fatalf("AbsorbAndMarkHooks() error = %v", err) + } + if marker.Target != "codex" || marker.AbsorbedFromVersion != "0.2.20" || marker.AbsorbedAt == "" { + t.Fatalf("marker = %#v", marker) + } + + row, ok, err := store.GetHookEnablement(ctx, "codex", "SessionStart", "session-start-loaf") + if err != nil || !ok { + t.Fatalf("GetHookEnablement() = ok=%v err=%v", ok, err) + } + if row.AbsorbedAt == nil || *row.AbsorbedAt == "" || row.Enablement != HookEnablementDisabled { + t.Fatalf("absorbed row = %#v", row) + } + originalAbsorbedAt := *row.AbsorbedAt + originalID := row.ID + + enabled, err := store.SetHookEnablement(ctx, "codex", "SessionStart", "session-start-loaf", true) + if err != nil { + t.Fatalf("enable toggle error = %v", err) + } + if enabled.ID != originalID || enabled.AbsorbedAt == nil || *enabled.AbsorbedAt != originalAbsorbedAt { + t.Fatalf("enable toggle mutated identity/provenance: %#v", enabled) + } + + disabled, err := store.SetHookEnablement(ctx, "codex", "SessionStart", "session-start-loaf", false) + if err != nil { + t.Fatalf("disable toggle error = %v", err) + } + if disabled.ID != originalID || disabled.AbsorbedAt == nil || *disabled.AbsorbedAt != originalAbsorbedAt { + t.Fatalf("disable toggle mutated identity/provenance: %#v", disabled) + } + + // Explicit toggles never write absorbed_at on a fresh row. + fresh, err := store.SetHookEnablement(ctx, "cursor", "SessionStart", "session-start-loaf", false) + if err != nil { + t.Fatalf("fresh disable error = %v", err) + } + if fresh.AbsorbedAt != nil { + t.Fatalf("explicit toggle set absorbed_at = %v, want nil", *fresh.AbsorbedAt) + } +} + +func TestAbsorbAndMarkIsTransactionalAllOrNothing(t *testing.T) { + store := hookEnablementFixture(t) + ctx := context.Background() + disabled := []HookEnablementRef{ + {Event: "SessionStart", HookID: "session-start-loaf"}, + {Event: "PreToolUse", HookID: "check-secrets"}, + } + injected := errors.New("injected absorb failure") + + stages := []struct { + name string + hook func(*absorbAndMarkWriteHooks) + }{ + {"after enablements", func(h *absorbAndMarkWriteHooks) { + h.afterEnablements = func(*sql.Tx) error { return injected } + }}, + {"after marker", func(h *absorbAndMarkWriteHooks) { + h.afterMarker = func(*sql.Tx) error { return injected } + }}, + {"before commit", func(h *absorbAndMarkWriteHooks) { + h.beforeCommit = func(*sql.Tx) error { return injected } + }}, + } + + for _, stage := range stages { + t.Run(stage.name, func(t *testing.T) { + hooks := &absorbAndMarkWriteHooks{} + stage.hook(hooks) + _, err := store.absorbAndMarkHooksWithHooks(ctx, "codex", "0.2.20", disabled, hooks) + if err == nil { + t.Fatal("error = nil, want injected failure") + } + if !errors.Is(err, injected) && !strings.Contains(err.Error(), "injected absorb failure") { + t.Fatalf("error = %v, want injected failure", err) + } + + var enablementCount, markerCount int + if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM hook_enablements WHERE target = 'codex'`).Scan(&enablementCount); err != nil { + t.Fatalf("count enablements: %v", err) + } + if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM hook_absorption_markers WHERE target = 'codex'`).Scan(&markerCount); err != nil { + t.Fatalf("count markers: %v", err) + } + if enablementCount != 0 || markerCount != 0 { + t.Fatalf("partial commit under failure: enablements=%d markers=%d", enablementCount, markerCount) + } + if _, ok, err := store.GetHookAbsorptionMarker(ctx, "codex"); err != nil || ok { + t.Fatalf("marker present after failure: ok=%v err=%v", ok, err) + } + }) + } + + // Successful absorb-and-mark leaves both records and marker. + marker, err := store.AbsorbAndMarkHooks(ctx, "codex", "0.2.20", disabled) + if err != nil { + t.Fatalf("AbsorbAndMarkHooks() error = %v", err) + } + if marker.AbsorbedFromVersion != "0.2.20" { + t.Fatalf("marker version = %q", marker.AbsorbedFromVersion) + } + list, err := store.ListHookEnablements(ctx, "codex") + if err != nil { + t.Fatalf("ListHookEnablements() error = %v", err) + } + if len(list) != 2 { + t.Fatalf("list len = %d, want 2", len(list)) + } + for _, row := range list { + if row.Enablement != HookEnablementDisabled || row.AbsorbedAt == nil { + t.Fatalf("absorbed row = %#v", row) + } + } + + // Marker uniqueness: a second absorb for the same target must fail. + if _, err := store.AbsorbAndMarkHooks(ctx, "codex", "0.2.21", disabled[:1]); err == nil || !strings.Contains(err.Error(), "UNIQUE") { + t.Fatalf("second absorb error = %v, want UNIQUE violation on marker", err) + } +} + +func TestFailedRepeatAbsorbPreservesEnablementIdentity(t *testing.T) { + store := hookEnablementFixture(t) + ctx := context.Background() + refs := []HookEnablementRef{ + {Event: "SessionStart", HookID: "session-start-loaf"}, + {Event: "PreToolUse", HookID: "check-secrets"}, + } + + if _, err := store.AbsorbAndMarkHooks(ctx, "codex", "0.2.20", refs); err != nil { + t.Fatalf("AbsorbAndMarkHooks() error = %v", err) + } + before, err := store.ListHookEnablements(ctx, "codex") + if err != nil { + t.Fatalf("ListHookEnablements() error = %v", err) + } + if len(before) != 2 { + t.Fatalf("list len = %d, want 2", len(before)) + } + for _, row := range before { + if row.AbsorbedAt == nil || *row.AbsorbedAt == "" { + t.Fatalf("absorbed row missing absorbed_at: %#v", row) + } + } + + // Repeat absorption for the same target must fail on the marker's + // UNIQUE(target), regardless of version. + if _, err := store.AbsorbAndMarkHooks(ctx, "codex", "0.2.21", refs); err == nil || !strings.Contains(err.Error(), "UNIQUE") { + t.Fatalf("repeat absorb error = %v, want UNIQUE violation on marker", err) + } + + after, err := store.ListHookEnablements(ctx, "codex") + if err != nil { + t.Fatalf("ListHookEnablements() after failed absorb error = %v", err) + } + if len(after) != len(before) { + t.Fatalf("failed absorb changed row count: before=%d after=%d", len(before), len(after)) + } + for i := range before { + if after[i].ID != before[i].ID { + t.Fatalf("failed absorb re-minted ID: %q -> %q", before[i].ID, after[i].ID) + } + if after[i].AbsorbedAt == nil || *after[i].AbsorbedAt != *before[i].AbsorbedAt { + t.Fatalf("failed absorb mutated absorbed_at: %#v -> %#v", before[i], after[i]) + } + if after[i].UpdatedAt != before[i].UpdatedAt { + t.Fatalf("failed absorb mutated updated_at: %q -> %q", before[i].UpdatedAt, after[i].UpdatedAt) + } + } + + // A toggle upsert conflict after the failed absorb still preserves the + // original id and absorbed_at. + original, ok, err := store.GetHookEnablement(ctx, "codex", "SessionStart", "session-start-loaf") + if err != nil || !ok { + t.Fatalf("GetHookEnablement() = ok=%v err=%v", ok, err) + } + toggled, err := store.SetHookEnablement(ctx, "codex", "SessionStart", "session-start-loaf", true) + if err != nil { + t.Fatalf("toggle after failed absorb error = %v", err) + } + if toggled.ID != original.ID || toggled.AbsorbedAt == nil || *toggled.AbsorbedAt != *original.AbsorbedAt { + t.Fatalf("toggle after failed absorb mutated identity/provenance: %#v", toggled) + } +} + +func TestListHookEnablementsIncludesTombstones(t *testing.T) { + store := hookEnablementFixture(t) + ctx := context.Background() + + if _, err := store.SetHookEnablement(ctx, "cursor", "PreToolUse", "retired-hook", false); err != nil { + t.Fatalf("SetHookEnablement() error = %v", err) + } + if _, err := store.SetHookEnablement(ctx, "cursor", "Stop", "kb-staleness-nudge", true); err != nil { + t.Fatalf("SetHookEnablement() error = %v", err) + } + // Other target must not leak into the list. + if _, err := store.SetHookEnablement(ctx, "codex", "SessionStart", "session-start-loaf", false); err != nil { + t.Fatalf("SetHookEnablement() error = %v", err) + } + + list, err := store.ListHookEnablements(ctx, "cursor") + if err != nil { + t.Fatalf("ListHookEnablements() error = %v", err) + } + if len(list) != 2 { + t.Fatalf("list = %#v, want 2 cursor rows including retired tombstone", list) + } + if list[0].HookID != "retired-hook" || list[1].HookID != "kb-staleness-nudge" { + t.Fatalf("order/content = %#v", list) + } +} + +func TestHookTrustedPathsRecordCurrentAndPrevious(t *testing.T) { + store := hookEnablementFixture(t) + ctx := context.Background() + + first, err := store.RecordHookTrustedPath(ctx, "codex", "/usr/local/bin/loaf") + if err != nil { + t.Fatalf("first RecordHookTrustedPath() error = %v", err) + } + if !first.IsCurrent || first.Path != "/usr/local/bin/loaf" { + t.Fatalf("first = %#v", first) + } + + second, err := store.RecordHookTrustedPath(ctx, "codex", "/opt/loaf/bin/loaf") + if err != nil { + t.Fatalf("second RecordHookTrustedPath() error = %v", err) + } + if !second.IsCurrent || second.Path != "/opt/loaf/bin/loaf" { + t.Fatalf("second = %#v", second) + } + + current, ok, err := store.CurrentHookTrustedPath(ctx, "codex") + if err != nil || !ok || current.Path != "/opt/loaf/bin/loaf" { + t.Fatalf("CurrentHookTrustedPath() = %#v ok=%v err=%v", current, ok, err) + } + + paths, err := store.ListHookTrustedPaths(ctx, "codex") + if err != nil { + t.Fatalf("ListHookTrustedPaths() error = %v", err) + } + if len(paths) != 2 { + t.Fatalf("paths = %#v, want current + previous", paths) + } + if !paths[0].IsCurrent || paths[0].Path != "/opt/loaf/bin/loaf" { + t.Fatalf("current-first order broken: %#v", paths) + } + if paths[1].IsCurrent || paths[1].Path != "/usr/local/bin/loaf" { + t.Fatalf("previous path missing: %#v", paths) + } + + // Re-recording a previous path promotes it back to current without duplicating. + promoted, err := store.RecordHookTrustedPath(ctx, "codex", "/usr/local/bin/loaf") + if err != nil { + t.Fatalf("promote RecordHookTrustedPath() error = %v", err) + } + if promoted.ID != first.ID || !promoted.IsCurrent { + t.Fatalf("promote = %#v, want same ID current", promoted) + } + paths, err = store.ListHookTrustedPaths(ctx, "codex") + if err != nil { + t.Fatalf("ListHookTrustedPaths() after promote error = %v", err) + } + if len(paths) != 2 { + t.Fatalf("paths after promote = %#v, want still 2", paths) + } + var currentCount int + for _, path := range paths { + if path.IsCurrent { + currentCount++ + } + } + if currentCount != 1 { + t.Fatalf("current count = %d, want 1", currentCount) + } +} + +func TestHookTrustedPathsSingleCurrentPerTargetIsSchemaEnforced(t *testing.T) { + store := hookEnablementFixture(t) + + mustExecSchemaSQL(t, store, ` +INSERT INTO hook_trusted_paths (id, target, path, is_current, created_at, updated_at) +VALUES ('htp_a', 'codex', '/usr/local/bin/loaf', 1, '2026-08-08T00:00:00Z', '2026-08-08T00:00:00Z') +`) + err := execSchemaSQL(t, store, ` +INSERT INTO hook_trusted_paths (id, target, path, is_current, created_at, updated_at) +VALUES ('htp_b', 'codex', '/opt/loaf/bin/loaf', 1, '2026-08-08T00:00:01Z', '2026-08-08T00:00:01Z') +`) + // The driver reports the violated column set, not the index name. A + // UNIQUE failure on target alone can only come from the partial index + // idx_hook_trusted_paths_current; the table-level constraint would name + // hook_trusted_paths.path as well. + if err == nil || !strings.Contains(err.Error(), "UNIQUE constraint failed: hook_trusted_paths.target") || strings.Contains(err.Error(), "hook_trusted_paths.path") { + t.Fatalf("second current insert error = %v, want UNIQUE violation from idx_hook_trusted_paths_current", err) + } + + // A non-current row for the same target and a current row for another + // target remain allowed. + mustExecSchemaSQL(t, store, ` +INSERT INTO hook_trusted_paths (id, target, path, is_current, created_at, updated_at) +VALUES ('htp_c', 'codex', '/opt/loaf/bin/loaf', 0, '2026-08-08T00:00:02Z', '2026-08-08T00:00:02Z') +`) + mustExecSchemaSQL(t, store, ` +INSERT INTO hook_trusted_paths (id, target, path, is_current, created_at, updated_at) +VALUES ('htp_d', 'cursor', '/usr/local/bin/loaf', 1, '2026-08-08T00:00:03Z', '2026-08-08T00:00:03Z') +`) +} + +func TestHookEnablementOpaqueIDsAreNotDerivedFromNaturalKey(t *testing.T) { + store := hookEnablementFixture(t) + ctx := context.Background() + + a, err := store.SetHookEnablement(ctx, "codex", "SessionStart", "session-start-loaf", false) + if err != nil { + t.Fatalf("SetHookEnablement a error = %v", err) + } + b, err := store.SetHookEnablement(ctx, "cursor", "SessionStart", "session-start-loaf", false) + if err != nil { + t.Fatalf("SetHookEnablement b error = %v", err) + } + if a.ID == b.ID { + t.Fatalf("distinct natural keys produced identical IDs %q", a.ID) + } + if !strings.HasPrefix(a.ID, "hen_") || !strings.HasPrefix(b.ID, "hen_") { + t.Fatalf("IDs = %q, %q; want hen_ prefix", a.ID, b.ID) + } + if strings.Contains(a.ID, "codex") || strings.Contains(a.ID, "session-start-loaf") { + t.Fatalf("ID appears derived from natural key: %q", a.ID) + } +} diff --git a/internal/state/intents_explorations_schema_test.go b/internal/state/intents_explorations_schema_test.go index 0aa49ed0b..d3a4fdadb 100644 --- a/internal/state/intents_explorations_schema_test.go +++ b/internal/state/intents_explorations_schema_test.go @@ -332,9 +332,9 @@ VALUES ('membership:cross', ?, 'expl:a', 'conv:b', '2026-07-19T00:00:00Z') // 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) + migration, ok := schemaMigrationByName("intents_and_explorations") + if !ok { + t.Fatal("intents_and_explorations migration not found") } for _, table := range []string{ "intents", "intent_snapshots", "intent_deferrals", "intent_dispositions", diff --git a/internal/state/journal_first_migration_test.go b/internal/state/journal_first_migration_test.go index f2d379099..a7743a6eb 100644 --- a/internal/state/journal_first_migration_test.go +++ b/internal/state/journal_first_migration_test.go @@ -537,8 +537,8 @@ func TestBackupVerifiesMigratedJournalFirstDatabase(t *testing.T) { } func TestJournalFirstMigrationExcludedFromAutoApply(t *testing.T) { - if CurrentSchemaVersion() != 12 { - t.Fatalf("CurrentSchemaVersion() = %d, want 12 (migration 10 must not auto-apply on store open)", CurrentSchemaVersion()) + if CurrentSchemaVersion() != 13 { + t.Fatalf("CurrentSchemaVersion() = %d, want 13 (migration 10 must not auto-apply on store open)", CurrentSchemaVersion()) } for _, m := range SchemaMigrations() { if m.Version == journalFirstMigrationVersion { diff --git a/internal/state/migrations/0013_hook_enablement.sql b/internal/state/migrations/0013_hook_enablement.sql new file mode 100644 index 000000000..d590386b8 --- /dev/null +++ b/internal/state/migrations/0013_hook_enablement.sql @@ -0,0 +1,49 @@ +-- Hook enablement, absorption markers, and trusted executable paths. +-- +-- User-scoped and host-local: no project_id. Absence of an enablement row means +-- the hook is enabled. A disabled row suppresses projection. Records for retired +-- hook IDs are retained as inert tombstones (no deletion path). Absorption +-- provenance (absorbed_at) is immutable once set. The absorption marker is +-- independent of installed-manifest rows and gates run-once migration. + +CREATE TABLE IF NOT EXISTS hook_enablements ( + id TEXT PRIMARY KEY NOT NULL, + target TEXT NOT NULL CHECK (length(trim(target)) > 0), + event TEXT NOT NULL CHECK (length(trim(event)) > 0), + hook_id TEXT NOT NULL CHECK (length(trim(hook_id)) > 0), + enablement TEXT NOT NULL CHECK (enablement IN ('enabled', 'disabled')), + absorbed_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (target, event, hook_id) +); +CREATE INDEX IF NOT EXISTS idx_hook_enablements_target + ON hook_enablements (target); + +-- Per-target run-once absorption marker. Written in the same transaction as +-- the absorbed disabled records; never inferred from manifest rows. +CREATE TABLE IF NOT EXISTS hook_absorption_markers ( + id TEXT PRIMARY KEY NOT NULL, + target TEXT NOT NULL CHECK (length(trim(target)) > 0), + absorbed_from_version TEXT NOT NULL CHECK (length(trim(absorbed_from_version)) > 0), + absorbed_at TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE (target) +); + +-- Per-target trusted Loaf executable install paths. is_current marks the +-- currently resolved install path; prior paths remain as recognition history. +CREATE TABLE IF NOT EXISTS hook_trusted_paths ( + id TEXT PRIMARY KEY NOT NULL, + target TEXT NOT NULL CHECK (length(trim(target)) > 0), + path TEXT NOT NULL CHECK (length(trim(path)) > 0), + is_current INTEGER NOT NULL CHECK (is_current IN (0, 1)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (target, path) +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_hook_trusted_paths_current + ON hook_trusted_paths (target) + WHERE is_current = 1; +CREATE INDEX IF NOT EXISTS idx_hook_trusted_paths_target + ON hook_trusted_paths (target); diff --git a/internal/state/schema.go b/internal/state/schema.go index 259653f2c..0889489e3 100644 --- a/internal/state/schema.go +++ b/internal/state/schema.go @@ -43,6 +43,9 @@ var journalOriginsAndDeferralsSQL string //go:embed migrations/0012_intents_and_explorations.sql var intentsAndExplorationsSQL string +//go:embed migrations/0013_hook_enablement.sql +var hookEnablementSQL string + const schemaMigrationsDDL = `CREATE TABLE IF NOT EXISTS schema_migrations ( version INTEGER PRIMARY KEY NOT NULL, name TEXT NOT NULL, @@ -116,6 +119,11 @@ func SchemaMigrations() []SchemaMigration { Name: "intents_and_explorations", SQL: normalizeMigrationSQL(intentsAndExplorationsSQL), }, + { + Version: 13, + Name: "hook_enablement", + SQL: normalizeMigrationSQL(hookEnablementSQL), + }, } } diff --git a/internal/state/schema_test.go b/internal/state/schema_test.go index ba697cc54..9aea1f2a6 100644 --- a/internal/state/schema_test.go +++ b/internal/state/schema_test.go @@ -59,16 +59,29 @@ var requiredInitialTables = []string{ "exploration_conversations", "journal_conversation_handles", "source_availability_observations", + "hook_enablements", + "hook_absorption_markers", + "hook_trusted_paths", "schema_migrations", } +// userScopedTables are host-local tables without project_id. They are excluded +// from the project-scoped foreign-key guardrail. +var userScopedTables = map[string]bool{ + "hook_enablements": true, + "hook_absorption_markers": true, + "hook_trusted_paths": true, + "schema_migrations": true, + "projects": true, +} + func TestSchemaMigrationsAreOrderedAndChecksummed(t *testing.T) { migrations := SchemaMigrations() - if len(migrations) != 11 { - t.Fatalf("len(SchemaMigrations()) = %d, want 11", len(migrations)) + if len(migrations) != 12 { + t.Fatalf("len(SchemaMigrations()) = %d, want 12", len(migrations)) } - wantVersions := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12} + wantVersions := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12, 13} for i, migration := range migrations { if migration.Version != wantVersions[i] { t.Fatalf("migration[%d].Version = %d, want %d", i, migration.Version, wantVersions[i]) @@ -104,6 +117,12 @@ func TestSchemaMigrationsAreOrderedAndChecksummed(t *testing.T) { if migrations[9].Name != "journal_origins_and_deferrals" { t.Fatalf("migration[9].Name = %q, want journal_origins_and_deferrals", migrations[9].Name) } + if migrations[10].Name != "intents_and_explorations" { + t.Fatalf("migration[10].Name = %q, want intents_and_explorations", migrations[10].Name) + } + if migrations[11].Name != "hook_enablement" { + t.Fatalf("migration[11].Name = %q, want hook_enablement", migrations[11].Name) + } for _, migration := range migrations { if strings.TrimSpace(migration.SQL) == "" { t.Fatalf("migration %d SQL is empty", migration.Version) @@ -154,6 +173,7 @@ func TestOperationalTablesHaveStableIDsAndTimestamps(t *testing.T) { "exploration_conversations": true, "journal_conversation_handles": true, "source_availability_observations": true, + "hook_absorption_markers": true, } sql := currentSchemaSQL() for _, table := range requiredInitialTables { @@ -216,7 +236,7 @@ func TestJournalOriginTablesDoNotForeignKeyOptionalRows(t *testing.T) { func TestProjectScopedTablesUseForeignKeys(t *testing.T) { sql := currentSchemaSQL() for _, table := range requiredInitialTables { - if table == "projects" || table == "schema_migrations" { + if userScopedTables[table] { continue } body := tableBody(t, sql, table) @@ -226,6 +246,20 @@ func TestProjectScopedTablesUseForeignKeys(t *testing.T) { } } +func TestUserScopedHookTablesHaveNoProjectID(t *testing.T) { + sql := currentSchemaSQL() + for _, table := range []string{"hook_enablements", "hook_absorption_markers", "hook_trusted_paths"} { + body := tableBody(t, sql, table) + if strings.Contains(body, "project_id") { + t.Fatalf("%s must be user-scoped without project_id:\n%s", table, body) + } + } + enablement := tableBody(t, sql, "hook_enablements") + if !strings.Contains(enablement, "UNIQUE (target, event, hook_id)") { + t.Fatalf("hook_enablements missing UNIQUE(target, event, hook_id):\n%s", enablement) + } +} + func TestInitialSchemaDoesNotDefineSecretStorageColumns(t *testing.T) { columnsByTable := schemaColumnNames(t, currentSchemaSQL()) for _, forbidden := range []string{ @@ -315,6 +349,10 @@ func TestSchemaDocumentationMirrorsExecutableMigration(t *testing.T) { if sqlDoc != SchemaMigrations()[10].SQL { t.Fatal("docs/schema/0012_intents_and_explorations.sql must match embedded migration 0012 exactly") } + sqlDoc = readRepoFile(t, "docs", "schema", "0013_hook_enablement.sql") + if sqlDoc != SchemaMigrations()[11].SQL { + t.Fatal("docs/schema/0013_hook_enablement.sql must match embedded migration 0013 exactly") + } dbmlDoc := readRepoFile(t, "docs", "schema", "operational-state.dbml") mermaidDoc := readRepoFile(t, "docs", "schema", "operational-state.mmd") @@ -578,6 +616,27 @@ func currentSchemaSQL() string { return strings.Join(parts, "\n") } +func schemaMigrationByName(name string) (SchemaMigration, bool) { + for _, migration := range SchemaMigrations() { + if migration.Name == name { + return migration, true + } + } + return SchemaMigration{}, false +} + +func sameIntSlice(got, want []int) bool { + if len(got) != len(want) { + return false + } + for i := range got { + if got[i] != want[i] { + return false + } + } + return true +} + func tableBody(t *testing.T, sql string, table string) string { t.Helper() re := regexp.MustCompile(`(?is)CREATE TABLE IF NOT EXISTS ` + regexp.QuoteMeta(table) + ` \((.*?)\);`) diff --git a/internal/state/schema_upgrade.go b/internal/state/schema_upgrade.go index 000805e2c..2f849e6c7 100644 --- a/internal/state/schema_upgrade.go +++ b/internal/state/schema_upgrade.go @@ -145,6 +145,41 @@ func classifySchemaUpgradeTargetWithPolicy(path string, version int, allowJourna return false, nil } +// IsUninitialized reports whether this database has never held any schema at +// all: no applied migrations and no user tables of any kind. It asks exactly +// the question BootstrapIfEmpty answers before it writes, and answers it +// without writing, so a read-only caller can tell a database that is merely +// new from one it cannot read as Loaf's. +// +// The distinction matters because both spellings fail an ordinary schema read +// with the same "no such table" error. A new database means there is nothing +// recorded yet; a readable database carrying somebody else's tables means the +// caller is looking at the wrong file, and treating that as "nothing recorded" +// would report every default as though it had been chosen. +func (s *Store) IsUninitialized(ctx context.Context) (bool, error) { + if s == nil || s.db == nil { + return false, fmt.Errorf("inspect state database: store is nil") + } + var ledgerTables int + if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'`).Scan(&ledgerTables); err != nil { + return false, fmt.Errorf("inspect state schema ledger: %w", err) + } + if ledgerTables > 0 { + var applied int + if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations`).Scan(&applied); err != nil { + return false, fmt.Errorf("inspect state migrations: %w", err) + } + if applied > 0 { + return false, nil + } + } + var userTables int + if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT IN ('schema_migrations', 'sqlite_sequence')`).Scan(&userTables); err != nil { + return false, fmt.Errorf("inspect state tables: %w", err) + } + return userTables == 0, nil +} + // BootstrapIfEmpty applies the complete canonical schema only when the // serialized database owner proves that no user schema exists yet. Existing // databases are never mutated by this method. diff --git a/internal/state/schema_upgrade_test.go b/internal/state/schema_upgrade_test.go index 027d37ecc..7233475d2 100644 --- a/internal/state/schema_upgrade_test.go +++ b/internal/state/schema_upgrade_test.go @@ -46,8 +46,9 @@ func TestSchemaUpgradeSchema9PreviewAndApply(t *testing.T) { if err != nil { t.Fatalf("PreviewSchemaUpgrade() error = %v", err) } - 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) + wantPending := pendingSchemaMigrationVersions(9) + if preview.CurrentVersion != 9 || !sameIntSlice(preview.PendingVersions, wantPending) || preview.BackupPath != "" { + t.Fatalf("preview = %#v, want schema9 pending %v without backup", preview, wantPending) } result, err := ApplySchemaUpgrade(ctx, root, resolver) if err != nil { @@ -125,8 +126,9 @@ 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) != 2 || backup.Pending[0] != 11 || backup.Pending[1] != 12 { - t.Fatalf("backup source classification=%#v err=%v, want verified schema9 source", backup, verifyErr) + wantPending := pendingSchemaMigrationVersions(9) + if verifyErr != nil || backup.Fingerprint.Version != 9 || !sameIntSlice(backup.Pending, wantPending) { + t.Fatalf("backup source classification=%#v err=%v, want verified schema9 source pending %v", backup, verifyErr, wantPending) } after := schemaVersionAndMigrationCount(t, databasePath) if before != after || after != "9/9" { @@ -178,8 +180,10 @@ 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 != "12/12" { - t.Fatalf("live schema after schema10 upgrade = %s, want 12/12", got) + // schema-10 seed includes the journal-first row plus every auto-applied migration. + want := fmt.Sprintf("%d/%d", CurrentSchemaVersion(), len(SchemaMigrations())+1) + if got := schemaVersionAndMigrationCount(t, databasePath); got != want { + t.Fatalf("live schema after schema10 upgrade = %s, want %s", got, want) } } @@ -267,9 +271,10 @@ func TestSchema10OrdinaryWritesRequireUpgradeWithoutMutation(t *testing.T) { root, resolver, databasePath := seedSchema10UpgradeTarget(t) before := schema10MutableCounts(t, databasePath) err := tc.run(root, resolver) + wantPending := pendingSchemaMigrationVersions(journalFirstMigrationVersion) var required *SchemaUpgradeRequiredError - 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) + if !errors.As(err, &required) || required.Code != SchemaUpgradeRequiredCode || !sameIntSlice(required.PendingVersions, wantPending) { + t.Fatalf("%s error=%v required=%#v, want schema-upgrade-required pending %v", tc.name, err, required, wantPending) } after := schema10MutableCounts(t, databasePath) if before != after { @@ -527,18 +532,19 @@ func TestSchema11DatabasesClassifyAndUpgradeToCurrent(t *testing.T) { // Ordinary writes return the typed upgrade requirement, never a // bare invalid-database error. + wantPending := pendingSchemaMigrationVersions(11) _, err := LogJournal(ctx, root, resolver, JournalLogOptions{Entry: "decision(test): blocked"}) var required *SchemaUpgradeRequiredError - if !errors.As(err, &required) || len(required.PendingVersions) != 1 || required.PendingVersions[0] != 12 { - t.Fatalf("journal log error = %v, want schema-upgrade-required pending [12]", err) + if !errors.As(err, &required) || !sameIntSlice(required.PendingVersions, wantPending) { + t.Fatalf("journal log error = %v, want schema-upgrade-required pending %v", err, wantPending) } preview, err := PreviewSchemaUpgrade(ctx, root, resolver) if err != nil { t.Fatalf("PreviewSchemaUpgrade() error = %v", err) } - if preview.CurrentVersion != 11 || len(preview.PendingVersions) != 1 || preview.PendingVersions[0] != 12 { - t.Fatalf("preview = %#v, want schema 11 pending [12]", preview) + if preview.CurrentVersion != 11 || !sameIntSlice(preview.PendingVersions, wantPending) { + t.Fatalf("preview = %#v, want schema 11 pending %v", preview, wantPending) } result, err := ApplySchemaUpgrade(ctx, root, resolver) @@ -559,3 +565,67 @@ func TestSchema11DatabasesClassifyAndUpgradeToCurrent(t *testing.T) { }) } } + +// IsUninitialized is the read-only half of BootstrapIfEmpty's precondition, so +// the two have to agree on every shape: a database Bootstrap would fill reads +// as uninitialized, and one it would refuse to fill does not. +func TestIsUninitializedAgreesWithBootstrapPrecondition(t *testing.T) { + ctx := context.Background() + for _, testCase := range []struct { + name string + seed func(t *testing.T, path string) + want bool + }{ + {name: "fresh file", seed: func(t *testing.T, path string) {}, want: true}, + {name: "migrated", seed: func(t *testing.T, path string) { + store, err := OpenStore(path) + if err != nil { + t.Fatalf("OpenStore() error = %v", err) + } + defer store.Close() + if err := store.ApplyMigrations(ctx); err != nil { + t.Fatalf("ApplyMigrations() error = %v", err) + } + }, want: false}, + {name: "somebody else's tables", seed: func(t *testing.T, path string) { + db, err := sql.Open(sqliteDriverName, "file:"+filepath.ToSlash(path)) + if err != nil { + t.Fatalf("sql.Open() error = %v", err) + } + defer db.Close() + if _, err := db.Exec(`CREATE TABLE notes (id INTEGER PRIMARY KEY)`); err != nil { + t.Fatalf("create foreign table error = %v", err) + } + }, want: false}, + } { + t.Run(testCase.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "loaf.sqlite") + testCase.seed(t, path) + + store, err := OpenStore(path) + if err != nil { + t.Fatalf("OpenStore() error = %v", err) + } + defer store.Close() + + uninitialized, err := store.IsUninitialized(ctx) + if err != nil { + t.Fatalf("IsUninitialized() error = %v", err) + } + if uninitialized != testCase.want { + t.Fatalf("IsUninitialized() = %v, want %v", uninitialized, testCase.want) + } + + // Bootstrap answers the same question by writing; it must reach the + // same verdict, or the plan and apply paths disagree about which + // databases are safe to treat as empty. + bootstrapped, err := store.BootstrapIfEmpty(ctx) + if err != nil { + t.Fatalf("BootstrapIfEmpty() error = %v", err) + } + if bootstrapped != testCase.want { + t.Fatalf("BootstrapIfEmpty() = %v, want it to agree with IsUninitialized() = %v", bootstrapped, testCase.want) + } + }) + } +} diff --git a/plugins/loaf/.loaf-target-manifest.json b/plugins/loaf/.loaf-target-manifest.json index c61e23abb..fbc73c485 100644 --- a/plugins/loaf/.loaf-target-manifest.json +++ b/plugins/loaf/.loaf-target-manifest.json @@ -7,6 +7,14 @@ "claude-session-start-v1" ], "artifacts": [ + { + "id": "hook-file:hooks/hooks.json", + "kind": "hook-file", + "source_path": "hooks/hooks.json", + "destination": "hooks/hooks.json", + "sha256": "564917493bfa795fee8a067e235e13eec5f992e098cb94540d2202cc6fe3cad4", + "mode": 420 + }, { "id": "hook-file:hooks/instructions/post-merge.md", "kind": "hook-file", @@ -71,13 +79,6 @@ "sha256": "f3dc96bb160540e08bfa75e6c604087222c003a7abf8d9fd77800997375fd681", "mode": 493 }, - { - "id": "hook-projection:hooks/hooks.json", - "kind": "hook-projection", - "source_path": "hooks/hooks.json", - "destination": "hooks/hooks.json", - "sha256": "564917493bfa795fee8a067e235e13eec5f992e098cb94540d2202cc6fe3cad4" - }, { "id": "managed-instructions", "kind": "instruction", diff --git a/plugins/loaf/bin/native/darwin-arm64/loaf b/plugins/loaf/bin/native/darwin-arm64/loaf index cce078506..d801bd2d7 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/loaf-reference/SKILL.md b/plugins/loaf/skills/loaf-reference/SKILL.md index 79e686acc..c6a63f336 100644 --- a/plugins/loaf/skills/loaf-reference/SKILL.md +++ b/plugins/loaf/skills/loaf-reference/SKILL.md @@ -63,6 +63,7 @@ Names and one-line purposes only. Run `loaf --help` for options, argum | `loaf install` | Onboard Loaf into a folder or a not-yet-installed AI tool configuration | — | | `loaf upgrade` | Refresh Loaf in place: harness content sync plus deprecation cleanup anywhere, and project-surface refresh only inside a detected Loaf repo | — | | `loaf config` | Validate and refresh project Loaf config | check | +| `loaf hooks` | Inspect and set which Loaf hooks project into an installed harness's hooks file | list, enable, disable | | `loaf init` | Initialize a project with Loaf structure | — | | `loaf release` | Create a new release with changelog, version bump, and tag | — | | `loaf search` | Search SQLite artifact bodies, journal entries, and indexed docs | — |