Repository Work kind — a code repository as a first-class Work (self-build slice D, EW-766)
Branch feat/repo-work-kind, based on origin/develop.
Adds repo — chip label Repository — to the Work kind vocabulary so a
plain code repository (ever-works/ever-works, directory-web-template, the
website repo, …) can be a Work that Tasks, Goals and fleet runs attach to.
The data repository of a Repository Work IS the code repository; nothing is
generated, provisioned or deployed for it — and, after the review pass,
nothing is ever deleted, rewritten or committed into it by the platform's
content pipelines either.
What shipped
Contracts (@ever-works/contracts)
USER_SELECTABLE_WORK_KINDSgains'repo'(and thereforeWORK_KINDS,normalizeWorkKind,isUserSelectableWorkKind).WORK_KIND_CAPABILITIES.repo: items off (labelKey: 'items'), taxonomy / comparisons / community PR / import-export / source validation off,deploy: false,kb: true, metrics['agents', 'open-tasks', 'days-active'](all pre-existingWorkMetricIds),repos: { data: true, work: false, website: false }.isRepositoryWorkKind()— a KIND test, deliberately not a capability test (the content pipelines are not capability-gated for any other kind).- Tests: the "every user-selectable kind is deployable" invariant became
"every user-selectable kind that provisions a website repository is
deployable" plus two explicit pins —
repois the only selectable kind without a website repo, and no kind deploys without one. Newrepodescribe block covers normalize / selectable / capabilities / metrics.
Agent (@ever-works/agent)
CreateWorkDto.repositoryUrl?: string(optional, ≤ 400 chars, trimmed).kindkeeps its@IsIn([...USER_SELECTABLE_WORK_KINDS, 'default'])whitelist, sorepopasses DTO validation on bothPOST /api/worksandPOST /api/works/quick-create— but quick-create builds itsCreateWorkDtowithoutrepositoryUrl(QuickCreateWorkDtohas none), socreateWorkrejects it with 400 before any row exists (see below).works/repository-work-source.ts—parseRepositoryWorkSource(url): pure, dependency-free parser for GitHubowner/repoURLs (.git, trailing slash,www.,http, scheme-less accepted; credentials, query, fragment, deeper paths, ssh remotes and every other host — includinggitlab.comandbitbucket.org, see follow-ups — rejected). Repository names may start with a dot (.github,.dotfiles); owners may not. Owner/repo case is preserved —getDataRepo()is used verbatim in clone URLs.works/repository-work-guard.ts— the ONE place the "never touch a Repository Work's repositories" rule lives:isRepositoryWork,assertNotRepositoryWork(work, action)(400),hasRepositoryRole/assertRepositoryRole(work, role)(400, derived fromWORK_KIND_CAPABILITIES.repos). Every data-repository writer calls it instead of testingwork.kindinline.WorkLifecycleService.createWork,kind === 'repo'branch — all of it before any side effect:- parses the URL; missing / unparseable → 400;
- refuses (400) a URL hosted on a different provider than the DTO's
gitProviderinstead of silently overriding the choice; - verifies the caller can read the repository through their OWN
connected account (
gitFacade.hasRepositoryAccess, the same probe the import analyser runs); not found / not accessible / no connection → 400; - refuses (409) a repository another account already wraps — the git
facade's on-disk checkout is keyed by
owner/repo, so two tenants pointing at one third-party repo would share and clobber one working copy with two tokens; collaborators join the existing Work as members (WorkRepository.findRepositoryWorksWrapping); - skips website-template validation (
websiteTemplateId: null), the Ever Works Deploy quota (deployProvider: null, same posture ascreateCompanyWork), the managed Ever Works Git repo provisioning (storageProvider === 'ever-works-git'is overridden by the URL's provider), and the post-createdataGenerator.getItemsclone; - persists
owner,gitProvider/storageProviderfrom the URL,sourceRepository = { url, owner, repo, type: 'link_existing', relatedRepositories: { data: { owner, repo } } },generateStatus = { status: 'generated', step: 'linked' }andsyncIntervalMinutes = 0(otherwise the EW-628 poller would hit the wrapped repo's API with the owner's token every five minutes, forever).
WorkLifecycleService.deleteWork(the P1): for a Repository Work the delete never reaches the git provider — thedatarole IS the wrapped repo, and thework/websitefallbacks (<slug>,<slug>-website) resolve to live names under the third-party owner (with the default slug,<slug>is the wrapped repo again). An explicitdelete_data_repository: trueis refused with 400; thework/websiteroles are skipped for every kind that never provisions them (so a Company Work no longer attempts<slug>-websiteeither); the local-checkout cleanup is skipped for the kind because that checkout, if it exists, is another Work's.- Refusals (400 via the shared guard) on every other data-repository writer
reachable for the kind:
WorkGenerationService.generateItems/updateItemsGenerator/regenerateMarkdown/updateReadme/submitItem/removeItem/updateItemMetadata/extractItemDetails(when scoped to a Work) /bulkCaptureImages/updateDomainType/updateWebsiteRepository;WorkLifecycleService.syncFromDataRepositoryandupdateWork(adeployProviderfor the kind);WorkScheduleService.updateSchedule(thePUT /works/:id/schedule/ MCPupdate_scheduleupsert — enable and pause alike, since both commit.works/works.ymlinto the wrapped repo and a schedule would only tick into the generator's 400);WebsiteUpdateService.updateRepository(every caller funnels through it, incl.DeployServiceand the template auto-update poller);CommunityPrProcessorService.processWork(the schedule skips the kind quietly);ItemHealthService.checkItem;ComparisonGenerationService.generateNextComparison/generateManualComparison;RepositoryManagementService.updateRepositoryVisibility(any role).RepositoryManagementService.getRepositoriesStatuslists only the roles the kind provisions, andWorkQueryService.workItemsanswers[]without cloning. .works/works.ymlschema:repospec (source.{repo,branch},tasks.{base_branch,checks},branding) added toKIND_SPEC_SCHEMAS;tasks.checksis bounded (≤ 20 entries, ≤ 500 chars each) and documented as a trust boundary — anyone who can land a commit in the wrapped repo authors those strings;works.v2.schema.jsonregenerated.
API (apps/api)
DeployService.deployrefuses a Repository Work with 400 before the deploy facade resolves a provider (oneworkRepository.findById): a repo Work persistsdeployProvider: null, so the facade would otherwise throwNoDeployProviderErrorfirst and the kind-specific message would never be seen. Covers direct calls anddeployBatch. Two spec cases (refusal with the facade mocked to throw the way production does;awesome-repostays on the existing path).- The controller needs no change: it delegates to the agent DTO and
WorkLifecycleService.
Web (apps/web)
- Kind vocabulary everywhere it is enumerated:
work-kinds/catalog.ts(GitBranchicon, teal tone),NewPageClient(chip, placeholder examples, intent label, canvas route,CHIP_TO_WORK_KIND),/newpageVALID_CHIP_TYPES,new-work-client(InitialWorkKind, order, icon, placeholders, intent),/works/newpageVALID_WORK_KINDS,WorksCreateComposer,WorkAICreator(type only + template-picker gate),work.tools.ts(workKindSchema,createWorkManual.repositoryUrl), server actionworks.ts(aiWorkKindSchema,createWorkSchema.repositoryUrl, prompt label),lib/api/work.ts/works.tsDTOs.WorkTemplatePicker.KIND_TO_CHIP_TYPEdeliberately has norepoentry (no website-template facet; documented inline). - New
components/works/RepositoryWorkForm.tsx: URL + derived name / slug / description, submits through the manualcreateWorkserver action withkind: 'repo'+repositoryUrl. Rendered bynew-work-clientwhenever the effective kind isrepo— in place of bothWorkAICreatorandWorkImportForm, whatever the creation mode. Its client-side URL pattern mirrors the API (GitHub only; dot-leading repo names allowed). - The
createWorkserver action requires a connected personal git provider forkind: 'repo'even under managed storage — the API verifies access with the caller's own account, so managed storage is no shortcut. - Repository chip routing:
/newand the Works composer send the composer text (a repo URL, typically) to/works/new?mode=manual&kind=repo&prompt=…instead of opening a chat turn — there is nothing for the chat AI to generate.new-work-clientdoes the same in-page. One unit test per site. - Work detail: the Generator tab (
WorkTabs), the generator page and the schedule page are not offered for the kind (the API refuses every generator action and the schedule upsert); Settings hides the repository-visibility and community-PR cards; the delete dialog offers only the repository checkboxes the kind provisions (none for a Repository Work). Items and the provider-repository settings were already capability-gated; the Deploy tab is hidden becauseconfigCacheis null for the kind.WorkTabs,SettingsFormandDeleteComponenteach carry a unit spec pinning the gate forrepoagainstdefault. - i18n:
dashboard.newPage.chips.repo,newPage.chipDescriptions.repo,workCreation.kinds.repo,workCreation.kindDescriptions.repo,workKind.repoand theworkCreation.repo.*form block added to all 21 locale files (localized, same posture ascampaign);urlHelpnames GitHub only. No literal dots in any leaf key. - e2e:
flow-work-kind-template-activation-deep.spec.tskeepsrepoOUT of the verbatim round-trip loop (registration is verified against the caller's own GitHub connection, which API-registered e2e users lack) and pins the create contract with three dedicatedrepotests: missingrepositoryUrl→ 400 naming the field; non-GitHub / unparseable URL → 400; valid URL with no connected account → 400. The happy path is unit-covered with a mocked git facade.
Docs
docs/features/creating-a-work.md: Repository row in the creation methods table + a "Repository Works" section (GitHub only).docs/agent-services/works-yml-schema.md:repoin the kind list + spec example + thetasks.checkstrust-boundary note.
Where the brief and the code disagreed
- "Import or manual create can register an existing repo without the
analyzer?" Neither could as-is.
WorkImportService.initiateImportalways parses throughSourceRepoAnalyzerService, and even itslink_existingpath persists only the top-levelowner/repo— never thedatarole — soWork.getDataRepo()would have fallen back to the derived<slug>-datarepo (the same field-by-field fallback EW-028 fixed for managed storage). The manual path had no repository input at all. The smallest additive change was arepositoryUrlfield onCreateWorkDtoplus arepo-only branch increateWorkthat writes thedatarole explicitly. - "Make the create path reject nothing." A
repoWork without a repository is a contradiction, and the alternative — silently pointing the fleet at a<slug>-datarepo nobody created — is the failure mode the codebase already documents as a bug. The branch therefore rejects a missing, unparseable, unreachable, provider-mismatched or already-wrappedrepositoryUrlforkind: 'repo'only; every other kind is untouched and ignores the field. This is the one place the brief's fallback wording was not followed, deliberately. - "The resolver must not pick a website template." The agent-side
WebsiteTemplateResolverService.resolveForWorkalways returns a template (its return type is non-nullable, and four services depend on that). Rather than change it, the create path never gives it awebsiteTemplateIdto resolve and the capability registry keeps the website generator away fromrepoWorks; the web-side picker is gated. repovsawesome-repo. The existing kind already contains the word "repo"; the new one is the plainrepothe brief asked for. Tests pin that the two never collide innormalizeWorkKind, capabilities, the guard, or the delete path.- Program spec note.
Workspace/knowledge/notes/2026-09-02-self-build-fleet-program.mddoes not exist in the Workspace checkout on this machine; the slice was implemented from the brief alone. - "Nothing is generated for it" was not the same as "nothing can write
into it." The first cut gated four
WorkGenerationServiceentry points and left item submission, comparisons, source validation, community-PR intake, the website-template sync, repository-visibility flips and — worst —deleteWorkopen for the kind. The review pass closed all of them behind the one shared guard (repository-work-guard.ts) so a new pipeline cannot forget the check.
Verification
Run from the worktree root unless noted. Counts are from the final run on this branch.
| Check | Result |
|---|---|
cd packages/agent && npx jest src/works/__tests__ src/services/__tests__/work-lifecycle src/services/__tests__/work-generation.service.spec.ts src/services/__tests__/repository-management.service.spec.ts src/community-pr src/generators/website-generator/website-update.service.spec.ts src/services/__tests__/work-query.service.spec.ts src/services/__tests__/item-health.service.spec.ts | 15 suites, 495 tests passed |
cd packages/agent && npx jest src/works-config (incl. the works.v2.schema.json drift guard) | 13 suites, 177 tests passed |
cd apps/api && npx jest deploy.service | 2 suites, 78 tests passed |
cd apps/web && npx vitest run src/components/works/RepositoryWorkForm.unit.spec.tsx src/components/new/NewPageClient.unit.spec.tsx src/components/works/WorksCreateComposer.unit.spec.tsx "src/app/[locale]/(dashboard)/works/new/new-work-client.unit.spec.tsx" src/app/actions/dashboard/works.unit.spec.ts src/components/works/shared/WorkKindBadge.unit.spec.tsx src/lib/ai/tools/tool-selection.unit.spec.ts src/lib/ai/tools/tool-selection.realistic.unit.spec.ts src/lib/ai/tools/generated/registry-parity.unit.spec.ts src/components/works/detail/overview/resolve-work-metrics.unit.spec.ts | 10 files, 154 tests passed |
cd packages/agent && npx tsc --noEmit -p tsconfig.json | clean (exit 0) |
cd apps/api && npx tsc -p tsconfig.build.json --noEmit | clean (exit 0) |
cd apps/web && npx tsc --noEmit | clean (exit 0) |
npx prettier --check on every changed file (locale JSON and docs included) | clean |
Not run here: Playwright e2e (needs a live API + registered user). The
repo tests in flow-work-kind-template-activation-deep.spec.ts are all
refusal cases that need no external credentials.
Follow-ups
- A Repository Work's Overview could show the repository coordinates and default branch prominently; today they are only visible through the generic repository info block.
.works/works.ymlfor a Repository Work is written into the user's code repository byWorksConfigWriterlike any other kind (on adeployProviderchange, on a schedule upsert and on the generation paths — all of which the kind now refuses). That is the intended place forspec.tasks.base_branch/checks, but nothing reads those two keys yet —TaskWorkspaceServicestill takes the base branch fromwork.taskIsolationBaseBranch/ the repo default. Whatever consumeschecksmust treat it as untrusted (see the schema comment).- GitLab / Bitbucket: refused by the parser (and the web form) until a
git-provider plugin for them exists; only
githubships today. When one lands, add itsHOST_RULESentry and resolveowneras every path segment but the last (GitLab nested groups). - One Repository Work per repository per account: a second account is
refused with 409 because the local checkout is keyed by
owner/repo. A per-Work checkout key inGitOperationswould lift that; until then, collaborators join the Work as members. - KB mirror (
kb: true) still commits.content/kb/**into the wrapped repository's default branch — by design for the kind, but worth a PR-based mode for protected branches. - PostHog gate: the chip is fail-open like every other kind; create a
works-repoflag (active: false) to hide it before launch if wanted.