Files
smart-org/todo-v2-final.md
mjalmoudy94 d532929189 feat(integration): prepaid billing, engine UI, credits console, direct tasks, v2 final
- Prepaid credit system: org wallet, debit on usage, chat gate, admin credit console
- Engine UI: /manager, /tasks, /tasks/[id], /workforce, /dashboard, /traces pages
- Direct task creation (HTTP POST /tasks, MCP tasks_create) at pending_manager_review
- Pause/resume/cancel/answer actions in engine API and UI
- Employee cap (7 per org default), review pipeline guard (active reviewer required)
- Scheduler: wide window prevents org starvation; archived employees excluded from cap
- Tenant isolation: project-id cross-org 404, engine never uses another tenant's sandbox
- Order route fix: manager built with pipeline states + employee profiles
- Contract extension: ModelGrantPort.get_org_policy with DefaultOrgPolicy
- Engine event types in @agy/contracts (task.step, task.gate, run.log, run.tool, team.changed)
- api-engine.ts typed client; api-credit.ts client
- Alembic migration c4d5e6f7a8b9: prepaid credit + engine UI tables
- 859 tests passed, 1 skipped; ruff/mypy/tsc/eslint/vitest all green

Refs: PLAN-v2.md §9 (T1–T43), todo-v2-final.md
2026-10-14 23:19:05 +03:30

76 KiB
Raw Permalink Blame History

final check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/ruff check . && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app && cd ../web && node /projects/smart-org/node_modules/vitest/vitest.mjs run && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json

Smart Org v2 — last work (prepaid credit, direct tasks in the engine, Agent 2 phase 5 UI, cleanup)

Base: branch dev, HEAD fef5a9d. todo-finish-plan.md (T1–T27) is done and must NOT be edited. Single alembic head today: b9c0d1e2f3a4. The one new migration in this file (T1) uses down_revision = "b9c0d1e2f3a4"; check ls apps/api/alembic/versions first, and if a newer head exists, chain off it instead.

Binding product decisions (from the owner): D-A cap 7, D-B prepaid billing, D-C build phase 5 UI here, D-D direct tasks enter the engine + pause/resume/cancel, D-E housekeeping. Rules for every task: tenant isolation (every query filtered by organization_id, a foreign id is a 404), no model/provider names for non-owners, no secret in a response/log/event, Persian/RTL UI with @agy/ui, no silent except Exception, never weaken an existing test (update it only where a decision below changes the behaviour, and say so in its docstring). Do NOT commit.

What the code has today (checked, so it is not planned again, only used):

  • Engine events already published: run.log, run.tool (engine/events.py::RunFeed, on run:, task: and org:), task.gate (task: + org:), team.changed (org:), task.created (manager CREATE_TASK). task.step is not emitted anywhere (T4 adds it). Stage moves have one choke point: app/application/engine/state.py::advance (used by Scheduler._move and the manager).
  • There are no engine read endpoints: routers/workforce.py has only the legacy task/run/employee routes (no stage, turns, gates, diff, tier, role+tier hire). T17–T19 add a new router routers/engine.py.
  • The diff is never stored (the worktree is gone after merge) and the pushed sha is not stored on the task (T1–T3 add last_diff and merged_sha to task_pipeline_state).
  • Billing: UsageLedgerRepository.record (repositories/audit.py:115) is the single place every usage row is written (model calls via record_model_usage, sandbox, memory reflection). Nothing debits a wallet. CreditWalletRepository.adjust is a Python read-modify-write (not atomic). There is no credit history table. Free-credit holes: POST /organizations/{org}/billing/add-credit lets any org admin with billing.manage add positive credit, and POST .../payments/{id}/settle lets the same admin settle a pending payment as succeeded (= free credit). Both must become owner-only under prepaid (T12).
  • Scheduler._budget_reason (engine/scheduler.py:622-649) pauses only on balance < 0 and treats a missing wallet as "no budget" (T7 reverts). ManagerTurns.chat never checks credit (T8).
  • WorkforceService.create_task (workforce_service.py:435) makes no pipeline state, so HTTP POST /tasks and MCP tasks_create (interfaces/mcp/gateway.py:451, calls create_task) run through the legacy one-shot start_run (T14). PAUSED → PENDING_MANAGER_REVIEW is a legal transition, but no API exists (T16).
  • Web: lib/api-engine.ts is an empty stub; /manager, /tasks, /tasks/[id], /workforce, /workforce/[id], /dashboard, /traces are the pre-v2 pages (legacy task/run views, routing-policy select, no stage/tier). /billing shows add-credit + checkout to org admins. /admin/organizations and /admin/model-grants exist; there is no owner credit page.
  • D-E verified: tests/test_integration_thread_project.py:69-70 and tests/test_v2_e2e_smoke.py:134-136 say the HTTP message route builds its manager without pipeline states (stale since tests/test_integration_order_route.py); apps/web/src/lib/api-org.ts:303-310 says the event is not in @agy/contracts (it is, since T15 of the old plan). docs/plan/reports/agent-3-final.md: line 25 and §6 step 9 (lines 160-161) claim an MCP system route the owner can enable (only a read-only suggestion exists: GET /api/admin/domains/suggested, and the label mcp is refused); §5 line 128 lists a system_routes table (no such table in models_delivery.py); there is no sandbox restart claim in the final report itself — the restart gap is only in TODO-agent-3 6.3 / phase-6 report, so T42 adds one explicit "no restart" line to §8 instead of deleting a claim.

Shared test commands (abbreviated below as PYTEST / MYPY / RUFF / WEB):

  • PYTEST = cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider
  • MYPY = PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy
  • RUFF = PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/ruff check
  • WEB = cd /projects/smart-org && cp packages/contracts/src/index.ts node_modules/@agy/contracts/src/index.ts && cd apps/web && node /projects/smart-org/node_modules/vitest/vitest.mjs run
  • TSC = node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json The check: lines below are written out in full.

T1 — migration + ORM: credit_transactions, task_pipeline_state.last_diff/merged_sha, policy default 7

status: done group: prepare after: none files: apps/api/app/infrastructure/db/models_billing.py (new), apps/api/app/infrastructure/db/models.py:1026-1028, apps/api/app/infrastructure/db/models_engine.py:51-93, apps/api/alembic/versions/c4d5e6f7a8b9_prepaid_credit_and_engine_ui.py (new) change:

  1. New models_billing.py (use pk()/created() from app.infrastructure.db.columns, never import db/models.py): CreditTransactionRow, table credit_transactions: id pk; organization_id FK organizations.id ondelete CASCADE, not null, indexed; kind String(16) not null (owner_credit | starting_credit | usage | adjustment); amount Float not null (signed: credit > 0, debit < 0); balance_after Float not null; reason String(500) default ""; actor_user_id String(32) nullable; usage_event_id String(32) nullable, unique (idempotency of a usage debit); created_at created(). Index("ix_credit_tx_org_created", "organization_id", "created_at").
  2. models.py: add from app.infrastructure.db import models_billing as _models_billing # noqa: E402,F401 next to the three existing lines (1026-1028).
  3. models_engine.py::TaskPipelineStateRow: add last_diff: Text, default "", nullable=False (latest accepted diff, capped by the writer) and merged_sha: String(64), default "", nullable=False.
  4. Migration c4d5e6f7a8b9, down_revision = "b9c0d1e2f3a4" (verify it is still the single head): create credit_transactions (+ unique on usage_event_id, the index), add the two columns with server_default="", and alter_column("org_policies", "max_employees", server_default="7") (was "5", D-A). Downgrade reverses all three (server_default back to "5"). Use batch_alter_table for SQLite. check: cd /projects/smart-org/apps/api && rm -f _alembic_check.db && export DATABASE_URL=sqlite+aiosqlite:///./_alembic_check.db PYTHONPATH=$PWD && A=/home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/alembic && $A upgrade head && $A check && $A downgrade b9c0d1e2f3a4 && $A upgrade head && $A heads && rm -f _alembic_check.db && unset DATABASE_URL && /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_foundation.py tests/test_engine_pipeline.py

T2 — domain + repositories: new state fields, org turn list, paused counts

