Task Breakdown: Fork lifecycle — readiness, Actions hygiene, upstream sync, divergence, checkout keys
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-02-fork-lifecycle
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. An implementer should never have to guess a path.
- Every task carries Test (the spec file and what it asserts, or the command that runs it) and Done when (an observable, checkable condition).
- "Done when" is stated explicitly for every task and is checkable without reading the diff.
- Add new tasks at the bottom of their phase rather than renumbering — APW-01 references T1–T8 (P0), T9–T34 (P1), T15, T27, T43 and T44. T43–T44 were added by the program audit; T46–T51 by the 2026-09-17 audit pass.
- Phase boundaries are ship boundaries:
developmust be green and deployable at the end of each phase. - P0 is independently shippable on branch
feat/apw-02-fork-lifecycle-p0and touches no table, route, job or UI; P1 ships onfeat/apw-02-fork-lifecycle. - Program audit resolutions (CONTRACTS §0)
applied here: R-1 (T11), R-2 (T15), R-4 (T43), R-8 (T30, T36), R-14 (T2 — generic fixtures only), R-21 (T23), R-22
(no suite under
apps/api/test/). - Prerequisites. P0: none — it is Wave 0 and ships alone. P1: APW-03 P1 (T1's contracts barrel and
T12's
AppSpecService.getEffectiveSpec/AppSpecAppliedEvent) and APW-03 P2's seam (T24AppsCatalogService, T26's adapter) for T26/T28's spec reads, APW-06 T1–T3 (packages/agent/src/app-runtime/ports.tsand theIDeploymentPluginApp additions) for T28's proxies, and APW-03 P3'sAppLicenseService(its T42) for the license leg. The license and tier services are injected@Optional(); where aports.tsfile does not exist yet, the task that needs it creates it from CONTRACTS §3 verbatim and APW-06 T3 modifies it — nothing here removes what APW-06 declared. This epic createspackages/agent/src/app-works/, which APW-01 T11 and every later epic build on, so T15 lands before APW-01 T11 in the program merge order. - Unit and controller tests never call GitHub. Live behaviour is pinned once by the contract probe (T42) against a throwaway repository in a test organization, and by APW-13's suite.
- Test commands run from the monorepo root unless a task says otherwise: plugin
cd packages/plugin && npx vitest run <path>; GitHub plugincd packages/plugins/github && npx vitest run <path>; contractscd packages/contracts && npx vitest run <path>; agentcd packages/agent && npx jest <path>; APIcd apps/api && npx jest <path>; taskscd packages/tasks && npx vitest run <path>; web unitcd apps/web && npx vitest run <path>; web e2ecd apps/web && npx playwright test <path>.
Phase P0 — Safe working copies and non-blocking fork requests (Wave 0)
Delivers spec FR-1…FR-13 and ACC-02-01…ACC-02-03.
P0.1 — Contracts
- T1. Optional fields for keys, expectations and waiting.
Modify
packages/plugin/src/contracts/capabilities/git-provider.interface.ts—GitCloneOptionsgainscheckoutKey?: stringandexpectExisting?: boolean;ForkRepositoryOptionsgainswaitForReady?: boolean;GitRepositorygainsforkReadiness?: 'ready' | 'pending';IGitOperations.getLocalDir(owner, repo, checkoutKey?)andremoveLocalDir(owner, repo, checkoutKey?). JSDoc on each states the default keeps today's behaviour. Modifypackages/plugin/src/facades/git-facade.interface.ts—getLocalDir(providerId, owner, repo, checkoutKey?). Test: type-level only in this task —pnpm --filter @ever-works/plugin type-checkandpnpm --filter @ever-works/github-plugin type-checkpass with no edit to any implementer; behaviour is asserted by the T2–T5 specs. Done when:pnpm --filter @ever-works/plugin buildemits declarations and every existing implementer compiles unchanged.
P0.2 — Entity, table, migration
None in P0.
P0.3 — Services
-
T2. Exact checkout directories. Modify
packages/plugin/src/git/git-operations.ts— export purecheckoutDirectoryName(cloneUrl, owner, repo, checkoutKey?)per plan §2.1: with a key,v2/k/<sha256(key)[0,16]>-<slug(key)>after validating^[a-z0-9][a-z0-9:_-]{0,127}$(else throw); without,v2/r/<sha256(cloneUrl)[0,16]>-<slug(owner)>--<slug(repo)>;slugreusesslugifyTextand is capped at 40 characters per part.getLocalDir,removeLocalDirandcloneOrPulluse it;cloneBranchis unchanged. Modifypackages/plugin/src/git/index.ts— exportcheckoutDirectoryName. Test: createpackages/plugin/src/git/__tests__/git-operations.checkout-key.spec.ts— two distinct owner/repository pairs whose normalized names collide (the arrange step asserts the legacy slug of both is equal, so the fixture cannot silently stop colliding) give two different directories; key dirs never equal repo dirs; owner.., repo../xand keywork:../xcannot leave the base (resolved path starts with base);removeLocalDirof one leaves a sibling and a pre-created legacy-slug directory intact (ACC-02-01); runcd packages/plugin && npx vitest run src/git/__tests__/git-operations.checkout-key.spec.ts. Done when: the spec passes andpackages/plugin/src/git/__tests__/git-http-agent.spec.tsstill passes. -
T3. Refuse the empty-copy fallback when a repository must exist. Modify
packages/plugin/src/git/git-operations.ts— exportRepositoryNotReadyError(code = 'repository_not_ready'); incloneOrPull's catch, whenexpectExisting === true, remove the directory and throw it instead ofgit.init+addRemote. Without the flag the fallback is unchanged. Test: createpackages/plugin/src/git/__tests__/git-operations.expect-existing.spec.ts— using a local HTTP-less fixture (a stubbedgit.clonerejecting withNotFoundErrorand with an "empty" message): flag set ⇒ error and no directory; flag unset ⇒ directory initialised withoriginas today (ACC-02-02); runcd packages/plugin && npx vitest run src/git/__tests__/git-operations.expect-existing.spec.ts. Done when: both paths are covered andgit grep -n "expectExisting: true" packages/agent/srcreturns no existing caller. -
T4. Plugin and facade pass-through. Modify
packages/plugins/github/src/github.plugin.ts—getLocalDir/removeLocalDirforward the optional key. Modifypackages/agent/src/facades/git.facade.ts—FacadeCloneOptionsgainscheckoutKey?,expectExisting?; thecloneOrPullcoalescing key appends bothcloneOptions.checkoutKey ?? ''andcloneOptions.expectExisting === true(FR-8: a plain clone and anexpectExistingclone of the same coordinates must never share one in-flight clone, because the plain one may legally leave an emptygit initdirectory behind and theexpectExistingcaller would then be handed it);getLocalDir/removeLocalDiraccept and forward the key. Test: extendpackages/plugins/github/src/__tests__/github.plugin.spec.ts(forwarding) and createpackages/agent/src/facades/__tests__/git.facade.checkout-key.spec.ts— two calls with different keys for one repo do not coalesce; two identical calls do; two calls with the same key whoseexpectExistingdiffers do not coalesce, and theexpectExistingone still throwsRepositoryNotReadyErrorwhen the repository is empty (ACC-02-01, ACC-02-02). Done when:pnpm --filter @ever-works/agent testis green. -
T5. Fork requests find the real existing fork and can return immediately. Modify
packages/plugins/github/src/github-api.service.ts—forkRepositoryalways resolves the target owner and checks<target>/<name ?? repo>; returns it only whenfork === trueandsource.full_name(elseparent.full_name) equalsowner/repocase-insensitively, withforkReadinessfrom its default-branch head;waitForReady === falsereturns the mappedPOST /forksresponse withforkReadiness: 'pending'; the default keeps the 24 × 5 s poll. This is step (1) of the full lookup, not the whole of FR-10: a fork the member renamed is found only once T52 routesforkRepositorythrough T17's three-stepfindExistingFork, so ACC-02-03 is not signed off on P0 alone. Modifypackages/plugins/github/src/github.plugin.tsandpackages/agent/src/facades/git.facade.ts— no signature change; forward the options object untouched. Test: createpackages/plugins/github/src/__tests__/github-api.service.fork.spec.ts(Octokit mocked) — existing fork found withoutname; a same-named non-fork is not returned and a fork request is made; fork of a fork matched bysource;waitForReady: falsemakes exactly one POST and zerorepos.getpolls and answers well inside 10 s on a fake clock; default path still polls (ACC-02-03). Done when:packages/agent/src/template-catalog/template-catalog.service.spec.ts(theforkTemplateForUsercaller) passes unchanged. -
T6 (parallel with T5). Remote-rewrite audit. Modify only where the audit finds a shared working copy — the current
replaceRemotecallers arepackages/agent/src/generators/website-generator/branch-sync.service.ts,packages/agent/src/generators/website-generator/website-generator.service.ts,packages/agent/src/generators/website-generator/website-update.service.tsandpackages/agent/src/template-catalog/template-customization.service.ts(re-rungit grep -n "replaceRemote" packages/agent/srcfirst); each one that works in a shared working copy moves onto a per-callcheckoutKeyorcloneBranch. Test: for each changed caller, extend its spec (packages/agent/src/generators/website-generator/branch-sync.service.spec.ts,packages/agent/src/generators/website-generator/website-generator.service.spec.ts,packages/agent/src/generators/website-generator/website-update.service.spec.ts,packages/agent/src/template-catalog/__tests__/template-customization.service.spec.ts) — the directory it rewrites is unique per call; unchanged callers keep their specs green. Done when: the PR description lists everyreplaceRemotecaller with its verdict and the four specs pass.
P0.7 — Tests and docs
-
T7. Regression sweep. Modify: none expected — a failing caller is fixed in the file T2–T5 changed (
packages/plugin/src/git/git-operations.tsorpackages/agent/src/facades/git.facade.ts), never by editing the caller's spec. No documentation page changes in P0 (the behaviour is internal); T37 documents working copies. Test:cd packages/agent && npx jest src/generators/data-generator/data-generator.service.spec.ts src/comparison-generator/comparison-generator.module.spec.ts src/community-pr/community-pr-processor.service.spec.ts src/account-transfer src/works-config/__tests__/works-config-repository-sync.service.spec.ts src/works-config/services/works-config-repository-sync.workid.spec.ts src/services/__tests__/knowledge-base-git-mirror.service.spec.ts src/template-catalog/template-catalog.service.spec.ts— everycloneOrPullcaller's existing specs. Done when: all listed specs pass with no edits. -
T8. P0 ship gate. Modify
docs/specs/features/app-works/APW-02-fork-lifecycle/tasks.md— tick P0. Test:pnpm format && pnpm lint && pnpm type-check && pnpm test && pnpm buildfrom the root. Done when:developis green; no migration, route, job or UI string was added.
Phase P1 — The fork lifecycle (Wave 1)
Delivers spec FR-14…FR-59 (incl. FR-18a, FR-24a) and ACC-02-04…ACC-02-23.
P1.1 — Contracts
-
T9. Repository facts and typed provider errors. Modify
packages/plugin/src/contracts/capabilities/git-provider.interface.ts—GitRepositorygainssource?,allowForking?,archived?,visibility?,stars?,sizeKb?,licenseSpdx?,empty?,movedFrom?. Createpackages/plugin/src/contracts/capabilities/git-provider.app-forks.ts—GitProviderErrorReason,GitProviderRequestError,GitForkSyncResult,GitForkDivergence,GitRepositoryCopyInput,GitRepositoryCopyResult,GitWorkflowRef,GitActionsPermissionsInput,GitActionsPermissionsResult,GitWebhookInputexactly as plan §3.3. Modifypackages/plugin/src/contracts/capabilities/index.ts—export * from './git-provider.app-forks.js';. Modifypackages/plugin/src/git/index.ts— re-export the new types for plugin authors. Test: createpackages/plugin/src/contracts/__tests__/git-provider-app-forks.spec.ts— the error class keepsreason,status,details;instanceof Error. Done when: the spec passes andpnpm --filter @ever-works/plugin buildsucceeds. -
T10 (parallel with T9). Optional capability methods. Modify
packages/plugin/src/contracts/capabilities/git-provider.interface.ts— add toIGitProviderPluginthe optionalfindExistingFork?,syncForkBranch?,getForkDivergence?,createRepositoryCopy?,setActionsPermissions?,createWebhook?,deleteWebhook?with the signatures of plan §3.3 — and APW-09'screateBranchFromSha?/updateBranchRef?with the exact signatures in CONTRACTS §3 unless APW-09 has already added them — and JSDoc stating callers must materialise the method before calling (lazy-plugin proxy rule). Test: type-level —pnpm --filter @ever-works/plugin type-check,pnpm --filter @ever-works/github-plugin type-checkandpnpm --filter @ever-works/agent type-checkpass with no edit to any existing git-provider implementation (runtime behaviour is asserted by T17–T22). Done when: every existing git-provider implementation compiles unchanged. -
T11. Upstream state contracts in the shared App Works folder (Resolution R-1). Create
packages/contracts/src/apps/app-upstream.ts— the four state unions,AppUpstreamStateResponse(incl.readiness.setupPullRequestNumber),APP_FORK_READINESS_REASONSand every constant of plan §3.4; importAPP_PRIVATE_COPY_MAX_SIZE_KBfrom./app-source.jswhen APW-01 T2 has landed, otherwise define it here with a comment naming APW-01 as owner. Modifypackages/contracts/src/apps/index.ts—export * from './app-upstream.js';(Create it, and addexport * from './apps/index.js';topackages/contracts/src/index.ts, if APW-03 T1 has not landed). Test: createpackages/contracts/src/apps/__tests__/app-upstream.spec.ts— pins the unions and each numeric constant ([2_000, 4_000, 8_000, 15_000],15_000,900_000,30_000,3,3,600_000,5_000,60_000,50,'0 6 * * 1',3_600_000,300_000,6,2_000,1_800_000,'*/10 * * * *',50,20,50,100,300,60_000,60_000,3_600_000,3,600_000,86_400_000,25,86_400_000), the env nameEVER_WORKS_APP_FORK_READINESS_TIMEOUT_MSand the four readiness reasons. Done when:import { AppUpstreamStateResponse } from '@ever-works/contracts'type-checks inapps/web(pnpm --filter ever-works-web type-check) andgit grep -n "app-upstream.dto" packages/contractsreturns nothing.
P1.2 — Entity, table, migration
-
T12.
WorkUpstreamStateentity. Createpackages/agent/src/entities/work-upstream-state.entity.ts— every column, default and index of plan §3.1 (incl.setupPullRequestNumber,setupCheckedAt);TimestampColumnfor every time;@ManyToOne(() => Work, { onDelete: 'CASCADE' }); scope columns without relations. Modifypackages/agent/src/entities/index.ts,packages/agent/src/database/_entity-names.ts,packages/agent/src/database/_entities-inventory.ts— the three registration steps. Test: createpackages/agent/src/entities/__tests__/work-upstream-state.entity.spec.ts— index names, defaults (preparing,unknown,available,pending, counters0), scope columns present. Done when:packages/agent/src/database/database.module.spec.tspasses without a magic-number edit. -
T13. Migration. Create
apps/api/src/migrations/1792020000000-CreateWorkUpstreamStates.ts— Table API, every column of T12, FK toworks(id)ON DELETE CASCADE, three indexes;down()drops indexes then table. Test: createapps/api/src/migrations/__tests__/CreateWorkUpstreamStates.spec.ts—up()creates exactly the table + indexes;down()removes only them;apps/api/src/migrations/__tests__/migrations-directory-contract.spec.tspasses. Done when: a fresh database migrates and rolls back cleanly on Postgres and SQLite. -
T14. Repository. Create
packages/agent/src/database/repositories/work-upstream-state.repository.ts—findByWorkId,create,update,claimDue(nowMs, limit)(transactional select + stamp),findStalePreparing(nowMs, idleMs, limit),findUnavailableDueForRecheck(nowMs, limit),claimSetupPullRequestChecks(nowMs, minIntervalMs, limit),incrementManualSync(workId, nowMs, windowMs, max),incrementManualRetry(workId, nowMs, windowMs, max)(atomic conditional updates returning allowed/denied). Modifypackages/agent/src/database/index.ts— export. Test: createpackages/agent/src/database/repositories/__tests__/work-upstream-state.repository.spec.ts—claimDueandclaimSetupPullRequestChecksnever return a row twice across two concurrent calls; limit honoured; manual counters roll after the window; the 7th sync and the 4th retry are denied. Done when: the spec passes on the SQLite test database. -
T15. Agent module, barrel, Activity types (Resolution R-2). Create
packages/agent/src/app-works/app-works.module.ts(TypeOrmModule.forFeature([WorkUpstreamState]), repository provider, services added by later tasks) andpackages/agent/src/app-works/index.ts. Modifypackages/agent/package.json— add the./app-workssubpath export mirroring./pr-review. Modifypackages/agent/src/entities/activity-log.types.ts— appendAPP_FORK = 'app_fork',APP_ACTIONS = 'app_actions',APP_UPSTREAM = 'app_upstream'(dotted events stored inaction) per plan §3.5. Test: extendpackages/agent/src/entities/__tests__/activity-log.types.spec.ts— the three new pairs pinned, every existing pair unchanged; createpackages/agent/src/app-works/__tests__/app-works.module.spec.ts— the module compiles with only its own providers. Done when:@ever-works/agent/app-worksresolves fromapps/apiandpackages/tasks.
P1.3 — Services
-
T16. GitHub error classification and repository facts. Create
packages/plugins/github/src/github-errors.ts—toGitProviderError(err, permissionHint?)per the table in plan §4.2. Modifypackages/plugins/github/src/github-api.service.ts—getRepositorymaps the new facts, computesemptyonly whensize === 0, setsmovedFrom, keepsnullon 404 and rethrows other failures throughtoGitProviderError. Test: createpackages/plugins/github/src/__tests__/github-errors.spec.ts(every row, incl. rate-limit reset and secondary retry times — ACC-02-16) andpackages/plugins/github/src/__tests__/github-api.service.repository-facts.spec.ts(every fact,movedFrom,emptyonly whensize === 0, typed errors from every 403 variant). Done when: the existingpackages/plugins/github/src/__tests__/github-api.service.*.spec.tsfiles pass unchanged. -
T17.
findExistingFork. Modifypackages/plugins/github/src/github-api.service.tsandpackages/plugins/github/src/github.plugin.ts— the three-step lookup of plan §4.3 (same-name identity check, GraphQL forks with affiliations filtered by owner, REST forks fallback ≤ 3 pages). The method is consumed in two directions: APW-01's inspect calls it per candidate owner, andforkRepositoryitself calls it before every create request (T52) — so it is a capability, never a private helper of the create path. Test: extendpackages/plugins/github/src/__tests__/github-api.service.fork.spec.ts— renamed fork found via GraphQL; GraphQL error ⇒ REST fallback; nothing found ⇒null; a match owned by another owner ignored (ACC-02-03). Done when: at most 1 REST + 1 GraphQL + 1 REST call on the happy path (asserted). -
T18. Sync, divergence and branch update. Modify
packages/plugins/github/src/github-api.service.tsandpackages/plugins/github/src/github.plugin.ts—syncForkBranch,getForkDivergence, and (unless already present) APW-09'screateBranchFromSha/updateBranchRefper plan §4.3. Test: createpackages/plugins/github/src/__tests__/github-api.service.fork-sync.spec.ts— merge-upstream 200 (fast-forward,merge,none), 409, 422; compare basehead string is<upstreamOwner>:<upstreamBranch>...<forkBranch>and mapsahead_by/behind_by;updateBranchRefalways sendsforce: false; 422 non-fast-forward ⇒unprocessable(ACC-02-09, ACC-02-10, ACC-02-19). Done when: the spec is green and no request in it carriesforce: true. -
T19.
createRepositoryCopy. Modifypackages/plugins/github/src/github-api.service.tsandpackages/plugins/github/src/github.plugin.ts— refusesizeKb > maxSizeKband root.gitattributeswithfilter=lfsbefore any git work; isolatedcloneBranch; up-to-date short-circuit;replaceRemote+pushwithremoteRef, neverforce; directory removed infinally. Test: extendpackages/plugins/github/src/__tests__/github-api.service.fork-sync.spec.tswith aGitOperationsdouble — refusals make no clone; push called once withoutforce; second run with equal heads makes no push; directory removed on error (ACC-02-15). Done when: the spec is green and the double records zeroforcepushes. -
T20.
setActionsPermissions. Modifypackages/plugins/github/src/github-actions.service.ts— paginatelistWorkflows(up tomaxWorkflows), addsetActionsPermissions(owner, repo, input, token, baseUrl)per plan §4.3; leaveenableDeploymentWorkflowsunchanged. Modifypackages/plugins/github/src/github.plugin.ts— expose it as the capability method. Test: createpackages/plugins/github/src/__tests__/github-actions.service.permissions.spec.ts— 150 workflows withmaxWorkflows: 100⇒truncated; allowlist kept; skip ids untouched;enableWorkflowsenables only listed paths; repo switch called only whenenabledis set; 403 ⇒permission_missingand stop (ACC-02-08, ACC-02-20). Done when: the spec is green andenableDeploymentWorkflows' existing specs pass unchanged. -
T21 (parallel with T20). Webhooks. Modify
packages/plugins/github/src/github-api.service.tsandpackages/plugins/github/src/github.plugin.ts—createWebhook(update by URL, else create) anddeleteWebhook(404 success). Test: createpackages/plugins/github/src/__tests__/github-api.service.webhooks.spec.ts— create vs update by URL; delete 404 is success; the secret is never included in a thrown error message. Done when: the spec is green. -
T22. Facade methods. Modify
packages/agent/src/facades/git.facade.ts— the eight methods of plan §4.2 with the materialise-then-call guard. Test: createpackages/agent/src/facades/__tests__/git.facade.app-forks.spec.ts— each method's absent path throwsGitOperationNotSupportedError; present path forwards arguments and token. Done when:apps/api/src/common/filters/facade-exception.filter.spec.tsstill maps the unsupported error to 409 with no edit. -
T23. Ready-handler port and
AppUpstreamStateService(Resolution R-21). Createpackages/agent/src/app-works/app-fork-ready-handler.port.ts(AppForkReadyOutcomeincl.setupPullRequestNumber,AppForkReadyHandler,APP_FORK_READY_HANDLER). Createpackages/agent/src/app-works/app-upstream-state.service.ts—get(workId, userId)(view check, 404 for other accounts and non-appWorks, response mapping incl. warnings and remaining limits),beginAttempt,probeReadiness,recordCopyPushed,markReady(emitsapp.fork.readyonce),timeout(emitsapp.fork.timeoutonce per attempt),fail,retryReadiness(workId, userId),requestSync(workId, userId),beginSync,finishSync(events per plan §6.3 step 8),recordConflict(plan §6.5 — the Agent from APW-08'sAPP_WORK_AGENT_RESOLVER(packages/agent/src/app-works/app-work-agent-resolver.ts, APW-08 T25):resolve({ userId: ownerUserId, workId })?.agentId, injected@Optional(); never chosen here). Modifypackages/agent/src/app-works/app-works.module.ts— provide + export. Test: createpackages/agent/src/app-works/__tests__/app-upstream-state.service.spec.ts— every error code of plan §4.1; events emitted exactly once across repeated calls; conflict Task Agent: resolver returns an id ⇒ TaskagentIdis that id; resolver returnsnull, throws or is unbound ⇒agentIdnulland exactly one owner notification; an open labelled Task is commented, not duplicated (ACC-02-11) and the comment goes throughTaskChatService.postwithauthorType: 'user',authorId= the Work's owner and a body that contains no@(asserted directly, because the chat service fans out one agent run per@<slug>mention and this path must start none), while the Task lookup uses exactly the five open statuses of plan §6.5 — a Task indoneorcancelledis not commented and a replacement Task is created instead;timeoutonce per attempt andretryReadinessrefused withretry_limit_reachedon the 4th call in an hour (ACC-02-05);fail('access_revoked')thenretryReadinessaccepted (ACC-02-06); readiness fromprobeReadinesson an empty then non-empty repository (ACC-02-04); another account's Work ⇒ 404 (ACC-02-21). Done when: the spec is green andgit grep -n "findByUserIdScoped" packages/agent/src/app-worksreturns nothing (no local Agent-picking rule survives). -
T24.
AppForkReadinessService. Createpackages/agent/src/app-works/app-fork-readiness.service.ts—run(payload, { sleep })per plan §6.2, injecting the state service (remote proxy in the worker),GitFacadeService,AppActionsHygieneService,APP_FORK_READY_HANDLER(@Optional()), and APW-04'sAPP_PROVISION_EVENTS_PORT(@Optional();forkReady(workId)aftermarkReady, failures logged only). Test: createpackages/agent/src/app-works/__tests__/app-fork-readiness.service.spec.tswith a fake clock and recordedsleepdurations —[2000, 4000, 8000, 15000, 15000, …]; empty ⇒ keep polling; ready within 30 s of the first commit ⇒ hygiene before handler, handler once (ACC-02-04); elapsed 900 000 ms ⇒ timeout (ACC-02-05);unauthorized⇒access_revoked(ACC-02-06); rate limited ⇒ sleeps toretryAt; private copy refusals and full history pushed once (ACC-02-15);copyPushedShaset ⇒ no second push; handlerwaiting_for_setup_prandfailedoutcomes recorded. Done when: the spec is green and no test path callsforkRepositoryorcreateRepository. -
T25.
AppActionsHygieneService. Createpackages/agent/src/app-works/app-actions-hygiene.service.tsper plan §6.7. Test: createpackages/agent/src/app-works/__tests__/app-actions-hygiene.service.spec.ts— link ⇒not_applicablewith no provider call; seen ids passed asskipWorkflowIds;needs_adminvspermission_missingnaming the permission, never blocking the caller;app.actions.disabledonly when something was disabled; the build workflow path always in the allowlist (ACC-02-08, ACC-02-20). Done when: the spec is green and no call in it setsenabled: false. -
T26.
AppUpstreamSyncServiceand schedule helper. Createpackages/agent/src/app-works/upstream-schedule.ts—computeNextUpstreamSync(schedule, from, workId)reusingcomputeNextHeartbeatfrompackages/agent/src/agents/heartbeat-cron.ts(default, hourly clamp, stable jitter). The helper reads all fourupstreamSyncfields (plan §6.4, schema.md §19):schedule,enabled(false⇒nextSyncAtstaysnulland the dispatcher never fires, while manual Sync now still works),branch(the branch compared and merged) andmode(mergeonly). All four are read from APW-03's effective spec, with the documented defaults when the block or the field is absent. Createpackages/agent/src/app-works/app-upstream-sync.service.ts—run(payload)per plan §6.3 withDistributedTaskLockService, aProviderCallBudgetwrapper,AppLicenseService(@Optional(), APW-03). The per-App-Work claim is taken API-side, not in the worker:rundoes not callDistributedTaskLockServiceitself — it callsAppUpstreamStateService.beginSync(workId), a remote-proxied atomic conditional update on the newsyncLeaseUntilcolumn that returns allowed/denied, andfinishSync(workId)releases it.DistributedTaskLockServiceneeds@InjectRepository(CacheEntry)and a callback cannot cross the SuperJSON remote proxy, and the worker imports no database module — so the lock cannot live there.AppLicenseServiceis reached the same way: through a remote proxy named in T28 (the API-sideAppUpstreamStateServicealso exposesrequestLicenseEvaluation(workId, reason)), so a missing binding can never silently skip FR-37.AppLicenseService.request(workId, reason)gains its reason union in CONTRACTS §2A as part of this task's PR. Createpackages/agent/src/app-works/app-upstream-conflict.copy.ts— the spec §6.3 templates. Test: createpackages/agent/src/app-works/__tests__/upstream-schedule.spec.tsandpackages/agent/src/app-works/__tests__/app-upstream-sync.service.spec.ts— lock not acquired; archived/unavailable/ missing/too-large pauses and their one-time events (ACC-02-15, ACC-02-17, ACC-02-18); rename followed (ACC-02-19); behind-only + license same ⇒ fast-forward with the commit count (ACC-02-09); license worse ⇒ PR with note (ACC-02-12); 409 race ⇒ PR; diverged ⇒ PR create then update, never merged, rewritten history never force-moved (ACC-02-10); closed PR not reopened at the same head;mergeable nullre-read 3 × 5 s; conflict ⇒recordConflict(ACC-02-11); private copy counts from merge base and10000+cap; budget stop at 20 calls; skip until reset + 60 s, secondary backoff doubling to 3 600 000 ms, persistent notice after 3 runs (ACC-02-16); hygiene and license request only when the tracked branch changed. Done when: both specs are green and the sync spec's provider double records no push to the upstream and noforcewrite.
P1.4 — API
-
T27. Upstream routes. Create
apps/api/src/app-works/app-works.module.ts(imports the agentAppWorksModule, binds nothing APW-01 owns — APW-01 T15 adds the handler binding here) andapps/api/src/app-works/app-upstream.controller.ts— the three routes, throttles and error codes of plan §4.1,@ApiOperationon each;GETfires the divergence dispatch per plan. Modifyapps/api/src/api.module.ts— importAppWorksModule. Test: createapps/api/src/app-works/app-upstream.controller.spec.ts— throttle metadata, 200/202 shapes, sync202without awaiting the job (ACC-02-14), 404 for another account and for a non-appWork (ACC-02-21), 409/422/429 codes incl. the 7th manual sync and the 4th retry (ACC-02-05, ACC-02-14), divergence dispatch at most once per 600 s withstalein the body (ACC-02-13). Done when:cd apps/api && pnpm testis green. -
T28. Dispatcher service and remote proxies. Create
packages/agent/src/app-works/app-upstream-sync-dispatcher.service.ts—dispatchDue()per plan §6.6. Modifyapps/api/src/trigger/trigger-internal.controller.ts— addAppUpstreamStateServiceandAppUpstreamSyncDispatcherServicetoremoteMap; Modifyapps/api/src/trigger/trigger-internal.module.ts— importAppWorksModule. Modifypackages/tasks/src/trigger/worker/modules/trigger-internal.module.ts—APP_UPSTREAM_STATE_SERVICEandAPP_UPSTREAM_SYNC_DISPATCHER_SERVICEviacreateRemoteProxy, exported. Test: createpackages/agent/src/app-works/__tests__/app-upstream-sync-dispatcher.service.spec.ts(cap 50, stamp-before-dispatch, rate-limited skip — ACC-02-16; stale readiness ≤ 3 re-dispatches within 10 minutes then timeout — ACC-02-07; daily re-check resumes an unavailable upstream — ACC-02-17); extendapps/api/src/trigger/trigger-internal.controller.spec.ts(both names inremoteMap). Done when: both specs are green.
P1.5 — Web
-
T29. Client plumbing. Modify
apps/web/src/lib/api/work.ts—workAPI.getUpstream,syncUpstream,retryUpstreamReadinesstyped withAppUpstreamStateResponse. Modifyapps/web/src/app/actions/dashboard/works.ts—syncUpstreamAction,retryUpstreamReadinessAction(auth from cookie,revalidatePathon the Work route, error codes passed through). Test: extendapps/web/src/app/actions/dashboard/works.unit.spec.ts— codes surface unchanged; unauthenticated redirects. Done when: the spec is green andpnpm --filter ever-works-web type-checkis clean. -
T30. Upstream card and the Upstream tab (Resolution R-8). Create
apps/web/src/components/works/app/AppUpstreamCard.tsx(variantstabandoverview),apps/web/src/components/works/app/UpstreamDivergenceBadge.tsxandapps/web/src/components/works/app/AppUpstreamWarnings.tsxper plan §5.2 and spec §6.1–6.2,data-testidsapp-upstream-card,app-upstream-relation,app-upstream-readiness,app-upstream-sync-now,app-upstream-badge,app-upstream-warning-<code>. Modifyapps/web/src/app/[locale]/(dashboard)/works/[id]/page.tsx— render theoverviewcard for forks and private copies when readiness isreadyorwaiting_for_setup_pr. Createapps/web/src/app/[locale]/(dashboard)/works/[id]/upstream/page.tsx— the one Upstream tab page: thetabcard (relation, readiness with Try again when timed out or failed, divergence, sync, workflows) and an empty slot below it for APW-09's section. Modifyapps/web/src/components/works/detail/WorkTabs.tsx(anUpstreamtab for kindappwith relation fork or private copy) andapps/web/src/lib/constants.ts(ROUTES.DASHBOARD_WORK_UPSTREAM). Modifyapps/web/src/components/activity-log/ActivityTypeBadge.tsx— colours +TYPE_TO_I18Nforapp_fork,app_actions,app_upstream. Test: createapps/web/src/components/works/app/UpstreamDivergenceBadge.unit.spec.tsx,apps/web/src/components/works/app/AppUpstreamCard.unit.spec.tsxandapps/web/src/components/works/app/AppUpstreamWarnings.unit.spec.tsx— five badge messages with plural forms and "Checked {ago}" (ACC-02-13); result lines incl. the license note (ACC-02-12); PR and Task links; workflows toggle; 409/429 copy; poll every 5 s while running, stops at 360 polls and on unmount; thetabvariant renders relation and every readiness state, and Try again callsretryUpstreamReadinessActiononce (ACC-02-23); the missing-fork and rename warnings (ACC-02-18, ACC-02-19). Extendapps/web/src/components/works/detail/WorkTabs.unit.spec.tsx— the tab present for fork and private copy, hidden forlinkand for every other kind (ACC-02-23). Done when: the card renders every warning from a fixture without a network call and exactly one Upstream tab entry exists inWorkTabs.tsx.
P1.6 — Background
-
T31. Dispatcher symbols and runtime bindings. Create
packages/agent/src/tasks/app-fork-readiness.types.ts,packages/agent/src/tasks/app-fork-readiness-dispatcher.ts,packages/agent/src/tasks/app-upstream-sync.types.ts,packages/agent/src/tasks/app-upstream-sync-dispatcher.ts(interface +Symbol()each, payloads of plan §6.1 incl. the readinessreason). Modifypackages/agent/src/tasks/index.ts(exports),packages/agent/src/tasks/_tasks-symbols.ts(two names, alphabetical),packages/agent/src/tasks/job-runtime.providers.ts(DISPATCHER_SYMBOLS+ 2 and its arity comment),packages/agent/src/tasks/__tests__/job-runtime.providers.spec.ts(14 → 16 one5f43f44d; recount at merge). Modifypackages/tasks/src/trigger/trigger.module.ts(provide + export both) andpackages/tasks/src/trigger/trigger.service.ts(dispatchAppForkReadiness,dispatchAppUpstreamSyncmirroringdispatchTemplateCustomization, with the idempotency keys of plan §6.1). Test:packages/agent/src/tasks/tasks.spec.tsandpackages/agent/src/tasks/__tests__/job-runtime.providers.spec.tspass without a magic-number edit beyond 14 → 16; extendpackages/tasks/src/__tests__/trigger.service.spec.ts—nullwhen unconfigured, idempotency key shape (incl.app-fork-readiness:<workId>:setup_merged). Done when: the three specs are green. -
T32.
app-fork-readinesstask. Createpackages/tasks/src/tasks/trigger/app-fork-readiness.task.ts—task({ id: 'app-fork-readiness', maxDuration: 1_200 }), thetemplate-customization.task.tspreamble,sleepviawait.for. Modifypackages/tasks/src/tasks/trigger/index.ts— export. Modifypackages/tasks/src/trigger/worker/modules/trigger-worker.module.ts— provideAppForkReadinessService,AppActionsHygieneService(state service from the remote proxy). Test: create (new directory)packages/tasks/src/tasks/trigger/__tests__/app-fork-readiness.task.spec.ts— drained credentials skip; payload forwarded;sleepwired. Done when:cd packages/tasks && npx vitest run src/tasks/trigger/__tests__/app-fork-readiness.task.spec.tsis green. -
T33.
app-upstream-synctask. Createpackages/tasks/src/tasks/trigger/app-upstream-sync.task.ts—maxDuration: 1_800. Modifypackages/tasks/src/trigger/worker/modules/trigger-worker.module.ts— export / provideAppUpstreamSyncService. It builds because it needs no database and no callback: the per-Work claim isAppUpstreamStateService.beginSync/finishSyncthrough the T28 remote proxy, and the license leg isrequestLicenseEvaluationon the same proxy. Do not import a database module and do not injectDistributedTaskLockServicehere. Test: create (new directory)packages/tasks/src/tasks/trigger/__tests__/app-upstream-sync.task.spec.ts— payload forwarded toAppUpstreamSyncService.run; drained credentials skip; the worker module spec asserts the module compiles with no database module, thatbeginSyncis reached through the remote proxy, and that a denied claim makes no provider call. Done when:cd packages/tasks && npx vitest run src/tasks/trigger/__tests__/app-upstream-sync.task.spec.tsis green. -
T34.
app-upstream-sync-dispatchercron. Createpackages/tasks/src/tasks/trigger/app-upstream-sync-dispatcher.task.ts—schedules.taskonprocess.env.APP_UPSTREAM_SYNC_DISPATCHER_CRON ?? '*/10 * * * *'with the five-field validation and fallback copied fromdata-repo-sync-dispatcher.task.ts(which reads its own override the same way,data-repo-sync-dispatcher.task.ts:48-53), callingAPP_UPSTREAM_SYNC_DISPATCHER_SERVICE.dispatchDue()and returning the counters. The variable name isAPP_UPSTREAM_SYNC_DISPATCHER_CRON, it is documented inapps/api/.env.examplebeside the otherAPP_*switches, and it is listed in CONTRACTS §7 as an operator override — a dispatcher cron is not a feature switch, so R-30's family switch (EVER_WORKS_APP_SYNC_ENABLED) stays the only on/off control. Modifypackages/tasks/src/tasks/trigger/index.ts— export. Modifyapps/api/.env.example—APP_UPSTREAM_SYNC_DISPATCHER_CRONwith its default. Test: create (new directory)packages/tasks/src/tasks/trigger/__tests__/app-upstream-sync-dispatcher.task.spec.ts— the default is used when the variable is unset, a valid five-field override is honoured, and an invalid override falls back to the default cron. Done when: a local run with a fixture due row dispatches one sync and stampsnextSyncAt.
P1.7 — i18n, tests, docs
-
T35. i18n keys. Modify
apps/web/messages/en.json—dashboard.workDetail.appUpstream,dashboard.workDetail.upstream.tabNameand the three activity labels from plan §8 with spec §6 copy verbatim. Modify the 20 sibling locale files inapps/web/messages/— mirror the keys. No leaf key contains.. Test: createapps/web/src/components/works/app/app-upstream-messages.unit.spec.ts(pattern ofapps/web/src/components/tasks/tasks-kanban-messages.unit.spec.ts) — every leaf of those trees exists in all 21 files inapps/web/messages/, and no leaf key contains.; runcd apps/web && npx vitest run src/components/works/app/app-upstream-messages.unit.spec.ts. Done when: the spec is green andpnpm --filter ever-works-web buildlogs no missing-message warning. -
T36. e2e. Create
apps/web/e2e/flow-app-work-upstream-card.spec.ts— mocked upstream states render each result and warning (ACC-02-17, ACC-02-18, ACC-02-19); Sync now shows Syncing… within 2 s and handlessync_in_progressandsync_limit_reached(ACC-02-14);/works/:id/upstreamshows relation, readiness (with Try again for a timed-out fixture) and inherited workflows (ACC-02-23). PrefergetByTestId. Test:cd apps/web && npx playwright test e2e/flow-app-work-upstream-card.spec.ts— ACC-02-14, ACC-02-17, ACC-02-18, ACC-02-19, ACC-02-23 in the PR lane. Done when: it passes and the existing Work detail specs pass unchanged. -
T37. Docs. Modify
docs/features/app-works.md(created by APW-01 T29; Create it here if APW-01 has not landed) — sections "Preparing and readiness" (incl. the setup pull request), "Inherited workflows", "Upstream sync", "Divergence", "The Upstream tab", "Permissions" (the matrix of plan §4.5), "What Ever Works never does" (push to upstream, force-push your branch, resolve conflicts for you). Modifyapps/docs/sidebarsPlatform.tsif the page is new. Test:pnpm --filter ever-works-docs build. Done when: the docs build has no broken links.
P1.8 — Program audit additions (2026-09-17)
-
T43. Setup pull request follow-through (FR-24a, Resolution R-4). Modify
packages/agent/src/app-works/app-upstream-state.service.ts—checkSetupPullRequest(workId)per plan §6.2:getPullRequestStatusforsetupPullRequestNumber, stampsetupCheckedAt; merged ⇒ dispatchapp-fork-readinesswithreason: 'setup_merged'; closed unmerged ⇒failed/setup_pull_request_closed;markReadystoressetupPullRequestNumberfrom the handler outcome. Modifypackages/agent/src/app-works/app-fork-readiness.service.ts—reason === 'setup_merged'skips copy, polling and hygiene and calls the handler once. Modifypackages/agent/src/app-works/app-upstream-sync-dispatcher.service.ts—claimSetupPullRequestChecks(now, 600_000, 50)per tick. Modifyapps/api/src/app-works/app-upstream.controller.ts—GETruns the check in the background when waiting andsetupCheckedAtis older than 60 000 ms. Test: extendpackages/agent/src/app-works/__tests__/app-upstream-state.service.spec.ts(open ⇒ unchanged state; merged ⇒ onesetup_mergeddispatch; closed ⇒failed/setup_pull_request_closed; the facade double records no merge, comment, reopen or update call),packages/agent/src/app-works/__tests__/app-fork-readiness.service.spec.ts(setup_merged⇒ zero probes, zero hygiene calls, handler exactly once, stateready),packages/agent/src/app-works/__tests__/app-upstream-sync-dispatcher.service.spec.ts(≤ 50 checks per tick) andapps/api/src/app-works/app-upstream.controller.spec.ts(on-view check at most once per 60 s) — ACC-02-22. Done when: the four specs are green. -
T44. Non-production readiness deadline (FR-18a). Modify
packages/agent/src/app-works/app-fork-readiness.service.ts—resolveReadinessTimeoutMs()per plan §6.2 step 0:EVER_WORKS_APP_FORK_READINESS_TIMEOUT_MShonoured only whenNODE_ENV !== 'production', clamped to 5 000–900 000; production always 900 000. Modifyapps/api/.env.example— document the variable, commented out, with "non-production only". Test: extendpackages/agent/src/app-works/__tests__/app-fork-readiness.service.spec.ts— override10000outside production ⇒ timeout at 10 000 ms; override1⇒ clamped to 5 000; any override withNODE_ENV=production⇒ 900 000; unset ⇒ 900 000 (ACC-02-05). This is what lets APW-01 T40 and APW-13's ACC-NEG-09 reach timed out in the PR lane. Done when: the spec is green andgit grep -n "EVER_WORKS_APP_FORK_READINESS_TIMEOUT_MS" packages/agent/srcshows exactly one read site.