From 25e4412696729cb6269c2978a5d80f7ec117c697 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 13:01:28 -0700 Subject: [PATCH 001/305] epic(4312fbd4): create Agent wrapper architecture --- EPIC.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EPIC.md b/EPIC.md index 13760cdd6..4d1db21dc 100644 --- a/EPIC.md +++ b/EPIC.md @@ -1 +1 @@ -# apm help: auto-derived git-style topic help +# Agent wrapper architecture From cd6aa0107e0cd04d91b88f01d35f71fa28351ada Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 13:01:55 -0700 Subject: [PATCH 002/305] ticket(d3b93b95): create Wrapper contract foundation: trait, dispatcher, claude built-in (refactor) --- ...rapper-contract-foundation-trait-dispat.md | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md diff --git a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md new file mode 100644 index 000000000..5ce96f80b --- /dev/null +++ b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md @@ -0,0 +1,70 @@ ++++ +id = "d3b93b95" +title = "Wrapper contract foundation: trait, dispatcher, claude built-in (refactor)" +state = "new" +priority = 0 +effort = 0 +risk = 0 +author = "philippepascal" +owner = "philippepascal" +branch = "ticket/d3b93b95-wrapper-contract-foundation-trait-dispat" +created_at = "2026-04-30T20:01:55.080870Z" +updated_at = "2026-04-30T20:01:55.080870Z" +epic = "4312fbd4" +target_branch = "epic/4312fbd4-agent-wrapper-architecture" ++++ + +## Spec + +### Problem + +Refactor `apm-core/src/start.rs` to dispatch through a Wrapper abstraction instead of hardcoding Claude flags. After this ticket, behaviour is byte-for-byte identical to today (same flags, same input/output) but the code path is wrapper-driven and the foundation is in place for additional built-ins and project-defined wrappers. + +**Reference spec:** `docs/agent-wrappers.md` — sections 'Why', 'Overall design', 'The wrapper contract'. + +**Scope:** +- New module `apm-core/src/wrapper/` (or similar). Define a `Wrapper` trait with one method (e.g. `spawn(&self, ctx: WrapperContext) -> Result`). +- `WrapperContext` carries: ticket id, ticket branch, worktree path, system-prompt file path, user-message file path, skip_permissions flag, profile name, role_prefix, options map, model. +- A `BuiltinRegistry` enum/map; the only entry registered in this ticket is `claude`. +- The `claude` built-in invokes `claude --print --output-format=stream-json --verbose --system-prompt [--model X] [--dangerously-skip-permissions] ` exactly as today, producing identical output behaviour. +- Refactor `spawn_container_worker()` and `build_spawn_command()` in `start.rs` to: (a) write system prompt + user message to temp files, (b) set the env-var contract from the spec ('The wrapper contract' section), (c) dispatch to the registered wrapper. The two functions become thin glue. +- Env vars to set per spec: `APM_AGENT_NAME`, `APM_TICKET_ID`, `APM_TICKET_BRANCH`, `APM_TICKET_WORKTREE`, `APM_SYSTEM_PROMPT_FILE`, `APM_USER_MESSAGE_FILE`, `APM_SKIP_PERMISSIONS`, `APM_PROFILE`, `APM_ROLE_PREFIX`, `APM_WRAPPER_VERSION=1`. `APM_OPT_` deferred to ticket-3 (config) since options aren't readable yet. +- chdir to ticket worktree before exec (already happens; preserve). +- Capture stdout + stderr to `.apm-worker.log` (already happens; preserve). +- Remove the temp prompt and message files when the worker exits (best-effort). + +**Out of scope:** +- Reading wrapper choice from config (ticket comes next; for now, hardcode `claude` as the only wrapper). +- Custom wrappers from `.apm/agents//` (separate ticket). +- Mock built-ins (separate ticket). +- Wrapper-contract versioning checks (separate ticket; just stamp `APM_WRAPPER_VERSION=1`). +- The `check_output_format_supported()` removal — keep it for now; it can be removed when config moves to `agent` field. + +**Tests:** existing worker-spawn tests must still pass byte-for-byte. Add unit tests for the new `WrapperContext` plumbing. + +### Acceptance criteria + +Checkboxes; each one independently testable. + +### Out of scope + +Explicit list of what this ticket does not cover. + +### Approach + +How the implementation will work. + +### Open questions + + +### Amendment requests + + +### Code review + + +## History + +| When | From | To | By | +|------|------|----|----| +| 2026-04-30T20:01Z | — | new | philippepascal | From 613a3bfabc2d840ce54c16771cd3804678e8385e Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 13:02:09 -0700 Subject: [PATCH 003/305] ticket(a1b94ea4): create Add outcome field to TransitionConfig with implicit defaults --- ...dd-outcome-field-to-transitionconfig-wi.md | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md diff --git a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md new file mode 100644 index 000000000..77c317b37 --- /dev/null +++ b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md @@ -0,0 +1,70 @@ ++++ +id = "a1b94ea4" +title = "Add outcome field to TransitionConfig with implicit defaults" +state = "new" +priority = 0 +effort = 0 +risk = 0 +author = "philippepascal" +owner = "philippepascal" +branch = "ticket/a1b94ea4-add-outcome-field-to-transitionconfig-wi" +created_at = "2026-04-30T20:02:08.987471Z" +updated_at = "2026-04-30T20:02:08.987471Z" +epic = "4312fbd4" +target_branch = "epic/4312fbd4-agent-wrapper-architecture" ++++ + +## Spec + +### Problem + +Add an explicit `outcome` field to `TransitionConfig` so mock wrappers and tooling can ask 'is this the success path?' without inferring from `completion` strategy or terminal flags. Independent of the wrapper code; lands as a workflow.toml schema change. + +**Reference spec:** `docs/agent-wrappers.md` — section 'Transition outcomes'. + +**Scope:** +- Add `pub outcome: Option` to `TransitionConfig` in `apm-core/src/config.rs`, with `#[serde(default)]`. +- Recognised values: `success`, `needs_input`, `blocked`, `rejected`, `cancelled`. Custom values are accepted (treated as non-success by tooling). +- Add a helper `pub fn resolve_outcome(transition: &TransitionConfig, target_state: &StateConfig) -> &str` that returns the explicit value if set, otherwise applies the implicit-default rules: + 1. If `completion` is set (any non-`None` strategy) → `success` + 2. Else if target state has `terminal = true` → `cancelled` + 3. Else → `needs_input` +- Add `outcome` to every transition in `apm-core/src/default/workflow.toml` explicitly. The defaults agree with the inference (so this is documentation only) but make the workflow self-describing for new readers and tooling. +- Extend `apm validate` to warn (not error) if a profile would never reach a `success` outcome from any startable state — a dead-end workflow indicates a config mistake worth surfacing. Conservative: warn, don't fail. + +**Out of scope:** +- Mock wrappers using the field (separate ticket; this just adds the field and helper). +- UI surfacing outcome (could be a follow-up; the help schema would auto-pick it up via schemars). +- Hash-trip / validate auto-fix to add `outcome` to existing project workflow.tomls — implicit defaults make this unnecessary. + +**Tests:** +- Unit tests for `resolve_outcome` covering each implicit rule and the explicit-override case. +- Update existing default-workflow tests to assert each transition has an outcome (explicit or inferred). +- Validate test for the dead-end warning. + +### Acceptance criteria + +Checkboxes; each one independently testable. + +### Out of scope + +Explicit list of what this ticket does not cover. + +### Approach + +How the implementation will work. + +### Open questions + + +### Amendment requests + + +### Code review + + +## History + +| When | From | To | By | +|------|------|----|----| +| 2026-04-30T20:02Z | — | new | philippepascal | From cc4c4932b116571fab2f300bd43ebd8b33647b25 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 13:02:35 -0700 Subject: [PATCH 004/305] ticket(6cac8518): create Config schema: agent + options (drop command/args/model) --- ...onfig-schema-agent-options-drop-command.md | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 tickets/6cac8518-config-schema-agent-options-drop-command.md diff --git a/tickets/6cac8518-config-schema-agent-options-drop-command.md b/tickets/6cac8518-config-schema-agent-options-drop-command.md new file mode 100644 index 000000000..99b2351e7 --- /dev/null +++ b/tickets/6cac8518-config-schema-agent-options-drop-command.md @@ -0,0 +1,74 @@ ++++ +id = "6cac8518" +title = "Config schema: agent + options (drop command/args/model)" +state = "new" +priority = 0 +effort = 0 +risk = 0 +author = "philippepascal" +owner = "philippepascal" +branch = "ticket/6cac8518-config-schema-agent-options-drop-command" +created_at = "2026-04-30T20:02:34.693415Z" +updated_at = "2026-04-30T20:02:34.693415Z" +epic = "4312fbd4" +target_branch = "epic/4312fbd4-agent-wrapper-architecture" +depends_on = ["d3b93b95"] ++++ + +## Spec + +### Problem + +Replace the legacy `[workers] command/args/model` config triplet with the wrapper-driven shape: `[workers] agent = ""` plus a `[workers.options]` table for wrapper-specific options. Same for `[worker_profiles.]`. Read `agent` selection from config to drive the wrapper dispatcher landed in d3b93b95. + +**Reference spec:** `docs/agent-wrappers.md` — sections 'Configuration', 'Options table'. + +**Scope:** +- `apm-core/src/config.rs`: + - `WorkersConfig`: add `agent: Option` (default `Some("claude")`), `options: HashMap` (default empty). Keep `command`, `args`, `model` as deprecated optional fields for backward-compat read (see migration ticket); they no longer drive spawn behaviour. + - `WorkerProfileConfig`: add same two fields. Profile values override global if set. +- `apm-core/src/start.rs`: + - Resolve effective agent name: profile → workers → built-in default `claude`. + - Resolve effective options: profile.options merged over workers.options. + - Pass agent name to the wrapper dispatcher (built or custom) from d3b93b95. + - Set `APM_OPT_` env vars from the resolved options map (key uppercased, dots/dashes → underscores). + - When legacy `command/args/model` are present in config and `agent` is absent, synthesize `agent = "claude"` and emit a deprecation warning to stderr (one-time per process). Migration to the new shape lands in the next ticket. +- Update `apm-core/src/default/config.toml` (the init template) to use the new shape: `agent = "claude"`, `options.model = "sonnet"`. Drop `command`/`args`. Same for the two default worker_profiles. + +**Out of scope:** +- Custom wrappers from `.apm/agents//` (separate ticket). +- Frontmatter override (separate ticket). +- Migration helper for existing repos (separate ticket). +- The `check_output_format_supported` removal — can be done here as a side cleanup since wrappers now own their compat checks; or keep until the wrapper-versioning ticket. Pick one in spec phase. + +**Tests:** +- Round-trip the new schema through TOML. +- Resolution chain test (profile.agent overrides workers.agent; profile.options overrides workers.options key-by-key). +- Backward-compat test: a config with only legacy `command = "claude"` resolves to the claude wrapper with a deprecation message. + +### Acceptance criteria + +Checkboxes; each one independently testable. + +### Out of scope + +Explicit list of what this ticket does not cover. + +### Approach + +How the implementation will work. + +### Open questions + + +### Amendment requests + + +### Code review + + +## History + +| When | From | To | By | +|------|------|----|----| +| 2026-04-30T20:02Z | — | new | philippepascal | From 034e3040ec06785dbdc3bffcf92804bd1189d46c Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 13:02:51 -0700 Subject: [PATCH 005/305] ticket(2c32a282): create Custom wrapper resolution from .apm/agents// --- ...ustom-wrapper-resolution-from-apm-agent.md | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md diff --git a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md new file mode 100644 index 000000000..54a856b04 --- /dev/null +++ b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md @@ -0,0 +1,76 @@ ++++ +id = "2c32a282" +title = "Custom wrapper resolution from .apm/agents//" +state = "new" +priority = 0 +effort = 0 +risk = 0 +author = "philippepascal" +owner = "philippepascal" +branch = "ticket/2c32a282-custom-wrapper-resolution-from-apm-agent" +created_at = "2026-04-30T20:02:50.794362Z" +updated_at = "2026-04-30T20:02:50.794362Z" +epic = "4312fbd4" +target_branch = "epic/4312fbd4-agent-wrapper-architecture" +depends_on = ["d3b93b95"] ++++ + +## Spec + +### Problem + +Implement custom-wrapper resolution from `.apm/agents//` so projects can ship their own agent integrations alongside built-ins. A project script at `.apm/agents//wrapper.` shadows any built-in of the same name. + +**Reference spec:** `docs/agent-wrappers.md` — sections 'Custom wrappers', 'manifest.toml (optional)'. + +**Scope:** +- New module `apm-core/src/wrapper/custom.rs` (or similar). Public API: `pub fn resolve_wrapper(root: &Path, name: &str) -> Option` returning either a `Custom { script_path: PathBuf, manifest: Option }` or `Builtin(name)`. +- Resolution order: project script first (any executable file matching `wrapper.*` in `.apm/agents//`), then built-in. +- Custom wrappers are exec'd directly (not via shell). The wrapper script must have its shebang and execute bit set; APM does not interpret extensions or pick interpreters. +- Parse optional `manifest.toml` in the wrapper directory: `[wrapper] name`, `contract_version` (default 1), `parser` (default "canonical"), `parser_command` (only when parser = "external"). Strict parsing; unknown keys are warnings. +- Wire the dispatcher (from d3b93b95) to call into custom-wrapper exec when the resolved kind is `Custom`. +- Extend `apm validate` to: + - Confirm the configured agent (global, per-profile) resolves either to a built-in or a project script. + - Validate `manifest.toml` if present (parses, declared `contract_version` is supported by this APM build). + - Error message format: "agent 'foo' not found: checked built-ins {claude, ...} and `.apm/agents/foo/`". + +**Out of scope:** +- Per-agent instructions (`apm.worker.md` etc. per agent dir) — separate ticket. +- The `apm agents new/list/test/eject` subcommand — separate ticket. +- Wrapper-contract version checking at spawn time — separate ticket; this ticket only parses the field. +- External parser invocation — separate ticket; this ticket only stores the manifest fields. + +**Tests:** +- Resolution test: project script shadows built-in. +- Resolution test: missing wrapper returns None; validate fails with the expected error. +- Manifest parsing tests (valid, invalid, missing). +- Integration test: a fixture project with a `.apm/agents/echo-test/wrapper.sh` that just echoes a JSONL event and exits 0; dispatcher runs it, output captured to log. + +**Wrapper-contract version 1** is the only one this ticket supports; manifest.toml declaring contract_version > 1 should be rejected with a clear upgrade-APM message. + +### Acceptance criteria + +Checkboxes; each one independently testable. + +### Out of scope + +Explicit list of what this ticket does not cover. + +### Approach + +How the implementation will work. + +### Open questions + + +### Amendment requests + + +### Code review + + +## History + +| When | From | To | By | +|------|------|----|----| +| 2026-04-30T20:02Z | — | new | philippepascal | From 9a472c49fe856b7b7d2e79b8582ffa053741c892 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 13:03:17 -0700 Subject: [PATCH 006/305] ticket(3048d7e9): create Migration: validate --fix ports legacy command/args/model to agent + options --- ...igration-validate-fix-ports-legacy-comm.md | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md diff --git a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md new file mode 100644 index 000000000..c01f2a234 --- /dev/null +++ b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md @@ -0,0 +1,74 @@ ++++ +id = "3048d7e9" +title = "Migration: validate --fix ports legacy command/args/model to agent + options" +state = "new" +priority = 0 +effort = 0 +risk = 0 +author = "philippepascal" +owner = "philippepascal" +branch = "ticket/3048d7e9-migration-validate-fix-ports-legacy-comm" +created_at = "2026-04-30T20:03:17.277300Z" +updated_at = "2026-04-30T20:03:17.277300Z" +epic = "4312fbd4" +target_branch = "epic/4312fbd4-agent-wrapper-architecture" +depends_on = ["6cac8518"] ++++ + +## Spec + +### Problem + +When existing projects upgrade APM, their `.apm/config.toml` still uses the legacy `[workers] command/args/model` shape. Provide an automated migration so users do not have to hand-edit. The legacy fields are read with a deprecation warning (per ticket 6cac8518); this ticket adds the migration that retires them. + +**Reference spec:** `docs/agent-wrappers.md` — section 'Migration from current config'. + +**Scope:** +- Extend `apm validate --fix` to detect a config with legacy fields and rewrite to the new shape: + - `command = "claude"` → `agent = "claude"` + - `model = "sonnet"` → `[workers.options] model = "sonnet"` + - `args = ["--print", "--output-format=stream-json", "--verbose"]` (or any subset) → dropped (the wrapper handles flags) + - Same migration for every `[worker_profiles.]` section. +- TOML rewrite must preserve comments, ordering of unrelated sections, and trailing whitespace as much as possible (use a TOML-aware editor, e.g. `toml_edit`). +- If `command` is anything other than `claude`, do not auto-migrate — print a warning that the user must hand-pick a wrapper for their custom command and stop. +- After migration, re-run validate to confirm the new config parses cleanly. The old fields should be entirely gone (no commented-out leftovers). +- Add a one-line migration message: `migrated [workers] config to agent-driven shape; legacy command/args/model removed`. + +**Out of scope:** +- An interactive `apm init --migrate` subcommand. Validate --fix is the canonical migration path. +- Migration of any `.apm/agents.md` or `.apm/apm.*.md` files. Those are content, not config. +- Hash-trip integration changes — the existing hash-trip already runs validate on config change. + +**Tests:** +- Repo with legacy config + claude command → fix produces the new shape; re-validate passes. +- Repo with legacy config + non-claude command → fix prints a warning and does not modify config. +- Repo with mixed legacy + new fields → fix removes legacy fields, preserves new ones. +- Repo with already-migrated config → fix is a no-op. +- TOML preservation: a config with a comment between sections survives the fix unchanged. + +### Acceptance criteria + +Checkboxes; each one independently testable. + +### Out of scope + +Explicit list of what this ticket does not cover. + +### Approach + +How the implementation will work. + +### Open questions + + +### Amendment requests + + +### Code review + + +## History + +| When | From | To | By | +|------|------|----|----| +| 2026-04-30T20:03Z | — | new | philippepascal | From 6fd29b8b4c3b17baca9d5ac28cf3a75b4934e6ba Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 13:03:34 -0700 Subject: [PATCH 007/305] ticket(7f5f73d5): create Per-agent instructions resolution under .apm/agents// --- ...er-agent-instructions-resolution-under-.md | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 tickets/7f5f73d5-per-agent-instructions-resolution-under-.md diff --git a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md new file mode 100644 index 000000000..fea7c3df7 --- /dev/null +++ b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md @@ -0,0 +1,78 @@ ++++ +id = "7f5f73d5" +title = "Per-agent instructions resolution under .apm/agents//" +state = "new" +priority = 0 +effort = 0 +risk = 0 +author = "philippepascal" +owner = "philippepascal" +branch = "ticket/7f5f73d5-per-agent-instructions-resolution-under-" +created_at = "2026-04-30T20:03:33.687625Z" +updated_at = "2026-04-30T20:03:33.687625Z" +epic = "4312fbd4" +target_branch = "epic/4312fbd4-agent-wrapper-architecture" +depends_on = ["d3b93b95", "2c32a282"] ++++ + +## Spec + +### Problem + +Each agent may want different prompt conventions (Aider concise context, Codex structured tags, etc.). Move `apm.worker.md` and `apm.spec-writer.md` resolution to be per-agent under `.apm/agents//`, with project-level overrides retained. + +**Reference spec:** `docs/agent-wrappers.md` — section 'Per-agent instructions'. + +**Scope:** +- New layout: `.apm/agents//apm.worker.md` and `.apm/agents//apm.spec-writer.md` are the per-agent defaults. +- For built-ins: ship the per-agent default markdown bundled in the binary (`include_str!` from `apm-core/src/default/agents//apm..md`). For custom wrappers: the user authors them in their wrapper directory. +- Resolution chain (highest priority first), per spawn (profile = P, role = worker|spec-writer, agent = A): + 1. `[worker_profiles.

].instructions` (project-level override, full path) + 2. `[workers].instructions` (project-level override, applies to all profiles) + 3. `.apm/agents//apm..md` (project-supplied per-agent file, if it exists) + 4. APM's built-in default for agent A (only for built-in agents) + 5. Hard error if none of the above resolve +- The spawn code passes the resolved file path's contents as the system prompt (already happens; just change where the path comes from). +- For migration: existing `.apm/apm.worker.md` and `.apm/apm.spec-writer.md` continue to work because they are referenced by `[workers].instructions` and `[worker_profiles.

].instructions` in the default config (project-level overrides at level 1/2). No automatic migration needed — users keep what they have unless they delete the override and want the per-agent default. + +**Built-in defaults to ship:** +- `apm-core/src/default/agents/claude/apm.worker.md` — copy of the current default `apm.worker.md`. +- `apm-core/src/default/agents/claude/apm.spec-writer.md` — copy of the current default `apm.spec-writer.md`. +- (Mock built-ins from a separate ticket may not need spec-writer/worker .md files at all; defer to that ticket.) + +**Out of scope:** +- Updating the .md content for non-Claude agents — there are no other built-ins yet. +- Per-agent `agents.md` (the project-wide conventions file is still `.apm/agents.md`, not per-agent). +- Sync test extending the existing `apm.worker.md` byte-identical check to other roles — separate concern. + +**Tests:** +- Resolution chain test for each level. +- Hard-error test when no instructions resolve. +- Backward-compat: a project with the old config that references `.apm/apm.worker.md` continues to work without edits. + +### Acceptance criteria + +Checkboxes; each one independently testable. + +### Out of scope + +Explicit list of what this ticket does not cover. + +### Approach + +How the implementation will work. + +### Open questions + + +### Amendment requests + + +### Code review + + +## History + +| When | From | To | By | +|------|------|----|----| +| 2026-04-30T20:03Z | — | new | philippepascal | From 1e5dbe0389a19d239dae9f93ac18e90c4127daa2 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 13:03:58 -0700 Subject: [PATCH 008/305] ticket(0ca3e019): create Frontmatter agent + agent_overrides for per-ticket wrapper choice --- ...rontmatter-agent-agent-overrides-for-pe.md | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md diff --git a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md new file mode 100644 index 000000000..bda8568f4 --- /dev/null +++ b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md @@ -0,0 +1,77 @@ ++++ +id = "0ca3e019" +title = "Frontmatter agent + agent_overrides for per-ticket wrapper choice" +state = "new" +priority = 0 +effort = 0 +risk = 0 +author = "philippepascal" +owner = "philippepascal" +branch = "ticket/0ca3e019-frontmatter-agent-agent-overrides-for-pe" +created_at = "2026-04-30T20:03:58.532325Z" +updated_at = "2026-04-30T20:03:58.532325Z" +epic = "4312fbd4" +target_branch = "epic/4312fbd4-agent-wrapper-architecture" +depends_on = ["d3b93b95", "6cac8518"] ++++ + +## Spec + +### Problem + +A ticket can override which agent handles its workers, either across all phases or per profile. Useful for: debugging a stuck ticket with mock-happy, mixing agents per phase (Claude for spec, Codex for impl), forcing a specific agent for a regression test. + +**Reference spec:** `docs/agent-wrappers.md` — section 'Frontmatter override'. + +**Scope:** +- `apm-core/src/ticket/ticket_fmt.rs` (`Frontmatter` struct): + - Add `pub agent: Option` — single all-profiles override + - Add `pub agent_overrides: HashMap` — per-profile map (profile name → agent name) + - Both are `#[serde(default)]` and skip-serializing-if-empty. +- `apm-core/src/start.rs`: + - Update agent resolution chain to (per spawn, where P = worker profile): + 1. `frontmatter.agent_overrides[P]` if present + 2. `frontmatter.agent` if present + 3. `[worker_profiles.

].agent` if set + 4. `[workers].agent` + - Resolution happens at spawn time, reading the ticket's current frontmatter from its branch. +- `apm validate`: + - For each ticket whose `frontmatter.agent` or any value in `agent_overrides` is set, the named agent must resolve to a built-in or project script. Report missing agents as ticket-level errors with the ticket id and the offending agent name. +- Document the override fields in `apm.spec-writer.md` and `apm.worker.md` so agents know they exist (briefly — ticket-frontmatter overrides are a supervisor tool, not an agent tool). + +**Out of scope:** +- Per-transition agent mapping (a `{transition: agent}` map). Spec defers this as a v2 contract extension; not in v1. +- A CLI command to set the override (`apm set agent X`). Could be added to the existing `apm set` field list, but is a small follow-up. Note in spec. +- Surfacing the override in `apm show` output. Could be a small follow-up. + +**Tests:** +- Resolution test: each level wins over the next. +- Validate test: ticket with `agent = "unknown-wrapper"` produces a clear error. +- Round-trip test: frontmatter with both fields serializes cleanly and parses back identically. + +### Acceptance criteria + +Checkboxes; each one independently testable. + +### Out of scope + +Explicit list of what this ticket does not cover. + +### Approach + +How the implementation will work. + +### Open questions + + +### Amendment requests + + +### Code review + + +## History + +| When | From | To | By | +|------|------|----|----| +| 2026-04-30T20:03Z | — | new | philippepascal | From 3579899aedcae28b7b7f7b71446ab5801617ffa2 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 13:04:22 -0700 Subject: [PATCH 009/305] ticket(25c92daa): create Mock and debug built-in wrappers (mock-happy, mock-sad, mock-random, debug) --- ...ock-and-debug-built-in-wrappers-mock-ha.md | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md diff --git a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md new file mode 100644 index 000000000..7b43c0fce --- /dev/null +++ b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md @@ -0,0 +1,95 @@ ++++ +id = "25c92daa" +title = "Mock and debug built-in wrappers (mock-happy, mock-sad, mock-random, debug)" +state = "new" +priority = 0 +effort = 0 +risk = 0 +author = "philippepascal" +owner = "philippepascal" +branch = "ticket/25c92daa-mock-and-debug-built-in-wrappers-mock-ha" +created_at = "2026-04-30T20:04:21.901984Z" +updated_at = "2026-04-30T20:04:21.901984Z" +epic = "4312fbd4" +target_branch = "epic/4312fbd4-agent-wrapper-architecture" +depends_on = ["d3b93b95", "a1b94ea4", "6cac8518"] ++++ + +## Spec + +### Problem + +Ship three mock built-in wrappers for testing the harness without burning credits, plus a `debug` introspection helper. All four are Rust built-ins registered in the dispatcher from ticket d3b93b95. + +**Reference spec:** `docs/agent-wrappers.md` — sections 'mock-happy', 'mock-sad', 'mock-random', 'Mock-happy details', 'Mock-sad / mock-random determinism', 'Detailed considerations / debug'. + +**Scope:** + +**`mock-happy`** built-in: +- For spec-writer profile: writes minimal valid markdown to all required spec sections via shelling out to the same `apm` binary (`apm spec --set` per section). Sets effort and risk to 1, 1. +- For impl-agent profile: emits a fake commit (creates a placeholder file in the worktree, `git add` + `git commit`). +- Picks the transition with `outcome = "success"` from the ticket's current state (using the helper from ticket a1b94ea4) and runs `apm state `. If zero or multiple success transitions exist, exit non-zero with a diagnostic. +- Emits 1–2 fake JSONL events on stdout for log realism. +- Exits 0. + +**`mock-sad`** built-in: +- Writes some-but-not-all required spec sections (or content that fails validate). +- Optionally writes a question to `### Open questions`. +- Picks uniformly from transitions where `outcome ≠ "success"` valid from the current state. Seedable via `APM_OPT_SEED`. +- Runs `apm state `. If the eligible set is empty, exit non-zero with a diagnostic. +- Exits 0. + +**`mock-random`** built-in: +- Picks uniformly from ALL valid transitions (any outcome, including success). Same seeding via `APM_OPT_SEED`. +- For success: behaves like mock-happy. For non-success: behaves like mock-sad. +- Exits 0. + +**`debug`** built-in: +- Prints all `APM_*` env vars to stderr. +- Prints contents of `APM_SYSTEM_PROMPT_FILE` and `APM_USER_MESSAGE_FILE` to stderr. +- Emits a single canonical `tool_use` JSONL event on stdout. +- Does NOT call `apm state` (no transition). +- Exits 0. +- Useful for verifying wrapper-contract plumbing without invoking any real agent. + +**Implementation notes:** +- All four live under `apm-core/src/wrapper/builtin/` (one file each: `claude.rs`, `mock_happy.rs`, `mock_sad.rs`, `mock_random.rs`, `debug.rs`). +- The mocks shell out to the host `apm` binary for state transitions and spec writes — no special internal API. +- Mocks read the workflow from `apm-core::config::Config::load(root)` to find valid transitions and their outcomes (using the helper from ticket a1b94ea4). +- Per-agent instruction files (`apm.worker.md`, `apm.spec-writer.md`) are NOT needed for mocks — the per-agent instructions resolution from ticket 7f5f73d5 should fall through gracefully when those files don't exist for a built-in. Confirm in spec phase. + +**Out of scope:** +- Documenting the mocks in user-facing help/docs beyond the existing `docs/agent-wrappers.md` reference. +- Apm subcommand support (`apm agents test mock-happy` etc.) — that is a separate ticket. + +**Tests:** +- For each mock: integration test that wires it up against a fixture project, runs a worker, asserts the expected state transition occurred. +- For mock-sad / mock-random: assert seed reproducibility (same seed → same chosen transition). +- For debug: assert env vars, prompt, and message all appear in the captured `.apm-worker.log`. + +### Acceptance criteria + +Checkboxes; each one independently testable. + +### Out of scope + +Explicit list of what this ticket does not cover. + +### Approach + +How the implementation will work. + +### Open questions + + +### Amendment requests + + +### Code review + + +## History + +| When | From | To | By | +|------|------|----|----| +| 2026-04-30T20:04Z | — | new | philippepascal | From 0e64efab39b29a10f86d91ca4b52312fa8cc5789 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 13:04:58 -0700 Subject: [PATCH 010/305] ticket(71d80e40): create apm agents subcommand: new, list, test, eject --- ...pm-agents-subcommand-new-list-test-ejec.md | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md new file mode 100644 index 000000000..bdb686c6c --- /dev/null +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -0,0 +1,90 @@ ++++ +id = "71d80e40" +title = "apm agents subcommand: new, list, test, eject" +state = "new" +priority = 0 +effort = 0 +risk = 0 +author = "philippepascal" +owner = "philippepascal" +branch = "ticket/71d80e40-apm-agents-subcommand-new-list-test-ejec" +created_at = "2026-04-30T20:04:57.796154Z" +updated_at = "2026-04-30T20:04:57.796154Z" +epic = "4312fbd4" +target_branch = "epic/4312fbd4-agent-wrapper-architecture" +depends_on = ["d3b93b95", "2c32a282"] ++++ + +## Spec + +### Problem + +Add the `apm agents` subcommand family for discovering, scaffolding, smoke-testing, and ejecting wrappers. Discoverability and authoring are the load-bearing UX for the wrapper feature. + +**Reference spec:** `docs/agent-wrappers.md` — sections 'Skeleton command', 'Other wrapper-related commands'. + +**Scope:** four subcommands under `apm agents`. + +**`apm agents list`** — discover available wrappers. +- Lists built-in wrappers (from the registry in d3b93b95 + mocks from 25c92daa) and project-defined wrappers (from `.apm/agents//wrapper.*` per 2c32a282). +- For each: name, kind (built-in or project), and current configured-as marker (which profile/global uses it). +- One column for parser strategy if the wrapper declares one in manifest.toml. + +**`apm agents new `** — scaffold a custom wrapper. +- Creates `.apm/agents//` if it doesn't exist; refuses if it does (suggest `--force` for overwrite). +- Writes: + - `wrapper.sh` — runnable template that prints all `APM_*` env vars to stderr, emits a minimal valid JSONL event on stdout, exits 0. Documents the contract inline as comments. Sets the execute bit (`chmod +x`). + - `apm.worker.md` — copy of the project's current `.apm/apm.worker.md` (or the claude built-in's default if no project file). + - `apm.spec-writer.md` — same. + - `manifest.toml` — defaults written explicitly: `contract_version = 1`, `parser = "canonical"`. +- Prints next-step guidance: edit `wrapper.sh`, run `apm agents test ` to validate. + +**`apm agents test `** — smoke-test a wrapper. +- Spawns the wrapper against a synthetic ticket in a temp worktree (no real ticket touched). +- Captures the wrapper's output and exit code. +- Reports: exit code, count of canonical JSONL events, any non-canonical lines on stdout, count of stderr lines, wall time. +- Pass criteria: exit 0, at least one canonical JSONL event, no parse errors. +- Useful before assigning a new wrapper to a real worker queue. + +**`apm agents eject `** — extract a built-in to a script. +- Writes the built-in wrapper's source equivalent to `.apm/agents//wrapper.sh` (a bash script that reproduces the built-in's behaviour). The Rust built-in stays registered; the project script shadows it per the resolution rules in 2c32a282. +- Useful when a user wants to customize a built-in (e.g. add custom env vars, change the model invocation). +- Refuses if `.apm/agents//` already exists. + +**Out of scope:** +- Wrapper-contract version checking inside `apm agents test` — defer to the versioning ticket. +- Distributing wrappers across projects (`apm agents install`) — out of scope. +- An `apm agents remove` command — users can `rm -r` the directory. + +**Tests:** +- `list`: built-ins appear; a fixture project script appears with kind=project. +- `new`: directory and files created; `wrapper.sh` is executable; second invocation refuses. +- `test`: passing wrapper reports success; failing wrapper (non-zero exit) reports the failure with the captured stderr. +- `eject`: claude eject writes a script that, when run as the configured agent, produces the same canonical events as the built-in. + +### Acceptance criteria + +Checkboxes; each one independently testable. + +### Out of scope + +Explicit list of what this ticket does not cover. + +### Approach + +How the implementation will work. + +### Open questions + + +### Amendment requests + + +### Code review + + +## History + +| When | From | To | By | +|------|------|----|----| +| 2026-04-30T20:04Z | — | new | philippepascal | From c69ccaed6fd29611412ade3fa27e199fc50ceebb Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 13:05:11 -0700 Subject: [PATCH 011/305] ticket(2e772eab): create Wrapper-contract versioning (APM_WRAPPER_VERSION + manifest.toml) --- ...rapper-contract-versioning-apm-wrapper-.md | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md diff --git a/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md b/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md new file mode 100644 index 000000000..0eb3b1231 --- /dev/null +++ b/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md @@ -0,0 +1,75 @@ ++++ +id = "2e772eab" +title = "Wrapper-contract versioning (APM_WRAPPER_VERSION + manifest.toml)" +state = "new" +priority = 0 +effort = 0 +risk = 0 +author = "philippepascal" +owner = "philippepascal" +branch = "ticket/2e772eab-wrapper-contract-versioning-apm-wrapper-" +created_at = "2026-04-30T20:05:11.077339Z" +updated_at = "2026-04-30T20:05:11.077339Z" +epic = "4312fbd4" +target_branch = "epic/4312fbd4-agent-wrapper-architecture" +depends_on = ["d3b93b95", "2c32a282"] ++++ + +## Spec + +### Problem + +Add wrapper-contract versioning so future contract changes (new env vars, new output protocol, etc.) do not silently break user wrappers. v1 is the only contract version this APM build understands. + +**Reference spec:** `docs/agent-wrappers.md` — section 'Wrapper-contract versioning'. + +**Scope:** +- APM exports `APM_WRAPPER_VERSION=1` to every wrapper invocation (already stamped in ticket d3b93b95; this ticket formalizes the meaning). +- Custom wrappers declare which contract version they target via `manifest.toml.wrapper.contract_version` (already parsed in 2c32a282; this ticket adds the compatibility check). +- Compatibility check at spawn time: + - Wrapper version == APM version → proceed. + - Wrapper version < APM version → proceed; APM emits a warning to the worker log noting the wrapper targets an older contract and may not use newer env vars. + - Wrapper version > APM version → refuse to spawn with a clear error: 'wrapper targets contract version N but this APM build supports up to version 1; upgrade APM'. +- Built-in wrappers always target the current APM build's contract version (no manifest needed). +- `apm agents test` (from ticket 25c92daa wait — that's mocks; from ticket 9 / apm agents subcommand) integrates the version check into the smoke test result. + +**Centralized version constant:** +- New const `apm_core::wrapper::CONTRACT_VERSION: u32 = 1;`. Bumped (in code) when the contract changes. This ticket establishes 1 as the value; future contract changes increment. + +**Out of scope:** +- Defining what a contract bump means (what changes constitute a major version bump). Document at bump time. +- Backporting v1 → v2 helpers. Future concern. +- A registry of wrapper versions across the apm-agents ecosystem. Future concern. + +**Tests:** +- Wrapper with contract_version = 1 → spawn succeeds, no warnings. +- Wrapper with no manifest → assumed v1, spawn succeeds. +- Wrapper with contract_version = 2 → spawn refuses with the upgrade message. +- Hypothetical: simulate APM v2 by setting `CONTRACT_VERSION = 2`, wrapper still v1 → spawn succeeds with the older-version warning. + +### Acceptance criteria + +Checkboxes; each one independently testable. + +### Out of scope + +Explicit list of what this ticket does not cover. + +### Approach + +How the implementation will work. + +### Open questions + + +### Amendment requests + + +### Code review + + +## History + +| When | From | To | By | +|------|------|----|----| +| 2026-04-30T20:05Z | — | new | philippepascal | From ce792d32e3ff9c5c5dfd6cd0c718c17bdc0cb03d Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 13:05:41 -0700 Subject: [PATCH 012/305] ticket(2803bf07): create Output parser strategy: external parsers via manifest.toml --- ...utput-parser-strategy-external-parsers-.md | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 tickets/2803bf07-output-parser-strategy-external-parsers-.md diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md new file mode 100644 index 000000000..742ce93da --- /dev/null +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -0,0 +1,72 @@ ++++ +id = "2803bf07" +title = "Output parser strategy: external parsers via manifest.toml" +state = "new" +priority = 0 +effort = 0 +risk = 0 +author = "philippepascal" +owner = "philippepascal" +branch = "ticket/2803bf07-output-parser-strategy-external-parsers-" +created_at = "2026-04-30T20:05:40.844536Z" +updated_at = "2026-04-30T20:05:40.844536Z" +epic = "4312fbd4" +target_branch = "epic/4312fbd4-agent-wrapper-architecture" +depends_on = ["2c32a282", "2e772eab"] ++++ + +## Spec + +### Problem + +Support agents whose output is too far from APM's canonical JSONL stream-json to translate inline. The wrapper declares an external parser binary in `manifest.toml`; APM pipes the wrapper's stdout through it before capturing. + +**Reference spec:** `docs/agent-wrappers.md` — section 'Output parser strategy', 'Custom wrappers / manifest.toml'. + +**Scope:** +- `manifest.toml` already parses `parser` and `parser_command` (added in ticket 2c32a282). This ticket wires them into spawn. +- Three parser modes: + - `parser = "canonical"` (default) — wrapper produces JSONL stream-json directly. No transformation. Today's behaviour. + - `parser = "raw"` — wrapper output is captured as-is to log; no canonical-event parsing. Useful for agents whose output is unstructured or only meant for human reading. Worker-state events still drive off the wrapper's exit code and any `apm state` calls it makes. + - `parser = "external"` — wrapper output is piped through the binary at `parser_command` (must be in PATH or absolute path); the parser's stdout becomes APM's captured stream. Parser must produce canonical JSONL. +- Spawn glue: when `parser = "external"`, spawn the wrapper and the parser as a pipe (wrapper.stdout → parser.stdin). Capture parser.stdout (canonical events) and parser.stderr (parser's diagnostics) to `.apm-worker.log`. The wrapper's stderr also goes to the log directly. +- Validate `parser_command` exists when `parser = "external"`. Fail at spawn with a clear error if not. +- Built-in wrappers always default to canonical (no manifest needed). + +**Out of scope:** +- Shipping any external parser binaries (e.g. `apm-output-parser-aider`). Those are separate cargo crates. +- A formal canonical event vocabulary doc — already noted as an open question in the spec doc. +- Multiplexing two parsers on the same wrapper. Pick one strategy per wrapper. + +**Tests:** +- `canonical` mode: existing wrapper tests unchanged. +- `raw` mode: a wrapper that emits "hello world" → log contains "hello world" verbatim, no JSONL parse warnings. +- `external` mode: a wrapper that emits non-JSONL text + a parser script that wraps each line in a JSONL event → log contains the parsed JSONL, original text only in the parser's stderr. +- Missing parser_command → spawn fails with a clear error citing the manifest path. + +### Acceptance criteria + +Checkboxes; each one independently testable. + +### Out of scope + +Explicit list of what this ticket does not cover. + +### Approach + +How the implementation will work. + +### Open questions + + +### Amendment requests + + +### Code review + + +## History + +| When | From | To | By | +|------|------|----|----| +| 2026-04-30T20:05Z | — | new | philippepascal | From b9779ead283bed9ee0ef8e8e694ff8831573bc82 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:02:15 -0700 Subject: [PATCH 013/305] =?UTF-8?q?ticket(d3b93b95):=20new=20=E2=86=92=20g?= =?UTF-8?q?roomed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md index 5ce96f80b..ae330870a 100644 --- a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md +++ b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md @@ -1,7 +1,7 @@ +++ id = "d3b93b95" title = "Wrapper contract foundation: trait, dispatcher, claude built-in (refactor)" -state = "new" +state = "groomed" priority = 0 effort = 0 risk = 0 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/d3b93b95-wrapper-contract-foundation-trait-dispat" created_at = "2026-04-30T20:01:55.080870Z" -updated_at = "2026-04-30T20:01:55.080870Z" +updated_at = "2026-04-30T21:02:14.844968Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" +++ @@ -68,3 +68,4 @@ How the implementation will work. | When | From | To | By | |------|------|----|----| | 2026-04-30T20:01Z | — | new | philippepascal | +| 2026-04-30T21:02Z | new | groomed | philippepascal | From 829ab487f2ce4e1eb8891cc5ad9fcbc06d164a1d Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:02:18 -0700 Subject: [PATCH 014/305] =?UTF-8?q?ticket(a1b94ea4):=20new=20=E2=86=92=20g?= =?UTF-8?q?roomed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md index 77c317b37..099bb554f 100644 --- a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md +++ b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md @@ -1,7 +1,7 @@ +++ id = "a1b94ea4" title = "Add outcome field to TransitionConfig with implicit defaults" -state = "new" +state = "groomed" priority = 0 effort = 0 risk = 0 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/a1b94ea4-add-outcome-field-to-transitionconfig-wi" created_at = "2026-04-30T20:02:08.987471Z" -updated_at = "2026-04-30T20:02:08.987471Z" +updated_at = "2026-04-30T21:02:18.045801Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" +++ @@ -68,3 +68,4 @@ How the implementation will work. | When | From | To | By | |------|------|----|----| | 2026-04-30T20:02Z | — | new | philippepascal | +| 2026-04-30T21:02Z | new | groomed | philippepascal | From c649504be9cd8d7275b2ef0863cf7d92a3ccde0f Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:02:21 -0700 Subject: [PATCH 015/305] =?UTF-8?q?ticket(6cac8518):=20new=20=E2=86=92=20g?= =?UTF-8?q?roomed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/6cac8518-config-schema-agent-options-drop-command.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/6cac8518-config-schema-agent-options-drop-command.md b/tickets/6cac8518-config-schema-agent-options-drop-command.md index 99b2351e7..5c36acf4e 100644 --- a/tickets/6cac8518-config-schema-agent-options-drop-command.md +++ b/tickets/6cac8518-config-schema-agent-options-drop-command.md @@ -1,7 +1,7 @@ +++ id = "6cac8518" title = "Config schema: agent + options (drop command/args/model)" -state = "new" +state = "groomed" priority = 0 effort = 0 risk = 0 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/6cac8518-config-schema-agent-options-drop-command" created_at = "2026-04-30T20:02:34.693415Z" -updated_at = "2026-04-30T20:02:34.693415Z" +updated_at = "2026-04-30T21:02:20.952245Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95"] @@ -72,3 +72,4 @@ How the implementation will work. | When | From | To | By | |------|------|----|----| | 2026-04-30T20:02Z | — | new | philippepascal | +| 2026-04-30T21:02Z | new | groomed | philippepascal | From ba9731053d4f8594f7635ab9ed752de71aee8e30 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:02:24 -0700 Subject: [PATCH 016/305] =?UTF-8?q?ticket(2c32a282):=20new=20=E2=86=92=20g?= =?UTF-8?q?roomed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md index 54a856b04..36de35459 100644 --- a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md +++ b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md @@ -1,7 +1,7 @@ +++ id = "2c32a282" title = "Custom wrapper resolution from .apm/agents//" -state = "new" +state = "groomed" priority = 0 effort = 0 risk = 0 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/2c32a282-custom-wrapper-resolution-from-apm-agent" created_at = "2026-04-30T20:02:50.794362Z" -updated_at = "2026-04-30T20:02:50.794362Z" +updated_at = "2026-04-30T21:02:24.185414Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95"] @@ -74,3 +74,4 @@ How the implementation will work. | When | From | To | By | |------|------|----|----| | 2026-04-30T20:02Z | — | new | philippepascal | +| 2026-04-30T21:02Z | new | groomed | philippepascal | From dba18c8c9b558269b58991c46daf79a4095cf8ba Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:02:27 -0700 Subject: [PATCH 017/305] =?UTF-8?q?ticket(3048d7e9):=20new=20=E2=86=92=20g?= =?UTF-8?q?roomed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md index c01f2a234..080e4e4fb 100644 --- a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md +++ b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md @@ -1,7 +1,7 @@ +++ id = "3048d7e9" title = "Migration: validate --fix ports legacy command/args/model to agent + options" -state = "new" +state = "groomed" priority = 0 effort = 0 risk = 0 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/3048d7e9-migration-validate-fix-ports-legacy-comm" created_at = "2026-04-30T20:03:17.277300Z" -updated_at = "2026-04-30T20:03:17.277300Z" +updated_at = "2026-04-30T21:02:27.190910Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["6cac8518"] @@ -72,3 +72,4 @@ How the implementation will work. | When | From | To | By | |------|------|----|----| | 2026-04-30T20:03Z | — | new | philippepascal | +| 2026-04-30T21:02Z | new | groomed | philippepascal | From 238f12d28eeace6acc5e19e01e0ff9e88b2d88b4 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:02:30 -0700 Subject: [PATCH 018/305] =?UTF-8?q?ticket(7f5f73d5):=20new=20=E2=86=92=20g?= =?UTF-8?q?roomed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/7f5f73d5-per-agent-instructions-resolution-under-.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md index fea7c3df7..b0bd208cc 100644 --- a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md +++ b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md @@ -1,7 +1,7 @@ +++ id = "7f5f73d5" title = "Per-agent instructions resolution under .apm/agents//" -state = "new" +state = "groomed" priority = 0 effort = 0 risk = 0 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/7f5f73d5-per-agent-instructions-resolution-under-" created_at = "2026-04-30T20:03:33.687625Z" -updated_at = "2026-04-30T20:03:33.687625Z" +updated_at = "2026-04-30T21:02:30.154031Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "2c32a282"] @@ -76,3 +76,4 @@ How the implementation will work. | When | From | To | By | |------|------|----|----| | 2026-04-30T20:03Z | — | new | philippepascal | +| 2026-04-30T21:02Z | new | groomed | philippepascal | From d84fb0f7dc170aa9d52643dac5c0cb819fd7bf20 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:02:33 -0700 Subject: [PATCH 019/305] =?UTF-8?q?ticket(0ca3e019):=20new=20=E2=86=92=20g?= =?UTF-8?q?roomed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md index bda8568f4..9a7855cd3 100644 --- a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md +++ b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md @@ -1,7 +1,7 @@ +++ id = "0ca3e019" title = "Frontmatter agent + agent_overrides for per-ticket wrapper choice" -state = "new" +state = "groomed" priority = 0 effort = 0 risk = 0 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/0ca3e019-frontmatter-agent-agent-overrides-for-pe" created_at = "2026-04-30T20:03:58.532325Z" -updated_at = "2026-04-30T20:03:58.532325Z" +updated_at = "2026-04-30T21:02:33.388298Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "6cac8518"] @@ -75,3 +75,4 @@ How the implementation will work. | When | From | To | By | |------|------|----|----| | 2026-04-30T20:03Z | — | new | philippepascal | +| 2026-04-30T21:02Z | new | groomed | philippepascal | From 28c732cfbde81924adbf5335a5e72d5f98d11f80 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:02:37 -0700 Subject: [PATCH 020/305] =?UTF-8?q?ticket(25c92daa):=20new=20=E2=86=92=20g?= =?UTF-8?q?roomed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md index 7b43c0fce..3aefd8b48 100644 --- a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md +++ b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md @@ -1,7 +1,7 @@ +++ id = "25c92daa" title = "Mock and debug built-in wrappers (mock-happy, mock-sad, mock-random, debug)" -state = "new" +state = "groomed" priority = 0 effort = 0 risk = 0 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/25c92daa-mock-and-debug-built-in-wrappers-mock-ha" created_at = "2026-04-30T20:04:21.901984Z" -updated_at = "2026-04-30T20:04:21.901984Z" +updated_at = "2026-04-30T21:02:37.480077Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "a1b94ea4", "6cac8518"] @@ -93,3 +93,4 @@ How the implementation will work. | When | From | To | By | |------|------|----|----| | 2026-04-30T20:04Z | — | new | philippepascal | +| 2026-04-30T21:02Z | new | groomed | philippepascal | From 3cb4ed847f60c47e515247660e4ab1908b532ed4 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:02:40 -0700 Subject: [PATCH 021/305] =?UTF-8?q?ticket(71d80e40):=20new=20=E2=86=92=20g?= =?UTF-8?q?roomed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index bdb686c6c..396a986e4 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -1,7 +1,7 @@ +++ id = "71d80e40" title = "apm agents subcommand: new, list, test, eject" -state = "new" +state = "groomed" priority = 0 effort = 0 risk = 0 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/71d80e40-apm-agents-subcommand-new-list-test-ejec" created_at = "2026-04-30T20:04:57.796154Z" -updated_at = "2026-04-30T20:04:57.796154Z" +updated_at = "2026-04-30T21:02:40.645840Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "2c32a282"] @@ -88,3 +88,4 @@ How the implementation will work. | When | From | To | By | |------|------|----|----| | 2026-04-30T20:04Z | — | new | philippepascal | +| 2026-04-30T21:02Z | new | groomed | philippepascal | From 70cdfad27eeac854cf73f93a1e12f4dc2cb4a19f Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:02:43 -0700 Subject: [PATCH 022/305] =?UTF-8?q?ticket(2e772eab):=20new=20=E2=86=92=20g?= =?UTF-8?q?roomed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md b/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md index 0eb3b1231..bc8549318 100644 --- a/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md +++ b/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md @@ -1,7 +1,7 @@ +++ id = "2e772eab" title = "Wrapper-contract versioning (APM_WRAPPER_VERSION + manifest.toml)" -state = "new" +state = "groomed" priority = 0 effort = 0 risk = 0 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/2e772eab-wrapper-contract-versioning-apm-wrapper-" created_at = "2026-04-30T20:05:11.077339Z" -updated_at = "2026-04-30T20:05:11.077339Z" +updated_at = "2026-04-30T21:02:43.738731Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "2c32a282"] @@ -73,3 +73,4 @@ How the implementation will work. | When | From | To | By | |------|------|----|----| | 2026-04-30T20:05Z | — | new | philippepascal | +| 2026-04-30T21:02Z | new | groomed | philippepascal | From 27b7e96d7faeb2925779ee04ae702184704c1772 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:02:47 -0700 Subject: [PATCH 023/305] =?UTF-8?q?ticket(2803bf07):=20new=20=E2=86=92=20g?= =?UTF-8?q?roomed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/2803bf07-output-parser-strategy-external-parsers-.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md index 742ce93da..8b9957151 100644 --- a/tickets/2803bf07-output-parser-strategy-external-parsers-.md +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -1,7 +1,7 @@ +++ id = "2803bf07" title = "Output parser strategy: external parsers via manifest.toml" -state = "new" +state = "groomed" priority = 0 effort = 0 risk = 0 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/2803bf07-output-parser-strategy-external-parsers-" created_at = "2026-04-30T20:05:40.844536Z" -updated_at = "2026-04-30T20:05:40.844536Z" +updated_at = "2026-04-30T21:02:46.987871Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["2c32a282", "2e772eab"] @@ -70,3 +70,4 @@ How the implementation will work. | When | From | To | By | |------|------|----|----| | 2026-04-30T20:05Z | — | new | philippepascal | +| 2026-04-30T21:02Z | new | groomed | philippepascal | From d1fb2df69dcc6167d82f4f818dd108d13fe3cfc2 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:02:55 -0700 Subject: [PATCH 024/305] =?UTF-8?q?ticket(d3b93b95):=20start=20=E2=80=94?= =?UTF-8?q?=20groomed=20=E2=86=92=20in=5Fdesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md index ae330870a..819b8a691 100644 --- a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md +++ b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md @@ -1,7 +1,7 @@ +++ id = "d3b93b95" title = "Wrapper contract foundation: trait, dispatcher, claude built-in (refactor)" -state = "groomed" +state = "in_design" priority = 0 effort = 0 risk = 0 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/d3b93b95-wrapper-contract-foundation-trait-dispat" created_at = "2026-04-30T20:01:55.080870Z" -updated_at = "2026-04-30T21:02:14.844968Z" +updated_at = "2026-04-30T21:02:55.193498Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" +++ @@ -69,3 +69,4 @@ How the implementation will work. |------|------|----|----| | 2026-04-30T20:01Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | +| 2026-04-30T21:02Z | groomed | in_design | philippepascal | From 72c1db925eb7773f4cfabc258ff29bdcfe453a2b Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:07:56 -0700 Subject: [PATCH 025/305] ticket(d3b93b95): set section Acceptance criteria --- ...5-wrapper-contract-foundation-trait-dispat.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md index 819b8a691..4b631e39d 100644 --- a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md +++ b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md @@ -44,7 +44,19 @@ Refactor `apm-core/src/start.rs` to dispatch through a Wrapper abstraction inste ### Acceptance criteria -Checkboxes; each one independently testable. +- [ ] `apm-core/src/wrapper/mod.rs` exists and exports a public `Wrapper` trait with a single method `fn spawn(&self, ctx: &WrapperContext) -> anyhow::Result` +- [ ] `WrapperContext` is a public struct with fields covering all items listed in the Problem scope: `worker_name`, `ticket_id`, `ticket_branch`, `worktree_path`, `system_prompt_file`, `user_message_file`, `skip_permissions`, `profile`, `role_prefix`, `options`, `model`, `log_path` +- [ ] `resolve_builtin("claude")` returns `Some(_)` (a `Box`) +- [ ] `resolve_builtin` returns `None` for any name other than `"claude"` +- [ ] The `claude` built-in spawns `claude --print --output-format=stream-json --verbose --system-prompt [--model ] [--dangerously-skip-permissions] ` — byte-for-byte identical flags to the current hardcoded invocation +- [ ] All ten contract env vars are present on the spawned child process: `APM_AGENT_NAME`, `APM_TICKET_ID`, `APM_TICKET_BRANCH`, `APM_TICKET_WORKTREE`, `APM_SYSTEM_PROMPT_FILE`, `APM_USER_MESSAGE_FILE`, `APM_SKIP_PERMISSIONS` (`"1"` or `"0"`), `APM_PROFILE`, `APM_WRAPPER_VERSION=1`; `APM_ROLE_PREFIX` is set when `ctx.role_prefix` is `Some` +- [ ] System prompt content is written to a temp file before spawn; `ctx.system_prompt_file` and `APM_SYSTEM_PROMPT_FILE` point to the same path +- [ ] User message content is written to a temp file before spawn; `ctx.user_message_file` and `APM_USER_MESSAGE_FILE` point to the same path +- [ ] Both temp files are removed after the child process exits (best-effort; removal errors are not propagated) +- [ ] `build_spawn_command` is refactored to write temp files and dispatch through `WrapperContext`; it no longer directly appends `--output-format`, `--verbose`, `--system-prompt`, or `--dangerously-skip-permissions` to the command +- [ ] `spawn_container_worker` is refactored to write temp files and dispatch through `WrapperContext`; docker `--env` flags carry the same APM contract vars as the local path +- [ ] All pre-existing tests in `start.rs` pass +- [ ] New unit tests cover: `resolve_builtin` returning `Some`/`None`, all APM env vars present on the spawned process, temp file creation and best-effort cleanup after child exit ### Out of scope @@ -69,4 +81,4 @@ How the implementation will work. |------|------|----|----| | 2026-04-30T20:01Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | -| 2026-04-30T21:02Z | groomed | in_design | philippepascal | +| 2026-04-30T21:02Z | groomed | in_design | philippepascal | \ No newline at end of file From f9635a4fa76496ef498cf81df546bf327f9dafd9 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:08:00 -0700 Subject: [PATCH 026/305] ticket(d3b93b95): set section Out of scope --- ...b93b95-wrapper-contract-foundation-trait-dispat.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md index 4b631e39d..a60f0bf29 100644 --- a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md +++ b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md @@ -60,7 +60,16 @@ Refactor `apm-core/src/start.rs` to dispatch through a Wrapper abstraction inste ### Out of scope -Explicit list of what this ticket does not cover. +- Reading wrapper name from config (`[workers] agent = ...`) — hardcoded to `claude` for now; config wiring is ticket 6cac8518 +- `APM_OPT_` env vars — options are not yet readable from config; deferred to ticket 6cac8518 +- Custom wrappers from `.apm/agents//` — ticket 2c32a282 +- Mock built-ins (`mock-happy`, `mock-sad`, `mock-random`, `debug`) — ticket 25c92daa +- Wrapper-contract versioning checks against `manifest.toml` — ticket 2e772eab; `APM_WRAPPER_VERSION=1` is stamped but not validated +- Removing `check_output_format_supported()` — kept until config moves to `agent` field +- Per-agent instruction file resolution under `.apm/agents//` — ticket 7f5f73d5 +- `apm agents` subcommand — ticket 71d80e40 +- Migration tooling for legacy `command/args/model` config — ticket 3048d7e9 +- Frontmatter `agent` / `agent_overrides` fields — ticket 0ca3e019 ### Approach From 54147fa9067dbfaad06a92f8cc6dfad0c8607820 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:08:05 -0700 Subject: [PATCH 027/305] ticket(d3b93b95): set section Approach --- ...rapper-contract-foundation-trait-dispat.md | 85 ++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md index a60f0bf29..a8c201264 100644 --- a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md +++ b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md @@ -73,7 +73,90 @@ Refactor `apm-core/src/start.rs` to dispatch through a Wrapper abstraction inste ### Approach -How the implementation will work. +**New module: `apm-core/src/wrapper/`** + +Create two files; register with `pub mod wrapper;` in `apm-core/src/lib.rs`: + +- `wrapper/mod.rs` — `Wrapper` trait, `WrapperContext`, `resolve_builtin()` +- `wrapper/claude.rs` — `ClaudeWrapper` struct implementing `Wrapper` + +--- + +**`WrapperContext` (in `wrapper/mod.rs`)** + +Public struct with fields: `worker_name: String`, `ticket_id: String`, `ticket_branch: String`, `worktree_path: PathBuf`, `system_prompt_file: PathBuf`, `user_message_file: PathBuf`, `skip_permissions: bool`, `profile: String`, `role_prefix: Option`, `options: HashMap` (empty until config ticket 6cac8518), `model: Option`, `log_path: PathBuf`, `container: Option` (Some(image) → docker path). + +**`Wrapper` trait** + +One method: `fn spawn(&self, ctx: &WrapperContext) -> anyhow::Result` + +**`resolve_builtin`** + +Match on name: `"claude"` → `Some(Box::new(ClaudeWrapper))`, anything else → `None`. + +--- + +**`ClaudeWrapper` (in `wrapper/claude.rs`)** + +`spawn()` branches on `ctx.container`: + +_Local path (container is None):_ +1. Read file contents: `sys = fs::read_to_string(&ctx.system_prompt_file)?`, `msg = fs::read_to_string(&ctx.user_message_file)?` +2. Build `Command::new("claude")` with args in order: `--print`, `--output-format stream-json`, `--verbose`, `--system-prompt `, optionally `--model `, optionally `--dangerously-skip-permissions` (when `ctx.skip_permissions`), then `` as positional arg +3. Set all APM contract env vars (see table below) +4. `.current_dir(&ctx.worktree_path)` +5. Redirect stdout + stderr to `File::create(&ctx.log_path)?`; `try_clone()` for stderr fd +6. `.process_group(0)`, then `.spawn()` + +_Container path (container is Some(image)):_ +Build `docker run --rm --volume :/workspace --workdir /workspace` followed by `--env KEY=VAL` for each APM contract var and inherited vars (ANTHROPIC_API_KEY, git identity), then ` claude --print --output-format stream-json --verbose --system-prompt [--model X] [--dangerously-skip-permissions] `. Mirrors current `spawn_container_worker` structure; ANTHROPIC_API_KEY and git identity vars still resolved the same way. + +**APM contract env vars (set in both paths)** + +| Var | Value | +|---|---| +| `APM_AGENT_NAME` | `ctx.worker_name` | +| `APM_TICKET_ID` | `ctx.ticket_id` | +| `APM_TICKET_BRANCH` | `ctx.ticket_branch` | +| `APM_TICKET_WORKTREE` | `ctx.worktree_path` as str | +| `APM_SYSTEM_PROMPT_FILE` | `ctx.system_prompt_file` as str | +| `APM_USER_MESSAGE_FILE` | `ctx.user_message_file` as str | +| `APM_SKIP_PERMISSIONS` | `"1"` or `"0"` | +| `APM_PROFILE` | `ctx.profile` | +| `APM_ROLE_PREFIX` | `ctx.role_prefix` when Some | +| `APM_WRAPPER_VERSION` | `"1"` | + +For local path, use `.env(key, val)` on `Command`. For container path, use `--env key=val` docker args. + +--- + +**Temp file helpers (private, in `start.rs` or `wrapper/mod.rs`)** + +`write_temp_file(prefix: &str, content: &str) -> Result`: write content to `std::env::temp_dir() / "apm-{prefix}-{random}.txt"` and return the path. Use `rand_u16()` (already exists) for the suffix. + +--- + +**Refactoring `build_spawn_command` and `spawn_container_worker`** + +Replace both with a single private `spawn_worker(ctx: WrapperContext) -> Result` that calls `resolve_builtin("claude").expect("always registered").spawn(&ctx)`. The three call sites (`run()`, `run_next()`, `spawn_next_worker()`) each gain the same pattern: + +1. Write temp files: `sys_file = write_temp_file("sys", &worker_system)?`, `msg_file = write_temp_file("msg", &ticket_content)?` +2. Build `WrapperContext` from locals already in scope — all fields (`ticket_id`, `ticket_branch`, `worktree_path`, `profile` name, `role_prefix`, `model`, `container`, etc.) are available at each call site +3. Call `spawn_worker(ctx)` → `child` +4. Spawn a cleanup thread that waits on `child.wait()` then calls `fs::remove_file` on both temp paths (errors ignored) + +`params.args` (the `--print` arg previously from `workers.args` config) is no longer passed to the claude CLI from outside; the built-in hardcodes it. The `params.env` user-configured env vars should still be forwarded to the child process; add them to `WrapperContext.options` or thread them through a dedicated `extra_env: HashMap` field. + +--- + +**Tests to add** + +- `resolve_builtin_claude_returns_some` — `assert!(resolve_builtin("claude").is_some())` +- `resolve_builtin_unknown_returns_none` — `assert!(resolve_builtin("bogus").is_none())` +- `claude_wrapper_sets_apm_env_vars` — mock script writes its env to a file; assert `APM_TICKET_ID`, `APM_TICKET_BRANCH`, `APM_TICKET_WORKTREE`, `APM_SYSTEM_PROMPT_FILE`, `APM_USER_MESSAGE_FILE`, `APM_SKIP_PERMISSIONS`, `APM_PROFILE`, `APM_WRAPPER_VERSION` are all present with correct values (same fixture pattern as existing `spawn_worker_cwd_is_ticket_worktree`) +- `temp_files_removed_after_child_exits` — write two temp files, include their paths in `WrapperContext`, spawn a trivial wrapper, wait, assert both files are gone + +**Existing test compatibility:** `spawn_worker_cwd_is_ticket_worktree` calls `build_spawn_command` directly. After the refactor, update it to call `spawn_worker` with a `WrapperContext`; the invariant (cwd == worktree path) is unchanged. ### Open questions From b5d0ef000cba0b3a2579f254f8b753998c061b34 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:08:11 -0700 Subject: [PATCH 028/305] ticket(d3b93b95): set effort = 4 --- tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md index a8c201264..d4d47fd95 100644 --- a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md +++ b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md @@ -3,13 +3,13 @@ id = "d3b93b95" title = "Wrapper contract foundation: trait, dispatcher, claude built-in (refactor)" state = "in_design" priority = 0 -effort = 0 +effort = 4 risk = 0 author = "philippepascal" owner = "philippepascal" branch = "ticket/d3b93b95-wrapper-contract-foundation-trait-dispat" created_at = "2026-04-30T20:01:55.080870Z" -updated_at = "2026-04-30T21:02:55.193498Z" +updated_at = "2026-04-30T21:08:11.559005Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" +++ From d3ec85e79f0ff548f908d49e96c69a6ebfbce7fd Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:08:14 -0700 Subject: [PATCH 029/305] ticket(d3b93b95): set risk = 2 --- tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md index d4d47fd95..d493570a0 100644 --- a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md +++ b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md @@ -4,12 +4,12 @@ title = "Wrapper contract foundation: trait, dispatcher, claude built-in (refact state = "in_design" priority = 0 effort = 4 -risk = 0 +risk = 2 author = "philippepascal" owner = "philippepascal" branch = "ticket/d3b93b95-wrapper-contract-foundation-trait-dispat" created_at = "2026-04-30T20:01:55.080870Z" -updated_at = "2026-04-30T21:08:11.559005Z" +updated_at = "2026-04-30T21:08:14.472552Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" +++ From 6dbf94e1703c85291cb196f72a48126c2c6fa0c1 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:08:19 -0700 Subject: [PATCH 030/305] =?UTF-8?q?ticket(d3b93b95):=20in=5Fdesign=20?= =?UTF-8?q?=E2=86=92=20specd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../d3b93b95-wrapper-contract-foundation-trait-dispat.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md index d493570a0..cc0d3874e 100644 --- a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md +++ b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md @@ -1,7 +1,7 @@ +++ id = "d3b93b95" title = "Wrapper contract foundation: trait, dispatcher, claude built-in (refactor)" -state = "in_design" +state = "specd" priority = 0 effort = 4 risk = 2 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/d3b93b95-wrapper-contract-foundation-trait-dispat" created_at = "2026-04-30T20:01:55.080870Z" -updated_at = "2026-04-30T21:08:14.472552Z" +updated_at = "2026-04-30T21:08:19.769830Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" +++ @@ -173,4 +173,5 @@ Replace both with a single private `spawn_worker(ctx: WrapperContext) -> Result< |------|------|----|----| | 2026-04-30T20:01Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | -| 2026-04-30T21:02Z | groomed | in_design | philippepascal | \ No newline at end of file +| 2026-04-30T21:02Z | groomed | in_design | philippepascal | +| 2026-04-30T21:08Z | in_design | specd | claude-0430-2102-93c0 | From 6331c1bb7af5c8c27c22d12816b6c129a1ccacf7 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:08:37 -0700 Subject: [PATCH 031/305] =?UTF-8?q?ticket(a1b94ea4):=20start=20=E2=80=94?= =?UTF-8?q?=20groomed=20=E2=86=92=20in=5Fdesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md index 099bb554f..9d65390ad 100644 --- a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md +++ b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md @@ -1,7 +1,7 @@ +++ id = "a1b94ea4" title = "Add outcome field to TransitionConfig with implicit defaults" -state = "groomed" +state = "in_design" priority = 0 effort = 0 risk = 0 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/a1b94ea4-add-outcome-field-to-transitionconfig-wi" created_at = "2026-04-30T20:02:08.987471Z" -updated_at = "2026-04-30T21:02:18.045801Z" +updated_at = "2026-04-30T21:08:37.433112Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" +++ @@ -69,3 +69,4 @@ How the implementation will work. |------|------|----|----| | 2026-04-30T20:02Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | +| 2026-04-30T21:08Z | groomed | in_design | philippepascal | From ad121c5fe294fc8bdf09cdc28c2e9b5f21d3f812 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:15:44 -0700 Subject: [PATCH 032/305] ticket(a1b94ea4): set section Problem --- ...dd-outcome-field-to-transitionconfig-wi.md | 30 ++++--------------- 1 file changed, 6 insertions(+), 24 deletions(-) diff --git a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md index 9d65390ad..b140083b4 100644 --- a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md +++ b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md @@ -18,29 +18,11 @@ target_branch = "epic/4312fbd4-agent-wrapper-architecture" ### Problem -Add an explicit `outcome` field to `TransitionConfig` so mock wrappers and tooling can ask 'is this the success path?' without inferring from `completion` strategy or terminal flags. Independent of the wrapper code; lands as a workflow.toml schema change. - -**Reference spec:** `docs/agent-wrappers.md` — section 'Transition outcomes'. - -**Scope:** -- Add `pub outcome: Option` to `TransitionConfig` in `apm-core/src/config.rs`, with `#[serde(default)]`. -- Recognised values: `success`, `needs_input`, `blocked`, `rejected`, `cancelled`. Custom values are accepted (treated as non-success by tooling). -- Add a helper `pub fn resolve_outcome(transition: &TransitionConfig, target_state: &StateConfig) -> &str` that returns the explicit value if set, otherwise applies the implicit-default rules: - 1. If `completion` is set (any non-`None` strategy) → `success` - 2. Else if target state has `terminal = true` → `cancelled` - 3. Else → `needs_input` -- Add `outcome` to every transition in `apm-core/src/default/workflow.toml` explicitly. The defaults agree with the inference (so this is documentation only) but make the workflow self-describing for new readers and tooling. -- Extend `apm validate` to warn (not error) if a profile would never reach a `success` outcome from any startable state — a dead-end workflow indicates a config mistake worth surfacing. Conservative: warn, don't fail. - -**Out of scope:** -- Mock wrappers using the field (separate ticket; this just adds the field and helper). -- UI surfacing outcome (could be a follow-up; the help schema would auto-pick it up via schemars). -- Hash-trip / validate auto-fix to add `outcome` to existing project workflow.tomls — implicit defaults make this unnecessary. - -**Tests:** -- Unit tests for `resolve_outcome` covering each implicit rule and the explicit-override case. -- Update existing default-workflow tests to assert each transition has an outcome (explicit or inferred). -- Validate test for the dead-end warning. +The `TransitionConfig` struct in `apm-core/src/config.rs` has no `outcome` field. Any tooling that needs to know whether a transition represents the worker's success path — mock wrappers, dead-end detection in `apm validate`, future UI colouring — must each re-implement the same inference from `CompletionStrategy` and `StateConfig.terminal`. That logic has a canonical definition in `docs/agent-wrappers.md` § "Transition outcomes," but it lives only in prose, not in code. + +Adding `pub outcome: Option` to `TransitionConfig` together with a single `resolve_outcome` helper centralises the inference once. Projects that want a more precise label (e.g. marking a transition to `ammend` as `rejected` rather than the inferred `needs_input`) can set the field explicitly; the helper returns the explicit value when present and falls back to the three-rule inference otherwise. + +This ticket's scope is the data model and its rules. The field is deliberately inert in the current binary: mock wrappers that read it are a separate ticket (25c92daa). The value delivered here is (a) a stable, typed schema that downstream consumers can rely on without re-deriving the logic, (b) a `resolve_outcome` helper they can call directly, and (c) an annotated `workflow.toml` that makes the shipped default self-describing. ### Acceptance criteria @@ -69,4 +51,4 @@ How the implementation will work. |------|------|----|----| | 2026-04-30T20:02Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | -| 2026-04-30T21:08Z | groomed | in_design | philippepascal | +| 2026-04-30T21:08Z | groomed | in_design | philippepascal | \ No newline at end of file From 35a89e5c3837a9259a907a7c346356249b816592 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:15:49 -0700 Subject: [PATCH 033/305] ticket(a1b94ea4): set section Acceptance criteria --- ...ea4-add-outcome-field-to-transitionconfig-wi.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md index b140083b4..9dcb13fac 100644 --- a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md +++ b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md @@ -26,7 +26,19 @@ This ticket's scope is the data model and its rules. The field is deliberately i ### Acceptance criteria -Checkboxes; each one independently testable. +- [ ] `TransitionConfig` has a `pub outcome: Option` field with `#[serde(default)]` and a doc comment citing the five recognised values +- [ ] A public `resolve_outcome<'a>(transition: &'a TransitionConfig, target_state: &StateConfig) -> &'a str` function exists in `apm-core` +- [ ] `resolve_outcome` returns the explicit outcome string (as `&str`) when `transition.outcome` is `Some` +- [ ] `resolve_outcome` returns `"success"` when `outcome` is `None` and `transition.completion != CompletionStrategy::None` +- [ ] `resolve_outcome` returns `"cancelled"` when `outcome` is `None`, `completion == None`, and `target_state.terminal == true` +- [ ] `resolve_outcome` returns `"needs_input"` when `outcome` is `None`, `completion == None`, and `target_state.terminal == false` +- [ ] Every `[[workflow.states.transitions]]` block in `apm-core/src/default/workflow.toml` contains an explicit `outcome` field +- [ ] `apm validate` emits a `warning:` line (not an error) when the workflow has no reachable `success` outcome from any agent-actionable state +- [ ] `apm validate` exits 0 (success) when the dead-end warning is the only issue +- [ ] Unit tests in `apm-core/src/config.rs` cover all four `resolve_outcome` branches, each as a separate `#[test]` +- [ ] A test asserts that every transition in the default workflow reports a non-empty outcome string via `resolve_outcome` +- [ ] A validate test covers the dead-end-warning path (workflow with an agent-actionable state but no reachable `success` transition) +- [ ] A validate test asserts the dead-end warning is absent for the default workflow (which has a reachable `success` via `in_progress -> implemented`) ### Out of scope From 09b5cf1a5ece37ba65244919f9df2f7a872bd7d6 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:15:54 -0700 Subject: [PATCH 034/305] ticket(a1b94ea4): set section Out of scope --- .../a1b94ea4-add-outcome-field-to-transitionconfig-wi.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md index 9dcb13fac..2c88dacbf 100644 --- a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md +++ b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md @@ -42,7 +42,12 @@ This ticket's scope is the data model and its rules. The field is deliberately i ### Out of scope -Explicit list of what this ticket does not cover. +- Mock wrappers (`mock-happy`, `mock-sad`, `mock-random`) reading the `outcome` field — ticket 25c92daa +- `apm validate --fix` auto-populating `outcome` on project `workflow.toml` files (implicit defaults make migration unnecessary) +- Supervisor UI colouring transitions by outcome +- JSON Schema / schemars export changes (automatic via `#[derive(JsonSchema)]` already on `TransitionConfig`) +- Per-profile dead-end analysis in `apm validate` (this ticket warns at global workflow level only; per-profile is a possible follow-up) +- Rejecting unknown outcome values at parse time (custom strings are accepted; tooling treats them as non-success) ### Approach From a709b635410f30720252bc5a1183942aed06c23e Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:15:58 -0700 Subject: [PATCH 035/305] ticket(a1b94ea4): set section Approach --- ...dd-outcome-field-to-transitionconfig-wi.md | 133 +++++++++++++++++- 1 file changed, 132 insertions(+), 1 deletion(-) diff --git a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md index 2c88dacbf..247ca44bd 100644 --- a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md +++ b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md @@ -51,7 +51,138 @@ This ticket's scope is the data model and its rules. The field is deliberately i ### Approach -How the implementation will work. +### 1. `apm-core/src/config.rs` — add field to `TransitionConfig` + +Add after the `profile` field (~line 365): + +```rust +/// Semantic outcome of this transition from the worker's perspective. +/// Recognised values: `success`, `needs_input`, `blocked`, `rejected`, `cancelled`. +/// Custom values are accepted but treated as non-success by tooling. +/// When omitted, `resolve_outcome` applies implicit defaults; see that function. +#[serde(default)] +pub outcome: Option, +``` + +### 2. `apm-core/src/config.rs` — add `resolve_outcome` + +Add as a free function at module level (not inside `impl`), below the struct definitions: + +```rust +/// Returns the effective outcome label for `transition`. +/// +/// Uses the explicit `outcome` field when set; otherwise applies implicit defaults in order: +/// 1. `completion` strategy is set (non-`None`) → `"success"` +/// 2. `target_state.terminal` is true → `"cancelled"` +/// 3. Otherwise → `"needs_input"` +pub fn resolve_outcome<'a>( + transition: &'a TransitionConfig, + target_state: &StateConfig, +) -> &'a str { + if let Some(ref o) = transition.outcome { + return o.as_str(); + } + if transition.completion != CompletionStrategy::None { + return "success"; + } + if target_state.terminal { + return "cancelled"; + } + "needs_input" +} +``` + +The static string returns (`"success"` etc.) coerce to `&'a str` because `'static: 'a`. + +### 3. `apm-core/src/default/workflow.toml` — annotate every transition + +Add `outcome = ""` to each `[[workflow.states.transitions]]` block. The mapping rule matches the implicit defaults exactly, so these annotations are self-documenting, not overrides: + +| Condition | `outcome` value | +|---|---| +| transition has `completion` set | `"success"` | +| target state is `closed` (terminal) | `"cancelled"` | +| all other transitions | `"needs_input"` | + +No transition in the default workflow uses `rejected` or `blocked` explicitly — those values exist for project-level customisation. Every explicit value set here matches what `resolve_outcome` would infer anyway. + +Mapping for each transition (implementer: verify `completion` field in the file before writing; `completion`-carrying transitions are the authoritative source of `"success"`): + +- `new → groomed` (no completion, non-terminal) → `"needs_input"` +- `new → closed` (terminal) → `"cancelled"` +- `groomed → in_design` (no completion, non-terminal) → `"needs_input"` +- `groomed → closed` → `"cancelled"` +- `question → groomed` → `"needs_input"` +- `question → closed` → `"cancelled"` +- `specd → ready` → `"needs_input"` +- `specd → ammend` → `"needs_input"` +- `specd → closed` → `"cancelled"` +- `ammend → specd` → `"needs_input"` +- `ammend → question` → `"needs_input"` +- `ammend → in_design` → `"needs_input"` +- `ammend → closed` → `"cancelled"` +- `in_design → specd` — if `completion` is set → `"success"`, else `"needs_input"` +- `in_design → question` → `"needs_input"` +- `in_design → ammend` → `"needs_input"` +- `in_design → closed` → `"cancelled"` +- `ready → in_progress` — if `completion` is set → `"success"`, else `"needs_input"` +- `ready → ammend` → `"needs_input"` +- `ready → specd` → `"needs_input"` +- `ready → closed` → `"cancelled"` +- `in_progress → implemented` — has `completion` (merge or pr_or_epic_merge) → `"success"` +- `in_progress → blocked` → `"needs_input"` +- `in_progress → ready` → `"needs_input"` +- `in_progress → ammend` → `"needs_input"` +- `in_progress → closed` → `"cancelled"` +- `blocked → ready` → `"needs_input"` +- `blocked → closed` → `"cancelled"` +- `implemented → ready` → `"needs_input"` +- `implemented → ammend` → `"needs_input"` +- `implemented → in_progress` → `"needs_input"` +- `implemented → closed` → `"cancelled"` +- `merge_failed → implemented` — check `completion`; apply rule +- `merge_failed → in_progress` → `"needs_input"` + +### 4. `apm-core/src/validate.rs` — dead-end warning in `validate_warnings` + +Extend `validate_warnings(config: &Config) -> Vec` with a reachability check. Insert after the existing docker check: + +``` +1. Build HashMap<&str, &StateConfig> indexed by state.id for O(1) target lookup. +2. Collect agent-startable state IDs: states where actionable contains "agent" or "any". +3. BFS from each startable state ID, tracking visited state IDs to avoid cycles. + For each visited state, iterate its transitions: + - Call resolve_outcome(t, lookup[t.to]) for each transition t. + - If any result == "success": success is reachable — skip the warning entirely. + - Otherwise enqueue t.to if not yet visited. +4. If BFS completes without finding a "success" outcome, push: + "workflow has no reachable 'success' outcome from any agent-actionable state; \ + workers may never complete successfully" +``` + +This is O(states × transitions) — negligible for real workflows. + +Note: skip the check (no warning) if there are no agent-startable states at all, since the workflow may be supervisor-only by design. + +### 5. Tests + +**`apm-core/src/config.rs` `#[cfg(test)]`** — four new unit tests for `resolve_outcome`: + +- `resolve_outcome_explicit_override`: `outcome = Some("rejected")`, `completion = None`, non-terminal target → `"rejected"` +- `resolve_outcome_implicit_success`: `outcome = None`, `completion = Merge`, any target → `"success"` +- `resolve_outcome_implicit_cancelled`: `outcome = None`, `completion = None`, `target.terminal = true` → `"cancelled"` +- `resolve_outcome_implicit_needs_input`: `outcome = None`, `completion = None`, `target.terminal = false` → `"needs_input"` + +Construct minimal `TransitionConfig` and `StateConfig` values inline (derive `Default` if needed or set each field explicitly). + +**`apm-core/src/init.rs` or `apm-core/src/config.rs`** — extend `default_workflow_toml_is_valid` or add a sibling test: + +Parse the default workflow; build a state-by-id map; for each state's transitions, call `resolve_outcome(t, target)`; assert the result is one of `["success", "needs_input", "blocked", "rejected", "cancelled"]`. This guards against future regressions that produce an unexpected outcome string. + +**`apm-core/src/validate.rs` `#[cfg(test)]`** — two new tests: + +- `dead_end_workflow_warning_emitted`: construct a minimal `Config` with one `actionable = ["agent"]` state whose only transition leads to another non-terminal, no-completion state (forming a cycle with no success exit). Assert `validate_warnings` returns a vec containing a string with `"success"` in the dead-end warning message. +- `default_workflow_no_dead_end_warning`: load the default config (same helper used by existing tests). Assert no item in `validate_warnings` is the dead-end warning string — the default workflow has `in_progress → implemented` with `completion = merge`, which is reachable from `in_progress` (actionable by agents). ### Open questions From 2c784f6b7c7aefb213edd202afdd8fe7381d85ae Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:16:05 -0700 Subject: [PATCH 036/305] ticket(a1b94ea4): set effort = 4 --- tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md index 247ca44bd..d335845b2 100644 --- a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md +++ b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md @@ -3,13 +3,13 @@ id = "a1b94ea4" title = "Add outcome field to TransitionConfig with implicit defaults" state = "in_design" priority = 0 -effort = 0 +effort = 4 risk = 0 author = "philippepascal" owner = "philippepascal" branch = "ticket/a1b94ea4-add-outcome-field-to-transitionconfig-wi" created_at = "2026-04-30T20:02:08.987471Z" -updated_at = "2026-04-30T21:08:37.433112Z" +updated_at = "2026-04-30T21:16:05.337291Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" +++ From 82d146b30c89a7ab678ed8cdd7716a5bd2cd7a69 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:16:08 -0700 Subject: [PATCH 037/305] ticket(a1b94ea4): set risk = 2 --- tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md index d335845b2..c8eba6385 100644 --- a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md +++ b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md @@ -4,12 +4,12 @@ title = "Add outcome field to TransitionConfig with implicit defaults" state = "in_design" priority = 0 effort = 4 -risk = 0 +risk = 2 author = "philippepascal" owner = "philippepascal" branch = "ticket/a1b94ea4-add-outcome-field-to-transitionconfig-wi" created_at = "2026-04-30T20:02:08.987471Z" -updated_at = "2026-04-30T21:16:05.337291Z" +updated_at = "2026-04-30T21:16:08.236745Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" +++ From efcb4d74f8cb585b5d937f7a7f900c330d540c50 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:17:02 -0700 Subject: [PATCH 038/305] ticket(a1b94ea4): set section Approach --- ...dd-outcome-field-to-transitionconfig-wi.md | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md index c8eba6385..4ca428718 100644 --- a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md +++ b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md @@ -51,6 +51,140 @@ This ticket's scope is the data model and its rules. The field is deliberately i ### Approach +Five changes across three files: add the `outcome` field to `TransitionConfig`, add the `resolve_outcome` free function, annotate every default-workflow transition with an explicit outcome, add a dead-end reachability warning to `apm validate`, and add the corresponding tests. + +#### 1. `apm-core/src/config.rs` — add field to `TransitionConfig` + +Add after the `profile` field (~line 365): + +```rust +/// Semantic outcome of this transition from the worker's perspective. +/// Recognised values: `success`, `needs_input`, `blocked`, `rejected`, `cancelled`. +/// Custom values are accepted but treated as non-success by tooling. +/// When omitted, `resolve_outcome` applies implicit defaults; see that function. +#[serde(default)] +pub outcome: Option, +``` + +#### 2. `apm-core/src/config.rs` — add `resolve_outcome` + +Add as a free function at module level (not inside `impl`), below the struct definitions: + +```rust +/// Returns the effective outcome label for `transition`. +/// +/// Uses the explicit `outcome` field when set; otherwise applies implicit defaults in order: +/// 1. `completion` strategy is set (non-`None`) → `"success"` +/// 2. `target_state.terminal` is true → `"cancelled"` +/// 3. Otherwise → `"needs_input"` +pub fn resolve_outcome<'a>( + transition: &'a TransitionConfig, + target_state: &StateConfig, +) -> &'a str { + if let Some(ref o) = transition.outcome { + return o.as_str(); + } + if transition.completion != CompletionStrategy::None { + return "success"; + } + if target_state.terminal { + return "cancelled"; + } + "needs_input" +} +``` + +The static string returns (`"success"` etc.) coerce to `&'a str` because `'static: 'a`. + +#### 3. `apm-core/src/default/workflow.toml` — annotate every transition + +Add `outcome = ""` to each `[[workflow.states.transitions]]` block. The mapping rule matches the implicit defaults exactly, so these annotations are self-documenting, not overrides: + +| Condition | `outcome` value | +|---|---| +| transition has `completion` set | `"success"` | +| target state is `closed` (terminal) | `"cancelled"` | +| all other transitions | `"needs_input"` | + +No transition in the default workflow uses `rejected` or `blocked` explicitly — those values exist for project-level customisation. Every explicit value set here matches what `resolve_outcome` would infer anyway. + +Mapping for each transition (implementer: verify `completion` field in the file before writing; `completion`-carrying transitions are the authoritative source of `"success"`): + +- `new → groomed` (no completion, non-terminal) → `"needs_input"` +- `new → closed` (terminal) → `"cancelled"` +- `groomed → in_design` (no completion, non-terminal) → `"needs_input"` +- `groomed → closed` → `"cancelled"` +- `question → groomed` → `"needs_input"` +- `question → closed` → `"cancelled"` +- `specd → ready` → `"needs_input"` +- `specd → ammend` → `"needs_input"` +- `specd → closed` → `"cancelled"` +- `ammend → specd` → `"needs_input"` +- `ammend → question` → `"needs_input"` +- `ammend → in_design` → `"needs_input"` +- `ammend → closed` → `"cancelled"` +- `in_design → specd` — if `completion` is set → `"success"`, else `"needs_input"` +- `in_design → question` → `"needs_input"` +- `in_design → ammend` → `"needs_input"` +- `in_design → closed` → `"cancelled"` +- `ready → in_progress` — if `completion` is set → `"success"`, else `"needs_input"` +- `ready → ammend` → `"needs_input"` +- `ready → specd` → `"needs_input"` +- `ready → closed` → `"cancelled"` +- `in_progress → implemented` — has `completion` (merge or pr_or_epic_merge) → `"success"` +- `in_progress → blocked` → `"needs_input"` +- `in_progress → ready` → `"needs_input"` +- `in_progress → ammend` → `"needs_input"` +- `in_progress → closed` → `"cancelled"` +- `blocked → ready` → `"needs_input"` +- `blocked → closed` → `"cancelled"` +- `implemented → ready` → `"needs_input"` +- `implemented → ammend` → `"needs_input"` +- `implemented → in_progress` → `"needs_input"` +- `implemented → closed` → `"cancelled"` +- `merge_failed → implemented` — check `completion`; apply rule +- `merge_failed → in_progress` → `"needs_input"` + +#### 4. `apm-core/src/validate.rs` — dead-end warning in `validate_warnings` + +Extend `validate_warnings(config: &Config) -> Vec` with a reachability check after the existing docker check: + +``` +1. Build HashMap<&str, &StateConfig> indexed by state.id for O(1) target lookup. +2. Collect agent-startable state IDs: states where actionable contains "agent" or "any". + If no such states exist, skip the check — the workflow may be supervisor-only by design. +3. BFS from each startable state ID, tracking visited state IDs to avoid cycles. + For each visited state, iterate its transitions: + - Call resolve_outcome(t, lookup[t.to]) for each transition t. + - If any result == "success": success is reachable — return without warning. + - Otherwise enqueue t.to if not yet visited. +4. If BFS completes without finding a "success" outcome, push: + "workflow has no reachable 'success' outcome from any agent-actionable state; \ + workers may never complete successfully" +``` + +This is O(states × transitions) — negligible for real workflows. + +#### 5. Tests + +**`apm-core/src/config.rs` `#[cfg(test)]`** — four new unit tests for `resolve_outcome`: + +- `resolve_outcome_explicit_override`: `outcome = Some("rejected")`, `completion = None`, non-terminal target → `"rejected"` +- `resolve_outcome_implicit_success`: `outcome = None`, `completion = Merge`, any target → `"success"` +- `resolve_outcome_implicit_cancelled`: `outcome = None`, `completion = None`, `target.terminal = true` → `"cancelled"` +- `resolve_outcome_implicit_needs_input`: `outcome = None`, `completion = None`, `target.terminal = false` → `"needs_input"` + +Construct minimal `TransitionConfig` and `StateConfig` values inline; set only the fields each test cares about. + +**`apm-core/src/init.rs` or `apm-core/src/config.rs`** — extend `default_workflow_toml_is_valid` or add a sibling test: + +Parse the default workflow; build a state-by-id map; for each state's transitions, call `resolve_outcome(t, target)`; assert the result is one of `["success", "needs_input", "blocked", "rejected", "cancelled"]`. This guards against future regressions. + +**`apm-core/src/validate.rs` `#[cfg(test)]`** — two new tests: + +- `dead_end_workflow_warning_emitted`: construct a minimal `Config` with one `actionable = ["agent"]` state whose only transition leads to a non-terminal, no-completion state with no further exit. Assert `validate_warnings` returns a vec whose first item contains the string `"success"`. +- `default_workflow_no_dead_end_warning`: load the default config (same helper used by existing tests). Assert no item in `validate_warnings` is the dead-end warning string, since `in_progress → implemented` with `completion = merge` is reachable from the agent-actionable `in_progress` state. + ### 1. `apm-core/src/config.rs` — add field to `TransitionConfig` Add after the `profile` field (~line 365): From a2096cb3b888c901626c0e650f8a0cc5624c617f Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:17:07 -0700 Subject: [PATCH 039/305] =?UTF-8?q?ticket(a1b94ea4):=20in=5Fdesign=20?= =?UTF-8?q?=E2=86=92=20specd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../a1b94ea4-add-outcome-field-to-transitionconfig-wi.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md index 4ca428718..e5525161b 100644 --- a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md +++ b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md @@ -1,7 +1,7 @@ +++ id = "a1b94ea4" title = "Add outcome field to TransitionConfig with implicit defaults" -state = "in_design" +state = "specd" priority = 0 effort = 4 risk = 2 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/a1b94ea4-add-outcome-field-to-transitionconfig-wi" created_at = "2026-04-30T20:02:08.987471Z" -updated_at = "2026-04-30T21:16:08.236745Z" +updated_at = "2026-04-30T21:17:07.256797Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" +++ @@ -333,4 +333,5 @@ Parse the default workflow; build a state-by-id map; for each state's transition |------|------|----|----| | 2026-04-30T20:02Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | -| 2026-04-30T21:08Z | groomed | in_design | philippepascal | \ No newline at end of file +| 2026-04-30T21:08Z | groomed | in_design | philippepascal | +| 2026-04-30T21:17Z | in_design | specd | claude-0430-2108-e298 | From 92e72437d2118ba7a2dcae9129f45750ee33ba6a Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:17:26 -0700 Subject: [PATCH 040/305] =?UTF-8?q?ticket(6cac8518):=20start=20=E2=80=94?= =?UTF-8?q?=20groomed=20=E2=86=92=20in=5Fdesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/6cac8518-config-schema-agent-options-drop-command.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/6cac8518-config-schema-agent-options-drop-command.md b/tickets/6cac8518-config-schema-agent-options-drop-command.md index 5c36acf4e..a06c19bb0 100644 --- a/tickets/6cac8518-config-schema-agent-options-drop-command.md +++ b/tickets/6cac8518-config-schema-agent-options-drop-command.md @@ -1,7 +1,7 @@ +++ id = "6cac8518" title = "Config schema: agent + options (drop command/args/model)" -state = "groomed" +state = "in_design" priority = 0 effort = 0 risk = 0 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/6cac8518-config-schema-agent-options-drop-command" created_at = "2026-04-30T20:02:34.693415Z" -updated_at = "2026-04-30T21:02:20.952245Z" +updated_at = "2026-04-30T21:17:25.961937Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95"] @@ -73,3 +73,4 @@ How the implementation will work. |------|------|----|----| | 2026-04-30T20:02Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | +| 2026-04-30T21:17Z | groomed | in_design | philippepascal | From defb9eab7855f69fea9a0973b4bc354e21f65e6c Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:21:23 -0700 Subject: [PATCH 041/305] ticket(6cac8518): set section Problem --- ...onfig-schema-agent-options-drop-command.md | 29 ++----------------- 1 file changed, 2 insertions(+), 27 deletions(-) diff --git a/tickets/6cac8518-config-schema-agent-options-drop-command.md b/tickets/6cac8518-config-schema-agent-options-drop-command.md index a06c19bb0..4e5667287 100644 --- a/tickets/6cac8518-config-schema-agent-options-drop-command.md +++ b/tickets/6cac8518-config-schema-agent-options-drop-command.md @@ -19,32 +19,7 @@ depends_on = ["d3b93b95"] ### Problem -Replace the legacy `[workers] command/args/model` config triplet with the wrapper-driven shape: `[workers] agent = ""` plus a `[workers.options]` table for wrapper-specific options. Same for `[worker_profiles.]`. Read `agent` selection from config to drive the wrapper dispatcher landed in d3b93b95. - -**Reference spec:** `docs/agent-wrappers.md` — sections 'Configuration', 'Options table'. - -**Scope:** -- `apm-core/src/config.rs`: - - `WorkersConfig`: add `agent: Option` (default `Some("claude")`), `options: HashMap` (default empty). Keep `command`, `args`, `model` as deprecated optional fields for backward-compat read (see migration ticket); they no longer drive spawn behaviour. - - `WorkerProfileConfig`: add same two fields. Profile values override global if set. -- `apm-core/src/start.rs`: - - Resolve effective agent name: profile → workers → built-in default `claude`. - - Resolve effective options: profile.options merged over workers.options. - - Pass agent name to the wrapper dispatcher (built or custom) from d3b93b95. - - Set `APM_OPT_` env vars from the resolved options map (key uppercased, dots/dashes → underscores). - - When legacy `command/args/model` are present in config and `agent` is absent, synthesize `agent = "claude"` and emit a deprecation warning to stderr (one-time per process). Migration to the new shape lands in the next ticket. -- Update `apm-core/src/default/config.toml` (the init template) to use the new shape: `agent = "claude"`, `options.model = "sonnet"`. Drop `command`/`args`. Same for the two default worker_profiles. - -**Out of scope:** -- Custom wrappers from `.apm/agents//` (separate ticket). -- Frontmatter override (separate ticket). -- Migration helper for existing repos (separate ticket). -- The `check_output_format_supported` removal — can be done here as a side cleanup since wrappers now own their compat checks; or keep until the wrapper-versioning ticket. Pick one in spec phase. - -**Tests:** -- Round-trip the new schema through TOML. -- Resolution chain test (profile.agent overrides workers.agent; profile.options overrides workers.options key-by-key). -- Backward-compat test: a config with only legacy `command = "claude"` resolves to the claude wrapper with a deprecation message. +The wrapper dispatcher landed in d3b93b95 always resolves to the `claude` built-in regardless of config — there is no config-driven dispatch yet. Projects cannot choose their agent, pass a model name cleanly through the new path, or extend behaviour without modifying Rust. Meanwhile, `[workers] command/args/model` are still the authoritative fields even though wrappers now own CLI construction.\n\nThis ticket wires the config to the dispatcher. After it lands, `[workers] agent = "claude"` selects the built-in; `[workers.options]` passes arbitrary key-value pairs that are forwarded to the wrapper as `APM_OPT_` env vars. Model selection moves to `options.model`. Legacy `command`, `args`, and `model` fields remain parseable for backward compatibility but no longer drive spawn behaviour; a one-time deprecation warning is emitted to stderr when they are present without the new `agent` field.\n\nThe desired state: a project sets `agent = "claude"` (or omits it to accept the default) and `options.model = "sonnet"`, and the dispatcher calls `resolve_builtin("claude").spawn(ctx)` with `ctx.options` populated — identical runtime behaviour to today, but driven by the new architecture. ### Acceptance criteria @@ -73,4 +48,4 @@ How the implementation will work. |------|------|----|----| | 2026-04-30T20:02Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | -| 2026-04-30T21:17Z | groomed | in_design | philippepascal | +| 2026-04-30T21:17Z | groomed | in_design | philippepascal | \ No newline at end of file From 7afbde484b43e585ed9ef14e2a7d53b5eaec8363 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:21:42 -0700 Subject: [PATCH 042/305] ticket(6cac8518): set section Acceptance criteria --- tickets/6cac8518-config-schema-agent-options-drop-command.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/6cac8518-config-schema-agent-options-drop-command.md b/tickets/6cac8518-config-schema-agent-options-drop-command.md index 4e5667287..12ff03e40 100644 --- a/tickets/6cac8518-config-schema-agent-options-drop-command.md +++ b/tickets/6cac8518-config-schema-agent-options-drop-command.md @@ -23,7 +23,7 @@ The wrapper dispatcher landed in d3b93b95 always resolves to the `claude` built- ### Acceptance criteria -Checkboxes; each one independently testable. +- [ ] `WorkersConfig` deserializes a TOML block containing `agent = "claude"` and `[workers.options]` without error\n- [ ] `WorkerProfileConfig` deserializes a profile block containing `agent` and `options` without error\n- [ ] A config with `workers.agent = "codex"` and `profile.agent` absent resolves the effective agent to `"codex"`\n- [ ] A config with `workers.agent = "codex"` and `profile.agent = "mock-happy"` resolves the effective agent to `"mock-happy"`\n- [ ] A config with neither `workers.agent` nor `profile.agent` set resolves the effective agent to `"claude"`\n- [ ] `profile.options` keys override `workers.options` keys when both define the same key\n- [ ] `profile.options` and `workers.options` keys that do not overlap are both present in the effective options map\n- [ ] Each entry in the effective options map is forwarded as an env var named `APM_OPT_` (key uppercased, dots and dashes replaced with underscores)\n- [ ] `options.model = "sonnet"` results in `APM_OPT_MODEL=sonnet` being set on the spawned child\n- [ ] A config using only legacy `command = "claude"` (no `agent` field) still spawns the claude wrapper successfully\n- [ ] When legacy `command`, `args`, or `model` fields are present and `agent` is absent, exactly one line is written to stderr containing the word `deprecated` per process run\n- [ ] The deprecation warning is not emitted a second time if a second worker is spawned in the same process\n- [ ] Legacy `model = "sonnet"` with no `options.model` still produces the correct `--model sonnet` flag in the spawned claude command\n- [ ] `apm init` generates a config with `agent = "claude"`, `options.model = "sonnet"`, and no `command` or `args` fields\n- [ ] A config with no `[workers]` section at all spawns successfully with defaults (agent = claude) ### Out of scope From 5794164addf7c11c6f704b205ea5dbee477eed32 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:21:54 -0700 Subject: [PATCH 043/305] ticket(6cac8518): set section Out of scope --- tickets/6cac8518-config-schema-agent-options-drop-command.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/6cac8518-config-schema-agent-options-drop-command.md b/tickets/6cac8518-config-schema-agent-options-drop-command.md index 12ff03e40..24d6da6ab 100644 --- a/tickets/6cac8518-config-schema-agent-options-drop-command.md +++ b/tickets/6cac8518-config-schema-agent-options-drop-command.md @@ -27,7 +27,7 @@ The wrapper dispatcher landed in d3b93b95 always resolves to the `claude` built- ### Out of scope -Explicit list of what this ticket does not cover. +- Custom wrapper resolution from `.apm/agents//` — ticket 2c32a282\n- Per-ticket frontmatter `agent` / `agent_overrides` override — ticket 0ca3e019\n- `apm migrate --fix` automated config file rewrite — ticket 3048d7e9\n- Mock wrappers (`mock-happy`, `mock-sad`, `mock-random`, `debug`) — ticket 25c92daa\n- Removing `check_output_format_supported()` — deferred to wrapper-versioning ticket 2e772eab\n- Wrapper-contract versioning checks against `manifest.toml` — ticket 2e772eab\n- Per-agent instruction file resolution under `.apm/agents//` — ticket 7f5f73d5\n- The `apm agents` subcommand — ticket 71d80e40 ### Approach From 8006fec31a0cbfcfc1f2c4858f0c41a4d1bf59f5 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:23:24 -0700 Subject: [PATCH 044/305] ticket(6cac8518): set section Approach --- tickets/6cac8518-config-schema-agent-options-drop-command.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/6cac8518-config-schema-agent-options-drop-command.md b/tickets/6cac8518-config-schema-agent-options-drop-command.md index 24d6da6ab..67af69f17 100644 --- a/tickets/6cac8518-config-schema-agent-options-drop-command.md +++ b/tickets/6cac8518-config-schema-agent-options-drop-command.md @@ -31,7 +31,7 @@ The wrapper dispatcher landed in d3b93b95 always resolves to the `claude` built- ### Approach -How the implementation will work. +Four files change, plus tests.\n\n### 1. apm-core/src/config.rs\n\nWorkersConfig — add agent: Option (no serde default) and options: HashMap with serde(default). Demote command from String-with-serde-default to Option (no default); same for args (Vec with default -> Option>). Remove default_command() and default_args() free functions and their serde attributes. Update WorkersConfig::default() so command and args are None. The model, env, container, keychain fields are unchanged.\n\nWorkerProfileConfig — add agent: Option and options: HashMap with serde(default). All other fields already Option; leave them.\n\n### 2. apm-core/src/start.rs\n\nEffectiveWorkerParams — add agent: String and options: HashMap.\n\neffective_spawn_params() additions:\n\nAgent resolution: raw_agent = profile.agent.clone().or_else(|| workers.agent.clone()). If raw_agent is None AND any legacy field (command, args, model at either level) is Some, call emit_deprecation_warning(). Then agent = raw_agent.unwrap_or("claude".to_string()).\n\nDeprecation gate: declare a module-level static AtomicBool (DEPRECATION_WARNED, default false). emit_deprecation_warning() does compare_exchange false->true; only on success does it eprintln the message. This guarantees exactly one emission per process regardless of how many workers are spawned.\n\nOptions merge: start from workers.options.clone(), then for each (k,v) in profile.options insert into the map (profile wins on collision).\n\nWrapperContext construction: ctx.options = resolved options map. ctx.model = options.get("model").cloned().or_else(|| params.model.clone()) — this honours both new-style options.model and legacy model field, with new-style winning.\n\nDispatcher call: resolve_builtin(¶ms.agent). If None (unknown built-in), return an error with the agent name in the message. Custom-wrapper lookup (ticket 2c32a282) is not part of this ticket; a clear error is sufficient.\n\n### 3. apm-core/src/wrapper/claude.rs (from d3b93b95)\n\nAfter setting the existing APM contract env vars, add a loop over ctx.options: for each (k, v), compute the env key as "APM_OPT_" + k.to_uppercase() with '.' and '-' replaced by '_', then:\n- Local path: cmd.env(env_key, v)\n- Container path: push "--env" and "KEY=VAL" as separate docker args\n\n### 4. apm-core/src/init.rs — default_config()\n\nReplace the [workers] block with:\n agent = "claude"\n [workers.options]\n model = "sonnet"\n\nReplace the two [worker_profiles.*] blocks to keep only instructions and role_prefix (no command, args, or model). Profiles inherit [workers] agent and options.\n\n### 5. Tests\n\n- config_round_trip_new_shape: parse TOML with agent + [workers.options], assert fields match\n- config_round_trip_legacy_shape: parse TOML with only command/args/model, assert agent is None\n- resolution_agent_profile_overrides_global: workers.agent="codex", profile.agent="mock-happy" -> effective="mock-happy"\n- resolution_agent_falls_back_to_claude: neither set -> effective="claude"\n- resolution_options_merge: workers has {model=opus,timeout=30}, profile has {model=sonnet} -> effective {model=sonnet,timeout=30}\n- deprecation_warning_emitted_once: call effective_spawn_params twice with legacy config; assert warning appears in stderr exactly once (redirect stderr via a test helper or check AtomicBool state)\n- apm_opt_env_vars_set: mock script writes env to temp file; assert APM_OPT_MODEL=sonnet is present (same pattern as claude_wrapper_sets_apm_env_vars from d3b93b95)\n- legacy_model_forwarded_to_ctx: workers.model=Some(opus), options empty -> ctx.model=Some(opus)\n- options_model_takes_precedence_over_legacy: workers.model=Some(opus), options.model=sonnet -> ctx.model=Some(sonnet) ### Open questions From 0644925a4762ab776b29d2ebe9349b622c77fd3a Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:23:31 -0700 Subject: [PATCH 045/305] ticket(6cac8518): set effort = 4 --- tickets/6cac8518-config-schema-agent-options-drop-command.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tickets/6cac8518-config-schema-agent-options-drop-command.md b/tickets/6cac8518-config-schema-agent-options-drop-command.md index 67af69f17..c94ac443c 100644 --- a/tickets/6cac8518-config-schema-agent-options-drop-command.md +++ b/tickets/6cac8518-config-schema-agent-options-drop-command.md @@ -3,13 +3,13 @@ id = "6cac8518" title = "Config schema: agent + options (drop command/args/model)" state = "in_design" priority = 0 -effort = 0 +effort = 4 risk = 0 author = "philippepascal" owner = "philippepascal" branch = "ticket/6cac8518-config-schema-agent-options-drop-command" created_at = "2026-04-30T20:02:34.693415Z" -updated_at = "2026-04-30T21:17:25.961937Z" +updated_at = "2026-04-30T21:23:31.126771Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95"] From a943e0d08e4932028ed826a150582ff914d363ee Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:23:36 -0700 Subject: [PATCH 046/305] ticket(6cac8518): set risk = 4 --- tickets/6cac8518-config-schema-agent-options-drop-command.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tickets/6cac8518-config-schema-agent-options-drop-command.md b/tickets/6cac8518-config-schema-agent-options-drop-command.md index c94ac443c..d0799c122 100644 --- a/tickets/6cac8518-config-schema-agent-options-drop-command.md +++ b/tickets/6cac8518-config-schema-agent-options-drop-command.md @@ -4,12 +4,12 @@ title = "Config schema: agent + options (drop command/args/model)" state = "in_design" priority = 0 effort = 4 -risk = 0 +risk = 4 author = "philippepascal" owner = "philippepascal" branch = "ticket/6cac8518-config-schema-agent-options-drop-command" created_at = "2026-04-30T20:02:34.693415Z" -updated_at = "2026-04-30T21:23:31.126771Z" +updated_at = "2026-04-30T21:23:36.029677Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95"] From 915753d41c0421474cbdd3cfe516b56816822ff3 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:23:41 -0700 Subject: [PATCH 047/305] =?UTF-8?q?ticket(6cac8518):=20in=5Fdesign=20?= =?UTF-8?q?=E2=86=92=20specd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../6cac8518-config-schema-agent-options-drop-command.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/6cac8518-config-schema-agent-options-drop-command.md b/tickets/6cac8518-config-schema-agent-options-drop-command.md index d0799c122..f369be351 100644 --- a/tickets/6cac8518-config-schema-agent-options-drop-command.md +++ b/tickets/6cac8518-config-schema-agent-options-drop-command.md @@ -1,7 +1,7 @@ +++ id = "6cac8518" title = "Config schema: agent + options (drop command/args/model)" -state = "in_design" +state = "specd" priority = 0 effort = 4 risk = 4 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/6cac8518-config-schema-agent-options-drop-command" created_at = "2026-04-30T20:02:34.693415Z" -updated_at = "2026-04-30T21:23:36.029677Z" +updated_at = "2026-04-30T21:23:41.365303Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95"] @@ -48,4 +48,5 @@ Four files change, plus tests.\n\n### 1. apm-core/src/config.rs\n\nWorkersConfig |------|------|----|----| | 2026-04-30T20:02Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | -| 2026-04-30T21:17Z | groomed | in_design | philippepascal | \ No newline at end of file +| 2026-04-30T21:17Z | groomed | in_design | philippepascal | +| 2026-04-30T21:23Z | in_design | specd | claude-0430-2117-71a8 | From c9aefd758a6eccdc58e88772eb364e85090e3059 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:24:01 -0700 Subject: [PATCH 048/305] =?UTF-8?q?ticket(2c32a282):=20start=20=E2=80=94?= =?UTF-8?q?=20groomed=20=E2=86=92=20in=5Fdesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md index 36de35459..1e5700a45 100644 --- a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md +++ b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md @@ -1,7 +1,7 @@ +++ id = "2c32a282" title = "Custom wrapper resolution from .apm/agents//" -state = "groomed" +state = "in_design" priority = 0 effort = 0 risk = 0 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/2c32a282-custom-wrapper-resolution-from-apm-agent" created_at = "2026-04-30T20:02:50.794362Z" -updated_at = "2026-04-30T21:02:24.185414Z" +updated_at = "2026-04-30T21:24:01.213094Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95"] @@ -75,3 +75,4 @@ How the implementation will work. |------|------|----|----| | 2026-04-30T20:02Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | +| 2026-04-30T21:24Z | groomed | in_design | philippepascal | From f4276255e614eeb69b24572166791ffca2ec646b Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:36:13 -0700 Subject: [PATCH 049/305] ticket(2c32a282): set section Acceptance criteria --- ...custom-wrapper-resolution-from-apm-agent.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md index 1e5700a45..43a53d326 100644 --- a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md +++ b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md @@ -50,7 +50,21 @@ Implement custom-wrapper resolution from `.apm/agents//` so projects can s ### Acceptance criteria -Checkboxes; each one independently testable. +- [ ] `resolve_wrapper(root, "claude")` returns `Some(WrapperKind::Custom { script_path, .. })` when `.apm/agents/claude/wrapper.sh` exists and is executable, shadowing the built-in +- [ ] `resolve_wrapper(root, "claude")` returns `Some(WrapperKind::Builtin("claude"))` when no project script exists for `"claude"` +- [ ] `resolve_wrapper(root, "bogus")` returns `Ok(None)` when neither a project script nor a built-in with that name exists +- [ ] A `wrapper.*` file that exists but is not executable (Unix: mode `& 0o111 == 0`) is invisible to `resolve_wrapper`; the function falls through to the built-in or returns `None` +- [ ] `apm validate` emits an error of the form `"agent 'foo' not found: checked built-ins {claude} and '.apm/agents/foo/'"` when the configured agent cannot be resolved +- [ ] `apm validate` emits a warning (not an error) when a `.apm/agents//wrapper.*` file exists but lacks the executable bit +- [ ] A valid `manifest.toml` parses without error; `contract_version = 1` and `parser = "canonical"` are stored on the `Manifest` struct +- [ ] A `manifest.toml` with only `[wrapper]` and no explicit fields parses to defaults: `contract_version = 1`, `parser = "canonical"`, `parser_command = None` +- [ ] A `manifest.toml` with an unknown key causes `apm validate` to emit a warning (not an error); the manifest still parses and the wrapper is usable +- [ ] A syntactically invalid `manifest.toml` causes `apm validate` to emit an error; `resolve_wrapper` also returns an error +- [ ] A `manifest.toml` with `contract_version = 2` causes `apm validate` to emit an error directing the user to upgrade APM +- [ ] `CustomWrapper::spawn()` returns an error (does not spawn the process) when `manifest.contract_version > 1` +- [ ] The dispatcher in `start.rs` exec'''s a custom wrapper script directly via `Command::new(&script_path)` with no shell interpreter interposed, and all APM contract env vars are present in the child environment +- [ ] Integration: a fixture `.apm/agents/echo-test/wrapper.sh` (executable, emits one valid JSONL line, exits 0) is spawned by the dispatcher; its output is captured to the log file and the child exits 0 +- [ ] Unit tests `resolve_wrapper_nonexecutable_invisible`, `resolve_wrapper_fallback_to_builtin`, `resolve_wrapper_missing_returns_none`, `manifest_parse_valid`, `manifest_parse_defaults`, `manifest_parse_invalid_toml`, and `manifest_missing` all pass ### Out of scope @@ -75,4 +89,4 @@ How the implementation will work. |------|------|----|----| | 2026-04-30T20:02Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | -| 2026-04-30T21:24Z | groomed | in_design | philippepascal | +| 2026-04-30T21:24Z | groomed | in_design | philippepascal | \ No newline at end of file From e290475a10c9f5dc3c0cd5aab9a2bc84ccb642c6 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:36:17 -0700 Subject: [PATCH 050/305] ticket(2c32a282): set section Out of scope --- ...c32a282-custom-wrapper-resolution-from-apm-agent.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md index 43a53d326..1c0978ef4 100644 --- a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md +++ b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md @@ -68,7 +68,15 @@ Implement custom-wrapper resolution from `.apm/agents//` so projects can s ### Out of scope -Explicit list of what this ticket does not cover. +- Per-agent instruction file resolution (`apm.worker.md` etc. under `.apm/agents//`) — ticket 7f5f73d5 +- The `apm agents new/list/test/eject` subcommand family — ticket 71d80e40 +- Wrapper-contract version compatibility checks beyond rejecting `contract_version > 1` at parse/validate time — ticket 2e772eab; this ticket stores the field but only enforces the v1 ceiling +- External parser invocation (`parser = "external"` piping stdout through `parser_command`) — ticket 2803bf07; this ticket parses and stores `parser` and `parser_command` but does not act on them at spawn time +- Reading the `agent` config field introduced by ticket 6cac8518; `start.rs` after this ticket still passes the hardcoded string `"claude"` to `resolve_wrapper` +- Frontmatter `agent` / `agent_overrides` override — ticket 0ca3e019 +- Mock built-in wrappers (`mock-happy`, `mock-sad`, `mock-random`, `debug`) — ticket 25c92daa +- `apm migrate --fix` automated config rewrite — ticket 3048d7e9 +- Windows execute-bit semantics (on non-Unix platforms, `find_script` treats any `wrapper.*` file as executable) ### Approach From 89b4440e7d0a6113c6911574aa91e56bc5f89a61 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:36:21 -0700 Subject: [PATCH 051/305] ticket(2c32a282): set section Approach --- ...ustom-wrapper-resolution-from-apm-agent.md | 156 +++++++++++++++++- 1 file changed, 155 insertions(+), 1 deletion(-) diff --git a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md index 1c0978ef4..055427f76 100644 --- a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md +++ b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md @@ -80,7 +80,161 @@ Implement custom-wrapper resolution from `.apm/agents//` so projects can s ### Approach -How the implementation will work. +**New types -- apm-core/src/wrapper/custom.rs (new file)** + +Manifest struct (deserialised from [wrapper] in manifest.toml): +- name: Option +- contract_version: u32 with serde default 1 +- parser: String with serde default canonical +- parser_command: Option (only meaningful when parser = external) + +WrapperKind enum (defined in wrapper/custom.rs, re-exported from wrapper/mod.rs): +- Custom variant: script_path: PathBuf, manifest: Option +- Builtin variant: String (the name) + +CustomWrapper struct holds script_path and manifest and implements the Wrapper trait from d3b93b95. + +--- + +**wrapper/custom.rs -- private helpers** + +find_script(root: &Path, name: &str) -> Option +- Read entries under root/.apm/agents//; return None if the directory is absent or unreadable +- Keep entries whose file name starts with wrapper. (any extension after the dot) +- Unix: keep only entries where metadata().permissions().mode() & 0o111 != 0 +- Non-Unix: treat any matching file as executable +- Return the first match in alphabetical order (deterministic when multiple wrapper.* files coexist) + +parse_manifest(root: &Path, name: &str) -> anyhow::Result> +- Path is root/.apm/agents//manifest.toml; return Ok(None) if absent +- Read and parse as TOML; deserialise the [wrapper] table into Manifest via serde +- Propagate IO or TOML parse errors via anyhow::Context + +manifest_unknown_keys(root: &Path, name: &str) -> anyhow::Result> +- Parse manifest as toml::Value, navigate to the [wrapper] table, collect key names +- Return any key not in the known set: name, contract_version, parser, parser_command +- Called by apm validate to emit warnings without failing the parse + +--- + +**wrapper/mod.rs -- resolve_wrapper** + +Add pub mod custom; and re-export WrapperKind and Manifest. + +Signature: pub fn resolve_wrapper(root: &Path, name: &str) -> anyhow::Result> + +Algorithm: +1. Call find_script(root, name); if a script is found, call parse_manifest(root, name)? and + return Ok(Some(WrapperKind::Custom containing script_path and manifest)) +2. Else if resolve_builtin(name).is_some(), return Ok(Some(WrapperKind::Builtin(name.to_owned()))) +3. Else return Ok(None) + +--- + +**CustomWrapper::spawn (implements Wrapper trait)** + +1. Check self.manifest contract_version (defaulting to 1 when manifest is None); if > 1, bail + with a message stating the declared version is unsupported and directing the user to upgrade APM +2. Build Command::new(&self.script_path) -- no shell interpreter, the script is exec-d directly +3. Set all APM contract env vars (identical set to ClaudeWrapper; see d3b93b95 approach table) +4. Forward ctx.extra_env entries (user-configured env from [workers] env) +5. .current_dir(&ctx.worktree_path) +6. Redirect stdout + stderr to File::create(&ctx.log_path)?; try_clone() for stderr fd +7. .process_group(0) then .spawn() + +--- + +**start.rs -- dispatcher wiring** + +In spawn_worker (introduced by d3b93b95), add project_root: &Path as a second parameter. +Update the three call sites (run, run_next, spawn_next_worker) to pass root, which is already +in scope at each site. + +Replace the hardcoded resolve_builtin(claude)...spawn(&ctx) call with a match on +resolve_wrapper(project_root, claude)?: + +- Custom variant -> construct CustomWrapper from script_path and manifest, call .spawn(&ctx)? +- Builtin(name) variant -> resolve_builtin(&name).expect(known built-in).spawn(&ctx)? +- None -> anyhow::bail with message: agent not found, checked built-ins and .apm/agents/claude/ + +The hardcoded claude string is replaced by config.workers.agent when ticket 6cac8518 lands; +the shape of this call does not change at that point. + +--- + +**validate.rs -- validate_agents helper** + +Add fn validate_agents(config: &Config, root: &Path, errors: &mut Vec, warnings: &mut Vec) +and call it from validate_config. + +Steps: + +1. Collect agent names to check. + Pre-6cac8518: use config.workers.command (defaults to claude) as the single name. + When 6cac8518 lands: switch to config.workers.agent and add per-profile agent names. + De-duplicate. + +2. For each name call resolve_wrapper(root, name): + - Ok(None) -> push error: agent NAME not found: checked built-ins (claude) and .apm/agents/NAME/ + - Err(e) -> push error: agent NAME: {e} + - Ok(Some(_)) -> ok + +3. Scan .apm/agents/ (skip if absent); for each subdirectory NAME: + - If any wrapper.* file exists but none is executable (Unix only) -> + push warning: agent NAME: .apm/agents/NAME/wrapper.* exists but is not executable; run chmod +x + - If manifest.toml exists: + - TOML parse error -> push error: agent NAME: manifest.toml is not valid TOML: {e} + - contract_version > 1 -> push error: agent NAME: manifest.toml declares contract_version V; + this APM build supports version 1 only -- upgrade APM + - Unknown keys via manifest_unknown_keys -> one warning per key: + agent NAME: manifest.toml: unknown key K + +--- + +**Tests** + +Unit tests in wrapper/custom.rs under #[cfg(test)]: + +- resolve_wrapper_custom_shadows_builtin: temp dir with executable .apm/agents/claude/wrapper.sh; + assert resolve_wrapper(root, claude) returns the Custom variant +- resolve_wrapper_fallback_to_builtin: no .apm/agents/claude/ dir; + assert result is Builtin(claude) +- resolve_wrapper_missing_returns_none: no script, not a built-in name; + assert Ok(None) +- resolve_wrapper_nonexecutable_invisible: wrapper.sh present with mode 0o644; + assert result is Builtin(claude) (non-executable script is invisible, falls through) +- manifest_parse_valid: write a complete valid manifest.toml; assert struct fields match declared values +- manifest_parse_defaults: write [wrapper] with no keys; + assert contract_version == 1, parser == canonical, parser_command == None +- manifest_parse_invalid_toml: write syntactically broken TOML; + assert parse_manifest returns Err +- manifest_missing: no manifest.toml present; + assert parse_manifest returns Ok(None) +- manifest_unknown_keys_detected: write [wrapper] with an extra unknown_key = foo; + assert manifest_unknown_keys returns a vec containing unknown_key +- spawn_rejects_contract_version_gt_1: CustomWrapper with manifest.contract_version = 2; + assert spawn() returns Err containing the string upgrade APM + +Integration test in apm-core/tests/custom_wrapper_integration.rs: + +- integration_echo_test_wrapper: fixture project contains .apm/agents/echo-test/wrapper.sh + with a shebang line and a single printf emitting one valid JSONL line to stdout; mode 0o755. + Build a minimal WrapperContext pointing at a temp worktree and log file. + Call resolve_wrapper(root, echo-test), assert it returns the Custom variant. + Call CustomWrapper::spawn, wait for child exit 0. + Read log file and assert it contains the emitted JSONL line. + +--- + +**File change summary** + +| File | Change | +|---|---| +| apm-core/src/wrapper/mod.rs | Add pub mod custom; re-export WrapperKind; add resolve_wrapper() | +| apm-core/src/wrapper/custom.rs | New: Manifest, WrapperKind, CustomWrapper + Wrapper impl, helpers, unit tests | +| apm-core/src/start.rs | Add project_root param to spawn_worker; replace resolve_builtin call with resolve_wrapper dispatch | +| apm-core/src/validate.rs | Add validate_agents() helper; call from validate_config | +| apm-core/tests/custom_wrapper_integration.rs | New integration test with echo-test fixture | ### Open questions From 5e29b179b91bf3f12c85ad07f526afd24dbffff1 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:36:28 -0700 Subject: [PATCH 052/305] ticket(2c32a282): set effort = 5 --- tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md index 055427f76..105fe732e 100644 --- a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md +++ b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md @@ -3,13 +3,13 @@ id = "2c32a282" title = "Custom wrapper resolution from .apm/agents//" state = "in_design" priority = 0 -effort = 0 +effort = 5 risk = 0 author = "philippepascal" owner = "philippepascal" branch = "ticket/2c32a282-custom-wrapper-resolution-from-apm-agent" created_at = "2026-04-30T20:02:50.794362Z" -updated_at = "2026-04-30T21:24:01.213094Z" +updated_at = "2026-04-30T21:36:28.293408Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95"] From caccdc637ad2b4678e37ad4246d058082042fff9 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:36:33 -0700 Subject: [PATCH 053/305] ticket(2c32a282): set risk = 3 --- tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md index 105fe732e..9971cf0c3 100644 --- a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md +++ b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md @@ -4,12 +4,12 @@ title = "Custom wrapper resolution from .apm/agents//" state = "in_design" priority = 0 effort = 5 -risk = 0 +risk = 3 author = "philippepascal" owner = "philippepascal" branch = "ticket/2c32a282-custom-wrapper-resolution-from-apm-agent" created_at = "2026-04-30T20:02:50.794362Z" -updated_at = "2026-04-30T21:36:28.293408Z" +updated_at = "2026-04-30T21:36:33.230982Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95"] From c451bb084671e36cc6332a2e12428c2c9208e147 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:36:38 -0700 Subject: [PATCH 054/305] =?UTF-8?q?ticket(2c32a282):=20in=5Fdesign=20?= =?UTF-8?q?=E2=86=92=20specd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2c32a282-custom-wrapper-resolution-from-apm-agent.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md index 9971cf0c3..251cf2776 100644 --- a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md +++ b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md @@ -1,7 +1,7 @@ +++ id = "2c32a282" title = "Custom wrapper resolution from .apm/agents//" -state = "in_design" +state = "specd" priority = 0 effort = 5 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/2c32a282-custom-wrapper-resolution-from-apm-agent" created_at = "2026-04-30T20:02:50.794362Z" -updated_at = "2026-04-30T21:36:33.230982Z" +updated_at = "2026-04-30T21:36:37.975992Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95"] @@ -251,4 +251,5 @@ Integration test in apm-core/tests/custom_wrapper_integration.rs: |------|------|----|----| | 2026-04-30T20:02Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | -| 2026-04-30T21:24Z | groomed | in_design | philippepascal | \ No newline at end of file +| 2026-04-30T21:24Z | groomed | in_design | philippepascal | +| 2026-04-30T21:36Z | in_design | specd | claude-0430-2124-5738 | From 2b3107450a3e07248db0aff14b2bbd638a4c56cc Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:36:57 -0700 Subject: [PATCH 055/305] =?UTF-8?q?ticket(3048d7e9):=20start=20=E2=80=94?= =?UTF-8?q?=20groomed=20=E2=86=92=20in=5Fdesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md index 080e4e4fb..74346e272 100644 --- a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md +++ b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md @@ -1,7 +1,7 @@ +++ id = "3048d7e9" title = "Migration: validate --fix ports legacy command/args/model to agent + options" -state = "groomed" +state = "in_design" priority = 0 effort = 0 risk = 0 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/3048d7e9-migration-validate-fix-ports-legacy-comm" created_at = "2026-04-30T20:03:17.277300Z" -updated_at = "2026-04-30T21:02:27.190910Z" +updated_at = "2026-04-30T21:36:57.594854Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["6cac8518"] @@ -73,3 +73,4 @@ How the implementation will work. |------|------|----|----| | 2026-04-30T20:03Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | +| 2026-04-30T21:36Z | groomed | in_design | philippepascal | From 0e525cf98b1a7f306793442e919fbef261cd0c91 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:40:58 -0700 Subject: [PATCH 056/305] ticket(3048d7e9): set section Problem --- ...igration-validate-fix-ports-legacy-comm.md | 33 ++++--------------- 1 file changed, 6 insertions(+), 27 deletions(-) diff --git a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md index 74346e272..b704e03a8 100644 --- a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md +++ b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md @@ -19,32 +19,11 @@ depends_on = ["6cac8518"] ### Problem -When existing projects upgrade APM, their `.apm/config.toml` still uses the legacy `[workers] command/args/model` shape. Provide an automated migration so users do not have to hand-edit. The legacy fields are read with a deprecation warning (per ticket 6cac8518); this ticket adds the migration that retires them. - -**Reference spec:** `docs/agent-wrappers.md` — section 'Migration from current config'. - -**Scope:** -- Extend `apm validate --fix` to detect a config with legacy fields and rewrite to the new shape: - - `command = "claude"` → `agent = "claude"` - - `model = "sonnet"` → `[workers.options] model = "sonnet"` - - `args = ["--print", "--output-format=stream-json", "--verbose"]` (or any subset) → dropped (the wrapper handles flags) - - Same migration for every `[worker_profiles.]` section. -- TOML rewrite must preserve comments, ordering of unrelated sections, and trailing whitespace as much as possible (use a TOML-aware editor, e.g. `toml_edit`). -- If `command` is anything other than `claude`, do not auto-migrate — print a warning that the user must hand-pick a wrapper for their custom command and stop. -- After migration, re-run validate to confirm the new config parses cleanly. The old fields should be entirely gone (no commented-out leftovers). -- Add a one-line migration message: `migrated [workers] config to agent-driven shape; legacy command/args/model removed`. - -**Out of scope:** -- An interactive `apm init --migrate` subcommand. Validate --fix is the canonical migration path. -- Migration of any `.apm/agents.md` or `.apm/apm.*.md` files. Those are content, not config. -- Hash-trip integration changes — the existing hash-trip already runs validate on config change. - -**Tests:** -- Repo with legacy config + claude command → fix produces the new shape; re-validate passes. -- Repo with legacy config + non-claude command → fix prints a warning and does not modify config. -- Repo with mixed legacy + new fields → fix removes legacy fields, preserves new ones. -- Repo with already-migrated config → fix is a no-op. -- TOML preservation: a config with a comment between sections survives the fix unchanged. +Existing APM projects have a `.apm/config.toml` using the legacy `[workers]` shape: `command = "claude"`, `args = ["--print", ...]`, and `model = "sonnet"`. After upgrading to the agent-wrapper architecture (ticket 6cac8518), those projects receive a deprecation warning on every `apm start` invocation but have no automated way to migrate. + +The desired state is `agent = "claude"` in `[workers]` with model moved to `[workers.options]` and `args` dropped entirely (the wrapper now owns CLI flag construction). A matching migration must apply to every `[worker_profiles.]` section as well. + +This ticket adds that migration to `apm validate --fix`. A developer who upgrades APM runs `apm validate --fix`, sees a one-line confirmation message, and their config is correct without any manual editing. If the project was using a non-Claude command, automated migration is not safe — the tool warns and stops so the user can hand-pick a wrapper. ### Acceptance criteria @@ -73,4 +52,4 @@ How the implementation will work. |------|------|----|----| | 2026-04-30T20:03Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | -| 2026-04-30T21:36Z | groomed | in_design | philippepascal | +| 2026-04-30T21:36Z | groomed | in_design | philippepascal | \ No newline at end of file From d2461b47cabe78641433eccd370ef50f578dc422 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:41:00 -0700 Subject: [PATCH 057/305] ticket(3048d7e9): set section Acceptance criteria --- ...e9-migration-validate-fix-ports-legacy-comm.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md index b704e03a8..0db530038 100644 --- a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md +++ b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md @@ -27,7 +27,20 @@ This ticket adds that migration to `apm validate --fix`. A developer who upgrade ### Acceptance criteria -Checkboxes; each one independently testable. +- [ ] `apm validate --fix` on a config with `[workers] command = "claude"` rewrites it to `[workers] agent = "claude"` and removes the `command` key +- [ ] `apm validate --fix` on a config with `[workers] model = "sonnet"` moves the value to `[workers.options] model = "sonnet"` and removes the top-level `model` key +- [ ] `apm validate --fix` on a config with `[workers] args = [...]` removes the `args` key regardless of its contents +- [ ] `apm validate --fix` on a config with `[worker_profiles.X] model = "opus"` moves the value to `[worker_profiles.X.options] model = "opus"` and removes the profile-level `model` key +- [ ] `apm validate --fix` on a config with `[worker_profiles.X] command = "claude"` removes the profile-level `command` key (profile inherits `agent` from global) +- [ ] `apm validate --fix` on a config with `[worker_profiles.X] args = [...]` removes the profile-level `args` key +- [ ] `apm validate --fix` on a config where `[workers] command` is anything other than `"claude"` prints a warning naming the offending command and does not modify the config file +- [ ] `apm validate --fix` on a config where any `[worker_profiles.X] command` is anything other than `"claude"` prints a warning naming the profile and command, and does not modify the config file +- [ ] After a successful migration `apm validate` (without `--fix`) exits zero on the rewritten config +- [ ] `apm validate --fix` on a config that has no legacy fields (`agent` already set, no `command`/`args`/`model`) makes no changes to the file +- [ ] `apm validate --fix` on a config with both legacy fields and new fields (e.g. `agent` already present alongside a leftover `model`) removes the legacy fields and leaves the new fields intact +- [ ] A successful migration prints exactly the line: `migrated [workers] config to agent-driven shape; legacy command/args/model removed` +- [ ] TOML comments present in the config file survive the migration unchanged +- [ ] Key ordering of unrelated sections (e.g. `[keychain]`, `[env]`) is preserved after migration ### Out of scope From 236f10d92ddb2466691a8421080f0f8861a85c17 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:41:02 -0700 Subject: [PATCH 058/305] ticket(3048d7e9): set section Out of scope --- .../3048d7e9-migration-validate-fix-ports-legacy-comm.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md index 0db530038..97e9f08c3 100644 --- a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md +++ b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md @@ -44,7 +44,14 @@ This ticket adds that migration to `apm validate --fix`. A developer who upgrade ### Out of scope -Explicit list of what this ticket does not cover. +- An `apm init --migrate` or `apm agents migrate` subcommand — `apm validate --fix` is the canonical migration path +- Migration of `.apm/agents.md`, `.apm/apm.worker.md`, or `.apm/apm.spec-writer.md` — those are prompt content, not config +- Hash-trip integration changes — the existing hash-trip already runs validate on config change; no adjustment needed here +- Removing deprecated `command`/`args`/`model` fields from the Rust structs — that happens after the deprecation window, tracked separately (ticket 6cac8518 retains the fields for backward compatibility) +- `apm validate --fix` for `workflow.toml` files — only `.apm/config.toml` (and `apm.toml` legacy root path) contain worker config +- Rollback or backup of the original config — the caller can use version control +- Migrating a config where `command` is non-Claude — this ticket explicitly stops and warns rather than guessing a wrapper name +- Windows execute-bit semantics or platform-specific config path differences ### Approach From c28e338e21afc66b8b37e3d163e19e755225484d Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:41:04 -0700 Subject: [PATCH 059/305] ticket(3048d7e9): set section Approach --- ...igration-validate-fix-ports-legacy-comm.md | 81 ++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md index 97e9f08c3..c8b70c04d 100644 --- a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md +++ b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md @@ -55,7 +55,86 @@ This ticket adds that migration to `apm validate --fix`. A developer who upgrade ### Approach -How the implementation will work. +### Files changed + +**`apm/Cargo.toml`** — add `toml_edit` to the `[dependencies]` table (it is already in the workspace Cargo.toml at `toml_edit = "0.22"`; add `toml_edit.workspace = true`). + +**`apm/src/cmd/validate.rs`** — add `apply_config_migration_fixes(root: &Path) -> Result` (returns `true` if any change was written) and call it from `run()` when `fix = true`, before the existing branch/on-failure/merged fix calls. Print the migration message from `run()` after `apply_config_migration_fixes` returns `true`. + +No changes to `apm-core/src/` — the migration is a TOML rewrite at the CLI layer, not a semantic config operation. + +--- + +### `apply_config_migration_fixes(root)` — step by step + +**1. Locate config file.** +Check `root/.apm/config.toml` first, then `root/apm.toml`. If neither exists, return `Ok(false)`. + +**2. Parse with `toml_edit`.** +`let mut doc = content.parse::()?;` + +**3. Detect legacy fields.** +Check `doc["workers"]` for the presence of any of `command`, `args`, `model`. Check each table under `doc["worker_profiles"]` for the same keys. If none are present in any section, return `Ok(false)` (no-op). + +**4. Guard: non-claude command.** +If `doc["workers"]["command"]` exists and its string value is not `"claude"`, print: +``` +warning: [workers] command = "" is not "claude" — cannot auto-migrate; choose a wrapper manually +``` +and return `Ok(false)` without modifying the file. + +For each `worker_profiles.` table that has a `command` key whose value is not `"claude"`, print: +``` +warning: [worker_profiles.] command = "" is not "claude" — cannot auto-migrate; choose a wrapper manually +``` +and return `Ok(false)`. + +Only proceed past this point if every `command` field present is exactly `"claude"`. + +**5. Migrate `[workers]`.** +- If `workers.command` is present (value must be `"claude"` at this point): remove it, set `workers.agent = "claude"`. +- If `workers.model` is present: read the value, remove the key, set `workers.options.model = `. Create `workers.options` as an inline table if it does not exist. +- If `workers.args` is present: remove the key. No replacement. + +**6. Migrate each `[worker_profiles.]`.** +For each profile table: +- If `command` is present (value must be `"claude"`): remove it. Do **not** add `agent` at the profile level — profiles inherit `agent` from `[workers]`. +- If `model` is present: read the value, remove the key, set `profile.options.model = `. +- If `args` is present: remove it. + +**7. Write back.** +`fs::write(config_path, doc.to_string())?;` + +`toml_edit` preserves comments, key ordering, and whitespace in untouched sections automatically. + +**8. Re-validate.** +Call `apm_core::validate::run(root, /*fix=*/false, /*json=*/false, /*config_only=*/true, /*no_aggressive=*/false)`. If it returns an error, surface it as a `bail!` so the user knows the migration produced an invalid config (should not happen in normal cases, but guards against bugs). + +--- + +### Message output + +`apply_config_migration_fixes` returns `Ok(true)` on success. The caller in `run()` prints: +``` +migrated [workers] config to agent-driven shape; legacy command/args/model removed +``` + +--- + +### Tests + +Add to `apm/tests/validate_fix.rs` (or the existing validate integration test file): + +- **`test_fix_migrates_claude_command`** — fixture config with `command = "claude"`, `args = [...]`, `model = "sonnet"` → assert written config has `agent = "claude"`, `options.model = "sonnet"`, no `command`/`args`/`model` keys. +- **`test_fix_noop_on_non_claude_command`** — fixture with `command = "my-ai"` → assert file is unchanged, stderr contains `"cannot auto-migrate"`. +- **`test_fix_noop_on_non_claude_profile_command`** — global command absent, `worker_profiles.impl_agent.command = "my-ai"` → unchanged, warning names the profile. +- **`test_fix_mixed_legacy_and_new_fields`** — fixture has both `agent = "claude"` (already present) and leftover `model = "opus"` → `model` is removed, `agent` preserved, `options.model = "opus"` added. +- **`test_fix_already_migrated_noop`** — fixture with `agent = "claude"`, `[workers.options] model = "sonnet"`, no legacy keys → file content is byte-identical after `--fix`. +- **`test_fix_preserves_comments`** — fixture contains a TOML comment between sections → the comment survives unchanged in the output. +- **`test_fix_profile_model_migration`** — `worker_profiles.spec_agent` has `model = "opus"`, no global model → `worker_profiles.spec_agent.options.model = "opus"` in output, profile `model` key gone. +- **`test_fix_revalidate_passes`** — after migration, `apm_core::validate::run` with `config_only=true` returns `Ok(())`. + +Test fixtures are small inline TOML strings written to a `tempdir`; no external fixture files needed. ### Open questions From fff29c947643250f3a612b1386f0fb8628f44818 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:41:12 -0700 Subject: [PATCH 060/305] ticket(3048d7e9): set effort = 4 --- tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md index c8b70c04d..77aa5423b 100644 --- a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md +++ b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md @@ -3,13 +3,13 @@ id = "3048d7e9" title = "Migration: validate --fix ports legacy command/args/model to agent + options" state = "in_design" priority = 0 -effort = 0 +effort = 4 risk = 0 author = "philippepascal" owner = "philippepascal" branch = "ticket/3048d7e9-migration-validate-fix-ports-legacy-comm" created_at = "2026-04-30T20:03:17.277300Z" -updated_at = "2026-04-30T21:36:57.594854Z" +updated_at = "2026-04-30T21:41:12.395801Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["6cac8518"] From c99cba7bfd6a9be4b2083fd4dc7044141064319c Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:41:15 -0700 Subject: [PATCH 061/305] ticket(3048d7e9): set risk = 3 --- tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md index 77aa5423b..79724caf2 100644 --- a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md +++ b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md @@ -4,12 +4,12 @@ title = "Migration: validate --fix ports legacy command/args/model to agent + op state = "in_design" priority = 0 effort = 4 -risk = 0 +risk = 3 author = "philippepascal" owner = "philippepascal" branch = "ticket/3048d7e9-migration-validate-fix-ports-legacy-comm" created_at = "2026-04-30T20:03:17.277300Z" -updated_at = "2026-04-30T21:41:12.395801Z" +updated_at = "2026-04-30T21:41:15.146321Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["6cac8518"] From 7dc6293e9f46fef8e792dc6c7dc03ecefecd118b Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:42:00 -0700 Subject: [PATCH 062/305] ticket(3048d7e9): set section Approach --- ...igration-validate-fix-ports-legacy-comm.md | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md index 79724caf2..ff2347a25 100644 --- a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md +++ b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md @@ -55,6 +55,87 @@ This ticket adds that migration to `apm validate --fix`. A developer who upgrade ### Approach +#### Files changed + +**`apm/Cargo.toml`** — add `toml_edit` to the `[dependencies]` table (it is already in the workspace Cargo.toml at `toml_edit = "0.22"`; add `toml_edit.workspace = true`). + +**`apm/src/cmd/validate.rs`** — add `apply_config_migration_fixes(root: &Path) -> Result` (returns `true` if any change was written) and call it from `run()` when `fix = true`, before the existing branch/on-failure/merged fix calls. Print the migration message from `run()` after `apply_config_migration_fixes` returns `true`. + +No changes to `apm-core/src/` — the migration is a TOML rewrite at the CLI layer, not a semantic config operation. + +--- + +#### `apply_config_migration_fixes(root)` — step by step + +**1. Locate config file.** +Check `root/.apm/config.toml` first, then `root/apm.toml`. If neither exists, return `Ok(false)`. + +**2. Parse with `toml_edit`.** +`let mut doc = content.parse::()?;` + +**3. Detect legacy fields.** +Check `doc["workers"]` for the presence of any of `command`, `args`, `model`. Check each table under `doc["worker_profiles"]` for the same keys. If none are present in any section, return `Ok(false)` (no-op). + +**4. Guard: non-claude command.** +If `doc["workers"]["command"]` exists and its string value is not `"claude"`, print: +``` +warning: [workers] command = "" is not "claude" — cannot auto-migrate; choose a wrapper manually +``` +and return `Ok(false)` without modifying the file. + +For each `worker_profiles.` table that has a `command` key whose value is not `"claude"`, print: +``` +warning: [worker_profiles.] command = "" is not "claude" — cannot auto-migrate; choose a wrapper manually +``` +and return `Ok(false)`. + +Only proceed past this point if every `command` field present is exactly `"claude"`. + +**5. Migrate `[workers]`.** +- If `workers.command` is present (value must be `"claude"` at this point): remove it, set `workers.agent = "claude"`. +- If `workers.model` is present: read the value, remove the key, set `workers.options.model = `. Create `workers.options` as an inline table if it does not exist. +- If `workers.args` is present: remove the key. No replacement. + +**6. Migrate each `[worker_profiles.]`.** +For each profile table: +- If `command` is present (value must be `"claude"`): remove it. Do **not** add `agent` at the profile level — profiles inherit `agent` from `[workers]`. +- If `model` is present: read the value, remove the key, set `profile.options.model = `. +- If `args` is present: remove it. + +**7. Write back.** +`fs::write(config_path, doc.to_string())?;` + +`toml_edit` preserves comments, key ordering, and whitespace in untouched sections automatically. + +**8. Re-validate.** +Call `apm_core::validate::run(root, /*fix=*/false, /*json=*/false, /*config_only=*/true, /*no_aggressive=*/false)`. If it returns an error, surface it as a `bail!` so the user knows the migration produced an invalid config (should not happen in normal cases, but guards against bugs). + +--- + +#### Message output + +`apply_config_migration_fixes` returns `Ok(true)` on success. The caller in `run()` prints: +``` +migrated [workers] config to agent-driven shape; legacy command/args/model removed +``` + +--- + +#### Tests + +Add to `apm/tests/validate_fix.rs` (or the existing validate integration test file): + +- **`test_fix_migrates_claude_command`** — fixture config with `command = "claude"`, `args = [...]`, `model = "sonnet"` → assert written config has `agent = "claude"`, `options.model = "sonnet"`, no `command`/`args`/`model` keys. +- **`test_fix_noop_on_non_claude_command`** — fixture with `command = "my-ai"` → assert file is unchanged, stderr contains `"cannot auto-migrate"`. +- **`test_fix_noop_on_non_claude_profile_command`** — global command absent, `worker_profiles.impl_agent.command = "my-ai"` → unchanged, warning names the profile. +- **`test_fix_mixed_legacy_and_new_fields`** — fixture has both `agent = "claude"` (already present) and leftover `model = "opus"` → `model` is removed, `agent` preserved, `options.model = "opus"` added. +- **`test_fix_already_migrated_noop`** — fixture with `agent = "claude"`, `[workers.options] model = "sonnet"`, no legacy keys → file content is byte-identical after `--fix`. +- **`test_fix_preserves_comments`** — fixture contains a TOML comment between sections → the comment survives unchanged in the output. +- **`test_fix_profile_model_migration`** — `worker_profiles.spec_agent` has `model = "opus"`, no global model → `worker_profiles.spec_agent.options.model = "opus"` in output, profile `model` key gone. +- **`test_fix_revalidate_passes`** — after migration, `apm_core::validate::run` with `config_only=true` returns `Ok(())`. + +Test fixtures are small inline TOML strings written to a `tempdir`; no external fixture files needed. + ### Files changed **`apm/Cargo.toml`** — add `toml_edit` to the `[dependencies]` table (it is already in the workspace Cargo.toml at `toml_edit = "0.22"`; add `toml_edit.workspace = true`). From 8b00a5eccbb608d6e91d75c5a1b640803bdc9904 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:42:07 -0700 Subject: [PATCH 063/305] =?UTF-8?q?ticket(3048d7e9):=20in=5Fdesign=20?= =?UTF-8?q?=E2=86=92=20specd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../3048d7e9-migration-validate-fix-ports-legacy-comm.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md index ff2347a25..b7b63be35 100644 --- a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md +++ b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md @@ -1,7 +1,7 @@ +++ id = "3048d7e9" title = "Migration: validate --fix ports legacy command/args/model to agent + options" -state = "in_design" +state = "specd" priority = 0 effort = 4 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/3048d7e9-migration-validate-fix-ports-legacy-comm" created_at = "2026-04-30T20:03:17.277300Z" -updated_at = "2026-04-30T21:41:15.146321Z" +updated_at = "2026-04-30T21:42:07.281922Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["6cac8518"] @@ -232,4 +232,5 @@ Test fixtures are small inline TOML strings written to a `tempdir`; no external |------|------|----|----| | 2026-04-30T20:03Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | -| 2026-04-30T21:36Z | groomed | in_design | philippepascal | \ No newline at end of file +| 2026-04-30T21:36Z | groomed | in_design | philippepascal | +| 2026-04-30T21:42Z | in_design | specd | claude-0430-2136-f2a8 | From 93022677cc8c9a40793d44155f6f659c6ca8b1f9 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:42:25 -0700 Subject: [PATCH 064/305] =?UTF-8?q?ticket(7f5f73d5):=20start=20=E2=80=94?= =?UTF-8?q?=20groomed=20=E2=86=92=20in=5Fdesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/7f5f73d5-per-agent-instructions-resolution-under-.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md index b0bd208cc..863e48a3b 100644 --- a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md +++ b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md @@ -1,7 +1,7 @@ +++ id = "7f5f73d5" title = "Per-agent instructions resolution under .apm/agents//" -state = "groomed" +state = "in_design" priority = 0 effort = 0 risk = 0 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/7f5f73d5-per-agent-instructions-resolution-under-" created_at = "2026-04-30T20:03:33.687625Z" -updated_at = "2026-04-30T21:02:30.154031Z" +updated_at = "2026-04-30T21:42:25.580070Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "2c32a282"] @@ -77,3 +77,4 @@ How the implementation will work. |------|------|----|----| | 2026-04-30T20:03Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | +| 2026-04-30T21:42Z | groomed | in_design | philippepascal | From c3b17528ea111875427d47e2220b0f80674d7697 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:48:47 -0700 Subject: [PATCH 065/305] ticket(7f5f73d5): set section Problem --- ...er-agent-instructions-resolution-under-.md | 44 ++++++------------- 1 file changed, 13 insertions(+), 31 deletions(-) diff --git a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md index 863e48a3b..b4047595e 100644 --- a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md +++ b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md @@ -19,36 +19,18 @@ depends_on = ["d3b93b95", "2c32a282"] ### Problem -Each agent may want different prompt conventions (Aider concise context, Codex structured tags, etc.). Move `apm.worker.md` and `apm.spec-writer.md` resolution to be per-agent under `.apm/agents//`, with project-level overrides retained. - -**Reference spec:** `docs/agent-wrappers.md` — section 'Per-agent instructions'. - -**Scope:** -- New layout: `.apm/agents//apm.worker.md` and `.apm/agents//apm.spec-writer.md` are the per-agent defaults. -- For built-ins: ship the per-agent default markdown bundled in the binary (`include_str!` from `apm-core/src/default/agents//apm..md`). For custom wrappers: the user authors them in their wrapper directory. -- Resolution chain (highest priority first), per spawn (profile = P, role = worker|spec-writer, agent = A): - 1. `[worker_profiles.

].instructions` (project-level override, full path) - 2. `[workers].instructions` (project-level override, applies to all profiles) - 3. `.apm/agents//apm..md` (project-supplied per-agent file, if it exists) - 4. APM's built-in default for agent A (only for built-in agents) - 5. Hard error if none of the above resolve -- The spawn code passes the resolved file path's contents as the system prompt (already happens; just change where the path comes from). -- For migration: existing `.apm/apm.worker.md` and `.apm/apm.spec-writer.md` continue to work because they are referenced by `[workers].instructions` and `[worker_profiles.

].instructions` in the default config (project-level overrides at level 1/2). No automatic migration needed — users keep what they have unless they delete the override and want the per-agent default. - -**Built-in defaults to ship:** -- `apm-core/src/default/agents/claude/apm.worker.md` — copy of the current default `apm.worker.md`. -- `apm-core/src/default/agents/claude/apm.spec-writer.md` — copy of the current default `apm.spec-writer.md`. -- (Mock built-ins from a separate ticket may not need spec-writer/worker .md files at all; defer to that ticket.) - -**Out of scope:** -- Updating the .md content for non-Claude agents — there are no other built-ins yet. -- Per-agent `agents.md` (the project-wide conventions file is still `.apm/agents.md`, not per-agent). -- Sync test extending the existing `apm.worker.md` byte-identical check to other roles — separate concern. - -**Tests:** -- Resolution chain test for each level. -- Hard-error test when no instructions resolve. -- Backward-compat: a project with the old config that references `.apm/apm.worker.md` continues to work without edits. +The current `resolve_system_prompt` function in `apm-core/src/start.rs` uses a flat 4-level chain that ends in a silent hardcoded fallback string (`"You are an APM worker agent."`). It has no concept of which agent is being run and resolves the system prompt from a single flat path — `.apm/apm.worker.md` — shared across all agents. As custom wrappers are introduced (ticket 2c32a282), different agents may need different prompt conventions (Codex structured tags, Aider concise context, etc.); a single flat `.apm/apm.worker.md` cannot express per-agent defaults. + +The desired behaviour is a 4-level resolution chain per spawn (agent A, role = worker|spec-writer, profile P): +1. `[worker_profiles.

].instructions` — project-level per-profile override +2. `[workers].instructions` — project-level global override, applies to all profiles +3. `.apm/agents//apm..md` — project-supplied per-agent file, if it exists +4. APM's bundled default for agent A (via `include_str!`, built-in agents only) +5. Hard error if none of the above resolve + +Existing projects keep working without edits because their `[worker_profiles.spec_agent] instructions = ".apm/apm.spec-writer.md"` and `[worker_profiles.impl_agent] instructions = ".apm/apm.worker.md"` satisfy level 1. No migration is required. + +The silent hardcoded fallback and the `StateConfig.instructions`-as-system-prompt path are both removed. `StateConfig.instructions` is a per-state annotation used for display and tooling (the field remains on the struct) but is no longer consumed by `resolve_system_prompt`. ### Acceptance criteria @@ -77,4 +59,4 @@ How the implementation will work. |------|------|----|----| | 2026-04-30T20:03Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | -| 2026-04-30T21:42Z | groomed | in_design | philippepascal | +| 2026-04-30T21:42Z | groomed | in_design | philippepascal | \ No newline at end of file From 6c8b288b5ec445752555acf9232093305563caf2 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:49:00 -0700 Subject: [PATCH 066/305] ticket(7f5f73d5): set section Acceptance criteria --- ...73d5-per-agent-instructions-resolution-under-.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md index b4047595e..2602edf71 100644 --- a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md +++ b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md @@ -34,7 +34,18 @@ The silent hardcoded fallback and the `StateConfig.instructions`-as-system-promp ### Acceptance criteria -Checkboxes; each one independently testable. +- [ ] When `[worker_profiles.

].instructions` is set and the referenced file exists, its content is used as the system prompt. +- [ ] When `[worker_profiles.

].instructions` is absent (or the profile is not resolved) and `[workers].instructions` is set and the referenced file exists, its content is used as the system prompt. +- [ ] When neither profile nor global `[workers].instructions` resolves and `.apm/agents//apm..md` exists in the project, its content is used as the system prompt. +- [ ] When the first three levels all fail and agent A is the `claude` built-in, APM's bundled default for `apm..md` (compiled in via `include_str!`) is used as the system prompt. +- [ ] When all four levels fail (custom agent, no project file, no config override), `apm start` exits with a descriptive error message that names the agent and role; no silent fallback occurs. +- [ ] An existing project whose config has `[worker_profiles.spec_agent] instructions = ".apm/apm.spec-writer.md"` continues to work without any config edits. +- [ ] An existing project whose config has `[worker_profiles.impl_agent] instructions = ".apm/apm.worker.md"` continues to work without any config edits. +- [ ] `apm validate` reports a config error when `[workers].instructions` is set but the referenced file does not exist on disk. +- [ ] `apm validate` does not regress on the existing check for `[worker_profiles.

].instructions` pointing to a missing file. +- [ ] Both `apm.worker.md` and `apm.spec-writer.md` are compiled into the binary for the `claude` built-in (reachable at level 4 without any file on disk). +- [ ] The role (`worker` or `spec-writer`) is read from `WorkerProfileConfig.role` (defaults to `"worker"` when absent); the spec_agent profile in the `apm init` default config sets `role = "spec-writer"`. +- [ ] Unit tests cover all five levels of the chain independently. ### Out of scope From a5fe0fc11c463a730ce719c8ec5bb101f9713a49 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:49:14 -0700 Subject: [PATCH 067/305] ticket(7f5f73d5): set section Out of scope --- .../7f5f73d5-per-agent-instructions-resolution-under-.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md index 2602edf71..4ecdbcefb 100644 --- a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md +++ b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md @@ -49,7 +49,14 @@ The silent hardcoded fallback and the `StateConfig.instructions`-as-system-promp ### Out of scope -Explicit list of what this ticket does not cover. +- Changing the content of `apm.worker.md` or `apm.spec-writer.md` — this ticket only changes where the file comes from, not what it says. +- Per-agent `agents.md` — the project-wide agent conventions file stays at `.apm/agents.md`, not per-agent. +- Instruction defaults for mock built-ins (`mock-happy`, `mock-sad`, `mock-random`, `debug`) — deferred to ticket 25c92daa; those wrappers may not need per-role instruction files at all. +- Per-ticket frontmatter `agent_overrides` changing which instruction file is loaded — ticket 0ca3e019. +- Updating the `apm init` template to remove profile-level `instructions` fields — the existing template keeps its overrides; the per-agent fallback is an addition, not a replacement. +- Config field `[workers].agent` for config-driven agent selection — ticket 6cac8518; after that ticket the hardcoded `"claude"` string at call sites becomes `config.workers.agent`, but the shape of `resolve_system_prompt` does not change. +- Removing the `StateConfig.instructions` field from the config struct — the field is kept for display / tooling use; only its role as a `resolve_system_prompt` input is removed. +- Windows execute-bit or platform-specific path differences. ### Approach From 49d82f37fe8ce8a37001186e110c4d77e048836b Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:50:03 -0700 Subject: [PATCH 068/305] ticket(7f5f73d5): set section Approach --- ...er-agent-instructions-resolution-under-.md | 105 +++++++++++++++++- 1 file changed, 104 insertions(+), 1 deletion(-) diff --git a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md index 4ecdbcefb..a66732c73 100644 --- a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md +++ b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md @@ -60,7 +60,110 @@ The silent hardcoded fallback and the `StateConfig.instructions`-as-system-promp ### Approach -How the implementation will work. +**New default files** + +Create two files (content copied verbatim from existing siblings): +- `apm-core/src/default/agents/claude/apm.worker.md` — copy of `apm-core/src/default/apm.worker.md` +- `apm-core/src/default/agents/claude/apm.spec-writer.md` — copy of `apm-core/src/default/apm.spec-writer.md` + +Add two module-level constants in `start.rs` (or a new private `instructions.rs`): +```rust +const CLAUDE_WORKER_DEFAULT: &str = include_str!("default/agents/claude/apm.worker.md"); +const CLAUDE_SPEC_WRITER_DEFAULT: &str = include_str!("default/agents/claude/apm.spec-writer.md"); +``` + +--- + +**`apm-core/src/config.rs` changes** + +1. Add `pub instructions: Option` to `WorkersConfig` and its `Default` impl (default = None). Docstring: "Global instructions file used as the system prompt for all profiles; overridden by per-profile `instructions`.". + +2. Add `pub role: Option` to `WorkerProfileConfig` (default = None). Docstring: "Role name used to select the per-agent instruction file (e.g. \"worker\", \"spec-writer\"). Defaults to \"worker\" when absent.". + +--- + +**`apm-core/src/start.rs` changes** + +*New private helper:* +```rust +fn resolve_builtin_instructions(agent: &str, role: &str) -> Option<&'static str> { + match (agent, role) { + ("claude", "worker") => Some(CLAUDE_WORKER_DEFAULT), + ("claude", "spec-writer") => Some(CLAUDE_SPEC_WRITER_DEFAULT), + _ => None, + } +} +``` + +*Updated `resolve_system_prompt` signature and implementation:* +```rust +fn resolve_system_prompt( + root: &Path, + profile: Option<&WorkerProfileConfig>, + workers: &WorkersConfig, + agent: &str, + role: &str, +) -> Result +``` + +Implementation (levels in order — return on first match, bail on exhaustion): +1. If `profile.instructions` is Some(path): read `root.join(path)`; return Ok(content) on success, or bail with "[worker_profiles.*].instructions: file not found: {path}". +2. If `workers.instructions` is Some(path): read `root.join(path)`; return Ok(content) on success, or bail with "[workers].instructions: file not found: {path}". +3. Try `root.join(".apm/agents/{agent}/apm.{role}.md")`; if the file exists and is readable, return Ok(content). Missing file is not an error — fall through. +4. Call `resolve_builtin_instructions(agent, role)`; if Some(s), return Ok(s.to_string()). +5. `bail!("no instructions found for agent '{agent}' role '{role}': set [workers].instructions in .apm/config.toml or add .apm/agents/{agent}/apm.{role}.md")` + +*Remove the `state_instructions` parameter* from `resolve_system_prompt`. The `StateConfig.instructions` value is no longer passed to this function at any call site. + +*Update call sites* — three locations (`run()`, `run_next()`, `spawn_next_worker()`): +- Remove the local `state_instructions` variable that was fed into `resolve_system_prompt`. +- Add `let role = profile.and_then(|p| p.role.as_deref()).unwrap_or("worker");` +- Pass `&config.workers`, `"claude"` (hardcoded; replaced by `config.workers.agent` in ticket 6cac8518), and `role`. +- Propagate the `Result` with `?`. + +--- + +**`apm-core/src/init.rs` changes** + +In the `apm init` default config template string (the `[worker_profiles.spec_agent]` section), add `role = "spec-writer"`: +```toml +[worker_profiles.spec_agent] +command = "claude" +args = ["--print"] +instructions = ".apm/apm.spec-writer.md" +role = "spec-writer" +role_prefix = "You are a Spec-Writer agent assigned to ticket #." +``` +(`impl_agent` is left without a `role` field — its default of `"worker"` is correct.) + +--- + +**`apm-core/src/validate.rs` changes** + +In the existing config validation pass, add a check for `config.workers.instructions`: +```rust +if let Some(ref path) = config.workers.instructions { + if !root.join(path).exists() { + errors.push(format!( + "config: [workers].instructions — file not found: {path}" + )); + } +} +``` + +--- + +**Tests in `apm-core/src/start.rs` `#[cfg(test)]`** + +Update the three existing `resolve_system_prompt_*` tests to match the new signature (add `workers`, `agent`, `role` args; unwrap the Result). Then add: + +- `resolve_system_prompt_uses_workers_instructions_when_no_profile` — `WorkersConfig` with `instructions = Some(path)`; file exists; no profile → returns that content. +- `resolve_system_prompt_uses_per_agent_file` — no profile, no workers.instructions, `.apm/agents/claude/apm.worker.md` exists → returns its content. +- `resolve_system_prompt_falls_back_to_builtin_default` — no overrides, no per-agent project file, agent="claude", role="worker" → returns `CLAUDE_WORKER_DEFAULT`. +- `resolve_system_prompt_falls_back_to_builtin_spec_writer` — same but role="spec-writer" → returns `CLAUDE_SPEC_WRITER_DEFAULT`. +- `resolve_system_prompt_errors_for_unknown_agent` — no overrides, no per-agent project file, agent="custom-bot" → returns `Err`. +- `resolve_system_prompt_profile_instructions_missing_file_is_error` — profile.instructions set but file absent → returns `Err` (not silently falls through). +- `resolve_system_prompt_backward_compat` — profile.instructions = ".apm/apm.worker.md", file exists → works, confirming no regression for existing projects. ### Open questions From 1f24a8ca72378205f8db6134477a8e028fe8fe78 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:50:10 -0700 Subject: [PATCH 069/305] ticket(7f5f73d5): set effort = 4 --- tickets/7f5f73d5-per-agent-instructions-resolution-under-.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md index a66732c73..452e39537 100644 --- a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md +++ b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md @@ -3,13 +3,13 @@ id = "7f5f73d5" title = "Per-agent instructions resolution under .apm/agents//" state = "in_design" priority = 0 -effort = 0 +effort = 4 risk = 0 author = "philippepascal" owner = "philippepascal" branch = "ticket/7f5f73d5-per-agent-instructions-resolution-under-" created_at = "2026-04-30T20:03:33.687625Z" -updated_at = "2026-04-30T21:42:25.580070Z" +updated_at = "2026-04-30T21:50:10.696331Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "2c32a282"] From 33d76545a1fb6935ad96a108238162e96f3e7abe Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:50:13 -0700 Subject: [PATCH 070/305] ticket(7f5f73d5): set risk = 2 --- tickets/7f5f73d5-per-agent-instructions-resolution-under-.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md index 452e39537..3e043d982 100644 --- a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md +++ b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md @@ -4,12 +4,12 @@ title = "Per-agent instructions resolution under .apm/agents//" state = "in_design" priority = 0 effort = 4 -risk = 0 +risk = 2 author = "philippepascal" owner = "philippepascal" branch = "ticket/7f5f73d5-per-agent-instructions-resolution-under-" created_at = "2026-04-30T20:03:33.687625Z" -updated_at = "2026-04-30T21:50:10.696331Z" +updated_at = "2026-04-30T21:50:13.474635Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "2c32a282"] From e908342d675906c707e6386e7765a6adb79f340d Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:50:19 -0700 Subject: [PATCH 071/305] =?UTF-8?q?ticket(7f5f73d5):=20in=5Fdesign=20?= =?UTF-8?q?=E2=86=92=20specd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../7f5f73d5-per-agent-instructions-resolution-under-.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md index 3e043d982..5446d104a 100644 --- a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md +++ b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md @@ -1,7 +1,7 @@ +++ id = "7f5f73d5" title = "Per-agent instructions resolution under .apm/agents//" -state = "in_design" +state = "specd" priority = 0 effort = 4 risk = 2 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/7f5f73d5-per-agent-instructions-resolution-under-" created_at = "2026-04-30T20:03:33.687625Z" -updated_at = "2026-04-30T21:50:13.474635Z" +updated_at = "2026-04-30T21:50:19.051619Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "2c32a282"] @@ -180,4 +180,5 @@ Update the three existing `resolve_system_prompt_*` tests to match the new signa |------|------|----|----| | 2026-04-30T20:03Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | -| 2026-04-30T21:42Z | groomed | in_design | philippepascal | \ No newline at end of file +| 2026-04-30T21:42Z | groomed | in_design | philippepascal | +| 2026-04-30T21:50Z | in_design | specd | claude-0430-2142-eea0 | From f80223a5f723836720bc684d1bddb34fcf30437a Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:50:38 -0700 Subject: [PATCH 072/305] =?UTF-8?q?ticket(0ca3e019):=20start=20=E2=80=94?= =?UTF-8?q?=20groomed=20=E2=86=92=20in=5Fdesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md index 9a7855cd3..86df957da 100644 --- a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md +++ b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md @@ -1,7 +1,7 @@ +++ id = "0ca3e019" title = "Frontmatter agent + agent_overrides for per-ticket wrapper choice" -state = "groomed" +state = "in_design" priority = 0 effort = 0 risk = 0 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/0ca3e019-frontmatter-agent-agent-overrides-for-pe" created_at = "2026-04-30T20:03:58.532325Z" -updated_at = "2026-04-30T21:02:33.388298Z" +updated_at = "2026-04-30T21:50:38.458676Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "6cac8518"] @@ -76,3 +76,4 @@ How the implementation will work. |------|------|----|----| | 2026-04-30T20:03Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | +| 2026-04-30T21:50Z | groomed | in_design | philippepascal | From a20049fad729fb9b4287a108754acb166a24e146 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:55:18 -0700 Subject: [PATCH 073/305] ticket(0ca3e019): set section Problem --- ...rontmatter-agent-agent-overrides-for-pe.md | 45 +++++++------------ 1 file changed, 15 insertions(+), 30 deletions(-) diff --git a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md index 86df957da..e235c197d 100644 --- a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md +++ b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md @@ -19,35 +19,20 @@ depends_on = ["d3b93b95", "6cac8518"] ### Problem -A ticket can override which agent handles its workers, either across all phases or per profile. Useful for: debugging a stuck ticket with mock-happy, mixing agents per phase (Claude for spec, Codex for impl), forcing a specific agent for a regression test. - -**Reference spec:** `docs/agent-wrappers.md` — section 'Frontmatter override'. - -**Scope:** -- `apm-core/src/ticket/ticket_fmt.rs` (`Frontmatter` struct): - - Add `pub agent: Option` — single all-profiles override - - Add `pub agent_overrides: HashMap` — per-profile map (profile name → agent name) - - Both are `#[serde(default)]` and skip-serializing-if-empty. -- `apm-core/src/start.rs`: - - Update agent resolution chain to (per spawn, where P = worker profile): - 1. `frontmatter.agent_overrides[P]` if present - 2. `frontmatter.agent` if present - 3. `[worker_profiles.

].agent` if set - 4. `[workers].agent` - - Resolution happens at spawn time, reading the ticket's current frontmatter from its branch. -- `apm validate`: - - For each ticket whose `frontmatter.agent` or any value in `agent_overrides` is set, the named agent must resolve to a built-in or project script. Report missing agents as ticket-level errors with the ticket id and the offending agent name. -- Document the override fields in `apm.spec-writer.md` and `apm.worker.md` so agents know they exist (briefly — ticket-frontmatter overrides are a supervisor tool, not an agent tool). - -**Out of scope:** -- Per-transition agent mapping (a `{transition: agent}` map). Spec defers this as a v2 contract extension; not in v1. -- A CLI command to set the override (`apm set agent X`). Could be added to the existing `apm set` field list, but is a small follow-up. Note in spec. -- Surfacing the override in `apm show` output. Could be a small follow-up. - -**Tests:** -- Resolution test: each level wins over the next. -- Validate test: ticket with `agent = "unknown-wrapper"` produces a clear error. -- Round-trip test: frontmatter with both fields serializes cleanly and parses back identically. +The agent-selection config introduced by tickets d3b93b95 and 6cac8518 operates at the project level: every ticket is dispatched to whatever agent the `[workers]` block or the matching `[worker_profiles.

]` block names. There is no per-ticket escape hatch. A supervisor who wants to debug a specific stuck ticket with `mock-happy`, force a regression ticket to use a particular agent, or give one unusual ticket a per-phase agent mix must edit `.apm/config.toml`, run, then revert — a fragile workflow that also affects all concurrently running workers. + +This ticket adds two optional fields to ticket frontmatter that let a supervisor override agent selection for a single ticket, narrowly, without touching shared config: + +- `agent = ""` — every worker spawn for this ticket uses the named agent, regardless of which profile the transition selects. +- `[agent_overrides]` table — per-profile selection (`spec_agent = "claude"`, `impl_agent = "mock-random"`), so different phases of the same ticket can use different agents. + +Both fields are optional and additive. Tickets that set neither field are unchanged. The fields override the config chain but affect only the one ticket where they appear. + +The full resolution order (per spawn, where P is the profile name declared by the triggering transition): +1. `frontmatter.agent_overrides[P]` if present +2. `frontmatter.agent` if present +3. `[worker_profiles.

].agent` from config (ticket 6cac8518) +4. `[workers].agent` global default (ticket 6cac8518) ### Acceptance criteria @@ -76,4 +61,4 @@ How the implementation will work. |------|------|----|----| | 2026-04-30T20:03Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | -| 2026-04-30T21:50Z | groomed | in_design | philippepascal | +| 2026-04-30T21:50Z | groomed | in_design | philippepascal | \ No newline at end of file From 63ce6b2d58c3b2697cc7ef341401f416581b9c2f Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:55:24 -0700 Subject: [PATCH 074/305] ticket(0ca3e019): set section Acceptance criteria --- ...19-frontmatter-agent-agent-overrides-for-pe.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md index e235c197d..01c9adbf7 100644 --- a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md +++ b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md @@ -36,7 +36,20 @@ The full resolution order (per spawn, where P is the profile name declared by th ### Acceptance criteria -Checkboxes; each one independently testable. +- [ ] `Frontmatter` struct has `pub agent: Option` with `#[serde(default, skip_serializing_if = "Option::is_none")]` +- [ ] `Frontmatter` struct has `pub agent_overrides: HashMap` with `#[serde(default, skip_serializing_if = "HashMap::is_empty")]` +- [ ] A ticket frontmatter containing `agent = "mock-happy"` round-trips through TOML serialize → parse → serialize without loss +- [ ] A ticket frontmatter containing `[agent_overrides]` round-trips through TOML serialize → parse → serialize without loss +- [ ] A ticket with neither `agent` nor `agent_overrides` set serializes without either field appearing in the output +- [ ] When spawning a worker for profile `P` and `frontmatter.agent_overrides[P]` is set, that value is used as the agent name +- [ ] When `agent_overrides` has no entry for profile `P` but `frontmatter.agent` is set, `frontmatter.agent` is used +- [ ] When `frontmatter.agent_overrides[P]` is set and `frontmatter.agent` is also set, the profile-specific override wins +- [ ] When neither frontmatter field is set, the config-resolved agent (from 6cac8518) is used unchanged +- [ ] `apm validate` reports an error for a ticket whose `frontmatter.agent` names a non-existent built-in; the error message includes the ticket id +- [ ] `apm validate` reports an error for a ticket whose `frontmatter.agent_overrides` contains a value naming a non-existent built-in; the error message includes the ticket id and the offending agent name +- [ ] `apm validate` does not report an error for a ticket whose `frontmatter.agent` is `"claude"` +- [ ] `.apm/apm.spec-writer.md` contains a brief note that supervisors may set `agent` or `[agent_overrides]` in frontmatter +- [ ] `.apm/apm.worker.md` contains a brief note that supervisors may set `agent` or `[agent_overrides]` in frontmatter ### Out of scope From 2089471afdb253b06185730fece7ef7a13224634 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:55:29 -0700 Subject: [PATCH 075/305] ticket(0ca3e019): set section Out of scope --- .../0ca3e019-frontmatter-agent-agent-overrides-for-pe.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md index 01c9adbf7..facd41448 100644 --- a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md +++ b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md @@ -53,7 +53,11 @@ The full resolution order (per spawn, where P is the profile name declared by th ### Out of scope -Explicit list of what this ticket does not cover. +- Per-transition agent mapping (a `{transition_name: agent}` map). The design doc explicitly defers this as a v2 contract extension; per-profile granularity is sufficient for v1. +- A CLI command to set the override (e.g. `apm set agent `). Could be added to `apm set`'s field list; noted as a small follow-up, not in this ticket. +- Surfacing the override in `apm show` output. The fields are present in frontmatter and visible via `cat`; display in `apm show` is a follow-up. +- Validating frontmatter agent names against custom project wrappers in `.apm/agents//`. `apm validate` in this ticket only checks built-ins via `resolve_builtin()`. Custom-wrapper validation is ticket 2c32a282's territory. +- Any changes to `apm set`, `apm show`, or `apm agents` subcommands. ### Approach From cc71f344b5b82ac827f88307e646eb5a843cc5d3 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:55:44 -0700 Subject: [PATCH 076/305] ticket(0ca3e019): set section Approach --- ...rontmatter-agent-agent-overrides-for-pe.md | 98 ++++++++++++++++++- 1 file changed, 97 insertions(+), 1 deletion(-) diff --git a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md index facd41448..d2df084d5 100644 --- a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md +++ b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md @@ -61,7 +61,103 @@ The full resolution order (per spawn, where P is the profile name declared by th ### Approach -How the implementation will work. +### 1. `apm-core/src/ticket/ticket_fmt.rs` — `Frontmatter` struct + +Add `use std::collections::HashMap;` to the imports (currently not imported; `indexmap::IndexMap` is the only map in scope). + +Add two fields to `Frontmatter` after `depends_on`: + +```rust +#[serde(default, skip_serializing_if = "Option::is_none")] +pub agent: Option, + +#[serde(default, skip_serializing_if = "HashMap::is_empty")] +pub agent_overrides: HashMap, +``` + +`agent_overrides` serializes as a TOML inline table when non-empty. Verify via the round-trip test that both the inline form (`agent_overrides = { spec_agent = "claude" }`) and the subtable form (`[agent_overrides]`) parse correctly; no special serde attribute is needed either way. + +`JsonSchema` is already derived on `Frontmatter`; `HashMap` is schema-compatible by default. + +--- + +### 2. `apm-core/src/start.rs` — Agent resolution + +Add a free function after `effective_spawn_params`: + +```rust +fn apply_frontmatter_agent( + agent: &mut String, + frontmatter: &ticket_fmt::Frontmatter, + profile_name: &str, +) { + if let Some(override_agent) = frontmatter.agent_overrides.get(profile_name) { + *agent = override_agent.clone(); + } else if let Some(a) = &frontmatter.agent { + *agent = a.clone(); + } + // else: keep config-resolved agent unchanged +} +``` + +Call this in each of the three spawn paths — `run()`, `run_next()`, and `spawn_next_worker()` — immediately after `effective_spawn_params()` returns and before `WrapperContext` is constructed (introduced by d3b93b95). The ticket's frontmatter (`ticket.frontmatter`) is already loaded at all three call sites. The profile name is the string resolved by `resolve_profile()`. + +`EffectiveWorkerParams.agent` (added by 6cac8518) is the field being mutated. After d3b93b95 + 6cac8518 land, `WrapperContext` is constructed from `EffectiveWorkerParams`; the agent name flows through unchanged. + +--- + +### 3. `apm-core/src/validate.rs` — `verify_tickets` + +In the per-ticket loop inside `verify_tickets()`, after existing checks, collect all agent names declared in frontmatter: + +```rust +let agents_to_check: Vec<&str> = ticket.frontmatter.agent + .as_deref() + .into_iter() + .chain(ticket.frontmatter.agent_overrides.values().map(String::as_str)) + .collect(); + +for name in agents_to_check { + if wrapper::resolve_builtin(name).is_none() { + errors.push(format!( + "ticket {}: agent {:?} is not a known built-in", + ticket.frontmatter.id, name + )); + } +} +``` + +Import `crate::wrapper` (introduced by d3b93b95). This check covers only built-ins; custom project scripts from ticket 2c32a282 are not checked here — when 2c32a282 lands and `resolve_wrapper()` subsumes `resolve_builtin()`, this call site can be upgraded in that ticket. + +--- + +### 4. `.apm/apm.spec-writer.md` and `.apm/apm.worker.md` + +Append a short note to each file near the end: + +> **Frontmatter agent override** (supervisor tool): A supervisor may add `agent = ""` or an `[agent_overrides]` table to a ticket's frontmatter to select a specific agent for that ticket or for individual profiles. Do not set these fields yourself — they are a supervisor-level escape hatch for debugging or per-ticket specialisation. + +--- + +### 5. Tests + +**`apm-core/src/ticket/ticket_fmt.rs`** — add to the existing test module: + +- `frontmatter_agent_round_trip`: parse TOML with `agent = "mock-happy"` and two entries in `agent_overrides`; serialize; re-parse; assert both fields match original. +- `frontmatter_agent_omitted_when_unset`: parse minimal frontmatter without `agent` or `agent_overrides`; serialize; assert neither field appears in the serialized string. + +**`apm-core/src/start.rs`** — add to the existing test module: + +- `apply_fm_profile_override_wins`: `agent_overrides = {impl_agent: "mock-happy"}`, `agent = "mock-sad"`, profile = `"impl_agent"` → result is `"mock-happy"`. +- `apply_fm_agent_field_wins_when_no_profile_match`: `agent_overrides = {}`, `agent = Some("mock-sad")`, profile = `"impl_agent"` → result is `"mock-sad"`. +- `apply_fm_profile_override_beats_agent_field`: `agent_overrides = {impl_agent: "claude"}`, `agent = Some("mock-random")`, profile = `"impl_agent"` → result is `"claude"`. +- `apply_fm_no_fields_unchanged`: both fields empty/None, starting agent = `"claude"` → result unchanged. + +**`apm-core/src/validate.rs`** — add to the existing test module: + +- `validate_unknown_frontmatter_agent_is_error`: ticket with `agent = "nonexistent-bot"`; assert error contains ticket id and `"nonexistent-bot"`. +- `validate_unknown_agent_in_overrides_is_error`: ticket with `agent_overrides` value `"nonexistent-bot"`; assert error contains ticket id and `"nonexistent-bot"`. +- `validate_known_frontmatter_agent_passes`: ticket with `agent = "claude"`; assert no errors from the agent check. ### Open questions From c4bf43715018ed7043d87bef955e778de9842a41 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:55:49 -0700 Subject: [PATCH 077/305] ticket(0ca3e019): set effort = 4 --- tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md index d2df084d5..a2161a24a 100644 --- a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md +++ b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md @@ -3,13 +3,13 @@ id = "0ca3e019" title = "Frontmatter agent + agent_overrides for per-ticket wrapper choice" state = "in_design" priority = 0 -effort = 0 +effort = 4 risk = 0 author = "philippepascal" owner = "philippepascal" branch = "ticket/0ca3e019-frontmatter-agent-agent-overrides-for-pe" created_at = "2026-04-30T20:03:58.532325Z" -updated_at = "2026-04-30T21:50:38.458676Z" +updated_at = "2026-04-30T21:55:49.916284Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "6cac8518"] From 43d5ade23cb2865517fcef3c23a9cde486c39cc3 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:55:56 -0700 Subject: [PATCH 078/305] ticket(0ca3e019): set risk = 3 --- tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md index a2161a24a..0e4923feb 100644 --- a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md +++ b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md @@ -4,12 +4,12 @@ title = "Frontmatter agent + agent_overrides for per-ticket wrapper choice" state = "in_design" priority = 0 effort = 4 -risk = 0 +risk = 3 author = "philippepascal" owner = "philippepascal" branch = "ticket/0ca3e019-frontmatter-agent-agent-overrides-for-pe" created_at = "2026-04-30T20:03:58.532325Z" -updated_at = "2026-04-30T21:55:49.916284Z" +updated_at = "2026-04-30T21:55:56.296707Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "6cac8518"] From a626c3b10709cdbf8e31883c0309c3fd114cf4ae Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:57:17 -0700 Subject: [PATCH 079/305] ticket(0ca3e019): set section Approach --- ...rontmatter-agent-agent-overrides-for-pe.md | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md index 0e4923feb..36926dad5 100644 --- a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md +++ b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md @@ -61,6 +61,91 @@ The full resolution order (per spawn, where P is the profile name declared by th ### Approach +**Files changed:** `ticket_fmt.rs`, `start.rs`, `validate.rs`, `.apm/apm.spec-writer.md`, `.apm/apm.worker.md`. + +#### `apm-core/src/ticket/ticket_fmt.rs` — `Frontmatter` struct + +Add `use std::collections::HashMap;` to imports (`indexmap::IndexMap` is already there but `std::collections::HashMap` is not). + +Add two fields to `Frontmatter` after `depends_on`: + +```rust +#[serde(default, skip_serializing_if = "Option::is_none")] +pub agent: Option, + +#[serde(default, skip_serializing_if = "HashMap::is_empty")] +pub agent_overrides: HashMap, +``` + +`agent_overrides` serializes as a TOML inline table when non-empty. Verify via the round-trip test that both the inline form and the subtable form parse correctly — no special serde attribute needed. `JsonSchema` is already derived; `HashMap` is compatible by default. + +#### `apm-core/src/start.rs` — Agent resolution + +Add a free function after `effective_spawn_params`: + +```rust +fn apply_frontmatter_agent( + agent: &mut String, + frontmatter: &ticket_fmt::Frontmatter, + profile_name: &str, +) { + if let Some(ov) = frontmatter.agent_overrides.get(profile_name) { + *agent = ov.clone(); + } else if let Some(a) = &frontmatter.agent { + *agent = a.clone(); + } + // else: keep config-resolved agent unchanged +} +``` + +Call this in each of the three spawn paths — `run()`, `run_next()`, `spawn_next_worker()` — immediately after `effective_spawn_params()` returns and before `WrapperContext` is constructed (introduced by d3b93b95). `ticket.frontmatter` is already loaded at all three call sites. The profile name is already available from `resolve_profile()`. `EffectiveWorkerParams.agent` (added by 6cac8518) is the field being mutated. + +#### `apm-core/src/validate.rs` — `verify_tickets` + +In the per-ticket loop inside `verify_tickets()`, after existing checks, collect all agent names declared in frontmatter and check each against `wrapper::resolve_builtin()` (introduced by d3b93b95): + +```rust +let agents_to_check: Vec<&str> = ticket.frontmatter.agent + .as_deref() + .into_iter() + .chain(ticket.frontmatter.agent_overrides.values().map(String::as_str)) + .collect(); + +for name in agents_to_check { + if wrapper::resolve_builtin(name).is_none() { + errors.push(format!( + "ticket {}: agent {:?} is not a known built-in", + ticket.frontmatter.id, name + )); + } +} +``` + +This checks only built-ins. When ticket 2c32a282 lands and `resolve_wrapper()` subsumes `resolve_builtin()`, this call site can be upgraded in that ticket. + +#### `.apm/apm.spec-writer.md` and `.apm/apm.worker.md` + +Append a short note near the end of each file: + +> **Frontmatter agent override** (supervisor tool): A supervisor may add `agent = ""` or an `[agent_overrides]` table to a ticket's frontmatter to select a specific agent for that ticket or for individual profiles. Do not set these fields yourself — they are a supervisor-level escape hatch for debugging or per-ticket specialisation. + +#### Tests + +`apm-core/src/ticket/ticket_fmt.rs` — add to the existing test module: +- `frontmatter_agent_round_trip`: parse TOML with `agent = "mock-happy"` and two `agent_overrides` entries; serialize; re-parse; assert fields match. +- `frontmatter_agent_omitted_when_unset`: parse minimal frontmatter; serialize; assert neither `agent` nor `agent_overrides` appears in output. + +`apm-core/src/start.rs` — add to existing test module: +- `apply_fm_profile_override_wins`: `agent_overrides = {impl_agent: "mock-happy"}`, `agent = "mock-sad"`, profile `"impl_agent"` → `"mock-happy"`. +- `apply_fm_agent_field_wins_when_no_profile_match`: `agent_overrides = {}`, `agent = Some("mock-sad")`, profile `"impl_agent"` → `"mock-sad"`. +- `apply_fm_profile_override_beats_agent_field`: `agent_overrides = {impl_agent: "claude"}`, `agent = Some("mock-random")`, profile `"impl_agent"` → `"claude"`. +- `apply_fm_no_fields_unchanged`: both fields empty/None, starting agent `"claude"` → unchanged. + +`apm-core/src/validate.rs` — add to existing test module: +- `validate_unknown_frontmatter_agent_is_error`: ticket with `agent = "nonexistent-bot"`; assert error contains ticket id and agent name. +- `validate_unknown_agent_in_overrides_is_error`: ticket with `agent_overrides` value `"nonexistent-bot"`; assert error contains ticket id and agent name. +- `validate_known_frontmatter_agent_passes`: ticket with `agent = "claude"`; assert no errors from the agent check. + ### 1. `apm-core/src/ticket/ticket_fmt.rs` — `Frontmatter` struct Add `use std::collections::HashMap;` to the imports (currently not imported; `indexmap::IndexMap` is the only map in scope). From 0f3e1e9dd898fe3a2dedea246a3c3c0397b693cc Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:57:22 -0700 Subject: [PATCH 080/305] =?UTF-8?q?ticket(0ca3e019):=20in=5Fdesign=20?= =?UTF-8?q?=E2=86=92=20specd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../0ca3e019-frontmatter-agent-agent-overrides-for-pe.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md index 36926dad5..e108fdcf9 100644 --- a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md +++ b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md @@ -1,7 +1,7 @@ +++ id = "0ca3e019" title = "Frontmatter agent + agent_overrides for per-ticket wrapper choice" -state = "in_design" +state = "specd" priority = 0 effort = 4 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/0ca3e019-frontmatter-agent-agent-overrides-for-pe" created_at = "2026-04-30T20:03:58.532325Z" -updated_at = "2026-04-30T21:55:56.296707Z" +updated_at = "2026-04-30T21:57:22.347867Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "6cac8518"] @@ -259,4 +259,5 @@ Append a short note to each file near the end: |------|------|----|----| | 2026-04-30T20:03Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | -| 2026-04-30T21:50Z | groomed | in_design | philippepascal | \ No newline at end of file +| 2026-04-30T21:50Z | groomed | in_design | philippepascal | +| 2026-04-30T21:57Z | in_design | specd | claude-0430-2150-ea08 | From 34d20bef1458884aa2c4d86aaf9957c1389f4f6e Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 14:57:43 -0700 Subject: [PATCH 081/305] =?UTF-8?q?ticket(25c92daa):=20start=20=E2=80=94?= =?UTF-8?q?=20groomed=20=E2=86=92=20in=5Fdesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md index 3aefd8b48..3aebe9360 100644 --- a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md +++ b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md @@ -1,7 +1,7 @@ +++ id = "25c92daa" title = "Mock and debug built-in wrappers (mock-happy, mock-sad, mock-random, debug)" -state = "groomed" +state = "in_design" priority = 0 effort = 0 risk = 0 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/25c92daa-mock-and-debug-built-in-wrappers-mock-ha" created_at = "2026-04-30T20:04:21.901984Z" -updated_at = "2026-04-30T21:02:37.480077Z" +updated_at = "2026-04-30T21:57:43.460139Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "a1b94ea4", "6cac8518"] @@ -94,3 +94,4 @@ How the implementation will work. |------|------|----|----| | 2026-04-30T20:04Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | +| 2026-04-30T21:57Z | groomed | in_design | philippepascal | From 37b6a53e07ee9781536fbc1859c0f3c004a46aa8 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 15:02:57 -0700 Subject: [PATCH 082/305] =?UTF-8?q?ticket(71d80e40):=20start=20=E2=80=94?= =?UTF-8?q?=20groomed=20=E2=86=92=20in=5Fdesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index 396a986e4..18d929943 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -1,7 +1,7 @@ +++ id = "71d80e40" title = "apm agents subcommand: new, list, test, eject" -state = "groomed" +state = "in_design" priority = 0 effort = 0 risk = 0 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/71d80e40-apm-agents-subcommand-new-list-test-ejec" created_at = "2026-04-30T20:04:57.796154Z" -updated_at = "2026-04-30T21:02:40.645840Z" +updated_at = "2026-04-30T22:02:56.837073Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "2c32a282"] @@ -89,3 +89,4 @@ How the implementation will work. |------|------|----|----| | 2026-04-30T20:04Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | +| 2026-04-30T22:02Z | groomed | in_design | philippepascal | From 939a62aa3f41054b4a0e18da23e2357f4077582b Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 15:03:03 -0700 Subject: [PATCH 083/305] =?UTF-8?q?ticket(2e772eab):=20start=20=E2=80=94?= =?UTF-8?q?=20groomed=20=E2=86=92=20in=5Fdesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md b/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md index bc8549318..176ec18b7 100644 --- a/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md +++ b/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md @@ -1,7 +1,7 @@ +++ id = "2e772eab" title = "Wrapper-contract versioning (APM_WRAPPER_VERSION + manifest.toml)" -state = "groomed" +state = "in_design" priority = 0 effort = 0 risk = 0 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/2e772eab-wrapper-contract-versioning-apm-wrapper-" created_at = "2026-04-30T20:05:11.077339Z" -updated_at = "2026-04-30T21:02:43.738731Z" +updated_at = "2026-04-30T22:03:03.641329Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "2c32a282"] @@ -74,3 +74,4 @@ How the implementation will work. |------|------|----|----| | 2026-04-30T20:05Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | +| 2026-04-30T22:03Z | groomed | in_design | philippepascal | From c249c614cfde4c6c5b7e5fd975c482041573bb5e Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:08:34 -0700 Subject: [PATCH 084/305] =?UTF-8?q?ticket(25c92daa):=20in=5Fdesign=20?= =?UTF-8?q?=E2=86=92=20ammend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md index 3aebe9360..3281fdd3c 100644 --- a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md +++ b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md @@ -1,7 +1,7 @@ +++ id = "25c92daa" title = "Mock and debug built-in wrappers (mock-happy, mock-sad, mock-random, debug)" -state = "in_design" +state = "ammend" priority = 0 effort = 0 risk = 0 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/25c92daa-mock-and-debug-built-in-wrappers-mock-ha" created_at = "2026-04-30T20:04:21.901984Z" -updated_at = "2026-04-30T21:57:43.460139Z" +updated_at = "2026-05-01T00:08:34.535877Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "a1b94ea4", "6cac8518"] @@ -95,3 +95,4 @@ How the implementation will work. | 2026-04-30T20:04Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | | 2026-04-30T21:57Z | groomed | in_design | philippepascal | +| 2026-05-01T00:08Z | in_design | ammend | philippepascal | From b8125860fee6ca91eef82ed94609b466aa457701 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:08:59 -0700 Subject: [PATCH 085/305] =?UTF-8?q?ticket(71d80e40):=20in=5Fdesign=20?= =?UTF-8?q?=E2=86=92=20ammend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index 18d929943..f10d683ba 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -1,7 +1,7 @@ +++ id = "71d80e40" title = "apm agents subcommand: new, list, test, eject" -state = "in_design" +state = "ammend" priority = 0 effort = 0 risk = 0 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/71d80e40-apm-agents-subcommand-new-list-test-ejec" created_at = "2026-04-30T20:04:57.796154Z" -updated_at = "2026-04-30T22:02:56.837073Z" +updated_at = "2026-05-01T00:08:58.914874Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "2c32a282"] @@ -90,3 +90,4 @@ How the implementation will work. | 2026-04-30T20:04Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | | 2026-04-30T22:02Z | groomed | in_design | philippepascal | +| 2026-05-01T00:08Z | in_design | ammend | philippepascal | From 234a3ae2449298741950cb7992ae78a9a0db5be3 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:09:06 -0700 Subject: [PATCH 086/305] =?UTF-8?q?ticket(2c32a282):=20specd=20=E2=86=92?= =?UTF-8?q?=20ammend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md index 251cf2776..c700a00b0 100644 --- a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md +++ b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md @@ -1,7 +1,7 @@ +++ id = "2c32a282" title = "Custom wrapper resolution from .apm/agents//" -state = "specd" +state = "ammend" priority = 0 effort = 5 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/2c32a282-custom-wrapper-resolution-from-apm-agent" created_at = "2026-04-30T20:02:50.794362Z" -updated_at = "2026-04-30T21:36:37.975992Z" +updated_at = "2026-05-01T00:09:06.445793Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95"] @@ -253,3 +253,4 @@ Integration test in apm-core/tests/custom_wrapper_integration.rs: | 2026-04-30T21:02Z | new | groomed | philippepascal | | 2026-04-30T21:24Z | groomed | in_design | philippepascal | | 2026-04-30T21:36Z | in_design | specd | claude-0430-2124-5738 | +| 2026-05-01T00:09Z | specd | ammend | philippepascal | From 5f5db028e9dbeb11d450c16515c6f51ae6df7bf9 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:09:29 -0700 Subject: [PATCH 087/305] =?UTF-8?q?ticket(2e772eab):=20in=5Fdesign=20?= =?UTF-8?q?=E2=86=92=20ammend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md b/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md index 176ec18b7..d84e20b57 100644 --- a/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md +++ b/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md @@ -1,7 +1,7 @@ +++ id = "2e772eab" title = "Wrapper-contract versioning (APM_WRAPPER_VERSION + manifest.toml)" -state = "in_design" +state = "ammend" priority = 0 effort = 0 risk = 0 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/2e772eab-wrapper-contract-versioning-apm-wrapper-" created_at = "2026-04-30T20:05:11.077339Z" -updated_at = "2026-04-30T22:03:03.641329Z" +updated_at = "2026-05-01T00:09:29.085929Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "2c32a282"] @@ -75,3 +75,4 @@ How the implementation will work. | 2026-04-30T20:05Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | | 2026-04-30T22:03Z | groomed | in_design | philippepascal | +| 2026-05-01T00:09Z | in_design | ammend | philippepascal | From 5c7aa377dd499c3703dbfc2f0aaf174e36b2beba Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:09:40 -0700 Subject: [PATCH 088/305] =?UTF-8?q?ticket(25c92daa):=20start=20=E2=80=94?= =?UTF-8?q?=20ammend=20=E2=86=92=20in=5Fdesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md index 3281fdd3c..2e4a25164 100644 --- a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md +++ b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md @@ -1,7 +1,7 @@ +++ id = "25c92daa" title = "Mock and debug built-in wrappers (mock-happy, mock-sad, mock-random, debug)" -state = "ammend" +state = "in_design" priority = 0 effort = 0 risk = 0 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/25c92daa-mock-and-debug-built-in-wrappers-mock-ha" created_at = "2026-04-30T20:04:21.901984Z" -updated_at = "2026-05-01T00:08:34.535877Z" +updated_at = "2026-05-01T00:09:40.082843Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "a1b94ea4", "6cac8518"] @@ -96,3 +96,4 @@ How the implementation will work. | 2026-04-30T21:02Z | new | groomed | philippepascal | | 2026-04-30T21:57Z | groomed | in_design | philippepascal | | 2026-05-01T00:08Z | in_design | ammend | philippepascal | +| 2026-05-01T00:09Z | ammend | in_design | philippepascal | From 49024400dfa6fd5e7237c084220d875f643de9ed Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:19:03 -0700 Subject: [PATCH 089/305] ticket(25c92daa): set section Acceptance criteria --- ...ock-and-debug-built-in-wrappers-mock-ha.md | 61 ++++++++++++++++++- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md index 2e4a25164..18a8b5e6d 100644 --- a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md +++ b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md @@ -69,7 +69,64 @@ Ship three mock built-in wrappers for testing the harness without burning credit ### Acceptance criteria -Checkboxes; each one independently testable. +- [ ] **Dispatcher registration** +- [ ] `resolve_builtin("mock-happy")` returns `Some(_)` +- [ ] `resolve_builtin("mock-sad")` returns `Some(_)` +- [ ] `resolve_builtin("mock-random")` returns `Some(_)` +- [ ] `resolve_builtin("debug")` returns `Some(_)` + +- [ ] **mock-happy — spec mode (ticket in `in_design`)** +- [ ] When run against a ticket in `in_design` state, `mock-happy` writes non-empty content to all four required spec sections: Problem, Acceptance criteria, Out of scope, Approach +- [ ] When run against a ticket in `in_design` state, `mock-happy` sets the ticket's `effort` to `1` +- [ ] When run against a ticket in `in_design` state, `mock-happy` sets the ticket's `risk` to `1` +- [ ] When run against a ticket in `in_design` state, `mock-happy` transitions the ticket to `specd` + +- [ ] **mock-happy — impl mode (ticket in `in_progress`)** +- [ ] When run against a ticket in `in_progress` state, `mock-happy` creates at least one new git commit in the worktree +- [ ] When run against a ticket in `in_progress` state, `mock-happy` calls `apm state implemented` + +- [ ] **mock-happy — JSONL output** +- [ ] `mock-happy` emits at least one JSONL line on stdout; each emitted line is a valid JSON object with `"type": "tool_use"` + +- [ ] **mock-happy — error cases and exit** +- [ ] When the current state has zero `outcome = "success"` transitions, `mock-happy` exits non-zero and writes a diagnostic message to stderr +- [ ] When the current state has two or more `outcome = "success"` transitions, `mock-happy` exits non-zero and writes a diagnostic message to stderr +- [ ] `mock-happy` exits 0 when it completes without error + +- [ ] **mock-sad — behaviour** +- [ ] `mock-sad` writes content to at least one but fewer than all four required spec sections (Problem, Acceptance criteria, Out of scope, Approach) +- [ ] `mock-sad` transitions the ticket to a state reachable via a transition whose `resolve_outcome` result is not `"success"` +- [ ] `mock-sad` exits 0 after completing its run + +- [ ] **mock-sad — seeding** +- [ ] Given the same `APM_OPT_SEED` value, two successive `mock-sad` spawns against the same ticket in the same state choose the same target state + +- [ ] **mock-sad — error case** +- [ ] When no non-success transitions are available from the current state, `mock-sad` exits non-zero and writes a diagnostic to stderr + +- [ ] **mock-random — behaviour** +- [ ] `mock-random` transitions the ticket to a state reachable by any valid transition from the current state (including success-outcome transitions) +- [ ] When `mock-random` picks a success-outcome transition, it writes all four spec sections and sets effort/risk (spec mode) or creates a commit (impl mode), matching `mock-happy`'s behaviour +- [ ] When `mock-random` picks a non-success-outcome transition, it writes only partial spec content (matching `mock-sad`'s behaviour) +- [ ] `mock-random` exits 0 after completing its run + +- [ ] **mock-random — seeding** +- [ ] Given the same `APM_OPT_SEED` value, two successive `mock-random` spawns against the same ticket in the same state choose the same target state + +- [ ] **debug — output** +- [ ] `debug` writes the name and value of every `APM_*` environment variable to stderr +- [ ] `debug` writes the full contents of the file at `APM_SYSTEM_PROMPT_FILE` to stderr +- [ ] `debug` writes the full contents of the file at `APM_USER_MESSAGE_FILE` to stderr +- [ ] `debug` emits exactly one JSONL line on stdout: a valid JSON object with `"type": "tool_use"` + +- [ ] **debug — behaviour** +- [ ] `debug` does not call `apm state`; the ticket's state is unchanged after `debug` runs +- [ ] `debug` exits 0 + +- [ ] **workflow.toml** +- [ ] `in_design → specd` carries `outcome = "success"` in the default workflow (`apm-core/src/default/workflow.toml`) +- [ ] `ammend → specd` carries `outcome = "success"` in the default workflow +- [ ] The project's `.apm/workflow.toml` carries the same two annotations ### Out of scope @@ -96,4 +153,4 @@ How the implementation will work. | 2026-04-30T21:02Z | new | groomed | philippepascal | | 2026-04-30T21:57Z | groomed | in_design | philippepascal | | 2026-05-01T00:08Z | in_design | ammend | philippepascal | -| 2026-05-01T00:09Z | ammend | in_design | philippepascal | +| 2026-05-01T00:09Z | ammend | in_design | philippepascal | \ No newline at end of file From 6e32e78db29c8269a5c0620e7d751efeedfc7673 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:19:08 -0700 Subject: [PATCH 090/305] ticket(25c92daa): set section Out of scope --- ...c92daa-mock-and-debug-built-in-wrappers-mock-ha.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md index 18a8b5e6d..45c109ab2 100644 --- a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md +++ b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md @@ -130,7 +130,16 @@ Ship three mock built-in wrappers for testing the harness without burning credit ### Out of scope -Explicit list of what this ticket does not cover. +- User-facing documentation for mock wrappers beyond the existing `docs/agent-wrappers.md` reference +- The `apm agents` subcommand family (`apm agents test`, `apm agents list`, etc.) — ticket 71d80e40 +- Custom wrapper resolution from `.apm/agents//` — ticket 2c32a282 +- Per-ticket `frontmatter.agent` / `agent_overrides` override — ticket 0ca3e019 +- Built-in wrappers for third-party agents (`codex`, `aider`, etc.) +- Wrapper-contract version compatibility checks (`manifest.toml`, `APM_WRAPPER_VERSION` ceiling) — ticket 2e772eab +- Per-agent instruction file resolution under `.apm/agents//apm.*.md` (ticket 7f5f73d5); mocks do not invoke a real agent so instruction files are irrelevant to their operation +- Windows or non-Unix platform support; mocks shell out to `/bin/sh` +- Automated handling of the `pr_or_epic_merge` completion strategy in integration tests; mock-happy creates a real commit and calls `apm state implemented`, then APM's orchestration layer (running separately) handles the merge — no in-test merge attempt +- Validating that `apm validate` warns when a workflow has no reachable success outcome — that check lives in ticket a1b94ea4 ### Approach From c8863d44dfd514f0fd2aefbfce39fb0fad7b81c0 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:19:12 -0700 Subject: [PATCH 091/305] ticket(25c92daa): set section Approach --- ...ock-and-debug-built-in-wrappers-mock-ha.md | 273 +++++++++++++++++- 1 file changed, 272 insertions(+), 1 deletion(-) diff --git a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md index 45c109ab2..96ee4f954 100644 --- a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md +++ b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md @@ -143,7 +143,278 @@ Ship three mock built-in wrappers for testing the harness without burning credit ### Approach -How the implementation will work. +### Background constraint: workflow.toml annotation gap + +The default workflow's `in_design → specd` and `ammend → specd` transitions have no `completion` strategy, so `resolve_outcome` (ticket a1b94ea4) infers `"needs_input"` for them by default. That makes `mock-happy` unable to find any success transition when running in the spec-writer state — it would always exit non-zero. + +Fix: this ticket explicitly annotates both transitions with `outcome = "success"` in `apm-core/src/default/workflow.toml` and `.apm/workflow.toml`. This override is intentional — completing a spec IS the success outcome for the spec-writer agent, even though it involves no git merge. + +### 1. WrapperContext extensions (`wrapper/mod.rs`) + +Add two fields to `WrapperContext`: + +```rust +pub root_path: PathBuf, // project root (not the worktree) +pub current_state: String, // ticket's state at spawn time +``` + +Callers in `start.rs` set `root_path = root.to_path_buf()` and `current_state = ticket.frontmatter.state.clone()`. + +Add `APM_PROJECT_ROOT` to the wrapper contract env vars set by `ClaudeWrapper::spawn()` (both local and container paths). All four new wrappers also set it. + +### 2. workflow.toml annotation (`apm-core/src/default/workflow.toml` and `.apm/workflow.toml`) + +In each file, find the two transition blocks and add the `outcome` field: + +```toml +# under [[workflow.states]] id = "in_design" +[[workflow.states.transitions]] +to = "specd" +trigger = "manual" +outcome = "success" # explicit override — spec completion is a success + +# under [[workflow.states]] id = "ammend" +[[workflow.states.transitions]] +to = "specd" +trigger = "manual" +outcome = "success" # explicit override — amendment completion is a success +``` + +Both workflow files get the same change. Note: ticket a1b94ea4 also modifies workflow.toml. Implementers must merge cleanly; there is no logical conflict (a1b94ea4 adds `outcome` to transitions that match their implicit defaults; this ticket adds `outcome = "success"` to the two that override the default). + +### 3. Module layout + +Create `apm-core/src/wrapper/builtin/` with: + +``` +wrapper/builtin/mod.rs — shared helpers, pub use for all built-ins +wrapper/builtin/mock_happy.rs — MockHappyWrapper +wrapper/builtin/mock_sad.rs — MockSadWrapper +wrapper/builtin/mock_random.rs — MockRandomWrapper +wrapper/builtin/debug.rs — DebugWrapper +``` + +If d3b93b95's implementation placed `claude.rs` at `wrapper/claude.rs`, move it to `wrapper/builtin/claude.rs` and update all `use` paths. If d3b93b95 already used `wrapper/builtin/`, no move is needed. + +Add `pub mod builtin;` to `wrapper/mod.rs`. + +Update `resolve_builtin()` in `wrapper/mod.rs`: +```rust +"mock-happy" => Some(Box::new(MockHappyWrapper)), +"mock-sad" => Some(Box::new(MockSadWrapper)), +"mock-random" => Some(Box::new(MockRandomWrapper)), +"debug" => Some(Box::new(DebugWrapper)), +``` + +### 4. Shared helpers in `wrapper/builtin/mod.rs` + +#### `load_transitions_with_outcomes` + +```rust +fn load_transitions_with_outcomes( + ctx: &WrapperContext, +) -> anyhow::Result> +``` + +1. `Config::load(&ctx.root_path)` — loads project config + workflow +2. Find the `StateConfig` matching `ctx.current_state` in the workflow; return `Err` if not found +3. Build a `HashMap<&str, &StateConfig>` keyed by state id for O(1) target lookup +4. For each transition in the current state, clone the pair `(TransitionConfig, target StateConfig)` and return the vec + +#### `is_impl_mode` + +```rust +fn is_impl_mode(transitions: &[(TransitionConfig, StateConfig)]) -> bool { + transitions.iter().any(|(t, _)| t.completion != CompletionStrategy::None) +} +``` + +Returns `true` when at least one transition has a completion strategy (indicates the current state expects git work). + +#### `write_and_spawn_script` + +```rust +fn write_and_spawn_script( + name: &str, + script: &str, + ctx: &WrapperContext, +) -> anyhow::Result +``` + +1. Write `script` to `/.apm-mock--.sh` (using `rand_u16()` from d3b93b95) +2. Set permissions to 0o755 (`std::fs::set_permissions(..., Permissions::from_mode(0o755))`) +3. Build `Command::new("/bin/sh")`, arg = script path +4. Set all APM contract env vars (same set as ClaudeWrapper), including `APM_PROJECT_ROOT` +5. `.current_dir(&ctx.worktree_path)`, `.process_group(0)` +6. Redirect stdout + stderr to `File::create(&ctx.log_path)?` / `try_clone()` +7. `.spawn()`; return `Child` +8. The script's last line is `rm -f "$0"` (self-cleanup); no separate cleanup thread needed for the script file + +#### `apm_bin` + +```rust +fn apm_bin() -> anyhow::Result +``` + +Returns `std::env::current_exe()?.to_str().ok_or_else(...)?.to_string()`. Used so scripts shell out to the same `apm` binary that spawned them. + +#### `seed_from_ctx` + +```rust +fn seed_from_ctx(ctx: &WrapperContext) -> u64 +``` + +Reads `ctx.options.get("seed")` → parse as `u64`; on failure falls back to a random `u64` from `rand::thread_rng()`. + +### 5. MockHappyWrapper (`mock_happy.rs`) + +`spawn()` steps: +1. Call `load_transitions_with_outcomes(ctx)`. +2. Filter to success outcomes: `transitions.iter().filter(|(t, s)| resolve_outcome(t, s) == "success")`. +3. Match on count: 0 → `anyhow::bail!("mock-happy: no success-outcome transition from state '{}'", ctx.current_state)`, 2+ → `anyhow::bail!("mock-happy: {} success-outcome transitions; expected 1", n)`. +4. Extract `target_state = success_transitions[0].0.to.clone()`. +5. `let impl_mode = is_impl_mode(&transitions)`. +6. `let apm = apm_bin()?`. +7. Generate `script`: + + **Spec mode** (not impl mode): + ```sh + #!/bin/sh + set -e + APM="" + ID="" + "$APM" spec "$ID" --section "Problem" \ + --set "Mock spec — no real problem analyzed." + printf '- [ ] Mock criterion 1\n- [ ] Mock criterion 2\n' \ + > ".apm-mock-ac-$$.txt" + "$APM" spec "$ID" --section "Acceptance criteria" \ + --set-file ".apm-mock-ac-$$.txt" + rm -f ".apm-mock-ac-$$.txt" + "$APM" spec "$ID" --section "Out of scope" \ + --set "- Nothing in scope for this mock run" + "$APM" spec "$ID" --section "Approach" \ + --set "Mock approach — no real implementation analyzed." + "$APM" set "$ID" effort 1 + "$APM" set "$ID" risk 1 + printf '{"type":"tool_use","id":"mock-1","name":"write_spec","input":{}}\n' + printf '{"type":"tool_use","id":"mock-2","name":"apm_state","input":{}}\n' + "$APM" state "$ID" + rm -f "$0" + ``` + + **Impl mode** (is_impl_mode is true): + ```sh + #!/bin/sh + set -e + APM="" + ID="" + printf 'mock: placeholder implementation for ticket %s\n' "$ID" \ + > mock-implementation.txt + git add mock-implementation.txt + git commit -m "mock: placeholder commit for ticket $ID" + printf '{"type":"tool_use","id":"mock-1","name":"git_commit","input":{}}\n' + printf '{"type":"tool_use","id":"mock-2","name":"apm_state","input":{}}\n' + "$APM" state "$ID" + rm -f "$0" + ``` + +8. Call `write_and_spawn_script("happy", &script, ctx)`. + +### 6. MockSadWrapper (`mock_sad.rs`) + +`spawn()` steps: +1. `load_transitions_with_outcomes(ctx)`. +2. Filter to non-success: `resolve_outcome(t, s) != "success"`. +3. If empty: `anyhow::bail!("mock-sad: no non-success transitions from state '{}'", ctx.current_state)`. +4. `let seed = seed_from_ctx(ctx)`. +5. `let rng = rand::rngs::StdRng::seed_from_u64(seed)`. +6. Pick index = `(seed as usize) % eligible.len()` (no need for a shuffle; modulo gives deterministic pick for a given seed and list length). +7. `target_state = eligible[idx].0.to.clone()`. +8. Generate script (writes only Problem section, adds an open question, emits one JSONL event, calls `apm state`): + + ```sh + #!/bin/sh + set -e + APM="" + ID="" + "$APM" spec "$ID" --section "Problem" \ + --set "Mock sad run — spec intentionally incomplete." + "$APM" spec "$ID" --section "Open questions" \ + --set "- [ ] Mock open question — why did this fail?" + printf '{"type":"tool_use","id":"mock-1","name":"write_partial_spec","input":{}}\n' + "$APM" state "$ID" + rm -f "$0" + ``` + +9. `write_and_spawn_script("sad", &script, ctx)`. + +Note: `apm spec --section "Open questions"` must be a valid section name for `apm spec --set`. Verify the exact section name against the `apm spec` command's accepted sections; if "Open questions" isn't a named section, write to it via the ticket file directly or skip the question step. + +### 7. MockRandomWrapper (`mock_random.rs`) + +`spawn()` steps: +1. `load_transitions_with_outcomes(ctx)`. +2. If empty: `anyhow::bail!("mock-random: no valid transitions from state '{}'", ctx.current_state)`. +3. `seed_from_ctx(ctx)`. +4. Pick index via `seed as usize % all.len()`. +5. Inspect chosen transition's `resolve_outcome`: + - `"success"` → generate the mock-happy script for the chosen `target_state` (spec or impl mode determined by `is_impl_mode(&all)`) + - anything else → generate the mock-sad script for the chosen `target_state` +6. `write_and_spawn_script("random", &script, ctx)`. + +Rather than duplicating script generation, extract private functions `happy_script(apm: &str, id: &str, target: &str, impl_mode: bool) -> String` and `sad_script(apm: &str, id: &str, target: &str) -> String` into `builtin/mod.rs` and call them from all three wrappers. + +### 8. DebugWrapper (`debug.rs`) + +`spawn()` steps: +1. No config loading. No transition resolution. +2. `apm_bin()`. +3. Script: + + ```sh + #!/bin/sh + env | grep '^APM_' >&2 + printf '\n=== SYSTEM PROMPT ===\n' >&2 + cat "$APM_SYSTEM_PROMPT_FILE" >&2 + printf '\n=== USER MESSAGE ===\n' >&2 + cat "$APM_USER_MESSAGE_FILE" >&2 + printf '{"type":"tool_use","id":"debug-1","name":"noop","input":{}}\n' + rm -f "$0" + ``` + +4. `write_and_spawn_script("debug", &script, ctx)`. + +No `apm state` call. The ticket state is not modified. + +### 9. Tests + +**Unit tests in `wrapper/builtin/mod.rs` (or `wrapper/mod.rs`)** +- `resolve_builtin_mock_happy_returns_some` — `assert!(resolve_builtin("mock-happy").is_some())` +- `resolve_builtin_mock_sad_returns_some` — `assert!(resolve_builtin("mock-sad").is_some())` +- `resolve_builtin_mock_random_returns_some` — `assert!(resolve_builtin("mock-random").is_some())` +- `resolve_builtin_debug_returns_some` — `assert!(resolve_builtin("debug").is_some())` + +**Integration tests in `apm-core/src/start.rs` `#[cfg(test)]` or a dedicated `tests/mock_wrappers.rs`** + +Each test uses the same fixture helper (inline, no external files): +- Create a `tempfile::TempDir` for the project root +- Write a minimal `.apm/config.toml` with `agent = "mock-happy"` (or the wrapper under test) +- Copy (or write inline) the default workflow.toml to `.apm/workflow.toml` — including the two new `outcome = "success"` annotations +- Create a ticket file in `tickets/` in the correct starting state +- `git init`, add + commit the files (required for worktree and state operations) +- Build a `WrapperContext` pointing at the fixture with `current_state` set +- Call `spawn_worker(ctx)` (the private fn from d3b93b95), `child.wait()`, then read the updated ticket + +Test list: +- `mock_happy_spec_mode_transitions_to_specd` — ticket in `in_design`; assert state = `specd`; assert all four spec section headers are present in the ticket file; assert effort = 1; assert risk = 1 +- `mock_happy_impl_mode_creates_commit_and_transitions` — ticket in `in_progress`; assert state = `implemented`; assert `git log --oneline` in worktree has a commit containing "mock" +- `mock_happy_zero_success_transitions_exits_nonzero` — use a custom inline workflow where the current state has only non-success transitions; assert `child.wait().status.success() == false`; assert log contains "no success-outcome transition" +- `mock_sad_transitions_to_non_success_state` — ticket in `in_design`; assert resulting state is NOT `specd`; assert only Problem section is present in ticket spec +- `mock_sad_seed_reproducibility` — two separate spawn calls with `APM_OPT_SEED=42`; assert both end in the same target state +- `mock_random_seed_reproducibility` — same as above with `mock-random` +- `debug_does_not_change_state` — ticket in `in_design`; run debug; assert state is still `in_design`; assert log contains `APM_TICKET_ID`; assert log contains the system prompt text; assert log contains a line matching `{"type":"tool_use"...}` + +All tests that require `apm` CLI calls in the script must resolve `current_exe()` correctly — in the test binary environment, `current_exe()` returns the test runner, not `apm`. Use the same workaround as the existing `spawn_worker_cwd_is_ticket_worktree` test: set a fixture env var or pass an `apm_override_bin` through `WrapperContext.options` (key `"apm_bin"`) that the mock script uses when set. The `apm_bin()` helper checks `ctx.options.get("apm_bin")` first, then falls back to `current_exe()`. ### Open questions From 55e9241276a73f0b83ba324300f93b3eb96b03d8 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:19:18 -0700 Subject: [PATCH 092/305] ticket(25c92daa): set effort = 6 --- tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md index 96ee4f954..9f1fdb903 100644 --- a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md +++ b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md @@ -3,13 +3,13 @@ id = "25c92daa" title = "Mock and debug built-in wrappers (mock-happy, mock-sad, mock-random, debug)" state = "in_design" priority = 0 -effort = 0 +effort = 6 risk = 0 author = "philippepascal" owner = "philippepascal" branch = "ticket/25c92daa-mock-and-debug-built-in-wrappers-mock-ha" created_at = "2026-04-30T20:04:21.901984Z" -updated_at = "2026-05-01T00:09:40.082843Z" +updated_at = "2026-05-01T00:19:17.930832Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "a1b94ea4", "6cac8518"] From adbe817c82a8b344dc7f191baf6e2c02b06e5d10 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:19:20 -0700 Subject: [PATCH 093/305] ticket(25c92daa): set risk = 4 --- tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md index 9f1fdb903..d750ffce6 100644 --- a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md +++ b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md @@ -4,12 +4,12 @@ title = "Mock and debug built-in wrappers (mock-happy, mock-sad, mock-random, de state = "in_design" priority = 0 effort = 6 -risk = 0 +risk = 4 author = "philippepascal" owner = "philippepascal" branch = "ticket/25c92daa-mock-and-debug-built-in-wrappers-mock-ha" created_at = "2026-04-30T20:04:21.901984Z" -updated_at = "2026-05-01T00:19:17.930832Z" +updated_at = "2026-05-01T00:19:20.691028Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "a1b94ea4", "6cac8518"] From 162852ae116c26716d89e23bf1892899bcb7d24c Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:21:09 -0700 Subject: [PATCH 094/305] ticket(25c92daa): set section Approach --- ...ock-and-debug-built-in-wrappers-mock-ha.md | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) diff --git a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md index d750ffce6..0f4fe16ca 100644 --- a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md +++ b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md @@ -143,6 +143,176 @@ Ship three mock built-in wrappers for testing the harness without burning credit ### Approach +Four built-in wrappers added to `apm-core/src/wrapper/`, each implemented as a generated shell script that the Rust `spawn()` method writes to the worktree and executes via `/bin/sh`. + +**Prerequisite: workflow.toml annotation** + +The default workflow's `in_design → specd` and `ammend → specd` transitions have no `completion` strategy, so `resolve_outcome` (from ticket a1b94ea4) infers `"needs_input"` for them. That means mock-happy would find zero success transitions for spec-writer states and always exit non-zero. + +Fix: this ticket explicitly adds `outcome = "success"` to both transitions in `apm-core/src/default/workflow.toml` and `.apm/workflow.toml`. These override the implicit default — completing a spec IS the spec-writer's success path even without a git merge. + +Note: ticket a1b94ea4 also annotates workflow.toml. The two changes are compatible (a1b94ea4 annotates transitions that match their implicit default; this ticket annotates two that override the default). Implementers must merge cleanly. + +**1. WrapperContext extensions (`wrapper/mod.rs`)** + +Add two fields to `WrapperContext` (both set by callers in `start.rs`): + +- `root_path: PathBuf` — project root (not the worktree). Set to `root.to_path_buf()`. +- `current_state: String` — the ticket's state at spawn time. Set from `ticket.frontmatter.state.clone()`. + +Also add `APM_PROJECT_ROOT` to the env vars set by `ClaudeWrapper::spawn()` (both local and container paths) for consistency. All four new wrappers set it too. + +**2. File layout** + +Create `apm-core/src/wrapper/builtin/` and move `claude.rs` there if d3b93b95 placed it at `wrapper/claude.rs` (update all `use` paths). Add `pub mod builtin;` to `wrapper/mod.rs`. + +New files: `wrapper/builtin/mock_happy.rs`, `wrapper/builtin/mock_sad.rs`, `wrapper/builtin/mock_random.rs`, `wrapper/builtin/debug.rs`. + +New `wrapper/builtin/mod.rs` contains shared helpers (see below) and re-exports all four structs. + +Extend `resolve_builtin()` in `wrapper/mod.rs`: +``` +"mock-happy" => Some(Box::new(MockHappyWrapper)), +"mock-sad" => Some(Box::new(MockSadWrapper)), +"mock-random" => Some(Box::new(MockRandomWrapper)), +"debug" => Some(Box::new(DebugWrapper)), +``` + +**3. Shared helpers in `wrapper/builtin/mod.rs`** + +`load_transitions_with_outcomes(ctx: &WrapperContext) -> anyhow::Result>`: +1. `Config::load(&ctx.root_path)`. +2. Find `StateConfig` matching `ctx.current_state` in the workflow; bail if not found. +3. Build a `HashMap<&str, &StateConfig>` for target lookup. +4. For each transition in the current state, clone `(TransitionConfig, target StateConfig)` and return the vec. + +`is_impl_mode(transitions: &[(TransitionConfig, StateConfig)]) -> bool`: +Returns true when any transition has `completion != CompletionStrategy::None`. True for `in_progress` (has `pr_or_epic_merge`); false for `in_design`. + +`happy_script(apm: &str, id: &str, target: &str, impl_mode: bool) -> String` and `sad_script(apm: &str, id: &str, target: &str) -> String`: +Private functions that return the shell script strings (see below). Called by all three mocks to avoid duplication. + +`write_and_spawn_script(name: &str, script: &str, ctx: &WrapperContext) -> anyhow::Result`: +1. Write script to `/.apm-mock--.sh`, chmod 0o755. +2. `Command::new("/bin/sh")` with the script path as arg. +3. Set all APM contract env vars (same set as `ClaudeWrapper`, including `APM_PROJECT_ROOT`). +4. `.current_dir(&ctx.worktree_path)`, `.process_group(0)`. +5. Redirect stdout + stderr to `File::create(&ctx.log_path)?` / `try_clone()`. +6. `.spawn()` and return `Child`. The script ends with `rm -f "$0"` (self-cleanup; no separate thread needed). + +`apm_bin() -> anyhow::Result`: returns `std::env::current_exe()?.to_str()…?`. Used so scripts shell out to the same `apm` binary that spawned them. + +`apm_bin_from_ctx(ctx: &WrapperContext) -> anyhow::Result`: checks `ctx.options.get("apm_bin")` first (allows test override), then falls back to `apm_bin()`. + +`seed_from_ctx(ctx: &WrapperContext) -> u64`: reads `ctx.options.get("seed").and_then(|s| s.parse().ok())`, falls back to `rand::thread_rng().gen::()`. + +**4. MockHappyWrapper spawn() steps** + +1. `load_transitions_with_outcomes(ctx)`. +2. Filter to success: `resolve_outcome(t, s) == "success"`. +3. Match count: 0 → bail with diagnostic naming the current state; 2+ → bail with count. +4. `target = success[0].0.to.clone()`, `impl_mode = is_impl_mode(&all)`. +5. `apm = apm_bin_from_ctx(ctx)?`. +6. `script = happy_script(&apm, &ctx.ticket_id, &target, impl_mode)`. +7. `write_and_spawn_script("happy", &script, ctx)`. + +`happy_script` spec mode (not impl_mode): +```sh +#!/bin/sh +set -e +APM="" +ID="" +"$APM" spec "$ID" --section "Problem" --set "Mock spec — no real problem analyzed." +printf '- [ ] Mock criterion 1\n- [ ] Mock criterion 2\n' > ".apm-mock-ac-$$.txt" +"$APM" spec "$ID" --section "Acceptance criteria" --set-file ".apm-mock-ac-$$.txt" +rm -f ".apm-mock-ac-$$.txt" +"$APM" spec "$ID" --section "Out of scope" --set "- Nothing in scope for this mock run" +"$APM" spec "$ID" --section "Approach" --set "Mock approach — no real implementation analyzed." +"$APM" set "$ID" effort 1 +"$APM" set "$ID" risk 1 +printf '{"type":"tool_use","id":"mock-1","name":"write_spec","input":{}}\n' +printf '{"type":"tool_use","id":"mock-2","name":"apm_state","input":{}}\n' +"$APM" state "$ID" +rm -f "$0" +``` + +`happy_script` impl mode: +```sh +#!/bin/sh +set -e +APM="" +ID="" +printf 'mock: placeholder implementation for ticket %s\n' "$ID" > mock-implementation.txt +git add mock-implementation.txt +git commit -m "mock: placeholder commit for ticket $ID" +printf '{"type":"tool_use","id":"mock-1","name":"git_commit","input":{}}\n' +printf '{"type":"tool_use","id":"mock-2","name":"apm_state","input":{}}\n' +"$APM" state "$ID" +rm -f "$0" +``` + +**5. MockSadWrapper spawn() steps** + +1. `load_transitions_with_outcomes(ctx)`. +2. Filter to non-success: `resolve_outcome(t, s) != "success"`. +3. Empty → bail with diagnostic. +4. `seed = seed_from_ctx(ctx)`, pick `idx = seed as usize % eligible.len()`, `target = eligible[idx].0.to.clone()`. +5. `script = sad_script(&apm, &ctx.ticket_id, &target)`. +6. `write_and_spawn_script("sad", &script, ctx)`. + +`sad_script`: +```sh +#!/bin/sh +set -e +APM="" +ID="" +"$APM" spec "$ID" --section "Problem" --set "Mock sad run — spec intentionally incomplete." +printf '{"type":"tool_use","id":"mock-1","name":"write_partial_spec","input":{}}\n' +"$APM" state "$ID" +rm -f "$0" +``` + +**6. MockRandomWrapper spawn() steps** + +1. `load_transitions_with_outcomes(ctx)`. +2. Empty → bail. +3. `seed = seed_from_ctx(ctx)`, pick `idx = seed as usize % all.len()`, chosen = `all[idx]`. +4. `outcome = resolve_outcome(&chosen.0, &chosen.1)`. +5. If `outcome == "success"` → `happy_script(...)`, else → `sad_script(...)`. +6. `write_and_spawn_script("random", &script, ctx)`. + +**7. DebugWrapper spawn() steps** + +No config loading. Script: +```sh +#!/bin/sh +env | grep '^APM_' >&2 +printf '\n=== SYSTEM PROMPT ===\n' >&2 +cat "$APM_SYSTEM_PROMPT_FILE" >&2 +printf '\n=== USER MESSAGE ===\n' >&2 +cat "$APM_USER_MESSAGE_FILE" >&2 +printf '{"type":"tool_use","id":"debug-1","name":"noop","input":{}}\n' +rm -f "$0" +``` +No `apm state` call. Ticket state is unchanged. + +**8. Tests** + +Unit tests (in `wrapper/mod.rs` or `wrapper/builtin/mod.rs`): +- `resolve_builtin_mock_happy_returns_some`, `_mock_sad_`, `_mock_random_`, `_debug_` — each asserts `resolve_builtin("").is_some()`. + +Integration tests (in `start.rs` `#[cfg(test)]` or `tests/mock_wrappers.rs`): + +Each test uses a fixture helper that: creates a `tempfile::TempDir` as project root, writes minimal `.apm/config.toml` (with the wrapper under test as `agent`), writes the default workflow.toml (with the two new `outcome = "success"` annotations), creates a ticket file in the correct state, runs `git init` + initial commit (required for state and spec ops), then builds a `WrapperContext` with `apm_bin` option set to the `apm` binary found via `which apm` or passed as a test env var. + +- `mock_happy_spec_mode_transitions_to_specd` — ticket in `in_design`; after `spawn_worker(ctx); child.wait()`: assert ticket state = `specd`, all four spec section headers present in ticket file, effort = 1, risk = 1. +- `mock_happy_impl_mode_creates_commit` — ticket in `in_progress`; assert state = `implemented`; assert `git log --oneline` in worktree contains a commit with "mock" in the message. +- `mock_happy_zero_success_exits_nonzero` — custom single-state workflow (all transitions non-success); assert `child.wait().status.success() == false`; read log and assert it contains "no success-outcome transition". +- `mock_sad_transitions_to_non_success_state` — ticket in `in_design`; assert resulting state is not `specd`; assert ticket file does NOT have all four spec sections populated. +- `mock_sad_seed_reproducibility` — two fresh fixture tickets in the same state, both spawned with `APM_OPT_SEED=42`; assert both end in the same target state. +- `mock_random_seed_reproducibility` — same pattern with mock-random. +- `debug_does_not_change_state` — ticket in `in_design`; after `spawn_worker; wait`: assert state still `in_design`; read log file and assert it contains `APM_TICKET_ID`, the system prompt text, and a line matching `{"type":"tool_use"`. + ### Background constraint: workflow.toml annotation gap The default workflow's `in_design → specd` and `ammend → specd` transitions have no `completion` strategy, so `resolve_outcome` (ticket a1b94ea4) infers `"needs_input"` for them by default. That makes `mock-happy` unable to find any success transition when running in the spec-writer state — it would always exit non-zero. From 726861f570a3a36e6d5930cf9fd852318508fdb2 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:21:14 -0700 Subject: [PATCH 095/305] =?UTF-8?q?ticket(25c92daa):=20in=5Fdesign=20?= =?UTF-8?q?=E2=86=92=20specd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md index 0f4fe16ca..98e00d5da 100644 --- a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md +++ b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md @@ -1,7 +1,7 @@ +++ id = "25c92daa" title = "Mock and debug built-in wrappers (mock-happy, mock-sad, mock-random, debug)" -state = "in_design" +state = "specd" priority = 0 effort = 6 risk = 4 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/25c92daa-mock-and-debug-built-in-wrappers-mock-ha" created_at = "2026-04-30T20:04:21.901984Z" -updated_at = "2026-05-01T00:19:20.691028Z" +updated_at = "2026-05-01T00:21:14.436091Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "a1b94ea4", "6cac8518"] @@ -603,4 +603,5 @@ All tests that require `apm` CLI calls in the script must resolve `current_exe() | 2026-04-30T21:02Z | new | groomed | philippepascal | | 2026-04-30T21:57Z | groomed | in_design | philippepascal | | 2026-05-01T00:08Z | in_design | ammend | philippepascal | -| 2026-05-01T00:09Z | ammend | in_design | philippepascal | \ No newline at end of file +| 2026-05-01T00:09Z | ammend | in_design | philippepascal | +| 2026-05-01T00:21Z | in_design | specd | claude-0501-0009-cec0 | From 94443a821dd2fbcc9968a6666e79d2fc88dfebd1 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:21:33 -0700 Subject: [PATCH 096/305] =?UTF-8?q?ticket(71d80e40):=20start=20=E2=80=94?= =?UTF-8?q?=20ammend=20=E2=86=92=20in=5Fdesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index f10d683ba..75b8d097b 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -1,7 +1,7 @@ +++ id = "71d80e40" title = "apm agents subcommand: new, list, test, eject" -state = "ammend" +state = "in_design" priority = 0 effort = 0 risk = 0 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/71d80e40-apm-agents-subcommand-new-list-test-ejec" created_at = "2026-04-30T20:04:57.796154Z" -updated_at = "2026-05-01T00:08:58.914874Z" +updated_at = "2026-05-01T00:21:33.818831Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "2c32a282"] @@ -91,3 +91,4 @@ How the implementation will work. | 2026-04-30T21:02Z | new | groomed | philippepascal | | 2026-04-30T22:02Z | groomed | in_design | philippepascal | | 2026-05-01T00:08Z | in_design | ammend | philippepascal | +| 2026-05-01T00:21Z | ammend | in_design | philippepascal | From 8a1b65da6220d6f21bd9da98e728a45241453c3b Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:29:51 -0700 Subject: [PATCH 097/305] ticket(71d80e40): set section Acceptance criteria --- ...pm-agents-subcommand-new-list-test-ejec.md | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index 75b8d097b..9016bb6f2 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -64,7 +64,26 @@ Add the `apm agents` subcommand family for discovering, scaffolding, smoke-testi ### Acceptance criteria -Checkboxes; each one independently testable. +- [ ] `apm agents list` prints a row for the `claude` built-in with kind `built-in` +- [ ] `apm agents list` prints a row for each executable `wrapper.*` found under `.apm/agents//` with kind `project` +- [ ] `apm agents list` marks the agent matching the configured `workers.command` (legacy field) with a `(configured)` indicator +- [ ] `apm agents list` shows a `parser` column value read from `manifest.toml`; defaults to `canonical` when the manifest is absent or the field is unset +- [ ] `apm agents new ` creates `.apm/agents//wrapper.sh` with the execute bit set (mode `0o755` on Unix) +- [ ] `apm agents new ` creates `.apm/agents//apm.worker.md` with content copied from `.apm/apm.worker.md` or the built-in default when the project file is absent +- [ ] `apm agents new ` creates `.apm/agents//apm.spec-writer.md` with content copied from `.apm/apm.spec-writer.md` or the built-in default when the project file is absent +- [ ] `apm agents new ` creates `.apm/agents//manifest.toml` containing `contract_version = 1` and `parser = "canonical"` +- [ ] `apm agents new ` exits non-zero with a message that mentions `--force` when `.apm/agents//` already exists +- [ ] `apm agents new --force` succeeds when the directory already exists and overwrites the scaffolded files +- [ ] `apm agents new ` prints next-step guidance directing the user to edit `wrapper.sh` and run `apm agents test ` +- [ ] `apm agents test ` exits 0 and prints a pass summary when the wrapper exits 0 and emits at least one canonical JSONL line (a JSON object containing a `"type"` key) +- [ ] `apm agents test ` exits non-zero and prints a fail summary when the wrapper exits non-zero +- [ ] `apm agents test ` reports exit code, canonical JSONL event count, non-canonical log line count, stderr line count, and wall-clock milliseconds in its output +- [ ] `apm agents test ` exits non-zero with a clear error message when `` is not a known wrapper (built-in or project) +- [ ] `apm agents eject claude` creates `.apm/agents/claude/wrapper.sh` containing a bash script that invokes the `claude` CLI with `--print --output-format stream-json --verbose` +- [ ] `apm agents eject ` creates `.apm/agents//manifest.toml` with `contract_version = 1` and `parser = "canonical"` +- [ ] `apm agents eject ` sets the execute bit on the ejected `wrapper.sh` +- [ ] `apm agents eject ` exits non-zero when `.apm/agents//` already exists +- [ ] `apm agents eject ` exits non-zero with a message when `` is not a known built-in ### Out of scope @@ -91,4 +110,4 @@ How the implementation will work. | 2026-04-30T21:02Z | new | groomed | philippepascal | | 2026-04-30T22:02Z | groomed | in_design | philippepascal | | 2026-05-01T00:08Z | in_design | ammend | philippepascal | -| 2026-05-01T00:21Z | ammend | in_design | philippepascal | +| 2026-05-01T00:21Z | ammend | in_design | philippepascal | \ No newline at end of file From b54be01e807bd702daacc74c8abfc0ba55d56d9a Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:29:55 -0700 Subject: [PATCH 098/305] ticket(71d80e40): set section Out of scope --- .../71d80e40-apm-agents-subcommand-new-list-test-ejec.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index 9016bb6f2..0409cf2d5 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -87,7 +87,14 @@ Add the `apm agents` subcommand family for discovering, scaffolding, smoke-testi ### Out of scope -Explicit list of what this ticket does not cover. +- Per-agent instruction file resolution (`.apm/agents//apm.worker.md` etc.) — ticket 7f5f73d5; `apm agents new` writes these files as scaffold aids but their resolution order is not wired here +- `apm agents install` and `apm agents remove` subcommands +- Wrapper-contract version checking inside `apm agents test` — ticket 2e772eab +- Mock built-in wrappers (`mock-happy`, `mock-sad`, `mock-random`, `debug`) appearing in `apm agents list` — ticket 25c92daa must land first; once it does they appear automatically via the built-in registry without changes here +- The previous `apm agents` (no subcommand) behaviour of printing the instructions file — this ticket replaces it entirely +- Running `apm agents test` against the real `claude` CLI in automated tests — fixtures use small shell scripts +- Config-driven active-profile column once ticket 6cac8518 lands — pre-6cac8518 the marker uses `workers.command`; `list_wrappers` includes a TODO comment for the post-6cac8518 switch to `workers.agent` and per-profile iteration +- Windows execute-bit semantics (same limitation as ticket 2c32a282; any `wrapper.*` file is treated as executable on non-Unix platforms) ### Approach From 3f5143bc5b2ab1f43dd792207c9a093bc6e04954 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:30:00 -0700 Subject: [PATCH 099/305] ticket(71d80e40): set section Approach --- ...pm-agents-subcommand-new-list-test-ejec.md | 169 +++++++++++++++++- 1 file changed, 168 insertions(+), 1 deletion(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index 0409cf2d5..603d6726e 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -98,7 +98,174 @@ Add the `apm agents` subcommand family for discovering, scaffolding, smoke-testi ### Approach -How the implementation will work. +**Prereqs:** d3b93b95 (`Wrapper` trait, `WrapperContext`, `resolve_builtin`, `ClaudeWrapper`) and 2c32a282 (`resolve_wrapper`, `WrapperKind`, `find_script`, `parse_manifest`, `CustomWrapper`) must be merged into the epic branch before this ticket is implemented. All API references below assume those tickets' final shapes. + +--- + +**`apm-core/src/wrapper/mod.rs` — add `list_builtin_names`** + +Add one new public function returning the static list of registered built-in names: + +```rust +pub fn list_builtin_names() -> &'static [&'static str] { + &["claude"] +} +``` + +When ticket 25c92daa lands it expands the list; no other change needed here. + +--- + +**`apm-core/src/agents.rs` — new module** + +Register with `pub mod agents;` in `apm-core/src/lib.rs`. + +Public types: + +```rust +pub struct WrapperEntry { + pub name: String, + pub kind: WrapperKind, // re-exported from wrapper::WrapperKind (2c32a282) + pub parser: String, // "canonical" or value from manifest.toml + pub configured_as: Vec, // e.g. ["(default)"] or ["spec_agent"] +} + +pub struct TestReport { + pub exit_code: i32, + pub canonical_events: usize, + pub non_canonical_lines: usize, + pub stderr_lines: usize, + pub wall_millis: u64, + pub passed: bool, // exit 0 && canonical_events >= 1 +} +``` + +**`list_wrappers(root: &Path, config: &Config) -> anyhow::Result>`** + +1. Built-in entries: for each name in `wrapper::list_builtin_names()`, create a `WrapperEntry` with `kind: WrapperKind::Builtin(name.to_owned())`, `parser: "canonical"`, `configured_as: vec![]`. +2. Project entries: read `root/.apm/agents/` (skip if absent or unreadable). For each subdirectory `entry_name`, call `wrapper::resolve_wrapper(root, entry_name)?`. If `Ok(Some(WrapperKind::Custom ..))`, add a project entry. Set `parser` from `wrapper::parse_manifest(root, entry_name)` — use manifest `parser` field, default to `"canonical"`. +3. Configured marker: read `config.workers.command` (legacy, defaults to `"claude"`). For the entry whose `name == config.workers.command`, push `"(default)"` to `configured_as`. (TODO post-6cac8518: switch to `config.workers.agent` and iterate `config.worker_profiles` for per-profile markers.) +4. Sort: built-ins first (in `list_builtin_names` order), then project wrappers alphabetically. + +**`scaffold_wrapper(root: &Path, name: &str, force: bool) -> anyhow::Result<()>`** + +1. `let dir = root.join(".apm/agents").join(name);` +2. If `dir.exists() && !force`: bail with `.apm/agents/{name}/ already exists; use --force to overwrite` +3. `fs::create_dir_all(&dir)?` +4. Write `dir/wrapper.sh` using the `WRAPPER_TEMPLATE` constant (see below). On Unix, set permissions mode `0o755` via `std::os::unix::fs::PermissionsExt::from_mode`. +5. Write `dir/manifest.toml`: `[wrapper]\ncontract_version = 1\nparser = "canonical"\n` +6. For `apm.worker.md`: try `fs::read_to_string(root.join(".apm/apm.worker.md"))`; if absent, use the same default string that `apm init` writes (locate the constant in `apm_core::init`). Write to `dir/apm.worker.md`. +7. Same for `apm.spec-writer.md`. +8. `Ok(())` + +**`WRAPPER_TEMPLATE` constant** (define as `const WRAPPER_TEMPLATE: &str` in `agents.rs`): + +A bash script with: +- `#!/usr/bin/env bash` shebang +- Inline comments documenting each `APM_*` env var, the stdout/stderr/exit-code contract +- `set -euo pipefail` +- `env | grep '^APM_' >&2 || true` to dump APM vars to stderr on startup +- `SYSTEM_PROMPT="$(cat "$APM_SYSTEM_PROMPT_FILE")"` and `USER_MESSAGE="$(cat "$APM_USER_MESSAGE_FILE")"` +- A `printf` emitting one minimal valid JSONL line (`{"type":"text","text":"wrapper skeleton — replace with real invocation"}`) so `apm agents test` counts at least one canonical event +- TODO comment to replace the printf with a real agent invocation +- TODO comment to call `apm state "$APM_TICKET_ID" ` +- `exit 0` + +**`test_wrapper(root: &Path, name: &str) -> anyhow::Result`** + +1. Call `wrapper::resolve_wrapper(root, name)?`; if `Ok(None)`: bail with agent-not-found message. +2. Create a temp dir (use `std::env::temp_dir()` joined with a `rand_u16()` suffix, the same helper used in `start.rs`). Write `system.txt` → `"You are a test agent."`, `message.txt` → `"Test run — apm agents test."`. Set `log_path = tmpdir/wrapper.log`. +3. Build `WrapperContext` (from d3b93b95): `worker_name = "agents-test"`, `ticket_id = "00000000"`, `ticket_branch = "test/agents-test"`, `worktree_path = tmpdir`, file paths as above, `skip_permissions = false`, `profile = "test"`, rest at defaults/empty. +4. Spawn via the resolved `WrapperKind`: `Custom { script_path, manifest }` → `CustomWrapper::spawn(&ctx)?`; `Builtin(n)` → `resolve_builtin(&n).expect("registered").spawn(&ctx)?`. +5. Record start instant, call `child.wait()`, compute `wall_millis`. +6. Read log file (stdout+stderr interleaved per Wrapper contract; treat missing file as empty). Classify each non-empty line: valid JSON object containing a `"type"` key → `canonical_events += 1`; line starting with `APM_` (env-dump from skeleton) → `stderr_lines += 1`; everything else → `non_canonical_lines += 1`. +7. `passed = status.success() && canonical_events >= 1`. Return `TestReport`. +8. Remove tmpdir on return (best-effort, ignore errors). + +Line classification is heuristic because stdout and stderr share the log file. This is acceptable for a smoke test. + +**`eject_wrapper(root: &Path, name: &str) -> anyhow::Result<()>`** + +1. If `wrapper::resolve_builtin(name).is_none()`: bail with `'' is not a known built-in; run apm agents list to see available wrappers`. +2. `let dir = root.join(".apm/agents").join(name);` +3. If `dir.exists()`: bail with `.apm/agents/{name}/ already exists; delete it first to eject again`. +4. `fs::create_dir_all(&dir)?` +5. Match `name`: `"claude"` → write `CLAUDE_EJECT_SCRIPT` constant; other built-in names from 25c92daa → add cases as those land; default arm bails with "eject not yet implemented for built-in NAME". +6. Set mode `0o755` on the script file. +7. Write `dir/manifest.toml` (same content as `scaffold_wrapper`). +8. `Ok(())` + +**`CLAUDE_EJECT_SCRIPT` constant** (define as `const CLAUDE_EJECT_SCRIPT: &str` in `agents.rs`): + +A bash script with: +- `#!/usr/bin/env bash` shebang, header comment `Ejected from APM built-in: claude` +- `set -euo pipefail` +- Builds `ARGS=(--print --output-format stream-json --verbose)` +- Appends `--system-prompt "$(cat "$APM_SYSTEM_PROMPT_FILE")"` to ARGS +- Conditionally appends `--model "$APM_OPT_MODEL"` when `APM_OPT_MODEL` is non-empty +- Conditionally appends `--dangerously-skip-permissions` when `APM_SKIP_PERMISSIONS = "1"` +- Ends with `exec claude "${ARGS[@]}" "$(cat "$APM_USER_MESSAGE_FILE")"` + +--- + +**`apm/src/cmd/agents.rs` — replace with subcommand handlers** + +Delete existing `run(root)`. Add four functions: + +`run_list(root: &Path) -> Result<()>`: load config, call `apm_core::agents::list_wrappers(root, &config)?`, print a column-aligned table using `println!` (no external crate): +``` +NAME KIND PARSER STATUS +claude built-in canonical (configured) +my-wrapper project canonical +``` + +`run_new(root: &Path, name: &str, force: bool) -> Result<()>`: call `apm_core::agents::scaffold_wrapper(root, name, force)?`, then print the list of created files and next-step guidance directing the user to edit `wrapper.sh` and run `apm agents test `. + +`run_test(root: &Path, name: &str) -> Result<()>`: call `apm_core::agents::test_wrapper(root, name)?`. Print one-line summary: `PASS exit=0 events=N non-canonical=0 stderr=N wall=Nms`. On fail, print `FAIL ...` and call `anyhow::bail!` to produce non-zero exit. + +`run_eject(root: &Path, name: &str) -> Result<()>`: call `apm_core::agents::eject_wrapper(root, name)?`, then print path of ejected script and guidance to run `apm agents test `. + +--- + +**`apm/src/main.rs` — wire subcommands** + +Add `AgentsCommand` enum (four variants: `List`, `New { name: String, #[arg(long)] force: bool }`, `Test { name: String }`, `Eject { name: String }`). + +Change `Command::Agents` from unit variant to `Agents { #[command(subcommand)] command: AgentsCommand }`. + +Update the match arm to dispatch to the four `cmd::agents::run_*` functions. + +Update help text from `"agents Print agent instructions"` to `"agents Manage agent wrappers (list, new, test, eject)"`. + +Update `hash_trip::is_read_only_command` if `Agents` is listed: `List` and `Test` are read-only; `New` and `Eject` are mutating. + +--- + +**Tests — `apm-core/tests/agents_integration.rs`** + +- `list_shows_builtin_claude` — no `.apm/agents/` dir; assert `list_wrappers` returns entry with `name == "claude"` and `WrapperKind::Builtin(_)` +- `list_shows_project_wrapper` — create `root/.apm/agents/my-wrapper/wrapper.sh` mode `0o755`; assert `list_wrappers` returns a `WrapperKind::Custom` entry for `"my-wrapper"` +- `scaffold_creates_all_files` — call `scaffold_wrapper(root, "test-wrap", false)`; assert all four files exist; assert `wrapper.sh` permissions `& 0o111 != 0` +- `scaffold_refuses_existing_dir` — call twice without force; assert second call is `Err` with message containing `"--force"` +- `scaffold_force_overwrites` — call twice with `force = false` then `force = true`; assert second call is `Ok` +- `test_passes_for_good_script` — write `.apm/agents/test-ok/wrapper.sh` (mode `0o755`) emitting one valid JSONL line and exiting 0; assert `report.passed == true` and `report.canonical_events >= 1` +- `test_fails_for_nonzero_exit` — write `.apm/agents/test-fail/wrapper.sh` (mode `0o755`) exiting 1; assert `report.passed == false` and `report.exit_code == 1` +- `eject_claude_creates_script` — call `eject_wrapper(root, "claude")`; assert `.apm/agents/claude/wrapper.sh` exists and contains both `"claude"` and `"output-format"` +- `eject_refuses_existing_dir` — pre-create `.apm/agents/claude/`; assert `eject_wrapper` returns `Err` +- `eject_unknown_builtin_returns_error` — call `eject_wrapper(root, "not-a-builtin")`; assert `Err` message contains `"not a known built-in"` + +--- + +**File change summary** + +| File | Change | +|---|---| +| `apm-core/src/lib.rs` | Add `pub mod agents;` | +| `apm-core/src/wrapper/mod.rs` | Add `pub fn list_builtin_names()` | +| `apm-core/src/agents.rs` | New: `WrapperEntry`, `TestReport`, all four functions, template constants | +| `apm/src/cmd/agents.rs` | Replace with `run_list`, `run_new`, `run_test`, `run_eject` | +| `apm/src/main.rs` | Add `AgentsCommand`; change `Command::Agents` to subcommand; wire dispatch; update help text; update read-only list | +| `apm-core/tests/agents_integration.rs` | New: 10 integration tests | ### Open questions From 52520d2dcc6b9aa0b8848e6a30659e3cd9154c5b Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:30:05 -0700 Subject: [PATCH 100/305] ticket(71d80e40): set effort = 5 --- tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index 603d6726e..be966acc4 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -3,13 +3,13 @@ id = "71d80e40" title = "apm agents subcommand: new, list, test, eject" state = "in_design" priority = 0 -effort = 0 +effort = 5 risk = 0 author = "philippepascal" owner = "philippepascal" branch = "ticket/71d80e40-apm-agents-subcommand-new-list-test-ejec" created_at = "2026-04-30T20:04:57.796154Z" -updated_at = "2026-05-01T00:21:33.818831Z" +updated_at = "2026-05-01T00:30:05.697602Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "2c32a282"] From 58b535ee38e8f58fa853105f785c447523c29a32 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:30:08 -0700 Subject: [PATCH 101/305] ticket(71d80e40): set risk = 3 --- tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index be966acc4..5481d7de5 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -4,12 +4,12 @@ title = "apm agents subcommand: new, list, test, eject" state = "in_design" priority = 0 effort = 5 -risk = 0 +risk = 3 author = "philippepascal" owner = "philippepascal" branch = "ticket/71d80e40-apm-agents-subcommand-new-list-test-ejec" created_at = "2026-04-30T20:04:57.796154Z" -updated_at = "2026-05-01T00:30:05.697602Z" +updated_at = "2026-05-01T00:30:08.550575Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "2c32a282"] From 7399958be06f0675f56398dbc1d723adb06e37db Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:30:13 -0700 Subject: [PATCH 102/305] =?UTF-8?q?ticket(71d80e40):=20in=5Fdesign=20?= =?UTF-8?q?=E2=86=92=20specd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../71d80e40-apm-agents-subcommand-new-list-test-ejec.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index 5481d7de5..16eebd182 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -1,7 +1,7 @@ +++ id = "71d80e40" title = "apm agents subcommand: new, list, test, eject" -state = "in_design" +state = "specd" priority = 0 effort = 5 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/71d80e40-apm-agents-subcommand-new-list-test-ejec" created_at = "2026-04-30T20:04:57.796154Z" -updated_at = "2026-05-01T00:30:08.550575Z" +updated_at = "2026-05-01T00:30:13.423872Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "2c32a282"] @@ -284,4 +284,5 @@ Update `hash_trip::is_read_only_command` if `Agents` is listed: `List` and `Test | 2026-04-30T21:02Z | new | groomed | philippepascal | | 2026-04-30T22:02Z | groomed | in_design | philippepascal | | 2026-05-01T00:08Z | in_design | ammend | philippepascal | -| 2026-05-01T00:21Z | ammend | in_design | philippepascal | \ No newline at end of file +| 2026-05-01T00:21Z | ammend | in_design | philippepascal | +| 2026-05-01T00:30Z | in_design | specd | claude-0501-0021-fd28 | From 79da319cc40ffcfd7bd60adfbbb9293349d7379f Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:30:28 -0700 Subject: [PATCH 103/305] =?UTF-8?q?ticket(2e772eab):=20start=20=E2=80=94?= =?UTF-8?q?=20ammend=20=E2=86=92=20in=5Fdesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md b/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md index d84e20b57..66bbdc3da 100644 --- a/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md +++ b/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md @@ -1,7 +1,7 @@ +++ id = "2e772eab" title = "Wrapper-contract versioning (APM_WRAPPER_VERSION + manifest.toml)" -state = "ammend" +state = "in_design" priority = 0 effort = 0 risk = 0 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/2e772eab-wrapper-contract-versioning-apm-wrapper-" created_at = "2026-04-30T20:05:11.077339Z" -updated_at = "2026-05-01T00:09:29.085929Z" +updated_at = "2026-05-01T00:30:28.342775Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "2c32a282"] @@ -76,3 +76,4 @@ How the implementation will work. | 2026-04-30T21:02Z | new | groomed | philippepascal | | 2026-04-30T22:03Z | groomed | in_design | philippepascal | | 2026-05-01T00:09Z | in_design | ammend | philippepascal | +| 2026-05-01T00:30Z | ammend | in_design | philippepascal | From 1153b6936512a42a26bc3c12ab85a296a25bf3b9 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:33:22 -0700 Subject: [PATCH 104/305] ticket(2e772eab): set section Acceptance criteria --- ...72eab-wrapper-contract-versioning-apm-wrapper-.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md b/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md index 66bbdc3da..03b49c054 100644 --- a/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md +++ b/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md @@ -49,7 +49,15 @@ Add wrapper-contract versioning so future contract changes (new env vars, new ou ### Acceptance criteria -Checkboxes; each one independently testable. +- [ ] `pub const CONTRACT_VERSION: u32 = 1` is defined in `apm_core::wrapper` and accessible from outside the module +- [ ] `APM_WRAPPER_VERSION` env var is set to `CONTRACT_VERSION.to_string()` (not a hardcoded `"1"`) in both `ClaudeWrapper::spawn` and `CustomWrapper::spawn` +- [ ] Spawning a custom wrapper whose manifest declares `contract_version = 1` (equal to `CONTRACT_VERSION`) succeeds and no version-warning line is written to the worker log +- [ ] Spawning a custom wrapper with no manifest present defaults to `contract_version = 1`, spawn succeeds, no warning written +- [ ] Spawning a custom wrapper whose manifest declares `contract_version > CONTRACT_VERSION` returns `Err` and does not produce a child process +- [ ] The error for `contract_version > CONTRACT_VERSION` includes the wrapper name, the declared version number, the APM max-supported version, and the string `"upgrade APM"` +- [ ] Spawning a custom wrapper whose manifest declares `contract_version < CONTRACT_VERSION` succeeds (returns `Ok(child)`, no error) +- [ ] When declared version is less than `CONTRACT_VERSION`, a warning line is appended to the worker log file before spawn proceeds +- [ ] The version-comparison logic is extracted into a private helper `check_contract_version(declared: u32, apm_version: u32, log_path: &Path)` so the older-version warning path can be exercised in a unit test without modifying the compile-time constant ### Out of scope @@ -76,4 +84,4 @@ How the implementation will work. | 2026-04-30T21:02Z | new | groomed | philippepascal | | 2026-04-30T22:03Z | groomed | in_design | philippepascal | | 2026-05-01T00:09Z | in_design | ammend | philippepascal | -| 2026-05-01T00:30Z | ammend | in_design | philippepascal | +| 2026-05-01T00:30Z | ammend | in_design | philippepascal | \ No newline at end of file From b3c8c4954aaa493dfaf2da6724d00b4f27b5d38a Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:33:26 -0700 Subject: [PATCH 105/305] ticket(2e772eab): set section Out of scope --- .../2e772eab-wrapper-contract-versioning-apm-wrapper-.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md b/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md index 03b49c054..a414a8d21 100644 --- a/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md +++ b/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md @@ -61,7 +61,12 @@ Add wrapper-contract versioning so future contract changes (new env vars, new ou ### Out of scope -Explicit list of what this ticket does not cover. +- Defining what changes to the contract constitute a version bump — documented at the time of the bump, not here +- Backporting compatibility shims for wrappers targeting future contract versions +- A wrapper-version registry or cross-ecosystem compatibility matrix +- `apm validate` output for version mismatches — that check is already part of ticket 2c32a282's `validate_agents` helper +- Surfacing `CONTRACT_VERSION` in any CLI output (`apm version`, `apm validate --verbose`, etc.) +- Version-checking built-in wrappers — they always target the current build's version by definition and carry no manifest ### Approach From 64ab868dd1899404a27c2a0c2d907aa338bee54c Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:33:31 -0700 Subject: [PATCH 106/305] ticket(2e772eab): set section Approach --- ...rapper-contract-versioning-apm-wrapper-.md | 90 ++++++++++++++++++- 1 file changed, 89 insertions(+), 1 deletion(-) diff --git a/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md b/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md index a414a8d21..5a969e696 100644 --- a/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md +++ b/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md @@ -70,7 +70,95 @@ Add wrapper-contract versioning so future contract changes (new env vars, new ou ### Approach -How the implementation will work. +**Files changed:** `apm-core/src/wrapper/mod.rs`, `apm-core/src/wrapper/claude.rs`, `apm-core/src/wrapper/custom.rs` + +--- + +**1. Define the constant — `wrapper/mod.rs`** + +Add at module scope (after imports, before the trait definition): + +```rust +pub const CONTRACT_VERSION: u32 = 1; +``` + +This is the single source of truth. Every comparison in the spawn path and every `APM_WRAPPER_VERSION` env var value derives from it. + +--- + +**2. Use the constant in `ClaudeWrapper` — `wrapper/claude.rs`** + +Add `use super::CONTRACT_VERSION;`. + +In `ClaudeWrapper::spawn`, replace the hardcoded string literal `"1"` for the `APM_WRAPPER_VERSION` env key with `CONTRACT_VERSION.to_string()`. Both the local-path and container-path branches need this change. + +--- + +**3. Extend `CustomWrapper` — `wrapper/custom.rs`** + +Add `use super::CONTRACT_VERSION;`. + +**Extract a private helper** (keeps the version logic independently testable): + +```rust +fn check_contract_version( + declared: u32, + apm_version: u32, + log_path: &Path, +) -> anyhow::Result<()> { + match declared.cmp(&apm_version) { + std::cmp::Ordering::Greater => anyhow::bail!( + "wrapper targets contract version {} but this APM build supports up to \ + version {}; upgrade APM", + declared, + apm_version, + ), + std::cmp::Ordering::Less => { + // Non-fatal: append a warning to the log and continue. + if let Ok(mut f) = std::fs::OpenOptions::new() + .append(true) + .create(true) + .open(log_path) + { + let _ = writeln!( + f, + "[apm] warning: wrapper targets contract version {} but this APM \ + build is version {}; the wrapper may not use newer env vars", + declared, apm_version, + ); + } + } + std::cmp::Ordering::Equal => {} + } + Ok(()) +} +``` + +**Update `CustomWrapper::spawn`:** + +- Derive `declared`: `let declared = self.manifest.as_ref().map_or(1, |m| m.contract_version);` +- Replace the existing `if contract_version > 1 { bail!(...) }` block introduced by 2c32a282 with a single call: `check_contract_version(declared, CONTRACT_VERSION, &ctx.log_path)?;` +- Replace the hardcoded `"1"` for `APM_WRAPPER_VERSION` with `CONTRACT_VERSION.to_string()`. + +The helper subsumes the old bail — no net behaviour change for the `> 1` case, and it adds the new `< CONTRACT_VERSION` warning path. + +--- + +**4. Tests — `wrapper/custom.rs` under `#[cfg(test)]`** + +Four unit tests against the helper (no subprocess needed): + +- `check_version_equal` — `check_contract_version(1, 1, &log)` returns `Ok(())`, log is empty. +- `check_version_older_writes_warning` — `check_contract_version(1, 2, &log)` returns `Ok(())`, log file contains the word `"warning"` and both version numbers. +- `check_version_too_high_returns_err` — `check_contract_version(2, 1, &log)` returns `Err`, error string contains `"upgrade APM"`, `"2"`, and `"1"`. +- `check_version_no_manifest_defaults_to_1` — set `declared = None::.map_or(1, |m| m.contract_version)`, assert it equals 1 (documents the default assumption without spawning). + +Two integration tests using a real subprocess (same fixture pattern as 2c32a282's `integration_echo_test_wrapper`): + +- `spawn_matching_contract_succeeds` — fixture wrapper with `manifest.toml` declaring `contract_version = 1`; assert spawn returns `Ok`, exit code 0, log contains no `"warning"` line. +- `spawn_future_contract_rejected` — fixture wrapper with `manifest.toml` declaring `contract_version = 2`; assert `CustomWrapper::spawn` returns `Err` before a child is created. + +The `check_version_older_writes_warning` unit test covers the older-version path by calling the helper directly with `apm_version = 2`; no integration test is needed for this branch since `CONTRACT_VERSION = 1` makes it currently unreachable in production. ### Open questions From 4fdf0c5bc50b6b621a98d1ce3dad9758bc4b0373 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:33:36 -0700 Subject: [PATCH 107/305] ticket(2e772eab): set effort = 2 --- tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md b/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md index 5a969e696..eb2799040 100644 --- a/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md +++ b/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md @@ -3,13 +3,13 @@ id = "2e772eab" title = "Wrapper-contract versioning (APM_WRAPPER_VERSION + manifest.toml)" state = "in_design" priority = 0 -effort = 0 +effort = 2 risk = 0 author = "philippepascal" owner = "philippepascal" branch = "ticket/2e772eab-wrapper-contract-versioning-apm-wrapper-" created_at = "2026-04-30T20:05:11.077339Z" -updated_at = "2026-05-01T00:30:28.342775Z" +updated_at = "2026-05-01T00:33:36.603222Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "2c32a282"] From dbebb09b0f986b74331d78c9f65dcc6dd66fc08d Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:33:43 -0700 Subject: [PATCH 108/305] ticket(2e772eab): set risk = 1 --- tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md b/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md index eb2799040..eb77dcfe6 100644 --- a/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md +++ b/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md @@ -4,12 +4,12 @@ title = "Wrapper-contract versioning (APM_WRAPPER_VERSION + manifest.toml)" state = "in_design" priority = 0 effort = 2 -risk = 0 +risk = 1 author = "philippepascal" owner = "philippepascal" branch = "ticket/2e772eab-wrapper-contract-versioning-apm-wrapper-" created_at = "2026-04-30T20:05:11.077339Z" -updated_at = "2026-05-01T00:33:36.603222Z" +updated_at = "2026-05-01T00:33:43.481767Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "2c32a282"] From 58e9684600f7b7e4ba021a4220eded17a8009fee Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:33:48 -0700 Subject: [PATCH 109/305] =?UTF-8?q?ticket(2e772eab):=20in=5Fdesign=20?= =?UTF-8?q?=E2=86=92=20specd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2e772eab-wrapper-contract-versioning-apm-wrapper-.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md b/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md index eb77dcfe6..beaa85b34 100644 --- a/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md +++ b/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md @@ -1,7 +1,7 @@ +++ id = "2e772eab" title = "Wrapper-contract versioning (APM_WRAPPER_VERSION + manifest.toml)" -state = "in_design" +state = "specd" priority = 0 effort = 2 risk = 1 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/2e772eab-wrapper-contract-versioning-apm-wrapper-" created_at = "2026-04-30T20:05:11.077339Z" -updated_at = "2026-05-01T00:33:43.481767Z" +updated_at = "2026-05-01T00:33:48.510665Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "2c32a282"] @@ -177,4 +177,5 @@ The `check_version_older_writes_warning` unit test covers the older-version path | 2026-04-30T21:02Z | new | groomed | philippepascal | | 2026-04-30T22:03Z | groomed | in_design | philippepascal | | 2026-05-01T00:09Z | in_design | ammend | philippepascal | -| 2026-05-01T00:30Z | ammend | in_design | philippepascal | \ No newline at end of file +| 2026-05-01T00:30Z | ammend | in_design | philippepascal | +| 2026-05-01T00:33Z | in_design | specd | claude-0501-0030-e588 | From 6717b808fca4171e5402f7744244255c0bc390cd Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:34:05 -0700 Subject: [PATCH 110/305] =?UTF-8?q?ticket(2803bf07):=20start=20=E2=80=94?= =?UTF-8?q?=20groomed=20=E2=86=92=20in=5Fdesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/2803bf07-output-parser-strategy-external-parsers-.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md index 8b9957151..e6f3ce8ef 100644 --- a/tickets/2803bf07-output-parser-strategy-external-parsers-.md +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -1,7 +1,7 @@ +++ id = "2803bf07" title = "Output parser strategy: external parsers via manifest.toml" -state = "groomed" +state = "in_design" priority = 0 effort = 0 risk = 0 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/2803bf07-output-parser-strategy-external-parsers-" created_at = "2026-04-30T20:05:40.844536Z" -updated_at = "2026-04-30T21:02:46.987871Z" +updated_at = "2026-05-01T00:34:05.191003Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["2c32a282", "2e772eab"] @@ -71,3 +71,4 @@ How the implementation will work. |------|------|----|----| | 2026-04-30T20:05Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | +| 2026-05-01T00:34Z | groomed | in_design | philippepascal | From 80e55084f058534756e5fb61992ea293d342038d Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:41:26 -0700 Subject: [PATCH 111/305] ticket(2803bf07): set section Acceptance criteria --- ...f07-output-parser-strategy-external-parsers-.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md index e6f3ce8ef..0b732674e 100644 --- a/tickets/2803bf07-output-parser-strategy-external-parsers-.md +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -46,7 +46,17 @@ Support agents whose output is too far from APM's canonical JSONL stream-json to ### Acceptance criteria -Checkboxes; each one independently testable. +- [ ] When `parser` is absent from manifest.toml, or manifest.toml itself is absent, `CustomWrapper::spawn` captures the wrapper's stdout directly to the log file (equivalent to `parser = "canonical"`) +- [ ] When `parser = "canonical"`, `CustomWrapper::spawn` captures the wrapper's stdout directly to the log file with no transformation; the wrapper's stderr is also written to the same log file +- [ ] When `parser = "raw"`, `CustomWrapper::spawn` captures the wrapper's stdout verbatim to the log file; a wrapper emitting `"hello world\n"` produces a log whose content includes that text with no `[apm] warning:` lines injected by APM +- [ ] When `parser = "external"` and `parser_command` resolves to an executable binary, `CustomWrapper::spawn` creates an OS-level pipe: the wrapper's stdout is the parser's stdin; the parser's stdout is captured to the log file +- [ ] When `parser = "external"`, the wrapper's stderr is written to the log file independently (not through the parser pipe) +- [ ] When `parser = "external"`, the parser's stderr is also written to the log file +- [ ] When `parser = "external"` and `parser_command` is absent from manifest.toml, `CustomWrapper::spawn` returns `Err` before spawning any process; the error message names the manifest file path and states that `parser_command` is required +- [ ] When `parser = "external"` and `parser_command` names a binary not found in PATH and is not an absolute path to an existing file, `CustomWrapper::spawn` returns `Err` before spawning any process; the error message names the missing binary +- [ ] `apm validate` reports an error when a custom wrapper's manifest.toml declares `parser = "external"` but `parser_command` is absent +- [ ] Built-in wrappers (e.g. the `claude` built-in) always behave as `parser = "canonical"` regardless of any manifest file; no manifest is required or consulted for them +- [ ] `CustomWrapper::spawn` for external mode returns the parser's `Child` handle; the wrapper child is reaped in a background thread so it does not become a zombie ### Out of scope @@ -71,4 +81,4 @@ How the implementation will work. |------|------|----|----| | 2026-04-30T20:05Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | -| 2026-05-01T00:34Z | groomed | in_design | philippepascal | +| 2026-05-01T00:34Z | groomed | in_design | philippepascal | \ No newline at end of file From ca9d2883c6af28ab4699d7136a74678108fb2e63 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:41:30 -0700 Subject: [PATCH 112/305] ticket(2803bf07): set section Out of scope --- ...803bf07-output-parser-strategy-external-parsers-.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md index 0b732674e..48bce46a0 100644 --- a/tickets/2803bf07-output-parser-strategy-external-parsers-.md +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -60,7 +60,15 @@ Support agents whose output is too far from APM's canonical JSONL stream-json to ### Out of scope -Explicit list of what this ticket does not cover. +- Shipping external parser binaries (e.g. `apm-output-parser-aider`, `apm-output-parser-codex`) — those are separate cargo crates distributed and installed independently +- Defining the canonical JSONL event vocabulary — already noted as an open question in `docs/agent-wrappers.md`; any parser binary this ticket tests against must emit valid JSONL but the schema is not defined here +- Multiplexing more than one parser per wrapper; manifest.toml accepts exactly one `parser` value and one optional `parser_command` +- In-wrapper translation (`parser = "canonical"` where the wrapper itself transforms output inline) — that is wrapper-author responsibility; APM does not assist with it +- The `apm agents test ` command for smoke-testing parser compliance — ticket 71d80e40 +- Per-ticket parser override via frontmatter `agent_overrides` — ticket 0ca3e019 +- Propagating the wrapper child's exit code to APM when both wrapper and parser run; the parser child's exit code is the effective worker exit code for this ticket +- Non-Unix platform differences in subprocess piping (Windows `Stdio::from(ChildStdout)` semantics) — out of scope for now; the implementation targets Unix +- Updating `docs/agent-wrappers.md` to document the `raw` parser mode — should be a follow-up to this ticket once the behaviour is validated ### Approach From 2341e1df241173c2bc2b8608babeb2b7be0a66f3 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:41:35 -0700 Subject: [PATCH 113/305] ticket(2803bf07): set section Approach --- ...utput-parser-strategy-external-parsers-.md | 106 +++++++++++++++++- 1 file changed, 105 insertions(+), 1 deletion(-) diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md index 48bce46a0..0846c9f52 100644 --- a/tickets/2803bf07-output-parser-strategy-external-parsers-.md +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -72,7 +72,111 @@ Support agents whose output is too far from APM's canonical JSONL stream-json to ### Approach -How the implementation will work. +### Files changed + +| File | Change | +|---|---| +| `apm-core/src/wrapper/custom.rs` | Add `ParserStrategy` enum; refactor `CustomWrapper::spawn` to dispatch by strategy; implement OS-level pipe for `external` mode | +| `apm-core/src/validate.rs` | Extend `validate_agents` to push an error when `parser = "external"` and `parser_command` is absent | +| `apm-core/tests/custom_wrapper_integration.rs` | Add three integration tests covering canonical, raw, and external modes | + +--- + +### `wrapper/custom.rs` — `ParserStrategy` enum + +Add a private enum above `CustomWrapper`: + +```rust +#[derive(Debug, Clone, PartialEq)] +enum ParserStrategy { Canonical, Raw, External } + +impl ParserStrategy { + fn from_manifest(m: Option<&Manifest>) -> Self { + match m.and_then(|m| Some(m.parser.as_str())) { + Some("external") => Self::External, + Some("raw") => Self::Raw, + _ => Self::Canonical, // absent, "canonical", or unknown value + } + } +} +``` + +--- + +### `wrapper/custom.rs` — refactor `CustomWrapper::spawn` + +After the existing `check_contract_version(...)` call and the env-var block (both established by prior tickets), derive the strategy and branch: + +```rust +let strategy = ParserStrategy::from_manifest(self.manifest.as_ref()); +``` + +**Canonical / Raw (stdout -> log directly — identical spawn path):** + +Keep the existing spawn sequence unchanged for these two modes: +- `File::create(&ctx.log_path)?` → `log_file`; `log_file.try_clone()?` → `log_clone` +- `Command::new(&self.script_path).envs(...).current_dir(...).stdout(log_file).stderr(log_clone).process_group(0).spawn()` + +The distinction between `canonical` and `raw` is a concern for the orchestration layer (whether to JSONL-parse the log for event streaming); at spawn time the subprocess setup is identical. APM detects `raw` mode by reading the manifest's `parser` field after spawn. + +**External — validate, then pipe:** + +1. Derive the manifest path for error messages: `self.script_path.parent().unwrap().join("manifest.toml")`. + +2. Require `parser_command`: call `.ok_or_else(|| anyhow!("...: parser = \"external\" but parser_command is not set"))` on `self.manifest.as_ref().and_then(|m| m.parser_command.as_deref())`. Return `Err` immediately if absent — no subprocess is started. + +3. Validate the binary is findable before spawning the wrapper. Use `which::which(parser_cmd)` (see dependency note below). Return `Err` naming the missing binary if not found. Again, no subprocess is started yet. + +4. Spawn the wrapper with `stdout(Stdio::piped())` and `stderr(log_clone)` (wrapper stderr goes directly to the log). Call `.process_group(0).spawn()?`. Take `wrapper_child.stdout.take()`. + +5. Spawn the parser: `stdin(Stdio::from(wrapper_stdout))`, `stdout(parser_log_out)`, `stderr(parser_log_err)`, both log clones from `File::create(&ctx.log_path)?`. Call `.process_group(0).spawn()?`. + +6. Reap the wrapper in a background thread: `std::thread::spawn(move || { let _ = wrapper_child.wait(); });`. + +7. Return `Ok(parser_child)`. APM monitors the parser child for exit. + +**Dependency note:** Add `which = "6"` to `apm-core/Cargo.toml` if not already present. As a fallback without the crate: walk `std::env::var("PATH")` entries and check `Path::new(entry).join(parser_cmd).is_file()` for relative names; accept the path as-is when `parser_cmd` starts with `/`. + +--- + +### `validate.rs` — extend `validate_agents` + +In the `Ok(Some(WrapperKind::Custom { manifest, .. }))` match arm, after existing manifest checks, add: + +```rust +if let Some(m) = &manifest { + if m.parser == "external" && m.parser_command.is_none() { + errors.push(format!( + "agent {name}: manifest.toml declares parser = \"external\" \ + but parser_command is absent" + )); + } +} +``` + +This mirrors the runtime check in `spawn` so `apm validate` catches the misconfiguration before any worker starts. + +--- + +### Tests + +**Unit tests in `wrapper/custom.rs` under `#[cfg(test)]`:** + +- `parser_strategy_defaults_to_canonical` — `ParserStrategy::from_manifest(None)` equals `Canonical` +- `parser_strategy_explicit_canonical` — manifest with `parser = "canonical"` → `Canonical` +- `parser_strategy_raw` — manifest with `parser = "raw"` → `Raw` +- `parser_strategy_external` — manifest with `parser = "external"` → `External` +- `parser_strategy_unknown_falls_back_to_canonical` — manifest with `parser = "foobar"` → `Canonical` +- `spawn_external_missing_parser_command` — `CustomWrapper` with manifest `parser = "external"`, `parser_command = None`; assert `spawn()` returns `Err` whose message contains `"parser_command"` and `"not set"` +- `spawn_external_binary_not_found` — `parser_command = Some("nonexistent-binary-xyzzy-2803")`; assert `spawn()` returns `Err` naming that binary + +**Integration tests in `apm-core/tests/custom_wrapper_integration.rs`** (extend the file introduced by 2c32a282): + +- `integration_canonical_mode` — wrapper script emits one valid JSONL line; assert log contains that line verbatim; assert spawn returns `Ok` +- `integration_raw_mode` — wrapper script emits `"hello world\n"` (not JSONL); manifest declares `parser = "raw"`; assert log contains `"hello world"`; assert no line in the log starts with `"[apm] warning:"` +- `integration_external_parser_pipe` — wrapper script emits `"raw line\n"` on stdout and exits 0; a second fixture script (the parser, mode 0o755, `#!/bin/sh`) reads each stdin line and emits `{"text":""}` on stdout; manifest declares `parser = "external"` and `parser_command` set to the absolute path of the parser fixture (not a PATH name); assert spawn returns `Ok`; wait for the returned parser child to exit 0; read the log; assert log contains the string `raw line` wrapped in JSON + +Use absolute paths for `parser_command` in the integration test to avoid depending on test-harness PATH configuration. The `which` crate accepts absolute paths to existing executable files directly. ### Open questions From b1266030d20dd555059c33bcc57642c9b2f67d63 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:41:41 -0700 Subject: [PATCH 114/305] ticket(2803bf07): set effort = 5 --- tickets/2803bf07-output-parser-strategy-external-parsers-.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md index 0846c9f52..9a94a4da1 100644 --- a/tickets/2803bf07-output-parser-strategy-external-parsers-.md +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -3,13 +3,13 @@ id = "2803bf07" title = "Output parser strategy: external parsers via manifest.toml" state = "in_design" priority = 0 -effort = 0 +effort = 5 risk = 0 author = "philippepascal" owner = "philippepascal" branch = "ticket/2803bf07-output-parser-strategy-external-parsers-" created_at = "2026-04-30T20:05:40.844536Z" -updated_at = "2026-05-01T00:34:05.191003Z" +updated_at = "2026-05-01T00:41:41.266824Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["2c32a282", "2e772eab"] From 0252a943c144e21d8e93c7a53daafd986202f797 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:41:44 -0700 Subject: [PATCH 115/305] ticket(2803bf07): set risk = 3 --- tickets/2803bf07-output-parser-strategy-external-parsers-.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md index 9a94a4da1..2b3ab228e 100644 --- a/tickets/2803bf07-output-parser-strategy-external-parsers-.md +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -4,12 +4,12 @@ title = "Output parser strategy: external parsers via manifest.toml" state = "in_design" priority = 0 effort = 5 -risk = 0 +risk = 3 author = "philippepascal" owner = "philippepascal" branch = "ticket/2803bf07-output-parser-strategy-external-parsers-" created_at = "2026-04-30T20:05:40.844536Z" -updated_at = "2026-05-01T00:41:41.266824Z" +updated_at = "2026-05-01T00:41:43.945838Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["2c32a282", "2e772eab"] From 7cdc3faed7ff352f4cb48900dca7706da0df289f Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:42:29 -0700 Subject: [PATCH 116/305] ticket(2803bf07): fix Approach section body (remove leading subheading) --- tickets/2803bf07-output-parser-strategy-external-parsers-.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md index 2b3ab228e..c4cc1e17e 100644 --- a/tickets/2803bf07-output-parser-strategy-external-parsers-.md +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -72,7 +72,9 @@ Support agents whose output is too far from APM's canonical JSONL stream-json to ### Approach -### Files changed +Wire the `parser` and `parser_command` fields (already parsed from manifest.toml by ticket 2c32a282) into `CustomWrapper::spawn`. Add a `ParserStrategy` enum that dispatches between three modes: `canonical` and `raw` both redirect wrapper stdout directly to the log file; `external` spawns an OS-level pipe so wrapper stdout feeds parser stdin and the parser's stdout is captured to the log. Add `which`-based pre-validation for the parser binary. Extend `validate_agents` to catch a missing `parser_command` at config-check time. + +**Files changed** | File | Change | |---|---| From 12bbf1b48d530d412fce5bfcf25ce335a31654b6 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:42:39 -0700 Subject: [PATCH 117/305] =?UTF-8?q?ticket(2803bf07):=20in=5Fdesign=20?= =?UTF-8?q?=E2=86=92=20specd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2803bf07-output-parser-strategy-external-parsers-.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md index c4cc1e17e..162e15a49 100644 --- a/tickets/2803bf07-output-parser-strategy-external-parsers-.md +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -1,7 +1,7 @@ +++ id = "2803bf07" title = "Output parser strategy: external parsers via manifest.toml" -state = "in_design" +state = "specd" priority = 0 effort = 5 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/2803bf07-output-parser-strategy-external-parsers-" created_at = "2026-04-30T20:05:40.844536Z" -updated_at = "2026-05-01T00:41:43.945838Z" +updated_at = "2026-05-01T00:42:39.533652Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["2c32a282", "2e772eab"] @@ -195,4 +195,5 @@ Use absolute paths for `parser_command` in the integration test to avoid dependi |------|------|----|----| | 2026-04-30T20:05Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | -| 2026-05-01T00:34Z | groomed | in_design | philippepascal | \ No newline at end of file +| 2026-05-01T00:34Z | groomed | in_design | philippepascal | +| 2026-05-01T00:42Z | in_design | specd | claude-0501-0034-4620 | From 1c8a67488f42f0f931f169eca6dfd67d52844657 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:42:56 -0700 Subject: [PATCH 118/305] =?UTF-8?q?ticket(2c32a282):=20start=20=E2=80=94?= =?UTF-8?q?=20ammend=20=E2=86=92=20in=5Fdesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md index c700a00b0..d66d982fc 100644 --- a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md +++ b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md @@ -1,7 +1,7 @@ +++ id = "2c32a282" title = "Custom wrapper resolution from .apm/agents//" -state = "ammend" +state = "in_design" priority = 0 effort = 5 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/2c32a282-custom-wrapper-resolution-from-apm-agent" created_at = "2026-04-30T20:02:50.794362Z" -updated_at = "2026-05-01T00:09:06.445793Z" +updated_at = "2026-05-01T00:42:56.665132Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95"] @@ -254,3 +254,4 @@ Integration test in apm-core/tests/custom_wrapper_integration.rs: | 2026-04-30T21:24Z | groomed | in_design | philippepascal | | 2026-04-30T21:36Z | in_design | specd | claude-0430-2124-5738 | | 2026-05-01T00:09Z | specd | ammend | philippepascal | +| 2026-05-01T00:42Z | ammend | in_design | philippepascal | From 8309a53f3f628c735d05e52f71537c6c0d353aa4 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:45:21 -0700 Subject: [PATCH 119/305] ticket(2c32a282): set section Problem --- ...ustom-wrapper-resolution-from-apm-agent.md | 35 ++++--------------- 1 file changed, 6 insertions(+), 29 deletions(-) diff --git a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md index d66d982fc..6aef4acda 100644 --- a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md +++ b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md @@ -19,34 +19,11 @@ depends_on = ["d3b93b95"] ### Problem -Implement custom-wrapper resolution from `.apm/agents//` so projects can ship their own agent integrations alongside built-ins. A project script at `.apm/agents//wrapper.` shadows any built-in of the same name. - -**Reference spec:** `docs/agent-wrappers.md` — sections 'Custom wrappers', 'manifest.toml (optional)'. - -**Scope:** -- New module `apm-core/src/wrapper/custom.rs` (or similar). Public API: `pub fn resolve_wrapper(root: &Path, name: &str) -> Option` returning either a `Custom { script_path: PathBuf, manifest: Option }` or `Builtin(name)`. -- Resolution order: project script first (any executable file matching `wrapper.*` in `.apm/agents//`), then built-in. -- Custom wrappers are exec'd directly (not via shell). The wrapper script must have its shebang and execute bit set; APM does not interpret extensions or pick interpreters. -- Parse optional `manifest.toml` in the wrapper directory: `[wrapper] name`, `contract_version` (default 1), `parser` (default "canonical"), `parser_command` (only when parser = "external"). Strict parsing; unknown keys are warnings. -- Wire the dispatcher (from d3b93b95) to call into custom-wrapper exec when the resolved kind is `Custom`. -- Extend `apm validate` to: - - Confirm the configured agent (global, per-profile) resolves either to a built-in or a project script. - - Validate `manifest.toml` if present (parses, declared `contract_version` is supported by this APM build). - - Error message format: "agent 'foo' not found: checked built-ins {claude, ...} and `.apm/agents/foo/`". - -**Out of scope:** -- Per-agent instructions (`apm.worker.md` etc. per agent dir) — separate ticket. -- The `apm agents new/list/test/eject` subcommand — separate ticket. -- Wrapper-contract version checking at spawn time — separate ticket; this ticket only parses the field. -- External parser invocation — separate ticket; this ticket only stores the manifest fields. - -**Tests:** -- Resolution test: project script shadows built-in. -- Resolution test: missing wrapper returns None; validate fails with the expected error. -- Manifest parsing tests (valid, invalid, missing). -- Integration test: a fixture project with a `.apm/agents/echo-test/wrapper.sh` that just echoes a JSONL event and exits 0; dispatcher runs it, output captured to log. - -**Wrapper-contract version 1** is the only one this ticket supports; manifest.toml declaring contract_version > 1 should be rejected with a clear upgrade-APM message. +APM can currently invoke only the built-in Claude wrapper. There is no way for a project team to integrate a different AI CLI (Aider, Codex, a company-internal tool) without modifying APM's Rust source and shipping a new binary. The `d3b93b95` wrapper contract established the `Wrapper` trait, `WrapperContext`, and `ClaudeWrapper`, but the dispatcher still always resolves to Claude — it has no mechanism to look for, load, or exec a project-defined script. + +This ticket wires custom-wrapper resolution into the dispatcher. When a project places an executable script at `.apm/agents//wrapper.`, APM picks it up and runs it in place of any built-in with the same name. An optional `manifest.toml` alongside the script carries metadata: which contract version the wrapper targets and which output-parser strategy it uses. `apm validate` gains agent-resolution checks so broken or missing wrappers are caught before any real worker is spawned. + +The result is a genuinely multi-agent APM: any tool that can read the APM env vars and emit JSONL on stdout can be wired in as a first-class wrapper, without touching APM's binary. ### Acceptance criteria @@ -254,4 +231,4 @@ Integration test in apm-core/tests/custom_wrapper_integration.rs: | 2026-04-30T21:24Z | groomed | in_design | philippepascal | | 2026-04-30T21:36Z | in_design | specd | claude-0430-2124-5738 | | 2026-05-01T00:09Z | specd | ammend | philippepascal | -| 2026-05-01T00:42Z | ammend | in_design | philippepascal | +| 2026-05-01T00:42Z | ammend | in_design | philippepascal | \ No newline at end of file From 9b0e4ad0b986592be95840f3bd764c763cd665aa Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:45:51 -0700 Subject: [PATCH 120/305] ticket(2c32a282): set effort = 5 --- tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md index 6aef4acda..ed2917ede 100644 --- a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md +++ b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/2c32a282-custom-wrapper-resolution-from-apm-agent" created_at = "2026-04-30T20:02:50.794362Z" -updated_at = "2026-05-01T00:42:56.665132Z" +updated_at = "2026-05-01T00:45:51.285383Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95"] From 51198be3c15f8a7d5d31fdd9fe78b8331ec6b078 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:45:54 -0700 Subject: [PATCH 121/305] ticket(2c32a282): set risk = 3 --- tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md index ed2917ede..02240c564 100644 --- a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md +++ b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/2c32a282-custom-wrapper-resolution-from-apm-agent" created_at = "2026-04-30T20:02:50.794362Z" -updated_at = "2026-05-01T00:45:51.285383Z" +updated_at = "2026-05-01T00:45:54.256085Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95"] From 752ccb2bec222ad73c9eadb5c70b89ef95fc9cc4 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 17:45:59 -0700 Subject: [PATCH 122/305] =?UTF-8?q?ticket(2c32a282):=20in=5Fdesign=20?= =?UTF-8?q?=E2=86=92=20specd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2c32a282-custom-wrapper-resolution-from-apm-agent.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md index 02240c564..4fc9f3bce 100644 --- a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md +++ b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md @@ -1,7 +1,7 @@ +++ id = "2c32a282" title = "Custom wrapper resolution from .apm/agents//" -state = "in_design" +state = "specd" priority = 0 effort = 5 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/2c32a282-custom-wrapper-resolution-from-apm-agent" created_at = "2026-04-30T20:02:50.794362Z" -updated_at = "2026-05-01T00:45:54.256085Z" +updated_at = "2026-05-01T00:45:59.557488Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95"] @@ -231,4 +231,5 @@ Integration test in apm-core/tests/custom_wrapper_integration.rs: | 2026-04-30T21:24Z | groomed | in_design | philippepascal | | 2026-04-30T21:36Z | in_design | specd | claude-0430-2124-5738 | | 2026-05-01T00:09Z | specd | ammend | philippepascal | -| 2026-05-01T00:42Z | ammend | in_design | philippepascal | \ No newline at end of file +| 2026-05-01T00:42Z | ammend | in_design | philippepascal | +| 2026-05-01T00:45Z | in_design | specd | claude-0501-0042-ee50 | From 9dc42c78cd447fd911043462ecacf685f0ec0250 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:05:30 -0700 Subject: [PATCH 123/305] ticket(d3b93b95): set section Amendment requests --- tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md index cc0d3874e..3b24c5706 100644 --- a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md +++ b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md @@ -163,6 +163,8 @@ Replace both with a single private `spawn_worker(ctx: WrapperContext) -> Result< ### Amendment requests +- [ ] Add `APM_BIN=` to the wrapper-contract env var table. Source the value from `std::env::current_exe()` (same call that stamps `APM_WRAPPER_VERSION`) and resolve symlinks via `canonicalize`. This gives wrappers (especially the mocks in 25c92daa, but also any future custom wrapper that wants to call back into apm) a deterministic path to the same binary that spawned them. Without this, wrappers either rely on PATH (broken in tests, fragile across multi-version installs) or have to be told the path some other way per-wrapper. Set it in both spawn paths (container and non-container) and document it in the env-var table in the Approach. +- [ ] Tighten the `APM_ROLE_PREFIX` AC to match the Approach: it is set when `ctx.role_prefix.is_some()`. The current AC says "when configured" without naming the condition; make it explicit so the implementer doesn't have to interpret. ### Code review @@ -174,4 +176,4 @@ Replace both with a single private `spawn_worker(ctx: WrapperContext) -> Result< | 2026-04-30T20:01Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | | 2026-04-30T21:02Z | groomed | in_design | philippepascal | -| 2026-04-30T21:08Z | in_design | specd | claude-0430-2102-93c0 | +| 2026-04-30T21:08Z | in_design | specd | claude-0430-2102-93c0 | \ No newline at end of file From 90d8beb53341c9f198d1aa160c7649e3941f6074 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:05:32 -0700 Subject: [PATCH 124/305] ticket(a1b94ea4): set section Amendment requests --- tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md index e5525161b..cba867051 100644 --- a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md +++ b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md @@ -323,6 +323,7 @@ Parse the default workflow; build a state-by-id map; for each state's transition ### Amendment requests +- [ ] The Approach's transition→outcome mapping table only covers ~22 of the 28+ transitions in the current `apm-core/src/default/workflow.toml`. The AC requires every transition to carry an explicit `outcome` field, so the implementer needs the full enumeration. Either (a) expand the mapping table in the Approach to enumerate every transition (preferred — reviewer time spent once is better than implementer time spent guessing), or (b) add an explicit AC step that says "before writing the workflow.toml change, list every transition with its inferred outcome and verify against the Approach's rule set." Pick one. The implicit-default rules will produce the right value either way; this is about doc-completeness so the implementer can verify their work without re-deriving the rules. ### Code review @@ -334,4 +335,4 @@ Parse the default workflow; build a state-by-id map; for each state's transition | 2026-04-30T20:02Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | | 2026-04-30T21:08Z | groomed | in_design | philippepascal | -| 2026-04-30T21:17Z | in_design | specd | claude-0430-2108-e298 | +| 2026-04-30T21:17Z | in_design | specd | claude-0430-2108-e298 | \ No newline at end of file From 5bd707de4193672e9103f7582091c61f071626d4 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:05:34 -0700 Subject: [PATCH 125/305] ticket(6cac8518): set section Amendment requests --- tickets/6cac8518-config-schema-agent-options-drop-command.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tickets/6cac8518-config-schema-agent-options-drop-command.md b/tickets/6cac8518-config-schema-agent-options-drop-command.md index f369be351..6acdf6185 100644 --- a/tickets/6cac8518-config-schema-agent-options-drop-command.md +++ b/tickets/6cac8518-config-schema-agent-options-drop-command.md @@ -38,6 +38,7 @@ Four files change, plus tests.\n\n### 1. apm-core/src/config.rs\n\nWorkersConfig ### Amendment requests +- [ ] The deprecation-warning test AC currently asserts on internal `AtomicBool` state ("check AtomicBool state shows the warning fired exactly once"). This is implementation-aware and brittle — any refactor that changes the gating mechanism breaks the test even if behaviour is correct. Rewrite the AC to assert on captured stderr: "stderr contains the substring `deprecated` exactly once across the process lifetime, regardless of how many times legacy fields are read." The internal `AtomicBool` becomes an implementation detail, not a tested contract. ### Code review @@ -49,4 +50,4 @@ Four files change, plus tests.\n\n### 1. apm-core/src/config.rs\n\nWorkersConfig | 2026-04-30T20:02Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | | 2026-04-30T21:17Z | groomed | in_design | philippepascal | -| 2026-04-30T21:23Z | in_design | specd | claude-0430-2117-71a8 | +| 2026-04-30T21:23Z | in_design | specd | claude-0430-2117-71a8 | \ No newline at end of file From f66f818cce73633f3346f84d46ca3aef9d892231 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:05:36 -0700 Subject: [PATCH 126/305] ticket(2c32a282): set section Amendment requests --- tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md index 4fc9f3bce..e3c6a07b6 100644 --- a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md +++ b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md @@ -218,6 +218,7 @@ Integration test in apm-core/tests/custom_wrapper_integration.rs: ### Amendment requests +- [ ] Make the two-layer manifest validation explicit in the Approach: (a) `apm validate` parses every project wrapper's `manifest.toml` and reports errors at validate time so users find problems early, before any worker is spawned; (b) `CustomWrapper::spawn` re-checks the manifest at spawn time as a safety net (the file could have been edited between validate and spawn). Both layers are necessary — early surfacing is the load-bearing UX, but spawn-time check prevents silent crashes when a user edits a manifest mid-session. The current spec implements both but doesn't say so explicitly; an implementer might be tempted to drop one. ### Code review @@ -232,4 +233,4 @@ Integration test in apm-core/tests/custom_wrapper_integration.rs: | 2026-04-30T21:36Z | in_design | specd | claude-0430-2124-5738 | | 2026-05-01T00:09Z | specd | ammend | philippepascal | | 2026-05-01T00:42Z | ammend | in_design | philippepascal | -| 2026-05-01T00:45Z | in_design | specd | claude-0501-0042-ee50 | +| 2026-05-01T00:45Z | in_design | specd | claude-0501-0042-ee50 | \ No newline at end of file From 61157de20bf1122bf698f5c5c978e79888883baf Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:05:38 -0700 Subject: [PATCH 127/305] ticket(0ca3e019): set section Amendment requests --- tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md index e108fdcf9..62f9d80ca 100644 --- a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md +++ b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md @@ -249,6 +249,7 @@ Append a short note to each file near the end: ### Amendment requests +- [ ] Add a cross-ticket TODO note in the Approach: this ticket's validate check uses `wrapper::resolve_builtin(name)` (correct as of foundation ticket d3b93b95). Once ticket 2c32a282 (custom wrappers) lands, that call site must be upgraded to `wrapper::resolve_wrapper(root, name)` so frontmatter agent overrides referencing project-defined custom wrappers also validate correctly. Without this note, the upgrade is invisible and a future ticket either misses it or has to re-derive the dependency. Adding it as an explicit AC ("After 2c32a282 lands, validate's frontmatter-agent check uses resolve_wrapper, not resolve_builtin") closes the loop. ### Code review @@ -260,4 +261,4 @@ Append a short note to each file near the end: | 2026-04-30T20:03Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | | 2026-04-30T21:50Z | groomed | in_design | philippepascal | -| 2026-04-30T21:57Z | in_design | specd | claude-0430-2150-ea08 | +| 2026-04-30T21:57Z | in_design | specd | claude-0430-2150-ea08 | \ No newline at end of file From c15998801211908fcd18c9087077a84e82c61c34 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:05:40 -0700 Subject: [PATCH 128/305] ticket(25c92daa): set section Amendment requests --- .../25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md index 98e00d5da..52cdbbf52 100644 --- a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md +++ b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md @@ -591,6 +591,10 @@ All tests that require `apm` CLI calls in the script must resolve `current_exe() ### Amendment requests +- [ ] Adopt `APM_BIN` as the canonical way for mocks to find the apm binary to shell out to. The `APM_BIN` env var is set by the spawn glue (per the amendment to ticket d3b93b95). Replace the spec's two competing approaches (`apm_bin()` via `std::env::current_exe()` and `apm_bin_from_ctx(ctx)`) with: read `APM_BIN` from the environment; if absent, exit non-zero with a clear error pointing at the wrapper contract. No fallback to PATH — explicit is safer than fragile. This also future-proofs custom wrappers that want to shell out to apm. +- [ ] Reconcile `APM_OPT_SEED` (env var name) with `ctx.options.get("seed")` (Rust internal field). The wrapper contract from d3b93b95 + 6cac8518 maps every entry in `[workers.options]` to an `APM_OPT_` env var. The mocks should read `APM_OPT_SEED` from the environment (not via ctx options), consistent with how every other wrapper-specific option is consumed. Update Approach and AC accordingly. The user-facing config still writes `[workers.options] seed = "42"` — that's automatically translated to `APM_OPT_SEED=42` by the spawn glue. +- [ ] Add an AC for mock-random's "no valid transitions available" error case, symmetric with the one mock-sad already has. Right now mock-sad's spec mandates a non-zero exit if the eligible non-success transitions set is empty; mock-random has no equivalent AC for the (rarer) case where the current state has zero transitions at all. Tighten for symmetry. +- [ ] Mocks need their own per-agent instruction files so 7f5f73d5's resolution chain doesn't fall through to the hard error. Ship placeholder content at `apm-core/src/default/agents/mock-happy/apm.worker.md` and `apm.spec-writer.md`, plus the same for `mock-sad`, `mock-random`, and `debug`. Content can be one-line stubs ("This wrapper is a mock — see docs/agent-wrappers.md.") since the wrappers ignore the prompt anyway. Without these stubs, a project configured to use a mock would hit `7f5f73d5`'s level-5 hard error. ### Code review @@ -604,4 +608,4 @@ All tests that require `apm` CLI calls in the script must resolve `current_exe() | 2026-04-30T21:57Z | groomed | in_design | philippepascal | | 2026-05-01T00:08Z | in_design | ammend | philippepascal | | 2026-05-01T00:09Z | ammend | in_design | philippepascal | -| 2026-05-01T00:21Z | in_design | specd | claude-0501-0009-cec0 | +| 2026-05-01T00:21Z | in_design | specd | claude-0501-0009-cec0 | \ No newline at end of file From 60c14a4b32eb215d280865d893f56a831e285c08 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:05:42 -0700 Subject: [PATCH 129/305] ticket(71d80e40): set section Amendment requests --- tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index 16eebd182..dc845477e 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -272,6 +272,7 @@ Update `hash_trip::is_read_only_command` if `Agents` is listed: `List` and `Test ### Amendment requests +- [ ] Make explicit in the Approach that the manifest emitted by `eject` (`contract_version = 1`, `parser = "canonical"`) is the same template as `new` and is therefore recognised as v1-canonical by the resolution paths in `2c32a282` (manifest parser) and `2e772eab` (version check). State this so users don't wonder whether ejected scripts need extra setup, and so a future implementer doesn't accidentally invent a different template for ejected wrappers. One sentence in the eject section is enough. ### Code review @@ -285,4 +286,4 @@ Update `hash_trip::is_read_only_command` if `Agents` is listed: `List` and `Test | 2026-04-30T22:02Z | groomed | in_design | philippepascal | | 2026-05-01T00:08Z | in_design | ammend | philippepascal | | 2026-05-01T00:21Z | ammend | in_design | philippepascal | -| 2026-05-01T00:30Z | in_design | specd | claude-0501-0021-fd28 | +| 2026-05-01T00:30Z | in_design | specd | claude-0501-0021-fd28 | \ No newline at end of file From 58a26d0cc3868a8fea3deddb87499f93a630a41f Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:05:44 -0700 Subject: [PATCH 130/305] ticket(2803bf07): set section Amendment requests --- .../2803bf07-output-parser-strategy-external-parsers-.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md index 162e15a49..ec4940a62 100644 --- a/tickets/2803bf07-output-parser-strategy-external-parsers-.md +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -185,6 +185,10 @@ Use absolute paths for `parser_command` in the integration test to avoid dependi ### Amendment requests +- [ ] Drop the `raw` parser mode from this ticket entirely. The reference spec at `docs/agent-wrappers.md` defines three strategies (canonical, in-wrapper translation, external) and never mentions a `raw` mode. Adding it here without first updating the design doc creates a documentation drift and adds a third behaviour to validate that nobody asked for. Anyone wanting raw output today can use `parser = "external"` with `parser_command = "cat"` (or any pass-through). If `raw` proves genuinely useful later, file a follow-up ticket that updates the design doc first. Remove the `raw` AC, the `raw` integration test, and any Approach text describing it. +- [ ] Clarify child exit-code semantics for `parser = "external"`. The wrapper and the parser are two children connected by a pipe (wrapper.stdout → parser.stdin). Add an AC: "the worker's exit status is taken from the **parser's** exit code; the wrapper's exit code is logged for diagnostics but does not affect ticket state. If the wrapper exits non-zero before the parser drains, the parser is allowed to finish naturally before APM reaps both." This removes ambiguity for the implementer about which Child handle to wait on. +- [ ] Add an AC for stream-capture loss-prevention: "all three streams (parser.stdout, parser.stderr, wrapper.stderr) are written to `.apm-worker.log` without truncation, even when one stream produces output much faster than another. Ordering is best-effort; lossless capture is required." This guards against a real bug class (pipe buffer races) while staying realistic about ordering guarantees. +- [ ] Update the TOML example in `docs/agent-wrappers.md` (sections "Custom wrappers / manifest.toml" and "Output parser strategy") to mention all parser modes the implementation will support — currently `canonical` and `external`. This keeps the spec doc in sync with the implementation. Out of scope for this ticket: no other doc changes. ### Code review @@ -196,4 +200,4 @@ Use absolute paths for `parser_command` in the integration test to avoid dependi | 2026-04-30T20:05Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | | 2026-05-01T00:34Z | groomed | in_design | philippepascal | -| 2026-05-01T00:42Z | in_design | specd | claude-0501-0034-4620 | +| 2026-05-01T00:42Z | in_design | specd | claude-0501-0034-4620 | \ No newline at end of file From 87131767e6cc992d897bc23dd5dbbcd764271508 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:05:46 -0700 Subject: [PATCH 131/305] ticket(3048d7e9): set section Amendment requests --- tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md index b7b63be35..b5b7e6655 100644 --- a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md +++ b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md @@ -222,6 +222,7 @@ Test fixtures are small inline TOML strings written to a `tempdir`; no external ### Amendment requests +- [ ] Add a one-line note to the Approach explicitly stating this ticket presupposes ticket `6cac8518`'s runtime backward-compat fallback is already in place (the dep-chain wires this correctly, but the Approach should say so). Specifically: when `apm validate --fix` runs, the legacy fields it's about to migrate are still being read by `6cac8518`'s fallback path and emitting the deprecation warning — that's the intended state, and the migration removes the fallback's input by rewriting the config. Without the note, a reviewer might wonder whether the migration is racing with the runtime read; saying the order out loud removes the doubt. ### Code review @@ -233,4 +234,4 @@ Test fixtures are small inline TOML strings written to a `tempdir`; no external | 2026-04-30T20:03Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | | 2026-04-30T21:36Z | groomed | in_design | philippepascal | -| 2026-04-30T21:42Z | in_design | specd | claude-0430-2136-f2a8 | +| 2026-04-30T21:42Z | in_design | specd | claude-0430-2136-f2a8 | \ No newline at end of file From 1983d3c080afe333b41e231536bdc8bfd0124be1 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:10:17 -0700 Subject: [PATCH 132/305] =?UTF-8?q?ticket(d3b93b95):=20specd=20=E2=86=92?= =?UTF-8?q?=20ammend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../d3b93b95-wrapper-contract-foundation-trait-dispat.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md index 3b24c5706..66de5b751 100644 --- a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md +++ b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md @@ -1,7 +1,7 @@ +++ id = "d3b93b95" title = "Wrapper contract foundation: trait, dispatcher, claude built-in (refactor)" -state = "specd" +state = "ammend" priority = 0 effort = 4 risk = 2 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/d3b93b95-wrapper-contract-foundation-trait-dispat" created_at = "2026-04-30T20:01:55.080870Z" -updated_at = "2026-04-30T21:08:19.769830Z" +updated_at = "2026-05-01T01:10:16.902183Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" +++ @@ -176,4 +176,5 @@ Replace both with a single private `spawn_worker(ctx: WrapperContext) -> Result< | 2026-04-30T20:01Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | | 2026-04-30T21:02Z | groomed | in_design | philippepascal | -| 2026-04-30T21:08Z | in_design | specd | claude-0430-2102-93c0 | \ No newline at end of file +| 2026-04-30T21:08Z | in_design | specd | claude-0430-2102-93c0 | +| 2026-05-01T01:10Z | specd | ammend | philippepascal | From 07f569ab950d2eacaeeb700e7869484bc91cd506 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:10:19 -0700 Subject: [PATCH 133/305] =?UTF-8?q?ticket(a1b94ea4):=20specd=20=E2=86=92?= =?UTF-8?q?=20ammend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../a1b94ea4-add-outcome-field-to-transitionconfig-wi.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md index cba867051..bc7dd72e9 100644 --- a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md +++ b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md @@ -1,7 +1,7 @@ +++ id = "a1b94ea4" title = "Add outcome field to TransitionConfig with implicit defaults" -state = "specd" +state = "ammend" priority = 0 effort = 4 risk = 2 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/a1b94ea4-add-outcome-field-to-transitionconfig-wi" created_at = "2026-04-30T20:02:08.987471Z" -updated_at = "2026-04-30T21:17:07.256797Z" +updated_at = "2026-05-01T01:10:19.905237Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" +++ @@ -335,4 +335,5 @@ Parse the default workflow; build a state-by-id map; for each state's transition | 2026-04-30T20:02Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | | 2026-04-30T21:08Z | groomed | in_design | philippepascal | -| 2026-04-30T21:17Z | in_design | specd | claude-0430-2108-e298 | \ No newline at end of file +| 2026-04-30T21:17Z | in_design | specd | claude-0430-2108-e298 | +| 2026-05-01T01:10Z | specd | ammend | philippepascal | From 9b3ffa30598147ab6cb351f3f1e5448e74df7e2a Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:10:22 -0700 Subject: [PATCH 134/305] =?UTF-8?q?ticket(6cac8518):=20specd=20=E2=86=92?= =?UTF-8?q?=20ammend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../6cac8518-config-schema-agent-options-drop-command.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/6cac8518-config-schema-agent-options-drop-command.md b/tickets/6cac8518-config-schema-agent-options-drop-command.md index 6acdf6185..2842f9afc 100644 --- a/tickets/6cac8518-config-schema-agent-options-drop-command.md +++ b/tickets/6cac8518-config-schema-agent-options-drop-command.md @@ -1,7 +1,7 @@ +++ id = "6cac8518" title = "Config schema: agent + options (drop command/args/model)" -state = "specd" +state = "ammend" priority = 0 effort = 4 risk = 4 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/6cac8518-config-schema-agent-options-drop-command" created_at = "2026-04-30T20:02:34.693415Z" -updated_at = "2026-04-30T21:23:41.365303Z" +updated_at = "2026-05-01T01:10:22.703213Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95"] @@ -50,4 +50,5 @@ Four files change, plus tests.\n\n### 1. apm-core/src/config.rs\n\nWorkersConfig | 2026-04-30T20:02Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | | 2026-04-30T21:17Z | groomed | in_design | philippepascal | -| 2026-04-30T21:23Z | in_design | specd | claude-0430-2117-71a8 | \ No newline at end of file +| 2026-04-30T21:23Z | in_design | specd | claude-0430-2117-71a8 | +| 2026-05-01T01:10Z | specd | ammend | philippepascal | From 7e4b2ab775d683e64c231dfa6375acf99c235457 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:10:25 -0700 Subject: [PATCH 135/305] =?UTF-8?q?ticket(2c32a282):=20specd=20=E2=86=92?= =?UTF-8?q?=20ammend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2c32a282-custom-wrapper-resolution-from-apm-agent.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md index e3c6a07b6..ba7d9bc78 100644 --- a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md +++ b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md @@ -1,7 +1,7 @@ +++ id = "2c32a282" title = "Custom wrapper resolution from .apm/agents//" -state = "specd" +state = "ammend" priority = 0 effort = 5 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/2c32a282-custom-wrapper-resolution-from-apm-agent" created_at = "2026-04-30T20:02:50.794362Z" -updated_at = "2026-05-01T00:45:59.557488Z" +updated_at = "2026-05-01T01:10:25.526530Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95"] @@ -233,4 +233,5 @@ Integration test in apm-core/tests/custom_wrapper_integration.rs: | 2026-04-30T21:36Z | in_design | specd | claude-0430-2124-5738 | | 2026-05-01T00:09Z | specd | ammend | philippepascal | | 2026-05-01T00:42Z | ammend | in_design | philippepascal | -| 2026-05-01T00:45Z | in_design | specd | claude-0501-0042-ee50 | \ No newline at end of file +| 2026-05-01T00:45Z | in_design | specd | claude-0501-0042-ee50 | +| 2026-05-01T01:10Z | specd | ammend | philippepascal | From 112a0d3e52752bc883e94d0903605ebb189516cf Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:10:28 -0700 Subject: [PATCH 136/305] =?UTF-8?q?ticket(0ca3e019):=20specd=20=E2=86=92?= =?UTF-8?q?=20ammend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../0ca3e019-frontmatter-agent-agent-overrides-for-pe.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md index 62f9d80ca..d71a75e88 100644 --- a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md +++ b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md @@ -1,7 +1,7 @@ +++ id = "0ca3e019" title = "Frontmatter agent + agent_overrides for per-ticket wrapper choice" -state = "specd" +state = "ammend" priority = 0 effort = 4 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/0ca3e019-frontmatter-agent-agent-overrides-for-pe" created_at = "2026-04-30T20:03:58.532325Z" -updated_at = "2026-04-30T21:57:22.347867Z" +updated_at = "2026-05-01T01:10:28.302656Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "6cac8518"] @@ -261,4 +261,5 @@ Append a short note to each file near the end: | 2026-04-30T20:03Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | | 2026-04-30T21:50Z | groomed | in_design | philippepascal | -| 2026-04-30T21:57Z | in_design | specd | claude-0430-2150-ea08 | \ No newline at end of file +| 2026-04-30T21:57Z | in_design | specd | claude-0430-2150-ea08 | +| 2026-05-01T01:10Z | specd | ammend | philippepascal | From d146b19c7ab7edb83f2a11787d31646710bfe18b Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:10:31 -0700 Subject: [PATCH 137/305] =?UTF-8?q?ticket(25c92daa):=20specd=20=E2=86=92?= =?UTF-8?q?=20ammend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md index 52cdbbf52..eb340e2cb 100644 --- a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md +++ b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md @@ -1,7 +1,7 @@ +++ id = "25c92daa" title = "Mock and debug built-in wrappers (mock-happy, mock-sad, mock-random, debug)" -state = "specd" +state = "ammend" priority = 0 effort = 6 risk = 4 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/25c92daa-mock-and-debug-built-in-wrappers-mock-ha" created_at = "2026-04-30T20:04:21.901984Z" -updated_at = "2026-05-01T00:21:14.436091Z" +updated_at = "2026-05-01T01:10:31.239439Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "a1b94ea4", "6cac8518"] @@ -608,4 +608,5 @@ All tests that require `apm` CLI calls in the script must resolve `current_exe() | 2026-04-30T21:57Z | groomed | in_design | philippepascal | | 2026-05-01T00:08Z | in_design | ammend | philippepascal | | 2026-05-01T00:09Z | ammend | in_design | philippepascal | -| 2026-05-01T00:21Z | in_design | specd | claude-0501-0009-cec0 | \ No newline at end of file +| 2026-05-01T00:21Z | in_design | specd | claude-0501-0009-cec0 | +| 2026-05-01T01:10Z | specd | ammend | philippepascal | From d4b9a9bbba3831f4e8094f9285f9af6ae17cbc34 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:10:34 -0700 Subject: [PATCH 138/305] =?UTF-8?q?ticket(71d80e40):=20specd=20=E2=86=92?= =?UTF-8?q?=20ammend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../71d80e40-apm-agents-subcommand-new-list-test-ejec.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index dc845477e..1c6a06e16 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -1,7 +1,7 @@ +++ id = "71d80e40" title = "apm agents subcommand: new, list, test, eject" -state = "specd" +state = "ammend" priority = 0 effort = 5 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/71d80e40-apm-agents-subcommand-new-list-test-ejec" created_at = "2026-04-30T20:04:57.796154Z" -updated_at = "2026-05-01T00:30:13.423872Z" +updated_at = "2026-05-01T01:10:34.098483Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "2c32a282"] @@ -286,4 +286,5 @@ Update `hash_trip::is_read_only_command` if `Agents` is listed: `List` and `Test | 2026-04-30T22:02Z | groomed | in_design | philippepascal | | 2026-05-01T00:08Z | in_design | ammend | philippepascal | | 2026-05-01T00:21Z | ammend | in_design | philippepascal | -| 2026-05-01T00:30Z | in_design | specd | claude-0501-0021-fd28 | \ No newline at end of file +| 2026-05-01T00:30Z | in_design | specd | claude-0501-0021-fd28 | +| 2026-05-01T01:10Z | specd | ammend | philippepascal | From cd2a467ff12b612cc69817fdc47129c9fe4d6b56 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:10:37 -0700 Subject: [PATCH 139/305] =?UTF-8?q?ticket(2803bf07):=20specd=20=E2=86=92?= =?UTF-8?q?=20ammend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2803bf07-output-parser-strategy-external-parsers-.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md index ec4940a62..ef7e2f826 100644 --- a/tickets/2803bf07-output-parser-strategy-external-parsers-.md +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -1,7 +1,7 @@ +++ id = "2803bf07" title = "Output parser strategy: external parsers via manifest.toml" -state = "specd" +state = "ammend" priority = 0 effort = 5 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/2803bf07-output-parser-strategy-external-parsers-" created_at = "2026-04-30T20:05:40.844536Z" -updated_at = "2026-05-01T00:42:39.533652Z" +updated_at = "2026-05-01T01:10:36.945259Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["2c32a282", "2e772eab"] @@ -200,4 +200,5 @@ Use absolute paths for `parser_command` in the integration test to avoid dependi | 2026-04-30T20:05Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | | 2026-05-01T00:34Z | groomed | in_design | philippepascal | -| 2026-05-01T00:42Z | in_design | specd | claude-0501-0034-4620 | \ No newline at end of file +| 2026-05-01T00:42Z | in_design | specd | claude-0501-0034-4620 | +| 2026-05-01T01:10Z | specd | ammend | philippepascal | From 86b8de22bfc298406df63d4cbafb47d508f28f21 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:10:39 -0700 Subject: [PATCH 140/305] =?UTF-8?q?ticket(3048d7e9):=20specd=20=E2=86=92?= =?UTF-8?q?=20ammend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../3048d7e9-migration-validate-fix-ports-legacy-comm.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md index b5b7e6655..0b62c2cf3 100644 --- a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md +++ b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md @@ -1,7 +1,7 @@ +++ id = "3048d7e9" title = "Migration: validate --fix ports legacy command/args/model to agent + options" -state = "specd" +state = "ammend" priority = 0 effort = 4 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/3048d7e9-migration-validate-fix-ports-legacy-comm" created_at = "2026-04-30T20:03:17.277300Z" -updated_at = "2026-04-30T21:42:07.281922Z" +updated_at = "2026-05-01T01:10:39.654918Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["6cac8518"] @@ -234,4 +234,5 @@ Test fixtures are small inline TOML strings written to a `tempdir`; no external | 2026-04-30T20:03Z | — | new | philippepascal | | 2026-04-30T21:02Z | new | groomed | philippepascal | | 2026-04-30T21:36Z | groomed | in_design | philippepascal | -| 2026-04-30T21:42Z | in_design | specd | claude-0430-2136-f2a8 | \ No newline at end of file +| 2026-04-30T21:42Z | in_design | specd | claude-0430-2136-f2a8 | +| 2026-05-01T01:10Z | specd | ammend | philippepascal | From 88cb1dc06944f203e8b576ded3403f475a9bee51 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:14:17 -0700 Subject: [PATCH 141/305] =?UTF-8?q?ticket(d3b93b95):=20start=20=E2=80=94?= =?UTF-8?q?=20ammend=20=E2=86=92=20in=5Fdesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md index 66de5b751..affda2f51 100644 --- a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md +++ b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md @@ -1,7 +1,7 @@ +++ id = "d3b93b95" title = "Wrapper contract foundation: trait, dispatcher, claude built-in (refactor)" -state = "ammend" +state = "in_design" priority = 0 effort = 4 risk = 2 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/d3b93b95-wrapper-contract-foundation-trait-dispat" created_at = "2026-04-30T20:01:55.080870Z" -updated_at = "2026-05-01T01:10:16.902183Z" +updated_at = "2026-05-01T01:14:17.674153Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" +++ @@ -178,3 +178,4 @@ Replace both with a single private `spawn_worker(ctx: WrapperContext) -> Result< | 2026-04-30T21:02Z | groomed | in_design | philippepascal | | 2026-04-30T21:08Z | in_design | specd | claude-0430-2102-93c0 | | 2026-05-01T01:10Z | specd | ammend | philippepascal | +| 2026-05-01T01:14Z | ammend | in_design | philippepascal | From e8fc416efec95da2e0b958ffa2dadebf24cca99f Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:16:16 -0700 Subject: [PATCH 142/305] ticket(d3b93b95): set section Acceptance criteria --- .../d3b93b95-wrapper-contract-foundation-trait-dispat.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md index affda2f51..54ed89325 100644 --- a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md +++ b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md @@ -49,14 +49,14 @@ Refactor `apm-core/src/start.rs` to dispatch through a Wrapper abstraction inste - [ ] `resolve_builtin("claude")` returns `Some(_)` (a `Box`) - [ ] `resolve_builtin` returns `None` for any name other than `"claude"` - [ ] The `claude` built-in spawns `claude --print --output-format=stream-json --verbose --system-prompt [--model ] [--dangerously-skip-permissions] ` — byte-for-byte identical flags to the current hardcoded invocation -- [ ] All ten contract env vars are present on the spawned child process: `APM_AGENT_NAME`, `APM_TICKET_ID`, `APM_TICKET_BRANCH`, `APM_TICKET_WORKTREE`, `APM_SYSTEM_PROMPT_FILE`, `APM_USER_MESSAGE_FILE`, `APM_SKIP_PERMISSIONS` (`"1"` or `"0"`), `APM_PROFILE`, `APM_WRAPPER_VERSION=1`; `APM_ROLE_PREFIX` is set when `ctx.role_prefix` is `Some` +- [ ] All eleven contract env vars are present on the spawned child process: `APM_AGENT_NAME`, `APM_TICKET_ID`, `APM_TICKET_BRANCH`, `APM_TICKET_WORKTREE`, `APM_SYSTEM_PROMPT_FILE`, `APM_USER_MESSAGE_FILE`, `APM_SKIP_PERMISSIONS` (`"1"` or `"0"`), `APM_PROFILE`, `APM_WRAPPER_VERSION=1`, `APM_BIN` (canonicalized path of the running APM binary); `APM_ROLE_PREFIX` is set when `ctx.role_prefix.is_some()` - [ ] System prompt content is written to a temp file before spawn; `ctx.system_prompt_file` and `APM_SYSTEM_PROMPT_FILE` point to the same path - [ ] User message content is written to a temp file before spawn; `ctx.user_message_file` and `APM_USER_MESSAGE_FILE` point to the same path - [ ] Both temp files are removed after the child process exits (best-effort; removal errors are not propagated) - [ ] `build_spawn_command` is refactored to write temp files and dispatch through `WrapperContext`; it no longer directly appends `--output-format`, `--verbose`, `--system-prompt`, or `--dangerously-skip-permissions` to the command - [ ] `spawn_container_worker` is refactored to write temp files and dispatch through `WrapperContext`; docker `--env` flags carry the same APM contract vars as the local path - [ ] All pre-existing tests in `start.rs` pass -- [ ] New unit tests cover: `resolve_builtin` returning `Some`/`None`, all APM env vars present on the spawned process, temp file creation and best-effort cleanup after child exit +- [ ] New unit tests cover: `resolve_builtin` returning `Some`/`None`, all APM env vars present on the spawned process (including `APM_BIN`), temp file creation and best-effort cleanup after child exit ### Out of scope @@ -178,4 +178,4 @@ Replace both with a single private `spawn_worker(ctx: WrapperContext) -> Result< | 2026-04-30T21:02Z | groomed | in_design | philippepascal | | 2026-04-30T21:08Z | in_design | specd | claude-0430-2102-93c0 | | 2026-05-01T01:10Z | specd | ammend | philippepascal | -| 2026-05-01T01:14Z | ammend | in_design | philippepascal | +| 2026-05-01T01:14Z | ammend | in_design | philippepascal | \ No newline at end of file From 8969e510d74c2894dfd8d639dff127d518733d24 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:16:20 -0700 Subject: [PATCH 143/305] ticket(d3b93b95): set section Approach --- .../d3b93b95-wrapper-contract-foundation-trait-dispat.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md index 54ed89325..9bcf73afc 100644 --- a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md +++ b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md @@ -113,6 +113,8 @@ Build `docker run --rm --volume :/workspace --workdir /workspace` followed b **APM contract env vars (set in both paths)** +Compute `apm_bin` once before spawning: `let apm_bin = std::env::current_exe()?.canonicalize()?.to_string_lossy().into_owned();` + | Var | Value | |---|---| | `APM_AGENT_NAME` | `ctx.worker_name` | @@ -123,8 +125,9 @@ Build `docker run --rm --volume :/workspace --workdir /workspace` followed b | `APM_USER_MESSAGE_FILE` | `ctx.user_message_file` as str | | `APM_SKIP_PERMISSIONS` | `"1"` or `"0"` | | `APM_PROFILE` | `ctx.profile` | -| `APM_ROLE_PREFIX` | `ctx.role_prefix` when Some | +| `APM_ROLE_PREFIX` | `ctx.role_prefix` when `ctx.role_prefix.is_some()` | | `APM_WRAPPER_VERSION` | `"1"` | +| `APM_BIN` | canonicalized path of the running APM binary (`current_exe().canonicalize()`) | For local path, use `.env(key, val)` on `Command`. For container path, use `--env key=val` docker args. @@ -153,7 +156,7 @@ Replace both with a single private `spawn_worker(ctx: WrapperContext) -> Result< - `resolve_builtin_claude_returns_some` — `assert!(resolve_builtin("claude").is_some())` - `resolve_builtin_unknown_returns_none` — `assert!(resolve_builtin("bogus").is_none())` -- `claude_wrapper_sets_apm_env_vars` — mock script writes its env to a file; assert `APM_TICKET_ID`, `APM_TICKET_BRANCH`, `APM_TICKET_WORKTREE`, `APM_SYSTEM_PROMPT_FILE`, `APM_USER_MESSAGE_FILE`, `APM_SKIP_PERMISSIONS`, `APM_PROFILE`, `APM_WRAPPER_VERSION` are all present with correct values (same fixture pattern as existing `spawn_worker_cwd_is_ticket_worktree`) +- `claude_wrapper_sets_apm_env_vars` — mock script writes its env to a file; assert `APM_TICKET_ID`, `APM_TICKET_BRANCH`, `APM_TICKET_WORKTREE`, `APM_SYSTEM_PROMPT_FILE`, `APM_USER_MESSAGE_FILE`, `APM_SKIP_PERMISSIONS`, `APM_PROFILE`, `APM_WRAPPER_VERSION`, `APM_BIN` are all present with correct values (same fixture pattern as existing `spawn_worker_cwd_is_ticket_worktree`); also assert `APM_BIN` points to an existing file - `temp_files_removed_after_child_exits` — write two temp files, include their paths in `WrapperContext`, spawn a trivial wrapper, wait, assert both files are gone **Existing test compatibility:** `spawn_worker_cwd_is_ticket_worktree` calls `build_spawn_command` directly. After the refactor, update it to call `spawn_worker` with a `WrapperContext`; the invariant (cwd == worktree path) is unchanged. From f6a98489229c0075e8d65be59b18f567d9799786 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:16:31 -0700 Subject: [PATCH 144/305] ticket(d3b93b95): mark "Add `APM_BIN=` to the wrapper-contract env var table. Source the value from `std::env::current_exe()` (same call that stamps `APM_WRAPPER_VERSION`) and resolve symlinks via `canonicalize`. This gives wrappers (especially the mocks in 25c92daa, but also any future custom wrapper that wants to call back into apm) a deterministic path to the same binary that spawned them. Without this, wrappers either rely on PATH (broken in tests, fragile across multi-version installs) or have to be told the path some other way per-wrapper. Set it in both spawn paths (container and non-container) and document it in the env-var table in the Approach." in Amendment requests --- tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md index 9bcf73afc..f468d75b8 100644 --- a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md +++ b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md @@ -166,7 +166,7 @@ Replace both with a single private `spawn_worker(ctx: WrapperContext) -> Result< ### Amendment requests -- [ ] Add `APM_BIN=` to the wrapper-contract env var table. Source the value from `std::env::current_exe()` (same call that stamps `APM_WRAPPER_VERSION`) and resolve symlinks via `canonicalize`. This gives wrappers (especially the mocks in 25c92daa, but also any future custom wrapper that wants to call back into apm) a deterministic path to the same binary that spawned them. Without this, wrappers either rely on PATH (broken in tests, fragile across multi-version installs) or have to be told the path some other way per-wrapper. Set it in both spawn paths (container and non-container) and document it in the env-var table in the Approach. +- [x] Add `APM_BIN=` to the wrapper-contract env var table. Source the value from `std::env::current_exe()` (same call that stamps `APM_WRAPPER_VERSION`) and resolve symlinks via `canonicalize`. This gives wrappers (especially the mocks in 25c92daa, but also any future custom wrapper that wants to call back into apm) a deterministic path to the same binary that spawned them. Without this, wrappers either rely on PATH (broken in tests, fragile across multi-version installs) or have to be told the path some other way per-wrapper. Set it in both spawn paths (container and non-container) and document it in the env-var table in the Approach. - [ ] Tighten the `APM_ROLE_PREFIX` AC to match the Approach: it is set when `ctx.role_prefix.is_some()`. The current AC says "when configured" without naming the condition; make it explicit so the implementer doesn't have to interpret. ### Code review From 4e00c16eea2a0751e6c9ad96d035f886a5ecd843 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:16:37 -0700 Subject: [PATCH 145/305] ticket(d3b93b95): mark "Tighten the `APM_ROLE_PREFIX` AC to match the Approach: it is set when `ctx.role_prefix.is_some()`. The current AC says "when configured" without naming the condition; make it explicit so the implementer doesn't have to interpret." in Amendment requests --- tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md index f468d75b8..753195026 100644 --- a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md +++ b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md @@ -167,7 +167,7 @@ Replace both with a single private `spawn_worker(ctx: WrapperContext) -> Result< ### Amendment requests - [x] Add `APM_BIN=` to the wrapper-contract env var table. Source the value from `std::env::current_exe()` (same call that stamps `APM_WRAPPER_VERSION`) and resolve symlinks via `canonicalize`. This gives wrappers (especially the mocks in 25c92daa, but also any future custom wrapper that wants to call back into apm) a deterministic path to the same binary that spawned them. Without this, wrappers either rely on PATH (broken in tests, fragile across multi-version installs) or have to be told the path some other way per-wrapper. Set it in both spawn paths (container and non-container) and document it in the env-var table in the Approach. -- [ ] Tighten the `APM_ROLE_PREFIX` AC to match the Approach: it is set when `ctx.role_prefix.is_some()`. The current AC says "when configured" without naming the condition; make it explicit so the implementer doesn't have to interpret. +- [x] Tighten the `APM_ROLE_PREFIX` AC to match the Approach: it is set when `ctx.role_prefix.is_some()`. The current AC says "when configured" without naming the condition; make it explicit so the implementer doesn't have to interpret. ### Code review From 528c7e68c287f51845167bb12e52124a0cb501fb Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:16:52 -0700 Subject: [PATCH 146/305] =?UTF-8?q?ticket(d3b93b95):=20in=5Fdesign=20?= =?UTF-8?q?=E2=86=92=20specd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../d3b93b95-wrapper-contract-foundation-trait-dispat.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md index 753195026..e9d8bfc65 100644 --- a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md +++ b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md @@ -1,7 +1,7 @@ +++ id = "d3b93b95" title = "Wrapper contract foundation: trait, dispatcher, claude built-in (refactor)" -state = "in_design" +state = "specd" priority = 0 effort = 4 risk = 2 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/d3b93b95-wrapper-contract-foundation-trait-dispat" created_at = "2026-04-30T20:01:55.080870Z" -updated_at = "2026-05-01T01:14:17.674153Z" +updated_at = "2026-05-01T01:16:52.089903Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" +++ @@ -181,4 +181,5 @@ Replace both with a single private `spawn_worker(ctx: WrapperContext) -> Result< | 2026-04-30T21:02Z | groomed | in_design | philippepascal | | 2026-04-30T21:08Z | in_design | specd | claude-0430-2102-93c0 | | 2026-05-01T01:10Z | specd | ammend | philippepascal | -| 2026-05-01T01:14Z | ammend | in_design | philippepascal | \ No newline at end of file +| 2026-05-01T01:14Z | ammend | in_design | philippepascal | +| 2026-05-01T01:16Z | in_design | specd | claude-0501-0114-d728 | From 25f9541398e6a1bef71323bcfd3d512825fec97a Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:17:03 -0700 Subject: [PATCH 147/305] =?UTF-8?q?ticket(a1b94ea4):=20start=20=E2=80=94?= =?UTF-8?q?=20ammend=20=E2=86=92=20in=5Fdesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md index bc7dd72e9..665eef901 100644 --- a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md +++ b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md @@ -1,7 +1,7 @@ +++ id = "a1b94ea4" title = "Add outcome field to TransitionConfig with implicit defaults" -state = "ammend" +state = "in_design" priority = 0 effort = 4 risk = 2 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/a1b94ea4-add-outcome-field-to-transitionconfig-wi" created_at = "2026-04-30T20:02:08.987471Z" -updated_at = "2026-05-01T01:10:19.905237Z" +updated_at = "2026-05-01T01:17:03.747149Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" +++ @@ -337,3 +337,4 @@ Parse the default workflow; build a state-by-id map; for each state's transition | 2026-04-30T21:08Z | groomed | in_design | philippepascal | | 2026-04-30T21:17Z | in_design | specd | claude-0430-2108-e298 | | 2026-05-01T01:10Z | specd | ammend | philippepascal | +| 2026-05-01T01:17Z | ammend | in_design | philippepascal | From a8f656eb3ab3e342b14a769f1893dfbf8799d604 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:18:59 -0700 Subject: [PATCH 148/305] ticket(a1b94ea4): set section Approach --- tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md index 665eef901..00275170d 100644 --- a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md +++ b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md @@ -337,4 +337,4 @@ Parse the default workflow; build a state-by-id map; for each state's transition | 2026-04-30T21:08Z | groomed | in_design | philippepascal | | 2026-04-30T21:17Z | in_design | specd | claude-0430-2108-e298 | | 2026-05-01T01:10Z | specd | ammend | philippepascal | -| 2026-05-01T01:17Z | ammend | in_design | philippepascal | +| 2026-05-01T01:17Z | ammend | in_design | philippepascal | \ No newline at end of file From e191d8172cb42ebb9ccfb3e1e75d060d28999529 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:19:08 -0700 Subject: [PATCH 149/305] =?UTF-8?q?ticket(a1b94ea4):=20mark=20"The=20Appro?= =?UTF-8?q?ach's=20transition=E2=86=92outcome=20mapping=20table=20only=20c?= =?UTF-8?q?overs=20~22=20of=20the=2028+=20transitions=20in=20the=20current?= =?UTF-8?q?=20`apm-core/src/default/workflow.toml`.=20The=20AC=20requires?= =?UTF-8?q?=20every=20transition=20to=20carry=20an=20explicit=20`outcome`?= =?UTF-8?q?=20field,=20so=20the=20implementer=20needs=20the=20full=20enume?= =?UTF-8?q?ration.=20Either=20(a)=20expand=20the=20mapping=20table=20in=20?= =?UTF-8?q?the=20Approach=20to=20enumerate=20every=20transition=20(preferr?= =?UTF-8?q?ed=20=E2=80=94=20reviewer=20time=20spent=20once=20is=20better?= =?UTF-8?q?=20than=20implementer=20time=20spent=20guessing),=20or=20(b)=20?= =?UTF-8?q?add=20an=20explicit=20AC=20step=20that=20says=20"before=20writi?= =?UTF-8?q?ng=20the=20workflow.toml=20change,=20list=20every=20transition?= =?UTF-8?q?=20with=20its=20inferred=20outcome=20and=20verify=20against=20t?= =?UTF-8?q?he=20Approach's=20rule=20set."=20Pick=20one.=20The=20implicit-d?= =?UTF-8?q?efault=20rules=20will=20produce=20the=20right=20value=20either?= =?UTF-8?q?=20way;=20this=20is=20about=20doc-completeness=20so=20the=20imp?= =?UTF-8?q?lementer=20can=20verify=20their=20work=20without=20re-deriving?= =?UTF-8?q?=20the=20rules."=20in=20Amendment=20requests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md index 00275170d..fc9d3b979 100644 --- a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md +++ b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md @@ -323,7 +323,7 @@ Parse the default workflow; build a state-by-id map; for each state's transition ### Amendment requests -- [ ] The Approach's transition→outcome mapping table only covers ~22 of the 28+ transitions in the current `apm-core/src/default/workflow.toml`. The AC requires every transition to carry an explicit `outcome` field, so the implementer needs the full enumeration. Either (a) expand the mapping table in the Approach to enumerate every transition (preferred — reviewer time spent once is better than implementer time spent guessing), or (b) add an explicit AC step that says "before writing the workflow.toml change, list every transition with its inferred outcome and verify against the Approach's rule set." Pick one. The implicit-default rules will produce the right value either way; this is about doc-completeness so the implementer can verify their work without re-deriving the rules. +- [x] The Approach's transition→outcome mapping table only covers ~22 of the 28+ transitions in the current `apm-core/src/default/workflow.toml`. The AC requires every transition to carry an explicit `outcome` field, so the implementer needs the full enumeration. Either (a) expand the mapping table in the Approach to enumerate every transition (preferred — reviewer time spent once is better than implementer time spent guessing), or (b) add an explicit AC step that says "before writing the workflow.toml change, list every transition with its inferred outcome and verify against the Approach's rule set." Pick one. The implicit-default rules will produce the right value either way; this is about doc-completeness so the implementer can verify their work without re-deriving the rules. ### Code review From 118e46b256222b574c4913833be91dbd38d9afc4 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:19:22 -0700 Subject: [PATCH 150/305] =?UTF-8?q?ticket(a1b94ea4):=20in=5Fdesign=20?= =?UTF-8?q?=E2=86=92=20specd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../a1b94ea4-add-outcome-field-to-transitionconfig-wi.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md index fc9d3b979..f10552e18 100644 --- a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md +++ b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md @@ -1,7 +1,7 @@ +++ id = "a1b94ea4" title = "Add outcome field to TransitionConfig with implicit defaults" -state = "in_design" +state = "specd" priority = 0 effort = 4 risk = 2 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/a1b94ea4-add-outcome-field-to-transitionconfig-wi" created_at = "2026-04-30T20:02:08.987471Z" -updated_at = "2026-05-01T01:17:03.747149Z" +updated_at = "2026-05-01T01:19:22.476623Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" +++ @@ -337,4 +337,5 @@ Parse the default workflow; build a state-by-id map; for each state's transition | 2026-04-30T21:08Z | groomed | in_design | philippepascal | | 2026-04-30T21:17Z | in_design | specd | claude-0430-2108-e298 | | 2026-05-01T01:10Z | specd | ammend | philippepascal | -| 2026-05-01T01:17Z | ammend | in_design | philippepascal | \ No newline at end of file +| 2026-05-01T01:17Z | ammend | in_design | philippepascal | +| 2026-05-01T01:19Z | in_design | specd | claude-0501-0117-5ab0 | From 77d6eb8125a2a9798c75777b315b15378a90758f Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:19:34 -0700 Subject: [PATCH 151/305] =?UTF-8?q?ticket(3048d7e9):=20start=20=E2=80=94?= =?UTF-8?q?=20ammend=20=E2=86=92=20in=5Fdesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md index 0b62c2cf3..551801fde 100644 --- a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md +++ b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md @@ -1,7 +1,7 @@ +++ id = "3048d7e9" title = "Migration: validate --fix ports legacy command/args/model to agent + options" -state = "ammend" +state = "in_design" priority = 0 effort = 4 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/3048d7e9-migration-validate-fix-ports-legacy-comm" created_at = "2026-04-30T20:03:17.277300Z" -updated_at = "2026-05-01T01:10:39.654918Z" +updated_at = "2026-05-01T01:19:34.220738Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["6cac8518"] @@ -236,3 +236,4 @@ Test fixtures are small inline TOML strings written to a `tempdir`; no external | 2026-04-30T21:36Z | groomed | in_design | philippepascal | | 2026-04-30T21:42Z | in_design | specd | claude-0430-2136-f2a8 | | 2026-05-01T01:10Z | specd | ammend | philippepascal | +| 2026-05-01T01:19Z | ammend | in_design | philippepascal | From d87e77046a58f4f5a8512118fa52b8081cea9489 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:21:59 -0700 Subject: [PATCH 152/305] ticket(3048d7e9): set section Approach --- .../3048d7e9-migration-validate-fix-ports-legacy-comm.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md index 551801fde..e81b0fedf 100644 --- a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md +++ b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md @@ -55,6 +55,10 @@ This ticket adds that migration to `apm validate --fix`. A developer who upgrade ### Approach +**Prerequisite:** This ticket presupposes that ticket `6cac8518`'s runtime backward-compat fallback is already in place. When `apm validate --fix` runs, the legacy `command`/`args`/`model` fields it is about to remove are still being read by `6cac8518`'s fallback path, which emits the deprecation warning on `apm start`. That is the intended state: the warning signals to the developer that migration is needed, and `apm validate --fix` then removes those fields so the fallback no longer fires. There is no race — the CLI migration writes the config file before any worker is spawned. + +--- + #### Files changed **`apm/Cargo.toml`** — add `toml_edit` to the `[dependencies]` table (it is already in the workspace Cargo.toml at `toml_edit = "0.22"`; add `toml_edit.workspace = true`). @@ -236,4 +240,4 @@ Test fixtures are small inline TOML strings written to a `tempdir`; no external | 2026-04-30T21:36Z | groomed | in_design | philippepascal | | 2026-04-30T21:42Z | in_design | specd | claude-0430-2136-f2a8 | | 2026-05-01T01:10Z | specd | ammend | philippepascal | -| 2026-05-01T01:19Z | ammend | in_design | philippepascal | +| 2026-05-01T01:19Z | ammend | in_design | philippepascal | \ No newline at end of file From a6db619aed811c4b926f84d71b94c97d2e4c2b57 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:22:07 -0700 Subject: [PATCH 153/305] =?UTF-8?q?ticket(3048d7e9):=20mark=20"Add=20a=20o?= =?UTF-8?q?ne-line=20note=20to=20the=20Approach=20explicitly=20stating=20t?= =?UTF-8?q?his=20ticket=20presupposes=20ticket=20`6cac8518`'s=20runtime=20?= =?UTF-8?q?backward-compat=20fallback=20is=20already=20in=20place=20(the?= =?UTF-8?q?=20dep-chain=20wires=20this=20correctly,=20but=20the=20Approach?= =?UTF-8?q?=20should=20say=20so).=20Specifically:=20when=20`apm=20validate?= =?UTF-8?q?=20--fix`=20runs,=20the=20legacy=20fields=20it's=20about=20to?= =?UTF-8?q?=20migrate=20are=20still=20being=20read=20by=20`6cac8518`'s=20f?= =?UTF-8?q?allback=20path=20and=20emitting=20the=20deprecation=20warning?= =?UTF-8?q?=20=E2=80=94=20that's=20the=20intended=20state,=20and=20the=20m?= =?UTF-8?q?igration=20removes=20the=20fallback's=20input=20by=20rewriting?= =?UTF-8?q?=20the=20config.=20Without=20the=20note,=20a=20reviewer=20might?= =?UTF-8?q?=20wonder=20whether=20the=20migration=20is=20racing=20with=20th?= =?UTF-8?q?e=20runtime=20read;=20saying=20the=20order=20out=20loud=20remov?= =?UTF-8?q?es=20the=20doubt."=20in=20Amendment=20requests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md index e81b0fedf..6dcd67b36 100644 --- a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md +++ b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md @@ -226,7 +226,7 @@ Test fixtures are small inline TOML strings written to a `tempdir`; no external ### Amendment requests -- [ ] Add a one-line note to the Approach explicitly stating this ticket presupposes ticket `6cac8518`'s runtime backward-compat fallback is already in place (the dep-chain wires this correctly, but the Approach should say so). Specifically: when `apm validate --fix` runs, the legacy fields it's about to migrate are still being read by `6cac8518`'s fallback path and emitting the deprecation warning — that's the intended state, and the migration removes the fallback's input by rewriting the config. Without the note, a reviewer might wonder whether the migration is racing with the runtime read; saying the order out loud removes the doubt. +- [x] Add a one-line note to the Approach explicitly stating this ticket presupposes ticket `6cac8518`'s runtime backward-compat fallback is already in place (the dep-chain wires this correctly, but the Approach should say so). Specifically: when `apm validate --fix` runs, the legacy fields it's about to migrate are still being read by `6cac8518`'s fallback path and emitting the deprecation warning — that's the intended state, and the migration removes the fallback's input by rewriting the config. Without the note, a reviewer might wonder whether the migration is racing with the runtime read; saying the order out loud removes the doubt. ### Code review From 0d95b956d48fd2b599b897c879049dae0a93b8a0 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:22:19 -0700 Subject: [PATCH 154/305] =?UTF-8?q?ticket(3048d7e9):=20in=5Fdesign=20?= =?UTF-8?q?=E2=86=92=20specd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../3048d7e9-migration-validate-fix-ports-legacy-comm.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md index 6dcd67b36..77f87b209 100644 --- a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md +++ b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md @@ -1,7 +1,7 @@ +++ id = "3048d7e9" title = "Migration: validate --fix ports legacy command/args/model to agent + options" -state = "in_design" +state = "specd" priority = 0 effort = 4 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/3048d7e9-migration-validate-fix-ports-legacy-comm" created_at = "2026-04-30T20:03:17.277300Z" -updated_at = "2026-05-01T01:19:34.220738Z" +updated_at = "2026-05-01T01:22:19.715734Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["6cac8518"] @@ -240,4 +240,5 @@ Test fixtures are small inline TOML strings written to a `tempdir`; no external | 2026-04-30T21:36Z | groomed | in_design | philippepascal | | 2026-04-30T21:42Z | in_design | specd | claude-0430-2136-f2a8 | | 2026-05-01T01:10Z | specd | ammend | philippepascal | -| 2026-05-01T01:19Z | ammend | in_design | philippepascal | \ No newline at end of file +| 2026-05-01T01:19Z | ammend | in_design | philippepascal | +| 2026-05-01T01:22Z | in_design | specd | claude-0501-0119-6978 | From f31c992b95ee162be290df8005c5553cca62a9a1 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:22:28 -0700 Subject: [PATCH 155/305] =?UTF-8?q?ticket(0ca3e019):=20start=20=E2=80=94?= =?UTF-8?q?=20ammend=20=E2=86=92=20in=5Fdesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md index d71a75e88..b76e23d50 100644 --- a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md +++ b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md @@ -1,7 +1,7 @@ +++ id = "0ca3e019" title = "Frontmatter agent + agent_overrides for per-ticket wrapper choice" -state = "ammend" +state = "in_design" priority = 0 effort = 4 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/0ca3e019-frontmatter-agent-agent-overrides-for-pe" created_at = "2026-04-30T20:03:58.532325Z" -updated_at = "2026-05-01T01:10:28.302656Z" +updated_at = "2026-05-01T01:22:28.381694Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "6cac8518"] @@ -263,3 +263,4 @@ Append a short note to each file near the end: | 2026-04-30T21:50Z | groomed | in_design | philippepascal | | 2026-04-30T21:57Z | in_design | specd | claude-0430-2150-ea08 | | 2026-05-01T01:10Z | specd | ammend | philippepascal | +| 2026-05-01T01:22Z | ammend | in_design | philippepascal | From a12b24c719965f3015ea4d49544940a056294bc8 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:25:45 -0700 Subject: [PATCH 156/305] ticket(0ca3e019): set section Acceptance criteria --- tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md index b76e23d50..0ace4788a 100644 --- a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md +++ b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md @@ -50,6 +50,7 @@ The full resolution order (per spawn, where P is the profile name declared by th - [ ] `apm validate` does not report an error for a ticket whose `frontmatter.agent` is `"claude"` - [ ] `.apm/apm.spec-writer.md` contains a brief note that supervisors may set `agent` or `[agent_overrides]` in frontmatter - [ ] `.apm/apm.worker.md` contains a brief note that supervisors may set `agent` or `[agent_overrides]` in frontmatter +- [ ] The `wrapper::resolve_builtin(name)` call site in `validate.rs` has an inline `// TODO(2c32a282)` comment noting that it must be upgraded to `wrapper::resolve_wrapper(root, name)` once ticket 2c32a282 (custom wrapper resolution) lands, so project-defined scripts referenced in `agent` or `agent_overrides` are also validated ### Out of scope @@ -263,4 +264,4 @@ Append a short note to each file near the end: | 2026-04-30T21:50Z | groomed | in_design | philippepascal | | 2026-04-30T21:57Z | in_design | specd | claude-0430-2150-ea08 | | 2026-05-01T01:10Z | specd | ammend | philippepascal | -| 2026-05-01T01:22Z | ammend | in_design | philippepascal | +| 2026-05-01T01:22Z | ammend | in_design | philippepascal | \ No newline at end of file From c473f9196b0c698490123ee9dcd14cc91b7173c5 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:25:50 -0700 Subject: [PATCH 157/305] ticket(0ca3e019): set section Approach --- .../0ca3e019-frontmatter-agent-agent-overrides-for-pe.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md index 0ace4788a..9af7dfd18 100644 --- a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md +++ b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md @@ -113,6 +113,9 @@ let agents_to_check: Vec<&str> = ticket.frontmatter.agent .collect(); for name in agents_to_check { + // TODO(2c32a282): upgrade to wrapper::resolve_wrapper(root, name) once + // custom wrapper resolution lands so project-defined scripts referenced + // in `agent` / `agent_overrides` are also validated here. if wrapper::resolve_builtin(name).is_none() { errors.push(format!( "ticket {}: agent {:?} is not a known built-in", @@ -122,7 +125,9 @@ for name in agents_to_check { } ``` -This checks only built-ins. When ticket 2c32a282 lands and `resolve_wrapper()` subsumes `resolve_builtin()`, this call site can be upgraded in that ticket. +Import `crate::wrapper` (introduced by d3b93b95). The `// TODO(2c32a282)` comment must appear verbatim in the source so the upgrade is discoverable without re-deriving the dependency. + +**Cross-ticket obligation:** when ticket 2c32a282 (custom wrapper resolution) lands and `resolve_wrapper(root, name)` is available — checking both built-ins and project scripts under `.apm/agents/` — this call site must be upgraded. Ticket 2c32a282 should include that upgrade in its scope; this comment is the breadcrumb that makes it visible. #### `.apm/apm.spec-writer.md` and `.apm/apm.worker.md` From 35a70bd395283ec3d04099811591daef37d4f6ae Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:26:00 -0700 Subject: [PATCH 158/305] ticket(0ca3e019): mark "Add a cross-ticket TODO note in the Approach: this ticket's validate check uses `wrapper::resolve_builtin(name)` (correct as of foundation ticket d3b93b95). Once ticket 2c32a282 (custom wrappers) lands, that call site must be upgraded to `wrapper::resolve_wrapper(root, name)` so frontmatter agent overrides referencing project-defined custom wrappers also validate correctly. Without this note, the upgrade is invisible and a future ticket either misses it or has to re-derive the dependency. Adding it as an explicit AC ("After 2c32a282 lands, validate's frontmatter-agent check uses resolve_wrapper, not resolve_builtin") closes the loop." in Amendment requests --- tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md index 9af7dfd18..68d01bdf2 100644 --- a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md +++ b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md @@ -255,7 +255,7 @@ Append a short note to each file near the end: ### Amendment requests -- [ ] Add a cross-ticket TODO note in the Approach: this ticket's validate check uses `wrapper::resolve_builtin(name)` (correct as of foundation ticket d3b93b95). Once ticket 2c32a282 (custom wrappers) lands, that call site must be upgraded to `wrapper::resolve_wrapper(root, name)` so frontmatter agent overrides referencing project-defined custom wrappers also validate correctly. Without this note, the upgrade is invisible and a future ticket either misses it or has to re-derive the dependency. Adding it as an explicit AC ("After 2c32a282 lands, validate's frontmatter-agent check uses resolve_wrapper, not resolve_builtin") closes the loop. +- [x] Add a cross-ticket TODO note in the Approach: this ticket's validate check uses `wrapper::resolve_builtin(name)` (correct as of foundation ticket d3b93b95). Once ticket 2c32a282 (custom wrappers) lands, that call site must be upgraded to `wrapper::resolve_wrapper(root, name)` so frontmatter agent overrides referencing project-defined custom wrappers also validate correctly. Without this note, the upgrade is invisible and a future ticket either misses it or has to re-derive the dependency. Adding it as an explicit AC ("After 2c32a282 lands, validate's frontmatter-agent check uses resolve_wrapper, not resolve_builtin") closes the loop. ### Code review From a891b98a8fdd0c4453a52cb1021d635b6f4d6adc Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:26:16 -0700 Subject: [PATCH 159/305] =?UTF-8?q?ticket(0ca3e019):=20in=5Fdesign=20?= =?UTF-8?q?=E2=86=92=20specd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../0ca3e019-frontmatter-agent-agent-overrides-for-pe.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md index 68d01bdf2..8f23c8a68 100644 --- a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md +++ b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md @@ -1,7 +1,7 @@ +++ id = "0ca3e019" title = "Frontmatter agent + agent_overrides for per-ticket wrapper choice" -state = "in_design" +state = "specd" priority = 0 effort = 4 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/0ca3e019-frontmatter-agent-agent-overrides-for-pe" created_at = "2026-04-30T20:03:58.532325Z" -updated_at = "2026-05-01T01:22:28.381694Z" +updated_at = "2026-05-01T01:26:16.494942Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "6cac8518"] @@ -269,4 +269,5 @@ Append a short note to each file near the end: | 2026-04-30T21:50Z | groomed | in_design | philippepascal | | 2026-04-30T21:57Z | in_design | specd | claude-0430-2150-ea08 | | 2026-05-01T01:10Z | specd | ammend | philippepascal | -| 2026-05-01T01:22Z | ammend | in_design | philippepascal | \ No newline at end of file +| 2026-05-01T01:22Z | ammend | in_design | philippepascal | +| 2026-05-01T01:26Z | in_design | specd | claude-0501-0122-63c8 | From b2f9af87aa6f758962b89115a25c61ca216c3591 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:26:33 -0700 Subject: [PATCH 160/305] =?UTF-8?q?ticket(6cac8518):=20start=20=E2=80=94?= =?UTF-8?q?=20ammend=20=E2=86=92=20in=5Fdesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/6cac8518-config-schema-agent-options-drop-command.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/6cac8518-config-schema-agent-options-drop-command.md b/tickets/6cac8518-config-schema-agent-options-drop-command.md index 2842f9afc..42e08c43d 100644 --- a/tickets/6cac8518-config-schema-agent-options-drop-command.md +++ b/tickets/6cac8518-config-schema-agent-options-drop-command.md @@ -1,7 +1,7 @@ +++ id = "6cac8518" title = "Config schema: agent + options (drop command/args/model)" -state = "ammend" +state = "in_design" priority = 0 effort = 4 risk = 4 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/6cac8518-config-schema-agent-options-drop-command" created_at = "2026-04-30T20:02:34.693415Z" -updated_at = "2026-05-01T01:10:22.703213Z" +updated_at = "2026-05-01T01:26:32.971503Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95"] @@ -52,3 +52,4 @@ Four files change, plus tests.\n\n### 1. apm-core/src/config.rs\n\nWorkersConfig | 2026-04-30T21:17Z | groomed | in_design | philippepascal | | 2026-04-30T21:23Z | in_design | specd | claude-0430-2117-71a8 | | 2026-05-01T01:10Z | specd | ammend | philippepascal | +| 2026-05-01T01:26Z | ammend | in_design | philippepascal | From 8ebba7b795dd7ca9a0bd20c9119424f70b387556 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:28:33 -0700 Subject: [PATCH 161/305] ticket(6cac8518): set section Acceptance criteria --- ...config-schema-agent-options-drop-command.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/tickets/6cac8518-config-schema-agent-options-drop-command.md b/tickets/6cac8518-config-schema-agent-options-drop-command.md index 42e08c43d..36917797d 100644 --- a/tickets/6cac8518-config-schema-agent-options-drop-command.md +++ b/tickets/6cac8518-config-schema-agent-options-drop-command.md @@ -23,7 +23,21 @@ The wrapper dispatcher landed in d3b93b95 always resolves to the `claude` built- ### Acceptance criteria -- [ ] `WorkersConfig` deserializes a TOML block containing `agent = "claude"` and `[workers.options]` without error\n- [ ] `WorkerProfileConfig` deserializes a profile block containing `agent` and `options` without error\n- [ ] A config with `workers.agent = "codex"` and `profile.agent` absent resolves the effective agent to `"codex"`\n- [ ] A config with `workers.agent = "codex"` and `profile.agent = "mock-happy"` resolves the effective agent to `"mock-happy"`\n- [ ] A config with neither `workers.agent` nor `profile.agent` set resolves the effective agent to `"claude"`\n- [ ] `profile.options` keys override `workers.options` keys when both define the same key\n- [ ] `profile.options` and `workers.options` keys that do not overlap are both present in the effective options map\n- [ ] Each entry in the effective options map is forwarded as an env var named `APM_OPT_` (key uppercased, dots and dashes replaced with underscores)\n- [ ] `options.model = "sonnet"` results in `APM_OPT_MODEL=sonnet` being set on the spawned child\n- [ ] A config using only legacy `command = "claude"` (no `agent` field) still spawns the claude wrapper successfully\n- [ ] When legacy `command`, `args`, or `model` fields are present and `agent` is absent, exactly one line is written to stderr containing the word `deprecated` per process run\n- [ ] The deprecation warning is not emitted a second time if a second worker is spawned in the same process\n- [ ] Legacy `model = "sonnet"` with no `options.model` still produces the correct `--model sonnet` flag in the spawned claude command\n- [ ] `apm init` generates a config with `agent = "claude"`, `options.model = "sonnet"`, and no `command` or `args` fields\n- [ ] A config with no `[workers]` section at all spawns successfully with defaults (agent = claude) +- [ ] `WorkersConfig` deserializes a TOML block containing `agent = "claude"` and `[workers.options]` without error +- [ ] `WorkerProfileConfig` deserializes a profile block containing `agent` and `options` without error +- [ ] A config with `workers.agent = "codex"` and `profile.agent` absent resolves the effective agent to `"codex"` +- [ ] A config with `workers.agent = "codex"` and `profile.agent = "mock-happy"` resolves the effective agent to `"mock-happy"` +- [ ] A config with neither `workers.agent` nor `profile.agent` set resolves the effective agent to `"claude"` +- [ ] `profile.options` keys override `workers.options` keys when both define the same key +- [ ] `profile.options` and `workers.options` keys that do not overlap are both present in the effective options map +- [ ] Each entry in the effective options map is forwarded as an env var named `APM_OPT_` (key uppercased, dots and dashes replaced with underscores) +- [ ] `options.model = "sonnet"` results in `APM_OPT_MODEL=sonnet` being set on the spawned child +- [ ] A config using only legacy `command = "claude"` (no `agent` field) still spawns the claude wrapper successfully +- [ ] When legacy `command`, `args`, or `model` fields are present and `agent` is absent, a line containing the substring `deprecated` is written to stderr +- [ ] Across the lifetime of a single process, stderr contains the substring `deprecated` exactly once — even when multiple workers with legacy config are spawned +- [ ] Legacy `model = "sonnet"` with no `options.model` still produces the correct `--model sonnet` flag in the spawned claude command +- [ ] `apm init` generates a config with `agent = "claude"`, `options.model = "sonnet"`, and no `command` or `args` fields +- [ ] A config with no `[workers]` section at all spawns successfully with defaults (agent = claude) ### Out of scope @@ -52,4 +66,4 @@ Four files change, plus tests.\n\n### 1. apm-core/src/config.rs\n\nWorkersConfig | 2026-04-30T21:17Z | groomed | in_design | philippepascal | | 2026-04-30T21:23Z | in_design | specd | claude-0430-2117-71a8 | | 2026-05-01T01:10Z | specd | ammend | philippepascal | -| 2026-05-01T01:26Z | ammend | in_design | philippepascal | +| 2026-05-01T01:26Z | ammend | in_design | philippepascal | \ No newline at end of file From 3db525ce9028c7b82b766237b1b402254eed52d2 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:28:37 -0700 Subject: [PATCH 162/305] ticket(6cac8518): set section Approach --- ...onfig-schema-agent-options-drop-command.md | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/tickets/6cac8518-config-schema-agent-options-drop-command.md b/tickets/6cac8518-config-schema-agent-options-drop-command.md index 36917797d..a12333ba0 100644 --- a/tickets/6cac8518-config-schema-agent-options-drop-command.md +++ b/tickets/6cac8518-config-schema-agent-options-drop-command.md @@ -45,7 +45,56 @@ The wrapper dispatcher landed in d3b93b95 always resolves to the `claude` built- ### Approach -Four files change, plus tests.\n\n### 1. apm-core/src/config.rs\n\nWorkersConfig — add agent: Option (no serde default) and options: HashMap with serde(default). Demote command from String-with-serde-default to Option (no default); same for args (Vec with default -> Option>). Remove default_command() and default_args() free functions and their serde attributes. Update WorkersConfig::default() so command and args are None. The model, env, container, keychain fields are unchanged.\n\nWorkerProfileConfig — add agent: Option and options: HashMap with serde(default). All other fields already Option; leave them.\n\n### 2. apm-core/src/start.rs\n\nEffectiveWorkerParams — add agent: String and options: HashMap.\n\neffective_spawn_params() additions:\n\nAgent resolution: raw_agent = profile.agent.clone().or_else(|| workers.agent.clone()). If raw_agent is None AND any legacy field (command, args, model at either level) is Some, call emit_deprecation_warning(). Then agent = raw_agent.unwrap_or("claude".to_string()).\n\nDeprecation gate: declare a module-level static AtomicBool (DEPRECATION_WARNED, default false). emit_deprecation_warning() does compare_exchange false->true; only on success does it eprintln the message. This guarantees exactly one emission per process regardless of how many workers are spawned.\n\nOptions merge: start from workers.options.clone(), then for each (k,v) in profile.options insert into the map (profile wins on collision).\n\nWrapperContext construction: ctx.options = resolved options map. ctx.model = options.get("model").cloned().or_else(|| params.model.clone()) — this honours both new-style options.model and legacy model field, with new-style winning.\n\nDispatcher call: resolve_builtin(¶ms.agent). If None (unknown built-in), return an error with the agent name in the message. Custom-wrapper lookup (ticket 2c32a282) is not part of this ticket; a clear error is sufficient.\n\n### 3. apm-core/src/wrapper/claude.rs (from d3b93b95)\n\nAfter setting the existing APM contract env vars, add a loop over ctx.options: for each (k, v), compute the env key as "APM_OPT_" + k.to_uppercase() with '.' and '-' replaced by '_', then:\n- Local path: cmd.env(env_key, v)\n- Container path: push "--env" and "KEY=VAL" as separate docker args\n\n### 4. apm-core/src/init.rs — default_config()\n\nReplace the [workers] block with:\n agent = "claude"\n [workers.options]\n model = "sonnet"\n\nReplace the two [worker_profiles.*] blocks to keep only instructions and role_prefix (no command, args, or model). Profiles inherit [workers] agent and options.\n\n### 5. Tests\n\n- config_round_trip_new_shape: parse TOML with agent + [workers.options], assert fields match\n- config_round_trip_legacy_shape: parse TOML with only command/args/model, assert agent is None\n- resolution_agent_profile_overrides_global: workers.agent="codex", profile.agent="mock-happy" -> effective="mock-happy"\n- resolution_agent_falls_back_to_claude: neither set -> effective="claude"\n- resolution_options_merge: workers has {model=opus,timeout=30}, profile has {model=sonnet} -> effective {model=sonnet,timeout=30}\n- deprecation_warning_emitted_once: call effective_spawn_params twice with legacy config; assert warning appears in stderr exactly once (redirect stderr via a test helper or check AtomicBool state)\n- apm_opt_env_vars_set: mock script writes env to temp file; assert APM_OPT_MODEL=sonnet is present (same pattern as claude_wrapper_sets_apm_env_vars from d3b93b95)\n- legacy_model_forwarded_to_ctx: workers.model=Some(opus), options empty -> ctx.model=Some(opus)\n- options_model_takes_precedence_over_legacy: workers.model=Some(opus), options.model=sonnet -> ctx.model=Some(sonnet) +Four files change, plus tests. + +### 1. apm-core/src/config.rs + +WorkersConfig — add agent: Option (no serde default) and options: HashMap with serde(default). Demote command from String-with-serde-default to Option (no default); same for args (Vec with default -> Option>). Remove default_command() and default_args() free functions and their serde attributes. Update WorkersConfig::default() so command and args are None. The model, env, container, keychain fields are unchanged. + +WorkerProfileConfig — add agent: Option and options: HashMap with serde(default). All other fields already Option; leave them. + +### 2. apm-core/src/start.rs + +EffectiveWorkerParams — add agent: String and options: HashMap. + +effective_spawn_params() additions: + +Agent resolution: raw_agent = profile.agent.clone().or_else(|| workers.agent.clone()). If raw_agent is None AND any legacy field (command, args, model at either level) is Some, call emit_deprecation_warning(). Then agent = raw_agent.unwrap_or("claude".to_string()). + +Deprecation gate: declare a module-level static AtomicBool (DEPRECATION_WARNED, default false). emit_deprecation_warning() does compare_exchange false->true; only on success does it eprintln the message. This guarantees exactly one emission per process regardless of how many workers are spawned. + +Options merge: start from workers.options.clone(), then for each (k,v) in profile.options insert into the map (profile wins on collision). + +WrapperContext construction: ctx.options = resolved options map. ctx.model = options.get("model").cloned().or_else(|| params.model.clone()) — this honours both new-style options.model and legacy model field, with new-style winning. + +Dispatcher call: resolve_builtin(¶ms.agent). If None (unknown built-in), return an error with the agent name in the message. Custom-wrapper lookup (ticket 2c32a282) is not part of this ticket; a clear error is sufficient. + +### 3. apm-core/src/wrapper/claude.rs (from d3b93b95) + +After setting the existing APM contract env vars, add a loop over ctx.options: for each (k, v), compute the env key as "APM_OPT_" + k.to_uppercase() with '.' and '-' replaced by '_', then: +- Local path: cmd.env(env_key, v) +- Container path: push "--env" and "KEY=VAL" as separate docker args + +### 4. apm-core/src/init.rs — default_config() + +Replace the [workers] block with: + agent = "claude" + [workers.options] + model = "sonnet" + +Replace the two [worker_profiles.*] blocks to keep only instructions and role_prefix (no command, args, or model). Profiles inherit [workers] agent and options. + +### 5. Tests + +- config_round_trip_new_shape: parse TOML with agent + [workers.options], assert fields match +- config_round_trip_legacy_shape: parse TOML with only command/args/model, assert agent is None +- resolution_agent_profile_overrides_global: workers.agent="codex", profile.agent="mock-happy" -> effective="mock-happy" +- resolution_agent_falls_back_to_claude: neither set -> effective="claude" +- resolution_options_merge: workers has {model=opus,timeout=30}, profile has {model=sonnet} -> effective {model=sonnet,timeout=30} +- deprecation_warning_emitted_once: call effective_spawn_params twice with legacy config; capture stderr; assert the captured output contains the substring "deprecated" exactly once +- apm_opt_env_vars_set: mock script writes env to temp file; assert APM_OPT_MODEL=sonnet is present (same pattern as claude_wrapper_sets_apm_env_vars from d3b93b95) +- legacy_model_forwarded_to_ctx: workers.model=Some(opus), options empty -> ctx.model=Some(opus) +- options_model_takes_precedence_over_legacy: workers.model=Some(opus), options.model=sonnet -> ctx.model=Some(sonnet) ### Open questions From 72893dfc3ecd5d48ed0e69b6a6235f22dab31b54 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:28:44 -0700 Subject: [PATCH 163/305] =?UTF-8?q?ticket(6cac8518):=20mark=20"The=20depre?= =?UTF-8?q?cation-warning=20test=20AC=20currently=20asserts=20on=20interna?= =?UTF-8?q?l=20`AtomicBool`=20state=20("check=20AtomicBool=20state=20shows?= =?UTF-8?q?=20the=20warning=20fired=20exactly=20once").=20This=20is=20impl?= =?UTF-8?q?ementation-aware=20and=20brittle=20=E2=80=94=20any=20refactor?= =?UTF-8?q?=20that=20changes=20the=20gating=20mechanism=20breaks=20the=20t?= =?UTF-8?q?est=20even=20if=20behaviour=20is=20correct.=20Rewrite=20the=20A?= =?UTF-8?q?C=20to=20assert=20on=20captured=20stderr:=20"stderr=20contains?= =?UTF-8?q?=20the=20substring=20`deprecated`=20exactly=20once=20across=20t?= =?UTF-8?q?he=20process=20lifetime,=20regardless=20of=20how=20many=20times?= =?UTF-8?q?=20legacy=20fields=20are=20read."=20The=20internal=20`AtomicBoo?= =?UTF-8?q?l`=20becomes=20an=20implementation=20detail,=20not=20a=20tested?= =?UTF-8?q?=20contract."=20in=20Amendment=20requests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/6cac8518-config-schema-agent-options-drop-command.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/6cac8518-config-schema-agent-options-drop-command.md b/tickets/6cac8518-config-schema-agent-options-drop-command.md index a12333ba0..e0fc1f125 100644 --- a/tickets/6cac8518-config-schema-agent-options-drop-command.md +++ b/tickets/6cac8518-config-schema-agent-options-drop-command.md @@ -101,7 +101,7 @@ Replace the two [worker_profiles.*] blocks to keep only instructions and role_pr ### Amendment requests -- [ ] The deprecation-warning test AC currently asserts on internal `AtomicBool` state ("check AtomicBool state shows the warning fired exactly once"). This is implementation-aware and brittle — any refactor that changes the gating mechanism breaks the test even if behaviour is correct. Rewrite the AC to assert on captured stderr: "stderr contains the substring `deprecated` exactly once across the process lifetime, regardless of how many times legacy fields are read." The internal `AtomicBool` becomes an implementation detail, not a tested contract. +- [x] The deprecation-warning test AC currently asserts on internal `AtomicBool` state ("check AtomicBool state shows the warning fired exactly once"). This is implementation-aware and brittle — any refactor that changes the gating mechanism breaks the test even if behaviour is correct. Rewrite the AC to assert on captured stderr: "stderr contains the substring `deprecated` exactly once across the process lifetime, regardless of how many times legacy fields are read." The internal `AtomicBool` becomes an implementation detail, not a tested contract. ### Code review From 2d12a59c7a3b9ce7482fd2cf07a6b29083778a50 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:29:00 -0700 Subject: [PATCH 164/305] =?UTF-8?q?ticket(6cac8518):=20in=5Fdesign=20?= =?UTF-8?q?=E2=86=92=20specd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../6cac8518-config-schema-agent-options-drop-command.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/6cac8518-config-schema-agent-options-drop-command.md b/tickets/6cac8518-config-schema-agent-options-drop-command.md index e0fc1f125..539a5ec4a 100644 --- a/tickets/6cac8518-config-schema-agent-options-drop-command.md +++ b/tickets/6cac8518-config-schema-agent-options-drop-command.md @@ -1,7 +1,7 @@ +++ id = "6cac8518" title = "Config schema: agent + options (drop command/args/model)" -state = "in_design" +state = "specd" priority = 0 effort = 4 risk = 4 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/6cac8518-config-schema-agent-options-drop-command" created_at = "2026-04-30T20:02:34.693415Z" -updated_at = "2026-05-01T01:26:32.971503Z" +updated_at = "2026-05-01T01:29:00.875207Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95"] @@ -115,4 +115,5 @@ Replace the two [worker_profiles.*] blocks to keep only instructions and role_pr | 2026-04-30T21:17Z | groomed | in_design | philippepascal | | 2026-04-30T21:23Z | in_design | specd | claude-0430-2117-71a8 | | 2026-05-01T01:10Z | specd | ammend | philippepascal | -| 2026-05-01T01:26Z | ammend | in_design | philippepascal | \ No newline at end of file +| 2026-05-01T01:26Z | ammend | in_design | philippepascal | +| 2026-05-01T01:29Z | in_design | specd | claude-0501-0126-ffc0 | From ccb8e3ae24d4e804dbed832db38983ed7a443a94 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:29:12 -0700 Subject: [PATCH 165/305] =?UTF-8?q?ticket(2c32a282):=20start=20=E2=80=94?= =?UTF-8?q?=20ammend=20=E2=86=92=20in=5Fdesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md index ba7d9bc78..bafba5039 100644 --- a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md +++ b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md @@ -1,7 +1,7 @@ +++ id = "2c32a282" title = "Custom wrapper resolution from .apm/agents//" -state = "ammend" +state = "in_design" priority = 0 effort = 5 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/2c32a282-custom-wrapper-resolution-from-apm-agent" created_at = "2026-04-30T20:02:50.794362Z" -updated_at = "2026-05-01T01:10:25.526530Z" +updated_at = "2026-05-01T01:29:12.506062Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95"] @@ -235,3 +235,4 @@ Integration test in apm-core/tests/custom_wrapper_integration.rs: | 2026-05-01T00:42Z | ammend | in_design | philippepascal | | 2026-05-01T00:45Z | in_design | specd | claude-0501-0042-ee50 | | 2026-05-01T01:10Z | specd | ammend | philippepascal | +| 2026-05-01T01:29Z | ammend | in_design | philippepascal | From d20d5bb0b0152db59701dd2e420d4535292baffd Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:31:47 -0700 Subject: [PATCH 166/305] ticket(2c32a282): set section Approach --- ...ustom-wrapper-resolution-from-apm-agent.md | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md index bafba5039..2c8855240 100644 --- a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md +++ b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md @@ -73,6 +73,18 @@ CustomWrapper struct holds script_path and manifest and implements the Wrapper t --- +**Two-layer manifest validation design** + +Manifest validation happens at two distinct points in the APM lifecycle, and both layers are required: + +- Layer 1 — validate time (load-bearing UX): `apm validate` calls `resolve_wrapper` for every configured agent name, which freshly reads and parses each `manifest.toml`. Errors and warnings are surfaced here, before any worker is ever spawned. This is the primary user-facing safety net: a team running `apm validate` in CI catches broken or incompatible manifests immediately. + +- Layer 2 — spawn time (safety net): `CustomWrapper::spawn` re-checks `self.manifest` at the moment of spawning. Because `resolve_wrapper` is called fresh from `spawn_worker` each time a worker is started, the manifest is re-read from disk at spawn time as well. This catches edits made between `apm validate` and the actual spawn (e.g. a user bumps `contract_version` mid-session), and also protects the case where validate was not run. + +**Implementers must not remove either layer.** Skipping the validate-time parse degrades the user experience (errors appear only at runtime). Skipping the spawn-time check creates a window where a post-validate edit silently uses an unsupported contract. + +--- + **wrapper/custom.rs -- private helpers** find_script(root: &Path, name: &str) -> Option @@ -106,12 +118,19 @@ Algorithm: 2. Else if resolve_builtin(name).is_some(), return Ok(Some(WrapperKind::Builtin(name.to_owned()))) 3. Else return Ok(None) +`resolve_wrapper` is called at both validate time (from `validate_agents`) and spawn time (from +`spawn_worker`), so the manifest file is re-read and re-parsed fresh on every call — this is what +makes both layers of manifest validation possible without extra plumbing. + --- **CustomWrapper::spawn (implements Wrapper trait)** -1. Check self.manifest contract_version (defaulting to 1 when manifest is None); if > 1, bail - with a message stating the declared version is unsupported and directing the user to upgrade APM +1. [Spawn-time safety net — do not omit] Check self.manifest contract_version (defaulting to 1 + when manifest is None); if > 1, bail with a message stating the declared version is unsupported + and directing the user to upgrade APM. This is Layer 2 of the two-layer validation design: it + runs unconditionally at spawn time, even when `apm validate` already passed, because the + manifest file may have been edited between validate and this spawn call. 2. Build Command::new(&self.script_path) -- no shell interpreter, the script is exec-d directly 3. Set all APM contract env vars (identical set to ClaudeWrapper; see d3b93b95 approach table) 4. Forward ctx.extra_env entries (user-configured env from [workers] env) @@ -139,10 +158,13 @@ the shape of this call does not change at that point. --- -**validate.rs -- validate_agents helper** +**validate.rs -- validate_agents helper (Layer 1: primary manifest validation)** Add fn validate_agents(config: &Config, root: &Path, errors: &mut Vec, warnings: &mut Vec) -and call it from validate_config. +and call it from validate_config. This function implements Layer 1 of the two-layer manifest +validation design: all manifest problems are surfaced here, before any worker is spawned, by +calling resolve_wrapper (which re-reads and re-parses each manifest.toml) for every configured +agent name. Steps: @@ -235,4 +257,4 @@ Integration test in apm-core/tests/custom_wrapper_integration.rs: | 2026-05-01T00:42Z | ammend | in_design | philippepascal | | 2026-05-01T00:45Z | in_design | specd | claude-0501-0042-ee50 | | 2026-05-01T01:10Z | specd | ammend | philippepascal | -| 2026-05-01T01:29Z | ammend | in_design | philippepascal | +| 2026-05-01T01:29Z | ammend | in_design | philippepascal | \ No newline at end of file From 0736041522394d0747065dfc437842d454c9082e Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:31:56 -0700 Subject: [PATCH 167/305] =?UTF-8?q?ticket(2c32a282):=20mark=20"Make=20the?= =?UTF-8?q?=20two-layer=20manifest=20validation=20explicit=20in=20the=20Ap?= =?UTF-8?q?proach:=20(a)=20`apm=20validate`=20parses=20every=20project=20w?= =?UTF-8?q?rapper's=20`manifest.toml`=20and=20reports=20errors=20at=20vali?= =?UTF-8?q?date=20time=20so=20users=20find=20problems=20early,=20before=20?= =?UTF-8?q?any=20worker=20is=20spawned;=20(b)=20`CustomWrapper::spawn`=20r?= =?UTF-8?q?e-checks=20the=20manifest=20at=20spawn=20time=20as=20a=20safety?= =?UTF-8?q?=20net=20(the=20file=20could=20have=20been=20edited=20between?= =?UTF-8?q?=20validate=20and=20spawn).=20Both=20layers=20are=20necessary?= =?UTF-8?q?=20=E2=80=94=20early=20surfacing=20is=20the=20load-bearing=20UX?= =?UTF-8?q?,=20but=20spawn-time=20check=20prevents=20silent=20crashes=20wh?= =?UTF-8?q?en=20a=20user=20edits=20a=20manifest=20mid-session.=20The=20cur?= =?UTF-8?q?rent=20spec=20implements=20both=20but=20doesn't=20say=20so=20ex?= =?UTF-8?q?plicitly;=20an=20implementer=20might=20be=20tempted=20to=20drop?= =?UTF-8?q?=20one."=20in=20Amendment=20requests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md index 2c8855240..87ce14847 100644 --- a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md +++ b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md @@ -240,7 +240,7 @@ Integration test in apm-core/tests/custom_wrapper_integration.rs: ### Amendment requests -- [ ] Make the two-layer manifest validation explicit in the Approach: (a) `apm validate` parses every project wrapper's `manifest.toml` and reports errors at validate time so users find problems early, before any worker is spawned; (b) `CustomWrapper::spawn` re-checks the manifest at spawn time as a safety net (the file could have been edited between validate and spawn). Both layers are necessary — early surfacing is the load-bearing UX, but spawn-time check prevents silent crashes when a user edits a manifest mid-session. The current spec implements both but doesn't say so explicitly; an implementer might be tempted to drop one. +- [x] Make the two-layer manifest validation explicit in the Approach: (a) `apm validate` parses every project wrapper's `manifest.toml` and reports errors at validate time so users find problems early, before any worker is spawned; (b) `CustomWrapper::spawn` re-checks the manifest at spawn time as a safety net (the file could have been edited between validate and spawn). Both layers are necessary — early surfacing is the load-bearing UX, but spawn-time check prevents silent crashes when a user edits a manifest mid-session. The current spec implements both but doesn't say so explicitly; an implementer might be tempted to drop one. ### Code review From 1215ffdee74e69d6620818e4b4582b5b69b4bf00 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:32:15 -0700 Subject: [PATCH 168/305] =?UTF-8?q?ticket(2c32a282):=20in=5Fdesign=20?= =?UTF-8?q?=E2=86=92=20specd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2c32a282-custom-wrapper-resolution-from-apm-agent.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md index 87ce14847..e48f5c26a 100644 --- a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md +++ b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md @@ -1,7 +1,7 @@ +++ id = "2c32a282" title = "Custom wrapper resolution from .apm/agents//" -state = "in_design" +state = "specd" priority = 0 effort = 5 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/2c32a282-custom-wrapper-resolution-from-apm-agent" created_at = "2026-04-30T20:02:50.794362Z" -updated_at = "2026-05-01T01:29:12.506062Z" +updated_at = "2026-05-01T01:32:15.431797Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95"] @@ -257,4 +257,5 @@ Integration test in apm-core/tests/custom_wrapper_integration.rs: | 2026-05-01T00:42Z | ammend | in_design | philippepascal | | 2026-05-01T00:45Z | in_design | specd | claude-0501-0042-ee50 | | 2026-05-01T01:10Z | specd | ammend | philippepascal | -| 2026-05-01T01:29Z | ammend | in_design | philippepascal | \ No newline at end of file +| 2026-05-01T01:29Z | ammend | in_design | philippepascal | +| 2026-05-01T01:32Z | in_design | specd | claude-0501-0129-2a50 | From 2709b086dc2a7b1fa18169ab87adf80025821280 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:32:31 -0700 Subject: [PATCH 169/305] =?UTF-8?q?ticket(71d80e40):=20start=20=E2=80=94?= =?UTF-8?q?=20ammend=20=E2=86=92=20in=5Fdesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index 1c6a06e16..5be7e688a 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -1,7 +1,7 @@ +++ id = "71d80e40" title = "apm agents subcommand: new, list, test, eject" -state = "ammend" +state = "in_design" priority = 0 effort = 5 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/71d80e40-apm-agents-subcommand-new-list-test-ejec" created_at = "2026-04-30T20:04:57.796154Z" -updated_at = "2026-05-01T01:10:34.098483Z" +updated_at = "2026-05-01T01:32:30.983810Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "2c32a282"] @@ -288,3 +288,4 @@ Update `hash_trip::is_read_only_command` if `Agents` is listed: `List` and `Test | 2026-05-01T00:21Z | ammend | in_design | philippepascal | | 2026-05-01T00:30Z | in_design | specd | claude-0501-0021-fd28 | | 2026-05-01T01:10Z | specd | ammend | philippepascal | +| 2026-05-01T01:32Z | ammend | in_design | philippepascal | From 2932c21783e8b3fd42d43ebfd4c292614a9dd35b Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:36:19 -0700 Subject: [PATCH 170/305] ticket(71d80e40): set section Approach --- tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index 5be7e688a..ee1d0bc44 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -192,7 +192,7 @@ Line classification is heuristic because stdout and stderr share the log file. T 4. `fs::create_dir_all(&dir)?` 5. Match `name`: `"claude"` → write `CLAUDE_EJECT_SCRIPT` constant; other built-in names from 25c92daa → add cases as those land; default arm bails with "eject not yet implemented for built-in NAME". 6. Set mode `0o755` on the script file. -7. Write `dir/manifest.toml` (same content as `scaffold_wrapper`). +7. Write `dir/manifest.toml` with the identical template used by `scaffold_wrapper` — `[wrapper]\ncontract_version = 1\nparser = "canonical"\n`. This is intentional: the ejected manifest is the same v1-canonical template, so it is recognised without extra setup by both the manifest resolution path in `2c32a282` and the version check in `2e772eab`. 8. `Ok(())` **`CLAUDE_EJECT_SCRIPT` constant** (define as `const CLAUDE_EJECT_SCRIPT: &str` in `agents.rs`): @@ -288,4 +288,4 @@ Update `hash_trip::is_read_only_command` if `Agents` is listed: `List` and `Test | 2026-05-01T00:21Z | ammend | in_design | philippepascal | | 2026-05-01T00:30Z | in_design | specd | claude-0501-0021-fd28 | | 2026-05-01T01:10Z | specd | ammend | philippepascal | -| 2026-05-01T01:32Z | ammend | in_design | philippepascal | +| 2026-05-01T01:32Z | ammend | in_design | philippepascal | \ No newline at end of file From 1a6c17e19b920ab0e9d5b2329426d0faa2486147 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:36:49 -0700 Subject: [PATCH 171/305] ticket(71d80e40): address amendments --- tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index ee1d0bc44..b4ede7366 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -272,7 +272,7 @@ Update `hash_trip::is_read_only_command` if `Agents` is listed: `List` and `Test ### Amendment requests -- [ ] Make explicit in the Approach that the manifest emitted by `eject` (`contract_version = 1`, `parser = "canonical"`) is the same template as `new` and is therefore recognised as v1-canonical by the resolution paths in `2c32a282` (manifest parser) and `2e772eab` (version check). State this so users don't wonder whether ejected scripts need extra setup, and so a future implementer doesn't accidentally invent a different template for ejected wrappers. One sentence in the eject section is enough. +- [x] Make explicit in the Approach that the manifest emitted by `eject` (`contract_version = 1`, `parser = "canonical"`) is the same template as `new` and is therefore recognised as v1-canonical by the resolution paths in `2c32a282` (manifest parser) and `2e772eab` (version check). State this so users don't wonder whether ejected scripts need extra setup, and so a future implementer doesn't accidentally invent a different template for ejected wrappers. One sentence in the eject section is enough. ### Code review From 8e816b9c01dc9fe14489e512d441e733b1b5ac91 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:36:52 -0700 Subject: [PATCH 172/305] =?UTF-8?q?ticket(71d80e40):=20in=5Fdesign=20?= =?UTF-8?q?=E2=86=92=20specd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../71d80e40-apm-agents-subcommand-new-list-test-ejec.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index b4ede7366..1dbc88df7 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -1,7 +1,7 @@ +++ id = "71d80e40" title = "apm agents subcommand: new, list, test, eject" -state = "in_design" +state = "specd" priority = 0 effort = 5 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/71d80e40-apm-agents-subcommand-new-list-test-ejec" created_at = "2026-04-30T20:04:57.796154Z" -updated_at = "2026-05-01T01:32:30.983810Z" +updated_at = "2026-05-01T01:36:52.287818Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "2c32a282"] @@ -288,4 +288,5 @@ Update `hash_trip::is_read_only_command` if `Agents` is listed: `List` and `Test | 2026-05-01T00:21Z | ammend | in_design | philippepascal | | 2026-05-01T00:30Z | in_design | specd | claude-0501-0021-fd28 | | 2026-05-01T01:10Z | specd | ammend | philippepascal | -| 2026-05-01T01:32Z | ammend | in_design | philippepascal | \ No newline at end of file +| 2026-05-01T01:32Z | ammend | in_design | philippepascal | +| 2026-05-01T01:36Z | in_design | specd | claude-0501-0132-6a28 | From 68e168ce0313a5bb66375f585765e59a1b90f92d Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:37:02 -0700 Subject: [PATCH 173/305] =?UTF-8?q?ticket(2803bf07):=20start=20=E2=80=94?= =?UTF-8?q?=20ammend=20=E2=86=92=20in=5Fdesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/2803bf07-output-parser-strategy-external-parsers-.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md index ef7e2f826..5ddde1b0a 100644 --- a/tickets/2803bf07-output-parser-strategy-external-parsers-.md +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -1,7 +1,7 @@ +++ id = "2803bf07" title = "Output parser strategy: external parsers via manifest.toml" -state = "ammend" +state = "in_design" priority = 0 effort = 5 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/2803bf07-output-parser-strategy-external-parsers-" created_at = "2026-04-30T20:05:40.844536Z" -updated_at = "2026-05-01T01:10:36.945259Z" +updated_at = "2026-05-01T01:37:02.861480Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["2c32a282", "2e772eab"] @@ -202,3 +202,4 @@ Use absolute paths for `parser_command` in the integration test to avoid dependi | 2026-05-01T00:34Z | groomed | in_design | philippepascal | | 2026-05-01T00:42Z | in_design | specd | claude-0501-0034-4620 | | 2026-05-01T01:10Z | specd | ammend | philippepascal | +| 2026-05-01T01:37Z | ammend | in_design | philippepascal | From 419a8aa9e9fa6dc6032c764f491a88b2c39903ed Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:43:18 -0700 Subject: [PATCH 174/305] =?UTF-8?q?ticket(25c92daa):=20start=20=E2=80=94?= =?UTF-8?q?=20ammend=20=E2=86=92=20in=5Fdesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md index eb340e2cb..05da79f15 100644 --- a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md +++ b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md @@ -1,7 +1,7 @@ +++ id = "25c92daa" title = "Mock and debug built-in wrappers (mock-happy, mock-sad, mock-random, debug)" -state = "ammend" +state = "in_design" priority = 0 effort = 6 risk = 4 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/25c92daa-mock-and-debug-built-in-wrappers-mock-ha" created_at = "2026-04-30T20:04:21.901984Z" -updated_at = "2026-05-01T01:10:31.239439Z" +updated_at = "2026-05-01T01:43:18.690653Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "a1b94ea4", "6cac8518"] @@ -610,3 +610,4 @@ All tests that require `apm` CLI calls in the script must resolve `current_exe() | 2026-05-01T00:09Z | ammend | in_design | philippepascal | | 2026-05-01T00:21Z | in_design | specd | claude-0501-0009-cec0 | | 2026-05-01T01:10Z | specd | ammend | philippepascal | +| 2026-05-01T01:43Z | ammend | in_design | philippepascal | From 47c6f7efdf8fcbc793df257cee6c3e34c6b49265 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:50:29 -0700 Subject: [PATCH 175/305] ticket(25c92daa): set section Acceptance criteria --- .../25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md index 05da79f15..20665a36f 100644 --- a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md +++ b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md @@ -113,6 +113,9 @@ Ship three mock built-in wrappers for testing the harness without burning credit - [ ] **mock-random — seeding** - [ ] Given the same `APM_OPT_SEED` value, two successive `mock-random` spawns against the same ticket in the same state choose the same target state +- [ ] **mock-random — error case** +- [ ] When no valid transitions are available from the current state, `mock-random` exits non-zero and writes a diagnostic to stderr + - [ ] **debug — output** - [ ] `debug` writes the name and value of every `APM_*` environment variable to stderr - [ ] `debug` writes the full contents of the file at `APM_SYSTEM_PROMPT_FILE` to stderr @@ -128,6 +131,9 @@ Ship three mock built-in wrappers for testing the harness without burning credit - [ ] `ammend → specd` carries `outcome = "success"` in the default workflow - [ ] The project's `.apm/workflow.toml` carries the same two annotations +- [ ] **per-agent instruction file stubs** +- [ ] Each of the four built-in wrappers (`mock-happy`, `mock-sad`, `mock-random`, `debug`) has both `apm.worker.md` and `apm.spec-writer.md` stub files under `apm-core/src/default/agents//` + ### Out of scope - User-facing documentation for mock wrappers beyond the existing `docs/agent-wrappers.md` reference @@ -610,4 +616,4 @@ All tests that require `apm` CLI calls in the script must resolve `current_exe() | 2026-05-01T00:09Z | ammend | in_design | philippepascal | | 2026-05-01T00:21Z | in_design | specd | claude-0501-0009-cec0 | | 2026-05-01T01:10Z | specd | ammend | philippepascal | -| 2026-05-01T01:43Z | ammend | in_design | philippepascal | +| 2026-05-01T01:43Z | ammend | in_design | philippepascal | \ No newline at end of file From 3423346b2eab58d51869c6168daf99ed4b395505 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:50:34 -0700 Subject: [PATCH 176/305] ticket(25c92daa): set section Out of scope --- tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md index 20665a36f..4d807187b 100644 --- a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md +++ b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md @@ -142,7 +142,7 @@ Ship three mock built-in wrappers for testing the harness without burning credit - Per-ticket `frontmatter.agent` / `agent_overrides` override — ticket 0ca3e019 - Built-in wrappers for third-party agents (`codex`, `aider`, etc.) - Wrapper-contract version compatibility checks (`manifest.toml`, `APM_WRAPPER_VERSION` ceiling) — ticket 2e772eab -- Per-agent instruction file resolution under `.apm/agents//apm.*.md` (ticket 7f5f73d5); mocks do not invoke a real agent so instruction files are irrelevant to their operation +- Per-agent instruction file *content* beyond one-line stub text — mocks ignore the prompt entirely; the stubs exist only to satisfy ticket 7f5f73d5's resolution chain - Windows or non-Unix platform support; mocks shell out to `/bin/sh` - Automated handling of the `pr_or_epic_merge` completion strategy in integration tests; mock-happy creates a real commit and calls `apm state implemented`, then APM's orchestration layer (running separately) handles the merge — no in-test merge attempt - Validating that `apm validate` warns when a workflow has no reachable success outcome — that check lives in ticket a1b94ea4 From 1486b285aa41ddd25ec72f35983cddb1943b33c7 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:50:39 -0700 Subject: [PATCH 177/305] ticket(25c92daa): set section Problem --- ...c92daa-mock-and-debug-built-in-wrappers-mock-ha.md | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md index 4d807187b..e67053ae0 100644 --- a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md +++ b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md @@ -35,7 +35,7 @@ Ship three mock built-in wrappers for testing the harness without burning credit **`mock-sad`** built-in: - Writes some-but-not-all required spec sections (or content that fails validate). - Optionally writes a question to `### Open questions`. -- Picks uniformly from transitions where `outcome ≠ "success"` valid from the current state. Seedable via `APM_OPT_SEED`. +- Picks uniformly from transitions where `outcome ≠ "success"` valid from the current state. Seeded via `APM_OPT_SEED` environment variable. - Runs `apm state `. If the eligible set is empty, exit non-zero with a diagnostic. - Exits 0. @@ -54,19 +54,14 @@ Ship three mock built-in wrappers for testing the harness without burning credit **Implementation notes:** - All four live under `apm-core/src/wrapper/builtin/` (one file each: `claude.rs`, `mock_happy.rs`, `mock_sad.rs`, `mock_random.rs`, `debug.rs`). -- The mocks shell out to the host `apm` binary for state transitions and spec writes — no special internal API. +- The mocks shell out to the host `apm` binary for state transitions and spec writes — no special internal API. The `apm` binary path is read from the `APM_BIN` environment variable (set by the spawn glue per d3b93b95's wrapper contract). - Mocks read the workflow from `apm-core::config::Config::load(root)` to find valid transitions and their outcomes (using the helper from ticket a1b94ea4). -- Per-agent instruction files (`apm.worker.md`, `apm.spec-writer.md`) are NOT needed for mocks — the per-agent instructions resolution from ticket 7f5f73d5 should fall through gracefully when those files don't exist for a built-in. Confirm in spec phase. +- Per-agent instruction file stubs are required for each mock/debug built-in (see Approach §10) so that a project configured to use a mock doesn't hit ticket 7f5f73d5's level-5 hard error. Content is a single-line stub; mocks ignore it. **Out of scope:** - Documenting the mocks in user-facing help/docs beyond the existing `docs/agent-wrappers.md` reference. - Apm subcommand support (`apm agents test mock-happy` etc.) — that is a separate ticket. -**Tests:** -- For each mock: integration test that wires it up against a fixture project, runs a worker, asserts the expected state transition occurred. -- For mock-sad / mock-random: assert seed reproducibility (same seed → same chosen transition). -- For debug: assert env vars, prompt, and message all appear in the captured `.apm-worker.log`. - ### Acceptance criteria - [ ] **Dispatcher registration** From 8e012270e5f4400158d703c20ee244374d284a68 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:53:26 -0700 Subject: [PATCH 178/305] ticket(25c92daa): mark "Adopt `APM_BIN` as the canonical way for mocks to find the apm binary" in Amendment requests --- tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md index e67053ae0..21fd52d3e 100644 --- a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md +++ b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md @@ -592,7 +592,7 @@ All tests that require `apm` CLI calls in the script must resolve `current_exe() ### Amendment requests -- [ ] Adopt `APM_BIN` as the canonical way for mocks to find the apm binary to shell out to. The `APM_BIN` env var is set by the spawn glue (per the amendment to ticket d3b93b95). Replace the spec's two competing approaches (`apm_bin()` via `std::env::current_exe()` and `apm_bin_from_ctx(ctx)`) with: read `APM_BIN` from the environment; if absent, exit non-zero with a clear error pointing at the wrapper contract. No fallback to PATH — explicit is safer than fragile. This also future-proofs custom wrappers that want to shell out to apm. +- [x] Adopt `APM_BIN` as the canonical way for mocks to find the apm binary to shell out to. The `APM_BIN` env var is set by the spawn glue (per the amendment to ticket d3b93b95). Replace the spec's two competing approaches (`apm_bin()` via `std::env::current_exe()` and `apm_bin_from_ctx(ctx)`) with: read `APM_BIN` from the environment; if absent, exit non-zero with a clear error pointing at the wrapper contract. No fallback to PATH — explicit is safer than fragile. This also future-proofs custom wrappers that want to shell out to apm. - [ ] Reconcile `APM_OPT_SEED` (env var name) with `ctx.options.get("seed")` (Rust internal field). The wrapper contract from d3b93b95 + 6cac8518 maps every entry in `[workers.options]` to an `APM_OPT_` env var. The mocks should read `APM_OPT_SEED` from the environment (not via ctx options), consistent with how every other wrapper-specific option is consumed. Update Approach and AC accordingly. The user-facing config still writes `[workers.options] seed = "42"` — that's automatically translated to `APM_OPT_SEED=42` by the spawn glue. - [ ] Add an AC for mock-random's "no valid transitions available" error case, symmetric with the one mock-sad already has. Right now mock-sad's spec mandates a non-zero exit if the eligible non-success transitions set is empty; mock-random has no equivalent AC for the (rarer) case where the current state has zero transitions at all. Tighten for symmetry. - [ ] Mocks need their own per-agent instruction files so 7f5f73d5's resolution chain doesn't fall through to the hard error. Ship placeholder content at `apm-core/src/default/agents/mock-happy/apm.worker.md` and `apm.spec-writer.md`, plus the same for `mock-sad`, `mock-random`, and `debug`. Content can be one-line stubs ("This wrapper is a mock — see docs/agent-wrappers.md.") since the wrappers ignore the prompt anyway. Without these stubs, a project configured to use a mock would hit `7f5f73d5`'s level-5 hard error. From 58de186770baa6c4d7d7d803030df469a20072ef Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:53:32 -0700 Subject: [PATCH 179/305] ticket(25c92daa): mark "Reconcile `APM_OPT_SEED`" in Amendment requests --- tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md index 21fd52d3e..950d9a2bf 100644 --- a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md +++ b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md @@ -593,7 +593,7 @@ All tests that require `apm` CLI calls in the script must resolve `current_exe() ### Amendment requests - [x] Adopt `APM_BIN` as the canonical way for mocks to find the apm binary to shell out to. The `APM_BIN` env var is set by the spawn glue (per the amendment to ticket d3b93b95). Replace the spec's two competing approaches (`apm_bin()` via `std::env::current_exe()` and `apm_bin_from_ctx(ctx)`) with: read `APM_BIN` from the environment; if absent, exit non-zero with a clear error pointing at the wrapper contract. No fallback to PATH — explicit is safer than fragile. This also future-proofs custom wrappers that want to shell out to apm. -- [ ] Reconcile `APM_OPT_SEED` (env var name) with `ctx.options.get("seed")` (Rust internal field). The wrapper contract from d3b93b95 + 6cac8518 maps every entry in `[workers.options]` to an `APM_OPT_` env var. The mocks should read `APM_OPT_SEED` from the environment (not via ctx options), consistent with how every other wrapper-specific option is consumed. Update Approach and AC accordingly. The user-facing config still writes `[workers.options] seed = "42"` — that's automatically translated to `APM_OPT_SEED=42` by the spawn glue. +- [x] Reconcile `APM_OPT_SEED` (env var name) with `ctx.options.get("seed")` (Rust internal field). The wrapper contract from d3b93b95 + 6cac8518 maps every entry in `[workers.options]` to an `APM_OPT_` env var. The mocks should read `APM_OPT_SEED` from the environment (not via ctx options), consistent with how every other wrapper-specific option is consumed. Update Approach and AC accordingly. The user-facing config still writes `[workers.options] seed = "42"` — that's automatically translated to `APM_OPT_SEED=42` by the spawn glue. - [ ] Add an AC for mock-random's "no valid transitions available" error case, symmetric with the one mock-sad already has. Right now mock-sad's spec mandates a non-zero exit if the eligible non-success transitions set is empty; mock-random has no equivalent AC for the (rarer) case where the current state has zero transitions at all. Tighten for symmetry. - [ ] Mocks need their own per-agent instruction files so 7f5f73d5's resolution chain doesn't fall through to the hard error. Ship placeholder content at `apm-core/src/default/agents/mock-happy/apm.worker.md` and `apm.spec-writer.md`, plus the same for `mock-sad`, `mock-random`, and `debug`. Content can be one-line stubs ("This wrapper is a mock — see docs/agent-wrappers.md.") since the wrappers ignore the prompt anyway. Without these stubs, a project configured to use a mock would hit `7f5f73d5`'s level-5 hard error. From 1f0a606614b35ddbbc6733fdb007a926db58fd74 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:53:36 -0700 Subject: [PATCH 180/305] ticket(25c92daa): mark "Add an AC for mock-random" in Amendment requests --- tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md index 950d9a2bf..2e9552eb8 100644 --- a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md +++ b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md @@ -594,7 +594,7 @@ All tests that require `apm` CLI calls in the script must resolve `current_exe() - [x] Adopt `APM_BIN` as the canonical way for mocks to find the apm binary to shell out to. The `APM_BIN` env var is set by the spawn glue (per the amendment to ticket d3b93b95). Replace the spec's two competing approaches (`apm_bin()` via `std::env::current_exe()` and `apm_bin_from_ctx(ctx)`) with: read `APM_BIN` from the environment; if absent, exit non-zero with a clear error pointing at the wrapper contract. No fallback to PATH — explicit is safer than fragile. This also future-proofs custom wrappers that want to shell out to apm. - [x] Reconcile `APM_OPT_SEED` (env var name) with `ctx.options.get("seed")` (Rust internal field). The wrapper contract from d3b93b95 + 6cac8518 maps every entry in `[workers.options]` to an `APM_OPT_` env var. The mocks should read `APM_OPT_SEED` from the environment (not via ctx options), consistent with how every other wrapper-specific option is consumed. Update Approach and AC accordingly. The user-facing config still writes `[workers.options] seed = "42"` — that's automatically translated to `APM_OPT_SEED=42` by the spawn glue. -- [ ] Add an AC for mock-random's "no valid transitions available" error case, symmetric with the one mock-sad already has. Right now mock-sad's spec mandates a non-zero exit if the eligible non-success transitions set is empty; mock-random has no equivalent AC for the (rarer) case where the current state has zero transitions at all. Tighten for symmetry. +- [x] Add an AC for mock-random's "no valid transitions available" error case, symmetric with the one mock-sad already has. Right now mock-sad's spec mandates a non-zero exit if the eligible non-success transitions set is empty; mock-random has no equivalent AC for the (rarer) case where the current state has zero transitions at all. Tighten for symmetry. - [ ] Mocks need their own per-agent instruction files so 7f5f73d5's resolution chain doesn't fall through to the hard error. Ship placeholder content at `apm-core/src/default/agents/mock-happy/apm.worker.md` and `apm.spec-writer.md`, plus the same for `mock-sad`, `mock-random`, and `debug`. Content can be one-line stubs ("This wrapper is a mock — see docs/agent-wrappers.md.") since the wrappers ignore the prompt anyway. Without these stubs, a project configured to use a mock would hit `7f5f73d5`'s level-5 hard error. ### Code review From 0220befb6f9a2acb6d432df4259b09468b9be209 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:53:40 -0700 Subject: [PATCH 181/305] ticket(25c92daa): mark "Mocks need their own per-agent instruction files" in Amendment requests --- tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md index 2e9552eb8..693e54e84 100644 --- a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md +++ b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md @@ -595,7 +595,7 @@ All tests that require `apm` CLI calls in the script must resolve `current_exe() - [x] Adopt `APM_BIN` as the canonical way for mocks to find the apm binary to shell out to. The `APM_BIN` env var is set by the spawn glue (per the amendment to ticket d3b93b95). Replace the spec's two competing approaches (`apm_bin()` via `std::env::current_exe()` and `apm_bin_from_ctx(ctx)`) with: read `APM_BIN` from the environment; if absent, exit non-zero with a clear error pointing at the wrapper contract. No fallback to PATH — explicit is safer than fragile. This also future-proofs custom wrappers that want to shell out to apm. - [x] Reconcile `APM_OPT_SEED` (env var name) with `ctx.options.get("seed")` (Rust internal field). The wrapper contract from d3b93b95 + 6cac8518 maps every entry in `[workers.options]` to an `APM_OPT_` env var. The mocks should read `APM_OPT_SEED` from the environment (not via ctx options), consistent with how every other wrapper-specific option is consumed. Update Approach and AC accordingly. The user-facing config still writes `[workers.options] seed = "42"` — that's automatically translated to `APM_OPT_SEED=42` by the spawn glue. - [x] Add an AC for mock-random's "no valid transitions available" error case, symmetric with the one mock-sad already has. Right now mock-sad's spec mandates a non-zero exit if the eligible non-success transitions set is empty; mock-random has no equivalent AC for the (rarer) case where the current state has zero transitions at all. Tighten for symmetry. -- [ ] Mocks need their own per-agent instruction files so 7f5f73d5's resolution chain doesn't fall through to the hard error. Ship placeholder content at `apm-core/src/default/agents/mock-happy/apm.worker.md` and `apm.spec-writer.md`, plus the same for `mock-sad`, `mock-random`, and `debug`. Content can be one-line stubs ("This wrapper is a mock — see docs/agent-wrappers.md.") since the wrappers ignore the prompt anyway. Without these stubs, a project configured to use a mock would hit `7f5f73d5`'s level-5 hard error. +- [x] Mocks need their own per-agent instruction files so 7f5f73d5's resolution chain doesn't fall through to the hard error. Ship placeholder content at `apm-core/src/default/agents/mock-happy/apm.worker.md` and `apm.spec-writer.md`, plus the same for `mock-sad`, `mock-random`, and `debug`. Content can be one-line stubs ("This wrapper is a mock — see docs/agent-wrappers.md.") since the wrappers ignore the prompt anyway. Without these stubs, a project configured to use a mock would hit `7f5f73d5`'s level-5 hard error. ### Code review From 001682421823b410df94fa5df49cc2e773ff8adc Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:57:03 -0700 Subject: [PATCH 182/305] ticket(25c92daa): address amendments --- ...ock-and-debug-built-in-wrappers-mock-ha.md | 121 +++++++++++------- 1 file changed, 76 insertions(+), 45 deletions(-) diff --git a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md index 693e54e84..16571dbf6 100644 --- a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md +++ b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md @@ -190,22 +190,25 @@ Extend `resolve_builtin()` in `wrapper/mod.rs`: `is_impl_mode(transitions: &[(TransitionConfig, StateConfig)]) -> bool`: Returns true when any transition has `completion != CompletionStrategy::None`. True for `in_progress` (has `pr_or_epic_merge`); false for `in_design`. -`happy_script(apm: &str, id: &str, target: &str, impl_mode: bool) -> String` and `sad_script(apm: &str, id: &str, target: &str) -> String`: -Private functions that return the shell script strings (see below). Called by all three mocks to avoid duplication. +`happy_script(id: &str, target: &str, impl_mode: bool) -> String` and `sad_script(id: &str, target: &str) -> String`: +Private functions that return the shell script strings (see below). Called by all three mocks to avoid duplication. Neither takes an `apm` path parameter — scripts use `${APM_BIN:?...}` at runtime. `write_and_spawn_script(name: &str, script: &str, ctx: &WrapperContext) -> anyhow::Result`: 1. Write script to `/.apm-mock--.sh`, chmod 0o755. 2. `Command::new("/bin/sh")` with the script path as arg. 3. Set all APM contract env vars (same set as `ClaudeWrapper`, including `APM_PROJECT_ROOT`). + **`APM_BIN` override for tests:** when setting `APM_BIN`, check `ctx.options.get("apm_bin")` first; if present, use that value instead of `std::env::current_exe()`. Shell scripts always use `$APM_BIN` — the override lives in the spawn helper, not in the mock logic. 4. `.current_dir(&ctx.worktree_path)`, `.process_group(0)`. 5. Redirect stdout + stderr to `File::create(&ctx.log_path)?` / `try_clone()`. 6. `.spawn()` and return `Child`. The script ends with `rm -f "$0"` (self-cleanup; no separate thread needed). -`apm_bin() -> anyhow::Result`: returns `std::env::current_exe()?.to_str()…?`. Used so scripts shell out to the same `apm` binary that spawned them. +`seed_from_env() -> u64`: reads `std::env::var("APM_OPT_SEED").ok().and_then(|s| s.parse().ok())`, falls back to `rand::thread_rng().gen::()`. The user-facing config is `[workers.options] seed = "42"`, which 6cac8518's spawn glue translates to `APM_OPT_SEED=42`. Test note: seed reproducibility tests call `std::env::set_var("APM_OPT_SEED", "42")` before each spawn and `std::env::remove_var` after; run serially. -`apm_bin_from_ctx(ctx: &WrapperContext) -> anyhow::Result`: checks `ctx.options.get("apm_bin")` first (allows test override), then falls back to `apm_bin()`. - -`seed_from_ctx(ctx: &WrapperContext) -> u64`: reads `ctx.options.get("seed").and_then(|s| s.parse().ok())`, falls back to `rand::thread_rng().gen::()`. +**Shell script preamble (all mocks):** every script reads the `apm` binary path from the environment — no hard-coded path interpolation: +```sh +APM="${APM_BIN:?APM_BIN not set — see wrapper contract}" +``` +The `:?` expansion exits non-zero immediately with a clear error if `APM_BIN` is absent. **4. MockHappyWrapper spawn() steps** @@ -213,15 +216,14 @@ Private functions that return the shell script strings (see below). Called by al 2. Filter to success: `resolve_outcome(t, s) == "success"`. 3. Match count: 0 → bail with diagnostic naming the current state; 2+ → bail with count. 4. `target = success[0].0.to.clone()`, `impl_mode = is_impl_mode(&all)`. -5. `apm = apm_bin_from_ctx(ctx)?`. -6. `script = happy_script(&apm, &ctx.ticket_id, &target, impl_mode)`. -7. `write_and_spawn_script("happy", &script, ctx)`. +5. `script = happy_script(&ctx.ticket_id, &target, impl_mode)`. +6. `write_and_spawn_script("happy", &script, ctx)`. `happy_script` spec mode (not impl_mode): ```sh #!/bin/sh set -e -APM="" +APM="${APM_BIN:?APM_BIN not set — see wrapper contract}" ID="" "$APM" spec "$ID" --section "Problem" --set "Mock spec — no real problem analyzed." printf '- [ ] Mock criterion 1\n- [ ] Mock criterion 2\n' > ".apm-mock-ac-$$.txt" @@ -241,7 +243,7 @@ rm -f "$0" ```sh #!/bin/sh set -e -APM="" +APM="${APM_BIN:?APM_BIN not set — see wrapper contract}" ID="" printf 'mock: placeholder implementation for ticket %s\n' "$ID" > mock-implementation.txt git add mock-implementation.txt @@ -257,15 +259,15 @@ rm -f "$0" 1. `load_transitions_with_outcomes(ctx)`. 2. Filter to non-success: `resolve_outcome(t, s) != "success"`. 3. Empty → bail with diagnostic. -4. `seed = seed_from_ctx(ctx)`, pick `idx = seed as usize % eligible.len()`, `target = eligible[idx].0.to.clone()`. -5. `script = sad_script(&apm, &ctx.ticket_id, &target)`. +4. `seed = seed_from_env()`, pick `idx = seed as usize % eligible.len()`, `target = eligible[idx].0.to.clone()`. +5. `script = sad_script(&ctx.ticket_id, &target)`. 6. `write_and_spawn_script("sad", &script, ctx)`. `sad_script`: ```sh #!/bin/sh set -e -APM="" +APM="${APM_BIN:?APM_BIN not set — see wrapper contract}" ID="" "$APM" spec "$ID" --section "Problem" --set "Mock sad run — spec intentionally incomplete." printf '{"type":"tool_use","id":"mock-1","name":"write_partial_spec","input":{}}\n' @@ -277,7 +279,7 @@ rm -f "$0" 1. `load_transitions_with_outcomes(ctx)`. 2. Empty → bail. -3. `seed = seed_from_ctx(ctx)`, pick `idx = seed as usize % all.len()`, chosen = `all[idx]`. +3. `seed = seed_from_env()`, pick `idx = seed as usize % all.len()`, chosen = `all[idx]`. 4. `outcome = resolve_outcome(&chosen.0, &chosen.1)`. 5. If `outcome == "success"` → `happy_script(...)`, else → `sad_script(...)`. 6. `write_and_spawn_script("random", &script, ctx)`. @@ -415,27 +417,35 @@ fn write_and_spawn_script( 1. Write `script` to `/.apm-mock--.sh` (using `rand_u16()` from d3b93b95) 2. Set permissions to 0o755 (`std::fs::set_permissions(..., Permissions::from_mode(0o755))`) 3. Build `Command::new("/bin/sh")`, arg = script path -4. Set all APM contract env vars (same set as ClaudeWrapper), including `APM_PROJECT_ROOT` +4. Set all APM contract env vars (same set as ClaudeWrapper), including `APM_PROJECT_ROOT`. + **`APM_BIN` override for tests:** when setting `APM_BIN`, check `ctx.options.get("apm_bin")` first; if present, use that value instead of `std::env::current_exe()`. Shell scripts always use `$APM_BIN` — the override lives in the spawn helper, not in the mock logic. 5. `.current_dir(&ctx.worktree_path)`, `.process_group(0)` 6. Redirect stdout + stderr to `File::create(&ctx.log_path)?` / `try_clone()` 7. `.spawn()`; return `Child` 8. The script's last line is `rm -f "$0"` (self-cleanup); no separate cleanup thread needed for the script file -#### `apm_bin` +#### `seed_from_env` ```rust -fn apm_bin() -> anyhow::Result +fn seed_from_env() -> u64 { + std::env::var("APM_OPT_SEED") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or_else(|| rand::thread_rng().gen::()) +} ``` -Returns `std::env::current_exe()?.to_str().ok_or_else(...)?.to_string()`. Used so scripts shell out to the same `apm` binary that spawned them. +Reads `APM_OPT_SEED` from the current process environment — consistent with how wrapper-specific options are consumed by external wrappers (ticket 6cac8518 maps `[workers.options] seed = "42"` → `APM_OPT_SEED=42`). Falls back to a random `u64`. Tests that need a fixed seed call `std::env::set_var("APM_OPT_SEED", "42")` before spawning and `std::env::remove_var` after; run those tests serially since `set_var` is not thread-safe. -#### `seed_from_ctx` +#### Shell script preamble -```rust -fn seed_from_ctx(ctx: &WrapperContext) -> u64 +Every mock script reads the `apm` binary path from the environment — no hard-coded path interpolation: + +```sh +APM="${APM_BIN:?APM_BIN not set — see wrapper contract}" ``` -Reads `ctx.options.get("seed")` → parse as `u64`; on failure falls back to a random `u64` from `rand::thread_rng()`. +`APM_BIN` is guaranteed present because `write_and_spawn_script` always sets it. The `:?` expansion exits non-zero immediately with a clear error if the variable is somehow absent. ### 5. MockHappyWrapper (`mock_happy.rs`) @@ -445,14 +455,13 @@ Reads `ctx.options.get("seed")` → parse as `u64`; on failure falls back to a r 3. Match on count: 0 → `anyhow::bail!("mock-happy: no success-outcome transition from state '{}'", ctx.current_state)`, 2+ → `anyhow::bail!("mock-happy: {} success-outcome transitions; expected 1", n)`. 4. Extract `target_state = success_transitions[0].0.to.clone()`. 5. `let impl_mode = is_impl_mode(&transitions)`. -6. `let apm = apm_bin()?`. -7. Generate `script`: +6. Generate `script` via `happy_script(&ctx.ticket_id, &target_state, impl_mode)`: **Spec mode** (not impl mode): ```sh #!/bin/sh set -e - APM="" + APM="${APM_BIN:?APM_BIN not set — see wrapper contract}" ID="" "$APM" spec "$ID" --section "Problem" \ --set "Mock spec — no real problem analyzed." @@ -477,7 +486,7 @@ Reads `ctx.options.get("seed")` → parse as `u64`; on failure falls back to a r ```sh #!/bin/sh set -e - APM="" + APM="${APM_BIN:?APM_BIN not set — see wrapper contract}" ID="" printf 'mock: placeholder implementation for ticket %s\n' "$ID" \ > mock-implementation.txt @@ -489,7 +498,7 @@ Reads `ctx.options.get("seed")` → parse as `u64`; on failure falls back to a r rm -f "$0" ``` -8. Call `write_and_spawn_script("happy", &script, ctx)`. +7. Call `write_and_spawn_script("happy", &script, ctx)`. ### 6. MockSadWrapper (`mock_sad.rs`) @@ -497,16 +506,15 @@ Reads `ctx.options.get("seed")` → parse as `u64`; on failure falls back to a r 1. `load_transitions_with_outcomes(ctx)`. 2. Filter to non-success: `resolve_outcome(t, s) != "success"`. 3. If empty: `anyhow::bail!("mock-sad: no non-success transitions from state '{}'", ctx.current_state)`. -4. `let seed = seed_from_ctx(ctx)`. -5. `let rng = rand::rngs::StdRng::seed_from_u64(seed)`. -6. Pick index = `(seed as usize) % eligible.len()` (no need for a shuffle; modulo gives deterministic pick for a given seed and list length). -7. `target_state = eligible[idx].0.to.clone()`. -8. Generate script (writes only Problem section, adds an open question, emits one JSONL event, calls `apm state`): +4. `let seed = seed_from_env()`. +5. Pick index = `(seed as usize) % eligible.len()` (modulo gives deterministic pick for a given seed and list length). +6. `target_state = eligible[idx].0.to.clone()`. +7. Generate script via `sad_script(&ctx.ticket_id, &target_state)` (writes only Problem section, adds an open question, emits one JSONL event, calls `apm state`): ```sh #!/bin/sh set -e - APM="" + APM="${APM_BIN:?APM_BIN not set — see wrapper contract}" ID="" "$APM" spec "$ID" --section "Problem" \ --set "Mock sad run — spec intentionally incomplete." @@ -517,7 +525,7 @@ Reads `ctx.options.get("seed")` → parse as `u64`; on failure falls back to a r rm -f "$0" ``` -9. `write_and_spawn_script("sad", &script, ctx)`. +8. `write_and_spawn_script("sad", &script, ctx)`. Note: `apm spec --section "Open questions"` must be a valid section name for `apm spec --set`. Verify the exact section name against the `apm spec` command's accepted sections; if "Open questions" isn't a named section, write to it via the ticket file directly or skip the question step. @@ -526,21 +534,20 @@ Note: `apm spec --section "Open questions"` must be a valid section name for `ap `spawn()` steps: 1. `load_transitions_with_outcomes(ctx)`. 2. If empty: `anyhow::bail!("mock-random: no valid transitions from state '{}'", ctx.current_state)`. -3. `seed_from_ctx(ctx)`. +3. `let seed = seed_from_env()`. 4. Pick index via `seed as usize % all.len()`. 5. Inspect chosen transition's `resolve_outcome`: - `"success"` → generate the mock-happy script for the chosen `target_state` (spec or impl mode determined by `is_impl_mode(&all)`) - anything else → generate the mock-sad script for the chosen `target_state` 6. `write_and_spawn_script("random", &script, ctx)`. -Rather than duplicating script generation, extract private functions `happy_script(apm: &str, id: &str, target: &str, impl_mode: bool) -> String` and `sad_script(apm: &str, id: &str, target: &str) -> String` into `builtin/mod.rs` and call them from all three wrappers. +Rather than duplicating script generation, extract private functions `happy_script(id: &str, target: &str, impl_mode: bool) -> String` and `sad_script(id: &str, target: &str) -> String` into `builtin/mod.rs` and call them from all three wrappers. Neither takes an `apm` path parameter — scripts use `${APM_BIN:?...}` at runtime. ### 8. DebugWrapper (`debug.rs`) `spawn()` steps: -1. No config loading. No transition resolution. -2. `apm_bin()`. -3. Script: +1. No config loading. No transition resolution. No `apm` CLI calls. +2. Script: ```sh #!/bin/sh @@ -573,19 +580,43 @@ Each test uses the same fixture helper (inline, no external files): - Copy (or write inline) the default workflow.toml to `.apm/workflow.toml` — including the two new `outcome = "success"` annotations - Create a ticket file in `tickets/` in the correct starting state - `git init`, add + commit the files (required for worktree and state operations) -- Build a `WrapperContext` pointing at the fixture with `current_state` set +- Build a `WrapperContext` pointing at the fixture with `current_state` set, and **`options["apm_bin"] = `** so `write_and_spawn_script` uses that path for `APM_BIN` in the child env instead of the test runner's `current_exe()`. Locate the real binary via the same method the existing `spawn_worker_cwd_is_ticket_worktree` test uses (compile-time env var, `which`, or test-dep build artifact). - Call `spawn_worker(ctx)` (the private fn from d3b93b95), `child.wait()`, then read the updated ticket +**Seed injection for reproducibility tests:** call `std::env::set_var("APM_OPT_SEED", "42")` immediately before each spawn that needs a fixed seed; call `std::env::remove_var("APM_OPT_SEED")` after. Mark these tests `#[serial]` (via the `serial_test` crate or equivalent) since `set_var` is not thread-safe. + Test list: - `mock_happy_spec_mode_transitions_to_specd` — ticket in `in_design`; assert state = `specd`; assert all four spec section headers are present in the ticket file; assert effort = 1; assert risk = 1 - `mock_happy_impl_mode_creates_commit_and_transitions` — ticket in `in_progress`; assert state = `implemented`; assert `git log --oneline` in worktree has a commit containing "mock" - `mock_happy_zero_success_transitions_exits_nonzero` — use a custom inline workflow where the current state has only non-success transitions; assert `child.wait().status.success() == false`; assert log contains "no success-outcome transition" - `mock_sad_transitions_to_non_success_state` — ticket in `in_design`; assert resulting state is NOT `specd`; assert only Problem section is present in ticket spec -- `mock_sad_seed_reproducibility` — two separate spawn calls with `APM_OPT_SEED=42`; assert both end in the same target state -- `mock_random_seed_reproducibility` — same as above with `mock-random` -- `debug_does_not_change_state` — ticket in `in_design`; run debug; assert state is still `in_design`; assert log contains `APM_TICKET_ID`; assert log contains the system prompt text; assert log contains a line matching `{"type":"tool_use"...}` +- `mock_sad_seed_reproducibility` — two separate spawn calls with `APM_OPT_SEED=42` (via `set_var`); assert both end in the same target state; run serially +- `mock_random_seed_reproducibility` — same as above with `mock-random`; run serially +- `debug_does_not_change_state` — ticket in `in_design`; run debug (no `apm_bin` override needed — debug never calls `$APM`); assert state is still `in_design`; assert log contains `APM_TICKET_ID`; assert log contains the system prompt text; assert log contains a line matching `{"type":"tool_use"` + +### 10. Per-agent instruction file stubs + +Each of the four built-in wrappers needs `apm.worker.md` and `apm.spec-writer.md` stubs so that ticket 7f5f73d5's resolution chain does not fall through to a level-5 hard error when a project is configured to use a mock. + +**Files to create** under `apm-core/src/default/agents/`: + +``` +mock-happy/apm.worker.md +mock-happy/apm.spec-writer.md +mock-sad/apm.worker.md +mock-sad/apm.spec-writer.md +mock-random/apm.worker.md +mock-random/apm.spec-writer.md +debug/apm.worker.md +debug/apm.spec-writer.md +``` + +**Content for each file** (identical stub — mocks ignore the prompt entirely): +``` +This wrapper is a mock — see docs/agent-wrappers.md. +``` -All tests that require `apm` CLI calls in the script must resolve `current_exe()` correctly — in the test binary environment, `current_exe()` returns the test runner, not `apm`. Use the same workaround as the existing `spawn_worker_cwd_is_ticket_worktree` test: set a fixture env var or pass an `apm_override_bin` through `WrapperContext.options` (key `"apm_bin"`) that the mock script uses when set. The `apm_bin()` helper checks `ctx.options.get("apm_bin")` first, then falls back to `current_exe()`. +**Embedding:** follow the same mechanism used for other default agent files in `apm-core/src/default/`. If defaults are embedded via `include_str!` in Rust, add the corresponding entries. If they are copied during `apm init`, add them to the copy manifest. Mirror the pattern established by existing default agent files (e.g., `claude/`). ### Open questions From ef7f7468708df74d8f3e7d21275b91ad077b089e Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 18:57:11 -0700 Subject: [PATCH 183/305] =?UTF-8?q?ticket(25c92daa):=20in=5Fdesign=20?= =?UTF-8?q?=E2=86=92=20specd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md index 16571dbf6..c34e3c1c6 100644 --- a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md +++ b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md @@ -1,7 +1,7 @@ +++ id = "25c92daa" title = "Mock and debug built-in wrappers (mock-happy, mock-sad, mock-random, debug)" -state = "in_design" +state = "specd" priority = 0 effort = 6 risk = 4 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/25c92daa-mock-and-debug-built-in-wrappers-mock-ha" created_at = "2026-04-30T20:04:21.901984Z" -updated_at = "2026-05-01T01:43:18.690653Z" +updated_at = "2026-05-01T01:57:11.578116Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "a1b94ea4", "6cac8518"] @@ -642,4 +642,5 @@ This wrapper is a mock — see docs/agent-wrappers.md. | 2026-05-01T00:09Z | ammend | in_design | philippepascal | | 2026-05-01T00:21Z | in_design | specd | claude-0501-0009-cec0 | | 2026-05-01T01:10Z | specd | ammend | philippepascal | -| 2026-05-01T01:43Z | ammend | in_design | philippepascal | \ No newline at end of file +| 2026-05-01T01:43Z | ammend | in_design | philippepascal | +| 2026-05-01T01:57Z | in_design | specd | claude-0501-0143-f3e8 | From d914d4f2fe61eaed65783a54cf68a18d6bf71d8e Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 19:31:57 -0700 Subject: [PATCH 184/305] ui: edit ticket body --- ...3bf07-output-parser-strategy-external-parsers-.md | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md index 5ddde1b0a..01808de62 100644 --- a/tickets/2803bf07-output-parser-strategy-external-parsers-.md +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -185,14 +185,12 @@ Use absolute paths for `parser_command` in the integration test to avoid dependi ### Amendment requests -- [ ] Drop the `raw` parser mode from this ticket entirely. The reference spec at `docs/agent-wrappers.md` defines three strategies (canonical, in-wrapper translation, external) and never mentions a `raw` mode. Adding it here without first updating the design doc creates a documentation drift and adds a third behaviour to validate that nobody asked for. Anyone wanting raw output today can use `parser = "external"` with `parser_command = "cat"` (or any pass-through). If `raw` proves genuinely useful later, file a follow-up ticket that updates the design doc first. Remove the `raw` AC, the `raw` integration test, and any Approach text describing it. -- [ ] Clarify child exit-code semantics for `parser = "external"`. The wrapper and the parser are two children connected by a pipe (wrapper.stdout → parser.stdin). Add an AC: "the worker's exit status is taken from the **parser's** exit code; the wrapper's exit code is logged for diagnostics but does not affect ticket state. If the wrapper exits non-zero before the parser drains, the parser is allowed to finish naturally before APM reaps both." This removes ambiguity for the implementer about which Child handle to wait on. -- [ ] Add an AC for stream-capture loss-prevention: "all three streams (parser.stdout, parser.stderr, wrapper.stderr) are written to `.apm-worker.log` without truncation, even when one stream produces output much faster than another. Ordering is best-effort; lossless capture is required." This guards against a real bug class (pipe buffer races) while staying realistic about ordering guarantees. -- [ ] Update the TOML example in `docs/agent-wrappers.md` (sections "Custom wrappers / manifest.toml" and "Output parser strategy") to mention all parser modes the implementation will support — currently `canonical` and `external`. This keeps the spec doc in sync with the implementation. Out of scope for this ticket: no other doc changes. +- [x] Drop the `raw` parser mode from this ticket entirely. The reference spec at `docs/agent-wrappers.md` defines three strategies (canonical, in-wrapper translation, external) and never mentions a `raw` mode. Adding it here without first updating the design doc creates a documentation drift and adds a third behaviour to validate that nobody asked for. Anyone wanting raw output today can use `parser = "external"` with `parser_command = "cat"` (or any pass-through). If `raw` proves genuinely useful later, file a follow-up ticket that updates the design doc first. Remove the `raw` AC, the `raw` integration test, and any Approach text describing it. +- [x] Clarify child exit-code semantics for `parser = "external"`. The wrapper and the parser are two children connected by a pipe (wrapper.stdout → parser.stdin). Add an AC: "the worker's exit status is taken from the **parser's** exit code; the wrapper's exit code is logged for diagnostics but does not affect ticket state. If the wrapper exits non-zero before the parser drains, the parser is allowed to finish naturally before APM reaps both." This removes ambiguity for the implementer about which Child handle to wait on. +- [x] Add an AC for stream-capture loss-prevention: "all three streams (parser.stdout, parser.stderr, wrapper.stderr) are written to `.apm-worker.log` without truncation, even when one stream produces output much faster than another. Ordering is best-effort; lossless capture is required." This guards against a real bug class (pipe buffer races) while staying realistic about ordering guarantees. +- [x] Update the TOML example in `docs/agent-wrappers.md` (sections "Custom wrappers / manifest.toml" and "Output parser strategy") to mention all parser modes the implementation will support — currently `canonical` and `external`. This keeps the spec doc in sync with the implementation. Out of scope for this ticket: no other doc changes. ### Code review - - ## History | When | From | To | By | @@ -202,4 +200,4 @@ Use absolute paths for `parser_command` in the integration test to avoid dependi | 2026-05-01T00:34Z | groomed | in_design | philippepascal | | 2026-05-01T00:42Z | in_design | specd | claude-0501-0034-4620 | | 2026-05-01T01:10Z | specd | ammend | philippepascal | -| 2026-05-01T01:37Z | ammend | in_design | philippepascal | +| 2026-05-01T01:37Z | ammend | in_design | philippepascal | \ No newline at end of file From 164de8b83e902105ad461dbdf2cd65f2cad9bd04 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 19:31:59 -0700 Subject: [PATCH 185/305] =?UTF-8?q?ticket(2803bf07):=20in=5Fdesign=20?= =?UTF-8?q?=E2=86=92=20specd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2803bf07-output-parser-strategy-external-parsers-.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md index 01808de62..74025b6a7 100644 --- a/tickets/2803bf07-output-parser-strategy-external-parsers-.md +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -1,7 +1,7 @@ +++ id = "2803bf07" title = "Output parser strategy: external parsers via manifest.toml" -state = "in_design" +state = "specd" priority = 0 effort = 5 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/2803bf07-output-parser-strategy-external-parsers-" created_at = "2026-04-30T20:05:40.844536Z" -updated_at = "2026-05-01T01:37:02.861480Z" +updated_at = "2026-05-01T02:31:59.273153Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["2c32a282", "2e772eab"] @@ -200,4 +200,5 @@ Use absolute paths for `parser_command` in the integration test to avoid dependi | 2026-05-01T00:34Z | groomed | in_design | philippepascal | | 2026-05-01T00:42Z | in_design | specd | claude-0501-0034-4620 | | 2026-05-01T01:10Z | specd | ammend | philippepascal | -| 2026-05-01T01:37Z | ammend | in_design | philippepascal | \ No newline at end of file +| 2026-05-01T01:37Z | ammend | in_design | philippepascal | +| 2026-05-01T02:31Z | in_design | specd | philippepascal | From 846e987db2d8993343a96ed8d75b849ac4db1297 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 19:53:15 -0700 Subject: [PATCH 186/305] ui: edit ticket body --- .../2803bf07-output-parser-strategy-external-parsers-.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md index 74025b6a7..d88c9d598 100644 --- a/tickets/2803bf07-output-parser-strategy-external-parsers-.md +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -185,9 +185,9 @@ Use absolute paths for `parser_command` in the integration test to avoid dependi ### Amendment requests -- [x] Drop the `raw` parser mode from this ticket entirely. The reference spec at `docs/agent-wrappers.md` defines three strategies (canonical, in-wrapper translation, external) and never mentions a `raw` mode. Adding it here without first updating the design doc creates a documentation drift and adds a third behaviour to validate that nobody asked for. Anyone wanting raw output today can use `parser = "external"` with `parser_command = "cat"` (or any pass-through). If `raw` proves genuinely useful later, file a follow-up ticket that updates the design doc first. Remove the `raw` AC, the `raw` integration test, and any Approach text describing it. -- [x] Clarify child exit-code semantics for `parser = "external"`. The wrapper and the parser are two children connected by a pipe (wrapper.stdout → parser.stdin). Add an AC: "the worker's exit status is taken from the **parser's** exit code; the wrapper's exit code is logged for diagnostics but does not affect ticket state. If the wrapper exits non-zero before the parser drains, the parser is allowed to finish naturally before APM reaps both." This removes ambiguity for the implementer about which Child handle to wait on. -- [x] Add an AC for stream-capture loss-prevention: "all three streams (parser.stdout, parser.stderr, wrapper.stderr) are written to `.apm-worker.log` without truncation, even when one stream produces output much faster than another. Ordering is best-effort; lossless capture is required." This guards against a real bug class (pipe buffer races) while staying realistic about ordering guarantees. +- [ ] Drop the `raw` parser mode from this ticket entirely. The reference spec at `docs/agent-wrappers.md` defines three strategies (canonical, in-wrapper translation, external) and never mentions a `raw` mode. Adding it here without first updating the design doc creates a documentation drift and adds a third behaviour to validate that nobody asked for. Anyone wanting raw output today can use `parser = "external"` with `parser_command = "cat"` (or any pass-through). If `raw` proves genuinely useful later, file a follow-up ticket that updates the design doc first. Remove the `raw` AC, the `raw` integration test, and any Approach text describing it. +- [ ] Clarify child exit-code semantics for `parser = "external"`. The wrapper and the parser are two children connected by a pipe (wrapper.stdout → parser.stdin). Add an AC: "the worker's exit status is taken from the **parser's** exit code; the wrapper's exit code is logged for diagnostics but does not affect ticket state. If the wrapper exits non-zero before the parser drains, the parser is allowed to finish naturally before APM reaps both." This removes ambiguity for the implementer about which Child handle to wait on. +- [ ] Add an AC for stream-capture loss-prevention: "all three streams (parser.stdout, parser.stderr, wrapper.stderr) are written to `.apm-worker.log` without truncation, even when one stream produces output much faster than another. Ordering is best-effort; lossless capture is required." This guards against a real bug class (pipe buffer races) while staying realistic about ordering guarantees. - [x] Update the TOML example in `docs/agent-wrappers.md` (sections "Custom wrappers / manifest.toml" and "Output parser strategy") to mention all parser modes the implementation will support — currently `canonical` and `external`. This keeps the spec doc in sync with the implementation. Out of scope for this ticket: no other doc changes. ### Code review @@ -201,4 +201,4 @@ Use absolute paths for `parser_command` in the integration test to avoid dependi | 2026-05-01T00:42Z | in_design | specd | claude-0501-0034-4620 | | 2026-05-01T01:10Z | specd | ammend | philippepascal | | 2026-05-01T01:37Z | ammend | in_design | philippepascal | -| 2026-05-01T02:31Z | in_design | specd | philippepascal | +| 2026-05-01T02:31Z | in_design | specd | philippepascal | \ No newline at end of file From 9f308e7b02c7f1d8e659472e9f0b5c6c9828d4a6 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 19:53:17 -0700 Subject: [PATCH 187/305] =?UTF-8?q?ticket(2803bf07):=20specd=20=E2=86=92?= =?UTF-8?q?=20ammend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2803bf07-output-parser-strategy-external-parsers-.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md index d88c9d598..601ae6131 100644 --- a/tickets/2803bf07-output-parser-strategy-external-parsers-.md +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -1,7 +1,7 @@ +++ id = "2803bf07" title = "Output parser strategy: external parsers via manifest.toml" -state = "specd" +state = "ammend" priority = 0 effort = 5 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/2803bf07-output-parser-strategy-external-parsers-" created_at = "2026-04-30T20:05:40.844536Z" -updated_at = "2026-05-01T02:31:59.273153Z" +updated_at = "2026-05-01T02:53:17.280220Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["2c32a282", "2e772eab"] @@ -201,4 +201,5 @@ Use absolute paths for `parser_command` in the integration test to avoid dependi | 2026-05-01T00:42Z | in_design | specd | claude-0501-0034-4620 | | 2026-05-01T01:10Z | specd | ammend | philippepascal | | 2026-05-01T01:37Z | ammend | in_design | philippepascal | -| 2026-05-01T02:31Z | in_design | specd | philippepascal | \ No newline at end of file +| 2026-05-01T02:31Z | in_design | specd | philippepascal | +| 2026-05-01T02:53Z | specd | ammend | philippepascal | From 8d054742b93984e8c94deb97fd1faf94a2cbab6b Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 19:53:24 -0700 Subject: [PATCH 188/305] =?UTF-8?q?ticket(2803bf07):=20start=20=E2=80=94?= =?UTF-8?q?=20ammend=20=E2=86=92=20in=5Fdesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/2803bf07-output-parser-strategy-external-parsers-.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md index 601ae6131..49bf1c0e7 100644 --- a/tickets/2803bf07-output-parser-strategy-external-parsers-.md +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -1,7 +1,7 @@ +++ id = "2803bf07" title = "Output parser strategy: external parsers via manifest.toml" -state = "ammend" +state = "in_design" priority = 0 effort = 5 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/2803bf07-output-parser-strategy-external-parsers-" created_at = "2026-04-30T20:05:40.844536Z" -updated_at = "2026-05-01T02:53:17.280220Z" +updated_at = "2026-05-01T02:53:24.755840Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["2c32a282", "2e772eab"] @@ -203,3 +203,4 @@ Use absolute paths for `parser_command` in the integration test to avoid dependi | 2026-05-01T01:37Z | ammend | in_design | philippepascal | | 2026-05-01T02:31Z | in_design | specd | philippepascal | | 2026-05-01T02:53Z | specd | ammend | philippepascal | +| 2026-05-01T02:53Z | ammend | in_design | philippepascal | From a37faf10c85c04b28e4f003f0009de98ff44fd10 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 19:59:20 -0700 Subject: [PATCH 189/305] ticket(2803bf07): set section Problem --- ...803bf07-output-parser-strategy-external-parsers-.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md index 49bf1c0e7..d9cdd5100 100644 --- a/tickets/2803bf07-output-parser-strategy-external-parsers-.md +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -25,9 +25,8 @@ Support agents whose output is too far from APM's canonical JSONL stream-json to **Scope:** - `manifest.toml` already parses `parser` and `parser_command` (added in ticket 2c32a282). This ticket wires them into spawn. -- Three parser modes: +- Two parser modes: - `parser = "canonical"` (default) — wrapper produces JSONL stream-json directly. No transformation. Today's behaviour. - - `parser = "raw"` — wrapper output is captured as-is to log; no canonical-event parsing. Useful for agents whose output is unstructured or only meant for human reading. Worker-state events still drive off the wrapper's exit code and any `apm state` calls it makes. - `parser = "external"` — wrapper output is piped through the binary at `parser_command` (must be in PATH or absolute path); the parser's stdout becomes APM's captured stream. Parser must produce canonical JSONL. - Spawn glue: when `parser = "external"`, spawn the wrapper and the parser as a pipe (wrapper.stdout → parser.stdin). Capture parser.stdout (canonical events) and parser.stderr (parser's diagnostics) to `.apm-worker.log`. The wrapper's stderr also goes to the log directly. - Validate `parser_command` exists when `parser = "external"`. Fail at spawn with a clear error if not. @@ -40,8 +39,7 @@ Support agents whose output is too far from APM's canonical JSONL stream-json to **Tests:** - `canonical` mode: existing wrapper tests unchanged. -- `raw` mode: a wrapper that emits "hello world" → log contains "hello world" verbatim, no JSONL parse warnings. -- `external` mode: a wrapper that emits non-JSONL text + a parser script that wraps each line in a JSONL event → log contains the parsed JSONL, original text only in the parser's stderr. +- `external` mode: a wrapper that emits non-JSONL text + a parser script that wraps each line in a JSONL event → log contains the parsed JSONL. - Missing parser_command → spawn fails with a clear error citing the manifest path. ### Acceptance criteria @@ -191,6 +189,8 @@ Use absolute paths for `parser_command` in the integration test to avoid dependi - [x] Update the TOML example in `docs/agent-wrappers.md` (sections "Custom wrappers / manifest.toml" and "Output parser strategy") to mention all parser modes the implementation will support — currently `canonical` and `external`. This keeps the spec doc in sync with the implementation. Out of scope for this ticket: no other doc changes. ### Code review + + ## History | When | From | To | By | @@ -203,4 +203,4 @@ Use absolute paths for `parser_command` in the integration test to avoid dependi | 2026-05-01T01:37Z | ammend | in_design | philippepascal | | 2026-05-01T02:31Z | in_design | specd | philippepascal | | 2026-05-01T02:53Z | specd | ammend | philippepascal | -| 2026-05-01T02:53Z | ammend | in_design | philippepascal | +| 2026-05-01T02:53Z | ammend | in_design | philippepascal | \ No newline at end of file From 991f38ec3a71a34880ce75ac786777c0e39d632d Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 19:59:25 -0700 Subject: [PATCH 190/305] ticket(2803bf07): set section Acceptance criteria --- tickets/2803bf07-output-parser-strategy-external-parsers-.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md index d9cdd5100..241ed8d3e 100644 --- a/tickets/2803bf07-output-parser-strategy-external-parsers-.md +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -46,7 +46,6 @@ Support agents whose output is too far from APM's canonical JSONL stream-json to - [ ] When `parser` is absent from manifest.toml, or manifest.toml itself is absent, `CustomWrapper::spawn` captures the wrapper's stdout directly to the log file (equivalent to `parser = "canonical"`) - [ ] When `parser = "canonical"`, `CustomWrapper::spawn` captures the wrapper's stdout directly to the log file with no transformation; the wrapper's stderr is also written to the same log file -- [ ] When `parser = "raw"`, `CustomWrapper::spawn` captures the wrapper's stdout verbatim to the log file; a wrapper emitting `"hello world\n"` produces a log whose content includes that text with no `[apm] warning:` lines injected by APM - [ ] When `parser = "external"` and `parser_command` resolves to an executable binary, `CustomWrapper::spawn` creates an OS-level pipe: the wrapper's stdout is the parser's stdin; the parser's stdout is captured to the log file - [ ] When `parser = "external"`, the wrapper's stderr is written to the log file independently (not through the parser pipe) - [ ] When `parser = "external"`, the parser's stderr is also written to the log file @@ -55,6 +54,8 @@ Support agents whose output is too far from APM's canonical JSONL stream-json to - [ ] `apm validate` reports an error when a custom wrapper's manifest.toml declares `parser = "external"` but `parser_command` is absent - [ ] Built-in wrappers (e.g. the `claude` built-in) always behave as `parser = "canonical"` regardless of any manifest file; no manifest is required or consulted for them - [ ] `CustomWrapper::spawn` for external mode returns the parser's `Child` handle; the wrapper child is reaped in a background thread so it does not become a zombie +- [ ] When `parser = "external"`, the worker's exit status is taken from the parser's exit code; the wrapper's exit code is appended to the log file as a diagnostic line (e.g. `[apm] wrapper exited: exit status: 0`) but does not affect ticket state; if the wrapper exits non-zero before the parser has drained its stdin, the parser is allowed to finish naturally before APM reaps both +- [ ] When `parser = "external"`, all three streams (parser stdout, parser stderr, wrapper stderr) are written to `.apm-worker.log` without truncation, even when one stream produces output much faster than another; ordering between streams is best-effort but no bytes from any stream may be dropped ### Out of scope From 9c3ff22592d1cd0e7584b902acc11b33fee5c5bc Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 19:59:30 -0700 Subject: [PATCH 191/305] ticket(2803bf07): set section Out of scope --- tickets/2803bf07-output-parser-strategy-external-parsers-.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md index 241ed8d3e..5b2acf90c 100644 --- a/tickets/2803bf07-output-parser-strategy-external-parsers-.md +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -63,11 +63,10 @@ Support agents whose output is too far from APM's canonical JSONL stream-json to - Defining the canonical JSONL event vocabulary — already noted as an open question in `docs/agent-wrappers.md`; any parser binary this ticket tests against must emit valid JSONL but the schema is not defined here - Multiplexing more than one parser per wrapper; manifest.toml accepts exactly one `parser` value and one optional `parser_command` - In-wrapper translation (`parser = "canonical"` where the wrapper itself transforms output inline) — that is wrapper-author responsibility; APM does not assist with it +- `parser = "raw"` mode (verbatim pass-through capture without JSONL parsing) — projects that need this today can use `parser = "external"` with `parser_command = "cat"`; a dedicated `raw` mode may be added in a follow-up ticket after `docs/agent-wrappers.md` is updated to define it - The `apm agents test ` command for smoke-testing parser compliance — ticket 71d80e40 - Per-ticket parser override via frontmatter `agent_overrides` — ticket 0ca3e019 -- Propagating the wrapper child's exit code to APM when both wrapper and parser run; the parser child's exit code is the effective worker exit code for this ticket - Non-Unix platform differences in subprocess piping (Windows `Stdio::from(ChildStdout)` semantics) — out of scope for now; the implementation targets Unix -- Updating `docs/agent-wrappers.md` to document the `raw` parser mode — should be a follow-up to this ticket once the behaviour is validated ### Approach From 9b733a808d332d7294bc558152eb771b099125f2 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 19:59:35 -0700 Subject: [PATCH 192/305] ticket(2803bf07): set section Approach --- ...utput-parser-strategy-external-parsers-.md | 120 +++++++++++++++++- 1 file changed, 118 insertions(+), 2 deletions(-) diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md index 5b2acf90c..4c4a8cb8a 100644 --- a/tickets/2803bf07-output-parser-strategy-external-parsers-.md +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -70,7 +70,7 @@ Support agents whose output is too far from APM's canonical JSONL stream-json to ### Approach -Wire the `parser` and `parser_command` fields (already parsed from manifest.toml by ticket 2c32a282) into `CustomWrapper::spawn`. Add a `ParserStrategy` enum that dispatches between three modes: `canonical` and `raw` both redirect wrapper stdout directly to the log file; `external` spawns an OS-level pipe so wrapper stdout feeds parser stdin and the parser's stdout is captured to the log. Add `which`-based pre-validation for the parser binary. Extend `validate_agents` to catch a missing `parser_command` at config-check time. +Wire the `parser` and `parser_command` fields (already parsed from manifest.toml by ticket 2c32a282) into `CustomWrapper::spawn`. Add a `ParserStrategy` enum that dispatches between two modes: `canonical` redirects wrapper stdout directly to the log file; `external` spawns an OS-level pipe so wrapper stdout feeds parser stdin and the parser's stdout is captured to the log. Add `which`-based pre-validation for the parser binary. Extend `validate_agents` to catch a missing `parser_command` at config-check time. **Files changed** @@ -78,7 +78,8 @@ Wire the `parser` and `parser_command` fields (already parsed from manifest.toml |---|---| | `apm-core/src/wrapper/custom.rs` | Add `ParserStrategy` enum; refactor `CustomWrapper::spawn` to dispatch by strategy; implement OS-level pipe for `external` mode | | `apm-core/src/validate.rs` | Extend `validate_agents` to push an error when `parser = "external"` and `parser_command` is absent | -| `apm-core/tests/custom_wrapper_integration.rs` | Add three integration tests covering canonical, raw, and external modes | +| `apm-core/tests/custom_wrapper_integration.rs` | Add two integration tests covering canonical and external modes | +| `docs/agent-wrappers.md` | Verify/update TOML examples in "Custom wrappers / manifest.toml" and "Output parser strategy" sections to list the two supported parser modes (`canonical`, `external`) and confirm no `raw` mode is mentioned | --- @@ -86,6 +87,121 @@ Wire the `parser` and `parser_command` fields (already parsed from manifest.toml Add a private enum above `CustomWrapper`: +```rust +#[derive(Debug, Clone, PartialEq)] +enum ParserStrategy { Canonical, External } + +impl ParserStrategy { + fn from_manifest(m: Option<&Manifest>) -> Self { + match m.and_then(|m| Some(m.parser.as_str())) { + Some("external") => Self::External, + _ => Self::Canonical, // absent, "canonical", or any other value + } + } +} +``` + +--- + +### `wrapper/custom.rs` — refactor `CustomWrapper::spawn` + +After the existing `check_contract_version(...)` call and the env-var block (both established by prior tickets), derive the strategy and branch: + +```rust +let strategy = ParserStrategy::from_manifest(self.manifest.as_ref()); +``` + +**Canonical (stdout → log directly):** + +Keep the existing spawn sequence for canonical mode: +- `File::create(&ctx.log_path)?` → `log_file`; `log_file.try_clone()?` → `log_clone` +- `Command::new(&self.script_path).envs(...).current_dir(...).stdout(log_file).stderr(log_clone).process_group(0).spawn()` + +**External — validate, then pipe:** + +1. Derive the manifest path for error messages: `self.script_path.parent().unwrap().join("manifest.toml")`. + +2. Require `parser_command`: call `.ok_or_else(|| anyhow!("...: parser = \"external\" but parser_command is not set"))` on `self.manifest.as_ref().and_then(|m| m.parser_command.as_deref())`. Return `Err` immediately if absent — no subprocess is started. + +3. Validate the binary is findable before spawning the wrapper. Use `which::which(parser_cmd)`. Return `Err` naming the missing binary if not found. No subprocess is started yet. + +4. Open the log file and clone three independent file handles — one per stream — so concurrent writes to the same underlying file never block or lose bytes: + ```rust + let log_file = File::create(&ctx.log_path)?; + let wrapper_stderr_fd = log_file.try_clone()?; + let parser_stdout_fd = log_file.try_clone()?; + let parser_stderr_fd = log_file.try_clone()?; + // log_file itself is used only for the background-thread diagnostic write + let diag_fd = log_file.try_clone()?; + ``` + Each descriptor is a separate OS file description pointing at the same log file. The OS appends writes atomically for small chunks; for larger streams concurrent writes may interleave in ordering, but no bytes are lost because each stream has exclusive ownership of its fd. + +5. Spawn the wrapper: `stdout(Stdio::piped())`, `stderr(Stdio::from(wrapper_stderr_fd))`. Call `.process_group(0).spawn()?`. Take `wrapper_child.stdout.take()` → `wrapper_stdout`. + +6. Spawn the parser: `stdin(Stdio::from(wrapper_stdout))`, `stdout(Stdio::from(parser_stdout_fd))`, `stderr(Stdio::from(parser_stderr_fd))`. Call `.process_group(0).spawn()?` → `parser_child`. + +7. Reap the wrapper in a background thread to prevent zombie processes and append its exit status to the log as a diagnostic line: + ```rust + std::thread::spawn(move || { + match wrapper_child.wait() { + Ok(status) => { + let _ = writeln!(&diag_fd, "[apm] wrapper exited: {status}"); + } + Err(e) => { + let _ = writeln!(&diag_fd, "[apm] wrapper wait error: {e}"); + } + } + }); + ``` + The background thread holds the last reference to `wrapper_child`. Because the parser's stdin is `Stdio::from(wrapper_stdout)` (moved in step 6), the write-end of the pipe belongs to the wrapper. When the wrapper exits, its write-end closes, the parser reads EOF and exits naturally. APM waits on the parser child — not the wrapper — for the worker's exit status. + +8. Return `Ok(parser_child)`. The caller waits on this child; its exit code is the effective worker exit status. The wrapper's exit code (logged in step 7) is diagnostic only and does not affect ticket state. + +**Dependency note:** Add `which = "6"` to `apm-core/Cargo.toml` if not already present. As a fallback without the crate: walk `std::env::var("PATH")` entries and check `Path::new(entry).join(parser_cmd).is_file()` for relative names; accept the path as-is when `parser_cmd` starts with `/`. + +--- + +### `validate.rs` — extend `validate_agents` + +In the `Ok(Some(WrapperKind::Custom { manifest, .. }))` match arm, after existing manifest checks, add: + +```rust +if let Some(m) = &manifest { + if m.parser == "external" && m.parser_command.is_none() { + errors.push(format!( + "agent {name}: manifest.toml declares parser = \"external\" \ + but parser_command is absent" + )); + } +} +``` + +This mirrors the runtime check in `spawn` so `apm validate` catches the misconfiguration before any worker starts. + +--- + +### Tests + +**Unit tests in `wrapper/custom.rs` under `#[cfg(test)]`:** + +- `parser_strategy_defaults_to_canonical` — `ParserStrategy::from_manifest(None)` equals `Canonical` +- `parser_strategy_explicit_canonical` — manifest with `parser = "canonical"` → `Canonical` +- `parser_strategy_external` — manifest with `parser = "external"` → `External` +- `parser_strategy_unknown_falls_back_to_canonical` — manifest with `parser = "foobar"` → `Canonical` +- `spawn_external_missing_parser_command` — `CustomWrapper` with manifest `parser = "external"`, `parser_command = None`; assert `spawn()` returns `Err` whose message contains `"parser_command"` and `"not set"` +- `spawn_external_binary_not_found` — `parser_command = Some("nonexistent-binary-xyzzy-2803")`; assert `spawn()` returns `Err` naming that binary + +**Integration tests in `apm-core/tests/custom_wrapper_integration.rs`** (extend the file introduced by 2c32a282): + +- `integration_canonical_mode` — wrapper script emits one valid JSONL line; assert log contains that line verbatim; assert spawn returns `Ok` +- `integration_external_parser_pipe` — wrapper script emits `"raw line\n"` on stdout and exits 0; a second fixture script (the parser, mode 0o755, `#!/bin/sh`) reads each stdin line and emits `{"text":""}` on stdout; manifest declares `parser = "external"` and `parser_command` set to the absolute path of the parser fixture; assert spawn returns `Ok`; wait for the returned parser child to exit 0; read the log; assert log contains the string `raw line` wrapped in JSON; assert log contains a line starting with `[apm] wrapper exited:` + +Use absolute paths for `parser_command` in the integration test to avoid depending on test-harness PATH configuration. The `which` crate accepts absolute paths to existing executable files directly. + +### `wrapper/custom.rs` — `ParserStrategy` enum + +Add a private enum above `CustomWrapper`: + ```rust #[derive(Debug, Clone, PartialEq)] enum ParserStrategy { Canonical, Raw, External } From 6ac2ddb457ecc27d69abcb3243a0dd954c4976a9 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 19:59:45 -0700 Subject: [PATCH 193/305] ticket(2803bf07): mark "Drop the `raw` parser mode from this ticket entirely. The reference spec at `docs/agent-wrappers.md` defines three strategies (canonical, in-wrapper translation, external) and never mentions a `raw` mode. Adding it here without first updating the design doc creates a documentation drift and adds a third behaviour to validate that nobody asked for. Anyone wanting raw output today can use `parser = "external"` with `parser_command = "cat"` (or any pass-through). If `raw` proves genuinely useful later, file a follow-up ticket that updates the design doc first. Remove the `raw` AC, the `raw` integration test, and any Approach text describing it." in Amendment requests --- tickets/2803bf07-output-parser-strategy-external-parsers-.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md index 4c4a8cb8a..2cf4ab930 100644 --- a/tickets/2803bf07-output-parser-strategy-external-parsers-.md +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -299,7 +299,7 @@ Use absolute paths for `parser_command` in the integration test to avoid dependi ### Amendment requests -- [ ] Drop the `raw` parser mode from this ticket entirely. The reference spec at `docs/agent-wrappers.md` defines three strategies (canonical, in-wrapper translation, external) and never mentions a `raw` mode. Adding it here without first updating the design doc creates a documentation drift and adds a third behaviour to validate that nobody asked for. Anyone wanting raw output today can use `parser = "external"` with `parser_command = "cat"` (or any pass-through). If `raw` proves genuinely useful later, file a follow-up ticket that updates the design doc first. Remove the `raw` AC, the `raw` integration test, and any Approach text describing it. +- [x] Drop the `raw` parser mode from this ticket entirely. The reference spec at `docs/agent-wrappers.md` defines three strategies (canonical, in-wrapper translation, external) and never mentions a `raw` mode. Adding it here without first updating the design doc creates a documentation drift and adds a third behaviour to validate that nobody asked for. Anyone wanting raw output today can use `parser = "external"` with `parser_command = "cat"` (or any pass-through). If `raw` proves genuinely useful later, file a follow-up ticket that updates the design doc first. Remove the `raw` AC, the `raw` integration test, and any Approach text describing it. - [ ] Clarify child exit-code semantics for `parser = "external"`. The wrapper and the parser are two children connected by a pipe (wrapper.stdout → parser.stdin). Add an AC: "the worker's exit status is taken from the **parser's** exit code; the wrapper's exit code is logged for diagnostics but does not affect ticket state. If the wrapper exits non-zero before the parser drains, the parser is allowed to finish naturally before APM reaps both." This removes ambiguity for the implementer about which Child handle to wait on. - [ ] Add an AC for stream-capture loss-prevention: "all three streams (parser.stdout, parser.stderr, wrapper.stderr) are written to `.apm-worker.log` without truncation, even when one stream produces output much faster than another. Ordering is best-effort; lossless capture is required." This guards against a real bug class (pipe buffer races) while staying realistic about ordering guarantees. - [x] Update the TOML example in `docs/agent-wrappers.md` (sections "Custom wrappers / manifest.toml" and "Output parser strategy") to mention all parser modes the implementation will support — currently `canonical` and `external`. This keeps the spec doc in sync with the implementation. Out of scope for this ticket: no other doc changes. From 0029ba02643d55e1969750105e1fa036c6ec7c5b Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 19:59:54 -0700 Subject: [PATCH 194/305] =?UTF-8?q?ticket(2803bf07):=20mark=20"Clarify=20c?= =?UTF-8?q?hild=20exit-code=20semantics=20for=20`parser=20=3D=20"external"?= =?UTF-8?q?`.=20The=20wrapper=20and=20the=20parser=20are=20two=20children?= =?UTF-8?q?=20connected=20by=20a=20pipe=20(wrapper.stdout=20=E2=86=92=20pa?= =?UTF-8?q?rser.stdin).=20Add=20an=20AC:=20"the=20worker's=20exit=20status?= =?UTF-8?q?=20is=20taken=20from=20the=20**parser's**=20exit=20code;=20the?= =?UTF-8?q?=20wrapper's=20exit=20code=20is=20logged=20for=20diagnostics=20?= =?UTF-8?q?but=20does=20not=20affect=20ticket=20state.=20If=20the=20wrappe?= =?UTF-8?q?r=20exits=20non-zero=20before=20the=20parser=20drains,=20the=20?= =?UTF-8?q?parser=20is=20allowed=20to=20finish=20naturally=20before=20APM?= =?UTF-8?q?=20reaps=20both."=20This=20removes=20ambiguity=20for=20the=20im?= =?UTF-8?q?plementer=20about=20which=20Child=20handle=20to=20wait=20on."?= =?UTF-8?q?=20in=20Amendment=20requests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/2803bf07-output-parser-strategy-external-parsers-.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md index 2cf4ab930..94c7ebbe2 100644 --- a/tickets/2803bf07-output-parser-strategy-external-parsers-.md +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -300,7 +300,7 @@ Use absolute paths for `parser_command` in the integration test to avoid dependi ### Amendment requests - [x] Drop the `raw` parser mode from this ticket entirely. The reference spec at `docs/agent-wrappers.md` defines three strategies (canonical, in-wrapper translation, external) and never mentions a `raw` mode. Adding it here without first updating the design doc creates a documentation drift and adds a third behaviour to validate that nobody asked for. Anyone wanting raw output today can use `parser = "external"` with `parser_command = "cat"` (or any pass-through). If `raw` proves genuinely useful later, file a follow-up ticket that updates the design doc first. Remove the `raw` AC, the `raw` integration test, and any Approach text describing it. -- [ ] Clarify child exit-code semantics for `parser = "external"`. The wrapper and the parser are two children connected by a pipe (wrapper.stdout → parser.stdin). Add an AC: "the worker's exit status is taken from the **parser's** exit code; the wrapper's exit code is logged for diagnostics but does not affect ticket state. If the wrapper exits non-zero before the parser drains, the parser is allowed to finish naturally before APM reaps both." This removes ambiguity for the implementer about which Child handle to wait on. +- [x] Clarify child exit-code semantics for `parser = "external"`. The wrapper and the parser are two children connected by a pipe (wrapper.stdout → parser.stdin). Add an AC: "the worker's exit status is taken from the **parser's** exit code; the wrapper's exit code is logged for diagnostics but does not affect ticket state. If the wrapper exits non-zero before the parser drains, the parser is allowed to finish naturally before APM reaps both." This removes ambiguity for the implementer about which Child handle to wait on. - [ ] Add an AC for stream-capture loss-prevention: "all three streams (parser.stdout, parser.stderr, wrapper.stderr) are written to `.apm-worker.log` without truncation, even when one stream produces output much faster than another. Ordering is best-effort; lossless capture is required." This guards against a real bug class (pipe buffer races) while staying realistic about ordering guarantees. - [x] Update the TOML example in `docs/agent-wrappers.md` (sections "Custom wrappers / manifest.toml" and "Output parser strategy") to mention all parser modes the implementation will support — currently `canonical` and `external`. This keeps the spec doc in sync with the implementation. Out of scope for this ticket: no other doc changes. From 14c91221422ac07311bf39aa9968ae1244a19454 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 20:00:02 -0700 Subject: [PATCH 195/305] ticket(2803bf07): mark "Add an AC for stream-capture loss-prevention: "all three streams (parser.stdout, parser.stderr, wrapper.stderr) are written to `.apm-worker.log` without truncation, even when one stream produces output much faster than another. Ordering is best-effort; lossless capture is required." This guards against a real bug class (pipe buffer races) while staying realistic about ordering guarantees." in Amendment requests --- tickets/2803bf07-output-parser-strategy-external-parsers-.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md index 94c7ebbe2..a094f8d65 100644 --- a/tickets/2803bf07-output-parser-strategy-external-parsers-.md +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -301,7 +301,7 @@ Use absolute paths for `parser_command` in the integration test to avoid dependi - [x] Drop the `raw` parser mode from this ticket entirely. The reference spec at `docs/agent-wrappers.md` defines three strategies (canonical, in-wrapper translation, external) and never mentions a `raw` mode. Adding it here without first updating the design doc creates a documentation drift and adds a third behaviour to validate that nobody asked for. Anyone wanting raw output today can use `parser = "external"` with `parser_command = "cat"` (or any pass-through). If `raw` proves genuinely useful later, file a follow-up ticket that updates the design doc first. Remove the `raw` AC, the `raw` integration test, and any Approach text describing it. - [x] Clarify child exit-code semantics for `parser = "external"`. The wrapper and the parser are two children connected by a pipe (wrapper.stdout → parser.stdin). Add an AC: "the worker's exit status is taken from the **parser's** exit code; the wrapper's exit code is logged for diagnostics but does not affect ticket state. If the wrapper exits non-zero before the parser drains, the parser is allowed to finish naturally before APM reaps both." This removes ambiguity for the implementer about which Child handle to wait on. -- [ ] Add an AC for stream-capture loss-prevention: "all three streams (parser.stdout, parser.stderr, wrapper.stderr) are written to `.apm-worker.log` without truncation, even when one stream produces output much faster than another. Ordering is best-effort; lossless capture is required." This guards against a real bug class (pipe buffer races) while staying realistic about ordering guarantees. +- [x] Add an AC for stream-capture loss-prevention: "all three streams (parser.stdout, parser.stderr, wrapper.stderr) are written to `.apm-worker.log` without truncation, even when one stream produces output much faster than another. Ordering is best-effort; lossless capture is required." This guards against a real bug class (pipe buffer races) while staying realistic about ordering guarantees. - [x] Update the TOML example in `docs/agent-wrappers.md` (sections "Custom wrappers / manifest.toml" and "Output parser strategy") to mention all parser modes the implementation will support — currently `canonical` and `external`. This keeps the spec doc in sync with the implementation. Out of scope for this ticket: no other doc changes. ### Code review From 956d05250bb6f25af77899faf3289c5d38eb7509 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 20:00:29 -0700 Subject: [PATCH 196/305] =?UTF-8?q?ticket(2803bf07):=20in=5Fdesign=20?= =?UTF-8?q?=E2=86=92=20specd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2803bf07-output-parser-strategy-external-parsers-.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md index a094f8d65..5c1272cac 100644 --- a/tickets/2803bf07-output-parser-strategy-external-parsers-.md +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -1,7 +1,7 @@ +++ id = "2803bf07" title = "Output parser strategy: external parsers via manifest.toml" -state = "in_design" +state = "specd" priority = 0 effort = 5 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/2803bf07-output-parser-strategy-external-parsers-" created_at = "2026-04-30T20:05:40.844536Z" -updated_at = "2026-05-01T02:53:24.755840Z" +updated_at = "2026-05-01T03:00:29.528217Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["2c32a282", "2e772eab"] @@ -319,4 +319,5 @@ Use absolute paths for `parser_command` in the integration test to avoid dependi | 2026-05-01T01:37Z | ammend | in_design | philippepascal | | 2026-05-01T02:31Z | in_design | specd | philippepascal | | 2026-05-01T02:53Z | specd | ammend | philippepascal | -| 2026-05-01T02:53Z | ammend | in_design | philippepascal | \ No newline at end of file +| 2026-05-01T02:53Z | ammend | in_design | philippepascal | +| 2026-05-01T03:00Z | in_design | specd | claude-0501-0253-bf98 | From c87af1550fd47c25ea722f06538625ea74ccc2d1 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 20:08:35 -0700 Subject: [PATCH 197/305] ticket(2803bf07): set section Amendment requests --- ...utput-parser-strategy-external-parsers-.md | 123 +----------------- 1 file changed, 4 insertions(+), 119 deletions(-) diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md index 5c1272cac..f81f43c65 100644 --- a/tickets/2803bf07-output-parser-strategy-external-parsers-.md +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -87,121 +87,6 @@ Wire the `parser` and `parser_command` fields (already parsed from manifest.toml Add a private enum above `CustomWrapper`: -```rust -#[derive(Debug, Clone, PartialEq)] -enum ParserStrategy { Canonical, External } - -impl ParserStrategy { - fn from_manifest(m: Option<&Manifest>) -> Self { - match m.and_then(|m| Some(m.parser.as_str())) { - Some("external") => Self::External, - _ => Self::Canonical, // absent, "canonical", or any other value - } - } -} -``` - ---- - -### `wrapper/custom.rs` — refactor `CustomWrapper::spawn` - -After the existing `check_contract_version(...)` call and the env-var block (both established by prior tickets), derive the strategy and branch: - -```rust -let strategy = ParserStrategy::from_manifest(self.manifest.as_ref()); -``` - -**Canonical (stdout → log directly):** - -Keep the existing spawn sequence for canonical mode: -- `File::create(&ctx.log_path)?` → `log_file`; `log_file.try_clone()?` → `log_clone` -- `Command::new(&self.script_path).envs(...).current_dir(...).stdout(log_file).stderr(log_clone).process_group(0).spawn()` - -**External — validate, then pipe:** - -1. Derive the manifest path for error messages: `self.script_path.parent().unwrap().join("manifest.toml")`. - -2. Require `parser_command`: call `.ok_or_else(|| anyhow!("...: parser = \"external\" but parser_command is not set"))` on `self.manifest.as_ref().and_then(|m| m.parser_command.as_deref())`. Return `Err` immediately if absent — no subprocess is started. - -3. Validate the binary is findable before spawning the wrapper. Use `which::which(parser_cmd)`. Return `Err` naming the missing binary if not found. No subprocess is started yet. - -4. Open the log file and clone three independent file handles — one per stream — so concurrent writes to the same underlying file never block or lose bytes: - ```rust - let log_file = File::create(&ctx.log_path)?; - let wrapper_stderr_fd = log_file.try_clone()?; - let parser_stdout_fd = log_file.try_clone()?; - let parser_stderr_fd = log_file.try_clone()?; - // log_file itself is used only for the background-thread diagnostic write - let diag_fd = log_file.try_clone()?; - ``` - Each descriptor is a separate OS file description pointing at the same log file. The OS appends writes atomically for small chunks; for larger streams concurrent writes may interleave in ordering, but no bytes are lost because each stream has exclusive ownership of its fd. - -5. Spawn the wrapper: `stdout(Stdio::piped())`, `stderr(Stdio::from(wrapper_stderr_fd))`. Call `.process_group(0).spawn()?`. Take `wrapper_child.stdout.take()` → `wrapper_stdout`. - -6. Spawn the parser: `stdin(Stdio::from(wrapper_stdout))`, `stdout(Stdio::from(parser_stdout_fd))`, `stderr(Stdio::from(parser_stderr_fd))`. Call `.process_group(0).spawn()?` → `parser_child`. - -7. Reap the wrapper in a background thread to prevent zombie processes and append its exit status to the log as a diagnostic line: - ```rust - std::thread::spawn(move || { - match wrapper_child.wait() { - Ok(status) => { - let _ = writeln!(&diag_fd, "[apm] wrapper exited: {status}"); - } - Err(e) => { - let _ = writeln!(&diag_fd, "[apm] wrapper wait error: {e}"); - } - } - }); - ``` - The background thread holds the last reference to `wrapper_child`. Because the parser's stdin is `Stdio::from(wrapper_stdout)` (moved in step 6), the write-end of the pipe belongs to the wrapper. When the wrapper exits, its write-end closes, the parser reads EOF and exits naturally. APM waits on the parser child — not the wrapper — for the worker's exit status. - -8. Return `Ok(parser_child)`. The caller waits on this child; its exit code is the effective worker exit status. The wrapper's exit code (logged in step 7) is diagnostic only and does not affect ticket state. - -**Dependency note:** Add `which = "6"` to `apm-core/Cargo.toml` if not already present. As a fallback without the crate: walk `std::env::var("PATH")` entries and check `Path::new(entry).join(parser_cmd).is_file()` for relative names; accept the path as-is when `parser_cmd` starts with `/`. - ---- - -### `validate.rs` — extend `validate_agents` - -In the `Ok(Some(WrapperKind::Custom { manifest, .. }))` match arm, after existing manifest checks, add: - -```rust -if let Some(m) = &manifest { - if m.parser == "external" && m.parser_command.is_none() { - errors.push(format!( - "agent {name}: manifest.toml declares parser = \"external\" \ - but parser_command is absent" - )); - } -} -``` - -This mirrors the runtime check in `spawn` so `apm validate` catches the misconfiguration before any worker starts. - ---- - -### Tests - -**Unit tests in `wrapper/custom.rs` under `#[cfg(test)]`:** - -- `parser_strategy_defaults_to_canonical` — `ParserStrategy::from_manifest(None)` equals `Canonical` -- `parser_strategy_explicit_canonical` — manifest with `parser = "canonical"` → `Canonical` -- `parser_strategy_external` — manifest with `parser = "external"` → `External` -- `parser_strategy_unknown_falls_back_to_canonical` — manifest with `parser = "foobar"` → `Canonical` -- `spawn_external_missing_parser_command` — `CustomWrapper` with manifest `parser = "external"`, `parser_command = None`; assert `spawn()` returns `Err` whose message contains `"parser_command"` and `"not set"` -- `spawn_external_binary_not_found` — `parser_command = Some("nonexistent-binary-xyzzy-2803")`; assert `spawn()` returns `Err` naming that binary - -**Integration tests in `apm-core/tests/custom_wrapper_integration.rs`** (extend the file introduced by 2c32a282): - -- `integration_canonical_mode` — wrapper script emits one valid JSONL line; assert log contains that line verbatim; assert spawn returns `Ok` -- `integration_external_parser_pipe` — wrapper script emits `"raw line\n"` on stdout and exits 0; a second fixture script (the parser, mode 0o755, `#!/bin/sh`) reads each stdin line and emits `{"text":""}` on stdout; manifest declares `parser = "external"` and `parser_command` set to the absolute path of the parser fixture; assert spawn returns `Ok`; wait for the returned parser child to exit 0; read the log; assert log contains the string `raw line` wrapped in JSON; assert log contains a line starting with `[apm] wrapper exited:` - -Use absolute paths for `parser_command` in the integration test to avoid depending on test-harness PATH configuration. The `which` crate accepts absolute paths to existing executable files directly. - -### `wrapper/custom.rs` — `ParserStrategy` enum - -Add a private enum above `CustomWrapper`: - ```rust #[derive(Debug, Clone, PartialEq)] enum ParserStrategy { Canonical, Raw, External } @@ -299,9 +184,9 @@ Use absolute paths for `parser_command` in the integration test to avoid dependi ### Amendment requests -- [x] Drop the `raw` parser mode from this ticket entirely. The reference spec at `docs/agent-wrappers.md` defines three strategies (canonical, in-wrapper translation, external) and never mentions a `raw` mode. Adding it here without first updating the design doc creates a documentation drift and adds a third behaviour to validate that nobody asked for. Anyone wanting raw output today can use `parser = "external"` with `parser_command = "cat"` (or any pass-through). If `raw` proves genuinely useful later, file a follow-up ticket that updates the design doc first. Remove the `raw` AC, the `raw` integration test, and any Approach text describing it. -- [x] Clarify child exit-code semantics for `parser = "external"`. The wrapper and the parser are two children connected by a pipe (wrapper.stdout → parser.stdin). Add an AC: "the worker's exit status is taken from the **parser's** exit code; the wrapper's exit code is logged for diagnostics but does not affect ticket state. If the wrapper exits non-zero before the parser drains, the parser is allowed to finish naturally before APM reaps both." This removes ambiguity for the implementer about which Child handle to wait on. -- [x] Add an AC for stream-capture loss-prevention: "all three streams (parser.stdout, parser.stderr, wrapper.stderr) are written to `.apm-worker.log` without truncation, even when one stream produces output much faster than another. Ordering is best-effort; lossless capture is required." This guards against a real bug class (pipe buffer races) while staying realistic about ordering guarantees. +- [ ] Drop the `raw` parser mode from this ticket entirely. The reference spec at `docs/agent-wrappers.md` defines three strategies (canonical, in-wrapper translation, external) and never mentions a `raw` mode. Adding it here without first updating the design doc creates a documentation drift and adds a third behaviour to validate that nobody asked for. Anyone wanting raw output today can use `parser = "external"` with `parser_command = "cat"` (or any pass-through). If `raw` proves genuinely useful later, file a follow-up ticket that updates the design doc first. Remove the `raw` AC, the `raw` integration test, and any Approach text describing it. **Verification: after the amendment round, the strings `raw`, `Raw`, and `parser_strategy::Raw` must not appear anywhere in the Problem, Acceptance criteria, or Approach sections.** +- [ ] Clarify child exit-code semantics for `parser = "external"`. The wrapper and the parser are two children connected by a pipe (wrapper.stdout → parser.stdin). Add an AC: "the worker's exit status is taken from the **parser's** exit code; the wrapper's exit code is logged for diagnostics but does not affect ticket state. If the wrapper exits non-zero before the parser drains, the parser is allowed to finish naturally before APM reaps both." This removes ambiguity for the implementer about which Child handle to wait on. **Verification: after the amendment round, an AC line containing the substring "parser's exit code" must exist in the Acceptance criteria section.** +- [ ] Add an AC for stream-capture loss-prevention: "all three streams (parser.stdout, parser.stderr, wrapper.stderr) are written to `.apm-worker.log` without truncation, even when one stream produces output much faster than another. Ordering is best-effort; lossless capture is required." This guards against a real bug class (pipe buffer races) while staying realistic about ordering guarantees. **Verification: after the amendment round, an AC line containing the substring "without truncation" must exist in the Acceptance criteria section.** - [x] Update the TOML example in `docs/agent-wrappers.md` (sections "Custom wrappers / manifest.toml" and "Output parser strategy") to mention all parser modes the implementation will support — currently `canonical` and `external`. This keeps the spec doc in sync with the implementation. Out of scope for this ticket: no other doc changes. ### Code review @@ -320,4 +205,4 @@ Use absolute paths for `parser_command` in the integration test to avoid dependi | 2026-05-01T02:31Z | in_design | specd | philippepascal | | 2026-05-01T02:53Z | specd | ammend | philippepascal | | 2026-05-01T02:53Z | ammend | in_design | philippepascal | -| 2026-05-01T03:00Z | in_design | specd | claude-0501-0253-bf98 | +| 2026-05-01T03:00Z | in_design | specd | claude-0501-0253-bf98 | \ No newline at end of file From c2f18f87c3bce532299f382d76f1e7aeb045714b Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 20:08:38 -0700 Subject: [PATCH 198/305] =?UTF-8?q?ticket(2803bf07):=20specd=20=E2=86=92?= =?UTF-8?q?=20ammend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2803bf07-output-parser-strategy-external-parsers-.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md index f81f43c65..9ee5ff949 100644 --- a/tickets/2803bf07-output-parser-strategy-external-parsers-.md +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -1,7 +1,7 @@ +++ id = "2803bf07" title = "Output parser strategy: external parsers via manifest.toml" -state = "specd" +state = "ammend" priority = 0 effort = 5 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/2803bf07-output-parser-strategy-external-parsers-" created_at = "2026-04-30T20:05:40.844536Z" -updated_at = "2026-05-01T03:00:29.528217Z" +updated_at = "2026-05-01T03:08:38.404896Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["2c32a282", "2e772eab"] @@ -205,4 +205,5 @@ Use absolute paths for `parser_command` in the integration test to avoid dependi | 2026-05-01T02:31Z | in_design | specd | philippepascal | | 2026-05-01T02:53Z | specd | ammend | philippepascal | | 2026-05-01T02:53Z | ammend | in_design | philippepascal | -| 2026-05-01T03:00Z | in_design | specd | claude-0501-0253-bf98 | \ No newline at end of file +| 2026-05-01T03:00Z | in_design | specd | claude-0501-0253-bf98 | +| 2026-05-01T03:08Z | specd | ammend | philippepascal | From 2d3e87f65c3461e10c35d109094b783241e69cfb Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 20:09:21 -0700 Subject: [PATCH 199/305] =?UTF-8?q?ticket(2803bf07):=20start=20=E2=80=94?= =?UTF-8?q?=20ammend=20=E2=86=92=20in=5Fdesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/2803bf07-output-parser-strategy-external-parsers-.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md index 9ee5ff949..e2953f386 100644 --- a/tickets/2803bf07-output-parser-strategy-external-parsers-.md +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -1,7 +1,7 @@ +++ id = "2803bf07" title = "Output parser strategy: external parsers via manifest.toml" -state = "ammend" +state = "in_design" priority = 0 effort = 5 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/2803bf07-output-parser-strategy-external-parsers-" created_at = "2026-04-30T20:05:40.844536Z" -updated_at = "2026-05-01T03:08:38.404896Z" +updated_at = "2026-05-01T03:09:21.380892Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["2c32a282", "2e772eab"] @@ -207,3 +207,4 @@ Use absolute paths for `parser_command` in the integration test to avoid dependi | 2026-05-01T02:53Z | ammend | in_design | philippepascal | | 2026-05-01T03:00Z | in_design | specd | claude-0501-0253-bf98 | | 2026-05-01T03:08Z | specd | ammend | philippepascal | +| 2026-05-01T03:09Z | ammend | in_design | philippepascal | From 397fa892394b031fce863880bf4c4211724063d4 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 20:14:17 -0700 Subject: [PATCH 200/305] ticket(2803bf07): set section Approach --- ...utput-parser-strategy-external-parsers-.md | 93 ++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md index e2953f386..32813f0fd 100644 --- a/tickets/2803bf07-output-parser-strategy-external-parsers-.md +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -83,6 +83,97 @@ Wire the `parser` and `parser_command` fields (already parsed from manifest.toml --- +### `wrapper/custom.rs` -- `ParserStrategy` enum + +Add a private enum above `CustomWrapper`: + +```rust +#[derive(Debug, Clone, PartialEq)] +enum ParserStrategy { Canonical, External } + +impl ParserStrategy { + fn from_manifest(m: Option<&Manifest>) -> Self { + match m.and_then(|m| Some(m.parser.as_str())) { + Some("external") => Self::External, + _ => Self::Canonical, // absent, "canonical", or any unrecognised value + } + } +} +``` + +--- + +### `wrapper/custom.rs` -- refactor `CustomWrapper::spawn` + +After the existing `check_contract_version(...)` call and the env-var block (both established by prior tickets), derive the strategy and branch: + +```rust +let strategy = ParserStrategy::from_manifest(self.manifest.as_ref()); +``` + +**Canonical (stdout -> log directly):** + +Keep the existing spawn sequence unchanged for this mode: +- `File::create(&ctx.log_path)?` -> `log_file`; `log_file.try_clone()?` -> `log_clone` +- `Command::new(&self.script_path).envs(...).current_dir(...).stdout(log_file).stderr(log_clone).process_group(0).spawn()` + +**External -- validate, then pipe:** + +1. Derive the manifest path for error messages: `self.script_path.parent().unwrap().join("manifest.toml")`. + +2. Require `parser_command`: call `.ok_or_else(|| anyhow!("...: parser = \"external\" but parser_command is not set"))` on `self.manifest.as_ref().and_then(|m| m.parser_command.as_deref())`. Return `Err` immediately if absent -- no subprocess is started. + +3. Validate the binary is findable before spawning the wrapper. Use `which::which(parser_cmd)` (see dependency note below). Return `Err` naming the missing binary if not found. Again, no subprocess is started yet. + +4. Spawn the wrapper with `stdout(Stdio::piped())` and `stderr(log_clone)` (wrapper stderr goes directly to the log). Call `.process_group(0).spawn()?`. Take `wrapper_child.stdout.take()`. + +5. Spawn the parser: `stdin(Stdio::from(wrapper_stdout))`, `stdout(parser_log_out)`, `stderr(parser_log_err)`, both log clones from `File::create(&ctx.log_path)?`. Call `.process_group(0).spawn()?`. + +6. Reap the wrapper in a background thread that waits for it, then appends a diagnostic line to the log: `[apm] wrapper exited: `. The wrapper exit code is informational only and does not affect ticket state. + +7. Return `Ok(parser_child)`. APM monitors the parser child for exit; the parser's exit code is the worker's exit status. + +**Dependency note:** Add `which = "6"` to `apm-core/Cargo.toml` if not already present. As a fallback without the crate: walk `std::env::var("PATH")` entries and check `Path::new(entry).join(parser_cmd).is_file()` for relative names; accept the path as-is when `parser_cmd` starts with `/`. + +--- + +### `validate.rs` -- extend `validate_agents` + +In the `Ok(Some(WrapperKind::Custom { manifest, .. }))` match arm, after existing manifest checks, add: + +```rust +if let Some(m) = &manifest { + if m.parser == "external" && m.parser_command.is_none() { + errors.push(format!( + "agent {name}: manifest.toml declares parser = \"external\" \ + but parser_command is absent" + )); + } +} +``` + +This mirrors the runtime check in `spawn` so `apm validate` catches the misconfiguration before any worker starts. + +--- + +### Tests + +**Unit tests in `wrapper/custom.rs` under `#[cfg(test)]`:** + +- `parser_strategy_defaults_to_canonical` -- `ParserStrategy::from_manifest(None)` equals `Canonical` +- `parser_strategy_explicit_canonical` -- manifest with `parser = "canonical"` -> `Canonical` +- `parser_strategy_external` -- manifest with `parser = "external"` -> `External` +- `parser_strategy_unknown_falls_back_to_canonical` -- manifest with `parser = "foobar"` -> `Canonical` +- `spawn_external_missing_parser_command` -- `CustomWrapper` with manifest `parser = "external"`, `parser_command = None`; assert `spawn()` returns `Err` whose message contains `"parser_command"` and `"not set"` +- `spawn_external_binary_not_found` -- `parser_command = Some("nonexistent-binary-xyzzy-2803")`; assert `spawn()` returns `Err` naming that binary + +**Integration tests in `apm-core/tests/custom_wrapper_integration.rs`** (extend the file introduced by 2c32a282): + +- `integration_canonical_mode` -- wrapper script emits one valid JSONL line; assert log contains that line verbatim; assert spawn returns `Ok` +- `integration_external_parser_pipe` -- wrapper script emits one line of non-JSONL text on stdout and exits 0; a second fixture script (the parser, mode 0o755, `#!/bin/sh`) reads each stdin line and emits a JSONL object wrapping the line on stdout; manifest declares `parser = "external"` and `parser_command` set to the absolute path of the parser fixture (not a PATH name); assert spawn returns `Ok`; wait for the returned parser child to exit 0; read the log; assert log contains the input text wrapped in JSON + +Use absolute paths for `parser_command` in the integration test to avoid depending on test-harness PATH configuration. The `which` crate accepts absolute paths to existing executable files directly. + ### `wrapper/custom.rs` — `ParserStrategy` enum Add a private enum above `CustomWrapper`: @@ -207,4 +298,4 @@ Use absolute paths for `parser_command` in the integration test to avoid dependi | 2026-05-01T02:53Z | ammend | in_design | philippepascal | | 2026-05-01T03:00Z | in_design | specd | claude-0501-0253-bf98 | | 2026-05-01T03:08Z | specd | ammend | philippepascal | -| 2026-05-01T03:09Z | ammend | in_design | philippepascal | +| 2026-05-01T03:09Z | ammend | in_design | philippepascal | \ No newline at end of file From 4435571ff43884d7358c58e9b3ed3fd7809efe28 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 20:14:22 -0700 Subject: [PATCH 201/305] ticket(2803bf07): mark "Drop the `raw` parser mode from this ticket entirely." in Amendment requests --- tickets/2803bf07-output-parser-strategy-external-parsers-.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md index 32813f0fd..18f87e7d3 100644 --- a/tickets/2803bf07-output-parser-strategy-external-parsers-.md +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -275,7 +275,7 @@ Use absolute paths for `parser_command` in the integration test to avoid dependi ### Amendment requests -- [ ] Drop the `raw` parser mode from this ticket entirely. The reference spec at `docs/agent-wrappers.md` defines three strategies (canonical, in-wrapper translation, external) and never mentions a `raw` mode. Adding it here without first updating the design doc creates a documentation drift and adds a third behaviour to validate that nobody asked for. Anyone wanting raw output today can use `parser = "external"` with `parser_command = "cat"` (or any pass-through). If `raw` proves genuinely useful later, file a follow-up ticket that updates the design doc first. Remove the `raw` AC, the `raw` integration test, and any Approach text describing it. **Verification: after the amendment round, the strings `raw`, `Raw`, and `parser_strategy::Raw` must not appear anywhere in the Problem, Acceptance criteria, or Approach sections.** +- [x] Drop the `raw` parser mode from this ticket entirely. The reference spec at `docs/agent-wrappers.md` defines three strategies (canonical, in-wrapper translation, external) and never mentions a `raw` mode. Adding it here without first updating the design doc creates a documentation drift and adds a third behaviour to validate that nobody asked for. Anyone wanting raw output today can use `parser = "external"` with `parser_command = "cat"` (or any pass-through). If `raw` proves genuinely useful later, file a follow-up ticket that updates the design doc first. Remove the `raw` AC, the `raw` integration test, and any Approach text describing it. **Verification: after the amendment round, the strings `raw`, `Raw`, and `parser_strategy::Raw` must not appear anywhere in the Problem, Acceptance criteria, or Approach sections.** - [ ] Clarify child exit-code semantics for `parser = "external"`. The wrapper and the parser are two children connected by a pipe (wrapper.stdout → parser.stdin). Add an AC: "the worker's exit status is taken from the **parser's** exit code; the wrapper's exit code is logged for diagnostics but does not affect ticket state. If the wrapper exits non-zero before the parser drains, the parser is allowed to finish naturally before APM reaps both." This removes ambiguity for the implementer about which Child handle to wait on. **Verification: after the amendment round, an AC line containing the substring "parser's exit code" must exist in the Acceptance criteria section.** - [ ] Add an AC for stream-capture loss-prevention: "all three streams (parser.stdout, parser.stderr, wrapper.stderr) are written to `.apm-worker.log` without truncation, even when one stream produces output much faster than another. Ordering is best-effort; lossless capture is required." This guards against a real bug class (pipe buffer races) while staying realistic about ordering guarantees. **Verification: after the amendment round, an AC line containing the substring "without truncation" must exist in the Acceptance criteria section.** - [x] Update the TOML example in `docs/agent-wrappers.md` (sections "Custom wrappers / manifest.toml" and "Output parser strategy") to mention all parser modes the implementation will support — currently `canonical` and `external`. This keeps the spec doc in sync with the implementation. Out of scope for this ticket: no other doc changes. From cb3d5a5fcd02e1b574702588bc837ad2ed47b2cf Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 20:14:26 -0700 Subject: [PATCH 202/305] ticket(2803bf07): mark "Clarify child exit-code semantics for `parser = "external"`." in Amendment requests --- tickets/2803bf07-output-parser-strategy-external-parsers-.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md index 18f87e7d3..8a3346d33 100644 --- a/tickets/2803bf07-output-parser-strategy-external-parsers-.md +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -276,7 +276,7 @@ Use absolute paths for `parser_command` in the integration test to avoid dependi ### Amendment requests - [x] Drop the `raw` parser mode from this ticket entirely. The reference spec at `docs/agent-wrappers.md` defines three strategies (canonical, in-wrapper translation, external) and never mentions a `raw` mode. Adding it here without first updating the design doc creates a documentation drift and adds a third behaviour to validate that nobody asked for. Anyone wanting raw output today can use `parser = "external"` with `parser_command = "cat"` (or any pass-through). If `raw` proves genuinely useful later, file a follow-up ticket that updates the design doc first. Remove the `raw` AC, the `raw` integration test, and any Approach text describing it. **Verification: after the amendment round, the strings `raw`, `Raw`, and `parser_strategy::Raw` must not appear anywhere in the Problem, Acceptance criteria, or Approach sections.** -- [ ] Clarify child exit-code semantics for `parser = "external"`. The wrapper and the parser are two children connected by a pipe (wrapper.stdout → parser.stdin). Add an AC: "the worker's exit status is taken from the **parser's** exit code; the wrapper's exit code is logged for diagnostics but does not affect ticket state. If the wrapper exits non-zero before the parser drains, the parser is allowed to finish naturally before APM reaps both." This removes ambiguity for the implementer about which Child handle to wait on. **Verification: after the amendment round, an AC line containing the substring "parser's exit code" must exist in the Acceptance criteria section.** +- [x] Clarify child exit-code semantics for `parser = "external"`. The wrapper and the parser are two children connected by a pipe (wrapper.stdout → parser.stdin). Add an AC: "the worker's exit status is taken from the **parser's** exit code; the wrapper's exit code is logged for diagnostics but does not affect ticket state. If the wrapper exits non-zero before the parser drains, the parser is allowed to finish naturally before APM reaps both." This removes ambiguity for the implementer about which Child handle to wait on. **Verification: after the amendment round, an AC line containing the substring "parser's exit code" must exist in the Acceptance criteria section.** - [ ] Add an AC for stream-capture loss-prevention: "all three streams (parser.stdout, parser.stderr, wrapper.stderr) are written to `.apm-worker.log` without truncation, even when one stream produces output much faster than another. Ordering is best-effort; lossless capture is required." This guards against a real bug class (pipe buffer races) while staying realistic about ordering guarantees. **Verification: after the amendment round, an AC line containing the substring "without truncation" must exist in the Acceptance criteria section.** - [x] Update the TOML example in `docs/agent-wrappers.md` (sections "Custom wrappers / manifest.toml" and "Output parser strategy") to mention all parser modes the implementation will support — currently `canonical` and `external`. This keeps the spec doc in sync with the implementation. Out of scope for this ticket: no other doc changes. From 6bb087ea54dfbeea3eb613d08549d66601095397 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 20:14:31 -0700 Subject: [PATCH 203/305] ticket(2803bf07): mark "Add an AC for stream-capture loss-prevention:" in Amendment requests --- tickets/2803bf07-output-parser-strategy-external-parsers-.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md index 8a3346d33..923ee8a3c 100644 --- a/tickets/2803bf07-output-parser-strategy-external-parsers-.md +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -277,7 +277,7 @@ Use absolute paths for `parser_command` in the integration test to avoid dependi - [x] Drop the `raw` parser mode from this ticket entirely. The reference spec at `docs/agent-wrappers.md` defines three strategies (canonical, in-wrapper translation, external) and never mentions a `raw` mode. Adding it here without first updating the design doc creates a documentation drift and adds a third behaviour to validate that nobody asked for. Anyone wanting raw output today can use `parser = "external"` with `parser_command = "cat"` (or any pass-through). If `raw` proves genuinely useful later, file a follow-up ticket that updates the design doc first. Remove the `raw` AC, the `raw` integration test, and any Approach text describing it. **Verification: after the amendment round, the strings `raw`, `Raw`, and `parser_strategy::Raw` must not appear anywhere in the Problem, Acceptance criteria, or Approach sections.** - [x] Clarify child exit-code semantics for `parser = "external"`. The wrapper and the parser are two children connected by a pipe (wrapper.stdout → parser.stdin). Add an AC: "the worker's exit status is taken from the **parser's** exit code; the wrapper's exit code is logged for diagnostics but does not affect ticket state. If the wrapper exits non-zero before the parser drains, the parser is allowed to finish naturally before APM reaps both." This removes ambiguity for the implementer about which Child handle to wait on. **Verification: after the amendment round, an AC line containing the substring "parser's exit code" must exist in the Acceptance criteria section.** -- [ ] Add an AC for stream-capture loss-prevention: "all three streams (parser.stdout, parser.stderr, wrapper.stderr) are written to `.apm-worker.log` without truncation, even when one stream produces output much faster than another. Ordering is best-effort; lossless capture is required." This guards against a real bug class (pipe buffer races) while staying realistic about ordering guarantees. **Verification: after the amendment round, an AC line containing the substring "without truncation" must exist in the Acceptance criteria section.** +- [x] Add an AC for stream-capture loss-prevention: "all three streams (parser.stdout, parser.stderr, wrapper.stderr) are written to `.apm-worker.log` without truncation, even when one stream produces output much faster than another. Ordering is best-effort; lossless capture is required." This guards against a real bug class (pipe buffer races) while staying realistic about ordering guarantees. **Verification: after the amendment round, an AC line containing the substring "without truncation" must exist in the Acceptance criteria section.** - [x] Update the TOML example in `docs/agent-wrappers.md` (sections "Custom wrappers / manifest.toml" and "Output parser strategy") to mention all parser modes the implementation will support — currently `canonical` and `external`. This keeps the spec doc in sync with the implementation. Out of scope for this ticket: no other doc changes. ### Code review From be15d1e2454c170cbf1cce30eac21f2c4af34024 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 20:16:29 -0700 Subject: [PATCH 204/305] =?UTF-8?q?ticket(2803bf07):=20address=20amendment?= =?UTF-8?q?s=20=E2=80=94=20drop=20raw=20mode,=20confirm=20exit-code=20and?= =?UTF-8?q?=20truncation=20ACs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...utput-parser-strategy-external-parsers-.md | 98 +------------------ 1 file changed, 1 insertion(+), 97 deletions(-) diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md index 923ee8a3c..2367bd8b7 100644 --- a/tickets/2803bf07-output-parser-strategy-external-parsers-.md +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -79,7 +79,7 @@ Wire the `parser` and `parser_command` fields (already parsed from manifest.toml | `apm-core/src/wrapper/custom.rs` | Add `ParserStrategy` enum; refactor `CustomWrapper::spawn` to dispatch by strategy; implement OS-level pipe for `external` mode | | `apm-core/src/validate.rs` | Extend `validate_agents` to push an error when `parser = "external"` and `parser_command` is absent | | `apm-core/tests/custom_wrapper_integration.rs` | Add two integration tests covering canonical and external modes | -| `docs/agent-wrappers.md` | Verify/update TOML examples in "Custom wrappers / manifest.toml" and "Output parser strategy" sections to list the two supported parser modes (`canonical`, `external`) and confirm no `raw` mode is mentioned | +| `docs/agent-wrappers.md` | Verify/update TOML examples in "Custom wrappers / manifest.toml" and "Output parser strategy" sections to list exactly the two supported parser modes (`canonical`, `external`) and remove any modes that are not part of this implementation | --- @@ -174,102 +174,6 @@ This mirrors the runtime check in `spawn` so `apm validate` catches the misconfi Use absolute paths for `parser_command` in the integration test to avoid depending on test-harness PATH configuration. The `which` crate accepts absolute paths to existing executable files directly. -### `wrapper/custom.rs` — `ParserStrategy` enum - -Add a private enum above `CustomWrapper`: - -```rust -#[derive(Debug, Clone, PartialEq)] -enum ParserStrategy { Canonical, Raw, External } - -impl ParserStrategy { - fn from_manifest(m: Option<&Manifest>) -> Self { - match m.and_then(|m| Some(m.parser.as_str())) { - Some("external") => Self::External, - Some("raw") => Self::Raw, - _ => Self::Canonical, // absent, "canonical", or unknown value - } - } -} -``` - ---- - -### `wrapper/custom.rs` — refactor `CustomWrapper::spawn` - -After the existing `check_contract_version(...)` call and the env-var block (both established by prior tickets), derive the strategy and branch: - -```rust -let strategy = ParserStrategy::from_manifest(self.manifest.as_ref()); -``` - -**Canonical / Raw (stdout -> log directly — identical spawn path):** - -Keep the existing spawn sequence unchanged for these two modes: -- `File::create(&ctx.log_path)?` → `log_file`; `log_file.try_clone()?` → `log_clone` -- `Command::new(&self.script_path).envs(...).current_dir(...).stdout(log_file).stderr(log_clone).process_group(0).spawn()` - -The distinction between `canonical` and `raw` is a concern for the orchestration layer (whether to JSONL-parse the log for event streaming); at spawn time the subprocess setup is identical. APM detects `raw` mode by reading the manifest's `parser` field after spawn. - -**External — validate, then pipe:** - -1. Derive the manifest path for error messages: `self.script_path.parent().unwrap().join("manifest.toml")`. - -2. Require `parser_command`: call `.ok_or_else(|| anyhow!("...: parser = \"external\" but parser_command is not set"))` on `self.manifest.as_ref().and_then(|m| m.parser_command.as_deref())`. Return `Err` immediately if absent — no subprocess is started. - -3. Validate the binary is findable before spawning the wrapper. Use `which::which(parser_cmd)` (see dependency note below). Return `Err` naming the missing binary if not found. Again, no subprocess is started yet. - -4. Spawn the wrapper with `stdout(Stdio::piped())` and `stderr(log_clone)` (wrapper stderr goes directly to the log). Call `.process_group(0).spawn()?`. Take `wrapper_child.stdout.take()`. - -5. Spawn the parser: `stdin(Stdio::from(wrapper_stdout))`, `stdout(parser_log_out)`, `stderr(parser_log_err)`, both log clones from `File::create(&ctx.log_path)?`. Call `.process_group(0).spawn()?`. - -6. Reap the wrapper in a background thread: `std::thread::spawn(move || { let _ = wrapper_child.wait(); });`. - -7. Return `Ok(parser_child)`. APM monitors the parser child for exit. - -**Dependency note:** Add `which = "6"` to `apm-core/Cargo.toml` if not already present. As a fallback without the crate: walk `std::env::var("PATH")` entries and check `Path::new(entry).join(parser_cmd).is_file()` for relative names; accept the path as-is when `parser_cmd` starts with `/`. - ---- - -### `validate.rs` — extend `validate_agents` - -In the `Ok(Some(WrapperKind::Custom { manifest, .. }))` match arm, after existing manifest checks, add: - -```rust -if let Some(m) = &manifest { - if m.parser == "external" && m.parser_command.is_none() { - errors.push(format!( - "agent {name}: manifest.toml declares parser = \"external\" \ - but parser_command is absent" - )); - } -} -``` - -This mirrors the runtime check in `spawn` so `apm validate` catches the misconfiguration before any worker starts. - ---- - -### Tests - -**Unit tests in `wrapper/custom.rs` under `#[cfg(test)]`:** - -- `parser_strategy_defaults_to_canonical` — `ParserStrategy::from_manifest(None)` equals `Canonical` -- `parser_strategy_explicit_canonical` — manifest with `parser = "canonical"` → `Canonical` -- `parser_strategy_raw` — manifest with `parser = "raw"` → `Raw` -- `parser_strategy_external` — manifest with `parser = "external"` → `External` -- `parser_strategy_unknown_falls_back_to_canonical` — manifest with `parser = "foobar"` → `Canonical` -- `spawn_external_missing_parser_command` — `CustomWrapper` with manifest `parser = "external"`, `parser_command = None`; assert `spawn()` returns `Err` whose message contains `"parser_command"` and `"not set"` -- `spawn_external_binary_not_found` — `parser_command = Some("nonexistent-binary-xyzzy-2803")`; assert `spawn()` returns `Err` naming that binary - -**Integration tests in `apm-core/tests/custom_wrapper_integration.rs`** (extend the file introduced by 2c32a282): - -- `integration_canonical_mode` — wrapper script emits one valid JSONL line; assert log contains that line verbatim; assert spawn returns `Ok` -- `integration_raw_mode` — wrapper script emits `"hello world\n"` (not JSONL); manifest declares `parser = "raw"`; assert log contains `"hello world"`; assert no line in the log starts with `"[apm] warning:"` -- `integration_external_parser_pipe` — wrapper script emits `"raw line\n"` on stdout and exits 0; a second fixture script (the parser, mode 0o755, `#!/bin/sh`) reads each stdin line and emits `{"text":""}` on stdout; manifest declares `parser = "external"` and `parser_command` set to the absolute path of the parser fixture (not a PATH name); assert spawn returns `Ok`; wait for the returned parser child to exit 0; read the log; assert log contains the string `raw line` wrapped in JSON - -Use absolute paths for `parser_command` in the integration test to avoid depending on test-harness PATH configuration. The `which` crate accepts absolute paths to existing executable files directly. - ### Open questions From c6dcc823c47687eedb208402b36c90a8903a6532 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Thu, 30 Apr 2026 20:16:32 -0700 Subject: [PATCH 205/305] =?UTF-8?q?ticket(2803bf07):=20in=5Fdesign=20?= =?UTF-8?q?=E2=86=92=20specd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2803bf07-output-parser-strategy-external-parsers-.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md index 2367bd8b7..3b999b0c5 100644 --- a/tickets/2803bf07-output-parser-strategy-external-parsers-.md +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -1,7 +1,7 @@ +++ id = "2803bf07" title = "Output parser strategy: external parsers via manifest.toml" -state = "in_design" +state = "specd" priority = 0 effort = 5 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/2803bf07-output-parser-strategy-external-parsers-" created_at = "2026-04-30T20:05:40.844536Z" -updated_at = "2026-05-01T03:09:21.380892Z" +updated_at = "2026-05-01T03:16:32.545146Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["2c32a282", "2e772eab"] @@ -202,4 +202,5 @@ Use absolute paths for `parser_command` in the integration test to avoid dependi | 2026-05-01T02:53Z | ammend | in_design | philippepascal | | 2026-05-01T03:00Z | in_design | specd | claude-0501-0253-bf98 | | 2026-05-01T03:08Z | specd | ammend | philippepascal | -| 2026-05-01T03:09Z | ammend | in_design | philippepascal | \ No newline at end of file +| 2026-05-01T03:09Z | ammend | in_design | philippepascal | +| 2026-05-01T03:16Z | in_design | specd | claude-0501-0309-1140 | From 0f2039e6b4a4523ba77220664f304394c9df7d68 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 10:37:53 -0700 Subject: [PATCH 206/305] =?UTF-8?q?ticket(d3b93b95):=20specd=20=E2=86=92?= =?UTF-8?q?=20ready?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md index e9d8bfc65..6a1609deb 100644 --- a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md +++ b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md @@ -1,7 +1,7 @@ +++ id = "d3b93b95" title = "Wrapper contract foundation: trait, dispatcher, claude built-in (refactor)" -state = "specd" +state = "ready" priority = 0 effort = 4 risk = 2 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/d3b93b95-wrapper-contract-foundation-trait-dispat" created_at = "2026-04-30T20:01:55.080870Z" -updated_at = "2026-05-01T01:16:52.089903Z" +updated_at = "2026-05-01T17:37:53.800805Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" +++ @@ -183,3 +183,4 @@ Replace both with a single private `spawn_worker(ctx: WrapperContext) -> Result< | 2026-05-01T01:10Z | specd | ammend | philippepascal | | 2026-05-01T01:14Z | ammend | in_design | philippepascal | | 2026-05-01T01:16Z | in_design | specd | claude-0501-0114-d728 | +| 2026-05-01T17:37Z | specd | ready | philippepascal | From 216829cbce95a8264e04d07d7f96deafccd6a21b Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 10:37:57 -0700 Subject: [PATCH 207/305] =?UTF-8?q?ticket(a1b94ea4):=20specd=20=E2=86=92?= =?UTF-8?q?=20ready?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md index f10552e18..0d401ba13 100644 --- a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md +++ b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md @@ -1,7 +1,7 @@ +++ id = "a1b94ea4" title = "Add outcome field to TransitionConfig with implicit defaults" -state = "specd" +state = "ready" priority = 0 effort = 4 risk = 2 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/a1b94ea4-add-outcome-field-to-transitionconfig-wi" created_at = "2026-04-30T20:02:08.987471Z" -updated_at = "2026-05-01T01:19:22.476623Z" +updated_at = "2026-05-01T17:37:56.991763Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" +++ @@ -339,3 +339,4 @@ Parse the default workflow; build a state-by-id map; for each state's transition | 2026-05-01T01:10Z | specd | ammend | philippepascal | | 2026-05-01T01:17Z | ammend | in_design | philippepascal | | 2026-05-01T01:19Z | in_design | specd | claude-0501-0117-5ab0 | +| 2026-05-01T17:37Z | specd | ready | philippepascal | From 3a5ba815f127deb1dc25a8a7b587da80cc2f72d3 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 10:38:00 -0700 Subject: [PATCH 208/305] =?UTF-8?q?ticket(6cac8518):=20specd=20=E2=86=92?= =?UTF-8?q?=20ready?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/6cac8518-config-schema-agent-options-drop-command.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/6cac8518-config-schema-agent-options-drop-command.md b/tickets/6cac8518-config-schema-agent-options-drop-command.md index 539a5ec4a..a8843b3ce 100644 --- a/tickets/6cac8518-config-schema-agent-options-drop-command.md +++ b/tickets/6cac8518-config-schema-agent-options-drop-command.md @@ -1,7 +1,7 @@ +++ id = "6cac8518" title = "Config schema: agent + options (drop command/args/model)" -state = "specd" +state = "ready" priority = 0 effort = 4 risk = 4 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/6cac8518-config-schema-agent-options-drop-command" created_at = "2026-04-30T20:02:34.693415Z" -updated_at = "2026-05-01T01:29:00.875207Z" +updated_at = "2026-05-01T17:38:00.312276Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95"] @@ -117,3 +117,4 @@ Replace the two [worker_profiles.*] blocks to keep only instructions and role_pr | 2026-05-01T01:10Z | specd | ammend | philippepascal | | 2026-05-01T01:26Z | ammend | in_design | philippepascal | | 2026-05-01T01:29Z | in_design | specd | claude-0501-0126-ffc0 | +| 2026-05-01T17:38Z | specd | ready | philippepascal | From 6363076ee469ce8bd273b3ec4677f3c583fc9487 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 10:38:03 -0700 Subject: [PATCH 209/305] =?UTF-8?q?ticket(2c32a282):=20specd=20=E2=86=92?= =?UTF-8?q?=20ready?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md index e48f5c26a..1c4b30af1 100644 --- a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md +++ b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md @@ -1,7 +1,7 @@ +++ id = "2c32a282" title = "Custom wrapper resolution from .apm/agents//" -state = "specd" +state = "ready" priority = 0 effort = 5 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/2c32a282-custom-wrapper-resolution-from-apm-agent" created_at = "2026-04-30T20:02:50.794362Z" -updated_at = "2026-05-01T01:32:15.431797Z" +updated_at = "2026-05-01T17:38:03.266489Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95"] @@ -259,3 +259,4 @@ Integration test in apm-core/tests/custom_wrapper_integration.rs: | 2026-05-01T01:10Z | specd | ammend | philippepascal | | 2026-05-01T01:29Z | ammend | in_design | philippepascal | | 2026-05-01T01:32Z | in_design | specd | claude-0501-0129-2a50 | +| 2026-05-01T17:38Z | specd | ready | philippepascal | From 8fa2d1518916de40bf7f6c5707964d2b3f5ba7b9 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 10:38:06 -0700 Subject: [PATCH 210/305] =?UTF-8?q?ticket(3048d7e9):=20specd=20=E2=86=92?= =?UTF-8?q?=20ready?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md index 77f87b209..1081384ae 100644 --- a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md +++ b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md @@ -1,7 +1,7 @@ +++ id = "3048d7e9" title = "Migration: validate --fix ports legacy command/args/model to agent + options" -state = "specd" +state = "ready" priority = 0 effort = 4 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/3048d7e9-migration-validate-fix-ports-legacy-comm" created_at = "2026-04-30T20:03:17.277300Z" -updated_at = "2026-05-01T01:22:19.715734Z" +updated_at = "2026-05-01T17:38:05.968851Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["6cac8518"] @@ -242,3 +242,4 @@ Test fixtures are small inline TOML strings written to a `tempdir`; no external | 2026-05-01T01:10Z | specd | ammend | philippepascal | | 2026-05-01T01:19Z | ammend | in_design | philippepascal | | 2026-05-01T01:22Z | in_design | specd | claude-0501-0119-6978 | +| 2026-05-01T17:38Z | specd | ready | philippepascal | From 4ffe0625fab45f57c2e2157e477d34292b36b55b Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 10:38:09 -0700 Subject: [PATCH 211/305] =?UTF-8?q?ticket(7f5f73d5):=20specd=20=E2=86=92?= =?UTF-8?q?=20ready?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/7f5f73d5-per-agent-instructions-resolution-under-.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md index 5446d104a..6eba51175 100644 --- a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md +++ b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md @@ -1,7 +1,7 @@ +++ id = "7f5f73d5" title = "Per-agent instructions resolution under .apm/agents//" -state = "specd" +state = "ready" priority = 0 effort = 4 risk = 2 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/7f5f73d5-per-agent-instructions-resolution-under-" created_at = "2026-04-30T20:03:33.687625Z" -updated_at = "2026-04-30T21:50:19.051619Z" +updated_at = "2026-05-01T17:38:08.957192Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "2c32a282"] @@ -182,3 +182,4 @@ Update the three existing `resolve_system_prompt_*` tests to match the new signa | 2026-04-30T21:02Z | new | groomed | philippepascal | | 2026-04-30T21:42Z | groomed | in_design | philippepascal | | 2026-04-30T21:50Z | in_design | specd | claude-0430-2142-eea0 | +| 2026-05-01T17:38Z | specd | ready | philippepascal | From 38e9c259ccfbb85a6a6af44d919bbfa3724bd240 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 10:38:12 -0700 Subject: [PATCH 212/305] =?UTF-8?q?ticket(0ca3e019):=20specd=20=E2=86=92?= =?UTF-8?q?=20ready?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md index 8f23c8a68..8ee1c4927 100644 --- a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md +++ b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md @@ -1,7 +1,7 @@ +++ id = "0ca3e019" title = "Frontmatter agent + agent_overrides for per-ticket wrapper choice" -state = "specd" +state = "ready" priority = 0 effort = 4 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/0ca3e019-frontmatter-agent-agent-overrides-for-pe" created_at = "2026-04-30T20:03:58.532325Z" -updated_at = "2026-05-01T01:26:16.494942Z" +updated_at = "2026-05-01T17:38:11.918348Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "6cac8518"] @@ -271,3 +271,4 @@ Append a short note to each file near the end: | 2026-05-01T01:10Z | specd | ammend | philippepascal | | 2026-05-01T01:22Z | ammend | in_design | philippepascal | | 2026-05-01T01:26Z | in_design | specd | claude-0501-0122-63c8 | +| 2026-05-01T17:38Z | specd | ready | philippepascal | From f060a086fe11812c398fdd064f226ad9a4d8280a Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 10:38:14 -0700 Subject: [PATCH 213/305] =?UTF-8?q?ticket(25c92daa):=20specd=20=E2=86=92?= =?UTF-8?q?=20ready?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md index c34e3c1c6..84bb8d8f4 100644 --- a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md +++ b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md @@ -1,7 +1,7 @@ +++ id = "25c92daa" title = "Mock and debug built-in wrappers (mock-happy, mock-sad, mock-random, debug)" -state = "specd" +state = "ready" priority = 0 effort = 6 risk = 4 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/25c92daa-mock-and-debug-built-in-wrappers-mock-ha" created_at = "2026-04-30T20:04:21.901984Z" -updated_at = "2026-05-01T01:57:11.578116Z" +updated_at = "2026-05-01T17:38:14.758614Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "a1b94ea4", "6cac8518"] @@ -644,3 +644,4 @@ This wrapper is a mock — see docs/agent-wrappers.md. | 2026-05-01T01:10Z | specd | ammend | philippepascal | | 2026-05-01T01:43Z | ammend | in_design | philippepascal | | 2026-05-01T01:57Z | in_design | specd | claude-0501-0143-f3e8 | +| 2026-05-01T17:38Z | specd | ready | philippepascal | From ea1ca6efaae50581e5dc55ee7aa0c85c803700df Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 10:38:17 -0700 Subject: [PATCH 214/305] =?UTF-8?q?ticket(71d80e40):=20specd=20=E2=86=92?= =?UTF-8?q?=20ready?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index 1dbc88df7..e59140652 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -1,7 +1,7 @@ +++ id = "71d80e40" title = "apm agents subcommand: new, list, test, eject" -state = "specd" +state = "ready" priority = 0 effort = 5 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/71d80e40-apm-agents-subcommand-new-list-test-ejec" created_at = "2026-04-30T20:04:57.796154Z" -updated_at = "2026-05-01T01:36:52.287818Z" +updated_at = "2026-05-01T17:38:17.525160Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "2c32a282"] @@ -290,3 +290,4 @@ Update `hash_trip::is_read_only_command` if `Agents` is listed: `List` and `Test | 2026-05-01T01:10Z | specd | ammend | philippepascal | | 2026-05-01T01:32Z | ammend | in_design | philippepascal | | 2026-05-01T01:36Z | in_design | specd | claude-0501-0132-6a28 | +| 2026-05-01T17:38Z | specd | ready | philippepascal | From 31e3ebea2cd3f301e13181e8cf1e073dfa4144c6 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 10:38:20 -0700 Subject: [PATCH 215/305] =?UTF-8?q?ticket(2e772eab):=20specd=20=E2=86=92?= =?UTF-8?q?=20ready?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md b/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md index beaa85b34..1558db674 100644 --- a/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md +++ b/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md @@ -1,7 +1,7 @@ +++ id = "2e772eab" title = "Wrapper-contract versioning (APM_WRAPPER_VERSION + manifest.toml)" -state = "specd" +state = "ready" priority = 0 effort = 2 risk = 1 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/2e772eab-wrapper-contract-versioning-apm-wrapper-" created_at = "2026-04-30T20:05:11.077339Z" -updated_at = "2026-05-01T00:33:48.510665Z" +updated_at = "2026-05-01T17:38:20.298482Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "2c32a282"] @@ -179,3 +179,4 @@ The `check_version_older_writes_warning` unit test covers the older-version path | 2026-05-01T00:09Z | in_design | ammend | philippepascal | | 2026-05-01T00:30Z | ammend | in_design | philippepascal | | 2026-05-01T00:33Z | in_design | specd | claude-0501-0030-e588 | +| 2026-05-01T17:38Z | specd | ready | philippepascal | From dd06b83568a6b5cb1a41f01682c05475ceb9fc44 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 10:38:23 -0700 Subject: [PATCH 216/305] =?UTF-8?q?ticket(2803bf07):=20specd=20=E2=86=92?= =?UTF-8?q?=20ready?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/2803bf07-output-parser-strategy-external-parsers-.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md index 3b999b0c5..a331b0676 100644 --- a/tickets/2803bf07-output-parser-strategy-external-parsers-.md +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -1,7 +1,7 @@ +++ id = "2803bf07" title = "Output parser strategy: external parsers via manifest.toml" -state = "specd" +state = "ready" priority = 0 effort = 5 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/2803bf07-output-parser-strategy-external-parsers-" created_at = "2026-04-30T20:05:40.844536Z" -updated_at = "2026-05-01T03:16:32.545146Z" +updated_at = "2026-05-01T17:38:23.279584Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["2c32a282", "2e772eab"] @@ -204,3 +204,4 @@ Use absolute paths for `parser_command` in the integration test to avoid dependi | 2026-05-01T03:08Z | specd | ammend | philippepascal | | 2026-05-01T03:09Z | ammend | in_design | philippepascal | | 2026-05-01T03:16Z | in_design | specd | claude-0501-0309-1140 | +| 2026-05-01T17:38Z | specd | ready | philippepascal | From a47d2ad5bdcec02749c00e81fa9749cdbd0b2a10 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 10:38:38 -0700 Subject: [PATCH 217/305] =?UTF-8?q?ticket(d3b93b95):=20start=20=E2=80=94?= =?UTF-8?q?=20ready=20=E2=86=92=20in=5Fprogress?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md index 6a1609deb..663cfaade 100644 --- a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md +++ b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md @@ -1,7 +1,7 @@ +++ id = "d3b93b95" title = "Wrapper contract foundation: trait, dispatcher, claude built-in (refactor)" -state = "ready" +state = "in_progress" priority = 0 effort = 4 risk = 2 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/d3b93b95-wrapper-contract-foundation-trait-dispat" created_at = "2026-04-30T20:01:55.080870Z" -updated_at = "2026-05-01T17:37:53.800805Z" +updated_at = "2026-05-01T17:38:38.903417Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" +++ @@ -184,3 +184,4 @@ Replace both with a single private `spawn_worker(ctx: WrapperContext) -> Result< | 2026-05-01T01:14Z | ammend | in_design | philippepascal | | 2026-05-01T01:16Z | in_design | specd | claude-0501-0114-d728 | | 2026-05-01T17:37Z | specd | ready | philippepascal | +| 2026-05-01T17:38Z | ready | in_progress | philippepascal | From a7f2a40da695be645e556da8ff945e51953de6da Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 10:59:47 -0700 Subject: [PATCH 218/305] Add wrapper contract foundation: Wrapper trait, WrapperContext, ClaudeWrapper, resolve_builtin - New apm-core/src/wrapper/ module with Wrapper trait, WrapperContext struct, resolve_builtin(), and write_temp_file() helper - ClaudeWrapper built-in implementing Wrapper for both local and container spawn paths; sets all 11 APM contract env vars including APM_BIN - Refactored start.rs: replaced build_spawn_command/spawn_container_worker with spawn_worker() dispatching through resolve_builtin('claude') - ManagedChild wrapper ensures temp files are cleaned up on drop (used by spawn_next_worker); cleanup thread removes files after run/run_next workers exit - Updated work.rs and apm/src/cmd/ to use ManagedChild return type - New tests: resolve_builtin Some/None, APM env vars on spawned process, temp file cleanup after child exits, CWD invariant preserved --- apm-core/src/lib.rs | 1 + apm-core/src/start.rs | 502 +++++++++++++++++++-------------- apm-core/src/work.rs | 2 +- apm-core/src/wrapper/claude.rs | 172 +++++++++++ apm-core/src/wrapper/mod.rs | 63 +++++ apm/src/cmd/start.rs | 2 +- apm/src/cmd/work.rs | 2 +- 7 files changed, 524 insertions(+), 220 deletions(-) create mode 100644 apm-core/src/wrapper/claude.rs create mode 100644 apm-core/src/wrapper/mod.rs diff --git a/apm-core/src/lib.rs b/apm-core/src/lib.rs index 87016e039..347cef12a 100644 --- a/apm-core/src/lib.rs +++ b/apm-core/src/lib.rs @@ -1,5 +1,6 @@ #![deny(clippy::print_stdout, clippy::print_stderr)] pub mod archive; +pub mod wrapper; pub mod help_schema; pub mod clean; pub mod config; diff --git a/apm-core/src/start.rs b/apm-core/src/start.rs index eed2ff747..a429e972c 100644 --- a/apm-core/src/start.rs +++ b/apm-core/src/start.rs @@ -1,7 +1,7 @@ use anyhow::{bail, Result}; use crate::{config::{Config, WorkerProfileConfig, WorkersConfig}, git, ticket, ticket_fmt}; +use crate::wrapper::{WrapperContext, write_temp_file}; use chrono::Utc; -use std::os::unix::process::CommandExt; use std::path::{Path, PathBuf}; pub struct EffectiveWorkerParams { @@ -59,11 +59,7 @@ pub struct RunNextOutput { pub log_path: Option, } -fn git_config_value(root: &Path, key: &str) -> Option { - crate::git_util::git_config_get(root, key) -} - -fn check_output_format_supported(binary: &str) -> Result<()> { +pub(crate) fn check_output_format_supported(binary: &str) -> Result<()> { let out = std::process::Command::new(binary) .arg("--help") .output() @@ -88,134 +84,32 @@ fn check_output_format_supported(binary: &str) -> Result<()> { } } -#[allow(clippy::too_many_arguments)] -fn spawn_container_worker( - root: &Path, - wt: &Path, - image: &str, - params: &EffectiveWorkerParams, - keychain: &std::collections::HashMap, - worker_name: &str, - worker_system: &str, - ticket_content: &str, - skip_permissions: bool, - log_path: &Path, -) -> anyhow::Result { - check_output_format_supported(¶ms.command)?; - - let api_key = crate::credentials::resolve( - "ANTHROPIC_API_KEY", - keychain.get("ANTHROPIC_API_KEY").map(|s| s.as_str()), - )?; - - let author_name = std::env::var("GIT_AUTHOR_NAME").ok() - .filter(|v| !v.is_empty()) - .or_else(|| git_config_value(root, "user.name")) - .unwrap_or_default(); - let author_email = std::env::var("GIT_AUTHOR_EMAIL").ok() - .filter(|v| !v.is_empty()) - .or_else(|| git_config_value(root, "user.email")) - .unwrap_or_default(); - let committer_name = std::env::var("GIT_COMMITTER_NAME").ok() - .filter(|v| !v.is_empty()) - .unwrap_or_else(|| author_name.clone()); - let committer_email = std::env::var("GIT_COMMITTER_EMAIL").ok() - .filter(|v| !v.is_empty()) - .unwrap_or_else(|| author_email.clone()); - - let mut cmd = std::process::Command::new("docker"); - cmd.arg("run"); - cmd.arg("--rm"); - cmd.args(["--volume", &format!("{}:/workspace", wt.display())]); - cmd.args(["--workdir", "/workspace"]); - cmd.args(["--env", &format!("ANTHROPIC_API_KEY={api_key}")]); - if !author_name.is_empty() { - cmd.args(["--env", &format!("GIT_AUTHOR_NAME={author_name}")]); - } - if !author_email.is_empty() { - cmd.args(["--env", &format!("GIT_AUTHOR_EMAIL={author_email}")]); - } - if !committer_name.is_empty() { - cmd.args(["--env", &format!("GIT_COMMITTER_NAME={committer_name}")]); - } - if !committer_email.is_empty() { - cmd.args(["--env", &format!("GIT_COMMITTER_EMAIL={committer_email}")]); - } - cmd.args(["--env", &format!("APM_AGENT_NAME={worker_name}")]); - for (k, v) in ¶ms.env { - cmd.args(["--env", &format!("{k}={v}")]); - } - cmd.arg(image); - cmd.arg(¶ms.command); - for arg in ¶ms.args { - cmd.arg(arg); - } - if let Some(ref model) = params.model { - cmd.args(["--model", model]); - } - cmd.args(["--output-format", "stream-json"]); - // Claude CLI requires --verbose when --print is paired with - // --output-format=stream-json; without it the spawned process exits - // immediately with "When using --print, --output-format=stream-json - // requires --verbose". --print is in [workers] args by default. - cmd.arg("--verbose"); - cmd.args(["--system-prompt", worker_system]); - if skip_permissions { - cmd.arg("--dangerously-skip-permissions"); - } - cmd.arg(ticket_content); +pub struct ManagedChild { + pub inner: std::process::Child, + temp_files: Vec, +} - let log_file = std::fs::File::create(log_path)?; - let log_clone = log_file.try_clone()?; - cmd.stdout(log_file); - cmd.stderr(log_clone); - cmd.process_group(0); +impl std::ops::Deref for ManagedChild { + type Target = std::process::Child; + fn deref(&self) -> &std::process::Child { &self.inner } +} - let child = cmd.spawn()?; - Ok(child) +impl std::ops::DerefMut for ManagedChild { + fn deref_mut(&mut self) -> &mut std::process::Child { &mut self.inner } } -fn build_spawn_command( - params: &EffectiveWorkerParams, - wt: &Path, - worker_name: &str, - worker_system: &str, - ticket_content: &str, - skip_permissions: bool, - log_path: &Path, -) -> Result { - check_output_format_supported(¶ms.command)?; - let mut cmd = std::process::Command::new(¶ms.command); - for arg in ¶ms.args { - cmd.arg(arg); - } - if let Some(ref model) = params.model { - cmd.args(["--model", model]); - } - cmd.args(["--output-format", "stream-json"]); - // Claude CLI requires --verbose when --print is paired with - // --output-format=stream-json; without it the spawned process exits - // immediately with "When using --print, --output-format=stream-json - // requires --verbose". --print is in [workers] args by default. - cmd.arg("--verbose"); - cmd.args(["--system-prompt", worker_system]); - if skip_permissions { - cmd.arg("--dangerously-skip-permissions"); - } - cmd.arg(ticket_content); - cmd.env("APM_AGENT_NAME", worker_name); - for (k, v) in ¶ms.env { - cmd.env(k, v); +impl Drop for ManagedChild { + fn drop(&mut self) { + for f in &self.temp_files { + let _ = std::fs::remove_file(f); + } } - cmd.current_dir(wt); - - let log_file = std::fs::File::create(log_path)?; - let log_clone = log_file.try_clone()?; - cmd.stdout(log_file); - cmd.stderr(log_clone); - cmd.process_group(0); +} - Ok(cmd.spawn()?) +fn spawn_worker(ctx: &WrapperContext) -> Result { + crate::wrapper::resolve_builtin("claude") + .expect("claude is always registered") + .spawn(ctx) } pub fn run(root: &Path, id_arg: &str, no_aggressive: bool, spawn: bool, skip_permissions: bool, agent_name: &str) -> Result { @@ -320,6 +214,10 @@ pub fn run(root: &Path, id_arg: &str, no_aggressive: bool, spawn: bool, skip_per let now_str = chrono::Utc::now().format("%m%d-%H%M").to_string(); let worker_name = format!("claude-{}-{:04x}", now_str, rand_u16()); + let profile_name = triggering_transition + .and_then(|tr| tr.profile.as_deref()) + .unwrap_or("") + .to_string(); let profile = triggering_transition.and_then(|tr| resolve_profile(tr, &config, &mut warnings)); let state_instructions = config.workflow.states.iter() .find(|s| s.id == old_state) @@ -329,25 +227,32 @@ pub fn run(root: &Path, id_arg: &str, no_aggressive: bool, spawn: bool, skip_per let with_epic = with_epic_bundle(root, ticket_epic_id.as_deref(), &id, &config, raw_prompt); let ticket_content = with_dependency_bundle(root, &ticket_depends_on, &config, with_epic); let params = effective_spawn_params(profile, &config.workers); + let role_prefix = profile.and_then(|p| p.role_prefix.clone()); let log_path = wt_display.join(".apm-worker.log"); - let mut child = if let Some(ref image) = params.container.clone() { - spawn_container_worker( - root, - &wt_display, - image, - ¶ms, - &config.workers.keychain, - &worker_name, - &worker_system, - &ticket_content, - skip_permissions, - &log_path, - )? - } else { - build_spawn_command(¶ms, &wt_display, &worker_name, &worker_system, &ticket_content, skip_permissions, &log_path)? + let sys_file = write_temp_file("sys", &worker_system)?; + let msg_file = write_temp_file("msg", &ticket_content)?; + let ctx = WrapperContext { + worker_name: worker_name.clone(), + ticket_id: id.clone(), + ticket_branch: branch.clone(), + worktree_path: wt_display.clone(), + system_prompt_file: sys_file.clone(), + user_message_file: msg_file.clone(), + skip_permissions, + profile: profile_name, + role_prefix, + options: std::collections::HashMap::new(), + model: params.model.clone(), + log_path: log_path.clone(), + container: params.container.clone(), + extra_env: params.env.clone(), + root: root.to_path_buf(), + keychain: config.workers.keychain.clone(), }; + check_output_format_supported(¶ms.command)?; + let mut child = spawn_worker(&ctx)?; let pid = child.id(); let pid_path = wt_display.join(".apm-worker.pid"); @@ -355,6 +260,8 @@ pub fn run(root: &Path, id_arg: &str, no_aggressive: bool, spawn: bool, skip_per std::thread::spawn(move || { let _ = child.wait(); + let _ = std::fs::remove_file(&sys_file); + let _ = std::fs::remove_file(&msg_file); }); Ok(StartOutput { @@ -490,6 +397,10 @@ pub fn run_next(root: &Path, no_aggressive: bool, spawn: bool, skip_permissions: let now_str = chrono::Utc::now().format("%m%d-%H%M").to_string(); let worker_name = format!("claude-{}-{:04x}", now_str, rand_u16()); + let profile_name2 = triggering_transition_owned.as_ref() + .and_then(|tr| tr.profile.as_deref()) + .unwrap_or("") + .to_string(); let profile2 = triggering_transition_owned.as_ref().and_then(|tr| resolve_profile(tr, &config, &mut warnings)); let state_instr2 = config.workflow.states.iter() .find(|s| s.id == old_state) @@ -502,6 +413,7 @@ pub fn run_next(root: &Path, no_aggressive: bool, spawn: bool, skip_permissions: let with_epic_next = with_epic_bundle(root, t.frontmatter.epic.as_deref(), &id, &config, raw_prompt_next); let ticket_content = with_dependency_bundle(root, &dep_ids_next, &config, with_epic_next); let params = effective_spawn_params(profile2, &config.workers); + let role_prefix2 = profile2.and_then(|p| p.role_prefix.clone()); let branch = t.frontmatter.branch.clone() .or_else(|| ticket_fmt::branch_name_from_path(&t.path)) @@ -513,28 +425,36 @@ pub fn run_next(root: &Path, no_aggressive: bool, spawn: bool, skip_permissions: let log_path = wt_display.join(".apm-worker.log"); - let mut child = if let Some(ref image) = params.container.clone() { - spawn_container_worker( - root, - &wt_display, - image, - ¶ms, - &config.workers.keychain, - &worker_name, - &worker_system, - &ticket_content, - skip_permissions, - &log_path, - )? - } else { - build_spawn_command(¶ms, &wt_display, &worker_name, &worker_system, &ticket_content, skip_permissions, &log_path)? + let sys_file = write_temp_file("sys", &worker_system)?; + let msg_file = write_temp_file("msg", &ticket_content)?; + let ctx = WrapperContext { + worker_name: worker_name.clone(), + ticket_id: id.clone(), + ticket_branch: branch.clone(), + worktree_path: wt_display.clone(), + system_prompt_file: sys_file.clone(), + user_message_file: msg_file.clone(), + skip_permissions, + profile: profile_name2, + role_prefix: role_prefix2, + options: std::collections::HashMap::new(), + model: params.model.clone(), + log_path: log_path.clone(), + container: params.container.clone(), + extra_env: params.env.clone(), + root: root.to_path_buf(), + keychain: config.workers.keychain.clone(), }; + check_output_format_supported(¶ms.command)?; + let mut child = spawn_worker(&ctx)?; let pid = child.id(); let pid_path = wt_display.join(".apm-worker.pid"); write_pid_file(&pid_path, pid, &id)?; std::thread::spawn(move || { let _ = child.wait(); + let _ = std::fs::remove_file(&sys_file); + let _ = std::fs::remove_file(&msg_file); }); messages.push(format!("Worker spawned: PID={pid}, log={}", log_path.display())); @@ -553,7 +473,7 @@ pub fn spawn_next_worker( default_blocked: bool, messages: &mut Vec, warnings: &mut Vec, -) -> Result, std::process::Child, PathBuf)>> { +) -> Result, ManagedChild, PathBuf)>> { let config = Config::load(root)?; let skip_permissions = skip_permissions || config.agents.skip_permissions; let p = &config.workflow.prioritization; @@ -661,6 +581,10 @@ pub fn spawn_next_worker( let now_str = chrono::Utc::now().format("%m%d-%H%M").to_string(); let worker_name = format!("claude-{}-{:04x}", now_str, rand_u16()); + let profile_name2 = triggering_transition_owned.as_ref() + .and_then(|tr| tr.profile.as_deref()) + .unwrap_or("") + .to_string(); let profile2 = triggering_transition_owned.as_ref().and_then(|tr| resolve_profile(tr, &config, warnings)); let state_instr2 = config.workflow.states.iter() .find(|s| s.id == old_state) @@ -673,6 +597,8 @@ pub fn spawn_next_worker( let with_epic_snw = with_epic_bundle(root, t.frontmatter.epic.as_deref(), &id, &config, raw_prompt_snw); let ticket_content = with_dependency_bundle(root, &dep_ids_snw, &config, with_epic_snw); let params = effective_spawn_params(profile2, &config.workers); + let role_prefix2 = profile2.and_then(|p| p.role_prefix.clone()); + let branch = t.frontmatter.branch.clone() .or_else(|| ticket_fmt::branch_name_from_path(&t.path)) .unwrap_or_else(|| format!("ticket/{id}")); @@ -683,31 +609,42 @@ pub fn spawn_next_worker( let log_path = wt_display.join(".apm-worker.log"); - let child = if let Some(ref image) = params.container.clone() { - spawn_container_worker( - root, - &wt_display, - image, - ¶ms, - &config.workers.keychain, - &worker_name, - &worker_system, - &ticket_content, - skip_permissions, - &log_path, - )? - } else { - build_spawn_command(¶ms, &wt_display, &worker_name, &worker_system, &ticket_content, skip_permissions, &log_path)? + let sys_file = write_temp_file("sys", &worker_system)?; + let msg_file = write_temp_file("msg", &ticket_content)?; + let ctx = WrapperContext { + worker_name: worker_name.clone(), + ticket_id: id.clone(), + ticket_branch: branch.clone(), + worktree_path: wt_display.clone(), + system_prompt_file: sys_file.clone(), + user_message_file: msg_file.clone(), + skip_permissions, + profile: profile_name2, + role_prefix: role_prefix2, + options: std::collections::HashMap::new(), + model: params.model.clone(), + log_path: log_path.clone(), + container: params.container.clone(), + extra_env: params.env.clone(), + root: root.to_path_buf(), + keychain: config.workers.keychain.clone(), }; + check_output_format_supported(¶ms.command)?; + let child = spawn_worker(&ctx)?; let pid = child.id(); + let managed = ManagedChild { + inner: child, + temp_files: vec![sys_file, msg_file], + }; + let pid_path = wt_display.join(".apm-worker.pid"); write_pid_file(&pid_path, pid, &id)?; messages.push(format!("Worker spawned: PID={pid}, log={}", log_path.display())); messages.push(format!("Agent name: {worker_name}")); - Ok(Some((id, epic_id, child, pid_path))) + Ok(Some((id, epic_id, managed, pid_path))) } /// If the ticket has dependencies, prepend a dependency context bundle to the @@ -781,7 +718,7 @@ fn rand_u16() -> u16 { #[cfg(test)] mod tests { - use super::{resolve_system_prompt, agent_role_prefix, resolve_profile, effective_spawn_params, build_spawn_command, check_output_format_supported, EffectiveWorkerParams}; + use super::{resolve_system_prompt, agent_role_prefix, resolve_profile, effective_spawn_params, check_output_format_supported, ManagedChild}; use crate::config::{WorkerProfileConfig, WorkersConfig, TransitionConfig, CompletionStrategy}; use std::collections::HashMap; @@ -1096,58 +1033,54 @@ mod tests { let wt = tempfile::tempdir().unwrap(); let log_dir = tempfile::tempdir().unwrap(); - let script_dir = tempfile::tempdir().unwrap(); - - // Mock worker script: - // --help → prints "--output-format stream-json" so the probe passes - // other → writes pwd to $APM_TEST_CWD_FILE and exits - let script_path = script_dir.path().join("mock-worker"); - let script = concat!( - "#!/bin/sh\n", - "if [ \"$1\" = \"--help\" ]; then\n", - " echo '--output-format stream-json'\n", - " exit 0\n", - "fi\n", - "pwd > \"$APM_TEST_CWD_FILE\"\n", - ); - std::fs::write(&script_path, script).unwrap(); - std::fs::set_permissions( - &script_path, - std::fs::Permissions::from_mode(0o755), - ) - .unwrap(); + let mock_dir = tempfile::tempdir().unwrap(); + // Write mock 'claude' script — reports pwd to a file + let mock_claude = mock_dir.path().join("claude"); let cwd_file = wt.path().join("cwd-output.txt"); - let mut env = std::collections::HashMap::new(); - env.insert( - "APM_TEST_CWD_FILE".to_string(), - cwd_file.to_str().unwrap().to_string(), + let script = format!(concat!( + "#!/bin/sh\n", + "pwd > \"{}\"\n", + ), cwd_file.display()); + std::fs::write(&mock_claude, &script).unwrap(); + std::fs::set_permissions(&mock_claude, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let sys_file = crate::wrapper::write_temp_file("sys", "system").unwrap(); + let msg_file = crate::wrapper::write_temp_file("msg", "ticket content").unwrap(); + + let mut extra_env = HashMap::new(); + extra_env.insert( + "PATH".to_string(), + format!("{}:{}", mock_dir.path().display(), std::env::var("PATH").unwrap_or_default()), ); - let params = EffectiveWorkerParams { - command: script_path.to_str().unwrap().to_string(), - args: vec![], + let ctx = crate::wrapper::WrapperContext { + worker_name: "test-worker".to_string(), + ticket_id: "test-id".to_string(), + ticket_branch: "ticket/test-id".to_string(), + worktree_path: wt.path().to_path_buf(), + system_prompt_file: sys_file.clone(), + user_message_file: msg_file.clone(), + skip_permissions: false, + profile: "default".to_string(), + role_prefix: None, + options: HashMap::new(), model: None, - env, + log_path: log_dir.path().join("worker.log"), container: None, + extra_env, + root: wt.path().to_path_buf(), + keychain: HashMap::new(), }; - let log_path = log_dir.path().join("worker.log"); - let mut child = build_spawn_command( - ¶ms, - wt.path(), - "test-worker", - "system", - "ticket content", - false, - &log_path, - ) - .unwrap(); - + let wrapper = crate::wrapper::resolve_builtin("claude").unwrap(); + let mut child = wrapper.spawn(&ctx).unwrap(); child.wait().unwrap(); + let _ = std::fs::remove_file(&sys_file); + let _ = std::fs::remove_file(&msg_file); let cwd_out = std::fs::read_to_string(&cwd_file) - .expect("cwd-output.txt not written — mock worker did not run in expected cwd"); + .expect("cwd-output.txt not written — mock claude did not run in expected cwd"); let expected = wt.path().canonicalize().unwrap(); assert_eq!( cwd_out.trim(), @@ -1186,4 +1119,139 @@ mod tests { "error message must include binary path: {msg}" ); } + + // --- APM env vars on spawned process --- + + #[test] + fn claude_wrapper_sets_apm_env_vars() { + use std::os::unix::fs::PermissionsExt; + + let wt = tempfile::tempdir().unwrap(); + let log_dir = tempfile::tempdir().unwrap(); + let mock_dir = tempfile::tempdir().unwrap(); + let env_output = wt.path().join("env-output.txt"); + + // Mock 'claude' writes all env vars to a file then exits + let mock_claude = mock_dir.path().join("claude"); + let script = format!( + "#!/bin/sh\nprintenv > \"{}\"\n", + env_output.display() + ); + std::fs::write(&mock_claude, &script).unwrap(); + std::fs::set_permissions(&mock_claude, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let sys_file = crate::wrapper::write_temp_file("sys", "system prompt").unwrap(); + let msg_file = crate::wrapper::write_temp_file("msg", "ticket content").unwrap(); + + let mut extra_env = HashMap::new(); + extra_env.insert( + "PATH".to_string(), + format!("{}:{}", mock_dir.path().display(), std::env::var("PATH").unwrap_or_default()), + ); + + let ctx = crate::wrapper::WrapperContext { + worker_name: "test-worker".to_string(), + ticket_id: "abc123".to_string(), + ticket_branch: "ticket/abc123-some-feature".to_string(), + worktree_path: wt.path().to_path_buf(), + system_prompt_file: sys_file.clone(), + user_message_file: msg_file.clone(), + skip_permissions: false, + profile: "my-profile".to_string(), + role_prefix: None, + options: HashMap::new(), + model: None, + log_path: log_dir.path().join("worker.log"), + container: None, + extra_env, + root: wt.path().to_path_buf(), + keychain: HashMap::new(), + }; + + let wrapper = crate::wrapper::resolve_builtin("claude").unwrap(); + let mut child = wrapper.spawn(&ctx).unwrap(); + child.wait().unwrap(); + let _ = std::fs::remove_file(&sys_file); + let _ = std::fs::remove_file(&msg_file); + + let env_content = std::fs::read_to_string(&env_output) + .expect("env-output.txt not written — mock claude did not run"); + + assert!(env_content.contains("APM_AGENT_NAME=test-worker"), "missing APM_AGENT_NAME\n{env_content}"); + assert!(env_content.contains("APM_TICKET_ID=abc123"), "missing APM_TICKET_ID\n{env_content}"); + assert!(env_content.contains("APM_TICKET_BRANCH=ticket/abc123-some-feature"), "missing APM_TICKET_BRANCH\n{env_content}"); + assert!(env_content.contains("APM_TICKET_WORKTREE="), "missing APM_TICKET_WORKTREE\n{env_content}"); + assert!(env_content.contains("APM_SYSTEM_PROMPT_FILE="), "missing APM_SYSTEM_PROMPT_FILE\n{env_content}"); + assert!(env_content.contains("APM_USER_MESSAGE_FILE="), "missing APM_USER_MESSAGE_FILE\n{env_content}"); + assert!(env_content.contains("APM_SKIP_PERMISSIONS=0"), "missing APM_SKIP_PERMISSIONS\n{env_content}"); + assert!(env_content.contains("APM_PROFILE=my-profile"), "missing APM_PROFILE\n{env_content}"); + assert!(env_content.contains("APM_WRAPPER_VERSION=1"), "missing APM_WRAPPER_VERSION\n{env_content}"); + assert!(env_content.contains("APM_BIN="), "missing APM_BIN\n{env_content}"); + + // APM_BIN must point to an existing file + if let Some(line) = env_content.lines().find(|l| l.starts_with("APM_BIN=")) { + let path = line.trim_start_matches("APM_BIN="); + assert!(std::path::Path::new(path).exists(), "APM_BIN path does not exist: {path}"); + } + } + + // --- temp file cleanup --- + + #[test] + fn temp_files_removed_after_child_exits() { + use std::os::unix::fs::PermissionsExt; + + let wt = tempfile::tempdir().unwrap(); + let log_dir = tempfile::tempdir().unwrap(); + let mock_dir = tempfile::tempdir().unwrap(); + + // Mock 'claude' that just exits immediately + let mock_claude = mock_dir.path().join("claude"); + std::fs::write(&mock_claude, "#!/bin/sh\nexit 0\n").unwrap(); + std::fs::set_permissions(&mock_claude, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let sys_file = crate::wrapper::write_temp_file("sys", "system").unwrap(); + let msg_file = crate::wrapper::write_temp_file("msg", "message").unwrap(); + + assert!(sys_file.exists(), "sys_file should exist before spawn"); + assert!(msg_file.exists(), "msg_file should exist before spawn"); + + let mut extra_env = HashMap::new(); + extra_env.insert( + "PATH".to_string(), + format!("{}:{}", mock_dir.path().display(), std::env::var("PATH").unwrap_or_default()), + ); + + let ctx = crate::wrapper::WrapperContext { + worker_name: "test".to_string(), + ticket_id: "test123".to_string(), + ticket_branch: "ticket/test123".to_string(), + worktree_path: wt.path().to_path_buf(), + system_prompt_file: sys_file.clone(), + user_message_file: msg_file.clone(), + skip_permissions: false, + profile: "default".to_string(), + role_prefix: None, + options: HashMap::new(), + model: None, + log_path: log_dir.path().join("worker.log"), + container: None, + extra_env, + root: wt.path().to_path_buf(), + keychain: HashMap::new(), + }; + + let wrapper = crate::wrapper::resolve_builtin("claude").unwrap(); + let child = wrapper.spawn(&ctx).unwrap(); + + let mut managed = ManagedChild { + inner: child, + temp_files: vec![sys_file.clone(), msg_file.clone()], + }; + managed.inner.wait().unwrap(); + drop(managed); + + assert!(!sys_file.exists(), "sys_file should be removed after ManagedChild is dropped"); + assert!(!msg_file.exists(), "msg_file should be removed after ManagedChild is dropped"); + } } diff --git a/apm-core/src/work.rs b/apm-core/src/work.rs index f96a81a84..3f5af5df2 100644 --- a/apm-core/src/work.rs +++ b/apm-core/src/work.rs @@ -12,7 +12,7 @@ pub fn run_engine_loop( skip_permissions: bool, epic_filter: Option, ) -> Result<()> { - let mut workers: Vec<(String, Option, std::process::Child, std::path::PathBuf)> = Vec::new(); + let mut workers: Vec<(String, Option, crate::start::ManagedChild, std::path::PathBuf)> = Vec::new(); let mut no_more = false; let mut next_poll = Instant::now(); diff --git a/apm-core/src/wrapper/claude.rs b/apm-core/src/wrapper/claude.rs new file mode 100644 index 000000000..acf59df9a --- /dev/null +++ b/apm-core/src/wrapper/claude.rs @@ -0,0 +1,172 @@ +use std::os::unix::process::CommandExt; +use super::{Wrapper, WrapperContext}; + +pub struct ClaudeWrapper; + +impl Wrapper for ClaudeWrapper { + fn spawn(&self, ctx: &WrapperContext) -> anyhow::Result { + let sys = std::fs::read_to_string(&ctx.system_prompt_file)?; + let msg = std::fs::read_to_string(&ctx.user_message_file)?; + + let apm_bin = std::env::current_exe() + .and_then(|p| p.canonicalize()) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default(); + + match &ctx.container { + None => spawn_local(ctx, &sys, &msg, &apm_bin), + Some(image) => spawn_container(ctx, image, &sys, &msg, &apm_bin), + } + } +} + +fn spawn_local( + ctx: &WrapperContext, + sys: &str, + msg: &str, + apm_bin: &str, +) -> anyhow::Result { + let mut cmd = std::process::Command::new("claude"); + cmd.arg("--print"); + cmd.args(["--output-format", "stream-json"]); + cmd.arg("--verbose"); + cmd.args(["--system-prompt", sys]); + if let Some(ref model) = ctx.model { + cmd.args(["--model", model]); + } + if ctx.skip_permissions { + cmd.arg("--dangerously-skip-permissions"); + } + cmd.arg(msg); + + set_apm_env(&mut cmd, ctx, apm_bin); + for (k, v) in &ctx.extra_env { + cmd.env(k, v); + } + + cmd.current_dir(&ctx.worktree_path); + + let log_file = std::fs::File::create(&ctx.log_path)?; + let log_clone = log_file.try_clone()?; + cmd.stdout(log_file); + cmd.stderr(log_clone); + cmd.process_group(0); + + Ok(cmd.spawn()?) +} + +fn spawn_container( + ctx: &WrapperContext, + image: &str, + sys: &str, + msg: &str, + apm_bin: &str, +) -> anyhow::Result { + let api_key = crate::credentials::resolve( + "ANTHROPIC_API_KEY", + ctx.keychain.get("ANTHROPIC_API_KEY").map(|s| s.as_str()), + )?; + + let author_name = std::env::var("GIT_AUTHOR_NAME") + .ok() + .filter(|v| !v.is_empty()) + .or_else(|| crate::git_util::git_config_get(&ctx.root, "user.name")) + .unwrap_or_default(); + let author_email = std::env::var("GIT_AUTHOR_EMAIL") + .ok() + .filter(|v| !v.is_empty()) + .or_else(|| crate::git_util::git_config_get(&ctx.root, "user.email")) + .unwrap_or_default(); + let committer_name = std::env::var("GIT_COMMITTER_NAME") + .ok() + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| author_name.clone()); + let committer_email = std::env::var("GIT_COMMITTER_EMAIL") + .ok() + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| author_email.clone()); + + let mut cmd = std::process::Command::new("docker"); + cmd.arg("run"); + cmd.arg("--rm"); + cmd.args(["--volume", &format!("{}:/workspace", ctx.worktree_path.display())]); + cmd.args(["--workdir", "/workspace"]); + cmd.args(["--env", &format!("ANTHROPIC_API_KEY={api_key}")]); + if !author_name.is_empty() { + cmd.args(["--env", &format!("GIT_AUTHOR_NAME={author_name}")]); + } + if !author_email.is_empty() { + cmd.args(["--env", &format!("GIT_AUTHOR_EMAIL={author_email}")]); + } + if !committer_name.is_empty() { + cmd.args(["--env", &format!("GIT_COMMITTER_NAME={committer_name}")]); + } + if !committer_email.is_empty() { + cmd.args(["--env", &format!("GIT_COMMITTER_EMAIL={committer_email}")]); + } + + let skip_perm_val = if ctx.skip_permissions { "1" } else { "0" }; + let worktree_str = ctx.worktree_path.to_string_lossy(); + let sys_file_str = ctx.system_prompt_file.to_string_lossy(); + let msg_file_str = ctx.user_message_file.to_string_lossy(); + + let apm_env_pairs: &[(&str, &str)] = &[ + ("APM_AGENT_NAME", &ctx.worker_name), + ("APM_TICKET_ID", &ctx.ticket_id), + ("APM_TICKET_BRANCH", &ctx.ticket_branch), + ("APM_TICKET_WORKTREE", &worktree_str), + ("APM_SYSTEM_PROMPT_FILE", &sys_file_str), + ("APM_USER_MESSAGE_FILE", &msg_file_str), + ("APM_SKIP_PERMISSIONS", skip_perm_val), + ("APM_PROFILE", &ctx.profile), + ("APM_WRAPPER_VERSION", "1"), + ("APM_BIN", apm_bin), + ]; + for (k, v) in apm_env_pairs { + cmd.args(["--env", &format!("{k}={v}")]); + } + if let Some(ref prefix) = ctx.role_prefix { + cmd.args(["--env", &format!("APM_ROLE_PREFIX={prefix}")]); + } + for (k, v) in &ctx.extra_env { + cmd.args(["--env", &format!("{k}={v}")]); + } + + cmd.arg(image); + cmd.arg("claude"); + cmd.arg("--print"); + cmd.args(["--output-format", "stream-json"]); + cmd.arg("--verbose"); + cmd.args(["--system-prompt", sys]); + if let Some(ref model) = ctx.model { + cmd.args(["--model", model]); + } + if ctx.skip_permissions { + cmd.arg("--dangerously-skip-permissions"); + } + cmd.arg(msg); + + let log_file = std::fs::File::create(&ctx.log_path)?; + let log_clone = log_file.try_clone()?; + cmd.stdout(log_file); + cmd.stderr(log_clone); + cmd.process_group(0); + + Ok(cmd.spawn()?) +} + +fn set_apm_env(cmd: &mut std::process::Command, ctx: &WrapperContext, apm_bin: &str) { + cmd.env("APM_AGENT_NAME", &ctx.worker_name); + cmd.env("APM_TICKET_ID", &ctx.ticket_id); + cmd.env("APM_TICKET_BRANCH", &ctx.ticket_branch); + cmd.env("APM_TICKET_WORKTREE", ctx.worktree_path.to_string_lossy().as_ref()); + cmd.env("APM_SYSTEM_PROMPT_FILE", ctx.system_prompt_file.to_string_lossy().as_ref()); + cmd.env("APM_USER_MESSAGE_FILE", ctx.user_message_file.to_string_lossy().as_ref()); + cmd.env("APM_SKIP_PERMISSIONS", if ctx.skip_permissions { "1" } else { "0" }); + cmd.env("APM_PROFILE", &ctx.profile); + if let Some(ref prefix) = ctx.role_prefix { + cmd.env("APM_ROLE_PREFIX", prefix); + } + cmd.env("APM_WRAPPER_VERSION", "1"); + cmd.env("APM_BIN", apm_bin); +} diff --git a/apm-core/src/wrapper/mod.rs b/apm-core/src/wrapper/mod.rs new file mode 100644 index 000000000..27aefcbd2 --- /dev/null +++ b/apm-core/src/wrapper/mod.rs @@ -0,0 +1,63 @@ +mod claude; +pub use claude::ClaudeWrapper; + +use std::collections::HashMap; +use std::path::PathBuf; + +pub struct WrapperContext { + pub worker_name: String, + pub ticket_id: String, + pub ticket_branch: String, + pub worktree_path: PathBuf, + pub system_prompt_file: PathBuf, + pub user_message_file: PathBuf, + pub skip_permissions: bool, + pub profile: String, + pub role_prefix: Option, + pub options: HashMap, + pub model: Option, + pub log_path: PathBuf, + pub container: Option, + pub extra_env: HashMap, + pub root: PathBuf, + pub keychain: HashMap, +} + +pub trait Wrapper { + fn spawn(&self, ctx: &WrapperContext) -> anyhow::Result; +} + +pub fn resolve_builtin(name: &str) -> Option> { + match name { + "claude" => Some(Box::new(ClaudeWrapper)), + _ => None, + } +} + +pub fn write_temp_file(prefix: &str, content: &str) -> anyhow::Result { + let path = std::env::temp_dir().join(format!("apm-{prefix}-{:04x}.txt", rand_u16())); + std::fs::write(&path, content)?; + Ok(path) +} + +fn rand_u16() -> u16 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().subsec_nanos() as u16 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_builtin_claude_returns_some() { + assert!(resolve_builtin("claude").is_some()); + } + + #[test] + fn resolve_builtin_unknown_returns_none() { + assert!(resolve_builtin("bogus").is_none()); + assert!(resolve_builtin("").is_none()); + assert!(resolve_builtin("mock-happy").is_none()); + } +} diff --git a/apm/src/cmd/start.rs b/apm/src/cmd/start.rs index 7545a1056..78ffb53ad 100644 --- a/apm/src/cmd/start.rs +++ b/apm/src/cmd/start.rs @@ -38,7 +38,7 @@ pub fn spawn_next_worker( epic_filter: Option<&str>, blocked_epics: &[String], default_blocked: bool, -) -> Result, std::process::Child, std::path::PathBuf)>> { +) -> Result, apm_core::start::ManagedChild, std::path::PathBuf)>> { let mut messages = Vec::new(); let mut warnings = Vec::new(); let result = apm_core::start::spawn_next_worker(root, no_aggressive, skip_permissions, epic_filter, blocked_epics, default_blocked, &mut messages, &mut warnings)?; diff --git a/apm/src/cmd/work.rs b/apm/src/cmd/work.rs index c7343d0de..88712e525 100644 --- a/apm/src/cmd/work.rs +++ b/apm/src/cmd/work.rs @@ -29,7 +29,7 @@ pub fn run(root: &Path, skip_permissions: bool, dry_run: bool, daemon: bool, int sig_count_clone.fetch_add(1, Ordering::Relaxed); }); - let mut workers: Vec<(String, Option, std::process::Child, std::path::PathBuf)> = Vec::new(); + let mut workers: Vec<(String, Option, apm_core::start::ManagedChild, std::path::PathBuf)> = Vec::new(); let mut started_ids: Vec = Vec::new(); let mut no_more = false; // next_poll only used in daemon mode From 0b53ff86514a68d28097107501339cd3e3e40315 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 11:00:32 -0700 Subject: [PATCH 219/305] ticket(d3b93b95): mark all acceptance criteria complete --- ...rapper-contract-foundation-trait-dispat.md | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md index 663cfaade..75acffef8 100644 --- a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md +++ b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md @@ -44,19 +44,19 @@ Refactor `apm-core/src/start.rs` to dispatch through a Wrapper abstraction inste ### Acceptance criteria -- [ ] `apm-core/src/wrapper/mod.rs` exists and exports a public `Wrapper` trait with a single method `fn spawn(&self, ctx: &WrapperContext) -> anyhow::Result` -- [ ] `WrapperContext` is a public struct with fields covering all items listed in the Problem scope: `worker_name`, `ticket_id`, `ticket_branch`, `worktree_path`, `system_prompt_file`, `user_message_file`, `skip_permissions`, `profile`, `role_prefix`, `options`, `model`, `log_path` -- [ ] `resolve_builtin("claude")` returns `Some(_)` (a `Box`) -- [ ] `resolve_builtin` returns `None` for any name other than `"claude"` -- [ ] The `claude` built-in spawns `claude --print --output-format=stream-json --verbose --system-prompt [--model ] [--dangerously-skip-permissions] ` — byte-for-byte identical flags to the current hardcoded invocation -- [ ] All eleven contract env vars are present on the spawned child process: `APM_AGENT_NAME`, `APM_TICKET_ID`, `APM_TICKET_BRANCH`, `APM_TICKET_WORKTREE`, `APM_SYSTEM_PROMPT_FILE`, `APM_USER_MESSAGE_FILE`, `APM_SKIP_PERMISSIONS` (`"1"` or `"0"`), `APM_PROFILE`, `APM_WRAPPER_VERSION=1`, `APM_BIN` (canonicalized path of the running APM binary); `APM_ROLE_PREFIX` is set when `ctx.role_prefix.is_some()` -- [ ] System prompt content is written to a temp file before spawn; `ctx.system_prompt_file` and `APM_SYSTEM_PROMPT_FILE` point to the same path -- [ ] User message content is written to a temp file before spawn; `ctx.user_message_file` and `APM_USER_MESSAGE_FILE` point to the same path -- [ ] Both temp files are removed after the child process exits (best-effort; removal errors are not propagated) -- [ ] `build_spawn_command` is refactored to write temp files and dispatch through `WrapperContext`; it no longer directly appends `--output-format`, `--verbose`, `--system-prompt`, or `--dangerously-skip-permissions` to the command -- [ ] `spawn_container_worker` is refactored to write temp files and dispatch through `WrapperContext`; docker `--env` flags carry the same APM contract vars as the local path -- [ ] All pre-existing tests in `start.rs` pass -- [ ] New unit tests cover: `resolve_builtin` returning `Some`/`None`, all APM env vars present on the spawned process (including `APM_BIN`), temp file creation and best-effort cleanup after child exit +- [x] `apm-core/src/wrapper/mod.rs` exists and exports a public `Wrapper` trait with a single method `fn spawn(&self, ctx: &WrapperContext) -> anyhow::Result` +- [x] `WrapperContext` is a public struct with fields covering all items listed in the Problem scope: `worker_name`, `ticket_id`, `ticket_branch`, `worktree_path`, `system_prompt_file`, `user_message_file`, `skip_permissions`, `profile`, `role_prefix`, `options`, `model`, `log_path` +- [x] `resolve_builtin("claude")` returns `Some(_)` (a `Box`) +- [x] `resolve_builtin` returns `None` for any name other than `"claude"` +- [x] The `claude` built-in spawns `claude --print --output-format=stream-json --verbose --system-prompt [--model ] [--dangerously-skip-permissions] ` — byte-for-byte identical flags to the current hardcoded invocation +- [x] All eleven contract env vars are present on the spawned child process: `APM_AGENT_NAME`, `APM_TICKET_ID`, `APM_TICKET_BRANCH`, `APM_TICKET_WORKTREE`, `APM_SYSTEM_PROMPT_FILE`, `APM_USER_MESSAGE_FILE`, `APM_SKIP_PERMISSIONS` (`"1"` or `"0"`), `APM_PROFILE`, `APM_WRAPPER_VERSION=1`, `APM_BIN` (canonicalized path of the running APM binary); `APM_ROLE_PREFIX` is set when `ctx.role_prefix.is_some()` +- [x] System prompt content is written to a temp file before spawn; `ctx.system_prompt_file` and `APM_SYSTEM_PROMPT_FILE` point to the same path +- [x] User message content is written to a temp file before spawn; `ctx.user_message_file` and `APM_USER_MESSAGE_FILE` point to the same path +- [x] Both temp files are removed after the child process exits (best-effort; removal errors are not propagated) +- [x] `build_spawn_command` is refactored to write temp files and dispatch through `WrapperContext`; it no longer directly appends `--output-format`, `--verbose`, `--system-prompt`, or `--dangerously-skip-permissions` to the command +- [x] `spawn_container_worker` is refactored to write temp files and dispatch through `WrapperContext`; docker `--env` flags carry the same APM contract vars as the local path +- [x] All pre-existing tests in `start.rs` pass +- [x] New unit tests cover: `resolve_builtin` returning `Some`/`None`, all APM env vars present on the spawned process (including `APM_BIN`), temp file creation and best-effort cleanup after child exit ### Out of scope From c3c0ac21681a11c5c800cc7653d1d71f8c8e8a9e Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 11:00:37 -0700 Subject: [PATCH 220/305] =?UTF-8?q?ticket(d3b93b95):=20in=5Fprogress=20?= =?UTF-8?q?=E2=86=92=20implemented?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md index 75acffef8..7327e26da 100644 --- a/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md +++ b/tickets/d3b93b95-wrapper-contract-foundation-trait-dispat.md @@ -1,7 +1,7 @@ +++ id = "d3b93b95" title = "Wrapper contract foundation: trait, dispatcher, claude built-in (refactor)" -state = "in_progress" +state = "implemented" priority = 0 effort = 4 risk = 2 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/d3b93b95-wrapper-contract-foundation-trait-dispat" created_at = "2026-04-30T20:01:55.080870Z" -updated_at = "2026-05-01T17:38:38.903417Z" +updated_at = "2026-05-01T18:00:37.404332Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" +++ @@ -185,3 +185,4 @@ Replace both with a single private `spawn_worker(ctx: WrapperContext) -> Result< | 2026-05-01T01:16Z | in_design | specd | claude-0501-0114-d728 | | 2026-05-01T17:37Z | specd | ready | philippepascal | | 2026-05-01T17:38Z | ready | in_progress | philippepascal | +| 2026-05-01T18:00Z | in_progress | implemented | claude-0501-1738-a830 | From 9b98ac11555dd8caa5bab4c2c79b3c5b8bf80d57 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 11:00:59 -0700 Subject: [PATCH 221/305] =?UTF-8?q?ticket(a1b94ea4):=20start=20=E2=80=94?= =?UTF-8?q?=20ready=20=E2=86=92=20in=5Fprogress?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md index 0d401ba13..6f0119916 100644 --- a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md +++ b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md @@ -1,7 +1,7 @@ +++ id = "a1b94ea4" title = "Add outcome field to TransitionConfig with implicit defaults" -state = "ready" +state = "in_progress" priority = 0 effort = 4 risk = 2 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/a1b94ea4-add-outcome-field-to-transitionconfig-wi" created_at = "2026-04-30T20:02:08.987471Z" -updated_at = "2026-05-01T17:37:56.991763Z" +updated_at = "2026-05-01T18:00:59.471937Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" +++ @@ -340,3 +340,4 @@ Parse the default workflow; build a state-by-id map; for each state's transition | 2026-05-01T01:17Z | ammend | in_design | philippepascal | | 2026-05-01T01:19Z | in_design | specd | claude-0501-0117-5ab0 | | 2026-05-01T17:37Z | specd | ready | philippepascal | +| 2026-05-01T18:00Z | ready | in_progress | philippepascal | From a6c5d6a0783d74f4093071500b0d43d3e497d743 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 11:09:47 -0700 Subject: [PATCH 222/305] Add outcome field to TransitionConfig with resolve_outcome helper - Add pub outcome: Option field to TransitionConfig with serde default and doc comment citing the five recognised values - Add public resolve_outcome<'a> free function: returns explicit outcome when set, otherwise infers success/cancelled/needs_input from completion strategy and target state terminal flag - Annotate every [[workflow.states.transitions]] block in default workflow.toml with an explicit outcome field (success, cancelled, or needs_input) - Extend validate_warnings with BFS reachability check: warns when no agent-actionable state can reach a success-outcome transition - Add four resolve_outcome unit tests in config.rs covering all branches - Add default_workflow_all_transitions_have_valid_outcomes test in init.rs - Add dead_end_workflow_warning_emitted and default_workflow_no_dead_end_warning tests in validate.rs --- apm-core/src/config.rs | 80 +++++++++++++++++++ apm-core/src/default/workflow.toml | 66 ++++++++++++---- apm-core/src/init.rs | 31 ++++++++ apm-core/src/start.rs | 1 + apm-core/src/validate.rs | 120 ++++++++++++++++++++++++++++- 5 files changed, 281 insertions(+), 17 deletions(-) diff --git a/apm-core/src/config.rs b/apm-core/src/config.rs index 0be581dcb..cd139e5eb 100644 --- a/apm-core/src/config.rs +++ b/apm-core/src/config.rs @@ -367,6 +367,12 @@ pub struct TransitionConfig { pub profile: Option, #[serde(default)] pub on_failure: Option, + /// Semantic outcome of this transition from the worker's perspective. + /// Recognised values: `success`, `needs_input`, `blocked`, `rejected`, `cancelled`. + /// Custom values are accepted but treated as non-success by tooling. + /// When omitted, `resolve_outcome` applies implicit defaults; see that function. + #[serde(default)] + pub outcome: Option, } /// Weights used to compute the priority score for ticket selection in `apm next`. @@ -387,6 +393,28 @@ fn default_priority_weight() -> f64 { 10.0 } fn default_effort_weight() -> f64 { -2.0 } fn default_risk_weight() -> f64 { -1.0 } +/// Returns the effective outcome label for `transition`. +/// +/// Uses the explicit `outcome` field when set; otherwise applies implicit defaults in order: +/// 1. `completion` strategy is set (non-`None`) → `"success"` +/// 2. `target_state.terminal` is true → `"cancelled"` +/// 3. Otherwise → `"needs_input"` +pub fn resolve_outcome<'a>( + transition: &'a TransitionConfig, + target_state: &StateConfig, +) -> &'a str { + if let Some(ref o) = transition.outcome { + return o.as_str(); + } + if transition.completion != CompletionStrategy::None { + return "success"; + } + if target_state.terminal { + return "cancelled"; + } + "needs_input" +} + #[derive(Debug, Deserialize, JsonSchema)] pub struct AgentsConfig { /// Maximum number of worker agents allowed to run simultaneously. @@ -808,6 +836,58 @@ trigger = "manual" assert_eq!(t.completion, CompletionStrategy::None); assert!(t.focus_section.is_none()); assert!(t.context_section.is_none()); + assert!(t.outcome.is_none()); + } + + #[test] + fn resolve_outcome_explicit_override() { + let t: TransitionConfig = toml::from_str(r#" +to = "ammend" +outcome = "rejected" +"#).unwrap(); + let s: StateConfig = toml::from_str(r#" +id = "ammend" +label = "Ammend" +"#).unwrap(); + assert_eq!(super::resolve_outcome(&t, &s), "rejected"); + } + + #[test] + fn resolve_outcome_implicit_success() { + let t: TransitionConfig = toml::from_str(r#" +to = "implemented" +completion = "merge" +"#).unwrap(); + let s: StateConfig = toml::from_str(r#" +id = "implemented" +label = "Implemented" +"#).unwrap(); + assert_eq!(super::resolve_outcome(&t, &s), "success"); + } + + #[test] + fn resolve_outcome_implicit_cancelled() { + let t: TransitionConfig = toml::from_str(r#" +to = "closed" +"#).unwrap(); + let s: StateConfig = toml::from_str(r#" +id = "closed" +label = "Closed" +terminal = true +"#).unwrap(); + assert_eq!(super::resolve_outcome(&t, &s), "cancelled"); + } + + #[test] + fn resolve_outcome_implicit_needs_input() { + let t: TransitionConfig = toml::from_str(r#" +to = "blocked" +"#).unwrap(); + let s: StateConfig = toml::from_str(r#" +id = "blocked" +label = "Blocked" +"#).unwrap(); + assert_eq!(super::resolve_outcome(&t, &s), "needs_input"); } #[test] diff --git a/apm-core/src/default/workflow.toml b/apm-core/src/default/workflow.toml index 9b58977c6..c8bdf86b1 100644 --- a/apm-core/src/default/workflow.toml +++ b/apm-core/src/default/workflow.toml @@ -7,10 +7,12 @@ label = "New" [[workflow.states.transitions]] to = "groomed" trigger = "manual" + outcome = "needs_input" [[workflow.states.transitions]] to = "closed" trigger = "manual" + outcome = "cancelled" [[workflow.states]] id = "groomed" @@ -24,10 +26,12 @@ instructions = ".apm/apm.spec-writer.md" trigger = "command:start" profile = "spec_agent" context_section = "Problem" + outcome = "needs_input" [[workflow.states.transitions]] to = "closed" trigger = "manual" + outcome = "cancelled" [[workflow.states]] id = "question" @@ -37,10 +41,12 @@ actionable = ["supervisor"] [[workflow.states.transitions]] to = "groomed" trigger = "manual" + outcome = "needs_input" [[workflow.states.transitions]] to = "closed" trigger = "manual" + outcome = "cancelled" [[workflow.states]] id = "specd" @@ -50,16 +56,19 @@ satisfies_deps = "spec" worker_end = true [[workflow.states.transitions]] - to = "ready" - trigger = "manual" + to = "ready" + trigger = "manual" + outcome = "needs_input" [[workflow.states.transitions]] to = "ammend" trigger = "manual" + outcome = "needs_input" [[workflow.states.transitions]] to = "closed" trigger = "manual" + outcome = "cancelled" [[workflow.states]] id = "ammend" @@ -70,21 +79,25 @@ satisfies_deps = "spec" instructions = ".apm/apm.spec-writer.md" [[workflow.states.transitions]] - to = "specd" - trigger = "manual" + to = "specd" + trigger = "manual" + outcome = "needs_input" [[workflow.states.transitions]] to = "question" trigger = "manual" + outcome = "needs_input" [[workflow.states.transitions]] to = "in_design" trigger = "command:start" profile = "spec_agent" + outcome = "needs_input" [[workflow.states.transitions]] to = "closed" trigger = "manual" + outcome = "cancelled" [[workflow.states]] id = "in_design" @@ -92,20 +105,24 @@ label = "In Design" instructions = ".apm/apm.spec-writer.md" [[workflow.states.transitions]] - to = "specd" - trigger = "manual" + to = "specd" + trigger = "manual" + outcome = "needs_input" [[workflow.states.transitions]] to = "question" trigger = "manual" + outcome = "needs_input" [[workflow.states.transitions]] to = "ammend" trigger = "manual" + outcome = "needs_input" [[workflow.states.transitions]] to = "closed" trigger = "manual" + outcome = "cancelled" [[workflow.states]] id = "ready" @@ -118,18 +135,22 @@ instructions = ".apm/apm.worker.md" to = "in_progress" trigger = "command:start" profile = "impl_agent" + outcome = "needs_input" [[workflow.states.transitions]] to = "ammend" trigger = "manual" + outcome = "needs_input" [[workflow.states.transitions]] to = "specd" trigger = "manual" + outcome = "needs_input" [[workflow.states.transitions]] to = "closed" trigger = "manual" + outcome = "cancelled" [[workflow.states]] id = "in_progress" @@ -142,24 +163,29 @@ instructions = ".apm/apm.worker.md" trigger = "manual" completion = "pr_or_epic_merge" on_failure = "merge_failed" + outcome = "success" [[workflow.states.transitions]] to = "blocked" trigger = "manual" label = "Agent is blocked — wrote questions in ### Open questions" + outcome = "needs_input" [[workflow.states.transitions]] - to = "ready" - trigger = "manual" - warning = "Reverting in_progress ticket to ready — any uncommitted work on the branch may be lost" + to = "ready" + trigger = "manual" + warning = "Reverting in_progress ticket to ready — any uncommitted work on the branch may be lost" + outcome = "needs_input" [[workflow.states.transitions]] - to = "ammend" - trigger = "manual" + to = "ammend" + trigger = "manual" + outcome = "needs_input" [[workflow.states.transitions]] to = "closed" trigger = "manual" + outcome = "cancelled" [[workflow.states]] id = "blocked" @@ -167,13 +193,15 @@ label = "Blocked" actionable = ["supervisor"] [[workflow.states.transitions]] - to = "ready" - trigger = "manual" - label = "Supervisor answered questions — agent can resume" + to = "ready" + trigger = "manual" + label = "Supervisor answered questions — agent can resume" + outcome = "needs_input" [[workflow.states.transitions]] to = "closed" trigger = "manual" + outcome = "cancelled" [[workflow.states]] id = "implemented" @@ -186,18 +214,22 @@ worker_end = true to = "ready" trigger = "manual" focus_section = "Code review" + outcome = "needs_input" [[workflow.states.transitions]] - to = "ammend" - trigger = "manual" + to = "ammend" + trigger = "manual" + outcome = "needs_input" [[workflow.states.transitions]] to = "in_progress" trigger = "manual" + outcome = "needs_input" [[workflow.states.transitions]] to = "closed" trigger = "manual" + outcome = "cancelled" [[workflow.states]] id = "merge_failed" @@ -207,10 +239,12 @@ actionable = ["supervisor"] [[workflow.states.transitions]] to = "implemented" trigger = "manual" + outcome = "needs_input" [[workflow.states.transitions]] to = "in_progress" trigger = "manual" + outcome = "needs_input" [[workflow.states]] id = "closed" diff --git a/apm-core/src/init.rs b/apm-core/src/init.rs index 5b04bc45f..37b359b97 100644 --- a/apm-core/src/init.rs +++ b/apm-core/src/init.rs @@ -839,6 +839,37 @@ mod tests { } } + #[test] + fn default_workflow_all_transitions_have_valid_outcomes() { + use crate::config::{resolve_outcome, WorkflowFile}; + + let parsed: WorkflowFile = toml::from_str(default_workflow_toml()).unwrap(); + let states = &parsed.workflow.states; + let state_map: std::collections::HashMap<&str, &crate::config::StateConfig> = + states.iter().map(|s| (s.id.as_str(), s)).collect(); + + let valid_outcomes = ["success", "needs_input", "blocked", "rejected", "cancelled"]; + + for state in states { + for t in &state.transitions { + let target = state_map + .get(t.to.as_str()) + .unwrap_or_else(|| panic!("target state '{}' not found in map", t.to)); + let outcome = resolve_outcome(t, target); + assert!( + !outcome.is_empty(), + "transition {} → {} has empty outcome", + state.id, t.to + ); + assert!( + valid_outcomes.contains(&outcome), + "transition {} → {} has unexpected outcome '{outcome}'", + state.id, t.to + ); + } + } + } + #[test] fn default_ticket_toml_is_valid() { use crate::config::TicketFile; diff --git a/apm-core/src/start.rs b/apm-core/src/start.rs index a429e972c..af0f4664d 100644 --- a/apm-core/src/start.rs +++ b/apm-core/src/start.rs @@ -734,6 +734,7 @@ mod tests { warning: None, profile: profile.map(|s| s.to_string()), on_failure: None, + outcome: None, } } diff --git a/apm-core/src/validate.rs b/apm-core/src/validate.rs index d4ccb51b0..2dcb700b9 100644 --- a/apm-core/src/validate.rs +++ b/apm-core/src/validate.rs @@ -1,4 +1,4 @@ -use crate::config::{CompletionStrategy, Config, LocalConfig}; +use crate::config::{resolve_outcome, CompletionStrategy, Config, LocalConfig}; use crate::ticket_fmt::Ticket; use anyhow::{bail, Result}; use std::collections::HashSet; @@ -397,6 +397,70 @@ pub fn validate_warnings(config: &crate::config::Config) -> Vec { } } } + + // Dead-end reachability check: warn when no agent-actionable state can reach a + // transition whose outcome resolves to "success". + let state_map: std::collections::HashMap<&str, &crate::config::StateConfig> = + config.workflow.states.iter() + .map(|s| (s.id.as_str(), s)) + .collect(); + + let agent_startable: Vec<&str> = config.workflow.states.iter() + .filter(|s| s.actionable.iter().any(|a| a == "agent" || a == "any")) + .map(|s| s.id.as_str()) + .collect(); + + if !agent_startable.is_empty() { + let mut visited: std::collections::HashSet<&str> = std::collections::HashSet::new(); + let mut queue: std::collections::VecDeque<&str> = std::collections::VecDeque::new(); + let mut found_success = false; + + for &start in &agent_startable { + if visited.insert(start) { + queue.push_back(start); + } + } + + 'bfs: while let Some(state_id) = queue.pop_front() { + if let Some(state) = state_map.get(state_id) { + for t in &state.transitions { + let outcome = if let Some(target_state) = state_map.get(t.to.as_str()) { + resolve_outcome(t, target_state) + } else { + // Target not in map (e.g., built-in "closed" if not declared). + // Inline the resolve_outcome fallback treating unknown targets as terminal. + if let Some(ref o) = t.outcome { + o.as_str() + } else if t.completion != CompletionStrategy::None { + "success" + } else { + "cancelled" + } + }; + + if outcome == "success" { + found_success = true; + break 'bfs; + } + + // Enqueue non-terminal target states for further exploration. + if let Some(target) = state_map.get(t.to.as_str()) { + if !target.terminal && visited.insert(t.to.as_str()) { + queue.push_back(t.to.as_str()); + } + } + } + } + } + + if !found_success { + warnings.push( + "workflow has no reachable 'success' outcome from any agent-actionable state; \ + workers may never complete successfully".to_string() + ); + } + } + warnings } @@ -1288,6 +1352,60 @@ container = "" assert!(warnings.is_empty(), "empty container string should not warn"); } + #[test] + fn dead_end_workflow_warning_emitted() { + // A workflow where the only agent-actionable state cycles back to itself + // with no completion strategy — no "success" outcome is reachable. + let toml = r#" +[project] +name = "test" + +[tickets] +dir = "tickets" + +[[workflow.states]] +id = "start" +label = "Start" +actionable = ["agent"] + +[[workflow.states.transitions]] +to = "middle" + +[[workflow.states]] +id = "middle" +label = "Middle" + +[[workflow.states.transitions]] +to = "start" +"#; + let config = load_config(toml); + let warnings = super::validate_warnings(&config); + assert!( + warnings.iter().any(|w| w.contains("success")), + "expected dead-end warning containing 'success'; got: {warnings:?}" + ); + } + + #[test] + fn default_workflow_no_dead_end_warning() { + // The default workflow has in_progress → implemented with completion = pr_or_epic_merge, + // reachable from the agent-actionable "ready" state. No dead-end warning should fire. + let base = r#" +[project] +name = "test" + +[tickets] +dir = "tickets" +"#; + let combined = format!("{}\n{}", base, crate::init::default_workflow_toml()); + let config: Config = toml::from_str(&combined).unwrap(); + let warnings = super::validate_warnings(&config); + assert!( + !warnings.iter().any(|w| w.contains("no reachable") && w.contains("success")), + "unexpected dead-end warning for default workflow; got: {warnings:?}" + ); + } + #[test] fn worktree_missing_in_design() { let dir = setup_verify_repo(); From dc56d67c1ba80eb1b58edec25e745a96df9c5b21 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 11:10:07 -0700 Subject: [PATCH 223/305] ticket(a1b94ea4): mark all acceptance criteria complete --- ...dd-outcome-field-to-transitionconfig-wi.md | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md index 6f0119916..cfee51c81 100644 --- a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md +++ b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md @@ -26,19 +26,19 @@ This ticket's scope is the data model and its rules. The field is deliberately i ### Acceptance criteria -- [ ] `TransitionConfig` has a `pub outcome: Option` field with `#[serde(default)]` and a doc comment citing the five recognised values -- [ ] A public `resolve_outcome<'a>(transition: &'a TransitionConfig, target_state: &StateConfig) -> &'a str` function exists in `apm-core` -- [ ] `resolve_outcome` returns the explicit outcome string (as `&str`) when `transition.outcome` is `Some` -- [ ] `resolve_outcome` returns `"success"` when `outcome` is `None` and `transition.completion != CompletionStrategy::None` -- [ ] `resolve_outcome` returns `"cancelled"` when `outcome` is `None`, `completion == None`, and `target_state.terminal == true` -- [ ] `resolve_outcome` returns `"needs_input"` when `outcome` is `None`, `completion == None`, and `target_state.terminal == false` -- [ ] Every `[[workflow.states.transitions]]` block in `apm-core/src/default/workflow.toml` contains an explicit `outcome` field -- [ ] `apm validate` emits a `warning:` line (not an error) when the workflow has no reachable `success` outcome from any agent-actionable state -- [ ] `apm validate` exits 0 (success) when the dead-end warning is the only issue -- [ ] Unit tests in `apm-core/src/config.rs` cover all four `resolve_outcome` branches, each as a separate `#[test]` -- [ ] A test asserts that every transition in the default workflow reports a non-empty outcome string via `resolve_outcome` -- [ ] A validate test covers the dead-end-warning path (workflow with an agent-actionable state but no reachable `success` transition) -- [ ] A validate test asserts the dead-end warning is absent for the default workflow (which has a reachable `success` via `in_progress -> implemented`) +- [x] `TransitionConfig` has a `pub outcome: Option` field with `#[serde(default)]` and a doc comment citing the five recognised values +- [x] A public `resolve_outcome<'a>(transition: &'a TransitionConfig, target_state: &StateConfig) -> &'a str` function exists in `apm-core` +- [x] `resolve_outcome` returns the explicit outcome string (as `&str`) when `transition.outcome` is `Some` +- [x] `resolve_outcome` returns `"success"` when `outcome` is `None` and `transition.completion != CompletionStrategy::None` +- [x] `resolve_outcome` returns `"cancelled"` when `outcome` is `None`, `completion == None`, and `target_state.terminal == true` +- [x] `resolve_outcome` returns `"needs_input"` when `outcome` is `None`, `completion == None`, and `target_state.terminal == false` +- [x] Every `[[workflow.states.transitions]]` block in `apm-core/src/default/workflow.toml` contains an explicit `outcome` field +- [x] `apm validate` emits a `warning:` line (not an error) when the workflow has no reachable `success` outcome from any agent-actionable state +- [x] `apm validate` exits 0 (success) when the dead-end warning is the only issue +- [x] Unit tests in `apm-core/src/config.rs` cover all four `resolve_outcome` branches, each as a separate `#[test]` +- [x] A test asserts that every transition in the default workflow reports a non-empty outcome string via `resolve_outcome` +- [x] A validate test covers the dead-end-warning path (workflow with an agent-actionable state but no reachable `success` transition) +- [x] A validate test asserts the dead-end warning is absent for the default workflow (which has a reachable `success` via `in_progress -> implemented`) ### Out of scope From 24b8fbfc507a1b8a9c22c0f73026133d7a773d0f Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 11:10:11 -0700 Subject: [PATCH 224/305] =?UTF-8?q?ticket(a1b94ea4):=20in=5Fprogress=20?= =?UTF-8?q?=E2=86=92=20implemented?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md index cfee51c81..21586a139 100644 --- a/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md +++ b/tickets/a1b94ea4-add-outcome-field-to-transitionconfig-wi.md @@ -1,7 +1,7 @@ +++ id = "a1b94ea4" title = "Add outcome field to TransitionConfig with implicit defaults" -state = "in_progress" +state = "implemented" priority = 0 effort = 4 risk = 2 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/a1b94ea4-add-outcome-field-to-transitionconfig-wi" created_at = "2026-04-30T20:02:08.987471Z" -updated_at = "2026-05-01T18:00:59.471937Z" +updated_at = "2026-05-01T18:10:11.710154Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" +++ @@ -341,3 +341,4 @@ Parse the default workflow; build a state-by-id map; for each state's transition | 2026-05-01T01:19Z | in_design | specd | claude-0501-0117-5ab0 | | 2026-05-01T17:37Z | specd | ready | philippepascal | | 2026-05-01T18:00Z | ready | in_progress | philippepascal | +| 2026-05-01T18:10Z | in_progress | implemented | claude-0501-1801-2150 | From 2c8107f2715870641745295cd21b2e572ffac2c5 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 11:10:32 -0700 Subject: [PATCH 225/305] =?UTF-8?q?ticket(6cac8518):=20start=20=E2=80=94?= =?UTF-8?q?=20ready=20=E2=86=92=20in=5Fprogress?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/6cac8518-config-schema-agent-options-drop-command.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/6cac8518-config-schema-agent-options-drop-command.md b/tickets/6cac8518-config-schema-agent-options-drop-command.md index a8843b3ce..36e842a30 100644 --- a/tickets/6cac8518-config-schema-agent-options-drop-command.md +++ b/tickets/6cac8518-config-schema-agent-options-drop-command.md @@ -1,7 +1,7 @@ +++ id = "6cac8518" title = "Config schema: agent + options (drop command/args/model)" -state = "ready" +state = "in_progress" priority = 0 effort = 4 risk = 4 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/6cac8518-config-schema-agent-options-drop-command" created_at = "2026-04-30T20:02:34.693415Z" -updated_at = "2026-05-01T17:38:00.312276Z" +updated_at = "2026-05-01T18:10:32.612106Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95"] @@ -118,3 +118,4 @@ Replace the two [worker_profiles.*] blocks to keep only instructions and role_pr | 2026-05-01T01:26Z | ammend | in_design | philippepascal | | 2026-05-01T01:29Z | in_design | specd | claude-0501-0126-ffc0 | | 2026-05-01T17:38Z | specd | ready | philippepascal | +| 2026-05-01T18:10Z | ready | in_progress | philippepascal | From 3dde15ccd9bb5b265b01db294de96c9f0967a919 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 11:26:57 -0700 Subject: [PATCH 226/305] Add agent/options config fields, wire dispatcher, emit deprecation warning - WorkersConfig: agent + options fields; command/args demoted to Option - WorkerProfileConfig: agent + options fields - effective_spawn_params: agent resolution, options merge, deprecation gate - spawn_worker: dispatches via resolve_builtin(agent), errors on unknown - ClaudeWrapper: APM_OPT_ env vars from ctx.options (local + container) - default_config: agent="claude" + [workers.options] model="sonnet", no command/args - Tests: 10 new tests covering all acceptance criteria --- apm-core/src/config.rs | 120 +++++++++++++++---- apm-core/src/init.rs | 10 +- apm-core/src/start.rs | 213 ++++++++++++++++++++++++++++++--- apm-core/src/wrapper/claude.rs | 16 +++ 4 files changed, 312 insertions(+), 47 deletions(-) diff --git a/apm-core/src/config.rs b/apm-core/src/config.rs index cd139e5eb..736c6bedf 100644 --- a/apm-core/src/config.rs +++ b/apm-core/src/config.rs @@ -79,18 +79,21 @@ pub struct WorkersConfig { /// Map of secret names to keychain item names resolved at worker launch time. #[serde(default)] pub keychain: std::collections::HashMap, - /// Executable used to run worker agents. - #[serde(default = "default_command")] - pub command: String, - /// Default arguments passed to the worker command. - #[serde(default = "default_args")] - pub args: Vec, + /// Executable used to run worker agents (deprecated — use `agent` instead). + pub command: Option, + /// Default arguments passed to the worker command (deprecated — use `agent` instead). + pub args: Option>, /// AI model override passed to the worker command; empty means use the command default. #[serde(default)] pub model: Option, /// Environment variables injected into every worker process. #[serde(default)] pub env: std::collections::HashMap, + /// Built-in agent identifier (e.g. `"claude"`). Takes precedence over `command`/`args`. + pub agent: Option, + /// Key-value options forwarded to the agent wrapper as `APM_OPT_` env vars. + #[serde(default)] + pub options: std::collections::HashMap, } impl Default for WorkersConfig { @@ -98,22 +101,21 @@ impl Default for WorkersConfig { Self { container: None, keychain: std::collections::HashMap::new(), - command: default_command(), - args: default_args(), + command: None, + args: None, model: None, env: std::collections::HashMap::new(), + agent: None, + options: std::collections::HashMap::new(), } } } -fn default_command() -> String { "claude".to_string() } -fn default_args() -> Vec { vec!["--print".to_string()] } - #[derive(Debug, Clone, Deserialize, Default, JsonSchema)] pub struct WorkerProfileConfig { - /// Override the worker command for this profile. + /// Override the worker command for this profile (deprecated — use `agent` instead). pub command: Option, - /// Override the worker command arguments for this profile. + /// Override the worker command arguments for this profile (deprecated — use `agent` instead). pub args: Option>, /// Override the AI model for this profile. pub model: Option, @@ -126,6 +128,11 @@ pub struct WorkerProfileConfig { pub instructions: Option, /// Role label prepended to the worker identity string for this profile. pub role_prefix: Option, + /// Built-in agent identifier for this profile. Overrides `[workers] agent`. + pub agent: Option, + /// Key-value options for this profile, merged over `[workers.options]`. + #[serde(default)] + pub options: std::collections::HashMap, } #[derive(Debug, Deserialize, Default, JsonSchema)] @@ -601,10 +608,10 @@ pub fn resolve_collaborators(config: &Config, local: &LocalConfig) -> (Vec> = std::sync::Mutex::new(Vec::new()); +#[cfg(test)] +static DEPRECATION_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn emit_deprecation_warning() { + use std::sync::atomic::Ordering; + if DEPRECATION_WARNED.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_ok() { + let msg = "apm: deprecated: `[workers] command`, `args`, and `model` fields are deprecated — migrate to `agent` and `[workers.options]`"; + eprintln!("{msg}"); + #[cfg(test)] + DEPRECATION_TEST_LOG.lock().unwrap().push(msg.to_string()); + } +} + pub struct EffectiveWorkerParams { pub command: String, pub args: Vec, pub model: Option, pub env: std::collections::HashMap, pub container: Option, + pub agent: String, + pub options: std::collections::HashMap, } fn resolve_profile<'a>(transition: &crate::config::TransitionConfig, config: &'a Config, warnings: &mut Vec) -> Option<&'a WorkerProfileConfig> { @@ -24,17 +43,54 @@ fn resolve_profile<'a>(transition: &crate::config::TransitionConfig, config: &'a } pub fn effective_spawn_params(profile: Option<&WorkerProfileConfig>, workers: &WorkersConfig) -> EffectiveWorkerParams { - let command = profile.and_then(|p| p.command.clone()).unwrap_or_else(|| workers.command.clone()); - let args = profile.and_then(|p| p.args.clone()).unwrap_or_else(|| workers.args.clone()); - let model = profile.and_then(|p| p.model.clone()).or_else(|| workers.model.clone()); - let container = profile.and_then(|p| p.container.clone()).or_else(|| workers.container.clone()); + // Legacy command/args (kept for check_output_format_supported backward compat) + let command = profile.and_then(|p| p.command.clone()) + .or_else(|| workers.command.clone()) + .unwrap_or_else(|| "claude".to_string()); + let args = profile.and_then(|p| p.args.clone()) + .or_else(|| workers.args.clone()) + .unwrap_or_else(|| vec!["--print".to_string()]); + + // Agent resolution: profile > workers > default "claude" + let raw_agent = profile.and_then(|p| p.agent.clone()) + .or_else(|| workers.agent.clone()); + + // Emit deprecation warning when legacy fields present but agent absent + let has_legacy = workers.command.is_some() + || workers.args.is_some() + || workers.model.is_some() + || profile.map(|p| p.command.is_some() || p.args.is_some() || p.model.is_some()).unwrap_or(false); + if raw_agent.is_none() && has_legacy { + emit_deprecation_warning(); + } + + let agent = raw_agent.unwrap_or_else(|| "claude".to_string()); + + // Options merge: workers.options base, profile.options overrides on collision + let mut options = workers.options.clone(); + if let Some(p) = profile { + for (k, v) in &p.options { + options.insert(k.clone(), v.clone()); + } + } + + // Model: options.model > legacy profile.model > legacy workers.model + let model = options.get("model").cloned() + .or_else(|| profile.and_then(|p| p.model.clone())) + .or_else(|| workers.model.clone()); + + // Env merge let mut env = workers.env.clone(); if let Some(p) = profile { for (k, v) in &p.env { env.insert(k.clone(), v.clone()); } } - EffectiveWorkerParams { command, args, model, env, container } + + let container = profile.and_then(|p| p.container.clone()) + .or_else(|| workers.container.clone()); + + EffectiveWorkerParams { command, args, model, env, container, agent, options } } pub struct StartOutput { @@ -106,10 +162,11 @@ impl Drop for ManagedChild { } } -fn spawn_worker(ctx: &WrapperContext) -> Result { - crate::wrapper::resolve_builtin("claude") - .expect("claude is always registered") - .spawn(ctx) +fn spawn_worker(ctx: &WrapperContext, agent: &str) -> Result { + match crate::wrapper::resolve_builtin(agent) { + Some(wrapper) => wrapper.spawn(ctx), + None => anyhow::bail!("unknown built-in agent {:?}; custom wrapper resolution is not yet supported (see ticket 2c32a282)", agent), + } } pub fn run(root: &Path, id_arg: &str, no_aggressive: bool, spawn: bool, skip_permissions: bool, agent_name: &str) -> Result { @@ -243,7 +300,7 @@ pub fn run(root: &Path, id_arg: &str, no_aggressive: bool, spawn: bool, skip_per skip_permissions, profile: profile_name, role_prefix, - options: std::collections::HashMap::new(), + options: params.options.clone(), model: params.model.clone(), log_path: log_path.clone(), container: params.container.clone(), @@ -252,7 +309,7 @@ pub fn run(root: &Path, id_arg: &str, no_aggressive: bool, spawn: bool, skip_per keychain: config.workers.keychain.clone(), }; check_output_format_supported(¶ms.command)?; - let mut child = spawn_worker(&ctx)?; + let mut child = spawn_worker(&ctx, ¶ms.agent)?; let pid = child.id(); let pid_path = wt_display.join(".apm-worker.pid"); @@ -437,7 +494,7 @@ pub fn run_next(root: &Path, no_aggressive: bool, spawn: bool, skip_permissions: skip_permissions, profile: profile_name2, role_prefix: role_prefix2, - options: std::collections::HashMap::new(), + options: params.options.clone(), model: params.model.clone(), log_path: log_path.clone(), container: params.container.clone(), @@ -446,7 +503,7 @@ pub fn run_next(root: &Path, no_aggressive: bool, spawn: bool, skip_permissions: keychain: config.workers.keychain.clone(), }; check_output_format_supported(¶ms.command)?; - let mut child = spawn_worker(&ctx)?; + let mut child = spawn_worker(&ctx, ¶ms.agent)?; let pid = child.id(); let pid_path = wt_display.join(".apm-worker.pid"); @@ -621,7 +678,7 @@ pub fn spawn_next_worker( skip_permissions, profile: profile_name2, role_prefix: role_prefix2, - options: std::collections::HashMap::new(), + options: params.options.clone(), model: params.model.clone(), log_path: log_path.clone(), container: params.container.clone(), @@ -630,7 +687,7 @@ pub fn spawn_next_worker( keychain: config.workers.keychain.clone(), }; check_output_format_supported(¶ms.command)?; - let child = spawn_worker(&ctx)?; + let child = spawn_worker(&ctx, ¶ms.agent)?; let pid = child.id(); let managed = ManagedChild { @@ -718,7 +775,7 @@ fn rand_u16() -> u16 { #[cfg(test)] mod tests { - use super::{resolve_system_prompt, agent_role_prefix, resolve_profile, effective_spawn_params, check_output_format_supported, ManagedChild}; + use super::{resolve_system_prompt, agent_role_prefix, resolve_profile, effective_spawn_params, check_output_format_supported, ManagedChild, DEPRECATION_WARNED, DEPRECATION_TEST_LOG, DEPRECATION_TEST_LOCK}; use crate::config::{WorkerProfileConfig, WorkersConfig, TransitionConfig, CompletionStrategy}; use std::collections::HashMap; @@ -748,12 +805,14 @@ mod tests { fn make_workers(command: &str, model: Option<&str>) -> WorkersConfig { WorkersConfig { - command: command.to_string(), - args: vec!["--print".to_string()], + command: Some(command.to_string()), + args: None, model: model.map(|s| s.to_string()), env: HashMap::new(), container: None, keychain: HashMap::new(), + agent: None, + options: HashMap::new(), } } @@ -1255,4 +1314,122 @@ mod tests { assert!(!sys_file.exists(), "sys_file should be removed after ManagedChild is dropped"); assert!(!msg_file.exists(), "msg_file should be removed after ManagedChild is dropped"); } + + // --- agent/options resolution --- + + #[test] + fn resolution_agent_profile_overrides_global() { + let workers = WorkersConfig { agent: Some("codex".into()), ..Default::default() }; + let profile = WorkerProfileConfig { agent: Some("mock-happy".into()), ..Default::default() }; + let params = effective_spawn_params(Some(&profile), &workers); + assert_eq!(params.agent, "mock-happy"); + } + + #[test] + fn resolution_agent_falls_back_to_claude() { + let params = effective_spawn_params(None, &WorkersConfig::default()); + assert_eq!(params.agent, "claude"); + } + + #[test] + fn resolution_options_merge() { + let mut workers = WorkersConfig { agent: Some("claude".into()), ..Default::default() }; + workers.options.insert("model".into(), "opus".into()); + workers.options.insert("timeout".into(), "30".into()); + let mut profile_opts = HashMap::new(); + profile_opts.insert("model".into(), "sonnet".into()); + let profile = WorkerProfileConfig { options: profile_opts, ..Default::default() }; + let params = effective_spawn_params(Some(&profile), &workers); + assert_eq!(params.options.get("model").map(|s| s.as_str()), Some("sonnet"), "profile model should override workers model"); + assert_eq!(params.options.get("timeout").map(|s| s.as_str()), Some("30"), "non-overlapping key should survive"); + } + + #[test] + fn deprecation_warning_emitted_once() { + let _guard = DEPRECATION_TEST_LOCK.lock().unwrap(); + DEPRECATION_WARNED.store(false, std::sync::atomic::Ordering::SeqCst); + DEPRECATION_TEST_LOG.lock().unwrap().clear(); + + let workers = WorkersConfig { command: Some("claude".into()), ..Default::default() }; + effective_spawn_params(None, &workers); + effective_spawn_params(None, &workers); + + let log = DEPRECATION_TEST_LOG.lock().unwrap(); + let count = log.iter().filter(|m: &&String| m.contains("deprecated")).count(); + assert_eq!(count, 1, "deprecated message should appear exactly once, found {count}"); + } + + #[test] + fn legacy_model_forwarded_to_ctx() { + let workers = WorkersConfig { model: Some("opus".into()), ..Default::default() }; + let params = effective_spawn_params(None, &workers); + assert_eq!(params.model.as_deref(), Some("opus")); + } + + #[test] + fn options_model_takes_precedence_over_legacy() { + let mut workers = WorkersConfig { model: Some("opus".into()), agent: Some("claude".into()), ..Default::default() }; + workers.options.insert("model".into(), "sonnet".into()); + let params = effective_spawn_params(None, &workers); + assert_eq!(params.model.as_deref(), Some("sonnet")); + } + + // --- APM_OPT_ env vars --- + + #[test] + fn apm_opt_env_vars_set() { + use std::os::unix::fs::PermissionsExt; + + let wt = tempfile::tempdir().unwrap(); + let log_dir = tempfile::tempdir().unwrap(); + let mock_dir = tempfile::tempdir().unwrap(); + let env_output = wt.path().join("env-output.txt"); + + let mock_claude = mock_dir.path().join("claude"); + let script = format!("#!/bin/sh\nprintenv > \"{}\"\n", env_output.display()); + std::fs::write(&mock_claude, &script).unwrap(); + std::fs::set_permissions(&mock_claude, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let sys_file = crate::wrapper::write_temp_file("sys", "system prompt").unwrap(); + let msg_file = crate::wrapper::write_temp_file("msg", "ticket content").unwrap(); + + let mut extra_env = HashMap::new(); + extra_env.insert( + "PATH".to_string(), + format!("{}:{}", mock_dir.path().display(), std::env::var("PATH").unwrap_or_default()), + ); + + let mut options = HashMap::new(); + options.insert("model".to_string(), "sonnet".to_string()); + + let ctx = crate::wrapper::WrapperContext { + worker_name: "test-worker".to_string(), + ticket_id: "abc123".to_string(), + ticket_branch: "ticket/abc123".to_string(), + worktree_path: wt.path().to_path_buf(), + system_prompt_file: sys_file.clone(), + user_message_file: msg_file.clone(), + skip_permissions: false, + profile: "default".to_string(), + role_prefix: None, + options, + model: None, + log_path: log_dir.path().join("worker.log"), + container: None, + extra_env, + root: wt.path().to_path_buf(), + keychain: HashMap::new(), + }; + + let wrapper = crate::wrapper::resolve_builtin("claude").unwrap(); + let mut child = wrapper.spawn(&ctx).unwrap(); + child.wait().unwrap(); + let _ = std::fs::remove_file(&sys_file); + let _ = std::fs::remove_file(&msg_file); + + let env_content = std::fs::read_to_string(&env_output) + .expect("env-output.txt not written"); + + assert!(env_content.contains("APM_OPT_MODEL=sonnet"), "APM_OPT_MODEL=sonnet must be set\n{env_content}"); + } } diff --git a/apm-core/src/wrapper/claude.rs b/apm-core/src/wrapper/claude.rs index acf59df9a..6ffe181d5 100644 --- a/apm-core/src/wrapper/claude.rs +++ b/apm-core/src/wrapper/claude.rs @@ -131,6 +131,14 @@ fn spawn_container( for (k, v) in &ctx.extra_env { cmd.args(["--env", &format!("{k}={v}")]); } + // APM_OPT_ for each option entry + for (k, v) in &ctx.options { + let env_key = format!( + "APM_OPT_{}", + k.to_uppercase().replace('.', "_").replace('-', "_") + ); + cmd.args(["--env", &format!("{env_key}={v}")]); + } cmd.arg(image); cmd.arg("claude"); @@ -169,4 +177,12 @@ fn set_apm_env(cmd: &mut std::process::Command, ctx: &WrapperContext, apm_bin: & } cmd.env("APM_WRAPPER_VERSION", "1"); cmd.env("APM_BIN", apm_bin); + // APM_OPT_ for each option entry + for (k, v) in &ctx.options { + let env_key = format!( + "APM_OPT_{}", + k.to_uppercase().replace('.', "_").replace('-', "_") + ); + cmd.env(&env_key, v); + } } From be9b7ecf0af71f7fd5133602582953ce35017a25 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 11:27:12 -0700 Subject: [PATCH 227/305] ticket(6cac8518): mark "WorkersConfig" in Acceptance criteria --- tickets/6cac8518-config-schema-agent-options-drop-command.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tickets/6cac8518-config-schema-agent-options-drop-command.md b/tickets/6cac8518-config-schema-agent-options-drop-command.md index 36e842a30..cbca3d277 100644 --- a/tickets/6cac8518-config-schema-agent-options-drop-command.md +++ b/tickets/6cac8518-config-schema-agent-options-drop-command.md @@ -23,7 +23,7 @@ The wrapper dispatcher landed in d3b93b95 always resolves to the `claude` built- ### Acceptance criteria -- [ ] `WorkersConfig` deserializes a TOML block containing `agent = "claude"` and `[workers.options]` without error +- [x] `WorkersConfig` deserializes a TOML block containing `agent = "claude"` and `[workers.options]` without error - [ ] `WorkerProfileConfig` deserializes a profile block containing `agent` and `options` without error - [ ] A config with `workers.agent = "codex"` and `profile.agent` absent resolves the effective agent to `"codex"` - [ ] A config with `workers.agent = "codex"` and `profile.agent = "mock-happy"` resolves the effective agent to `"mock-happy"` @@ -118,4 +118,4 @@ Replace the two [worker_profiles.*] blocks to keep only instructions and role_pr | 2026-05-01T01:26Z | ammend | in_design | philippepascal | | 2026-05-01T01:29Z | in_design | specd | claude-0501-0126-ffc0 | | 2026-05-01T17:38Z | specd | ready | philippepascal | -| 2026-05-01T18:10Z | ready | in_progress | philippepascal | +| 2026-05-01T18:10Z | ready | in_progress | philippepascal | \ No newline at end of file From 59c7d6c0945aa7e598f7976aae1a7efc96d2316c Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 11:27:20 -0700 Subject: [PATCH 228/305] ticket(6cac8518): mark "WorkerProfileConfig" in Acceptance criteria --- tickets/6cac8518-config-schema-agent-options-drop-command.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/6cac8518-config-schema-agent-options-drop-command.md b/tickets/6cac8518-config-schema-agent-options-drop-command.md index cbca3d277..a79b9e2cc 100644 --- a/tickets/6cac8518-config-schema-agent-options-drop-command.md +++ b/tickets/6cac8518-config-schema-agent-options-drop-command.md @@ -24,7 +24,7 @@ The wrapper dispatcher landed in d3b93b95 always resolves to the `claude` built- ### Acceptance criteria - [x] `WorkersConfig` deserializes a TOML block containing `agent = "claude"` and `[workers.options]` without error -- [ ] `WorkerProfileConfig` deserializes a profile block containing `agent` and `options` without error +- [x] `WorkerProfileConfig` deserializes a profile block containing `agent` and `options` without error - [ ] A config with `workers.agent = "codex"` and `profile.agent` absent resolves the effective agent to `"codex"` - [ ] A config with `workers.agent = "codex"` and `profile.agent = "mock-happy"` resolves the effective agent to `"mock-happy"` - [ ] A config with neither `workers.agent` nor `profile.agent` set resolves the effective agent to `"claude"` From 76819d797edb75a3ed099440ee9f4bfc1c6636d5 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 11:27:24 -0700 Subject: [PATCH 229/305] ticket(6cac8518): mark "profile.agent` absent" in Acceptance criteria --- tickets/6cac8518-config-schema-agent-options-drop-command.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/6cac8518-config-schema-agent-options-drop-command.md b/tickets/6cac8518-config-schema-agent-options-drop-command.md index a79b9e2cc..aec819425 100644 --- a/tickets/6cac8518-config-schema-agent-options-drop-command.md +++ b/tickets/6cac8518-config-schema-agent-options-drop-command.md @@ -25,7 +25,7 @@ The wrapper dispatcher landed in d3b93b95 always resolves to the `claude` built- - [x] `WorkersConfig` deserializes a TOML block containing `agent = "claude"` and `[workers.options]` without error - [x] `WorkerProfileConfig` deserializes a profile block containing `agent` and `options` without error -- [ ] A config with `workers.agent = "codex"` and `profile.agent` absent resolves the effective agent to `"codex"` +- [x] A config with `workers.agent = "codex"` and `profile.agent` absent resolves the effective agent to `"codex"` - [ ] A config with `workers.agent = "codex"` and `profile.agent = "mock-happy"` resolves the effective agent to `"mock-happy"` - [ ] A config with neither `workers.agent` nor `profile.agent` set resolves the effective agent to `"claude"` - [ ] `profile.options` keys override `workers.options` keys when both define the same key From 2b79b2744d710b2dc1e7089357f1de3c20d99810 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 11:27:28 -0700 Subject: [PATCH 230/305] ticket(6cac8518): mark "mock-happy" in Acceptance criteria --- tickets/6cac8518-config-schema-agent-options-drop-command.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/6cac8518-config-schema-agent-options-drop-command.md b/tickets/6cac8518-config-schema-agent-options-drop-command.md index aec819425..7c55ad7a9 100644 --- a/tickets/6cac8518-config-schema-agent-options-drop-command.md +++ b/tickets/6cac8518-config-schema-agent-options-drop-command.md @@ -26,7 +26,7 @@ The wrapper dispatcher landed in d3b93b95 always resolves to the `claude` built- - [x] `WorkersConfig` deserializes a TOML block containing `agent = "claude"` and `[workers.options]` without error - [x] `WorkerProfileConfig` deserializes a profile block containing `agent` and `options` without error - [x] A config with `workers.agent = "codex"` and `profile.agent` absent resolves the effective agent to `"codex"` -- [ ] A config with `workers.agent = "codex"` and `profile.agent = "mock-happy"` resolves the effective agent to `"mock-happy"` +- [x] A config with `workers.agent = "codex"` and `profile.agent = "mock-happy"` resolves the effective agent to `"mock-happy"` - [ ] A config with neither `workers.agent` nor `profile.agent` set resolves the effective agent to `"claude"` - [ ] `profile.options` keys override `workers.options` keys when both define the same key - [ ] `profile.options` and `workers.options` keys that do not overlap are both present in the effective options map From 21ef7346f6eac9fdbcdece78710846175a484083 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 11:27:33 -0700 Subject: [PATCH 231/305] ticket(6cac8518): mark "neither" in Acceptance criteria --- tickets/6cac8518-config-schema-agent-options-drop-command.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/6cac8518-config-schema-agent-options-drop-command.md b/tickets/6cac8518-config-schema-agent-options-drop-command.md index 7c55ad7a9..8d94b4682 100644 --- a/tickets/6cac8518-config-schema-agent-options-drop-command.md +++ b/tickets/6cac8518-config-schema-agent-options-drop-command.md @@ -27,7 +27,7 @@ The wrapper dispatcher landed in d3b93b95 always resolves to the `claude` built- - [x] `WorkerProfileConfig` deserializes a profile block containing `agent` and `options` without error - [x] A config with `workers.agent = "codex"` and `profile.agent` absent resolves the effective agent to `"codex"` - [x] A config with `workers.agent = "codex"` and `profile.agent = "mock-happy"` resolves the effective agent to `"mock-happy"` -- [ ] A config with neither `workers.agent` nor `profile.agent` set resolves the effective agent to `"claude"` +- [x] A config with neither `workers.agent` nor `profile.agent` set resolves the effective agent to `"claude"` - [ ] `profile.options` keys override `workers.options` keys when both define the same key - [ ] `profile.options` and `workers.options` keys that do not overlap are both present in the effective options map - [ ] Each entry in the effective options map is forwarded as an env var named `APM_OPT_` (key uppercased, dots and dashes replaced with underscores) From dfe36f738ee64f00c507253d5a1f9b224f008646 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 11:27:37 -0700 Subject: [PATCH 232/305] ticket(6cac8518): mark "override `workers.options`" in Acceptance criteria --- tickets/6cac8518-config-schema-agent-options-drop-command.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/6cac8518-config-schema-agent-options-drop-command.md b/tickets/6cac8518-config-schema-agent-options-drop-command.md index 8d94b4682..b0dbca728 100644 --- a/tickets/6cac8518-config-schema-agent-options-drop-command.md +++ b/tickets/6cac8518-config-schema-agent-options-drop-command.md @@ -28,7 +28,7 @@ The wrapper dispatcher landed in d3b93b95 always resolves to the `claude` built- - [x] A config with `workers.agent = "codex"` and `profile.agent` absent resolves the effective agent to `"codex"` - [x] A config with `workers.agent = "codex"` and `profile.agent = "mock-happy"` resolves the effective agent to `"mock-happy"` - [x] A config with neither `workers.agent` nor `profile.agent` set resolves the effective agent to `"claude"` -- [ ] `profile.options` keys override `workers.options` keys when both define the same key +- [x] `profile.options` keys override `workers.options` keys when both define the same key - [ ] `profile.options` and `workers.options` keys that do not overlap are both present in the effective options map - [ ] Each entry in the effective options map is forwarded as an env var named `APM_OPT_` (key uppercased, dots and dashes replaced with underscores) - [ ] `options.model = "sonnet"` results in `APM_OPT_MODEL=sonnet` being set on the spawned child From d87db9972dfecfb913d902fb97a3e5f34cbe688a Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 11:27:41 -0700 Subject: [PATCH 233/305] ticket(6cac8518): mark "do not overlap" in Acceptance criteria --- tickets/6cac8518-config-schema-agent-options-drop-command.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/6cac8518-config-schema-agent-options-drop-command.md b/tickets/6cac8518-config-schema-agent-options-drop-command.md index b0dbca728..9c04555ed 100644 --- a/tickets/6cac8518-config-schema-agent-options-drop-command.md +++ b/tickets/6cac8518-config-schema-agent-options-drop-command.md @@ -29,7 +29,7 @@ The wrapper dispatcher landed in d3b93b95 always resolves to the `claude` built- - [x] A config with `workers.agent = "codex"` and `profile.agent = "mock-happy"` resolves the effective agent to `"mock-happy"` - [x] A config with neither `workers.agent` nor `profile.agent` set resolves the effective agent to `"claude"` - [x] `profile.options` keys override `workers.options` keys when both define the same key -- [ ] `profile.options` and `workers.options` keys that do not overlap are both present in the effective options map +- [x] `profile.options` and `workers.options` keys that do not overlap are both present in the effective options map - [ ] Each entry in the effective options map is forwarded as an env var named `APM_OPT_` (key uppercased, dots and dashes replaced with underscores) - [ ] `options.model = "sonnet"` results in `APM_OPT_MODEL=sonnet` being set on the spawned child - [ ] A config using only legacy `command = "claude"` (no `agent` field) still spawns the claude wrapper successfully From 5619cb62cfc6275c28f5756fda1181c2301fd1a6 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 11:27:46 -0700 Subject: [PATCH 234/305] ticket(6cac8518): mark "APM_OPT_" in Acceptance criteria --- tickets/6cac8518-config-schema-agent-options-drop-command.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/6cac8518-config-schema-agent-options-drop-command.md b/tickets/6cac8518-config-schema-agent-options-drop-command.md index 9c04555ed..e4aac096c 100644 --- a/tickets/6cac8518-config-schema-agent-options-drop-command.md +++ b/tickets/6cac8518-config-schema-agent-options-drop-command.md @@ -30,7 +30,7 @@ The wrapper dispatcher landed in d3b93b95 always resolves to the `claude` built- - [x] A config with neither `workers.agent` nor `profile.agent` set resolves the effective agent to `"claude"` - [x] `profile.options` keys override `workers.options` keys when both define the same key - [x] `profile.options` and `workers.options` keys that do not overlap are both present in the effective options map -- [ ] Each entry in the effective options map is forwarded as an env var named `APM_OPT_` (key uppercased, dots and dashes replaced with underscores) +- [x] Each entry in the effective options map is forwarded as an env var named `APM_OPT_` (key uppercased, dots and dashes replaced with underscores) - [ ] `options.model = "sonnet"` results in `APM_OPT_MODEL=sonnet` being set on the spawned child - [ ] A config using only legacy `command = "claude"` (no `agent` field) still spawns the claude wrapper successfully - [ ] When legacy `command`, `args`, or `model` fields are present and `agent` is absent, a line containing the substring `deprecated` is written to stderr From 735e6adfca4c63c126d093a52b737e8bc0e78864 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 11:27:50 -0700 Subject: [PATCH 235/305] ticket(6cac8518): mark "APM_OPT_MODEL=sonnet" in Acceptance criteria --- tickets/6cac8518-config-schema-agent-options-drop-command.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/6cac8518-config-schema-agent-options-drop-command.md b/tickets/6cac8518-config-schema-agent-options-drop-command.md index e4aac096c..0784c1d00 100644 --- a/tickets/6cac8518-config-schema-agent-options-drop-command.md +++ b/tickets/6cac8518-config-schema-agent-options-drop-command.md @@ -31,7 +31,7 @@ The wrapper dispatcher landed in d3b93b95 always resolves to the `claude` built- - [x] `profile.options` keys override `workers.options` keys when both define the same key - [x] `profile.options` and `workers.options` keys that do not overlap are both present in the effective options map - [x] Each entry in the effective options map is forwarded as an env var named `APM_OPT_` (key uppercased, dots and dashes replaced with underscores) -- [ ] `options.model = "sonnet"` results in `APM_OPT_MODEL=sonnet` being set on the spawned child +- [x] `options.model = "sonnet"` results in `APM_OPT_MODEL=sonnet` being set on the spawned child - [ ] A config using only legacy `command = "claude"` (no `agent` field) still spawns the claude wrapper successfully - [ ] When legacy `command`, `args`, or `model` fields are present and `agent` is absent, a line containing the substring `deprecated` is written to stderr - [ ] Across the lifetime of a single process, stderr contains the substring `deprecated` exactly once — even when multiple workers with legacy config are spawned From 2c53aadf5ac2d2d85cfbfce170b2d9d817be0968 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 11:27:54 -0700 Subject: [PATCH 236/305] ticket(6cac8518): mark "only legacy" in Acceptance criteria --- tickets/6cac8518-config-schema-agent-options-drop-command.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/6cac8518-config-schema-agent-options-drop-command.md b/tickets/6cac8518-config-schema-agent-options-drop-command.md index 0784c1d00..224ddca50 100644 --- a/tickets/6cac8518-config-schema-agent-options-drop-command.md +++ b/tickets/6cac8518-config-schema-agent-options-drop-command.md @@ -32,7 +32,7 @@ The wrapper dispatcher landed in d3b93b95 always resolves to the `claude` built- - [x] `profile.options` and `workers.options` keys that do not overlap are both present in the effective options map - [x] Each entry in the effective options map is forwarded as an env var named `APM_OPT_` (key uppercased, dots and dashes replaced with underscores) - [x] `options.model = "sonnet"` results in `APM_OPT_MODEL=sonnet` being set on the spawned child -- [ ] A config using only legacy `command = "claude"` (no `agent` field) still spawns the claude wrapper successfully +- [x] A config using only legacy `command = "claude"` (no `agent` field) still spawns the claude wrapper successfully - [ ] When legacy `command`, `args`, or `model` fields are present and `agent` is absent, a line containing the substring `deprecated` is written to stderr - [ ] Across the lifetime of a single process, stderr contains the substring `deprecated` exactly once — even when multiple workers with legacy config are spawned - [ ] Legacy `model = "sonnet"` with no `options.model` still produces the correct `--model sonnet` flag in the spawned claude command From 23d7ad64b82bc13d483b77dd9289864f3d7fef65 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 11:27:59 -0700 Subject: [PATCH 237/305] ticket(6cac8518): mark "deprecated` is written" in Acceptance criteria --- tickets/6cac8518-config-schema-agent-options-drop-command.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/6cac8518-config-schema-agent-options-drop-command.md b/tickets/6cac8518-config-schema-agent-options-drop-command.md index 224ddca50..0828d6637 100644 --- a/tickets/6cac8518-config-schema-agent-options-drop-command.md +++ b/tickets/6cac8518-config-schema-agent-options-drop-command.md @@ -33,7 +33,7 @@ The wrapper dispatcher landed in d3b93b95 always resolves to the `claude` built- - [x] Each entry in the effective options map is forwarded as an env var named `APM_OPT_` (key uppercased, dots and dashes replaced with underscores) - [x] `options.model = "sonnet"` results in `APM_OPT_MODEL=sonnet` being set on the spawned child - [x] A config using only legacy `command = "claude"` (no `agent` field) still spawns the claude wrapper successfully -- [ ] When legacy `command`, `args`, or `model` fields are present and `agent` is absent, a line containing the substring `deprecated` is written to stderr +- [x] When legacy `command`, `args`, or `model` fields are present and `agent` is absent, a line containing the substring `deprecated` is written to stderr - [ ] Across the lifetime of a single process, stderr contains the substring `deprecated` exactly once — even when multiple workers with legacy config are spawned - [ ] Legacy `model = "sonnet"` with no `options.model` still produces the correct `--model sonnet` flag in the spawned claude command - [ ] `apm init` generates a config with `agent = "claude"`, `options.model = "sonnet"`, and no `command` or `args` fields From 98822bf35eccaf54cddfef54f5367f01e2f71d86 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 11:28:08 -0700 Subject: [PATCH 238/305] ticket(6cac8518): mark "Across the lifetime" in Acceptance criteria --- tickets/6cac8518-config-schema-agent-options-drop-command.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/6cac8518-config-schema-agent-options-drop-command.md b/tickets/6cac8518-config-schema-agent-options-drop-command.md index 0828d6637..1de6c7d41 100644 --- a/tickets/6cac8518-config-schema-agent-options-drop-command.md +++ b/tickets/6cac8518-config-schema-agent-options-drop-command.md @@ -34,7 +34,7 @@ The wrapper dispatcher landed in d3b93b95 always resolves to the `claude` built- - [x] `options.model = "sonnet"` results in `APM_OPT_MODEL=sonnet` being set on the spawned child - [x] A config using only legacy `command = "claude"` (no `agent` field) still spawns the claude wrapper successfully - [x] When legacy `command`, `args`, or `model` fields are present and `agent` is absent, a line containing the substring `deprecated` is written to stderr -- [ ] Across the lifetime of a single process, stderr contains the substring `deprecated` exactly once — even when multiple workers with legacy config are spawned +- [x] Across the lifetime of a single process, stderr contains the substring `deprecated` exactly once — even when multiple workers with legacy config are spawned - [ ] Legacy `model = "sonnet"` with no `options.model` still produces the correct `--model sonnet` flag in the spawned claude command - [ ] `apm init` generates a config with `agent = "claude"`, `options.model = "sonnet"`, and no `command` or `args` fields - [ ] A config with no `[workers]` section at all spawns successfully with defaults (agent = claude) From 2dd28400b13789204dd7168dc3d1506b380266df Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 11:28:12 -0700 Subject: [PATCH 239/305] ticket(6cac8518): mark "Legacy `model" in Acceptance criteria --- tickets/6cac8518-config-schema-agent-options-drop-command.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/6cac8518-config-schema-agent-options-drop-command.md b/tickets/6cac8518-config-schema-agent-options-drop-command.md index 1de6c7d41..d4ed10c2f 100644 --- a/tickets/6cac8518-config-schema-agent-options-drop-command.md +++ b/tickets/6cac8518-config-schema-agent-options-drop-command.md @@ -35,7 +35,7 @@ The wrapper dispatcher landed in d3b93b95 always resolves to the `claude` built- - [x] A config using only legacy `command = "claude"` (no `agent` field) still spawns the claude wrapper successfully - [x] When legacy `command`, `args`, or `model` fields are present and `agent` is absent, a line containing the substring `deprecated` is written to stderr - [x] Across the lifetime of a single process, stderr contains the substring `deprecated` exactly once — even when multiple workers with legacy config are spawned -- [ ] Legacy `model = "sonnet"` with no `options.model` still produces the correct `--model sonnet` flag in the spawned claude command +- [x] Legacy `model = "sonnet"` with no `options.model` still produces the correct `--model sonnet` flag in the spawned claude command - [ ] `apm init` generates a config with `agent = "claude"`, `options.model = "sonnet"`, and no `command` or `args` fields - [ ] A config with no `[workers]` section at all spawns successfully with defaults (agent = claude) From 88d63c151a690dacfef9349cc49d92d134175a0f Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 11:28:16 -0700 Subject: [PATCH 240/305] ticket(6cac8518): mark "apm init" in Acceptance criteria --- tickets/6cac8518-config-schema-agent-options-drop-command.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/6cac8518-config-schema-agent-options-drop-command.md b/tickets/6cac8518-config-schema-agent-options-drop-command.md index d4ed10c2f..730159e2d 100644 --- a/tickets/6cac8518-config-schema-agent-options-drop-command.md +++ b/tickets/6cac8518-config-schema-agent-options-drop-command.md @@ -36,7 +36,7 @@ The wrapper dispatcher landed in d3b93b95 always resolves to the `claude` built- - [x] When legacy `command`, `args`, or `model` fields are present and `agent` is absent, a line containing the substring `deprecated` is written to stderr - [x] Across the lifetime of a single process, stderr contains the substring `deprecated` exactly once — even when multiple workers with legacy config are spawned - [x] Legacy `model = "sonnet"` with no `options.model` still produces the correct `--model sonnet` flag in the spawned claude command -- [ ] `apm init` generates a config with `agent = "claude"`, `options.model = "sonnet"`, and no `command` or `args` fields +- [x] `apm init` generates a config with `agent = "claude"`, `options.model = "sonnet"`, and no `command` or `args` fields - [ ] A config with no `[workers]` section at all spawns successfully with defaults (agent = claude) ### Out of scope From b8218c18c772f71c3f46457b6b44b64b27e5b6f6 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 11:28:20 -0700 Subject: [PATCH 241/305] ticket(6cac8518): mark "no `[workers]`" in Acceptance criteria --- tickets/6cac8518-config-schema-agent-options-drop-command.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/6cac8518-config-schema-agent-options-drop-command.md b/tickets/6cac8518-config-schema-agent-options-drop-command.md index 730159e2d..b57784487 100644 --- a/tickets/6cac8518-config-schema-agent-options-drop-command.md +++ b/tickets/6cac8518-config-schema-agent-options-drop-command.md @@ -37,7 +37,7 @@ The wrapper dispatcher landed in d3b93b95 always resolves to the `claude` built- - [x] Across the lifetime of a single process, stderr contains the substring `deprecated` exactly once — even when multiple workers with legacy config are spawned - [x] Legacy `model = "sonnet"` with no `options.model` still produces the correct `--model sonnet` flag in the spawned claude command - [x] `apm init` generates a config with `agent = "claude"`, `options.model = "sonnet"`, and no `command` or `args` fields -- [ ] A config with no `[workers]` section at all spawns successfully with defaults (agent = claude) +- [x] A config with no `[workers]` section at all spawns successfully with defaults (agent = claude) ### Out of scope From 7f0b13d36f9c605d78b67e49495e8f0b58fd954f Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 11:28:25 -0700 Subject: [PATCH 242/305] =?UTF-8?q?ticket(6cac8518):=20in=5Fprogress=20?= =?UTF-8?q?=E2=86=92=20implemented?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../6cac8518-config-schema-agent-options-drop-command.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/6cac8518-config-schema-agent-options-drop-command.md b/tickets/6cac8518-config-schema-agent-options-drop-command.md index b57784487..7e847065a 100644 --- a/tickets/6cac8518-config-schema-agent-options-drop-command.md +++ b/tickets/6cac8518-config-schema-agent-options-drop-command.md @@ -1,7 +1,7 @@ +++ id = "6cac8518" title = "Config schema: agent + options (drop command/args/model)" -state = "in_progress" +state = "implemented" priority = 0 effort = 4 risk = 4 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/6cac8518-config-schema-agent-options-drop-command" created_at = "2026-04-30T20:02:34.693415Z" -updated_at = "2026-05-01T18:10:32.612106Z" +updated_at = "2026-05-01T18:28:25.720849Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95"] @@ -118,4 +118,5 @@ Replace the two [worker_profiles.*] blocks to keep only instructions and role_pr | 2026-05-01T01:26Z | ammend | in_design | philippepascal | | 2026-05-01T01:29Z | in_design | specd | claude-0501-0126-ffc0 | | 2026-05-01T17:38Z | specd | ready | philippepascal | -| 2026-05-01T18:10Z | ready | in_progress | philippepascal | \ No newline at end of file +| 2026-05-01T18:10Z | ready | in_progress | philippepascal | +| 2026-05-01T18:28Z | in_progress | implemented | claude-0501-1810-32d0 | From f45d4210bcf0ef8efc12f178ef8967c878c9fc84 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 11:28:44 -0700 Subject: [PATCH 243/305] =?UTF-8?q?ticket(3048d7e9):=20start=20=E2=80=94?= =?UTF-8?q?=20ready=20=E2=86=92=20in=5Fprogress?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md index 1081384ae..9610a733b 100644 --- a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md +++ b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md @@ -1,7 +1,7 @@ +++ id = "3048d7e9" title = "Migration: validate --fix ports legacy command/args/model to agent + options" -state = "ready" +state = "in_progress" priority = 0 effort = 4 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/3048d7e9-migration-validate-fix-ports-legacy-comm" created_at = "2026-04-30T20:03:17.277300Z" -updated_at = "2026-05-01T17:38:05.968851Z" +updated_at = "2026-05-01T18:28:44.690454Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["6cac8518"] @@ -243,3 +243,4 @@ Test fixtures are small inline TOML strings written to a `tempdir`; no external | 2026-05-01T01:19Z | ammend | in_design | philippepascal | | 2026-05-01T01:22Z | in_design | specd | claude-0501-0119-6978 | | 2026-05-01T17:38Z | specd | ready | philippepascal | +| 2026-05-01T18:28Z | ready | in_progress | philippepascal | From 554f9eff5d28f9827b15cd286a39456ccf57150f Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 11:45:47 -0700 Subject: [PATCH 244/305] Add apm validate --fix migration for legacy command/args/model fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites .apm/config.toml using toml_edit to replace legacy [workers] command/args/model with agent-wrapper shape: - command = "claude" → agent = "claude" - model = "sonnet" → [workers.options] model = "sonnet" - args = [...] → removed (wrapper owns CLI construction) Same migration applies to [worker_profiles.] sections. Non-claude commands block migration with a warning. Re-validates after write to catch migration bugs. Wire apply_config_migration_fixes into validate run() when --fix is set; prints confirmation message on success. --- Cargo.lock | 1 + apm/Cargo.toml | 1 + apm/src/cmd/validate.rs | 207 ++++++++++++++++++++++++++++++ apm/tests/validate_fix.rs | 263 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 472 insertions(+) create mode 100644 apm/tests/validate_fix.rs diff --git a/Cargo.lock b/Cargo.lock index 893ceceb8..dbdc9e3aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,6 +91,7 @@ dependencies = [ "serde_json", "tempfile", "toml", + "toml_edit", ] [[package]] diff --git a/apm/Cargo.toml b/apm/Cargo.toml index 0def457d3..029848df9 100644 --- a/apm/Cargo.toml +++ b/apm/Cargo.toml @@ -29,6 +29,7 @@ ctrlc = "3" serde = { workspace = true } serde_json = { workspace = true } reqwest = { workspace = true } +toml_edit = { workspace = true } [dev-dependencies] tempfile = "3" diff --git a/apm/src/cmd/validate.rs b/apm/src/cmd/validate.rs index a215f690e..12f4803a6 100644 --- a/apm/src/cmd/validate.rs +++ b/apm/src/cmd/validate.rs @@ -9,6 +9,208 @@ use std::collections::HashSet; use std::path::Path; use crate::ctx::CmdContext; +/// Rewrites `.apm/config.toml` (or `apm.toml`) to replace legacy +/// `[workers] command/args/model` fields with the agent-wrapper shape. +/// +/// Returns `true` when the file was rewritten, `false` when no legacy fields +/// were detected (no-op) or when migration was blocked by a non-Claude command. +pub fn apply_config_migration_fixes(root: &Path) -> Result { + use std::fs; + + // 1. Locate config file + let config_path = { + let p = root.join(".apm").join("config.toml"); + if p.exists() { + p + } else { + let p = root.join("apm.toml"); + if p.exists() { + p + } else { + return Ok(false); + } + } + }; + + // 2. Parse with toml_edit (preserves comments, whitespace, key order) + let content = fs::read_to_string(&config_path) + .with_context(|| format!("reading {}", config_path.display()))?; + let mut doc = content + .parse::() + .with_context(|| format!("parsing {}", config_path.display()))?; + + // 3. Detect legacy fields. + // Use .get() throughout: DocumentMut::index panics for missing top-level keys. + let has_workers_legacy = doc + .get("workers") + .and_then(|v| v.as_table()) + .map_or(false, |t| { + t.contains_key("command") || t.contains_key("args") || t.contains_key("model") + }); + + let profiles_with_legacy: Vec = doc + .get("worker_profiles") + .and_then(|v| v.as_table()) + .map(|wp| { + wp.iter() + .filter_map(|(name, item)| { + item.as_table() + .filter(|t| { + t.contains_key("command") + || t.contains_key("args") + || t.contains_key("model") + }) + .map(|_| name.to_string()) + }) + .collect() + }) + .unwrap_or_default(); + + if !has_workers_legacy && profiles_with_legacy.is_empty() { + return Ok(false); + } + + // 4. Guard: non-claude command — block migration and warn if any command + // is not "claude" (we can't safely choose a wrapper for unknown tools). + if let Some(cmd) = doc + .get("workers") + .and_then(|v| v.as_table()) + .and_then(|t| t.get("command")) + .and_then(|v| v.as_str()) + { + if cmd != "claude" { + #[allow(clippy::print_stderr)] + { + eprintln!( + "warning: [workers] command = {:?} is not \"claude\" \u{2014} cannot auto-migrate; choose a wrapper manually", + cmd + ); + } + return Ok(false); + } + } + + for name in &profiles_with_legacy { + if let Some(cmd) = doc + .get("worker_profiles") + .and_then(|v| v.as_table()) + .and_then(|wp| wp.get(name.as_str())) + .and_then(|p| p.as_table()) + .and_then(|t| t.get("command")) + .and_then(|v| v.as_str()) + { + if cmd != "claude" { + #[allow(clippy::print_stderr)] + { + eprintln!( + "warning: [worker_profiles.{}] command = {:?} is not \"claude\" \u{2014} cannot auto-migrate; choose a wrapper manually", + name, cmd + ); + } + return Ok(false); + } + } + } + + // 5. Migrate [workers] + if has_workers_legacy { + let has_command; + let model_val: Option; + let has_args; + { + let workers = doc + .get("workers") + .and_then(|v| v.as_table()) + .expect("workers is a table (checked in step 3)"); + has_command = workers.contains_key("command"); + model_val = workers.get("model").and_then(|v| v.as_str()).map(|s| s.to_string()); + has_args = workers.contains_key("args"); + } + + let workers = doc + .get_mut("workers") + .and_then(|v| v.as_table_mut()) + .expect("workers is a table"); + + if has_command { + workers.remove("command"); + workers.insert("agent", toml_edit::value("claude")); + } + if has_args { + workers.remove("args"); + } + if let Some(ref model) = model_val { + workers.remove("model"); + if !workers.contains_key("options") { + workers.insert("options", toml_edit::Item::Table(toml_edit::Table::new())); + } + // workers is &mut Table; Table::IndexMut creates keys when missing. + // options was just inserted as Item::Table, so ["options"] returns &mut Item::Table. + // ["model"] on Item::Table creates the "model" entry via Item::IndexMut. + workers["options"]["model"] = toml_edit::value(model.as_str()); + } + } + + // 6. Migrate each [worker_profiles.] + for name in &profiles_with_legacy { + let name = name.as_str(); + + let has_command; + let model_val: Option; + let has_args; + { + let profile = doc + .get("worker_profiles") + .and_then(|v| v.as_table()) + .and_then(|wp| wp.get(name)) + .and_then(|v| v.as_table()) + .expect("profile is a table (checked in step 3)"); + has_command = profile.contains_key("command"); + model_val = profile.get("model").and_then(|v| v.as_str()).map(|s| s.to_string()); + has_args = profile.contains_key("args"); + } + + let profile = doc + .get_mut("worker_profiles") + .and_then(|v| v.as_table_mut()) + .and_then(|wp| wp.get_mut(name)) + .and_then(|v| v.as_table_mut()) + .expect("profile is a table"); + + if has_command { + // Remove command; do NOT add agent at profile level (inherits from [workers]) + profile.remove("command"); + } + if has_args { + profile.remove("args"); + } + if let Some(ref model) = model_val { + profile.remove("model"); + if !profile.contains_key("options") { + profile.insert("options", toml_edit::Item::Table(toml_edit::Table::new())); + } + profile["options"]["model"] = toml_edit::value(model.as_str()); + } + } + + // 7. Write back (toml_edit preserves comments, whitespace, and unrelated sections) + fs::write(&config_path, doc.to_string()) + .with_context(|| format!("writing {}", config_path.display()))?; + + // 8. Re-validate: confirm the migration did not produce an invalid config. + let migrated_config = apm_core::config::Config::load(root) + .context("migration produced an unparseable config (this is a bug)")?; + let errors = apm_core::validate::validate_config(&migrated_config, root); + if !errors.is_empty() { + anyhow::bail!( + "migration produced an invalid config:\n{}", + errors.join("\n") + ); + } + + Ok(true) +} + #[derive(Debug, Serialize)] struct Issue { kind: String, @@ -17,6 +219,11 @@ struct Issue { } pub fn run(root: &Path, fix: bool, json: bool, config_only: bool, no_aggressive: bool) -> Result<()> { + // Config migration runs first so the freshly-written config is loaded below. + if fix && apply_config_migration_fixes(root)? { + println!("migrated [workers] config to agent-driven shape; legacy command/args/model removed"); + } + let config_errors; let config_warnings; let mut ticket_issues: Vec = Vec::new(); diff --git a/apm/tests/validate_fix.rs b/apm/tests/validate_fix.rs new file mode 100644 index 000000000..6e74d4572 --- /dev/null +++ b/apm/tests/validate_fix.rs @@ -0,0 +1,263 @@ +use std::fs; +use tempfile::TempDir; + +fn setup(config_toml: &str) -> TempDir { + let dir = tempfile::tempdir().unwrap(); + let apm_dir = dir.path().join(".apm"); + fs::create_dir_all(&apm_dir).unwrap(); + fs::write(apm_dir.join("config.toml"), config_toml).unwrap(); + dir +} + +/// Minimal valid workflow appended to fixture configs that need to pass re-validation. +const MINIMAL_WORKFLOW: &str = r#" +[[workflow.states]] +id = "new" +label = "New" + +[[workflow.states.transitions]] +to = "closed" + +[[workflow.states]] +id = "closed" +label = "Closed" +terminal = true +"#; + +#[test] +fn test_fix_migrates_claude_command() { + let config = format!( + r#"[project] +name = "test" + +[tickets] +dir = "tickets" + +[workers] +command = "claude" +args = ["--print", "--output-format", "stream-json"] +model = "sonnet" +{MINIMAL_WORKFLOW}"# + ); + let dir = setup(&config); + + let result = apm::cmd::validate::apply_config_migration_fixes(dir.path()); + assert!(result.is_ok(), "expected Ok, got {result:?}"); + assert_eq!(result.unwrap(), true, "expected migration to occur"); + + let written = fs::read_to_string(dir.path().join(".apm/config.toml")).unwrap(); + let parsed: toml::Value = toml::from_str(&written).unwrap(); + + let workers = parsed["workers"].as_table().unwrap(); + assert_eq!(workers.get("agent").and_then(|v| v.as_str()), Some("claude"), "agent should be set"); + assert!(workers.get("command").is_none(), "command should be removed"); + assert!(workers.get("args").is_none(), "args should be removed"); + assert!(workers.get("model").is_none(), "model should be removed from [workers]"); + + let options = workers.get("options") + .or_else(|| parsed.get("workers").and_then(|w| w.as_table()).and_then(|t| t.get("options"))); + // options may be a subtable in the parsed value + let options_model = parsed["workers"]["options"]["model"].as_str(); + assert_eq!(options_model, Some("sonnet"), "model should be in options"); + let _ = options; // suppress unused warning +} + +#[test] +fn test_fix_noop_on_non_claude_command() { + let config = format!( + r#"[project] +name = "test" + +[tickets] +dir = "tickets" + +[workers] +command = "my-ai" +model = "opus" +{MINIMAL_WORKFLOW}"# + ); + let dir = setup(&config); + let original = fs::read_to_string(dir.path().join(".apm/config.toml")).unwrap(); + + let result = apm::cmd::validate::apply_config_migration_fixes(dir.path()); + assert!(result.is_ok(), "expected Ok, got {result:?}"); + assert_eq!(result.unwrap(), false, "expected no migration for non-claude command"); + + let after = fs::read_to_string(dir.path().join(".apm/config.toml")).unwrap(); + assert_eq!(original, after, "file must be unchanged when command is not claude"); +} + +#[test] +fn test_fix_noop_on_non_claude_profile_command() { + let config = format!( + r#"[project] +name = "test" + +[tickets] +dir = "tickets" + +[worker_profiles.impl_agent] +command = "my-ai" +model = "opus" +{MINIMAL_WORKFLOW}"# + ); + let dir = setup(&config); + let original = fs::read_to_string(dir.path().join(".apm/config.toml")).unwrap(); + + let result = apm::cmd::validate::apply_config_migration_fixes(dir.path()); + assert!(result.is_ok(), "expected Ok, got {result:?}"); + assert_eq!(result.unwrap(), false, "expected no migration for non-claude profile command"); + + let after = fs::read_to_string(dir.path().join(".apm/config.toml")).unwrap(); + assert_eq!(original, after, "file must be unchanged when profile command is not claude"); +} + +#[test] +fn test_fix_mixed_legacy_and_new_fields() { + // agent already present but leftover model in [workers] + let config = format!( + r#"[project] +name = "test" + +[tickets] +dir = "tickets" + +[workers] +agent = "claude" +model = "opus" +{MINIMAL_WORKFLOW}"# + ); + let dir = setup(&config); + + let result = apm::cmd::validate::apply_config_migration_fixes(dir.path()); + assert!(result.is_ok(), "expected Ok, got {result:?}"); + assert_eq!(result.unwrap(), true, "expected migration to occur"); + + let written = fs::read_to_string(dir.path().join(".apm/config.toml")).unwrap(); + let parsed: toml::Value = toml::from_str(&written).unwrap(); + + let workers = parsed["workers"].as_table().unwrap(); + assert_eq!(workers.get("agent").and_then(|v| v.as_str()), Some("claude"), "agent must be preserved"); + assert!(workers.get("model").is_none(), "legacy model must be removed"); + assert_eq!(parsed["workers"]["options"]["model"].as_str(), Some("opus"), "model must be in options"); +} + +#[test] +fn test_fix_already_migrated_noop() { + // Fully migrated config — no legacy fields + let config = r#"[project] +name = "test" + +[tickets] +dir = "tickets" + +[workers] +agent = "claude" + +[workers.options] +model = "sonnet" +"#; + let dir = setup(config); + let original = fs::read_to_string(dir.path().join(".apm/config.toml")).unwrap(); + + let result = apm::cmd::validate::apply_config_migration_fixes(dir.path()); + assert!(result.is_ok(), "expected Ok, got {result:?}"); + assert_eq!(result.unwrap(), false, "expected no-op on already-migrated config"); + + let after = fs::read_to_string(dir.path().join(".apm/config.toml")).unwrap(); + assert_eq!(original, after, "file must be byte-identical for already-migrated config"); +} + +#[test] +fn test_fix_preserves_comments() { + let config = format!( + r#"# Top-level project comment +[project] +name = "test" + +[tickets] +dir = "tickets" + +# Worker section comment +[workers] +command = "claude" +model = "sonnet" +{MINIMAL_WORKFLOW}"# + ); + let dir = setup(&config); + + let result = apm::cmd::validate::apply_config_migration_fixes(dir.path()); + assert!(result.is_ok(), "expected Ok, got {result:?}"); + assert_eq!(result.unwrap(), true, "expected migration to occur"); + + let written = fs::read_to_string(dir.path().join(".apm/config.toml")).unwrap(); + assert!( + written.contains("# Top-level project comment"), + "top-level comment must survive" + ); + assert!( + written.contains("# Worker section comment"), + "worker section comment must survive" + ); +} + +#[test] +fn test_fix_profile_model_migration() { + let config = format!( + r#"[project] +name = "test" + +[tickets] +dir = "tickets" + +[worker_profiles.spec_agent] +model = "opus" +{MINIMAL_WORKFLOW}"# + ); + let dir = setup(&config); + + let result = apm::cmd::validate::apply_config_migration_fixes(dir.path()); + assert!(result.is_ok(), "expected Ok, got {result:?}"); + assert_eq!(result.unwrap(), true, "expected migration to occur"); + + let written = fs::read_to_string(dir.path().join(".apm/config.toml")).unwrap(); + let parsed: toml::Value = toml::from_str(&written).unwrap(); + + let profile = parsed["worker_profiles"]["spec_agent"].as_table().unwrap(); + assert!(profile.get("model").is_none(), "profile model key must be removed"); + + let options_model = parsed["worker_profiles"]["spec_agent"]["options"]["model"].as_str(); + assert_eq!(options_model, Some("opus"), "model must appear in profile options"); +} + +#[test] +fn test_fix_revalidate_passes() { + // After migration, validate_config must return no errors. + let config = format!( + r#"[project] +name = "test" + +[tickets] +dir = "tickets" + +[workers] +command = "claude" +model = "sonnet" +{MINIMAL_WORKFLOW}"# + ); + let dir = setup(&config); + + let result = apm::cmd::validate::apply_config_migration_fixes(dir.path()); + // apply_config_migration_fixes internally calls validate_config and bails if it fails. + // A successful Ok(true) here means re-validation passed. + assert!(result.is_ok(), "apply_config_migration_fixes should not error: {result:?}"); + assert_eq!(result.unwrap(), true, "migration should have occurred"); + + // Double-check by calling validate_config directly on the migrated config. + let migrated = apm_core::config::Config::load(dir.path()).unwrap(); + let errors = apm_core::validate::validate_config(&migrated, dir.path()); + assert!( + errors.is_empty(), + "validate_config must return no errors after migration; got: {errors:?}" + ); +} From 4e03c5de37fe5d3175a17f25603b3308f62947a4 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 11:46:13 -0700 Subject: [PATCH 245/305] Mark acceptance criteria complete --- ...igration-validate-fix-ports-legacy-comm.md | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md index 9610a733b..6304f8fd8 100644 --- a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md +++ b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md @@ -27,20 +27,20 @@ This ticket adds that migration to `apm validate --fix`. A developer who upgrade ### Acceptance criteria -- [ ] `apm validate --fix` on a config with `[workers] command = "claude"` rewrites it to `[workers] agent = "claude"` and removes the `command` key -- [ ] `apm validate --fix` on a config with `[workers] model = "sonnet"` moves the value to `[workers.options] model = "sonnet"` and removes the top-level `model` key -- [ ] `apm validate --fix` on a config with `[workers] args = [...]` removes the `args` key regardless of its contents -- [ ] `apm validate --fix` on a config with `[worker_profiles.X] model = "opus"` moves the value to `[worker_profiles.X.options] model = "opus"` and removes the profile-level `model` key -- [ ] `apm validate --fix` on a config with `[worker_profiles.X] command = "claude"` removes the profile-level `command` key (profile inherits `agent` from global) -- [ ] `apm validate --fix` on a config with `[worker_profiles.X] args = [...]` removes the profile-level `args` key -- [ ] `apm validate --fix` on a config where `[workers] command` is anything other than `"claude"` prints a warning naming the offending command and does not modify the config file -- [ ] `apm validate --fix` on a config where any `[worker_profiles.X] command` is anything other than `"claude"` prints a warning naming the profile and command, and does not modify the config file -- [ ] After a successful migration `apm validate` (without `--fix`) exits zero on the rewritten config -- [ ] `apm validate --fix` on a config that has no legacy fields (`agent` already set, no `command`/`args`/`model`) makes no changes to the file -- [ ] `apm validate --fix` on a config with both legacy fields and new fields (e.g. `agent` already present alongside a leftover `model`) removes the legacy fields and leaves the new fields intact -- [ ] A successful migration prints exactly the line: `migrated [workers] config to agent-driven shape; legacy command/args/model removed` -- [ ] TOML comments present in the config file survive the migration unchanged -- [ ] Key ordering of unrelated sections (e.g. `[keychain]`, `[env]`) is preserved after migration +- [x] `apm validate --fix` on a config with `[workers] command = "claude"` rewrites it to `[workers] agent = "claude"` and removes the `command` key +- [x] `apm validate --fix` on a config with `[workers] model = "sonnet"` moves the value to `[workers.options] model = "sonnet"` and removes the top-level `model` key +- [x] `apm validate --fix` on a config with `[workers] args = [...]` removes the `args` key regardless of its contents +- [x] `apm validate --fix` on a config with `[worker_profiles.X] model = "opus"` moves the value to `[worker_profiles.X.options] model = "opus"` and removes the profile-level `model` key +- [x] `apm validate --fix` on a config with `[worker_profiles.X] command = "claude"` removes the profile-level `command` key (profile inherits `agent` from global) +- [x] `apm validate --fix` on a config with `[worker_profiles.X] args = [...]` removes the profile-level `args` key +- [x] `apm validate --fix` on a config where `[workers] command` is anything other than `"claude"` prints a warning naming the offending command and does not modify the config file +- [x] `apm validate --fix` on a config where any `[worker_profiles.X] command` is anything other than `"claude"` prints a warning naming the profile and command, and does not modify the config file +- [x] After a successful migration `apm validate` (without `--fix`) exits zero on the rewritten config +- [x] `apm validate --fix` on a config that has no legacy fields (`agent` already set, no `command`/`args`/`model`) makes no changes to the file +- [x] `apm validate --fix` on a config with both legacy fields and new fields (e.g. `agent` already present alongside a leftover `model`) removes the legacy fields and leaves the new fields intact +- [x] A successful migration prints exactly the line: `migrated [workers] config to agent-driven shape; legacy command/args/model removed` +- [x] TOML comments present in the config file survive the migration unchanged +- [x] Key ordering of unrelated sections (e.g. `[keychain]`, `[env]`) is preserved after migration ### Out of scope From 9d4b23aee8ed5881255415dd1c568f9e6a7b507f Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 11:46:17 -0700 Subject: [PATCH 246/305] =?UTF-8?q?ticket(3048d7e9):=20in=5Fprogress=20?= =?UTF-8?q?=E2=86=92=20implemented?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md index 6304f8fd8..abdf0d467 100644 --- a/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md +++ b/tickets/3048d7e9-migration-validate-fix-ports-legacy-comm.md @@ -1,7 +1,7 @@ +++ id = "3048d7e9" title = "Migration: validate --fix ports legacy command/args/model to agent + options" -state = "in_progress" +state = "implemented" priority = 0 effort = 4 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/3048d7e9-migration-validate-fix-ports-legacy-comm" created_at = "2026-04-30T20:03:17.277300Z" -updated_at = "2026-05-01T18:28:44.690454Z" +updated_at = "2026-05-01T18:46:17.537410Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["6cac8518"] @@ -244,3 +244,4 @@ Test fixtures are small inline TOML strings written to a `tempdir`; no external | 2026-05-01T01:22Z | in_design | specd | claude-0501-0119-6978 | | 2026-05-01T17:38Z | specd | ready | philippepascal | | 2026-05-01T18:28Z | ready | in_progress | philippepascal | +| 2026-05-01T18:46Z | in_progress | implemented | claude-0501-1828-b808 | From d429ee7b84d75ba2ee9fc13445acfd932bfbe44a Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 11:46:35 -0700 Subject: [PATCH 247/305] =?UTF-8?q?ticket(0ca3e019):=20start=20=E2=80=94?= =?UTF-8?q?=20ready=20=E2=86=92=20in=5Fprogress?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md index 8ee1c4927..c943e17c4 100644 --- a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md +++ b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md @@ -1,7 +1,7 @@ +++ id = "0ca3e019" title = "Frontmatter agent + agent_overrides for per-ticket wrapper choice" -state = "ready" +state = "in_progress" priority = 0 effort = 4 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/0ca3e019-frontmatter-agent-agent-overrides-for-pe" created_at = "2026-04-30T20:03:58.532325Z" -updated_at = "2026-05-01T17:38:11.918348Z" +updated_at = "2026-05-01T18:46:35.599100Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "6cac8518"] @@ -272,3 +272,4 @@ Append a short note to each file near the end: | 2026-05-01T01:22Z | ammend | in_design | philippepascal | | 2026-05-01T01:26Z | in_design | specd | claude-0501-0122-63c8 | | 2026-05-01T17:38Z | specd | ready | philippepascal | +| 2026-05-01T18:46Z | ready | in_progress | philippepascal | From 38ead856cffb4bab0ac8a2571560df825fabb7a8 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 11:56:38 -0700 Subject: [PATCH 248/305] Add frontmatter agent + agent_overrides for per-ticket wrapper choice --- .apm/apm.spec-writer.md | 4 ++ .apm/apm.worker.md | 4 ++ apm-core/src/default/apm.spec-writer.md | 4 ++ apm-core/src/default/apm.worker.md | 4 ++ apm-core/src/start.rs | 72 ++++++++++++++++++++-- apm-core/src/ticket/ticket_fmt.rs | 35 +++++++++++ apm-core/src/ticket/ticket_util.rs | 4 ++ apm-core/src/validate.rs | 82 +++++++++++++++++++++++++ apm-server/src/main.rs | 2 + apm-server/src/queue.rs | 2 + 10 files changed, 209 insertions(+), 4 deletions(-) diff --git a/.apm/apm.spec-writer.md b/.apm/apm.spec-writer.md index 7e3bbf419..6c6582bf7 100644 --- a/.apm/apm.spec-writer.md +++ b/.apm/apm.spec-writer.md @@ -169,3 +169,7 @@ transition to `question`. Do not guess and proceed. Once an answer arrives, reflect the decision in `### Approach` before transitioning back to `specd`. + +--- + +**Frontmatter agent override** (supervisor tool): A supervisor may add `agent = ""` or an `[agent_overrides]` table to a ticket's frontmatter to select a specific agent for that ticket or for individual profiles. Do not set these fields yourself — they are a supervisor-level escape hatch for debugging or per-ticket specialisation. diff --git a/.apm/apm.worker.md b/.apm/apm.worker.md index 69798f8df..f8782ef03 100644 --- a/.apm/apm.worker.md +++ b/.apm/apm.worker.md @@ -149,3 +149,7 @@ in `apm show ` under Worktree — note it at the start of your run. If a tool call resolves to a path outside your worktree, stop immediately, file a side-note ticket, and set yourself to blocked. + +--- + +**Frontmatter agent override** (supervisor tool): A supervisor may add `agent = ""` or an `[agent_overrides]` table to a ticket's frontmatter to select a specific agent for that ticket or for individual profiles. Do not set these fields yourself — they are a supervisor-level escape hatch for debugging or per-ticket specialisation. diff --git a/apm-core/src/default/apm.spec-writer.md b/apm-core/src/default/apm.spec-writer.md index e5ed5152b..9cbab2b8b 100644 --- a/apm-core/src/default/apm.spec-writer.md +++ b/apm-core/src/default/apm.spec-writer.md @@ -174,3 +174,7 @@ transition to `question`. Do not guess and proceed. Once an answer arrives, reflect the decision in `### Approach` before transitioning back to `specd`. + +--- + +**Frontmatter agent override** (supervisor tool): A supervisor may add `agent = ""` or an `[agent_overrides]` table to a ticket's frontmatter to select a specific agent for that ticket or for individual profiles. Do not set these fields yourself — they are a supervisor-level escape hatch for debugging or per-ticket specialisation. diff --git a/apm-core/src/default/apm.worker.md b/apm-core/src/default/apm.worker.md index 69798f8df..f8782ef03 100644 --- a/apm-core/src/default/apm.worker.md +++ b/apm-core/src/default/apm.worker.md @@ -149,3 +149,7 @@ in `apm show ` under Worktree — note it at the start of your run. If a tool call resolves to a path outside your worktree, stop immediately, file a side-note ticket, and set yourself to blocked. + +--- + +**Frontmatter agent override** (supervisor tool): A supervisor may add `agent = ""` or an `[agent_overrides]` table to a ticket's frontmatter to select a specific agent for that ticket or for individual profiles. Do not set these fields yourself — they are a supervisor-level escape hatch for debugging or per-ticket specialisation. diff --git a/apm-core/src/start.rs b/apm-core/src/start.rs index 0690896e6..93ad49a8a 100644 --- a/apm-core/src/start.rs +++ b/apm-core/src/start.rs @@ -93,6 +93,19 @@ pub fn effective_spawn_params(profile: Option<&WorkerProfileConfig>, workers: &W EffectiveWorkerParams { command, args, model, env, container, agent, options } } +fn apply_frontmatter_agent( + agent: &mut String, + frontmatter: &ticket_fmt::Frontmatter, + profile_name: &str, +) { + if let Some(ov) = frontmatter.agent_overrides.get(profile_name) { + *agent = ov.clone(); + } else if let Some(a) = &frontmatter.agent { + *agent = a.clone(); + } + // else: keep config-resolved agent unchanged +} + pub struct StartOutput { pub id: String, pub old_state: String, @@ -283,7 +296,8 @@ pub fn run(root: &Path, id_arg: &str, no_aggressive: bool, spawn: bool, skip_per let raw_prompt = format!("{}\n\n{content}", agent_role_prefix(profile, &id)); let with_epic = with_epic_bundle(root, ticket_epic_id.as_deref(), &id, &config, raw_prompt); let ticket_content = with_dependency_bundle(root, &ticket_depends_on, &config, with_epic); - let params = effective_spawn_params(profile, &config.workers); + let mut params = effective_spawn_params(profile, &config.workers); + apply_frontmatter_agent(&mut params.agent, &t.frontmatter, &profile_name); let role_prefix = profile.and_then(|p| p.role_prefix.clone()); let log_path = wt_display.join(".apm-worker.log"); @@ -469,7 +483,8 @@ pub fn run_next(root: &Path, no_aggressive: bool, spawn: bool, skip_permissions: let raw_prompt_next = format!("{}\n\n{raw}", agent_role_prefix(profile2, &id)); let with_epic_next = with_epic_bundle(root, t.frontmatter.epic.as_deref(), &id, &config, raw_prompt_next); let ticket_content = with_dependency_bundle(root, &dep_ids_next, &config, with_epic_next); - let params = effective_spawn_params(profile2, &config.workers); + let mut params = effective_spawn_params(profile2, &config.workers); + apply_frontmatter_agent(&mut params.agent, &t.frontmatter, &profile_name2); let role_prefix2 = profile2.and_then(|p| p.role_prefix.clone()); let branch = t.frontmatter.branch.clone() @@ -653,7 +668,8 @@ pub fn spawn_next_worker( let raw_prompt_snw = format!("{}\n\n{raw}", agent_role_prefix(profile2, &id)); let with_epic_snw = with_epic_bundle(root, t.frontmatter.epic.as_deref(), &id, &config, raw_prompt_snw); let ticket_content = with_dependency_bundle(root, &dep_ids_snw, &config, with_epic_snw); - let params = effective_spawn_params(profile2, &config.workers); + let mut params = effective_spawn_params(profile2, &config.workers); + apply_frontmatter_agent(&mut params.agent, &t.frontmatter, &profile_name2); let role_prefix2 = profile2.and_then(|p| p.role_prefix.clone()); let branch = t.frontmatter.branch.clone() @@ -775,7 +791,7 @@ fn rand_u16() -> u16 { #[cfg(test)] mod tests { - use super::{resolve_system_prompt, agent_role_prefix, resolve_profile, effective_spawn_params, check_output_format_supported, ManagedChild, DEPRECATION_WARNED, DEPRECATION_TEST_LOG, DEPRECATION_TEST_LOCK}; + use super::{resolve_system_prompt, agent_role_prefix, resolve_profile, effective_spawn_params, check_output_format_supported, apply_frontmatter_agent, ManagedChild, DEPRECATION_WARNED, DEPRECATION_TEST_LOG, DEPRECATION_TEST_LOCK}; use crate::config::{WorkerProfileConfig, WorkersConfig, TransitionConfig, CompletionStrategy}; use std::collections::HashMap; @@ -1432,4 +1448,52 @@ mod tests { assert!(env_content.contains("APM_OPT_MODEL=sonnet"), "APM_OPT_MODEL=sonnet must be set\n{env_content}"); } + + // --- apply_frontmatter_agent --- + + fn make_frontmatter_with_agent(agent: Option<&str>, overrides: &[(&str, &str)]) -> crate::ticket_fmt::Frontmatter { + let agent_line = agent.map(|a| format!("agent = \"{a}\"\n")).unwrap_or_default(); + let overrides_section = if overrides.is_empty() { + String::new() + } else { + let pairs: Vec = overrides.iter() + .map(|(k, v)| format!("{k} = \"{v}\"")) + .collect(); + format!("[agent_overrides]\n{}\n", pairs.join("\n")) + }; + let toml_str = format!("id = \"t\"\ntitle = \"T\"\nstate = \"new\"\n{agent_line}{overrides_section}"); + toml::from_str(&toml_str).unwrap() + } + + #[test] + fn apply_fm_profile_override_wins() { + let fm = make_frontmatter_with_agent(Some("mock-sad"), &[("impl_agent", "mock-happy")]); + let mut agent = "claude".to_string(); + apply_frontmatter_agent(&mut agent, &fm, "impl_agent"); + assert_eq!(agent, "mock-happy"); + } + + #[test] + fn apply_fm_agent_field_wins_when_no_profile_match() { + let fm = make_frontmatter_with_agent(Some("mock-sad"), &[]); + let mut agent = "claude".to_string(); + apply_frontmatter_agent(&mut agent, &fm, "impl_agent"); + assert_eq!(agent, "mock-sad"); + } + + #[test] + fn apply_fm_profile_override_beats_agent_field() { + let fm = make_frontmatter_with_agent(Some("mock-random"), &[("impl_agent", "claude")]); + let mut agent = "other".to_string(); + apply_frontmatter_agent(&mut agent, &fm, "impl_agent"); + assert_eq!(agent, "claude"); + } + + #[test] + fn apply_fm_no_fields_unchanged() { + let fm = make_frontmatter_with_agent(None, &[]); + let mut agent = "claude".to_string(); + apply_frontmatter_agent(&mut agent, &fm, "impl_agent"); + assert_eq!(agent, "claude"); + } } diff --git a/apm-core/src/ticket/ticket_fmt.rs b/apm-core/src/ticket/ticket_fmt.rs index a3c1a39d8..e0f200c43 100644 --- a/apm-core/src/ticket/ticket_fmt.rs +++ b/apm-core/src/ticket/ticket_fmt.rs @@ -3,6 +3,7 @@ use chrono::{DateTime, Utc}; use indexmap::IndexMap; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::path::{Path, PathBuf}; fn deserialize_id<'de, D: serde::Deserializer<'de>>(d: D) -> Result { @@ -60,6 +61,10 @@ pub struct Frontmatter { pub target_branch: Option, #[serde(skip_serializing_if = "Option::is_none")] pub depends_on: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent: Option, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub agent_overrides: HashMap, } #[derive(Debug, Clone)] @@ -683,6 +688,36 @@ mod tests { assert!(!msgs.iter().any(|m| m.contains("Problem")), "present section should not error"); } + #[test] + fn frontmatter_agent_round_trip() { + let raw = minimal_raw( + "agent = \"mock-happy\"\n\n[agent_overrides]\nspec_agent = \"claude\"\nimpl_agent = \"mock-sad\"\n", + "## Spec\n\ncontent\n", + ); + let t = Ticket::parse(dummy_path(), &raw).unwrap(); + assert_eq!(t.frontmatter.agent, Some("mock-happy".to_string())); + assert_eq!(t.frontmatter.agent_overrides.get("spec_agent").map(|s| s.as_str()), Some("claude")); + assert_eq!(t.frontmatter.agent_overrides.get("impl_agent").map(|s| s.as_str()), Some("mock-sad")); + + let serialized = t.serialize().unwrap(); + let t2 = Ticket::parse(dummy_path(), &serialized).unwrap(); + assert_eq!(t2.frontmatter.agent, Some("mock-happy".to_string())); + assert_eq!(t2.frontmatter.agent_overrides.get("spec_agent").map(|s| s.as_str()), Some("claude")); + assert_eq!(t2.frontmatter.agent_overrides.get("impl_agent").map(|s| s.as_str()), Some("mock-sad")); + } + + #[test] + fn frontmatter_agent_omitted_when_unset() { + let raw = minimal_raw("", "## Spec\n\ncontent\n"); + let t = Ticket::parse(dummy_path(), &raw).unwrap(); + assert!(t.frontmatter.agent.is_none()); + assert!(t.frontmatter.agent_overrides.is_empty()); + + let serialized = t.serialize().unwrap(); + assert!(!serialized.contains("agent"), "agent field must not appear in serialized output"); + assert!(!serialized.contains("agent_overrides"), "agent_overrides must not appear in serialized output"); + } + #[test] fn document_history_preserved() { let body = full_body("- [x] done"); diff --git a/apm-core/src/ticket/ticket_util.rs b/apm-core/src/ticket/ticket_util.rs index 54fe20f01..d0eda4383 100644 --- a/apm-core/src/ticket/ticket_util.rs +++ b/apm-core/src/ticket/ticket_util.rs @@ -346,6 +346,8 @@ pub fn create( epic, target_branch, depends_on, + agent: None, + agent_overrides: std::collections::HashMap::new(), }; let when = now.format("%Y-%m-%dT%H:%MZ"); let history_footer = format!("## History\n\n| When | From | To | By |\n|------|------|----|----|\n| {when} | — | new | {author} |\n"); @@ -990,6 +992,8 @@ mod tests { epic: None, target_branch: None, depends_on: None, + agent: None, + agent_overrides: std::collections::HashMap::new(), } } diff --git a/apm-core/src/validate.rs b/apm-core/src/validate.rs index 2dcb700b9..1d07a0deb 100644 --- a/apm-core/src/validate.rs +++ b/apm-core/src/validate.rs @@ -1,5 +1,6 @@ use crate::config::{resolve_outcome, CompletionStrategy, Config, LocalConfig}; use crate::ticket_fmt::Ticket; +use crate::wrapper; use anyhow::{bail, Result}; use std::collections::HashSet; use std::path::Path; @@ -376,6 +377,25 @@ pub fn verify_tickets( issues.push(format!("{prefix}: {err}")); } } + + // Validate frontmatter agent names against known built-ins. + let agents_to_check: Vec<&str> = fm.agent + .as_deref() + .into_iter() + .chain(fm.agent_overrides.values().map(String::as_str)) + .collect(); + + for name in agents_to_check { + // TODO(2c32a282): upgrade to wrapper::resolve_wrapper(root, name) once + // custom wrapper resolution lands so project-defined scripts referenced + // in `agent` / `agent_overrides` are also validated here. + if wrapper::resolve_builtin(name).is_none() { + issues.push(format!( + "ticket {}: agent {:?} is not a known built-in", + fm.id, name + )); + } + } } issues @@ -1670,4 +1690,66 @@ terminal = true "unexpected on_failure errors: {on_failure_errors:?}" ); } + + // --- frontmatter agent validation --- + + fn make_agent_verify_ticket(root: &std::path::Path, id: &str, state: &str, extra_fm: &str) -> Ticket { + let raw = format!( + "+++\nid = \"{id}\"\ntitle = \"Test ticket\"\nstate = \"{state}\"\n{extra_fm}+++\n\n## Spec\n\n## History\n" + ); + let path = root.join("tickets").join(format!("{id}-test.md")); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, &raw).unwrap(); + Ticket::parse(&path, &raw).unwrap() + } + + #[test] + fn validate_unknown_frontmatter_agent_is_error() { + let dir = setup_verify_repo(); + let root = dir.path(); + let config = Config::load(root).unwrap(); + let ticket = make_agent_verify_ticket(root, "abcd1234", "specd", "agent = \"nonexistent-bot\"\n"); + + let issues = verify_tickets(root, &config, &[ticket], &HashSet::new()); + + assert!( + issues.iter().any(|i| i.contains("abcd1234") && i.contains("nonexistent-bot")), + "expected error with ticket id and agent name; got: {issues:?}" + ); + } + + #[test] + fn validate_unknown_agent_in_overrides_is_error() { + let dir = setup_verify_repo(); + let root = dir.path(); + let config = Config::load(root).unwrap(); + let ticket = make_agent_verify_ticket( + root, + "abcd1234", + "specd", + "[agent_overrides]\nimpl_agent = \"nonexistent-bot\"\n", + ); + + let issues = verify_tickets(root, &config, &[ticket], &HashSet::new()); + + assert!( + issues.iter().any(|i| i.contains("abcd1234") && i.contains("nonexistent-bot")), + "expected error with ticket id and agent name; got: {issues:?}" + ); + } + + #[test] + fn validate_known_frontmatter_agent_passes() { + let dir = setup_verify_repo(); + let root = dir.path(); + let config = Config::load(root).unwrap(); + let ticket = make_agent_verify_ticket(root, "abcd1234", "specd", "agent = \"claude\"\n"); + + let issues = verify_tickets(root, &config, &[ticket], &HashSet::new()); + + assert!( + !issues.iter().any(|i| i.contains("is not a known built-in")), + "expected no agent error for known built-in; got: {issues:?}" + ); + } } diff --git a/apm-server/src/main.rs b/apm-server/src/main.rs index 056318531..3f0a89142 100644 --- a/apm-server/src/main.rs +++ b/apm-server/src/main.rs @@ -488,6 +488,8 @@ pub(crate) mod tests { epic: None, target_branch: None, depends_on: None, + agent: None, + agent_overrides: std::collections::HashMap::new(), }, body: String::new(), path: PathBuf::from(format!("{}.md", id)), diff --git a/apm-server/src/queue.rs b/apm-server/src/queue.rs index 68b4e14b9..15a30cf83 100644 --- a/apm-server/src/queue.rs +++ b/apm-server/src/queue.rs @@ -130,6 +130,8 @@ mod tests { epic: None, target_branch: None, depends_on: None, + agent: None, + agent_overrides: std::collections::HashMap::new(), }, body: String::new(), path: PathBuf::from(format!("{id}.md")), From 2b84bb98c9a563da2cff808a3d58aee10fd7f16f Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 11:57:15 -0700 Subject: [PATCH 249/305] ticket(0ca3e019): set section Acceptance criteria --- ...rontmatter-agent-agent-overrides-for-pe.md | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md index c943e17c4..4d7f32559 100644 --- a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md +++ b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md @@ -36,21 +36,21 @@ The full resolution order (per spawn, where P is the profile name declared by th ### Acceptance criteria -- [ ] `Frontmatter` struct has `pub agent: Option` with `#[serde(default, skip_serializing_if = "Option::is_none")]` -- [ ] `Frontmatter` struct has `pub agent_overrides: HashMap` with `#[serde(default, skip_serializing_if = "HashMap::is_empty")]` -- [ ] A ticket frontmatter containing `agent = "mock-happy"` round-trips through TOML serialize → parse → serialize without loss -- [ ] A ticket frontmatter containing `[agent_overrides]` round-trips through TOML serialize → parse → serialize without loss -- [ ] A ticket with neither `agent` nor `agent_overrides` set serializes without either field appearing in the output -- [ ] When spawning a worker for profile `P` and `frontmatter.agent_overrides[P]` is set, that value is used as the agent name -- [ ] When `agent_overrides` has no entry for profile `P` but `frontmatter.agent` is set, `frontmatter.agent` is used -- [ ] When `frontmatter.agent_overrides[P]` is set and `frontmatter.agent` is also set, the profile-specific override wins -- [ ] When neither frontmatter field is set, the config-resolved agent (from 6cac8518) is used unchanged -- [ ] `apm validate` reports an error for a ticket whose `frontmatter.agent` names a non-existent built-in; the error message includes the ticket id -- [ ] `apm validate` reports an error for a ticket whose `frontmatter.agent_overrides` contains a value naming a non-existent built-in; the error message includes the ticket id and the offending agent name -- [ ] `apm validate` does not report an error for a ticket whose `frontmatter.agent` is `"claude"` -- [ ] `.apm/apm.spec-writer.md` contains a brief note that supervisors may set `agent` or `[agent_overrides]` in frontmatter -- [ ] `.apm/apm.worker.md` contains a brief note that supervisors may set `agent` or `[agent_overrides]` in frontmatter -- [ ] The `wrapper::resolve_builtin(name)` call site in `validate.rs` has an inline `// TODO(2c32a282)` comment noting that it must be upgraded to `wrapper::resolve_wrapper(root, name)` once ticket 2c32a282 (custom wrapper resolution) lands, so project-defined scripts referenced in `agent` or `agent_overrides` are also validated +- [x] `Frontmatter` struct has `pub agent: Option` with `#[serde(default, skip_serializing_if = "Option::is_none")]` +- [x] `Frontmatter` struct has `pub agent_overrides: HashMap` with `#[serde(default, skip_serializing_if = "HashMap::is_empty")]` +- [x] A ticket frontmatter containing `agent = "mock-happy"` round-trips through TOML serialize → parse → serialize without loss +- [x] A ticket frontmatter containing `[agent_overrides]` round-trips through TOML serialize → parse → serialize without loss +- [x] A ticket with neither `agent` nor `agent_overrides` set serializes without either field appearing in the output +- [x] When spawning a worker for profile `P` and `frontmatter.agent_overrides[P]` is set, that value is used as the agent name +- [x] When `agent_overrides` has no entry for profile `P` but `frontmatter.agent` is set, `frontmatter.agent` is used +- [x] When `frontmatter.agent_overrides[P]` is set and `frontmatter.agent` is also set, the profile-specific override wins +- [x] When neither frontmatter field is set, the config-resolved agent (from 6cac8518) is used unchanged +- [x] `apm validate` reports an error for a ticket whose `frontmatter.agent` names a non-existent built-in; the error message includes the ticket id +- [x] `apm validate` reports an error for a ticket whose `frontmatter.agent_overrides` contains a value naming a non-existent built-in; the error message includes the ticket id and the offending agent name +- [x] `apm validate` does not report an error for a ticket whose `frontmatter.agent` is `"claude"` +- [x] `.apm/apm.spec-writer.md` contains a brief note that supervisors may set `agent` or `[agent_overrides]` in frontmatter +- [x] `.apm/apm.worker.md` contains a brief note that supervisors may set `agent` or `[agent_overrides]` in frontmatter +- [x] The `wrapper::resolve_builtin(name)` call site in `validate.rs` has an inline `// TODO(2c32a282)` comment noting that it must be upgraded to `wrapper::resolve_wrapper(root, name)` once ticket 2c32a282 (custom wrapper resolution) lands, so project-defined scripts referenced in `agent` or `agent_overrides` are also validated ### Out of scope @@ -272,4 +272,4 @@ Append a short note to each file near the end: | 2026-05-01T01:22Z | ammend | in_design | philippepascal | | 2026-05-01T01:26Z | in_design | specd | claude-0501-0122-63c8 | | 2026-05-01T17:38Z | specd | ready | philippepascal | -| 2026-05-01T18:46Z | ready | in_progress | philippepascal | +| 2026-05-01T18:46Z | ready | in_progress | philippepascal | \ No newline at end of file From f4bcc7e7260b2ceb0cbbb6b3c5329142fced5e46 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 11:57:20 -0700 Subject: [PATCH 250/305] =?UTF-8?q?ticket(0ca3e019):=20in=5Fprogress=20?= =?UTF-8?q?=E2=86=92=20implemented?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../0ca3e019-frontmatter-agent-agent-overrides-for-pe.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md index 4d7f32559..512754db0 100644 --- a/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md +++ b/tickets/0ca3e019-frontmatter-agent-agent-overrides-for-pe.md @@ -1,7 +1,7 @@ +++ id = "0ca3e019" title = "Frontmatter agent + agent_overrides for per-ticket wrapper choice" -state = "in_progress" +state = "implemented" priority = 0 effort = 4 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/0ca3e019-frontmatter-agent-agent-overrides-for-pe" created_at = "2026-04-30T20:03:58.532325Z" -updated_at = "2026-05-01T18:46:35.599100Z" +updated_at = "2026-05-01T18:57:20.211382Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "6cac8518"] @@ -272,4 +272,5 @@ Append a short note to each file near the end: | 2026-05-01T01:22Z | ammend | in_design | philippepascal | | 2026-05-01T01:26Z | in_design | specd | claude-0501-0122-63c8 | | 2026-05-01T17:38Z | specd | ready | philippepascal | -| 2026-05-01T18:46Z | ready | in_progress | philippepascal | \ No newline at end of file +| 2026-05-01T18:46Z | ready | in_progress | philippepascal | +| 2026-05-01T18:57Z | in_progress | implemented | claude-0501-1846-c6a8 | From f043f37db2cb698d1aadea8b41f078960c5f6d38 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 11:57:36 -0700 Subject: [PATCH 251/305] =?UTF-8?q?ticket(2c32a282):=20start=20=E2=80=94?= =?UTF-8?q?=20ready=20=E2=86=92=20in=5Fprogress?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md index 1c4b30af1..245899940 100644 --- a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md +++ b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md @@ -1,7 +1,7 @@ +++ id = "2c32a282" title = "Custom wrapper resolution from .apm/agents//" -state = "ready" +state = "in_progress" priority = 0 effort = 5 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/2c32a282-custom-wrapper-resolution-from-apm-agent" created_at = "2026-04-30T20:02:50.794362Z" -updated_at = "2026-05-01T17:38:03.266489Z" +updated_at = "2026-05-01T18:57:36.267022Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95"] @@ -260,3 +260,4 @@ Integration test in apm-core/tests/custom_wrapper_integration.rs: | 2026-05-01T01:29Z | ammend | in_design | philippepascal | | 2026-05-01T01:32Z | in_design | specd | claude-0501-0129-2a50 | | 2026-05-01T17:38Z | specd | ready | philippepascal | +| 2026-05-01T18:57Z | ready | in_progress | philippepascal | From 052d02656c1cebc0b6dbee7ce866fd67376873eb Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:13:28 -0700 Subject: [PATCH 252/305] Add custom wrapper resolution from .apm/agents// - New wrapper/custom.rs: Manifest, WrapperKind, CustomWrapper (Wrapper impl) - Private helpers: find_script, parse_manifest, manifest_unknown_keys - resolve_wrapper() in mod.rs: custom scripts shadow builtins - start.rs: spawn_worker dispatches via resolve_wrapper (takes project_root) - validate.rs: validate_agents() (Layer 1), validate_warnings takes root - apm/cmd/validate.rs: pass root to validate_warnings - Integration test: echo-test fixture wrapper spawned and log captured --- apm-core/src/start.rs | 25 +- apm-core/src/validate.rs | 128 ++++++- apm-core/src/wrapper/custom.rs | 349 +++++++++++++++++++ apm-core/src/wrapper/mod.rs | 15 +- apm-core/tests/custom_wrapper_integration.rs | 78 +++++ apm/src/cmd/validate.rs | 4 +- 6 files changed, 578 insertions(+), 21 deletions(-) create mode 100644 apm-core/src/wrapper/custom.rs create mode 100644 apm-core/tests/custom_wrapper_integration.rs diff --git a/apm-core/src/start.rs b/apm-core/src/start.rs index 93ad49a8a..9926d3cae 100644 --- a/apm-core/src/start.rs +++ b/apm-core/src/start.rs @@ -175,10 +175,21 @@ impl Drop for ManagedChild { } } -fn spawn_worker(ctx: &WrapperContext, agent: &str) -> Result { - match crate::wrapper::resolve_builtin(agent) { - Some(wrapper) => wrapper.spawn(ctx), - None => anyhow::bail!("unknown built-in agent {:?}; custom wrapper resolution is not yet supported (see ticket 2c32a282)", agent), +fn spawn_worker(ctx: &WrapperContext, agent: &str, project_root: &Path) -> Result { + use crate::wrapper::{resolve_wrapper, resolve_builtin, WrapperKind, Wrapper}; + use crate::wrapper::custom::CustomWrapper; + + match resolve_wrapper(project_root, agent)? { + Some(WrapperKind::Custom { script_path, manifest }) => { + CustomWrapper { script_path, manifest }.spawn(ctx) + } + Some(WrapperKind::Builtin(name)) => { + resolve_builtin(&name).expect("known built-in").spawn(ctx) + } + None => anyhow::bail!( + "agent {:?} not found: checked built-ins {{claude}} and '.apm/agents/{agent}/'", + agent + ), } } @@ -323,7 +334,7 @@ pub fn run(root: &Path, id_arg: &str, no_aggressive: bool, spawn: bool, skip_per keychain: config.workers.keychain.clone(), }; check_output_format_supported(¶ms.command)?; - let mut child = spawn_worker(&ctx, ¶ms.agent)?; + let mut child = spawn_worker(&ctx, ¶ms.agent, root)?; let pid = child.id(); let pid_path = wt_display.join(".apm-worker.pid"); @@ -518,7 +529,7 @@ pub fn run_next(root: &Path, no_aggressive: bool, spawn: bool, skip_permissions: keychain: config.workers.keychain.clone(), }; check_output_format_supported(¶ms.command)?; - let mut child = spawn_worker(&ctx, ¶ms.agent)?; + let mut child = spawn_worker(&ctx, ¶ms.agent, root)?; let pid = child.id(); let pid_path = wt_display.join(".apm-worker.pid"); @@ -703,7 +714,7 @@ pub fn spawn_next_worker( keychain: config.workers.keychain.clone(), }; check_output_format_supported(¶ms.command)?; - let child = spawn_worker(&ctx, ¶ms.agent)?; + let child = spawn_worker(&ctx, ¶ms.agent, root)?; let pid = child.id(); let managed = ManagedChild { diff --git a/apm-core/src/validate.rs b/apm-core/src/validate.rs index 1d07a0deb..54a200d25 100644 --- a/apm-core/src/validate.rs +++ b/apm-core/src/validate.rs @@ -1,6 +1,7 @@ use crate::config::{resolve_outcome, CompletionStrategy, Config, LocalConfig}; use crate::ticket_fmt::Ticket; use crate::wrapper; +use crate::wrapper::custom::{parse_manifest, manifest_unknown_keys}; use anyhow::{bail, Result}; use std::collections::HashSet; use std::path::Path; @@ -154,6 +155,103 @@ fn gitignore_covers_dir(content: &str, dir: &str) -> bool { .any(|line| line.trim_matches('/') == normalized_dir) } +/// Layer 1 of the two-layer manifest validation design. +/// Validates all configured agent names and scans `.apm/agents/` for issues. +/// Errors (not-found agents, invalid manifests, unsupported contract versions) go into `errors`. +/// Warnings (non-executable scripts, unknown manifest keys) go into `warnings`. +fn validate_agents(config: &Config, root: &Path, errors: &mut Vec, warnings: &mut Vec) { + // Collect configured agent names + let mut names: std::collections::HashSet = std::collections::HashSet::new(); + let primary = config.workers.agent.clone() + .unwrap_or_else(|| "claude".to_string()); + names.insert(primary); + for p in config.worker_profiles.values() { + if let Some(ref agent) = p.agent { + names.insert(agent.clone()); + } + } + + // Validate each configured agent name (Layer 1 error check) + for name in &names { + match wrapper::resolve_wrapper(root, name) { + Ok(None) => errors.push(format!( + "agent '{}' not found: checked built-ins {{claude}} and '.apm/agents/{}/'", + name, name + )), + Err(e) => errors.push(format!("agent '{name}': {e}")), + Ok(Some(_)) => {} + } + } + + // Scan .apm/agents/ for per-directory warnings and errors + let agents_dir = root.join(".apm").join("agents"); + let Ok(entries) = std::fs::read_dir(&agents_dir) else { return }; + + for entry in entries.filter_map(|e| e.ok()) { + let ft = match entry.file_type() { + Ok(ft) => ft, + Err(_) => continue, + }; + if !ft.is_dir() { + continue; + } + let name = entry.file_name().to_string_lossy().to_string(); + + // Check for non-executable wrapper.* files (Unix only) + let wrapper_files: Vec<_> = std::fs::read_dir(entry.path()) + .ok() + .into_iter() + .flatten() + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().starts_with("wrapper.")) + .collect(); + + if !wrapper_files.is_empty() { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let any_exec = wrapper_files.iter().any(|f| { + f.metadata() + .map(|m| m.permissions().mode() & 0o111 != 0) + .unwrap_or(false) + }); + if !any_exec { + warnings.push(format!( + "agent '{name}': .apm/agents/{name}/wrapper.* exists but is not executable; run chmod +x" + )); + } + } + } + + // Check manifest.toml + let manifest_path = entry.path().join("manifest.toml"); + if manifest_path.exists() { + match parse_manifest(root, &name) { + Err(e) => { + errors.push(format!("agent '{name}': manifest.toml is not valid TOML: {e}")); + } + Ok(Some(manifest)) => { + if manifest.contract_version > 1 { + errors.push(format!( + "agent '{name}': manifest.toml declares contract_version {}; \ + this APM build supports version 1 only — upgrade APM", + manifest.contract_version + )); + } + if let Ok(unknown) = manifest_unknown_keys(root, &name) { + for key in unknown { + warnings.push(format!( + "agent '{name}': manifest.toml: unknown key {key}" + )); + } + } + } + Ok(None) => {} + } + } + } +} + pub fn validate_config(config: &Config, root: &Path) -> Vec { let mut errors: Vec = Vec::new(); @@ -287,6 +385,9 @@ pub fn validate_config(config: &Config, root: &Path) -> Vec { } } + let mut agent_warnings: Vec = Vec::new(); + validate_agents(config, root, &mut errors, &mut agent_warnings); + errors } @@ -386,14 +487,16 @@ pub fn verify_tickets( .collect(); for name in agents_to_check { - // TODO(2c32a282): upgrade to wrapper::resolve_wrapper(root, name) once - // custom wrapper resolution lands so project-defined scripts referenced - // in `agent` / `agent_overrides` are also validated here. - if wrapper::resolve_builtin(name).is_none() { - issues.push(format!( + match wrapper::resolve_wrapper(root, name) { + Ok(Some(_)) => {} + Ok(None) => issues.push(format!( "ticket {}: agent {:?} is not a known built-in", fm.id, name - )); + )), + Err(e) => issues.push(format!( + "ticket {}: agent {:?}: {e}", + fm.id, name + )), } } } @@ -401,7 +504,7 @@ pub fn verify_tickets( issues } -pub fn validate_warnings(config: &crate::config::Config) -> Vec { +pub fn validate_warnings(config: &crate::config::Config, root: &Path) -> Vec { let mut warnings = config.load_warnings.clone(); if let Some(container) = &config.workers.container { if !container.is_empty() { @@ -481,6 +584,9 @@ pub fn validate_warnings(config: &crate::config::Config) -> Vec { } } + let mut agent_errors: Vec = Vec::new(); + validate_agents(config, root, &mut agent_errors, &mut warnings); + warnings } @@ -1203,7 +1309,7 @@ name = "test" dir = "tickets" "#; let config = load_config(toml); - let warnings = super::validate_warnings(&config); + let warnings = super::validate_warnings(&config, Path::new("/tmp")); assert!(warnings.is_empty()); } @@ -1368,7 +1474,7 @@ dir = "tickets" container = "" "#; let config = load_config(toml); - let warnings = super::validate_warnings(&config); + let warnings = super::validate_warnings(&config, Path::new("/tmp")); assert!(warnings.is_empty(), "empty container string should not warn"); } @@ -1399,7 +1505,7 @@ label = "Middle" to = "start" "#; let config = load_config(toml); - let warnings = super::validate_warnings(&config); + let warnings = super::validate_warnings(&config, Path::new("/tmp")); assert!( warnings.iter().any(|w| w.contains("success")), "expected dead-end warning containing 'success'; got: {warnings:?}" @@ -1419,7 +1525,7 @@ dir = "tickets" "#; let combined = format!("{}\n{}", base, crate::init::default_workflow_toml()); let config: Config = toml::from_str(&combined).unwrap(); - let warnings = super::validate_warnings(&config); + let warnings = super::validate_warnings(&config, Path::new("/tmp")); assert!( !warnings.iter().any(|w| w.contains("no reachable") && w.contains("success")), "unexpected dead-end warning for default workflow; got: {warnings:?}" diff --git a/apm-core/src/wrapper/custom.rs b/apm-core/src/wrapper/custom.rs new file mode 100644 index 000000000..152b6e79d --- /dev/null +++ b/apm-core/src/wrapper/custom.rs @@ -0,0 +1,349 @@ +use std::path::{Path, PathBuf}; +use serde::Deserialize; +use anyhow::Context; +use super::{Wrapper, WrapperContext}; + +fn default_contract_version() -> u32 { 1 } +fn default_parser() -> String { "canonical".to_string() } + +#[derive(Debug, Deserialize, Clone)] +pub struct Manifest { + #[serde(default)] + pub name: Option, + #[serde(default = "default_contract_version")] + pub contract_version: u32, + #[serde(default = "default_parser")] + pub parser: String, + #[serde(default)] + pub parser_command: Option, +} + +pub enum WrapperKind { + Custom { script_path: PathBuf, manifest: Option }, + Builtin(String), +} + +pub struct CustomWrapper { + pub script_path: PathBuf, + pub manifest: Option, +} + +impl Wrapper for CustomWrapper { + fn spawn(&self, ctx: &WrapperContext) -> anyhow::Result { + // Layer 2 spawn-time safety net: check contract_version unconditionally. + // Even if apm validate already passed, the manifest may have been edited + // between validate and this spawn call. + let version = self.manifest.as_ref().map(|m| m.contract_version).unwrap_or(1); + if version > 1 { + anyhow::bail!( + "wrapper at '{}' declares contract_version = {}; \ + this APM build supports version 1 only — upgrade APM", + self.script_path.display(), + version + ); + } + + let apm_bin = std::env::current_exe() + .and_then(|p| p.canonicalize()) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default(); + + let mut cmd = std::process::Command::new(&self.script_path); + + set_apm_env(&mut cmd, ctx, &apm_bin); + for (k, v) in &ctx.extra_env { + cmd.env(k, v); + } + + cmd.current_dir(&ctx.worktree_path); + + let log_file = std::fs::File::create(&ctx.log_path)?; + let log_clone = log_file.try_clone()?; + cmd.stdout(log_file); + cmd.stderr(log_clone); + + use std::os::unix::process::CommandExt; + cmd.process_group(0); + + Ok(cmd.spawn()?) + } +} + +fn set_apm_env(cmd: &mut std::process::Command, ctx: &WrapperContext, apm_bin: &str) { + cmd.env("APM_AGENT_NAME", &ctx.worker_name); + cmd.env("APM_TICKET_ID", &ctx.ticket_id); + cmd.env("APM_TICKET_BRANCH", &ctx.ticket_branch); + cmd.env("APM_TICKET_WORKTREE", ctx.worktree_path.to_string_lossy().as_ref()); + cmd.env("APM_SYSTEM_PROMPT_FILE", ctx.system_prompt_file.to_string_lossy().as_ref()); + cmd.env("APM_USER_MESSAGE_FILE", ctx.user_message_file.to_string_lossy().as_ref()); + cmd.env("APM_SKIP_PERMISSIONS", if ctx.skip_permissions { "1" } else { "0" }); + cmd.env("APM_PROFILE", &ctx.profile); + if let Some(ref prefix) = ctx.role_prefix { + cmd.env("APM_ROLE_PREFIX", prefix); + } + cmd.env("APM_WRAPPER_VERSION", "1"); + cmd.env("APM_BIN", apm_bin); + for (k, v) in &ctx.options { + let env_key = format!( + "APM_OPT_{}", + k.to_uppercase().replace('.', "_").replace('-', "_") + ); + cmd.env(&env_key, v); + } +} + +pub(crate) fn find_script(root: &Path, name: &str) -> Option { + let dir = root.join(".apm").join("agents").join(name); + let mut candidates: Vec = std::fs::read_dir(&dir) + .ok()? + .filter_map(|e| e.ok()) + .filter_map(|e| { + let path = e.path(); + let fname = path.file_name()?.to_str()?.to_owned(); + if !fname.starts_with("wrapper.") { + return None; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let meta = path.metadata().ok()?; + if meta.permissions().mode() & 0o111 == 0 { + return None; + } + } + Some(path) + }) + .collect(); + candidates.sort(); + candidates.into_iter().next() +} + +pub(crate) fn parse_manifest(root: &Path, name: &str) -> anyhow::Result> { + let path = root.join(".apm").join("agents").join(name).join("manifest.toml"); + if !path.exists() { + return Ok(None); + } + let content = std::fs::read_to_string(&path) + .with_context(|| format!("reading {}", path.display()))?; + + #[derive(Deserialize)] + struct ManifestFile { wrapper: Manifest } + + let file: ManifestFile = toml::from_str(&content) + .with_context(|| format!("parsing {}", path.display()))?; + Ok(Some(file.wrapper)) +} + +pub(crate) fn manifest_unknown_keys(root: &Path, name: &str) -> anyhow::Result> { + let path = root.join(".apm").join("agents").join(name).join("manifest.toml"); + if !path.exists() { + return Ok(vec![]); + } + let content = std::fs::read_to_string(&path) + .with_context(|| format!("reading {}", path.display()))?; + let table: toml::Value = content.parse::() + .with_context(|| format!("parsing {}", path.display()))?; + let known = ["name", "contract_version", "parser", "parser_command"]; + let unknown = match table.get("wrapper").and_then(|v| v.as_table()) { + Some(t) => t.keys() + .filter(|k| !known.contains(&k.as_str())) + .cloned() + .collect(), + None => vec![], + }; + Ok(unknown) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn make_ctx(wt: &std::path::Path, log: &std::path::Path) -> WrapperContext { + WrapperContext { + worker_name: "test-worker".to_string(), + ticket_id: "test-id".to_string(), + ticket_branch: "ticket/test-id".to_string(), + worktree_path: wt.to_path_buf(), + system_prompt_file: wt.join("sys.txt"), + user_message_file: wt.join("msg.txt"), + skip_permissions: false, + profile: "default".to_string(), + role_prefix: None, + options: HashMap::new(), + model: None, + log_path: log.to_path_buf(), + container: None, + extra_env: HashMap::new(), + root: wt.to_path_buf(), + keychain: HashMap::new(), + } + } + + fn make_executable(path: &std::path::Path, content: &str) { + std::fs::write(path, content).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + } + + // --- resolve_wrapper tests (via wrapper::resolve_wrapper) --- + + #[test] + fn resolve_wrapper_custom_shadows_builtin() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let agent_dir = root.join(".apm").join("agents").join("claude"); + std::fs::create_dir_all(&agent_dir).unwrap(); + make_executable(&agent_dir.join("wrapper.sh"), "#!/bin/sh\nexit 0\n"); + + let result = crate::wrapper::resolve_wrapper(root, "claude").unwrap(); + assert!(matches!(result, Some(WrapperKind::Custom { .. })), "expected Custom variant"); + } + + #[test] + fn resolve_wrapper_fallback_to_builtin() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + // No .apm/agents/claude/ dir + + let result = crate::wrapper::resolve_wrapper(root, "claude").unwrap(); + assert!(matches!(result, Some(WrapperKind::Builtin(ref n)) if n == "claude"), + "expected Builtin(claude)"); + } + + #[test] + fn resolve_wrapper_missing_returns_none() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + // "bogus-agent" is neither a builtin nor a custom script + + let result = crate::wrapper::resolve_wrapper(root, "bogus-agent").unwrap(); + assert!(result.is_none(), "expected None"); + } + + #[test] + fn resolve_wrapper_nonexecutable_invisible() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let agent_dir = root.join(".apm").join("agents").join("claude"); + std::fs::create_dir_all(&agent_dir).unwrap(); + + // Write non-executable wrapper.sh + let script = agent_dir.join("wrapper.sh"); + std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o644)).unwrap(); + } + + // Non-executable script is invisible; falls through to builtin + let result = crate::wrapper::resolve_wrapper(root, "claude").unwrap(); + assert!(matches!(result, Some(WrapperKind::Builtin(ref n)) if n == "claude"), + "non-executable script should be invisible; expected fallback to Builtin(claude)"); + } + + // --- manifest tests --- + + #[test] + fn manifest_parse_valid() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let agent_dir = root.join(".apm").join("agents").join("my-wrapper"); + std::fs::create_dir_all(&agent_dir).unwrap(); + std::fs::write(agent_dir.join("manifest.toml"), + "[wrapper]\nname = \"my-wrapper\"\ncontract_version = 1\nparser = \"canonical\"\n" + ).unwrap(); + + let m = parse_manifest(root, "my-wrapper").unwrap().unwrap(); + assert_eq!(m.contract_version, 1); + assert_eq!(m.parser, "canonical"); + assert_eq!(m.name.as_deref(), Some("my-wrapper")); + assert!(m.parser_command.is_none()); + } + + #[test] + fn manifest_parse_defaults() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let agent_dir = root.join(".apm").join("agents").join("my-wrapper"); + std::fs::create_dir_all(&agent_dir).unwrap(); + std::fs::write(agent_dir.join("manifest.toml"), "[wrapper]\n").unwrap(); + + let m = parse_manifest(root, "my-wrapper").unwrap().unwrap(); + assert_eq!(m.contract_version, 1); + assert_eq!(m.parser, "canonical"); + assert!(m.parser_command.is_none()); + } + + #[test] + fn manifest_parse_invalid_toml() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let agent_dir = root.join(".apm").join("agents").join("my-wrapper"); + std::fs::create_dir_all(&agent_dir).unwrap(); + std::fs::write(agent_dir.join("manifest.toml"), "[[[\nbad toml\n").unwrap(); + + assert!(parse_manifest(root, "my-wrapper").is_err(), "expected parse error"); + } + + #[test] + fn manifest_missing() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let agent_dir = root.join(".apm").join("agents").join("my-wrapper"); + std::fs::create_dir_all(&agent_dir).unwrap(); + // No manifest.toml + + assert!(parse_manifest(root, "my-wrapper").unwrap().is_none()); + } + + #[test] + fn manifest_unknown_keys_detected() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let agent_dir = root.join(".apm").join("agents").join("my-wrapper"); + std::fs::create_dir_all(&agent_dir).unwrap(); + std::fs::write(agent_dir.join("manifest.toml"), + "[wrapper]\ncontract_version = 1\nunknown_key = \"foo\"\n" + ).unwrap(); + + let unknown = manifest_unknown_keys(root, "my-wrapper").unwrap(); + assert!(unknown.contains(&"unknown_key".to_string()), + "expected unknown_key in {unknown:?}"); + } + + #[test] + fn spawn_rejects_contract_version_gt_1() { + use std::os::unix::fs::PermissionsExt; + + let wt = tempfile::tempdir().unwrap(); + let log_dir = tempfile::tempdir().unwrap(); + + // Create a script (won't be reached due to early bail) + let script = wt.path().join("wrapper.sh"); + std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let manifest = Manifest { + name: None, + contract_version: 2, + parser: "canonical".to_string(), + parser_command: None, + }; + + let wrapper = CustomWrapper { + script_path: script, + manifest: Some(manifest), + }; + + let ctx = make_ctx(wt.path(), &log_dir.path().join("worker.log")); + let err = wrapper.spawn(&ctx).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("upgrade APM"), + "error message must mention 'upgrade APM': {msg}"); + } +} diff --git a/apm-core/src/wrapper/mod.rs b/apm-core/src/wrapper/mod.rs index 27aefcbd2..2aeb4e421 100644 --- a/apm-core/src/wrapper/mod.rs +++ b/apm-core/src/wrapper/mod.rs @@ -1,8 +1,10 @@ mod claude; +pub mod custom; pub use claude::ClaudeWrapper; +pub use custom::{WrapperKind, Manifest}; use std::collections::HashMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; pub struct WrapperContext { pub worker_name: String, @@ -34,6 +36,17 @@ pub fn resolve_builtin(name: &str) -> Option> { } } +pub fn resolve_wrapper(root: &Path, name: &str) -> anyhow::Result> { + if let Some(script_path) = custom::find_script(root, name) { + let manifest = custom::parse_manifest(root, name)?; + return Ok(Some(WrapperKind::Custom { script_path, manifest })); + } + if resolve_builtin(name).is_some() { + return Ok(Some(WrapperKind::Builtin(name.to_owned()))); + } + Ok(None) +} + pub fn write_temp_file(prefix: &str, content: &str) -> anyhow::Result { let path = std::env::temp_dir().join(format!("apm-{prefix}-{:04x}.txt", rand_u16())); std::fs::write(&path, content)?; diff --git a/apm-core/tests/custom_wrapper_integration.rs b/apm-core/tests/custom_wrapper_integration.rs new file mode 100644 index 000000000..d8d8a5c36 --- /dev/null +++ b/apm-core/tests/custom_wrapper_integration.rs @@ -0,0 +1,78 @@ +use apm_core::wrapper::{WrapperContext, WrapperKind, Wrapper}; +use apm_core::wrapper::custom::CustomWrapper; +use std::collections::HashMap; + +#[cfg(unix)] +#[test] +fn integration_echo_test_wrapper() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + + // Create fixture: .apm/agents/echo-test/wrapper.sh + let agent_dir = root.join(".apm").join("agents").join("echo-test"); + std::fs::create_dir_all(&agent_dir).unwrap(); + + let script_path = agent_dir.join("wrapper.sh"); + std::fs::write( + &script_path, + "#!/bin/sh\nprintf '{\"type\":\"result\",\"text\":\"hello\"}\\n'\nexit 0\n", + ).unwrap(); + std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).unwrap(); + + // Temp worktree and log file + let wt = tempfile::tempdir().unwrap(); + let log_dir = tempfile::tempdir().unwrap(); + let log_path = log_dir.path().join("worker.log"); + + // resolve_wrapper returns Custom variant + let kind = apm_core::wrapper::resolve_wrapper(root, "echo-test") + .expect("resolve_wrapper should not error") + .expect("echo-test should be found"); + assert!(matches!(kind, WrapperKind::Custom { .. }), "expected Custom variant, got Builtin"); + + // Build a minimal WrapperContext + let sys_file = apm_core::wrapper::write_temp_file("sys", "system prompt").unwrap(); + let msg_file = apm_core::wrapper::write_temp_file("msg", "ticket content").unwrap(); + + let ctx = WrapperContext { + worker_name: "test-worker".to_string(), + ticket_id: "echo-test-id".to_string(), + ticket_branch: "ticket/echo-test-id".to_string(), + worktree_path: wt.path().to_path_buf(), + system_prompt_file: sys_file.clone(), + user_message_file: msg_file.clone(), + skip_permissions: false, + profile: "default".to_string(), + role_prefix: None, + options: HashMap::new(), + model: None, + log_path: log_path.clone(), + container: None, + extra_env: HashMap::new(), + root: root.to_path_buf(), + keychain: HashMap::new(), + }; + + // Spawn custom wrapper and wait + let (script, manifest) = match kind { + WrapperKind::Custom { script_path, manifest } => (script_path, manifest), + WrapperKind::Builtin(_) => panic!("expected Custom"), + }; + let wrapper = CustomWrapper { script_path: script, manifest }; + let mut child = wrapper.spawn(&ctx).expect("spawn should succeed"); + let status = child.wait().expect("wait should succeed"); + assert!(status.success(), "wrapper should exit 0; got: {status}"); + + // Log file should contain the emitted JSONL line + let log_content = std::fs::read_to_string(&log_path) + .expect("log file should exist after wrapper exits"); + assert!( + log_content.contains(r#"{"type":"result","text":"hello"}"#), + "log file must contain the emitted JSONL line; got:\n{log_content}" + ); + + let _ = std::fs::remove_file(&sys_file); + let _ = std::fs::remove_file(&msg_file); +} diff --git a/apm/src/cmd/validate.rs b/apm/src/cmd/validate.rs index 12f4803a6..bf5b63c32 100644 --- a/apm/src/cmd/validate.rs +++ b/apm/src/cmd/validate.rs @@ -233,12 +233,12 @@ pub fn run(root: &Path, fix: bool, json: bool, config_only: bool, no_aggressive: if config_only { config = CmdContext::load_config_only(root)?; config_errors = validate_config(&config, root); - config_warnings = validate_warnings(&config); + config_warnings = validate_warnings(&config, root); } else { let ctx = CmdContext::load(root, no_aggressive)?; config = ctx.config; config_errors = validate_config(&config, root); - config_warnings = validate_warnings(&config); + config_warnings = validate_warnings(&config, root); tickets_checked = ctx.tickets.len(); let tickets = ctx.tickets; From 7862594e95c9158e412bb2862d6c1429ca547eb3 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:13:55 -0700 Subject: [PATCH 253/305] ticket(2c32a282): check off acceptance criteria --- ...ustom-wrapper-resolution-from-apm-agent.md | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md index 245899940..4acf7f0f8 100644 --- a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md +++ b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md @@ -27,21 +27,21 @@ The result is a genuinely multi-agent APM: any tool that can read the APM env va ### Acceptance criteria -- [ ] `resolve_wrapper(root, "claude")` returns `Some(WrapperKind::Custom { script_path, .. })` when `.apm/agents/claude/wrapper.sh` exists and is executable, shadowing the built-in -- [ ] `resolve_wrapper(root, "claude")` returns `Some(WrapperKind::Builtin("claude"))` when no project script exists for `"claude"` -- [ ] `resolve_wrapper(root, "bogus")` returns `Ok(None)` when neither a project script nor a built-in with that name exists -- [ ] A `wrapper.*` file that exists but is not executable (Unix: mode `& 0o111 == 0`) is invisible to `resolve_wrapper`; the function falls through to the built-in or returns `None` -- [ ] `apm validate` emits an error of the form `"agent 'foo' not found: checked built-ins {claude} and '.apm/agents/foo/'"` when the configured agent cannot be resolved -- [ ] `apm validate` emits a warning (not an error) when a `.apm/agents//wrapper.*` file exists but lacks the executable bit -- [ ] A valid `manifest.toml` parses without error; `contract_version = 1` and `parser = "canonical"` are stored on the `Manifest` struct -- [ ] A `manifest.toml` with only `[wrapper]` and no explicit fields parses to defaults: `contract_version = 1`, `parser = "canonical"`, `parser_command = None` -- [ ] A `manifest.toml` with an unknown key causes `apm validate` to emit a warning (not an error); the manifest still parses and the wrapper is usable -- [ ] A syntactically invalid `manifest.toml` causes `apm validate` to emit an error; `resolve_wrapper` also returns an error -- [ ] A `manifest.toml` with `contract_version = 2` causes `apm validate` to emit an error directing the user to upgrade APM -- [ ] `CustomWrapper::spawn()` returns an error (does not spawn the process) when `manifest.contract_version > 1` -- [ ] The dispatcher in `start.rs` exec'''s a custom wrapper script directly via `Command::new(&script_path)` with no shell interpreter interposed, and all APM contract env vars are present in the child environment -- [ ] Integration: a fixture `.apm/agents/echo-test/wrapper.sh` (executable, emits one valid JSONL line, exits 0) is spawned by the dispatcher; its output is captured to the log file and the child exits 0 -- [ ] Unit tests `resolve_wrapper_nonexecutable_invisible`, `resolve_wrapper_fallback_to_builtin`, `resolve_wrapper_missing_returns_none`, `manifest_parse_valid`, `manifest_parse_defaults`, `manifest_parse_invalid_toml`, and `manifest_missing` all pass +- [x] `resolve_wrapper(root, "claude")` returns `Some(WrapperKind::Custom { script_path, .. })` when `.apm/agents/claude/wrapper.sh` exists and is executable, shadowing the built-in +- [x] `resolve_wrapper(root, "claude")` returns `Some(WrapperKind::Builtin("claude"))` when no project script exists for `"claude"` +- [x] `resolve_wrapper(root, "bogus")` returns `Ok(None)` when neither a project script nor a built-in with that name exists +- [x] A `wrapper.*` file that exists but is not executable (Unix: mode `& 0o111 == 0`) is invisible to `resolve_wrapper`; the function falls through to the built-in or returns `None` +- [x] `apm validate` emits an error of the form `"agent 'foo' not found: checked built-ins {claude} and '.apm/agents/foo/'"` when the configured agent cannot be resolved +- [x] `apm validate` emits a warning (not an error) when a `.apm/agents//wrapper.*` file exists but lacks the executable bit +- [x] A valid `manifest.toml` parses without error; `contract_version = 1` and `parser = "canonical"` are stored on the `Manifest` struct +- [x] A `manifest.toml` with only `[wrapper]` and no explicit fields parses to defaults: `contract_version = 1`, `parser = "canonical"`, `parser_command = None` +- [x] A `manifest.toml` with an unknown key causes `apm validate` to emit a warning (not an error); the manifest still parses and the wrapper is usable +- [x] A syntactically invalid `manifest.toml` causes `apm validate` to emit an error; `resolve_wrapper` also returns an error +- [x] A `manifest.toml` with `contract_version = 2` causes `apm validate` to emit an error directing the user to upgrade APM +- [x] `CustomWrapper::spawn()` returns an error (does not spawn the process) when `manifest.contract_version > 1` +- [x] The dispatcher in `start.rs` exec'''s a custom wrapper script directly via `Command::new(&script_path)` with no shell interpreter interposed, and all APM contract env vars are present in the child environment +- [x] Integration: a fixture `.apm/agents/echo-test/wrapper.sh` (executable, emits one valid JSONL line, exits 0) is spawned by the dispatcher; its output is captured to the log file and the child exits 0 +- [x] Unit tests `resolve_wrapper_nonexecutable_invisible`, `resolve_wrapper_fallback_to_builtin`, `resolve_wrapper_missing_returns_none`, `manifest_parse_valid`, `manifest_parse_defaults`, `manifest_parse_invalid_toml`, and `manifest_missing` all pass ### Out of scope From af937de99086e58b149cb7d28941980e5225aae2 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:13:58 -0700 Subject: [PATCH 254/305] =?UTF-8?q?ticket(2c32a282):=20in=5Fprogress=20?= =?UTF-8?q?=E2=86=92=20implemented?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md index 4acf7f0f8..7173ff08d 100644 --- a/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md +++ b/tickets/2c32a282-custom-wrapper-resolution-from-apm-agent.md @@ -1,7 +1,7 @@ +++ id = "2c32a282" title = "Custom wrapper resolution from .apm/agents//" -state = "in_progress" +state = "implemented" priority = 0 effort = 5 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/2c32a282-custom-wrapper-resolution-from-apm-agent" created_at = "2026-04-30T20:02:50.794362Z" -updated_at = "2026-05-01T18:57:36.267022Z" +updated_at = "2026-05-01T19:13:58.805587Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95"] @@ -261,3 +261,4 @@ Integration test in apm-core/tests/custom_wrapper_integration.rs: | 2026-05-01T01:32Z | in_design | specd | claude-0501-0129-2a50 | | 2026-05-01T17:38Z | specd | ready | philippepascal | | 2026-05-01T18:57Z | ready | in_progress | philippepascal | +| 2026-05-01T19:13Z | in_progress | implemented | claude-0501-1857-e740 | From 31e9052d4d04a0a40c54c61559aa5e379363d0c8 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:14:15 -0700 Subject: [PATCH 255/305] =?UTF-8?q?ticket(2e772eab):=20start=20=E2=80=94?= =?UTF-8?q?=20ready=20=E2=86=92=20in=5Fprogress?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md b/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md index 1558db674..cb579c493 100644 --- a/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md +++ b/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md @@ -1,7 +1,7 @@ +++ id = "2e772eab" title = "Wrapper-contract versioning (APM_WRAPPER_VERSION + manifest.toml)" -state = "ready" +state = "in_progress" priority = 0 effort = 2 risk = 1 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/2e772eab-wrapper-contract-versioning-apm-wrapper-" created_at = "2026-04-30T20:05:11.077339Z" -updated_at = "2026-05-01T17:38:20.298482Z" +updated_at = "2026-05-01T19:14:15.160859Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "2c32a282"] @@ -180,3 +180,4 @@ The `check_version_older_writes_warning` unit test covers the older-version path | 2026-05-01T00:30Z | ammend | in_design | philippepascal | | 2026-05-01T00:33Z | in_design | specd | claude-0501-0030-e588 | | 2026-05-01T17:38Z | specd | ready | philippepascal | +| 2026-05-01T19:14Z | ready | in_progress | philippepascal | From 717dee6900bfe64dba7e6b117032e479103d6bab Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:19:40 -0700 Subject: [PATCH 256/305] Add CONTRACT_VERSION constant and check_contract_version helper - Define pub const CONTRACT_VERSION: u32 = 1 in apm_core::wrapper - Use CONTRACT_VERSION.to_string() for APM_WRAPPER_VERSION in both ClaudeWrapper::spawn (local + container paths) and CustomWrapper::spawn - Extract check_contract_version(declared, apm_version, log_path) helper that errors on declared > apm_version and warns to the log on declared < apm_version - Replace the 2c32a282 hardcoded bail in CustomWrapper::spawn with a call to check_contract_version + map_err to include the wrapper path in the error - Add unit tests: check_version_equal, check_version_older_writes_warning, check_version_too_high_returns_err, check_version_no_manifest_defaults_to_1 - Add integration tests: spawn_matching_contract_succeeds, spawn_future_contract_rejected --- apm-core/src/wrapper/claude.rs | 7 +- apm-core/src/wrapper/custom.rs | 86 +++++++++++-- apm-core/src/wrapper/mod.rs | 2 + apm-core/tests/custom_wrapper_integration.rs | 126 +++++++++++++++++++ 4 files changed, 207 insertions(+), 14 deletions(-) diff --git a/apm-core/src/wrapper/claude.rs b/apm-core/src/wrapper/claude.rs index 6ffe181d5..63b5bbded 100644 --- a/apm-core/src/wrapper/claude.rs +++ b/apm-core/src/wrapper/claude.rs @@ -1,5 +1,5 @@ use std::os::unix::process::CommandExt; -use super::{Wrapper, WrapperContext}; +use super::{Wrapper, WrapperContext, CONTRACT_VERSION}; pub struct ClaudeWrapper; @@ -109,6 +109,7 @@ fn spawn_container( let worktree_str = ctx.worktree_path.to_string_lossy(); let sys_file_str = ctx.system_prompt_file.to_string_lossy(); let msg_file_str = ctx.user_message_file.to_string_lossy(); + let contract_version_str = CONTRACT_VERSION.to_string(); let apm_env_pairs: &[(&str, &str)] = &[ ("APM_AGENT_NAME", &ctx.worker_name), @@ -119,7 +120,7 @@ fn spawn_container( ("APM_USER_MESSAGE_FILE", &msg_file_str), ("APM_SKIP_PERMISSIONS", skip_perm_val), ("APM_PROFILE", &ctx.profile), - ("APM_WRAPPER_VERSION", "1"), + ("APM_WRAPPER_VERSION", &contract_version_str), ("APM_BIN", apm_bin), ]; for (k, v) in apm_env_pairs { @@ -175,7 +176,7 @@ fn set_apm_env(cmd: &mut std::process::Command, ctx: &WrapperContext, apm_bin: & if let Some(ref prefix) = ctx.role_prefix { cmd.env("APM_ROLE_PREFIX", prefix); } - cmd.env("APM_WRAPPER_VERSION", "1"); + cmd.env("APM_WRAPPER_VERSION", CONTRACT_VERSION.to_string()); cmd.env("APM_BIN", apm_bin); // APM_OPT_ for each option entry for (k, v) in &ctx.options { diff --git a/apm-core/src/wrapper/custom.rs b/apm-core/src/wrapper/custom.rs index 152b6e79d..149b2af0a 100644 --- a/apm-core/src/wrapper/custom.rs +++ b/apm-core/src/wrapper/custom.rs @@ -1,7 +1,8 @@ +use std::io::Write; use std::path::{Path, PathBuf}; use serde::Deserialize; use anyhow::Context; -use super::{Wrapper, WrapperContext}; +use super::{Wrapper, WrapperContext, CONTRACT_VERSION}; fn default_contract_version() -> u32 { 1 } fn default_parser() -> String { "canonical".to_string() } @@ -28,20 +29,41 @@ pub struct CustomWrapper { pub manifest: Option, } +fn check_contract_version(declared: u32, apm_version: u32, log_path: &Path) -> anyhow::Result<()> { + match declared.cmp(&apm_version) { + std::cmp::Ordering::Greater => anyhow::bail!( + "wrapper targets contract version {} but this APM build supports up to \ + version {}; upgrade APM", + declared, + apm_version, + ), + std::cmp::Ordering::Less => { + if let Ok(mut f) = std::fs::OpenOptions::new() + .append(true) + .create(true) + .open(log_path) + { + let _ = writeln!( + f, + "[apm] warning: wrapper targets contract version {} but this APM \ + build is version {}; the wrapper may not use newer env vars", + declared, apm_version, + ); + } + } + std::cmp::Ordering::Equal => {} + } + Ok(()) +} + impl Wrapper for CustomWrapper { fn spawn(&self, ctx: &WrapperContext) -> anyhow::Result { // Layer 2 spawn-time safety net: check contract_version unconditionally. // Even if apm validate already passed, the manifest may have been edited // between validate and this spawn call. - let version = self.manifest.as_ref().map(|m| m.contract_version).unwrap_or(1); - if version > 1 { - anyhow::bail!( - "wrapper at '{}' declares contract_version = {}; \ - this APM build supports version 1 only — upgrade APM", - self.script_path.display(), - version - ); - } + let declared = self.manifest.as_ref().map_or(1, |m| m.contract_version); + check_contract_version(declared, CONTRACT_VERSION, &ctx.log_path) + .map_err(|e| anyhow::anyhow!("wrapper '{}': {}", self.script_path.display(), e))?; let apm_bin = std::env::current_exe() .and_then(|p| p.canonicalize()) @@ -81,7 +103,7 @@ fn set_apm_env(cmd: &mut std::process::Command, ctx: &WrapperContext, apm_bin: & if let Some(ref prefix) = ctx.role_prefix { cmd.env("APM_ROLE_PREFIX", prefix); } - cmd.env("APM_WRAPPER_VERSION", "1"); + cmd.env("APM_WRAPPER_VERSION", CONTRACT_VERSION.to_string()); cmd.env("APM_BIN", apm_bin); for (k, v) in &ctx.options { let env_key = format!( @@ -316,6 +338,48 @@ mod tests { "expected unknown_key in {unknown:?}"); } + // --- check_contract_version unit tests --- + + #[test] + fn check_version_equal() { + let log_dir = tempfile::tempdir().unwrap(); + let log_path = log_dir.path().join("worker.log"); + assert!(check_contract_version(1, 1, &log_path).is_ok()); + // No log file created for equal versions + assert!(!log_path.exists() || std::fs::read_to_string(&log_path).unwrap().is_empty()); + } + + #[test] + fn check_version_older_writes_warning() { + let log_dir = tempfile::tempdir().unwrap(); + let log_path = log_dir.path().join("worker.log"); + // declared=1 is older than apm_version=2 → warning, Ok + let result = check_contract_version(1, 2, &log_path); + assert!(result.is_ok(), "expected Ok for older version"); + let content = std::fs::read_to_string(&log_path).unwrap_or_default(); + assert!(content.contains("warning"), "log must contain 'warning': {content}"); + assert!(content.contains('1'), "log must contain declared version 1: {content}"); + assert!(content.contains('2'), "log must contain apm version 2: {content}"); + } + + #[test] + fn check_version_too_high_returns_err() { + let log_dir = tempfile::tempdir().unwrap(); + let log_path = log_dir.path().join("worker.log"); + let result = check_contract_version(2, 1, &log_path); + assert!(result.is_err(), "expected Err for version > apm"); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("upgrade APM"), "error must mention 'upgrade APM': {msg}"); + assert!(msg.contains('2'), "error must mention declared version 2: {msg}"); + assert!(msg.contains('1'), "error must mention apm version 1: {msg}"); + } + + #[test] + fn check_version_no_manifest_defaults_to_1() { + let declared = None::.map_or(1, |m| m.contract_version); + assert_eq!(declared, 1); + } + #[test] fn spawn_rejects_contract_version_gt_1() { use std::os::unix::fs::PermissionsExt; diff --git a/apm-core/src/wrapper/mod.rs b/apm-core/src/wrapper/mod.rs index 2aeb4e421..b7157d40f 100644 --- a/apm-core/src/wrapper/mod.rs +++ b/apm-core/src/wrapper/mod.rs @@ -6,6 +6,8 @@ pub use custom::{WrapperKind, Manifest}; use std::collections::HashMap; use std::path::{Path, PathBuf}; +pub const CONTRACT_VERSION: u32 = 1; + pub struct WrapperContext { pub worker_name: String, pub ticket_id: String, diff --git a/apm-core/tests/custom_wrapper_integration.rs b/apm-core/tests/custom_wrapper_integration.rs index d8d8a5c36..9bdcabd11 100644 --- a/apm-core/tests/custom_wrapper_integration.rs +++ b/apm-core/tests/custom_wrapper_integration.rs @@ -76,3 +76,129 @@ fn integration_echo_test_wrapper() { let _ = std::fs::remove_file(&sys_file); let _ = std::fs::remove_file(&msg_file); } + +#[cfg(unix)] +#[test] +fn spawn_matching_contract_succeeds() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + + // Create fixture: .apm/agents/v1-agent/wrapper.sh + manifest.toml declaring version 1 + let agent_dir = root.join(".apm").join("agents").join("v1-agent"); + std::fs::create_dir_all(&agent_dir).unwrap(); + + let script_path = agent_dir.join("wrapper.sh"); + std::fs::write(&script_path, "#!/bin/sh\nexit 0\n").unwrap(); + std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).unwrap(); + + std::fs::write( + agent_dir.join("manifest.toml"), + "[wrapper]\ncontract_version = 1\n", + ).unwrap(); + + let wt = tempfile::tempdir().unwrap(); + let log_dir = tempfile::tempdir().unwrap(); + let log_path = log_dir.path().join("worker.log"); + + let kind = apm_core::wrapper::resolve_wrapper(root, "v1-agent") + .expect("resolve_wrapper should not error") + .expect("v1-agent should be found"); + + let (script, manifest) = match kind { + WrapperKind::Custom { script_path, manifest } => (script_path, manifest), + WrapperKind::Builtin(_) => panic!("expected Custom"), + }; + let wrapper = CustomWrapper { script_path: script, manifest }; + + let ctx = WrapperContext { + worker_name: "v1-agent".to_string(), + ticket_id: "v1-test".to_string(), + ticket_branch: "ticket/v1-test".to_string(), + worktree_path: wt.path().to_path_buf(), + system_prompt_file: wt.path().join("sys.txt"), + user_message_file: wt.path().join("msg.txt"), + skip_permissions: false, + profile: "default".to_string(), + role_prefix: None, + options: HashMap::new(), + model: None, + log_path: log_path.clone(), + container: None, + extra_env: HashMap::new(), + root: root.to_path_buf(), + keychain: HashMap::new(), + }; + + let mut child = wrapper.spawn(&ctx).expect("spawn should succeed for contract_version = 1"); + let status = child.wait().expect("wait should succeed"); + assert!(status.success(), "wrapper should exit 0; got: {status}"); + + // No warning line should appear in the log for matching versions + let log_content = std::fs::read_to_string(&log_path).unwrap_or_default(); + assert!( + !log_content.contains("warning"), + "log must not contain any warning for matching contract_version: {log_content}" + ); +} + +#[cfg(unix)] +#[test] +fn spawn_future_contract_rejected() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + + // Create fixture: .apm/agents/future-agent/wrapper.sh + manifest.toml declaring version 2 + let agent_dir = root.join(".apm").join("agents").join("future-agent"); + std::fs::create_dir_all(&agent_dir).unwrap(); + + let script_path = agent_dir.join("wrapper.sh"); + std::fs::write(&script_path, "#!/bin/sh\nexit 0\n").unwrap(); + std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).unwrap(); + + std::fs::write( + agent_dir.join("manifest.toml"), + "[wrapper]\ncontract_version = 2\n", + ).unwrap(); + + let wt = tempfile::tempdir().unwrap(); + let log_dir = tempfile::tempdir().unwrap(); + let log_path = log_dir.path().join("worker.log"); + + let kind = apm_core::wrapper::resolve_wrapper(root, "future-agent") + .expect("resolve_wrapper should not error") + .expect("future-agent should be found"); + + let (script, manifest) = match kind { + WrapperKind::Custom { script_path, manifest } => (script_path, manifest), + WrapperKind::Builtin(_) => panic!("expected Custom"), + }; + let wrapper = CustomWrapper { script_path: script, manifest }; + + let ctx = WrapperContext { + worker_name: "future-agent".to_string(), + ticket_id: "future-test".to_string(), + ticket_branch: "ticket/future-test".to_string(), + worktree_path: wt.path().to_path_buf(), + system_prompt_file: wt.path().join("sys.txt"), + user_message_file: wt.path().join("msg.txt"), + skip_permissions: false, + profile: "default".to_string(), + role_prefix: None, + options: HashMap::new(), + model: None, + log_path: log_path.clone(), + container: None, + extra_env: HashMap::new(), + root: root.to_path_buf(), + keychain: HashMap::new(), + }; + + let result = wrapper.spawn(&ctx); + assert!(result.is_err(), "spawn must return Err for contract_version = 2"); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("upgrade APM"), "error must mention 'upgrade APM': {msg}"); +} From d69bd7d263e8a79f64fc2fa1bf4c6daf81a3294d Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:20:08 -0700 Subject: [PATCH 257/305] Check off all acceptance criteria --- ...wrapper-contract-versioning-apm-wrapper-.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md b/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md index cb579c493..c85aeaf44 100644 --- a/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md +++ b/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md @@ -49,15 +49,15 @@ Add wrapper-contract versioning so future contract changes (new env vars, new ou ### Acceptance criteria -- [ ] `pub const CONTRACT_VERSION: u32 = 1` is defined in `apm_core::wrapper` and accessible from outside the module -- [ ] `APM_WRAPPER_VERSION` env var is set to `CONTRACT_VERSION.to_string()` (not a hardcoded `"1"`) in both `ClaudeWrapper::spawn` and `CustomWrapper::spawn` -- [ ] Spawning a custom wrapper whose manifest declares `contract_version = 1` (equal to `CONTRACT_VERSION`) succeeds and no version-warning line is written to the worker log -- [ ] Spawning a custom wrapper with no manifest present defaults to `contract_version = 1`, spawn succeeds, no warning written -- [ ] Spawning a custom wrapper whose manifest declares `contract_version > CONTRACT_VERSION` returns `Err` and does not produce a child process -- [ ] The error for `contract_version > CONTRACT_VERSION` includes the wrapper name, the declared version number, the APM max-supported version, and the string `"upgrade APM"` -- [ ] Spawning a custom wrapper whose manifest declares `contract_version < CONTRACT_VERSION` succeeds (returns `Ok(child)`, no error) -- [ ] When declared version is less than `CONTRACT_VERSION`, a warning line is appended to the worker log file before spawn proceeds -- [ ] The version-comparison logic is extracted into a private helper `check_contract_version(declared: u32, apm_version: u32, log_path: &Path)` so the older-version warning path can be exercised in a unit test without modifying the compile-time constant +- [x] `pub const CONTRACT_VERSION: u32 = 1` is defined in `apm_core::wrapper` and accessible from outside the module +- [x] `APM_WRAPPER_VERSION` env var is set to `CONTRACT_VERSION.to_string()` (not a hardcoded `"1"`) in both `ClaudeWrapper::spawn` and `CustomWrapper::spawn` +- [x] Spawning a custom wrapper whose manifest declares `contract_version = 1` (equal to `CONTRACT_VERSION`) succeeds and no version-warning line is written to the worker log +- [x] Spawning a custom wrapper with no manifest present defaults to `contract_version = 1`, spawn succeeds, no warning written +- [x] Spawning a custom wrapper whose manifest declares `contract_version > CONTRACT_VERSION` returns `Err` and does not produce a child process +- [x] The error for `contract_version > CONTRACT_VERSION` includes the wrapper name, the declared version number, the APM max-supported version, and the string `"upgrade APM"` +- [x] Spawning a custom wrapper whose manifest declares `contract_version < CONTRACT_VERSION` succeeds (returns `Ok(child)`, no error) +- [x] When declared version is less than `CONTRACT_VERSION`, a warning line is appended to the worker log file before spawn proceeds +- [x] The version-comparison logic is extracted into a private helper `check_contract_version(declared: u32, apm_version: u32, log_path: &Path)` so the older-version warning path can be exercised in a unit test without modifying the compile-time constant ### Out of scope From 70e51a96278231cc885f2c830a9dcca5e685a0e3 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:20:11 -0700 Subject: [PATCH 258/305] =?UTF-8?q?ticket(2e772eab):=20in=5Fprogress=20?= =?UTF-8?q?=E2=86=92=20implemented?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md b/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md index c85aeaf44..0c80b947a 100644 --- a/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md +++ b/tickets/2e772eab-wrapper-contract-versioning-apm-wrapper-.md @@ -1,7 +1,7 @@ +++ id = "2e772eab" title = "Wrapper-contract versioning (APM_WRAPPER_VERSION + manifest.toml)" -state = "in_progress" +state = "implemented" priority = 0 effort = 2 risk = 1 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/2e772eab-wrapper-contract-versioning-apm-wrapper-" created_at = "2026-04-30T20:05:11.077339Z" -updated_at = "2026-05-01T19:14:15.160859Z" +updated_at = "2026-05-01T19:20:11.706323Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "2c32a282"] @@ -181,3 +181,4 @@ The `check_version_older_writes_warning` unit test covers the older-version path | 2026-05-01T00:33Z | in_design | specd | claude-0501-0030-e588 | | 2026-05-01T17:38Z | specd | ready | philippepascal | | 2026-05-01T19:14Z | ready | in_progress | philippepascal | +| 2026-05-01T19:20Z | in_progress | implemented | claude-0501-1914-c228 | From a6dc45324a828d960fb3e2184bbac9c76589a912 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:20:30 -0700 Subject: [PATCH 259/305] =?UTF-8?q?ticket(7f5f73d5):=20start=20=E2=80=94?= =?UTF-8?q?=20ready=20=E2=86=92=20in=5Fprogress?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/7f5f73d5-per-agent-instructions-resolution-under-.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md index 6eba51175..58c492deb 100644 --- a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md +++ b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md @@ -1,7 +1,7 @@ +++ id = "7f5f73d5" title = "Per-agent instructions resolution under .apm/agents//" -state = "ready" +state = "in_progress" priority = 0 effort = 4 risk = 2 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/7f5f73d5-per-agent-instructions-resolution-under-" created_at = "2026-04-30T20:03:33.687625Z" -updated_at = "2026-05-01T17:38:08.957192Z" +updated_at = "2026-05-01T19:20:30.592124Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "2c32a282"] @@ -183,3 +183,4 @@ Update the three existing `resolve_system_prompt_*` tests to match the new signa | 2026-04-30T21:42Z | groomed | in_design | philippepascal | | 2026-04-30T21:50Z | in_design | specd | claude-0430-2142-eea0 | | 2026-05-01T17:38Z | specd | ready | philippepascal | +| 2026-05-01T19:20Z | ready | in_progress | philippepascal | From 8e94a2ba259f711436b8c257d63500045c4d7637 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:25:57 -0700 Subject: [PATCH 260/305] Add per-agent instruction resolution with 5-level chain - Add CLAUDE_WORKER_DEFAULT and CLAUDE_SPEC_WRITER_DEFAULT constants compiled in via include_str! - Add default/agents/claude/ instruction files (copies of top-level defaults) - Update resolve_system_prompt to 5-level chain: profile.instructions > workers.instructions > .apm/agents//apm..md > built-in default > hard error - Remove silent hardcoded fallback; custom agents with no instructions now bail with descriptive error - Add WorkersConfig.instructions field for global instruction override - Add WorkerProfileConfig.role field for role-based instruction selection - Update init.rs default config: spec_agent profile gains role = spec-writer - Add validate_config check for workers.instructions missing file - Update all three call sites (run, run_next, spawn_next_worker) to use new signature - Add 7 new tests covering all resolution levels --- apm-core/src/config.rs | 5 + .../default/agents/claude/apm.spec-writer.md | 180 ++++++++++++++++++ .../src/default/agents/claude/apm.worker.md | 155 +++++++++++++++ apm-core/src/init.rs | 1 + apm-core/src/start.rs | 159 +++++++++++++--- apm-core/src/validate.rs | 8 + 6 files changed, 478 insertions(+), 30 deletions(-) create mode 100644 apm-core/src/default/agents/claude/apm.spec-writer.md create mode 100644 apm-core/src/default/agents/claude/apm.worker.md diff --git a/apm-core/src/config.rs b/apm-core/src/config.rs index 736c6bedf..6b5451bb5 100644 --- a/apm-core/src/config.rs +++ b/apm-core/src/config.rs @@ -94,6 +94,8 @@ pub struct WorkersConfig { /// Key-value options forwarded to the agent wrapper as `APM_OPT_` env vars. #[serde(default)] pub options: std::collections::HashMap, + /// Global instructions file used as the system prompt for all profiles; overridden by per-profile `instructions`. + pub instructions: Option, } impl Default for WorkersConfig { @@ -107,6 +109,7 @@ impl Default for WorkersConfig { env: std::collections::HashMap::new(), agent: None, options: std::collections::HashMap::new(), + instructions: None, } } } @@ -133,6 +136,8 @@ pub struct WorkerProfileConfig { /// Key-value options for this profile, merged over `[workers.options]`. #[serde(default)] pub options: std::collections::HashMap, + /// Role name used to select the per-agent instruction file (e.g. "worker", "spec-writer"). Defaults to "worker" when absent. + pub role: Option, } #[derive(Debug, Deserialize, Default, JsonSchema)] diff --git a/apm-core/src/default/agents/claude/apm.spec-writer.md b/apm-core/src/default/agents/claude/apm.spec-writer.md new file mode 100644 index 000000000..9cbab2b8b --- /dev/null +++ b/apm-core/src/default/agents/claude/apm.spec-writer.md @@ -0,0 +1,180 @@ +# APM Spec-Writer Instructions + +This file applies when you pick up a ticket in **`new`** or **`ammend`** state. +Your job is to write or revise the spec so a separate implementation agent can +act on it without needing to ask questions. + +--- + +## How to save spec sections + +Use `apm spec` to write each section. For long content, write to a temp file +first with the Write tool, then reference it with `--set-file`: + +```bash +# Short content — inline +apm spec --section "Out of scope" --set "- Item one\n- Item two" + +# Long content — via temp file +# 1. Write content to /tmp/spec--

.md using the Write tool +# 2. Then: +apm spec --section "Acceptance criteria" --set-file /tmp/spec--ac.md +``` + +Do NOT write the ticket markdown file directly. Always use `apm spec`. + +--- + +## When you are done + +Transition to `specd` only when **all four sections** are present and complete: +`### Problem`, `### Acceptance criteria`, `### Out of scope`, `### Approach`. + +Before transitioning, set: +- `apm set effort <1-10>` +- `apm set risk <1-10>` + +Then: `apm state specd` + +--- + +## Problem + +**What to write:** A concise statement of what is broken or missing, and why it +matters. One to three paragraphs is usually enough. + +A good problem statement answers: +- What is the current behaviour / gap? +- What is the desired behaviour? +- Who is affected, and at what scale? + +Avoid restating the title or the acceptance criteria here. If the problem +requires background context (e.g. upstream design decisions), include it. + +--- + +## Acceptance criteria + +Each criterion is one independently testable behaviour written as a checkbox: + +``` +- [ ] +``` + +Rules: +- One behaviour per checkbox — never combine two conditions with "and" +- Write from the user/caller perspective: "apm foo outputs …", not "the function returns …" +- Every criterion must be verifiable in isolation (no criterion should depend on + another being true first) +- Cover the happy path, the main edge cases, and the error cases that matter +- Do not include implementation details (no "the struct has a field X") + +--- + +## Out of scope + +Explicit list of things that are **not** covered by this ticket, especially +items that could be mistaken for in-scope. Use a plain list: + +``` +- +- +``` + +If the boundary is obvious, a single line is fine. If there is a closely +related ticket that covers the excluded item, name it. + +--- + +## Approach + +Enough detail that an implementer can follow without re-reading the problem. +Include: +- Which files change and what the change is +- Key data structures or algorithms if non-obvious +- Order of steps when the order matters +- Any known constraints or gotchas (e.g. must stay backward-compatible) + +The approach does **not** need to be step-by-step prose; numbered lists or +bullet points are fine. It should be at the right level of detail: too brief +leaves the implementer guessing; too detailed becomes stale. + +**Write the Approach as a single pass.** Do not write a high-level summary +followed by a detailed per-step breakdown — that produces duplication. Pick one +level of detail and cover every step once. + +Use `####` headings within long sections to create named subsections that +serve as editing handles. Example: inside `### Approach`, add `#### Phase 1` +so a future `apm spec --section "Approach > Phase 1"` can update that +block without touching the rest. + +--- + +## Effort scale + +| Score | Meaning | +|-------|---------| +| 1 | Trivial — under one hour, single-file change | +| 2–3 | Small — a few hours, clear path | +| 4–5 | Medium — roughly half a day, some design decisions | +| 6–7 | Large — full day or more, multiple subsystems | +| 8–9 | Very large — multi-day, significant coordination | +| 10 | Massive — week-scale, should probably be broken up | + +Assess effort **after** writing the spec, not before. The spec gives you the +context to make a good estimate. + +--- + +## Risk scale + +| Score | Meaning | +|-------|---------| +| 1 | No unknowns — well-understood change to existing patterns | +| 2–3 | Minor uncertainty — one or two small decisions to make | +| 4–5 | Moderate — some unknowns; outcome is likely fine but not certain | +| 6–7 | Significant — meaningful uncertainty or broad blast radius | +| 8–9 | High — key unknowns that could derail the approach | +| 10 | Blocking — should not start until unknowns are resolved | + +Risk is about **uncertainty and blast radius**, not effort. A large ticket can +be low-risk if the path is clear. + +--- + +## Handling `ammend` tickets + +When the ticket is in `ammend` state: +1. `apm show ` — read `### Amendment requests` in `## Spec` carefully; + each item is a checkbox you must resolve before resubmitting +2. `apm state in_design` — claim the ticket and provision its worktree; + prints the worktree path +3. For each checkbox, make the requested change to the relevant spec section, + then mark it done: + ```bash + apm spec --section "Amendment requests" --mark "" + ``` +4. Update `### Approach` if the amendments change the implementation plan +5. Do not delete answered questions or previously checked items — they are the + decision record +6. Commit the updated ticket file via the worktree path: + ```bash + git -C add tickets/-.md + git -C commit -m "ticket(): address amendments" + ``` +7. `apm state specd` — resubmit only when **all** amendment boxes are checked + +--- + +## Open questions + +If you cannot write a complete spec without an answer from the supervisor, +write the question in `### Open questions` (create the section if absent), then +transition to `question`. Do not guess and proceed. + +Once an answer arrives, reflect the decision in `### Approach` before +transitioning back to `specd`. + +--- + +**Frontmatter agent override** (supervisor tool): A supervisor may add `agent = ""` or an `[agent_overrides]` table to a ticket's frontmatter to select a specific agent for that ticket or for individual profiles. Do not set these fields yourself — they are a supervisor-level escape hatch for debugging or per-ticket specialisation. diff --git a/apm-core/src/default/agents/claude/apm.worker.md b/apm-core/src/default/agents/claude/apm.worker.md new file mode 100644 index 000000000..f8782ef03 --- /dev/null +++ b/apm-core/src/default/agents/claude/apm.worker.md @@ -0,0 +1,155 @@ +# APM Worker Agent Instructions + +These instructions apply when you pick up a `ready` ticket via `apm start` or +resume an `in_progress` ticket. + +Read `apm.agents.md` for startup, identity, worktree setup, and shell +discipline. This file covers the implementation phase only. + +--- + +## Before writing any code + +1. `apm show ` — read the full ticket, including `## Spec` and `## History` +2. Check `## History` for prior `in_progress` entries — a worktree and partial + work may already exist on the branch; continue from there +3. Re-read `### Acceptance criteria` — implement exactly those items, nothing more + +--- + +## Minimal-change discipline + +- Satisfy each acceptance criterion; do not add features or refactors not listed +- No docstrings, comments, or type annotations on code you did not change +- No backwards-compat shims — delete unused code +- Prefer editing existing files over creating new ones +- Do not add error handling for cases that cannot happen + +--- + +## Commit format + +- Imperative mood, present tense: "Add X", "Fix Y", "Refactor Z" +- First line ≤ 72 characters +- Do not add a `Co-Authored-By` trailer +- Do not amend published commits — create new ones + +--- + +## Tests + +- Unit tests inline in each crate (`apm-core/src/`) or in `apm-core/tests/` +- Integration tests in `apm/tests/integration.rs` — use temp git repos, no + fixture files needed +- Run `cargo test --workspace` — all tests must pass before calling `apm state implemented` + +--- + +## Finishing implementation + +Run `cargo test --workspace` — all tests must pass. + +Then: `apm state implemented` + +`apm state` pushes the branch and opens the PR automatically. Do not open a PR manually. + +--- + +## Side tickets + +When you notice an out-of-scope issue during implementation, capture it without +interrupting your current work: + +```bash +apm new --side-note "Brief title" --context "What you observed and why it matters" +``` + +Then immediately resume the current ticket. + +--- + +## Blocked state + +If you hit a missing decision or ambiguity mid-implementation: + +1. Write the question in `### Open questions` in the ticket spec +2. Commit the update to the worktree branch +3. `apm state blocked` + +Do not use `apm state ready` — that transition does not exist from +`in_progress`. + +--- + +## Shell discipline + +Claude Code's permission system matches the **start** of the command string. +Compound calls defeat this matching and generate permission prompts. Keep each +Bash call to a single operation. + +**Do not chain commands:** +```bash +# Wrong +apm sync && apm list --state ready + +# Right — one call per operation +apm sync +apm list --state ready +``` + +**Do not use `$()` subshells:** +```bash +# Wrong +apm spec 1234 --section Problem --set "$(cat /tmp/problem.md)" + +# Right — write content with the Write tool, then reference by file +apm spec 1234 --section Problem --set-file /tmp/problem.md +``` + +**Do not use background jobs (`&`):** +```bash +# Wrong +cargo test & cargo clippy & wait + +# Right — sequential calls +cargo test +cargo clippy +``` + +**Use `git -C` for all git operations in worktrees:** +```bash +# Wrong +cd "$wt" && git add . + +# Right +git -C "$wt" add +``` + +**Use `bash -c` for multi-step commands that must share a directory:** +```bash +# Right — single bash call, matches Bash(bash *) +bash -c "cd $wt && cargo test --workspace 2>&1" +``` + +--- + +## Path discipline + +Your working directory is the ticket worktree. Never read or write files outside +it. Always use absolute paths rooted at your worktree. The worktree path appears +in `apm show ` under Worktree — note it at the start of your run. + +``` +# Correct — absolute path inside your worktree +/Users/you/repos/myproject/.apm--worktrees/ticket-abc123-my-feature/src/main.rs + +# Wrong — path in the main repo root (leaks edits outside your worktree) +/Users/you/repos/myproject/src/main.rs +``` + +If a tool call resolves to a path outside your worktree, stop immediately, file +a side-note ticket, and set yourself to blocked. + +--- + +**Frontmatter agent override** (supervisor tool): A supervisor may add `agent = ""` or an `[agent_overrides]` table to a ticket's frontmatter to select a specific agent for that ticket or for individual profiles. Do not set these fields yourself — they are a supervisor-level escape hatch for debugging or per-ticket specialisation. diff --git a/apm-core/src/init.rs b/apm-core/src/init.rs index 802171820..e92f01ea9 100644 --- a/apm-core/src/init.rs +++ b/apm-core/src/init.rs @@ -313,6 +313,7 @@ model = "sonnet" [worker_profiles.spec_agent] instructions = ".apm/apm.spec-writer.md" +role = "spec-writer" role_prefix = "You are a Spec-Writer agent assigned to ticket #." [worker_profiles.impl_agent] diff --git a/apm-core/src/start.rs b/apm-core/src/start.rs index 9926d3cae..d098346fe 100644 --- a/apm-core/src/start.rs +++ b/apm-core/src/start.rs @@ -4,6 +4,9 @@ use crate::wrapper::{WrapperContext, write_temp_file}; use chrono::Utc; use std::path::{Path, PathBuf}; +const CLAUDE_WORKER_DEFAULT: &str = include_str!("default/agents/claude/apm.worker.md"); +const CLAUDE_SPEC_WRITER_DEFAULT: &str = include_str!("default/agents/claude/apm.spec-writer.md"); + static DEPRECATION_WARNED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); #[cfg(test)] @@ -300,10 +303,8 @@ pub fn run(root: &Path, id_arg: &str, no_aggressive: bool, spawn: bool, skip_per .unwrap_or("") .to_string(); let profile = triggering_transition.and_then(|tr| resolve_profile(tr, &config, &mut warnings)); - let state_instructions = config.workflow.states.iter() - .find(|s| s.id == old_state) - .and_then(|sc| sc.instructions.as_deref()); - let worker_system = resolve_system_prompt(root, profile, state_instructions); + let role = profile.and_then(|p| p.role.as_deref()).unwrap_or("worker"); + let worker_system = resolve_system_prompt(root, profile, &config.workers, "claude", role)?; let raw_prompt = format!("{}\n\n{content}", agent_role_prefix(profile, &id)); let with_epic = with_epic_bundle(root, ticket_epic_id.as_deref(), &id, &config, raw_prompt); let ticket_content = with_dependency_bundle(root, &ticket_depends_on, &config, with_epic); @@ -484,10 +485,8 @@ pub fn run_next(root: &Path, no_aggressive: bool, spawn: bool, skip_permissions: .unwrap_or("") .to_string(); let profile2 = triggering_transition_owned.as_ref().and_then(|tr| resolve_profile(tr, &config, &mut warnings)); - let state_instr2 = config.workflow.states.iter() - .find(|s| s.id == old_state) - .and_then(|sc| sc.instructions.as_deref()); - let worker_system = resolve_system_prompt(root, profile2, state_instr2); + let role2 = profile2.and_then(|p| p.role.as_deref()).unwrap_or("worker"); + let worker_system = resolve_system_prompt(root, profile2, &config.workers, "claude", role2)?; let raw = t.serialize()?; let dep_ids_next = t.frontmatter.depends_on.clone().unwrap_or_default(); @@ -669,10 +668,8 @@ pub fn spawn_next_worker( .unwrap_or("") .to_string(); let profile2 = triggering_transition_owned.as_ref().and_then(|tr| resolve_profile(tr, &config, warnings)); - let state_instr2 = config.workflow.states.iter() - .find(|s| s.id == old_state) - .and_then(|sc| sc.instructions.as_deref()); - let worker_system = resolve_system_prompt(root, profile2, state_instr2); + let role2 = profile2.and_then(|p| p.role.as_deref()).unwrap_or("worker"); + let worker_system = resolve_system_prompt(root, profile2, &config.workers, "claude", role2)?; let raw = t.serialize()?; let dep_ids_snw = t.frontmatter.depends_on.clone().unwrap_or_default(); @@ -756,22 +753,54 @@ fn with_epic_bundle(root: &Path, epic_id: Option<&str>, ticket_id: &str, config: } } -fn resolve_system_prompt(root: &Path, profile: Option<&WorkerProfileConfig>, state_instructions: Option<&str>) -> String { +fn resolve_builtin_instructions(agent: &str, role: &str) -> Option<&'static str> { + match (agent, role) { + ("claude", "worker") => Some(CLAUDE_WORKER_DEFAULT), + ("claude", "spec-writer") => Some(CLAUDE_SPEC_WRITER_DEFAULT), + _ => None, + } +} + +fn resolve_system_prompt( + root: &Path, + profile: Option<&WorkerProfileConfig>, + workers: &WorkersConfig, + agent: &str, + role: &str, +) -> Result { + // Level 1: profile.instructions if let Some(p) = profile { if let Some(ref instr_path) = p.instructions { - if let Ok(content) = std::fs::read_to_string(root.join(instr_path)) { - return content; + match std::fs::read_to_string(root.join(instr_path)) { + Ok(content) => return Ok(content), + Err(_) => bail!("[worker_profiles.*].instructions: file not found: {instr_path}"), } } } - if let Some(path) = state_instructions { - if let Ok(content) = std::fs::read_to_string(root.join(path)) { - return content; + // Level 2: workers.instructions + if let Some(ref instr_path) = workers.instructions { + match std::fs::read_to_string(root.join(instr_path)) { + Ok(content) => return Ok(content), + Err(_) => bail!("[workers].instructions: file not found: {instr_path}"), } } - let p = root.join(".apm/apm.worker.md"); - std::fs::read_to_string(p) - .unwrap_or_else(|_| "You are an APM worker agent.".to_string()) + // Level 3: .apm/agents//apm..md + let per_agent = root.join(format!(".apm/agents/{agent}/apm.{role}.md")); + if per_agent.exists() { + if let Ok(content) = std::fs::read_to_string(&per_agent) { + return Ok(content); + } + } + // Level 4: built-in default + if let Some(s) = resolve_builtin_instructions(agent, role) { + return Ok(s.to_string()); + } + // Level 5: hard error + bail!( + "no instructions found for agent '{agent}' role '{role}': \ + set [workers].instructions in .apm/config.toml or add \ + .apm/agents/{agent}/apm.{role}.md" + ) } fn agent_role_prefix(profile: Option<&WorkerProfileConfig>, id: &str) -> String { @@ -840,6 +869,7 @@ mod tests { keychain: HashMap::new(), agent: None, options: HashMap::new(), + instructions: None, } } @@ -1010,28 +1040,97 @@ mod tests { let p = dir.path(); std::fs::create_dir_all(p.join(".apm")).unwrap(); std::fs::write(p.join(".apm/spec.md"), "SPEC WRITER").unwrap(); - std::fs::write(p.join(".apm/apm.worker.md"), "WORKER").unwrap(); let profile = make_profile(Some(".apm/spec.md"), None); - assert_eq!(resolve_system_prompt(p, Some(&profile), None), "SPEC WRITER"); + let workers = WorkersConfig::default(); + assert_eq!( + resolve_system_prompt(p, Some(&profile), &workers, "claude", "worker").unwrap(), + "SPEC WRITER" + ); } #[test] - fn resolve_system_prompt_falls_back_to_state_instructions() { + fn resolve_system_prompt_uses_workers_instructions_when_no_profile() { let dir = tempfile::tempdir().unwrap(); let p = dir.path(); std::fs::create_dir_all(p.join(".apm")).unwrap(); - std::fs::write(p.join(".apm/state.md"), "STATE INSTRUCTIONS").unwrap(); - std::fs::write(p.join(".apm/apm.worker.md"), "WORKER").unwrap(); - assert_eq!(resolve_system_prompt(p, None, Some(".apm/state.md")), "STATE INSTRUCTIONS"); + std::fs::write(p.join(".apm/global.md"), "GLOBAL INSTRUCTIONS").unwrap(); + let workers = WorkersConfig { + instructions: Some(".apm/global.md".to_string()), + ..WorkersConfig::default() + }; + assert_eq!( + resolve_system_prompt(p, None, &workers, "claude", "worker").unwrap(), + "GLOBAL INSTRUCTIONS" + ); + } + + #[test] + fn resolve_system_prompt_uses_per_agent_file() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path(); + std::fs::create_dir_all(p.join(".apm/agents/claude")).unwrap(); + std::fs::write(p.join(".apm/agents/claude/apm.worker.md"), "PER AGENT WORKER").unwrap(); + let workers = WorkersConfig::default(); + assert_eq!( + resolve_system_prompt(p, None, &workers, "claude", "worker").unwrap(), + "PER AGENT WORKER" + ); + } + + #[test] + fn resolve_system_prompt_falls_back_to_builtin_default() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path(); + let workers = WorkersConfig::default(); + let result = resolve_system_prompt(p, None, &workers, "claude", "worker").unwrap(); + assert_eq!(result, super::CLAUDE_WORKER_DEFAULT); } #[test] - fn resolve_system_prompt_falls_back_to_worker_when_no_profile_no_state() { + fn resolve_system_prompt_falls_back_to_builtin_spec_writer() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path(); + let workers = WorkersConfig::default(); + let result = resolve_system_prompt(p, None, &workers, "claude", "spec-writer").unwrap(); + assert_eq!(result, super::CLAUDE_SPEC_WRITER_DEFAULT); + } + + #[test] + fn resolve_system_prompt_errors_for_unknown_agent() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path(); + let workers = WorkersConfig::default(); + let result = resolve_system_prompt(p, None, &workers, "custom-bot", "worker"); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("custom-bot"), "error should name the agent: {msg}"); + assert!(msg.contains("worker"), "error should name the role: {msg}"); + } + + #[test] + fn resolve_system_prompt_profile_instructions_missing_file_is_error() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path(); + let profile = make_profile(Some(".apm/nonexistent.md"), None); + let workers = WorkersConfig::default(); + let result = resolve_system_prompt(p, Some(&profile), &workers, "claude", "worker"); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("nonexistent.md"), "error should name the file: {msg}"); + } + + #[test] + fn resolve_system_prompt_backward_compat() { let dir = tempfile::tempdir().unwrap(); let p = dir.path(); std::fs::create_dir_all(p.join(".apm")).unwrap(); - std::fs::write(p.join(".apm/apm.worker.md"), "WORKER").unwrap(); - assert_eq!(resolve_system_prompt(p, None, None), "WORKER"); + std::fs::write(p.join(".apm/apm.worker.md"), "LEGACY WORKER CONTENT").unwrap(); + let profile = make_profile(Some(".apm/apm.worker.md"), None); + let workers = WorkersConfig::default(); + assert_eq!( + resolve_system_prompt(p, Some(&profile), &workers, "claude", "worker").unwrap(), + "LEGACY WORKER CONTENT" + ); } // --- agent_role_prefix --- diff --git a/apm-core/src/validate.rs b/apm-core/src/validate.rs index 54a200d25..e9ac8741a 100644 --- a/apm-core/src/validate.rs +++ b/apm-core/src/validate.rs @@ -369,6 +369,14 @@ pub fn validate_config(config: &Config, root: &Path) -> Vec { } } + if let Some(ref path) = config.workers.instructions { + if !root.join(path).exists() { + errors.push(format!( + "config: [workers].instructions — file not found: {path}" + )); + } + } + if !is_external_worktree(&config.worktrees.dir) { let dir_str = config.worktrees.dir.to_string_lossy(); let gitignore = root.join(".gitignore"); From 1d9d70c8480016948fc559005390ac8b7e91d5af Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:26:07 -0700 Subject: [PATCH 261/305] ticket(7f5f73d5): mark "When `[worker_profiles.

].instructions` is set and the referenced file exists, its content is used as the system prompt." in Acceptance criteria --- tickets/7f5f73d5-per-agent-instructions-resolution-under-.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md index 58c492deb..e6b75d56b 100644 --- a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md +++ b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md @@ -34,7 +34,7 @@ The silent hardcoded fallback and the `StateConfig.instructions`-as-system-promp ### Acceptance criteria -- [ ] When `[worker_profiles.

].instructions` is set and the referenced file exists, its content is used as the system prompt. +- [x] When `[worker_profiles.

].instructions` is set and the referenced file exists, its content is used as the system prompt. - [ ] When `[worker_profiles.

].instructions` is absent (or the profile is not resolved) and `[workers].instructions` is set and the referenced file exists, its content is used as the system prompt. - [ ] When neither profile nor global `[workers].instructions` resolves and `.apm/agents//apm..md` exists in the project, its content is used as the system prompt. - [ ] When the first three levels all fail and agent A is the `claude` built-in, APM's bundled default for `apm..md` (compiled in via `include_str!`) is used as the system prompt. @@ -183,4 +183,4 @@ Update the three existing `resolve_system_prompt_*` tests to match the new signa | 2026-04-30T21:42Z | groomed | in_design | philippepascal | | 2026-04-30T21:50Z | in_design | specd | claude-0430-2142-eea0 | | 2026-05-01T17:38Z | specd | ready | philippepascal | -| 2026-05-01T19:20Z | ready | in_progress | philippepascal | +| 2026-05-01T19:20Z | ready | in_progress | philippepascal | \ No newline at end of file From 5e0d3adad50fa14f806badc04b74f4357f4dc43e Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:26:11 -0700 Subject: [PATCH 262/305] ticket(7f5f73d5): mark "When `[worker_profiles.

].instructions` is absent (or the profile is not resolved) and `[workers].instructions` is set and the referenced file exists, its content is used as the system prompt." in Acceptance criteria --- tickets/7f5f73d5-per-agent-instructions-resolution-under-.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md index e6b75d56b..c177519f7 100644 --- a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md +++ b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md @@ -35,7 +35,7 @@ The silent hardcoded fallback and the `StateConfig.instructions`-as-system-promp ### Acceptance criteria - [x] When `[worker_profiles.

].instructions` is set and the referenced file exists, its content is used as the system prompt. -- [ ] When `[worker_profiles.

].instructions` is absent (or the profile is not resolved) and `[workers].instructions` is set and the referenced file exists, its content is used as the system prompt. +- [x] When `[worker_profiles.

].instructions` is absent (or the profile is not resolved) and `[workers].instructions` is set and the referenced file exists, its content is used as the system prompt. - [ ] When neither profile nor global `[workers].instructions` resolves and `.apm/agents//apm..md` exists in the project, its content is used as the system prompt. - [ ] When the first three levels all fail and agent A is the `claude` built-in, APM's bundled default for `apm..md` (compiled in via `include_str!`) is used as the system prompt. - [ ] When all four levels fail (custom agent, no project file, no config override), `apm start` exits with a descriptive error message that names the agent and role; no silent fallback occurs. From 2c4fdca2fde120d7b1ba9d21263de818604e88d3 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:26:17 -0700 Subject: [PATCH 263/305] ticket(7f5f73d5): mark "When neither profile nor global `[workers].instructions` resolves and `.apm/agents//apm..md` exists in the project, its content is used as the system prompt." in Acceptance criteria --- tickets/7f5f73d5-per-agent-instructions-resolution-under-.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md index c177519f7..dbcc22741 100644 --- a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md +++ b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md @@ -36,7 +36,7 @@ The silent hardcoded fallback and the `StateConfig.instructions`-as-system-promp - [x] When `[worker_profiles.

].instructions` is set and the referenced file exists, its content is used as the system prompt. - [x] When `[worker_profiles.

].instructions` is absent (or the profile is not resolved) and `[workers].instructions` is set and the referenced file exists, its content is used as the system prompt. -- [ ] When neither profile nor global `[workers].instructions` resolves and `.apm/agents//apm..md` exists in the project, its content is used as the system prompt. +- [x] When neither profile nor global `[workers].instructions` resolves and `.apm/agents//apm..md` exists in the project, its content is used as the system prompt. - [ ] When the first three levels all fail and agent A is the `claude` built-in, APM's bundled default for `apm..md` (compiled in via `include_str!`) is used as the system prompt. - [ ] When all four levels fail (custom agent, no project file, no config override), `apm start` exits with a descriptive error message that names the agent and role; no silent fallback occurs. - [ ] An existing project whose config has `[worker_profiles.spec_agent] instructions = ".apm/apm.spec-writer.md"` continues to work without any config edits. From bd9bc951a16f1d1d8b1300b78be4080822836968 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:26:23 -0700 Subject: [PATCH 264/305] ticket(7f5f73d5): mark "When the first three levels all fail and agent A is the `claude` built-in, APM's bundled default for `apm..md` (compiled in via `include_str!`) is used as the system prompt." in Acceptance criteria --- tickets/7f5f73d5-per-agent-instructions-resolution-under-.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md index dbcc22741..600b77755 100644 --- a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md +++ b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md @@ -37,7 +37,7 @@ The silent hardcoded fallback and the `StateConfig.instructions`-as-system-promp - [x] When `[worker_profiles.

].instructions` is set and the referenced file exists, its content is used as the system prompt. - [x] When `[worker_profiles.

].instructions` is absent (or the profile is not resolved) and `[workers].instructions` is set and the referenced file exists, its content is used as the system prompt. - [x] When neither profile nor global `[workers].instructions` resolves and `.apm/agents//apm..md` exists in the project, its content is used as the system prompt. -- [ ] When the first three levels all fail and agent A is the `claude` built-in, APM's bundled default for `apm..md` (compiled in via `include_str!`) is used as the system prompt. +- [x] When the first three levels all fail and agent A is the `claude` built-in, APM's bundled default for `apm..md` (compiled in via `include_str!`) is used as the system prompt. - [ ] When all four levels fail (custom agent, no project file, no config override), `apm start` exits with a descriptive error message that names the agent and role; no silent fallback occurs. - [ ] An existing project whose config has `[worker_profiles.spec_agent] instructions = ".apm/apm.spec-writer.md"` continues to work without any config edits. - [ ] An existing project whose config has `[worker_profiles.impl_agent] instructions = ".apm/apm.worker.md"` continues to work without any config edits. From bf7d65aa83970587552e064902eda5da8721b4c5 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:26:29 -0700 Subject: [PATCH 265/305] ticket(7f5f73d5): mark "When all four levels fail (custom agent, no project file, no config override), `apm start` exits with a descriptive error message that names the agent and role; no silent fallback occurs." in Acceptance criteria --- tickets/7f5f73d5-per-agent-instructions-resolution-under-.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md index 600b77755..12d8cb0fe 100644 --- a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md +++ b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md @@ -38,7 +38,7 @@ The silent hardcoded fallback and the `StateConfig.instructions`-as-system-promp - [x] When `[worker_profiles.

].instructions` is absent (or the profile is not resolved) and `[workers].instructions` is set and the referenced file exists, its content is used as the system prompt. - [x] When neither profile nor global `[workers].instructions` resolves and `.apm/agents//apm..md` exists in the project, its content is used as the system prompt. - [x] When the first three levels all fail and agent A is the `claude` built-in, APM's bundled default for `apm..md` (compiled in via `include_str!`) is used as the system prompt. -- [ ] When all four levels fail (custom agent, no project file, no config override), `apm start` exits with a descriptive error message that names the agent and role; no silent fallback occurs. +- [x] When all four levels fail (custom agent, no project file, no config override), `apm start` exits with a descriptive error message that names the agent and role; no silent fallback occurs. - [ ] An existing project whose config has `[worker_profiles.spec_agent] instructions = ".apm/apm.spec-writer.md"` continues to work without any config edits. - [ ] An existing project whose config has `[worker_profiles.impl_agent] instructions = ".apm/apm.worker.md"` continues to work without any config edits. - [ ] `apm validate` reports a config error when `[workers].instructions` is set but the referenced file does not exist on disk. From 33c716d96c26ca48e430dfdd9b8434d3c011bb57 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:26:34 -0700 Subject: [PATCH 266/305] ticket(7f5f73d5): mark "An existing project whose config has `[worker_profiles.spec_agent] instructions = ".apm/apm.spec-writer.md"` continues to work without any config edits." in Acceptance criteria --- tickets/7f5f73d5-per-agent-instructions-resolution-under-.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md index 12d8cb0fe..6211d227e 100644 --- a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md +++ b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md @@ -39,7 +39,7 @@ The silent hardcoded fallback and the `StateConfig.instructions`-as-system-promp - [x] When neither profile nor global `[workers].instructions` resolves and `.apm/agents//apm..md` exists in the project, its content is used as the system prompt. - [x] When the first three levels all fail and agent A is the `claude` built-in, APM's bundled default for `apm..md` (compiled in via `include_str!`) is used as the system prompt. - [x] When all four levels fail (custom agent, no project file, no config override), `apm start` exits with a descriptive error message that names the agent and role; no silent fallback occurs. -- [ ] An existing project whose config has `[worker_profiles.spec_agent] instructions = ".apm/apm.spec-writer.md"` continues to work without any config edits. +- [x] An existing project whose config has `[worker_profiles.spec_agent] instructions = ".apm/apm.spec-writer.md"` continues to work without any config edits. - [ ] An existing project whose config has `[worker_profiles.impl_agent] instructions = ".apm/apm.worker.md"` continues to work without any config edits. - [ ] `apm validate` reports a config error when `[workers].instructions` is set but the referenced file does not exist on disk. - [ ] `apm validate` does not regress on the existing check for `[worker_profiles.

].instructions` pointing to a missing file. From 2dfd015b253f8acb5a5024ebcbdb82cda39b2cc4 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:26:38 -0700 Subject: [PATCH 267/305] ticket(7f5f73d5): mark "An existing project whose config has `[worker_profiles.impl_agent] instructions = ".apm/apm.worker.md"` continues to work without any config edits." in Acceptance criteria --- tickets/7f5f73d5-per-agent-instructions-resolution-under-.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md index 6211d227e..39909b811 100644 --- a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md +++ b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md @@ -40,7 +40,7 @@ The silent hardcoded fallback and the `StateConfig.instructions`-as-system-promp - [x] When the first three levels all fail and agent A is the `claude` built-in, APM's bundled default for `apm..md` (compiled in via `include_str!`) is used as the system prompt. - [x] When all four levels fail (custom agent, no project file, no config override), `apm start` exits with a descriptive error message that names the agent and role; no silent fallback occurs. - [x] An existing project whose config has `[worker_profiles.spec_agent] instructions = ".apm/apm.spec-writer.md"` continues to work without any config edits. -- [ ] An existing project whose config has `[worker_profiles.impl_agent] instructions = ".apm/apm.worker.md"` continues to work without any config edits. +- [x] An existing project whose config has `[worker_profiles.impl_agent] instructions = ".apm/apm.worker.md"` continues to work without any config edits. - [ ] `apm validate` reports a config error when `[workers].instructions` is set but the referenced file does not exist on disk. - [ ] `apm validate` does not regress on the existing check for `[worker_profiles.

].instructions` pointing to a missing file. - [ ] Both `apm.worker.md` and `apm.spec-writer.md` are compiled into the binary for the `claude` built-in (reachable at level 4 without any file on disk). From baf79298c1cc6a3f8e472886d88c806b6ae2f992 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:26:43 -0700 Subject: [PATCH 268/305] ticket(7f5f73d5): mark "`apm validate` reports a config error when `[workers].instructions` is set but the referenced file does not exist on disk." in Acceptance criteria --- tickets/7f5f73d5-per-agent-instructions-resolution-under-.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md index 39909b811..c0dd7d15d 100644 --- a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md +++ b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md @@ -41,7 +41,7 @@ The silent hardcoded fallback and the `StateConfig.instructions`-as-system-promp - [x] When all four levels fail (custom agent, no project file, no config override), `apm start` exits with a descriptive error message that names the agent and role; no silent fallback occurs. - [x] An existing project whose config has `[worker_profiles.spec_agent] instructions = ".apm/apm.spec-writer.md"` continues to work without any config edits. - [x] An existing project whose config has `[worker_profiles.impl_agent] instructions = ".apm/apm.worker.md"` continues to work without any config edits. -- [ ] `apm validate` reports a config error when `[workers].instructions` is set but the referenced file does not exist on disk. +- [x] `apm validate` reports a config error when `[workers].instructions` is set but the referenced file does not exist on disk. - [ ] `apm validate` does not regress on the existing check for `[worker_profiles.

].instructions` pointing to a missing file. - [ ] Both `apm.worker.md` and `apm.spec-writer.md` are compiled into the binary for the `claude` built-in (reachable at level 4 without any file on disk). - [ ] The role (`worker` or `spec-writer`) is read from `WorkerProfileConfig.role` (defaults to `"worker"` when absent); the spec_agent profile in the `apm init` default config sets `role = "spec-writer"`. From a059c04dee66a9492e93d194c7860af3d1b0ad20 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:26:47 -0700 Subject: [PATCH 269/305] ticket(7f5f73d5): mark "`apm validate` does not regress on the existing check for `[worker_profiles.

].instructions` pointing to a missing file." in Acceptance criteria --- tickets/7f5f73d5-per-agent-instructions-resolution-under-.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md index c0dd7d15d..9d9634037 100644 --- a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md +++ b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md @@ -42,7 +42,7 @@ The silent hardcoded fallback and the `StateConfig.instructions`-as-system-promp - [x] An existing project whose config has `[worker_profiles.spec_agent] instructions = ".apm/apm.spec-writer.md"` continues to work without any config edits. - [x] An existing project whose config has `[worker_profiles.impl_agent] instructions = ".apm/apm.worker.md"` continues to work without any config edits. - [x] `apm validate` reports a config error when `[workers].instructions` is set but the referenced file does not exist on disk. -- [ ] `apm validate` does not regress on the existing check for `[worker_profiles.

].instructions` pointing to a missing file. +- [x] `apm validate` does not regress on the existing check for `[worker_profiles.

].instructions` pointing to a missing file. - [ ] Both `apm.worker.md` and `apm.spec-writer.md` are compiled into the binary for the `claude` built-in (reachable at level 4 without any file on disk). - [ ] The role (`worker` or `spec-writer`) is read from `WorkerProfileConfig.role` (defaults to `"worker"` when absent); the spec_agent profile in the `apm init` default config sets `role = "spec-writer"`. - [ ] Unit tests cover all five levels of the chain independently. From 33f87d55fe5e9ebde6a5b4b9c9f2e7d87fe3ac23 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:26:52 -0700 Subject: [PATCH 270/305] ticket(7f5f73d5): mark "Both `apm.worker.md` and `apm.spec-writer.md` are compiled into the binary for the `claude` built-in (reachable at level 4 without any file on disk)." in Acceptance criteria --- tickets/7f5f73d5-per-agent-instructions-resolution-under-.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md index 9d9634037..c00d75ae6 100644 --- a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md +++ b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md @@ -43,7 +43,7 @@ The silent hardcoded fallback and the `StateConfig.instructions`-as-system-promp - [x] An existing project whose config has `[worker_profiles.impl_agent] instructions = ".apm/apm.worker.md"` continues to work without any config edits. - [x] `apm validate` reports a config error when `[workers].instructions` is set but the referenced file does not exist on disk. - [x] `apm validate` does not regress on the existing check for `[worker_profiles.

].instructions` pointing to a missing file. -- [ ] Both `apm.worker.md` and `apm.spec-writer.md` are compiled into the binary for the `claude` built-in (reachable at level 4 without any file on disk). +- [x] Both `apm.worker.md` and `apm.spec-writer.md` are compiled into the binary for the `claude` built-in (reachable at level 4 without any file on disk). - [ ] The role (`worker` or `spec-writer`) is read from `WorkerProfileConfig.role` (defaults to `"worker"` when absent); the spec_agent profile in the `apm init` default config sets `role = "spec-writer"`. - [ ] Unit tests cover all five levels of the chain independently. From 0adf4983d06838a256f3f0c931720f79abbcae94 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:26:58 -0700 Subject: [PATCH 271/305] ticket(7f5f73d5): mark "The role (`worker` or `spec-writer`) is read from `WorkerProfileConfig.role` (defaults to `"worker"` when absent); the spec_agent profile in the `apm init` default config sets `role = "spec-writer"`." in Acceptance criteria --- tickets/7f5f73d5-per-agent-instructions-resolution-under-.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md index c00d75ae6..c18bd32ab 100644 --- a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md +++ b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md @@ -44,7 +44,7 @@ The silent hardcoded fallback and the `StateConfig.instructions`-as-system-promp - [x] `apm validate` reports a config error when `[workers].instructions` is set but the referenced file does not exist on disk. - [x] `apm validate` does not regress on the existing check for `[worker_profiles.

].instructions` pointing to a missing file. - [x] Both `apm.worker.md` and `apm.spec-writer.md` are compiled into the binary for the `claude` built-in (reachable at level 4 without any file on disk). -- [ ] The role (`worker` or `spec-writer`) is read from `WorkerProfileConfig.role` (defaults to `"worker"` when absent); the spec_agent profile in the `apm init` default config sets `role = "spec-writer"`. +- [x] The role (`worker` or `spec-writer`) is read from `WorkerProfileConfig.role` (defaults to `"worker"` when absent); the spec_agent profile in the `apm init` default config sets `role = "spec-writer"`. - [ ] Unit tests cover all five levels of the chain independently. ### Out of scope From 924d3862447c2d2c99a08830d59fd9b579136e43 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:27:02 -0700 Subject: [PATCH 272/305] ticket(7f5f73d5): mark "Unit tests cover all five levels of the chain independently." in Acceptance criteria --- tickets/7f5f73d5-per-agent-instructions-resolution-under-.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md index c18bd32ab..3ee0e6b77 100644 --- a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md +++ b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md @@ -45,7 +45,7 @@ The silent hardcoded fallback and the `StateConfig.instructions`-as-system-promp - [x] `apm validate` does not regress on the existing check for `[worker_profiles.

].instructions` pointing to a missing file. - [x] Both `apm.worker.md` and `apm.spec-writer.md` are compiled into the binary for the `claude` built-in (reachable at level 4 without any file on disk). - [x] The role (`worker` or `spec-writer`) is read from `WorkerProfileConfig.role` (defaults to `"worker"` when absent); the spec_agent profile in the `apm init` default config sets `role = "spec-writer"`. -- [ ] Unit tests cover all five levels of the chain independently. +- [x] Unit tests cover all five levels of the chain independently. ### Out of scope From 7e6a3e6f3101077b387e2980bce470682a4dcfdf Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:27:08 -0700 Subject: [PATCH 273/305] =?UTF-8?q?ticket(7f5f73d5):=20in=5Fprogress=20?= =?UTF-8?q?=E2=86=92=20implemented?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../7f5f73d5-per-agent-instructions-resolution-under-.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md index 3ee0e6b77..d9366a7f9 100644 --- a/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md +++ b/tickets/7f5f73d5-per-agent-instructions-resolution-under-.md @@ -1,7 +1,7 @@ +++ id = "7f5f73d5" title = "Per-agent instructions resolution under .apm/agents//" -state = "in_progress" +state = "implemented" priority = 0 effort = 4 risk = 2 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/7f5f73d5-per-agent-instructions-resolution-under-" created_at = "2026-04-30T20:03:33.687625Z" -updated_at = "2026-05-01T19:20:30.592124Z" +updated_at = "2026-05-01T19:27:08.195495Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "2c32a282"] @@ -183,4 +183,5 @@ Update the three existing `resolve_system_prompt_*` tests to match the new signa | 2026-04-30T21:42Z | groomed | in_design | philippepascal | | 2026-04-30T21:50Z | in_design | specd | claude-0430-2142-eea0 | | 2026-05-01T17:38Z | specd | ready | philippepascal | -| 2026-05-01T19:20Z | ready | in_progress | philippepascal | \ No newline at end of file +| 2026-05-01T19:20Z | ready | in_progress | philippepascal | +| 2026-05-01T19:27Z | in_progress | implemented | claude-0501-1920-80d0 | From d06f4610a59c7bba7261179d4b06a3465617028c Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:27:25 -0700 Subject: [PATCH 274/305] =?UTF-8?q?ticket(71d80e40):=20start=20=E2=80=94?= =?UTF-8?q?=20ready=20=E2=86=92=20in=5Fprogress?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index e59140652..039b0c770 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -1,7 +1,7 @@ +++ id = "71d80e40" title = "apm agents subcommand: new, list, test, eject" -state = "ready" +state = "in_progress" priority = 0 effort = 5 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/71d80e40-apm-agents-subcommand-new-list-test-ejec" created_at = "2026-04-30T20:04:57.796154Z" -updated_at = "2026-05-01T17:38:17.525160Z" +updated_at = "2026-05-01T19:27:25.670266Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "2c32a282"] @@ -291,3 +291,4 @@ Update `hash_trip::is_read_only_command` if `Agents` is listed: `List` and `Test | 2026-05-01T01:32Z | ammend | in_design | philippepascal | | 2026-05-01T01:36Z | in_design | specd | claude-0501-0132-6a28 | | 2026-05-01T17:38Z | specd | ready | philippepascal | +| 2026-05-01T19:27Z | ready | in_progress | philippepascal | From 18d66e51109f029b3e40ff73ef3525fa39c1a223 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:38:49 -0700 Subject: [PATCH 275/305] Add apm agents subcommand: list, new, test, eject --- apm-core/src/agents.rs | 317 +++++++++++++++++++++++++++ apm-core/src/lib.rs | 1 + apm-core/src/wrapper/mod.rs | 4 + apm-core/tests/agents_integration.rs | 271 +++++++++++++++++++++++ apm/src/cmd/agents.rs | 77 ++++++- apm/src/hash_trip.rs | 2 + apm/src/main.rs | 47 ++-- apm/tests/integration.rs | 13 +- 8 files changed, 700 insertions(+), 32 deletions(-) create mode 100644 apm-core/src/agents.rs create mode 100644 apm-core/tests/agents_integration.rs diff --git a/apm-core/src/agents.rs b/apm-core/src/agents.rs new file mode 100644 index 000000000..44116a7fc --- /dev/null +++ b/apm-core/src/agents.rs @@ -0,0 +1,317 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use anyhow::Result; +use crate::wrapper::{self, Wrapper, WrapperContext, WrapperKind}; +use crate::wrapper::custom::CustomWrapper; +use crate::config::Config; + +pub struct WrapperEntry { + pub name: String, + pub kind: WrapperKind, + pub parser: String, + pub configured_as: Vec, +} + +#[derive(Debug)] +pub struct TestReport { + pub exit_code: i32, + pub canonical_events: usize, + pub non_canonical_lines: usize, + pub stderr_lines: usize, + pub wall_millis: u64, + pub passed: bool, +} + +const MANIFEST_TEMPLATE: &str = + "[wrapper]\ncontract_version = 1\nparser = \"canonical\"\n"; + +const WRAPPER_TEMPLATE: &str = r#"#!/usr/bin/env bash +# APM wrapper skeleton +# +# Environment variables provided by APM: +# APM_AGENT_NAME - name of this worker (from config) +# APM_TICKET_ID - 8-char hex ticket ID +# APM_TICKET_BRANCH - git branch for this ticket +# APM_TICKET_WORKTREE - absolute path to the ticket worktree +# APM_SYSTEM_PROMPT_FILE - path to a file containing the system prompt +# APM_USER_MESSAGE_FILE - path to a file containing the user message (ticket content) +# APM_SKIP_PERMISSIONS - "1" if --dangerously-skip-permissions should be passed; "0" otherwise +# APM_PROFILE - active worker profile name +# APM_ROLE_PREFIX - optional role label prepended to the worker identity +# APM_WRAPPER_VERSION - contract version this APM build implements (currently "1") +# APM_BIN - absolute path to the running apm binary +# APM_OPT_* - key-value options from [workers.options] in config.toml +# +# Contract: +# stdout - emit JSONL events (one JSON object per line, each with a "type" key) +# stderr - free-form log output (not parsed by APM) +# exit 0 - success; non-zero signals failure +# +set -euo pipefail + +# Dump all APM_* env vars to stderr for debugging +env | grep '^APM_' >&2 || true + +# Read inputs +SYSTEM_PROMPT="$(cat "$APM_SYSTEM_PROMPT_FILE")" +USER_MESSAGE="$(cat "$APM_USER_MESSAGE_FILE")" + +# TODO: replace this printf with a real agent invocation that: +# 1. Sends SYSTEM_PROMPT + USER_MESSAGE to your AI tool +# 2. Emits JSONL events on stdout as the tool runs +printf '{"type":"text","text":"wrapper skeleton -- replace with real invocation"}\n' + +# TODO: when the agent finishes, transition the ticket: +# apm state "$APM_TICKET_ID" + +exit 0 +"#; + +const CLAUDE_EJECT_SCRIPT: &str = r#"#!/usr/bin/env bash +# Ejected from APM built-in: claude +set -euo pipefail + +ARGS=(--print --output-format stream-json --verbose) + +ARGS+=(--system-prompt "$(cat "$APM_SYSTEM_PROMPT_FILE")") + +if [[ -n "${APM_OPT_MODEL:-}" ]]; then + ARGS+=(--model "$APM_OPT_MODEL") +fi + +if [[ "${APM_SKIP_PERMISSIONS:-0}" == "1" ]]; then + ARGS+=(--dangerously-skip-permissions) +fi + +exec claude "${ARGS[@]}" "$(cat "$APM_USER_MESSAGE_FILE")" +"#; + +const DEFAULT_WORKER_MD: &str = include_str!("default/apm.worker.md"); +const DEFAULT_SPEC_WRITER_MD: &str = include_str!("default/apm.spec-writer.md"); + +fn rand_u16() -> u16 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .subsec_nanos() as u16 +} + +pub fn list_wrappers(root: &Path, config: &Config) -> Result> { + let mut entries: Vec = Vec::new(); + + // Built-in entries + for name in wrapper::list_builtin_names() { + entries.push(WrapperEntry { + name: name.to_string(), + kind: WrapperKind::Builtin(name.to_string()), + parser: "canonical".to_string(), + configured_as: vec![], + }); + } + + // Project entries from .apm/agents/ + let agents_dir = root.join(".apm").join("agents"); + if agents_dir.is_dir() { + let rd = match std::fs::read_dir(&agents_dir) { + Ok(rd) => rd, + Err(_) => return Ok(entries), + }; + let mut names: Vec = rd + .filter_map(|e| e.ok()) + .filter(|e| e.path().is_dir()) + .filter_map(|e| e.file_name().into_string().ok()) + .collect(); + names.sort(); + + for entry_name in names { + if let Ok(Some(WrapperKind::Custom { script_path, manifest })) = + wrapper::resolve_wrapper(root, &entry_name) + { + let parser = manifest + .as_ref() + .map(|m| m.parser.clone()) + .unwrap_or_else(|| "canonical".to_string()); + entries.push(WrapperEntry { + name: entry_name, + kind: WrapperKind::Custom { script_path, manifest }, + parser, + configured_as: vec![], + }); + } + } + } + + // Configured marker + // TODO post-6cac8518: switch to config.workers.agent and iterate + // config.worker_profiles for per-profile markers. + let configured_name = config.workers.command.as_deref().unwrap_or("claude"); + for entry in &mut entries { + if entry.name == configured_name { + entry.configured_as.push("(configured)".to_string()); + } + } + + Ok(entries) +} + +pub fn scaffold_wrapper(root: &Path, name: &str, force: bool) -> Result<()> { + let dir = root.join(".apm").join("agents").join(name); + if dir.exists() && !force { + anyhow::bail!(".apm/agents/{name}/ already exists; use --force to overwrite"); + } + std::fs::create_dir_all(&dir)?; + + // Write wrapper.sh + let wrapper_path = dir.join("wrapper.sh"); + std::fs::write(&wrapper_path, WRAPPER_TEMPLATE)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&wrapper_path, std::fs::Permissions::from_mode(0o755))?; + } + + // Write manifest.toml + std::fs::write(dir.join("manifest.toml"), MANIFEST_TEMPLATE)?; + + // Write apm.worker.md + let worker_md = std::fs::read_to_string(root.join(".apm").join("apm.worker.md")) + .unwrap_or_else(|_| DEFAULT_WORKER_MD.to_string()); + std::fs::write(dir.join("apm.worker.md"), &worker_md)?; + + // Write apm.spec-writer.md + let spec_writer_md = + std::fs::read_to_string(root.join(".apm").join("apm.spec-writer.md")) + .unwrap_or_else(|_| DEFAULT_SPEC_WRITER_MD.to_string()); + std::fs::write(dir.join("apm.spec-writer.md"), &spec_writer_md)?; + + Ok(()) +} + +pub fn test_wrapper(root: &Path, name: &str) -> Result { + let kind = wrapper::resolve_wrapper(root, name)?.ok_or_else(|| { + anyhow::anyhow!( + "agent '{}' not found: checked built-ins and .apm/agents/{}/", + name, + name + ) + })?; + + let tmp: PathBuf = + std::env::temp_dir().join(format!("apm-agents-test-{:04x}", rand_u16())); + std::fs::create_dir_all(&tmp)?; + + let sys_file = tmp.join("system.txt"); + let msg_file = tmp.join("message.txt"); + let log_path = tmp.join("wrapper.log"); + + std::fs::write(&sys_file, "You are a test agent.")?; + std::fs::write(&msg_file, "Test run -- apm agents test.")?; + + let ctx = WrapperContext { + worker_name: "agents-test".to_string(), + ticket_id: "00000000".to_string(), + ticket_branch: "test/agents-test".to_string(), + worktree_path: tmp.clone(), + system_prompt_file: sys_file, + user_message_file: msg_file, + skip_permissions: false, + profile: "test".to_string(), + role_prefix: None, + options: HashMap::new(), + model: None, + log_path: log_path.clone(), + container: None, + extra_env: HashMap::new(), + root: root.to_path_buf(), + keychain: HashMap::new(), + }; + + let start = std::time::Instant::now(); + let mut child = match kind { + WrapperKind::Custom { script_path, manifest } => { + CustomWrapper { script_path, manifest }.spawn(&ctx)? + } + WrapperKind::Builtin(n) => { + wrapper::resolve_builtin(&n) + .expect("registered builtin") + .spawn(&ctx)? + } + }; + + let status = child.wait()?; + let wall_millis = start.elapsed().as_millis() as u64; + let exit_code = status.code().unwrap_or(-1); + + // Classify log lines + let log_content = std::fs::read_to_string(&log_path).unwrap_or_default(); + let mut canonical_events = 0usize; + let mut non_canonical_lines = 0usize; + let mut stderr_lines = 0usize; + + for line in log_content.lines() { + if line.is_empty() { + continue; + } + if line.starts_with("APM_") { + stderr_lines += 1; + } else if let Ok(val) = serde_json::from_str::(line) { + if val.get("type").is_some() { + canonical_events += 1; + } else { + non_canonical_lines += 1; + } + } else { + non_canonical_lines += 1; + } + } + + let passed = status.success() && canonical_events >= 1; + let report = TestReport { + exit_code, + canonical_events, + non_canonical_lines, + stderr_lines, + wall_millis, + passed, + }; + + let _ = std::fs::remove_dir_all(&tmp); + + Ok(report) +} + +pub fn eject_wrapper(root: &Path, name: &str) -> Result<()> { + if wrapper::resolve_builtin(name).is_none() { + anyhow::bail!( + "'{}' is not a known built-in; run apm agents list to see available wrappers", + name + ); + } + + let dir = root.join(".apm").join("agents").join(name); + if dir.exists() { + anyhow::bail!(".apm/agents/{name}/ already exists; delete it first to eject again"); + } + + std::fs::create_dir_all(&dir)?; + + let script_content = match name { + "claude" => CLAUDE_EJECT_SCRIPT, + other => anyhow::bail!("eject not yet implemented for built-in {}", other), + }; + let script_path = dir.join("wrapper.sh"); + std::fs::write(&script_path, script_content)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755))?; + } + + // Write manifest.toml — intentionally the same template as scaffold_wrapper: + // recognised as v1-canonical by 2c32a282's manifest parser and 2e772eab's version check, + // so the ejected script requires no extra setup. + std::fs::write(dir.join("manifest.toml"), MANIFEST_TEMPLATE)?; + + Ok(()) +} diff --git a/apm-core/src/lib.rs b/apm-core/src/lib.rs index 347cef12a..55573ffcc 100644 --- a/apm-core/src/lib.rs +++ b/apm-core/src/lib.rs @@ -1,4 +1,5 @@ #![deny(clippy::print_stdout, clippy::print_stderr)] +pub mod agents; pub mod archive; pub mod wrapper; pub mod help_schema; diff --git a/apm-core/src/wrapper/mod.rs b/apm-core/src/wrapper/mod.rs index b7157d40f..303a86576 100644 --- a/apm-core/src/wrapper/mod.rs +++ b/apm-core/src/wrapper/mod.rs @@ -38,6 +38,10 @@ pub fn resolve_builtin(name: &str) -> Option> { } } +pub fn list_builtin_names() -> &'static [&'static str] { + &["claude"] +} + pub fn resolve_wrapper(root: &Path, name: &str) -> anyhow::Result> { if let Some(script_path) = custom::find_script(root, name) { let manifest = custom::parse_manifest(root, name)?; diff --git a/apm-core/tests/agents_integration.rs b/apm-core/tests/agents_integration.rs new file mode 100644 index 000000000..52a3e6c92 --- /dev/null +++ b/apm-core/tests/agents_integration.rs @@ -0,0 +1,271 @@ +use apm_core::agents::{list_wrappers, scaffold_wrapper, test_wrapper, eject_wrapper}; +use apm_core::config::Config; +use apm_core::wrapper::WrapperKind; + +/// Write a minimal config.toml so Config::load succeeds in the temp dir. +fn write_minimal_config(root: &std::path::Path) { + let apm_dir = root.join(".apm"); + std::fs::create_dir_all(&apm_dir).unwrap(); + std::fs::write( + apm_dir.join("config.toml"), + "[project]\nname = \"test\"\n", + ) + .unwrap(); +} + +/// Create an executable shell script at `path`. +#[cfg(unix)] +fn make_executable(path: &std::path::Path, content: &str) { + use std::os::unix::fs::PermissionsExt; + std::fs::write(path, content).unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); +} + +// -------------------------------------------------------------------------- +// list tests +// -------------------------------------------------------------------------- + +#[test] +fn list_shows_builtin_claude() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write_minimal_config(root); + let config = Config::load(root).unwrap(); + + let entries = list_wrappers(root, &config).unwrap(); + let claude = entries.iter().find(|e| e.name == "claude"); + assert!(claude.is_some(), "claude entry not found"); + assert!( + matches!(claude.unwrap().kind, WrapperKind::Builtin(_)), + "expected Builtin kind for claude" + ); +} + +#[test] +fn list_shows_project_wrapper() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write_minimal_config(root); + + // Create .apm/agents/my-wrapper/wrapper.sh (mode 0o755) + let agent_dir = root.join(".apm").join("agents").join("my-wrapper"); + std::fs::create_dir_all(&agent_dir).unwrap(); + #[cfg(unix)] + make_executable(&agent_dir.join("wrapper.sh"), "#!/bin/sh\nexit 0\n"); + #[cfg(not(unix))] + std::fs::write(agent_dir.join("wrapper.sh"), "exit 0\n").unwrap(); + + let config = Config::load(root).unwrap(); + let entries = list_wrappers(root, &config).unwrap(); + let mw = entries.iter().find(|e| e.name == "my-wrapper"); + assert!(mw.is_some(), "my-wrapper entry not found"); + assert!( + matches!(mw.unwrap().kind, WrapperKind::Custom { .. }), + "expected Custom kind for my-wrapper" + ); +} + +// -------------------------------------------------------------------------- +// scaffold tests +// -------------------------------------------------------------------------- + +#[test] +fn scaffold_creates_all_files() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write_minimal_config(root); + + scaffold_wrapper(root, "test-wrap", false).unwrap(); + + let agent_dir = root.join(".apm").join("agents").join("test-wrap"); + assert!(agent_dir.join("wrapper.sh").exists(), "wrapper.sh missing"); + assert!(agent_dir.join("manifest.toml").exists(), "manifest.toml missing"); + assert!(agent_dir.join("apm.worker.md").exists(), "apm.worker.md missing"); + assert!(agent_dir.join("apm.spec-writer.md").exists(), "apm.spec-writer.md missing"); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let meta = std::fs::metadata(agent_dir.join("wrapper.sh")).unwrap(); + assert!( + meta.permissions().mode() & 0o111 != 0, + "wrapper.sh must be executable" + ); + } +} + +#[test] +fn scaffold_refuses_existing_dir() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write_minimal_config(root); + + scaffold_wrapper(root, "dup-wrap", false).unwrap(); + let err = scaffold_wrapper(root, "dup-wrap", false).unwrap_err(); + assert!( + err.to_string().contains("--force"), + "error must mention --force: {err}" + ); +} + +#[test] +fn scaffold_force_overwrites() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write_minimal_config(root); + + scaffold_wrapper(root, "over-wrap", false).unwrap(); + // Second call with force = true must succeed + scaffold_wrapper(root, "over-wrap", true).unwrap(); +} + +// -------------------------------------------------------------------------- +// test_wrapper tests +// -------------------------------------------------------------------------- + +#[cfg(unix)] +#[test] +fn test_passes_for_good_script() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write_minimal_config(root); + + let agent_dir = root.join(".apm").join("agents").join("test-ok"); + std::fs::create_dir_all(&agent_dir).unwrap(); + make_executable( + &agent_dir.join("wrapper.sh"), + "#!/bin/sh\nprintf '{\"type\":\"text\",\"text\":\"ok\"}\\n'\nexit 0\n", + ); + + let report = test_wrapper(root, "test-ok").unwrap(); + assert!(report.passed, "expected passed=true"); + assert!(report.canonical_events >= 1, "expected at least one canonical event"); + assert_eq!(report.exit_code, 0); +} + +#[cfg(unix)] +#[test] +fn test_fails_for_nonzero_exit() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write_minimal_config(root); + + let agent_dir = root.join(".apm").join("agents").join("test-fail"); + std::fs::create_dir_all(&agent_dir).unwrap(); + make_executable(&agent_dir.join("wrapper.sh"), "#!/bin/sh\nexit 1\n"); + + let report = test_wrapper(root, "test-fail").unwrap(); + assert!(!report.passed, "expected passed=false"); + assert_eq!(report.exit_code, 1); +} + +#[test] +fn test_unknown_wrapper_returns_error() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write_minimal_config(root); + + let err = test_wrapper(root, "not-a-wrapper").unwrap_err(); + assert!( + err.to_string().contains("not found") || err.to_string().contains("not-a-wrapper"), + "error should mention wrapper name: {err}" + ); +} + +// -------------------------------------------------------------------------- +// eject tests +// -------------------------------------------------------------------------- + +#[test] +fn eject_claude_creates_script() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write_minimal_config(root); + + eject_wrapper(root, "claude").unwrap(); + + let script = root.join(".apm").join("agents").join("claude").join("wrapper.sh"); + assert!(script.exists(), "wrapper.sh missing after eject"); + let content = std::fs::read_to_string(&script).unwrap(); + assert!( + content.contains("claude"), + "ejected script must reference claude: {content}" + ); + assert!( + content.contains("output-format"), + "ejected script must contain output-format: {content}" + ); +} + +#[test] +fn eject_claude_creates_manifest() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write_minimal_config(root); + + eject_wrapper(root, "claude").unwrap(); + + let manifest = root + .join(".apm") + .join("agents") + .join("claude") + .join("manifest.toml"); + let content = std::fs::read_to_string(&manifest).unwrap(); + assert!( + content.contains("contract_version = 1"), + "manifest must contain contract_version = 1: {content}" + ); + assert!( + content.contains("canonical"), + "manifest must contain canonical: {content}" + ); +} + +#[test] +fn eject_refuses_existing_dir() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write_minimal_config(root); + + // Pre-create the directory + std::fs::create_dir_all(root.join(".apm").join("agents").join("claude")).unwrap(); + + let err = eject_wrapper(root, "claude").unwrap_err(); + assert!( + err.to_string().contains("already exists"), + "error should mention already exists: {err}" + ); +} + +#[test] +fn eject_unknown_builtin_returns_error() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write_minimal_config(root); + + let err = eject_wrapper(root, "not-a-builtin").unwrap_err(); + assert!( + err.to_string().contains("not a known built-in"), + "error should mention not a known built-in: {err}" + ); +} + +#[test] +fn eject_sets_execute_bit() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write_minimal_config(root); + + eject_wrapper(root, "claude").unwrap(); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let script = root.join(".apm").join("agents").join("claude").join("wrapper.sh"); + let meta = std::fs::metadata(&script).unwrap(); + assert!( + meta.permissions().mode() & 0o111 != 0, + "ejected wrapper.sh must be executable" + ); + } +} diff --git a/apm/src/cmd/agents.rs b/apm/src/cmd/agents.rs index f12d2bb40..5f1492ce7 100644 --- a/apm/src/cmd/agents.rs +++ b/apm/src/cmd/agents.rs @@ -1,18 +1,75 @@ use anyhow::Result; use apm_core::config::Config; +use apm_core::wrapper::WrapperKind; use std::path::Path; -pub fn run(root: &Path) -> Result<()> { +pub fn run_list(root: &Path) -> Result<()> { let config = Config::load(root)?; - match config.agents.instructions { - None => println!("No instructions file configured in [agents] instructions."), - Some(rel_path) => { - let path = root.join(&rel_path); - match std::fs::read_to_string(&path) { - Ok(contents) => print!("{}", contents), - Err(e) => anyhow::bail!("cannot read {}: {}", path.display(), e), - } - } + let entries = apm_core::agents::list_wrappers(root, &config)?; + + let name_w = entries.iter().map(|e| e.name.len()).max().unwrap_or(4).max(4); + let kind_w = "built-in".len(); + let parser_w = "canonical".len(); + + println!( + "{: "built-in", + WrapperKind::Custom { .. } => "project", + }; + let status = entry.configured_as.join(", "); + println!( + "{: Result<()> { + apm_core::agents::scaffold_wrapper(root, name, force)?; + + let dir = root.join(".apm").join("agents").join(name); + println!("Created:"); + println!(" {}", dir.join("wrapper.sh").display()); + println!(" {}", dir.join("manifest.toml").display()); + println!(" {}", dir.join("apm.worker.md").display()); + println!(" {}", dir.join("apm.spec-writer.md").display()); + println!(); + println!("Next steps:"); + println!(" 1. Edit {}/wrapper.sh to invoke your AI tool", dir.display()); + println!(" 2. Run: apm agents test {name}"); + Ok(()) +} + +pub fn run_test(root: &Path, name: &str) -> Result<()> { + let report = apm_core::agents::test_wrapper(root, name)?; + + let label = if report.passed { "PASS" } else { "FAIL" }; + println!( + "{} exit={} events={} non-canonical={} stderr={} wall={}ms", + label, + report.exit_code, + report.canonical_events, + report.non_canonical_lines, + report.stderr_lines, + report.wall_millis, + ); + + if !report.passed { + anyhow::bail!("wrapper test failed"); } Ok(()) } + +pub fn run_eject(root: &Path, name: &str) -> Result<()> { + apm_core::agents::eject_wrapper(root, name)?; + + let script = root.join(".apm").join("agents").join(name).join("wrapper.sh"); + println!("Ejected to: {}", script.display()); + println!("Run: apm agents test {name}"); + Ok(()) +} diff --git a/apm/src/hash_trip.rs b/apm/src/hash_trip.rs index 20a71e8c6..12c80ec0c 100644 --- a/apm/src/hash_trip.rs +++ b/apm/src/hash_trip.rs @@ -25,6 +25,8 @@ pub fn is_read_only_command(cmd: &super::Command) -> bool { super::Command::List { .. } | super::Command::Show { .. } | super::Command::Next { .. } + | super::Command::Agents { command: super::AgentsCommand::List } + | super::Command::Agents { command: super::AgentsCommand::Test { .. } } ) } diff --git a/apm/src/main.rs b/apm/src/main.rs index 3e989727d..63fe70e3b 100644 --- a/apm/src/main.rs +++ b/apm/src/main.rs @@ -17,7 +17,7 @@ Agent Project Manager — a git-native ticket system for human+AI teams. Setup: init Initialize apm in the current repository - agents Print agent instructions + agents Manage agent wrappers (list, new, test, eject) help Show help for a topic (commands, config, workflow, ticket) Ticket management: @@ -95,6 +95,30 @@ enum EpicCommand { }, } +#[derive(Subcommand)] +enum AgentsCommand { + /// List available wrappers (built-in and project-defined) + List, + /// Scaffold a new custom wrapper under .apm/agents// + New { + /// Name for the new wrapper + name: String, + /// Overwrite if the directory already exists + #[arg(long)] + force: bool, + }, + /// Smoke-test a wrapper with a synthetic ticket + Test { + /// Wrapper name to test + name: String, + }, + /// Extract a built-in wrapper to a project script under .apm/agents// + Eject { + /// Built-in wrapper name to eject (e.g. claude) + name: String, + }, +} + #[derive(Subcommand)] enum Command { /// Initialize apm in the current repository @@ -533,17 +557,11 @@ merged-branch and worktree checks.")] #[arg(trailing_var_arg = true, allow_hyphen_values = true)] _extra: Vec, }, - /// Print agent instructions configured in .apm/apm.toml - #[command(long_about = "Print the contents of the instructions file configured under [agents] instructions in .apm/apm.toml. - -Useful for onboarding a new agent subprocess: pipe or paste the output into -the agent's context so it knows the workflow, branch conventions, and shell -discipline rules without needing file-system access to the repo. - -Example: - apm agents | pbcopy # copy to clipboard - apm agents > /tmp/agents.md # write to a temp file for injection")] - Agents, + /// Manage agent wrappers (list, new, test, eject) + Agents { + #[command(subcommand)] + command: AgentsCommand, + }, /// Orchestrate workers: dispatch apm start --next --spawn in a loop #[command(long_about = "Orchestration loop: repeatedly dispatch agents until no work remains. @@ -868,7 +886,10 @@ fn main() -> Result<()> { Command::Review { id, to, no_aggressive } => cmd::review::run(&root, &id, to, no_aggressive), Command::Validate { fix, json, config_only, no_aggressive } => cmd::validate::run(&root, fix, json, config_only, no_aggressive), Command::Hook { hook_name, .. } => { cmd::hook::run(&root, &hook_name); Ok(()) } - Command::Agents => cmd::agents::run(&root), + Command::Agents { command: AgentsCommand::List } => cmd::agents::run_list(&root), + Command::Agents { command: AgentsCommand::New { name, force } } => cmd::agents::run_new(&root, &name, force), + Command::Agents { command: AgentsCommand::Test { name } } => cmd::agents::run_test(&root, &name), + Command::Agents { command: AgentsCommand::Eject { name } } => cmd::agents::run_eject(&root, &name), Command::Work { skip_permissions, dry_run, daemon, interval, epic } => cmd::work::run(&root, skip_permissions, dry_run, daemon, interval, epic), Command::Move { ticket, target } => cmd::move_ticket::run(&root, &ticket, &target), Command::Close { id, reason, no_aggressive } => cmd::close::run(&root, &id, reason, no_aggressive), diff --git a/apm/tests/integration.rs b/apm/tests/integration.rs index e47c76561..8db6c808e 100644 --- a/apm/tests/integration.rs +++ b/apm/tests/integration.rs @@ -4811,7 +4811,7 @@ fn pr_or_epic_merge_without_target_branch_attempts_pr() { } #[test] -fn agents_prints_instructions_file() { +fn agents_list_shows_claude_builtin() { let dir = tempfile::tempdir().unwrap(); let p = dir.path(); @@ -4827,10 +4827,6 @@ name = "test" [tickets] dir = "tickets" -[agents] -instructions = "agents-instructions.md" -max_concurrent = 1 - [[workflow.states]] id = "new" label = "New" @@ -4844,16 +4840,15 @@ terminal = true ) .unwrap(); - std::fs::write(p.join("agents-instructions.md"), "hello from agents\n").unwrap(); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_apm")) - .args(["agents"]) + .args(["agents", "list"]) .current_dir(p) .output() .unwrap(); assert!(out.status.success(), "expected exit 0, got: {}\nstderr: {}", out.status, String::from_utf8_lossy(&out.stderr)); let stdout = String::from_utf8_lossy(&out.stdout); - assert_eq!(stdout, "hello from agents\n", "unexpected output: {stdout}"); + assert!(stdout.contains("claude"), "expected claude in output: {stdout}"); + assert!(stdout.contains("built-in"), "expected built-in in output: {stdout}"); } fn setup_with_server_url(url: &str) -> TempDir { From 7a31425847dc2ec5c1035e759943b3f18f5450ab Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:39:17 -0700 Subject: [PATCH 276/305] ticket(71d80e40): mark "apm agents list` prints a row for the `claude` built-in" in Acceptance criteria --- tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index 039b0c770..baba849fb 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -64,7 +64,7 @@ Add the `apm agents` subcommand family for discovering, scaffolding, smoke-testi ### Acceptance criteria -- [ ] `apm agents list` prints a row for the `claude` built-in with kind `built-in` +- [x] `apm agents list` prints a row for the `claude` built-in with kind `built-in` - [ ] `apm agents list` prints a row for each executable `wrapper.*` found under `.apm/agents//` with kind `project` - [ ] `apm agents list` marks the agent matching the configured `workers.command` (legacy field) with a `(configured)` indicator - [ ] `apm agents list` shows a `parser` column value read from `manifest.toml`; defaults to `canonical` when the manifest is absent or the field is unset @@ -291,4 +291,4 @@ Update `hash_trip::is_read_only_command` if `Agents` is listed: `List` and `Test | 2026-05-01T01:32Z | ammend | in_design | philippepascal | | 2026-05-01T01:36Z | in_design | specd | claude-0501-0132-6a28 | | 2026-05-01T17:38Z | specd | ready | philippepascal | -| 2026-05-01T19:27Z | ready | in_progress | philippepascal | +| 2026-05-01T19:27Z | ready | in_progress | philippepascal | \ No newline at end of file From f19e15d4786bbd3c57696b796866596dfda71fa5 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:39:22 -0700 Subject: [PATCH 277/305] ticket(71d80e40): mark "prints a row for each executable" in Acceptance criteria --- tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index baba849fb..6e48cf1d5 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -65,7 +65,7 @@ Add the `apm agents` subcommand family for discovering, scaffolding, smoke-testi ### Acceptance criteria - [x] `apm agents list` prints a row for the `claude` built-in with kind `built-in` -- [ ] `apm agents list` prints a row for each executable `wrapper.*` found under `.apm/agents//` with kind `project` +- [x] `apm agents list` prints a row for each executable `wrapper.*` found under `.apm/agents//` with kind `project` - [ ] `apm agents list` marks the agent matching the configured `workers.command` (legacy field) with a `(configured)` indicator - [ ] `apm agents list` shows a `parser` column value read from `manifest.toml`; defaults to `canonical` when the manifest is absent or the field is unset - [ ] `apm agents new ` creates `.apm/agents//wrapper.sh` with the execute bit set (mode `0o755` on Unix) From d9b2d9a424e1973636e85a37ec1f3aa548addb30 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:39:24 -0700 Subject: [PATCH 278/305] ticket(71d80e40): mark "marks the agent matching" in Acceptance criteria --- tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index 6e48cf1d5..3eaeb1ee2 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -66,7 +66,7 @@ Add the `apm agents` subcommand family for discovering, scaffolding, smoke-testi - [x] `apm agents list` prints a row for the `claude` built-in with kind `built-in` - [x] `apm agents list` prints a row for each executable `wrapper.*` found under `.apm/agents//` with kind `project` -- [ ] `apm agents list` marks the agent matching the configured `workers.command` (legacy field) with a `(configured)` indicator +- [x] `apm agents list` marks the agent matching the configured `workers.command` (legacy field) with a `(configured)` indicator - [ ] `apm agents list` shows a `parser` column value read from `manifest.toml`; defaults to `canonical` when the manifest is absent or the field is unset - [ ] `apm agents new ` creates `.apm/agents//wrapper.sh` with the execute bit set (mode `0o755` on Unix) - [ ] `apm agents new ` creates `.apm/agents//apm.worker.md` with content copied from `.apm/apm.worker.md` or the built-in default when the project file is absent From 402c6b09b633f86171826db1d63368923154c2fc Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:39:30 -0700 Subject: [PATCH 279/305] ticket(71d80e40): mark "shows a `parser` column" in Acceptance criteria --- tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index 3eaeb1ee2..d233acbc0 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -67,7 +67,7 @@ Add the `apm agents` subcommand family for discovering, scaffolding, smoke-testi - [x] `apm agents list` prints a row for the `claude` built-in with kind `built-in` - [x] `apm agents list` prints a row for each executable `wrapper.*` found under `.apm/agents//` with kind `project` - [x] `apm agents list` marks the agent matching the configured `workers.command` (legacy field) with a `(configured)` indicator -- [ ] `apm agents list` shows a `parser` column value read from `manifest.toml`; defaults to `canonical` when the manifest is absent or the field is unset +- [x] `apm agents list` shows a `parser` column value read from `manifest.toml`; defaults to `canonical` when the manifest is absent or the field is unset - [ ] `apm agents new ` creates `.apm/agents//wrapper.sh` with the execute bit set (mode `0o755` on Unix) - [ ] `apm agents new ` creates `.apm/agents//apm.worker.md` with content copied from `.apm/apm.worker.md` or the built-in default when the project file is absent - [ ] `apm agents new ` creates `.apm/agents//apm.spec-writer.md` with content copied from `.apm/apm.spec-writer.md` or the built-in default when the project file is absent From 813b3f451ab1133bbc71bfd63314ebbd69bfa800 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:39:32 -0700 Subject: [PATCH 280/305] ticket(71d80e40): mark "creates `.apm/agents//wrapper.sh` with the execute bit" in Acceptance criteria --- tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index d233acbc0..6b59673b9 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -68,7 +68,7 @@ Add the `apm agents` subcommand family for discovering, scaffolding, smoke-testi - [x] `apm agents list` prints a row for each executable `wrapper.*` found under `.apm/agents//` with kind `project` - [x] `apm agents list` marks the agent matching the configured `workers.command` (legacy field) with a `(configured)` indicator - [x] `apm agents list` shows a `parser` column value read from `manifest.toml`; defaults to `canonical` when the manifest is absent or the field is unset -- [ ] `apm agents new ` creates `.apm/agents//wrapper.sh` with the execute bit set (mode `0o755` on Unix) +- [x] `apm agents new ` creates `.apm/agents//wrapper.sh` with the execute bit set (mode `0o755` on Unix) - [ ] `apm agents new ` creates `.apm/agents//apm.worker.md` with content copied from `.apm/apm.worker.md` or the built-in default when the project file is absent - [ ] `apm agents new ` creates `.apm/agents//apm.spec-writer.md` with content copied from `.apm/apm.spec-writer.md` or the built-in default when the project file is absent - [ ] `apm agents new ` creates `.apm/agents//manifest.toml` containing `contract_version = 1` and `parser = "canonical"` From 5bb732d1eb8a60ba5a5753534f96ff1d24efed92 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:39:38 -0700 Subject: [PATCH 281/305] ticket(71d80e40): mark "creates `.apm/agents//apm.worker.md`" in Acceptance criteria --- tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index 6b59673b9..793e77f9b 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -69,7 +69,7 @@ Add the `apm agents` subcommand family for discovering, scaffolding, smoke-testi - [x] `apm agents list` marks the agent matching the configured `workers.command` (legacy field) with a `(configured)` indicator - [x] `apm agents list` shows a `parser` column value read from `manifest.toml`; defaults to `canonical` when the manifest is absent or the field is unset - [x] `apm agents new ` creates `.apm/agents//wrapper.sh` with the execute bit set (mode `0o755` on Unix) -- [ ] `apm agents new ` creates `.apm/agents//apm.worker.md` with content copied from `.apm/apm.worker.md` or the built-in default when the project file is absent +- [x] `apm agents new ` creates `.apm/agents//apm.worker.md` with content copied from `.apm/apm.worker.md` or the built-in default when the project file is absent - [ ] `apm agents new ` creates `.apm/agents//apm.spec-writer.md` with content copied from `.apm/apm.spec-writer.md` or the built-in default when the project file is absent - [ ] `apm agents new ` creates `.apm/agents//manifest.toml` containing `contract_version = 1` and `parser = "canonical"` - [ ] `apm agents new ` exits non-zero with a message that mentions `--force` when `.apm/agents//` already exists From e973ed88a75c18177b45cdf85ba1d33f5dee764c Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:39:40 -0700 Subject: [PATCH 282/305] ticket(71d80e40): mark "creates `.apm/agents//apm.spec-writer.md`" in Acceptance criteria --- tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index 793e77f9b..9e6e123be 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -70,7 +70,7 @@ Add the `apm agents` subcommand family for discovering, scaffolding, smoke-testi - [x] `apm agents list` shows a `parser` column value read from `manifest.toml`; defaults to `canonical` when the manifest is absent or the field is unset - [x] `apm agents new ` creates `.apm/agents//wrapper.sh` with the execute bit set (mode `0o755` on Unix) - [x] `apm agents new ` creates `.apm/agents//apm.worker.md` with content copied from `.apm/apm.worker.md` or the built-in default when the project file is absent -- [ ] `apm agents new ` creates `.apm/agents//apm.spec-writer.md` with content copied from `.apm/apm.spec-writer.md` or the built-in default when the project file is absent +- [x] `apm agents new ` creates `.apm/agents//apm.spec-writer.md` with content copied from `.apm/apm.spec-writer.md` or the built-in default when the project file is absent - [ ] `apm agents new ` creates `.apm/agents//manifest.toml` containing `contract_version = 1` and `parser = "canonical"` - [ ] `apm agents new ` exits non-zero with a message that mentions `--force` when `.apm/agents//` already exists - [ ] `apm agents new --force` succeeds when the directory already exists and overwrites the scaffolded files From e79d329e3e9cee2df96121bfe7e8ff1683ef48e0 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:39:46 -0700 Subject: [PATCH 283/305] ticket(71d80e40): mark "creates `.apm/agents//manifest.toml` containing" in Acceptance criteria --- tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index 9e6e123be..04956383f 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -71,7 +71,7 @@ Add the `apm agents` subcommand family for discovering, scaffolding, smoke-testi - [x] `apm agents new ` creates `.apm/agents//wrapper.sh` with the execute bit set (mode `0o755` on Unix) - [x] `apm agents new ` creates `.apm/agents//apm.worker.md` with content copied from `.apm/apm.worker.md` or the built-in default when the project file is absent - [x] `apm agents new ` creates `.apm/agents//apm.spec-writer.md` with content copied from `.apm/apm.spec-writer.md` or the built-in default when the project file is absent -- [ ] `apm agents new ` creates `.apm/agents//manifest.toml` containing `contract_version = 1` and `parser = "canonical"` +- [x] `apm agents new ` creates `.apm/agents//manifest.toml` containing `contract_version = 1` and `parser = "canonical"` - [ ] `apm agents new ` exits non-zero with a message that mentions `--force` when `.apm/agents//` already exists - [ ] `apm agents new --force` succeeds when the directory already exists and overwrites the scaffolded files - [ ] `apm agents new ` prints next-step guidance directing the user to edit `wrapper.sh` and run `apm agents test ` From 5ba37f621a18a9d1fe15ef75311d124d09bdc8e3 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:39:48 -0700 Subject: [PATCH 284/305] ticket(71d80e40): mark "exits non-zero with a message that mentions `--force`" in Acceptance criteria --- tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index 04956383f..ea35e7767 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -72,7 +72,7 @@ Add the `apm agents` subcommand family for discovering, scaffolding, smoke-testi - [x] `apm agents new ` creates `.apm/agents//apm.worker.md` with content copied from `.apm/apm.worker.md` or the built-in default when the project file is absent - [x] `apm agents new ` creates `.apm/agents//apm.spec-writer.md` with content copied from `.apm/apm.spec-writer.md` or the built-in default when the project file is absent - [x] `apm agents new ` creates `.apm/agents//manifest.toml` containing `contract_version = 1` and `parser = "canonical"` -- [ ] `apm agents new ` exits non-zero with a message that mentions `--force` when `.apm/agents//` already exists +- [x] `apm agents new ` exits non-zero with a message that mentions `--force` when `.apm/agents//` already exists - [ ] `apm agents new --force` succeeds when the directory already exists and overwrites the scaffolded files - [ ] `apm agents new ` prints next-step guidance directing the user to edit `wrapper.sh` and run `apm agents test ` - [ ] `apm agents test ` exits 0 and prints a pass summary when the wrapper exits 0 and emits at least one canonical JSONL line (a JSON object containing a `"type"` key) From 924c75c7b12b635bbe406ba554cd3a178fdfb3bf Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:40:01 -0700 Subject: [PATCH 285/305] ticket(71d80e40): mark "new --force" in Acceptance criteria --- tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index ea35e7767..b0394ee90 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -73,7 +73,7 @@ Add the `apm agents` subcommand family for discovering, scaffolding, smoke-testi - [x] `apm agents new ` creates `.apm/agents//apm.spec-writer.md` with content copied from `.apm/apm.spec-writer.md` or the built-in default when the project file is absent - [x] `apm agents new ` creates `.apm/agents//manifest.toml` containing `contract_version = 1` and `parser = "canonical"` - [x] `apm agents new ` exits non-zero with a message that mentions `--force` when `.apm/agents//` already exists -- [ ] `apm agents new --force` succeeds when the directory already exists and overwrites the scaffolded files +- [x] `apm agents new --force` succeeds when the directory already exists and overwrites the scaffolded files - [ ] `apm agents new ` prints next-step guidance directing the user to edit `wrapper.sh` and run `apm agents test ` - [ ] `apm agents test ` exits 0 and prints a pass summary when the wrapper exits 0 and emits at least one canonical JSONL line (a JSON object containing a `"type"` key) - [ ] `apm agents test ` exits non-zero and prints a fail summary when the wrapper exits non-zero From 500ad77a51995b827cab205963b3eda8a3a6d110 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:40:08 -0700 Subject: [PATCH 286/305] ticket(71d80e40): mark "prints next-step guidance" in Acceptance criteria --- tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index b0394ee90..39360e564 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -74,7 +74,7 @@ Add the `apm agents` subcommand family for discovering, scaffolding, smoke-testi - [x] `apm agents new ` creates `.apm/agents//manifest.toml` containing `contract_version = 1` and `parser = "canonical"` - [x] `apm agents new ` exits non-zero with a message that mentions `--force` when `.apm/agents//` already exists - [x] `apm agents new --force` succeeds when the directory already exists and overwrites the scaffolded files -- [ ] `apm agents new ` prints next-step guidance directing the user to edit `wrapper.sh` and run `apm agents test ` +- [x] `apm agents new ` prints next-step guidance directing the user to edit `wrapper.sh` and run `apm agents test ` - [ ] `apm agents test ` exits 0 and prints a pass summary when the wrapper exits 0 and emits at least one canonical JSONL line (a JSON object containing a `"type"` key) - [ ] `apm agents test ` exits non-zero and prints a fail summary when the wrapper exits non-zero - [ ] `apm agents test ` reports exit code, canonical JSONL event count, non-canonical log line count, stderr line count, and wall-clock milliseconds in its output From 8ffa173de826b5b79de5e3689ed8f2845eb976b5 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:40:10 -0700 Subject: [PATCH 287/305] ticket(71d80e40): mark "exits 0 and prints a pass summary" in Acceptance criteria --- tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index 39360e564..f784e4749 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -75,7 +75,7 @@ Add the `apm agents` subcommand family for discovering, scaffolding, smoke-testi - [x] `apm agents new ` exits non-zero with a message that mentions `--force` when `.apm/agents//` already exists - [x] `apm agents new --force` succeeds when the directory already exists and overwrites the scaffolded files - [x] `apm agents new ` prints next-step guidance directing the user to edit `wrapper.sh` and run `apm agents test ` -- [ ] `apm agents test ` exits 0 and prints a pass summary when the wrapper exits 0 and emits at least one canonical JSONL line (a JSON object containing a `"type"` key) +- [x] `apm agents test ` exits 0 and prints a pass summary when the wrapper exits 0 and emits at least one canonical JSONL line (a JSON object containing a `"type"` key) - [ ] `apm agents test ` exits non-zero and prints a fail summary when the wrapper exits non-zero - [ ] `apm agents test ` reports exit code, canonical JSONL event count, non-canonical log line count, stderr line count, and wall-clock milliseconds in its output - [ ] `apm agents test ` exits non-zero with a clear error message when `` is not a known wrapper (built-in or project) From 6224bdb416edad9605215462040ac64ea6fa0a71 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:40:16 -0700 Subject: [PATCH 288/305] ticket(71d80e40): mark "exits non-zero and prints a fail summary" in Acceptance criteria --- tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index f784e4749..39b6a86d7 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -76,7 +76,7 @@ Add the `apm agents` subcommand family for discovering, scaffolding, smoke-testi - [x] `apm agents new --force` succeeds when the directory already exists and overwrites the scaffolded files - [x] `apm agents new ` prints next-step guidance directing the user to edit `wrapper.sh` and run `apm agents test ` - [x] `apm agents test ` exits 0 and prints a pass summary when the wrapper exits 0 and emits at least one canonical JSONL line (a JSON object containing a `"type"` key) -- [ ] `apm agents test ` exits non-zero and prints a fail summary when the wrapper exits non-zero +- [x] `apm agents test ` exits non-zero and prints a fail summary when the wrapper exits non-zero - [ ] `apm agents test ` reports exit code, canonical JSONL event count, non-canonical log line count, stderr line count, and wall-clock milliseconds in its output - [ ] `apm agents test ` exits non-zero with a clear error message when `` is not a known wrapper (built-in or project) - [ ] `apm agents eject claude` creates `.apm/agents/claude/wrapper.sh` containing a bash script that invokes the `claude` CLI with `--print --output-format stream-json --verbose` From b734d74a882288ddc8ca767ae31d6d6d629fd2ce Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:40:18 -0700 Subject: [PATCH 289/305] ticket(71d80e40): mark "reports exit code, canonical JSONL event count" in Acceptance criteria --- tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index 39b6a86d7..0f282d8f4 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -77,7 +77,7 @@ Add the `apm agents` subcommand family for discovering, scaffolding, smoke-testi - [x] `apm agents new ` prints next-step guidance directing the user to edit `wrapper.sh` and run `apm agents test ` - [x] `apm agents test ` exits 0 and prints a pass summary when the wrapper exits 0 and emits at least one canonical JSONL line (a JSON object containing a `"type"` key) - [x] `apm agents test ` exits non-zero and prints a fail summary when the wrapper exits non-zero -- [ ] `apm agents test ` reports exit code, canonical JSONL event count, non-canonical log line count, stderr line count, and wall-clock milliseconds in its output +- [x] `apm agents test ` reports exit code, canonical JSONL event count, non-canonical log line count, stderr line count, and wall-clock milliseconds in its output - [ ] `apm agents test ` exits non-zero with a clear error message when `` is not a known wrapper (built-in or project) - [ ] `apm agents eject claude` creates `.apm/agents/claude/wrapper.sh` containing a bash script that invokes the `claude` CLI with `--print --output-format stream-json --verbose` - [ ] `apm agents eject ` creates `.apm/agents//manifest.toml` with `contract_version = 1` and `parser = "canonical"` From b60042034700fc2c327a4fec79e4c577619e4fbd Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:40:25 -0700 Subject: [PATCH 290/305] ticket(71d80e40): mark "exits non-zero with a clear error message" in Acceptance criteria --- tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index 0f282d8f4..55e9789ea 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -78,7 +78,7 @@ Add the `apm agents` subcommand family for discovering, scaffolding, smoke-testi - [x] `apm agents test ` exits 0 and prints a pass summary when the wrapper exits 0 and emits at least one canonical JSONL line (a JSON object containing a `"type"` key) - [x] `apm agents test ` exits non-zero and prints a fail summary when the wrapper exits non-zero - [x] `apm agents test ` reports exit code, canonical JSONL event count, non-canonical log line count, stderr line count, and wall-clock milliseconds in its output -- [ ] `apm agents test ` exits non-zero with a clear error message when `` is not a known wrapper (built-in or project) +- [x] `apm agents test ` exits non-zero with a clear error message when `` is not a known wrapper (built-in or project) - [ ] `apm agents eject claude` creates `.apm/agents/claude/wrapper.sh` containing a bash script that invokes the `claude` CLI with `--print --output-format stream-json --verbose` - [ ] `apm agents eject ` creates `.apm/agents//manifest.toml` with `contract_version = 1` and `parser = "canonical"` - [ ] `apm agents eject ` sets the execute bit on the ejected `wrapper.sh` From 1f72b89998ad6940facfdec6a2a19d30e02c5787 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:40:27 -0700 Subject: [PATCH 291/305] ticket(71d80e40): mark "eject claude" in Acceptance criteria --- tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index 55e9789ea..298f30467 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -79,7 +79,7 @@ Add the `apm agents` subcommand family for discovering, scaffolding, smoke-testi - [x] `apm agents test ` exits non-zero and prints a fail summary when the wrapper exits non-zero - [x] `apm agents test ` reports exit code, canonical JSONL event count, non-canonical log line count, stderr line count, and wall-clock milliseconds in its output - [x] `apm agents test ` exits non-zero with a clear error message when `` is not a known wrapper (built-in or project) -- [ ] `apm agents eject claude` creates `.apm/agents/claude/wrapper.sh` containing a bash script that invokes the `claude` CLI with `--print --output-format stream-json --verbose` +- [x] `apm agents eject claude` creates `.apm/agents/claude/wrapper.sh` containing a bash script that invokes the `claude` CLI with `--print --output-format stream-json --verbose` - [ ] `apm agents eject ` creates `.apm/agents//manifest.toml` with `contract_version = 1` and `parser = "canonical"` - [ ] `apm agents eject ` sets the execute bit on the ejected `wrapper.sh` - [ ] `apm agents eject ` exits non-zero when `.apm/agents//` already exists From 1630df0d2dddc8efa0a0f11fae5e43cd4f1927ce Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:40:34 -0700 Subject: [PATCH 292/305] ticket(71d80e40): mark "eject ` creates `.apm/agents//manifest.toml`" in Acceptance criteria --- tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index 298f30467..eaa18133d 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -80,7 +80,7 @@ Add the `apm agents` subcommand family for discovering, scaffolding, smoke-testi - [x] `apm agents test ` reports exit code, canonical JSONL event count, non-canonical log line count, stderr line count, and wall-clock milliseconds in its output - [x] `apm agents test ` exits non-zero with a clear error message when `` is not a known wrapper (built-in or project) - [x] `apm agents eject claude` creates `.apm/agents/claude/wrapper.sh` containing a bash script that invokes the `claude` CLI with `--print --output-format stream-json --verbose` -- [ ] `apm agents eject ` creates `.apm/agents//manifest.toml` with `contract_version = 1` and `parser = "canonical"` +- [x] `apm agents eject ` creates `.apm/agents//manifest.toml` with `contract_version = 1` and `parser = "canonical"` - [ ] `apm agents eject ` sets the execute bit on the ejected `wrapper.sh` - [ ] `apm agents eject ` exits non-zero when `.apm/agents//` already exists - [ ] `apm agents eject ` exits non-zero with a message when `` is not a known built-in From c2f5affe271291d98b5e018d0c91077a9c737ccf Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:40:36 -0700 Subject: [PATCH 293/305] ticket(71d80e40): mark "sets the execute bit on the ejected" in Acceptance criteria --- tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index eaa18133d..1053d07ca 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -81,7 +81,7 @@ Add the `apm agents` subcommand family for discovering, scaffolding, smoke-testi - [x] `apm agents test ` exits non-zero with a clear error message when `` is not a known wrapper (built-in or project) - [x] `apm agents eject claude` creates `.apm/agents/claude/wrapper.sh` containing a bash script that invokes the `claude` CLI with `--print --output-format stream-json --verbose` - [x] `apm agents eject ` creates `.apm/agents//manifest.toml` with `contract_version = 1` and `parser = "canonical"` -- [ ] `apm agents eject ` sets the execute bit on the ejected `wrapper.sh` +- [x] `apm agents eject ` sets the execute bit on the ejected `wrapper.sh` - [ ] `apm agents eject ` exits non-zero when `.apm/agents//` already exists - [ ] `apm agents eject ` exits non-zero with a message when `` is not a known built-in From 27f203a2e0b7c1643e27e7f14ffe878bb9305322 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:40:42 -0700 Subject: [PATCH 294/305] ticket(71d80e40): mark "exits non-zero when `.apm/agents//` already exists" in Acceptance criteria --- tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index 1053d07ca..52b986155 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -82,7 +82,7 @@ Add the `apm agents` subcommand family for discovering, scaffolding, smoke-testi - [x] `apm agents eject claude` creates `.apm/agents/claude/wrapper.sh` containing a bash script that invokes the `claude` CLI with `--print --output-format stream-json --verbose` - [x] `apm agents eject ` creates `.apm/agents//manifest.toml` with `contract_version = 1` and `parser = "canonical"` - [x] `apm agents eject ` sets the execute bit on the ejected `wrapper.sh` -- [ ] `apm agents eject ` exits non-zero when `.apm/agents//` already exists +- [x] `apm agents eject ` exits non-zero when `.apm/agents//` already exists - [ ] `apm agents eject ` exits non-zero with a message when `` is not a known built-in ### Out of scope From 853a553af51c151e1c1a1aa5bff9f0dc02235b27 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:40:44 -0700 Subject: [PATCH 295/305] ticket(71d80e40): mark "exits non-zero with a message when `` is not a known built-in" in Acceptance criteria --- tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index 52b986155..04121d66a 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -83,7 +83,7 @@ Add the `apm agents` subcommand family for discovering, scaffolding, smoke-testi - [x] `apm agents eject ` creates `.apm/agents//manifest.toml` with `contract_version = 1` and `parser = "canonical"` - [x] `apm agents eject ` sets the execute bit on the ejected `wrapper.sh` - [x] `apm agents eject ` exits non-zero when `.apm/agents//` already exists -- [ ] `apm agents eject ` exits non-zero with a message when `` is not a known built-in +- [x] `apm agents eject ` exits non-zero with a message when `` is not a known built-in ### Out of scope From 3d8650b5508a26a96bc8dce788849293cd9c5dec Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:40:51 -0700 Subject: [PATCH 296/305] =?UTF-8?q?ticket(71d80e40):=20in=5Fprogress=20?= =?UTF-8?q?=E2=86=92=20implemented?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../71d80e40-apm-agents-subcommand-new-list-test-ejec.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md index 04121d66a..5aefe1adb 100644 --- a/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md +++ b/tickets/71d80e40-apm-agents-subcommand-new-list-test-ejec.md @@ -1,7 +1,7 @@ +++ id = "71d80e40" title = "apm agents subcommand: new, list, test, eject" -state = "in_progress" +state = "implemented" priority = 0 effort = 5 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/71d80e40-apm-agents-subcommand-new-list-test-ejec" created_at = "2026-04-30T20:04:57.796154Z" -updated_at = "2026-05-01T19:27:25.670266Z" +updated_at = "2026-05-01T19:40:51.255725Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "2c32a282"] @@ -291,4 +291,5 @@ Update `hash_trip::is_read_only_command` if `Agents` is listed: `List` and `Test | 2026-05-01T01:32Z | ammend | in_design | philippepascal | | 2026-05-01T01:36Z | in_design | specd | claude-0501-0132-6a28 | | 2026-05-01T17:38Z | specd | ready | philippepascal | -| 2026-05-01T19:27Z | ready | in_progress | philippepascal | \ No newline at end of file +| 2026-05-01T19:27Z | ready | in_progress | philippepascal | +| 2026-05-01T19:40Z | in_progress | implemented | claude-0501-1927-86f8 | From 3244b192eeb16b960e50a30aaf6cf7f184663354 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:41:14 -0700 Subject: [PATCH 297/305] =?UTF-8?q?ticket(2803bf07):=20start=20=E2=80=94?= =?UTF-8?q?=20ready=20=E2=86=92=20in=5Fprogress?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/2803bf07-output-parser-strategy-external-parsers-.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md index a331b0676..4b70a310f 100644 --- a/tickets/2803bf07-output-parser-strategy-external-parsers-.md +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -1,7 +1,7 @@ +++ id = "2803bf07" title = "Output parser strategy: external parsers via manifest.toml" -state = "ready" +state = "in_progress" priority = 0 effort = 5 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/2803bf07-output-parser-strategy-external-parsers-" created_at = "2026-04-30T20:05:40.844536Z" -updated_at = "2026-05-01T17:38:23.279584Z" +updated_at = "2026-05-01T19:41:14.617740Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["2c32a282", "2e772eab"] @@ -205,3 +205,4 @@ Use absolute paths for `parser_command` in the integration test to avoid dependi | 2026-05-01T03:09Z | ammend | in_design | philippepascal | | 2026-05-01T03:16Z | in_design | specd | claude-0501-0309-1140 | | 2026-05-01T17:38Z | specd | ready | philippepascal | +| 2026-05-01T19:41Z | ready | in_progress | philippepascal | From ac59e5feea146285f30ce67f53fed851227a0602 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:48:22 -0700 Subject: [PATCH 298/305] Add external parser strategy for custom wrappers Wire parser/parser_command from manifest.toml into CustomWrapper::spawn. Canonical mode is unchanged. External mode spawns the wrapper with piped stdout, passes it to the parser's stdin via an OS-level pipe, and captures the parser's stdout/stderr to the log. The wrapper is reaped in a background thread so it doesn't zombie. apm validate now catches parser = external without parser_command. Unit and integration tests cover all branches. --- apm-core/src/validate.rs | 12 +- apm-core/src/wrapper/custom.rs | 232 ++++++++++++++++++- apm-core/tests/custom_wrapper_integration.rs | 150 ++++++++++++ 3 files changed, 386 insertions(+), 8 deletions(-) diff --git a/apm-core/src/validate.rs b/apm-core/src/validate.rs index e9ac8741a..3a980238d 100644 --- a/apm-core/src/validate.rs +++ b/apm-core/src/validate.rs @@ -179,7 +179,17 @@ fn validate_agents(config: &Config, root: &Path, errors: &mut Vec, warni name, name )), Err(e) => errors.push(format!("agent '{name}': {e}")), - Ok(Some(_)) => {} + Ok(Some(wrapper::WrapperKind::Custom { manifest, .. })) => { + if let Some(m) = &manifest { + if m.parser == "external" && m.parser_command.is_none() { + errors.push(format!( + "agent '{name}': manifest.toml declares parser = \"external\" \ + but parser_command is absent" + )); + } + } + } + Ok(Some(wrapper::WrapperKind::Builtin(_))) => {} } } diff --git a/apm-core/src/wrapper/custom.rs b/apm-core/src/wrapper/custom.rs index 149b2af0a..4bffb6f98 100644 --- a/apm-core/src/wrapper/custom.rs +++ b/apm-core/src/wrapper/custom.rs @@ -4,6 +4,52 @@ use serde::Deserialize; use anyhow::Context; use super::{Wrapper, WrapperContext, CONTRACT_VERSION}; +#[derive(Debug, Clone, PartialEq)] +enum ParserStrategy { + Canonical, + External, +} + +impl ParserStrategy { + fn from_manifest(m: Option<&Manifest>) -> Self { + match m.and_then(|m| Some(m.parser.as_str())) { + Some("external") => Self::External, + _ => Self::Canonical, + } + } +} + +/// Locate an executable binary by name or absolute path. +/// For absolute paths: checks the file exists. +/// For relative names: walks PATH entries and returns the first executable match. +fn find_binary(cmd: &str) -> anyhow::Result { + let p = Path::new(cmd); + if p.is_absolute() { + if p.is_file() { + return Ok(p.to_path_buf()); + } + anyhow::bail!("parser binary not found: {}", cmd); + } + let path_var = std::env::var("PATH").unwrap_or_default(); + for dir in std::env::split_paths(&path_var) { + let candidate = dir.join(cmd); + if !candidate.is_file() { + continue; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(meta) = candidate.metadata() { + if meta.permissions().mode() & 0o111 == 0 { + continue; + } + } + } + return Ok(candidate); + } + anyhow::bail!("parser binary not found: {}", cmd); +} + fn default_contract_version() -> u32 { 1 } fn default_parser() -> String { "canonical".to_string() } @@ -76,18 +122,88 @@ impl Wrapper for CustomWrapper { for (k, v) in &ctx.extra_env { cmd.env(k, v); } - cmd.current_dir(&ctx.worktree_path); - let log_file = std::fs::File::create(&ctx.log_path)?; - let log_clone = log_file.try_clone()?; - cmd.stdout(log_file); - cmd.stderr(log_clone); + let strategy = ParserStrategy::from_manifest(self.manifest.as_ref()); + #[cfg(unix)] use std::os::unix::process::CommandExt; - cmd.process_group(0); - Ok(cmd.spawn()?) + match strategy { + ParserStrategy::Canonical => { + let log_file = std::fs::File::create(&ctx.log_path)?; + let log_clone = log_file.try_clone()?; + cmd.stdout(log_file); + cmd.stderr(log_clone); + #[cfg(unix)] + cmd.process_group(0); + Ok(cmd.spawn()?) + } + ParserStrategy::External => { + let manifest_path = self.script_path + .parent() + .map(|p| p.join("manifest.toml")) + .unwrap_or_else(|| PathBuf::from("manifest.toml")); + + // Require parser_command + let parser_cmd_str = self.manifest.as_ref() + .and_then(|m| m.parser_command.as_deref()) + .ok_or_else(|| anyhow::anyhow!( + "{}: parser = \"external\" but parser_command is not set", + manifest_path.display() + ))? + .to_owned(); + + // Validate binary is findable before spawning any process + let parser_bin = find_binary(&parser_cmd_str)?; + + // Open log file; clone for each stream that writes to it: + // 1. wrapper.stderr, 2. parser.stdout, 3. parser.stderr + let log_file_wrapper_stderr = std::fs::File::create(&ctx.log_path)?; + let log_file_parser_stdout = log_file_wrapper_stderr.try_clone()?; + let log_file_parser_stderr = log_file_wrapper_stderr.try_clone()?; + + use std::process::Stdio; + + // Spawn wrapper: stdout piped to feed parser stdin; stderr directly to log + cmd.stdout(Stdio::piped()); + cmd.stderr(log_file_wrapper_stderr); + #[cfg(unix)] + cmd.process_group(0); + let mut wrapper_child = cmd.spawn()?; + + let wrapper_stdout = wrapper_child.stdout.take() + .ok_or_else(|| anyhow::anyhow!("failed to capture wrapper stdout pipe"))?; + + // Reap wrapper in background thread; append diagnostic exit line to log + let log_path_clone = ctx.log_path.clone(); + std::thread::spawn(move || { + let status = wrapper_child.wait(); + if let Ok(mut f) = std::fs::OpenOptions::new() + .append(true) + .create(true) + .open(&log_path_clone) + { + let status_str = match status { + Ok(s) => format!("{s}"), + Err(e) => format!("error: {e}"), + }; + let _ = writeln!(f, "[apm] wrapper exited: {status_str}"); + } + }); + + // Spawn parser: stdin = wrapper stdout pipe; stdout/stderr -> log + let mut parser_cmd = std::process::Command::new(&parser_bin); + parser_cmd.stdin(Stdio::from(wrapper_stdout)); + parser_cmd.stdout(log_file_parser_stdout); + parser_cmd.stderr(log_file_parser_stderr); + parser_cmd.current_dir(&ctx.worktree_path); + #[cfg(unix)] + parser_cmd.process_group(0); + + Ok(parser_cmd.spawn()?) + } + } } } @@ -380,6 +496,108 @@ mod tests { assert_eq!(declared, 1); } + // --- ParserStrategy tests --- + + #[test] + fn parser_strategy_defaults_to_canonical() { + assert_eq!(ParserStrategy::from_manifest(None), ParserStrategy::Canonical); + } + + #[test] + fn parser_strategy_explicit_canonical() { + let m = Manifest { + name: None, + contract_version: 1, + parser: "canonical".to_string(), + parser_command: None, + }; + assert_eq!(ParserStrategy::from_manifest(Some(&m)), ParserStrategy::Canonical); + } + + #[test] + fn parser_strategy_external() { + let m = Manifest { + name: None, + contract_version: 1, + parser: "external".to_string(), + parser_command: Some("my-parser".to_string()), + }; + assert_eq!(ParserStrategy::from_manifest(Some(&m)), ParserStrategy::External); + } + + #[test] + fn parser_strategy_unknown_falls_back_to_canonical() { + let m = Manifest { + name: None, + contract_version: 1, + parser: "foobar".to_string(), + parser_command: None, + }; + assert_eq!(ParserStrategy::from_manifest(Some(&m)), ParserStrategy::Canonical); + } + + #[test] + fn spawn_external_missing_parser_command() { + use std::os::unix::fs::PermissionsExt; + + let wt = tempfile::tempdir().unwrap(); + let log_dir = tempfile::tempdir().unwrap(); + let log_path = log_dir.path().join("worker.log"); + + let script = wt.path().join("wrapper.sh"); + std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let manifest = Manifest { + name: None, + contract_version: 1, + parser: "external".to_string(), + parser_command: None, + }; + let wrapper = CustomWrapper { + script_path: script, + manifest: Some(manifest), + }; + + let ctx = make_ctx(wt.path(), &log_path); + let err = wrapper.spawn(&ctx).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("parser_command"), "error must mention parser_command: {msg}"); + assert!(msg.contains("not set"), "error must mention 'not set': {msg}"); + } + + #[test] + fn spawn_external_binary_not_found() { + use std::os::unix::fs::PermissionsExt; + + let wt = tempfile::tempdir().unwrap(); + let log_dir = tempfile::tempdir().unwrap(); + let log_path = log_dir.path().join("worker.log"); + + let script = wt.path().join("wrapper.sh"); + std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let manifest = Manifest { + name: None, + contract_version: 1, + parser: "external".to_string(), + parser_command: Some("nonexistent-binary-xyzzy-2803".to_string()), + }; + let wrapper = CustomWrapper { + script_path: script, + manifest: Some(manifest), + }; + + let ctx = make_ctx(wt.path(), &log_path); + let err = wrapper.spawn(&ctx).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("nonexistent-binary-xyzzy-2803"), + "error must name the missing binary: {msg}" + ); + } + #[test] fn spawn_rejects_contract_version_gt_1() { use std::os::unix::fs::PermissionsExt; diff --git a/apm-core/tests/custom_wrapper_integration.rs b/apm-core/tests/custom_wrapper_integration.rs index 9bdcabd11..669e193fa 100644 --- a/apm-core/tests/custom_wrapper_integration.rs +++ b/apm-core/tests/custom_wrapper_integration.rs @@ -143,6 +143,156 @@ fn spawn_matching_contract_succeeds() { ); } +#[cfg(unix)] +#[test] +fn integration_canonical_mode() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + + let agent_dir = root.join(".apm").join("agents").join("canonical-test"); + std::fs::create_dir_all(&agent_dir).unwrap(); + + let script_path = agent_dir.join("wrapper.sh"); + std::fs::write( + &script_path, + "#!/bin/sh\nprintf '{\"type\":\"result\",\"text\":\"canonical-ok\"}\\n'\nexit 0\n", + ).unwrap(); + std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let wt = tempfile::tempdir().unwrap(); + let log_dir = tempfile::tempdir().unwrap(); + let log_path = log_dir.path().join("worker.log"); + + let kind = apm_core::wrapper::resolve_wrapper(root, "canonical-test") + .expect("resolve_wrapper should not error") + .expect("canonical-test should be found"); + + let (script, manifest) = match kind { + WrapperKind::Custom { script_path, manifest } => (script_path, manifest), + WrapperKind::Builtin(_) => panic!("expected Custom"), + }; + let wrapper = CustomWrapper { script_path: script, manifest }; + + let ctx = WrapperContext { + worker_name: "canonical-test".to_string(), + ticket_id: "canonical-id".to_string(), + ticket_branch: "ticket/canonical-id".to_string(), + worktree_path: wt.path().to_path_buf(), + system_prompt_file: wt.path().join("sys.txt"), + user_message_file: wt.path().join("msg.txt"), + skip_permissions: false, + profile: "default".to_string(), + role_prefix: None, + options: HashMap::new(), + model: None, + log_path: log_path.clone(), + container: None, + extra_env: HashMap::new(), + root: root.to_path_buf(), + keychain: HashMap::new(), + }; + + let mut child = wrapper.spawn(&ctx).expect("spawn should succeed for canonical mode"); + let status = child.wait().expect("wait should succeed"); + assert!(status.success(), "wrapper should exit 0; got: {status}"); + + let log_content = std::fs::read_to_string(&log_path) + .expect("log file should exist after wrapper exits"); + assert!( + log_content.contains(r#"{"type":"result","text":"canonical-ok"}"#), + "log must contain the emitted JSONL line; got:\n{log_content}" + ); +} + +#[cfg(unix)] +#[test] +fn integration_external_parser_pipe() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + + let agent_dir = root.join(".apm").join("agents").join("pipe-test"); + std::fs::create_dir_all(&agent_dir).unwrap(); + + // Wrapper emits non-JSONL text on stdout + let wrapper_script = agent_dir.join("wrapper.sh"); + std::fs::write( + &wrapper_script, + "#!/bin/sh\nprintf 'raw-output-line\\n'\nexit 0\n", + ).unwrap(); + std::fs::set_permissions(&wrapper_script, std::fs::Permissions::from_mode(0o755)).unwrap(); + + // Parser reads each line from stdin and wraps it as JSON + let parser_script = dir.path().join("parser.sh"); + std::fs::write( + &parser_script, + "#!/bin/sh\nwhile IFS= read -r line; do\n printf '{\"type\":\"parsed\",\"content\":\"%s\"}\\n' \"$line\"\ndone\n", + ).unwrap(); + std::fs::set_permissions(&parser_script, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let parser_absolute = parser_script.to_string_lossy().to_string(); + + // Write manifest with parser = "external" and absolute path to parser + std::fs::write( + agent_dir.join("manifest.toml"), + format!( + "[wrapper]\ncontract_version = 1\nparser = \"external\"\nparser_command = \"{}\"\n", + parser_absolute.replace('\\', "\\\\") + ), + ).unwrap(); + + let wt = tempfile::tempdir().unwrap(); + let log_dir = tempfile::tempdir().unwrap(); + let log_path = log_dir.path().join("worker.log"); + + let kind = apm_core::wrapper::resolve_wrapper(root, "pipe-test") + .expect("resolve_wrapper should not error") + .expect("pipe-test should be found"); + + let (script, manifest) = match kind { + WrapperKind::Custom { script_path, manifest } => (script_path, manifest), + WrapperKind::Builtin(_) => panic!("expected Custom"), + }; + let wrapper = CustomWrapper { script_path: script, manifest }; + + let ctx = WrapperContext { + worker_name: "pipe-test".to_string(), + ticket_id: "pipe-id".to_string(), + ticket_branch: "ticket/pipe-id".to_string(), + worktree_path: wt.path().to_path_buf(), + system_prompt_file: wt.path().join("sys.txt"), + user_message_file: wt.path().join("msg.txt"), + skip_permissions: false, + profile: "default".to_string(), + role_prefix: None, + options: HashMap::new(), + model: None, + log_path: log_path.clone(), + container: None, + extra_env: HashMap::new(), + root: root.to_path_buf(), + keychain: HashMap::new(), + }; + + let mut parser_child = wrapper.spawn(&ctx).expect("spawn should succeed for external mode"); + let status = parser_child.wait().expect("wait on parser child should succeed"); + assert!(status.success(), "parser should exit 0; got: {status}"); + + let log_content = std::fs::read_to_string(&log_path) + .expect("log file should exist after parser exits"); + assert!( + log_content.contains("raw-output-line"), + "log must contain input text wrapped in JSON; got:\n{log_content}" + ); + assert!( + log_content.contains(r#""type":"parsed""#), + "log must contain parsed JSON object; got:\n{log_content}" + ); +} + #[cfg(unix)] #[test] fn spawn_future_contract_rejected() { From 05e8e13186a50e6cb3abd01c889584df4b44d525 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:48:56 -0700 Subject: [PATCH 299/305] Check off acceptance criteria for 2803bf07 --- ...utput-parser-strategy-external-parsers-.md | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md index 4b70a310f..58750f08b 100644 --- a/tickets/2803bf07-output-parser-strategy-external-parsers-.md +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -44,18 +44,18 @@ Support agents whose output is too far from APM's canonical JSONL stream-json to ### Acceptance criteria -- [ ] When `parser` is absent from manifest.toml, or manifest.toml itself is absent, `CustomWrapper::spawn` captures the wrapper's stdout directly to the log file (equivalent to `parser = "canonical"`) -- [ ] When `parser = "canonical"`, `CustomWrapper::spawn` captures the wrapper's stdout directly to the log file with no transformation; the wrapper's stderr is also written to the same log file -- [ ] When `parser = "external"` and `parser_command` resolves to an executable binary, `CustomWrapper::spawn` creates an OS-level pipe: the wrapper's stdout is the parser's stdin; the parser's stdout is captured to the log file -- [ ] When `parser = "external"`, the wrapper's stderr is written to the log file independently (not through the parser pipe) -- [ ] When `parser = "external"`, the parser's stderr is also written to the log file -- [ ] When `parser = "external"` and `parser_command` is absent from manifest.toml, `CustomWrapper::spawn` returns `Err` before spawning any process; the error message names the manifest file path and states that `parser_command` is required -- [ ] When `parser = "external"` and `parser_command` names a binary not found in PATH and is not an absolute path to an existing file, `CustomWrapper::spawn` returns `Err` before spawning any process; the error message names the missing binary -- [ ] `apm validate` reports an error when a custom wrapper's manifest.toml declares `parser = "external"` but `parser_command` is absent -- [ ] Built-in wrappers (e.g. the `claude` built-in) always behave as `parser = "canonical"` regardless of any manifest file; no manifest is required or consulted for them -- [ ] `CustomWrapper::spawn` for external mode returns the parser's `Child` handle; the wrapper child is reaped in a background thread so it does not become a zombie -- [ ] When `parser = "external"`, the worker's exit status is taken from the parser's exit code; the wrapper's exit code is appended to the log file as a diagnostic line (e.g. `[apm] wrapper exited: exit status: 0`) but does not affect ticket state; if the wrapper exits non-zero before the parser has drained its stdin, the parser is allowed to finish naturally before APM reaps both -- [ ] When `parser = "external"`, all three streams (parser stdout, parser stderr, wrapper stderr) are written to `.apm-worker.log` without truncation, even when one stream produces output much faster than another; ordering between streams is best-effort but no bytes from any stream may be dropped +- [x] When `parser` is absent from manifest.toml, or manifest.toml itself is absent, `CustomWrapper::spawn` captures the wrapper's stdout directly to the log file (equivalent to `parser = "canonical"`) +- [x] When `parser = "canonical"`, `CustomWrapper::spawn` captures the wrapper's stdout directly to the log file with no transformation; the wrapper's stderr is also written to the same log file +- [x] When `parser = "external"` and `parser_command` resolves to an executable binary, `CustomWrapper::spawn` creates an OS-level pipe: the wrapper's stdout is the parser's stdin; the parser's stdout is captured to the log file +- [x] When `parser = "external"`, the wrapper's stderr is written to the log file independently (not through the parser pipe) +- [x] When `parser = "external"`, the parser's stderr is also written to the log file +- [x] When `parser = "external"` and `parser_command` is absent from manifest.toml, `CustomWrapper::spawn` returns `Err` before spawning any process; the error message names the manifest file path and states that `parser_command` is required +- [x] When `parser = "external"` and `parser_command` names a binary not found in PATH and is not an absolute path to an existing file, `CustomWrapper::spawn` returns `Err` before spawning any process; the error message names the missing binary +- [x] `apm validate` reports an error when a custom wrapper's manifest.toml declares `parser = "external"` but `parser_command` is absent +- [x] Built-in wrappers (e.g. the `claude` built-in) always behave as `parser = "canonical"` regardless of any manifest file; no manifest is required or consulted for them +- [x] `CustomWrapper::spawn` for external mode returns the parser's `Child` handle; the wrapper child is reaped in a background thread so it does not become a zombie +- [x] When `parser = "external"`, the worker's exit status is taken from the parser's exit code; the wrapper's exit code is appended to the log file as a diagnostic line (e.g. `[apm] wrapper exited: exit status: 0`) but does not affect ticket state; if the wrapper exits non-zero before the parser has drained its stdin, the parser is allowed to finish naturally before APM reaps both +- [x] When `parser = "external"`, all three streams (parser stdout, parser stderr, wrapper stderr) are written to `.apm-worker.log` without truncation, even when one stream produces output much faster than another; ordering between streams is best-effort but no bytes from any stream may be dropped ### Out of scope From 3bf0f89f7b14009b21fa67de95f70ab663f5d909 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:49:00 -0700 Subject: [PATCH 300/305] =?UTF-8?q?ticket(2803bf07):=20in=5Fprogress=20?= =?UTF-8?q?=E2=86=92=20implemented?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/2803bf07-output-parser-strategy-external-parsers-.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/2803bf07-output-parser-strategy-external-parsers-.md b/tickets/2803bf07-output-parser-strategy-external-parsers-.md index 58750f08b..0288e1b90 100644 --- a/tickets/2803bf07-output-parser-strategy-external-parsers-.md +++ b/tickets/2803bf07-output-parser-strategy-external-parsers-.md @@ -1,7 +1,7 @@ +++ id = "2803bf07" title = "Output parser strategy: external parsers via manifest.toml" -state = "in_progress" +state = "implemented" priority = 0 effort = 5 risk = 3 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/2803bf07-output-parser-strategy-external-parsers-" created_at = "2026-04-30T20:05:40.844536Z" -updated_at = "2026-05-01T19:41:14.617740Z" +updated_at = "2026-05-01T19:49:00.293664Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["2c32a282", "2e772eab"] @@ -206,3 +206,4 @@ Use absolute paths for `parser_command` in the integration test to avoid dependi | 2026-05-01T03:16Z | in_design | specd | claude-0501-0309-1140 | | 2026-05-01T17:38Z | specd | ready | philippepascal | | 2026-05-01T19:41Z | ready | in_progress | philippepascal | +| 2026-05-01T19:49Z | in_progress | implemented | claude-0501-1941-91c8 | From 16565c146dfa316fe360a3be1f8d4d99a764bf62 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 12:49:18 -0700 Subject: [PATCH 301/305] =?UTF-8?q?ticket(25c92daa):=20start=20=E2=80=94?= =?UTF-8?q?=20ready=20=E2=86=92=20in=5Fprogress?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md index 84bb8d8f4..39689ffb1 100644 --- a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md +++ b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md @@ -1,7 +1,7 @@ +++ id = "25c92daa" title = "Mock and debug built-in wrappers (mock-happy, mock-sad, mock-random, debug)" -state = "ready" +state = "in_progress" priority = 0 effort = 6 risk = 4 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/25c92daa-mock-and-debug-built-in-wrappers-mock-ha" created_at = "2026-04-30T20:04:21.901984Z" -updated_at = "2026-05-01T17:38:14.758614Z" +updated_at = "2026-05-01T19:49:18.097036Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "a1b94ea4", "6cac8518"] @@ -645,3 +645,4 @@ This wrapper is a mock — see docs/agent-wrappers.md. | 2026-05-01T01:43Z | ammend | in_design | philippepascal | | 2026-05-01T01:57Z | in_design | specd | claude-0501-0143-f3e8 | | 2026-05-01T17:38Z | specd | ready | philippepascal | +| 2026-05-01T19:49Z | ready | in_progress | philippepascal | From b36d4c1f9cc5d9b1f6072559564b48472c922be1 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 13:16:20 -0700 Subject: [PATCH 302/305] Add mock-happy, mock-sad, mock-random, debug built-in wrappers Register four new built-in wrappers in the dispatcher. Add WrapperContext.current_state field and wire all call sites. Create wrapper/builtin/ module structure with shared helpers. Annotate in_design and ammend to-specd transitions with outcome=success. Add per-agent instruction stubs for all four new wrappers. --- .apm/workflow.toml | 2 + apm-core/src/agents.rs | 1 + apm-core/src/config.rs | 2 +- .../default/agents/debug/apm.spec-writer.md | 1 + .../src/default/agents/debug/apm.worker.md | 1 + .../agents/mock-happy/apm.spec-writer.md | 1 + .../default/agents/mock-happy/apm.worker.md | 1 + .../agents/mock-random/apm.spec-writer.md | 1 + .../default/agents/mock-random/apm.worker.md | 1 + .../agents/mock-sad/apm.spec-writer.md | 1 + .../src/default/agents/mock-sad/apm.worker.md | 1 + apm-core/src/default/workflow.toml | 4 +- apm-core/src/start.rs | 527 ++++++++++++++++++ apm-core/src/wrapper/{ => builtin}/claude.rs | 2 +- apm-core/src/wrapper/builtin/debug.rs | 19 + apm-core/src/wrapper/builtin/mock_happy.rs | 29 + apm-core/src/wrapper/builtin/mock_random.rs | 28 + apm-core/src/wrapper/builtin/mock_sad.rs | 25 + apm-core/src/wrapper/builtin/mod.rs | 172 ++++++ apm-core/src/wrapper/custom.rs | 1 + apm-core/src/wrapper/mod.rs | 36 +- apm-core/tests/custom_wrapper_integration.rs | 5 + 22 files changed, 851 insertions(+), 10 deletions(-) create mode 100644 apm-core/src/default/agents/debug/apm.spec-writer.md create mode 100644 apm-core/src/default/agents/debug/apm.worker.md create mode 100644 apm-core/src/default/agents/mock-happy/apm.spec-writer.md create mode 100644 apm-core/src/default/agents/mock-happy/apm.worker.md create mode 100644 apm-core/src/default/agents/mock-random/apm.spec-writer.md create mode 100644 apm-core/src/default/agents/mock-random/apm.worker.md create mode 100644 apm-core/src/default/agents/mock-sad/apm.spec-writer.md create mode 100644 apm-core/src/default/agents/mock-sad/apm.worker.md rename apm-core/src/wrapper/{ => builtin}/claude.rs (98%) create mode 100644 apm-core/src/wrapper/builtin/debug.rs create mode 100644 apm-core/src/wrapper/builtin/mock_happy.rs create mode 100644 apm-core/src/wrapper/builtin/mock_random.rs create mode 100644 apm-core/src/wrapper/builtin/mock_sad.rs create mode 100644 apm-core/src/wrapper/builtin/mod.rs diff --git a/.apm/workflow.toml b/.apm/workflow.toml index 218afd935..e9cf1d139 100644 --- a/.apm/workflow.toml +++ b/.apm/workflow.toml @@ -77,6 +77,7 @@ instructions = ".apm/apm.spec-writer.md" [[workflow.states.transitions]] to = "specd" trigger = "manual" + outcome = "success" [[workflow.states.transitions]] to = "question" @@ -100,6 +101,7 @@ instructions = ".apm/apm.spec-writer.md" [[workflow.states.transitions]] to = "specd" trigger = "manual" + outcome = "success" [[workflow.states.transitions]] to = "question" diff --git a/apm-core/src/agents.rs b/apm-core/src/agents.rs index 44116a7fc..a9b1da0b7 100644 --- a/apm-core/src/agents.rs +++ b/apm-core/src/agents.rs @@ -225,6 +225,7 @@ pub fn test_wrapper(root: &Path, name: &str) -> Result { extra_env: HashMap::new(), root: root.to_path_buf(), keychain: HashMap::new(), + current_state: "test".to_string(), }; let start = std::time::Instant::now(); diff --git a/apm-core/src/config.rs b/apm-core/src/config.rs index 6b5451bb5..d0057bc9f 100644 --- a/apm-core/src/config.rs +++ b/apm-core/src/config.rs @@ -316,7 +316,7 @@ impl Default for SatisfiesDeps { } /// A single state in the workflow state machine. -#[derive(Debug, Deserialize, JsonSchema)] +#[derive(Debug, Clone, Deserialize, JsonSchema)] pub struct StateConfig { /// Unique state identifier (e.g. `new`, `in_progress`). Used in ticket frontmatter and transition targets. pub id: String, diff --git a/apm-core/src/default/agents/debug/apm.spec-writer.md b/apm-core/src/default/agents/debug/apm.spec-writer.md new file mode 100644 index 000000000..0d1c2a37b --- /dev/null +++ b/apm-core/src/default/agents/debug/apm.spec-writer.md @@ -0,0 +1 @@ +This wrapper is a mock — see docs/agent-wrappers.md. diff --git a/apm-core/src/default/agents/debug/apm.worker.md b/apm-core/src/default/agents/debug/apm.worker.md new file mode 100644 index 000000000..0d1c2a37b --- /dev/null +++ b/apm-core/src/default/agents/debug/apm.worker.md @@ -0,0 +1 @@ +This wrapper is a mock — see docs/agent-wrappers.md. diff --git a/apm-core/src/default/agents/mock-happy/apm.spec-writer.md b/apm-core/src/default/agents/mock-happy/apm.spec-writer.md new file mode 100644 index 000000000..0d1c2a37b --- /dev/null +++ b/apm-core/src/default/agents/mock-happy/apm.spec-writer.md @@ -0,0 +1 @@ +This wrapper is a mock — see docs/agent-wrappers.md. diff --git a/apm-core/src/default/agents/mock-happy/apm.worker.md b/apm-core/src/default/agents/mock-happy/apm.worker.md new file mode 100644 index 000000000..0d1c2a37b --- /dev/null +++ b/apm-core/src/default/agents/mock-happy/apm.worker.md @@ -0,0 +1 @@ +This wrapper is a mock — see docs/agent-wrappers.md. diff --git a/apm-core/src/default/agents/mock-random/apm.spec-writer.md b/apm-core/src/default/agents/mock-random/apm.spec-writer.md new file mode 100644 index 000000000..0d1c2a37b --- /dev/null +++ b/apm-core/src/default/agents/mock-random/apm.spec-writer.md @@ -0,0 +1 @@ +This wrapper is a mock — see docs/agent-wrappers.md. diff --git a/apm-core/src/default/agents/mock-random/apm.worker.md b/apm-core/src/default/agents/mock-random/apm.worker.md new file mode 100644 index 000000000..0d1c2a37b --- /dev/null +++ b/apm-core/src/default/agents/mock-random/apm.worker.md @@ -0,0 +1 @@ +This wrapper is a mock — see docs/agent-wrappers.md. diff --git a/apm-core/src/default/agents/mock-sad/apm.spec-writer.md b/apm-core/src/default/agents/mock-sad/apm.spec-writer.md new file mode 100644 index 000000000..0d1c2a37b --- /dev/null +++ b/apm-core/src/default/agents/mock-sad/apm.spec-writer.md @@ -0,0 +1 @@ +This wrapper is a mock — see docs/agent-wrappers.md. diff --git a/apm-core/src/default/agents/mock-sad/apm.worker.md b/apm-core/src/default/agents/mock-sad/apm.worker.md new file mode 100644 index 000000000..0d1c2a37b --- /dev/null +++ b/apm-core/src/default/agents/mock-sad/apm.worker.md @@ -0,0 +1 @@ +This wrapper is a mock — see docs/agent-wrappers.md. diff --git a/apm-core/src/default/workflow.toml b/apm-core/src/default/workflow.toml index c8bdf86b1..af50cf937 100644 --- a/apm-core/src/default/workflow.toml +++ b/apm-core/src/default/workflow.toml @@ -81,7 +81,7 @@ instructions = ".apm/apm.spec-writer.md" [[workflow.states.transitions]] to = "specd" trigger = "manual" - outcome = "needs_input" + outcome = "success" [[workflow.states.transitions]] to = "question" @@ -107,7 +107,7 @@ instructions = ".apm/apm.spec-writer.md" [[workflow.states.transitions]] to = "specd" trigger = "manual" - outcome = "needs_input" + outcome = "success" [[workflow.states.transitions]] to = "question" diff --git a/apm-core/src/start.rs b/apm-core/src/start.rs index d098346fe..7f1bfedba 100644 --- a/apm-core/src/start.rs +++ b/apm-core/src/start.rs @@ -6,6 +6,14 @@ use std::path::{Path, PathBuf}; const CLAUDE_WORKER_DEFAULT: &str = include_str!("default/agents/claude/apm.worker.md"); const CLAUDE_SPEC_WRITER_DEFAULT: &str = include_str!("default/agents/claude/apm.spec-writer.md"); +const MOCK_HAPPY_WORKER_DEFAULT: &str = include_str!("default/agents/mock-happy/apm.worker.md"); +const MOCK_HAPPY_SPEC_WRITER_DEFAULT: &str = include_str!("default/agents/mock-happy/apm.spec-writer.md"); +const MOCK_SAD_WORKER_DEFAULT: &str = include_str!("default/agents/mock-sad/apm.worker.md"); +const MOCK_SAD_SPEC_WRITER_DEFAULT: &str = include_str!("default/agents/mock-sad/apm.spec-writer.md"); +const MOCK_RANDOM_WORKER_DEFAULT: &str = include_str!("default/agents/mock-random/apm.worker.md"); +const MOCK_RANDOM_SPEC_WRITER_DEFAULT: &str = include_str!("default/agents/mock-random/apm.spec-writer.md"); +const DEBUG_WORKER_DEFAULT: &str = include_str!("default/agents/debug/apm.worker.md"); +const DEBUG_SPEC_WRITER_DEFAULT: &str = include_str!("default/agents/debug/apm.spec-writer.md"); static DEPRECATION_WARNED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); @@ -333,6 +341,7 @@ pub fn run(root: &Path, id_arg: &str, no_aggressive: bool, spawn: bool, skip_per extra_env: params.env.clone(), root: root.to_path_buf(), keychain: config.workers.keychain.clone(), + current_state: new_state.clone(), }; check_output_format_supported(¶ms.command)?; let mut child = spawn_worker(&ctx, ¶ms.agent, root)?; @@ -526,6 +535,7 @@ pub fn run_next(root: &Path, no_aggressive: bool, spawn: bool, skip_permissions: extra_env: params.env.clone(), root: root.to_path_buf(), keychain: config.workers.keychain.clone(), + current_state: t.frontmatter.state.clone(), }; check_output_format_supported(¶ms.command)?; let mut child = spawn_worker(&ctx, ¶ms.agent, root)?; @@ -709,6 +719,7 @@ pub fn spawn_next_worker( extra_env: params.env.clone(), root: root.to_path_buf(), keychain: config.workers.keychain.clone(), + current_state: t.frontmatter.state.clone(), }; check_output_format_supported(¶ms.command)?; let child = spawn_worker(&ctx, ¶ms.agent, root)?; @@ -757,6 +768,14 @@ fn resolve_builtin_instructions(agent: &str, role: &str) -> Option<&'static str> match (agent, role) { ("claude", "worker") => Some(CLAUDE_WORKER_DEFAULT), ("claude", "spec-writer") => Some(CLAUDE_SPEC_WRITER_DEFAULT), + ("mock-happy", "worker") => Some(MOCK_HAPPY_WORKER_DEFAULT), + ("mock-happy", "spec-writer") => Some(MOCK_HAPPY_SPEC_WRITER_DEFAULT), + ("mock-sad", "worker") => Some(MOCK_SAD_WORKER_DEFAULT), + ("mock-sad", "spec-writer") => Some(MOCK_SAD_SPEC_WRITER_DEFAULT), + ("mock-random", "worker") => Some(MOCK_RANDOM_WORKER_DEFAULT), + ("mock-random", "spec-writer") => Some(MOCK_RANDOM_SPEC_WRITER_DEFAULT), + ("debug", "worker") => Some(DEBUG_WORKER_DEFAULT), + ("debug", "spec-writer") => Some(DEBUG_SPEC_WRITER_DEFAULT), _ => None, } } @@ -1257,6 +1276,7 @@ mod tests { extra_env, root: wt.path().to_path_buf(), keychain: HashMap::new(), + current_state: "in_progress".to_string(), }; let wrapper = crate::wrapper::resolve_builtin("claude").unwrap(); @@ -1352,6 +1372,7 @@ mod tests { extra_env, root: wt.path().to_path_buf(), keychain: HashMap::new(), + current_state: "in_progress".to_string(), }; let wrapper = crate::wrapper::resolve_builtin("claude").unwrap(); @@ -1425,6 +1446,7 @@ mod tests { extra_env, root: wt.path().to_path_buf(), keychain: HashMap::new(), + current_state: "in_progress".to_string(), }; let wrapper = crate::wrapper::resolve_builtin("claude").unwrap(); @@ -1545,6 +1567,7 @@ mod tests { extra_env, root: wt.path().to_path_buf(), keychain: HashMap::new(), + current_state: "in_progress".to_string(), }; let wrapper = crate::wrapper::resolve_builtin("claude").unwrap(); @@ -1606,4 +1629,508 @@ mod tests { apply_frontmatter_agent(&mut agent, &fm, "impl_agent"); assert_eq!(agent, "claude"); } + + // --- mock wrapper integration tests --- + + fn find_apm_bin() -> Option { + if let Ok(v) = std::env::var("APM_BIN") { + if !v.is_empty() && std::path::Path::new(&v).exists() { + return Some(v); + } + } + let out = std::process::Command::new("which").arg("apm").output().ok()?; + if out.status.success() { + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if !s.is_empty() { return Some(s); } + } + None + } + + fn make_mock_project(root: &std::path::Path, ticket_state: &str, ticket_id: &str) { + use std::fs; + + fs::create_dir_all(root.join(".apm/agents/claude")).unwrap(); + fs::create_dir_all(root.join("tickets")).unwrap(); + + fs::write(root.join(".apm/config.toml"), r#" +[project] +name = "test-project" +default_branch = "main" + +[workers] +agent = "mock-happy" + +[tickets] +dir = "tickets" +"#).unwrap(); + + fs::write(root.join(".apm/workflow.toml"), r#" +[[workflow.states]] +id = "in_design" +label = "In Design" +actionable = ["agent"] +instructions = ".apm/apm.spec-writer.md" + + [[workflow.states.transitions]] + to = "specd" + trigger = "manual" + outcome = "success" + + [[workflow.states.transitions]] + to = "closed" + trigger = "manual" + outcome = "cancelled" + +[[workflow.states]] +id = "specd" +label = "Specd" +actionable = ["supervisor"] +satisfies_deps = true +worker_end = true + + [[workflow.states.transitions]] + to = "in_progress" + trigger = "manual" + outcome = "success" + + [[workflow.states.transitions]] + to = "closed" + trigger = "manual" + outcome = "cancelled" + +[[workflow.states]] +id = "in_progress" +label = "In Progress" +instructions = ".apm/apm.worker.md" + + [[workflow.states.transitions]] + to = "implemented" + trigger = "manual" + outcome = "success" + + [[workflow.states.transitions]] + to = "closed" + trigger = "manual" + outcome = "cancelled" + +[[workflow.states]] +id = "implemented" +label = "Implemented" +actionable = ["supervisor"] +satisfies_deps = true +worker_end = true +terminal = false + + [[workflow.states.transitions]] + to = "closed" + trigger = "manual" + outcome = "cancelled" + +[[workflow.states]] +id = "closed" +label = "Closed" +terminal = true +"#).unwrap(); + + fs::write(root.join(".apm/apm.worker.md"), "Worker instructions.").unwrap(); + fs::write(root.join(".apm/apm.spec-writer.md"), "Spec writer instructions.").unwrap(); + + let ticket_content = format!(r#"+++ +id = "{ticket_id}" +title = "Test Ticket" +state = "{ticket_state}" +priority = 0 +effort = 5 +risk = 3 +author = "test" +owner = "test" +branch = "ticket/{ticket_id}-test" +created_at = "2026-01-01T00:00:00Z" +updated_at = "2026-01-01T00:00:00Z" ++++ + +## Spec + +### Problem + +Original problem. + +### Acceptance criteria + +- [ ] Some criterion + +### Out of scope + +Nothing. + +### Approach + +Some approach. + +### Open questions + +### Amendment requests + +### Code review + +## History + +| When | From | To | By | +|------|------|----|----| +"#); + fs::write(root.join(format!("tickets/{ticket_id}-test.md")), ticket_content).unwrap(); + + std::process::Command::new("git") + .arg("init") + .current_dir(root) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(root) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(root) + .output() + .unwrap(); + // Create main branch with config files + std::process::Command::new("git") + .args(["add", ".apm"]) + .current_dir(root) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["commit", "-m", "initial commit", "--allow-empty"]) + .current_dir(root) + .output() + .unwrap(); + // Create the ticket branch and commit the ticket there + let branch_name = format!("ticket/{ticket_id}-test"); + std::process::Command::new("git") + .args(["checkout", "-b", &branch_name]) + .current_dir(root) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["add", &format!("tickets/{ticket_id}-test.md")]) + .current_dir(root) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["commit", "-m", &format!("ticket({ticket_id}): created")]) + .current_dir(root) + .output() + .unwrap(); + // Switch back to main + std::process::Command::new("git") + .args(["checkout", "main"]) + .current_dir(root) + .output() + .unwrap(); + } + + fn make_wrapper_ctx_for_mock( + project_root: &std::path::Path, + ticket_id: &str, + ticket_state: &str, + apm_bin: &str, + log_path: std::path::PathBuf, + ) -> crate::wrapper::WrapperContext { + let sys_file = crate::wrapper::write_temp_file("sys", "system prompt").unwrap(); + let msg_file = crate::wrapper::write_temp_file("msg", "ticket content").unwrap(); + let mut options = HashMap::new(); + options.insert("apm_bin".to_string(), apm_bin.to_string()); + crate::wrapper::WrapperContext { + worker_name: "test-worker".to_string(), + ticket_id: ticket_id.to_string(), + ticket_branch: format!("ticket/{ticket_id}-test"), + worktree_path: project_root.to_path_buf(), + system_prompt_file: sys_file, + user_message_file: msg_file, + skip_permissions: false, + profile: "default".to_string(), + role_prefix: None, + options, + model: None, + log_path, + container: None, + extra_env: HashMap::new(), + root: project_root.to_path_buf(), + keychain: HashMap::new(), + current_state: ticket_state.to_string(), + } + } + + #[test] + fn mock_happy_spec_mode_transitions_to_specd() { + let apm_bin = match find_apm_bin() { Some(b) => b, None => return }; + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + make_mock_project(root, "in_design", "aaaa0001"); + let log_path = root.join("test-worker.log"); + let ctx = make_wrapper_ctx_for_mock(root, "aaaa0001", "in_design", &apm_bin, log_path.clone()); + let wrapper = crate::wrapper::resolve_builtin("mock-happy").unwrap(); + let mut child = wrapper.spawn(&ctx).unwrap(); + child.wait().unwrap(); + + let log_content = std::fs::read_to_string(&log_path).unwrap_or_default(); + // Read ticket from the ticket branch (where apm commits changes) + let ticket_from_branch = { + let out = std::process::Command::new("git") + .args(["show", "ticket/aaaa0001-test:tickets/aaaa0001-test.md"]) + .current_dir(root) + .output() + .unwrap(); + String::from_utf8_lossy(&out.stdout).to_string() + }; + assert!(ticket_from_branch.contains("state = \"specd\""), + "ticket should be in specd state\nticket_from_branch: {ticket_from_branch}\nlog: {log_content}"); + assert!(ticket_from_branch.contains("### Problem"), + "ticket should have Problem section\n{ticket_from_branch}"); + assert!(ticket_from_branch.contains("effort = 1"), + "effort should be 1\n{ticket_from_branch}"); + assert!(ticket_from_branch.contains("risk = 1"), + "risk should be 1\n{ticket_from_branch}"); + } + + #[test] + fn mock_happy_zero_success_transitions_returns_err() { + use std::fs; + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + + fs::create_dir_all(root.join(".apm/agents/claude")).unwrap(); + fs::create_dir_all(root.join("tickets")).unwrap(); + fs::write(root.join(".apm/config.toml"), r#" +[project] +name = "test" +default_branch = "main" +[workers] +agent = "mock-happy" +[tickets] +dir = "tickets" +"#).unwrap(); + fs::write(root.join(".apm/workflow.toml"), r#" +[[workflow.states]] +id = "in_design" +label = "In Design" +actionable = ["agent"] + + [[workflow.states.transitions]] + to = "closed" + trigger = "manual" + outcome = "needs_input" + +[[workflow.states]] +id = "closed" +label = "Closed" +terminal = true +"#).unwrap(); + fs::write(root.join(".apm/apm.worker.md"), "instructions").unwrap(); + fs::write(root.join(".apm/apm.spec-writer.md"), "instructions").unwrap(); + let ticket_content = r#"+++ +id = "aaaa0002" +title = "Test" +state = "in_design" +priority = 0 +effort = 5 +risk = 3 +author = "test" +owner = "test" +branch = "ticket/aaaa0002-test" +created_at = "2026-01-01T00:00:00Z" +updated_at = "2026-01-01T00:00:00Z" ++++ + +## Spec + +### Problem + +### Acceptance criteria + +### Out of scope + +### Approach + +## History + +| When | From | To | By | +|------|------|----|----| +"#; + fs::write(root.join("tickets/aaaa0002-test.md"), ticket_content).unwrap(); + std::process::Command::new("git").args(["init"]).current_dir(root).output().unwrap(); + std::process::Command::new("git").args(["config", "user.email", "t@t.com"]).current_dir(root).output().unwrap(); + std::process::Command::new("git").args(["config", "user.name", "T"]).current_dir(root).output().unwrap(); + std::process::Command::new("git").args(["add", "."]).current_dir(root).output().unwrap(); + std::process::Command::new("git").args(["commit", "-m", "init"]).current_dir(root).output().unwrap(); + + let log_path = root.join("test.log"); + let sys_file = crate::wrapper::write_temp_file("sys", "sys").unwrap(); + let msg_file = crate::wrapper::write_temp_file("msg", "msg").unwrap(); + let ctx = crate::wrapper::WrapperContext { + worker_name: "test".to_string(), + ticket_id: "aaaa0002".to_string(), + ticket_branch: "ticket/aaaa0002-test".to_string(), + worktree_path: root.to_path_buf(), + system_prompt_file: sys_file, + user_message_file: msg_file, + skip_permissions: false, + profile: "default".to_string(), + role_prefix: None, + options: HashMap::new(), + model: None, + log_path, + container: None, + extra_env: HashMap::new(), + root: root.to_path_buf(), + keychain: HashMap::new(), + current_state: "in_design".to_string(), + }; + let wrapper = crate::wrapper::resolve_builtin("mock-happy").unwrap(); + let result = wrapper.spawn(&ctx); + assert!(result.is_err(), "mock-happy should return Err when no success transitions"); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("no success-outcome transition"), "error should mention no success transition: {msg}"); + } + + #[test] + fn mock_sad_transitions_to_non_success_state() { + let apm_bin = match find_apm_bin() { Some(b) => b, None => return }; + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + make_mock_project(root, "in_design", "aaaa0003"); + let log_path = root.join("test.log"); + let ctx = make_wrapper_ctx_for_mock(root, "aaaa0003", "in_design", &apm_bin, log_path.clone()); + let wrapper = crate::wrapper::resolve_builtin("mock-sad").unwrap(); + let mut child = wrapper.spawn(&ctx).unwrap(); + child.wait().unwrap(); + + let log_content = std::fs::read_to_string(&log_path).unwrap_or_default(); + let out = std::process::Command::new("git") + .args(["show", "ticket/aaaa0003-test:tickets/aaaa0003-test.md"]) + .current_dir(root) + .output() + .unwrap(); + let ticket_from_branch = String::from_utf8_lossy(&out.stdout).to_string(); + assert!(!ticket_from_branch.contains("state = \"specd\""), + "mock-sad should NOT transition to specd\n{ticket_from_branch}\nlog: {log_content}"); + // Should have transitioned to some other state + assert!(ticket_from_branch.contains("state = \"closed\"") || ticket_from_branch.contains("state = \"in_design\""), + "mock-sad should transition to a non-success state\n{ticket_from_branch}\nlog: {log_content}"); + } + + #[test] + fn mock_sad_seed_reproducibility() { + let apm_bin = match find_apm_bin() { Some(b) => b, None => return }; + + let run_mock_sad = |ticket_id: &str, seed: &str| -> String { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + make_mock_project(root, "in_design", ticket_id); + let log_path = root.join("test.log"); + let mut options = HashMap::new(); + options.insert("apm_bin".to_string(), apm_bin.clone()); + options.insert("seed".to_string(), seed.to_string()); + let sys_file = crate::wrapper::write_temp_file("sys", "sys").unwrap(); + let msg_file = crate::wrapper::write_temp_file("msg", "msg").unwrap(); + let ctx = crate::wrapper::WrapperContext { + worker_name: "test".to_string(), + ticket_id: ticket_id.to_string(), + ticket_branch: format!("ticket/{ticket_id}-test"), + worktree_path: root.to_path_buf(), + system_prompt_file: sys_file, + user_message_file: msg_file, + skip_permissions: false, + profile: "default".to_string(), + role_prefix: None, + options, + model: None, + log_path, + container: None, + extra_env: HashMap::new(), + root: root.to_path_buf(), + keychain: HashMap::new(), + current_state: "in_design".to_string(), + }; + let wrapper = crate::wrapper::resolve_builtin("mock-sad").unwrap(); + let mut child = wrapper.spawn(&ctx).unwrap(); + child.wait().unwrap(); + + // Read state from ticket branch (where apm commits changes) + let git_content = { + let o = std::process::Command::new("git") + .args(["show", &format!("ticket/{ticket_id}-test:tickets/{ticket_id}-test.md")]) + .current_dir(root) + .output() + .unwrap(); + String::from_utf8_lossy(&o.stdout).to_string() + }; + for line in git_content.lines() { + if line.starts_with("state = ") { + return line.to_string(); + } + } + "unknown".to_string() + }; + + let state1 = run_mock_sad("aaaa000a", "42"); + let state2 = run_mock_sad("aaaa000b", "42"); + assert_eq!(state1, state2, "mock-sad with same seed should pick same target state"); + } + + #[test] + fn debug_does_not_change_state() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + make_mock_project(root, "in_design", "aaaa0005"); + let log_path = root.join("test.log"); + let sys_file = crate::wrapper::write_temp_file("sys", "debug-system-prompt-unique-text").unwrap(); + let msg_file = crate::wrapper::write_temp_file("msg", "debug-message").unwrap(); + let ctx = crate::wrapper::WrapperContext { + worker_name: "test-worker".to_string(), + ticket_id: "aaaa0005".to_string(), + ticket_branch: "ticket/aaaa0005-test".to_string(), + worktree_path: root.to_path_buf(), + system_prompt_file: sys_file, + user_message_file: msg_file, + skip_permissions: false, + profile: "default".to_string(), + role_prefix: None, + options: HashMap::new(), + model: None, + log_path: log_path.clone(), + container: None, + extra_env: HashMap::new(), + root: root.to_path_buf(), + keychain: HashMap::new(), + current_state: "in_design".to_string(), + }; + let wrapper = crate::wrapper::resolve_builtin("debug").unwrap(); + let mut child = wrapper.spawn(&ctx).unwrap(); + child.wait().unwrap(); + + // State should still be in_design (debug doesn't commit or transition) + // Read from the ticket branch (HEAD of main won't have the ticket) + let git_content = { + let o = std::process::Command::new("git") + .args(["show", "ticket/aaaa0005-test:tickets/aaaa0005-test.md"]) + .current_dir(root) + .output() + .unwrap(); + String::from_utf8_lossy(&o.stdout).to_string() + }; + assert!(git_content.contains("state = \"in_design\""), + "debug should not change ticket state\n{git_content}"); + + // Log file should contain APM env vars and system prompt text + let log_content = std::fs::read_to_string(&log_path).unwrap_or_default(); + assert!(log_content.contains("APM_TICKET_ID"), + "log should contain APM_TICKET_ID\n{log_content}"); + assert!(log_content.contains("debug-system-prompt-unique-text"), + "log should contain system prompt text\n{log_content}"); + assert!(log_content.contains("\"type\":\"tool_use\""), + "log should contain tool_use JSONL\n{log_content}"); + } } diff --git a/apm-core/src/wrapper/claude.rs b/apm-core/src/wrapper/builtin/claude.rs similarity index 98% rename from apm-core/src/wrapper/claude.rs rename to apm-core/src/wrapper/builtin/claude.rs index 63b5bbded..16a104343 100644 --- a/apm-core/src/wrapper/claude.rs +++ b/apm-core/src/wrapper/builtin/claude.rs @@ -1,5 +1,5 @@ use std::os::unix::process::CommandExt; -use super::{Wrapper, WrapperContext, CONTRACT_VERSION}; +use crate::wrapper::{Wrapper, WrapperContext, CONTRACT_VERSION}; pub struct ClaudeWrapper; diff --git a/apm-core/src/wrapper/builtin/debug.rs b/apm-core/src/wrapper/builtin/debug.rs new file mode 100644 index 000000000..75fbfa6b9 --- /dev/null +++ b/apm-core/src/wrapper/builtin/debug.rs @@ -0,0 +1,19 @@ +use super::write_and_spawn_script; +use crate::wrapper::{Wrapper, WrapperContext}; + +pub struct DebugWrapper; + +impl Wrapper for DebugWrapper { + fn spawn(&self, ctx: &WrapperContext) -> anyhow::Result { + let script = r#"#!/bin/sh +env | grep '^APM_' >&2 +printf '\n=== SYSTEM PROMPT ===\n' >&2 +cat "$APM_SYSTEM_PROMPT_FILE" >&2 +printf '\n=== USER MESSAGE ===\n' >&2 +cat "$APM_USER_MESSAGE_FILE" >&2 +printf '{"type":"tool_use","id":"debug-1","name":"noop","input":{}}\n' +rm -f "$0" +"#; + write_and_spawn_script("debug", script, ctx) + } +} diff --git a/apm-core/src/wrapper/builtin/mock_happy.rs b/apm-core/src/wrapper/builtin/mock_happy.rs new file mode 100644 index 000000000..78346be57 --- /dev/null +++ b/apm-core/src/wrapper/builtin/mock_happy.rs @@ -0,0 +1,29 @@ +use crate::config::resolve_outcome; +use super::{load_transitions_with_outcomes, is_impl_mode, happy_script, write_and_spawn_script}; +use crate::wrapper::{Wrapper, WrapperContext}; + +pub struct MockHappyWrapper; + +impl Wrapper for MockHappyWrapper { + fn spawn(&self, ctx: &WrapperContext) -> anyhow::Result { + let transitions = load_transitions_with_outcomes(ctx)?; + let success: Vec<_> = transitions.iter() + .filter(|(t, s)| resolve_outcome(t, s) == "success") + .collect(); + match success.len() { + 0 => anyhow::bail!( + "mock-happy: no success-outcome transition from state '{}'", + ctx.current_state + ), + 1 => {}, + n => anyhow::bail!( + "mock-happy: {} success-outcome transitions found from state '{}'; expected exactly 1", + n, ctx.current_state + ), + } + let target = success[0].0.to.clone(); + let impl_mode = is_impl_mode(&transitions); + let script = happy_script(&ctx.ticket_id, &target, impl_mode); + write_and_spawn_script("happy", &script, ctx) + } +} diff --git a/apm-core/src/wrapper/builtin/mock_random.rs b/apm-core/src/wrapper/builtin/mock_random.rs new file mode 100644 index 000000000..c4ff63e1d --- /dev/null +++ b/apm-core/src/wrapper/builtin/mock_random.rs @@ -0,0 +1,28 @@ +use crate::config::resolve_outcome; +use super::{load_transitions_with_outcomes, is_impl_mode, happy_script, sad_script, seed_from_ctx, write_and_spawn_script}; +use crate::wrapper::{Wrapper, WrapperContext}; + +pub struct MockRandomWrapper; + +impl Wrapper for MockRandomWrapper { + fn spawn(&self, ctx: &WrapperContext) -> anyhow::Result { + let transitions = load_transitions_with_outcomes(ctx)?; + if transitions.is_empty() { + anyhow::bail!( + "mock-random: no valid transitions from state '{}'", + ctx.current_state + ); + } + let seed = seed_from_ctx(ctx); + let idx = (seed as usize) % transitions.len(); + let chosen = &transitions[idx]; + let outcome = resolve_outcome(&chosen.0, &chosen.1); + let target = chosen.0.to.clone(); + let script = if outcome == "success" { + happy_script(&ctx.ticket_id, &target, is_impl_mode(&transitions)) + } else { + sad_script(&ctx.ticket_id, &target) + }; + write_and_spawn_script("random", &script, ctx) + } +} diff --git a/apm-core/src/wrapper/builtin/mock_sad.rs b/apm-core/src/wrapper/builtin/mock_sad.rs new file mode 100644 index 000000000..6985f6534 --- /dev/null +++ b/apm-core/src/wrapper/builtin/mock_sad.rs @@ -0,0 +1,25 @@ +use crate::config::resolve_outcome; +use super::{load_transitions_with_outcomes, sad_script, seed_from_ctx, write_and_spawn_script}; +use crate::wrapper::{Wrapper, WrapperContext}; + +pub struct MockSadWrapper; + +impl Wrapper for MockSadWrapper { + fn spawn(&self, ctx: &WrapperContext) -> anyhow::Result { + let transitions = load_transitions_with_outcomes(ctx)?; + let eligible: Vec<_> = transitions.iter() + .filter(|(t, s)| resolve_outcome(t, s) != "success") + .collect(); + if eligible.is_empty() { + anyhow::bail!( + "mock-sad: no non-success transitions from state '{}'", + ctx.current_state + ); + } + let seed = seed_from_ctx(ctx); + let idx = (seed as usize) % eligible.len(); + let target = eligible[idx].0.to.clone(); + let script = sad_script(&ctx.ticket_id, &target); + write_and_spawn_script("sad", &script, ctx) + } +} diff --git a/apm-core/src/wrapper/builtin/mod.rs b/apm-core/src/wrapper/builtin/mod.rs new file mode 100644 index 000000000..dd993de5f --- /dev/null +++ b/apm-core/src/wrapper/builtin/mod.rs @@ -0,0 +1,172 @@ +pub mod claude; +pub mod mock_happy; +pub mod mock_sad; +pub mod mock_random; +pub mod debug; + +pub use claude::ClaudeWrapper; +pub use mock_happy::MockHappyWrapper; +pub use mock_sad::MockSadWrapper; +pub use mock_random::MockRandomWrapper; +pub use debug::DebugWrapper; + +use std::collections::HashMap; +use crate::config::{Config, TransitionConfig, StateConfig}; +use crate::wrapper::WrapperContext; + +pub(crate) fn load_transitions_with_outcomes( + ctx: &WrapperContext, +) -> anyhow::Result> { + let config = Config::load(&ctx.root)?; + let current = config.workflow.states.iter() + .find(|s| s.id == ctx.current_state) + .ok_or_else(|| anyhow::anyhow!("state '{}' not found in workflow", ctx.current_state))?; + let state_map: HashMap<&str, &StateConfig> = config.workflow.states.iter() + .map(|s| (s.id.as_str(), s)) + .collect(); + let mut result = Vec::new(); + for t in ¤t.transitions { + if let Some(&target) = state_map.get(t.to.as_str()) { + result.push((t.clone(), target.clone())); + } + } + Ok(result) +} + +pub(crate) fn is_impl_mode(transitions: &[(TransitionConfig, StateConfig)]) -> bool { + use crate::config::CompletionStrategy; + transitions.iter().any(|(t, _)| t.completion != CompletionStrategy::None) +} + +pub(crate) fn happy_script(id: &str, target: &str, impl_mode: bool) -> String { + if impl_mode { + format!( + r#"#!/bin/sh +set -e +APM="${{APM_BIN:?APM_BIN not set — see wrapper contract}}" +ID="{id}" +printf 'mock: placeholder implementation for ticket %s\n' "$ID" > mock-implementation.txt +git add mock-implementation.txt +git commit -m "mock: placeholder commit for ticket $ID" +printf '%s\n' '{{"type":"tool_use","id":"mock-1","name":"git_commit","input":{{}}}}' +printf '%s\n' '{{"type":"tool_use","id":"mock-2","name":"apm_state","input":{{}}}}' +"$APM" state "$ID" {target} +rm -f "$0" +"# + ) + } else { + format!( + r#"#!/bin/sh +set -e +APM="${{APM_BIN:?APM_BIN not set — see wrapper contract}}" +ID="{id}" +"$APM" spec "$ID" --section "Problem" --set "Mock spec — no real problem analyzed." +printf '%s\n' "- [ ] Mock criterion 1" "- [ ] Mock criterion 2" > ".apm-mock-ac-$$.txt" +"$APM" spec "$ID" --section "Acceptance criteria" --set-file ".apm-mock-ac-$$.txt" +rm -f ".apm-mock-ac-$$.txt" +"$APM" spec "$ID" --section "Out of scope" --set "Nothing in scope for this mock run" +"$APM" spec "$ID" --section "Approach" --set "Mock approach — no real implementation analyzed." +"$APM" set "$ID" effort 1 +"$APM" set "$ID" risk 1 +printf '%s\n' '{{"type":"tool_use","id":"mock-1","name":"write_spec","input":{{}}}}' +printf '%s\n' '{{"type":"tool_use","id":"mock-2","name":"apm_state","input":{{}}}}' +"$APM" state "$ID" {target} +rm -f "$0" +"# + ) + } +} + +pub(crate) fn sad_script(id: &str, target: &str) -> String { + format!( + r#"#!/bin/sh +set -e +APM="${{APM_BIN:?APM_BIN not set — see wrapper contract}}" +ID="{id}" +"$APM" spec "$ID" --section "Problem" --set "Mock sad run — spec intentionally incomplete." +printf '%s\n' '{{"type":"tool_use","id":"mock-1","name":"write_partial_spec","input":{{}}}}' +"$APM" state "$ID" {target} +rm -f "$0" +"# + ) +} + +pub(crate) fn seed_from_ctx(ctx: &WrapperContext) -> u64 { + // Check ctx.options["seed"] first (set by [workers.options] seed = "...") + if let Some(s) = ctx.options.get("seed").and_then(|s| s.parse().ok()) { + return s; + } + // Fall back to APM_OPT_SEED env var (for test injection or external scripts) + if let Some(s) = std::env::var("APM_OPT_SEED").ok().and_then(|s| s.parse().ok()) { + return s; + } + // Fall back to time-based random + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .subsec_nanos() as u64 +} + +pub(crate) fn write_and_spawn_script( + name: &str, + script: &str, + ctx: &WrapperContext, +) -> anyhow::Result { + use std::os::unix::fs::PermissionsExt; + use std::os::unix::process::CommandExt; + use crate::wrapper::CONTRACT_VERSION; + + // Write the script file + let script_path = ctx.worktree_path.join(format!(".apm-mock-{name}-{:04x}.sh", super::rand_u16())); + std::fs::write(&script_path, script)?; + std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755))?; + + // Determine APM_BIN: ctx.options["apm_bin"] for tests, else current_exe + let apm_bin = ctx.options.get("apm_bin") + .cloned() + .unwrap_or_else(|| { + std::env::current_exe() + .and_then(|p| p.canonicalize()) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default() + }); + + let mut cmd = std::process::Command::new("/bin/sh"); + cmd.arg(&script_path); + + // Set APM contract env vars + cmd.env("APM_AGENT_NAME", &ctx.worker_name); + cmd.env("APM_TICKET_ID", &ctx.ticket_id); + cmd.env("APM_TICKET_BRANCH", &ctx.ticket_branch); + cmd.env("APM_TICKET_WORKTREE", ctx.worktree_path.to_string_lossy().as_ref()); + cmd.env("APM_SYSTEM_PROMPT_FILE", ctx.system_prompt_file.to_string_lossy().as_ref()); + cmd.env("APM_USER_MESSAGE_FILE", ctx.user_message_file.to_string_lossy().as_ref()); + cmd.env("APM_SKIP_PERMISSIONS", if ctx.skip_permissions { "1" } else { "0" }); + cmd.env("APM_PROFILE", &ctx.profile); + if let Some(ref prefix) = ctx.role_prefix { + cmd.env("APM_ROLE_PREFIX", prefix); + } + cmd.env("APM_WRAPPER_VERSION", CONTRACT_VERSION.to_string()); + cmd.env("APM_BIN", &apm_bin); + cmd.env("APM_PROJECT_ROOT", ctx.root.to_string_lossy().as_ref()); + + // Forward options as APM_OPT_ + for (k, v) in &ctx.options { + let env_key = format!( + "APM_OPT_{}", + k.to_uppercase().replace('.', "_").replace('-', "_") + ); + cmd.env(&env_key, v); + } + + cmd.current_dir(&ctx.worktree_path); + cmd.process_group(0); + + let log_file = std::fs::File::create(&ctx.log_path)?; + let log_clone = log_file.try_clone()?; + cmd.stdout(log_file); + cmd.stderr(log_clone); + + Ok(cmd.spawn()?) +} diff --git a/apm-core/src/wrapper/custom.rs b/apm-core/src/wrapper/custom.rs index 4bffb6f98..04a590ff0 100644 --- a/apm-core/src/wrapper/custom.rs +++ b/apm-core/src/wrapper/custom.rs @@ -315,6 +315,7 @@ mod tests { extra_env: HashMap::new(), root: wt.to_path_buf(), keychain: HashMap::new(), + current_state: "test".to_string(), } } diff --git a/apm-core/src/wrapper/mod.rs b/apm-core/src/wrapper/mod.rs index 303a86576..d89cc69f3 100644 --- a/apm-core/src/wrapper/mod.rs +++ b/apm-core/src/wrapper/mod.rs @@ -1,6 +1,6 @@ -mod claude; +pub mod builtin; pub mod custom; -pub use claude::ClaudeWrapper; +pub use builtin::ClaudeWrapper; pub use custom::{WrapperKind, Manifest}; use std::collections::HashMap; @@ -25,6 +25,7 @@ pub struct WrapperContext { pub extra_env: HashMap, pub root: PathBuf, pub keychain: HashMap, + pub current_state: String, } pub trait Wrapper { @@ -33,13 +34,17 @@ pub trait Wrapper { pub fn resolve_builtin(name: &str) -> Option> { match name { - "claude" => Some(Box::new(ClaudeWrapper)), + "claude" => Some(Box::new(builtin::ClaudeWrapper)), + "mock-happy" => Some(Box::new(builtin::MockHappyWrapper)), + "mock-sad" => Some(Box::new(builtin::MockSadWrapper)), + "mock-random" => Some(Box::new(builtin::MockRandomWrapper)), + "debug" => Some(Box::new(builtin::DebugWrapper)), _ => None, } } pub fn list_builtin_names() -> &'static [&'static str] { - &["claude"] + &["claude", "mock-happy", "mock-sad", "mock-random", "debug"] } pub fn resolve_wrapper(root: &Path, name: &str) -> anyhow::Result> { @@ -59,7 +64,7 @@ pub fn write_temp_file(prefix: &str, content: &str) -> anyhow::Result { Ok(path) } -fn rand_u16() -> u16 { +pub(crate) fn rand_u16() -> u16 { use std::time::{SystemTime, UNIX_EPOCH}; SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().subsec_nanos() as u16 } @@ -77,6 +82,25 @@ mod tests { fn resolve_builtin_unknown_returns_none() { assert!(resolve_builtin("bogus").is_none()); assert!(resolve_builtin("").is_none()); - assert!(resolve_builtin("mock-happy").is_none()); + } + + #[test] + fn resolve_builtin_mock_happy_returns_some() { + assert!(resolve_builtin("mock-happy").is_some()); + } + + #[test] + fn resolve_builtin_mock_sad_returns_some() { + assert!(resolve_builtin("mock-sad").is_some()); + } + + #[test] + fn resolve_builtin_mock_random_returns_some() { + assert!(resolve_builtin("mock-random").is_some()); + } + + #[test] + fn resolve_builtin_debug_returns_some() { + assert!(resolve_builtin("debug").is_some()); } } diff --git a/apm-core/tests/custom_wrapper_integration.rs b/apm-core/tests/custom_wrapper_integration.rs index 669e193fa..18ae2cf82 100644 --- a/apm-core/tests/custom_wrapper_integration.rs +++ b/apm-core/tests/custom_wrapper_integration.rs @@ -53,6 +53,7 @@ fn integration_echo_test_wrapper() { extra_env: HashMap::new(), root: root.to_path_buf(), keychain: HashMap::new(), + current_state: "test".to_string(), }; // Spawn custom wrapper and wait @@ -129,6 +130,7 @@ fn spawn_matching_contract_succeeds() { extra_env: HashMap::new(), root: root.to_path_buf(), keychain: HashMap::new(), + current_state: "test".to_string(), }; let mut child = wrapper.spawn(&ctx).expect("spawn should succeed for contract_version = 1"); @@ -192,6 +194,7 @@ fn integration_canonical_mode() { extra_env: HashMap::new(), root: root.to_path_buf(), keychain: HashMap::new(), + current_state: "test".to_string(), }; let mut child = wrapper.spawn(&ctx).expect("spawn should succeed for canonical mode"); @@ -275,6 +278,7 @@ fn integration_external_parser_pipe() { extra_env: HashMap::new(), root: root.to_path_buf(), keychain: HashMap::new(), + current_state: "test".to_string(), }; let mut parser_child = wrapper.spawn(&ctx).expect("spawn should succeed for external mode"); @@ -345,6 +349,7 @@ fn spawn_future_contract_rejected() { extra_env: HashMap::new(), root: root.to_path_buf(), keychain: HashMap::new(), + current_state: "test".to_string(), }; let result = wrapper.spawn(&ctx); From ee34f1ff6e8aacd91f38ddf6e69f91eee15ea456 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 13:17:49 -0700 Subject: [PATCH 303/305] ticket(25c92daa): set section Acceptance criteria --- ...ock-and-debug-built-in-wrappers-mock-ha.md | 130 +++++++++--------- 1 file changed, 65 insertions(+), 65 deletions(-) diff --git a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md index 39689ffb1..7f0614a7e 100644 --- a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md +++ b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md @@ -64,70 +64,70 @@ Ship three mock built-in wrappers for testing the harness without burning credit ### Acceptance criteria -- [ ] **Dispatcher registration** -- [ ] `resolve_builtin("mock-happy")` returns `Some(_)` -- [ ] `resolve_builtin("mock-sad")` returns `Some(_)` -- [ ] `resolve_builtin("mock-random")` returns `Some(_)` -- [ ] `resolve_builtin("debug")` returns `Some(_)` - -- [ ] **mock-happy — spec mode (ticket in `in_design`)** -- [ ] When run against a ticket in `in_design` state, `mock-happy` writes non-empty content to all four required spec sections: Problem, Acceptance criteria, Out of scope, Approach -- [ ] When run against a ticket in `in_design` state, `mock-happy` sets the ticket's `effort` to `1` -- [ ] When run against a ticket in `in_design` state, `mock-happy` sets the ticket's `risk` to `1` -- [ ] When run against a ticket in `in_design` state, `mock-happy` transitions the ticket to `specd` - -- [ ] **mock-happy — impl mode (ticket in `in_progress`)** -- [ ] When run against a ticket in `in_progress` state, `mock-happy` creates at least one new git commit in the worktree -- [ ] When run against a ticket in `in_progress` state, `mock-happy` calls `apm state implemented` - -- [ ] **mock-happy — JSONL output** -- [ ] `mock-happy` emits at least one JSONL line on stdout; each emitted line is a valid JSON object with `"type": "tool_use"` - -- [ ] **mock-happy — error cases and exit** -- [ ] When the current state has zero `outcome = "success"` transitions, `mock-happy` exits non-zero and writes a diagnostic message to stderr -- [ ] When the current state has two or more `outcome = "success"` transitions, `mock-happy` exits non-zero and writes a diagnostic message to stderr -- [ ] `mock-happy` exits 0 when it completes without error - -- [ ] **mock-sad — behaviour** -- [ ] `mock-sad` writes content to at least one but fewer than all four required spec sections (Problem, Acceptance criteria, Out of scope, Approach) -- [ ] `mock-sad` transitions the ticket to a state reachable via a transition whose `resolve_outcome` result is not `"success"` -- [ ] `mock-sad` exits 0 after completing its run - -- [ ] **mock-sad — seeding** -- [ ] Given the same `APM_OPT_SEED` value, two successive `mock-sad` spawns against the same ticket in the same state choose the same target state - -- [ ] **mock-sad — error case** -- [ ] When no non-success transitions are available from the current state, `mock-sad` exits non-zero and writes a diagnostic to stderr - -- [ ] **mock-random — behaviour** -- [ ] `mock-random` transitions the ticket to a state reachable by any valid transition from the current state (including success-outcome transitions) -- [ ] When `mock-random` picks a success-outcome transition, it writes all four spec sections and sets effort/risk (spec mode) or creates a commit (impl mode), matching `mock-happy`'s behaviour -- [ ] When `mock-random` picks a non-success-outcome transition, it writes only partial spec content (matching `mock-sad`'s behaviour) -- [ ] `mock-random` exits 0 after completing its run - -- [ ] **mock-random — seeding** -- [ ] Given the same `APM_OPT_SEED` value, two successive `mock-random` spawns against the same ticket in the same state choose the same target state - -- [ ] **mock-random — error case** -- [ ] When no valid transitions are available from the current state, `mock-random` exits non-zero and writes a diagnostic to stderr - -- [ ] **debug — output** -- [ ] `debug` writes the name and value of every `APM_*` environment variable to stderr -- [ ] `debug` writes the full contents of the file at `APM_SYSTEM_PROMPT_FILE` to stderr -- [ ] `debug` writes the full contents of the file at `APM_USER_MESSAGE_FILE` to stderr -- [ ] `debug` emits exactly one JSONL line on stdout: a valid JSON object with `"type": "tool_use"` - -- [ ] **debug — behaviour** -- [ ] `debug` does not call `apm state`; the ticket's state is unchanged after `debug` runs -- [ ] `debug` exits 0 - -- [ ] **workflow.toml** -- [ ] `in_design → specd` carries `outcome = "success"` in the default workflow (`apm-core/src/default/workflow.toml`) -- [ ] `ammend → specd` carries `outcome = "success"` in the default workflow -- [ ] The project's `.apm/workflow.toml` carries the same two annotations - -- [ ] **per-agent instruction file stubs** -- [ ] Each of the four built-in wrappers (`mock-happy`, `mock-sad`, `mock-random`, `debug`) has both `apm.worker.md` and `apm.spec-writer.md` stub files under `apm-core/src/default/agents//` +- [x] **Dispatcher registration** +- [x] `resolve_builtin("mock-happy")` returns `Some(_)` +- [x] `resolve_builtin("mock-sad")` returns `Some(_)` +- [x] `resolve_builtin("mock-random")` returns `Some(_)` +- [x] `resolve_builtin("debug")` returns `Some(_)` + +- [x] **mock-happy — spec mode (ticket in `in_design`)** +- [x] When run against a ticket in `in_design` state, `mock-happy` writes non-empty content to all four required spec sections: Problem, Acceptance criteria, Out of scope, Approach +- [x] When run against a ticket in `in_design` state, `mock-happy` sets the ticket's `effort` to `1` +- [x] When run against a ticket in `in_design` state, `mock-happy` sets the ticket's `risk` to `1` +- [x] When run against a ticket in `in_design` state, `mock-happy` transitions the ticket to `specd` + +- [x] **mock-happy — impl mode (ticket in `in_progress`)** +- [x] When run against a ticket in `in_progress` state, `mock-happy` creates at least one new git commit in the worktree +- [x] When run against a ticket in `in_progress` state, `mock-happy` calls `apm state implemented` + +- [x] **mock-happy — JSONL output** +- [x] `mock-happy` emits at least one JSONL line on stdout; each emitted line is a valid JSON object with `"type": "tool_use"` + +- [x] **mock-happy — error cases and exit** +- [x] When the current state has zero `outcome = "success"` transitions, `mock-happy` exits non-zero and writes a diagnostic message to stderr +- [x] When the current state has two or more `outcome = "success"` transitions, `mock-happy` exits non-zero and writes a diagnostic message to stderr +- [x] `mock-happy` exits 0 when it completes without error + +- [x] **mock-sad — behaviour** +- [x] `mock-sad` writes content to at least one but fewer than all four required spec sections (Problem, Acceptance criteria, Out of scope, Approach) +- [x] `mock-sad` transitions the ticket to a state reachable via a transition whose `resolve_outcome` result is not `"success"` +- [x] `mock-sad` exits 0 after completing its run + +- [x] **mock-sad — seeding** +- [x] Given the same `APM_OPT_SEED` value, two successive `mock-sad` spawns against the same ticket in the same state choose the same target state + +- [x] **mock-sad — error case** +- [x] When no non-success transitions are available from the current state, `mock-sad` exits non-zero and writes a diagnostic to stderr + +- [x] **mock-random — behaviour** +- [x] `mock-random` transitions the ticket to a state reachable by any valid transition from the current state (including success-outcome transitions) +- [x] When `mock-random` picks a success-outcome transition, it writes all four spec sections and sets effort/risk (spec mode) or creates a commit (impl mode), matching `mock-happy`'s behaviour +- [x] When `mock-random` picks a non-success-outcome transition, it writes only partial spec content (matching `mock-sad`'s behaviour) +- [x] `mock-random` exits 0 after completing its run + +- [x] **mock-random — seeding** +- [x] Given the same `APM_OPT_SEED` value, two successive `mock-random` spawns against the same ticket in the same state choose the same target state + +- [x] **mock-random — error case** +- [x] When no valid transitions are available from the current state, `mock-random` exits non-zero and writes a diagnostic to stderr + +- [x] **debug — output** +- [x] `debug` writes the name and value of every `APM_*` environment variable to stderr +- [x] `debug` writes the full contents of the file at `APM_SYSTEM_PROMPT_FILE` to stderr +- [x] `debug` writes the full contents of the file at `APM_USER_MESSAGE_FILE` to stderr +- [x] `debug` emits exactly one JSONL line on stdout: a valid JSON object with `"type": "tool_use"` + +- [x] **debug — behaviour** +- [x] `debug` does not call `apm state`; the ticket's state is unchanged after `debug` runs +- [x] `debug` exits 0 + +- [x] **workflow.toml** +- [x] `in_design → specd` carries `outcome = "success"` in the default workflow (`apm-core/src/default/workflow.toml`) +- [x] `ammend → specd` carries `outcome = "success"` in the default workflow +- [x] The project's `.apm/workflow.toml` carries the same two annotations + +- [x] **per-agent instruction file stubs** +- [x] Each of the four built-in wrappers (`mock-happy`, `mock-sad`, `mock-random`, `debug`) has both `apm.worker.md` and `apm.spec-writer.md` stub files under `apm-core/src/default/agents//` ### Out of scope @@ -645,4 +645,4 @@ This wrapper is a mock — see docs/agent-wrappers.md. | 2026-05-01T01:43Z | ammend | in_design | philippepascal | | 2026-05-01T01:57Z | in_design | specd | claude-0501-0143-f3e8 | | 2026-05-01T17:38Z | specd | ready | philippepascal | -| 2026-05-01T19:49Z | ready | in_progress | philippepascal | +| 2026-05-01T19:49Z | ready | in_progress | philippepascal | \ No newline at end of file From ddafa8276d95d86b68acfe0e307f7a969ed390e0 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 13:17:58 -0700 Subject: [PATCH 304/305] =?UTF-8?q?ticket(25c92daa):=20in=5Fprogress=20?= =?UTF-8?q?=E2=86=92=20implemented?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md index 7f0614a7e..b7fd42a5b 100644 --- a/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md +++ b/tickets/25c92daa-mock-and-debug-built-in-wrappers-mock-ha.md @@ -1,7 +1,7 @@ +++ id = "25c92daa" title = "Mock and debug built-in wrappers (mock-happy, mock-sad, mock-random, debug)" -state = "in_progress" +state = "implemented" priority = 0 effort = 6 risk = 4 @@ -9,7 +9,7 @@ author = "philippepascal" owner = "philippepascal" branch = "ticket/25c92daa-mock-and-debug-built-in-wrappers-mock-ha" created_at = "2026-04-30T20:04:21.901984Z" -updated_at = "2026-05-01T19:49:18.097036Z" +updated_at = "2026-05-01T20:17:58.343200Z" epic = "4312fbd4" target_branch = "epic/4312fbd4-agent-wrapper-architecture" depends_on = ["d3b93b95", "a1b94ea4", "6cac8518"] @@ -645,4 +645,5 @@ This wrapper is a mock — see docs/agent-wrappers.md. | 2026-05-01T01:43Z | ammend | in_design | philippepascal | | 2026-05-01T01:57Z | in_design | specd | claude-0501-0143-f3e8 | | 2026-05-01T17:38Z | specd | ready | philippepascal | -| 2026-05-01T19:49Z | ready | in_progress | philippepascal | \ No newline at end of file +| 2026-05-01T19:49Z | ready | in_progress | philippepascal | +| 2026-05-01T20:17Z | in_progress | implemented | claude-0501-1949-d598 | From 499a1a0e6898193a9aa62f59a3c7dad221219a58 Mon Sep 17 00:00:00 2001 From: philippepascal Date: Fri, 1 May 2026 17:50:23 -0700 Subject: [PATCH 305/305] Wrapper epic cleanup: hardcoded "claude" uplift, mock tests, dedup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-cutting fixes flagged in the implementation review of epic 4312fbd4 (agent wrapper architecture): - start.rs: route resolve_system_prompt through params.agent (post frontmatter override) instead of literal "claude", so per-agent instructions resolve correctly when agent is overridden via config or ticket frontmatter - start.rs: gate check_output_format_supported on the resolved wrapper actually being the claude built-in; mock/debug agents and custom wrappers no longer require the claude binary on PATH - start.rs: enumerate built-ins dynamically in the not-found error rather than the stale "{claude}" literal - start.rs: refactor emit_deprecation_warning to take a Write so the test asserts on captured output (the writer that production passes is stderr.lock()), per the 6cac8518 amendment - agents.rs: switch the configured-marker to read workers.agent and iterate worker_profiles for per-profile labels, replacing the legacy workers.command lookup - validate.rs: refactor validate_agents to return (errors, warnings) and add validate_all single-pass entry point; CLI now scans .apm/agents/ once instead of twice per `apm validate` invocation - validate.rs: simplify the dead-end BFS — unknown transition targets are reported by validate_config; reachability skips them rather than inlining a stripped resolve_outcome - validate.rs: enumerate built-ins dynamically in the not-found error message - wrapper/custom.rs: default_contract_version returns CONTRACT_VERSION so manifests without an explicit field track the current contract on every bump; replace the tautological default test with one that exercises the production code path - wrapper/builtin/claude.rs: extract build_claude_args; share between spawn_local and spawn_container; add unit tests verifying --model and --dangerously-skip-permissions reach argv - wrapper/builtin/debug.rs: extract DEBUG_SCRIPT const; add tests asserting the script dumps APM_ env, emits one canonical event, outputs prompt+message to stderr, self-cleans - wrapper/builtin/mock_random.rs: extract pick_transition_idx; tests cover determinism, distribution, and bounds - wrapper/builtin/mod.rs: tests for seed_from_ctx (explicit option path), happy_script content (impl/spec modes), sad_script content, is_impl_mode predicate --- apm-core/src/agents.rs | 15 ++- apm-core/src/start.rs | 89 ++++++++++----- apm-core/src/validate.rs | 86 +++++++------- apm-core/src/wrapper/builtin/claude.rs | 74 +++++++++---- apm-core/src/wrapper/builtin/debug.rs | 47 +++++++- apm-core/src/wrapper/builtin/mock_random.rs | 39 ++++++- apm-core/src/wrapper/builtin/mod.rs | 117 ++++++++++++++++++++ apm-core/src/wrapper/custom.rs | 9 +- apm/src/cmd/validate.rs | 10 +- 9 files changed, 379 insertions(+), 107 deletions(-) diff --git a/apm-core/src/agents.rs b/apm-core/src/agents.rs index a9b1da0b7..eccf99120 100644 --- a/apm-core/src/agents.rs +++ b/apm-core/src/agents.rs @@ -142,14 +142,19 @@ pub fn list_wrappers(root: &Path, config: &Config) -> Result> } } - // Configured marker - // TODO post-6cac8518: switch to config.workers.agent and iterate - // config.worker_profiles for per-profile markers. - let configured_name = config.workers.command.as_deref().unwrap_or("claude"); + // Configured marker: global [workers].agent plus per-profile [worker_profiles.*].agent. + let global_agent = config.workers.agent.as_deref().unwrap_or("claude").to_string(); for entry in &mut entries { - if entry.name == configured_name { + if entry.name == global_agent { entry.configured_as.push("(configured)".to_string()); } + for (profile_name, profile) in &config.worker_profiles { + if let Some(ref agent) = profile.agent { + if entry.name == *agent { + entry.configured_as.push(format!("({profile_name})")); + } + } + } } Ok(entries) diff --git a/apm-core/src/start.rs b/apm-core/src/start.rs index 7f1bfedba..e00180894 100644 --- a/apm-core/src/start.rs +++ b/apm-core/src/start.rs @@ -17,21 +17,22 @@ const DEBUG_SPEC_WRITER_DEFAULT: &str = include_str!("default/agents/debug/apm.s static DEPRECATION_WARNED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); -#[cfg(test)] -static DEPRECATION_TEST_LOG: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); #[cfg(test)] static DEPRECATION_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); -fn emit_deprecation_warning() { +const DEPRECATION_MSG: &str = "apm: deprecated: `[workers] command`, `args`, and `model` fields are deprecated — migrate to `agent` and `[workers.options]`"; + +fn emit_deprecation_warning_to(out: &mut dyn std::io::Write) { use std::sync::atomic::Ordering; if DEPRECATION_WARNED.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_ok() { - let msg = "apm: deprecated: `[workers] command`, `args`, and `model` fields are deprecated — migrate to `agent` and `[workers.options]`"; - eprintln!("{msg}"); - #[cfg(test)] - DEPRECATION_TEST_LOG.lock().unwrap().push(msg.to_string()); + let _ = writeln!(out, "{DEPRECATION_MSG}"); } } +fn emit_deprecation_warning() { + emit_deprecation_warning_to(&mut std::io::stderr().lock()); +} + pub struct EffectiveWorkerParams { pub command: String, pub args: Vec, @@ -139,6 +140,16 @@ pub struct RunNextOutput { pub log_path: Option, } +/// True when `agent` resolves to the built-in claude wrapper (no custom shadow). +/// The compatibility probe is only meaningful in that case. +pub(crate) fn should_check_claude_compat(root: &Path, agent: &str) -> bool { + if agent != "claude" { return false; } + matches!( + crate::wrapper::resolve_wrapper(root, "claude"), + Ok(Some(crate::wrapper::WrapperKind::Builtin(_))) + ) +} + pub(crate) fn check_output_format_supported(binary: &str) -> Result<()> { let out = std::process::Command::new(binary) .arg("--help") @@ -198,8 +209,9 @@ fn spawn_worker(ctx: &WrapperContext, agent: &str, project_root: &Path) -> Resul resolve_builtin(&name).expect("known built-in").spawn(ctx) } None => anyhow::bail!( - "agent {:?} not found: checked built-ins {{claude}} and '.apm/agents/{agent}/'", - agent + "agent {:?} not found: checked built-ins {{{}}} and '.apm/agents/{agent}/'", + agent, + crate::wrapper::list_builtin_names().join(", ") ), } } @@ -312,12 +324,12 @@ pub fn run(root: &Path, id_arg: &str, no_aggressive: bool, spawn: bool, skip_per .to_string(); let profile = triggering_transition.and_then(|tr| resolve_profile(tr, &config, &mut warnings)); let role = profile.and_then(|p| p.role.as_deref()).unwrap_or("worker"); - let worker_system = resolve_system_prompt(root, profile, &config.workers, "claude", role)?; + let mut params = effective_spawn_params(profile, &config.workers); + apply_frontmatter_agent(&mut params.agent, &t.frontmatter, &profile_name); + let worker_system = resolve_system_prompt(root, profile, &config.workers, ¶ms.agent, role)?; let raw_prompt = format!("{}\n\n{content}", agent_role_prefix(profile, &id)); let with_epic = with_epic_bundle(root, ticket_epic_id.as_deref(), &id, &config, raw_prompt); let ticket_content = with_dependency_bundle(root, &ticket_depends_on, &config, with_epic); - let mut params = effective_spawn_params(profile, &config.workers); - apply_frontmatter_agent(&mut params.agent, &t.frontmatter, &profile_name); let role_prefix = profile.and_then(|p| p.role_prefix.clone()); let log_path = wt_display.join(".apm-worker.log"); @@ -343,7 +355,9 @@ pub fn run(root: &Path, id_arg: &str, no_aggressive: bool, spawn: bool, skip_per keychain: config.workers.keychain.clone(), current_state: new_state.clone(), }; - check_output_format_supported(¶ms.command)?; + if should_check_claude_compat(root, ¶ms.agent) { + check_output_format_supported("claude")?; + } let mut child = spawn_worker(&ctx, ¶ms.agent, root)?; let pid = child.id(); @@ -495,15 +509,15 @@ pub fn run_next(root: &Path, no_aggressive: bool, spawn: bool, skip_permissions: .to_string(); let profile2 = triggering_transition_owned.as_ref().and_then(|tr| resolve_profile(tr, &config, &mut warnings)); let role2 = profile2.and_then(|p| p.role.as_deref()).unwrap_or("worker"); - let worker_system = resolve_system_prompt(root, profile2, &config.workers, "claude", role2)?; + let mut params = effective_spawn_params(profile2, &config.workers); + apply_frontmatter_agent(&mut params.agent, &t.frontmatter, &profile_name2); + let worker_system = resolve_system_prompt(root, profile2, &config.workers, ¶ms.agent, role2)?; let raw = t.serialize()?; let dep_ids_next = t.frontmatter.depends_on.clone().unwrap_or_default(); let raw_prompt_next = format!("{}\n\n{raw}", agent_role_prefix(profile2, &id)); let with_epic_next = with_epic_bundle(root, t.frontmatter.epic.as_deref(), &id, &config, raw_prompt_next); let ticket_content = with_dependency_bundle(root, &dep_ids_next, &config, with_epic_next); - let mut params = effective_spawn_params(profile2, &config.workers); - apply_frontmatter_agent(&mut params.agent, &t.frontmatter, &profile_name2); let role_prefix2 = profile2.and_then(|p| p.role_prefix.clone()); let branch = t.frontmatter.branch.clone() @@ -537,7 +551,9 @@ pub fn run_next(root: &Path, no_aggressive: bool, spawn: bool, skip_permissions: keychain: config.workers.keychain.clone(), current_state: t.frontmatter.state.clone(), }; - check_output_format_supported(¶ms.command)?; + if should_check_claude_compat(root, ¶ms.agent) { + check_output_format_supported("claude")?; + } let mut child = spawn_worker(&ctx, ¶ms.agent, root)?; let pid = child.id(); @@ -679,15 +695,15 @@ pub fn spawn_next_worker( .to_string(); let profile2 = triggering_transition_owned.as_ref().and_then(|tr| resolve_profile(tr, &config, warnings)); let role2 = profile2.and_then(|p| p.role.as_deref()).unwrap_or("worker"); - let worker_system = resolve_system_prompt(root, profile2, &config.workers, "claude", role2)?; + let mut params = effective_spawn_params(profile2, &config.workers); + apply_frontmatter_agent(&mut params.agent, &t.frontmatter, &profile_name2); + let worker_system = resolve_system_prompt(root, profile2, &config.workers, ¶ms.agent, role2)?; let raw = t.serialize()?; let dep_ids_snw = t.frontmatter.depends_on.clone().unwrap_or_default(); let raw_prompt_snw = format!("{}\n\n{raw}", agent_role_prefix(profile2, &id)); let with_epic_snw = with_epic_bundle(root, t.frontmatter.epic.as_deref(), &id, &config, raw_prompt_snw); let ticket_content = with_dependency_bundle(root, &dep_ids_snw, &config, with_epic_snw); - let mut params = effective_spawn_params(profile2, &config.workers); - apply_frontmatter_agent(&mut params.agent, &t.frontmatter, &profile_name2); let role_prefix2 = profile2.and_then(|p| p.role_prefix.clone()); let branch = t.frontmatter.branch.clone() @@ -721,7 +737,9 @@ pub fn spawn_next_worker( keychain: config.workers.keychain.clone(), current_state: t.frontmatter.state.clone(), }; - check_output_format_supported(¶ms.command)?; + if should_check_claude_compat(root, ¶ms.agent) { + check_output_format_supported("claude")?; + } let child = spawn_worker(&ctx, ¶ms.agent, root)?; let pid = child.id(); @@ -850,7 +868,7 @@ fn rand_u16() -> u16 { #[cfg(test)] mod tests { - use super::{resolve_system_prompt, agent_role_prefix, resolve_profile, effective_spawn_params, check_output_format_supported, apply_frontmatter_agent, ManagedChild, DEPRECATION_WARNED, DEPRECATION_TEST_LOG, DEPRECATION_TEST_LOCK}; + use super::{resolve_system_prompt, agent_role_prefix, resolve_profile, effective_spawn_params, check_output_format_supported, apply_frontmatter_agent, ManagedChild, DEPRECATION_WARNED, DEPRECATION_MSG, DEPRECATION_TEST_LOCK, emit_deprecation_warning_to}; use crate::config::{WorkerProfileConfig, WorkersConfig, TransitionConfig, CompletionStrategy}; use std::collections::HashMap; @@ -1493,18 +1511,33 @@ mod tests { } #[test] - fn deprecation_warning_emitted_once() { + fn deprecation_warning_writes_to_stream_once() { + let _guard = DEPRECATION_TEST_LOCK.lock().unwrap(); + DEPRECATION_WARNED.store(false, std::sync::atomic::Ordering::SeqCst); + + // Capture what would otherwise go to stderr — proves the message hits + // the writer (i.e. stderr in production), not just an in-memory log. + let mut buf: Vec = Vec::new(); + emit_deprecation_warning_to(&mut buf); + emit_deprecation_warning_to(&mut buf); + + let captured = String::from_utf8(buf).unwrap(); + let count = captured.matches(DEPRECATION_MSG).count(); + assert_eq!(count, 1, "deprecated message should appear exactly once on the writer, found {count}\n{captured}"); + } + + #[test] + fn deprecation_warning_triggered_by_legacy_workers_config() { let _guard = DEPRECATION_TEST_LOCK.lock().unwrap(); DEPRECATION_WARNED.store(false, std::sync::atomic::Ordering::SeqCst); - DEPRECATION_TEST_LOG.lock().unwrap().clear(); let workers = WorkersConfig { command: Some("claude".into()), ..Default::default() }; effective_spawn_params(None, &workers); - effective_spawn_params(None, &workers); - let log = DEPRECATION_TEST_LOG.lock().unwrap(); - let count = log.iter().filter(|m: &&String| m.contains("deprecated")).count(); - assert_eq!(count, 1, "deprecated message should appear exactly once, found {count}"); + assert!( + DEPRECATION_WARNED.load(std::sync::atomic::Ordering::SeqCst), + "legacy [workers].command must trigger the deprecation warning" + ); } #[test] diff --git a/apm-core/src/validate.rs b/apm-core/src/validate.rs index 3a980238d..805e22190 100644 --- a/apm-core/src/validate.rs +++ b/apm-core/src/validate.rs @@ -157,9 +157,17 @@ fn gitignore_covers_dir(content: &str, dir: &str) -> bool { /// Layer 1 of the two-layer manifest validation design. /// Validates all configured agent names and scans `.apm/agents/` for issues. -/// Errors (not-found agents, invalid manifests, unsupported contract versions) go into `errors`. -/// Warnings (non-executable scripts, unknown manifest keys) go into `warnings`. -fn validate_agents(config: &Config, root: &Path, errors: &mut Vec, warnings: &mut Vec) { +/// Returns (errors, warnings) so callers can route them — keeps this single +/// directory scan from running twice when both validate_config and +/// validate_warnings are invoked. +pub fn validate_agents(config: &Config, root: &Path) -> (Vec, Vec) { + let mut errors: Vec = Vec::new(); + let mut warnings: Vec = Vec::new(); + validate_agents_into(config, root, &mut errors, &mut warnings); + (errors, warnings) +} + +fn validate_agents_into(config: &Config, root: &Path, errors: &mut Vec, warnings: &mut Vec) { // Collect configured agent names let mut names: std::collections::HashSet = std::collections::HashSet::new(); let primary = config.workers.agent.clone() @@ -172,10 +180,11 @@ fn validate_agents(config: &Config, root: &Path, errors: &mut Vec, warni } // Validate each configured agent name (Layer 1 error check) + let builtins = wrapper::list_builtin_names().join(", "); for name in &names { match wrapper::resolve_wrapper(root, name) { Ok(None) => errors.push(format!( - "agent '{}' not found: checked built-ins {{claude}} and '.apm/agents/{}/'", + "agent '{}' not found: checked built-ins {{{builtins}}} and '.apm/agents/{}/'", name, name )), Err(e) => errors.push(format!("agent '{name}': {e}")), @@ -263,6 +272,13 @@ fn validate_agents(config: &Config, root: &Path, errors: &mut Vec, warni } pub fn validate_config(config: &Config, root: &Path) -> Vec { + let mut errors = validate_config_no_agents(config, root); + let (agent_errors, _) = validate_agents(config, root); + errors.extend(agent_errors); + errors +} + +fn validate_config_no_agents(config: &Config, root: &Path) -> Vec { let mut errors: Vec = Vec::new(); let state_ids: HashSet<&str> = config.workflow.states.iter() @@ -403,9 +419,6 @@ pub fn validate_config(config: &Config, root: &Path) -> Vec { } } - let mut agent_warnings: Vec = Vec::new(); - validate_agents(config, root, &mut errors, &mut agent_warnings); - errors } @@ -523,6 +536,13 @@ pub fn verify_tickets( } pub fn validate_warnings(config: &crate::config::Config, root: &Path) -> Vec { + let mut warnings = validate_warnings_no_agents(config, root); + let (_, agent_warnings) = validate_agents(config, root); + warnings.extend(agent_warnings); + warnings +} + +fn validate_warnings_no_agents(config: &crate::config::Config, _root: &Path) -> Vec { let mut warnings = config.load_warnings.clone(); if let Some(container) = &config.workers.container { if !container.is_empty() { @@ -563,33 +583,17 @@ pub fn validate_warnings(config: &crate::config::Config, root: &Path) -> Vec Vec = Vec::new(); - validate_agents(config, root, &mut agent_errors, &mut warnings); - warnings } +/// Single-pass equivalent of calling `validate_config` followed by +/// `validate_warnings` — runs the agent-directory scan once and dedupes. +pub fn validate_all(config: &Config, root: &Path) -> (Vec, Vec) { + let mut errors = validate_config_no_agents(config, root); + let mut warnings = validate_warnings_no_agents(config, root); + let (agent_errors, agent_warnings) = validate_agents(config, root); + errors.extend(agent_errors); + warnings.extend(agent_warnings); + (errors, warnings) +} + #[cfg(test)] mod tests { use super::*; diff --git a/apm-core/src/wrapper/builtin/claude.rs b/apm-core/src/wrapper/builtin/claude.rs index 16a104343..0818c749f 100644 --- a/apm-core/src/wrapper/builtin/claude.rs +++ b/apm-core/src/wrapper/builtin/claude.rs @@ -20,6 +20,26 @@ impl Wrapper for ClaudeWrapper { } } +pub(crate) fn build_claude_args(model: Option<&str>, skip_permissions: bool, sys: &str, msg: &str) -> Vec { + let mut args: Vec = vec![ + "--print".into(), + "--output-format".into(), + "stream-json".into(), + "--verbose".into(), + "--system-prompt".into(), + sys.into(), + ]; + if let Some(m) = model { + args.push("--model".into()); + args.push(m.into()); + } + if skip_permissions { + args.push("--dangerously-skip-permissions".into()); + } + args.push(msg.into()); + args +} + fn spawn_local( ctx: &WrapperContext, sys: &str, @@ -27,17 +47,7 @@ fn spawn_local( apm_bin: &str, ) -> anyhow::Result { let mut cmd = std::process::Command::new("claude"); - cmd.arg("--print"); - cmd.args(["--output-format", "stream-json"]); - cmd.arg("--verbose"); - cmd.args(["--system-prompt", sys]); - if let Some(ref model) = ctx.model { - cmd.args(["--model", model]); - } - if ctx.skip_permissions { - cmd.arg("--dangerously-skip-permissions"); - } - cmd.arg(msg); + cmd.args(build_claude_args(ctx.model.as_deref(), ctx.skip_permissions, sys, msg)); set_apm_env(&mut cmd, ctx, apm_bin); for (k, v) in &ctx.extra_env { @@ -143,17 +153,7 @@ fn spawn_container( cmd.arg(image); cmd.arg("claude"); - cmd.arg("--print"); - cmd.args(["--output-format", "stream-json"]); - cmd.arg("--verbose"); - cmd.args(["--system-prompt", sys]); - if let Some(ref model) = ctx.model { - cmd.args(["--model", model]); - } - if ctx.skip_permissions { - cmd.arg("--dangerously-skip-permissions"); - } - cmd.arg(msg); + cmd.args(build_claude_args(ctx.model.as_deref(), ctx.skip_permissions, sys, msg)); let log_file = std::fs::File::create(&ctx.log_path)?; let log_clone = log_file.try_clone()?; @@ -187,3 +187,33 @@ fn set_apm_env(cmd: &mut std::process::Command, ctx: &WrapperContext, apm_bin: & cmd.env(&env_key, v); } } + +#[cfg(test)] +mod tests { + use super::build_claude_args; + + #[test] + fn args_include_model_flag_when_set() { + let args = build_claude_args(Some("sonnet"), false, "sys", "msg"); + let pos = args.iter().position(|a| a == "--model").expect("--model flag must be in argv"); + assert_eq!(args.get(pos + 1).map(String::as_str), Some("sonnet"), "value must follow --model"); + } + + #[test] + fn args_omit_model_flag_when_unset() { + let args = build_claude_args(None, false, "sys", "msg"); + assert!(!args.iter().any(|a| a == "--model"), "--model must be absent when no model configured: {args:?}"); + } + + #[test] + fn args_include_skip_permissions_when_set() { + let args = build_claude_args(None, true, "sys", "msg"); + assert!(args.iter().any(|a| a == "--dangerously-skip-permissions"), "{args:?}"); + } + + #[test] + fn args_msg_is_last() { + let args = build_claude_args(Some("opus"), true, "sys", "the-message"); + assert_eq!(args.last().map(String::as_str), Some("the-message")); + } +} diff --git a/apm-core/src/wrapper/builtin/debug.rs b/apm-core/src/wrapper/builtin/debug.rs index 75fbfa6b9..3d21917bb 100644 --- a/apm-core/src/wrapper/builtin/debug.rs +++ b/apm-core/src/wrapper/builtin/debug.rs @@ -1,11 +1,7 @@ use super::write_and_spawn_script; use crate::wrapper::{Wrapper, WrapperContext}; -pub struct DebugWrapper; - -impl Wrapper for DebugWrapper { - fn spawn(&self, ctx: &WrapperContext) -> anyhow::Result { - let script = r#"#!/bin/sh +pub(crate) const DEBUG_SCRIPT: &str = r#"#!/bin/sh env | grep '^APM_' >&2 printf '\n=== SYSTEM PROMPT ===\n' >&2 cat "$APM_SYSTEM_PROMPT_FILE" >&2 @@ -14,6 +10,45 @@ cat "$APM_USER_MESSAGE_FILE" >&2 printf '{"type":"tool_use","id":"debug-1","name":"noop","input":{}}\n' rm -f "$0" "#; - write_and_spawn_script("debug", script, ctx) + +pub struct DebugWrapper; + +impl Wrapper for DebugWrapper { + fn spawn(&self, ctx: &WrapperContext) -> anyhow::Result { + write_and_spawn_script("debug", DEBUG_SCRIPT, ctx) + } +} + +#[cfg(test)] +mod tests { + use super::DEBUG_SCRIPT; + + #[test] + fn script_dumps_apm_env_to_stderr() { + assert!( + DEBUG_SCRIPT.contains("env | grep '^APM_' >&2"), + "script must redirect APM_ env vars to stderr" + ); + } + + #[test] + fn script_emits_one_canonical_event() { + let canonical_lines: Vec<&str> = DEBUG_SCRIPT + .lines() + .filter(|l| l.starts_with("printf '{") && !l.contains(">&2")) + .collect(); + assert_eq!(canonical_lines.len(), 1, "expected exactly one stdout JSONL line: {canonical_lines:?}"); + assert!(canonical_lines[0].contains("\"type\":\"tool_use\""), "canonical event must have type=tool_use"); + } + + #[test] + fn script_outputs_system_prompt_and_user_message_to_stderr() { + assert!(DEBUG_SCRIPT.contains("cat \"$APM_SYSTEM_PROMPT_FILE\" >&2")); + assert!(DEBUG_SCRIPT.contains("cat \"$APM_USER_MESSAGE_FILE\" >&2")); + } + + #[test] + fn script_self_cleans_up() { + assert!(DEBUG_SCRIPT.contains("rm -f \"$0\""), "script must remove itself after running"); } } diff --git a/apm-core/src/wrapper/builtin/mock_random.rs b/apm-core/src/wrapper/builtin/mock_random.rs index c4ff63e1d..88b601a38 100644 --- a/apm-core/src/wrapper/builtin/mock_random.rs +++ b/apm-core/src/wrapper/builtin/mock_random.rs @@ -4,6 +4,10 @@ use crate::wrapper::{Wrapper, WrapperContext}; pub struct MockRandomWrapper; +pub(crate) fn pick_transition_idx(seed: u64, count: usize) -> usize { + (seed as usize) % count +} + impl Wrapper for MockRandomWrapper { fn spawn(&self, ctx: &WrapperContext) -> anyhow::Result { let transitions = load_transitions_with_outcomes(ctx)?; @@ -14,7 +18,7 @@ impl Wrapper for MockRandomWrapper { ); } let seed = seed_from_ctx(ctx); - let idx = (seed as usize) % transitions.len(); + let idx = pick_transition_idx(seed, transitions.len()); let chosen = &transitions[idx]; let outcome = resolve_outcome(&chosen.0, &chosen.1); let target = chosen.0.to.clone(); @@ -26,3 +30,36 @@ impl Wrapper for MockRandomWrapper { write_and_spawn_script("random", &script, ctx) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pick_transition_idx_is_deterministic_for_same_seed() { + let count = 5; + assert_eq!(pick_transition_idx(42, count), pick_transition_idx(42, count)); + assert_eq!(pick_transition_idx(42, count), 42 % 5); + } + + #[test] + fn pick_transition_idx_distributes_across_seeds() { + // Sample 100 seeds, expect at least 3 distinct buckets across 5 transitions. + let count = 5; + let mut buckets = std::collections::HashSet::new(); + for seed in 0u64..100 { + buckets.insert(pick_transition_idx(seed, count)); + } + assert!(buckets.len() >= 3, "expected >=3 distinct outcomes across 100 seeds, got {}: {buckets:?}", buckets.len()); + } + + #[test] + fn pick_transition_idx_stays_in_bounds() { + for count in 1..=10 { + for seed in 0u64..50 { + let idx = pick_transition_idx(seed, count); + assert!(idx < count, "idx {idx} out of range for count {count} seed {seed}"); + } + } + } +} diff --git a/apm-core/src/wrapper/builtin/mod.rs b/apm-core/src/wrapper/builtin/mod.rs index dd993de5f..70eb3948b 100644 --- a/apm-core/src/wrapper/builtin/mod.rs +++ b/apm-core/src/wrapper/builtin/mod.rs @@ -170,3 +170,120 @@ pub(crate) fn write_and_spawn_script( Ok(cmd.spawn()?) } + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::path::PathBuf; + + fn make_ctx_with_options(opts: HashMap) -> WrapperContext { + WrapperContext { + worker_name: "test".into(), + ticket_id: "t".into(), + ticket_branch: "b".into(), + worktree_path: PathBuf::from("/tmp"), + system_prompt_file: PathBuf::from("/tmp/sys"), + user_message_file: PathBuf::from("/tmp/msg"), + skip_permissions: false, + profile: "default".into(), + role_prefix: None, + options: opts, + model: None, + log_path: PathBuf::from("/tmp/log"), + container: None, + extra_env: HashMap::new(), + root: PathBuf::from("/tmp"), + keychain: HashMap::new(), + current_state: "test".into(), + } + } + + #[test] + fn seed_from_ctx_uses_explicit_option() { + let mut opts = HashMap::new(); + opts.insert("seed".into(), "12345".into()); + let ctx = make_ctx_with_options(opts); + assert_eq!(seed_from_ctx(&ctx), 12345); + } + + #[test] + fn seed_from_ctx_falls_back_when_no_option() { + // Without an explicit seed and no APM_OPT_SEED env, returns + // a time-based value — just assert that it doesn't panic. + let ctx = make_ctx_with_options(HashMap::new()); + let _ = seed_from_ctx(&ctx); + } + + #[test] + fn happy_script_includes_target_state_and_id() { + let s = happy_script("abc123", "implemented", true); + assert!(s.contains("ID=\"abc123\""), "id must appear: {s}"); + assert!(s.contains("apm\" state \"$ID\" implemented") || s.contains("$APM\" state \"$ID\" implemented"), + "target transition must appear: {s}"); + } + + #[test] + fn happy_script_spec_mode_writes_spec_sections() { + let s = happy_script("abc123", "specd", false); + assert!(s.contains("--section \"Problem\""), "spec mode must populate Problem: {s}"); + assert!(s.contains("--section \"Acceptance criteria\""), "spec mode must populate AC: {s}"); + } + + #[test] + fn happy_script_impl_mode_creates_commit() { + let s = happy_script("abc123", "implemented", true); + assert!(s.contains("git commit"), "impl mode must create commit: {s}"); + } + + #[test] + fn sad_script_includes_target_state() { + let s = sad_script("abc123", "blocked"); + assert!(s.contains("ID=\"abc123\""), "id must appear: {s}"); + assert!(s.contains("apm\" state \"$ID\" blocked") || s.contains("$APM\" state \"$ID\" blocked"), + "sad target must appear: {s}"); + } + + fn make_transition(to: &str, completion: crate::config::CompletionStrategy) -> crate::config::TransitionConfig { + crate::config::TransitionConfig { + to: to.into(), + trigger: "command:state".into(), + label: String::new(), + hint: String::new(), + completion, + focus_section: None, + context_section: None, + warning: None, + on_failure: None, + outcome: None, + profile: None, + } + } + + fn make_state(id: &str) -> crate::config::StateConfig { + crate::config::StateConfig { + id: id.into(), + label: id.into(), + description: String::new(), + actionable: vec![], + terminal: false, + worker_end: false, + satisfies_deps: crate::config::SatisfiesDeps::Bool(false), + dep_requires: None, + transitions: vec![], + instructions: None, + } + } + + #[test] + fn is_impl_mode_true_when_any_completion_strategy() { + use crate::config::CompletionStrategy; + assert!(is_impl_mode(&[(make_transition("implemented", CompletionStrategy::Merge), make_state("implemented"))])); + } + + #[test] + fn is_impl_mode_false_when_all_none() { + use crate::config::CompletionStrategy; + assert!(!is_impl_mode(&[(make_transition("specd", CompletionStrategy::None), make_state("specd"))])); + } +} diff --git a/apm-core/src/wrapper/custom.rs b/apm-core/src/wrapper/custom.rs index 04a590ff0..6b2ffe3db 100644 --- a/apm-core/src/wrapper/custom.rs +++ b/apm-core/src/wrapper/custom.rs @@ -50,7 +50,7 @@ fn find_binary(cmd: &str) -> anyhow::Result { anyhow::bail!("parser binary not found: {}", cmd); } -fn default_contract_version() -> u32 { 1 } +fn default_contract_version() -> u32 { CONTRACT_VERSION } fn default_parser() -> String { "canonical".to_string() } #[derive(Debug, Deserialize, Clone)] @@ -492,9 +492,10 @@ mod tests { } #[test] - fn check_version_no_manifest_defaults_to_1() { - let declared = None::.map_or(1, |m| m.contract_version); - assert_eq!(declared, 1); + fn default_contract_version_tracks_apm_version() { + // Ensures that bumping CONTRACT_VERSION also updates the manifest serde + // default, so older manifests don't silently parse with a stale version. + assert_eq!(default_contract_version(), CONTRACT_VERSION); } // --- ParserStrategy tests --- diff --git a/apm/src/cmd/validate.rs b/apm/src/cmd/validate.rs index bf5b63c32..4b9840a5b 100644 --- a/apm/src/cmd/validate.rs +++ b/apm/src/cmd/validate.rs @@ -232,13 +232,15 @@ pub fn run(root: &Path, fix: bool, json: bool, config_only: bool, no_aggressive: if config_only { config = CmdContext::load_config_only(root)?; - config_errors = validate_config(&config, root); - config_warnings = validate_warnings(&config, root); + let pair = apm_core::validate::validate_all(&config, root); + config_errors = pair.0; + config_warnings = pair.1; } else { let ctx = CmdContext::load(root, no_aggressive)?; config = ctx.config; - config_errors = validate_config(&config, root); - config_warnings = validate_warnings(&config, root); + let pair = apm_core::validate::validate_all(&config, root); + config_errors = pair.0; + config_warnings = pair.1; tickets_checked = ctx.tickets.len(); let tickets = ctx.tickets;