status: done group: prepare after: T1 files: apps/api/app/domain/workforce/pipeline.py:425-450, apps/api/app/infrastructure/repositories/engine.py:64-108,128-140,396-412, apps/api/tests/test_engine_repo_ui.py (new) change:

  1. TaskPipelineState dataclass: add last_diff: str = "" and merged_sha: str = "" (after waiver_reason).
  2. repositories/engine.py: map both in _state; add both to _PATCHABLE; set them in create if it lists columns explicitly.
  3. EngineTurnRepository.list_for_org(organization_id, *, limit=50): join TaskRow on EngineTurnRow.task_id == TaskRow.id, filter TaskRow.organization_id, newest started_at first, limit capped at 200.
  4. PipelineStateRepository.count_at_by_org(stage) -> dict[str, int] (group by organization_id) and stage_counts(organization_id) -> dict[str, int] (group by stage for one org).
  5. Tests: patch + read back last_diff/merged_sha; list_for_org never returns another org's turns; stage_counts and count_at_by_org on two orgs. check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_engine_repo_ui.py tests/test_engine_pipeline.py tests/test_engine_flow.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app/infrastructure/repositories/engine.py app/domain/workforce/pipeline.py

T3 — the scheduler stores the latest diff and the merged sha

status: done group: prepare after: T2 files: apps/api/app/application/engine/scheduler.py:399-447,479-545, apps/api/tests/test_engine_ui_fields.py (new) change:

  1. _after_implementation: in the existing _patch(state, last_diff_hash=..., same_diff_count=...) call also pass last_diff=diff.text[:20000] (one write, one version bump). Only after the diff guard passed.
  2. _merge_step: right before await self._move(state, task, Stage.COMPLETED), call await self._patch(state, merged_sha=final_sha).
  3. Test (import world, _team, _task, _scheduler and the scripted replies from tests.test_engine_flow, like tests/test_integration_policy.py does): run the happy path to COMPLETED and assert state.last_diff contains the fake diff text and state.merged_sha equals the sha the fake workspace pushed. check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_engine_ui_fields.py tests/test_engine_flow.py tests/test_integration_policy.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app/application/engine/scheduler.py

T4 — publish task.step on every stage move

status: done group: prepare after: none files: apps/api/app/application/engine/events.py:186-207, apps/api/app/application/engine/state.py:38-64, apps/api/tests/test_engine_step_event.py (new) change:

  1. events.py: add async def emit_task_step(*, task_id, organization_id, from_stage, to_stage, pipeline) publishing RealtimeEvent(type="task.step", channels=(task_channel(task_id), org_channel(organization_id)), organization_id=..., data={"task_id", "from": from_stage, "to": to_stage, "pipeline": pipeline, "status": coarse_status(to_stage).value}). Add it to __all__. No model/employee names in the payload.
  2. state.advance: after a successful states.advance(...), call emit_task_step with the returned state's organization_id and pipeline. Publishing never raises (publish swallows), so the move is unaffected.
  3. Test: install a recording publisher with set_publisher (restore the old one in a finally; see how tests/test_realtime.py captures events), advance one state, assert exactly one task.step event on task:<id> + org:<id> with the right from/to; a refused (illegal) move publishes nothing. check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_engine_step_event.py tests/test_engine_pipeline.py tests/test_engine_manager.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app/application/engine/events.py app/application/engine/state.py

T5 — credit ledger repository (atomic credit/debit, history) + a test helper to fund an org

status: done group: prepare after: T1 files: apps/api/app/domain/billing/models.py:62-85, apps/api/app/infrastructure/repositories/credit.py (new), apps/api/tests/billing_helpers.py (new), apps/api/tests/test_credit_ledger.py (new) change:

  1. Domain: CreditKind(StrEnum): owner_credit, starting_credit, usage, adjustment; dataclass CreditTransaction(organization_id, kind, amount, balance_after, reason="", actor_user_id=None, usage_event_id=None, id, created_at).
  2. CreditLedgerRepository(session):
    • _ensure_wallet(org): create a 0.0 CreditWalletRow when missing (same as CreditWalletRepository.ensure).
    • apply(*, organization_id, amount, kind, reason="", actor_user_id=None, usage_event_id=None) -> CreditTransaction: atomic SQL update update(CreditWalletRow).where(organization_id == org) .values(balance=CreditWalletRow.balance + amount, updated_at=utcnow()) (never read-modify-write in Python), then read the new balance and insert the transaction row with balance_after; flush.
    • debit_for_usage(*, organization_id, usage_event_id, amount) -> CreditTransaction | None: returns None when amount <= 0 or a row with this usage_event_id already exists (idempotent); otherwise apply(amount=-amount, kind=usage, usage_event_id=...). Catch IntegrityError only around a nested savepoint (session.begin_nested()) so a duplicate race returns None instead of poisoning the session.
    • history(organization_id, *, kinds: tuple[CreditKind, ...] | None = None, limit=50) newest first, limit capped at 200, always filtered by org.
    • last_credit_at_by_org() -> dict[str, datetime] (max created_at of non-usage rows, group by org).
  3. tests/billing_helpers.py: async def fund_org(session_factory, organization_id, amount=100.0) that opens a session, calls CreditLedgerRepository.apply(kind=adjustment, reason="test funding") and commits. Later tasks import it to fund orgs whose tests need engine work under prepaid.
  4. Tests: credit then debit gives the right balance_after; the same usage_event_id twice debits once; org B's wallet never moves when org A is debited; a missing wallet is created at 0 then credited. check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_credit_ledger.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app/infrastructure/repositories/credit.py app/domain/billing/models.py

T6 — every usage event debits the wallet (single place: UsageLedgerRepository.record)

status: done group: prepare after: T5 files: apps/api/app/infrastructure/repositories/audit.py:109-133, apps/api/app/application/services/workforce_service.py:103-153, apps/api/tests/test_credit_debit.py (new) change:

  1. UsageLedgerRepository.record: give the UsageEventRow an explicit id (new_id()), flush, then in the same session (same transaction = atomic with the usage row) call CreditLedgerRepository(self.session).debit_for_usage(organization_id=usage.organization_id, usage_event_id=row.id, amount=total). Return float | None (the new balance, None when nothing was debited). Update the class docstring: prepaid — the ledger is still the source of truth and every row debits the wallet exactly once. Keep the UsageLedgerPort signature compatible (returning a value from a -> None protocol method is fine for callers that ignore it; update the port annotation in ports/audit.py only if mypy demands it).
  2. record_model_usage: when record returns a balance, also await emit_wallet_changed( organization_id=..., balance=...) so the sidebar badge and the billing page follow the debit live.
  3. Tests: a model usage of price P on an org with balance B leaves B-P and one usage transaction whose usage_event_id is the usage row id; a zero-priced row creates no transaction; a sandbox usage (via SandboxService path or a direct record) debits too; another org is untouched; a rolled-back session leaves neither the usage row nor the debit. check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_credit_debit.py tests/test_credit_ledger.py tests/test_workforce.py tests/test_engine_runner.py tests/test_e2e_smoke.py tests/test_workers.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app/infrastructure/repositories/audit.py app/application/services/workforce_service.py

T7 — prepaid pause: a zero, negative or missing wallet pauses engine work (D-B)

status: done group: prepare after: T3, T5 files: apps/api/app/application/engine/scheduler.py:89,618-649, apps/api/tests/test_engine_flow.py:88-112,536-601, apps/api/tests/test_v2_e2e_smoke.py change:

  1. _budget_reason: wallet = await self.wallets.get_for_org(org); return BUDGET_PAUSED_MESSAGE when wallet is None or wallet.balance <= 0. Rewrite the docstring: billing is prepaid; the platform owner credits the wallet, every usage row debits it (T6), so zero means "no credit left". Set BUDGET_PAUSED_MESSAGE = "اعتبار سازمان تمام شده است؛ کار تا افزودن اعتبار توسط مالک سامانه متوقف شد.". Keep the per-task cap and the monthly budget checks unchanged.
  2. tests/test_engine_flow.py: keep _OverdrawnWallet + its test; rename test_a_zero_wallet_does_not_pause_a_fresh_org → test_a_zero_wallet_pauses_the_task_before_a_turn_is_spent asserting PAUSED, WAITING_APPROVAL, scripted.calls == 0 (docstring: prepaid decision D-B reverses the post-paid behaviour of the old T3); add test_a_missing_wallet_pauses_the_task (_Wallets(None)) and test_a_funded_wallet_does_not_pause (a _FundedWallet with balance = 5.0, one tick, stage not PAUSED). Update the module docstring bullet at line 17.
  3. tests/test_v2_e2e_smoke.py: right after the wizard completes, await fund_org(session_factory, org_id, 1000.0) (from tests.billing_helpers) with a comment "prepaid: the owner credits a new org before work". check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_engine_flow.py tests/test_v2_e2e_smoke.py tests/test_integration_policy.py tests/test_integration_runtime.py tests/test_error_log.py tests/test_workers.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app/application/engine/scheduler.py

T8 — the manager chat does not call a model on a zero-credit org

