Skip to content

chore: linear-vercel — current with main + #247 orchestration + Linear-MCP attachments (CI-only draft)#386

Closed
isadeks wants to merge 227 commits into
mainfrom
linear-vercel
Closed

chore: linear-vercel — current with main + #247 orchestration + Linear-MCP attachments (CI-only draft)#386
isadeks wants to merge 227 commits into
mainfrom
linear-vercel

Conversation

@isadeks

@isadeks isadeks commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

What

Brings the linear-vercel demo branch fully current and adds the #247 Linear parent/sub-issue orchestration + the Linear-MCP attachments feature.

Draft — opened to run CI on linear-vercel, not as a merge request. linear-vercel is the Linear+Vercel demo branch; it is not intended to merge into main as-is. This PR exists so the build / CodeQL / secret-scan checks run against the branch head.

Branch state

linear-vercel was a stale parallel fork (was 60 "behind" the fork's stale main; only 5 commits behind real aws-samples/main). This branch:

Verification (local + live)

  • mise //cdk:test2600 tests / 143 suites green
  • agent pytest — 1123 green
  • compile clean, eslint clean, docs mirror in sync
  • Deployed to backgroundagent-dev; live-verified the attachments path end-to-end (epic with a paperclip spec → agent fetched it via the Linear MCP → PR used the spec values).

🤖 Generated with Claude Code

isadeks and others added 30 commits May 26, 2026 18:01
… wiring yet)

Lands the runtime pieces of the screenshot-on-preview-deploy feature:

- `ScreenshotBucket` construct (`cdk/src/constructs/screenshot-bucket.ts`):
  public-read on `screenshots/*`, SSE-S3, 30-day TTL. Bucket policy
  scoped to the prefix so anything written outside is invisible.

- GitHub webhook receiver (`cdk/src/handlers/github-webhook.ts`):
  HMAC-verifies `X-Hub-Signature-256`, filters to
  `deployment_status` events with `state=success` and
  `environment=Preview`, dedups on `(repo, deployment_id, status_id)`,
  async-invokes the processor. Topology mirrors `linear-webhook.ts`.

- Webhook processor (`cdk/src/handlers/github-webhook-processor.ts`):
  Looks up the open PR for the deploy SHA via the GitHub Commits API,
  captures a screenshot of `deployment.environment_url` via AgentCore
  Browser, PUTs the PNG to the screenshot bucket, posts a markdown
  embed in a fresh PR comment.

- AgentCore Browser wrapper (`cdk/src/handlers/shared/agentcore-browser.ts`):
  Drives Chrome DevTools Protocol over WebSocket directly, avoiding
  Playwright bloat. SigV4-signs the WSS handshake. Smoke-tested locally
  against example.com and a Vercel demo URL — 6.5s end-to-end, valid PNG.

- GitHub webhook verify helper (`cdk/src/handlers/shared/github-webhook-verify.ts`):
  Mirrors `linear-verify.ts` — secret cache with 5min TTL, transparent
  re-fetch once on signature failure.

Stack wiring (IAM grants, API Gateway route, Lambda construction)
is the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- New `GitHubScreenshotIntegration` construct (mirrors `LinearIntegration`):
  bundles the screenshot bucket, dedup table, signing-secret placeholder,
  receiver Lambda, processor Lambda, and the API Gateway route. cdk-nag
  suppressions added inline (HMAC auth instead of Cognito; AgentCore
  Browser sessions have no per-resource ARN; Secrets Manager rotation
  is owned by GitHub).

- Wired into `agent.ts` after the LinearIntegration block. Reuses the
  existing `githubTokenSecret` (the processor uses ABCA's main GitHub
  token to look up which PR a deploy SHA belongs to and post the
  screenshot comment — no new credential).

- Three new stack outputs: `GitHubWebhookUrl`, `GitHubWebhookSecretArn`,
  `ScreenshotBucketName`.

- Bumped agent.test.ts table count from 13 to 14 to account for the
  new dedup table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ot bucket

cdk-nag's S2 fires on any bucket that has `blockPublicPolicy: false`
even when the policy is intentionally permissive. Add the suppression
with the same rationale as S1/S5 — public reads are required by
GitHub Markdown renderers and Linear `imageUploadFromUrl`, and the
read grant is prefix-scoped to `screenshots/*`.

Caught when the first deploy attempt aborted at synth-time on the new
GitHubScreenshotIntegration construct.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The first deploy attempt failed at CFN-execute time on the bucket
policy:

  s3:PutBucketPolicy ... because public policies are prevented by
  the BlockPublicPolicy setting in S3 Block Public Access.

Account-level Block Public Access is on for this AWS account, which
overrides per-bucket BPA settings. Disabling it would change the
security posture of the whole account, so route around the constraint
with the AWS-recommended pattern: private S3 + CloudFront with Origin
Access Control.

Changes:
- `ScreenshotBucket` is now `BLOCK_ALL` BPA, no public bucket policy.
  Adds a `cloudfront.Distribution` whose origin is the bucket via
  `S3BucketOrigin.withOriginAccessControl`. The distribution policy is
  scoped to the CloudFront service principal only, so account-level
  BPA accepts it.
- Processor reads `SCREENSHOT_PUBLIC_HOST` (the CloudFront domain)
  instead of building an S3 URL. PR comments now embed
  `https://<dist>.cloudfront.net/screenshots/...` URLs.
- New stack output `ScreenshotCloudFrontDomain`.
- Bucket-level S2/S5 suppressions removed (no longer applicable —
  bucket is private). Distribution gets CFR1/CFR2/CFR3/CFR4/CFR7
  suppressions with rationales.

Heads up on deploy time: CloudFront distributions take 5-15 min to
provision on first create.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The CommonRuleSet was 403'ing GitHub deployment_status webhooks before
the request reached our Lambda — the deployment payload contains
absolute Vercel preview URLs in the body, which trips GenericRFI_BODY.

Mirror the Linear webhook exemption: the GitHub webhook path is
HMAC-verified in the Lambda, parsed as strict JSON, never
interpolated into SQL/HTML, and rate-limited by the priority-3 rule.
CRS still applies to every other route.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…loyment

GitHub's `deployment_status` webhook puts the deployed URL on the
*status* object, not the deployment itself. The deployment object is
immutable per (sha, environment); the status changes through the
deploy lifecycle (`pending` → `success`) and carries the URL only
once the deploy finishes.

Symptom: receiver kept short-circuiting `success` events from Vercel
with `{ok: true, skipped_no_url: true}` because we read the wrong
field. Verified by inspecting the webhook delivery payload via
`gh api .../deliveries/<id> --jq .request.payload.deployment_status` —
URL was there all along.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…dshake

Node 24's global WebSocket (from undici) does NOT support arbitrary
HTTP headers on the upgrade request — passing them as the second arg
gets silently ignored. AgentCore Browser's WSS handshake requires
SigV4-signed Authorization + X-Amz-* headers, so the connection was
opening but then getting rejected, which surfaced as an empty
`error` event ("AgentCore Browser WebSocket error: ").

