diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 68516ad..a96f2e9 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -11,7 +11,7 @@ { "name": "agent-plugin-playground", "displayName": "Agent Plugin Playground", - "description": "Bun-powered native plugin playground for one active experiment at a time", + "description": "Bun-powered Agent Attention approval gates in the native plugin playground", "author": { "name": "My Agent Dojo" }, diff --git a/bun.lock b/bun.lock index 02fef7b..7b1d50e 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,10 @@ "": { "name": "agent-plugin-template-tooling", }, + "packages/agent-attention": { + "name": "agent-attention", + "version": "0.0.0", + }, "packages/skill-a": { "name": "skill-a", "version": "0.0.0", @@ -23,6 +27,8 @@ }, }, "packages": { + "agent-attention": ["agent-attention@workspace:packages/agent-attention"], + "camelcase": ["camelcase@8.0.0", "", {}, "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA=="], "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], diff --git a/experiments/agent-attention/hooks/agent-attention-codex-stop.test.ts b/experiments/agent-attention/hooks/agent-attention-codex-stop.test.ts index feec4b8..eb18609 100644 --- a/experiments/agent-attention/hooks/agent-attention-codex-stop.test.ts +++ b/experiments/agent-attention/hooks/agent-attention-codex-stop.test.ts @@ -47,9 +47,8 @@ describe('Agent Attention Codex Stop guard', () => { test('rejects missing and wrong-typed correlation fields', () => { expect(isAgentAttentionStopInput(null)).toBe(false) expect(isAgentAttentionStopInput([])).toBe(false) - expect(isAgentAttentionStopInput({ cwd: '', session_id: THREAD_ID })).toBe( - false, - ) + expect(isAgentAttentionStopInput({ session_id: THREAD_ID })).toBe(true) + expect(isAgentAttentionStopInput({ cwd: 42, session_id: THREAD_ID })).toBe(false) expect(isAgentAttentionStopInput({ cwd: '/tmp/repo' })).toBe(false) expect( isAgentAttentionStopInput({ diff --git a/experiments/agent-attention/hooks/agent-attention-codex-stop.ts b/experiments/agent-attention/hooks/agent-attention-codex-stop.ts index 9ea900d..6df1a5f 100644 --- a/experiments/agent-attention/hooks/agent-attention-codex-stop.ts +++ b/experiments/agent-attention/hooks/agent-attention-codex-stop.ts @@ -2,68 +2,23 @@ import { join } from 'node:path' +import { + handleAgentAttentionStop as handleInstalledStop, + invalidAgentAttentionStopInput, + isAgentAttentionStopInput, + runAgentAttentionStop as runInstalledStop, + type AgentAttentionStopCheck, + type AgentAttentionStopInput, + type AgentAttentionStopOutput, + type AgentAttentionStopRuntime, +} from '../../../packages/agent-attention/src/stop' + +export { isAgentAttentionStopInput } + /** Experiment-local Codex hook command kept aligned with its fixture. */ export const AGENT_ATTENTION_STOP_HOOK_COMMAND = 'bun "$(git rev-parse --show-toplevel)/experiments/agent-attention/hooks/agent-attention-codex-stop.ts"' -/** Stable Stop fields used to correlate one exact task with owner state. */ -export interface AgentAttentionStopInput { - cwd: string - session_id: string - stop_hook_active?: boolean -} - -/** Minimal owner result consumed by the hook adapter. */ -export interface AgentAttentionStopCheck { - hook_action: 'allow' | 'continue' - reason?: string -} - -/** Injectable owner seam for public-hook tests. */ -export interface AgentAttentionStopRuntime { - checkStop: (threadId: string) => Promise -} - -/** Codex Stop output that either permits stop or requests one continuation. */ -export type AgentAttentionStopOutput = - | { continue: true; suppressOutput: true } - | { decision: 'block'; reason: string } - -const INVALID_STOP_INPUT: AgentAttentionStopOutput = { - decision: 'block', - reason: - 'Agent Attention could not correlate this Stop event to structured owner state. Repair the hook payload contract before stopping.', -} - -/** - * Validate only the stable task-correlation fields needed by the owner. - * - * @param value - Untrusted Codex hook payload - * @returns True when the hook can correlate one exact task - * - * @example - * ```ts - * isAgentAttentionStopInput({ cwd: '/tmp/repo', session_id: crypto.randomUUID() }) - * ``` - */ -export function isAgentAttentionStopInput( - value: unknown, -): value is AgentAttentionStopInput { - if (!value || typeof value !== 'object' || Array.isArray(value)) return false - const input = value as Record - if (typeof input.cwd !== 'string' || input.cwd.trim() === '') return false - if (typeof input.session_id !== 'string' || input.session_id.trim() === '') { - return false - } - if ( - input.stop_hook_active !== undefined && - typeof input.stop_hook_active !== 'boolean' - ) { - return false - } - return true -} - /** * Enforce explicit owner state without reading assistant prose or transcripts. * @@ -81,19 +36,7 @@ export async function handleAgentAttentionStop( input: AgentAttentionStopInput, runtime: AgentAttentionStopRuntime = createDefaultRuntime(), ): Promise { - if (input.stop_hook_active) { - return { continue: true, suppressOutput: true } - } - const check = await runtime.checkStop(input.session_id) - if (check.hook_action === 'continue') { - return { - decision: 'block', - reason: - check.reason ?? - 'Agent Attention owner state requires an actionable repair before stopping.', - } - } - return { continue: true, suppressOutput: true } + return handleInstalledStop(input, runtime) } /** @@ -107,18 +50,7 @@ export async function runAgentAttentionStop( input: unknown, runtime: AgentAttentionStopRuntime = createDefaultRuntime(), ): Promise { - if (!isAgentAttentionStopInput(input)) { - return INVALID_STOP_INPUT - } - try { - return await handleAgentAttentionStop(input, runtime) - } catch (error) { - const detail = error instanceof Error ? error.message : 'unknown error' - return { - decision: 'block', - reason: `Agent Attention could not verify structured owner state. Repair the owner check before stopping: ${detail}`, - } - } + return runInstalledStop(input, runtime) } function createDefaultRuntime(): AgentAttentionStopRuntime { @@ -161,6 +93,6 @@ if (import.meta.main) { const output = await runAgentAttentionStop(parsed) process.stdout.write(`${JSON.stringify(output)}\n`) } catch { - process.stdout.write(`${JSON.stringify(INVALID_STOP_INPUT)}\n`) + process.stdout.write(`${JSON.stringify(invalidAgentAttentionStopInput())}\n`) } } diff --git a/experiments/agent-attention/runtime/agent-attention/agent-attention.py b/experiments/agent-attention/runtime/agent-attention/agent-attention.py index d84939f..6ccdab7 100755 --- a/experiments/agent-attention/runtime/agent-attention/agent-attention.py +++ b/experiments/agent-attention/runtime/agent-attention/agent-attention.py @@ -10,6 +10,7 @@ import math import os import plistlib +import stat import subprocess import sys import time @@ -31,6 +32,7 @@ {"name": "check-stop", "summary": "Check structured owner state before a task stops."}, ) MANAGED_URL_PREFIX = "remindctl URL (managed): " +REMINDCTL_COMMAND_TIMEOUT_SECONDS = 30 class ContractError(Exception): @@ -162,30 +164,31 @@ def acquire_request_lock( return descriptor -def release_request_lock(path: Path, descriptor: int) -> None: - """Remove only the path still backed by the held request lock.""" +def release_request_lock(_path: Path, descriptor: int) -> None: + """Release one persistent request-lock inode.""" try: - try: - path_stat = os.stat(path, follow_symlinks=False) - except FileNotFoundError: - path_stat = None - descriptor_stat = os.fstat(descriptor) - if path_stat and ( - path_stat.st_dev == descriptor_stat.st_dev - and path_stat.st_ino == descriptor_stat.st_ino - ): - path.unlink() - finally: fcntl.flock(descriptor, fcntl.LOCK_UN) + finally: os.close(descriptor) def append_audit(path: Path, value: Any) -> None: """Append one private JSON audit event.""" path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) - with path.open("a", encoding="utf-8") as handle: + flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open(path, flags, 0o600) + try: + metadata = os.fstat(descriptor) + if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: + raise ContractError("audit path must be one singly linked regular file") + os.fchmod(descriptor, 0o600) + except BaseException: + os.close(descriptor) + raise + with os.fdopen(descriptor, "a", encoding="utf-8") as handle: handle.write(json.dumps(value, sort_keys=True) + "\n") - os.chmod(path, 0o600) def run_json(command: list[str], *, timeout_seconds: float | None = None) -> Any: @@ -622,7 +625,8 @@ def _submit_approval(args: argparse.Namespace) -> dict[str, Any]: notification_at, "--json", "--no-input", - ] + ], + timeout_seconds=REMINDCTL_COMMAND_TIMEOUT_SECONDS, ) except OSError as error: request_claim_path.unlink(missing_ok=True) @@ -673,7 +677,9 @@ def _submit_approval(args: argparse.Namespace) -> dict[str, Any]: ) raise try: - created_inventory = read_inventory(config) + created_inventory = read_inventory( + config, timeout_seconds=REMINDCTL_COMMAND_TIMEOUT_SECONDS + ) except (ContractError, json.JSONDecodeError, OSError, subprocess.SubprocessError) as error: write_json( path, @@ -989,7 +995,8 @@ def read_exact_completed_reminder(reminder_id: str, list_id: str) -> dict[str, A list_id, "--json", "--no-input", - ] + ], + timeout_seconds=REMINDCTL_COMMAND_TIMEOUT_SECONDS, ) inventory = require_reminder_inventory( inventory, document_name="completed reminder inventory" @@ -1136,7 +1143,8 @@ def record_outcome(args: argparse.Namespace) -> dict[str, Any]: updated_notes, "--json", "--no-input", - ] + ], + timeout_seconds=REMINDCTL_COMMAND_TIMEOUT_SECONDS, ) except OSError: claim_path.unlink(missing_ok=True) @@ -1648,7 +1656,8 @@ def main() -> int: json.dumps( base_result( "error", - changed="unknown", + changed=False, + change_uncertain=True, retry_safe=False, error_category="contract_or_runtime", next_safe_action="inspect current state before retry", diff --git a/experiments/agent-attention/runtime/agent-attention/test_agent_attention.py b/experiments/agent-attention/runtime/agent-attention/test_agent_attention.py index 961206c..02e92c3 100644 --- a/experiments/agent-attention/runtime/agent-attention/test_agent_attention.py +++ b/experiments/agent-attention/runtime/agent-attention/test_agent_attention.py @@ -8,6 +8,7 @@ import json import os import plistlib +import stat import subprocess import sys import tempfile @@ -19,7 +20,9 @@ from typing import Any -RUNTIME = Path(__file__).with_name("agent-attention.py") +RUNTIME = Path( + os.environ.get("AGENT_ATTENTION_RUNTIME", Path(__file__).with_name("agent-attention.py")) +) RUNTIME_SPEC = importlib.util.spec_from_file_location("agent_attention_runtime", RUNTIME) assert RUNTIME_SPEC and RUNTIME_SPEC.loader AGENT_ATTENTION = importlib.util.module_from_spec(RUNTIME_SPEC) @@ -309,6 +312,8 @@ def test_parser_construction_errors_use_the_structured_error_boundary(self) -> N result = json.loads(completed.stdout) self.assertEqual(result["status"], "error") self.assertEqual(result["error_category"], "contract_or_runtime") + self.assertIs(result["changed"], False) + self.assertIs(result["change_uncertain"], True) def test_non_object_config_uses_the_structured_error_boundary(self) -> None: (self.state_dir / "config.json").write_text("[]", encoding="utf-8") @@ -565,6 +570,38 @@ def test_outcome_execute_updates_only_exact_completed_gate_once(self) -> None: len((self.state_dir / "outcome-audit.jsonl").read_text().splitlines()), 1 ) + def test_outcome_bounds_completed_reads_and_edit(self) -> None: + before = {**self.target} + after = {**before} + + def fake_run_json(command: list[str], **_: Any) -> Any: + if command[1:3] == ["show", "completed"]: + return [{**after}] + if command[1] == "edit": + after["notes"] = command[command.index("--notes") + 1] + after["lastModifiedDate"] = "2026-08-10T05:45:01Z" + return {**after} + raise AssertionError(f"unexpected command: {command}") + + run_json = mock.Mock(side_effect=fake_run_json) + with mock.patch.object(AGENT_ATTENTION, "run_json", run_json): + result = AGENT_ATTENTION.record_outcome( + AGENT_ATTENTION.argparse.Namespace( + state_dir=self.state_dir, + reminder_id=REMINDER_ID, + outcome="Bound every outcome command.", + finished_at=FINISHED_AT, + execute=True, + ) + ) + self.assertEqual(result["status"], "recorded") + self.assertEqual(len(run_json.call_args_list), 3) + for invocation in run_json.call_args_list: + self.assertEqual( + invocation.kwargs, + {"timeout_seconds": AGENT_ATTENTION.REMINDCTL_COMMAND_TIMEOUT_SECONDS}, + ) + def test_outcome_rejects_a_second_terminal_result(self) -> None: first = self.run_cli( "record-outcome", @@ -870,11 +907,51 @@ def test_submit_recovers_request_lock_abandoned_before_creation(self) -> None: json.dumps({"thread_id": THREAD_ID, "request_id": "abandoned"}), encoding="utf-8", ) + lock_inode = lock_path.stat().st_ino result = self.result(self.run_cli(*self.submit_arguments(), "--execute")) self.assertEqual(result["status"], "gated") - self.assertFalse(lock_path.exists()) + self.assertTrue(lock_path.exists()) + self.assertEqual(lock_path.stat().st_ino, lock_inode) self.assertEqual(len([call for call in self.calls() if call[0] == "add"]), 1) + def test_submit_bounds_remindctl_add_while_request_lock_is_held(self) -> None: + created_gate: dict[str, Any] = {} + + def fake_run_json(command: list[str], **_: Any) -> Any: + created_gate.update( + { + "id": NEW_REMINDER_ID, + "listID": command[command.index("--list-id") + 1], + "title": command[command.index("--title") + 1], + "notes": command[command.index("--notes") + 1], + "url": command[command.index("--url") + 1], + "priority": command[command.index("--priority") + 1], + "isCompleted": False, + } + ) + return {"id": NEW_REMINDER_ID} + + args = AGENT_ATTENTION.parser().parse_args( + ["--state-dir", str(self.state_dir), *self.submit_arguments(), "--execute"] + ) + run_json = mock.Mock(side_effect=fake_run_json) + read_inventory = mock.Mock(side_effect=lambda _config, **_: [{**created_gate}]) + with ( + mock.patch.object(AGENT_ATTENTION, "run_json", run_json), + mock.patch.object(AGENT_ATTENTION, "read_inventory", read_inventory), + ): + result = AGENT_ATTENTION.submit_approval(args) + self.assertEqual(result["status"], "gated") + self.assertEqual(run_json.call_count, 1) + self.assertEqual( + run_json.call_args.kwargs, + {"timeout_seconds": AGENT_ATTENTION.REMINDCTL_COMMAND_TIMEOUT_SECONDS}, + ) + read_inventory.assert_called_once_with( + {"version": 1, "list": self.mapping["list"]}, + timeout_seconds=AGENT_ATTENTION.REMINDCTL_COMMAND_TIMEOUT_SECONDS, + ) + def test_submit_reclaims_claim_abandoned_before_request_declaration(self) -> None: arguments = self.submit_arguments() preview = self.result(self.run_cli(*arguments)) @@ -955,10 +1032,46 @@ def test_failed_submit_releases_the_per_thread_request_lock(self) -> None: env_update={"FAKE_REMINDERS_NEW_ID": ""}, ) self.assertEqual(completed.returncode, 1) - self.assertFalse( + self.assertTrue( (self.state_dir / "request-locks" / f"{THREAD_ID}.json").exists() ) + def test_audit_file_is_private_before_the_first_write(self) -> None: + path = self.state_dir / "audit-mode-test.jsonl" + real_fdopen = os.fdopen + modes_before_write: list[int] = [] + + def checked_fdopen(descriptor: int, *args: Any, **kwargs: Any) -> Any: + modes_before_write.append(stat.S_IMODE(os.fstat(descriptor).st_mode)) + return real_fdopen(descriptor, *args, **kwargs) + + previous_umask = os.umask(0) + try: + with mock.patch.object(AGENT_ATTENTION.os, "fdopen", side_effect=checked_fdopen): + AGENT_ATTENTION.append_audit(path, {"event": "bounded-test"}) + finally: + os.umask(previous_umask) + self.assertEqual(modes_before_write, [0o600]) + self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600) + + def test_audit_rejects_symlinks_and_multiply_linked_files(self) -> None: + target = self.state_dir / "audit-target.jsonl" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("sentinel\n", encoding="utf-8") + symlink = self.state_dir / "audit-symlink.jsonl" + symlink.symlink_to(target) + with self.assertRaises(OSError): + AGENT_ATTENTION.append_audit(symlink, {"event": "must-not-write"}) + self.assertEqual(target.read_text(encoding="utf-8"), "sentinel\n") + + hardlink = self.state_dir / "audit-hardlink.jsonl" + os.link(target, hardlink) + with self.assertRaisesRegex( + AGENT_ATTENTION.ContractError, "singly linked regular file" + ): + AGENT_ATTENTION.append_audit(hardlink, {"event": "must-not-write"}) + self.assertEqual(target.read_text(encoding="utf-8"), "sentinel\n") + def test_submit_releases_owned_claim_when_remindctl_never_starts(self) -> None: missing_bin = self.root / "missing-bin" missing_bin.mkdir() diff --git a/experiments/agent-attention/skill/SKILL.md b/experiments/agent-attention/skill/SKILL.md index 6e7b47a..dadda1f 100644 --- a/experiments/agent-attention/skill/SKILL.md +++ b/experiments/agent-attention/skill/SKILL.md @@ -10,14 +10,14 @@ Keep discussion, disagreement, multi-choice, and unclear requests in Codex. ## Owner -`experiments/agent-attention/runtime/agent-attention/agent-attention.py` owns +`runtime/agent-attention.py` inside the installed plugin owns admission, exact task binding, native gate creation, structured state, delivery claims, and outcome receipts. ## Route -1. From the playground root, run - `python3 experiments/agent-attention/runtime/agent-attention/agent-attention.py submit --help`. +1. Resolve this skill's installed plugin root, then run + `bin/agent-attention submit --help`. 2. Submit the exact owning task and decision through the help-owned structured fields. Preview first. 3. If admitted, rerun the same command with `--execute`. One gate and one alert diff --git a/packages/agent-attention/package.json b/packages/agent-attention/package.json new file mode 100644 index 0000000..aced9ba --- /dev/null +++ b/packages/agent-attention/package.json @@ -0,0 +1,7 @@ +{ + "name": "agent-attention", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "src/main.ts" +} diff --git a/packages/agent-attention/src/main.test.ts b/packages/agent-attention/src/main.test.ts new file mode 100644 index 0000000..4533f51 --- /dev/null +++ b/packages/agent-attention/src/main.test.ts @@ -0,0 +1,61 @@ +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import { afterEach, expect, test } from "bun:test" + +const temporaryRoots: string[] = [] + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function runMain(python: string): ReturnType { + return Bun.spawnSync({ + cmd: [process.execPath, join(import.meta.dir, "main.ts"), "commands"], + env: { ...process.env, AGENT_ATTENTION_PYTHON: python }, + stdout: "pipe", + stderr: "pipe", + }) +} + +test("missing Python diagnostics preserve the command result envelope", () => { + const completed = runMain("/missing/python3") + const result = JSON.parse(completed.stdout.toString()) + + expect(completed.exitCode).toBe(1) + expect(result).toMatchObject({ + contract_id: "agent-attention.approval-gate", + schema_version: "1", + status: "error", + error_category: "missing_python", + }) + expect(result.run_id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ) +}) + +test("main flushes complete stdout and stderr before exiting", () => { + const root = mkdtempSync(join(tmpdir(), "agent-attention-output-")) + temporaryRoots.push(root) + const executable = join(root, "python") + writeFileSync( + executable, + `#!/usr/bin/env bun +const stdout = "o".repeat(1_000_000) +const stderr = "e".repeat(1_000_000) +process.stdout.write(stdout) +process.stderr.write(stderr) +process.exitCode = 23 +`, + ) + chmodSync(executable, 0o755) + + const completed = runMain(executable) + + expect(completed.exitCode).toBe(23) + expect(completed.stdout.byteLength).toBe(1_000_000) + expect(completed.stderr.byteLength).toBe(1_000_000) +}) diff --git a/packages/agent-attention/src/main.ts b/packages/agent-attention/src/main.ts new file mode 100644 index 0000000..2b8e6f8 --- /dev/null +++ b/packages/agent-attention/src/main.ts @@ -0,0 +1,109 @@ +import { randomUUID } from "node:crypto" +import { join } from "node:path" + +import { + invalidAgentAttentionStopInput, + runAgentAttentionStop, + type AgentAttentionStopRuntime, +} from "./stop" + +interface ProcessResult { + exitCode: number + stdout: string + stderr: string +} + +async function writeOutput( + stream: NodeJS.WriteStream, + content: string, +): Promise { + if (content.length === 0) return + await new Promise((resolve, reject) => { + stream.write(content, (error) => { + if (error) reject(error) + else resolve() + }) + }) +} + +function pythonExecutable(): string { + return process.env.AGENT_ATTENTION_PYTHON || "python3" +} + +async function runPython(arguments_: string[], timeout?: number): Promise { + const owner = join(import.meta.dir, "agent-attention.py") + try { + const child = Bun.spawn([pythonExecutable(), owner, ...arguments_], { + stdin: "inherit", + stdout: "pipe", + stderr: "pipe", + ...(timeout === undefined ? {} : { timeout }), + }) + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]) + return { exitCode, stdout, stderr } + } catch (error) { + const detail = error instanceof Error ? error.message : "unknown error" + return { + exitCode: 1, + stdout: `${JSON.stringify({ + contract_id: "agent-attention.approval-gate", + schema_version: "1", + run_id: randomUUID(), + status: "error", + changed: false, + retry_safe: false, + error_category: "missing_python", + next_safe_action: "Install python3 in a standard system location, then retry.", + })}\n`, + stderr: `Agent Attention could not start python3: ${detail}\n`, + } + } +} + +function installedStopRuntime(): AgentAttentionStopRuntime { + return { + checkStop: async (threadId) => { + const result = await runPython(["check-stop", "--thread-id", threadId], 5_000) + if (result.exitCode !== 0) throw new Error(result.stderr.trim() || "owner check failed") + const parsed = JSON.parse(result.stdout) as { hook_action?: unknown; reason?: unknown } + if (parsed.hook_action !== "allow" && parsed.hook_action !== "continue") { + throw new Error("Agent Attention stop check returned an invalid action") + } + return { + hook_action: parsed.hook_action, + ...(typeof parsed.reason === "string" ? { reason: parsed.reason } : {}), + } + }, + } +} + +async function main(): Promise { + const arguments_ = process.argv.slice(2) + if (arguments_[0] === "hook-stop") { + let input: unknown + try { + input = await Bun.stdin.json() + } catch { + await writeOutput( + process.stdout, + `${JSON.stringify(invalidAgentAttentionStopInput())}\n`, + ) + return 0 + } + const output = await runAgentAttentionStop(input, installedStopRuntime()) + await writeOutput(process.stdout, `${JSON.stringify(output)}\n`) + return 0 + } + const result = await runPython(arguments_) + await Promise.all([ + writeOutput(process.stdout, result.stdout), + writeOutput(process.stderr, result.stderr), + ]) + return result.exitCode +} + +process.exitCode = await main() diff --git a/packages/agent-attention/src/stop.ts b/packages/agent-attention/src/stop.ts new file mode 100644 index 0000000..d7b8b06 --- /dev/null +++ b/packages/agent-attention/src/stop.ts @@ -0,0 +1,77 @@ +/** Stable Stop fields used to correlate one exact task with owner state. */ +export interface AgentAttentionStopInput { + cwd?: string + session_id: string + stop_hook_active?: boolean +} + +/** Minimal owner result consumed by the hook adapter. */ +export interface AgentAttentionStopCheck { + hook_action: "allow" | "continue" + reason?: string +} + +/** Structured owner seam shared by source and installed Stop adapters. */ +export interface AgentAttentionStopRuntime { + checkStop: (threadId: string) => Promise +} + +/** Codex Stop output that either permits stop or requests one continuation. */ +export type AgentAttentionStopOutput = + | { continue: true; suppressOutput: true } + | { decision: "block"; reason: string } + +const invalidStopInput: AgentAttentionStopOutput = { + decision: "block", + reason: + "Agent Attention could not correlate this Stop event to structured owner state. Repair the hook payload contract before stopping.", +} + +/** Validate only stable task-correlation fields needed by structured owner state. */ +export function isAgentAttentionStopInput(value: unknown): value is AgentAttentionStopInput { + if (!value || typeof value !== "object" || Array.isArray(value)) return false + const input = value as Record + if (input.cwd !== undefined && typeof input.cwd !== "string") return false + if (typeof input.session_id !== "string" || input.session_id.trim() === "") return false + return input.stop_hook_active === undefined || typeof input.stop_hook_active === "boolean" +} + +/** Enforce explicit owner state without reading assistant prose or transcripts. */ +export async function handleAgentAttentionStop( + input: AgentAttentionStopInput, + runtime: AgentAttentionStopRuntime, +): Promise { + if (input.stop_hook_active) return { continue: true, suppressOutput: true } + const check = await runtime.checkStop(input.session_id) + if (check.hook_action === "continue") { + return { + decision: "block", + reason: + check.reason ?? + "Agent Attention owner state requires an actionable repair before stopping.", + } + } + return { continue: true, suppressOutput: true } +} + +/** Convert owner failures into one actionable Stop block. */ +export async function runAgentAttentionStop( + input: unknown, + runtime: AgentAttentionStopRuntime, +): Promise { + if (!isAgentAttentionStopInput(input)) return invalidStopInput + try { + return await handleAgentAttentionStop(input, runtime) + } catch (error) { + const detail = error instanceof Error ? error.message : "unknown error" + return { + decision: "block", + reason: `Agent Attention could not verify structured owner state. Repair the owner check before stopping: ${detail}`, + } + } +} + +/** Return the fail-closed response for malformed hook stdin. */ +export function invalidAgentAttentionStopInput(): AgentAttentionStopOutput { + return invalidStopInput +} diff --git a/plugin.config.json b/plugin.config.json index 0bdd0c1..45e4420 100644 --- a/plugin.config.json +++ b/plugin.config.json @@ -3,7 +3,7 @@ "name": "agent-plugin-playground", "displayName": "Agent Plugin Playground", "version": "0.1.0", - "description": "Bun-powered native plugin playground for one active experiment at a time", + "description": "Bun-powered Agent Attention approval gates in the native plugin playground", "author": { "name": "My Agent Dojo" }, @@ -14,8 +14,8 @@ "bun" ], "category": "Developer Tools", - "shortDescription": "One active plugin experiment", - "longDescription": "Use a private playground to inspect native plugin declarations, run the capability tour, and prepare one experiment without claiming release qualification.", + "shortDescription": "Route one blocking approval", + "longDescription": "Route one genuine yes or no Codex approval blocker into Apple Reminders with exact-task state, installed Stop enforcement, and retained outcome receipts.", "capabilities": [ "Execute verified Bun code", "Download Bun after approval", @@ -23,10 +23,12 @@ "Use network during repair", "Inspect native plugin declarations", "Prove lifecycle hook mechanics", - "Verify with a native subagent" + "Verify with a native subagent", + "Route Apple Reminders approval gates", + "Enforce exact-task Stop state" ], "defaultPrompts": [ - "Run the native plugin capability tour." + "Route this blocking yes or no approval through Agent Attention." ], "brandColor": "#3B5CCC", "composerIcon": "./assets/composer-icon.svg", diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index f688f5f..bd958be 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -3,7 +3,7 @@ "displayName": "Agent Plugin Playground", "version": "0.1.0", "defaultEnabled": false, - "description": "Bun-powered native plugin playground for one active experiment at a time", + "description": "Bun-powered Agent Attention approval gates in the native plugin playground", "author": { "name": "My Agent Dojo" }, diff --git a/plugin/.codex-plugin/plugin.json b/plugin/.codex-plugin/plugin.json index 6497152..3d77eac 100644 --- a/plugin/.codex-plugin/plugin.json +++ b/plugin/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "agent-plugin-playground", "version": "0.1.0", - "description": "Bun-powered native plugin playground for one active experiment at a time", + "description": "Bun-powered Agent Attention approval gates in the native plugin playground", "author": { "name": "My Agent Dojo" }, @@ -15,8 +15,8 @@ "hooks": "./hooks/codex/hooks.json", "interface": { "displayName": "Agent Plugin Playground", - "shortDescription": "One active plugin experiment", - "longDescription": "Use a private playground to inspect native plugin declarations, run the capability tour, and prepare one experiment without claiming release qualification.", + "shortDescription": "Route one blocking approval", + "longDescription": "Route one genuine yes or no Codex approval blocker into Apple Reminders with exact-task state, installed Stop enforcement, and retained outcome receipts.", "developerName": "My Agent Dojo", "category": "Developer Tools", "capabilities": [ @@ -26,10 +26,12 @@ "Use network during repair", "Inspect native plugin declarations", "Prove lifecycle hook mechanics", - "Verify with a native subagent" + "Verify with a native subagent", + "Route Apple Reminders approval gates", + "Enforce exact-task Stop state" ], "defaultPrompt": [ - "Run the native plugin capability tour." + "Route this blocking yes or no approval through Agent Attention." ], "brandColor": "#3B5CCC", "composerIcon": "./assets/composer-icon.svg", diff --git a/plugin/bin/agent-attention b/plugin/bin/agent-attention new file mode 100755 index 0000000..a078ae9 --- /dev/null +++ b/plugin/bin/agent-attention @@ -0,0 +1,9 @@ +#!/bin/sh +# Generated from runtime/skill-catalog.json. Edit the source, then run bun run generate. +set -eu +case "$0" in +*/*) launcher_dir=${0%/*} ;; +*) launcher_dir=. ;; +esac +plugin_root=$(CDPATH='' cd -- "$launcher_dir/.." && pwd -P) +exec "$plugin_root/runtime/runtime-exec" run agent-attention -- "$@" diff --git a/plugin/hooks/codex/hooks.json b/plugin/hooks/codex/hooks.json index a34660f..f994689 100644 --- a/plugin/hooks/codex/hooks.json +++ b/plugin/hooks/codex/hooks.json @@ -16,6 +16,12 @@ { "type": "command", "command": "\"${PLUGIN_ROOT}/hooks/native-capability-hook\" Stop codex" + }, + { + "type": "command", + "command": "\"${PLUGIN_ROOT}/bin/agent-attention\" hook-stop", + "timeout": 10, + "statusMessage": "Checking Agent Attention owner state" } ] } diff --git a/plugin/runtime/agent-attention-5133bb12807899e9.js b/plugin/runtime/agent-attention-5133bb12807899e9.js new file mode 100644 index 0000000..fa0d3b5 --- /dev/null +++ b/plugin/runtime/agent-attention-5133bb12807899e9.js @@ -0,0 +1,141 @@ +// @bun +// packages/agent-attention/src/main.ts +import { randomUUID } from "crypto"; +import { join } from "path"; + +// packages/agent-attention/src/stop.ts +var invalidStopInput = { + decision: "block", + reason: "Agent Attention could not correlate this Stop event to structured owner state. Repair the hook payload contract before stopping." +}; +function isAgentAttentionStopInput(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) + return false; + const input = value; + if (input.cwd !== undefined && typeof input.cwd !== "string") + return false; + if (typeof input.session_id !== "string" || input.session_id.trim() === "") + return false; + return input.stop_hook_active === undefined || typeof input.stop_hook_active === "boolean"; +} +async function handleAgentAttentionStop(input, runtime) { + if (input.stop_hook_active) + return { continue: true, suppressOutput: true }; + const check = await runtime.checkStop(input.session_id); + if (check.hook_action === "continue") { + return { + decision: "block", + reason: check.reason ?? "Agent Attention owner state requires an actionable repair before stopping." + }; + } + return { continue: true, suppressOutput: true }; +} +async function runAgentAttentionStop(input, runtime) { + if (!isAgentAttentionStopInput(input)) + return invalidStopInput; + try { + return await handleAgentAttentionStop(input, runtime); + } catch (error) { + const detail = error instanceof Error ? error.message : "unknown error"; + return { + decision: "block", + reason: `Agent Attention could not verify structured owner state. Repair the owner check before stopping: ${detail}` + }; + } +} +function invalidAgentAttentionStopInput() { + return invalidStopInput; +} + +// packages/agent-attention/src/main.ts +async function writeOutput(stream, content) { + if (content.length === 0) + return; + await new Promise((resolve, reject) => { + stream.write(content, (error) => { + if (error) + reject(error); + else + resolve(); + }); + }); +} +function pythonExecutable() { + return process.env.AGENT_ATTENTION_PYTHON || "python3"; +} +async function runPython(arguments_, timeout) { + const owner = join(import.meta.dir, "agent-attention.py"); + try { + const child = Bun.spawn([pythonExecutable(), owner, ...arguments_], { + stdin: "inherit", + stdout: "pipe", + stderr: "pipe", + ...timeout === undefined ? {} : { timeout } + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited + ]); + return { exitCode, stdout, stderr }; + } catch (error) { + const detail = error instanceof Error ? error.message : "unknown error"; + return { + exitCode: 1, + stdout: `${JSON.stringify({ + contract_id: "agent-attention.approval-gate", + schema_version: "1", + run_id: randomUUID(), + status: "error", + changed: false, + retry_safe: false, + error_category: "missing_python", + next_safe_action: "Install python3 in a standard system location, then retry." + })} +`, + stderr: `Agent Attention could not start python3: ${detail} +` + }; + } +} +function installedStopRuntime() { + return { + checkStop: async (threadId) => { + const result = await runPython(["check-stop", "--thread-id", threadId], 5000); + if (result.exitCode !== 0) + throw new Error(result.stderr.trim() || "owner check failed"); + const parsed = JSON.parse(result.stdout); + if (parsed.hook_action !== "allow" && parsed.hook_action !== "continue") { + throw new Error("Agent Attention stop check returned an invalid action"); + } + return { + hook_action: parsed.hook_action, + ...typeof parsed.reason === "string" ? { reason: parsed.reason } : {} + }; + } + }; +} +async function main() { + const arguments_ = process.argv.slice(2); + if (arguments_[0] === "hook-stop") { + let input; + try { + input = await Bun.stdin.json(); + } catch { + await writeOutput(process.stdout, `${JSON.stringify(invalidAgentAttentionStopInput())} +`); + return 0; + } + const output = await runAgentAttentionStop(input, installedStopRuntime()); + await writeOutput(process.stdout, `${JSON.stringify(output)} +`); + return 0; + } + const result = await runPython(arguments_); + await Promise.all([ + writeOutput(process.stdout, result.stdout), + writeOutput(process.stderr, result.stderr) + ]); + return result.exitCode; +} +process.exitCode = await main(); diff --git a/plugin/runtime/agent-attention.py b/plugin/runtime/agent-attention.py new file mode 100755 index 0000000..6ccdab7 --- /dev/null +++ b/plugin/runtime/agent-attention.py @@ -0,0 +1,1673 @@ +#!/usr/bin/env python3 +"""Minimal Apple Reminders approval gates for Codex tasks.""" + +from __future__ import annotations + +import argparse +import fcntl +import hashlib +import json +import math +import os +import plistlib +import stat +import subprocess +import sys +import time +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, NoReturn + + +COMMAND_CATALOG = ( + {"name": "commands", "summary": "List the machine-readable command surface."}, + {"name": "doctor", "summary": "Check Reminders, configuration, and link readiness."}, + {"name": "configure", "summary": "Bind one explicit Agent Attention list."}, + {"name": "submit", "summary": "Validate and route one structured approval blocker."}, + {"name": "poll", "summary": "Claim at most one completed approval for delivery."}, + {"name": "watch", "summary": "Poll for one approval within a bounded foreground window."}, + {"name": "record-delivery", "summary": "Record a successful Codex task delivery."}, + {"name": "record-outcome", "summary": "Preview or append one bounded terminal outcome."}, + {"name": "check-stop", "summary": "Check structured owner state before a task stops."}, +) +MANAGED_URL_PREFIX = "remindctl URL (managed): " +REMINDCTL_COMMAND_TIMEOUT_SECONDS = 30 + + +class ContractError(Exception): + """Raised when a gate cannot be handled without guessing.""" + + +class ContractArgumentParser(argparse.ArgumentParser): + """Route public usage errors through the structured result boundary.""" + + def error(self, message: str) -> NoReturn: + raise ContractError(f"invalid command arguments: {message}") + + +def default_state_dir() -> Path: + """Return the private user-owned runtime state directory.""" + xdg_state = os.environ.get("XDG_STATE_HOME") + if xdg_state: + path = Path(xdg_state) + if not path.is_absolute(): + raise ContractError("XDG_STATE_HOME must be an absolute path") + return path / "agent-attention" + return Path.home() / ".local" / "state" / "agent-attention" + + +def load_json(path: Path) -> Any: + """Load one JSON document from disk.""" + try: + with path.open(encoding="utf-8") as handle: + return json.load(handle) + except UnicodeDecodeError as error: + raise ContractError(f"persisted JSON is not valid UTF-8: {path.name}") from error + + +def require_json_object(value: Any, *, document_name: str) -> dict[str, Any]: + """Require one JSON object before field access.""" + if not isinstance(value, dict): + raise ContractError(f"{document_name} must be a JSON object") + return value + + +def require_nonempty_text(value: Any, *, field_name: str) -> str: + """Require one nonempty string at a persisted contract boundary.""" + if not isinstance(value, str) or not value.strip(): + raise ContractError(f"{field_name} must be nonempty text") + return value + + +def require_json_object_array(value: Any, *, document_name: str) -> list[dict[str, Any]]: + """Require one JSON array containing only objects.""" + if not isinstance(value, list): + raise ContractError(f"{document_name} must be a JSON array") + if any(not isinstance(item, dict) for item in value): + raise ContractError(f"{document_name} entries must be JSON objects") + return value + + +def require_reminder_inventory(value: Any, *, document_name: str) -> list[dict[str, Any]]: + """Require reminder objects with unique nonempty text IDs.""" + inventory = require_json_object_array(value, document_name=document_name) + seen_ids: set[str] = set() + for item in inventory: + reminder_id = require_nonempty_text( + item.get("id"), field_name=f"{document_name} reminder ID" + ) + if reminder_id in seen_ids: + raise ContractError(f"{document_name} reminder IDs must be unique") + seen_ids.add(reminder_id) + return inventory + + +def write_json(path: Path, value: Any, *, exclusive: bool = False) -> bool: + """Write private JSON atomically enough for single-host gate custody.""" + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + if exclusive: + try: + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + except FileExistsError: + return False + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + json.dump(value, handle, sort_keys=True) + handle.write("\n") + return True + + temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") + try: + descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + json.dump(value, handle, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + return True + + +def acquire_request_lock( + path: Path, value: dict[str, Any], *, blocking: bool = False +) -> int | None: + """Acquire one crash-recoverable process-owned request lock.""" + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + flags = os.O_RDWR | os.O_CREAT + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open(path, flags, 0o600) + try: + operation = fcntl.LOCK_EX if blocking else fcntl.LOCK_EX | fcntl.LOCK_NB + fcntl.flock(descriptor, operation) + except BlockingIOError: + os.close(descriptor) + return None + try: + encoded = (json.dumps(value, sort_keys=True) + "\n").encode() + os.fchmod(descriptor, 0o600) + os.ftruncate(descriptor, 0) + os.lseek(descriptor, 0, os.SEEK_SET) + remaining = memoryview(encoded) + while remaining: + written = os.write(descriptor, remaining) + if written <= 0: + raise OSError("request lock metadata write made no progress") + remaining = remaining[written:] + os.fsync(descriptor) + except BaseException: + fcntl.flock(descriptor, fcntl.LOCK_UN) + os.close(descriptor) + raise + return descriptor + + +def release_request_lock(_path: Path, descriptor: int) -> None: + """Release one persistent request-lock inode.""" + try: + fcntl.flock(descriptor, fcntl.LOCK_UN) + finally: + os.close(descriptor) + + +def append_audit(path: Path, value: Any) -> None: + """Append one private JSON audit event.""" + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open(path, flags, 0o600) + try: + metadata = os.fstat(descriptor) + if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: + raise ContractError("audit path must be one singly linked regular file") + os.fchmod(descriptor, 0o600) + except BaseException: + os.close(descriptor) + raise + with os.fdopen(descriptor, "a", encoding="utf-8") as handle: + handle.write(json.dumps(value, sort_keys=True) + "\n") + + +def run_json(command: list[str], *, timeout_seconds: float | None = None) -> Any: + """Run a command whose primary output is one JSON document.""" + try: + completed = subprocess.run( + command, + capture_output=True, + text=True, + timeout=timeout_seconds, + ) + except subprocess.TimeoutExpired as error: + raise ContractError("command exceeded the bounded execution window") from error + except UnicodeDecodeError as error: + raise ContractError("command output is not valid UTF-8") from error + if completed.returncode != 0: + detail = completed.stderr.strip() or completed.stdout.strip() + raise ContractError(f"command failed: {detail}") + try: + return json.loads(completed.stdout) + except json.JSONDecodeError as error: + raise ContractError("command returned invalid JSON") from error + + +def base_result(status: str, **values: Any) -> dict[str, Any]: + """Build one correlated machine-readable result.""" + return { + "contract_id": "agent-attention.approval-gate", + "schema_version": "1", + "run_id": str(uuid.uuid4()), + "status": status, + **values, + } + + +def read_config(state_dir: Path) -> dict[str, Any]: + """Load the explicit list binding and reject incomplete configuration.""" + path = state_dir / "config.json" + if not path.exists(): + raise ContractError( + "not configured; run configure with the exact Agent Attention list ID" + ) + config = require_json_object(load_json(path), document_name="config") + if config.get("version") != 1: + raise ContractError("unsupported config version") + list_config = config.get("list") + if not isinstance(list_config, dict): + raise ContractError("configured list ID and name are required") + require_nonempty_text(list_config.get("id"), field_name="configured list ID") + require_nonempty_text(list_config.get("name"), field_name="configured list name") + return config + + +def configure(args: argparse.Namespace) -> dict[str, Any]: + """Persist one explicit Apple Reminders list binding.""" + state_dir: Path = args.state_dir + list_id = require_nonempty_text(args.list_id, field_name="list ID") + list_name = require_nonempty_text(args.list_name, field_name="list name") + config = { + "version": 1, + "list": {"id": list_id, "name": list_name}, + } + write_json(state_dir / "config.json", config) + return base_result( + "configured", + changed=True, + list={"id": list_id, "name": list_name}, + next_safe_action="run doctor", + ) + + +def validate_thread_id(value: str) -> str: + """Normalize a Codex thread UUID for stable URLs and mappings.""" + try: + return str(uuid.UUID(value)) + except ValueError as error: + raise ContractError("thread ID must be one UUID") from error + + +def router_notes(intent: dict[str, Any]) -> tuple[str, str]: + """Render one calm recommendation-first approval contract.""" + required_line = f"Approval meaning: {intent['approval_meaning']}" + lines = [ + f"Recommended: {intent['recommendation']}", + "Next: Tick to approve. Open Codex to discuss or disagree.", + "", + f"Consequence: {intent['consequence']}", + f"Continuation: {intent['continuation']}", + "", + required_line, + "Tick = approve only this action.", + ] + return "\n".join(lines), required_line + + +def validate_text_field(intent: dict[str, Any], field: str, limit: int) -> str: + """Require one bounded single-line structured intent field.""" + value = intent.get(field) + if not isinstance(value, str): + raise ContractError(f"structured intent field must be text: {field}") + value = value.strip() + if not value or "\n" in value or "\r" in value or len(value) > limit: + raise ContractError(f"structured intent field is invalid: {field}") + return value + + +def approval_intent(args: argparse.Namespace) -> dict[str, Any]: + """Build the structured intent owned by the public submit parser.""" + thread_id = validate_thread_id(args.thread_id) + return { + "version": 1, + "decision_type": args.decision_type, + "unblocks_paused_task": args.unblocks_paused_task, + "action": args.action, + "recommendation": args.recommendation, + "consequence": args.consequence, + "thread_id": thread_id, + "discussion_link": args.discussion_link, + "continuation": args.continuation, + "approval_meaning": args.approval_meaning, + } + + +def validate_approval_intent(intent: dict[str, Any]) -> tuple[dict[str, Any], list[str]]: + """Admit only an explicit yes/no decision that unblocks one paused task.""" + reasons: list[str] = [] + if intent.get("decision_type") != "yes_no": + reasons.append("decision_type must be yes_no; discussion or multi-choice stays in Codex") + if intent.get("unblocks_paused_task") is not True: + reasons.append("the approval must unblock a paused owning task") + + normalized = {**intent} + for field, limit in ( + ("action", 100), + ("recommendation", 200), + ("consequence", 300), + ("continuation", 300), + ("approval_meaning", 300), + ): + try: + normalized[field] = validate_text_field(intent, field, limit) + except ContractError as error: + reasons.append(str(error)) + + thread_id = validate_thread_id(str(intent.get("thread_id", ""))) + normalized["thread_id"] = thread_id + expected_link = f"agent-attention://threads/{thread_id}" + if intent.get("discussion_link") != expected_link: + reasons.append("discussion_link must target the exact owning Codex task") + normalized["discussion_link"] = expected_link + meaning = normalized.get("approval_meaning", "") + if isinstance(meaning, str) and not meaning.casefold().startswith("approve "): + reasons.append("approval_meaning must explicitly begin with Approve") + return normalized, reasons + + +def request_path(state_dir: Path, thread_id: str) -> Path: + """Return the exact structured owner-state path for one Codex task.""" + return state_dir / "requests" / f"{validate_thread_id(thread_id)}.json" + + +def completed_or_active_request_result( + state_dir: Path, existing: Any, request_identifier: str, thread_id: str +) -> dict[str, Any] | None: + """Return an idempotent result or reject a distinct active gate.""" + existing = require_json_object(existing, document_name="request state") + status = existing.get("status") + existing_request_id = existing.get("request_id") + if not isinstance(existing_request_id, str) or len(existing_request_id) != 64 or any( + character not in "0123456789abcdef" for character in existing_request_id + ): + raise ContractError("request state request_id must be a lowercase SHA-256 digest") + if existing.get("thread_id") != thread_id: + raise ContractError("request state thread_id does not match its owner path") + if status == "declared": + matching_mappings = [] + for mapping_path in sorted((state_dir / "gates").glob("*.json")): + mapping = validate_gate_mapping( + load_json(mapping_path), expected_reminder_id=mapping_path.stem + ) + if ( + mapping.get("request_id") == existing_request_id + and mapping["thread_id"] == thread_id + ): + matching_mappings.append(mapping) + if len(matching_mappings) > 1: + raise ContractError("declared request resolves to multiple published gates") + if matching_mappings: + mapping = matching_mappings[0] + existing = { + **existing, + "status": "gated", + "reminder_id": mapping["reminder_id"], + "updated_at": datetime.now(timezone.utc).isoformat(), + } + write_json(request_path(state_dir, thread_id), existing) + status = "gated" + unresolved_attempt = status == "declared" or ( + status == "repair" + and (state_dir / "request-claims" / f"{existing_request_id}.json").exists() + ) + if unresolved_attempt: + if existing_request_id == request_identifier: + return base_result( + "claimed", + changed=False, + request_id=request_identifier, + thread_id=thread_id, + repair="inspect exact request state before retry; no second gate was created", + ) + raise ContractError("the owning task has an unresolved gate creation attempt") + if status not in {"gated", "delivered", "completed"}: + return None + if existing_request_id == request_identifier: + return base_result( + "already_gated", + changed=False, + request_id=request_identifier, + reminder_id=existing.get("reminder_id"), + thread_id=thread_id, + ) + if status in {"gated", "delivered"}: + raise ContractError("the owning task already has a different admitted gate") + return None + + +def update_request_state( + state_dir: Path, + mapping: dict[str, Any], + status: str, + **values: Any, +) -> None: + """Advance matching router state without requiring it for legacy V1 gates.""" + path = request_path(state_dir, mapping["thread_id"]) + if not path.exists(): + return + lock_path = state_dir / "request-locks" / f"{mapping['thread_id']}.json" + descriptor = acquire_request_lock( + lock_path, + {"thread_id": mapping["thread_id"], "operation": "update-request-state"}, + blocking=True, + ) + assert descriptor is not None + try: + if not path.exists(): + return + state = require_json_object(load_json(path), document_name="request state") + if state.get("reminder_id") != mapping["reminder_id"]: + return + if state.get("status") == "completed" and status != "completed": + return + updated = { + **state, + "status": status, + "updated_at": datetime.now(timezone.utc).isoformat(), + **values, + } + for field, value in values.items(): + if value is None: + updated.pop(field, None) + write_json(path, updated) + finally: + release_request_lock(lock_path, descriptor) + + +def reconcile_declared_request( + state_dir: Path, mapping: dict[str, Any] +) -> None: + """Bind a published gate back to request state after a publication crash.""" + request_identifier = mapping.get("request_id") + if not isinstance(request_identifier, str): + return + path = request_path(state_dir, mapping["thread_id"]) + if not path.exists(): + return + state = require_json_object(load_json(path), document_name="request state") + if state.get("status") != "declared": + return + if ( + state.get("request_id") != request_identifier + or state.get("thread_id") != mapping["thread_id"] + ): + return + write_json( + path, + { + **state, + "status": "gated", + "reminder_id": mapping["reminder_id"], + "updated_at": datetime.now(timezone.utc).isoformat(), + }, + ) + + +def _submit_approval(args: argparse.Namespace) -> dict[str, Any]: + """Validate, deduplicate, and optionally create one native approval gate.""" + state_dir: Path = args.state_dir + intent, reasons = validate_approval_intent(approval_intent(args)) + thread_id = intent["thread_id"] + path = request_path(state_dir, thread_id) + request_identifier = hashlib.sha256( + json.dumps(intent, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + + if path.exists(): + existing_result = completed_or_active_request_result( + state_dir, load_json(path), request_identifier, thread_id + ) + if existing_result: + return existing_result + + if args.execute: + request_lock_path = state_dir / "request-locks" / f"{thread_id}.json" + request_lock_descriptor = acquire_request_lock( + request_lock_path, + { + "request_id": request_identifier, + "thread_id": thread_id, + "locked_at": datetime.now(timezone.utc).isoformat(), + }, + ) + if request_lock_descriptor is None: + return base_result( + "claimed", + changed=False, + request_id=request_identifier, + thread_id=thread_id, + repair="inspect exact request state before retry; no second gate was created", + ) + args._agent_attention_request_lock = ( + request_lock_path, + request_lock_descriptor, + ) + if path.exists(): + existing_result = completed_or_active_request_result( + state_dir, load_json(path), request_identifier, thread_id + ) + if existing_result: + return existing_result + + if reasons: + repair = "; ".join(reasons) + if args.execute: + write_json( + path, + { + "version": 1, + "request_id": request_identifier, + "thread_id": thread_id, + "intent": intent, + "status": "repair", + "repair": repair, + "updated_at": datetime.now(timezone.utc).isoformat(), + }, + ) + return base_result( + "rejected", + changed=args.execute, + request_id=request_identifier, + thread_id=thread_id, + repair=repair, + next_safe_action="repair the structured intent or continue discussion in Codex", + ) + + config = read_config(state_dir) + notes, required_line = router_notes(intent) + notification_at = datetime.now(timezone.utc).isoformat() + preview = { + "title": f"[APPROVE] {intent['action']}", + "notes": notes, + "url": intent["discussion_link"], + "priority": "none", + "notification_at": notification_at, + "list": config["list"], + "thread_id": thread_id, + } + if not args.execute: + return base_result( + "admitted_preview", + changed=False, + request_id=request_identifier, + preview=preview, + side_effect="create one Apple Reminder with one immediate native alert", + next_safe_action="review the structured gate, then rerun with --execute", + ) + + request_claim_path = state_dir / "request-claims" / f"{request_identifier}.json" + if request_claim_path.exists() and not path.exists(): + # Owner state is published before creation starts. Under the held per-thread + # lock, an absent owner proves even a truncated claim is safe to reclaim. + request_claim_path.unlink() + if not write_json( + request_claim_path, + { + "request_id": request_identifier, + "thread_id": thread_id, + "claimed_at": datetime.now(timezone.utc).isoformat(), + }, + exclusive=True, + ): + return base_result( + "claimed", + changed=False, + request_id=request_identifier, + thread_id=thread_id, + repair="inspect exact request state before retry; no second gate was created", + ) + + declared = { + "version": 1, + "request_id": request_identifier, + "thread_id": thread_id, + "intent": intent, + "status": "declared", + "updated_at": notification_at, + } + write_json(path, declared) + try: + created = run_json( + [ + "remindctl", + "add", + "--title", + preview["title"], + "--list-id", + config["list"]["id"], + "--notes", + notes, + "--url", + intent["discussion_link"], + "--priority", + "none", + "--alarm", + notification_at, + "--json", + "--no-input", + ], + timeout_seconds=REMINDCTL_COMMAND_TIMEOUT_SECONDS, + ) + except OSError as error: + request_claim_path.unlink(missing_ok=True) + write_json( + path, + { + **declared, + "status": "repair", + "repair": f"gate creation did not start; repair remindctl before retry: {error}", + "updated_at": datetime.now(timezone.utc).isoformat(), + }, + ) + raise + except ContractError as error: + write_json( + path, + { + **declared, + "status": "repair", + "repair": f"gate creation failed; inspect exact configured list before retry: {error}", + "updated_at": datetime.now(timezone.utc).isoformat(), + }, + ) + raise + reminder_id = created.get("id") if isinstance(created, dict) else None + if not reminder_id: + write_json( + path, + { + **declared, + "status": "repair", + "repair": "creation response lacks a stable reminder ID; inspect before retry", + "updated_at": datetime.now(timezone.utc).isoformat(), + }, + ) + raise ContractError("created reminder response lacks a stable ID; inspect before retry") + try: + reminder_id = validate_reminder_id(reminder_id) + except ContractError as error: + write_json( + path, + { + **declared, + "status": "repair", + "repair": f"created reminder returned an invalid stable ID: {error}", + "updated_at": datetime.now(timezone.utc).isoformat(), + }, + ) + raise + try: + created_inventory = read_inventory( + config, timeout_seconds=REMINDCTL_COMMAND_TIMEOUT_SECONDS + ) + except (ContractError, json.JSONDecodeError, OSError, subprocess.SubprocessError) as error: + write_json( + path, + { + **declared, + "status": "repair", + "reminder_id": reminder_id, + "repair": f"created reminder could not be verified; inspect exact stable ID before retry: {error}", + "updated_at": datetime.now(timezone.utc).isoformat(), + }, + ) + raise + created_matches = [item for item in created_inventory if item.get("id") == reminder_id] + if len(created_matches) != 1: + write_json( + path, + { + **declared, + "status": "repair", + "reminder_id": reminder_id, + "repair": "created stable reminder ID did not resolve exactly once; inspect before retry", + "updated_at": datetime.now(timezone.utc).isoformat(), + }, + ) + raise ContractError("created stable reminder ID did not resolve exactly once") + created_gate = created_matches[0] + def fail_created_verification(field: str) -> NoReturn: + message = f"created reminder failed exact verification: {field}" + write_json( + path, + { + **declared, + "status": "repair", + "reminder_id": reminder_id, + "repair": message, + "updated_at": datetime.now(timezone.utc).isoformat(), + }, + ) + raise ContractError(message) + + for field, expected in ( + ("listID", config["list"]["id"]), + ("title", preview["title"]), + ("url", intent["discussion_link"]), + ("priority", "none"), + ): + if created_gate.get(field) != expected: + fail_created_verification(field) + try: + created_notes = reminder_notes(created_gate.get("notes")) + except ContractError: + fail_created_verification("notes") + if not created_notes.startswith(notes): + fail_created_verification("notes") + if created_gate.get("isCompleted") is not False: + fail_created_verification("isCompleted") + + mapping = { + "version": 1, + "list": config["list"], + "reminder_id": reminder_id, + "expected_title": preview["title"], + "required_notes_line": required_line, + "thread_id": thread_id, + "approval_meaning": intent["approval_meaning"], + "created_at": notification_at, + "request_id": request_identifier, + } + if not write_json( + state_dir / "gates" / f"{reminder_id}.json", mapping, exclusive=True + ): + write_json( + path, + { + **declared, + "status": "repair", + "reminder_id": reminder_id, + "repair": "stable reminder ID mapping already exists; inspect before retry", + "updated_at": datetime.now(timezone.utc).isoformat(), + }, + ) + raise ContractError("stable reminder ID mapping already exists; inspect before retry") + write_json( + path, + { + **declared, + "status": "gated", + "reminder_id": reminder_id, + "notification_at": notification_at, + "updated_at": datetime.now(timezone.utc).isoformat(), + }, + ) + return base_result( + "gated", + changed=True, + request_id=request_identifier, + reminder_id=reminder_id, + thread_id=thread_id, + notification_count=1, + next_safe_action="keep the owning task paused until exact completion delivery", + ) + + +def submit_approval(args: argparse.Namespace) -> dict[str, Any]: + """Release the owned per-thread request lock on every terminal path.""" + try: + return _submit_approval(args) + finally: + request_lock = getattr(args, "_agent_attention_request_lock", None) + if ( + isinstance(request_lock, tuple) + and len(request_lock) == 2 + and isinstance(request_lock[0], Path) + and isinstance(request_lock[1], int) + ): + release_request_lock(request_lock[0], request_lock[1]) + + +def read_inventory( + config: dict[str, Any], *, timeout_seconds: float | None = None +) -> list[dict[str, Any]]: + """Read only the configured Apple Reminders list.""" + inventory = run_json( + [ + "remindctl", + "show", + "all", + "--list-id", + config["list"]["id"], + "--json", + "--no-input", + ], + timeout_seconds=timeout_seconds, + ) + return require_reminder_inventory(inventory, document_name="remindctl inventory") + + +def event_id(mapping: dict[str, Any]) -> str: + """Bind one approval event to its reminder, thread, and exact meaning.""" + payload = { + "approval_meaning": mapping["approval_meaning"], + "reminder_id": mapping["reminder_id"], + "thread_id": mapping["thread_id"], + } + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + return hashlib.sha256(encoded).hexdigest() + + +def validate_event_id(value: str) -> str: + """Require the exact lowercase SHA-256 receipt key shape.""" + if len(value) != 64 or any(character not in "0123456789abcdef" for character in value): + raise ContractError("event ID must be exactly 64 lowercase hexadecimal characters") + return value + + +def validate_reminder_id(value: Any) -> str: + """Reject path syntax while preserving one opaque stable reminder ID.""" + if ( + not isinstance(value, str) + or not value + or value in {".", ".."} + or "/" in value + or "\\" in value + or "\x00" in value + ): + raise ContractError("reminder ID must be one opaque path segment") + return value + + +def validate_event_binding( + value: Any, identifier: str, *, document_name: str +) -> dict[str, Any]: + """Reprove one claim or receipt against its exact event key.""" + required = ("approval_meaning", "event_id", "reminder_id", "thread_id") + if not isinstance(value, dict) or any( + not isinstance(value.get(field), str) or not value[field] for field in required + ): + raise ContractError(f"{document_name} lacks the exact event binding") + if value["event_id"] != identifier or event_id(value) != identifier: + raise ContractError(f"{document_name} does not match the exact event ID") + return value + + +def validate_gate_mapping( + value: Any, *, expected_reminder_id: str | None = None +) -> dict[str, Any]: + """Require one complete gate mapping before deriving identity.""" + mapping = require_json_object(value, document_name="gate mapping") + if mapping.get("version") != 1: + raise ContractError("unsupported gate mapping version") + reminder_id = validate_reminder_id(mapping.get("reminder_id")) + if expected_reminder_id is not None and reminder_id != expected_reminder_id: + raise ContractError("gate mapping stable reminder ID does not match") + for field in ( + "approval_meaning", + "expected_title", + "required_notes_line", + "thread_id", + ): + require_nonempty_text(mapping.get(field), field_name=f"gate mapping {field}") + validate_thread_id(mapping["thread_id"]) + list_config = mapping.get("list") + if not isinstance(list_config, dict): + raise ContractError("gate mapping list must be a JSON object") + require_nonempty_text(list_config.get("id"), field_name="gate mapping list ID") + require_nonempty_text(list_config.get("name"), field_name="gate mapping list name") + return mapping + + +def outcome_id(mapping: dict[str, Any], outcome: str, finished_at: str) -> str: + """Bind one terminal outcome receipt to its delivered approval event.""" + payload = { + "event_id": event_id(mapping), + "finished_at": finished_at, + "outcome": outcome, + } + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + return hashlib.sha256(encoded).hexdigest() + + +def validate_outcome(value: str) -> str: + """Accept one concise outcome line, never project history.""" + outcome = value.strip() + if not outcome or "\n" in outcome or "\r" in outcome or len(outcome) > 200: + raise ContractError("outcome must be one concise line of at most 200 characters") + return outcome + + +def validate_finished_at(value: str) -> str: + """Require an explicit timezone-aware terminal timestamp.""" + parse_timezone_aware_timestamp(value, field_name="finished-at") + return value + + +def parse_timezone_aware_timestamp(value: Any, *, field_name: str) -> datetime: + """Parse one ISO 8601 timestamp with an explicit timezone.""" + if not isinstance(value, str): + raise ContractError(f"{field_name} must be an ISO 8601 timestamp") + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as error: + raise ContractError(f"{field_name} must be an ISO 8601 timestamp") from error + if parsed.tzinfo is None: + raise ContractError(f"{field_name} must include a timezone") + return parsed + + +def append_outcome_notes(current_notes: str, addition: str) -> str: + """Append an outcome while preserving remindctl's managed URL footer.""" + lines = current_notes.rstrip().splitlines() + managed_url = lines[-1] if lines and lines[-1].startswith(MANAGED_URL_PREFIX) else None + if managed_url: + lines = lines[:-1] + body = "\n".join(lines).rstrip() + updated = f"{body}\n\n{addition}" if body else addition + if managed_url: + updated = f"{updated}\n\n{managed_url}" + return updated + + +def contains_outcome_notes(current_notes: str, addition: str) -> bool: + """Recognize one exact outcome block with an optional managed URL footer.""" + return current_notes == append_outcome_notes(current_notes.replace(addition, "").strip(), addition) + + +def read_gate_mapping(state_dir: Path, reminder_id: str) -> dict[str, Any]: + """Load only the mapping owned by one exact stable reminder ID.""" + reminder_id = validate_reminder_id(reminder_id) + path = state_dir / "gates" / f"{reminder_id}.json" + if not path.exists(): + raise ContractError("no gate mapping exists for the exact stable reminder ID") + return validate_gate_mapping(load_json(path), expected_reminder_id=reminder_id) + + +def validate_delivery_receipt( + receipt: Any, mapping: dict[str, Any], identifier: str +) -> dict[str, Any]: + """Require one receipt bound to the exact delivered approval contract.""" + receipt = require_json_object(receipt, document_name="delivery receipt") + expected = { + "approval_meaning": mapping["approval_meaning"], + "event_id": identifier, + "reminder_id": mapping["reminder_id"], + "thread_id": mapping["thread_id"], + } + for field, value in expected.items(): + if receipt.get(field) != value: + raise ContractError(f"delivery receipt does not match gate field: {field}") + validate_event_binding(receipt, identifier, document_name="delivery receipt") + parse_timezone_aware_timestamp( + receipt.get("delivered_at"), field_name="delivery receipt delivered_at" + ) + return receipt + + +def reminder_notes(value: Any) -> str: + """Normalize absent reminder notes and reject schema drift.""" + if value is None: + return "" + if not isinstance(value, str): + raise ContractError("reminder notes must be text") + return value + + +def read_exact_completed_reminder(reminder_id: str, list_id: str) -> dict[str, Any]: + """Resolve one stable ID inside the configured list's Completed view.""" + inventory = run_json( + [ + "remindctl", + "show", + "completed", + "--list-id", + list_id, + "--json", + "--no-input", + ], + timeout_seconds=REMINDCTL_COMMAND_TIMEOUT_SECONDS, + ) + inventory = require_reminder_inventory( + inventory, document_name="completed reminder inventory" + ) + matches = [item for item in inventory if item.get("id") == reminder_id] + if len(matches) != 1: + raise ContractError("exact stable reminder ID did not resolve once in Completed history") + return matches[0] + + +def validate_outcome_target( + reminder: dict[str, Any], mapping: dict[str, Any], config: dict[str, Any] +) -> None: + """Reprove identity, meaning, list, and completed state before mutation.""" + if reminder.get("listID") != config["list"]["id"]: + raise ContractError("reminder resolved outside the configured list") + if mapping.get("list", {}).get("id") != config["list"]["id"]: + raise ContractError("gate mapping list does not match configuration") + if reminder.get("title") != mapping.get("expected_title"): + raise ContractError("reminder title changed; refusing outcome update") + if mapping.get("required_notes_line") not in reminder_notes( + reminder.get("notes") + ).splitlines(): + raise ContractError("approval meaning is absent from reminder notes") + if reminder.get("isCompleted") is not True or not reminder.get("completionDate"): + raise ContractError("outcome requires an already completed reminder") + + +def record_outcome(args: argparse.Namespace) -> dict[str, Any]: + """Preview or append one bounded outcome to one delivered completed gate.""" + state_dir: Path = args.state_dir + outcome = validate_outcome(args.outcome) + finished_at = validate_finished_at(args.finished_at) + config = read_config(state_dir) + mapping = read_gate_mapping(state_dir, args.reminder_id) + delivery_id = event_id(mapping) + delivery_path = state_dir / "receipts" / f"{delivery_id}.json" + if not delivery_path.exists(): + raise ContractError("cannot record outcome without the delivery receipt") + validate_delivery_receipt(load_json(delivery_path), mapping, delivery_id) + + identifier = outcome_id(mapping, outcome, finished_at) + receipt_path = state_dir / "outcomes" / f"{delivery_id}.json" + if receipt_path.exists(): + receipt = require_json_object( + load_json(receipt_path), document_name="outcome receipt" + ) + if receipt.get("outcome_id") != identifier: + raise ContractError("a different terminal outcome is already recorded for this gate") + update_request_state( + state_dir, + mapping, + "completed", + outcome_id=identifier, + finished_at=finished_at, + ) + return base_result( + "already_recorded", + changed=False, + outcome_id=identifier, + reminder_id=args.reminder_id, + ) + + addition = f"Outcome: {outcome}\nFinished: {finished_at}" + if not args.execute: + before = read_exact_completed_reminder(args.reminder_id, config["list"]["id"]) + validate_outcome_target(before, mapping, config) + return base_result( + "preview", + changed=False, + reminder_id=args.reminder_id, + append=addition, + side_effect="append outcome to exact completed Apple Reminder", + next_safe_action="review preview, then rerun with --execute", + ) + + claim_path = state_dir / "outcome-claims" / f"{delivery_id}.json" + claim = { + "event_id": delivery_id, + "finished_at": finished_at, + "outcome": outcome, + "outcome_id": identifier, + "reminder_id": args.reminder_id, + } + claim_created = write_json(claim_path, claim, exclusive=True) + if not claim_created: + existing_claim = require_json_object( + load_json(claim_path), document_name="outcome claim" + ) + if existing_claim.get("outcome_id") != identifier: + raise ContractError("a different terminal outcome claim already exists for this gate") + + try: + before = read_exact_completed_reminder(args.reminder_id, config["list"]["id"]) + validate_outcome_target(before, mapping, config) + except (ContractError, json.JSONDecodeError, OSError, subprocess.SubprocessError): + if claim_created: + claim_path.unlink(missing_ok=True) + raise + current_notes = reminder_notes(before.get("notes")) + updated_notes = append_outcome_notes(current_notes, addition) + if contains_outcome_notes(current_notes, addition): + receipt = { + "event_id": delivery_id, + "finished_at": finished_at, + "outcome": outcome, + "outcome_id": identifier, + "recorded_at": datetime.now(timezone.utc).isoformat(), + "reminder_id": args.reminder_id, + "recovered": True, + } + if write_json(receipt_path, receipt, exclusive=True): + append_audit(state_dir / "outcome-audit.jsonl", receipt) + update_request_state( + state_dir, + mapping, + "completed", + outcome_id=identifier, + finished_at=finished_at, + ) + return base_result( + "already_recorded", + changed=False, + outcome_id=identifier, + reminder_id=args.reminder_id, + ) + + if not claim_created: + return base_result( + "claimed", + changed=False, + outcome_id=identifier, + reminder_id=args.reminder_id, + repair="inspect the exact reminder before releasing this outcome claim", + ) + + try: + run_json( + [ + "remindctl", + "edit", + args.reminder_id, + "--notes", + updated_notes, + "--json", + "--no-input", + ], + timeout_seconds=REMINDCTL_COMMAND_TIMEOUT_SECONDS, + ) + except OSError: + claim_path.unlink(missing_ok=True) + raise + after = read_exact_completed_reminder(args.reminder_id, config["list"]["id"]) + validate_outcome_target(after, mapping, config) + if after.get("notes") != updated_notes: + raise ContractError("outcome notes failed exact post-update verification") + for key, value in before.items(): + if key not in {"notes", "lastModifiedDate"} and after.get(key) != value: + raise ContractError(f"unexpected reminder field changed: {key}") + + receipt = { + "event_id": delivery_id, + "finished_at": finished_at, + "outcome": outcome, + "outcome_id": identifier, + "recorded_at": datetime.now(timezone.utc).isoformat(), + "reminder_id": args.reminder_id, + "completion_date": after["completionDate"], + } + if not write_json(receipt_path, receipt, exclusive=True): + return base_result( + "already_recorded", + changed=False, + outcome_id=identifier, + reminder_id=args.reminder_id, + ) + append_audit(state_dir / "outcome-audit.jsonl", receipt) + update_request_state( + state_dir, + mapping, + "completed", + outcome_id=identifier, + finished_at=finished_at, + ) + return base_result( + "recorded", + changed=True, + outcome_id=identifier, + reminder_id=args.reminder_id, + ) + + +def poll(args: argparse.Namespace) -> dict[str, Any]: + """Claim at most one newly completed approval gate for task delivery.""" + state_dir: Path = args.state_dir + config = read_config(state_dir) + mapping_paths = sorted((state_dir / "gates").glob("*.json")) + if not mapping_paths: + return base_result("waiting", changed=False, open_gate_count=0) + inventory = read_inventory( + config, + timeout_seconds=getattr(args, "command_timeout_seconds", None), + ) + items_by_id = {item["id"]: item for item in inventory} + preserved_claim: dict[str, Any] | None = None + + for mapping_path in mapping_paths: + mapping = validate_gate_mapping( + load_json(mapping_path), expected_reminder_id=mapping_path.stem + ) + reconcile_declared_request(state_dir, mapping) + identifier = event_id(mapping) + receipt_path = state_dir / "receipts" / f"{identifier}.json" + if receipt_path.exists(): + receipt = validate_delivery_receipt( + load_json(receipt_path), mapping, identifier + ) + update_request_state( + state_dir, + mapping, + "delivered", + event_id=identifier, + delivered_at=receipt["delivered_at"], + ) + continue + reminder = items_by_id.get(mapping.get("reminder_id")) + repair: str | None = None + if not reminder: + repair = "configured stable reminder ID did not resolve" + elif reminder.get("listID") != config["list"]["id"]: + repair = "reminder resolved outside the configured list" + elif reminder.get("title") != mapping.get("expected_title"): + repair = "reminder title changed; refusing semantic inference" + elif mapping.get("required_notes_line") not in reminder_notes( + reminder.get("notes") + ).splitlines(): + repair = "approval meaning is absent from reminder notes" + if repair: + repair = f"{repair}; reminder ID: {mapping.get('reminder_id')}" + write_json( + mapping_path, + { + **mapping, + "status": "repair", + "repair": repair, + "updated_at": datetime.now(timezone.utc).isoformat(), + }, + ) + update_request_state(state_dir, mapping, "repair", repair=repair) + continue + if mapping.get("status") == "repair": + mapping = { + field: value + for field, value in mapping.items() + if field not in {"status", "repair", "updated_at"} + } + write_json(mapping_path, mapping) + update_request_state(state_dir, mapping, "gated", repair=None) + completion_state = reminder.get("isCompleted") + if completion_state is False: + continue + if completion_state is not True: + repair = f"reminder isCompleted must be a JSON boolean; reminder ID: {mapping['reminder_id']}" + write_json( + mapping_path, + { + **mapping, + "status": "repair", + "repair": repair, + "updated_at": datetime.now(timezone.utc).isoformat(), + }, + ) + update_request_state(state_dir, mapping, "repair", repair=repair) + continue + completion_date = reminder.get("completionDate") + try: + parse_timezone_aware_timestamp( + completion_date, field_name="reminder completionDate" + ) + except ContractError as error: + repair = f"{error}; reminder ID: {mapping['reminder_id']}" + write_json( + mapping_path, + { + **mapping, + "status": "repair", + "repair": repair, + "updated_at": datetime.now(timezone.utc).isoformat(), + }, + ) + update_request_state(state_dir, mapping, "repair", repair=repair) + continue + + claim_path = state_dir / "claims" / f"{identifier}.json" + if claim_path.exists(): + preserved_claim = preserved_claim or base_result( + "claimed", + changed=False, + event_id=identifier, + repair="inspect the destination task before releasing this claim", + ) + continue + + claim = { + "claimed_at": datetime.now(timezone.utc).isoformat(), + "event_id": identifier, + "reminder_id": mapping["reminder_id"], + "completion_date": completion_date, + "thread_id": mapping["thread_id"], + "approval_meaning": mapping["approval_meaning"], + } + if not write_json(claim_path, claim, exclusive=True): + preserved_claim = preserved_claim or base_result( + "claimed", changed=False, event_id=identifier + ) + continue + return base_result( + "deliver", + changed=True, + event_id=identifier, + completion_date=completion_date, + thread_id=mapping["thread_id"], + prompt=( + f"Agent Attention approval received. {mapping['approval_meaning']} " + f"Receipt key: {identifier}. This approval applies only to that action." + ), + next_safe_action="deliver once with the Codex task tool, then record-delivery", + ) + + return preserved_claim or base_result( + "waiting", changed=False, open_gate_count=len(mapping_paths) + ) + + +def watch(args: argparse.Namespace) -> dict[str, Any]: + """Wait in the foreground for one bounded completion detection window.""" + if ( + not math.isfinite(args.interval_seconds) + or args.interval_seconds <= 0 + or args.interval_seconds > 15 + ): + raise ContractError("interval-seconds must be greater than zero and at most 15") + if ( + not math.isfinite(args.timeout_seconds) + or args.timeout_seconds <= 0 + or args.timeout_seconds > 3600 + ): + raise ContractError("timeout-seconds must be greater than zero and at most 3600") + started = time.monotonic() + deadline = started + args.timeout_seconds + polls = 0 + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + return base_result( + "waiting", + changed=False, + poll_count=polls, + watch_elapsed_seconds=round(time.monotonic() - started, 3), + ) + args.command_timeout_seconds = remaining + result = poll(args) + polls += 1 + if result["status"] in {"deliver", "claimed"}: + if result["status"] == "deliver": + completed = parse_timezone_aware_timestamp( + result["completion_date"], field_name="reminder completionDate" + ) + result["detection_latency_seconds"] = round( + max(0.0, (datetime.now(timezone.utc) - completed).total_seconds()), 3 + ) + result["poll_count"] = polls + result["watch_elapsed_seconds"] = round(time.monotonic() - started, 3) + return result + remaining = deadline - time.monotonic() + if remaining <= 0: + return base_result( + "waiting", + changed=False, + poll_count=polls, + watch_elapsed_seconds=round(time.monotonic() - started, 3), + ) + time.sleep(min(args.interval_seconds, remaining)) + + +def record_delivery(args: argparse.Namespace) -> dict[str, Any]: + """Record successful supported task delivery without reopening the reminder.""" + state_dir: Path = args.state_dir + identifier = validate_event_id(args.event_id) + claim_path = state_dir / "claims" / f"{identifier}.json" + receipt_path = state_dir / "receipts" / f"{identifier}.json" + if receipt_path.exists(): + receipt_candidate = require_json_object( + load_json(receipt_path), document_name="delivery receipt" + ) + receipt = validate_delivery_receipt( + receipt_candidate, receipt_candidate, identifier + ) + update_request_state( + state_dir, + receipt, + "delivered", + event_id=identifier, + delivered_at=receipt.get("delivered_at"), + ) + return base_result("already_delivered", changed=False, event_id=identifier) + if not claim_path.exists(): + raise ContractError("cannot record delivery without an existing claim") + try: + tool_result = json.loads(args.tool_result) + except json.JSONDecodeError as error: + raise ContractError("tool result must be valid JSON") from error + if not isinstance(tool_result, dict) or tool_result.get("delivered") is not True: + raise ContractError("tool result does not confirm delivery") + + claim = validate_event_binding( + load_json(claim_path), identifier, document_name="delivery claim" + ) + receipt = { + **claim, + "delivered_at": datetime.now(timezone.utc).isoformat(), + "tool": "codex_app.send_message_to_thread", + "tool_result": tool_result, + } + if not write_json(receipt_path, receipt, exclusive=True): + return base_result("already_delivered", changed=False, event_id=identifier) + append_audit(state_dir / "audit.jsonl", receipt) + update_request_state( + state_dir, + claim, + "delivered", + event_id=identifier, + delivered_at=receipt["delivered_at"], + ) + return base_result("recorded", changed=True, event_id=identifier) + + +def check_stop(args: argparse.Namespace) -> dict[str, Any]: + """Return a stop-hook decision from exact structured owner state only.""" + state_dir: Path = args.state_dir + thread_id = validate_thread_id(args.thread_id) + path = request_path(state_dir, thread_id) + if not path.exists(): + return base_result( + "clear", + changed=False, + thread_id=thread_id, + hook_action="allow", + ) + state = load_json(path) + if ( + not isinstance(state, dict) + or state.get("thread_id") != thread_id + or state.get("version") != 1 + ): + return base_result( + "repair_needed", + changed=False, + thread_id=thread_id, + hook_action="continue", + reason="Agent Attention owner state is malformed. Repair the exact request state before stopping.", + ) + status = state.get("status") + if status == "declared": + return base_result( + "repair_needed", + changed=False, + thread_id=thread_id, + hook_action="continue", + reason="Agent Attention blocker was declared but has no gate or repair result. Finish submit or record an actionable repair.", + ) + if status == "delivered": + intent = state.get("intent") + if not isinstance(intent, dict) or not isinstance(intent.get("continuation"), str): + return base_result( + "repair_needed", + changed=False, + thread_id=thread_id, + hook_action="continue", + reason="Agent Attention owner state is malformed. Repair the exact request state before stopping.", + ) + continuation = intent["continuation"] + return base_result( + "resume_needed", + changed=False, + thread_id=thread_id, + hook_action="continue", + reason=f"Agent Attention approval was delivered. Resume: {continuation}", + ) + if status in {"gated", "repair", "completed"}: + values: dict[str, Any] = {} + if status == "repair": + values["repair"] = state.get("repair") + return base_result( + status, + changed=False, + thread_id=thread_id, + hook_action="allow", + **values, + ) + return base_result( + "repair_needed", + changed=False, + thread_id=thread_id, + hook_action="continue", + reason=f"Agent Attention owner state has unsupported status: {status}", + ) + + +def doctor(args: argparse.Namespace) -> dict[str, Any]: + """Report readiness without reading private message or reminder content.""" + state_dir: Path = args.state_dir + reminders = run_json( + ["remindctl", "doctor", "--for-agent", "--json"], timeout_seconds=30 + ) + if not isinstance(reminders, dict): + raise ContractError("remindctl doctor must return a JSON object") + authorization = reminders.get("authorization") + authorized = bool( + isinstance(authorization, dict) and authorization.get("authorized") is True + ) + config_path = state_dir / "config.json" + config_status: dict[str, Any] = {"configured": False} + if config_path.exists(): + config = read_config(state_dir) + config_status = {"configured": True, "list": config["list"]} + + handler_path = Path.home() / "Applications" / "Agent Attention Link.app" + info_path = handler_path / "Contents" / "Info.plist" + handler = {"installed": False, "path": str(handler_path)} + if info_path.exists(): + try: + with info_path.open("rb") as handle: + info = plistlib.load(handle) + except (plistlib.InvalidFileException, TypeError, ValueError) as error: + raise ContractError("link handler Info.plist is malformed") from error + if not isinstance(info, dict): + raise ContractError("link handler Info.plist must contain a dictionary") + url_types = info.get("CFBundleURLTypes", []) + if not isinstance(url_types, list) or any( + not isinstance(item, dict) for item in url_types + ): + raise ContractError("link handler Info.plist URL types are malformed") + schemes: list[str] = [] + for item in url_types: + item_schemes = item.get("CFBundleURLSchemes", []) + if not isinstance(item_schemes, list) or any( + not isinstance(scheme, str) for scheme in item_schemes + ): + raise ContractError("link handler Info.plist URL schemes are malformed") + schemes.extend(item_schemes) + executable_name = info.get("CFBundleExecutable") + if ( + not isinstance(executable_name, str) + or not executable_name + or executable_name in {".", ".."} + or "/" in executable_name + or "\\" in executable_name + ): + raise ContractError("link handler Info.plist executable is malformed") + executable_path = handler_path / "Contents" / "MacOS" / executable_name + executable_ready = ( + executable_path.is_file() + and not executable_path.is_symlink() + and os.access(executable_path, os.X_OK) + ) + handler["installed"] = "agent-attention" in schemes and executable_ready + + ready = authorized and config_status["configured"] and handler["installed"] + return base_result( + "ready" if ready else "repair_needed", + changed=False, + reminders={"authorized": authorized}, + config=config_status, + link_handler=handler, + next_safe_action=( + "submit or poll an approval gate" + if ready + else "configure the list and run install-link-handler.sh" + ), + ) + + +def commands(_: argparse.Namespace) -> dict[str, Any]: + """Expose the same command catalog used to build rendered help.""" + return base_result("ok", changed=False, commands=list(COMMAND_CATALOG)) + + +def parser() -> argparse.ArgumentParser: + """Build the stable command surface.""" + command = ContractArgumentParser( + prog="agent-attention", + description="Create and deliver bounded Apple Reminders approval gates.", + ) + command.add_argument("--state-dir", type=Path, default=default_state_dir()) + subcommands = command.add_subparsers(dest="command", required=True) + help_by_name = {item["name"]: item["summary"] for item in COMMAND_CATALOG} + + commands_command = subcommands.add_parser("commands", help=help_by_name["commands"]) + commands_command.set_defaults(handler=commands) + + doctor_command = subcommands.add_parser("doctor", help=help_by_name["doctor"]) + doctor_command.set_defaults(handler=doctor) + + configure_command = subcommands.add_parser("configure", help=help_by_name["configure"]) + configure_command.add_argument("--list-id", required=True) + configure_command.add_argument("--list-name", required=True) + configure_command.set_defaults(handler=configure) + + submit_command = subcommands.add_parser("submit", help=help_by_name["submit"]) + submit_command.add_argument("--thread-id", required=True, help="Exact owning Codex task UUID.") + submit_command.add_argument("--decision-type", required=True, help="Use yes_no only for an approvable gate.") + submit_command.add_argument("--unblocks-paused-task", action="store_true", help="Declare that the answer resumes paused work.") + submit_command.add_argument("--action", required=True, help="Short action shown in the reminder title.") + submit_command.add_argument("--recommendation", required=True, help="Recommendation-first decision guidance.") + submit_command.add_argument("--consequence", required=True, help="Bounded consequence of approval.") + submit_command.add_argument("--discussion-link", required=True, help="Exact agent-attention task link.") + submit_command.add_argument("--continuation", required=True, help="Exact work to resume after delivery.") + submit_command.add_argument("--approval-meaning", required=True, help="Sentence beginning with Approve.") + submit_command.add_argument("--execute", action="store_true", help="Create the admitted reminder and one alert.") + submit_command.set_defaults(handler=submit_approval) + + poll_command = subcommands.add_parser("poll", help=help_by_name["poll"]) + poll_command.set_defaults(handler=poll) + + watch_command = subcommands.add_parser("watch", help=help_by_name["watch"]) + watch_command.add_argument("--interval-seconds", type=float, default=5.0, help="Poll interval above 0 and at most 15 seconds.") + watch_command.add_argument("--timeout-seconds", type=float, default=30.0, help="Bounded foreground window above 0 and at most 3600 seconds.") + watch_command.set_defaults(handler=watch) + + record_command = subcommands.add_parser("record-delivery", help=help_by_name["record-delivery"]) + record_command.add_argument("--event-id", required=True) + record_command.add_argument("--tool-result", required=True) + record_command.set_defaults(handler=record_delivery) + + outcome_command = subcommands.add_parser("record-outcome", help=help_by_name["record-outcome"]) + outcome_command.add_argument("--reminder-id", required=True) + outcome_command.add_argument("--outcome", required=True) + outcome_command.add_argument("--finished-at", required=True) + outcome_command.add_argument("--execute", action="store_true") + outcome_command.set_defaults(handler=record_outcome) + + stop_command = subcommands.add_parser("check-stop", help=help_by_name["check-stop"]) + stop_command.add_argument("--thread-id", required=True, help="Exact Codex task UUID from the Stop event.") + stop_command.set_defaults(handler=check_stop) + return command + + +def main() -> int: + """Dispatch one command and emit one JSON result.""" + try: + args = parser().parse_args() + result = args.handler(args) + except (ContractError, json.JSONDecodeError, OSError, subprocess.SubprocessError) as error: + print(str(error), file=sys.stderr) + print( + json.dumps( + base_result( + "error", + changed=False, + change_uncertain=True, + retry_safe=False, + error_category="contract_or_runtime", + next_safe_action="inspect current state before retry", + ) + ) + ) + return 1 + print(json.dumps(result, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugin/runtime/bundle-inventory.json b/plugin/runtime/bundle-inventory.json index c5604e6..2632f98 100644 --- a/plugin/runtime/bundle-inventory.json +++ b/plugin/runtime/bundle-inventory.json @@ -1,6 +1,11 @@ { "schemaVersion": 1, "bundles": { + "agent-attention": { + "path": "runtime/agent-attention-5133bb12807899e9.js", + "bytes": 4589, + "sha256": "5133bb12807899e90fdc0f861ca096ca8be9faa963063742630ce786ff0303c6" + }, "hello-world": { "path": "runtime/hello-world.js", "bytes": 995, diff --git a/plugin/runtime/bundle-inventory.sh b/plugin/runtime/bundle-inventory.sh index 61249bd..ed5d2dc 100644 --- a/plugin/runtime/bundle-inventory.sh +++ b/plugin/runtime/bundle-inventory.sh @@ -2,6 +2,11 @@ # Generated from bundle-inventory.json by scripts/build.ts. Edit workspace sources, then run bun run build. runtime_inventory_select_bundle() { case "$1" in + 'agent-attention') + RUNTIME_BUNDLE_PATH='runtime/agent-attention-5133bb12807899e9.js' + RUNTIME_BUNDLE_BYTES='4589' + RUNTIME_BUNDLE_SHA256='5133bb12807899e90fdc0f861ca096ca8be9faa963063742630ce786ff0303c6' + ;; 'hello-world') RUNTIME_BUNDLE_PATH='runtime/hello-world.js' RUNTIME_BUNDLE_BYTES='995' diff --git a/plugin/runtime/skill-catalog.sh b/plugin/runtime/skill-catalog.sh index bdb8523..92b5b32 100644 --- a/plugin/runtime/skill-catalog.sh +++ b/plugin/runtime/skill-catalog.sh @@ -2,6 +2,10 @@ # Generated from runtime/skill-catalog.json. Edit the source, then run bun run generate. runtime_catalog_select_skill() { case "$1" in + agent-attention) + RUNTIME_SKILL_ENTRY='runtime/agent-attention.js' + RUNTIME_SKILL_PROFILE='bun' + ;; hello-world) RUNTIME_SKILL_ENTRY='runtime/hello-world.js' RUNTIME_SKILL_PROFILE='bun' diff --git a/plugin/skills/agent-attention/SKILL.md b/plugin/skills/agent-attention/SKILL.md new file mode 100644 index 0000000..dadda1f --- /dev/null +++ b/plugin/skills/agent-attention/SKILL.md @@ -0,0 +1,36 @@ +--- +name: agent-attention +description: "Route a genuine yes/no approval blocker from a paused Codex task into Apple Reminders." +--- + +# Agent Attention + +Use only when one explicit yes/no approval blocks the current Codex task. +Keep discussion, disagreement, multi-choice, and unclear requests in Codex. + +## Owner + +`runtime/agent-attention.py` inside the installed plugin owns +admission, exact task binding, native gate creation, structured state, delivery +claims, and outcome receipts. + +## Route + +1. Resolve this skill's installed plugin root, then run + `bin/agent-attention submit --help`. +2. Submit the exact owning task and decision through the help-owned structured + fields. Preview first. +3. If admitted, rerun the same command with `--execute`. One gate and one alert + are the expected side effects. +4. If rejected, follow the returned repair or keep the decision in Codex. +5. When gated, leave the task paused. No response means no approval. +6. After exact-task delivery, apply only the stated approval meaning, run the + continuation, then preview `record-outcome` and rerun it with `--execute`. + +Never infer approval from prose. Never create a second gate for the same +request. Never delete or reopen the completed reminder. + +## Next safe action + +Start with the `submit` preview. Stop on owner-state repair, missing EventKit +access, or an exact Reminders approval gate. diff --git a/runtime/skill-catalog.json b/runtime/skill-catalog.json index ddabb0c..602ee51 100644 --- a/runtime/skill-catalog.json +++ b/runtime/skill-catalog.json @@ -1,6 +1,11 @@ { "schemaVersion": 1, "skills": { + "agent-attention": { + "entry": "runtime/agent-attention.js", + "runtimeProfile": "bun", + "workspace": "packages/agent-attention" + }, "hello-world": { "entry": "runtime/hello-world.js", "runtimeProfile": "bun" diff --git a/scripts/agent-attention-installed.test.ts b/scripts/agent-attention-installed.test.ts new file mode 100644 index 0000000..6921a7f --- /dev/null +++ b/scripts/agent-attention-installed.test.ts @@ -0,0 +1,149 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import { afterAll, beforeAll, expect, test } from "bun:test" + +import { copyPluginPayload } from "./plugin-files" +import { hookDeclarationBody } from "./plugin-config" + +const root = join(import.meta.dir, "..") +const temporaryRoot = mkdtempSync(join(tmpdir(), "agent-attention-installed-")) +const installedRoot = join(temporaryRoot, "installed") + +beforeAll(() => copyPluginPayload(root, installedRoot)) + +afterAll(() => rmSync(temporaryRoot, { recursive: true, force: true })) + +test("Agent Attention is generated into the installable payload", () => { + const catalog = JSON.parse( + readFileSync(join(root, "runtime", "skill-catalog.json"), "utf8"), + ) + const inventory = JSON.parse( + readFileSync(join(installedRoot, "runtime", "bundle-inventory.json"), "utf8"), + ) + + expect(catalog.skills["agent-attention"]).toEqual({ + entry: "runtime/agent-attention.js", + runtimeProfile: "bun", + workspace: "packages/agent-attention", + }) + expect(inventory.bundles["agent-attention"].path).toMatch( + /^runtime\/agent-attention-[a-f0-9]{16}\.js$/, + ) + expect(readFileSync(join(installedRoot, "runtime", "agent-attention.py"))).toEqual( + readFileSync( + join( + root, + "experiments", + "agent-attention", + "runtime", + "agent-attention", + "agent-attention.py", + ), + ), + ) + expect( + readFileSync(join(installedRoot, "skills", "agent-attention", "SKILL.md")), + ).toEqual( + readFileSync( + join(root, "experiments", "agent-attention", "skill", "SKILL.md"), + ), + ) +}) + +test("Codex Stop declares the custody-launched installed adapter", () => { + const declaration = hookDeclarationBody("codex") as { + hooks: { Stop: Array<{ hooks: Array<{ command: string; timeout?: number }> }> } + } + const commands = declaration.hooks.Stop.flatMap((group) => group.hooks) + + expect(commands).toContainEqual({ + type: "command", + command: '"${PLUGIN_ROOT}/bin/agent-attention" hook-stop', + timeout: 10, + statusMessage: "Checking Agent Attention owner state", + }) +}) + +test("installed Stop adapter fails closed when Python is unavailable", () => { + const inventory = JSON.parse( + readFileSync(join(installedRoot, "runtime", "bundle-inventory.json"), "utf8"), + ) + const bundlePath = join(installedRoot, inventory.bundles["agent-attention"].path) + const completed = Bun.spawnSync({ + cmd: [process.execPath, bundlePath, "hook-stop"], + cwd: temporaryRoot, + env: { ...process.env, AGENT_ATTENTION_PYTHON: "/missing/python3" }, + stdin: Buffer.from( + JSON.stringify({ + cwd: temporaryRoot, + session_id: "019fc54e-ff95-7ca1-af49-5720c36fdc0d", + }), + ), + stdout: "pipe", + stderr: "pipe", + }) + const result = JSON.parse(completed.stdout.toString()) + + expect(completed.exitCode, completed.stderr.toString()).toBe(0) + expect(result).toMatchObject({ decision: "block" }) + expect(result.reason).toContain("could not verify structured owner state") + expect(result.reason).toContain("could not start python3") + expect(completed.stderr.toString()).not.toContain(root) +}) + +test("installed Python sidecar passes the bounded lifecycle suite", () => { + const completed = Bun.spawnSync({ + cmd: [ + "python3", + "-m", + "unittest", + "experiments/agent-attention/runtime/agent-attention/test_agent_attention.py", + ], + cwd: root, + env: { + ...process.env, + AGENT_ATTENTION_RUNTIME: join(installedRoot, "runtime", "agent-attention.py"), + }, + stdout: "pipe", + stderr: "pipe", + }) + + expect(completed.exitCode, completed.stderr.toString()).toBe(0) + expect(completed.stderr.toString()).toContain("OK") +}, 30_000) + +test("installed Stop adapter blocks unresolved exact-task state", () => { + const threadId = "019fc54e-ff95-7ca1-af49-5720c36fdc0d" + const stateRoot = join(temporaryRoot, "state") + const requestDirectory = join(stateRoot, "agent-attention", "requests") + mkdirSync(requestDirectory, { recursive: true, mode: 0o700 }) + writeFileSync( + join(requestDirectory, `${threadId}.json`), + `${JSON.stringify({ + version: 1, + thread_id: threadId, + status: "declared", + intent: { continuation: "Run the exact continuation." }, + })}\n`, + { mode: 0o600 }, + ) + const inventory = JSON.parse( + readFileSync(join(installedRoot, "runtime", "bundle-inventory.json"), "utf8"), + ) + const bundlePath = join(installedRoot, inventory.bundles["agent-attention"].path) + const completed = Bun.spawnSync({ + cmd: [process.execPath, bundlePath, "hook-stop"], + cwd: temporaryRoot, + env: { ...process.env, XDG_STATE_HOME: stateRoot }, + stdin: Buffer.from(JSON.stringify({ cwd: temporaryRoot, session_id: threadId })), + stdout: "pipe", + stderr: "pipe", + }) + const result = JSON.parse(completed.stdout.toString()) + + expect(completed.exitCode, completed.stderr.toString()).toBe(0) + expect(result).toMatchObject({ decision: "block" }) + expect(result.reason).toContain("declared but has no gate") +}) diff --git a/scripts/agent-attention-payload.ts b/scripts/agent-attention-payload.ts new file mode 100644 index 0000000..34fca2c --- /dev/null +++ b/scripts/agent-attention-payload.ts @@ -0,0 +1,55 @@ +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + statSync, + writeFileSync, +} from "node:fs" +import { dirname, join } from "node:path" + +import type { GeneratedFile } from "./plugin-config" + +const projections = [ + { + source: "experiments/agent-attention/runtime/agent-attention/agent-attention.py", + target: "plugin/runtime/agent-attention.py", + executable: true, + }, + { + source: "experiments/agent-attention/skill/SKILL.md", + target: "plugin/skills/agent-attention/SKILL.md", + executable: false, + }, +] as const + +/** Render Agent Attention sidecars from reviewed experiment sources. */ +export function renderAgentAttentionPayload(root: string): GeneratedFile[] { + return projections.map(({ source, target }) => ({ + path: target, + contents: readFileSync(join(root, source), "utf8"), + })) +} + +/** Write Agent Attention sidecars and preserve runtime executability. */ +export function writeAgentAttentionPayload(root: string): GeneratedFile[] { + const files = renderAgentAttentionPayload(root) + for (const [index, file] of files.entries()) { + const path = join(root, file.path) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, file.contents) + if (projections[index].executable) chmodSync(path, 0o755) + } + return files +} + +/** Return missing, stale, or non-executable Agent Attention projections. */ +export function checkAgentAttentionPayload(root: string): string[] { + return renderAgentAttentionPayload(root) + .filter((file, index) => { + const path = join(root, file.path) + if (!existsSync(path) || readFileSync(path, "utf8") !== file.contents) return true + return projections[index].executable && (statSync(path).mode & 0o111) === 0 + }) + .map((file) => file.path) +} diff --git a/scripts/build.test.ts b/scripts/build.test.ts index 9d4bee2..3644005 100644 --- a/scripts/build.test.ts +++ b/scripts/build.test.ts @@ -1461,7 +1461,12 @@ function runRepositoryBuild(): { test("workspace bundles build, relocate, and execute without hooks, workspaces, or node_modules", () => { const result = runRepositoryBuild() - expect(Object.keys(result.bundles)).toEqual(["hello-world", "skill-a", "skill-b"]) + expect(Object.keys(result.bundles)).toEqual([ + "agent-attention", + "hello-world", + "skill-a", + "skill-b", + ]) validateBundleClosure(root) const installedRoot = temporaryDirectory("relocated-plugin-") diff --git a/scripts/build.ts b/scripts/build.ts index 2f2cbde..be1f384 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -1638,6 +1638,7 @@ const modelOnlySkillFiles = [ "skills/capability-tour/references/capability-reviewer.md", "skills/runtime-custody/SKILL.md", ] +const agentAttentionSidecarFiles = ["runtime/agent-attention.py"] const allowedPayloadSurfaces = new Set([ ".claude-plugin", ".codex-plugin", @@ -1684,6 +1685,7 @@ export function validateBunOnlyPayload(root: string): void { ...capabilityHookFiles, ...capabilityAssetFiles, ...modelOnlySkillFiles, + ...agentAttentionSidecarFiles, ] const bundleInventory = JSON.parse( readFileSync(join(root, "plugin", "runtime", "bundle-inventory.json"), "utf8"), diff --git a/scripts/generate.ts b/scripts/generate.ts index 5f31a3c..5caa63a 100644 --- a/scripts/generate.ts +++ b/scripts/generate.ts @@ -1,5 +1,9 @@ import { resolve } from "node:path" +import { + checkAgentAttentionPayload, + writeAgentAttentionPayload, +} from "./agent-attention-payload" import { checkNativeCapabilityFixture, writeNativeCapabilityFixture, @@ -39,6 +43,7 @@ for (const argument of arguments_) { const config = loadPluginConfig(root) if (check) { const drifted = [ + ...checkAgentAttentionPayload(root), ...checkGeneratedFiles(root, config), ...checkNativeCapabilityFixture(root), ...checkRuntimeCustodyFiles(root), @@ -50,8 +55,9 @@ if (check) { } } const files = check - ? [] - : [ + ? [] + : [ + ...writeAgentAttentionPayload(root), ...writeGeneratedFiles(root, config), ...writeNativeCapabilityFixture(root), ...writeRuntimeCustodyFiles(root), diff --git a/scripts/native-capability-surface.test.ts b/scripts/native-capability-surface.test.ts index 1a670e1..ba6724c 100644 --- a/scripts/native-capability-surface.test.ts +++ b/scripts/native-capability-surface.test.ts @@ -60,6 +60,12 @@ test("generation projects one exact native hook declaration per supported client type: "command", command: '"${PLUGIN_ROOT}/hooks/native-capability-hook" Stop codex', }, + { + type: "command", + command: '"${PLUGIN_ROOT}/bin/agent-attention" hook-stop', + timeout: 10, + statusMessage: "Checking Agent Attention owner state", + }, ], }, ], @@ -87,7 +93,9 @@ test("checked-in manifests expose one coherent tour identity and relative native ) expect(config.name).toBe("agent-plugin-playground") - expect(config.defaultPrompts).toEqual(["Run the native plugin capability tour."]) + expect(config.defaultPrompts).toEqual([ + "Route this blocking yes or no approval through Agent Attention.", + ]) expect(claudeManifest).toMatchObject({ name: config.name, displayName: config.displayName, diff --git a/scripts/plugin-config.ts b/scripts/plugin-config.ts index 3098920..d8cf266 100644 --- a/scripts/plugin-config.ts +++ b/scripts/plugin-config.ts @@ -444,10 +444,21 @@ export function hookDeclarationBody(client: "claude" | "codex"): Record `"\${${pluginRoot}}/hooks/native-capability-hook" ${event} ${client}` + const stopHooks: Array> = [ + { type: "command", command: command("Stop") }, + ] + if (client === "codex") { + stopHooks.push({ + type: "command", + command: '"${PLUGIN_ROOT}/bin/agent-attention" hook-stop', + timeout: 10, + statusMessage: "Checking Agent Attention owner state", + }) + } return { hooks: { SessionStart: [{ hooks: [{ type: "command", command: command("SessionStart") }] }], - Stop: [{ hooks: [{ type: "command", command: command("Stop") }] }], + Stop: [{ hooks: stopHooks }], }, } } diff --git a/scripts/prove-distribution.ts b/scripts/prove-distribution.ts index 4b29920..c170882 100644 --- a/scripts/prove-distribution.ts +++ b/scripts/prove-distribution.ts @@ -166,6 +166,7 @@ const packagedSkills = entries .filter((entry) => entry.startsWith(`${packageName}/skills/`) && entry.endsWith("/SKILL.md")) .map((entry) => entry.slice(`${packageName}/skills/`.length, -"/SKILL.md".length)) if (JSON.stringify(packagedSkills) !== JSON.stringify([ + "agent-attention", "capability-tour", "hello-world", "runtime-custody", @@ -177,8 +178,13 @@ if (JSON.stringify(packagedSkills) !== JSON.stringify([ const packagedLaunchers = entries .filter((entry) => entry.startsWith(`${packageName}/bin/`) && !entry.endsWith("/")) .map((entry) => entry.slice(`${packageName}/bin/`.length)) -if (JSON.stringify(packagedLaunchers) !== JSON.stringify(["hello-world", "skill-a", "skill-b"])) { - throw new Error("package launcher inventory does not preserve the v0.2.0 closure") +if (JSON.stringify(packagedLaunchers) !== JSON.stringify([ + "agent-attention", + "hello-world", + "skill-a", + "skill-b", +])) { + throw new Error("package launcher inventory does not preserve the current closure") } const catalog = JSON.parse(readFileSync(join(root, "runtime", "skill-catalog.json"), "utf8")) const bundles = JSON.parse( @@ -186,15 +192,20 @@ const bundles = JSON.parse( ) for (const surface of [catalog.skills, bundles.bundles]) { const surfaceKeys = Object.keys(surface).sort() - if (JSON.stringify(surfaceKeys) !== JSON.stringify(["hello-world", "skill-a", "skill-b"])) { + if (JSON.stringify(surfaceKeys) !== JSON.stringify([ + "agent-attention", + "hello-world", + "skill-a", + "skill-b", + ])) { throw new Error( - `capability-tour entered the executable runtime closure: ${JSON.stringify(surfaceKeys)}`, + `executable runtime closure does not match the expected skills: ${JSON.stringify(surfaceKeys)}`, ) } } const coldXdg = join(extractedRoot, "cold-xdg") -for (const skillId of ["hello-world", "skill-a", "skill-b"]) { +for (const skillId of ["agent-attention", "hello-world", "skill-a", "skill-b"]) { const launcher = join(installedRoot, "bin", skillId) const launcherText = readFileSync(launcher, "utf8") if (!launcherText.includes(`runtime/runtime-exec\" run ${skillId} --`)) { diff --git a/scripts/prove-harness-install.ts b/scripts/prove-harness-install.ts index 495a830..9809795 100644 --- a/scripts/prove-harness-install.ts +++ b/scripts/prove-harness-install.ts @@ -1393,6 +1393,7 @@ export function proveInstalledCapabilityEvidence( .filter((path) => /^skills\/[^/]+\/SKILL\.md$/.test(path)) .map((path) => path.slice("skills/".length, -"/SKILL.md".length)) const portableSkills = [ + "agent-attention", "capability-tour", "hello-world", "runtime-custody", @@ -1402,7 +1403,7 @@ export function proveInstalledCapabilityEvidence( if (JSON.stringify(installedSkills) !== JSON.stringify(portableSkills)) { throw new Error(`${client} installed portable skill inventory differs`) } - const executableSkills = ["hello-world", "skill-a", "skill-b"] + const executableSkills = ["agent-attention", "hello-world", "skill-a", "skill-b"] const launchers = installedInventory .filter((path) => path.startsWith("bin/")) .map((path) => path.slice("bin/".length)) diff --git a/scripts/runtime-custody-config.test.ts b/scripts/runtime-custody-config.test.ts index f9229b4..65b70d5 100644 --- a/scripts/runtime-custody-config.test.ts +++ b/scripts/runtime-custody-config.test.ts @@ -53,6 +53,7 @@ test("renders one custody launcher for every catalog skill", () => { .map((file) => file.path) expect(launchers).toEqual([ + "plugin/bin/agent-attention", "plugin/bin/hello-world", "plugin/bin/skill-a", "plugin/bin/skill-b", diff --git a/scripts/runtime-custody-generation.test.ts b/scripts/runtime-custody-generation.test.ts index 54d7ae1..736e3f0 100644 --- a/scripts/runtime-custody-generation.test.ts +++ b/scripts/runtime-custody-generation.test.ts @@ -74,6 +74,11 @@ test("runtime custody sources generate one thin launcher and checked shell proje expect(catalog).toEqual({ schemaVersion: 1, skills: { + "agent-attention": { + entry: "runtime/agent-attention.js", + runtimeProfile: "bun", + workspace: "packages/agent-attention", + }, "hello-world": { entry: "runtime/hello-world.js", runtimeProfile: "bun", @@ -94,6 +99,7 @@ test("runtime custody sources generate one thin launcher and checked shell proje fileURLToPath(new URL("../plugin/skills", import.meta.url)), ).sort() expect(installedSkills).toEqual([ + "agent-attention", "capability-tour", "hello-world", "runtime-custody", @@ -128,15 +134,20 @@ test("runtime custody sources generate one thin launcher and checked shell proje ).text() expect(skillDocument).not.toContain("Status: not yet invocable") } - expect(generated.filter((file) => file.path.startsWith("plugin/bin/")).length).toBe(3) + expect(generated.filter((file) => file.path.startsWith("plugin/bin/")).length).toBe(4) const launcherNames = readdirSync( fileURLToPath(new URL("../plugin/bin", import.meta.url)), ).sort() - expect(launcherNames).toEqual(["hello-world", "skill-a", "skill-b"]) + expect(launcherNames).toEqual(["agent-attention", "hello-world", "skill-a", "skill-b"]) const bundleInventory = await Bun.file( new URL("../plugin/runtime/bundle-inventory.json", import.meta.url), ).json() - expect(Object.keys(bundleInventory.bundles).sort()).toEqual(["hello-world", "skill-a", "skill-b"]) + expect(Object.keys(bundleInventory.bundles).sort()).toEqual([ + "agent-attention", + "hello-world", + "skill-a", + "skill-b", + ]) expect(bundleInventory.bundles).not.toHaveProperty("capability-tour") const lockProjection = await Bun.file(