Task Breakdown: Upstream pull requests
Ordered tasks derived from
plan.md. Each is small enough to land in one PR and ships with tests per Constitution VI. The schema task ships its migration in the same PR per Constitution V.
Epic ID: APW-09-upstream-pull-requests
Spec: ./spec.md · Plan: ./plan.md
Status: Draft
Last updated: 2026-09-17
How to use
- Tasks are sequential by default.
(parallel)means it may run alongside its predecessor. - Every task names the exact files to create or modify.
(new)marks a file that does not exist yet; every other path was checked withgit ls-filesondevelop@ee45946e5. - Every task carries Create/Modify, Test and Done when. "Done when" is checkable without reading the diff.
- Add new tasks at the bottom rather than renumbering.
- Phase boundaries are ship boundaries:
developstays green and deployable. - Migration timestamps come from the reserved block
179209<slot>00000; re-stamp ifdevelopmoved past1791240000000(newest onee45946e5, re-verified 2026-09-17). - Never run anything in this epic against a real third-party repository during development: provider calls are mocked at the plugin boundary, and manual verification uses a throwaway upstream repository the team owns.
- Program resolutions applied (CONTRACTS §0): R-1 (types in
packages/contracts/src/apps/), R-2 (Activity familyapp_upstream_pr), R-8 (APW-02 owns the Upstream tab), R-17 (holds and rail refusals), R-18 (publishrung, never bulk-approvable), R-22 (noapps/api/test/suites).upr/below =packages/agent/src/upstream-pull-requests/__tests__/.
Phase P1 — Foundations (Wave 1)
Delivers the provider contract, the record, eligibility and the member-token rule. Nothing is ever sent upstream in this phase.
P1.1 — Provider contract
-
T1. Cross-repository pull request fields. Modify
packages/plugin/src/contracts/capabilities/git-provider.interface.ts—CreatePROptions.headOwner?,headRepo?,maintainerCanModify?;GitPullRequest.headRepoFullName?: string | null; the same field onGitPullRequestStatus;ListPullRequestsOptions.head?: string(owner:branch— plan §4, G17);GitDiffResult.totalCommits?: number(G13);GitPullRequestReviewtyped as plan §3.3 (G23); and the optional methods themselves (createBranchFromSha?,updateBranchRef?,listPullRequestReviews?,listPullRequestReviewComments?,getInteractionLimit?) unless APW-02 T10 has already added them (CONTRACTS §2A: whichever lands first creates them, APW-09 keeps the semantics). Modifypackages/plugins/github/src/github-api.service.ts—createPullRequestcomposesheadas{headOwner}:{head}whenheadOwneris set, sendsmaintainer_can_modifywhen defined, sendshead_repowhenheadOwner === owner(G23);createPullRequest,getPullRequest,listPullRequests,getPullRequestStatusmaphead.repo?.full_name ?? null;getCompareDiffandgetPullRequestDiffmap compare'stotal_commits;listPullRequestspassesheadthrough when set. Modifypackages/plugins/github/src/github.plugin.ts— delegate every method added to the capability (github.plugin.ts:285-354is an explicit pass-through list; a method with no delegation reads as absent to the lazy-plugin proxy and every call would surface asproviderUnsupported, G17). Test:packages/plugins/github/src/__tests__/github-api.service.cross-repo.spec.ts(new) — composition, omission (a call without the new fields sends exactly today's request),head_reposent only for a same-owner head, mapping on all four reads, deleted head repository →null,totalCommitsmapped,head=on a list (ACC-09-14, provider half). Run:pnpm --filter @ever-works/github-plugin test cross-repo. Done when: the new spec is green and the existinggithub-api.service.pr-insights.spec.tsandgithub-api.service.merge.spec.tspass unchanged. -
T2 (parallel with T1). Reviews, review comments, interaction limits. Modify
packages/plugin/src/contracts/capabilities/git-provider.interface.ts—GitPullRequestReview,GitPullRequestReviewComment,listPullRequestReviews?,listPullRequestReviewComments?,getInteractionLimit?(plan §4). Modifypackages/plugins/github/src/github-api.service.tsandpackages/plugins/github/src/github.plugin.ts— implement withpulls.listReviews(≤ 100, body ≤ 8 KB,ida number,statemapped onto plan §3.3's union),pulls.listReviewComments(≤ 100, body ≤ 4 KB),interactions.getRestrictionsForRepo(404/403/empty →null, never'none'— G16: "cannot tell" must not read as "unrestricted"). Test: extendpackages/plugins/github/src/__tests__/github-api.service.cross-repo.spec.ts— caps and truncation, the four interaction-limit values, 404 and 403 →null, the five review states includingdismissedandpending. Run:pnpm --filter @ever-works/github-plugin test cross-repo. Done when: the spec is green. -
T3 (parallel with T1). Branch at a commit; fast-forward a branch. Modify
packages/plugin/src/contracts/capabilities/git-provider.interface.ts—createBranchFromSha?,updateBranchRef?(skip the interface edit when APW-02 T10 already added them with these signatures). Landed early (recorded 2026-09-17): APW-02 T9/T10 have already added both to the interface —createBranchFromSha?(owner, repo, name, sha, token)atgit-provider.interface.ts:935andupdateBranchRef?(owner, repo, name, sha, { force: false }, token)at:948, both optional, both returningPromise<GitBranch>(matching the siblingcreateBranch?; no return type was fixed before). So the interface modification above is a skip, and this task's remaining scope is the plugin implementation, the facade passthrough and the tests — do not re-declare the two methods. This is a pointer, not a change of scope: nothing in T3 is withdrawn, and if a different return type is ever wanted it changes in this epic and in APW-02 in one PR (CONTRACTS §3). Modifypackages/plugins/github/src/github-api.service.ts—git.createRef({ ref: 'refs/heads/{name}', sha });git.updateRef({ ref: 'heads/{name}', sha, force: false })(422 not-fast-forward → typedBranchNotFastForwardError). Modifypackages/plugins/github/src/github.plugin.ts— delegate both methods, unless APW-02 T18 has already done it (CONTRACTS §2A; in that case this task extends its tests only and changes no implementation — G17). Test: extendpackages/plugins/github/src/__tests__/github-api.service.cross-repo.spec.ts— non-fast-forward is surfaced as the typed error, never retried withforce. Run:pnpm --filter @ever-works/github-plugin test cross-repo. Done when: the spec is green. -
T4. Facade pass-throughs and the member token. Modify
packages/agent/src/facades/git.facade.ts— pass-throughs for T1–T3 (absent method →GitOperationNotSupportedErrorafter materialising the plugin and testingtypeof impl.<method> === 'function'—git.facade.ts:143-158, never a bareGitFacadeError('providerUnsupported'), whose signature is(message, operation, providerId?), G17; reads →null); addgetMemberAccountToken({ userId, providerId })that resolves onlyfindUsableGitProviderAccountthengetPatFromSettings(providerId, userId, undefined), and never accepts aworkId. Test:packages/agent/src/facades/__tests__/git.facade.member-token.spec.ts(new) — spies provetryResolveEverWorksGitPlatformTokenandgetInstallationTokenForWorkare never called; no account + no PAT →null; a provider withoutcreateBranchFromShathrowsGitOperationNotSupportedErrorand notGitFacadeError(ACC-09-14, token half). Run:pnpm --filter @ever-works/agent test git.facade. Done when: the new spec and the existinggit.facade.spec.tsare green. -
T5. Spike: a fork branch at an upstream-only commit, and the two provider behaviours G16 flags. Modify
docs/specs/features/app-works/APW-09-upstream-pull-requests/plan.md§9.2 — record the outcome. Against a throwaway upstream repository and a fork both owned by the team: (a) create a commit on the upstream default branch that the fork does not have; callcreateBranchFromSha(fork, 'upstream-pr/spike', sha)with the fork owner's token. Then, on the same throwaway pair, (b) turn pull requests off at the repository level and try to open one — record the status, thecodeand whether any read answers it (getRepository,interactions.getRestrictionsForRepo, or open-time only); (c) exhaust the outside-contributor cap (or read the repository's cap) and record what the provider answers; (d) with a non-admin token, read the interaction limit and record whether a 403/404 distinguishes "no limit" from "cannot tell"; (e) open a pull request as a first-time contributor and record howaction_requiredsurfaces — inchecks.listForRef(github-api.service.ts:989), in workflow runs, or in check suites — because FR-30's summary depends on it. Test: manual, run once against the throwaway pair; every provider response (status and message) is pasted into the PR description. Done when: all five results are recorded in plan §9.2 — (a) decides whether thesyncForkBranch?mirror branch fallback is needed; (b) and (c) pin the reads behindpullRequestsDisabledandoutsideContributorCap; (d) pinsgetInteractionLimit'snull; (e) pins whethersummarizeUpstreamChecksneedsgetWorkflowRunForCommitas an input (if it does, a task is appended here to add it).
P1.2 — Record and shared types
-
T6. Contracts and Activity family. Create
packages/contracts/src/apps/upstream-pull-request.types.ts(new) exactly as plan §3.3 (Resolution R-1 — the one shared types folder), including the seven appended refusal codes,UPSTREAM_ERROR_CODES,UpstreamPreparationReport,UpstreamEligibilityView,UpstreamPullRequestView,UpstreamPullRequestDetailViewandGitPullRequestReview. Modifypackages/contracts/src/apps/index.ts(created by APW-03 T1; if APW-03 has not landed, create it and addexport * from './apps/index.js';topackages/contracts/src/index.tsexactly as APW-03 T1 specifies) —export * from './upstream-pull-request.types.js';. Modifypackages/agent/src/entities/activity-log.types.ts— appendAPP_UPSTREAM_PR = 'app_upstream_pr'(Resolution R-2; the dottedapp.upstream_pr.*event goes inaction). Modifypackages/agent/src/activity-log/feed-kind.ts— append[ActivityActionType.APP_UPSTREAM_PR]: 'deliveryWhenCompleted'toFEED_KIND_RULES(APW09-G03: the table-driven spec atfeed-kind.spec.ts:14-19fails until every member has an explicit entry, and the comment atfeed-kind.ts:48-52says so). No existing rule is changed or reordered. Modify nothing inpackages/contracts/src/api/shared-view/publishable-activity.ts: resolution R-34 classifies every new family, and App Works events default toNEVER_PUBLISH—app_upstream_prmust stay offPUBLISHABLE_ACTIVITY_ACTIONS(its Live Feed narration names a repository and a pull-request number). Test:packages/contracts/src/apps/__tests__/upstream-pull-request.types.spec.ts(new) — pins every union, every number and every copy-bearing code (30 refusals +activeProposal); extendpackages/agent/src/entities/__tests__/activity-log.types.spec.tswith['APP_UPSTREAM_PR', 'app_upstream_pr']; extendpackages/agent/src/activity-log/feed-kind.spec.ts— the new member is mapped, and withActivityStatus.FAILEDforapp.upstream_pr.refused/.failed/.expiredit resolves to the problem kind whileCOMPLETEDapp.upstream_pr.openedresolves to delivery (plan §3.4); extendpackages/agent/src/shared-views/__tests__/publishable-activity.spec.ts—app_upstream_pris onNEVER_PUBLISH_ACTIVITY_ACTIONSand not on the publishable allowlist (R-34; that spec already fails until every kind is classified). Run:pnpm --filter @ever-works/contracts testandpnpm --filter @ever-works/agent test activity-log.types feed-kind publishable-activity. Done when: all three specs are green andpnpm --filter @ever-works/contracts buildemits the declarations. -
T7. Entity + migrations. Create
packages/agent/src/entities/upstream-pull-request.entity.ts(new) per plan §3.1 (dates viaPortableDateColumn, scope columns without relations,@ManyToOnetoUserandWorkwithonDelete: 'CASCADE'), including the 2026-09-17 additions:upstreamBaseSha(G01),preparationStartedAt/preparationPausedMs/preparationHeldSince(G14),claUrl/missingPieces(G04) andextraFilesAcknowledgedAt/extraFilesAcknowledgedById(G06). Createpackages/agent/src/entities/work-upstream-pr-setting.entity.tsandpackages/agent/src/entities/upstream-pr-suggestion.entity.ts(new) per plan §3.1A/§3.1B (G07, G20). Modifypackages/agent/src/entities/index.ts,packages/agent/src/database/_entity-names.ts,packages/agent/src/database/_entities-inventory.ts. Createapps/api/src/migrations/1792090000000-CreateUpstreamPullRequests.ts(new) — table, five indexes (partial unique on both engines, incl.idx_upr_preparing_started),down()drops only these;apps/api/src/migrations/1792090100000-CreateUpstreamPrSuggestions.tsandapps/api/src/migrations/1792090200000-CreateWorkUpstreamPrSettings.ts(new) — slots 01 and 02, each with its own guardedup()and its owndown(). Createpackages/agent/src/database/repositories/upstream-pull-request.repository.ts(new) — scoped finders, CAS state updateupdateStateIf(id, userId, from, to, patch), limit-window counts, due-row selection,findByPreparationTaskId/findByFollowUpTaskId(the row lookups G18 routes finalize by), the due-preparingselection G14's sweep needs, andstopTrackingForUser/stopTrackingForOrganization(XC-14). Modifypackages/agent/src/database/index.ts— export the new repositories (the pattern every feature-owned repository follows;database/index.ts:59-60shows the Agents/Tasks precedent). Do not add them topackages/agent/src/database/_repository-inventory.ts: that list isDatabaseModule's own providers only (_repository-inventory.ts:21-27), and the drift spec asserts the two lengths are equal (database.module.spec.ts:48-51), so an entry there without aDatabaseModuleprovider turns the suite red. Test:packages/agent/src/entities/__tests__/upstream-pull-request.entity.spec.ts(new) — index names and scope columns;apps/api/src/migrations/__tests__/CreateUpstreamPullRequests.spec.ts(new) — all three migrations re-runnable;packages/agent/src/database/repositories/__tests__/upstream-pull-request.repository.spec.ts(new) — a second active row for one source Task fails, a closed one does not; the two row lookups; apreparingrow whose running time exceeds 90 minutes is selected and a held one is not (ACC-09-24, 25, 34, 38). Run:pnpm --filter @ever-works/agent test upstream-pull-requestandcd apps/api && pnpm test CreateUpstreamPullRequests. Done when: the specs are green and the drift checks inpackages/agent/src/database/database.module.spec.tspass unchanged.
P1.3 — Eligibility
-
T8. Pure rules and limits. Create
packages/agent/src/upstream-pull-requests/upstream-eligibility.rules.ts,packages/agent/src/upstream-pull-requests/upstream-rate-limits.ts,packages/agent/src/upstream-pull-requests/summarize-upstream-checks.ts,packages/agent/src/upstream-pull-requests/upstream-fingerprint.ts(new).upstream-eligibility.rules.tstakes the App spec'supstreamPullRequests.maxOpen(default 3, hard ceiling 10, FR-26 — the effective open-per-upstream cap ismin(maxOpen, UPSTREAM_LIMITS.openPerUpstreamCeiling)and the constant is a ceiling, never the limit, G15), the platform-wide per-upstream ceiling (UPSTREAM_LIMITS.platformOpenedPerUpstreamPer24h, FR-39) and the operator deny list (FR-41) as inputs, and returns the refusinglimitkey alongside the code so the API can build the429body (FR-27, G10).summarizeUpstreamChecksreturnsunknownfor a non-empty list it cannot classify, and neverpassingfor an empty one; the review-summary rule is plan §4's — latest non-pending, non-dismissedreview per author,changes_requested>approved>commented(G23) — andseenReviewIdsevicts the oldest id pastUPSTREAM_TEXT.maxSeenReviewIds. Test:upr/upstream-eligibility.rules.spec.ts,upr/upstream-rate-limits.spec.ts,upr/summarize-upstream-checks.spec.ts,upr/upstream-fingerprint.spec.ts(new) — every branch in plan §10.1, including 24-hour windows at23:59:59and24:00:01,maxOpen1/3/10 against the ceiling, the platform ceiling refusing while the member's own allowance remains, the deny list, the five review states and the 200-id eviction (ACC-09-02, 12, 15, 17, 28, 29). Run:pnpm --filter @ever-works/agent test upstream-pull-requests. Done when: the four specs are green. -
T9.
UpstreamEligibilityService. Createpackages/agent/src/upstream-pull-requests/upstream-eligibility.service.tsandpackages/agent/src/upstream-pull-requests/upstream-pull-requests.module.ts(new) — reads APW-01sourceRepository.type/upstream, APW-03 App specupstreamPullRequests.enabledand.maxOpenthroughAppSpecService.getEffectiveSpec(G15), APW-08 delivery state (merged or later), APW-02getRepository(archived,source, the pull-requests-disabled flag),getInteractionLimit(collaborators_only/contributors_only→collaboratorsOnly,null→ "cannot tell", never "allowed", G16), the maintainer opt-out marker (FR-40) and the operator deny list (FR-41), fork push permission and token scope via T4.UpstreamPullRequestsModulewires its ownTypeOrmModule.forFeature([UpstreamPullRequest, WorkUpstreamPrSetting, UpstreamPrSuggestion])and providesUpstreamPullRequestRepository(T7). Modifyapps/api/src/api.module.ts— importUpstreamPullRequestsModule. Test:upr/upstream-eligibility.service.spec.ts(new) — each refusal code (includingpullRequestsDisabled,outsideContributorCap,maintainerOptOut,deniedUpstream),maxOpenhonoured per App Work, and that the member token (never a Work-resolved token) is used for every read (ACC-09-02, 03, 29). Run:pnpm --filter @ever-works/agent test upstream-eligibility. Done when: the spec is green. -
T10. Read-only API. Create
apps/api/src/works/upstream-pull-requests.controller.ts(new) withGET /api/works/:id/upstream-pull-requestsandGET …/eligibility; createapps/api/src/works/dto/upstream-pull-request.dto.ts(new) — the field lists are plan §3.3'sUpstreamPullRequestView,UpstreamEligibilityViewand (added by T19)UpstreamPullRequestDetailView, each with@ApiPropertyso the OpenAPI document is complete for the parity table (G11, XC-23); modifyapps/api/src/works/works.module.ts. Test:apps/api/src/works/upstream-pull-requests.controller.spec.ts(new) — shapes (every view field present, no row leaksfingerprint),listreturns other members' rows for the same Work with their author, a foreignworkIdanswers404, throttles (ACC-09-03, 23, 36). Run:cd apps/api && pnpm test upstream-pull-requests.controller. Done when: the spec is green. -
T11. P1 web: eligibility on the Task. Create
apps/web/src/lib/api/upstream-pull-requests.ts(new) andapps/web/src/components/tasks/ProposeUpstreamAction.tsx(new) — a disabled Propose upstream button with the reason for fork App Works, hidden for link, since nothing can be prepared yet. Modifyapps/web/src/components/tasks/TaskDetailClient.tsx— mount it. Modifyapps/web/messages/en.json—dashboard.tasksPage.proposeUpstream.actionanddashboard.workDetail.upstream.refusals.*; mirror keys into the 20 sibling locales. Test:apps/web/src/components/tasks/ProposeUpstreamAction.unit.spec.tsx(new) — hidden for link, disabled with the S10 copy for a private copy, disabled without push access (ACC-09-02). Run:pnpm --filter ever-works-web test ProposeUpstreamAction. Done when: the spec is green. -
T12. P1 ship gate. Modify
docs/specs/features/app-works/TRACKER.md(APW-09 notes) and this file's P1 checkboxes. Test: rootpnpm format:check && pnpm lint && pnpm type-check && pnpm test && pnpm build. Done when: the commands are green and ACC-09-02 and ACC-09-03 (eligibility half) are walked.
Phase P2 — Prepare, approve, open, track (Wave 2)
-
T13. Workspace squash. Modify
packages/plugin/src/contracts/capabilities/workspace.interface.ts—WorkspaceFinalizeOptions.squashOnto?: string. Modify the finalize implementations inpackages/plugins/sandbox-workspace/src/andpackages/plugins/local-workspace/src/—git merge-base --is-ancestor <sha> HEADelse refuse;git reset --soft <sha>; single commit withidentity; push. Modifypackages/agent/src/facades/workspace.facade.ts— pass through. Test:packages/plugins/sandbox-workspace/src/__tests__/sandbox-workspace.squash.spec.tsandpackages/plugins/local-workspace/src/__tests__/local-workspace.squash.spec.ts(new) — 3 agent commits become 1; non-ancestor refused; omitted option unchanged (ACC-09-04, workspace half). Run:pnpm --filter @ever-works/sandbox-workspace-plugin test squashandpnpm --filter @ever-works/local-workspace-plugin test squash. Done when: both specs are green and the existingsandbox-workspace.spec.ts/local-workspace.spec.tspass. -
T14. Approval type (Resolution R-18). Modify
packages/agent/src/entities/agent-action-proposal.entity.ts— append'upstream_pull_request'to the union andAGENT_ACTION_PROPOSAL_ACTION_TYPES;AgentActionProposalDecidedViagains'expired'. Modifypackages/agent/src/agent-approvals/risk-scorer.ts— cross-scope by action type. Modifypackages/agent/src/agent-approvals/agent-approvals.service.ts—requiresIndividualDecisionreturnstruefor the type, soapproveAllcounts itexcluded(never approvable in bulk); addexpire(proposalId). Modifypackages/agent/src/safety/guardrail-interop.ts— appendupstream_pull_request: 'publish.external'(thepublishrung) to the proposal tablePROPOSAL_ACTION_CATEGORY; append-only beside AW-24 P2's own action types. Test: extendpackages/agent/src/agent-approvals/__tests__/risk-scorer.spec.ts— an empty payload still flags cross-scope; extendpackages/agent/src/agent-approvals/__tests__/agent-approvals.service.spec.ts— guardrailautonomousnever auto-approves;approveAllcounts itexcluded;expiresetsdecidedVia: 'expired'; extendpackages/agent/src/agents/__tests__/guardrails.ladder-interop.spec.ts— the new key maps topublish.external, every pre-existing key is unchanged, an enforcedoffrung returnsblockand the proposal is rejected withdecidedVia: 'guardrail'(ACC-09-11, 13). Run:pnpm --filter @ever-works/agent test risk-scorer agent-approvals.service guardrails.ladder-interop. Done when: the three specs are green. -
T15. Preparation service and finalize branch. Create
packages/agent/src/upstream-pull-requests/upstream-preparation.service.ts(new) —start(userId, workId, { taskId, maintainerCanModify }): the operator switch (T39) → eligibility → row (preparing, unique active index,upstreamBaseSha= the upstream default-branch head read in this same step, G01) → branch nameupstream-pr/{slug≤40}-{4 hex}→createBranchFromShaat that sha → preparation Task (titlePrepare upstream pull request: {source title}, labelsupstream-pr:<id>,branchRefpreset torow.headBranch, the source PR diff (≤ 256 KB) and upstream guide files (each ≤ 64 KB) seeded throughseedPendingInputas fenced untrusted entries — never in the system prompt, task-transition.service.ts:870-897, G09) →dispatchAgentRunwith the reviewerdelegationScopeso a Fleet dispatcher refuses it (G09) → returns202. Modifypackages/agent/src/tasks-domain/task-workspace.service.ts— two early branches, both by row lookup and both before APW-08'sAppChangeGuardandsimulateMerge(G01, G02, G09):provisionForRunresolvesbaseReffrom the row whenrow.preparationTaskId === task.id(row.headBranch) orrow.followUpTaskId === task.id(the follow-up branch), sohandle.baseShaandtask.baseShaare the upstream-side commit;finalizeRuncallsonPreparationFinalizedwithsquashOnto: row.upstreamBaseShaand the member identity for a preparation Task, andUpstreamReviewFollowUpService.onFollowUpFinalized(no squash) for a follow-up Task, skippingsimulateMergeandopenPullRequestForBranchfor both.finalizeRemotePushrefuses both bindings. Routing is byfindByPreparationTaskId/findByFollowUpTaskId, never by a Task label — labels are free-form and client-writable (apps/api/src/tasks/tasks.dto.ts:62-72, 224-234), so a hand-addedupstream-pr:<id>must change nothing (G18). Test:upr/upstream-preparation.service.spec.ts(new) — double start → one row (S27); Fleet refusal; the Task branch is the prepared branch cut from the upstream head; the squash base equals the sha passed tocreateBranchFromShaand not the fork's branch head (ACC-09-24); no same-repository PR is ever opened for a preparation or a follow-up binding, asserted oncreatePullRequestnever being called (ACC-09-04, 25); a Task carrying a hand-added label is finalized normally (G18). Run:pnpm --filter @ever-works/agent test upstream-preparation. Done when: the spec is green andtask-workspace.service.spec.tspasses unchanged. -
T16. Verifier. Create
packages/agent/src/upstream-pull-requests/upstream-preparation.verifier.ts(new) —getCompareDiffoverupstreamOwner/upstreamRepo,baseBranch...{forkOwner}:{headBranch}with{ maxFiles: 300, maxBytes: 1024 * 1024 }(the hard cap is 1 MiB,pr-insights.ts:28; the default 256 KiB at:22silently drops patches, G13);notSingleCommitreadsGitDiffResult.totalCommits(T1) and counts the commit count, never the file list (G13); refusalsexcludedPath(plan §3.3 globs incl..ever-works/**+ App spec protected paths, path andpreviousPath),tooManyExtraFiles,tooLarge(1,000 lines / 30 files or the project's smaller stated limit reported by the run),secretDetected(scanForSecretsand the pattern name only stored — neverassertNoSecrets, whoseBadRequestExceptionembeds the matched sample,packages/agent/src/utils/secret-scan.ts:47-55, G13),tooLarge/secretDetectedwhen the compare istruncatedor any file carriespatchOmitted(APW-08's guard refusestruncatedtoo, APW-08 plan §2.5),checksRed(unlessalreadyRedOnBase), the FR-47 report validation (reportInvalid,missingPieces ≤ 10, check bounds), and flagsaiNotAccepted/dcoRequired/ CLA; builds title (≤ 72), body (≤ 8,000) with the disclosure line last. Test:upr/upstream-preparation.verifier.spec.ts(new) — the fixture "fork with 40 unrelated commits" yields a diff with only the source files; every exclusion incl. renames; the 1,240-line and 300-line-limit refusals;aiNotAccepted; template filled with the disclosure last and no Ever Works link; a truncated compare and apatchOmittedfile both refuse; a secret match stores the pattern name and no sample; a report with 11 checks, one 31-minute check or an 81-minute total refusesreportInvalid(ACC-09-05, 06, 07, 09, 16, 39). Run:pnpm --filter @ever-works/agent test upstream-preparation.verifier. Done when: the spec is green. -
T17. Proposal, listener, open job. Create
packages/agent/src/upstream-pull-requests/upstream-approval.listener.ts,packages/agent/src/upstream-pull-requests/upstream-open.service.ts,packages/agent/src/tasks/upstream-pr-open-dispatcher.ts,packages/tasks/src/tasks/trigger/upstream-pr-open.task.ts(all new). Modifypackages/agent/src/tasks/_tasks-symbols.ts(alphabetical),packages/agent/src/tasks/index.ts,packages/agent/src/tasks/job-runtime.providers.ts(DISPATCHER_SYMBOLS+ 1 and its arity comment),packages/tasks/src/trigger/trigger.module.ts,packages/tasks/src/trigger/trigger.service.ts,packages/tasks/src/tasks/trigger/index.ts. Proposal viacreateProposal({ actionType: 'upstream_pull_request', humanDecisionRequired: true, subjectKey }); the listener ignores non-author decisions (kept as written, but it never inserts a second proposal — theUNIQUE (actionType, subjectKey)index already holds that row,agent-action-proposal.entity.ts:128; it re-raises the same proposal when the service offers a re-open path and otherwise leaves the rowawaiting_approval, notifies the author and records the refusal, G08);decidedVia === 'expired'→ row stateexpired+ Activityapp.upstream_pr.expired(G08); open job: CAS → fingerprint/TTL/extra-files acknowledgement/eligibility/limits →createPullRequestwithoptions.tokenfromgetMemberAccountToken→open,nextCheckAt = +30 min, preparation Taskdone, ActivityactionType: 'app_upstream_pr',action: 'app.upstream_pr.opened'. The 409activeProposalpath and the 429 withlimitare built here from T8's rules (G10), the recovery path useslistPullRequests({ state: 'all', head: 'fork:branch' })(T1) and adopts an existing pull request only when its head repo and branch equal the row's (G17), and the operator switches of T39 are read before any provider call. Test:upr/upstream-approval.listener.spec.ts,upr/upstream-open.service.spec.ts(new) — non-author decision ignored and no second proposal inserted; a changed head, title, body, base or maintainer choice opens nothing; a 73-hour-old approval opens nothing and lands onexpired(notwithdrawn); the member token andowner:branchhead are used; a second PR on one upstream in 24 h is refused with thelimitkey and the next slot; an unacknowledged extra-file diff cannot be approved and the open job refuses it as well (ACC-09-11…ACC-09-15, 26); the lost-open recovery and the adopt-on-422 case both pass; extendpackages/agent/src/tasks/__tests__/job-runtime.providers.spec.ts— the dispatcher count grows by one. Run:pnpm --filter @ever-works/agent test upstream-approval upstream-open job-runtime.providers. Done when: the specs are green. -
T18. Status job. Create
packages/agent/src/upstream-pull-requests/upstream-status.service.ts(new),packages/tasks/src/tasks/trigger/upstream-pr-status.task.ts(new, cron3-59/10 * * * *). Modifypackages/tasks/src/tasks/trigger/index.ts— export it. Per due row (≤ 100):getPullRequestStatus+listPullRequestReviews(≤ 4 requests);summarizeUpstreamChecks; new review ids → Inbox notice per review; CLA-named checks →signatureState; merged/closed → final + Activity; cadence (30 min / 6 h / pause 90 days); expire approvals past 72 h (expire); delete fork branches of terminal rows within 10 minutes (deleteBranch,forkBranchDeletedAt); re-dispatch approved rows stuck 15 minutes. Test:upr/upstream-status.service.spec.ts(new) — one Inbox notice per review, cadence and pause, branch deletion (ACC-09-17, 18, 19, 20, 22);packages/tasks/src/__tests__/upstream-pr-status.task.spec.ts(new) — cron string and batch size. Run:pnpm --filter @ever-works/agent test upstream-statusandpnpm --filter @ever-works/trigger-tasks test upstream-pr-status. Done when: both specs are green. -
T19. Write endpoints. Modify
apps/api/src/works/upstream-pull-requests.controller.ts—POST(propose),GET :prId(with diff, returningUpstreamPullRequestDetailView),POST :prId/signed,POST :prId/acknowledge-extra-files(records the acknowledgement for the caller;409 extraFilesNotPresentwhen the diff marks none — G06, FR-49),POST :prId/withdraw,POST :prId/check(≥ 60 s apart); error contract of plan §5, including the seven appended refusal codes,409 activeProposal,409 settingWriteUnavailableand a429body that always carrieslimit(G10).POST :prId/signedcarries@HumanOnly()(see T38). Test: extendapps/api/src/works/upstream-pull-requests.controller.spec.ts— every route's shape and codes,409 activeProposal,429 rateLimitedwithlimitandnextSlotAt(andlimit: 'openPerUpstream'with nonextSlotAt),publishingOffdistinguished fromblocked, the approval view carries the full diff, the acknowledgement route's two outcomes and its effect on an approve arriving through the API, withdraw state, 404 for another account (ACC-09-03, 10, 12, 22, 23, 26, 28). Run:cd apps/api && pnpm test upstream-pull-requests.controller. Done when: the spec is green. -
T20. Enable toggle via the App spec (deterministic, with a recorded pending state). Create
packages/agent/src/upstream-pull-requests/upstream-setting.service.ts(new) — readsupstreamPullRequests.enabledfrom the effective App spec and, on a toggle, writes the change itself:commitFilesonto a branch plus a setup pull request where R-4 requires one (the same pattern APW-03's apply job uses), recordingrequestedEnabled, the branch and the pull request inwork_upstream_pr_settings(T7, §3.1A). The service offers APW-08'sPOST /api/works/:id/evolverequest builder as the alternative whenAPP_WORK_AGENT_RESOLVER.resolve({ userId: member, workId })answers and an isolated runtime exists, and handles409 agentRequired/409 noIsolatedRuntimeby falling back to the deterministic path instead of failing the request (APW-08 T25 returns both today) — the toggle must never depend on an Agent being resolvable. With no write path at all it answers409 settingWriteUnavailable.read()reports pending whilerequestedEnableddiffers from the applied value, so the answer survives a reload. Test:upr/upstream-setting.service.spec.ts(new) — default off; toggling opens no pull request upstream and files exactly one App spec change; with no resolvable Agent and no isolated runtime the deterministic path is still taken; with no write path it refuses with the code and changes nothing;read()reports pending until the change merges and the applied value afterwards (ACC-09-01, 27). Run:pnpm --filter @ever-works/agent test upstream-setting. Done when: the spec is green. -
T21. Upstream pull requests section, dialog, approval review (Resolution R-8). Modify
apps/web/src/app/[locale]/(dashboard)/works/[id]/upstream/page.tsx(created by APW-02 T30 — the one Upstream tab with its relation card, readiness, sync status and Actions hygiene) — append the Upstream pull requests section below APW-02's cards. This epic creates no page, no relation card and no tab entry. Createapps/web/src/components/works/detail/upstream/UpstreamPullRequestsSection.tsx,apps/web/src/components/works/detail/upstream/UpstreamApprovalReview.tsx,apps/web/src/components/tasks/ProposeUpstreamDialog.tsx,apps/web/src/app/actions/works/upstream-pull-requests.ts(all new). Modifyapps/web/src/components/tasks/ProposeUpstreamAction.tsx(enable the action),apps/web/src/components/inbox/InboxDecisionDetail.tsx(approval item links toUpstreamApprovalReviewat/works/:id/upstream?review=<prId>, and notice actions render — T36),apps/web/src/components/approvals/ApprovalsQueue.tsx(never offered to bulk approval),apps/web/src/lib/api/agent-approvals.tsandapps/web/src/lib/api/agents.shared.ts(action type unions),apps/web/src/components/activity-log/ActivityTypeBadge.tsx(app_upstream_pr→appUpstreamPr). Test:apps/web/src/components/works/detail/upstream/UpstreamPullRequestsSection.unit.spec.tsx,UpstreamApprovalReview.unit.spec.tsx(beside it) andapps/web/src/components/tasks/ProposeUpstreamDialog.unit.spec.tsx(new) — state/check/review chip text, private-copy notice, org-fork maintainer note, the extra-files tick gates Approve and open and posts the acknowledgement, the agent-reported check label, stale copy, the pending toggle copy, the paused-by-platform and credential-paused banners (ACC-09-10, 17, 27, 28, 32, 34, 39); extendapps/web/src/components/approvals/ApprovalsQueue.unit.spec.tsx— anupstream_pull_requestrow is excluded from bulk approval; extendapps/web/src/components/inbox/InboxDecisionsClient.unit.spec.tsx— the approval item links to the review route. Run:pnpm --filter ever-works-web test Upstream ProposeUpstreamDialog ApprovalsQueue InboxDecisionsClient. Done when: the specs are green andWorkTabs.unit.spec.tsxpasses unchanged (this epic does not touch tabs). -
T22. P2 i18n. Modify
apps/web/messages/en.json— every sub-tree in plan §8.1, onerefusalsleaf per value ofUPSTREAM_REFUSAL_CODESafter the seven 2026-09-17 additions (30) and therateLimitedleaves keyed bylimit(G10); mirror keys into the 20 sibling locales (node apps/web/scripts/sync-locale-parity.mjs). Test:apps/web/src/lib/__tests__/app-works-upstream-messages.unit.spec.ts(new) — every leaf of the plan §8.1 sub-trees exists in all 21 locale files, therefusalstree has exactly one leaf per code and no leaf for a code that does not exist, everylimitkey ofUPSTREAM_LIMITShas copy, and no leaf key contains a.(ACC-09-23, 28, strings half). Run:pnpm --filter ever-works-web test app-works-upstream-messages. Done when: the spec is green. -
T23. Skill. Create in
ever-works/skills:skills/upstream-contribution/SKILL.mdfromskill-draft/SKILL.mdand itsmanifest.jsonrow — including the report contract (T37: the file path, the schema and the caps, FR-47) and the FR-12 bounds the Skill states. Modifypackages/agent/src/upstream-pull-requests/upstream-preparation.service.ts— bind the Skill to the preparation Agent at Work scope on first use (idempotent). Test: extendupr/upstream-preparation.service.spec.ts— two preparations on one App Work create one binding. Done when: the spec is green and the Skill appears in the catalog. -
T24. P2 e2e. Create
apps/web/e2e/app-works-upstream-tab.spec.ts,apps/web/e2e/app-works-propose-upstream.spec.ts,apps/web/e2e/app-works-upstream-refusals.spec.ts(new) per plan §10.4. No suite underapps/api/test/(R-22). Every spec in this lane runs against the fake GitHub: the command carriesEVER_WORKS_E2E_FAKES=1(it is the single non-production hook CONTRACTS §7 defines, and without it the lane talks to the real GitHub — G05), and each spec asserts that no provider call left the fake. Test:cd apps/web && EVER_WORKS_E2E_FAKES=1 pnpm exec playwright test app-works-upstream-tab app-works-propose-upstream app-works-upstream-refusals— pass (ACC-09-01, 03, 08, 09, 10, 12, 15, 17, 26, 30, 37), including the axe scan and thear/herender of FR-42 (XC-25) inapp-works-upstream-tab. Done when: the three specs pass. -
T25. P2 ship gate. Modify
docs/specs/features/app-works/TRACKER.mdand this file's P2 checkboxes. Test: rootpnpm format:check && pnpm lint && pnpm type-check && pnpm test && pnpm build. Done when: the commands are green and ACC-09-01…ACC-09-17, 19, 20, 22, 23 are walked against a throwaway upstream repository.
Phase P3 — Reviews and suggestions (Wave 2)
-
T26. Address review. Create
packages/agent/src/upstream-pull-requests/upstream-review-follow-up.service.ts,packages/agent/src/tasks/upstream-pr-push-dispatcher.ts,packages/tasks/src/tasks/trigger/upstream-pr-push.task.ts(all new); register symbols, exports and runtime bindings in the files T17 modified. Flow per plan §2.6: update branch from the PR head → Taskupstream-pr-update:<id>with fenced review bodies and inline comments (≤ 64 KB) seeded throughseedPendingInput→ finalize (no squash) →onFollowUpFinalizedverifies the update diff (exclusions, ≤ 500 lines, secrets) → push approval → fast-forward viaupdateBranchRef(typed non-fast-forward refusal) →pushTimestamps. Blocked whilesignatureState = 'cla_pending_check'; ≤ 5 pushes per 24 h. Modifypackages/agent/src/tasks-domain/task-workspace.service.ts— a Task bound torow.followUpTaskId(looked up by row, never by theupstream-pr-update:<id>label) callsonFollowUpFinalizedfromfinalizeRunbefore APW-08'sAppChangeGuardandsimulateMerge, andfinalizeRemotePushrefuses it: without this the follow-up Task reachessimulateMergeagainst the fork base and thenopenPullRequestForBranch(task-workspace.service.ts:1235-1272), opening a pull request inside the fork (G02). The control-token neutralisation this brief needs is a new shared helper inpackages/contracts/src/apps/(e.g.neutralizeUntrustedText) —neutralizeControlTokensis a module-private function inapps/api/src/fleet/fleet-agent-task-planner.service.ts:77andpackages/agentcannot import it (G22). Modifyapps/api/src/works/upstream-pull-requests.controller.ts—POST :prId/address-review. Test:upr/upstream-review-follow-up.service.spec.ts(new) — a follow-up finalize callsonFollowUpFinalizedandcreatePullRequestis never called; nothing pushed before approval, then a fast-forward; non-fast-forward refusal; the 6th push in 24 h refused (ACC-09-18, 25);packages/tasks/src/__tests__/upstream-pr-push.task.spec.ts(new) — the job calls the service once per dispatch; extend the controller spec withaddress-review. Run:pnpm --filter @ever-works/agent test upstream-review-follow-upandpnpm --filter @ever-works/trigger-tasks test upstream-pr-push. Done when: the specs are green. -
T27. Signature acknowledgement flow. Modify
packages/agent/src/upstream-pull-requests/upstream-preparation.service.ts—needs_signaturepause and one-time resume on…/signed; DCO →refused/dcoRequired. Test:upr/upstream-signature.spec.ts(new) — a CLA yieldsneeds_signature;signedresumes exactly once; DCO refuses withdcoRequired; noSigned-off-bytrailer or agreement call is ever produced (ACC-09-08). Run:pnpm --filter @ever-works/agent test upstream-signature. Done when: the spec is green. -
T28. Agent suggestions. Modify
packages/agent/src/agents/agent-tool.service.ts— addsuggestUpstreamContribution({ reason ≤ 300 }), offered only on fork App Works with proposals on; it records the suggestion server-side (aupstream_pr_suggestionsrow, §3.1B, G20) and the labelupstream-candidateis display only. Modifypackages/agent/src/safety/action-category.ts— appendsuggestUpstreamContribution: 'write.internal'toENTRY_POINT_CATEGORY(action-category.ts:49), so the tool is not unclassified, and extendentry-point-coverage.spec.ts(G22). Createpackages/agent/src/upstream-pull-requests/upstream-suggestion.service.ts(new) — on APW-08'sapp.change.live, for a Task with a recorded suggestion marker (theupstream_pr_suggestionsrow the tool call created, §3.1B): Inbox suggestion,sentAtstamped when the notice goes out (≤ 1 per Task, ≤ 3 per App Work per 7 days, counted fromsentAt— not from the Task label, which a person can add by hand, G18); dismiss viaPOST …/suggestions/:taskId/dismiss, which writesdismissedAtso the Task is never suggested again. Test:upr/upstream-suggestion.service.spec.ts(new) — nothing prepared until Propose; the per-Task and 7-day limits hold across a restart (the rows, not an in-memory counter); a dismissed Task is not suggested again; a hand-addedupstream-candidatelabel with no recorded suggestion sends nothing (ACC-09-21, 40);packages/agent/src/agents/__tests__/agent-tool-upstream-suggestion.spec.ts(new) — the tool is offered only on fork App Works with proposals on and only records a suggestion. Run:pnpm --filter @ever-works/agent test upstream-suggestion agent-tool-upstream-suggestion. Done when: both specs are green. -
T29. P3 e2e and gate. Modify
apps/web/e2e/app-works-propose-upstream.spec.ts— add Address review and a suggestion. Test:cd apps/web && pnpm exec playwright test app-works-propose-upstreamand the root gate. Done when: both are green and ACC-09-18, ACC-09-21 are walked; P3 ticked.
Cross-phase closing tasks
-
T30. Telemetry. Create
packages/monitoring/src/posthog/upstream-pr-events.ts(new, modelled onkb-events.ts) — plan §9.1. Modifypackages/monitoring/src/posthog/index.ts— export it. Createpackages/agent/src/upstream-pull-requests/upstream-pr-telemetry.port.ts(new) — an injectableUPSTREAM_PR_TELEMETRYinterface +Symbol()token inpackages/agent(emit(event, payload)), because@ever-works/agentdoes not depend on@ever-works/monitoring(its dependencies are@ever-works/agent-plugins,@ever-works/contracts,@ever-works/pluginand third-party packages —packages/agent/package.json), so T15–T18 cannot import the emitter (G21). The services of T15–T18 take the port by injection and no-op when it is not bound, which is the in-repo precedent (knowledge-base-reconcile.service.ts:61-106injects its client rather than reaching for a package);apps/apibinds it toemitUpstreamPrEventin the module that provides this epic's services. Test:packages/monitoring/src/posthog/__tests__/upstream-pr-events.spec.ts(new) — every event forwards its payload; a payload withtitle,body,diff,login,owner,repoorrepositoryis stripped, and the strip is asserted in all three modes:NODE_ENV=testfails loudly, dev warns and strips, production strips silently — exactlykb-events.ts:205-235'sscrubPayloadbehaviour, whose docstring says a runtime throw in production is too much surface for how many call sites it serves (T30's earlier "throws" wording described no shipped behaviour — G21); andupr/upstream-pr-telemetry.spec.ts(new) — a service with no port bound emits nothing and does not throw (ACC-09-23, telemetry half). Run:pnpm --filter @ever-works/monitoring test upstream-pr-eventsandpnpm --filter @ever-works/agent test upstream-pr-telemetry. Done when: both specs are green. -
T31. Structural guarantees. Create
packages/agent/src/upstream-pull-requests/__tests__/no-merge-no-comment.spec.ts(new) — scans the folder's sources formergePullRequest,closePullRequest,createPullRequestCommentand for any token resolution other thangetMemberAccountToken; fails on a match. It also asserts the two structural rules this epic's audit added: everyapp.upstream_pr.*event the sources write is in plan §3.4's status table (so none ships without anActivityStatus), and no source in the folder routes finalize by a Task label. Test:pnpm --filter @ever-works/agent test no-merge-no-comment— green, and red when amergePullRequestreference is added to any file in the folder (ACC-09-19, 14). Done when: both outcomes are observed and the red run's output is pasted into the PR description. -
T32. Docs. Create
docs/features/app-works-upstream-pull-requests.md(behaviour, limits, signatures, disclosure, the publishing setting). Modifyapps/docs/sidebarsPlatform.ts(list it),docs/features/approvals-and-escalations.md(the new approval type is executed on approval, author-only, never in approve-all),docs/specs/features/app-works/TRACKER.md. Test:pnpm --filter ever-works-docs build. Done when: the docs build has no broken-link warning for the new page. -
T33. Statuses. Modify
spec.md,plan.mdand this file — statusImplemented. Test: re-read every gate in plan §12 against the merged code. Done when: every checklist item still holds and the known gaps are still recorded. -
T34. Holds, the 90-minute deadline's storage and its sweep (added 2026-09-17, Resolution R-17; extended 2026-09-17 by G14). Modify
packages/agent/src/upstream-pull-requests/upstream-preparation.service.ts,packages/agent/src/upstream-pull-requests/upstream-preparation.holds.ts(new) andpackages/agent/src/upstream-pull-requests/upstream-status.service.ts— the 90-minute preparation deadline counts running time only, using APW-08'sclassifyAppWorkRunStop(APW-08 T46): awaitpauses the clock by stampingpreparationHeldSinceand keeps the rowpreparing; the release addsnow − preparationHeldSincetopreparationPausedMsand clears it; aneeds_inputstop keeps the rowpreparingwith the clock paused and the TaskBLOCKED.preparationStartedAtis set when the run is dispatched (T15). The status job selectsstate='preparing'rows throughidx_upr_preparing_started(T7) and fails one that has spentUPSTREAM_TIMEOUTS.preparationMsof running time withtimedOut+ Activity.failed— a parked row is never selected as overrun and never timed out (plan §7, ACC-09-38). Test:upr/upstream-preparation.holds.spec.ts(new) — a run parked for 3 hours is nottimedOut; aladderrefusal leaves the rowpreparingand opens nothing upstream; a released hold resumes the clock;upstream-status.service.spec.tsgains the sweep cases: apreparingrow at 90 running minutes fails, one at 89 does not, and one held at 200 wall-clock minutes does not. Run:pnpm --filter @ever-works/agent test upstream-preparation.holds upstream-status. Done when: the specs are green. -
T35 (P1, lands with T7). Classify new tables for workspace backup (R-25). Modify
packages/agent/src/account-transfer/backup/collectors/domain-specs.ts— append to theworksdomain:{ file: 'upstream-pull-requests.jsonl', entity: 'UpstreamPullRequest', scope: { by: 'parent', column: 'workId', from: 'workIds' } },{ file: 'upstream-pr-suggestions.jsonl', entity: 'UpstreamPrSuggestion', scope: { by: 'parent', column: 'workId', from: 'workIds' } }and{ file: 'work-upstream-pr-settings.jsonl', entity: 'WorkUpstreamPrSetting', scope: { by: 'parent', column: 'workId', from: 'workIds' } }(the two tables G07 and G20 add — every table an App Works epic adds is classified in the same PR, R-25).packages/agent/src/account-transfer/backup/redaction.tsis not modified: title and body are public by design,refusalDetailnever holds a token (plan §3.1), the setting row holds a branch name and a pull request number, and the suggestion row holds the Agent's reason — no column has a secret-shaped name. Test: extendpackages/agent/src/account-transfer/backup/collectors/collectors.spec.ts—UpstreamPullRequest,UpstreamPrSuggestionandWorkUpstreamPrSettingare each referenced exactly once, inworks, scopedparentonworkIdfromworkIds, not dropped; each planned query carrieswithin: { column: 'workId', ids }with the registered Work ids. Done when:pnpm --filter @ever-works/agent test -- collectors redactionis green and a backup of a workspace with one upstream pull request listsdata/works/upstream-pull-requests.jsonlwith one record.
-
T36 (P2). Inbox notices carry actions and reach their review (added 2026-09-17, G12/FR-36, plan §8.2). Modify
packages/agent/src/inbox/inbox-producer.port.ts— append toInboxNoticeInput, additively and without changing any existing field (inbox-producer.port.ts:79-101today carries title, body, agentId, agentRunId, taskId, workId, organizationId, notify only):messageKey?: string,params?: Record<string, string>, andactions?: { id: string; labelKey: string; href?: string; apiAction?: { path: string; body?: Record<string, unknown> } }[](≤ 2;hrefishttpsonly). Every existing producer is unaffected — an input without the new fields behaves exactly as today. Modifypackages/agent/src/inbox/inbox.service.ts— persist and return the new fields with the notice. Modifypackages/agent/src/agent-approvals/agent-approvals.service.ts— the Inbox mirror sendspayload.workIdandpayload.upstreamPullRequestIdforactionType === 'upstream_pull_request', derived by the platform exactly astaskIdalready is formerge_pull_request(:233-240). Modifyapps/web/src/components/inbox/InboxDecisionDetail.tsx,apps/web/src/lib/api/inbox.ts— render the actions (hrefas an external link,apiActionas a POST + revalidate) and resolvemessageKeythroughdashboard.inbox.upstream.*. Modifypackages/agent/src/upstream-pull-requests/upstream-status.service.tsandupstream-suggestion.service.ts— the review, signature and suggestion notices usemessageKey+actionsinstead of stored English text (S6, S9, S17), and the approval item links to/works/:id/upstream?review=<prId>. Test:packages/agent/src/inbox/__tests__/inbox-notice-actions.spec.ts(new) — an input without the new fields is byte-identical to today; actions are capped at 2 and a non-httpshrefis refused; the four notice kinds carry theirmessageKeyand the right actions; extendupr/upstream-status.service.spec.ts— the signature notice carries Open the agreement (href= the report'sclaUrl, now stored on the row, T7) and I've signed it — continue (apiActionto…/signed); extendapps/web/src/components/inbox/InboxDecisionsClient.unit.spec.tsx— the actions render and the approval item links to the review route (ACC-09-18, 21, 23). Run:pnpm --filter @ever-works/agent test inbox-notice-actions upstream-statusandpnpm --filter ever-works-web test InboxDecisionsClient. Done when: the specs are green and every existing Inbox producer's spec passes unchanged. -
T37 (P2). The preparation report contract, FR-12's enforcer and the workspace report vehicle (added 2026-09-17, G04/FR-47, plan §3.3, §4). Modify
packages/contracts/src/apps/upstream-pull-request.types.ts—UpstreamPreparationReportas plan §3.3 (status union,claUrl≤ 512,projectLimitLines, title ≤ 72, body ≤ 8,000,aiPolicyQuote≤ 300,aiPolicyFile,doesNotPort≤ 10,checks≤ 10 withstartedAt/endedAt/exitCode/alreadyRedOnBase/lastLines), plus its AJV schema and the validator that returns a typed refusal. Modifypackages/plugin/src/contracts/capabilities/workspace.interface.ts—WorkspaceFinalizeOptions.reportFiles?: string[]andWorkspaceFinalizeResult.reports?: { path: string; content: string }[](plan §4), and implement both inpackages/plugins/sandbox-workspace/src/andpackages/plugins/local-workspace/src/: read each listed workspace-relative file (≤ 5 paths, ≤UPSTREAM_TEXT.reportMaxByteseach) and drop it from the tree before the squash commit, so the report can neither reach the prepared branch nor the pull request. Modifypackages/agent/src/facades/workspace.facade.ts— pass the option through. Modifypackages/agent/src/upstream-pull-requests/upstream-preparation.service.ts— the brief names.ever-works/upstream-pr-report.jsonas the only report vehicle,onPreparationFinalizedvalidates it with the T16 validator, refusesreportInvalidwhen it is absent, oversized or malformed, and storesclaUrl,missingPieces,title,bodyandcheckResults(withsource: 'agent') from it. The report is never inferred from the run's prose. Modifydocs/specs/features/app-works/APW-09-upstream-pull-requests/skill-draft/SKILL.md— step 8 names the file, the exact schema, the caps and the FR-12 bounds; a Skill body that reports in prose fails T16's validation. Test: extendupr/upstream-preparation.verifier.spec.ts— a valid report parses; missing, 33 KB, invalid JSON, an unknownstatus, 11 checks, a 31-minute check, an 81-minute total, a 51-line tail and 11 missing pieces are each refusedreportInvalid/bounded as plan §3.3 states; extendpackages/plugins/sandbox-workspace/src/__tests__/sandbox-workspace.squash.spec.tsand the local twin — a report file listed inreportFilescomes back inreportsand is absent from the commit (ACC-09-39). Run:pnpm --filter @ever-works/agent test upstream-preparation.verifierandpnpm --filter @ever-works/sandbox-workspace-plugin test squash. Done when: the specs are green and a finalize with noreportFilesbehaves exactly as today. -
T38 (P2). Human-only approval and signature routes (added 2026-09-17, XC-07; plan §5). Modify
apps/api/src/agent-approvals/agent-approvals.controller.ts(or the controller that ownsPOST /api/agent-approvals/:id/approve|reject) andapps/api/src/works/upstream-pull-requests.controller.ts— apply@HumanOnly()(apps/api/src/safety/decorators/human-only.decorator.ts) to the approve and reject handlers and toPOST …/:prId/signed, and registerHumanActorGuardwhere those controllers are declared so the decorator is enforced. Publishing under a member's name and declaring that a signature exists are the two acts no API key, MCP tool, chat tool, schedule or Fleet run may perform (FR-21). Modifyapps/mcp/src/openapi-tools/whitelist.ts— neither route appears (see T40's parity table). Test: extendapps/api/src/safety/guards/human-actor.guard.spec.ts— anauthMethod: 'api-key'request gets403and arail_refusalsrow with reasonnon-human-actor; extendapps/api/src/works/upstream-pull-requests.controller.spec.tsand the approvals controller spec — the same two routes succeed in a session and refuse an API key, with no state change and no provider call on the refusal (ACC-09-35). Run:cd apps/api && pnpm test human-actor upstream-pull-requests.controller agent-approvals. Done when: the specs are green and every pre-existing@HumanOnly()route's spec passes unchanged. -
T39 (P2). The operator kill switch and the operator deny list (added 2026-09-17, XC-10/XC-22, FR-41/FR-46). Create
packages/agent/src/upstream-pull-requests/upstream-operator-policy.ts(new) — readsEVER_WORKS_APP_UPSTREAM_PRS_ENABLED(resolution R-30's binding name; the binding default istrue— the family runs unless an operator turns it off, CONTRACTS §7) and the deny list, and exposesisPaused()/isDenied(owner, repo). Modifyupstream-preparation.service.ts,upstream-open.service.ts,upstream-status.service.ts,upstream-suggestion.service.ts, the two dispatchers andpackages/tasks/src/tasks/trigger/upstream-pr-status.task.ts— each reads the switch itself before doing any work and fails closed (no preparation row, no open, no push, no poll, no suggestion); the deny list refuses at eligibility and again immediately before opening. Neither switch withdraws, closes or deletes anything already upstream (FR-46), and the tab shows "Upstream pull requests are paused by the platform." (T21). Modifyapps/api/src/works/upstream-pull-requests.controller.ts—GET …/eligibilityreports the pause so the toggle can render it; the deny list is read-only to members (an operator route is APW-10's, this epic only consumes it). Test:upr/upstream-operator-policy.spec.ts(new) — the switch defaults off in production, every dispatcher refuses with it off and performs zero provider calls, and turning it back on resumes with no row rewritten;upstream-eligibility.service.spec.tsgainsdeniedUpstream; the controller spec gains the paused eligibility shape (ACC-09-29, 34). Run:pnpm --filter @ever-works/agent test upstream-operator-policy upstream-eligibility. Done when: the specs are green and the switch is read at the dispatcher, not only at the endpoint. -
T40 (P2). MCP, CLI and chat parity (added 2026-09-17, XC-23; plan §5's parity table). Modify
apps/mcp/src/openapi-tools/whitelist.ts— addlist_upstream_pull_requests,check_upstream_eligibility,get_upstream_pull_request,check_upstream_pull_request_nowwith read hints andwithdraw_upstream_pull_requestwith the destructive hint and its confirmation argument. The five human-only/deliberate routes (propose, acknowledge-extra-files, signed, address-review, the approval decision) stay out, as plan §5 records. Createapps/cli/src/commands/work/upstream.command.ts(new, registered in that folder'sindex.ts) —work upstream list|show|eligibility|check|withdraw|address-review, matching the table. Modifyapps/web/src/lib/ai/tools/generated/registry.ts— the three read-only chat tools (list_upstream_pull_requests,check_upstream_eligibility,get_upstream_pull_request), pluscheck_upstream_pull_request_nowanddismiss_upstream_suggestion. Modify every route inapps/api/src/works/upstream-pull-requests.controller.ts—@ApiOperation+@ApiResponseso the OpenAPI document carries them (the whitelist is generated from it). Test:apps/mcp/src/openapi-tools/__tests__/upstream-parity.spec.ts(new) — every route in plan §5's table has either a whitelist entry with the stated hint or a recorded not-exposed reason, and the spec fails when a route is added without a row;apps/cli/src/commands/work/__tests__/upstream.command.spec.ts(new) — the commands exist and hit the stated paths; the registry spec asserts the chat tools' read-only scopes (ACC-09-36). Run:pnpm --filter @ever-works/mcp test upstream-parityandpnpm --filter ever-works-cli test upstream.command. Done when: the specs are green and the generated OpenAPI document lists every route of §5. -
T41 (P2). Keyboard and accessibility lane (added 2026-09-17, XC-25/FR-42; plan §8.3). Modify
apps/web/e2e/app-works-upstream-tab.spec.ts,app-works-propose-upstream.spec.tsandapp-works-upstream-refusals.spec.ts— an axe scan per surface (toggle, list, chips, dialog, approval, refusals) asserting no new violations against a recorded baseline; keyboard-only operation of the toggle, dialog, approval tick, Review, Check now and Withdraw with a visible focus ring;Escclosing the dialog and focus returning to the opening control; a polite live region announcing Preparing, Opening, a new review and a refusal; and anarandherender with no clipped chip or mirrored control. Modify the components of plan §8 — the pieces the scan finds (labels,aria-*, focus management, the live region) — without changing any copy or behaviour. Test:cd apps/web && pnpm exec playwright test app-works-upstream-tab app-works-propose-upstream app-works-upstream-refusalswith the a11y assertions enabled, pluspnpm --filter ever-works-web test Upstreamfor the unit-level focus behaviour (ACC-09-30). Run in the same lane as T24. Done when: the three specs pass with the axe checks on and the recorded baseline is committed. -
T42 (P2). Account and organization deletion stops tracking (added 2026-09-17, XC-14/FR-45). Create
packages/agent/src/upstream-pull-requests/upstream-deletion.handler.ts(new) —stopTrackingForUser(userId)/stopTrackingForOrganization(organizationId): every row of that member (or of every App Work the organization owns) goes terminal,nextCheckAtis cleared, in-flight open/push dispatches are cancelled, fork branches this epic created are deleted withinUPSTREAM_TIMEOUTS.deletionCascadeMs, and no further provider call uses that account. Nothing upstream is edited, closed or deleted (FR-32). Modify APW-01'sAPP_WORKS_ACCOUNT_DELETIONhandler registration (the handler itself is APW-01's, per CONTRACTS) to call it — this task adds the APW-09 side only, and if APW-01 has not landed the port yet, this task adds the consumer binding behind the existingUserAccountDeletionEvent(apps/api/src/events/index.ts:128). Test:upr/upstream-deletion.handler.spec.ts(new) — after the handler, no poll is scheduled, the two dispatchers are cancelled, the branches are deleted within the bound, the account's token is never used again, and nomergePullRequest/closePullRequest/createPullRequestCommentcall is made; the status job's due-row selection returns none of that member's rows (ACC-09-31). Run:pnpm --filter @ever-works/agent test upstream-deletion. Done when: the spec is green and APW-01's cascade spec passes with this handler registered. -
T43 (P2). The credential of record and its handover (added 2026-09-17, XC-18/FR-43). Create
packages/agent/src/upstream-pull-requests/upstream-credential.service.ts(new) — reads and records the App Work's credential of record (the member who created it, whose connection performed the fork, D2 / APW-01 FR-15), answersresolveForBackgroundJob(workId)and reports the paused reason when it is unusable (member left the organization, lost access, disconnected, or the scope was withdrawn). The record is APW-02's upstream state where it already carries one; this epic adds only the read and the pause. Modify the background jobs of §7 and APW-05's build polling callers — they resolve through this service instead of a Work-resolved token, and when it reports unusable they pause with the named reason instead of failing, writing Activityapp.upstream_pr.refused-free notes (no state change) and no upstream call. Modifyapps/api/src/works/upstream-pull-requests.controller.ts— expose the pause state on the tab's read, and the handover action (POST /api/works/:id/upstream/credential/handover, edit access required) that makes the caller's own connection the credential of record for work not yet started. Modifyapps/web/src/components/works/detail/upstream/UpstreamPullRequestsSection.tsx— the "Waiting for {member} to reconnect GitHub." banner with the handover action (plan §6.1's copy table). Test:upr/upstream-credential.service.spec.ts(new) — the recorded member is the fork's creator; an unusable credential pauses the jobs with the reason and makes no provider call; a handover changes the credential for work not yet started and re-authors nothing already opened; the preparation and push paths still use the publishing member's own token (FR-24), never the credential of record (ACC-09-32). Run:pnpm --filter @ever-works/agent test upstream-credential. Done when: the spec is green. -
T44 (P2). Contribution runs booked against the App Work's budget (added 2026-09-17, XC-19/FR-44). Modify
packages/agent/src/upstream-pull-requests/upstream-preparation.service.tsandupstream-review-follow-up.service.ts— every run this epic dispatches goes through the platform'sBudgetGuardServiceagainst the App Work's ownWorkBudget(packages/agent/src/budgets/budget-guard.service.ts,packages/agent/src/entities/work-budget.entity.ts) before the run is dispatched, exactly as the evolve loop's runs do. A budget refusal is a wait: the row keeps its state, nothing is opened or pushed, the member sees the reset time, and the run resumes when the budget allows. The existing per-feature caps (FR-26, APW-08's per-Mission cap) keep applying unchanged — the budget is an additional bound. Modifyapps/api/src/works/upstream-pull-requests.controller.ts— the write routes surface the wait as202 { state, waiting: 'budget', resetAt }rather than a422, andGET …/:prIdcarries it. Test:upr/upstream-budget.spec.ts(new) — a preparation over the cap dispatches no run, opens nothing and reports the reset time; the same Work's other counters are untouched; the alert fires at the Work's threshold; and a run under the cap dispatches exactly once (ACC-09-33). Run:pnpm --filter @ever-works/agent test upstream-budget. Done when: the spec is green and the budget guard's own spec passes unchanged. -
T45 (P2). The fake GitHub's upstream endpoints and the PR-lane seed (added 2026-09-17, G05/ACC-09-37). Modify APW-13's fake (
apps/web/e2e/fakes/github-fake/, APW-13 plan §8.3) — add the REST subset this epic calls and nothing else:POST /repos/:o/:r/git/refs,PATCH|DELETE /repos/:o/:r/git/refs/heads/*,GET /repos/:o/:r/interaction-limits,GET /repos/:o/:r/pulls/:n,GET /repos/:o/:r/pulls/:n/reviews,GET /repos/:o/:r/pulls/:n/comments,GET /repos/:o/:r/commits/:ref/check-runs,GET /repos/:o/:r/commits/:ref/status, andGET /repos/:o/:r/commits/:ref/statuses; extendGET /repos/:o/:r/compare/:baseheadto answertotal_commits(T1); extend the control API (POST /_control/seed) to seedupstream_pull_requestsrows and a matching approval proposal. Every seed route is honoured only whenEVER_WORKS_E2E_FAKES=1andNODE_ENV !== 'production', exactly like the fake's API base switch (APW-13 plan §8.3), and the fake records every call for the "zero writes" assertions. Test: the fake's contract test replays the newly recorded real responses (APW-13 §8.3's existing pattern);apps/web/e2e/app-works-propose-upstream.spec.ts(T24) completes a preparation toawaiting_approvalagainst the fake with no call leaving it, which is what ACC-NEG-06's "anawaiting_approvalproposal seeded" precondition needs and what no epic defined until now (ACC-09-37). Run:cd apps/web && EVER_WORKS_E2E_FAKES=1 pnpm exec playwright test app-works-propose-upstream. Done when: the spec passes against the fake and the fake's contract test is green. -
T46 (P2–P3 ship gate extension). Walk the new acceptance ids. Modify
docs/specs/features/app-works/TRACKER.md(APW-09 notes) and this file's P2/P3 checkboxes — including ACC-09-24…ACC-09-40, which are the audit additions of 2026-09-17. Test: rootpnpm format:check && pnpm lint && pnpm type-check && pnpm test && pnpm build. Done when: the commands are green and ACC-09-24…ACC-09-40 are walked (each one either passes or names the blocker in the epic's known-gaps list — never silently skipped).
Definition of Done
- Every checkbox is ticked; root
format:check,lint,type-check,test,buildgreen. - T31's structural spec is green: no code path in this epic merges, closes or comments upstream, or uses any token but the member's.
- ACC-09-01…ACC-09-23 walked against a throwaway upstream repository the team owns — never a third-party project.
- The known gaps in plan §12 are still recorded.