Skip to content

refactor(cost): rename experiments→runs + add path discriminator (#409)#522

Merged
lwgray merged 2 commits into
developfrom
feat/409-runs-path-rename
May 12, 2026
Merged

refactor(cost): rename experiments→runs + add path discriminator (#409)#522
lwgray merged 2 commits into
developfrom
feat/409-runs-path-rename

Conversation

@lwgray

@lwgray lwgray commented May 12, 2026

Copy link
Copy Markdown
Owner

Summary

  • Renames cost-tracking experiments table → runs (resolving namespace collision with MLflow's separate experiment concept).
  • Adds path column (direct | marcus | posidonius | unknown) so the three entry points are distinguishable in cost analytics.
  • One-shot idempotent migration in _runs_rename_migration() covers legacy DBs, fresh installs, and already-migrated DBs.
  • create_project now records one runs row per successful call, pre-generating run_id at wrapper entry and rebinding on success. Defaults path="direct" so unwrapped Direct MCP callers get the correct discriminator without code changes. experiments/spawn_agents.py sets path="marcus" (Posidonius config overrides to posidonius).

Why

"experiment_id" in cost-tracking was always about the path through a project — distinct invocations on a shared project_id. MLflow uses the same word for an unrelated namespace, which has caused recurring confusion. This makes the cost-tracking model say what it means and the MLflow tool keep its own namespace cleanly.

API renames (no compat aliases — pre-public)

  • CostStore.record_experimentrecord_run
  • CostAggregator.list_experimentslist_runs; experiment_summaryrun_summary
  • TokenEvent.experiment_id / PlannerContext.experiment_id / AgentBinding.experiment_idrun_id
  • get_cost_summary MCP tool: arg experiment_idrun_id; response key experimentsruns
  • Dataclass ExperimentRun (with new path field)

Test plan

  • pytest tests/unit/cost_tracking/ (1297 unit tests pass, 1 skipped)
  • pytest tests/unit/marcus_mcp/test_cost_tracking_tool.py tests/unit/marcus_mcp/test_create_project_run_recording.py
  • mypy src/cost_tracking/ src/marcus_mcp/tools/cost_tracking.py src/marcus_mcp/tools/nlp.py clean
  • Migration smoke test against legacy schema (5 new tests in TestRunsRenameMigration)
  • Coordinated with Cato PR (refactor(cost): mirror Marcus runs/path rename (Marcus #409) cato#40)

Related

🤖 Generated with Claude Code

)

Resolves the long-standing namespace collision between cost-tracking's
internal "experiment_id" concept and MLflow's separate experiment notion.
The cost-tracking table tracks *runs* through a project — distinct
invocations on a shared project_id, not MLflow experiments.

Schema:
- experiments -> runs
- experiments.experiment_id -> runs.run_id
- token_events.experiment_id -> token_events.run_id
- runs.path TEXT NOT NULL DEFAULT 'unknown' (new: direct/marcus/posidonius)
- indexes renamed: idx_experiments_* -> idx_runs_*, idx_te_exp* -> idx_te_run*
- new idx_runs_path

Migration: one-shot idempotent rename in _runs_rename_migration().
Detects legacy `experiments` table, runs ALTER TABLE RENAME, adds path
column with default 'unknown'. Safe on fresh installs (no-op) and on
already-migrated DBs (no-op).

Attribution:
- create_project pre-generates run_id at wrapper entry and rebinds on
  success. Defaults path="direct" so unwrapped Direct MCP callers get a
  correct discriminator without code changes.
- experiments/spawn_agents.py sets path="marcus" by default; Posidonius
  config can override to "posidonius".

API renames:
- CostStore.record_experiment -> record_run
- CostAggregator.list_experiments -> list_runs
- CostAggregator.experiment_summary -> run_summary
- TokenEvent.experiment_id -> run_id
- PlannerContext.experiment_id -> run_id
- AgentBinding.experiment_id -> run_id
- get_cost_summary MCP tool: experiment_id arg -> run_id, response key
  experiments -> runs
- Dataclass Experiment -> Run (with new path field)

MLflow's start_experiment MCP tool is untouched — that namespace
remains MLflow's. The cost-tracking "experiment" was always a different
concept; this rename makes that explicit.

Simon: 7ed3074d (decision)
Closes the rename work agreed on after Kaia's interpretation review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented May 12, 2026

Copy link
Copy Markdown

Claude finished @lwgray's task —— View job


PR Review: experiments→runs refactor + path discriminator

I've completed my review of this pull request. Here's my comprehensive feedback:

Overall Assessment: Strong refactor with good practices

This is a well-executed rename with comprehensive test coverage and careful migration handling. The code quality is high and the rationale is sound.

🎯 Strengths

1. Well-reasoned architectural decision

  • Clear motivation to resolve namespace collision with MLflow
  • Good documentation of the rationale (Simon decision 7ed3074d)
  • Sensible path discriminator (direct, marcus, posidonius, unknown)

2. Safe migration strategy

  • Idempotent migration in _runs_rename_migration() handles all cases:
    • Fresh installs (no migration needed)
    • Legacy DBs (full rename + path column addition)
    • Already-migrated DBs (graceful no-op)
  • Comprehensive test coverage for migration edge cases

3. Excellent test coverage

  • 5 new tests in TestRunsRenameMigration validate migration correctness
  • New test_create_project_run_recording.py tests run recording integration
  • Tests verify data preservation, idempotency, and fresh install behavior

4. API consistency

  • Complete rename throughout stack: CostStore.record_experimentrecord_run
  • TokenEvent.experiment_idrun_id across all components
  • MCP tool parameter experiment_idrun_id with descriptive schema docs

🔍 Code Quality

Migration Logic (cost_store.py:576-644):

def _runs_rename_migration(self) -> None:
  • Robust detection logic checking for both experiments and runs tables
  • Proper error handling and defensive programming
  • Index cleanup to match new naming conventions
  • Safe column addition with appropriate defaults

Schema Design:

  • New path column with sensible defaults and constraints
  • Maintains all existing relationships and data integrity
  • Generated columns and indexes preserved correctly

⚠️ Minor Issues

1. Comment inconsistency in cost_aggregator.py:278

# LEFT JOIN to experiments to pull a human-readable project_name

Should reference "runs" table, not "experiments" (line 298-301 show correct JOIN to runs)

2. Minor documentation lag
Some docstrings still reference "experiment" in conceptual contexts where they could be updated to "run" for full consistency.

🛡️ Security & Performance

Security: ✅ Good

  • No SQL injection risks (uses parameterized queries)
  • Migration doesn't expose sensitive data
  • Proper transaction handling

Performance: ✅ Minimal impact

  • Migration is one-time with efficient detection logic
  • New path column indexed appropriately
  • No performance regressions in aggregation queries

🧪 Test Quality

Migration tests (cost_store.py:96-270):

  • Cover all migration paths comprehensively
  • Test data preservation and column renames
  • Verify idempotency and fresh install behavior
  • Use realistic legacy schema recreation

API consistency tests:

  • Updated parameter names and response shapes
  • Integration tests for MCP tool changes

📋 Recommendations

Required:

  1. Fix comment in cost_aggregator.py:278 to reference "runs" instead of "experiments"

Optional but recommended:
2. Consider updating remaining conceptual references to "experiment" in docstrings for full consistency
3. Run mypy to ensure no type annotation issues with the renames

Verdict

This is a high-quality refactor that addresses a real architectural concern with:

  • ✅ Safe, well-tested migration strategy
  • ✅ Comprehensive API consistency
  • ✅ Clear documentation and rationale
  • ✅ Minimal performance impact
  • ✅ Strong test coverage

The single comment inconsistency is minor and easily fixed. The PR demonstrates excellent software engineering practices for a non-trivial schema migration.

Recommendation: Approve with the minor comment fix

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 151863f634

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +27 to +29
def _mock_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
"""Config validation needs CLAUDE_API_KEY set."""
monkeypatch.setenv("CLAUDE_API_KEY", "test-key-for-unit-tests")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Make the new run-recording test independent of local config

In a fresh checkout without config_marcus.json, MarcusConfig.from_file() returns the default config and validation does not read CLAUDE_API_KEY directly, so this fixture still leaves anthropic_api_key unset for tests that reach get_config(). I checked pytest -q tests/unit/marcus_mcp/test_create_project_run_recording.py::TestRunRecording::test_one_run_row_per_successful_call, and it fails before the patched creator is used; patch get_config()/set MARCUS_CONFIG to a test config or otherwise reset the config object so CI doesn't depend on a developer-local config file.

Useful? React with 👍 / 👎.

Comment on lines +221 to +229
state.cost_store.record_run(
Run(
run_id=_run_id,
project_id=_canonical,
project_name=project_name,
started_at=_started_at,
path=_path,
)
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid recording phantom runs for cached retries

When a client retries create_project within the existing 10-minute dedup window after the first call succeeded, _create_project_inner() returns the cached success without doing another project traversal or emitting costs. This wrapper still treats that cached result as a fresh success and inserts a new runs row with a new run_id, so dashboards/counts will show extra zero-cost runs for timeout/retry scenarios; record only when the inner call actually performed work or reuse the original run metadata.

Useful? React with 👍 / 👎.

P1 — make run-recording tests independent of local config
The _mock_api_key fixture set CLAUDE_API_KEY in env, but MarcusConfig.
from_file() only does ${CLAUDE_API_KEY} substitution when a config
file is actually loaded. On a fresh checkout without config_marcus.
json the substitution never fires, defaults stay unset, and the
get_config()/validate() call from create_project's kanban-provider
lookup raises. Codex reproduced this in a clean tree.

Replace the env-var fixture with a direct stub of the module-level
_config singleton, so the test is hermetic on any developer machine
and in CI regardless of local files.

P2 — don't insert phantom runs rows on dedup-cached retries
When create_project is invoked within the 10-minute dedup window
after a successful first call, _create_project_inner returns the
cached result immediately — no decomposition, no token_events. The
wrapper still pre-generated a fresh run_id at entry, so on success
it would insert a brand-new runs row for the cached replay. Result:
zero-cost phantom rows piling up per retry storm, breaking dashboard
counts and the runs picker.

Mark cached replays with an internal _dedup_cached flag inside the
inner function (set on a shallow copy so the cache entry stays
clean). The wrapper pops the flag and skips record_run +
rebind_project_id when set, so the agent's response is unchanged
but the runs table holds exactly one row per real invocation.

Also:
- Add pytestmark = pytest.mark.unit to both touched test files so
  the 16 cost/run-recording tests actually run under `pytest -m unit`
  (the PR-blocking CI marker). Mirrors the convention in sibling
  files like test_lease_recovery_state_sync.py.
- New regression test TestCodexP2DedupReplay verifies three identical
  calls in a row still produce exactly one runs row.

Baseline: 1313 passed, 1 skipped under `pytest -m unit` (was 1297;
+16 tests now actually run in CI).

Refs: #522 (Codex review)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented May 12, 2026

Copy link
Copy Markdown

Claude finished @lwgray's task —— View job


PR Review: experiments→runs refactor + path discriminator

I've completed a comprehensive review of this pull request. Here's my detailed feedback:

Overall Assessment: Strong refactor with good engineering practices

This is a well-executed rename with comprehensive test coverage and careful migration handling. The code quality is high and the rationale is sound.

🎯 Strengths

1. Well-reasoned architectural decision

  • Clear motivation to resolve namespace collision with MLflow
  • Good documentation of the rationale (Simon decision 7ed3074d)
  • Sensible path discriminator (direct, marcus, posidonius, unknown)

2. Safe migration strategy

  • Idempotent migration in _runs_rename_migration() handles all cases:
    • Fresh installs (no migration needed)
    • Legacy DBs (full rename + path column addition)
    • Already-migrated DBs (graceful no-op)
  • Comprehensive test coverage for migration edge cases (5 new tests in TestRunsRenameMigration)

3. Excellent test coverage

  • Tests verify data preservation, idempotency, and fresh install behavior
  • New test_create_project_run_recording.py tests run recording integration
  • Migration tests cover realistic legacy schema recreation

4. API consistency

  • Complete rename throughout stack: CostStore.record_experimentrecord_run
  • TokenEvent.experiment_idrun_id across all components
  • MCP tool parameter experiment_idrun_id with updated schema documentation

🔍 Code Quality Review

Migration Logic (cost_store.py:576-644):

  • ✅ Robust detection logic checking for both experiments and runs tables
  • ✅ Proper error handling and defensive programming
  • ✅ Index cleanup to match new naming conventions
  • ✅ Safe column addition with appropriate defaults

Deduplication Handling:

  • ✅ Correctly addresses Codex P2 concern about phantom runs
  • ✅ Uses _dedup_cached flag to prevent duplicate run records for cached retries (nlp.py:221-224)

⚠️ Issues Found

1. Comment inconsistency in cost_aggregator.py:298 (Required fix)

# LEFT JOIN to experiments to pull a human-readable project_name

Should reference "runs" table, not "experiments" (actual JOIN on line 328 correctly uses runs)

2. Codex feedback assessment:

  • P1 (Config dependency): Properly addressed with _stub_marcus_config fixture
  • P2 (Phantom runs): Already handled correctly with deduplication logic

🛡️ Security & Performance

Security: ✅ Good

  • No SQL injection risks (uses parameterized queries)
  • Migration doesn't expose sensitive data
  • Proper transaction handling

Performance: ✅ Minimal impact

  • Migration is one-time with efficient detection logic
  • New path column indexed appropriately
  • No performance regressions in aggregation queries

🧪 Test Quality Assessment

Migration tests: Comprehensive coverage including:

  • All migration paths (fresh, legacy, already-migrated)
  • Data preservation and column renames
  • Idempotency verification
  • Realistic legacy schema recreation

API consistency tests: Updated parameter names and response shapes throughout

📋 Recommendations

Required:

  1. Fix comment in cost_aggregator.py:298 to reference "runs" instead of "experiments"

Optional but recommended:
2. Consider updating remaining conceptual references to "experiment" in docstrings for full consistency

Verdict

This is a high-quality refactor that addresses a real architectural concern with:

  • ✅ Safe, well-tested migration strategy
  • ✅ Comprehensive API consistency
  • ✅ Clear documentation and rationale
  • ✅ Minimal performance impact
  • ✅ Strong test coverage
  • ✅ Proper handling of edge cases (deduplication, caching)

The single comment inconsistency is minor and easily fixed. The PR demonstrates excellent software engineering practices for a non-trivial schema migration.

Recommendation: Approve with the minor comment fix


@lwgray

lwgray commented May 12, 2026

Copy link
Copy Markdown
Owner Author

Addressed both Codex findings in f7390080:

P1 — test hermeticity. Replaced the _mock_api_key env-var fixture with a direct stub of the module-level _config singleton in src/config/marcus_config.py. MarcusConfig.from_file() only does ${CLAUDE_API_KEY} substitution when a real config file is loaded — on a fresh checkout that fallback never fires. Patching _config makes the test pass on any machine regardless of config_marcus.json presence. Verified by re-running with MARCUS_CONFIG=/tmp/nonexistent.json — still green.

P2 — phantom runs on cached retries. Real bug, thanks for catching. Reasoning:

  • First call: pre-generates run_id_A, pushes PlannerContext, inner does decomposition (emits token_events tagged run_id_A), wrapper records runs row for run_id_A. Correct.
  • Retry within 10-min dedup window: wrapper generates run_id_B (fresh), inner returns cached_result without doing planner work, wrapper records runs row for run_id_B. Phantom: no token_events ever produced under run_id_B.

Fix: inner now marks the cached return with a shallow-copied _dedup_cached=True sentinel; wrapper pops the flag and skips both record_run and rebind_project_id when it sees it. Agent response is unchanged. Cache entry stays clean (no flag mutation on the stored dict).

Plus: added pytestmark = pytest.mark.unit to both touched test files. The pre-existing 16 cost/run-recording tests in this directory were silently skipped by pytest -m unit (the PR-blocking CI job) — they only ran when you invoked the file path directly. Sibling tests in this directory (e.g. test_lease_recovery_state_sync.py) already follow this convention.

Baseline:

  • pytest -m unit1313 passed, 1 skipped, 0 failed (was 1297 — the +16 are the now-CI-visible tests)
  • New TestCodexP2DedupReplay::test_replay_does_not_create_phantom_run_row asserts three identical calls produce exactly one runs row.

@lwgray lwgray merged commit 2a0ea42 into develop May 12, 2026
9 checks passed
lwgray added a commit that referenced this pull request May 13, 2026
What changed
------------
- ``CostStore.close_run`` stamps ``ended_at`` plus task/agent counters
  with a COALESCE-guarded UPDATE (live values win, NULL preserves
  existing).
- ``CostStore.close_open_runs_for_project`` bulk-closes every open run
  for a project; called from ``end_experiment`` after ``monitor.stop()``.
- ``CostAggregator.run_audit`` reports ``run_open``; ``project_audit``
  reports ``runs_total`` and ``runs_open`` so the audit can detect the
  "every run open since insertion" failure mode.
- ``scripts/backfill_run_close_state.py`` walks every NULL row and
  derives ``ended_at`` from ``MAX(token_events.timestamp) WHERE
  run_id = ?`` — best on-disk approximation for unattended close.

Why
---
The ``runs`` table has lifecycle columns the schema anticipates, but
nothing in the Python code ever wrote them after #522. Every row has
been "open" since insertion, blocking dashboard queries on
``ended_at IS NOT NULL`` and any wall-clock-time analysis (run
duration, cost-per-minute, coordination-tax rate). The token-audit
work in #528 confirmed token attribution but never asserted lifecycle
closure — so the gap stayed silent.

Real-data validation
--------------------
Ran backfill on ``~/.marcus/costs.db``:

    $ python scripts/backfill_run_close_state.py
    Closed 1 runs.
    $ sqlite3 ~/.marcus/costs.db \
        "SELECT COUNT(*), SUM(ended_at IS NULL) FROM runs;"
    1|0

Test plan
---------
- [x] ``pytest tests/unit/cost_tracking/`` — 95 passed (11 new)
- [x] ``mypy src/cost_tracking/cost_store.py
       src/cost_tracking/cost_aggregator.py`` clean
- [x] Backfill script ran end-to-end against real costs.db

Related
-------
- Closes #537
- Follows #522 (which added ``record_run`` without a close path) and
  #528 (which audited tokens but not lifecycle)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
lwgray added a commit that referenced this pull request May 13, 2026
* fix(cost): close runs rows on completion (#537)

What changed
------------
- ``CostStore.close_run`` stamps ``ended_at`` plus task/agent counters
  with a COALESCE-guarded UPDATE (live values win, NULL preserves
  existing).
- ``CostStore.close_open_runs_for_project`` bulk-closes every open run
  for a project; called from ``end_experiment`` after ``monitor.stop()``.
- ``CostAggregator.run_audit`` reports ``run_open``; ``project_audit``
  reports ``runs_total`` and ``runs_open`` so the audit can detect the
  "every run open since insertion" failure mode.
- ``scripts/backfill_run_close_state.py`` walks every NULL row and
  derives ``ended_at`` from ``MAX(token_events.timestamp) WHERE
  run_id = ?`` — best on-disk approximation for unattended close.

Why
---
The ``runs`` table has lifecycle columns the schema anticipates, but
nothing in the Python code ever wrote them after #522. Every row has
been "open" since insertion, blocking dashboard queries on
``ended_at IS NOT NULL`` and any wall-clock-time analysis (run
duration, cost-per-minute, coordination-tax rate). The token-audit
work in #528 confirmed token attribution but never asserted lifecycle
closure — so the gap stayed silent.

Real-data validation
--------------------
Ran backfill on ``~/.marcus/costs.db``:

    $ python scripts/backfill_run_close_state.py
    Closed 1 runs.
    $ sqlite3 ~/.marcus/costs.db \
        "SELECT COUNT(*), SUM(ended_at IS NULL) FROM runs;"
    1|0

Test plan
---------
- [x] ``pytest tests/unit/cost_tracking/`` — 95 passed (11 new)
- [x] ``mypy src/cost_tracking/cost_store.py
       src/cost_tracking/cost_aggregator.py`` clean
- [x] Backfill script ran end-to-end against real costs.db

Related
-------
- Closes #537
- Follows #522 (which added ``record_run`` without a close path) and
  #528 (which audited tokens but not lifecycle)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cost): canonicalize project_id in close_open_runs_for_project (#537)

``record_run`` stored the dashless UUID form via
``canonical_project_id``, but ``end_experiment`` passed
``monitor.project_id`` straight through. When the monitor was handed
the dashed UUID form (ProjectRegistry path), the live close lookup
matched zero rows and silently closed nothing — leaving the
backfill script as the only safety net.

Normalize at the close-helper entry: ``canonical_project_id(...)``
or fall back to the original string for non-UUID inputs (matches
the rest of the cost layer, which normalizes on the way in).

Regression test asserts a dashed input still closes the dashless
row in ``runs``. Caught by Kaia on PR #538 review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* comment to not fix code

* fix(cost): close only latest open run per project on end_experiment (#537)

The cost schema allows multiple ``runs`` rows per ``project_id``
(retries, repeated traversals — ``run_id`` is the PK, project views
group/list runs). The bulk close in
``close_open_runs_for_project`` stamped every historical open row
for the project with the just-finished experiment's ``ended_at``
and final counters, corrupting older runs.

Replace with ``close_latest_open_run_for_project``: scope to the
``MAX(started_at)`` open run only. Older open rows are left to the
periodic ``backfill_run_close_state.py`` script, which derives
``ended_at`` from each row's own ``MAX(token_events.timestamp)`` —
the on-disk approximation that's correct per-run rather than a
batch-overwrite from the live hook.

Return type tightened from ``int`` (count) to ``Optional[str]``
(the closed run_id) so callers can log which row was touched.

Caught by Codex P2 on PR #538.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
lwgray added a commit that referenced this pull request May 14, 2026
* fix(experiments): isolate completion check from MLflow logging in monitor loop (#495)

Move _check_completion() into its own try/except in
LiveExperimentMonitor._monitor_loop so a ProjectMonitor /
MLflow failure (e.g. unreachable Planka board) no longer
swallows the kanban-DB completion signal. is_running now
flips to False and experiment_complete.json is written even
when the configured kanban provider can't be reached.

Also:
- Add tracked example configs derived from config_marcus.json:
  config_marcus.planka.example.json, config_marcus.cloud.example.json,
  and a refreshed config_marcus.local.example.json on the
  canonical schema.
- Refresh config_marcus.example.json: enable features.memory by
  default so TASK_STARTED / TASK_COMPLETED events reach
  marcus.db (Cato's progress timeline depends on them); drop
  unused sqlite_db_path / sqlite_attachments_dir keys; add
  header comments.
- Migrate docs/assets/*.mp4 to LFS pointers (matches
  .gitattributes filter).
- Refresh .secrets.baseline.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cost-tracking): SQLite cost foundation — schema, capture, aggregation (#409 phases 1-3) (#497)

* feat(cost-tracking): SQLite-backed token event store (#409 phase 1)

Foundation for the unified cost dashboard described in #409. Introduces
src/cost_tracking/cost_store.py with three tables and one view:

- experiments       — registry of runs (project, model, totals)
- token_events      — one row per LLM call (immutable token counts;
                      generated total_tokens column)
- model_prices      — versioned by effective_from; designed to be
                      edited by the Cato UI without repo changes
- v_event_cost      — view joining events × the price active at each
                      event's timestamp, exposing cost_usd at query time

Tokens are truth (immutable). Cost is derived (prices change). This
schema lets us tweak pricing without rewriting historical experiments —
old runs keep their original cost.

CostStore handles inserts only; aggregation queries land in a follow-up
phase (cost_aggregator.py). WAL mode enabled so background ingesters
(planner middleware, worker JSONL tail) can write while Cato reads.

Tests: 15 cases, 98.89% coverage on cost_store.py, mypy --strict clean.
Validates schema creation, WAL mode, generated total_tokens column,
upsert semantics on experiments, versioned price coexistence, and the
v_event_cost view's historical-price selection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cost-tracking): planner-side capture in providers (#409 phase 2)

- New src/cost_tracking/cost_recorder.py: ContextVar-based recorder
  with PlannerContext stack. Providers call record_planner_call after
  each successful API request. Failures are swallowed so the recorder
  cannot break the call path.
- anthropic_provider._call_claude captures all four token fields incl.
  cache_creation_input_tokens and cache_read_input_tokens (which the
  legacy middleware was dropping).
- local_provider._call_local_llm + cloud_provider._call_cloud_llm
  capture prompt_tokens / completion_tokens. Provider tag distinguished
  via _cost_provider_name hook ('local' vs 'cloud').

Tests: 11 new (8 recorder + 3 provider integration), 98%+ coverage on
new files. All 573 existing AI provider tests still pass. mypy --strict
clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cost-tracking): read-only cost aggregator (#409 phase 3)

Adds src/cost_tracking/cost_aggregator.py — the query layer that powers
Cato's /api/cost/* endpoints. Returns dicts shaped like the API
responses documented in #409.

Methods: list_experiments, experiment_summary, session_turns,
cache_hit_rate_by_agent, project_totals. All hit v_event_cost so
historical cost stays correct after price edits.

Tests: 11 cases, 97.56% coverage, mypy --strict clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cost-tracking): activate planner recorder at server startup (#409)

Wires CostStore + CostRecorder into MarcusServer.__init__ so planner-side
LLM calls start landing in ~/.marcus/costs.db immediately. set_recorder()
makes the singleton available to providers patched in phase 2.

Without an active PlannerContext, events fall back to 'unassigned' ids;
they still record correctly. MCP request handlers will push a context
in a follow-up phase.

84/84 marcus_mcp unit tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cost-tracking): seed legacy Anthropic models + ISO timestamp default (#497)

Addresses Codex P1 + P2 on PR #497.

P1: DEFAULT_SEED now covers claude-3-haiku-20240307 (Marcus's default
config), claude-3-sonnet-20241022 (historic settings default),
claude-3-5-sonnet-20241022, claude-3-5-haiku-20241022, claude-3-opus.
Without these, v_event_cost INNER JOIN dropped out-of-box runs.

P2: token_events.timestamp default now uses
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'). The bare CURRENT_TIMESTAMP
produced 'YYYY-MM-DD HH:MM:SS' which sorted before record_price()'s
isoformat() output, causing same-day price/event joins to misbehave.

4 new regression tests; 41/41 cost-tracking tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cost-tracking): worker JSONL ingester (#409 phase 4) (#498)

Batch ingester that reads Claude Code worker session JSONL files and
writes one token_events row per assistant turn carrying message.usage.

- Filters non-assistant records (queue-operation, user, etc)
- Skips assistant records lacking usage block
- Captures all 4 token fields incl. cache_creation/cache_read
- Per-session turn_index counter, independent across sessions
- UUID-based dedup so re-ingest is a no-op
- Caller supplies resolve_binding callable (decoupled from spawn registry)

API: WorkerJSONLIngester.ingest_file(path) / .ingest_directory(path)

11 tests, 95.89% coverage, mypy --strict clean. Smoke-tested against
real 549-turn Claude Code session: ingested all 549 events, $5.61
cost rollup arithmetically correct via the haiku-4-5 seed prices.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cost-tracking): MCP tool get_cost_summary (#409 phase 5) (#499)

* feat(cost-tracking): MCP tool get_cost_summary (#409 phase 5)

Exposes CostAggregator's experiment + project rollups via MCP so agents
and Cato can query cost data without direct DB access.

API: get_cost_summary(experiment_id|project_id) → API-shaped dict.
Read-only. Argument errors return {success: False, error} envelopes
rather than raising — friendlier for MCP clients.

Wired through handlers.py: imports, get_all_tool_definitions registry,
human_tools list, dispatch case in handle_tool_call.

8 new tests, mypy --strict clean. All 92 marcus_mcp unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cost-tracking): add get_cost_summary to observer ROLE_TOOLS (#499)

Addresses Codex P1 on PR #499.

handle_tool_call filters through get_client_tools()/ROLE_TOOLS in
src/marcus_mcp/tools/auth.py before dispatch. The previous PR
registered get_cost_summary in handlers.py but missed the role list,
so Cato (authenticated as observer) would have seen the tool omitted
from list_tools and gotten access-denied before ever reaching the
new dispatch branch.

Adds get_cost_summary to ROLE_TOOLS['observer'] alongside the existing
get_usage_report. Regression test in TestCodexP1ObserverAccess pins
the entry so future ROLE_TOOLS edits don't drop it silently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(kanban): handle dataclass / pydantic objects in JSON columns (#502) (#504)

* fix(kanban): handle dataclass / pydantic objects in JSON columns (#502)

Per-feature Implement and Test tasks were silently dropped during
SQLite kanban writes because their ``source_context`` carries
``UserOutcome`` dataclass instances attached by the outcome-coverage
pipeline (#449).  ``json.dumps`` raised ``TypeError`` on these,
the kanban write failed per-task, and the symptom propagated as
"recipe-scale projects have zero Implement parents on the board"
while Design / foundation tasks (carrying plain dicts) survived.

Adds a ``_json_default`` encoder fallback covering dataclasses,
Pydantic v1/v2 models, and ``__dict__`` objects.  Wires it into
all three JSON columns: ``source_context``, ``completion_criteria``,
``acceptance_criteria``.

Diagnosis: ``logs/marcus_20260510_215120.log`` recipe-revert-haiku
run shows ``Successfully generated all 15 tasks`` followed by
``TypeError: Object of type UserOutcome is not JSON serializable``
on each per-feature task during ``create_tasks_on_board``.

Two regression tests pin both fields against a synthetic dataclass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(kanban): handle nested non-JSON primitives in encoder (Codex P2)

Codex review on PR #504 flagged that ``model_dump()`` (Pydantic v2)
and ``dataclasses.asdict()`` return Python objects unchanged for
``datetime`` / ``UUID`` / ``Path`` / ``Enum`` / ``set`` fields.
``json.dumps`` then recursively invokes ``_json_default`` on those
nested values, finds no protocol match, and raises — the same silent
task-drop symptom as the original bug, one level deeper.

Two fixes:

- Switch Pydantic v2 path to ``model_dump(mode="json")``, which
  recursively emits JSON-safe primitives in one pass.  Falls back to
  the kwarg-less form for Pydantic v1 / older signatures.
- Add explicit handlers for ``datetime``/``date`` (ISO 8601),
  ``UUID`` (string), ``Path`` (string), ``Enum`` (``.value``),
  ``set``/``frozenset`` (list), and ``bytes`` (base64).  These catch
  the recursive case when a dataclass field embeds them.

Reorder the protocol: Pydantic first (it knows its own nested types),
dataclass second, stdlib third, ``__dict__`` last.  Also wrap
``.dict()`` callable in TypeError-only catch so a broken stub method
falls through instead of propagating.

Two new regression tests pin the nested-primitive and broken-dict-method
cases.  100/100 unit tests pass, mypy strict clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cost): project-axis aggregation + automatic PlannerContext push (#409) (#503)

After Kaia review of phase 8: Marcus's identity is project_id (GH-388,
spawn_agents.py); experiment_id is an MLflow tracking handle. Refactor
the cost layer to lead with project_id.

CostAggregator gains list_projects() and unassigned_totals().
list_projects derives rollups from token_events.project_id with no
dependence on the experiments table. unassigned_totals surfaces the
'no PlannerContext' gap as a visible row.

handle_tool_call in marcus_mcp/handlers.py now pushes a PlannerContext
for every tool dispatch via ExitStack. Resolves project_id from
arguments → state.agent_project_map → state.selected_project_id, in
that order. Falls back to 'unassigned' if nothing resolves.

10 new tests (4 aggregator, 6 resolver). All 156 pass.
mypy --strict clean.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cost): don't attribute project-creation LLM work to the active project (#503) (#507)

Codex P1: resolver fell back to state.selected_project_id for
create_project, so its LLM decomposition cost landed under the
previously-active project. Adds _PROJECT_CREATION_TOOLS guard
(create_project, add_project, switch_project, update_project) — for
these tools the resolver skips the active-project fallback so events
land in the visible 'unassigned' bucket rather than the wrong project.
Explicit project_id in args still wins.

6 new regression tests; 12/12 resolver tests pass; mypy clean.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(contract-validation): loosen _normalize_type to ignore stylistic noise (#505)

* fix(contract-validation): loosen _normalize_type to ignore stylistic noise

The cross-contract type consistency gate fires on stylistic LLM phrasing
rather than real type disagreements, forcing contract_first → feature_based
fallback on Haiku-shaped runs.  Three recipe-platform runs in
``logs/marcus_20260510_230613.log`` all failed on the same false positives:

- ``userid (string, UUID v4)`` vs ``userid (string, UUID)`` — same type
- ``description (string)`` vs ``description (string, 0-500 chars)``
- ``limit (number, default 20)`` vs ``limit (integer, default 20)``
- ``offset (number, default 0)`` vs ``offset (integer, default 0)``

None are real disagreements; they're format hints, length constraints,
and the ``number``/``integer`` JSON Schema synonym.

Loosening strategy in ``_normalize_type``:

1. Drop everything after the first comma.  Trailing constraints describe
   properties of the base type, not the type itself.
2. Canonicalize ``integer`` -> ``number`` per JSON Schema (integer is
   a number subtype).

Regression-pinned must-still-catch cases:

- ``string`` vs ``number`` (WidgetPosition collision)
- ``array of strings`` vs ``array of IngredientItem objects``

Five new tests cover both directions.  142 contract-adjacent unit tests
still pass.  Mypy strict clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(contract-validation): canonicalize integer inside union types (Codex P2)

Codex P2 on PR #505 flagged that the integer->number synonym swap only
triggered on whole-string equality.  Nullable pagination fields like
``limit (integer | null, optional)`` vs ``limit (number | null, optional)``
slipped through (both reduced to ``integer|null`` vs ``number|null``
after the comma drop) and still forced contract_first fallback.

Apply the canonicalization per union member when ``|`` is present.
One new regression test pins the case.  12/12 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cost): plumb project_name through list_projects (Marcus #409) (#508)

* feat(cost): plumb project_name through list_projects (Marcus #409)

Kaia review of Cato PR #33 caught that the dashboard's project picker
shows project_id.slice(0, 12) — Marcus IDs are 19-digit numbers so
the truncation makes runs indistinguishable in the dropdown.

Fix flows from the SQL outward: list_projects now LEFT JOINs the
experiments table on experiment_id and surfaces MAX(project_name) per
project. NULL when no MLflow run was registered for the project —
the dashboard falls back to the truncated id in that case.

MAX(project_name) handles the rare case of multiple experiments under
the same project_id having different names; deterministic and avoids
GROUP BY non-determinism. LEFT JOIN means projects without any
registered MLflow run still appear (project_name=NULL), preserving
the existing 'projects with events but no run' surface.

Tests: 2 new (project_name attached when experiment exists, NULL
when none registered). 18/18 aggregator tests pass. mypy --strict
clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(cost): tighten list_projects docstring + JOIN comments (Kaia review of #508)

Three documentation touch-ups from Kaia's review:

1. Docstring no longer claims 'no dependence on the experiments table' —
   that was true before the LEFT JOIN landed, now it's misleading.
   Reframes the contract: token_events is the primary source, experiments
   is best-effort enrichment for project_name.

2. JOIN comment now pins the safety invariant: experiments.experiment_id
   must be unique (it's the PK in cost_store.SCHEMA_SQL). If a future
   migration relaxes that, the LEFT JOIN fans out and silently inflates
   every total. Early warning lives next to the JOIN.

3. MAX(project_name) rationale reframed honestly: it's masking-not-fixing
   behavior. A name conflict usually means data drift. Intentional drift
   detection is a follow-up; today we stay deterministic.

No behavior change. 18/18 tests still pass; mypy --strict clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(outcome-coverage): promote 'Task X' LLM artifact to 'Implement X' (#509)

* fix(outcome-coverage): promote 'Task X' LLM artifact to 'Implement X'

Haiku and qwen-class models pattern-match the literal word ``task`` out
of the gap-fill schema's ``"<short task name>"`` field description and
stamp it as a name prefix.  Result on the kanban board was a mix of
``Implement X`` parents (from feature_based decomposer at
``advanced_parser.py:2980``) alongside ``Task X`` parents (from
``gap_fill_contract`` synthesis) — same semantic role, two different
verbs, neither informative on a board where everything is a task.

The phenomenon was visible only post-PR #479 because the snake_case
slug normalizer cleaned up ``task_signup_form`` into the deceptively-
deliberate-looking ``Task Signup Form``.  The LLM had always emitted
the slug; #479 just made the artifact look intentional.

Fix extends ``_normalize_gap_task_name`` to promote ``Task X`` /
``Task: X`` prefixes to ``Implement X``.  Slug pass runs first so
``task_signup_form`` -> ``Task Signup Form`` -> ``Implement Signup Form``
composes.  Bare ``"Task"`` with no payload is left alone (signals
upstream prompt failure; don't silently rewrite into ``"Implement "``).

This is normalization of LLM artifact noise, not a HOW prescription —
agents still choose implementation freely.  Kaia review confirmed the
fix preserves the multi-agency proclamation's WHAT-vs-HOW boundary.

Five new tests pin the new behavior.  72/72 file tests green, mypy
strict clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(outcome-coverage): promote only slug-origin Task prefixes (Codex P2 #509)

Codex P2 on PR #509 flagged that an unconditional ``Task `` -> ``Implement ``
rewrite mangles legitimate domain nouns in task-management products
(``Task Creation Form``, ``Task Assignment Rules``, ``Task Queue``)
where ``Task`` IS the domain term.

Restrict promotion to inputs where the slug pass actually fired
(underscores present, no spaces).  That's the only confirmed LLM
artifact signature in the logged failure mode — Haiku/qwen emit
``task_signup_form`` slugs because they pattern-match the literal
word ``task`` out of the schema's ``"<short task name>"`` field
description.  Human-readable ``Task X`` names from the LLM are
trusted as intentional.

Tests updated: dropped the too-aggressive direct-prefix promotion
assertions; added regression test pinning ``Task Creation Form`` /
``Task Assignment Rules`` / ``Task Queue`` as untouched.

13/13 normalizer tests green.  Mypy strict clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cost): seed Anthropic prices from the official pricing page (#409) (#510)

* fix(cost): seed Anthropic prices from the official pricing page (#409)

Corrects three real seed errors (Opus 4.5-4.7 at $15/$75 instead of
$5/$25, Haiku 4.5 at $0.80/$4 instead of $1/$5, invented
claude-3-sonnet-20241022 identifier) and adds missing models from the
official pricing page (Opus 4.5/4.6 family, dated identifiers, Sonnet
4.5/4.0, Sonnet 3.7).

cache_creation_per_million maps to the 5-min cache write multiplier.
1-hour cache writes aren't representable in the current single-column
schema; documented for users to override via Cato.

All 59 cost-tracking tests pass; mypy --strict clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cost): set seed effective_from to 2026-05-11 (today)

The seed was using 2025-01-01 as a placeholder — that's misleading.
We don't actually know when each price became effective on Anthropic's
side, only that these are current values as of today. Date the seed
accordingly.

Implication: v_event_cost picks the latest price with effective_from
<= event.timestamp, so any pre-existing token_events row with a
timestamp before today drops from cost rollups (INNER JOIN). For users
with existing data, the workaround is to insert a backdated price row
via Cato's Pricing > Override form — the schema is built for exactly
this.

All 59 cost-tracking tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(cost): refresh stale 'as of 2025-01-01' comment above DEFAULT_SEED

Kaia review of PR #510 caught that the comment block still claimed the
seed values came from 2025-01-01 pricing. They're actually the values
read from the official page on _SEED_DATE (which the seed block already
documents). Drops the date claim and points readers to the populating
block instead.

* fix(cost): compat seed for configured default model (Codex P2 on #510)

Marcus's built-in config (src/config/settings.py, pm_agent_config.json)
sets model to ``claude-3-sonnet-20241022`` — a non-canonical identifier
that record_usage stamps onto events verbatim. Without a matching seed
row, v_event_cost's inner join drops those events from all aggregations.

Add a compat ModelPrice using Sonnet 3.5 pricing under that exact
identifier so default-config installs price correctly out of the box.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cost): dedup token_events on request_id (#409) (#511)

* fix(cost): dedup token_events on request_id (#409)

Cato's dashboard polls run_ingest every 30s, and run_ingest builds a
fresh WorkerJSONLIngester each call — whose in-memory ``_seen_uuids``
set is empty. Without DB-level dedup, every poll re-inserts every event
and token counts double silently.

- Add partial UNIQUE INDEX on token_events(request_id) WHERE NOT NULL.
  Partial index keeps legacy/non-Claude rows (NULL request_id) unconstrained.
- Switch record_event to INSERT OR IGNORE; return existing event_id on
  duplicate so callers see idempotent behavior.
- Regression tests cover the dup and NULL cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cost): dedup migration in _init_schema before unique index (Kaia on #511)

CREATE UNIQUE INDEX IF NOT EXISTS on token_events(request_id) raises
IntegrityError on any DB that accumulated duplicates pre-PR-#511 —
which is every install that ran Cato's 30s polling for any length of
time. Marcus wouldn't start.

- Add _dedup_pre_index_migration: one-shot DELETE keeping MIN(event_id)
  per request_id. No-op on fresh installs (table absent) and on clean
  DBs (no matching rows).
- Call from _init_schema before executescript so executescript's
  CREATE UNIQUE INDEX always sees compacted data.
- Regression test builds a pre-#511-shape DB with duplicate request_ids
  + NULLs, reopens via CostStore, asserts dedup and index enforcement.
- Fix three pre-existing tests that used hardcoded duplicate request_ids
  for distinct logical events (worker_ingester turn_index tests + the
  basic two-record ingest test); real records always have distinct
  requestIds per call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cost): make dedup migration truly no-op + busy_timeout (#511 followup) (#512)

* fix(cost): make dedup migration truly no-op + add busy_timeout (#511 followup)

After PR #511 merged, restarting Marcus while Cato was polling caused
``OperationalError: database is locked``. Two issues:

1. _dedup_pre_index_migration ran a write-locking DELETE on every
   startup, even on clean DBs where the unique index already existed.
   Now short-circuits if ux_te_request_id is present — index existence
   implies zero duplicates, so the DELETE is provably unnecessary.

2. sqlite3 connections default to busy_timeout=0, so any concurrent
   writer (Cato's 30s run_ingest sweep) caused startup DDL to fail
   immediately instead of waiting. Set PRAGMA busy_timeout=5000.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(cost): assert dedup DELETE is skipped once index exists

Spy via sqlite3 set_trace_callback during a second CostStore open;
verify no DELETE FROM token_events statement is issued. Covers Kaia's
nit on PR #512: the short-circuit behavior on ux_te_request_id presence
is simple to read but worth a regression test now that we depend on it
to avoid contending with concurrent Cato polling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cost): project_summary aggregator (#409 dashboard precursor) (#513)

* feat(cost): add project_summary aggregator method (#409)

Mirrors experiment_summary but scoped to project_id. Marcus's main
code path doesn't open MLflow experiments; the project axis is the
only universal identity for cost data. This unlocks the project-first
Cato dashboard surface (token events exist with project_id even when
the experiments table is empty).

Returns the same shape as experiment_summary (summary + by_role,
by_agent, by_task, by_operation, by_model) so the dashboard can drop
in project_id wherever it previously used experiment_id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cost): UUID normalization + project budget caps (Kaia followups on #513)

Two Kaia-spotted issues addressed:

1. UUID normalization at the write path. Marcus has two project-id
   generators that disagree on format: ProjectRegistry uses dashed
   canonical UUIDs (str(uuid.uuid4())), SQLiteKanban auto-discovery
   uses dashless hex (.hex). Both end up in projects.json depending
   on which code path created the project. token_events stored
   whatever it got handed, so cost rows ended up in mixed format.

   - New canonical_project_id() helper picks dashless as the
     canonical form (matches the bulk of existing rows + cheapest
     path).
   - PlannerContext normalizes on construction (frozen dataclass +
     __post_init__).
   - WorkerJSONLIngester applies the same normalization to bindings
     coming from spawn_agents project_info.json.
   - Cato's dual-index name overlay becomes defense-in-depth; new
     writes are guaranteed to land in the canonical form.

2. project_budgets table for project-level cost caps. Lives in the
   cost DB (not ProjectRegistry) because the cap is a cost concept,
   and keeping it here lets Cato own the write path without touching
   project metadata. One row per project_id; set_project_budget
   upserts in place; setting <= 0 clears the cap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cost): count unpriced events in aggregator queries (Codex P2 on #513)

v_event_cost INNER-joins model_prices, so events whose
(model, provider) has no matching price get silently dropped before
COUNT(*). Real-world data has 905 such rows on this account
('<synthetic>' planner artifacts + local 'qwen-25-coder-q5'), causing
project_summary to undercount events / tokens, miss whole projects
that only use unpriced models, and return None for projects that
actually do have token events.

- Add v_event_cost_inclusive view (LEFT JOIN, cost_usd=0 when no
  matching price). Same shape as v_event_cost so the swap is mechanical.
- Switch every aggregator query that needs true event/token counts:
  project_summary, experiment_summary, list_projects, list_experiments,
  project_totals, unassigned_totals, session_turns. v_event_cost
  itself is kept for any caller that explicitly wants the priced-only
  semantics (none exists today, but the contract is preserved).
- Regression test: add a '<synthetic>' event to the populated fixture
  and assert project_summary counts it, tokens add up, and the
  by_model row has cost=$0 (correctly unpriced).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cost): per-operation/model token + cache breakdown (#513 followup)

User feedback on PR #513 dashboard: 'I need to know which calls are
using the most tokens and if they are at least cached tokens. I need
to know where I need to tighten my prompts.'

Enrich by_operation and by_model in project_summary with:
- input_tokens, cache_creation_tokens, cache_read_tokens,
  output_tokens — the full token-type split per row
- cache_hit_rate per row (cache_read / (input + cache_creation +
  cache_read)) so heavy non-cached operations surface immediately
- Sort by tokens DESC (was cost) so the highest-volume operation
  comes first — that's the prompt-tightening target

Same shape addition to by_model lets users see whether a specific
provider/model is benefiting from prompt caching.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cost): persistent project names + create_project rebind + monitor attribution (#515)

* feat(cost): persistent project names + create_project rebind + monitor attribution (#409)

User feedback on dashboard: "'Unnamed' is unacceptable, every token has
to be accounted for. No LLM calls happen before create_project so any
calls during that period all belong to that project."

Three coordinated fixes:

1. Persistent project_names table. Cost data outlives Marcus's project
   registry — when a project is deleted, its name was lost and the
   picker fell back to opaque hex IDs. New table snapshots
   (project_id, name) whenever a PlannerContext is pushed with a name.
   Cato reads from it as the primary name source. Names persist
   forever, even after registry deletion.

2. create_project two-phase attribution. Tool entry pushes a
   placeholder PlannerContext (project_id="pending:<random hex>") so
   heavy decomposition LLM calls land with attribution instead of in
   the 'unassigned' bucket. After create_project returns the real id,
   rebind_project_id UPDATEs every placeholder row to the real id.

   Concurrency-safe by construction: ContextVar scopes the placeholder
   per asyncio task, random UUID per call prevents collisions across
   parallel create_project calls. Note: ProjectRegistry.add_project
   itself is NOT race-safe yet — tracked separately as #514.

3. ProjectMonitor attribution. _analyze_project_health runs in a
   background loop outside any MCP context, so its AI calls landed in
   'unassigned'. Now wraps the analyze call in a synthetic
   "monitor:<board_id>" PlannerContext with the project name from
   current_state, keeping monitor LLM cost visible against the
   project being analyzed.

Also: spawn_agents project_info.json now includes project_name so
worker session ingestion can carry the name end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cost): Kaia review nits on #515

Four issues caught in Kaia's architecture review:

1. Orphan placeholder rows in project_names. Every create_project
   left a dead 'pending:<hex>' entry indexed by a key nothing would
   ever look up. rebind_project_id now DELETEs the placeholder row
   from project_names alongside the token_events UPDATE.

2. Hot-path SQL on every planner_context push. The recorder did one
   SQL upsert per tool call even when the (project_id, name) pair
   had already been snapshotted this process. Added a process-
   lifetime Set[(id, name)] cache so repeated pushes short-circuit.
   Renames bypass the cache and re-write. SQL stays the source of
   truth — cache is purely a hot-path optimization.

3. Dead fallback in handlers.py. result.get("project", {}).get("id")
   was never hit — the canonical return shape is result["project_id"].
   Removed.

4. Leaky abstraction note on registry._cache. Added a TODO pointing
   at the right fix (expose ProjectRegistry.get_cached_project)
   without ripping out the working code today.

Tests: 93 pass (3 new across orphan cleanup, dedup short-circuit,
rename re-write).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cost): attribute monitor LLM calls to real project_id (Codex P2 on #515)

The previous attribution wrapper used ``f"monitor:{state.board_id}"``
as the cost project_id. ``board_id`` and ``project_id`` are distinct
in every provider that separates the two (Planka, the SQLiteKanban
auto-discovery path) — verified against CostAggregator.list_projects
which GROUP BYs token_events.project_id, so a monitor:<board_id> id
shows up as a synthetic ghost project rather than rolling into the
real project's totals.

Fix: pull the real Marcus project_id off the kanban client
(self.kanban_client.project_id, which is the canonical id, not the
board id). When no project_id is available (kanban not initialized
or no active board), fall through to 'unassigned' rather than mint
a synthetic id — that's correct because without a project_id we
genuinely don't know what to attribute the call to.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cost): plug 100% planner-cost attribution leak (Marcus #409) (#516)

* fix(cost): plug 100% planner-cost attribution leak (#409 followup)

Diagnostic showed that despite #515 wiring create_project placeholder
attribution through handlers.py, every recent planner LLM call still
landed in 'unassigned' (267 events / 932k tokens). Root cause: TWO
independent bugs stacked:

1. FastMCP entry point bypasses handlers.py. Marcus exposes
   create_project via two paths: the legacy mcp.server.Server (stdio,
   routes through handlers.py:handle_tool_call) and FastMCP HTTP
   (routes directly to nlp.create_project via @app.tool() at
   server.py:1608). HTTP is what users actually run. So #515's
   placeholder push in handlers.py never fired in practice.

   Fix: move the placeholder push + rebind logic inside
   nlp.create_project itself. Now every caller path — FastMCP, legacy
   handler, direct Python — gets the same attribution behavior.

2. OpenAIProvider had no recorder hook. The other three providers
   (anthropic, cloud, local) call get_recorder().record_planner_call()
   after a successful response. openai_provider._call_openai did not.
   On accounts where the Anthropic key is missing/invalid, every
   fallback to OpenAI silently bypassed cost tracking — which made
   the 'planner' role disappear from cost breakdowns even though
   Marcus was making heavy decomposition calls.

   Fix: add the recorder hook to _call_openai with the same shape as
   the other providers. Recorder failures are swallowed so a
   cost-store outage cannot break the provider call path.

Validated live: a fresh create_project now produces 18 events
correctly attributed to the new project_id (rebound from placeholder).
Zero new 'unassigned' rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cost): shadow placeholder in background design phase (Codex P1 on #516)

``create_project`` wraps ``_create_project_inner`` in a
``planner_context`` carrying a synthetic ``pending:<uuid>``
placeholder project_id, then runs a one-shot ``rebind_project_id``
after the inner call returns. ``_run_design_phase`` is spawned via
``asyncio.ensure_future`` *during* the inner call, so the new task
inherits the placeholder via the parent's ContextVar state copy.
After the wrapper's one-shot rebind fires, the background design
phase keeps writing rows under the placeholder — those rows are
never rebound and stay hidden as ``pending:*``.

Fix: ``_run_design_phase`` pushes a fresh ``PlannerContext``
carrying the real project_id (resolved from ``kanban_client``) as
its very first action. That shadows the inherited placeholder for
the design phase's whole lifetime — every LLM call inside records
against the real project from the start. The wrapper's rebind
still catches the synchronous rows under the placeholder; the
background design phase no longer contributes any.

The body is split into ``_run_design_phase_body`` so the wrapper
can do the context push without forcing the entire 280-line body
into an extra indentation level. Behavior identical otherwise —
the existing 22 design-autocomplete tests still pass without
modification.

Defensive ``isinstance(_raw_pid, str)`` check: tests use
MagicMock-backed kanban_clients whose ``project_id`` resolves to
another MagicMock. Without the check we'd push a context with a
non-string project_id and the recorder would silently swallow the
SQL error.

Two new tests in ``TestRunDesignPhaseCostAttribution`` lock in the
fix: one captures ``recorder.current()`` from inside the body and
asserts it sees the real project_id (not the placeholder), the
other proves the MagicMock-style fallback doesn't crash.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cost): per-operation drill-down for planner LLM calls (#409) (#517)

* fix(cost): plug 100% planner-cost attribution leak (#409 followup)

Diagnostic showed that despite #515 wiring create_project placeholder
attribution through handlers.py, every recent planner LLM call still
landed in 'unassigned' (267 events / 932k tokens). Root cause: TWO
independent bugs stacked:

1. FastMCP entry point bypasses handlers.py. Marcus exposes
   create_project via two paths: the legacy mcp.server.Server (stdio,
   routes through handlers.py:handle_tool_call) and FastMCP HTTP
   (routes directly to nlp.create_project via @app.tool() at
   server.py:1608). HTTP is what users actually run. So #515's
   placeholder push in handlers.py never fired in practice.

   Fix: move the placeholder push + rebind logic inside
   nlp.create_project itself. Now every caller path — FastMCP, legacy
   handler, direct Python — gets the same attribution behavior.

2. OpenAIProvider had no recorder hook. The other three providers
   (anthropic, cloud, local) call get_recorder().record_planner_call()
   after a successful response. openai_provider._call_openai did not.
   On accounts where the Anthropic key is missing/invalid, every
   fallback to OpenAI silently bypassed cost tracking — which made
   the 'planner' role disappear from cost breakdowns even though
   Marcus was making heavy decomposition calls.

   Fix: add the recorder hook to _call_openai with the same shape as
   the other providers. Recorder failures are swallowed so a
   cost-store outage cannot break the provider call path.

Validated live: a fresh create_project now produces 18 events
correctly attributed to the new project_id (rebound from placeholder).
Zero new 'unassigned' rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cost): shadow placeholder in background design phase (Codex P1 on #516)

``create_project`` wraps ``_create_project_inner`` in a
``planner_context`` carrying a synthetic ``pending:<uuid>``
placeholder project_id, then runs a one-shot ``rebind_project_id``
after the inner call returns. ``_run_design_phase`` is spawned via
``asyncio.ensure_future`` *during* the inner call, so the new task
inherits the placeholder via the parent's ContextVar state copy.
After the wrapper's one-shot rebind fires, the background design
phase keeps writing rows under the placeholder — those rows are
never rebound and stay hidden as ``pending:*``.

Fix: ``_run_design_phase`` pushes a fresh ``PlannerContext``
carrying the real project_id (resolved from ``kanban_client``) as
its very first action. That shadows the inherited placeholder for
the design phase's whole lifetime — every LLM call inside records
against the real project from the start. The wrapper's rebind
still catches the synchronous rows under the placeholder; the
background design phase no longer contributes any.

The body is split into ``_run_design_phase_body`` so the wrapper
can do the context push without forcing the entire 280-line body
into an extra indentation level. Behavior identical otherwise —
the existing 22 design-autocomplete tests still pass without
modification.

Defensive ``isinstance(_raw_pid, str)`` check: tests use
MagicMock-backed kanban_clients whose ``project_id`` resolves to
another MagicMock. Without the check we'd push a context with a
non-string project_id and the recorder would silently swallow the
SQL error.

Two new tests in ``TestRunDesignPhaseCostAttribution`` lock in the
fix: one captures ``recorder.current()`` from inside the body and
asserts it sees the real project_id (not the placeholder), the
other proves the MagicMock-style fallback doesn't crash.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cost): per-operation drill-down for planner LLM calls (#409)

Tags every Marcus planner LLM call with a logical operation key so the
Cato dashboard can render *which* operation spent the tokens, not just
that some did. Fix 2 of the cost-tracking follow-ups after #515 landed
the attribution rebind.

What changed
- New ``src.cost_tracking.operations`` catalog mapping operation keys
  to ``{label, description, category}``. 32 entries grouped into
  ``decomposition``, ``runtime``, ``monitoring``, ``other``.
- New ``CostRecorder.operation_context(operation)`` context manager
  that pushes a child PlannerContext with ``operation_override`` set.
  Innermost wins; works under nested asyncio tasks via ContextVar.
- ``LLMAbstraction.analyze(..., operation=...)`` threads the key
  through to the recorder so call sites can tag without touching every
  provider HTTP path. High-level methods
  (``analyze_task_semantics``, ``analyze_blocker_and_suggest_solutions``,
  etc.) wrap themselves in the appropriate operation_context.
- ``AIAnalysisEngine.generate_structured_response(..., operation=...)``
  threads through too, so the decomposer / dependency wiring /
  contract generation / task-completeness validator all stamp the
  right key.
- Updated every call site to pass an explicit operation (decompose_prd,
  extract_outcomes, outcome_coverage_check, outcome_gap_fill,
  filter_outcomes, discover_domains, synthesize_foundation_tasks,
  generate_design_artifact, generate_design_decisions,
  generate_project_scaffold, generate_contracts, generate_task_detail,
  decompose_task, validate_task_completeness, validate_work,
  analyze_blocker, infer_dependencies, enrich_task,
  analyze_task_semantics, estimate_effort, plus post_analysis_*
  for the post-project analyzers).

Tests
- New TestOperationContext suite verifies override/pop semantics and
  no-parent no-op behavior.
- New test_operations_catalog.py verifies catalog shape, fallback for
  unknown keys, and that all call-site keys are registered.
- Existing analyze mocks updated to accept the new ``operation``
  kwarg (via ``**kwargs``) so they still work as side_effects.

The recorder is unchanged in its error-swallowing contract: failure
to attribute an operation never breaks the provider call path. Keys
that don't exist in the catalog fall through to a synthesized
"Unregistered operation" tooltip on the dashboard side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cost): operation-taxonomy review fixes from Kaia

P2 — drift detection at record time
- ``CostRecorder.record_planner_call`` now warns once per process
  per unregistered operation key. A typo at a call site (or a
  newly-added operation nobody remembered to catalog) silently
  lands in the dashboard's fallback bucket otherwise. The warning
  surfaces it in dev logs without spamming production.

P3 — AnalysisType ↔ catalog drift test
- New ``TestAnalysisTypeCoverage`` asserts every ``AnalysisType``
  enum value maps to a ``post_analysis_<value>`` catalog entry.
  Catches future drift when a new analyzer type is added without
  a corresponding catalog entry, instead of letting it silently
  fall through to the synthesized fallback label.

Risk — Protocol-based test double
- New ``LLMAnalyzeClient`` Protocol in
  ``src/ai/providers/protocols.py`` pins the ``analyze()``
  contract: ``async def analyze(prompt, context, *,
  operation: Optional[str] = None) -> str``.
- New ``make_analyze_mock`` helper in ``tests/unit/conftest.py``
  builds AsyncMocks that absorb unknown kwargs, so future
  signature additions to ``analyze()`` won't require fanning out
  ``**kwargs`` across every test mock.
- Test coverage for both the Protocol and the helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cost): preserve operation override without parent (Codex P2 + Kaia nit)

Codex P2 on PR #517
- ``operation_context`` used to yield ``None`` when no parent
  ``PlannerContext`` was active. ``record_planner_call`` then never
  saw the ``operation_override`` and recorded the provider's generic
  ``'analyze'`` bucket instead of the call site's intended operation.
- Background / standalone planner calls (e.g., the post-analysis
  path that builds ``operation=f"post_analysis_..."`` from a
  request but doesn't push a recorder context first) lost their
  per-operation drill-down entirely.
- Fix: synthesize an ``'unassigned'`` ``PlannerContext`` carrying
  just the override when no parent exists. Operation tag survives;
  project / experiment fall through to ``'unassigned'`` as before.
- Test ``test_synthesizes_unassigned_parent_when_no_context`` locks
  this in: an ``operation_context`` outside any project scope must
  still stamp the override onto ``token_events.operation``.

Kaia review nit on PR #517
- The five high-level ``LLMAbstraction`` methods
  (``analyze_task_semantics``, ``infer_dependencies_semantic``,
  ``generate_enhanced_description``, ``estimate_effort_intelligently``,
  ``analyze_blocker_and_suggest_solutions``) all repeated the same
  four-line preamble that pushed an ``operation_context``.
- Extracted to a ``@_tagged_operation("<key>")`` decorator. The
  recorder import stays inside the wrapper so importing
  ``llm_abstraction`` doesn't force the cost-tracking module load
  at startup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(cost): rename experiments→runs + add path discriminator (#409) (#522)

* refactor(cost): rename experiments -> runs, add path discriminator (#409)

Resolves the long-standing namespace collision between cost-tracking's
internal "experiment_id" concept and MLflow's separate experiment notion.
The cost-tracking table tracks *runs* through a project — distinct
invocations on a shared project_id, not MLflow experiments.

Schema:
- experiments -> runs
- experiments.experiment_id -> runs.run_id
- token_events.experiment_id -> token_events.run_id
- runs.path TEXT NOT NULL DEFAULT 'unknown' (new: direct/marcus/posidonius)
- indexes renamed: idx_experiments_* -> idx_runs_*, idx_te_exp* -> idx_te_run*
- new idx_runs_path

Migration: one-shot idempotent rename in _runs_rename_migration().
Detects legacy `experiments` table, runs ALTER TABLE RENAME, adds path
column with default 'unknown'. Safe on fresh installs (no-op) and on
already-migrated DBs (no-op).

Attribution:
- create_project pre-generates run_id at wrapper entry and rebinds on
  success. Defaults path="direct" so unwrapped Direct MCP callers get a
  correct discriminator without code changes.
- experiments/spawn_agents.py sets path="marcus" by default; Posidonius
  config can override to "posidonius".

API renames:
- CostStore.record_experiment -> record_run
- CostAggregator.list_experiments -> list_runs
- CostAggregator.experiment_summary -> run_summary
- TokenEvent.experiment_id -> run_id
- PlannerContext.experiment_id -> run_id
- AgentBinding.experiment_id -> run_id
- get_cost_summary MCP tool: experiment_id arg -> run_id, response key
  experiments -> runs
- Dataclass Experiment -> Run (with new path field)

MLflow's start_experiment MCP tool is untouched — that namespace
remains MLflow's. The cost-tracking "experiment" was always a different
concept; this rename makes that explicit.

Simon: 7ed3074d (decision)
Closes the rename work agreed on after Kaia's interpretation review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cost): address Codex review on #522 (P1+P2)

P1 — make run-recording tests independent of local config
The _mock_api_key fixture set CLAUDE_API_KEY in env, but MarcusConfig.
from_file() only does ${CLAUDE_API_KEY} substitution when a config
file is actually loaded. On a fresh checkout without config_marcus.
json the substitution never fires, defaults stay unset, and the
get_config()/validate() call from create_project's kanban-provider
lookup raises. Codex reproduced this in a clean tree.

Replace the env-var fixture with a direct stub of the module-level
_config singleton, so the test is hermetic on any developer machine
and in CI regardless of local files.

P2 — don't insert phantom runs rows on dedup-cached retries
When create_project is invoked within the 10-minute dedup window
after a successful first call, _create_project_inner returns the
cached result immediately — no decomposition, no token_events. The
wrapper still pre-generated a fresh run_id at entry, so on success
it would insert a brand-new runs row for the cached replay. Result:
zero-cost phantom rows piling up per retry storm, breaking dashboard
counts and the runs picker.

Mark cached replays with an internal _dedup_cached flag inside the
inner function (set on a shallow copy so the cache entry stays
clean). The wrapper pops the flag and skips record_run +
rebind_project_id when set, so the agent's response is unchanged
but the runs table holds exactly one row per real invocation.

Also:
- Add pytestmark = pytest.mark.unit to both touched test files so
  the 16 cost/run-recording tests actually run under `pytest -m unit`
  (the PR-blocking CI marker). Mirrors the convention in sibling
  files like test_lease_recovery_state_sync.py.
- New regression test TestCodexP2DedupReplay verifies three identical
  calls in a row still produce exactly one runs row.

Baseline: 1313 passed, 1 skipped under `pytest -m unit` (was 1297;
+16 tests now actually run in CI).

Refs: lwgray/marcus#522 (Codex review)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(outcome-coverage): inject success_signal into acceptance_criteria (#523 Slice A) (#524)

* docs: require college-student-readable GitHub issues and PR descriptions

Adds explicit style guidance to CLAUDE.md and the
github-issue-manager agent so issue bodies and PR descriptions are
written for readers with zero codebase context. Names the always/never
checklist (open with plain-English project framing, define every
internal term on first use, state the user-facing problem before the
technical one, include file paths, worked numerical examples for
measurable quantities, a "Where to look in the code first" table, and
a verification procedure). Style applies to new artifacts and rewrites
of existing ones.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(outcome-coverage): inject success_signal into acceptance_criteria (#523 Slice A)

Threads each in-scope user outcome's success_signal text into the
acceptance_criteria of every task the coverage mapping says addresses
it (both organic decomposition output and synthesized gap-fill tasks).
The existing WorkAnalyzer LLM static gate reads acceptance_criteria at
task completion, so this teaches that gate what user-observable
outcome a task must satisfy without changing WorkAnalyzer itself.

This is the static-layer half of #523. The runtime-layer half (extend
the integration smoke gate to accept a list of verifications driven by
success_signals) lands in a follow-up slice.

Changes
-------
* New helper `_enrich_acceptance_criteria_with_signals` — pure
  function; returns new task list with signal text appended,
  idempotent, skips out-of-scope outcomes, identity-passes uncovered
  tasks.
* New helper `_translate_stub_ids_to_real_ids` — rewrites the
  `_synth_for_coverage_<idx>` recoverage stub ids in
  `coverage_after_fill` to the real `gap_fill_<uuid>` ids so the
  enricher matches against the actual augmented task list.
* `SIGNAL_CRITERION_PREFIX = "User outcome verifiable: "` — stamped on
  every appended criterion so downstream consumers can tell signal
  criteria apart from template/LLM-emitted criteria and so re-running
  enrichment is idempotent.
* Wired into both `apply_outcome_coverage_to_feature_graph` and
  `apply_outcome_coverage_to_contract_graph` after gap-fill synthesis.

Tests
-----
* +13 unit tests for the enricher (purity, identity passthrough,
  out-of-scope skip, multi-outcome / multi-task, idempotence,
  empty / None / missing-mapping no-ops, unknown task id ignored)
* +6 unit tests for the stub-id translator
* +2 graph-level integration pins (feature + contract paths) verifying
  the signal lands on synthesized gap-fill tasks end-to-end
* Updated one existing test that asserted strict task identity post-
  coverage; now allows the enriched copy

Full unit suite: 3566 passed, 61 skipped, 0 failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(outcome-coverage): log signal-enrichment summary at INFO (#523 Slice A patch)

Adds a one-line INFO log when the enrichment pass changes the task
list ("Signal enrichment: N task(s) gained M signal criterion(s)
from K in-scope outcome(s)"). Silent on no-op so steady-state
idempotent re-runs don't spam logs.

Lets operators debug "did the signal land?" from logs alone, without
inspecting the task graph directly. Sibling to the existing
"Outcome coverage: score=..." line emitted by the wrapping
apply_outcome_coverage_to_*_graph helpers — together they let
operators correlate coverage score with enrichment effect for a
given run.

Tests
-----
* +1 unit test pinning the INFO log fires with the expected counts
* +1 unit test pinning silence on no-op idempotent re-runs

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: runtime smoke gate accepts per-outcome verifications (#523 Slice B) (#525)

* feat(product-smoke): VerificationSpec + multi-spec runner foundation (#523 Slice B)

Pure addition — no behavior change to existing callers.  Establishes
the primitive Slice B's smoke-gate update will consume.

A ``VerificationSpec`` is a shell command pinned to a
``UserOutcome.success_signal`` via the outcome's ``signal_id``.  Integration
agents declare one spec per in-scope outcome at task completion; Marcus
runs each via the existing ``verify_deliverable`` primitive and aggregates.

Single-primitive design (per #523 issue body): Marcus does not learn
about curl, Playwright, pytest, or any other tooling family.  The agent
picks tools appropriate to the deliverable shape; Marcus only knows how
to run shell commands and check exit codes.

Changes
-------
* ``VerificationSpec`` dataclass: ``signal_id``, ``command``,
  ``description``, optional ``readiness_probe``.
* ``VerificationsResult`` dataclass: aggregate + per-spec breakdown +
  agent-facing blocker.
* ``verify_verification_specs(specs, cwd)``: runs each spec through
  ``verify_deliverable``, returns aggregate.  Does NOT short-circuit on
  failure so operators see the full diagnostic in one run.
* Two blocker renderers: ``_render_no_specs_blocker`` for empty input,
  ``_render_verifications_failure_blocker`` for the first failing spec
  (names ``signal_id``, ``description``, ``command``, exit code, stderr
  tail — Slice B acceptance criterion).

Tests
-----
* +8 unit tests covering: dataclass fields, empty input rejection,
  all-pass aggregation, mixed-result failure rendering, readiness_probe
  forwarding, signal_id fallback when description is empty, and
  ``to_dict`` telemetry serialisation.

Full module: 36 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(smoke-gate): route through verify_verification_specs when verifications declared (#523 Slice B)

Extends ``report_task_progress`` to accept a ``verifications`` list and
routes the smoke gate through the multi-spec runner when present.
Legacy ``start_command`` path is preserved unchanged for backward
compatibility.

Contract precedence:
* Non-empty ``verifications`` → runs ``verify_verification_specs``;
  ``start_command``/``readiness_probe`` are ignored.
* ``verifications`` absent or empty list → legacy single-command path
  runs unchanged.
* Both absent on an integration task → existing missing-declaration
  rejection still fires.

Rejection shape grows ``error="verifications_failed"`` for the new
path; the runner's blocker (signal_id, description, command, exit
code, stderr — Slice B acceptance criterion) flows through unchanged.

Changes
-------
* ``_run_product_smoke_gate`` accepts ``verifications``, coerces dicts
  to ``VerificationSpec``, routes to multi-spec runner when non-empty.
* ``report_task_progress`` impl gains ``verifications`` parameter;
  docstring documents the precedence rules.
* MCP server wrappers (both registration sites in
  ``src/marcus_mcp/server.py``) expose ``verifications``.

Tests
-----
* +5 unit tests covering precedence, passthrough, rejection shape,
  dict-to-dataclass coercion, and empty-list fallthrough.
* Existing 12 smoke-gate tests pass unchanged — backward compat verified.

Full unit suite: 3581 passed, 61 skipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(integration-task): accept user outcomes and surface them on the task (#523 Slice B)

Wires the outcomes extracted by ``outcome_extractor`` (issue #449)
into the integration verification task so the agent knows which
``UserOutcome`` records the smoke gate's coverage check (next commit)
will require ``VerificationSpec`` entries for.

Changes
-------
1. ``IntegrationTaskGenerator.create_integration_task`` accepts an
   optional ``outcomes`` list.  Filters to in-scope, stores
   ``in_scope_outcome_ids`` on ``Task.source_context``, and appends
   a "Verifications required" section to the description listing each
   outcome's id, action, and success_signal with a worked
   ``report_task_progress`` example.
2. ``enhance_project_with_integration`` accepts and forwards
   ``outcomes`` to the generator.
3. Contract-first call site in ``nlp_tools.py`` stashes
   ``prd_analysis.user_outcomes`` alongside the existing requirements
   stash, reads it back at the enhance call, and clears on cleanup.

Feature-based wiring is deferred to a follow-up.  The snake-game
class of failure (#523's motivating reproduction) ships via the
default ``contract_first`` decomposer, so wiring the default path is
what closes the original failure case.

Backward compatible: ``outcomes=None`` preserves legacy description
shape (no source_context, no Verifications section, no coverage
requirement at the smoke gate).

Tests
-----
* +7 unit tests covering in_scope IDs on source_context, out-of-scope
  filtering, description growth (id/action/signal/example), legacy
  ``outcomes=None`` shape, empty list vs None distinction, all-out-of-
  scope handling, and enhance_project_with_integration forwarding.
* Existing 55 IntegrationTaskGenerator tests pass unchanged.

Full unit suite: 3588 passed, 61 skipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(smoke-gate): reject completion when verifications miss required outcomes (#523 Slice B)

Closes the loop on Slice B: when an integration task was created
with outcomes (commit 228ca479 stores ``in_scope_outcome_ids`` on
``Task.source_context``), the smoke gate rejects completion if any
required outcome has no matching ``VerificationSpec.signal_id`` in
the declared ``verifications`` list.

The check fires BEFORE any subprocess runs so an agent that forgot
a signal_id gets immediate, structural feedback without paying
verify-deliverable latency.  Retrying with the same incomplete list
will fail the same way — the fix is "add the missing entries to
verifications," not "wait for a flaky subprocess."

Rejection shape gains ``error="verifications_missing_coverage"`` and
three new fields: ``missing_outcome_ids``, ``required_outcome_ids``,
and ``declared_signal_ids``.  The blocker lists all three so the
agent can diff and fix in one pass.

Coverage rule does NOT fire when:
* ``task.source_context`` is ``None`` (legacy tasks — backward compat)
* ``in_scope_outcome_ids`` is missing or empty (trivially satisfied)

Tests
-----
* +5 unit tests covering coverage satisfied, missing-coverage
  rejection before subprocess, all-missing edge case, legacy
  ``source_context=None`` bypass, and empty-list trivial pass.

Full unit suite: 3593 passed, 61 skipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(smoke-gate): close verifications=None escape hatch (#523 Slice B, Kaia review on PR #525)

Slice B's coverage check fires only inside ``if verifications:``.
An agent could send ``verifications=None`` (or ``[]``) plus a working
legacy ``start_command``, fall through to ``verify_deliverable``,
and ship a project whose user-observable outcomes were never
verified — the exact failure mode Slice B was designed to prevent
(snake-game class).

Fix: preliminary check BEFORE the verifications/legacy branch
decision.  When the integration task carries declared in-scope
outcomes on ``source_context["in_scope_outcome_ids"]`` AND
``verifications`` is empty/None, reject with
``error="verifications_required_but_missing"``.

Tasks WITHOUT declared outcomes bypass the check and use the legacy
path — backward compatibility preserved.

Also adds defensive ``isinstance(..., list)`` on the
``in_scope_outcome_ids`` read at both the new check and the existing
coverage check.  Kanban providers that rehydrate ``source_context``
from JSON could in theory return a string here; ``list("o_play")``
would silently iterate characters and corrupt the required set.
Treat malformed values as wiring-absent.

Tests
-----
* +5 unit tests: outcomes+None rejects; outcomes+[] rejects;
  outcomes+matching verifications proceeds (happy path);
  legacy ``source_context=None`` bypasses check; malformed string
  ``in_scope_outcome_ids`` treated as absent.

Full unit suite: 3598 passed, 61 skipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cost): token audit + per-role by_operation split (#527 Phase 1) (#528)

When the dashboard says a snake-game cost 125M tokens, today you can
see one number but no way to drill in: 99.85% of all tokens land in
a single bucket called `turn`. That bucket comes from
worker_ingester.py hardcoding `operation="turn"` for every agent
LLM call, and the chart shows it because by_operation aggregates
both planner and worker rows together.

This change makes the cost data answer the question "where did the
tokens go?" by:

1. Splitting by_operation by agent_role so planner rows (semantic
   operations like parse_prd, decompose_prd) can be charted on
   their own — the chart isn't polluted by the worker `turn` bucket
   that dominates the total. Both run_summary and project_summary
   now emit `role` on every by_operation slice.

2. Adding `run_audit(run_id)` and `project_audit(project_id)` —
   token-attribution audits that answer "is every token I recorded
   for this scope accounted for?" They check: (a) sum-of-by-role
   tokens reconciles with grand-total tokens, (b) zero worker rows
   are missing task_id, (c) zero worker rows are missing agent_id.
   Both summaries now include the audit inline.

The architectural framing (issue #527, Simon decision `308a0951`):
`operation` is a planner-only attribution axis. Worker rows are
naturally attributed via task_id / agent_id / session_id / (in
Phase 2) tool_intent. The audit makes coverage explicit so the
dashboard can show "every token attributed" or surface the gap.

Tests:
- TestRunAudit (4 tests): reconciliation, zero-orphan, orphan
  detection when a worker event lacks task_id, zero-state on
  unknown run.
- TestProjectAudit (2 tests): project-scoped audit, scope
  isolation between projects.
- TestByOperationSplitByRole (4 tests): every by_operation slice
  carries role, planner/worker turn aggregate separately, audit
  field present on both summary types.
- Added `pytestmark = pytest.mark.unit` to test_cost_aggregator.py
  so the existing 34 tests + 10 new ones run in CI under
  `pytest -m unit` (closes the same CI gap fixed for sibling files
  in PR #525).

Baseline: 1400 passed, 1 skipped under `pytest -m unit`. mypy clean
on `src/cost_tracking/cost_aggregator.py`.

Ships with Cato Phase 1 dashboard work (sibling PR) — the new
backend fields are consumed by AuditBanner, TaskSpendPanel, and
the updated Oper…
@lwgray lwgray deleted the feat/409-runs-path-rename branch May 15, 2026 04:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant