AW-03 — My Decisions · Task breakdown
Ordered, executable tasks derived from
plan.md. Each carries explicit file paths and a definition of done. Every task ships with its tests (Constitution VI). Work top to bottom; tasks marked(parallel)may run alongside the task immediately above them.
Feature ID: aw-03-decision-queue
Spec: ./spec.md · Plan: ./plan.md
Status: Draft
Last updated: 2026-09-06
How to use
- All paths are repo-relative to the monorepo root.
- Run everything with
pnpmfrom the root unless a task says otherwise. - Migrations are authored from
apps/api/; nothing is run by hand on deploy — the API self-applies on boot. - Adding an entity means editing three registries
(
packages/agent/src/entities/index.ts,packages/agent/src/database/_entities-inventory.ts,packages/agent/src/database/_entity-names.ts) — a drift spec fails otherwise. - i18n leaf keys are camelCase and must never contain a literal
.; a dot in a leaf key reds several e2e shards at once. - Add new tasks at the bottom; never renumber.
- Commit style:
feat(decisions): …,test(decisions): …,chore(i18n): ….
PHASE 1 — The queue and the unblock
P1.A — Constants and pure logic
-
T1. Create the decisions module's constants and value types.
- Create
packages/agent/src/decisions/constants.tsexporting:DECISION_MAX_ASKS = 10,DECISION_QUEUE_PAGE_SIZE = 25,DECISION_QUEUE_MAX_LIMIT = 100,DECISION_ARCHIVE_ALL_MAX = 200,DECISION_UNDO_WINDOW_MS = 10 * 60_000,DECISION_RATIONALE_MAX_CHARS = 1000,DECISION_UNSCORED_RANK = 0.5,DECISION_DORMANT_AFTER_DAYS = 30,DECISION_QUEUE_REFRESH_MS = 30_000,DEFAULT_DECISION_HEALTHY_MAX_PER_DAY = 5,DEFAULT_DECISION_QUIET_WINDOW_DAYS = 14,DECISION_REPEAT_THRESHOLD = 3,DECISION_REPEAT_WINDOW_DAYS = 14. - Create
packages/agent/src/decisions/types.tswithDecisionSource,DecisionAskKind,DecisionAskStatus,DecisionStatus,DecisionDto,DecisionAskDto,DecisionResolutionOutcome,DecisionQueueFilter, and the mapperstoDecisionAskDto/toDecisionDtoexactly asplan.md§4.1 specifies. No NestJS or TypeORM imports. - Done when:
pnpm --filter @ever-works/agent type-checkis clean and every exported constant carries a one-line comment saying which spec FR it encodes.
- Create
-
T2. Write the pure ask/answer rules. - Create
packages/agent/src/decisions/decision-ask.tsexporting: -ASK_KIND_TO_QUESTION_KINDS: Record<DecisionAskKind, readonly HitlQuestionKind[]>— the mapping table inplan.md§2.3. -isQuestionKindAllowed(askKind, questionKind): boolean-requiresRationale(question: HitlQuestion, answer: HitlAnswer): boolean— true for a rejected approval, aconfirmansweredfalse, and achoicewhoseoptionId !== question.defaultOptionIdwhen a default exists. -validateRationale(value: string | null | undefined, required: boolean): { ok: true; value: string | null } | { ok: false; code: 'missing' | 'too-long' }-capAsks<T>(asks: readonly T[]): { kept: T[]; dropped: number }— keeps the firstDECISION_MAX_ASKS. -composeAnswerMessage(asks: DecisionAskDto[]): string— the single message delivered to the agent, one block per answered ask (prompt, the human- readable answer viadescribeHitlQuestionplus the answer value, and the rationale when present). -rankDecisions(a, b)— blocking desc, confidence desc withnulltreated asDECISION_UNSCORED_RANK,createdAtasc. -isDormant(decision, now)— open, no live linked work, older thanDECISION_DORMANT_AFTER_DAYS. Pure functions only: no TypeORM, no NestJS, no repository. - Test:packages/agent/src/decisions/__tests__/decision-ask.spec.ts— the full mapping table;requiresRationaleat every branch including a choice with no default; the rationale validator at 0, 1, 1000 and 1001 characters;capAsksat 9, 10, 11 and 14;composeAnswerMessageordering and its omission of unanswered asks;rankDecisionsproving an unscored decision sorts above0.4and below0.6. - Done when: the spec covers every branch and passes.
P1.B — Schema
-
T3. Add the
DecisionAskentity.- Create
packages/agent/src/entities/decision-ask.entity.tswith the columns, types, defaults and three indexes fromplan.md§3.1. UsePortableDateColumnfrompackages/agent/src/entities/_types.tsfor every date.decisionIdis a rawuuidcolumn with no@ManyToOne— document why (polymorphic + the entities-cycle rule) in the class docstring, mirroringagent-action-proposal.entity.ts. - Done when: the file builds and every column has a doc comment naming its writer.
- Create
-
T4. Add the additive columns to the two backing entities. - Modify
packages/agent/src/entities/agent-escalation.entity.ts: addmissionId,archivedAt,archivedByUserId,archivedReason,firstViewedAt; add@Index('idx_agent_escalation_mission_status', ['missionId', 'status']). - Modifypackages/agent/src/entities/agent-action-proposal.entity.ts: the same five columns plustaskId— this table has none today, and both the Task filter (spec FR-5) and the "what is this blocking" line (FR-11) need it, whileagent_escalationsalready carries one. Add@Index('idx_agent_action_proposals_task_status', ['taskId', 'status'])and@Index('idx_agent_action_proposals_mission_status', ['missionId', 'status']); append'archived'toAgentActionProposalStatusand toAGENT_ACTION_PROPOSAL_STATUSES. - Modifypackages/contracts/src/agents/escalation.types.ts: append'archived'toAgentEscalationStatusand toAGENT_ESCALATION_STATUSES; update the type's docstring, which currently says an escalation has "exactly two states". - Done when:pnpm --filter @ever-works/contracts buildandpnpm --filter @ever-works/agent buildare clean, and no existing read path that filtersstatus='open'needed a change. -
T5. Register the new entity in all three registries.
packages/agent/src/entities/index.ts—export * from './decision-ask.entity';packages/agent/src/database/_entities-inventory.ts— concrete import (never the barrel) + addDecisionAsktoENTITIES.packages/agent/src/database/_entity-names.ts— add'DecisionAsk'in alphabetical position.- Test:
packages/agent/src/database/database.module.spec.tsanddatabase.config.spec.tsmust pass unchanged — they assert the three lists do not drift. - Done when:
cd packages/agent && npx jest --testPathPattern='database'is green.
-
T6. Author the P1 migration.
- From
apps/api/:pnpm typeorm migration:generate -d typeorm.config.ts src/migrations/CreateDecisionAsks - Land it at
apps/api/src/migrations/1791030000000-CreateDecisionAsks.tsand hand-edit it to add, after the generated DDL:UPDATE agent_escalations SET "missionId" = t."missionId" FROM tasks t WHERE t.id = agent_escalations."taskId" AND agent_escalations."taskId" IS NOT NULLUPDATE agent_action_proposals SET "taskId" = r."taskId" FROM agent_runs r WHERE r.id = agent_action_proposals."runId" AND agent_action_proposals."runId" IS NOT NULL, then the sameFROM tasksupdate as (1) to fill that table'smissionIdfrom its freshly-filledtaskId- a chunked (500 rows) insert of one derived
decision_asksrow per currently-open escalation and pending proposal, using the same derivation rules T8 implements.
- Hand-check: 1
CREATE TABLE, 3CREATE INDEXon it, 5ADD COLUMNonagent_escalationsand 6 onagent_action_proposals, 3CREATE INDEXon those two tables. NoDROP, noALTER … TYPE, noNOT NULLwithout a default.downdrops only whatupadded, in reverse. - Done when:
pnpm typeorm migration:run -d typeorm.config.tsapplies on a fresh database and on a seeded one, a re-generate produces an empty diff, and the backfill leaves zero open decisions with zero asks.
- From
P1.C — Repository and domain services
-
T7. Add
DecisionAskRepository.- Create
packages/agent/src/database/repositories/decision-ask.repository.tswithlistForDecision,listForDecisions(batched, for the queue read),countsByDecision(groupedrequired/answered),findOwned,insertMany(idempotent ondedupKey),answer(CAS onstatus='open'),withdraw(CAS onstatus='answered'),supersedeOpen. Every method takesuserIdand composesownershipWherefrompackages/agent/src/database/ownership-scope.ts. - Test:
packages/agent/src/database/repositories/decision-ask.repository.scope.spec.ts— modelled onagent-escalation.repository.scope.spec.ts; assert every public method applies the owner filter and that a foreign id reads as absent. - Done when: the scope spec passes and no method can be called without a
userId.
- Create
-
T8. Add the ask materialiser.
- Create
packages/agent/src/decisions/decision-ask-materialiser.tsexportingderiveAsksForEscalation(row)andderiveAsksForProposal(row):- escalation, reason code in
budget-stop | guardrail-refusal | merge-refused→ onekind: 'approval'ask withquestion.kind = 'approval',prompt=decisionNeeded(capped 1000),action=summary; - every other reason code → one
kind: 'decision'ask withquestion.kind = 'text',prompt=decisionNeeded,context=summary; falls back to"A decision is needed."whendecisionNeededis empty or unparseable; - proposal → one
kind: 'approval'ask withquestion.kind = 'approval',prompt/action=title,risks=riskFlags. Each derived ask getsdedupKey = `${decisionType}:${decisionId}:derived`.
- escalation, reason code in
- Test:
packages/agent/src/decisions/__tests__/decision-ask-materialiser.spec.ts— one case per reason code and per action type, plus the empty/garbagedecisionNeededfallback. - Done when: every one of the ten reason codes and five action types has a case.
- Create
-
T9. Write asks at record-creation time.
- Modify
packages/agent/src/agents/agent-escalation.service.ts: after a successfulrepository.record(...), derive and insert the ask(s) through an@Optional()-injectedDecisionAskService, and resolve + stampmissionIdfrom the Task whentaskIdis set. Best-effort like the existing Inbox mirror — a failure warns and never fails the escalation. - Modify
packages/agent/src/agent-approvals/agent-approvals.service.ts: the same, aftercreateProposal, stampingtaskIdfromrunId → agent_runs.taskIdandmissionIdone hop further throughtasks.missionId. The Task is the thing the decision blocks; the Mission is only provenance for the filter and the chip. - Test: extend
packages/agent/src/agents/__tests__/agent-escalation.service.spec.tsandpackages/agent/src/agent-approvals/__tests__/— a recorded escalation and a created proposal each produce exactly one ask; a duplicatededupKeyproduces no second ask; an ask-write failure leaves the record intact. - Done when: both suites pass and neither service gained a required constructor argument (positional-arity rule).
- Modify
-
T10. Add
DecisionQueueService.- Create
packages/agent/src/decisions/decision-queue.service.tswithlist(userId, filter, scope)andgetOne(userId, decisionId, scope).listruns exactly the four queries inplan.md§2.1: the two record scans, the grouped ask counts, and one grouped blocking-signal query overagent_runs.awaitingInput+tasks.status = 'blocked'. It then maps throughtoDecisionDtoand sorts withrankDecisions.limitis clamped toDECISION_QUEUE_MAX_LIMIT. getOnestampsfirstViewedAton the backing record the first time a human reads it, and writes aDECISION_VIEWEDactivity entry.- Add
parseDecisionId(value): { source, id } | nullindecision-ask.ts, accepting onlyescalation:<uuid>andapproval:<uuid>. - Test:
packages/agent/src/decisions/__tests__/decision-queue.service.spec.ts— the ranking truth table; archived and resolved excluded fromopen; limit clamping at 0, 1, 100, 101;parseDecisionIdrejectinggarbage,escalation:not-a-uuid,unknown:<uuid>and an empty string; thefirstViewedAtstamp firing once and only once. - Done when: the queue read issues four queries for a 25-row page (assert the call count on the mocked repositories).
- Create
-
T11. Add
DecisionResolutionService— the heart of the epic.- Create
packages/agent/src/decisions/decision-resolution.service.tswithanswerAsk({ userId, decisionId, askId, answer, rationale, scope })andresolve({ userId, decisionId, source, note, scope }). answerAsk: validate the answer against the ask's question withvalidateHitlAnswer; applyrequiresRationale+validateRationale; CAS-write the answer; then, if no required ask remains open, callresolve.resolveperforms, in order, exactly the six steps inplan.md§2.2, and returnsDecisionResolutionOutcome. Delivery precedence:RunSteeringService.isLive(run)→steer;isResumable(run) && run.taskId→resume; otherwisedelivery: 'none'with a machine reason. A thrownresumeis caught and reported asdelivery: 'failed'— it must never roll back the answer. Task unblocking goes throughTaskTransitionService.recheckUnblockFor.- Every collaborator except
DecisionAskRepositoryis@Optional()and appended last, per this package's positional-arity convention. - Test:
packages/agent/src/decisions/__tests__/decision-resolution.service.spec.ts— the delivery matrix (live/parked/neither/throwing); resolution gated on the last required ask while an optional ask stays open; an invalid answer shape rejected without a write; a missing required rationale rejected without a write; a second answer to the same ask returning a conflict with the recorded answer and author;taskUnblocked: falsewithremainingBlockers: 1; and an explicit case proving aresumethrow leaves the answer and the closed decision in place. - Done when: every row of the matrix has a test and the suite passes.
- Create
-
T12. Point the four pre-existing resolution doors at the new service.
- Modify
packages/agent/src/inbox/inbox.service.ts: replace therouteEscalationReply+tryResumeLinkedRunpair and therouteApprovalReplycall withDecisionResolutionService.resolve(...), injected@Optional(). Keep the existing claim/CAS/reopen-on-throw structure byte for byte. - Modify
packages/agent/src/agents/agent-escalation.service.tsresolve()andpackages/agent/src/agent-approvals/agent-approvals.service.tsdecide()/approveAll()so that a resolution reached through them also supersedes any remaining open asks and runs the same delivery — through the same service, not a copy. - Test: extend
packages/agent/src/inbox/__tests__/— an Inbox escalation reply still resolves and still resumes, and now also supersedes remaining asks. Add cases to the escalation and approval service specs for the supersede-plus-deliver behaviour. - Done when: there is exactly one implementation of "close the record,
deliver the answer, unblock the Task" in the repository — grep for
tryResumeLinkedRunreturns nothing.
- Modify
-
T13. Wire the agent-side module. - Create
packages/agent/src/decisions/decisions.module.tsregisteringTypeOrmModule.forFeature([DecisionAsk, AgentEscalation, AgentActionProposal, AgentRun, Task]), providing and exportingDecisionAskRepository,DecisionAskService,DecisionQueueService,DecisionResolutionService. - Createpackages/agent/src/decisions/index.tsre-exporting the module, services, constants, types and pure helpers. - Add"./decisions"to theexportsmap inpackages/agent/package.json, following the shape of the existing"./agent-approvals"entry. - Done when:import { DecisionQueueService } from '@ever-works/agent/decisions'resolves fromapps/apiafterpnpm build.
P1.D — API
-
T14. Add the decisions controller and DTOs.
- Create
apps/api/src/decisions/dto/decisions.dto.tswithListDecisionsQueryDto(status,agentId,taskId,missionId,kind,q,limit@Min(1) @Max(100),offset@Min(0)) andAnswerAskBodyDto(answervalidated as an object,rationale?@MaxLength(1000)), allclass-validator-decorated. - Create
apps/api/src/decisions/decision-id.pipe.ts— aPipeTransformrejecting anything that is notescalation:<uuid>/approval:<uuid>with aBadRequestException. - Create
apps/api/src/decisions/decisions.controller.tswith the three P1 routes fromplan.md§4.1,@ApiTags('decisions'),@Throttle60/min on reads and 30/min on the write,@CurrentUser()plus the optionalScopeContextServicefromapps/api/src/scope/, and 404-never-403 for a foreign id. - Create
apps/api/src/decisions/decisions.module.tsimporting the agent-sideDecisionsModule; register it inapps/api/src/api.module.tsbesideAgentApprovalsModule(imported at line 53, listed at 195). - Test:
apps/api/src/decisions/decisions.controller.spec.ts— the pipe rejecting three malformed ids; limit clamping; 404 for a foreign id; 409 on a double answer; the resolution outcome surfacing on the response. - Done when:
cd apps/api && pnpm testis green and the routes appear in the OpenAPI document.
- Create
-
T15. Backfill controller specs for the two controllers that have none.
- Create
apps/api/src/escalations/escalations.controller.spec.ts— list, get, resolve; owner scoping; the new supersede-plus-deliver behaviour on resolve. - Create
apps/api/src/agent-approvals/agent-approvals.controller.spec.ts— approve, reject, approve-all; 409 on re-decide; the new supersede-plus-deliver behaviour. - Done when: both files exist, pass, and cover every route on their controller.
- Create
P1.E — Web
-
T16. Add the route constants and the sidebar entry.
- Modify
apps/web/src/lib/constants.ts: add toROUTES(nearDASHBOARD_INBOXat line 115)DASHBOARD_DECISIONS: '/decisions'andDASHBOARD_DECISION: (id: string) => `/decisions?id=${id}`, with a comment explaining the composite id form. - Modify
apps/web/src/components/dashboard/DashboardSidebar.tsx: one nav entry betweenDashboardandInbox, label keydashboard.sidebar.navigation.decisions, with an open-count badge fed the way the Inbox unread badge is. - Done when: the entry renders, is highlighted on
/decisions, and shows no badge when the count is zero.
- Modify
-
T17. Add the web API client and shared types.
- Create
apps/web/src/lib/api/decisions.shared.ts— the pureDecisionDto/DecisionAskDto/DecisionResolutionOutcomemirrors plusbandFor(snapshot),progressLabel(answered, required)andconfidenceLabel(value). - Create
apps/web/src/lib/api/decisions.ts—server-only,serverFetch/serverMutationfromapps/web/src/lib/api/server-api.ts, forwarding scope selectors throughapps/web/src/lib/api/bff-scope.tsthe way the sibling clients do. Mirror the DTO by hand and say so in a header comment, exactly asapps/web/src/lib/api/agent-approvals.tsdoes. - Test:
apps/web/src/lib/api/decisions.shared.unit.spec.ts— the three helpers at their boundaries. - Done when: no runtime import of
@ever-works/agentappears in either file.
- Create
-
T18. Add the server actions.
- Create
apps/web/src/app/actions/dashboard/decisions.tswithlistDecisionsAction,getDecisionAction,answerAskAction. Copy therequireAuth→redirect(ROUTES.AUTH_LOGIN)guard fromapps/web/src/app/actions/dashboard/agent-approvals.ts, and revalidate both'/[locale]/(dashboard)/decisions'and'/[locale]/(dashboard)/(home)'after every write. - Done when: an unauthenticated call redirects before any request is issued.
- Create
-
T19. Build the queue shell.
- Create
apps/web/src/app/[locale]/(dashboard)/decisions/page.tsx(RSC) anddecisions-client.tsx, perplan.md§5.1. A failed list read must be handed to the client as an error, never as an empty array. - Create, under
apps/web/src/components/decisions/:DecisionsClient.tsx,DecisionQueueList.tsx,DecisionCard.tsx,DecisionDetail.tsx,DecisionAskList.tsx,DecisionAskItem.tsx,DecisionEmptyState.tsx,DecisionErrorState.tsx,DecisionKeyboardSheet.tsx,index.ts. - Implement the two-pane layout, the counts, the tabs, the filters with URL
sync, paging at 25, the position walker, the 30-second counts-only refresh
gated on
document.visibilityState, and the stacked layout below 768 px — all as drawn inspec.md§6.2, §6.6–§6.9 and §6.15. - Done when: every state in the spec's §6 renders from a story or a fixture,
and the header never shows
0while loading.
- Create
-
T20. Build the five ask controls.
- Create
apps/web/src/components/decisions/asks/AskChoice.tsx,AskMultiChoice.tsx,AskApproval.tsx,AskFact.tsx,AskAccess.tsx,AskAction.tsx, plusapps/web/src/components/decisions/AskRationaleField.tsx. - Each is a labelled form control whose accessible name is the ask prompt; the
rationale field is
aria-describedby-linked to the control that made it required; submission announces its outcome through a polite live region owned byDecisionsClient. - Test:
apps/web/src/components/decisions/DecisionAskItem.unit.spec.tsxandasks/AskChoice.unit.spec.tsx— the required-rationale gate, the recommended badge, the disabled submit while a required rationale is empty. - Done when: all five kinds render, validate client-side, and are operable by keyboard alone.
- Create
-
T21. Add the entry points on the surfaces that already exist.
- Modify
apps/web/src/app/[locale]/(dashboard)/(home)/dashboard-client.tsx: a footer link under the existingApprovalsQueueblock usingdashboard.approvals.seeAll. Do not modifyApprovalsQueue.tsxitself. - Modify
apps/web/src/components/inbox/InboxClient.tsx: an Open in My Decisions link onescalationandapprovalitems. - Add the same link to the Task-detail escalation feed.
- Done when: all three links carry the composite decision id and land on a pre-selected decision.
- Modify
P1.F — i18n, tests, docs
-
T22. Add the
dashboard.decisionsnamespace. - Add the full key tree fromplan.md§8 toapps/web/messages/en.json, plusdashboard.sidebar.navigation.decisions,dashboard.approvals.seeAllandmetadata.pages.decisions. - Mirror the structure into the 20 sibling locale files inapps/web/messages/(ar, bg, de, es, fr, he, hi, id, it, ja, ko, nl, pl, pt, ru, th, tr, uk, vi, zh). English values are acceptable placeholders. - Verify: no leaf key contains a literal.; every leaf name is camelCase; all 21 files have identical key sets. - Done when: a structural diff across the 21 files is empty and the hydration e2e spec is green. -
T23. Add the P1 end-to-end specs.
- Create
apps/web/e2e/flow-decisions-queue.spec.ts— both record types in one ranked list; the tabs; filters reflected in the URL; paging; the position walker; both empty states; the error state; a foreign id rendering not-found. - Create
apps/web/e2e/flow-decisions-typed-asks.spec.ts— all five controls; the required reason on reject and on a non-recommended choice; a malformed answer refused. - Create
apps/web/e2e/flow-decisions-auto-unblock.spec.ts— answering the last ask closes the decision, restarts the parked Run, unblocks the Task, and renders each of the five resolution lines fromspec.md§6.5. - Create
apps/web/e2e/flow-decisions-validation-authz-matrix.spec.ts— 404 for foreign ids (never 403); throttle responses; the double-answer conflict. - Done when: all four pass locally and in CI.
- Create
-
T24. P1 documentation.
- Add a short user-facing page under
docs/features/describing My Decisions and the five kinds of ask, and list it inapps/docs/sidebarsPlatform.tsso it is not an orphan page. - Update the AW-03 row in
docs/specs/features/agent-workspace/TRACKER.mdtoP1 shipped. - Done when: the docs site builds and the tracker names the shipped phase.
- Add a short user-facing page under
PHASE 2 — Typed asks, undo, archive
P2.A — The two new question kinds
- T25. Extend the typed-question union.
- Modify
packages/contracts/src/hitl/hitl-question.types.ts: append'access'and'action'toHitlQuestionKindandHITL_QUESTION_KINDS; addHitlAccessQuestion/HitlAccessAnswerandHitlActionQuestion/HitlActionAnswerexactly asplan.md§2.3 specifies; add their branches toparseHitlQuestion,parseHitlAnswer,validateHitlAnsweranddescribeHitlQuestion. Keep the parsers tolerant of unknown extra keys and strict on the fields they read. - Test:
packages/contracts/src/__tests__/hitl-question.access-action.spec.ts— round-trip both kinds; reject anaccessquestion with an emptycapability; reject anaccessanswer against anactionquestion; confirm an unknown kind still returnsnull. - Done when:
pnpm --filter @ever-works/contracts testis green and no existing consumer needed a change.
- Modify
P2.B — Agent-side authoring
-
T26. Add
DecisionAskService.fileDecision. - Createpackages/agent/src/decisions/decision-ask.service.ts(if T9 created a thin version, extend it) withfileDecision({ userId, source, sourceId, asks }): validates each ask's kind↔question pairing, appliescapAsks, records the dropped count on the backing record, rejects anaccessquestion with an emptycapability, and rejects anyconnectionHintmatching the existing secret-scan patterns (Constitution VII). - Test: extendpackages/agent/src/decisions/__tests__/decision-ask.spec.ts— 14 asks keep 10 and report 4; an illegal kind pairing is rejected; a secret-shaped hint is rejected. - Done when: the over-limit note is readable from the decision DTO. -
T27. Add the agent chat tools.
- Create
packages/agent/src/decisions/decision-tools.tsexportingbuildDecisionTools({ userId, service })withopen_decision(file a decision with 1–10 typed asks) andlist_my_decisions. Type-only imports, in the shape ofpackages/agent/src/agents/agent-escalation-tools.ts. - Register it in
packages/agent/src/agents/agent-domain-tool-sources.tsalongsidebuildEscalationTools, with the keyword slots listed inplan.md§7. - Test:
packages/agent/src/decisions/__tests__/decision-tools.spec.ts— the tools are owner-scoped (userIdnever comes from the model), an over-limit call reports the dropped count, and a malformed argument returns an error object rather than throwing. - Done when: the tools appear in an assembled tool set for an agent with the relevant keywords.
- Create
-
T28. Add access verification.
- In
DecisionResolutionService, when anaccessask is answered and its question carries atoolPattern, call the existing tool-grant check (GET /api/tool-grants/checkbehind the api-side service) before accepting the answer; on a denial, refuse the write and return theaccess.stillDeniedcode. With notoolPattern, or when the check is unavailable, accept and recordverified: false. - Test: extend the resolution spec — denied, granted, no-pattern and check-unavailable.
- Done when: the refusal path leaves the ask open and writes nothing.
- In
P2.C — Undo
- T29. Add
DecisionUndoService.- Create
packages/agent/src/decisions/decision-undo.service.tswithundo({ userId, decisionId, askId, note, scope }):- refuse when
Date.now() - answeredAt >= DECISION_UNDO_WINDOW_MS(codewindow); - refuse when
resumedRunIdnames a run whose status is no longerqueued(codeacted); - otherwise: CAS the ask to
withdrawnkeeping the previous answer, reopen the backing record, cancel a still-queued resumed run through the existing run canceller, compose the withdrawal note and deliver it — injected into a live run when one exists, otherwise recorded on the decision and posted to the Task's thread.
- refuse when
- Add
canUndo(ask, run, now): { ok: true } | { ok: false; code: 'window' | 'acted' }todecision-ask.tsas a pure function so the UI and the service share one rule. - Test:
packages/agent/src/decisions/__tests__/decision-undo.service.spec.ts— the two guards at 9 min 59 s and 10 min 01 s and at run statusqueued/running; the withdrawn answer retained; the reopened decision; the cancelled run. - Done when: both refusal codes are distinguishable by the caller.
- Create
P2.D — Archive
-
T30. Add
DecisionArchiveService.- Create
packages/agent/src/decisions/decision-archive.service.tswitharchive,restoreandarchiveAll.archiveAllorders oldest-first, caps atDECISION_ARCHIVE_ALL_MAX, skips rows that are no longer open and reports{ archived, skipped }— mirroringAgentApprovalsService.approveAll's existing skip-and-report semantics. Archiving never resolves, decides or delivers anything. - Test:
packages/agent/src/decisions/__tests__/decision-archive.service.spec.ts— the 200 cap over 412 candidates; the skip count; restore returning asks to their prior state; an explicit assertion that no delivery was attempted. - Done when: the suite passes and
archiveAllissues one bulk update, not one per row.
- Create
-
T31. Add the P2 endpoints.
- Extend
apps/api/src/decisions/decisions.controller.tswithPOST …/asks/:askId/undo,POST …/archive,POST …/restoreandPOST /api/decisions/archive-all(throttled 5/min), plus their DTOs inapps/api/src/decisions/dto/decisions.dto.ts. - Test: extend
apps/api/src/decisions/decisions.controller.spec.ts— the archive-all cap and skip counts; both undo refusal codes; 409 when answering an archived decision. - Done when:
cd apps/api && pnpm testis green.
- Extend
P2.E — The sweeper
- T32. Add
DecisionSweeperServiceand its job.- Create
packages/agent/src/decisions/decision-sweeper.service.tswithsweep(): find open decisions whose linked Run iscancelledand whose Task no longer exists, archive them witharchivedReason='source-gone', capped at 500 rows per tick. - Create
packages/tasks/src/tasks/trigger/decision-queue-sweeper.task.tsas aschedules.task({ id: 'decision-queue-sweeper', cron: '*/30 * * * *', run })booting aNestApplicationContext(TriggerInternalModule)and delegating to the service — the shapepackages/tasks/src/tasks/trigger/agent-run-sweeper.task.tsuses. Export it frompackages/tasks/src/tasks/trigger/index.ts. - Test:
packages/agent/src/decisions/__tests__/decision-sweeper.spec.ts— an orphan is archived, a decision with a live Task is not, and the 500 cap holds. - Done when: no file under
packages/agent/src/decisions/imports a job SDK (Constitution IV).
- Create
P2.F — Web and tests
-
T33. Build the undo and archive UI.
- Create
apps/web/src/components/decisions/DecisionUndoMenu.tsx(the⋯menu with both disabled-undo explanations, driven by the sharedcanUndorule) andDecisionArchiveAllDialog.tsx(the confirmation naming the count, the filter and the over-cap sentence), matchingspec.md§6.4 and §6.11. - Add the Archived tab list with per-row Restore (§6.12).
- Extend
apps/web/src/app/actions/dashboard/decisions.tswithundoAskAction,archiveDecisionAction,restoreDecisionAction,archiveAllDecisionsAction. - Done when: both disabled-undo variants render their exact copy and the archive dialog cannot be confirmed without a count.
- Create
-
T34. Add the P2 end-to-end specs.
- Create
apps/web/e2e/flow-decisions-undo-withdrawal.spec.tsandapps/web/e2e/flow-decisions-archive-all.spec.tscoveringspec.mdS6, S7, E6, E7, E14 and E18. - Done when: both pass locally and in CI.
- Create
-
T35. P2 i18n and documentation.
- Add the
undo,archive,accessandoverLimitsub-trees toapps/web/messages/en.jsonif T22 stubbed them, and mirror across the 20 sibling locales. - Update the user-facing doc page with the undo rules and the archive
behaviour; update
TRACKER.mdtoP2 shipped. - Done when: the structural diff across the 21 message files is empty.
- Add the
PHASE 3 — The health signal
-
T36. Add the snapshot entity and the band overrides.
- Create
packages/agent/src/entities/decision-health-snapshot.entity.tsperplan.md§3.4, withuq_decision_health_dayunique on(userId, day)andidx_decision_health_user_day. - Modify
packages/agent/src/entities/work-agent-preference.entity.ts: adddecisionHealthyMaxPerDayanddecisionQuietWindowDays, both@Column({ type: 'int', nullable: true }), documented as "NULL = inherit the platform default; clamped at the service layer", mirroring the neighbouringmissionDefaultOutstandingCapcomment. - Register the new entity in all three registries (see T5).
- Done when: the database drift specs pass.
- Create
-
T37. Author the P3 migration.
- From
apps/api/:pnpm typeorm migration:generate -d typeorm.config.ts src/migrations/CreateDecisionHealthSnapshots - Land it at
apps/api/src/migrations/1791030100000-CreateDecisionHealthSnapshots.ts(AW-03 slot 01, README §5 rule 10). - Hand-check: 1
CREATE TABLE, 2CREATE INDEX, 2ADD COLUMNonwork_agent_preferences. No drop, no type change.downreverses exactly. - Done when: it applies on a fresh and a seeded database and a re-generate is empty.
- From
-
T38. Add
DecisionHealthService.- Create
packages/agent/src/decisions/decision-health.service.tswithsnapshotFor(userId, day),currentBand(userId)andtickAll(cursor). Bands exactly asspec.mdFR-52; overrides clamped to 1–100 and 3–90;tickAllcapped at 2000 users per tick with a cursor and idempotent on(userId, day). - Test:
packages/agent/src/decisions/__tests__/decision-health.spec.ts— every boundary (0-in-14-days, 1, 5, 6, 15, 16 per day, and 26 open), the clamps at 0/1/100/101 and 2/3/90/91, and running the tick twice for the same day producing one row. - Done when: every band boundary has a case.
- Create
-
T39. Add the repeat detector.
- Create
packages/agent/src/decisions/decision-repeat.tswithfingerprint(decision)(raising agent + reason or action type + a normalised prompt fingerprint) andclusterRepeats(decisions, now)returningMap<fingerprint, count>over the trailingDECISION_REPEAT_WINDOW_DAYS, withDECISION_REPEAT_THRESHOLDas the reporting floor. - Test:
packages/agent/src/decisions/__tests__/decision-repeat.spec.ts— 2 occurrences report nothing, 3 report a cluster, a 15-day-old occurrence falls out of the window, and clustering changes no decision's state. - Done when: the detector is provably advisory-only (assert no write).
- Create
-
T40. Add the daily tick job.
- Create
packages/tasks/src/tasks/trigger/decision-health-tick.task.ts—schedules.task({ id: 'decision-health-tick', cron: '20 7 * * *', run }), same shape asdigest-dispatcher.task.ts; export it frompackages/tasks/src/tasks/trigger/index.ts. - Add one line to the existing digest body in
packages/tasks/src/tasks/trigger/digest-dispatcher.task.ts(or the digest service it calls) reading "N decisions waiting". No new cron for the digest. - Done when: the task registers and a failed health tick cannot take the digest down.
- Create
-
T41. Add the P3 endpoints.
- Extend
apps/api/src/decisions/decisions.controller.tswithGET /api/decisions/health(204 when no snapshot exists) andPATCH /api/decisions/health/band(both fields optional, clamped). - Test: extend the controller spec — the 204 path and both clamps.
- Done when:
cd apps/api && pnpm testis green.
- Extend
-
T42. Build the health banner and the repeat chip.
- Create
apps/web/src/components/decisions/DecisionHealthBanner.tsxwith the four bands, the top-agent link, and the⋯menu (Adjust the band, Hide for 30 days, the latter persisted per browser). - Add the
Asked N timeschip toDecisionCard.tsxand the write-to-instructions action toDecisionDetail.tsx, linking to the raising Agent's instructions route with the latest answer as a draft rule. - Extend
apps/web/src/app/actions/dashboard/decisions.tswithgetDecisionHealthActionandupdateDecisionBandAction. - Test:
apps/web/src/components/decisions/DecisionHealthBanner.unit.spec.tsx— all four bands and the hidden state when the snapshot is absent. - Done when: the page still renders when
/healthreturns 204.
- Create
-
T43. Add the P3 end-to-end and accessibility specs.
- Create
apps/web/e2e/flow-decisions-health-band.spec.ts— the four bands at their boundaries, the override dialog, and the hidden line with no snapshot. - Create
apps/web/e2e/flow-decisions-a11y.spec.ts— the full keyboard map fromspec.md§6.17, focus order, the polite live region, and axe over both panes. - Done when: both pass and axe reports no serious or critical violations.
- Create
-
T44. P3 i18n and documentation.
- Add the
healthandrepeatsub-trees toapps/web/messages/en.jsonand mirror across the 20 sibling locales. - Update the user-facing doc page with the band table and the recurring-decision
guidance; update the AW-03 row in
TRACKER.mdtoP3 shipped. - Done when: the structural diff across the 21 message files is empty and the docs site builds.
- Add the
Definition of done (all phases)
-
pnpm lint,pnpm type-checkandpnpm testare green from the repository root. -
cd apps/api && pnpm testis green, including the three controller specs. - All eight
apps/web/e2e/flow-decisions-*.spec.tsfiles pass. - Both migrations apply forward on a fresh and a seeded database, and a re-generate produces an empty diff.
- Every one of the 21 files in
apps/web/messages/has an identical key set, every leaf key is camelCase, and no leaf key contains a literal.. -
grep -r "tryResumeLinkedRun"returns nothing: there is exactly one implementation of close-deliver-unblock in the repository. - No file under
packages/agent/src/decisions/imports a job-runtime SDK directly; every dispatch goes through the existing DI symbols. - No hardcoded plugin id appears anywhere in the new code.
- No answer text, rationale or ask prompt appears in an activity-log entry, a product-analytics event or an error report.
- Every acceptance criterion in
spec.md§8 has a test that exercises it.