From 1cc295d8def90b17c92bb095717f6af89f3804db Mon Sep 17 00:00:00 2001 From: Younes Abouelnagah Date: Fri, 1 May 2026 12:17:48 -0400 Subject: [PATCH 1/3] feat(forge): add Linear provider with hybrid fallback to GitHub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linear handles issue concepts (view, list, comment, recently-closed, auth-status, user-identity) while PR concepts fall through to GitHub. Key changes: - Fix buildPresetFromScripts to skip missing scripts instead of setting null, enabling hybrid providers that only implement a subset of concepts - Register linear provider in getProviderPresets - Pass forge config keys as CODEV_ env vars (e.g. linear-team → CODEV_LINEAR_TEAM) - Widen issueNumber types to accept alphanumeric identifiers (ENG-123) - Add 6 POSIX sh scripts for Linear GraphQL API Co-Authored-By: Claude Opus 4.6 --- codev/plans/695-linear-forge-provider.md | 84 +++++++++++++++++++ codev/specs/695-linear-forge-provider.md | 55 ++++++++++++ .../codev/scripts/forge/linear/auth-status.sh | 15 ++++ .../scripts/forge/linear/issue-comment.sh | 38 +++++++++ .../codev/scripts/forge/linear/issue-list.sh | 35 ++++++++ .../codev/scripts/forge/linear/issue-view.sh | 33 ++++++++ .../scripts/forge/linear/recently-closed.sh | 45 ++++++++++ .../scripts/forge/linear/user-identity.sh | 15 ++++ packages/codev/src/agent-farm/cli.ts | 17 ++-- .../src/agent-farm/commands/spawn-roles.ts | 2 +- .../src/agent-farm/commands/spawn-worktree.ts | 8 +- .../codev/src/agent-farm/commands/spawn.ts | 2 +- packages/codev/src/agent-farm/types.ts | 6 +- packages/codev/src/lib/forge-contracts.ts | 2 +- packages/codev/src/lib/forge.ts | 31 +++++-- 15 files changed, 368 insertions(+), 20 deletions(-) create mode 100644 codev/plans/695-linear-forge-provider.md create mode 100644 codev/specs/695-linear-forge-provider.md create mode 100755 packages/codev/scripts/forge/linear/auth-status.sh create mode 100755 packages/codev/scripts/forge/linear/issue-comment.sh create mode 100755 packages/codev/scripts/forge/linear/issue-list.sh create mode 100755 packages/codev/scripts/forge/linear/issue-view.sh create mode 100755 packages/codev/scripts/forge/linear/recently-closed.sh create mode 100755 packages/codev/scripts/forge/linear/user-identity.sh diff --git a/codev/plans/695-linear-forge-provider.md b/codev/plans/695-linear-forge-provider.md new file mode 100644 index 00000000..de66c69a --- /dev/null +++ b/codev/plans/695-linear-forge-provider.md @@ -0,0 +1,84 @@ +# Plan: Linear Forge Provider + +## Metadata +- **Spec**: 695-linear-forge-provider +- **Protocol**: ASPIR +- **Created**: 2026-05-01 + +## Phase 1: Fix fallback bug in buildPresetFromScripts + +**Goal**: Concepts without scripts should be omitted from the preset (not set to null), so they fall through to GitHub defaults via the resolution logic in `getForgeCommand`. + +**Files**: +- `packages/codev/src/lib/forge.ts:99-114` + +**Change**: In `buildPresetFromScripts`, remove the `else { preset[concept] = null; }` branch. Only include concepts that have a script file on disk or are explicitly disabled. + +**Done when**: A provider with only issue scripts does NOT disable PR concepts. PR concepts resolve to the GitHub default. + +## Phase 2: Register linear provider and pass config env vars + +**Goal**: Register `linear` in `getProviderPresets()` and enhance `executeForgeCommand` to pass non-concept forge config keys as environment variables. + +**Files**: +- `packages/codev/src/lib/forge.ts:127-131` — add `linear` to `_providerPresets` +- `packages/codev/src/lib/forge.ts:305-330` — enhance `executeForgeCommand` to export forge config keys as `CODEV_` env vars + +**Changes**: +1. Add `linear: buildPresetFromScripts('linear', ['team-activity', 'on-it-timestamps'])` to `_providerPresets` +2. In `executeForgeCommand`, after resolving `forgeConfig`, extract non-concept keys (keys not in `KNOWN_CONCEPTS` and not `provider`) and export them as uppercased `CODEV_` prefixed env vars. E.g., `forge.linear-team: "ENG"` → `CODEV_LINEAR_TEAM=ENG`. + +**Done when**: `getKnownProviders()` includes "linear". Scripts receive `CODEV_LINEAR_TEAM` from forge config. + +## Phase 3: Widen issue identifier types + +**Goal**: Accept alphanumeric identifiers (e.g., "ENG-123") throughout the agent-farm CLI and type system. + +**Files**: +- `packages/codev/src/lib/forge-contracts.ts:33` — `number: number` → `number: number | string` +- `packages/codev/src/agent-farm/types.ts:17` — `issueNumber?: number` → `issueNumber?: number | string` +- `packages/codev/src/agent-farm/types.ts:67` — `issueNumber?: number` → `issueNumber?: number | string` +- `packages/codev/src/agent-farm/cli.ts:195` — change argument description from "Issue number" to "Issue identifier" +- `packages/codev/src/agent-farm/cli.ts:230-231` — accept alphanumeric: if `parseInt` fails but matches `/^[A-Z]+-\d+$/i`, keep as string + +**Done when**: `afx spawn ENG-123 --protocol spir` parses without error. TypeScript compiles without type errors. + +## Phase 4: Create Linear forge scripts + +**Goal**: Implement 6 POSIX sh scripts in `packages/codev/scripts/forge/linear/`. + +**Scripts**: + +| Script | Concept | Env Vars | Output | +|--------|---------|----------|--------| +| `auth-status.sh` | auth-status | LINEAR_API_KEY | exit code 0 = authenticated | +| `user-identity.sh` | user-identity | LINEAR_API_KEY | plain text display name | +| `issue-view.sh` | issue-view | LINEAR_API_KEY, CODEV_ISSUE_ID | JSON: {title, body, state, comments[]} | +| `issue-list.sh` | issue-list | LINEAR_API_KEY, CODEV_LINEAR_TEAM | JSON: [{number, title, url, labels, createdAt, author, assignees}] | +| `issue-comment.sh` | issue-comment | LINEAR_API_KEY, CODEV_ISSUE_ID, CODEV_COMMENT_BODY | exit code 0 = success | +| `recently-closed.sh` | recently-closed | LINEAR_API_KEY, CODEV_LINEAR_TEAM, CODEV_SINCE_DATE | JSON: [{number, title, url, labels, createdAt, closedAt}] | + +All scripts: +- Use `curl -s` for HTTP + `jq` for JSON transformation +- Auth via `Authorization: $LINEAR_API_KEY` header +- Linear GraphQL endpoint: `https://api.linear.app/graphql` +- Map Linear fields to forge-contracts.ts interfaces (e.g., `issue.identifier` → `number`, `issue.team.states` → derive `state`) +- Fail with exit 1 and stderr message if LINEAR_API_KEY is not set + +**Done when**: Each script runs successfully with a real LINEAR_API_KEY and produces valid JSON matching the contracts. + +## Phase 5: Verification + +**Goal**: Confirm everything works end-to-end. + +**Checks**: +1. TypeScript compiles: `pnpm run build` from workspace root +2. Existing tests pass: `pnpm test` from workspace root +3. `codev doctor` shows linear as known provider +4. Manual verification: run scripts with LINEAR_API_KEY set, confirm JSON output + +**Done when**: All checks pass. Ready for PR. + +## Sequencing + +Phases 1-3 are code changes (TypeScript). Phase 4 is script creation (shell). Phase 5 is verification. Phases 1-3 must be sequential (each builds on prior). Phase 4 can be done after Phase 2 (needs the directory to exist and provider registered). Phase 5 is last. diff --git a/codev/specs/695-linear-forge-provider.md b/codev/specs/695-linear-forge-provider.md new file mode 100644 index 00000000..1f6458af --- /dev/null +++ b/codev/specs/695-linear-forge-provider.md @@ -0,0 +1,55 @@ +# Specification: Linear Forge Provider + +## Metadata +- **ID**: 695-linear-forge-provider +- **Status**: draft +- **Created**: 2026-05-01 +- **Protocol**: ASPIR + +## Problem Statement + +Codev's forge abstraction (spec 589) supports GitHub, GitLab, and Gitea via on-disk scripts. MachineWisdom uses Linear for issue tracking but keeps PRs on GitHub. There is no Linear provider, so MW repos with `forge.provider: "linear"` in `.codev/config.json` fall back to GitHub for everything — including issue concepts, which should resolve against Linear. + +Additionally, a bug in `buildPresetFromScripts` (forge.ts:110) sets `null` for concepts without a script file. Since `null` means "disabled" in the resolution logic, a Linear provider that only implements issue scripts would disable all PR concepts rather than letting them fall through to the GitHub default. This fundamentally breaks the hybrid forge model where one provider handles issues and another handles PRs. + +## Desired State + +- A `linear` provider registered in `getProviderPresets()` with 6 issue-oriented concept scripts +- PR concepts (pr-list, pr-exists, pr-merge, pr-search, pr-view, pr-diff, recently-merged) fall through to GitHub defaults — Linear only handles issues +- `buildPresetFromScripts` skips concepts without scripts (omits them from the preset) instead of setting null, so the resolution logic at `getForgeCommand` falls through to the GitHub default +- Issue identifiers are treated as opaque strings (e.g., "ENG-123") throughout the agent-farm CLI — `afx spawn ENG-123 --protocol spir` works +- Linear scripts authenticate via `LINEAR_API_KEY` env var and filter by team via `CODEV_LINEAR_TEAM` (passed from `forge.linear-team` config) + +## Stakeholders + +- **Primary**: MachineWisdom team (immediate users) +- **Secondary**: Any team using Linear for project management + GitHub for code +- **Upstream**: cluesmith/codev maintainers (PR target) + +## Success Criteria + +- [ ] `buildPresetFromScripts` no longer sets null for missing scripts — omitted concepts fall through to GitHub default +- [ ] `linear` provider registered with disabled: `['team-activity', 'on-it-timestamps']` +- [ ] 6 Linear scripts exist and produce valid JSON matching forge-contracts.ts interfaces +- [ ] `IssueListItem.number` accepts `string | number` +- [ ] `SpawnOptions.issueNumber` accepts `string | number` +- [ ] `afx spawn ENG-123 --protocol spir` parses correctly (no "Invalid issue number" error) +- [ ] `codev doctor` shows `linear` as a known provider with issue concepts resolved, PR concepts falling through to GitHub +- [ ] All existing tests pass (no regression) + +## Constraints + +- Scripts must be POSIX sh (curl + jq only) — no node, no python +- LINEAR_API_KEY is required for auth — scripts fail gracefully with helpful stderr if not set +- Scripts must match the JSON output contracts in forge-contracts.ts +- No new runtime dependencies on the codev package +- Must be backward-compatible — existing GitHub/GitLab/Gitea providers unchanged +- The `executeForgeCommand` function must export non-concept forge config keys (e.g., `linear-team`) as `CODEV_LINEAR_TEAM` environment variable + +## Solution Approach + +Fix the fallback bug first, then add the Linear provider scripts and register the provider. Widen type definitions to accept alphanumeric issue identifiers. Small enhancement to `executeForgeCommand` to pass `forge.linear-team` as `CODEV_LINEAR_TEAM` env var. + +## Open Questions + +None — the forge abstraction is well-documented (spec 589) and the Linear GraphQL API is stable. diff --git a/packages/codev/scripts/forge/linear/auth-status.sh b/packages/codev/scripts/forge/linear/auth-status.sh new file mode 100755 index 00000000..aa331c56 --- /dev/null +++ b/packages/codev/scripts/forge/linear/auth-status.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# Forge concept: auth-status (Linear via GraphQL API) +# Output: exit code (0 = authenticated) +set -e + +if [ -z "$LINEAR_API_KEY" ]; then + echo "LINEAR_API_KEY is not set" >&2 + exit 1 +fi + +curl -sf -X POST https://api.linear.app/graphql \ + -H "Content-Type: application/json" \ + -H "Authorization: $LINEAR_API_KEY" \ + -d '{"query":"{ viewer { id } }"}' \ + -o /dev/null diff --git a/packages/codev/scripts/forge/linear/issue-comment.sh b/packages/codev/scripts/forge/linear/issue-comment.sh new file mode 100755 index 00000000..37ee7ebf --- /dev/null +++ b/packages/codev/scripts/forge/linear/issue-comment.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# Forge concept: issue-comment (Linear via GraphQL API) +# Input: CODEV_ISSUE_ID, CODEV_COMMENT_BODY +# Output: exit code only +set -e + +if [ -z "$LINEAR_API_KEY" ]; then + echo "LINEAR_API_KEY is not set" >&2 + exit 1 +fi + +if [ -z "$CODEV_ISSUE_ID" ]; then + echo "CODEV_ISSUE_ID is not set" >&2 + exit 1 +fi + +ISSUE_UUID=$(curl -sf -X POST https://api.linear.app/graphql \ + -H "Content-Type: application/json" \ + -H "Authorization: $LINEAR_API_KEY" \ + -d "$(jq -n --arg id "$CODEV_ISSUE_ID" '{ + query: "query($id: String!) { issues(filter: { identifier: { eq: $id } }) { nodes { id } } }", + variables: { id: $id } + }')" \ + | jq -r '.data.issues.nodes[0].id') + +if [ -z "$ISSUE_UUID" ] || [ "$ISSUE_UUID" = "null" ]; then + echo "Issue not found: $CODEV_ISSUE_ID" >&2 + exit 1 +fi + +curl -sf -X POST https://api.linear.app/graphql \ + -H "Content-Type: application/json" \ + -H "Authorization: $LINEAR_API_KEY" \ + -d "$(jq -n --arg issueId "$ISSUE_UUID" --arg body "$CODEV_COMMENT_BODY" '{ + query: "mutation($issueId: String!, $body: String!) { commentCreate(input: { issueId: $issueId, body: $body }) { success } }", + variables: { issueId: $issueId, body: $body } + }')" \ + -o /dev/null diff --git a/packages/codev/scripts/forge/linear/issue-list.sh b/packages/codev/scripts/forge/linear/issue-list.sh new file mode 100755 index 00000000..e73a5992 --- /dev/null +++ b/packages/codev/scripts/forge/linear/issue-list.sh @@ -0,0 +1,35 @@ +#!/bin/sh +# Forge concept: issue-list (Linear via GraphQL API) +# Input: CODEV_LINEAR_TEAM (team key, e.g. "ENG") +# Output: JSON [{number, title, url, labels, createdAt, author, assignees}] +set -e + +if [ -z "$LINEAR_API_KEY" ]; then + echo "LINEAR_API_KEY is not set" >&2 + exit 1 +fi + +FILTER='{ state: { type: { nin: ["completed", "canceled"] } } }' +if [ -n "$CODEV_LINEAR_TEAM" ]; then + FILTER="$(jq -n --arg team "$CODEV_LINEAR_TEAM" '{ + team: { key: { eq: $team } }, + state: { type: { nin: ["completed", "canceled"] } } + }')" +fi + +curl -sf -X POST https://api.linear.app/graphql \ + -H "Content-Type: application/json" \ + -H "Authorization: $LINEAR_API_KEY" \ + -d "$(jq -n --argjson filter "$FILTER" '{ + query: "query($filter: IssueFilter) { issues(filter: $filter, first: 200) { nodes { identifier title url labels { nodes { name } } createdAt assignee { displayName } creator { displayName } } } }", + variables: { filter: $filter } + }')" \ + | jq '[.data.issues.nodes[] | { + number: .identifier, + title: .title, + url: .url, + labels: [.labels.nodes[] | { name: .name }], + createdAt: .createdAt, + author: (if .creator then { login: .creator.displayName } else null end), + assignees: (if .assignee then [{ login: .assignee.displayName }] else [] end) + }]' diff --git a/packages/codev/scripts/forge/linear/issue-view.sh b/packages/codev/scripts/forge/linear/issue-view.sh new file mode 100755 index 00000000..50d40f91 --- /dev/null +++ b/packages/codev/scripts/forge/linear/issue-view.sh @@ -0,0 +1,33 @@ +#!/bin/sh +# Forge concept: issue-view (Linear via GraphQL API) +# Input: CODEV_ISSUE_ID (e.g. "ENG-123") +# Output: JSON {title, body, state, comments[]} +set -e + +if [ -z "$LINEAR_API_KEY" ]; then + echo "LINEAR_API_KEY is not set" >&2 + exit 1 +fi + +if [ -z "$CODEV_ISSUE_ID" ]; then + echo "CODEV_ISSUE_ID is not set" >&2 + exit 1 +fi + +curl -sf -X POST https://api.linear.app/graphql \ + -H "Content-Type: application/json" \ + -H "Authorization: $LINEAR_API_KEY" \ + -d "$(jq -n --arg id "$CODEV_ISSUE_ID" '{ + query: "query($id: String!) { issueVcsByFilter: issues(filter: { identifier: { eq: $id } }) { nodes { title description state { name } comments { nodes { body createdAt user { displayName } } } } } }", + variables: { id: $id } + }')" \ + | jq '{ + title: .data.issueVcsByFilter.nodes[0].title, + body: (.data.issueVcsByFilter.nodes[0].description // ""), + state: .data.issueVcsByFilter.nodes[0].state.name, + comments: [.data.issueVcsByFilter.nodes[0].comments.nodes[] | { + body: .body, + createdAt: .createdAt, + author: { login: .user.displayName } + }] + }' diff --git a/packages/codev/scripts/forge/linear/recently-closed.sh b/packages/codev/scripts/forge/linear/recently-closed.sh new file mode 100755 index 00000000..c507e6c1 --- /dev/null +++ b/packages/codev/scripts/forge/linear/recently-closed.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Forge concept: recently-closed (Linear via GraphQL API) +# Input: CODEV_LINEAR_TEAM, CODEV_SINCE_DATE (optional, ISO date) +# Output: JSON [{number, title, url, labels, createdAt, closedAt}] +set -e + +if [ -z "$LINEAR_API_KEY" ]; then + echo "LINEAR_API_KEY is not set" >&2 + exit 1 +fi + +FILTER='{ state: { type: { in: ["completed", "canceled"] } } }' +if [ -n "$CODEV_LINEAR_TEAM" ] && [ -n "$CODEV_SINCE_DATE" ]; then + FILTER="$(jq -n --arg team "$CODEV_LINEAR_TEAM" --arg since "$CODEV_SINCE_DATE" '{ + team: { key: { eq: $team } }, + state: { type: { in: ["completed", "canceled"] } }, + completedAt: { gte: $since } + }')" +elif [ -n "$CODEV_LINEAR_TEAM" ]; then + FILTER="$(jq -n --arg team "$CODEV_LINEAR_TEAM" '{ + team: { key: { eq: $team } }, + state: { type: { in: ["completed", "canceled"] } } + }')" +elif [ -n "$CODEV_SINCE_DATE" ]; then + FILTER="$(jq -n --arg since "$CODEV_SINCE_DATE" '{ + state: { type: { in: ["completed", "canceled"] } }, + completedAt: { gte: $since } + }')" +fi + +curl -sf -X POST https://api.linear.app/graphql \ + -H "Content-Type: application/json" \ + -H "Authorization: $LINEAR_API_KEY" \ + -d "$(jq -n --argjson filter "$FILTER" '{ + query: "query($filter: IssueFilter) { issues(filter: $filter, first: 200, orderBy: completedAt) { nodes { identifier title url labels { nodes { name } } createdAt completedAt } } }", + variables: { filter: $filter } + }')" \ + | jq '[.data.issues.nodes[] | { + number: .identifier, + title: .title, + url: .url, + labels: [.labels.nodes[] | { name: .name }], + createdAt: .createdAt, + closedAt: .completedAt + }]' diff --git a/packages/codev/scripts/forge/linear/user-identity.sh b/packages/codev/scripts/forge/linear/user-identity.sh new file mode 100755 index 00000000..9c9eca0b --- /dev/null +++ b/packages/codev/scripts/forge/linear/user-identity.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# Forge concept: user-identity (Linear via GraphQL API) +# Output: plain text display name +set -e + +if [ -z "$LINEAR_API_KEY" ]; then + echo "LINEAR_API_KEY is not set" >&2 + exit 1 +fi + +curl -sf -X POST https://api.linear.app/graphql \ + -H "Content-Type: application/json" \ + -H "Authorization: $LINEAR_API_KEY" \ + -d '{"query":"{ viewer { displayName } }"}' \ + | jq -r '.data.viewer.displayName' diff --git a/packages/codev/src/agent-farm/cli.ts b/packages/codev/src/agent-farm/cli.ts index db54b091..4d695ac0 100644 --- a/packages/codev/src/agent-farm/cli.ts +++ b/packages/codev/src/agent-farm/cli.ts @@ -192,7 +192,7 @@ export async function runAgentFarm(args: string[]): Promise { const spawnCmd = program .command('spawn') .description('Spawn a new builder') - .argument('[number]', 'Issue number (positional)') + .argument('[identifier]', 'Issue identifier (positional, e.g. 315 or ENG-123)') .option('--protocol ', 'Protocol to use (spir, aspir, air, bugfix, maintain, experiment)') .option('--task ', 'Spawn builder with a task description') .option('--shell', 'Spawn a bare Claude session') @@ -227,10 +227,17 @@ export async function runAgentFarm(args: string[]): Promise { const { spawn } = await import('./commands/spawn.js'); try { const files = options.files ? (options.files as string).split(',').map((f: string) => f.trim()) : undefined; - const issueNumber = numberArg ? parseInt(numberArg, 10) : undefined; - if (numberArg && (isNaN(issueNumber!) || issueNumber! <= 0)) { - logger.error(`Invalid issue number: ${numberArg}`); - process.exit(1); + let issueNumber: number | string | undefined; + if (numberArg) { + const parsed = parseInt(numberArg, 10); + if (!isNaN(parsed) && parsed > 0) { + issueNumber = parsed; + } else if (/^[A-Z]+-\d+$/i.test(numberArg)) { + issueNumber = numberArg; + } else { + logger.error(`Invalid issue identifier: ${numberArg}`); + process.exit(1); + } } const amends = options.amends ? parseInt(options.amends as string, 10) : undefined; await spawn({ diff --git a/packages/codev/src/agent-farm/commands/spawn-roles.ts b/packages/codev/src/agent-farm/commands/spawn-roles.ts index 3b3f9f81..807aff34 100644 --- a/packages/codev/src/agent-farm/commands/spawn-roles.ts +++ b/packages/codev/src/agent-farm/commands/spawn-roles.ts @@ -38,7 +38,7 @@ export interface TemplateContext { name: string; }; issue?: { - number: number; + number: number | string; title: string; body: string; }; diff --git a/packages/codev/src/agent-farm/commands/spawn-worktree.ts b/packages/codev/src/agent-farm/commands/spawn-worktree.ts index 8727939c..379ac8e6 100644 --- a/packages/codev/src/agent-farm/commands/spawn-worktree.ts +++ b/packages/codev/src/agent-farm/commands/spawn-worktree.ts @@ -370,7 +370,7 @@ export function slugify(title: string): string { * Scans the builders directory for directories matching `bugfix-{issueNumber}-*`. * Returns the directory name if found, or null if no match exists. */ -export function findExistingBugfixWorktree(buildersDir: string, issueNumber: number): string | null { +export function findExistingBugfixWorktree(buildersDir: string, issueNumber: number | string): string | null { const prefix = `bugfix-${issueNumber}-`; try { const entries = readdirSync(buildersDir, { withFileTypes: true }); @@ -386,7 +386,7 @@ export function findExistingBugfixWorktree(buildersDir: string, issueNumber: num * Delegates to shared github utility but wraps with fatal() for spawn context. */ export async function fetchGitHubIssue( - issueNumber: number, + issueNumber: number | string, options?: { cwd?: string; forgeConfig?: ForgeConfig | null }, ): Promise { try { @@ -411,7 +411,7 @@ export async function fetchGitHubIssue( * Uses forge concept commands for PR search (graceful degradation if unavailable). */ export async function checkBugfixCollisions( - issueNumber: number, + issueNumber: number | string, worktreePath: string, issue: ForgeIssue, force: boolean, @@ -481,7 +481,7 @@ export async function checkBugfixCollisions( export async function executePreSpawnHooks( protocol: ProtocolDefinition | null, context: { - issueNumber?: number; + issueNumber?: number | string; issue?: ForgeIssue; worktreePath?: string; force?: boolean; diff --git a/packages/codev/src/agent-farm/commands/spawn.ts b/packages/codev/src/agent-farm/commands/spawn.ts index 69aabd5d..f73d35dc 100644 --- a/packages/codev/src/agent-farm/commands/spawn.ts +++ b/packages/codev/src/agent-farm/commands/spawn.ts @@ -243,7 +243,7 @@ async function resolveIssueProtocol( * Worktree naming: -- or bugfix-- * Handles legacy zero-padded IDs: worktree `spir-0076-feature` matches issueNumber=76. */ -function inferProtocolFromWorktree(config: Config, issueNumber: number): string | null { +function inferProtocolFromWorktree(config: Config, issueNumber: number | string): string | null { if (!existsSync(config.buildersDir)) return null; const strippedId = stripLeadingZeros(String(issueNumber)); const dirs = readdirSync(config.buildersDir); diff --git a/packages/codev/src/agent-farm/types.ts b/packages/codev/src/agent-farm/types.ts index 278dcec3..3e86cde2 100644 --- a/packages/codev/src/agent-farm/types.ts +++ b/packages/codev/src/agent-farm/types.ts @@ -14,7 +14,7 @@ export interface Builder { type: BuilderType; taskText?: string; // For task mode (display in dashboard) protocolName?: string; // For protocol mode - issueNumber?: number; // For bugfix mode + issueNumber?: number | string; // For bugfix mode terminalId?: string; // Terminal session ID } @@ -63,8 +63,8 @@ export interface StartOptions { } export interface SpawnOptions { - // Primary input: issue number as positional arg - issueNumber?: number; // Positional arg: `afx spawn 315` + // Primary input: issue identifier as positional arg + issueNumber?: number | string; // Positional arg: `afx spawn 315` or `afx spawn ENG-123` // Protocol selection (required for issue-based spawns) protocol?: string; // --protocol spir|aspir|air|bugfix|maintain|experiment diff --git a/packages/codev/src/lib/forge-contracts.ts b/packages/codev/src/lib/forge-contracts.ts index d25f9634..facf1014 100644 --- a/packages/codev/src/lib/forge-contracts.ts +++ b/packages/codev/src/lib/forge-contracts.ts @@ -30,7 +30,7 @@ export interface IssueViewResult { /** Single item in `issue-list` concept output. */ export interface IssueListItem { - number: number; + number: number | string; title: string; url: string; labels: Array<{ name: string }>; diff --git a/packages/codev/src/lib/forge.ts b/packages/codev/src/lib/forge.ts index 16357a02..9ff805fa 100644 --- a/packages/codev/src/lib/forge.ts +++ b/packages/codev/src/lib/forge.ts @@ -94,7 +94,8 @@ function getDefaultCommands(): Record { /** * Build a provider preset from on-disk scripts. - * Concepts without a script file are null (disabled). + * Concepts without a script file are omitted (fall through to default). + * Explicitly disabled concepts are set to null. */ function buildPresetFromScripts(provider: string, disabledConcepts: string[] = []): Record { const preset: Record = {}; @@ -106,8 +107,6 @@ function buildPresetFromScripts(provider: string, disabledConcepts: string[] = [ const scriptPath = resolveScriptPath(provider, concept); if (existsSync(scriptPath)) { preset[concept] = scriptPath; - } else { - preset[concept] = null; } } return preset; @@ -128,6 +127,7 @@ function getProviderPresets(): Record> { github: getDefaultCommands(), gitlab: buildPresetFromScripts('gitlab', ['team-activity', 'on-it-timestamps']), gitea: buildPresetFromScripts('gitea', ['team-activity', 'on-it-timestamps', 'pr-search', 'pr-diff']), + linear: buildPresetFromScripts('linear', ['team-activity', 'on-it-timestamps']), }; return _providerPresets; } @@ -314,10 +314,12 @@ export async function executeForgeCommand( return null; } + const forgeEnv = buildForgeEnv(forgeConfig); + try { const { stdout } = await execAsync(command, { cwd: options?.cwd, - env: { ...process.env, ...env }, + env: { ...process.env, ...forgeEnv, ...env }, timeout: 30_000, maxBuffer: options?.maxBuffer ?? DEFAULT_MAX_BUFFER, }); @@ -347,10 +349,12 @@ export function executeForgeCommandSync( return null; } + const forgeEnv = buildForgeEnv(forgeConfig); + try { const stdout = execSync(command, { cwd: options?.cwd, - env: { ...process.env, ...env }, + env: { ...process.env, ...forgeEnv, ...env }, encoding: 'utf-8', timeout: 30_000, maxBuffer: options?.maxBuffer ?? DEFAULT_MAX_BUFFER, @@ -368,6 +372,23 @@ export function executeForgeCommandSync( // Internal helpers // ============================================================================= +const _knownConceptSet = new Set(KNOWN_CONCEPTS); + +/** + * Build environment variables from non-concept forge config keys. + * E.g., `forge.linear-team: "ENG"` → `CODEV_LINEAR_TEAM=ENG`. + */ +function buildForgeEnv(forgeConfig: ForgeConfig | null): Record { + if (!forgeConfig) return {}; + const result: Record = {}; + for (const [key, value] of Object.entries(forgeConfig)) { + if (key === 'provider' || _knownConceptSet.has(key) || value === null) continue; + const envKey = 'CODEV_' + key.toUpperCase().replace(/-/g, '_'); + result[envKey] = value; + } + return result; +} + /** Parse command stdout: try JSON when raw=false (null on parse failure), raw string otherwise. */ function parseOutput(stdout: string, raw?: boolean): unknown | null { const trimmed = stdout.trim(); From 947d001dd5d333c0ffeaaa71eba200c69e2d8c00 Mon Sep 17 00:00:00 2001 From: Younes Abouelnagah Date: Fri, 1 May 2026 17:41:20 -0400 Subject: [PATCH 2/3] =?UTF-8?q?fix(forge):=20address=20PR=20#716=20review?= =?UTF-8?q?=20=E2=80=94=20DB=20migration,=20porch,=20scripts,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Migrate issue_number column from INTEGER to TEXT (schema, types, migration v8) so alphanumeric identifiers like "ENG-123" persist correctly through the DB layer. 2. Drop parseInt guard in porch getProjectSummary so alphanumeric project IDs route through fetchIssue instead of silently skipping. 3. Fix Linear scripts: recently-closed.sh uses updatedAt (valid enum value) instead of completedAt; issue-view.sh guards against empty result set with explicit error. 4. Add unit tests for Linear provider preset behavior (omission fallthrough, not null) and alphanumeric issue identifiers in spawn validation and mode detection. 5. Renumber spec/plan from 695 to 719 to match GitHub issue #719. Co-Authored-By: Claude Opus 4.6 --- ...ovider.md => 719-linear-forge-provider.md} | 2 +- ...ovider.md => 719-linear-forge-provider.md} | 2 +- .../codev/scripts/forge/linear/issue-view.sh | 24 +++++---- .../scripts/forge/linear/recently-closed.sh | 2 +- packages/codev/src/__tests__/forge.test.ts | 40 ++++++++++++++- .../src/agent-farm/__tests__/overview.test.ts | 6 +-- .../src/agent-farm/__tests__/spawn.test.ts | 18 +++++++ packages/codev/src/agent-farm/db/index.ts | 49 +++++++++++++++++++ packages/codev/src/agent-farm/db/schema.ts | 2 +- packages/codev/src/agent-farm/db/types.ts | 2 +- .../codev/src/agent-farm/servers/overview.ts | 2 +- packages/codev/src/agent-farm/state.ts | 2 +- packages/codev/src/commands/porch/prompts.ts | 11 ++--- 13 files changed, 134 insertions(+), 28 deletions(-) rename codev/plans/{695-linear-forge-provider.md => 719-linear-forge-provider.md} (99%) rename codev/specs/{695-linear-forge-provider.md => 719-linear-forge-provider.md} (98%) diff --git a/codev/plans/695-linear-forge-provider.md b/codev/plans/719-linear-forge-provider.md similarity index 99% rename from codev/plans/695-linear-forge-provider.md rename to codev/plans/719-linear-forge-provider.md index de66c69a..9fcb2ec6 100644 --- a/codev/plans/695-linear-forge-provider.md +++ b/codev/plans/719-linear-forge-provider.md @@ -1,7 +1,7 @@ # Plan: Linear Forge Provider ## Metadata -- **Spec**: 695-linear-forge-provider +- **Spec**: 719-linear-forge-provider - **Protocol**: ASPIR - **Created**: 2026-05-01 diff --git a/codev/specs/695-linear-forge-provider.md b/codev/specs/719-linear-forge-provider.md similarity index 98% rename from codev/specs/695-linear-forge-provider.md rename to codev/specs/719-linear-forge-provider.md index 1f6458af..b1eca8da 100644 --- a/codev/specs/695-linear-forge-provider.md +++ b/codev/specs/719-linear-forge-provider.md @@ -1,7 +1,7 @@ # Specification: Linear Forge Provider ## Metadata -- **ID**: 695-linear-forge-provider +- **ID**: 719-linear-forge-provider - **Status**: draft - **Created**: 2026-05-01 - **Protocol**: ASPIR diff --git a/packages/codev/scripts/forge/linear/issue-view.sh b/packages/codev/scripts/forge/linear/issue-view.sh index 50d40f91..9b1381c1 100755 --- a/packages/codev/scripts/forge/linear/issue-view.sh +++ b/packages/codev/scripts/forge/linear/issue-view.sh @@ -21,13 +21,17 @@ curl -sf -X POST https://api.linear.app/graphql \ query: "query($id: String!) { issueVcsByFilter: issues(filter: { identifier: { eq: $id } }) { nodes { title description state { name } comments { nodes { body createdAt user { displayName } } } } } }", variables: { id: $id } }')" \ - | jq '{ - title: .data.issueVcsByFilter.nodes[0].title, - body: (.data.issueVcsByFilter.nodes[0].description // ""), - state: .data.issueVcsByFilter.nodes[0].state.name, - comments: [.data.issueVcsByFilter.nodes[0].comments.nodes[] | { - body: .body, - createdAt: .createdAt, - author: { login: .user.displayName } - }] - }' + | jq 'if (.data.issueVcsByFilter.nodes | length) == 0 then + error("issue not found: \(env.CODEV_ISSUE_ID)") + else + .data.issueVcsByFilter.nodes[0] | { + title: .title, + body: (.description // ""), + state: .state.name, + comments: [.comments.nodes[] | { + body: .body, + createdAt: .createdAt, + author: { login: .user.displayName } + }] + } + end' diff --git a/packages/codev/scripts/forge/linear/recently-closed.sh b/packages/codev/scripts/forge/linear/recently-closed.sh index c507e6c1..da158406 100755 --- a/packages/codev/scripts/forge/linear/recently-closed.sh +++ b/packages/codev/scripts/forge/linear/recently-closed.sh @@ -32,7 +32,7 @@ curl -sf -X POST https://api.linear.app/graphql \ -H "Content-Type: application/json" \ -H "Authorization: $LINEAR_API_KEY" \ -d "$(jq -n --argjson filter "$FILTER" '{ - query: "query($filter: IssueFilter) { issues(filter: $filter, first: 200, orderBy: completedAt) { nodes { identifier title url labels { nodes { name } } createdAt completedAt } } }", + query: "query($filter: IssueFilter) { issues(filter: $filter, first: 200, orderBy: updatedAt) { nodes { identifier title url labels { nodes { name } } createdAt completedAt } } }", variables: { filter: $filter } }')" \ | jq '[.data.issues.nodes[] | { diff --git a/packages/codev/src/__tests__/forge.test.ts b/packages/codev/src/__tests__/forge.test.ts index b636a46b..b215ed41 100644 --- a/packages/codev/src/__tests__/forge.test.ts +++ b/packages/codev/src/__tests__/forge.test.ts @@ -427,11 +427,12 @@ describe('loadForgeConfig', () => { // ============================================================================= describe('provider presets', () => { - it('returns known providers', () => { + it('returns known providers including linear', () => { const providers = getKnownProviders(); expect(providers).toContain('github'); expect(providers).toContain('gitlab'); expect(providers).toContain('gitea'); + expect(providers).toContain('linear'); }); it('uses provider preset when no manual override', () => { @@ -471,6 +472,43 @@ describe('provider presets', () => { expect(results[0].status).toBe('provider'); expect(results[0].message).toContain('gitlab'); }); + + it('linear provider uses linear scripts for concepts with scripts', () => { + const config = { provider: 'linear' }; + const cmd = getForgeCommand('issue-view', config); + expect(cmd).toContain('scripts/forge/linear/issue-view.sh'); + }); + + it('linear provider falls through to github default for concepts without scripts', () => { + const config = { provider: 'linear' }; + const cmd = getForgeCommand('pr-list', config); + expect(cmd).toContain('scripts/forge/github/pr-list.sh'); + }); + + it('linear provider omits (not nulls) concepts without scripts so they fall through', () => { + const config = { provider: 'linear' }; + const resolutions = resolveAllConcepts(config); + const prList = resolutions.find(r => r.concept === 'pr-list'); + // pr-list has no linear script — should fall through to default, NOT be disabled + expect(prList?.source).toBe('default'); + expect(prList?.command).toContain('github/pr-list.sh'); + }); + + it('linear provider disables explicitly disabled concepts', () => { + const config = { provider: 'linear' }; + const resolutions = resolveAllConcepts(config); + const teamActivity = resolutions.find(r => r.concept === 'team-activity'); + expect(teamActivity?.source).toBe('disabled'); + expect(teamActivity?.command).toBeNull(); + }); + + it('linear provider resolves issue-view as preset', () => { + const config = { provider: 'linear' }; + const resolutions = resolveAllConcepts(config); + const issueView = resolutions.find(r => r.concept === 'issue-view'); + expect(issueView?.source).toBe('preset'); + expect(issueView?.command).toContain('linear/issue-view.sh'); + }); }); // ============================================================================= diff --git a/packages/codev/src/agent-farm/__tests__/overview.test.ts b/packages/codev/src/agent-farm/__tests__/overview.test.ts index eadf5417..076327d9 100644 --- a/packages/codev/src/agent-farm/__tests__/overview.test.ts +++ b/packages/codev/src/agent-farm/__tests__/overview.test.ts @@ -102,14 +102,14 @@ function issueItem(number: number, title: string, labels: Array<{ name: string } } /** Create a state.db in the workspace's .agent-farm/ with builder issue_number rows. */ -function createStateDb(root: string, rows: Array<{ worktree: string; issue_number: number }>): void { +function createStateDb(root: string, rows: Array<{ worktree: string; issue_number: number | string }>): void { const agentFarmDir = path.join(root, '.agent-farm'); fs.mkdirSync(agentFarmDir, { recursive: true }); const db = new Database(path.join(agentFarmDir, 'state.db')); - db.exec('CREATE TABLE IF NOT EXISTS builders (worktree TEXT, issue_number INTEGER)'); + db.exec('CREATE TABLE IF NOT EXISTS builders (worktree TEXT, issue_number TEXT)'); const insert = db.prepare('INSERT INTO builders (worktree, issue_number) VALUES (?, ?)'); for (const row of rows) { - insert.run(row.worktree, row.issue_number); + insert.run(row.worktree, String(row.issue_number)); } db.close(); } diff --git a/packages/codev/src/agent-farm/__tests__/spawn.test.ts b/packages/codev/src/agent-farm/__tests__/spawn.test.ts index d06c7b13..90cbb2c1 100644 --- a/packages/codev/src/agent-farm/__tests__/spawn.test.ts +++ b/packages/codev/src/agent-farm/__tests__/spawn.test.ts @@ -168,6 +168,16 @@ describe('Spawn Command', () => { const options: SpawnOptions = { issueNumber: 315, protocol: 'bugfix', force: true }; expect(validateSpawnOptions(options)).toBeNull(); }); + + it('should accept alphanumeric issue identifier (e.g. ENG-123)', () => { + const options: SpawnOptions = { issueNumber: 'ENG-123', protocol: 'spir' }; + expect(validateSpawnOptions(options)).toBeNull(); + }); + + it('should accept alphanumeric issue identifier + --protocol bugfix', () => { + const options: SpawnOptions = { issueNumber: 'PROJ-42', protocol: 'bugfix' }; + expect(validateSpawnOptions(options)).toBeNull(); + }); }); describe('valid options — alternative modes', () => { @@ -354,6 +364,14 @@ describe('Spawn Command', () => { // task + protocol is valid: protocol overrides the task's default expect(getSpawnMode({ task: 'Fix bug', protocol: 'spir' })).toBe('task'); }); + + it('returns spec for alphanumeric issue identifier + protocol', () => { + expect(getSpawnMode({ issueNumber: 'ENG-123', protocol: 'spir' })).toBe('spec'); + }); + + it('returns bugfix for alphanumeric issue identifier + bugfix protocol', () => { + expect(getSpawnMode({ issueNumber: 'PROJ-42', protocol: 'bugfix' })).toBe('bugfix'); + }); }); describe('generateShortId', () => { diff --git a/packages/codev/src/agent-farm/db/index.ts b/packages/codev/src/agent-farm/db/index.ts index 3ca86eea..df75d9a6 100644 --- a/packages/codev/src/agent-farm/db/index.ts +++ b/packages/codev/src/agent-farm/db/index.ts @@ -342,6 +342,55 @@ function ensureLocalDatabase(): Database.Database { db.prepare('INSERT INTO _migrations (version) VALUES (7)').run(); } + // Migration v8: Widen issue_number from INTEGER to TEXT (Linear identifiers like "ENG-123") + const v8 = db.prepare('SELECT version FROM _migrations WHERE version = 8').get(); + if (!v8) { + const tableInfo = db + .prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='builders'") + .get() as { sql: string } | undefined; + + if (tableInfo?.sql?.includes('issue_number INTEGER')) { + db.exec(` + CREATE TABLE builders_new ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + port INTEGER NOT NULL DEFAULT 0, + pid INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'spawning' + CHECK(status IN ('spawning', 'implementing', 'blocked', 'pr', 'complete')), + phase TEXT NOT NULL DEFAULT '', + worktree TEXT NOT NULL, + branch TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'spec' + CHECK(type IN ('spec', 'task', 'protocol', 'shell', 'worktree', 'bugfix')), + task_text TEXT, + protocol_name TEXT, + issue_number TEXT, + terminal_id TEXT, + started_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + INSERT INTO builders_new + SELECT id, name, port, pid, status, phase, worktree, branch, type, + task_text, protocol_name, CAST(issue_number AS TEXT), + terminal_id, started_at, updated_at + FROM builders; + DROP TABLE builders; + ALTER TABLE builders_new RENAME TO builders; + CREATE INDEX IF NOT EXISTS idx_builders_status ON builders(status); + CREATE INDEX IF NOT EXISTS idx_builders_port ON builders(port); + CREATE TRIGGER IF NOT EXISTS builders_updated_at + AFTER UPDATE ON builders + FOR EACH ROW + BEGIN + UPDATE builders SET updated_at = datetime('now') WHERE id = NEW.id; + END; + `); + console.log('[info] Migrated builders table: widened issue_number to TEXT'); + } + db.prepare('INSERT INTO _migrations (version) VALUES (8)').run(); + } + return db; } diff --git a/packages/codev/src/agent-farm/db/schema.ts b/packages/codev/src/agent-farm/db/schema.ts index f83407ae..8623633d 100644 --- a/packages/codev/src/agent-farm/db/schema.ts +++ b/packages/codev/src/agent-farm/db/schema.ts @@ -40,7 +40,7 @@ CREATE TABLE IF NOT EXISTS builders ( CHECK(type IN ('spec', 'task', 'protocol', 'shell', 'worktree', 'bugfix')), task_text TEXT, protocol_name TEXT, - issue_number INTEGER, + issue_number TEXT, terminal_id TEXT, started_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) diff --git a/packages/codev/src/agent-farm/db/types.ts b/packages/codev/src/agent-farm/db/types.ts index af0eec7d..f5f61201 100644 --- a/packages/codev/src/agent-farm/db/types.ts +++ b/packages/codev/src/agent-farm/db/types.ts @@ -34,7 +34,7 @@ export interface DbBuilder { type: string; task_text: string | null; protocol_name: string | null; - issue_number: number | null; + issue_number: string | null; terminal_id: string | null; started_at: string; updated_at: string; diff --git a/packages/codev/src/agent-farm/servers/overview.ts b/packages/codev/src/agent-farm/servers/overview.ts index 6a6cc3f3..e2061dc5 100644 --- a/packages/codev/src/agent-farm/servers/overview.ts +++ b/packages/codev/src/agent-farm/servers/overview.ts @@ -710,7 +710,7 @@ export class OverviewCache { try { const rows = db.prepare( 'SELECT worktree, issue_number FROM builders WHERE issue_number IS NOT NULL', - ).all() as Array<{ worktree: string; issue_number: number }>; + ).all() as Array<{ worktree: string; issue_number: string }>; for (const row of rows) { const builder = builders.find(b => b.worktreePath === row.worktree); if (builder) { diff --git a/packages/codev/src/agent-farm/state.ts b/packages/codev/src/agent-farm/state.ts index 585cc841..8f370e06 100644 --- a/packages/codev/src/agent-farm/state.ts +++ b/packages/codev/src/agent-farm/state.ts @@ -105,7 +105,7 @@ export function upsertBuilder(builder: Builder): void { type: builder.type, taskText: builder.taskText ?? null, protocolName: builder.protocolName ?? null, - issueNumber: builder.issueNumber ?? null, + issueNumber: builder.issueNumber != null ? String(builder.issueNumber) : null, terminalId: builder.terminalId ?? null, }); } diff --git a/packages/codev/src/commands/porch/prompts.ts b/packages/codev/src/commands/porch/prompts.ts index 36264ee8..0636bffd 100644 --- a/packages/codev/src/commands/porch/prompts.ts +++ b/packages/codev/src/commands/porch/prompts.ts @@ -27,13 +27,10 @@ import { resolveCodevFile } from '../../lib/skeleton.js'; * 3. Project title from status.yaml — last resort */ export async function getProjectSummary(workspaceRoot: string, projectId: string, projectTitle?: string): Promise { - // 1. Try GitHub issue - const issueNumber = parseInt(projectId, 10); - if (!isNaN(issueNumber)) { - const issue = await fetchIssue(issueNumber); - if (issue?.title) { - return issue.title; - } + // 1. Try forge issue lookup (supports numeric and alphanumeric IDs like "ENG-123") + const issue = await fetchIssue(projectId); + if (issue?.title) { + return issue.title; } // 2. Fallback: read first heading from spec file From 4938cf78f6298342588baa033fb23bcfa30aca48 Mon Sep 17 00:00:00 2001 From: Younes Abouelnagah Date: Wed, 6 May 2026 04:56:49 -0400 Subject: [PATCH 3/3] test: update project summary expectations for string IDs --- packages/codev/src/__tests__/project-summary.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/codev/src/__tests__/project-summary.test.ts b/packages/codev/src/__tests__/project-summary.test.ts index 9f8bf755..02121dad 100644 --- a/packages/codev/src/__tests__/project-summary.test.ts +++ b/packages/codev/src/__tests__/project-summary.test.ts @@ -41,7 +41,7 @@ describe('getProjectSummary', () => { const result = await getProjectSummary(testDir, '0126'); expect(result).toBe('Project Management Rework'); - expect(mockFetchIssue).toHaveBeenCalledWith(126); + expect(mockFetchIssue).toHaveBeenCalledWith('0126'); }); it('falls back to spec file heading when GitHub fails', async () => { @@ -88,9 +88,10 @@ describe('getProjectSummary', () => { }); it('returns null for non-numeric project IDs with no spec file', async () => { - // Non-numeric ID means GitHub fetch is skipped + mockFetchIssue.mockResolvedValue(null); + const result = await getProjectSummary(testDir, 'abc'); - expect(mockFetchIssue).not.toHaveBeenCalled(); + expect(mockFetchIssue).toHaveBeenCalledWith('abc'); expect(result).toBeNull(); });