status: done group: prepare after: T5, T6 files: apps/api/app/application/engine/manager.py:208-240,383-420, apps/api/app/application/container_engine.py:197-240, apps/api/app/application/services/workforce_service.py:392-428, apps/api/tests/test_credit_chat_gate.py (new) change:

  1. ManagerTurns.__init__: optional kwarg wallets: CreditWalletRepository | None = None, stored.
  2. chat(): after storing the user message and before any model call, when self.wallets is set and the org's wallet is missing or balance <= 0, store an assistant message CHAT_NO_CREDIT_MESSAGE = "اعتبار سازمان صفر است؛ مدیر پس از افزودن اعتبار توسط مالک سامانه پاسخ می‌دهد." and return (user_message, assistant_message) without calling the provider and without executing tags. Export the constant.
  3. Pass wallets=CreditWalletRepository(session) in container_engine._build_manager and in WorkforceService._manager_turns (session = self.tasks.session). The bootstrap builder (line ~59) needs nothing (it never chats).
  4. Test: an org at 0.0 → the reply is the note and ScriptedModel.calls == 0; after fund_org(...) the model is called once. Existing direct ManagerTurns(...) constructions (no wallets) stay unaffected. check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_credit_chat_gate.py tests/test_engine_manager.py tests/test_integration_review_fallback.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app/application/engine/manager.py app/application/container_engine.py app/application/services/workforce_service.py

T9 — fund the chat tests for prepaid + fix the two stale test comments (D-E)

status: done group: prepare after: T7, T8 files: apps/api/tests/test_integration_order_route.py, apps/api/tests/test_integration_thread_project.py:60-80, apps/api/tests/test_e2e_smoke.py, apps/api/tests/test_v2_e2e_smoke.py:130-140 change:

  1. In each test that chats with the manager on an org created over HTTP or by the wizard (wallet 0.0), call fund_org(session_factory, org_id) before the order, with a one-line comment (prepaid, D-B). Do not change any assertion. If test_e2e_smoke.py has no session_factory fixture in that test, add it to the test signature.
  2. Stale comments: test_integration_thread_project.py:69-70 and test_v2_e2e_smoke.py:134-136 claim the HTTP message route builds its manager without pipeline states. Rewrite them: the HTTP route builds states too (WorkforceService._manager_turns, covered by tests/test_integration_order_route.py); these tests call build_scheduler(session).manager.chat directly only to read the reply/state in the same session. check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_integration_order_route.py tests/test_integration_thread_project.py tests/test_e2e_smoke.py tests/test_v2_e2e_smoke.py && ! grep -n "without pipeline states" tests/test_integration_thread_project.py tests/test_v2_e2e_smoke.py

T10 — CreditService: owner credit, history, balances

status: done group: prepare after: T2, T5 files: apps/api/app/application/services/credit_service.py (new), apps/api/tests/test_credit_service.py (new) change:

  1. CreditService(session) with ledger = CreditLedgerRepository, wallets = CreditWalletRepository, orgs = OrganizationRepository, states = PipelineStateRepository.
    • MAX_OWNER_CREDIT = 100_000.0.
    • owner_credit(*, organization_id, amount, reason, actor_user_id) -> CreditTransaction: org must exist (NotFoundError("سازمان یافت نشد.")); 0 < amount <= MAX_OWNER_CREDIT else ValidationError; reason.strip() 3..500 chars else ValidationError("دلیل افزودن اعتبار لازم است."); ledger.apply(kind=owner_credit, ...); then emit_wallet_changed(organization_id, balance_after).
    • history(organization_id, limit=50): non-usage kinds only (owner_credit, starting_credit, adjustment).
    • balances() -> list[OrgBalance] (dataclass: organization_id, name, slug, balance, currency, paused_tasks, last_credit_at): every org (list_all), its wallet (0.0 when missing), states.count_at_by_org(Stage.PAUSED), ledger.last_credit_at_by_org().
    • grant_starting_credit(organization_id): when settings.billing_starting_credit > 0 (added by T13; read with getattr(settings, "billing_starting_credit", 0.0) so this task does not depend on T13) apply kind=starting_credit, reason="اعتبار آغازین"; otherwise no-op.
  2. Tests: credit → balance + one row; amount 0 / negative / above max / blank reason refused; unknown org 404; history excludes usage rows and other orgs; balances lists two orgs with their own numbers and paused counts. check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_credit_service.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app/application/services/credit_service.py

T11 — owner-only credit endpoints /api/admin/credits, /api/admin/orgs/{org}/credit

status: done group: prepare after: T10 files: apps/api/app/interfaces/http/routers/admin_credits.py (new), apps/api/app/interfaces/realtime/rpc_app.py:23-42,96-103, apps/api/tests/test_admin_credits.py (new) change:

  1. Router APIRouter(prefix="/admin", tags=["admin-credits"], dependencies=[Depends(require_platform_owner)]) (same pattern as admin_grants.py):
    • GET /credits → list[OrgBalanceOut] (organization_id, name, slug, balance, currency, paused_tasks, last_credit_at).
    • POST /orgs/{org_id}/credit body {amount: float, reason: str} → CreditTransactionOut (id, kind, amount, balance_after, reason, created_at). Audit with AuditAction.BILLING_CREDIT, organization_id=org_id, actor_user_id=owner.id, target_type="credit_wallet", metadata {amount, reason, balance} and client_meta(request).
    • GET /orgs/{org_id}/credit-history?limit=50 → list[CreditTransactionOut]. No model/provider field, no secret.
  2. Register it in rpc_app.py (import + include_router(admin_credits.router, prefix="/api") in the v2 block).
  3. Tests (fixture platform_owner_token from conftest; a manager-created org): owner credit → 200, wallet balance, transaction row, an audit row with the reason, a billing.wallet event (recording publisher); history lists it; list shows the org with its balance; a manager / org owner (non platform owner) gets 403 on all three; an unknown org id 404; amount ≤ 0 and empty reason → 4xx with a Persian message. check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_admin_credits.py tests/test_foundation.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app/interfaces/http/routers/admin_credits.py app/interfaces/realtime/rpc_app.py

T12 — close the free-credit holes in the tenant billing router

status: done group: prepare after: T5 files: apps/api/app/interfaces/http/routers/billing.py:1-30,208-300, apps/api/tests/test_billing_prepaid.py (new) change:

  1. POST /billing/add-credit: any amount now requires context.user.is_platform_owner, otherwise PermissionDeniedError("اعتبار سازمان را فقط مالک سامانه افزایش می‌دهد."). For the owner, write through CreditLedgerRepository.apply(kind=adjustment, reason="billing/add-credit", actor_user_id=...) instead of BillingService.add_credit so it appears in the credit history, keep the existing audit, and emit emit_wallet_changed. Keep refusing a result below zero for a negative amount.
  2. POST /billing/payments/{id}/settle: platform owner only (same message). POST /checkout stays as is (it only creates a pending payment). Update the module docstring: prepaid, the owner credits wallets.
  3. Tests: org owner/admin with billing.manage → 403 on add-credit (positive) and settle; platform owner → 200 and one adjustment transaction; GET /billing/summary still works for members with billing.view. check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_billing_prepaid.py tests/test_tenant_isolation.py tests/test_e2e_smoke.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app/interfaces/http/routers/billing.py

T13 — optional starting credit for new orgs (BILLING_STARTING_CREDIT, default 0)

status: done group: prepare after: T10 files: apps/api/app/core/config.py:125-200, .env.example:90-145, apps/api/app/application/services/org_setup_service.py:205-230, apps/api/app/interfaces/http/routers/organizations.py:140-150, apps/api/tests/test_billing_starting_credit.py (new) change:

  1. config.py: add a new marked block # >>> integration (billing) … # <<< integration after the agent-3 block with billing_starting_credit: float = 0.0 (comment: credit given to every new org at creation, prepaid; 0 = the owner credits by hand). Same block and line BILLING_STARTING_CREDIT=0 in .env.example.
  2. Wizard (org_setup_service.py, after the CreditWalletRow(...) add at ~line 223) and organizations.py (after line 147): await self.session.flush() / await session.flush() then await CreditService(session).grant_starting_credit(org.id) (lazy import to avoid cycles).
  3. Tests: with the setting at 0 a new org (HTTP and wizard) has balance 0 and no transaction; with monkeypatch.setattr(settings, "billing_starting_credit", 3.0) both paths give 3.0 and one starting_credit row. check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_billing_starting_credit.py tests/test_org_wizard.py tests/test_tenant_isolation.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app/core/config.py app/application/services/org_setup_service.py app/interfaces/http/routers/organizations.py

T14 — direct tasks enter the engine; the legacy "اجرا" refuses engine tasks (D-D)

