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.
P1.9 — Ship gate
- T38. P1 ship gate.
Modify
docs/specs/features/app-works/APW-02-fork-lifecycle/tasks.md— tick P1; Modifydocs/specs/features/app-works/ACCEPTANCE.md— record which ACC-02 ids are automated here and which are live scenarios for APW-13. Test: rootpnpm format && pnpm lint && pnpm type-check && pnpm test && pnpm build; migrations run on a fresh database. Done when:developis green and every ACC-02-04…ACC-02-23 id names a green test.
Cross-phase closing tasks
-
T39. Telemetry. Create
packages/agent/src/app-works/app-upstream-telemetry.port.ts—AppUpstreamTelemetryEvent, the per-event property allow-list andAPP_UPSTREAM_TELEMETRY_SINKper plan §9.1:track(event, props, distinctId), fire-and-forget, never throwing, unbound ⇒ counted and dropped. No package import:packages/agent,packages/pluginandpackages/tasksdo not depend on a monitoring package, and this epic does not add one. Modifyapps/api/src/app-works/app-works.module.ts— bind the token to the existing PostHog-backed sink (theFunnelAnalyticsSinkbinding ofzero-friction-funnel.service.tsis the pattern). Modifypackages/agent/src/app-works/app-fork-readiness.service.ts,packages/agent/src/app-works/app-actions-hygiene.service.ts,packages/agent/src/app-works/app-upstream-sync.service.tsandpackages/agent/src/app-works/app-upstream-sync-dispatcher.service.ts— emit the first six events of plan §9.1 through the injected@Optional()sink, withdistinctId= the Work owner's id (the constant'system:app-works'when no owner resolves) andworkIdas a property.git_checkout.not_readyis emitted by the facade —packages/agent/src/facades/git.facade.tswhere it catchesRepositoryNotReadyError— and not frompackages/plugin/src/git/git-operations.ts, which has no dependency injection and keeps throwing the same typed error unchanged. Test: createpackages/agent/src/app-works/__tests__/app-upstream.telemetry.spec.ts— each event emitted once per outcome with a bound sink; nothing thrown with the sink unbound; a property outside the allow-list is dropped (assert the sink never sees it); no payload contains a repository name, owner, file path, commit message or token; the facade, not the plugin, emitsgit_checkout.not_ready. Done when: the spec is green andgit grep -n "monitoring" packages/agent/src/app-works packages/agent/src/facadesreturns nothing. -
T40. Contracts and tracker. Modify
docs/specs/features/app-works/TRACKER.md— APW-02 P0/P1 status and PR links. Modifydocs/specs/features/app-works/CONTRACTS.mdonly if a name this epic created is missing — every name in the plan header must appear with APW-02 as owner. Test:git grep -n "APW-02" docs/specs/features/app-works/CONTRACTS.mdlists the route/works/:id/upstream,EVER_WORKS_APP_FORK_READINESS_TIMEOUT_MS,setupPullRequestNumberand every other name in the plan header. Done when: that listing is complete and TRACKER.md links every merged APW-02 PR. -
T41. Update statuses. Modify
docs/specs/features/app-works/APW-02-fork-lifecycle/spec.md,docs/specs/features/app-works/APW-02-fork-lifecycle/plan.mdand this file —Implemented/Done. Test:npx prettier --check docs/specs/features/app-works/APW-02-fork-lifecycle/*.md, and each gate in plan §12 re-checked against the merged code. Done when: the three files carry the new status and prettier reports no changes needed. -
T42. Live contract probe (before the P1 merge). Create
packages/plugins/github/src/__tests__/contract/app-forks.contract.ts(not a.spec.ts, so the default Vitest run never picks it up) andpackages/plugins/github/vitest.contract.config.ts(includes onlysrc/__tests__/contract/**/*.contract.ts) — an opt-in probe, skipped unlessGITHUB_CONTRACT_PROBE_TOKENandGITHUB_CONTRACT_PROBE_ORGare set and never in CI by default, that, against a tiny throwaway repository created and deleted inside a dedicated test organization, pins: fork of an already-forked repository returns the existing fork; GraphQLforksaffiliation arguments; merge-upstream outcomes; compareahead_by/behind_bydirection;PATCH git/refsforce semantics; workflow disable on a fork; the 403 messages for a missing App permission; webhook create/update. Never point the probe at a third-party repository. Create the recorded fixtures underpackages/plugins/github/src/__tests__/fixtures/app-forks/. Provisioning (owner action, before P1 merges). The two variables above are test-only and belong to the private operations repository, never to a deployable environment:GITHUB_CONTRACT_PROBE_TOKENis a fine-grained PAT scoped to that one organization withcontents,pull requests,actionsandadministration, andGITHUB_CONTRACT_PROBE_ORGnames a dedicated throwaway organization — the same organization APW-13'sE2E_*suite uses as its<e2e-upstream-org>placeholder, so the estate provisions one test organization and one scoped token, not two. CONTRACTS §7 lists both as test-only variables and CONTRACTS §8 records the organization as the shared placeholder; until they are provisioned the probe stays skipped and ACC-02-03, ACC-02-08 and ACC-02-20 keep their unit-level proof (T5, T17, T52, T20) without claiming the live one. Test: with both variables exported in the shell,cd packages/plugins/github && npx vitest run --config vitest.contract.config.ts; the unit specs of T16–T21 then assert against the committed fixtures (ACC-02-03, ACC-02-08, ACC-02-20); with either variable unset the probe self-skips and the run is green. Done when: the probe's recorded responses are committed as fixtures and T16–T21's specs read them. -
T45 (P1, lands with T12–T13). Classify new tables for workspace backup (R-25). Modify
packages/agent/src/account-transfer/backup/collectors/domain-specs.ts— append to theworksdomain, afterworks.jsonl:{ file: 'upstream-states.jsonl', entity: 'WorkUpstreamState', scope: { by: 'parent', column: 'workId', from: 'workIds' } }.packages/agent/src/account-transfer/backup/redaction.tsis not modified: no column of plan §3.1 carries a credential or has a secret-shaped name, so nothing joinsBACKUP_DROPPED_ENTITIESor the redaction maps. Test: extendpackages/agent/src/account-transfer/backup/collectors/collectors.spec.ts—WorkUpstreamStateis referenced exactly once, in theworksdomain, scopedparentonworkIdfromworkIds, and is not inBACKUP_DROPPED_ENTITIES; its planned query carrieswithin: { column: 'workId', ids }with the registered Work ids, and yields nothing when no Work id was registered. Done when:pnpm --filter @ever-works/agent test -- collectors redactionis green with the entity registered, and a backup of a workspace holding one App Work listsdata/works/upstream-states.jsonlwith one record inmanifest.json.
P1.10 — Audit-pass additions (2026-09-17): automation, completion and closed sets
-
T46. The workflow-change hold on the sync path (FR-60, FR-61 — ACC-02-24). Create
packages/agent/src/app-works/upstream-workflow-diff.ts— a pureworkflowChanges(compareFiles): { paths: string[]; truncated: boolean }over the compare result, matching.github/workflows/**at any depth (case-insensitive) and capping the reported paths at 100. Modifypackages/agent/src/app-works/app-upstream-sync.service.ts— in plan §6.3, before the fast-forward decision and before the push toever-works/upstream-sync: whenworkflowChangesis non-empty, do not fast-forward and do not push; persistsyncState = 'held_for_workflow_review'with the changed paths, emit noapp.upstream.synced, and return. The manual path, the scheduled path and any retry all take the same branch — there is no flag that skips it. When the member confirms (T47's route), the sync runs with the confirmation recorded; the Activity entry carries paths and counts only, never file content. Modifyapps/api/src/app-works/app-upstream.controller.tsandapps/api/src/works/dto/app-upstream.dto.ts(created by T27) —POST /api/works/:id/upstream/syncaccepts an optionalconfirmWorkflowChanges: true, refused with409 workflow_confirmation_requiredwhen the hold is active and the flag is absent; the refusal body names the changed paths. Modifyapps/web/src/components/works/app/AppUpstreamCard.tsx(T30) — the hold row with Review the changes and Sync anyway, wired to the same action with the flag set. Test: createpackages/agent/src/app-works/__tests__/upstream-workflow-diff.spec.ts(nested paths, case, the 100-path cap) and extendpackages/agent/src/app-works/__tests__/app-upstream-sync.service.spec.ts— an incoming range that touches a workflow file performs zero fast-forward calls and zero pushes toever-works/upstream-sync, while a range that does not touch one behaves exactly as before; confirming then performs the push once; a provider double proving no workflow-creating push happened before confirmation (ACC-02-24). Extendapps/api/src/app-works/app-upstream.controller.spec.ts— the missing flag is409with the paths, the flag proceeds. Extendapps/web/src/components/works/app/AppUpstreamCard.unit.spec.tsx— the hold row renders and Sync anyway sends the flag (ACC-02-24). Done when: the four specs are green and no test path can push a workflow-carrying range without the confirmation. -
T47. Merged sync pull request finishes the sync (FR-62 — ACC-02-26). Modify
packages/agent/src/entities/work-upstream-state.entity.ts(T12), the migration (T13) andpackages/agent/src/database/repositories/work-upstream-state.repository.ts(T14) — one new nullablesyncPullRequestCheckedAt: Datecolumn andclaimSyncPullRequestChecks(nowMs, minIntervalMs, limit)mirroringclaimSetupPullRequestChecks. Createpackages/agent/src/app-works/app-upstream-sync-pr.service.ts—checkSyncPullRequest(workId):getPullRequestStatusforsyncPullRequestNumber, stampsyncPullRequestCheckedAt; merged ⇒ dispatchapp-upstream-syncwith{ trigger: 'merged', mergedSha }; closed unmerged ⇒ clear the pull-request fields and recordsyncState = 'pull_request_closed'without touching the tracked branch. Modifypackages/agent/src/app-works/app-upstream-sync.service.ts—trigger: 'merged'runs the finish path and not the compare-and-open path: setlastSyncedUpstreamShafrom the pull request's head andlastSyncCommitCountfrom the compare, clearsyncPullRequestNumber/Url/branch, runAppActionsHygieneService.applyfor the range only when the range changed a workflow file, andAppLicenseService.request(workId, 'upstream_merged'), each exactly once — guarded by a state compare so a re-dispatch is a no-op. Modifypackages/agent/src/app-works/app-upstream-sync-dispatcher.service.ts(T28) — callclaimSyncPullRequestChecks(now, 600_000, 50)per tick beside the setup-pull-request checks. Test: extendpackages/agent/src/app-works/__tests__/app-upstream-sync.service.spec.ts(merged ⇒ sha, count, cleared fields, hygiene and license once; a second dispatch changes nothing; closed-unmerged ⇒ fields cleared and no hygiene) andpackages/agent/src/app-works/__tests__/app-upstream-sync-dispatcher.service.spec.ts(≤ 50 checks per tick, the interval honoured), and extendapps/api/src/app-works/app-upstream.controller.spec.ts(GETfires the check in the background when a sync pull request is open and the stamp is older than 60 000 ms) — ACC-02-26. Done when: the three specs are green and the Upstream card no longer shows "Not synced yet" after a merged sync pull request. -
T48. Private-copy divergence and sync through a plugin capability (FR-63 — ACC-02-27). Modify
packages/plugin/src/contracts/capabilities/git-provider.app-forks.ts(T9) andpackages/plugin/src/contracts/capabilities/git-provider.interface.ts(T10) — add the optionalgetRepositoryCopyDivergence?: it takes the source and target coordinates plusmaxCommits, and returnsaheadBy,behindBy,upstreamHeadShaand acappedflag, with JSDoc stating the caller must materialise it first. Modifypackages/plugins/github/src/github-api.service.tsandpackages/plugins/github/src/github.plugin.ts— implement it withGitOperations(fetchinto an isolatedcloneBranchworking copy,findMergeBase, cappedlog); Create the facade wrapper inpackages/agent/src/facades/git.facade.ts(T22) that throwsGitOperationNotSupportedErrorwhen the plugin lacks it. Modifypackages/agent/src/app-works/app-upstream-sync.service.ts— the private-copy branch of plan §6.3 calls the facade method for the counts and reusescreateRepositoryCopy({ branchName: 'ever-works/upstream-sync' })for the push; no agent-package code invokes git directly (Constitution I). A provider without the capability ends insyncState = 'provider_unsupported'with its copy, not a crash. Test: extendpackages/plugins/github/src/__tests__/github-api.service.fork-sync.spec.tswith aGitOperationsdouble (ahead/behind direction, the10000+cap,capped: true) andpackages/agent/src/facades/__tests__/git.facade.app-forks.spec.ts(absent ⇒ throws); extendpackages/agent/src/app-works/__tests__/app-upstream-sync.service.spec.ts— a private copy syncs through the capability with the counts from it, and a provider without it pauses withprovider_unsupported(ACC-02-27). Done when:git grep -n "isomorphic-git" packages/agent/src/app-worksreturns nothing and the three specs are green. -
T49. Sync settings come from the App spec (FR-64 — ACC-02-28). Modify
packages/agent/src/app-works/upstream-schedule.ts(T26) — read every field from APW-03's effective spec throughAppSpecService.getEffectiveSpec(workId, branch);enabled: false⇒nextSyncAtstaysnulland the dispatcher never selects the row (manual Sync now still works), withlastSyncReason = 'disabled_by_spec'recorded;branchnames the branch compared and merged, and FR-43's rename rule applies only when the branch was defaulted; the tracked branch isspec.source.branch. Createapps/api/src/app-works/app-spec-applied.listener.ts— an@OnEvent('app.spec.applied')handler in the API process that recomputessyncScheduleandnextSyncAtwhenchangedBlocksincludesupstreamSyncorsource, and re-dispatches nothing else. Modifyapps/api/src/app-works/app-works.module.ts(T27) — register the listener; the agentAppWorksModuleexports what it needs. Test: extendpackages/agent/src/app-works/__tests__/upstream-schedule.spec.ts— the four field cases, includingenabled: false⇒nulland a configuredbranch; createapps/api/src/app-works/__tests__/app-spec-applied.listener.spec.ts— the schedule is recomputed for anupstreamSyncorsourcechange and left alone for any other block; extendpackages/agent/src/app-works/__tests__/app-upstream-sync.service.spec.ts— a configured sync branch is the branch compared and merged (ACC-02-28). Done when: the three specs are green andgit grep -n "EVER_WORKS_APP_UPSTREAM" packages/agent/srcfinds no environment fallback for these settings. -
T50. Closed sets for readiness failure reasons, sync results and warning codes (FR-65 — ACC-02-29). Modify
packages/contracts/src/apps/app-upstream.ts(T11) — addAPP_READINESS_FAILURE_REASONS,APP_SYNC_REASONSandAPP_UPSTREAM_WARNING_CODESasas consttuples with their union types, one member per state: readinessaccess_revoked·dispatch_unavailable·copy_refused·too_large·provider_unsupported·timed_out·setup_pull_request_closed·handler_failed·blueprint_apply_failed·data_repository_missing; sync resultsup_to_date·fast_forwarded·pull_request_opened·pull_request_updated·pull_request_merged·pull_request_closed·conflict·held_for_workflow_review·skipped_rate_limited·skipped_budget·license_worse·disabled_by_spec·paused·provider_unsupported·failed; warnings the ten spec §6.2 rows, camelCased.AppUpstreamStateResponsegainsreadinessHandlerReason?: { code: string; permission?: string }and everywarnings[]entry carriescode: AppUpstreamWarningCode. A provider failure is reported through the typed provider reason, never ashandler_failed:<code>. Modifypackages/agent/src/app-works/app-upstream-state.service.ts(T23) andapp-upstream-sync.service.ts(T26) — write only members of those unions, and map everyGitProviderErrorReasononto the matching member. Modifyapps/web/src/lib/work-kinds/app-upstream.ts(new) — one camelCase i18n leaf per member of each union, andAppUpstreamCard/AppUpstreamWarningsread through it, soapp-upstream-warning-<code>test ids and the leaf names derive from the same value. Test: extendpackages/contracts/src/apps/__tests__/app-upstream.spec.ts— the three tuples pinned (append-only snapshot) and every member maps to an i18n leaf; extendapps/web/src/components/works/app/app-upstream-messages.unit.spec.ts(T35) — one leaf per member exists in all 21 locales and no leaf contains.; extend the sync and state service specs — every failure path writes a member and none writes a composed string (ACC-02-29). Done when: the four specs are green and the union sizes are asserted, not described. -
T51. A gated fork gets exactly the build workflow enabled (FR-66 — ACC-02-30). Modify
packages/agent/src/app-works/app-actions-hygiene.service.ts(T25) — after the disabling pass, read the workflow list for the platform's own path (ever-works-build.yml,.github/workflows/) and itsstate; when the provider reports the fork's workflows as gated and that workflow is notactive, callsetActionsPermissionswithenableWorkflows: ['<the build workflow path>']— exactly that one path, never an inherited workflow, never a second one — and recordbuildWorkflowEnabled: trueon the state row.FR-25/FR-26are unchanged: every inherited workflow is still disabled and nothing else is enabled. Modifypackages/agent/src/entities/work-upstream-state.entity.ts(T12) and the migration (T13) — one nullable booleanbuildWorkflowEnabled. Modify the live contract probepackages/plugins/github/src/__tests__/contract/app-forks.contract.ts(T42) — create a small repository that ships workflows, fork it, push a new push-triggered workflow through the API, and record whether it runs and whether an explicit enable call was required; the recorded responses land as fixtures underpackages/plugins/github/src/__tests__/fixtures/app-forks/, and the finding is written into APW-05's spec (a proposed change, since APW-05 is another epic's file) as the answer to "does the first Build on a fresh fork start?". Test: extendpackages/agent/src/app-works/__tests__/app-actions-hygiene.service.spec.ts— a gated fork enables exactly one path, an ungated fork makes no enable call, and the manifest of theenableWorkflowsargument equals exactly one entry in every case (ACC-02-30); the probe spec is skipped unless its two environment variables are set, exactly as today. Done when: the hygiene spec is green with the one-path assertion and the probe's recorded result is committed as a fixture with its verdict written down. -
T52. Every fork request finds a renamed fork (FR-10, S2 — ACC-02-03). Modify
packages/plugins/github/src/github-api.service.ts—forkRepositoryresolves the target owner and the target name as it does today, then calls the fullfindExistingForkof plan §4.3 (T17: same-name identity check, GraphQL fork-network search filtered toowner.login == target, REST/repos/{o}/{r}/forksfallback for at most 3 pages) beforePOST /repos/{owner}/{repo}/forks, and returns what it finds withforkReadiness: 'ready'. Today's name-only check stays as step (1) of that lookup — it is the cheap common case, not a separate path — so the change is additive: a fork that answers to<target>/<name ?? repo>is found by the same first call as before, and a fork the member renamed (which 404s on that call) is now found by step (2)/(3) instead of falling through to a create request.waitForReady: false(FR-12), the 24 × 5 s poll (FR-13), the identity rule (FR-11:sourcefirst, thenparent, case-insensitive) andforkTemplateForUser's behaviour are all unchanged; when steps (2) and (3) find nothing, the request proceeds toPOST /forksexactly as it does today. Modifypackages/plugins/github/src/github.plugin.tsandpackages/agent/src/facades/git.facade.tsonly if T17's plugin/facade exposure offindExistingForkis not already in place — no signature change and no new option. Test: extendpackages/plugins/github/src/__tests__/github-api.service.fork.spec.ts(Octokit mocked) — a fork renamed tome/tasks-forkis returned with zerocreateForkcalls and zerorepos.getfor<target>/<upstream name>beyond the first 404, proved by the mock's call log; a same-named non-fork is still not returned and still forks (FR-11); a fork of another upstream is still not returned; with the GraphQL call failing the REST/forksfallback finds the renamed fork within 3 pages and makes no fourth page request; nothing found ⇒ exactly onecreateForkcall, unchanged;waitForReady: falsestill makes one POST and no poll (ACC-02-03). Done when: the spec is green, a renamed fork costs zero create requests, andpackages/agent/src/template-catalog/template-catalog.service.spec.ts(theforkTemplateForUsercaller) passes unchanged. -
T53. The Upstream sync appears in the Schedules view (FR-32 — ACC-E2E-05). ACC-E2E-05 asserts that "the Schedules view lists the Upstream sync with the spec's cron expression", and no task in any epic put it there: this epic runs sync from its own dispatcher (
app-upstream-sync-dispatcher, §6.6) and the existing Schedules surface aggregates seven sources — recurring Tasks, Agent heartbeats, Work schedules, Mission ticks, item source-validation, data-sync polling and inbound triggers (packages/agent/src/schedules/schedules.service.ts:163,schedule-view.types.ts:1-13). This adds an eighth source rather than a second schedules surface. Modifypackages/agent/src/schedules/schedule-view.types.ts— append'app_upstream_sync'toScheduleSourceTypeand'app_work'toScheduleOwnerType. Both are appends: every existing member, every switch over them and every existing row keeps its behaviour. Modifypackages/agent/src/schedules/schedules.service.ts— project the new source beside the seven existing ones, with its own try/catch like each of them: one read-only row per App Work whoseWorkUpstreamState.nextSyncAtis set —sourceType: 'app_upstream_sync',ownerType: 'app_work',ownerId= the Work id,id=app_upstream_sync:<workId>(the existing synthetic-key convention), the schedule string = the App spec'supstreamSync.schedule(or'0 6 * * 1'when the spec sets none — the same value §6.4 computesnextSyncAtfrom),nextRunAt=nextSyncAt,status=active, orpausedwhen the row is paused (upstreamStatusarchived/unavailable, ordataRepositoryStatusmissing). Modifypackages/agent/src/schedules/schedule-control.service.ts—SOURCE_TYPESgains the member, andrunNowfor it delegates to this epic's existingPOST /api/works/:id/upstream/sync(FR-33's 6-per-hour cap and its refusal apply unchanged). Pause, resume, edit, duplicate and reassign are not offered for the source, with their reason, because the schedule is spec-owned and the dispatcher is the only writer — the existing controls for the other seven sources are untouched. Modifyapps/web/src/lib/api/schedules.ts— mirror both unions;apps/web/src/components/schedules/SchedulesList.tsxandSchedulesFilters.tsx— the row renders the cron and the next-run time withdashboard.schedules.sourceTypes.appUpstreamSyncanddashboard.schedules.entityKinds.appWork(plan §8). Test: extend the schedules service spec — an App Work withnextSyncAtyields exactly one row carrying the spec's cron and next-run time; an App Work withnextSyncAtnull (a link, or a paused row) yields none or apausedrow respectively;runNowdispatches the upstream sync and nothing else; an unreadable upstream-state source degrades to an empty slice while the other seven sources still return their rows; extendapps/web/src/components/schedules/SchedulesWorkspace.unit.spec.tsxand the filters spec — the row shows its cron and next-run time, offers Sync now only, and the new chip does not change any existing count (ACC-E2E-05). Done when: the Schedules view lists the Upstream sync with the spec's cron expression for a ready App Work, and no other source's rows, counts or filters changed.
Definition of Done
- Every checkbox above is ticked.
pnpm format:check,pnpm lint,pnpm type-check,pnpm testandpnpm buildare green from the root.- P0 merged on its own with no migration, route, job or UI change, and every pre-existing clone and fork caller's specs unchanged.
- No code path in this epic pushes to an upstream, force-pushes a tracked branch, merges or edits a setup pull request, or resolves a merge conflict (asserted by the sync service and state service specs' provider doubles recording every write).
work_upstream_statesmigrates forward and back cleanly; the dispatcher arity is 16 (14 one5f43f44dplus two).- Exactly one Upstream tab and route exist; APW-09 adds a section to it, never a second tab.
- ACC-02-01 … ACC-02-30 are each covered by an automated test here or listed as a live scenario in ACCEPTANCE.md for APW-13.
- A workflow-carrying upstream range can never fast-forward and can never reach the sync branch without the member's confirmation (asserted by the sync spec's provider double recording every write).
- Every gate in plan §12 is confirmed, and its carried-forward gaps are still recorded there.