Switch to the `ws` package which natively supports `options.headers`.
Also add an `unexpected-response` handler so HTTP-level handshake
failures (403, 400) surface with status codes instead of empty errors.

Smoke verified locally — the ws-based path opens cleanly against
example.com and Vercel preview URLs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lambda runtime returned a 403 on the WSS upgrade despite well-formed
SigV4 headers — `ws` rewrites the Host header during the upgrade
GET, which invalidates the canonical-request signature we computed
against the original Host. This works locally because Node's tooling
on macOS keeps the original Host through the handshake, but the
Lambda runtime's TLS stack normalizes differently.

Switch to query-parameter SigV4 (presigned URL): SignatureV4.presign
returns a wss://...?X-Amz-Algorithm=...&X-Amz-Signature=... URL where
the auth lives in the URL itself, so any Host-header rewriting
downstream doesn't break the signature.

Smoke verified locally — presigned URL connects cleanly to AgentCore
Browser and the screenshot pipeline runs end-to-end (6.3s, valid
PNG, captures example.com correctly).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The minimal IAM I shipped earlier (`StartBrowserSession`,
`StopBrowserSession`, `GetBrowserSession`, `UpdateBrowserStream`)
wasn't enough — the WSS automation-stream connect requires an
additional `ConnectBrowserAutomationStream`-flavored action that
isn't in the public CLI command list. Lambda invocations were
opening sessions cleanly but 403'ing on the WSS upgrade.

Widen to `bedrock-agentcore:*` to unblock the e2e flow. Followup:
scope back down to the specific connect action once it's documented
or surfaced via CloudTrail decoded-message-on-deny.

Smoke verified: PR #1 on isadeks/vercel-abca-linear now receives a
screenshot comment within ~7s of the deployment_status webhook.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends the screenshot processor to find a Linear issue via the PR's
title/body and post the same image comment there.

Approach (no GSI write-back needed):
- Regex-extract Linear identifier (e.g. `ABCA-42`) from PR title/body.
  These are present whether the agent put them there
  (`task_description` carries the identifier) or Linear's own GitHub
  integration auto-injected the back-reference on PR open.
- Scan `LinearWorkspaceRegistryTable` for `status=active` workspaces.
  Per-workspace, query Linear's `issueVcsBranchSearch` (which accepts
  the human-readable identifier) and accept the first exact-match
  hit.
- Post the markdown image comment via the existing `postIssueComment`
  helper from Phase 2.0b.

The Linear post is best-effort — if the registry table isn't wired,
the identifier doesn't extract, or the lookup misses, the GitHub PR
comment still lands. New env var `LINEAR_WORKSPACE_REGISTRY_TABLE_NAME`
is optional on the processor; the construct only sets it when the
prop is provided.

CDK: `GitHubScreenshotIntegrationProps` gains an optional
`linearWorkspaceRegistryTable`. When provided, the processor's IAM
grows: ReadData on the registry, GetSecretValue+PutSecretValue on
`bgagent-linear-oauth-*`. `agent.ts` wires
`linearIntegration.workspaceRegistryTable` into the screenshot
construct.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The command still pulled from the parked PAK secret
(`LinearApiTokenSecretArn`), which we removed in Phase 2.0b. Symptom:
`Could not find LinearApiTokenSecretArn in stack outputs.`

Rewrite to scan Secrets Manager for `bgagent-linear-oauth-*` secrets
and query each workspace's projects with its own OAuth token. Supports
`--slug <slug>` to scope to one workspace; without it, queries every
installed workspace and labels each project with its source.