status: done group: prepare after: T8 files: apps/api/app/application/services/workforce_service.py:435-470,500-520, apps/api/tests/legacy_tasks.py (new), apps/api/tests/test_workforce.py, apps/api/tests/test_direct_tasks_engine.py (new) change:

  1. create_task: after self.tasks.create(...), create a TaskPipelineState with PipelineStateRepository(self.tasks.session): stage=PENDING_MANAGER_REVIEW, kind=TaskKind.FEATURE, risk=Risk.MEDIUM, pipeline=resolve_pipeline(manager_choice=None, task_kind=TaskKind.FEATURE, has_active_reviewer=False) (the manager's triage picks the real pipeline), project_id = the given one or the org's single project (mirror ManagerTurns._project_id_for, manager.py:1071; store it on the task too). This is the "user order to the manager": the scheduler's triage turn takes it from there. The docstring says so. MCP tasks_create calls this method, so it is covered without touching the gateway.
  2. start_run: when PipelineStateRepository(session).get(task_id) is not None, raise ConflictError("این کار را موتور خودکار اجرا می‌کند؛ برای توقف، ادامه یا لغو از دکمه‌های همین کار استفاده کنید.").
  3. tests/legacy_tasks.py: async def legacy_task(session_factory, organization_id, created_by_user_id, *, title="کار قدیمی", project_id=None, assigned_employee_id=None) -> str inserting a plain TaskRow (no state): pre-v2 tasks still exist and still run through the legacy path.
  4. tests/test_workforce.py: every test whose purpose is the legacy run (POST /tasks/{id}/runs, lines ~229-318) creates its task with legacy_task(...) instead of HTTP; keep every assertion.
  5. New tests/test_direct_tasks_engine.py: HTTP POST /tasks creates a state at pending_manager_review with the org's project; POST /tasks/{id}/runs on it → 409; MCP tasks_create (see tests/test_mcp.py _call helper) also creates a state; a legacy task still runs. check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_direct_tasks_engine.py tests/test_workforce.py tests/test_integration_runtime.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app/application/services/workforce_service.py

T15 — update the remaining tests that relied on direct tasks running the legacy way

status: done group: prepare after: T9, T14 files: apps/api/tests/test_workers.py, apps/api/tests/test_e2e_smoke.py, apps/api/tests/test_seed.py, apps/api/tests/test_error_log.py, apps/api/tests/test_pagination.py, apps/api/tests/test_realtime.py, apps/api/tests/test_integration_tenant_project.py, apps/api/tests/test_mcp.py change:

  1. Run the full API suite first and list the failures (paste the list in your report; it must not be empty if any of these files posts /tasks then expects a legacy run/drain).
  2. For each failing test: if its purpose is the legacy run/drain/metering, create the task with tests.legacy_tasks.legacy_task(...); if its purpose is task creation, keep HTTP and assert the new pipeline state instead of a legacy run. Never delete an assertion; explain each change in the test's docstring (decision D-D).
  3. tests/test_mcp.py: in the existing tasks_create test add an assertion that the created task has a TaskPipelineState at pending_manager_review. check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider

T16 — Scheduler.user_action: pause, resume, cancel, answer

status: done group: prepare after: T7 files: apps/api/app/application/engine/scheduler.py:586-650, apps/api/app/infrastructure/repositories/workforce.py:483-512, apps/api/tests/test_engine_control.py (new) change:

  1. TaskRepository.append_description(task_id, text) -> Task | None (narrow write, like set_status).
  2. Scheduler.user_action(*, organization_id, task_id, action: Literal["pause","resume","cancel","answer"], user_id: str, answer: str = "") -> TaskPipelineState:
    • task = await self.tasks.get_for_org(organization_id, task_id) else NotFoundError("کار یافت نشد."); state = await self.states.get(task_id) else ConflictError("این کار در موتور خودکار نیست."); a terminal stage → ConflictError("این کار بسته شده است.").
    • pause: _move(state, task, Stage.PAUSED) (illegal transitions surface as the existing ConflictError).
    • resume: only from PAUSED (else ConflictError "فقط کار متوقف‌شده را می‌توان ادامه داد."); if await self._budget_reason(task) is not None raise ConflictError(reason) (no credit → no resume); then _move(..., Stage.PENDING_MANAGER_REVIEW).
    • cancel: _move(..., Stage.CANCELLED) (already releases the worktree and parks dependents).
    • answer: only from PENDING_USER_DECISION; answer.strip() 1..4000 chars; append "\n\nپاسخ کاربر: " + answer to the description; if task.thread_id, store a USER message in the thread via self.manager.messages; then _move(..., Stage.PENDING_MANAGER_REVIEW) (triage reads the answer).
    • After every move emit_task_status(task_id, organization_id, coarse_status(target).value). Return the re-read state.
  3. Tests (reuse tests.test_engine_flow helpers; pass a funded _Wallets): pause → PAUSED; resume → PENDING_MANAGER_REVIEW; resume with a zero wallet → ConflictError and still PAUSED; cancel → CANCELLED and the fake workspace's remove_worktree was called when a worktree existed; answer from PENDING_USER_DECISION moves to triage and the description contains the answer; org B id → NotFoundError. check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_engine_control.py tests/test_engine_flow.py tests/test_workforce.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app/application/engine/scheduler.py app/infrastructure/repositories/workforce.py

T17 — engine read API: status, task board, task detail, recent turns

status: done group: prepare after: T2, T3, T11 files: apps/api/app/interfaces/http/routers/engine.py (new), apps/api/app/interfaces/realtime/rpc_app.py, apps/api/tests/test_engine_api.py (new) change:

  1. router = APIRouter(prefix="/organizations/{org_id}/engine", tags=["engine"]); every route resolves get_org_context (any member may read) and filters by context.org_id. Register in rpc_app.py.
  2. GET /status → EngineStatusOut {balance, currency, credit_ok (wallet exists and balance > 0), automation_enabled, max_employees, team_size (non-archived employees), stage_counts: dict[str,int]} (policy from build_model_grants(session).get_org_policy).
  3. GET /tasks?stage=&limit=100 → list[EngineTaskOut] {id, title, status, stage|null, pipeline|null, kind|null, cycle, max_cycles, thread_id, assigned_employee_id, assignee_name, created_at, updated_at}; tasks via TaskRepository.list_for_org, states via PipelineStateRepository.list_for_org (dict by task id); legacy tasks have stage=null.
  4. GET /tasks/{task_id} → EngineTaskDetailOut = EngineTaskOut + description, stages: list[str] (the stage list of its pipeline from PIPELINES), waiver_reason, branch, depends_on, merged_sha, diff: str (state.last_diff), turns: [{id, run_id, role, mode, stage, employee_name, ok, error_kind, tool_calls, reply_excerpt (first 1000 chars), started_at, finished_at}], gates: [{id, gate, ok, detail, created_at}], runs: [{id, status, started_at, finished_at}]. Foreign task → 404.
  5. GET /turns?limit=50 → recent turns of the org (EngineTurnRepository.list_for_org) with task_title.
  6. No field can hold a model id/name, provider, account, container or port (state it in the module docstring, like workforce.py). Tests: org B member gets 404 on every path; a member (viewer) can read; serialised JSON of every endpoint contains none of the seeded AiModelRow.name/provider values nor ai_model_id; detail returns stages, gates, turns, diff and sha for a task built with the repositories. check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_engine_api.py tests/test_tenant_isolation.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app/interfaces/http/routers/engine.py app/interfaces/realtime/rpc_app.py

T18 — engine action API: pause / resume / cancel / answer

status: done group: prepare after: T16, T17 files: apps/api/app/interfaces/http/routers/engine.py, apps/api/app/domain/audit/models.py:58-68, apps/api/tests/test_engine_api_actions.py (new) change:

  1. AuditAction.TASK_CONTROL = "task.control".
  2. POST /tasks/{task_id}/pause|resume|cancel and POST /tasks/{task_id}/answer {content: str (1..4000)}: context.require(CAN_RUN_TASKS), await build_scheduler(session).user_action(...) (lazy import from app.application.container), audit TASK_CONTROL with metadata {action} (never the answer text), return the refreshed EngineTaskOut.
  3. Tests: member with CAN_RUN_TASKS pauses then resumes (org funded with fund_org); resume on a zero wallet → 409 with the Persian reason; cancel → stage cancelled + audit row; answer only from pending_user_decision (else 409); viewer → 403; org B → 404. check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_engine_api_actions.py tests/test_engine_api.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app/interfaces/http/routers/engine.py app/domain/audit/models.py

T19 — engine team API: team with tier, hire (role + tier), fire

status: done group: prepare after: T18 files: apps/api/app/interfaces/http/routers/engine.py, apps/api/tests/test_engine_api_team.py (new) change:

  1. GET /team → TeamOut {employees: [{id, name, role_key, status, tier ("fast"|"balanced"|"strong"| "unknown"), busy_task_id, busy_task_title, hired_by}], tiers: [{tier, label}] (distinct tiers of the org's granted models, labels «سریع/متعادل/قوی»), max_employees, count}. Tier = the tier of the profile's ai_model_id in grants.granted_models(org); not granted → unknown. Archived employees excluded.
  2. POST /team/hire {role: RoleKey (HIRABLE_ROLES), tier, name?} (CAN_MANAGE_EMPLOYEES) → build_scheduler(session).manager.hire(organization_id, role, tier, name, project_id=None); audit EMPLOYEE_CREATE; returns the team member row. POST /team/{employee_id}/fire → manager.fire(...); audit EMPLOYEE_UPDATE with {action: "fire"}. Hire/fire errors keep their Persian messages (409/404).
  3. Tests: hire with a granted tier → 201, the response has tier and no model id/name anywhere; a tier that is not granted → 409; the cap equals ModelGrantService.get_org_policy(org).max_employees (do not hard-code 5 or 7); firing the last manager → 409; member without CAN_MANAGE_EMPLOYEES → 403; org B → 404. check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_engine_api_team.py tests/test_engine_api.py tests/test_engine_manager.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app/interfaces/http/routers/engine.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/ruff check app tests

T20 — @agy/contracts: engine event types + payloads

status: done group: prepare after: T4 files: packages/contracts/src/index.ts:816-842 change:

  1. Append to WsEventType (comment // v2 AI workforce engine): "task.step" | "task.gate" | "run.log" | "run.tool" | "team.changed".
  2. Add payload interfaces mirroring the backend data key-for-key: TaskStepEventData {task_id; from; to; pipeline; status}, TaskGateEventData {task_id; gate; ok; detail}, RunLogEventData {run_id; task_id; lines: string[]}, RunToolEventData {run_id; task_id; tool; ok; summary}, TeamChangedEventData {change; employee_id; organization_id}. check: cd /projects/smart-org && cp packages/contracts/src/index.ts node_modules/@agy/contracts/src/index.ts && cd apps/web && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json && node /projects/smart-org/node_modules/vitest/vitest.mjs run src/test/org-memory.test.tsx

T21 — lib/api-engine.ts: typed client for the engine API

status: done group: prepare after: T17, T18, T19, T20 files: apps/web/src/lib/api-engine.ts, apps/web/src/test/engine-api.test.ts (new) change:

  1. Exported types mirroring T17–T19 exactly: EngineStage (string union of the 13 stages), EngineStatus, EngineTask, EngineTaskDetail, EngineTurn, EngineGate, EngineRun, TeamMember, TierOption, TeamView, HireRequest. No field named model/provider.
  2. engineApi (keep the satisfies line): status(orgId), listTasks(orgId, stage?), getTask(orgId, id), listTurns(orgId, limit?), pauseTask, resumeTask, cancelTask (POST, no body), answerTask(orgId, id, content), getTeam(orgId), hire(orgId, body), fire(orgId, employeeId); all through apiRequest.
  3. Test: vi.mock("@/lib/api", ...) capturing apiRequest calls; assert path + method + body for each function. check: cd /projects/smart-org && cp packages/contracts/src/index.ts node_modules/@agy/contracts/src/index.ts && cd apps/web && node /projects/smart-org/node_modules/vitest/vitest.mjs run src/test/engine-api.test.ts && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json

T22 — lib/engine-labels.ts: stage labels, board columns, tier labels

status: done group: prepare after: none files: apps/web/src/lib/engine-labels.ts (new), apps/web/src/test/engine-labels.test.ts (new) change:

  1. STAGE_LABELS: Record<string, string> (Persian for all 13 stages), stageTone(stage) → Badge tone (success completed, danger failed/cancelled, warning paused/pending_user_decision/error_review, info else).
  2. BOARD_COLUMNS in this order with labels: بررسی مدیر (pending_manager_review), تحلیل (pending_analysis), پیاده‌سازی (pending_implementation), بازبینی (pending_review, pending_final_review), تست (pending_test), ادغام (merge_request), انجام‌شده (completed, cancelled, failed), نیازمند تصمیم (pending_user_decision, error_review, paused). columnFor(stage: string | null, status: string): legacy tasks (stage null) map by coarse status (new→بررسی مدیر, running→پیاده‌سازی, waiting_approval→نیازمند تصمیم, done/failed/cancelled→ انجام‌شده). groupByColumn<T extends {stage; status}>(tasks).
  3. TIER_LABELS (fast سریع, balanced متعادل, strong قوی, unknown «نامشخص»), ROLE_LABELS (manager مدیر, planner برنامه‌ریز, developer برنامه‌نویس, reviewer بازبین, tester آزمونگر).
  4. Test: every stage lands in exactly one column; legacy mapping; grouping keeps order. check: cd /projects/smart-org/apps/web && node /projects/smart-org/node_modules/vitest/vitest.mjs run src/test/engine-labels.test.ts && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json

T23 — shared TaskActions (pause / resume / cancel with confirm)

status: done group: prepare after: T21, T22 files: apps/web/src/components/engine/task-actions.tsx (new), apps/web/src/test/engine-task-actions.test.tsx (new) change:

  1. TaskActions({ orgId, task: {id, stage}, canRun, onChanged, size? }): renders «توقف» (non-terminal, not paused), «ادامه» (paused), «لغو» (non-terminal); each opens ConfirmDialog (Persian text, «لغو» marked destructive) and calls engineApi.pauseTask/resumeTask/cancelTask; a server error shows its Persian message inline (ApiError.message); renders nothing when canRun is false or stage is null.
  2. Test: buttons per stage; confirm calls the API and onChanged; a 409 message is shown; nothing for a legacy task. check: cd /projects/smart-org && cp packages/contracts/src/index.ts node_modules/@agy/contracts/src/index.ts && cd apps/web && node /projects/smart-org/node_modules/vitest/vitest.mjs run src/test/engine-task-actions.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json

T24 — shared CreditNotice + useEngineStatus

status: done group: prepare after: T21 files: apps/web/src/components/engine/credit-notice.tsx (new), apps/web/src/test/engine-credit-notice.test.tsx (new) change:

  1. useEngineStatus(orgId): loads engineApi.status, reloads on billing.wallet, task.step and team.changed on org:<id> (useLiveChannel), returns {status, loading, error, reload}.
  2. CreditNotice({ status }): when status && !status.credit_ok a warning Card: «اعتبار سازمان به پایان رسیده است» + «کار کارمندان هوش مصنوعی تا افزودن اعتبار توسط مالک سامانه متوقف است. پس از افزودن اعتبار، کارهای متوقف‌شده را از صفحهٔ وظایف ادامه دهید.» and the balance; otherwise nothing.
  3. Test: renders the message at 0 and nothing when credit_ok; the hook reloads on a billing.wallet event (mock @/lib/realtime like src/test/org-mcp.test.tsx does). check: cd /projects/smart-org && cp packages/contracts/src/index.ts node_modules/@agy/contracts/src/index.ts && cd apps/web && node /projects/smart-org/node_modules/vitest/vitest.mjs run src/test/engine-credit-notice.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json

T25 — default per-org employee cap = 7 (D-A)

status: done group: g1 after: none files: apps/api/app/application/services/model_grant_service.py:49, apps/api/app/infrastructure/db/models_org.py:146, apps/api/app/infrastructure/repositories/org_grants.py:42, apps/api/tests/test_model_grants.py:332,395, apps/web/src/app/(app)/admin/model-grants/page.tsx:70 change:

  1. DEFAULT_MAX_EMPLOYEES = 7; OrgPolicyRow.max_employees default=7; OrgPolicyRecord.max_employees = 7. settings.engine_max_employees (20) stays the upper bound (min(DEFAULT, limit) is unchanged). The migration server default is T1's job (already "7").
  2. tests/test_model_grants.py:332 and :395 assert 7 (docstring note: D-A). Add one assertion in an existing policy test that 4 (the bootstrap team) ≤ the default cap.
  3. Web: React.useState("5") → "7" at admin/model-grants/page.tsx:70. check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_model_grants.py tests/test_integration_hire_cap.py tests/test_engine_manager.py tests/test_org_wizard.py && cd ../web && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json && grep -rn "DEFAULT_MAX_EMPLOYEES = 7" ../api/app/application/services/model_grant_service.py

T26 — lib/api-credit.ts: owner credit client

status: done group: g2 after: T11 files: apps/web/src/lib/api-credit.ts (new), apps/web/src/test/billing-api-credit.test.ts (new) change:

  1. Types OrgBalance, CreditTransaction (mirror T11) and creditApi = { balances(), credit(orgId, {amount, reason}), history(orgId, limit?) } via apiRequest on /api/admin/credits, /api/admin/orgs/{id}/credit, /api/admin/orgs/{id}/credit-history.
  2. Test: paths, methods and bodies (mock @/lib/api). check: cd /projects/smart-org/apps/web && node /projects/smart-org/node_modules/vitest/vitest.mjs run src/test/billing-api-credit.test.ts && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json

T27 — owner page /admin/credits + admin tab

status: done group: g2 after: T26 files: apps/web/src/app/(app)/admin/credits/page.tsx (new), apps/web/src/lib/nav.ts:31-46, apps/web/src/test/billing-admin-credits.test.tsx (new) change:

  1. Page (the admin layout already blocks non-owners): Table of orgs (نام، شناسه، اعتبار، کارهای متوقف، آخرین افزایش), balance ≤ 0 shown with a danger Badge «بدون اعتبار». Per row «افزودن اعتبار» opens a Modal with amount (number > 0) + reason (required, 3+ chars) and a confirm step; on success refresh the row and show a toast. «تاریخچه» opens a Drawer with creditApi.history (date fa-IR, amount, reason, balance after). Live: reload on billing.wallet from admin channel if available; otherwise reload after each action only.
  2. nav.ts ADMIN_ITEMS: add { href: "/admin/credits", label: "اعتبار سازمان‌ها", hint: "شارژ پیش‌پرداخت" } after the organizations entry.
  3. Test: rows render with balances; submit is disabled until amount and reason are valid; submit calls creditApi.credit with both; history drawer lists entries. check: cd /projects/smart-org/apps/web && node /projects/smart-org/node_modules/vitest/vitest.mjs run src/test/billing-admin-credits.test.tsx src/test/command-palette.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json

T28 — members: billing page is read-only + credit notice; wizard success says work starts after credit

status: done group: g2 after: T24, T27 files: apps/web/src/app/(app)/billing/page.tsx:150-270, apps/web/src/app/onboarding/page.tsx:362-380, apps/web/src/test/billing-prepaid.test.tsx (new) change:

  1. Billing page: the «افزایش اعتبار» card (add-credit + checkout, lines ~226-270) renders only for user.is_platform_owner; everyone else sees a CardHint «اعتبار سازمان به صورت پیش‌پرداخت توسط مالک سامانه افزوده می‌شود. هر مصرف از همین اعتبار کم می‌شود.». Render CreditNotice (status from useEngineStatus) at the top. Keep all existing widgets and the live wallet handling.
  2. Onboarding success screen: below the existing hint, when engineApi.status(result.organization.id) says !credit_ok (or the call fails), show «کار کارمندان پس از افزودن اعتبار توسط مالک سامانه آغاز می‌شود.».
  3. Test: a member sees no add-credit button and sees the hint; the owner sees the form; the notice shows at balance 0. Keep src/test/org-wizard.test.tsx green (mock engineApi.status there only if it breaks). check: cd /projects/smart-org && cp packages/contracts/src/index.ts node_modules/@agy/contracts/src/index.ts && cd apps/web && node /projects/smart-org/node_modules/vitest/vitest.mjs run src/test/billing-prepaid.test.tsx src/test/org-wizard.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json

T29 — manager components: inline task cards + pending-decision banner

status: done group: g3 after: T21, T22, T24 files: apps/web/src/app/(app)/manager/_components/task-cards.tsx (new), apps/web/src/app/(app)/manager/_components/decision-banner.tsx (new), apps/web/src/test/engine-manager.test.tsx (new) change:

  1. ThreadTaskCards({ tasks }): compact cards (title, stage Badge with STAGE_LABELS/stageTone, link /tasks/{id}) for the tasks of the active thread.
  2. DecisionBanner({ orgId, tasks, onAnswered }): for tasks at pending_user_decision a warning card «در انتظار تصمیم شما» with the task title, a textarea and «ارسال پاسخ» → engineApi.answerTask; errors inline in Persian; hidden when there is none.
  3. Test: cards show the Persian stage; the banner appears only for pending decisions and posts the answer. check: cd /projects/smart-org && cp packages/contracts/src/index.ts node_modules/@agy/contracts/src/index.ts && cd apps/web && node /projects/smart-org/node_modules/vitest/vitest.mjs run src/test/engine-manager.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json

T30 — wire the manager page (cards inline, banner, credit notice, live stages)

status: done group: g3 after: T29 files: apps/web/src/app/(app)/manager/page.tsx, apps/web/src/test/engine-manager-page.test.tsx (new) change:

  1. Load engineApi.listTasks(orgId) next to the existing loads; render CreditNotice + DecisionBanner at the top and ThreadTaskCards (tasks with thread_id === activeThreadId) under the messages.
  2. Live: on task.created reload the engine task list; on task.step patch the task's stage/status in place (data is TaskStepEventData). Keep message.created handling as is.
  3. The «کار جدید» form keeps api.createTask; its hint says the task goes to the AI manager first.
  4. Test (mock api, api-engine, realtime, session): a task.step event moves a card's badge; the banner shows for a pending decision; the notice shows at zero credit. check: cd /projects/smart-org && cp packages/contracts/src/index.ts node_modules/@agy/contracts/src/index.ts && cd apps/web && node /projects/smart-org/node_modules/vitest/vitest.mjs run src/test/engine-manager-page.test.tsx src/test/engine-manager.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json

T31 — task board component (columns by stage, filters, live)

status: done group: g4 after: T20, T22, T23 files: apps/web/src/app/(app)/tasks/_components/task-board.tsx (new), apps/web/src/test/engine-board.test.tsx (new) change:

  1. TaskBoard({ orgId, tasks, employees, canRun, onChanged }): columns from BOARD_COLUMNS (horizontal scroll on mobile, RTL), each card: title link, stage badge, assignee name, cycle n/max, TaskActions. Filters above: text search on title, assignee select, «فقط کارهای فعال» checkbox.
  2. Live: subscribe org:<id>; task.step moves the card to columnFor(to); task.created calls onChanged.
  3. Test: grouping into the 8 columns (a fixture with every stage + one legacy task); a task.step event moves a card; the filter hides non-matching cards. check: cd /projects/smart-org && cp packages/contracts/src/index.ts node_modules/@agy/contracts/src/index.ts && cd apps/web && node /projects/smart-org/node_modules/vitest/vitest.mjs run src/test/engine-board.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json

T32 — wire /tasks to the board

status: done group: g4 after: T31 files: apps/web/src/app/(app)/tasks/page.tsx change:

  1. Replace the list/status filter with TaskBoard fed by engineApi.listTasks(orgId) + api.listEmployees. Keep the create form (api.createTask), with the hint «کار جدید ابتدا به مدیر AI سپرده می‌شود.», and reload after create. CreditNotice at the top.
  2. Run the board test and add one case to src/test/engine-board.test.tsx only if the page needs it. check: cd /projects/smart-org && cp packages/contracts/src/index.ts node_modules/@agy/contracts/src/index.ts && cd apps/web && node /projects/smart-org/node_modules/vitest/vitest.mjs run src/test/engine-board.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json

T33 — task detail: pipeline stepper + turn timeline

status: done group: g5 after: T21, T22 files: apps/web/src/app/(app)/tasks/[id]/_components/pipeline-stepper.tsx (new), apps/web/src/app/(app)/tasks/[id]/_components/turn-timeline.tsx (new), apps/web/src/test/engine-task-detail.test.tsx (new) change:

  1. PipelineStepper({ stages, current }): the pipeline's stages in order with Persian labels; done stages check-marked, current highlighted; a side state (paused/pending_user_decision/error_review/cancelled/ failed) shown as a badge beside the stepper.
  2. TurnTimeline({ turns }): Timeline items «{ROLE_LABELS[role]} — {employee_name}», stage label, time fa-IR, outcome (موفق / خطا: error_kind), tool call count; the reply excerpt in a collapsible block.
  3. Test: current stage highlighted; timeline renders roles in Persian; a fixture turn carrying an unexpected model: "gpt-secret-x" field never renders that text. check: cd /projects/smart-org && cp packages/contracts/src/index.ts node_modules/@agy/contracts/src/index.ts && cd apps/web && node /projects/smart-org/node_modules/vitest/vitest.mjs run src/test/engine-task-detail.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json

T34 — task detail: live log panel

status: done group: g5 after: T20 files: apps/web/src/app/(app)/tasks/[id]/_components/live-log.tsx (new), apps/web/src/test/engine-live-log.test.tsx (new) change:

  1. LiveLog({ taskId }): subscribes task:<taskId> via useLiveChannel; a run.log event appends all its lines in ONE state update; a run.tool event appends one line «ابزار {tool}: موفق|ناموفق — summary»; keeps the last 2000 lines; monospace, dir="ltr" for the log body.
  2. Auto-scroll to bottom on new lines unless paused: a «توقف پیمایش / ادامهٔ پیمایش» toggle, and scrolling up by hand pauses it too. A «پاک کردن» button.
  3. Test: one event with 3 lines renders 3 rows (batched: one render per event); a tool event renders; with scroll paused the container's scrollTop is not changed; the 2000-line cap. check: cd /projects/smart-org && cp packages/contracts/src/index.ts node_modules/@agy/contracts/src/index.ts && cd apps/web && node /projects/smart-org/node_modules/vitest/vitest.mjs run src/test/engine-live-log.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json

status: done group: g5 after: T21 files: apps/web/src/app/(app)/tasks/[id]/_components/gates-panel.tsx (new), apps/web/src/app/(app)/tasks/[id]/_components/diff-viewer.tsx (new), apps/web/src/test/engine-gates-diff.test.tsx (new) change:

  1. GatesPanel({ gates, waiverReason }): one row per gate (Persian names: tests تست‌ها، diff_guard نگهبان تغییرات، no_progress بدون پیشرفت، green دروازهٔ سبز، lint لینت), green/red badge, time, expandable <pre> detail; when waiverReason a warning box «صرف‌نظر از تست با دلیل: …».
  2. DiffViewer({ diff, mergedSha }): read-only, dir="ltr", lines starting + green, - red, @@ muted; empty → «هنوز تغییری ثبت نشده است.». When mergedSha: «ادغام و ارسال شد: {sha.slice(0,12)}» with a link to /app («مشاهدهٔ خط لوله در صفحهٔ اپلیکیشن»).
  3. Test: red gate expands to its detail; waiver shown; diff coloring classes; sha link. check: cd /projects/smart-org && cp packages/contracts/src/index.ts node_modules/@agy/contracts/src/index.ts && cd apps/web && node /projects/smart-org/node_modules/vitest/vitest.mjs run src/test/engine-gates-diff.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json

T36 — wire /tasks/[id] (engine view for engine tasks, legacy view kept)

status: done group: g5 after: T23, T33, T34, T35 files: apps/web/src/app/(app)/tasks/[id]/page.tsx, apps/web/src/test/engine-task-page.test.tsx (new) change:

  1. Load engineApi.getTask(orgId, taskId). When stage is not null render: header with stage badge + TaskActions, an answer box when pending_user_decision (engineApi.answerTask), PipelineStepper, LiveLog, GatesPanel, DiffViewer, TurnTimeline. Hide the legacy «اجرا» button for engine tasks.
  2. Legacy tasks (stage null) keep the current runs/approvals view unchanged.
  3. Live: task.step and task.gate on task:<id> reload the detail (debounced 500 ms).
  4. Test: an engine task shows the stepper and no «اجرا»; a legacy task still shows «اجرا»; a task.gate event triggers a reload. check: cd /projects/smart-org && cp packages/contracts/src/index.ts node_modules/@agy/contracts/src/index.ts && cd apps/web && node /projects/smart-org/node_modules/vitest/vitest.mjs run src/test/engine-task-page.test.tsx src/test/engine-task-detail.test.tsx src/test/engine-live-log.test.tsx src/test/engine-gates-diff.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json

T37 — workforce components: team panel + hire dialog (role + tier only)

status: done group: g6 after: T21, T22 files: apps/web/src/app/(app)/workforce/_components/team-panel.tsx (new), apps/web/src/app/(app)/workforce/_components/hire-dialog.tsx (new), apps/web/src/test/engine-workforce.test.tsx (new) change:

  1. TeamPanel({ orgId, team, canManage, onChanged }): rows with name (link /workforce/{id}), role label, tier label, status badge, current task link (busy_task_title), «حذف از تیم» with ConfirmDialog → engineApi.fire; header «{count} از {max_employees} نفر».
  2. HireDialog({ orgId, tiers, onHired }): role select (manager/planner/developer/reviewer/tester), tier select from team.tiers, optional name → engineApi.hire; disabled when at the cap or no tier is granted («مالک سامانه هنوز مدلی برای این سازمان تخصیص نداده است.»).
  3. Test: render with a fixture whose employee carries extra fields ai_model_id: "m-123" and model_name: "gpt-secret-x" and assert neither string is in document.body.textContent; hire posts role + tier; fire asks for confirm first. check: cd /projects/smart-org && cp packages/contracts/src/index.ts node_modules/@agy/contracts/src/index.ts && cd apps/web && node /projects/smart-org/node_modules/vitest/vitest.mjs run src/test/engine-workforce.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json

T38 — wire /workforce to the team panel

status: done group: g6 after: T37 files: apps/web/src/app/(app)/workforce/page.tsx change:

  1. Replace the template/routing-based create form and list with TeamPanel + HireDialog fed by engineApi.getTeam(orgId); canManage = CAN_MANAGE_EMPLOYEES. Reload on team.changed (org:<id>).
  2. Remove imports that become unused (api.listAgentTemplates, api.createEmployee) from this page only. check: cd /projects/smart-org && cp packages/contracts/src/index.ts node_modules/@agy/contracts/src/index.ts && cd apps/web && node /projects/smart-org/node_modules/vitest/vitest.mjs run src/test/engine-workforce.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json

T39 — /workforce/[id]: tier instead of routing policy for non-owners

status: done group: g6 after: T21 files: apps/web/src/app/(app)/workforce/[id]/page.tsx:55-140,240-260, apps/web/src/test/engine-workforce-detail.test.tsx (new) change:

  1. Show the employee's tier label (from engineApi.getTeam) and role label. The routing-policy select (and the api.listRoutingChoices call) renders only for user.is_platform_owner; for everyone else the update payload omits routing_policy_id.
  2. Test: a non-owner sees the tier and no routing select and no routing choice names; the owner sees the select. check: cd /projects/smart-org && cp packages/contracts/src/index.ts node_modules/@agy/contracts/src/index.ts && cd apps/web && node /projects/smart-org/node_modules/vitest/vitest.mjs run src/test/engine-workforce-detail.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json

T40 — dashboard engine widgets

status: done group: g7 after: T21, T22, T24 files: apps/web/src/app/(app)/dashboard/_components/engine-widgets.tsx (new), apps/web/src/app/(app)/dashboard/page.tsx:100-125, apps/web/src/test/engine-dashboard.test.tsx (new) change:

  1. EngineWidgets({ orgId }): CreditNotice + balance Stat; «کارهای فعال» (up to 5 non-terminal engine tasks, stage badge, link); «توزیع مراحل» (one row per BOARD_COLUMNS column with a count bar from status.stage_counts); «آخرین کارهای انجام‌شده» (5 newest completed). Live via useEngineStatus + reload of engineApi.listTasks on task.step.
  2. Render it at the top of the dashboard page; keep every existing widget.
  3. Test: counts per column, active list, completed list, notice at zero credit. check: cd /projects/smart-org && cp packages/contracts/src/index.ts node_modules/@agy/contracts/src/index.ts && cd apps/web && node /projects/smart-org/node_modules/vitest/vitest.mjs run src/test/engine-dashboard.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json

T41 — traces: recent engine turns widget

status: done group: g7 after: T21, T22 files: apps/web/src/app/(app)/traces/_components/engine-turns.tsx (new), apps/web/src/app/(app)/traces/page.tsx:100-200, apps/web/src/test/engine-traces.test.tsx (new) change:

  1. EngineTurns({ orgId }): engineApi.listTurns(orgId, 30) as a Table (کار link, نقش, مرحله, نتیجه badge, ابزارها, مدت), reload on task.step (org:<id>).
  2. Add it as a section of the traces page; keep the existing run/audit widgets working.
  3. Test: rows render with Persian role/stage; a failed turn shows its error kind; no model text. check: cd /projects/smart-org && cp packages/contracts/src/index.ts node_modules/@agy/contracts/src/index.ts && cd apps/web && node /projects/smart-org/node_modules/vitest/vitest.mjs run src/test/engine-traces.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json

T42 — D-E: stale web comment + agent-3 final report overstatements

status: done group: g8 after: none files: apps/web/src/lib/api-org.ts:299-311, docs/plan/reports/agent-3-final.md:25,101,125-129,159-161,193-216 change:

  1. api-org.ts: rewrite the comment above MEMORY_CHANGED_EVENT: the type is part of WsEventType in @agy/contracts; the constant only names it. Type it as const satisfies WsEventType if that compiles, otherwise leave the value untouched.
  2. agent-3-final.md (verify each against code first: admin_domains.py::suggested_routes, models_delivery.py tables, sandbox.py admin router):
    • line 25 and line 101: say the MCP gateway host is a read-only suggestion, not a route the owner can enable (the label mcp is protected).
    • §5: remove system_routes from the table list (the tables are pipeline_runs, pipeline_steps, releases, app_runtimes, production_deploys; MCP lives as a suggestion computed from settings).
    • §6 step 9: replace "Enable the MCP system route; mcp.<root> must answer" with "check that GET /api/admin/domains/suggested shows mcp.<root> as a suggestion (enabled=false)".
    • §8: add "/admin/sandbox lists and stops containers; there is no restart action (TODO 6.3 open)". check: cd /projects/smart-org && ! grep -n "system_routes|Enable the MCP" docs/plan/reports/agent-3-final.md && grep -n "restart" docs/plan/reports/agent-3-final.md && cd apps/web && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json && node /projects/smart-org/node_modules/vitest/vitest.mjs run src/test/org-memory.test.tsx

T43 — Agent 2 final report + tick Agent 2's TODO

status: done group: g8 after: none files: docs/plan/reports/agent-2-final.md (new), docs/plan/TODO-agent-2-ai-workforce.md:41-155 change:

  1. Run this task after the other groups (it describes what exists; verify everything with ls/grep, never from this plan). agent-2-final.md per the TODO's "Final report" section: summary; a text diagram (order → manager chat → task + state → tick: triage → planner → developer → gates → tester → final → merge/push → on_branch_pushed → pipeline run); the tag protocol table (role/mode → tags, from engine/protocol.py); every endpoint added or changed (routers/engine.py T17–T19, admin_credits.py T11, the billing lockdown T12, POST /tasks creating a state and start_run 409 T14); tables (task_pipeline_state incl. last_diff/merged_sha, employee_profiles, engine_turns, gate_results, credit_transactions); settings (ENGINE_*, BILLING_STARTING_CREDIT); realtime events (task.step, task.gate, run.log, run.tool, team.changed); CHANGE REQUESTs: none open (get_org_policy landed as old T1, memory expiry as old T9); manual QA (owner credits an org → order → board moves live → task page log/gates/diff → pause/resume/cancel → zero credit pauses and resume is refused → hire/fire by tier with no model names); known limitations (turns sequential per tick, the diff is capped at 20,000 chars, budget-paused tasks are resumed by hand after credit is added, invoices from the billing worker are informational under prepaid).
  2. TODO-agent-2: tick each box whose deliverable exists in code (phases 1–4 and phase 5 items 5.1–5.7 when their files and tests exist); anything missing stays - [ ] with — open: <reason>. check: cd /projects/smart-org && test -s docs/plan/reports/agent-2-final.md && grep -c "^- [x]" docs/plan/TODO-agent-2-ai-workforce.md && grep -n "^- [ ]" docs/plan/TODO-agent-2-ai-workforce.md; grep -n "task.step|admin/orgs|credit_transactions" docs/plan/reports/agent-2-final.md

T44 — PLAN-v2 §9, README (prepaid, new env, engine UI)

status: done group: g8 after: T43 files: docs/plan/PLAN-v2.md:282-305, README.md:12-48,233-314 change:

  1. PLAN-v2 §9: replace the line "Zero-wallet pause fixed (T3): only an overdrawn wallet pauses a task" with the prepaid decision (D-B: owner credits, usage debits once per usage event, zero/negative/missing wallet pauses turns and the manager chat). Replace the "Left to Agent 2 phase 5" block with a ticked list pointing at todo-v2-final.md (UI, contracts, direct tasks through the engine, pause/resume/cancel, cap 7, owner credit console). Tick a line only if its task here is status: done at that moment.
  2. README: §1 one paragraph «اعتبار پیش‌پرداخت» (the owner credits each org at /admin/credits; work stops at zero); §8 table row BILLING_STARTING_CREDIT (default 0); §9 note that the ai-runtime pass pauses tasks of orgs without credit. Persian, same tone. check: cd /projects/smart-org && grep -n "پیش‌پرداخت|BILLING_STARTING_CREDIT" README.md && grep -n "todo-v2-final|prepaid" docs/plan/PLAN-v2.md

final

Every task above edits one to four small files (plus its own new test), so no further split is needed: final.

id group title
T1 prepare migration + ORM: credit_transactions, last_diff/merged_sha, policy default 7
T2 prepare domain + repositories: state fields, org turns list, paused/stage counts
T3 prepare scheduler stores the latest diff and the merged sha
T4 prepare publish task.step on every stage move
T5 prepare credit ledger repository (atomic credit/debit, history) + fund_org test helper
T6 prepare every usage event debits the wallet once (UsageLedgerRepository.record)
T7 prepare prepaid pause: zero/negative/missing wallet pauses engine turns
T8 prepare manager chat refuses to call a model at zero credit
T9 prepare fund the chat tests + fix the two stale test comments
T10 prepare CreditService: owner credit, history, balances, starting credit
T11 prepare owner-only /api/admin/credits + /api/admin/orgs/{org}/credit (+history)
T12 prepare close the free-credit holes in /billing/add-credit and /settle
T13 prepare optional BILLING_STARTING_CREDIT for new orgs
T14 prepare direct tasks (HTTP + MCP) get a pipeline state; legacy start_run refuses them
T15 prepare update the remaining tests that relied on the legacy direct-task path
T16 prepare Scheduler.user_action: pause/resume/cancel/answer
T17 prepare engine read API: status, board, detail, turns
T18 prepare engine action API: pause/resume/cancel/answer (audited)
T19 prepare engine team API: tier view, hire by role+tier, fire
T20 prepare contracts: engine event types + payloads
T21 prepare lib/api-engine.ts typed client
T22 prepare lib/engine-labels.ts (stages, columns, tiers, roles)
T23 prepare shared TaskActions with confirm
T24 prepare shared CreditNotice + useEngineStatus
T25 g1 default employee cap 7 (backend, tests, admin page default)
T26 g2 lib/api-credit.ts owner credit client
T27 g2 owner page /admin/credits + admin tab
T28 g2 billing page read-only for members + notice; wizard success note
T29 g3 manager components: inline task cards + decision banner
T30 g3 wire /manager
T31 g4 task board component
T32 g4 wire /tasks
T33 g5 pipeline stepper + turn timeline
T34 g5 live log panel
T35 g5 gates panel + diff viewer + waiver + sha link
T36 g5 wire /tasks/[id]
T37 g6 team panel + hire dialog (no model names)
T38 g6 wire /workforce
T39 g6 /workforce/[id]: tier for non-owners, routing only for the owner
T40 g7 dashboard engine widgets
T41 g7 traces engine turns widget
T42 g8 D-E: api-org comment + agent-3 report fixes
T43 g8 Agent 2 final report + tick TODO-agent-2 (run after the other groups)
T44 g8 PLAN-v2 §9 + README

Assumptions made (decide before running if any is wrong):

  1. Prepaid is strict: a missing wallet counts as zero, so it pauses (only test fakes pass wallets=None; every real org gets a wallet at creation). Debits may take a wallet below zero (a turn that started with credit finishes); the next turn is then paused.
  2. Budget-paused tasks are resumed by hand (the «ادامه» button, refused while credit is still zero). The engine cannot tell a budget pause from a user pause (no reason column), so adding credit does not resume tasks automatically. The manager chat is also gated at zero credit (it costs model calls).
  3. Direct tasks become engine tasks at pending_manager_review with kind feature; the manager's triage picks the real pipeline and assignee. Pre-v2 tasks without a state keep the legacy run path, and tests of that path use a direct-row helper.
  4. Owner credit history, the owner's add-credit, starting credit and usage debits all live in one new table credit_transactions; the platform owner is the only one who can add credit (org admins lose add-credit/settle; checkout still only creates a pending payment). Invoices from the billing worker stay informational.
  5. The starting credit is an env setting (BILLING_STARTING_CREDIT, default 0), not an owner-editable UI value.
  6. The diff shown on the task page is the last accepted diff, stored on the state and capped at 20,000 chars; the pushed sha is stored as merged_sha and linked to /app (Agent 3's page) without a run id.
  7. Org members (all roles) can read GET /engine/status, which includes the wallet balance, so they can see why work stopped. Mutations follow the existing capabilities (CAN_RUN_TASKS, CAN_MANAGE_EMPLOYEES).