You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
⚠️ Definition of Done: this issue must be completed in full, in a single PR. Do not split this
work across multiple PRs, and do not defer any Deliverable below to a follow-up issue. A PR that
satisfies only some of the Deliverables, stubs a required test, or leaves a checkbox
partially-done does NOT resolve this issue and will be closed.
Context
packages/loopover-engine/src/idea-intake.ts exports the discriminated union added by #7635:
IdeaSubmission.targetRepo is typed as IdeaTarget (line 32), and validateIdeaSubmission returns { ok: true, idea: IdeaSubmission } whose targetRepo is a resolved IdeaTarget (line 130).
But the validator's own targetRepo branch (lines 103-108) only recognises two input shapes:
a bare "owner/name"string (the back-compat wire form), which it resolves to { kind: "existing", repo };
an object whose kind is exactly "provision".
Anything else falls through to else errors.push("target_repo_required"). So passing the canonical { kind: "existing", repo: "acme/widgets" } — the exact shape the function itself produces, and the
shape any TypeScript caller writing against the exported IdeaSubmission type will construct — is
rejected with target_repo_required.
The round-trip fails: validateIdeaSubmission(validateIdeaSubmission(raw).idea) returns { ok: false, errors: ["target_repo_required"] } for every valid existing-repo submission. The provision variant round-trips correctly, so this is an asymmetry between the two arms of one union,
not a deliberate wire-format restriction.
This is reachable from real callers: src/mcp/server.ts:3731 and src/mcp/server.ts:3747
(loopover_intake_idea / loopover_plan_idea_claims) and src/api/routes.ts both call validateIdeaSubmission and then thread validated.idea.targetRepo — an IdeaTarget — into buildClaimPlan, which already accepts both variants (line 285). Only the validator lags.
Requirements
validateIdeaSubmission MUST accept { kind: "existing", repo: <string> } as a targetRepo input
and resolve it to that same IdeaTarget, applying the identical repo format check the bare-string
branch applies (currently the regex on line 104) and pushing the identical target_repo_malformed
error code when the repo value fails it.
An { kind: "existing" } object whose repo is absent, not a string, or an empty/whitespace-only
string MUST push target_repo_malformed (not target_repo_required) — it is a recognised-but-invalid
target, matching how the bare-string branch reports a recognised-but-malformed slug.
An object whose kind is neither "existing" nor "provision" MUST keep pushing target_repo_required, unchanged.
The bare-string and { kind: "provision" } behaviours MUST be byte-for-byte unchanged.
No new exported symbol, no new error code beyond the two that already exist.
⚠️ Required pattern: mirror the existing targetRepo branch structure in validateIdeaSubmission (packages/loopover-engine/src/idea-intake.ts:103-108) — one added else if arm on the same if/else if/else chain, reusing the same regex constant and the same two
existing error codes. What does NOT satisfy this issue: introducing a separate exported normalizeIdeaTarget() helper as a second parallel validation surface; loosening the targetRepo
check to accept any object with a repo property; changing the exported IdeaTarget union; or
adding a third error code.
Deliverables
validateIdeaSubmission({ id, title, body, targetRepo: { kind: "existing", repo: "acme/widgets" } })
in packages/loopover-engine/src/idea-intake.ts returns { ok: true } with idea.targetRepo deep-equal to { kind: "existing", repo: "acme/widgets" }, asserted by a new
named case in test/unit/idea-intake-bridge.test.ts.
A named regression test asserting the round-trip: feed a validated result's own idea object
back into validateIdeaSubmission and assert ok === true and that targetRepo is unchanged.
{ kind: "existing", repo: "not-a-slug" } returns { ok: false, errors: ["target_repo_malformed"] },
asserted by a new test case.
{ kind: "existing" } (no repo) and { kind: "existing", repo: "" } each return { ok: false, errors: ["target_repo_malformed"] }, asserted by new test cases.
{ kind: "bogus" } still returns { ok: false, errors: ["target_repo_required"] }, asserted by
a new test case.
Existing bare-string and { kind: "provision" } cases still pass unmodified.
All Deliverables above are required in a single PR. A PR that satisfies only some of them — for
example adding the { kind: "existing" } accept arm without the two invalid-repo cases, or without
the round-trip regression test — does not resolve this issue.
Test Coverage Requirements
packages/loopover-engine/src/**/*.tsis inside coverage.include in vitest.config.ts (second
entry, right after src/**/*.ts) and carries its own engine Codecov flag. The 99%+ branch-counted codecov/patch gate applies here exactly as it does to src/**. Every changed conditional needs both
arms exercised: the new kind === "existing" arm must be tested with a valid repo, a malformed repo, a missing repo, and a non-string repo; the fall-through else must still be tested with an
unrecognised kind. The round-trip test above is the required named regression test for this fix.
Expected Outcome
IdeaSubmission values built against the exported IdeaTarget type validate successfully instead of
failing with target_repo_required, and validateIdeaSubmission is idempotent over its own output for
both union variants rather than only the provision one.
Context
packages/loopover-engine/src/idea-intake.tsexports the discriminated union added by #7635:IdeaSubmission.targetRepois typed asIdeaTarget(line 32), andvalidateIdeaSubmissionreturns{ ok: true, idea: IdeaSubmission }whosetargetRepois a resolvedIdeaTarget(line 130).But the validator's own
targetRepobranch (lines 103-108) only recognises two input shapes:"owner/name"string (the back-compat wire form), which it resolves to{ kind: "existing", repo };kindis exactly"provision".Anything else falls through to
else errors.push("target_repo_required"). So passing the canonical{ kind: "existing", repo: "acme/widgets" }— the exact shape the function itself produces, and theshape any TypeScript caller writing against the exported
IdeaSubmissiontype will construct — isrejected with
target_repo_required.The round-trip fails:
validateIdeaSubmission(validateIdeaSubmission(raw).idea)returns{ ok: false, errors: ["target_repo_required"] }for every valid existing-repo submission. Theprovisionvariant round-trips correctly, so this is an asymmetry between the two arms of one union,not a deliberate wire-format restriction.
This is reachable from real callers:
src/mcp/server.ts:3731andsrc/mcp/server.ts:3747(
loopover_intake_idea/loopover_plan_idea_claims) andsrc/api/routes.tsboth callvalidateIdeaSubmissionand then threadvalidated.idea.targetRepo— anIdeaTarget— intobuildClaimPlan, which already accepts both variants (line 285). Only the validator lags.Requirements
validateIdeaSubmissionMUST accept{ kind: "existing", repo: <string> }as atargetRepoinputand resolve it to that same
IdeaTarget, applying the identicalrepoformat check the bare-stringbranch applies (currently the regex on line 104) and pushing the identical
target_repo_malformederror code when the
repovalue fails it.{ kind: "existing" }object whoserepois absent, not a string, or an empty/whitespace-onlystring MUST push
target_repo_malformed(nottarget_repo_required) — it is a recognised-but-invalidtarget, matching how the bare-string branch reports a recognised-but-malformed slug.
kindis neither"existing"nor"provision"MUST keep pushingtarget_repo_required, unchanged.{ kind: "provision" }behaviours MUST be byte-for-byte unchanged.Deliverables
validateIdeaSubmission({ id, title, body, targetRepo: { kind: "existing", repo: "acme/widgets" } })in
packages/loopover-engine/src/idea-intake.tsreturns{ ok: true }withidea.targetRepodeep-equal to{ kind: "existing", repo: "acme/widgets" }, asserted by a newnamed case in
test/unit/idea-intake-bridge.test.ts.ideaobjectback into
validateIdeaSubmissionand assertok === trueand thattargetRepois unchanged.{ kind: "existing", repo: "not-a-slug" }returns{ ok: false, errors: ["target_repo_malformed"] },asserted by a new test case.
{ kind: "existing" }(norepo) and{ kind: "existing", repo: "" }each return{ ok: false, errors: ["target_repo_malformed"] }, asserted by new test cases.{ kind: "bogus" }still returns{ ok: false, errors: ["target_repo_required"] }, asserted bya new test case.
{ kind: "provision" }cases still pass unmodified.All Deliverables above are required in a single PR. A PR that satisfies only some of them — for
example adding the
{ kind: "existing" }accept arm without the two invalid-repocases, or withoutthe round-trip regression test — does not resolve this issue.
Test Coverage Requirements
packages/loopover-engine/src/**/*.tsis insidecoverage.includeinvitest.config.ts(secondentry, right after
src/**/*.ts) and carries its ownengineCodecov flag. The 99%+ branch-countedcodecov/patchgate applies here exactly as it does tosrc/**. Every changed conditional needs botharms exercised: the new
kind === "existing"arm must be tested with a validrepo, a malformedrepo, a missingrepo, and a non-stringrepo; the fall-throughelsemust still be tested with anunrecognised
kind. The round-trip test above is the required named regression test for this fix.Expected Outcome
IdeaSubmissionvalues built against the exportedIdeaTargettype validate successfully instead offailing with
target_repo_required, andvalidateIdeaSubmissionis idempotent over its own output forboth union variants rather than only the
provisionone.Links & Resources
packages/loopover-engine/src/idea-intake.ts(lines 22-36, 90-136, 281-297)src/mcp/server.ts:3731,src/mcp/server.ts:3747(the two MCP tool call sites)src/api/routes.ts(POST /v1/loop/intake-idea,POST /v1/loop/plan-idea-claims)IdeaTargetunion this validator only half-implements.