Also: switch to the `Bearer <token>` auth header and the
`teams(first: 1) { nodes { name } }` shape (the old `team` field on
Project no longer exists in Linear's GraphQL).

Adds a `LINEAR_OAUTH_SECRET_PREFIX` const in `linear-oauth.ts` to
keep the secret-name contract in one place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Vercel posts the success `deployment_status` webhook the moment its
build finishes, which on the Linear-driven path is ~7-15s before the
agent's `gh pr create` returns. The processor's first lookup against
the GitHub commit-pulls API came back empty and we'd silently drop
the screenshot.

Add a retry wrapper with backoff (0s, 5s, 10s, 20s — total max ~35s)
around the PR lookup. The first hit returns immediately, so the
warm-cache happy path is unchanged.

Verified end-to-end on backgroundagent-dev: Linear issue ABCA-70 →
agent → PR #2 in vercel-abca-linear → Vercel preview → screenshot
landed on both the GitHub PR and the Linear issue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…issue comment spam

Move the trigger-label check ahead of every user-facing comment path in
the Linear webhook processor, and switch the default trigger label from
'bgagent' to 'abca'. An unlabeled issue is now a true no-op: no comment,
no reaction, no createTaskCore, no DDB writes — regardless of whether
the project is onboarded.

Why: workspace webhooks fire workspace-wide. A single un-onboarded team
in the same Linear workspace produced 47 identical "❌ project isn't
onboarded" comments on GRO-783 in 5 minutes because every Issue event
(create/update/label-change) hit the not-onboarded gate before the
label gate. With the gate order flipped, only issues that explicitly
opt in via the trigger label can ever generate user-facing feedback.

Per-project label_filter override is still respected — the project
mapping lookup now happens once, before the label gate, instead of after.

Tests: two new regression tests pin the spam scenario (unlabeled issue
in a non-onboarded project, and unlabeled issue with no projectId) to
zero side effects. Full CDK suite (89 suites / 1572 tests) passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Operator-facing setup walkthrough:
1. Connect Vercel to the GitHub repo
2. Vercel project settings (Git events on, Deployment Protection off
   for the demo, with a "production hardening" caveat for signed bypass)
3. Onboard the repo to ABCA (RepoTable put + bgagent linear onboard-project)
4. Configure the GitHub webhook (URL + secret from stack outputs,
   subscribe to Deployment statuses only)
5. Smoke test (label a Linear issue, watch screenshot land on PR + Linear)

Includes a troubleshooting section indexed by symptom (401/403 from
webhook, no comment lands, Linear post missing, CloudFront 403, Vercel
auth wall) and a forward-looking "production hardening" list for when
the feature graduates from demo.

Wires the new guide into the Starlight sync (docs/scripts/sync-starlight.mjs)
and sidebar (docs/astro.config.mjs).
Lets operators install the Linear OAuth app in additional workspaces
without re-pasting the same client_id/client_secret they already typed
during the initial bgagent linear setup.

The OAuth app's client_id/client_secret are workspace-independent —
Linear scopes consent per-workspace, not per-app. add-workspace scans
the LinearWorkspaceRegistryTable for the first active row and reads
those credentials from its per-workspace SM secret, avoiding the
re-prompt. Override flags (--client-id, --client-secret) cover the
edge case of running the same ABCA stack against two unrelated Linear
orgs that each have their own OAuth app.

Differs from setup in three ways:
- Refuses if no active workspace exists yet (use setup first)
- Skips the webhook-signing-secret prompt (one stack-wide secret
  covers all workspaces against the same OAuth app + receiver URL)
- Refuses to silently overwrite an already-onboarded workspace's
  registry row — a wrong-account login would otherwise produce a
  confusing duplicate

Adds findReusableOauthAppCredentials helper + 5 jest tests covering:
empty registry → null, happy path, missing client_id/secret in old
secrets → null, corrupted JSON → null, missing SecretString → null.
…space OAuth-app option

The previous version described the parked Phase 2.0a flow (AgentCore
Identity credential providers, oauth-register-workspace command, AWS-
hosted callback URL, https://localhost:8443 with self-signed cert).
None of that runs in the shipped 2.0b-O2 codebase — it's all per-
workspace Secrets Manager + plain HTTP localhost:8080 callback.

Changes:
- 'How it works' rewritten against the SM-direct flow, with a callout
  noting Phase 2.0a is parked
- Step 1 (oauth-register-workspace) deleted — not a real command
- Step 2 (Linear OAuth app) updated to point at localhost:8080/oauth/
  callback (the actual callback URL); flagged that app-template's
  printed value is still the parked-flow placeholder
- Step 4 (setup) rewritten to describe the PKCE → localhost:8080 →
  code exchange → SM upsert dance that actually ships
- Step 5 (webhook signing secret) folded into setup's interactive
  prompt as Step 3, matching how the wizard actually works
- Steps 6-8 renumbered to 4-6
- 'Adding additional Linear workspaces' expanded with the public-
  vs-per-workspace OAuth-app trade-off and the Option B path
  (--client-id/--client-secret overrides) for keeping apps private —
  this is the wrinkle that bit during demo-abca install where
  maguireb's private app rejected cross-workspace authorization
- Troubleshooting + quotas sections updated to reference SM secrets
  and the refresh+race-recovery flow rather than AgentCore Identity
- Stale Step 7 cross-references updated to Step 5

Followup task: update bgagent linear app-template to print
http://localhost:8080/oauth/callback as the default callback (today
it prints a placeholder for the parked AWS-hosted-callback flow).
…--client-secret flags

Secrets-on-command-line is a footgun: --client-secret leaks into
~/.zsh_history/.bash_history. The auto-detect-from-existing-workspace
default also wasn't always right — when each workspace runs its own
private OAuth app (the common case in multi-org production setups),
auto-detect silently picks the wrong credentials and fails with a
confusing "Could not find OAuth client" error after the OAuth dance.

New flow:
- Always prompt. Find any existing active workspace, show its
  client_id as the default in [brackets].
- Press Enter to accept the default (single shared OAuth app installed
  in multiple workspaces — the public-app case).
- Type a new client_id to install with a different OAuth app per
  workspace (the private-app case). Then promptSecret for the new
  client_secret.
- If the user typed the same client_id as the default, reuse the
  existing client_secret without prompting (no point asking the user
  to re-paste a secret we already have).

New helper promptLine(label, defaultValue?) for non-secret input with
default-on-empty semantics. promptSecret unchanged — used only for
client_secret.

Removes the --client-id and --client-secret flags entirely. Existing
flags retained: --region, --stack-name, --no-browser, --no-actor-app.
The interactive prompt previously printed 'found <slug>' and named the
source workspace in the explanation hint. The slug still appears as the
default value in [brackets] (structurally necessary), but no longer
leaks into instructional prose where a generic phrasing works just as
well.
… promptSecret

Previous implementation used readline.createInterface + rl.close(), which
leaves stdin in EOF state. Chaining a promptLine call followed by a
promptSecret call (which add-workspace does for client_id then
client_secret) makes the second readline interface fire 'close'
immediately and reject with 'No input provided.'

Switch to the same raw-mode stdin pattern as promptSecret: register a
'data' handler, accumulate characters, echo each one (visibly, since
this is for non-secret input), unwind cleanly on Enter. Both prompts
now manage stdin consistently and chain without state leakage.
The command still pulled from the parked PAK secret
(`LinearApiTokenSecretArn`), which we removed in Phase 2.0b. Symptom:
`Could not find LinearApiTokenSecretArn in stack outputs.`

Rewrite to scan Secrets Manager for `bgagent-linear-oauth-*` secrets
and query each workspace's projects with its own OAuth token. Supports
`--slug <slug>` to scope to one workspace; without it, queries every
installed workspace and labels each project with its source.

Also: switch to the `Bearer <token>` auth header and the
`teams(first: 1) { nodes { name } }` shape (the old `team` field on
Project no longer exists in Linear's GraphQL).

Adds a `LINEAR_OAUTH_SECRET_PREFIX` const in `linear-oauth.ts` to
keep the secret-name contract in one place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
'No Linear projects visible to any installed workspace' read like an
OAuth-scope or IAM problem when the API call succeeded — the workspace
just has zero projects. Differentiate the single-workspace and
multi-workspace cases and tell the user what to do (create a project).
Linear generates a fresh signing secret per webhook subscription, and
webhook subscriptions are workspace-scoped. The previous single
stack-wide LinearWebhookSecret could only verify events from the
workspace that owned its value — events from any other workspace
silently failed signature verification. add-workspace shipped earlier
today made this concrete: demo-abca couldn't trigger tasks because
its events failed verification against maguireb's signing secret.

Schema: add optional `webhook_signing_secret` to StoredOauthToken (TS
Lambda) and StoredLinearOauthToken (TS CLI). Optional preserves
back-compat with installs predating this change. Cross-language
parity test extended to allow optional fields and check that the
required-fields validator const matches the interface's required set.

Webhook receiver: parse body once, peek at organizationId
(untrusted — used only to select WHICH secret to verify against),
call new verifyLinearRequestForWorkspace which returns 'verified',
'mismatch', or 'no-per-workspace-secret'. On 'verified': dispatch.
On 'mismatch': reject 401 (NO fallback — would let an attacker
bypass the per-workspace secret by also matching stack-wide). On
'no-per-workspace-secret': fall through to the existing stack-wide
verifyLinearRequest path for back-compat.

CDK: webhook receiver Lambda gets registry table read + SM
GetSecretValue on the bgagent-linear-oauth-* prefix +
LINEAR_WORKSPACE_REGISTRY_TABLE_NAME env var. Stack-wide secret
left in place (single-workspace fallback path).

CLI setup: now writes the webhook signing secret to BOTH the per-
workspace OAuth bundle AND (on first install only) the stack-wide
secret. Re-running setup on an existing single-workspace install
auto-mirrors the stack-wide value into the per-workspace bundle —
zero-config migration. --rotate-webhook-secret re-prompts.

CLI add-workspace: always prompts for the workspace's signing
secret (no shared secret to reuse). Refuses to overwrite the
stack-wide secret since multi-workspace installs can't
meaningfully share one stack-wide value.

Tests:
- Multi-workspace test file with 6 cases including the critical
  cross-workspace impersonation rejection (workspace A signed with
  workspace B's secret → 401, lambda not invoked).
- Single-workspace back-compat: registry miss → fallback works.
- Migration mid-state: bundle without webhook_signing_secret →
  fallback works.
- Revoked workspace + no fallback match → 401.

Trust model preserved end-to-end: organizationId in body is
attacker-controlled but only selects the secret; signature still
gates everything. Documented in LINEAR_SETUP_GUIDE.md "How webhook
signature verification works".
The OAuth dance can't be re-run when an app is already installed in a
Linear workspace — Linear returns access_denied. That makes
\`setup --rotate-webhook-secret\` and \`add-workspace\` both unusable
for the common case of "this workspace works, but the webhook signing
secret needs to change." Use cases:

1. Rotation (security policy, planned cycle, key compromise)
2. Recovery from misconfig (typed wrong, copied from wrong page)
3. First-time set after Linear regenerated the signing secret on
   webhook recreation

The new command:
- Reads the existing per-workspace OAuth bundle from SM
- Prompts for the new signing secret (validates lin_wh_ prefix)
- Re-upserts the bundle with merged webhook_signing_secret + bumped
  updated_at

What it doesn't do: OAuth dance, DDB writes, stack-wide secret
writes, Linear API calls. Just the SM mutation. Per-workspace only —
mirrors the architectural choice from the multi-workspace fix that
the stack-wide secret is reserved for the FIRST install's
back-compat fallback.

Docs: troubleshooting section now points at this command for
"webhook signature verification fails repeatedly" — the most common
production path. The previous guidance to re-run setup
--rotate-webhook-secret remains the right primitive for
single-workspace deploys that haven't fully migrated.
…hook-secret

The flag's job — re-prompt for the signing secret on an already-installed
workspace — is now done better by \`bgagent linear update-webhook-secret\`,
which skips the OAuth dance entirely. Keeping the flag means two tools
that do nearly the same thing, and forcing the user to redo OAuth just
to type a new signing secret is wasteful.

setup's webhook flow simplifies to: stack-wide already set → mirror
into per-workspace bundle (auto-migration); else prompt + write to
both. No conditional flag-based branching.

Docs updated:
- Step 3 footnote points at update-webhook-secret for rotation
- 'How webhook signature verification works' single-workspace
  migration note: setup auto-mirrors on next run, no flag needed
…linear-vercel

# Conflicts:
#	cdk/package.json
#	cli/src/commands/linear.ts
The 'Adding additional Linear workspaces' section listed operations
as bullets but didn't sequence them in an actionable way. Multi-
workspace onboarding crosses three contexts (CLI, Linear OAuth app
config, Linear webhook config) and bullet-lists left it unclear
which steps to do where, in what order.

New layout:
- Brief 'Decide: shared vs per-workspace OAuth app' table up front
- Numbered walkthrough that interleaves the CLI + Linear browser
  work in the order you actually need to do them, including the
  pause-at-prompt-then-switch-to-browser step for the webhook
- Two FAQs at the end ('what if I skip step 4?' and 'what if I
  typed the signing secret wrong?') for the common gotchas

Also drops the stale --client-id / --client-secret command examples
that referenced the flags removed in ac5ce67. The walkthrough now
points the user at the interactive prompts directly.
The 'Adding additional Linear workspaces' section listed operations
as bullets but didn't sequence them in an actionable way. Multi-
workspace onboarding crosses three contexts (CLI, Linear OAuth app
config, Linear webhook config) and bullet-lists left it unclear
which steps to do where, in what order.

New layout:
- Brief 'Decide: shared vs per-workspace OAuth app' table up front
- Numbered walkthrough that interleaves the CLI + Linear browser
  work in the order you actually need to do them, including the
  pause-at-prompt-then-switch-to-browser step for the webhook
- Two FAQs at the end ('what if I skip step 4?' and 'what if I
  typed the signing secret wrong?') for the common gotchas

Also drops the stale --client-id / --client-secret command examples
that referenced the flags removed in ac5ce67. The walkthrough now
points the user at the interactive prompts directly.
The setup guide kept telling users to find the API URL in CFN outputs
or substitute their own region/account into a placeholder. The CLI
already has the URL — make it surface it.

\`bgagent linear webhook-info\` reads config.api_url and prints the
webhook URL plus the values to paste into Linear's webhook UI, plus
the followup command (\`update-webhook-secret\`). Read-only,
no AWS calls beyond what the existing config layer already does.

Setup guide trimmed:
- Step 1: removed the parked-flow footnote (fixed at the source by
  defaulting app-template's callback URL to http://localhost:8080/
  oauth/callback instead of the parked AgentCore Identity placeholder)
- Step 2: collapsed the 6-bullet wizard description into 2 sentences
  that describe what the user actually does, dropping the
  \`--client-id\` / \`--client-secret\` flag mention (those flags
  never existed on setup; they were on add-workspace and got removed
  earlier this branch)
- Step 3: now references webhook-info as the single source for what
  to paste into Linear, dropping the embedded URL template and CFN
  output instructions
- Multi-workspace step 4: same — references webhook-info instead of
  embedding a URL template
- Dropped two long callout blocks that explained internals operators
  don't need at setup time (where the OAuth token lives, where the
  signing secret is mirrored — covered in 'How webhook signature
  verification works' for those who want it)

Net: -51 lines from the guide, one new always-printable command, no
more URL substitution by the user.
…runbook

LINEAR_SETUP_GUIDE.md was 405 lines and described the same flow twice
(once for first-install single-workspace, once for multi-workspace).
The two walkthroughs only differed in `setup` vs `add-workspace`,
which is a single-line CLI difference. The PAK migration block was
46 lines for a release that's already shipped — anyone reading the
guide today is on 2.0b.

Net result: 405 → 215 lines (-47%). One unified walkthrough that
calls out the setup-vs-add-workspace branch at step 3, drops dead
sections (Out-of-scope items, "What's coming next" — neither was
load-bearing), folds the link-your-Linear-account step into a short
section near the end since most users hit the auto-link path
anyway.

Net changes:
- One walkthrough instead of two (single-workspace + multi-workspace
  collapsed; option A/B trade-off comes up at the relevant step).
- PAK migration runbook moved to LINEAR_PAK_MIGRATION_RUNBOOK.md +
  registered in astro sidebar so it's still findable but doesn't
  block-quote the main guide.
- Dropped "Out of scope in v1.x" + "Limits and budgets" tables —
  the rate-limit info is now one paragraph, the rest was noise.
- Fixed "Removing the integration" — it was using the parked
  AgentCore-Identity delete-oauth2-credential-provider call. Now
  uses Secrets Manager + DDB directly.
- Dropped the "Linear actor has no linked platform user" cross-link
  (was self-referential to the previous Step 5).
bgagent and others added 27 commits June 17, 2026 08:45
…#247 #58 deploy-block)

Deploying #57 rolled the stack back on AWS::Logs::DeliverySource
'AlreadyExists': the agentcore-alpha Runtime auto-creates DeliverySource
+ Delivery + DeliveryDestination per loggingConfig, and a construct-path
rename across alpha builds churned BOTH the CFN logical IDs AND the
account-scoped DeliverySource/DeliveryDestination Names. Because those
Names are account-unique, CFN's create-before-delete on the churned ids
hits AlreadyExists and rolls the whole stack back. (Not a #57 bug — any
deploy was blocked.)

Fix: pin the 4 account-name-unique log resources (2 DeliverySource +
2 DeliveryDestination) to their DEPLOYED logical IDs + Names via
overrideLogicalId + addPropertyOverride('Name', …), so CFN sees them as
the SAME resources and updates in place (diff: [~] DependsOn-only, no
replacement). The 2 CfnDelivery links have no Name and Ref the pinned
ids → harmless replace. Log delivery is stateless routing config.
Best-effort tryFindChild so a future alpha rename silently no-ops.

cdk diff confirms: Source/Dest = [~] in place (no AlreadyExists);
Delivery = [-]/[+] harmless. agent.test.ts (44) green.
…block, round 2)

Round-1 pinning fixed the DeliverySource/DeliveryDestination collision —
all 4 updated in place — but the rollback just moved one resource
downstream to AWS::Logs::Delivery ('identifier null already exists').
A Delivery has no Name, but it IS unique per (source, destination) pair;
with the source+dest now pinned, the churned Delivery logical id tried to
create-before-delete a SECOND link over the same pair → AlreadyExists.

Pin the 2 Delivery links' logical IDs too (logical-id only — no Name).
pinLogResource's liveName is now optional. cdk diff: all 6 log resources
[~] in place (Delivery no longer [-]/[+]); the inner [-]/[+] is just
DependsOn reordering. agent.test.ts (44) green.
… + panel preview deep-links (#247 UX.16 + UX.17)

Live-caught on the ABCA-301 fan-out epic: the synthetic integration node
has NO Linear sub-issue of its own, so its work spilled THREE standalone
comments onto the PARENT epic — '🤖 Starting integration…', '🔗 PR
opened: #191', '🖼️ Preview screenshot' — a comment stream that violates
the locked 'one maturing panel, no comment stream' spec and 100%
duplicates what the panel already shows (Integration row ✅ + Combined PR
+ Combined preview). Separately, the panel's combined preview embedded the
image but had no clickable link to the running combined deploy.

UX.16 — stop the flood (two emitters):
- agent: prompt_builder._channel_prompt_addendum now returns '' when
  channel_metadata has no linear_issue_id. The integration node is the
  only Linear task without one (orchestration-release omits it on
  purpose), so the agent no longer gets the 'post Starting/PR-opened to
  Linear' instructions → no groping onto the parent. Real sub-issues are
  unaffected (they have linear_issue_id).
- screenshot pipeline: persistScreenshotUrl now returns whether the deploy
  task is an integration node (read from the UpdateItem ALL_NEW
  channel_metadata.orchestration_sub_issue_id — no extra Get), and the
  processor SKIPS the standalone Linear screenshot comment for it. The
  GitHub PR comment still posts (load-bearing on the PR); the panel embed
  is the only Linear surface for the combined result.

UX.17 — panel preview deep-link:
- persist screenshot_preview_url (the live Vercel/Netlify deploy URL the
  shot was captured from) alongside screenshot_url; thread it through
  resolveCombinedScreenshotUrl → upsertEpicPanel → renderEpicPanel.
- the panel's combined preview is now a clickable linked image
  ([![combined preview](png)](preview)) + an 'Open the combined preview'
  link. Falls back to the plain image when no preview URL is known. The
  preview URL is payload-derived → parens percent-encoded (markdown
  breakout defense, same as the screenshot comment renderers).

Tests: agent test_linear_integration_node_gets_no_addendum (+ existing
linear test now carries linear_issue_id); processor 'persists BOTH urls'
+ 'integration node deploy persists but posts NO standalone Linear
comment'; renderEpicPanel deep-link + paren-encode + fallback; reconciler
#57 test extended to assert the deep-link. Also refreshed 8 stale
postRollup mocks to the merged LinearPostResult {ok} contract (merge
left them returning bare booleans → 5 pre-existing reds, now green).
cdk:compile clean; agent suite 1116 green.
…to the sub-issue they name (#247 UX.18)

Live-caught on ABCA-304: a reviewer commented '@bgagent for the footer
can you change it to ...' on the PARENT epic (the maturing panel lives
there, so that's the natural place to comment) and it was SILENTLY
DROPPED. The parent epic has no PR of its own, so the comment-trigger
fell through to the standalone GSI path, found no task for the parent
issue, and ignored it ('issue has no ABCA task — ignoring').

Fix: in handleCommentTrigger, detect when the commented issue is itself
an orchestration PARENT (deriveOrchestrationId(issueId) loads an
orchestration whose meta.parent_linear_issue_id === issueId — a pure
hash, so the parent's own id maps to its orchestration; a sub-issue's
doesn't). Route to handleParentEpicCommentTrigger:
 - 👀 on the comment IMMEDIATELY — a parent comment is never silently
   dropped again.
 - parseParentNodeReference(instruction, children) picks the target
   sub-issue: Linear identifier (ABCA-305) wins outright, else a
   significant (non-noise) title keyword ('footer' → 'Add a site-wide
   footer'). Exactly one started node with a PR → iterate it via the
   shared iterateOrchestrationChild (same pr-iteration + cascade marker
   + threaded ✅/❌ reply as commenting on the sub-issue directly).
 - 0/ambiguous match, or matched node has no PR → post a threaded reply
   on the parent: a best-effort 'did you mean <X>?' suggestion, the list
   of sub-issues + how to target one (@bgagent ABCA-123: ...), and the
   'create a sub-issue for NEW work' path. NEVER auto-creates an issue
   and NEVER silently drops (user's call: ask, don't create).

Refactor: extracted the per-child iteration into iterateOrchestrationChild
(skipAck/prNumber params) so the direct sub-issue path and the new
parent path share one code path.

New pure module orchestration-parent-comment.ts (parseParentNodeReference
+ suggestClosestNode + renderParentDisambiguationReply), 16 unit tests
incl. the exact live case. 4 handler-wiring tests (live case, identifier
targeting, ambiguous→ask, no-match→ask). Existing A6 sub-issue +
standalone trigger tests unchanged + green (36→40 in the orch suite).
cdk:compile clean.
…-routed iteration's ✅/❌ reply lands (#247 UX.19)

Live-caught on ABCA-304 immediately after UX.18 shipped: a comment left
on the PARENT epic was routed to the footer sub-issue and an iteration
spawned (👀 ack + 'routed to sub-issue … pr_number 193'), but the
iteration then FAILED (transient GITHUB_UNREACHABLE) and NO ❌ reply
appeared — the human saw 👀 then silence.

Root cause: replyToIterationComment posted the ✅/❌ reply with
issueId = changedSubIssueId (the sub-issue, ABCA-305), but the trigger
comment lives on the PARENT epic (ABCA-304). Linear's commentCreate
rejects a threaded reply whose parentId belongs to a different issue, so
the reply silently failed. (The cascade-skip on FAILED was correct; the
reply, which runs BEFORE the success gate, was the casualty.)

Fix: thread trigger_comment_issue_id (the issue the human actually
commented on) through channel_metadata —
 - iterateOrchestrationChild adds it (defaults to the sub-issue id; the
   parent-epic path passes snapshot.meta.parent_linear_issue_id).
 - parseTerminalTaskRecord reads it into TerminalTaskEvent.
 - replyToIterationComment uses it as commentCreate's issueId, falling
   back to changedSubIssueId for pre-UX.19 tasks.
Direct sub-issue comments are unaffected (issue id == sub-issue). The
standalone fanout reply path already replies on linear_issue_id = the
comment's own issue, so no change needed there.

Tests: reconciler 'PARENT-routed iteration replies on the PARENT issue
not the sub-issue'; webhook wiring asserts trigger_comment_issue_id =
the parent. 78 green across reconciler+webhook+matcher suites.
…stop webhook-redelivery reply spam (#247 UX.20)

CRITICAL live-caught: a no-match @bgagent comment on the parent epic
spammed 50+ duplicate disambiguation replies. Root cause: the UX.18
parent-comment handler posts its reply (and 👀) with NO idempotency
guard, and Linear REDELIVERS a comment webhook whenever the handler
exceeds its ~5s ack window (this path does several Linear API calls and
ran 7.7s). Each redelivery re-ran the whole handler → another reply. The
iteration path is deduped by ack_replied_at; the disambiguation path I
added in UX.18 had nothing. (Mitigated live by throttling the Linear
receiver+processor Lambdas to 0 concurrency; 49 spam comments deleted.)

Fix: new store op claimCommentAck — a conditional create-once write keyed
on (orchestration_id, 'ack#<commentId>') with a TTL, mirroring claimRollup.
handleParentEpicCommentTrigger claims BEFORE any side-effect; only the
first delivery proceeds (👀 + match/route/reply), redeliveries no-op. The
marker self-expires via the table's existing ttl attribute.
loadOrchestration now excludes any 'ack#…' (and any '<kind>#' marker) SK
from the children list so the dedup rows can't be mistaken for sub-issues
(real child SKs are UUIDs or '…__integration', never contain '#').

Tests: claimCommentAck (first-wins / redelivery-loses / error-propagates);
loadOrchestration excludes ack# rows; webhook 'redelivery posts EXACTLY
ONE reply' + 'matched iteration dedups to one task'. 58 green across
store+webhook suites.
…he self-reply loop (#247 UX.20 root cause)

The 50-reply spam was NOT webhook redelivery (my first UX.20 theory) — it
was a genuine INFINITE SELF-TRIGGER LOOP: the parent-epic disambiguation
reply embeds a literal example '@bgagent ABCA-123: <what to change>'. That
reply is itself a Linear comment → fires a Comment webhook →
parseCommentTrigger's /@bgagent/ regex matched the mention INSIDE the
bot's own reply → posted another disambiguation reply (also containing
@bgagent) → … ~50 deep. Each iteration was a NEW comment with a NEW id, so
the per-comment ack-claim (UX.20 round 1) couldn't dedup it — it guarded
redelivery, not self-reference.

Root fix (the guard the Linear path never had — Slack already skips its
own bot_id messages to avoid exactly this): parseCommentTrigger now
returns NOT-triggered for any comment whose trimmed body starts with one
of the bot's own template markers (👋 ✅ ❌ ⚠️ 🔄 🤖 🖼️ 🔗) — exported
isBotAuthoredComment(). The bot can never act on a comment it authored,
regardless of what example text the body contains. The pre-existing A6
trigger never looped only because the agent's progress comments happen
not to contain @bgagent; UX.18's reply was the first bot comment that did.

The UX.20-round-1 ack-claim (claimCommentAck) stays — it's still correct
defense against genuine webhook redelivery; this is the orthogonal,
primary fix for self-reference.

Tests: the EXACT spam body (👋 + embedded @bgagent example) → not
triggered; every template prefix → bot-authored; a real human @bgagent →
still triggers; leading-whitespace marker still caught. 41 green across
trigger+webhook suites.
…ation done — 👀→✅/❌ + In Review (#247 UX.21)

User-caught, three symptoms one cause: after a comment-iteration finishes,
(1) the trigger comment stayed on 👀 forever (never ✅), (2) the sub-issue
state flapped In Progress/In Review, and (3) the panel showed ✅ (correct,
reads child_status) while the sub-issue said In Progress — three views
disagreeing. Root cause: the reconciler posted the threaded ✅/❌ reply but
never settled the comment REACTION or the sub-issue STATE; state was
delegated to the AGENT via a prompt instruction (non-deterministic, raced,
got stuck).

Fix: the platform now owns settlement (as it already does for the parent
epic's reaction/state). In replyToIterationComment, after the reply:
 - swap the TRIGGER comment 👀 → ✅ (success) / ❌ (failure) so the comment
   reads done at a glance, not just the threaded reply.
 - on success, advance the SUB-ISSUE to In Review (PR updated & open,
   awaiting human merge — same convention the epic uses; user's choice).
   On failure, leave the state (never demote). The reaction/state target
   the actual trigger comment + the iterated sub-issue, so a parent-routed
   comment settles the parent's comment + the footer sub-issue correctly.
Gated once-only by the existing ack_replied_at claim; both helpers are
idempotent (re-converge to one marker / skip if already in target state).

New helper swapCommentReaction (mirrors swapIssueReaction but on a COMMENT
— queries comment(id){reactions}, deletes stale bgagent markers, adds the
target; never touches a human reaction). Tests: helper (swap/idempotent/
human-safe/no-token) + reconciler success→✅+In Review, failure→❌+no
transition. 80 green across linear-feedback + reconciler suites.
…d migration shim (no more hardcoded-into-every-stack)

User flagged the hardcode: the #58 fix pinned backgroundagent-dev's live
CloudFormation logical IDs + account-unique Names directly into agent.ts,
applied UNCONDITIONALLY. The old comment falsely claimed a fresh stack
"just deploys clean" via tryFindChild no-op, but the children DO exist on
every stack, so it would force ANY stack (a fresh deploy, another account,
CI, this code on main) to adopt the dev stack's identities. Wrong.

Why the pins exist at all: only a stack deployed BEFORE the agentcore-alpha
bump has pre-existing churned resources to collide with (AlreadyExists on
create-before-delete). A fresh stack has nothing to collide with and MUST
synth the current alpha's natural ids.

Fix: PINNED_LOG_DELIVERY_BY_STACK keyed by stackName (only
"backgroundagent-dev" listed) plus maybePinChurnedLogResources() that
applies the overrides ONLY when the running stackName matches (or a
"-c pinnedLogDeliveryStack=name" context selects it). Every other stack
gets no overrides and a pristine synth. Verified: real backgroundagent-dev
synth still pins (deploy stays unblocked); a different stack name synths
natural alpha ids. Delete the table entry + helper once the dev stack is
migrated. agent.test.ts (44) green.
… in gitleaks

CI secret-scan (gitleaks generic-api-key) false-positived on the
'orch_abc_SUB-1'-style idempotencyKey fixtures in the orchestration tests
(orchestration-release.test.ts / orchestration-reconciler.test.ts) — these
are made-up test values, not credentials. Scope a targetRules+paths
allowlist to those two test files, mirroring the existing wat-opaque-123 /
test-signing-secret-abc123 fixture exemptions. Range scan: no leaks found.
CI 'build (agentcore)' failed on //agent:lint (6 ruff errors that landed
across the #1 build-command + upstream-merge commits, committed without
the agent lint gate passing):
 - E501 ×4: config.py _KNOWN_WRITEABLE_WORKFLOW_IDS, test_prompts.py
   restack-assert + workflow-id loop, test_verify_commands.py real-failure
   fixture → wrapped.
 - PLR2004: post_hooks.py magic 127 → new SHELL_COMMAND_NOT_FOUND const.
 - SIM108: repo.py if/else default_branch → 'or' expression.
Then applied ruff format (the CI self-mutation guard enforces format, not
just check) — normalized config.py/repo.py/test_repo.py/test_verify_commands.py.
ruff check + ruff format --check both clean; agent suite 1116 green.
… debug printer (#247 PR #373 CodeQL high)

CodeQL flagged py/clear-text-logging-sensitive-data (HIGH) at
scripts/orchestration_debug.py:53 — the parent-meta printer derived
has_oauth via m.get('linear_oauth_secret_arn'), which READS the secret
ARN value (CodeQL's taint source) even though only 'yes'/'no' is printed.
The secret never actually reached output, but the value-read started the
taint. Fix: test KEY PRESENCE ('linear_oauth_secret_arn' in m) so the
secret string is never accessed at all — no taint source, and the printed
line provably logs only the parent issue id, repo, user id, and a
presence flag. Debug-only script (DynamoDB pretty-printer), not prod code.

Resolves the 1 high-severity CodeQL alert on PR #373.
…nted false positive (#247 PR #373)

User chose to leave the HIGH py/clear-text-logging-sensitive-data alert
red + document it (it's a dev-only debug printer; logs only ids + a
yes/no oauth-present flag, never the secret value). Keep the key-presence
check ('in m' — secret value never read) but correct the comment: CodeQL
still flags it because it taints the whole stdin dict and follows any
s(m,…) read into a print. Documented in PR #373's 'before merge' list for
a maintainer to dismiss.
CI 'build (agentcore)' failed on //cdk:eslint with 24 problems. Fixes:

SOURCE (real named constants for no-magic-numbers):
 - MAX_IDEMPOTENCY_KEY_LENGTH (128) for the 3 synthesized idempotency-key
   slices (linear-webhook-processor ×2, orchestration-reconciler restack).
 - DDB_BATCH_WRITE_MAX_ITEMS (25) + ORCH_ID_HASH_HEX_LENGTH (32) in
   orchestration-store; MIN_ABCA_BRANCH_SEGMENTS (3) in screenshot-url;
   DLQ_RETENTION_DAYS (14) + SWEEP_TIMEOUT_MINUTES (5) in the two
   reconciler constructs.

TESTS (config intent): extend the existing test-file eslint override —
which already turns off no-magic-numbers for test/**/*.ts — to also relax
max-len and no-shadow (tests legitimately have long fixture/assertion
lines and reuse small helper names like `row`/`makeDdb` across sibling
describe blocks). Acknowledged the e2e Promise.all over a fixed 2-element
array with the cdklabs/promiseall disable.

Also folds in eslint --fix reformatting (multi-line object/import
expansion) across the orchestration test + handler files so the tree
matches post-fix output (CI's self-mutation guard requires this).

NOTE: one eslint error remains on CI — import-x/no-unresolved for
@aws-cdk/integ-tests-alpha in test/integ/integ.task-api-smoke.ts. That
file is BYTE-IDENTICAL to upstream/main (green there) and the dep IS
declared in package.json; the error is a node_modules cache/install
artifact (the alpha dep wasn't in CI's restored cache). Verified locally:
a fresh `yarn install` fetches the dep and cdk eslint is fully clean.

agent ruff + cdk compile clean; affected suites 137 green.
…ruff's frozenset formatting (#247 PR #373)

The workflows contract test (CDK descriptors ↔ agent config) parses
_KNOWN_WRITEABLE_WORKFLOW_IDS out of agent/src/config.py with a regex that
required frozenset((…)) with adjacent parens. When the earlier lint pass
ran ruff format on config.py it expanded the frozenset to multi-line
(frozenset(\n  (\n    "…",\n  )\n)), so the regex returned null and the
test failed (1 of 2574). Fix: tolerate whitespace between the parens
(frozenset\(\s*\(…\)\s*\)) so it matches either single- or multi-line
formatting. 21 workflows tests green; regex verified against the live
config.py shape.
…st-GA) interaction channel (#56)

Design-only scoping of the actor=app / agent-session migration. Records:
- VERIFIED LIVE (2026-06-17): both deployed workspace tokens carry
  'read write app:assignable app:mentionable' → bgagent is already an app
  actor; the auth half of the migration is DONE, zero work.
- Linear is an interaction layer, not compute — switching changes trigger
  + status display only; all compute stays on ABCA's AgentCore/ECS.
- GO/NO-GO: adopt agent sessions as an ADDITIONAL flag-gated trigger/ack
  channel ONCE Linear GAs the Agents API (currently Developer Preview),
  reusing the channel-agnostic #247 engine. Do NOT rip out the now-hardened
  comment path. The win is real but partial — it retires the brittle
  string-match-trigger + hand-rolled-ack seam, but the parent-epic panel /
  cascade / base-branch engine stays ours (no native cross-issue rollup).
- Activity model (thought/action/response/elicitation/error + session
  states) maps cleanly onto the ack states #247 already built.

Includes the regenerated Starlight mirror (docs:sync). No code changes.
….24)

Time-boxed no-infra spike against the deployed app-actor token:
- API reachable: agentActivityCreate, agentSessionCreateOnIssue,
  AgentSession type, AgentActivityType enum all present + match docs.
- agentActivityCreate accepts our {agentSessionId, content:{type:thought,
  body}} input (failed only on session-id lookup, not schema) — ack-emission
  half proven callable.
- BLOCKER (config, not code): agentSessionCreateOnIssue → 'Agent sessions
  are not enabled for this application'. App needs the 'Agent session events'
  webhook category enabled in Linear Application settings (app-owner action).
- 10s-ack-vs-long-compute risk not yet proven E2E (needs a real session id,
  gated on enablement) — but its components are confirmed callable.
Throwaway issue created + deleted; app token scrubbed. No migration code.
…ong-compute risk (UX.24)

After the app owner enabled 'Agent session events':
- agentSessionCreateOnIssue now succeeds (status active).
- 10s risk RESOLVED: thought at t+0 → active, then a 14s no-activity gap →
  STILL active (not stale). The 10s rule is initial-ack-only; our webhook
  emits the thought synchronously like today's 👀, then the >10s async spawn
  proceeds — no architectural conflict.
- Full lifecycle derives: thought/action→active, response→complete,
  elicitation→awaitingInput, error→error (all 5 types accepted; states
  auto-derive). Maps 1:1 to the #247 ack model.
Remaining gate for an additive channel is the per-issue-session vs
cross-issue-epic-rollup gap + Preview→GA wait, NOT a technical blocker.
Spike issues created+deleted; token scrubbed; no migration code.
Live finding on ABCA-310: with 'Agent session events' left ON after the
UX.24 spike, every @bgagent mention also spawns a native agent session.
Our deployed comment path replies (👀 + reply) but emits no session
activity, so the session goes stale and Linear shows a misleading
'bgagent did not respond' banner despite the comment posting fine.

Consequence: adoption is not additive-for-free — the toggle must stay OFF
until the flag-gated adapter ships in the same change that flips it.
Interim: turn the toggle off (app owner).
Linear-channel tasks now know how (and when) to discover paperclip
attachments, project documents, and post-task-start comments via the
already-loaded `mcp__linear-server__*` tools. The webhook processor
issues a single GraphQL probe per triggered issue to flag presence of
attachments / project docs and prepends a one-line hint to the task
description so the agent has a reason to invoke the MCP. No new IAM,
tables, env vars, or CDK constructs — purely prompt + GraphQL.

The screened description-image pipeline (`extractImageUrlAttachments`)
is unchanged; embedded `![alt](https://…)` images still go through
Bedrock Guardrails at task-creation time. The new MCP path is
additive coverage for paperclips, project wikis, and live comments.

(cherry picked from commit 672bfa6)
Issue descriptions can carry markdown image references that
`extractImageUrlAttachments` (linear-webhook-processor.ts) turns into
URL attachments on the createTaskCore call. The shared `create-task-core`
path uploads the screened bytes to `ATTACHMENTS_BUCKET_NAME`, mirroring
the TaskApi and Slack flows — but `LinearIntegration` was the only
construct that never received the bucket reference, so any Linear
trigger with an image in the description failed at task creation with
503 "Attachment storage is not configured."

Add `attachmentsBucket` to `LinearIntegrationProps`, set the env var on
the webhook processor when present, and grant `grantPut` + `grantDelete`
to match the TaskApi pattern. Construct test locks in both the env var
and the IAM grants.

(cherry picked from commit 85aae22)
Markdown image URLs from Linear's CDN (`uploads.linear.app`) require the
workspace's OAuth token to fetch — the orchestrator's URL-resolver runs
unauthenticated and was 401ing, killing every Linear-with-image task
before the agent ever started:

  Hydration failed: AttachmentResolutionError: URL attachment fetch
  failed with status 401: https://uploads.linear.app/…

Filter Linear-hosted URLs out of the pre-fetch list. The agent picks
these up at runtime via `mcp__linear-server__extract_images` (which
mints fresh signed URLs the agent can fetch with `WebFetch`), so
removing them from the pre-fetch path doesn't lose coverage — it just
shifts the fetch from "Lambda with no auth" to "agent with the OAuth
token."

Trade-off: those bytes skip the input-Guardrail screening pass that
runs at task-creation time. The description text is still screened.

(cherry picked from commit 009d817)
DEM-9 (2026-05-27) hit a real failure mode: the agent posted "🔗 PR
opened" + "✅ Task completed successfully" and claimed it transitioned
the issue to In Review, but the issue stayed in In Progress. Root
cause: the DEM team's workflow has no In Review state. When
`mcp__linear-server__save_issue` gets a state name that doesn't exist
on the team, it silently returns 200 with the issue body unchanged,
so it looks like the call succeeded.

Update the Linear-channel prompt to tell the agent to:
  - cache `list_issue_statuses` once per task instead of looping;
  - check the cached state names before each transition and pick the
    closest available one if the requested name doesn't exist;
  - verify the response `state.name` matches what was asked, and NOT
    claim success when the state didn't actually move.

Also forbid embedding `uploads.linear.app/...` URLs in `save_comment`
bodies — Linear's CDN signed URLs don't render when re-embedded by a
bot, producing the broken-image icon you saw on the DEM-9 PR comment.
GitHub's image proxy caches the bytes so the same URL works in PRs;
agent should link to the PR instead.

(cherry picked from commit c0a9d77)
…e-context-probe

The attachments-probe file was authored before deploy/247-dev flipped
no-magic-numbers to error; the inline 5s (title-list cap) now trip eslint.
Name the constant. No behavior change.
…vercel

linear-vercel had diverged into a stale parallel fork (37 commits behind
main, missing the workflow-driven-tasks system + Jira that #247 depends on).
Main has since absorbed lv's screenshot pipeline (SigV4-presigned WSS,
private S3+CloudFront, de-Vercel-ize) and multi-workspace support, so lv's
source was effectively a subset of deploy/247-dev.

This advances linear-vercel to the deploy/247-dev tree + the 4 Linear-MCP
attachments commits cherry-picked on top (issue-context probe, Attachments
bucket wiring, uploads.linear.app skip, save_issue no-op detection). Both
parents are recorded so linear-vercel remains a true descendant of its prior
tip — no history rewrite, no force-push. Content is byte-identical to the
fully-tested integ/247-attachments (agent 1123 + CDK 2586 + CLI 407 green).

linear-vercel is NOT retired — it keeps its name and continues from here as
the current Linear+Vercel demo branch, now carrying #247.
The fork's main (isadeks/main) was 64 commits stale, making GitHub report
linear-vercel as '60 behind'. Against real upstream (aws-samples/main),
linear-vercel was only 5 commits behind. Merge those in to be fully current:
- ephemeral stack cleanup script (#109)
- dependency upgrade (#367)
- ADR governance: in-place refinement (#382)
- test-perf: disable Lambda bundling in unit-test synths (#366/#371)
- jira: reactive token refresh + retry on 401 (#370/#375)

Clean merge, zero conflicts (linear-vercel's foundation was already current —
merge-base 0e2806a, 2026-06-17).
@isadeks isadeks changed the title demo: linear-vercel — current with main + #247 orchestration + Linear-MCP attachments (CI only, draft) chore: linear-vercel — current with main + #247 orchestration + Linear-MCP attachments (CI-only draft) Jun 18, 2026
# positive (this dev-only debug helper logs only ids + a yes/no flag).
has_oauth = "yes" if "linear_oauth_secret_arn" in m else "no"
print(f" PARENT issue={s(m, 'parent_linear_issue_id')} repo={s(m, 'repo')} children={n}")
print(f" release_ctx: user={s(m, 'platform_user_id')} oauth={has_oauth}")
@isadeks isadeks closed this Jun 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants