Commit Graph

630 Commits

Author SHA1 Message Date
Aaddrick
9b4f051f09 docs: remove worker-apt-plan.md now that Phase 4a has shipped (#511)
The plan doc served its purpose through #494 (merge) → #498
(scaffolding) → #502 / #503 / #504 / #506 / #509 / #510 (cutover).
v2.0.5+claude1.3883.0 is the first release through the new pipeline,
verified end-to-end on five distros. #493 is closed.

Removes docs/worker-apt-plan.md and the two architecture-pointer
comments in worker/src/worker.js and worker/wrangler.toml that
referenced it. Both files now carry a short self-contained summary
of what the Worker does and why.

Also corrects worker.js's CDN-hostname reference from
objects.githubusercontent.com (the old name) to release-assets
(current, matches #509's regex fix).

Git history retains the full plan doc for anyone who needs the
design rationale; nothing is actually lost.
2026-04-23 16:25:56 -04:00
Aaddrick
8bce730056 docs: point install instructions at pkg.claude-desktop-debian.dev (#510)
Phase 4a-APT cutover (#493, #503) moves binary distribution behind a
Cloudflare Worker at pkg.claude-desktop-debian.dev. The Worker serves
repo metadata directly and 302-redirects .deb/.rpm requests to GitHub
Release assets, which makes the >100 MB .deb push cap irrelevant.

GitHub Pages auto-301s legacy aaddrick.github.io/claude-desktop-debian
URLs to pkg.claude-desktop-debian.dev, but the redirect uses http://
(Pages has no cert for pkg.<domain> — DNS points at Cloudflare, so
Pages can never pass domain verification). apt refuses that scheme
downgrade as a security policy, so existing users' sources.list
silently breaks on the next `apt update`. DNF accepts the downgrade
and keeps working.

Changes:

- README.md: install snippets (APT + DNF) now point at
  pkg.claude-desktop-debian.dev directly. New users never touch the
  Pages redirect chain.
- README.md: add a "Migrating from the old aaddrick.github.io URL"
  section with sed one-liners for existing users + a short background
  paragraph explaining why the change was needed.
- .github/workflows/ci.yml: release-notes install snippets (APT + DNF,
  both branches) and the generated claude-desktop.repo file's baseurl
  and gpgkey all point at pkg.<domain>. Smoke-test chain walkers
  deliberately keep starting at github.io (they test the full 3-hop
  Pages→Worker→Releases chain for clients that do follow the
  downgrade, like curl-without-L and dnf).

Refs #493, #503
2026-04-23 16:12:05 -04:00
Aaddrick
de19c1bb36 fix(ci): smoke test accepts release-assets CDN hostname (#509)
v2.0.4 rerun of update-apt-repo made it past hops 0 and 1 (the smoke
test scheme fix in #506 worked — Pages' http:// redirect no longer
trips the chain walker), but failed on hop 2:

  Hop 2: 302 .../releases/download/v2.0.4+claude1.3883.0/...deb
         -> https://release-assets.githubusercontent.com/...
  ::error::Hop 2 mismatch: expected https://objects\.githubusercontent\.com/,
           got https://release-assets.githubusercontent.com/...

GitHub migrated the Release asset CDN from objects.githubusercontent.com
to release-assets.githubusercontent.com (both have been serving in the
past; release-assets is the current canonical hostname). Accept either
hostname via alternation.

Verified against the actual v2.0.4 Release:
  $ curl -Is https://github.com/aaddrick/claude-desktop-debian/releases/download/v2.0.4+claude1.3883.0/claude-desktop_1.3883.0-2.0.4_amd64.deb \
    | grep -i location
  location: https://release-assets.githubusercontent.com/github-production-release-asset/...

Same fix in three sites:
- .github/workflows/ci.yml (update-apt-repo smoke test)
- .github/workflows/ci.yml (update-dnf-repo smoke test)
- .github/workflows/apt-repo-heartbeat.yml (daily heartbeat)

docs/worker-apt-plan.md has historical references to
objects.githubusercontent.com too; those can be updated in a follow-up
docs sweep — the architectural claim (binary bytes flow direct from
GitHub CDN, never through Cloudflare) is unchanged.

Refs #493, #503
v2.0.5+claude1.3883.0
2026-04-23 11:06:31 -04:00
Travis
0319c1d04d fix: strip CRLF from cowork-plugin-shim.sh during staging (#499) (#505)
* fix: strip CRLF from cowork-plugin-shim.sh during staging

The shim originates from the upstream Windows .exe extract and ships
with CRLF line endings. Bash fails to exec a script with CRLF
shebangs/commands ("$'\r': command not found", syntax errors at
function braces), so on Nix where the installed file is read-only the
Claude Code subprocess crashes immediately and every cowork session
reports `process_crashed`. Debian/AppImage installs inherit the same
CRLF file but bite less often because the shim is only invoked once
cowork is actively used.

Normalise at the single staging point both the deb and Nix paths
read from. The conversion is a no-op on LF-only input, so if upstream
ever switches to LF this patch remains safe.

Fixes #499

Reported-by: @olafkfreund

Co-Authored-By: Claude <claude@anthropic.com>

* test: use read builtin instead of sha256sum | awk

Style guide prefers parameter expansion / bash builtins over forking awk.
Same ground covered: the first whitespace-separated field of sha256sum
output is captured.

Co-Authored-By: Claude <claude@anthropic.com>

---------

Co-authored-by: Claude <claude@anthropic.com>
2026-04-23 09:52:04 -05:00
Aaddrick
eb90be32e9 fix(ci): smoke test accepts http:// on Pages 301 hop (#506)
* fix(ci): smoke test allows http:// on Pages 301 hop

Phase 4a-APT's first rerun of update-apt-repo succeeded all the way
through strip + push (v2.0.3 metadata is live on gh-pages now), but
the smoke test failed at hop 0:

  Hop 0: 301 https://aaddrick.github.io/.../*.deb
         -> http://pkg.claude-desktop-debian.dev/.../*.deb
  Hop 0 mismatch: expected https://pkg..., got http://pkg...

Pages emits http:// in the Location header because https_enforced is
unsettable on the repo's Pages config: DNS for pkg.<domain> points at
Cloudflare (Worker custom_domain), so Pages can never pass domain
verification to provision its own cert. Cloudflare serves both schemes
for pkg.<domain>, so the http vs https in Pages' redirect is cosmetic
— the chain still terminates correctly.

Relax hop 0's regex in both smoke tests (update-apt-repo,
update-dnf-repo) and the heartbeat workflow to accept https?://.
Later hops stay https-only since GitHub's Release-asset redirects
are always HTTPS.

Failure was the tail-end of run 24836419696's rerun:
https://github.com/aaddrick/claude-desktop-debian/actions/runs/24836419696

Refs #493, #503

* chore: retrigger CI (previous trigger lost to GH flake)
2026-04-23 10:43:53 -04:00
Aaddrick
09d5f4af68 fix(worker): use raw.githubusercontent.com as origin to avoid Pages 301 loop (#504)
Once the CNAME file is in place on gh-pages (Phase 4a-APT), GitHub
Pages auto-301s all aaddrick.github.io/claude-desktop-debian/* traffic
to pkg.claude-desktop-debian.dev/*. The Worker's origin fetch against
aaddrick.github.io gets 301'd by Pages, the 301 passes through to the
client, the client follows it back to pkg.<domain>, and the Worker
runs again — infinite loop.

Observed immediately after merging #503 and Pages finishing the CNAME
build:

  $ curl -I https://pkg.claude-desktop-debian.dev/dists/stable/InRelease
  HTTP/2 301
  location: http://pkg.claude-desktop-debian.dev/dists/stable/InRelease
  x-github-request-id: 3C94:286425:...
  x-served-by: cache-yyz4566-YYZ
  via: 1.1 varnish

(Scheme-downgrade to http is a separate Pages quirk when
 https_enforced=false, which is the case here because DNS points
 at Cloudflare, not Pages, so Pages can't provision a cert.)

raw.githubusercontent.com serves the same gh-pages branch content
without Pages' routing layer. All five metadata paths verified to
return 200:

  /dists/stable/InRelease
  /dists/stable/main/binary-amd64/Packages
  /KEY.gpg
  /rpm/x86_64/repodata/repomd.xml
  /rpm/x86_64/repodata/repomd.xml.asc

Also fixes the deploy-worker.yml post-deploy probe which still
hardcoded pkg-staging. That's what made #503's deploy show as
failed in the Actions UI even though the wrangler deploy itself
succeeded — route bound and Worker live, but the probe was
resolving a hostname wrangler had just removed.

Refs #493, #503

Co-authored-by: Claude <claude@anthropic.com>
2026-04-23 10:22:45 -04:00
Aaddrick
b9bc02dd8b feat(worker): flip route from staging to production for Phase 4a (#503)
Phase 2 container validation passed against
pkg-staging.claude-desktop-debian.dev — APT (debian:stable,
ubuntu:24.04, debian:testing) and DNF (fedora:latest, rockylinux:9)
both install the current pool version via the Worker chain. The one
remaining failure is #500's sha256 mismatch on RPM download, and
PR #502's gh release upload --clobber fix runs on the next release
that reaches update-dnf-repo.

This flip binds the Worker to pkg.claude-desktop-debian.dev. Once
this is deployed, the strip step's liveness probe in update-apt-repo
and update-dnf-repo will start succeeding, stripping .debs/.rpms from
the local pool tree before push — the original #493 blocker.

Pre-merge checklist (manual, outside this PR):

1. Add CNAME file containing pkg.claude-desktop-debian.dev to the
   gh-pages branch root (via Pages settings UI or direct push).
2. Wait for GitHub Pages cert provisioning. Typical ~1h; verify in
   repo Settings > Pages that the green cert indicator shows.
3. Merge this PR. CI deploys the Worker to the new route via
   deploy-worker.yml.
4. Confirm production probe responds:
     curl -fsI https://pkg.claude-desktop-debian.dev/dists/stable/InRelease
5. Re-run the failed update-apt-repo + update-dnf-repo jobs from the
   v2.0.3+claude1.3883.0 run (gh run rerun 24836419696 --failed) —
   this simultaneously validates #500's fix and completes the v2.0.3
   release for apt/dnf users.

Rollback: remove the CNAME file from gh-pages, unbind the Worker
route via the Cloudflare dashboard. gh-pages .deb assets from the
pre-strip history still exist and serve directly via github.io.

Refs #493, #500

Co-authored-by: Claude <claude@anthropic.com>
2026-04-23 10:09:46 -04:00
Aaddrick
0bcf7a473f fix(ci): resolve DNF Worker chain blockers (#500, #501) (#502)
Fix #500: rpmsign --addsign mutates RPMs in place, so the Release
asset uploaded by the release job (unsigned) diverged from the
signed copy in gh-pages. The Worker redirects to the Release asset,
so dnf saw a sha256 that didn't match repodata. Re-upload the signed
RPMs to the Release via gh release upload --clobber after signing.

Fix #501: The imported GPG keyring contains two keys; reprepro signs
InRelease with one and rpmsign signs repomd.xml.asc with the other,
but the published KEY.gpg only contained one of them. Strict clients
like rockylinux:9 rejected repo metadata with "Bad GPG signature".
Export the full keyring (all public keys) to KEY.gpg so both
signatures verify.

Validation (per issue reproduction steps):
- Re-run update-dnf-repo on a test tag
- sha256 of gh-pages RPM must match the Release asset download
- fedora:latest dnf install should succeed (was "All mirrors tried")
- rockylinux:9 dnf makecache should succeed (was "Bad GPG signature")

Co-authored-by: Claude <claude@anthropic.com>
2026-04-23 08:52:41 -04:00
Aaddrick
4fb076ec12 feat: APT/DNF Worker scaffolding (#498)
* feat: APT/DNF Worker scaffolding (#493)

Adds the implementation scaffolding for the Cloudflare Worker that
fronts the APT/DNF repo, per docs/worker-apt-plan.md.

New files:
  - worker/src/worker.js: redirects /pool/.../*.deb and /rpm/*/*.rpm
    to GitHub Release assets via 302; passes metadata through to
    the gh-pages origin
  - worker/wrangler.toml: bound to pkg-staging.claude-desktop-debian.dev
    initially; Phase 4a switches to pkg.claude-desktop-debian.dev
  - .github/workflows/deploy-worker.yml: deploys Worker on worker/**
    push, post-deploy probe verifies route bound + Worker responding
  - .github/workflows/apt-repo-heartbeat.yml: daily cron, deb+rpm
    matrix, walks ordered redirect chain + size match against Releases
    asset, opens format-specific tracking issue on failure (auto-close
    on recovery), gates on Worker liveness (skips silently before
    Phase 4a)

Modified:
  - .github/workflows/ci.yml: gated strip step + ordered-chain smoke
    test added to update-apt-repo and update-dnf-repo; the destructive
    strip only fires when the production Worker probe succeeds, so this
    PR can land before Phase 4a without affecting current behavior
  - docs/worker-apt-plan.md: bake in real domain values, mark Decisions
    table entries as concrete, fix Cloudflare API token permissions
    list (current names: Workers Scripts Edit, Account Settings Read,
    Workers Routes Edit; previous "Zone:Zone:Read" name no longer
    matches the dropdown)

Pre-Phase-4a behavior: the strip step's liveness probe targets the
production hostname which doesn't exist yet, so it always skips and
.debs/.rpms are pushed to gh-pages exactly as today. Smoke tests skip
on the same gate. Heartbeat workflow's gate skips before the Worker
is live. Nothing destructive happens until Phase 4a explicitly cuts
the Worker over to production.

Co-Authored-By: Claude <claude@anthropic.com>

* refactor: simplify worker scaffolding per cdd-code-simplifier review

- worker.js: use named capture group `asset` instead of opaque `m[1]`
  positional reference; inline single-use `tagFor()` helper; demote
  unused `arch` capture to non-capturing group.
- ci.yml: hoist `WORKER_DOMAIN` from per-step env to job-level env in
  both `update-apt-repo` and `update-dnf-repo` (matches the pattern
  already used in `apt-repo-heartbeat.yml`).
- apt-repo-heartbeat.yml: use github-script's native `context.serverUrl`
  / `context.runId` instead of reconstructing from process.env; spread
  `...context.repo` instead of repeating owner/repo on every API call;
  destructure `{ data: open }` to flatten `open.data` references.

All changes preserve behaviour. The contrarian-fix mechanisms (positive
Worker liveness probe gating the strip step, hop-by-hop ordered chain
walk in smoke tests) are unchanged. APT/DNF strip + smoke pairs remain
in-place per reviewer-readability preference.

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

---------

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 07:31:18 -04:00
Aaddrick
937b1cc7e3 docs: plan APT/DNF distribution via Cloudflare Worker (#493) (#494)
Adds docs/worker-apt-plan.md, the implementation plan for fixing CI
run 24811974733 where update-apt-repo was rejected by GitHub's 100 MB
per-file push cap (.deb is 130 MB after upstream growth + ion-dist).

Approach: Cloudflare Worker on a custom domain fronts existing GitHub
Pages metadata and 302-redirects binary requests to GitHub Release
assets (which CI already publishes successfully). Existing user
sources.list entries preserved via Pages auto-301 from *.github.io to
the custom domain.

Plan went through two contrarian review rounds. Replaces the prior
gh-pages-split-plan.md draft (split-into-separate-repo approach is
no longer needed once .debs stop being committed to gh-pages).

Co-authored-by: Claude <claude@anthropic.com>
2026-04-22 23:33:27 -04:00
aaddrick
e11edf3475 docs: credit @RayCharlizard for ion-dist fix (#490)
Co-Authored-By: Claude <claude@anthropic.com>
2026-04-22 21:44:05 -04:00
Travis
52899114d3 fix: copy ion-dist static assets for app:// protocol handler (#490)
The Claude Desktop app registers a custom app:// protocol handler rooted
at process.resourcesPath/ion-dist — that directory holds the static SPA
assets for internal windows like Third-Party Inference setup, Connectors
config, etc. The Windows installer ships ion-dist under lib/net45/resources
but scripts/staging/cowork-resources.sh never copied it into the electron
resources dest, so every app://localhost/* request fell through to the
static file handler's index.html fallback, which also failed because the
whole directory was missing.

Net effect: the Third-Party Inference setup window (Developer → Configure
Third-Party Inference…) opened as a blank window with
ERR_FILE_NOT_FOUND / ERR_UNEXPECTED on loadURL.

Add an ion-dist copy step to copy_cowork_resources() matching the
existing smol-bin / plugin-shim pattern (warn-and-continue on absence),
and a matching install stanza in nix/claude-desktop.nix so NixOS users
get the fix too — without the Nix hunk, ion-dist lands in the
nix-resources/ sentinel but the installPhase doesn't cherry-pick it.

Verified end-to-end:
- Fresh build ./build.sh --build appimage picks up ion-dist from the
  1.3883.0 Windows installer (84 MB uncompressed, ~42 MB delta in the
  AppImage after squashfs).
- Live install on CachyOS daily-driver; Developer → Configure Third-Party
  Inference… now renders the full 6-tab config UI (Connection / Sandbox
  & workspace / Connectors & extensions / Telemetry & updates / Usage
  limits / Plugins & skills / Egress Requirements) per the upstream
  docs at support.claude.com/en/articles/14680741.
- Logs clean of ERR_UNEXPECTED / ERR_FILE_NOT_FOUND on setup-desktop-3p.

Fixes #488

Co-authored-by: Claude <claude@anthropic.com>
v2.0.2+claude1.3883.0
2026-04-22 21:35:16 -04:00
Sum Abiut
2543ee58bc feat: add BATS unit tests for launcher-common.sh (#395)
* feat: add BATS unit tests for launcher-common.sh

scripts/launcher-common.sh is 798 lines handling critical startup logic —
display detection, Electron arg building, stale lock cleanup, cowork daemon
cleanup, and the full --doctor diagnostic system — but had zero test coverage.
A regression in any of these functions could silently break app launches across
display servers and package formats.

Add 48 BATS tests covering:
- setup_logging / log_message (XDG_CACHE_HOME fallback)
- detect_display_backend (X11, Wayland, XWayland, Niri auto-detect)
- build_electron_args (all display × package-type combinations)
- setup_electron_env (ELECTRON_FORCE_IS_PACKAGED, title bar)
- cleanup_stale_lock (dead PID removal, live PID preservation)
- cleanup_stale_cowork_socket (stale unix socket removal)
- Doctor helpers:
  - _pass / _fail / _warn / _info output
  - _cowork_distro_id
  - _cowork_pkg_hint (distro-specific package mapping)
  - _electron_version

Tests run fully sandboxed:
- HOME, XDG_CACHE_HOME, XDG_CONFIG_HOME, and XDG_RUNTIME_DIR redirected to a temp directory
- Host display variables cleared in setup() to prevent state leakage

* refactor: extract has_electron_arg helper to reduce test boilerplate

Replace repeated loop-and-flag patterns across 7 build_electron_args
tests with a shared has_electron_arg helper that supports glob matching.
Removes ~40 lines of duplicated code with no change in test coverage.
2026-04-22 23:49:20 +11:00
github-actions[bot]
e5e1349e2a Update Claude Desktop download URLs to version 1.3883.0
Updated download URLs resolved from official redirect endpoints.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
v2.0.1+claude1.3883.0
2026-04-22 01:38:54 +00:00
aaddrick
ec51e39f86 docs: credit @aJV99 for Wayland GDK_BACKEND fix (#397)
Co-Authored-By: Claude <claude@anthropic.com>
v2.0.1+claude1.3561.0
2026-04-21 18:51:25 -04:00
Abbas Alibhai
4ad652644b fix: export GDK_BACKEND=wayland in native Wayland mode (#397)
When the launcher runs in native Wayland mode (CLAUDE_USE_WAYLAND=1 or
forced by compositor), it sets the correct Electron ozone flags but does
not override GDK_BACKEND. A system-wide or session-level GDK_BACKEND=x11
then silently wins, causing GTK to connect via XWayland and producing
blurry rendering on HiDPI displays.

Export GDK_BACKEND=wayland in the native Wayland branch of
build_electron_args() so the ozone flags and GDK backend stay in sync.
2026-04-21 18:37:18 -04:00
Aaddrick
4e2b9d7256 fix(shortcut): scope Ctrl+Q to focused window, not system-wide (#484)
Replaces the globalShortcut registration in frame-fix-wrapper.js with a
per-window webContents 'before-input-event' handler. The previous global
grab stole Ctrl+Q from every app on the system and — on non-QWERTY
layouts — also swallowed whatever keysym sits at the physical "Q"
position (Ctrl+A on AZERTY be,us).

The new handler only fires when Claude has keyboard focus, so other apps
keep their Ctrl+Q. Menu-accelerator coverage for the hidden menu bar
case (original #321 motivation) is preserved because webContents
intercepts the key directly, independent of menu visibility.

Fixes: #399
Fixes: #474

Co-authored-by: Claude <claude@anthropic.com>
2026-04-21 17:51:40 -04:00
Travis
a9719c93cc fix(cowork): forward CLAUDE_CODE_OAUTH_TOKEN to VM spawn env (#482) (#485)
* fix(cowork): forward CLAUDE_CODE_OAUTH_TOKEN to VM spawn env (#482)

buildSpawnEnv and BwrapBackend.spawn both stripped every CLAUDE_CODE_*
env var from the daemon's process.env via filterEnv(process.env,
['CLAUDE_CODE_']) -- including CLAUDE_CODE_OAUTH_TOKEN, the standard
auth channel for the in-VM claude binary. The bwrap sandbox mounts
home as an empty --dir, so ~/.claude/.credentials.json is inaccessible
inside; env is the only viable auth path. Result: every shell tool
call returned "Not logged in. Please run /login".

Upstream's seA() does include the token in the spawn env it assembles,
but on Linux that payload isn't reaching the daemon's params.env, so
the daemon's inherited process.env is the only surviving source.
Stripping it severed auth.

Introduce FORWARDED_ENV_KEYS = ['CLAUDE_CODE_OAUTH_TOKEN'] plus a
forwardAuthEnv helper that re-adds the token from process.env when
appEnv doesn't provide one. Extract buildBaseSpawnEnv as the shared
env-construction path for both spawn sites so the filter/forward logic
can't drift.

Diagnosis and reference diff by @pb3ck in #482. This PR extends the
fix to BwrapBackend.spawn (the second call site), factors the shared
helper, and adds regression coverage.

Co-Authored-By: Claude <claude@anthropic.com>

* refactor(cowork): fold forwardAuthEnv into buildBaseSpawnEnv

The forwardAuthEnv helper had a single call site inside
buildBaseSpawnEnv. Inlining the forward loop removes a layer of
indirection for a private helper and consolidates the empty-string
guard comment next to the code it documents.

No behavior change. All 72 tests in cowork-path-translation.bats
still pass, including the four #482 regression tests.

Co-Authored-By: Claude <claude@anthropic.com>

* docs(readme): credit @pb3ck for #482 diagnosis

Co-Authored-By: Claude <claude@anthropic.com>

---------

Co-authored-by: Claude <claude@anthropic.com>
2026-04-21 16:33:59 -05:00
Aaddrick
35d4735b2d fix(triage): normalize claimed_version before drift compare (#483)
Reporter on #481 pasted the deb package version `claude-desktop
1.3561.0-2.0.0`. The classifier extracted `1.3561.0-2.0.0` verbatim,
and the naive `claimed != CLAUDE_DESKTOP_VERSION` string compare
flagged drift against `1.3561.0`. The issue is on the current
release — no drift should fire.

Fix normalizes both sides: strip a leading `v`, then strip anything
from the first `-` or space onward. Handles:

- `1.3561.0-2.0.0` → `1.3561.0` (deb package: upstream-REPO_VERSION)
- `v1.3561.0` → `1.3561.0` (copy-paste with prefix)
- `1.3561.0 stable` → `1.3561.0` (whitespace-separated qualifier)
- `1.3561.0` → `1.3561.0` (bare upstream, unchanged)

Same normalization applied to CURRENT_VERSION for symmetry, even
though the repo variable is always the bare upstream semver — keeps
the compare resilient if that ever changes.

Fixes the false drift banner on #481 and prevents the same shape
from tripping on any future issue where a reporter pastes their
`dpkg -l | grep claude` output or AppImage filename.

Co-authored-by: Claude <claude@anthropic.com>
2026-04-21 15:53:34 -04:00
Aaddrick
6fceb39d60 docs(triage): sync README with shipped pipeline; drop plan + research (#480)
The README was drafted as a design spec before implementation. Now
that the pipeline is live and the design has been validated end-to-
end, bring the doc into agreement with the code and retire the two
companion files.

README updates:
- Intro: state the production trigger (`issues: [opened]`) and the
  workflow_dispatch fallback; note v1 is manual-only
- Stage 7 table: reorder by actual priority (drift is no longer a
  top-of-gate veto); drift section rewritten to describe the banner-
  and-candidates-modifier behavior landed in PR #476
- Stage 8a rendered-output example: show the conditional drift
  banner + drift-bridge candidates block that actually render
- Stage 8b reason enum: add `reference-source unavailable` that was
  missing from the list
- Rollout posture: describe the cutover as completed, not deferred
- Implementation layout: drop "during rollout" qualifier; add
  helper-scripts row (validate.sh / drift-bridge.sh /
  suspicious-input-scan.sh / extract-json.py)
- Artifacts list: full set with 14-day retention, not just the
  original four
- Reasons.json SSOT pointer: actual path `.claude/scripts/reasons.json`
  instead of the aspirational `lib/templates/reasons.json`
- Potential future improvements: drop "Cutover to issues:[opened]"
  subsection (done)
- Clean up "v1" usage where it means "first version of the pipeline"
  (confusable with legacy v1 workflow)

Deleted:
- docs/issue-triage/implementation-plan.md — phased build sequence
  is complete; commit history preserves the record
- docs/issue-triage/research-trail.md — design-pass sources are cited
  inline in the README where needed

Workflow banner updated to drop the `implementation-plan.md` pointer.

Co-authored-by: Claude <claude@anthropic.com>
2026-04-21 15:46:13 -04:00
Aaddrick
6adf2bf46d chore(triage): v2 production cutover (#478)
Three changes bundled because they land together as the cutover:

1. **v2 `issues: [opened]` trigger enabled.** Workflow now fires
   automatically on new issues in addition to the existing
   workflow_dispatch path. `run-name`, `concurrency.group`, and the
   gate step's ISSUE_NUMBER all resolve via
   `github.event.issue.number || inputs.issue_number` so both
   trigger paths work. The existing `inputs.dry_run != true` gates
   on label/comment application — under an issues trigger that
   expression is empty ≠ true, so production posts/labels land.

2. **v1 `issues` trigger removed.** `issue-triage.yml` keeps
   `workflow_dispatch` for manual fallback (maintainer can still
   fire it if v2 is paused or rolled back), but no longer runs
   automatically. v1's `run-name`/concurrency dropped the now-dead
   `github.event.issue.number` fallback.

3. **Investigate timeout 600s → 1200s.** Bumped after two
   consecutive timeouts on #311 during Phase 4 + drift-as-banner
   verification. The investigator needs more tool-call budget on
   complex issues. Review step stays at 600s — it runs without
   tool access and has never timed out.

Rollback: revert this commit to restore v1's automatic trigger;
v2's `issues:` block goes back to workflow_dispatch-only in the
same operation.

Co-authored-by: Claude <claude@anthropic.com>
2026-04-21 08:55:06 -04:00
Aaddrick
03a121d89e docs(decisions): add decision log with D-001 (auto-update direction) (#477)
Introduces docs/DECISIONS.md as a TPM-style direction log for decisions
that shape what this project does and does not do. Decisions are stable,
dated, and revisited by opening an issue that cites the decision ID —
they're not deleted or silently reversed.

The first entry, D-001, records the decision that auto-update flows
through platform package managers (APT / DNF / AUR) and AppImageUpdate
only — no in-tree cron updaters. Captures the rationale, the accepted
trade-offs (AppImage users without a supported-distro repo have no
first-party auto-update path), and the alternatives considered.

Context: PR #320 proposed cron-driven auto-update scripts; the XRDP
portion was salvaged into PR #475, and this entry closes the loop on
why the auto-update portion was declined at the direction level.

Co-authored-by: Claude <claude@anthropic.com>
2026-04-21 08:46:29 -04:00
Aaddrick
f1eed0e16f fix(triage): drift-as-banner — demote drift from gate to modifier (#476)
Post-Phase 4 verification showed two issues (#311, #448) where the
pipeline successfully produced valuable findings against current
code, but the top-of-gate drift veto routed them to 8b drift-only
and the findings were discarded. The reporter cited an older version
(1.1.7464 on #311), the investigation ran cleanly on current
(1.3.5610), and the reviewer approved the findings — yet the comment
still read "couldn't reach a confident read."

This change keeps drift detection and keeps the drift-bridge sweep.
What changes is Stage 7: drift is no longer at the top of the gate.

When drift is detected and 8a or 8c would render cleanly, the
renderer prepends a drift banner (⚠ You reported this on X; bot
investigated on Y. Citations may still apply.) and appends the
drift-bridge-candidates block at the bottom. The finding citations
stand — they describe current code in hypothesis voice, which is
what the reader can verify against their own checkout.

When drift is detected and the pipeline would otherwise route to 8b
for any other reason (fetch-failure, invest-failure, review-failure,
no-findings, low-confidence), the reason is overridden to
`version-drift`. Drift-bridge candidates give the maintainer a more
actionable signal than "no findings" on its own.

Reviewer prompt gains one rubric addition: downgrade-confidence when
the cited surface clearly post-dates the reporter's version. Catches
the case where a finding is valid on current but wouldn't reproduce
on what the reporter saw. Doesn't degrade findings indiscriminately
— only when the reviewer can see version-specific evidence.

Confirmed-duplicate routing wins over the drift-reason override
(explicit exclusion in the override clause) because `triage:
duplicate` is still the more specific read.

Co-authored-by: Claude <claude@anthropic.com>
2026-04-21 08:33:49 -04:00
Aaddrick
3344832b4e fix(launcher): disable GPU compositing on XRDP sessions (#475)
XRDP sessions lack GPU acceleration; Electron's default GPU compositing
renders a blank window. Detect XRDP via the $XRDP_SESSION env var and
systemd-logind's session Type, then append --disable-gpu and
--disable-software-rasterizer to the Electron args.

Based on @davidamacey's fix in #320, with the detection hardened: we
use `loginctl show-session -p Type --value` instead of probing for
xrdp-sesman, because that daemon runs on any host with xrdp installed
and would false-positive on local sessions.

Adds tests/launcher-xrdp-detection.bats with 8 cases covering both
positive and negative detection paths, including the XDG_SESSION_ID-
unset and loginctl-nonzero edge cases.

Fixes: #319

Co-authored-by: davidamacey <davidamacey@gmail.com>
Co-authored-by: Claude <claude@anthropic.com>
2026-04-21 08:26:21 -04:00
Aaddrick
28882ea475 feat(triage): Phase 4 sub-PRs 3+4 — regression_of + edit-during-triage (#472)
* feat(triage): Phase 4 sub-PRs 3+4 — regression_of + edit-during-triage

Bundles the two remaining Phase 4 sub-phases. Both are small workflow
additions that build on infrastructure already in place: the Phase 1
input snapshot (updated_at captured at Stage 1) and the Phase 1
classify.json's regression_of field.

regression_of end-to-end (Stage 3b + Stage 4 + Stage 6)
- New step `Validate regression_of` between drift-check and fetch.
  Runs only when classify set regression_of to non-null.
- Validation: PR exists in this repo; PR is merged; PR's mergedAt
  precedes issue's createdAt. Any failure clears to null with a
  logged note and the issue proceeds as a regular bug.
- Valid regression → `gh pr diff` fetched (capped at 4000 lines) and
  inlined into the investigate prompt as primary context. Tells the
  investigator to start the search in the PR's changed files.
- Same diff inlined into the review prompt, wrapped as pipeline_data,
  so the reviewer can check whether findings land inside the named
  PR's changed files.
- Handles the spec's "cleared to null with logged note" requirement
  for upstream Electron PRs that aren't in this repo.

Edit-during-triage detection (Stage 8 post-processor)
- New step between 8a/8c post-processors and Apply labels. Runs for
  every variant.
- Re-fetches issue.updated_at live and compares against the Stage 1
  input_snapshot.updated_at.
- On mismatch: appends a `⚠ This issue was edited after triage
  began. ...` disclaimer to the rendered comment, pointing at
  input_snapshot.json as the audit trail.
- Catches inject-then-delete attacks (inject instructions, wait for
  bot, delete before a human reads) and honest mid-triage edits
  that would make the comment stale.

Step summary gains `regression_of validated` row.

With this PR, Phase 4 is complete: 8c enhancement-design, suspicious-
input tells, regression_of, edit-during-triage detection are all
live. All terminal paths (bug / enhancement / question / duplicate /
needs-info / not-actionable / suspicious) flow through the pipeline
end-to-end per spec.

Co-Authored-By: Claude <claude@anthropic.com>

* docs(triage): correct stale sort -u reference in date-compare comment

The comment above the ISO 8601 date check referenced `sort -u`,
which isn't used in the code. Rewrite to describe what the code
actually does: `[[ > ]]` on the raw timestamp strings, which is
valid because ISO 8601 sorts lexicographically as chronologically.
Also re-orient the prose around the invalid case (mergedAt AFTER
createdAt), matching the branch that the following `if` takes.

Co-Authored-By: Claude <claude@anthropic.com>

---------

Co-authored-by: Claude <claude@anthropic.com>
2026-04-20 23:41:48 -04:00
Aaddrick
9fc49bd260 feat(triage): Phase 4 sub-PR 2 — suspicious-input tells (#471)
* feat(triage): Phase 4 sub-PR 2 — suspicious-input tells

Adds a conservative Stage 2a tripwire that scans the raw issue body
and title for prompt-injection tells before any LLM call. A match
short-circuits routing to 8b with reason
`suspicious-input — manual review`, no Sonnet invocation.

The scan is the front-line filter; the actual injection mitigations
(wrap-as-data, fresh-context reviewer, schema-constrained output)
remain in place for everything that doesn't trip. The two layers are
complementary: the scan catches the obvious attempts cheaply, the
downstream defenses protect against the clever ones.

Taxonomy
- taxonomies/suspicious-input-tells.json — eight tells with regex
  patterns and rationale:
    - ignore-prior-instructions: classic opener
    - system-prompt-leak: exfiltration attempts
    - role-override: "you are now a different…"
    - forget-instructions: variation of ignore-prior
    - developer-mode: named jailbreaks (DAN, etc.)
    - instruction-injection-sysrole: chat-template tokens
    - long-base64-block: 200+ contiguous base64 chars
    - unicode-tag-sequence: U+E0000-E007F invisibles

Scanner
- scripts/triage/suspicious-input-scan.sh — pure bash, PCRE via
  grep -Pzi, writes suspicious-input.json with matched_tells[].
  Uses the same taxonomy-as-data pattern as reasons.json and
  label-blocklist.json.

Workflow
- Stage 2a step runs between input snapshot and classify, outputs
  `suspicious` boolean
- Classify + doublecheck both `if:`-gated so they skip on a hit
- Decide route takes suspicious first, before the doublecheck
  disagreement check — a tripped tell defers deterministically
- Step summary shows the suspicious flag

Co-Authored-By: Claude <claude@anthropic.com>

* refactor(triage): drop dead null-string guards in suspicious-input scan

jq -r '.body // ""' already returns an empty string for JSON null or a
missing field, so the subsequent `[[ "${body}" == "null" ]]` guards only
fire when a reporter's body is the literal four-character string "null"
— which isn't an injection signal and matches no tell. The comment
describing the guards was also wrong about jq's behavior. Remove both
guards and correct the comment.

Also fix a misleading comment about `|| true` (which isn't in the code)
and collapse the 4-line `suspicious` boolean derivation into a single
`jq 'length > 0'`.

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

---------

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 23:34:46 -04:00
Aaddrick
b9fe8e3c14 feat(triage): Phase 4 sub-PR 1 — Stage 8c enhancement-design variant (#470)
* feat(triage): Phase 4 sub-PR 1 — Stage 8c enhancement-design variant

Adds the third Stage 8 template variant. Previously, enhancement-
classified issues fell through to 8b human-deferral; now they run
through the investigate pipeline with enhancement-specific prompts
and render a lightweight acknowledgment + existing-surface citations
+ design-review questions from a fixed taxonomy.

Prompts and schemas
- taxonomies/enhancement-design-questions.json — six fixed IDs:
  config-schema-stability, backward-compat, security-surface,
  test-coverage, observability, packaging-format. Each carries a
  concrete question the renderer surfaces verbatim.
- schemas/comment-enhancement.json — structured output: 1-sentence
  acknowledgment_line, 0-3 existing_surfaces (each with file:line),
  1-3 design_question_ids (enum-matched against the taxonomy).
- prompts/comment-enhancement.txt — drafter prompt, hypothesis
  voice, rules of thumb for picking design questions.
- prompts/investigate-enhancement.txt — investigate variant. Same
  schema, but claim_type=absence is banned (by definition the
  enhancement's capability is absent; restating is redundant and
  tips into design-prescription). Findings must cite existing code
  the enhancement would touch.
- prompts/review-enhancement.txt — reviewer rubric reframed from
  "is this defect claim correct?" to "is this an existing surface
  the enhancement would actually touch?" Reject leans on
  real-but-irrelevant surfaces, since those actively misdirect.

Workflow
- Route decision: enhancement now enters the investigate path
  alongside bug and duplicate (route renamed `investigate`). Both
  the investigate step and the review step pick the enhancement-
  variant prompt when classification == enhancement.
- Decision gate: new enhancement branch slotted between
  invest-failure and no-findings. 8c fires when review succeeded
  (any kept count, including 0) OR when findings_passed was 0 and
  the review step was skipped by design — the design questions
  carry the comment alone.
- Stage 8c render: bash cross-joins design_question_ids against
  the taxonomy; a missing lookup errors loudly rather than
  silently dropping.
- 8c post-processor: 350-word cap per spec; trims the last
  existing_surfaces bullet when over cap.
- Apply labels: 8c variant → `triage: investigated` +
  `enhancement` class label.

Deferred to later Phase 4 sub-PRs: suspicious-input tells,
regression_of end-to-end diff fetch, edit-during-triage detection.

Co-Authored-By: Claude <claude@anthropic.com>

* refactor(triage): reuse classify step output instead of re-parsing classification.json

Drops two redundant `jq -r '.classification' /tmp/triage/classification.json`
calls in the investigate + review steps; both now read the value via a
`CLASSIFICATION_NAME` env var sourced from `steps.classify.outputs.classification`.
Matches the `Decide comment variant` step's existing pattern for
reading classify state, so the three call sites converge on one idiom.

No behavior change — the prompt-selection conditional reads the same
value; just fewer forks of jq.

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

---------

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 23:26:41 -04:00
Aaddrick
7e77833b11 fix(triage): pull broken-expectation rule up into first-pass classify (#469)
Post-rename verification on #448, #449, and #424 showed the first-pass
classifier now leans `enhancement` on all three, while the doublecheck
correctly reads them as `bug`. The disagreement failsafe defers each
to human review, which is safe but wastes the doublecheck as a
classification-recovery mechanism rather than a verification one.

Root cause: the "broken expectation wins" rule lives only in the
doublecheck prompt. First pass sees `enhancement` framing ("breaks X",
"should support Y") and weights it as an enhancement request. Adding
the rule to the primary classify prompt brings first-pass behavior in
line with doublecheck expectations.

Explicit examples added from the test set (minimize-to-tray, APT pool
regression, CTRL+C) so future calibration drift is easier to notice.

Co-authored-by: Claude <claude@anthropic.com>
2026-04-20 23:14:12 -04:00
Aaddrick
7d083d9163 refactor(triage): rename feature classification to enhancement (#466)
Aligns the v2 classifier vocabulary with the repo's GitHub label
vocabulary. Previously `classification=feature` was mapped to label
`enhancement` at Stage 9 — a redundant indirection that also caused
miscalibration on defects framed as enhancement-shaped asks (e.g.
#448 "breaks in-app schedulers and 'minimize to tray' expectation"
classified as feature + ambiguous when the maintainer read is bug).

Changes:
- classify.json enum: feature → enhancement
- classify-doublecheck-bugfeature.{json,txt} → classify-doublecheck-bug-vs-enhancement.{json,txt}
- Doublecheck rubric tightened: added "breaks X" / "stopped working"
  as explicit bug signals and a rule that a broken expectation wins
  over enhancement-shaped framing when both are present. Reduces the
  chance of #448-shaped defects routing to the ambiguous bucket.
- investigate.txt absence-claim ban: "feature X is missing" →
  "capability X is missing"
- reasons.json: "ambiguous bug/feature classification" →
  "ambiguous bug/enhancement classification"
- Workflow: doublecheck step renamed, classification checks updated,
  class_label map collapsed to direct (no more feature→enhancement
  remap).
- docs/issue-triage/{README.md,implementation-plan.md}: vocabulary
  updated throughout (~47 occurrences). 8c variant renamed
  Feature-design → Enhancement-design. Planned Phase 4 file names
  (comment-enhancement.json, enhancement-design-questions.json)
  follow suit.

Kept as-is:
- `.github/ISSUE_TEMPLATE/feature_request.yml` filename — preserves
  the GitHub convention reporters recognize on the issue-chooser page;
  classifier buckets issues filed through it as `enhancement`.
- v1 `issue-triage.yml` + `triage-classify.json` — untouched; v1 is
  slated for replacement and doesn't gain from this rename.

No behavioral change at runtime beyond the rubric tightening; the
rename collapses an indirection rather than adding logic.

Co-authored-by: Claude <claude@anthropic.com>
2026-04-20 22:58:33 -04:00
Aaddrick
471c62dde0 chore(codeowners): add @sabiut for testing & release quality (#468)
Gives @sabiut review ownership of /tests/, /scripts/doctor.sh, and the
test-artifacts + test-flags workflows. Shared review with @aaddrick on
/docs/TROUBLESHOOTING.md and /.github/workflows/shellcheck.yml.

Cowork override at the bottom of the file still wins for
/tests/cowork-*.bats per last-match-wins.

Announcement: #467

Co-authored-by: Claude <claude@anthropic.com>
2026-04-20 22:57:28 -04:00
Aaddrick
d0544d44e8 feat(triage): Phase 3 — Stage 6 adversarial reviewer + duplicate gate (#465)
* feat(triage): Phase 3 — Stage 6 adversarial reviewer + duplicate gate

Adds a fresh-context reviewer between mechanical validation (Stage 5)
and the decision gate (Stage 7). The reviewer steel-mans each surviving
finding, commits to a counter-reading, runs closed-world checks on
identifier claims, and emits approve / downgrade-confidence / reject
with structured rationale. It also rates each cited related_issue and
the duplicate_of target (exact / related / unrelated).

Stage 7 now gates on reviewer verdicts. approve keeps a finding at full
confidence; downgrade-confidence keeps it but subtracts 1 from its
contribution to the avg-confidence threshold (floor 0.5); reject drops
it. A new duplicate gate (between fetch-failure and invest-failure in
the priority table) fires when classification == duplicate and the
reviewer rated duplicate_of exact or related — routing the issue to 8b
with 'likely-duplicate-of-#N' as reason and 'triage: duplicate' as
label. An 'unrelated' rating discards the duplicate claim and the
remaining gates apply to the regular investigation output.

- schemas/review.json — reviewer verdict schema, per-finding rationale
  required, closed_world_check object for identifier claims, ratings
  for related_issues and duplicate_of
- prompts/review.txt — adversarial-reviewer prompt per spec §6; input
  is source excerpts + claim + closed_world_options + cited-issue
  bodies + duplicate_of body, wrapped as untrusted data; excludes
  draft comment, free-form reasoning, and voice instructions
- Workflow: fetch duplicate_of body (inline step), Stage 6 review
  call (schema-constrained, no tool access, timeout 600s,
  --max-budget-usd 1.50, extract-json fallback on prose), reviewer-
  aware filter step, expanded decision gate, triage: duplicate label
  path with class inheritance from the target issue (PR #459 item 8),
  <pipeline_data> wrappers on 8a-render inlined JSON (PR #459 item 3)
- Route duplicates through investigate pipeline so Stage 5 + Stage 6
  can rate the target (previously deferred straight to 8b)

See docs/issue-triage/{README.md §6-§7, implementation-plan.md §Phase 3}.

Co-Authored-By: Claude <claude@anthropic.com>

* refactor(triage): simplify Phase 3 verdict summary step

Two small cleanups in the Stage 6 / "Apply reviewer verdicts" plumbing
that don't touch load-bearing behavior (errexit guards, --slurpfile
cross-join, schema fallback, gate priority, prompt-injection wrappers
all preserved):

* Drop the unused dup_num step output — no consumer references
  steps.dup_fetch.outputs.dup_num; Resolve reason text reads
  .duplicate_of directly from classification.json.
* Collapse the dup_rating jq filter to a single-line
  .duplicate_of_rating.rating // "none" — jq already treats
  null.rating as null, so the explicit if/else was just ceremony.

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

---------

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 22:13:45 -04:00
Aaddrick
88df8e8e7e fix(triage): raise 8b comment word cap 150 → 300 (#464)
Re-dispatch of #394 showed the full drift-routing path works end-to-
end except for the post-processor word-cap: base 8b comment is ~50
words, drift-bridge-candidates block adds ~130 words for 10 bullets,
privacy note another ~30 when the reporter is first-time. Actual was
189 words vs 150 cap.

Spec §8b note already flagged this: "Verify length is under 150 words
(account for optional drift-bridge-candidates block)." The parenthetical
acknowledged the block expands the comment, but the original 150 was
the base-comment budget and was never adjusted when the drift-bridge
extension landed in Phase 2.

300 covers the observed worst case (~190) with headroom for edge cases
(long PR titles, longer commit subjects, future drift-bridge output
growth) while still bounding the comment at something scannable.

Capping the drift-bridge render at N entries is a separate concern —
deferred in favor of raising the limit first.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:45:32 -04:00
github-actions[bot]
f2487e0b19 Update Claude Desktop download URLs to version 1.3561.0
Updated download URLs resolved from official redirect endpoints.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
v2.0.0+claude1.3561.0
2026-04-21 01:38:28 +00:00
Aaddrick
caec9182c8 fix(triage): investigate timeout bypasses errexit + bump to 10m (#463)
Re-dispatch of #394 confirmed the 300s timeout bounds the step, but
also exposed a second bug: the step failed with exit 124 instead of
falling through to 8b gracefully. Downstream steps (Decide / Render /
Label / Post) were all skipped, and the raw/payload/stderr archives
that the earlier hardening created were never written because the
shell aborted at the assignment before `printf > investigate-raw.json`
could run.

Root cause: GHA's default shell is `bash -e {0}` (errexit). With
errexit on, a failing command substitution:

  raw=$(timeout 300s claude -p ...)

propagates the exit code and aborts the script BEFORE `claude_exit=$?`
runs. My prior assumption that assignments were exempt from errexit
under `bash -e` was wrong in this shell configuration.

## Fix

Use the if-form, which is the only reliable way to catch a failing
command substitution under `bash -e`:

  if raw=$(timeout 600s claude -p ... 2>log); then
    claude_exit=0
  else
    claude_exit=$?
  fi

A timeout (exit 124) or other CLI failure now sets `claude_exit`,
writes the archived artifacts, and falls through to 8b with a
specific warning — exactly the graceful path the earlier PR intended
but errexit short-circuited.

## Also bumped timeout 300s → 600s

The original 300s was chosen to be "typical investigate runtime + a
bit." Observed times: #424 ran 218s, #442 ran 220s — so 300s left
almost no headroom. Doubling to 600s gives room for complex issues
to converge while still being short of the ~9-minute hang that
motivated the timeout in the first place.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:30:15 -04:00
Aaddrick
ce2137f63a fix(triage): pass investigate schema to claude CLI (#462)
The investigate call was the only Sonnet invocation in v2 without
`--json-schema`. After the parser hardening in #461, re-dispatched
runs produced valid JSON — but with fields omitted and creative
top-level wrappers. The prompt-described schema isn't enforced
without the flag, and the model was using the freedom.

## What changed

Add `--json-schema "${schema}"` where `schema=$(cat
.claude/scripts/schemas/investigate.json)`, matching the classify
and doublecheck pattern.

Output parsing prefers the CLI-validated `.structured_output` field
(populated when schema fit cleanly), falling back to the existing
`.result` + `extract-json.py` + shape-check path for the case where
the CLI returns prose on schema miss. The hardened extraction from
#461 stays in place as the safety net.

## Why post-hoc still helps

Per Claude Code CLI docs (and confirmed via the claude-code-guide
research), `--json-schema` applies validation after the agent loop
ends — not at generation time. That's weaker than the Agent SDK's
constrained decoding, but still catches the specific failures seen
in the re-dispatch of #424 and #442:

- Top-level `pattern_sweep` and `proposed_anchors` omitted
- Per-finding `confidence` / `line_end` returned as null (violates
  required enum / integer)
- Extra top-level fields like `summary`, `classification`,
  `investigation_id`

If post-hoc validation isn't enough, the next escalation is the
Agent SDK (constrained decoding via grammar compilation).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:23:28 -04:00
Aaddrick
82908fbe64 fix(triage): harden Investigate step against hangs and parser drift (#461)
Three failure modes surfaced in the first round of dispatches against
real issues, all in the Stage 4 Investigate step:

- #394 hung for 9 min (the Claude CLI wedged; no per-call timeout);
  user had to cancel manually. Step log was silent because
  `2>/dev/null` swallowed stderr.
- #424 and #442 both ran to CLI completion but the payload's jq
  presence-check rejected the output. Raw response wasn't archived,
  so the specific rejection cause was unknowable post-hoc.

## Fix

- `timeout 300s claude -p ...` — bounds the step at 5 min; exit 124
  routes to 8b no-findings gracefully via the existing warning branch.
- `2>/tmp/triage/investigate-stderr.log` instead of `2>/dev/null` —
  CLI diagnostics ride along in the run's uploaded artifact bundle,
  available for post-mortem without a re-dispatch.
- Raw CLI response archived as `investigate-raw.json` before any
  parsing. Extracted payload archived as `investigate-payload.txt`
  before schema checks. Schema-reject no longer loses the evidence.
- Fence-strip + jq-presence-check replaced with
  `.claude/scripts/triage/extract-json.py`, which uses
  `json.JSONDecoder.raw_decode` to handle leading OR trailing prose
  around the JSON body. Addresses PR #459 review item 6.
- The shape check now verifies each of the four required fields is
  an `array`, not just present — `{"findings": "oops"}` would pass
  presence and explode downstream. Addresses PR #459 review item 7.

## Testing

`extract-json.py` exercised locally against: bare JSON, leading
prose, trailing prose, fence-wrapped JSON, pure prose (exit 1),
malformed JSON (exit 2). All cases produce the expected output or
exit code.

`actionlint -shellcheck` clean on the workflow.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:08:58 -04:00
Aaddrick
1de897f56e feat(triage): dry_run input + pre-dispatch fixes (#460)
Adds a dry_run dispatch input so the pipeline can be validated against
real issues without writing to the repo. Also folds in three items
from the #459 code review that are easier to ship before the first
round of dispatches than after.

## dry_run

- New boolean input on `workflow_dispatch` (default false)
- Guards `Apply labels` and `Post comment` steps
- Step summary shows a ⚠ banner + a "Dry run" row when enabled
- Artifacts still upload, so the rendered `comment.md` is inspectable

## Review fixups (from PR #459 review)

1. **Decision gate priority.** Spec §7 puts version drift ahead of
   fetch failure; implementation had them reversed. When both fire,
   `version-drift` is the more specific signal and is the only path
   that hands the maintainer drift-bridge candidates. Swapped.
2. **Issue titles wrapped as untrusted.** `<issue_title>` now carries
   `source="reporter, untrusted"` in all three prompt assemblies
   (classify / doublecheck / investigate). Instruction-as-data
   directive in each prompt updated to name both `<issue_title>` and
   `<issue_body>`. Reporter-controlled title injection surface closed.
5. **`drift-bridge.sh` version search is literal.** `--fixed-strings`
   added to `git log --grep` so `1.3.23` doesn't match `1x3y23`.

Items 3, 4, 6-9 from the review are deferred to Phase 3 (adversarial
reviewer) per the review's own scoping.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:48:01 -04:00
Aaddrick
755bef4c28 Merge pull request #459 from aaddrick/staging/triage-v2
feat(triage): issue triage v2 (Phases 0-2)
2026-04-20 18:37:45 -04:00
Aaddrick
34631068ee feat(triage): Phase 2 — investigate, mechanical validate, 8a findings (#458)
Extends the Phase 1 deferral-only pipeline with the bug-investigation
path: Stages 3 (fetch reference), 4 (investigate), 5 (mechanical
validate), 7 partial (decision gate), and 8a (findings variant).
Non-bug classifications still route through 8b; adversarial reviewer
is Phase 3.

## What Phase 2 adds

- **Stage 3 — Fetch reference.** `gh release download --pattern
  'reference-source.tar.gz'` with 3× exponential backoff (2s/8s/32s).
  Fetch failure routes to 8b with reason `reference-source unavailable`
  (the 7th reason added to `reasons.json`).
- **Stage 4 — Investigate.** `schemas/investigate.json` +
  `prompts/investigate.txt`. Claude reads repo + reference source via
  tool access (`--dangerously-skip-permissions`), emits structured
  findings / pattern_sweep / proposed_anchors / related_issues. Prompt
  enforces hypothesis voice, cross-cutting-sweep obligation, hard
  schema bans.
- **Stage 5 — Mechanical validation.** `.claude/scripts/triage/
  validate.sh` — pure bash. Checks per finding: file exists, line
  range valid, evidence_quote grep-matches at cited line, closed-world
  options extracted for identifier claims (grep heuristic for Phase 2;
  ast-grep upgrade deferred to Phase 3). Per anchor: `grep -P` match
  count exactly equal to expected_match_count. Per related_issue:
  `gh issue view` fetch + body excerpt. Emits `validation.json`.
- **Stage 3a — Version drift check.** Compares classify's
  `claimed_version` against `vars.CLAUDE_DESKTOP_VERSION`. Drift flag
  routes to 8b with `version drift` reason; investigation still runs.
- **Drift-bridge sweep.** `.claude/scripts/triage/drift-bridge.sh` —
  bash, resolves claimed_version to approximate date via `git log
  --grep`, then date-windowed `git log` on finding files + `gh pr
  list` basename search. Candidates attach to 8b as a rendered bullet
  block.
- **Stage 7 partial — Decision gate.** Priority: drift → 8b drift-
  bridge · fetch failure → 8b reference-source-unavailable ·
  investigate failure or zero surviving findings → 8b no-findings ·
  avg confidence < medium → 8b low-confidence · else → 8a.
- **Stage 8a — Findings variant.** `schemas/comment-findings.json` +
  `prompts/comment-findings.txt`. Claude emits structured comment
  object (hypothesis_line, findings[], patch_sketch?, related_issues);
  bash renders markdown. No post-hoc prose stripping — the schema
  guarantees shape. 400-word cap truncates the `<details>` patch block
  only.
- **Stage 8b extension.** Drift-bridge-candidates bullet block renders
  only when reason is `version drift` AND the sweep returned ≥1
  candidate. Phase 1's first-issue privacy note + reason-enum post-
  processor are preserved.
- **Stage 9.** Labels: 8a → `triage: investigated`; 8b routing
  unchanged. Artifacts extended with `investigation.json`,
  `validation.json`, `drift-bridge-candidates.json` (conditional).

## Risks validated locally

- Mechanical validation catches fabricated identifiers *and* non-
  matching anchors — smoke tested with a two-finding / two-anchor
  fixture (one real, one fabricated per kind); failure_reasons fire
  correctly on the fabricated ones.
- Closed-world extraction via grep heuristic: on a JS switch with
  three cases, returns all three as `closed_world_options` bounded
  to ±100 lines.
- `grep -c` exits 1 on no-match and prints "0" — validated the `|| true`
  idiom doesn't double-count.

## Deferred

- Stage 6 adversarial reviewer (Phase 3)
- Confirmed-duplicate routing with Stage 6's exact/related rating
  (Phase 3)
- Feature-design variant 8c (Phase 4)
- Suspicious-input tells + edit-during-triage detection (Phase 4)
- ast-grep upgrade for closed-world extraction (Phase 3)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:09:15 -04:00
Aaddrick
0f55547523 feat(triage): Phase 1 — gate, classify, 8b deferral, label/post/archive (#457)
Turns the Phase 0 skeleton into a live triage pipeline. Every dispatched
issue now gets a structured human-deferral comment and a triage label.
No investigation yet — that's Phase 2.

## Stages landed (per docs/issue-triage/implementation-plan.md §Phase 1)

- **Stage 1 — Gate.** `github-actions[bot]` author skip; manual dispatch
  intentionally bypasses the already-triaged / needs-human checks (those
  only matter on the `opened` trigger, deferred to cutover).
- **Stage 1 — Input snapshot.** `issue.body`, `issue.updated_at`,
  `sha256(issue.body)` captured before any LLM call; archived as
  `input_snapshot.json`. Edit-during-triage comparison lands in Phase 4.
- **Stage 2 — Classify.** `schemas/classify.json` + `prompts/classify.txt`.
  Fields: classification enum, confidence, claimed_version,
  suggested_labels[], duplicate_of, regression_of. Issue body wrapped as
  untrusted data.
- **Stage 2 — Doublecheck.** `schemas/classify-doublecheck-bugfeature.json`
  + `prompts/classify-doublecheck-bugfeature.txt`. Runs conditionally
  when the first pass returns `bug` or `feature`. Fresh context — no
  first-pass output exposed.
- **Stage 7 (partial) — Reason selection.** Two reasons fire in Phase 1:
  `ambiguous` when the doublecheck disagrees, `no-findings` otherwise.
  The other four reasons in `reasons.json` light up in Phases 2–4.
- **Stage 8b — Human-deferral render.** Bash-only template reading
  `reasons.json`. First-issue privacy note appended when the reporter
  has no prior issues on the repo. Post-processor enforces: reason line
  in `reasons.json` enum, comment under 150 words.
- **Stage 9 — Label + post + archive.** Cached `gh label list` at
  workflow start; cardinality-1 slots (triage state, class, priority)
  applied directly; categories filtered through the cache + blocklist.
  Never emits `priority: critical`. Artifacts uploaded with 14-day
  retention: `input_snapshot.json`, `classification.json`,
  `classification-doublecheck.json` (when ran), `comment.md`,
  `issue.json`, `repo-labels.json`.

## Validation

- actionlint + shellcheck clean on inline bash
- Schemas parse as JSON; prompts validated via jq
- Matches Phase 1 exit criteria once dispatched against real issues
  (bug with stack trace → needs-human + no-findings; ambiguous →
  needs-human + ambiguous; no hallucinated labels applied)

## Deferred to Phase 2+

- Investigation (Stage 4), mechanical validation (Stage 5), adversarial
  review (Stage 6)
- Findings variant (8a), feature-design variant (8c)
- Drift-bridge sweep (extends 8b with candidate commits/PRs)
- Confirmed-duplicate routing (needs Stage 5+6)
- Suspicious-input tells and edit-during-triage detection (Phase 4)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 17:39:37 -04:00
Aaddrick
b354353a36 feat(triage): Phase 0 scaffold for issue triage v2 (#456)
Directory scaffolding + skeleton workflow + issue templates. No live
behavior — v2 remains workflow_dispatch-only with `permissions: {}` and
a single job that echoes the issue number. v1 (`issue-triage.yml`) is
untouched.

Per docs/issue-triage/implementation-plan.md Phase 0:

- `.github/workflows/issue-triage-v2.yml` — skeleton workflow
- `.github/ISSUE_TEMPLATE/{config,bug_report,feature_request}.yml` —
  shapes input for the Stage 2 classifier and Stage 4 investigator;
  privacy disclosure in a non-editable markdown info block
- `.claude/scripts/prompts/.gitkeep` — prompts land per-phase
- `.claude/scripts/taxonomies/label-blocklist.json` — Stage 9 suggested-
  label gating (wontfix, invalid, duplicate, help wanted, good first
  issue); additional taxonomies land in Phase 4
- `.claude/scripts/reasons.json` — Stage 8b deferral-reason SSOT
  consumed by the renderer and post-processor (six entries)
- README Privacy section — keeps disclosure text discoverable without
  filing an issue; matches the templates' info block

Exit criteria: dispatch against any issue number prints correctly; no
API calls, no comments, no labels; `bug_report.yml` / `feature_request
.yml` render cleanly with the privacy block.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 17:29:17 -04:00
Aaddrick
b308c0ffd2 docs: add issue triage pipeline design (#455)
Adds the issue triage pipeline design under docs/issue-triage/:

- README.md — base pipeline spec
- implementation-plan.md — stage-by-stage plan
- research-trail.md — references that informed the design

Replaces the original single-file docs/issue-triage.md that was
reverted from main in f829d3b. Squash of 28 drafting commits from
the prior docs/triage-pipeline-design branch (backup at
backup/docs-triage-pipeline-design-pre-rebase).

Co-authored-by: Claude <claude@anthropic.com>
2026-04-20 17:13:39 -04:00
Travis
7e33c095da fix(kvm): probe virtiofsd fallback paths in KvmBackend (#447) (#454)
Follow-up to #453: the daemon still spawns virtiofsd via PATH lookup
(`spawnProcess('virtiofsd', ...)`), so on stock Debian/Ubuntu
(`/usr/libexec/virtiofsd`) and Arch/CachyOS/Manjaro
(`/usr/lib/virtiofsd`) the spawn ENOENTs and KvmBackend silently
falls through to virtio-9p — users who opted into
`COWORK_VM_BACKEND=kvm` and installed virtiofsd get 9p performance
without knowing.

Mirror doctor.sh's `_find_virtiofsd` in JS: probe `COWORK_VM_VIRTIOFSD_BIN`
override, then `which`, then the same fallback list. Pass the resolved
absolute path as argv[0] so the spawn bypasses PATH entirely.

Also:
- Add a `spawnFailed` flag the socket-wait loop checks for early exit
  when the async 'error' event fires (e.g. binary removed between
  probe and exec) — prevents a 5s stall before 9p fallback.
- Guard `this.virtiofsdProcess.kill()` against the race where the
  error handler has already zeroed it.
- Rename doctor.sh's test hook `_COWORK_DOCTOR_VFSD_PATHS` →
  `_COWORK_VFSD_PATHS` so doctor and daemon share the same env var
  for lock-step test parity (shipped 24h ago in #453, zero external
  users).

Verified on CachyOS via a node harness covering 8 scenarios:
PATH hit, fallback hit, fallback ordering, total miss, non-executable
rejection, explicit override wins over PATH, override non-executable
→ null, override missing → null (no fall-through).

All 45 BATS tests still pass after the env-var rename.

Not verifiable locally: Ubuntu `/usr/libexec/virtiofsd` hit (needs an
Ubuntu VM with `qemu-system-common`). Logic is symmetric to the Arch
case that is verified.

Co-authored-by: Claude <claude@anthropic.com>
2026-04-20 15:52:53 -05:00
Travis
89582bb8f0 fix: detect virtiofsd at off-PATH install locations (#447) (#453)
* fix: detect virtiofsd at off-PATH install locations (#447)

Ubuntu ships virtiofsd at /usr/libexec/virtiofsd (from qemu-system-common)
and Arch/CachyOS/Manjaro at /usr/lib/virtiofsd. Neither is on the default
$PATH, so doctor.sh's `command -v virtiofsd` always returned a false
negative — users would install the package and still see "virtiofsd: not
found" (reported most recently by @zabka in #445, originally flagged by
@jarrodcolburn).

Adds a _find_virtiofsd helper that searches PATH first, then the known
off-PATH install locations:

  - /usr/libexec/virtiofsd  (Debian/Ubuntu/Fedora/RHEL)
  - /usr/lib/qemu/virtiofsd (legacy Debian)
  - /usr/lib/virtiofsd      (Arch/CachyOS/Manjaro)

Splits virtiofsd out of the KVM tools loop into a dedicated three-branch
check:

  [PASS] virtiofsd: found                                  — on PATH
  [PASS] virtiofsd: found at <path> (not on PATH)          — off-PATH, bwrap default (virtiofsd unused)
  [WARN] virtiofsd: found at <path> but not on PATH        — off-PATH, COWORK_VM_BACKEND=kvm
         (+ info lines about 9p fallback + symlink Fix)
  [INFO]/[WARN] virtiofsd: not found                       — missing (severity ladder unchanged)

The WARN-on-KVM-active branch surfaces that KvmBackend spawns virtiofsd
by PATH name and will silently fall back to virtio-9p (lower performance)
if the binary is only reachable off-PATH — so the user knows a symlink
is needed to actually get virtiofs performance.

Tests: 6 new BATS cases in tests/cowork-bwrap-config.bats exercise the
helper (PATH hit / fallback hit / ordered fallback / total miss /
non-executable skip / default-list regression guard for the Arch path).
All 45 tests pass.

Does not touch cowork-vm-service.js — teaching KvmBackend to probe
these same paths would give Ubuntu KVM users real virtiofs performance
without a symlink, but that's a separate change.

Fixes #447

Co-Authored-By: Claude <claude@anthropic.com>

* style: collapse unnecessary line continuations in virtiofsd check

Simplifier pass — the five backslash-continued `_warn` / `_info`
invocations in the new virtiofsd three-severity block were all under
63 chars after collapsing, well within the project's 80-char
guideline. The continuations were visual noise, not wrap-driven.

Behavior byte-identical. All 45 BATS tests still pass.

Co-Authored-By: Claude <claude@anthropic.com>

---------

Co-authored-by: Claude <claude@anthropic.com>
2026-04-20 15:34:49 -05:00
Aaddrick
f593cedcac Merge pull request #443 from aaddrick/refactor/build-split-for-codeowners
refactor: split build.sh by subsystem + add CODEOWNERS
v2.0.0+claude1.3109.0
2026-04-20 08:19:35 -04:00
aaddrick
d939b0795e refactor: extract --doctor into scripts/doctor.sh
Moves run_doctor and its 9 internal helpers out of launcher-common.sh
(~670 lines) into their own scripts/doctor.sh. launcher-common.sh
now sources doctor.sh via a BASH_SOURCE-relative path, so any consumer
still gets the run_doctor entry point without needing to know about
the split.

Rationale: the testing / release-quality role concerns itself with
--doctor, and giving that subsystem its own file lets CODEOWNERS
scope it independently of the rest of launcher-common (display
detection, cleanup handlers, electron env) which remain in aaddrick's
domain.

Each packaging target now installs doctor.sh alongside launcher-common.sh:

  scripts/packaging/appimage.sh  → /usr/lib/claude-desktop/{launcher-common,doctor}.sh
  scripts/packaging/deb.sh       → /usr/lib/<pkg>/{launcher-common,doctor}.sh
  scripts/packaging/rpm.sh       → /usr/lib/<pkg>/{launcher-common,doctor}.sh
  nix/claude-desktop.nix         → $out/lib/claude-desktop/{launcher-common,doctor}.sh

Pure-move refactor. Function bodies byte-identical to the pre-split
launcher-common.sh content. Verified: `source launcher-common.sh` still
defines all 19 previous functions (9 launcher + 10 doctor); a live
run_doctor invocation produces the same output as before.

Co-Authored-By: Claude <claude@anthropic.com>
2026-04-20 08:09:03 -04:00
aaddrick
338f6ec1c1 docs: refresh for scripts/ split layout
Updates agent definitions, learnings, CLAUDE.md, and BUILDING.md so
path references point at the new module files instead of the old
monolithic build.sh.

Agent definitions:
  .claude/agents/issue-triage.md              — table of per-category
    investigation paths now points at scripts/patches/*.sh and
    scripts/packaging/*.sh instead of "build.sh (search patch_X)".
  .claude/agents/electron-linux-specialist.md — patching-functions
    table now includes each function's file location; directory tree
    illustration reflects the new scripts/ layout.

Documentation:
  CLAUDE.md                                   — "Working with Minified
    JavaScript" section points at scripts/patches/*.sh; frame-fix
    injection attributed to scripts/patches/app-asar.sh; the
    version-bump checks now grep scripts/setup/detect-host.sh.
  docs/BUILDING.md                            — automated version
    detection paragraph now mentions scripts/setup/detect-host.sh as
    the file that holds the URLs.
  docs/learnings/cowork-vm-daemon.md          — Patch 6 pointer now
    says scripts/patches/cowork.sh; line-number references dropped in
    favour of anchor-based search (line numbers drift between releases).
  docs/learnings/plugin-install.md            — Key Files section
    points at scripts/patches/cowork.sh for patch_cowork_linux.

Historical changelog-style references (e.g. docs/cowork-linux-handover.md
describing what was "added to build.sh" during initial cowork work)
are intentionally left unchanged — they describe a point-in-time state
of the codebase.

Co-Authored-By: Claude <claude@anthropic.com>
2026-04-20 07:31:02 -04:00
aaddrick
01f7125d6a ci: refresh issue-triage prompts for scripts/patches/ layout
Updates the inline prompt text that guides the triage investigation
agent so it looks for patches in the correct location. The previous
prompt told the agent "search build.sh for patch_ functions" — those
functions have moved into scripts/patches/*.sh organized by subsystem
(tray, cowork, claude-code, quick-window, titlebar, app-asar).

Without this, the triage agent would open build.sh, find only the
orchestrator's source statements, and fail to locate the actual
patch logic — producing lower-quality diagnoses.

Three prompt blocks updated: the "How This Project Patches" section,
the "All bugs are ours to fix" checklist, and the "Patch Approach"
output format. build.sh itself still appears as the orchestrator
reference for context.

Co-Authored-By: Claude <claude@anthropic.com>
2026-04-20 07:27:17 -04:00
aaddrick
564f465840 ci: update check-claude-version paths to scripts/setup/detect-host.sh
The auto-version-bump workflow greps/seds against the Claude Desktop
download URLs and SHA-256 checksums. With the build.sh split those
declarations now live in scripts/setup/detect-host.sh inside
detect_architecture's case statement.

Without this fix, the next upstream release triggers the workflow
and it silently fails to update either the URLs or the checksums
(greps return empty, seds match nothing, git diff finds no changes,
no commit, no tag).

Updates all 17 references — grep targets, sed targets, git
diff/add paths, and step labels / echo messages for consistency.
The patterns themselves (x86_64) / aarch64) case matching,
claude_download_url=' extraction, in-range claude_exe_sha256
replacement) are unchanged and still match the new file's content.

Co-Authored-By: Claude <claude@anthropic.com>
2026-04-20 07:26:40 -04:00
aaddrick
526acbad1e ci: enable shellcheck -x to follow sourced modules
Passes -x (--external-sources) to shellcheck so it follows the
'# shellcheck source=...' directives in build.sh and checks the
split modules in their sourced context. Without this, every sourced
module triggers SC1091 (can't follow source) plus SC2154/SC2034
noise from cross-file variable usage.

Also quotes $script_dir inside $(dirname $script_dir) in
scripts/packaging/rpm.sh — the heredoc-embedded command
substitution tripped SC2086 once shellcheck started analyzing the
subshell context.

Co-Authored-By: Claude <claude@anthropic.com>
2026-04-20 07:25:21 -04:00