Skip to content

Promote main to release - #2547

Merged
copse-release-bot[bot] merged 12 commits into
releasefrom
main
Sep 7, 2026
Merged

copse-release-bot[bot] merged 12 commits into
releasefrom
main

Conversation

@copse-release-bot

Copy link
Copy Markdown
Contributor

Daily automated promotion of main to the release-ready release branch.

The repository ruleset requires the full CI Passed gate. This PR will
merge automatically when that gate is green; failures leave it open
until the underlying issue is fixed on main.

jonathanKingston and others added 12 commits September 6, 2026 16:10
Moves Copse from Apache-2.0 to the GNU Affero General Public License v3.

Anyone may still use, study, modify and redistribute Copse — including
commercially. What changes is that a distributed modification, or one
made available to users over a network, must ship its complete
corresponding source under the same terms. That keeps Copse open source
under the OSD while closing the closed-rebrand path a permissive licence
leaves open, and it preserves the option to sell proprietary exceptions
later.

## The switch is prospective

Releases up to and including `0.1.0-beta.8` went out under Apache-2.0,
and [Apache-2.0 §2](https://www.apache.org/licenses/LICENSE-2.0#patent)
grants a *perpetual, irrevocable* licence — so that grant stands for
those versions and cannot be withdrawn. The README records the boundary
explicitly so there is no ambiguity about which release carries which
terms.

Exposure at the time of the switch: 0 forks, 1 star, and pull requests
are collaborators-only.

## Dependency compatibility — checked, not assumed

Going copyleft means every dependency must be GPL-compatible. Verified
before making the change:

| Dependency | Licence | Status |
|---|---|---|
| `@anthropic-ai/sandbox-runtime` | Apache-2.0 | One-way compatible with
GPLv3 |
| `@novnc/novnc` | MPL-2.0 | Sources carry **no** "Incompatible With
Secondary Licenses" notice, so §3.3 permits it in a Larger Work under
the GNU licences |
| `@nationaldesignstudio/rampart` | CC-BY-4.0 | [Listed by the FSF as
GPLv3-compatible](https://www.fsf.org/blogs/licensing/cc-by-4-0-and-cc-by-sa-4-0-added-to-our-list-of-free-licenses)
|
| Electron, Monaco, xterm, vendored Cursor plugins | MIT | Compatible |
| `packages/extract-zip` | BSD-2-Clause | Unchanged — fork of a
third-party package, keeps its own licence |

The noVNC check was the one real risk: an MPL-2.0 project *can* opt out
of GPL compatibility, but only by actively attaching the notice to its
file headers. noVNC's headers say only `Licensed under MPL 2.0 (see
LICENSE.txt)`.

## Attribution that was missing

There was no copyright notice anywhere in the repository — no `author`,
no `build.copyright` for the macOS bundle, and Apache-2.0's appendix was
never filled in. Enforcing any licence starts with being able to show
who holds the copyright, so this adds it.

## Contributions

`CONTRIBUTING.md` gains a DCO sign-off requirement (`git commit -s`) so
future contributions carry a provenance record.

No CLA yet, deliberately. A CLA grants rights *beyond* the inbound
licence and is what would enable selling proprietary exceptions; a DCO
does not. Since PRs are currently collaborators-only there is nothing
inbound to cover, and the deadline for adding one is the first outside
contribution, not a date.

## Changes

- `LICENSE` — full AGPL-3.0 text, verified word-for-word against the
SPDX canonical text
- `package.json` — `license`, plus new `author` and `build.copyright`
- 9 workspace packages — `Apache-2.0` → `AGPL-3.0-only`
- `README.md` — licence section with copyright, plain-English summary,
and the relicensing boundary
- `THIRD_PARTY_NOTICES.md` — updated, including why noVNC's MPL and
rampart's CC-BY remain compatible
- `CONTRIBUTING.md` — DCO section
- Three stale "Copse is Apache-2.0" statements in `docs/`

## Verification

- `oxfmt --check .` passes across 2,782 files
- All 11 `package.json` files parse and report the expected licence
- No test or script pins the licence value
- The site's "Free & open source" claims stay accurate — AGPL-3.0 is
OSI-approved, so no marketing copy needed changing

## Note before merging

Two things worth a second opinion, since neither is reversible in the
way code is:

1. **`AGPL-3.0-only` vs `AGPL-3.0-or-later`.** I chose `-only`, which is
the usual pick for projects planning to sell exceptions — it avoids
automatically adopting a future AGPLv4 you haven't read. Easy to change
if you'd rather follow the FSF's `-or-later` recommendation.
2. **This commit is not DCO signed-off**, since the sign-off is a
personal certification and shouldn't be made on your behalf. Worth
signing off your own commits from here on, given the policy this PR
introduces.

A solicitor's read of a one-page licence change is cheap relative to
what rests on it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01D6MCpnuHT1yLDRW5JNSi9F

---
_Generated by [Claude
Code](https://claude.ai/code/session_01D6MCpnuHT1yLDRW5JNSi9F)_

Co-authored-by: Claude <noreply@anthropic.com>
## Summary

- Adds `packages/store-kit` (`@copse/store-kit`): `copse-paths`,
`cached-store`, `persistent-store`, `secret-cipher`, `keyring-cipher`,
`os-keyring`, `secret-migration`, `secret-write-policy`,
`api-key-readability`, `storage-schema`, `user-data-migration`,
`zip-reader`, and `zip-archive` moved from `src/main/services/storage/`
with their eleven test files. Depends on `@copse/std`,
`@napi-rs/keyring`, and `zod`; no host-app imports.
- **Deduplicates the profile-root defaults.** Three packages had each
reimplemented them, because none could import the app's resolver:
`@copse/thread-store`'s `defaultWorkspaceRoot`, and
`@copse/hooks-dialects`' and `@copse/plugin-sdk`'s `defaultDataRoot`.
All three now delegate to `copseWorkspaceDir` / `copseDataRoot` here, so
`COPSE_DIR` and `COPSE_WORKSPACE_DIR` resolve identically everywhere
rather than by three copies agreeing. That was the main reason to do
this fold before the remaining ones.
- The storage files of the same names are one-line re-exports, so the
app's importers are unchanged. `settings`, `storage`, `knowledge-store`,
`long-task-tracker`, `project-namespace`, `plan-window-history`,
`usage-ledger`, and the `electron-store` backend stay in the app.
- The `cached-store` stub had no remaining importers (`persistent-store`
was its only one, and it moved too) and was deleted; the comment in
`storage.ts` that pointed at it now names the package.

Second of the small folds tracked in #2313. Part of #2303. Independent
of #2349 (`@copse/procwatch`), which is also on `main`.

## Why draft

Opened as a draft to secure the work: it was uncommitted in a
`.claude/worktrees/` checkout, which gets cleaned up. Local gates below
are green; mark ready once CI agrees.

## Validation

- `pnpm test -- workspace-package-invariants pnpm-workspace-invariants
module-boundaries stdout-protocol-guard settings-read-keys
packages/store-kit/ packages/thread-store/ packages/hooks-dialects/
packages/plugin-sdk/ src/main/services/storage/` — 448 pass
- `pnpm run typecheck`, `pnpm run check:dead-code`, `pnpm run build` —
clean
- `pnpm run lint`, `pnpm test` (full unit suite) — reported in a
follow-up comment
- Build plumbing and an equal-behaviour move; no user-visible change, so
no visual evidence is required.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…sites) (#2420)

Follow-on from #1330, now that #2419 has landed. **104 asserted
predicates, down from 118.**

First of five follow-ups; the rest are listed at the bottom.

## The pattern

Sixteen sites spelled "is this a usable string?" four different ways,
each as an inline `x is string` assertion:

```ts
.filter((part): part is string => Boolean(part))
.filter((p): p is string => typeof p === 'string' && p.length > 0)
.filter((g): g is string => typeof g === 'string' && g.trim().length > 0)
.filter((block): block is string => block !== undefined && block.trim().length > 0)
```

An inline predicate is the one shape `docs/type-safety.md`'s "an
exported predicate needs a test" rule can't reach — there's nothing to
import, so nothing to test. Naming it is the only remedy available, and
`@copse/std` is where #1332 already put `isDefined` / `isNonNull` for
exactly this reason.

## Why two helpers and not one

**`isNonEmptyString`** accepts `' '`; **`isNonBlankString`** does not.
The four spellings above are not one function — collapsing them into a
single helper would have silently changed behaviour at nine sites.

Neither is named as *the* default, deliberately: a call site has to
pick, and the name then says at a glance what the old body made you read
the condition to work out. That already caught one lie —
`nonEmptyString` in `terminal-bench-steering.mts` was the
blank-rejecting one, so its name meant the opposite of the codebase's
other `length > 0` checks.

## `Boolean(part)` is only equivalent under a condition

`Boolean(x)` and `isNonEmptyString(x)` agree exactly when nothing in the
array is a truthy non-string — a `5` would be kept by one and dropped by
the other, and the types cannot see the difference because the old
annotation claimed `x is string` either way.

So I checked rather than assumed. Every receiver at the six `Boolean()`
sites:

| site | element type |
| --- | --- |
| `agent-errors.ts` ×2, `settings-dialog.ts`, `semantic-index.ts` |
`(string \| null)[]` |
| `agent-service.ts`, `model-picker.ts` | `(string \| undefined)[]` |
| `agent-model-identity.ts` | `readonly (string \| null \| undefined)[]`
|
| `cx.ts` | `(string \| false \| null \| undefined)[]` |
| `workspace-trust.ts` | `any[]` |

No numerics, so the swap is exact. The test pins that equivalence
directly rather than describing it:

```ts
assert.deepEqual(values.filter(isNonEmptyString), values.filter((value) => Boolean(value)))
```

`workspace-trust.ts` is worth a look — it reads `any[]` off storage, and
it is the site where #2419 measured that *deleting* the annotation
degrades to `any` and trips `no-unsafe-*`. A named predicate narrows
`any → string` explicitly, so it fixes what deletion broke.

## Verification

- **No type moved.** Every named binding and signature in the 15 touched
files resolves to a byte-identical type before and after. The one
exception is the local `nonEmptyString` this deletes. (The
call-expression diff #2419 used doesn't work here — removing `Boolean()`
changes the number of calls in a file — so this compares declarations by
name instead.)
- **The tests can fail.** A `return true` body turns 8 of the 16 new
cases red.
- `pnpm run check` end to end on the rebased branch: **8,604 passing / 0
failing**.

No visual eval: `cx.ts`, `model-picker.ts` and `settings-dialog.ts` are
class-name and detail-string joins whose output is unchanged — same
predicate, same strings, unchanged DOM.

## Also

`firstNonEmptyString` in `@copse/std` had its own inline copy of this
check; it now uses `isNonEmptyString`, so the package states the concept
once.

Two sites in this family are deliberately left alone:
`github-ci-service.ts` narrows the *object* (`row is GhPrCheckRow & {
name: string }`), not the string, and `sync-thread-branch.ts` is domain
logic wearing a predicate.

## The rest of the follow-ups

| | sites | |
| --- | --- | --- |
| **this PR** | 16 | shared string-presence predicates |
| next | 8 | finish #1332's `isRecord` sweep — it matched on the name,
so `scripts/` copies and the `isUnknownRecord` / `isDetail` aliases
survived |
| then | 4 | `keyOf()` for `k in RECORD` predicates |
| then | 4 | contract tests for the `typeof x === 'function'` guards,
which assert a call contract nothing checks |
| then | ~10 | one-off conversions |
| separately | ~50 | structural boundary parsers → schemas.
Behaviour-affecting, wants its own issue |

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01J9mJfbsrvEeTMUdxTKf8xZ


---
_Generated by [Claude
Code](https://claude.ai/code/session_01J9mJfbsrvEeTMUdxTKf8xZ)_

Co-authored-by: Claude <noreply@anthropic.com>
…now also carries #2422 (#2421)

Follow-on from #1330. **97 asserted predicates, down from 104** for this
PR's own commit; **95** with #2422 included.

> **This branch now contains two changes.** #2422 was merged into it
rather than into `main`, so its commit is here as well and the diff
below is the pair. #2422's own description is the review material for
the `keyOf` half — it is the one with the settings-gate bug in it.
Everything under the headings below is this PR's commit only.
>
> Still stacked on #2420 — retarget to `main` once that merges. #2423
has been retargeted onto this branch.

## What #1332 missed, and why

#1332 consolidated 22 copies onto `@copse/std` and reported one
definition left standing. Seven survived, because the sweep matched the
**identifier**: five live in `scripts/`, and two are spelled
`isUnknownRecord`.

```
scripts/lib/cloud-hosts.mts                       isRecord
scripts/lib/edited-file-check.mts                 isRecord
scripts/lib/terminal-bench-task-image.mts         isRecord
scripts/lib/terminal-bench-tasks.mts              isRecord
scripts/skillsbench-agent-lib.mts                 isRecord
src/main/services/acp/session-update-adapter.ts   isUnknownRecord
src/main/services/remote/cursor-agent-discovery.ts  isUnknownRecord
```

Not a criticism of that PR — it's the failure mode of any name-based
sweep, and it's the reason the inventory from #2419 keys on *shape*
instead. These seven were sitting in it.

## The behaviour question

Five of the seven omit `!Array.isArray(value)`, so they accept arrays
where the canonical one doesn't. That's a real difference, so I checked
each rather than repeating #1332's argument on faith.

Every use is `isRecord(v)` followed by a **named string-key read**:

| file | what follows the check |
| --- | --- |
| `edited-file-check.mts` | `source[key]` for `'file_path'`,
`'notebook_path'`, … |
| `terminal-bench-task-image.mts` | `value['Id']`,
`image['RepoDigests']` |
| `terminal-bench-tasks.mts` | `value['schemaVersion']`, `value['name'
\| 'image' \| 'configSha256']` |
| `skillsbench-agent-lib.mts` | `!(name in args)` before the read |
| `cursor-agent-discovery.ts` | `rec['id']`, `rec['items']`,
`repoRec['url']`, … |

An array has none of those keys, so it already fell through to the same
`undefined` / `null` / throw. The narrowing has no reachable behaviour
change.

## Two files kept their predicate — both were traps, not copies

**`scripts/perf-report.mts`** had an `isRecord` that reads `t` and
`name` and narrows to a trace-line interface:

```ts
function isRecord(value: unknown): value is Record_ {
  if (typeof value !== 'object' || value === null) return false
  const t: unknown = Reflect.get(value, 't')
  const name: unknown = Reflect.get(value, 'name')
  return typeof t === 'number' && typeof name === 'string'
}
```

A sweep matching on the name — this one, next time — would have replaced
it and made the parser accept every object. It's now `isTraceLine`, with
`Record_` renamed to `TraceLine` to match, and a comment saying why the
name matters.

**`src/main/services/diagnostics/perf-ipc.ts#isDetail`** claims its
values are `string | number | boolean | undefined` and only checks the
container is an object. That one is a genuine lie, but fixing it drops
diagnostics: this is the debug perf branch, and rejecting an
object-valued detail would silently lose data rather than fix anything.
Left as-is, with the gap written down rather than implied. Say the word
if you'd rather it were tightened.

## Verification

- **No type moved.** Every declaration in the nine touched files
resolves identically before and after, except the seven deletions and
the `Record_ → TraceLine` rename.
- `pnpm run check` end to end: **8,604 passing / 0 failing**.

No renderer files touched by this commit, so no visual eval.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01J9mJfbsrvEeTMUdxTKf8xZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
)

Follow-on from #1330. **85 asserted predicates, down from 95.** Last of
the mechanical five.

> **Unstacked.** #2421 (which carried #2422) has merged, so this now
sits directly on `main` and the earlier note about retargeting is spent.
The merge commit resolves two conflicts, both caused by this branch
having already converted what `main` later carried:
`edited-file-check.mts` keeps the `isNonNull` import it needs, and the
inventory ratchet was **regenerated from the merged tree** rather than
reconciled by hand — 85 in 53 files, which is what the table below
claims.
>
> The diff also carries **five screenshot baselines** from #2523, which
the screenshot-review flow merged into this branch. I'd flagged these as
ones I didn't think this diff caused, and offered to drop them; CI has
since answered it, so no action is needed. On the merged head all
**eight e2e shards pass** against those baselines, and the screenshot
job now reports *"no changed screenshot candidates for `bfec6c4c30d8`"*
— the built app renders to match them and the branch is
screenshot-stable. Keeping them is correct.

## What converts

**Six inline annotations deleted**, so the compiler infers the predicate
they were asserting — `block.type === 'text'` in `agent-service` and
`plugin-model-turn`, `typeof name === 'string'` in three
`gh-cli-backend` filters, and one in `semantic-index`. These were
excluded from #2419's batch because their *file* showed a type
difference somewhere; measured individually, nothing moves.

**Four presence checks** that #1332's `isNonNull` / `isDefined` sweep
didn't reach, because they live in `scripts/`:

```ts
-  .filter((value): value is string => value !== null)
+  .filter(isNonNull)
```

## Two candidates dropped after measuring

Both are worth knowing about, because both look convertible and aren't:

- **`plugin-model-turn.ts`'s image filter** widens from `{ type:
'image'; dataUrl: string }[]` to one carrying `detail?`. The runtime
value is identical — the predicate only filters — but the annotation was
projecting the field away, and the declared shape is part of the plugin
payload. Left alone rather than quietly changing what a plugin sees.
- **`api-protocol.mts`**: `ts.isInterfaceDeclaration(s) && s.name.text
=== 'ApiClient'` degrades to `Statement | undefined`. The second
conjunct reads a property that only exists once the first has narrowed,
and inference won't carry that across.

## The other half: a "what stays" section in the docs

The inventory has been shrinking for four PRs, which invites the reading
that zero is the target. It isn't, and `docs/type-safety.md` now says
so. Two categories on the list are **finished work**:

- **The shared helpers themselves** — `isRecord`, `memberOf`, `keyOf`,
`isDefined`, `isNonNull`, `isNonEmptyString`, `isNonBlankString`,
`matchesFallbackType`. Each is one audited assertion with a real test
standing in for the dozens it replaced. Taking them off the list would
hide the assertions the codebase actually leans on.
- **Provenance guards over a callable** — `isToolFactory`,
`isRawExecute`, `isDynamicImport`, `isCreateGuard`. These narrow
`unknown` to a specific function *signature* from `typeof v ===
'function'`, which establishes only that it is callable. I went looking
for a fix and there isn't one: the values come from user-authored tool
modules and an optional dependency, so no check can see the signature.
The contract is enforced where the function is called — a `try`/`catch`
that isolates the failure to one file — not where it is narrowed. A
generic `isCallable<T>()` would only make the unchecked half convenient,
which is the opposite of what the rest of this work does.

That leaves the structural parsers over `unknown` as the genuine
remainder, and the answer there is a decoder rather than a better
predicate.

The ratchet's header comment also stops asserting a number that decays:
it now says where the list started (183 in 113 files) and where it
stands (85 in 53), and notes the second figure is enforced by the two
tests below rather than remembered. Both sides had carried the stale
line unchanged since #2419.

## Verification

- **No type moved.** Every declaration in the ten touched files resolves
byte-identically before and after.
- `pnpm run check` end to end, re-run after the merge onto current
`main`: **8,610 passing / 0 failing**.
- CI green on `bfec6c4c3`: `CI Passed`, `check`, `build`, all eight
`e2e` shards, `screenshot-artifacts`.

One renderer-adjacent file (`agent-service.ts`) is touched; it's a
text-block filter with identical output and unchanged DOM, so no visual
eval beyond the e2e run above.

## Where this leaves #1330

| | asserted predicates |
| --- | --- |
| `main` before #2419 | 183 |
| after #2419 | 118 |
| after #2420#2421 (+#2422) → this | **85** |

The remaining 85 are the shared helpers (8), the callable guards (4),
and ~70 structural boundary parsers — `command-hook-runner` (12),
`spine-schema` (9), `settings`, `knowledge-store`, `automation-service`,
the renderer seed parsers. That group is filed as #2424: it turned out
to be two unrelated problems, and neither is a schema sweep.

The lint half of "stop this recurring" is #2524, which is independent of
this and sits on `main` — it overlaps only in `docs/type-safety.md`,
where both add a section.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01J9mJfbsrvEeTMUdxTKf8xZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: copse-release-bot[bot] <304038887+copse-release-bot[bot]@users.noreply.github.com>
Co-authored-by: jonathanKingston <338988+jonathanKingston@users.noreply.github.com>
…2524)

Follow-on from #1330. This is the lint half of the answer to "can we
stop this recurring" — plus the second bug the question turned up.

`in` walks the prototype chain, so `key in RECORD` — where the key did
not come from a literal — answers **true** for the eight members every
object literal inherits: `toString`, `constructor`, `valueOf`,
`hasOwnProperty`, `__proto__`, `isPrototypeOf`, `propertyIsEnumerable`,
`toLocaleString`.

## The bug

#2422 fixed one allowlist written that way (`settings:set`). Asking how
many others there were found `plugins:setSetting`, and it is the worse
of the two:

```ts
const declared = pack?.manifest.settings
if (!declared || !(key in declared)) {
  throw new Error(`pack "${pluginId}" declares no setting "${key}"`)
}
await storageUpdate(pluginSettingsKey(pluginId), (raw) => {
  const current: Record<string, unknown> = isRecord(raw) ? { ...raw } : {}
  current[key] = value
  return current
})
```

`key` comes straight off the renderer. All eight inherited names passed
the gate, and unlike the `settings:set` case nothing downstream threw
first — **the write happened**. The comment immediately above that check
says it exists to stop a pack's bag growing arbitrary keys forever,
which is precisely what it permitted.

The new test loops the eight names and asserts both the rejection and
that nothing persisted. On the old body it fails:

```
AssertionError: Missing expected rejection: inherited key toString was accepted
```

Severity, stated plainly: writing `toString` into a pack's own settings
bag is a durable junk key, not a privilege escalation — the bag is
per-pack and the value is read back through the pack's own declared
schema. It is a gate that did not gate.

## The rule

```js
'no-restricted-syntax': [
  'error',
  { selector: "BinaryExpression[operator='in'][left.type!='Literal']", message: … },
]
```

Only a **dynamic** key is restricted. `'kind' in payload` is the
ordinary way to discriminate an object union and the tree has **248** of
those, so a blanket ban on `in` would bury the ten that mattered.
Counted with tsc rather than grep, so `for (const k in o)` and the word
in a string never count.

Tests are exempt. Several use `in` deliberately to demonstrate the
behaviour the rule forbids — `member-of.test.ts` asserts `key in SCHEMAS
=== true` for all eight next to `keyOf(SCHEMAS)(key) === false`. A test
that cannot state the wrong answer cannot pin the right one. All nine
remaining dynamic-key sites are tests.

Verified against a probe file: `key in rec` and `rec.a in rec` are
flagged, `'a' in rec` is not.

## The other nine sites

All converted to `Object.hasOwn`. Only
`prototypes/side-highlight/proto.js` was reachable by input — it reads
`location.hash` — and none of the rest can produce a wrong answer today:
`MODEL_CARDS`/`MODEL_INTELLECT_RAW`/`PUBLISHERS` are looked up with ids
that came from the same tables, and `api-protocol.mts`/`persistence.ts`
compare two objects built the same way. They are each one caller away
from mattering, and the rule doesn't have a "this one is fine" mode.

| file | what it gated |
| --- | --- |
| `packages/llm/src/model-cards.ts` (×2) | model-card id lookup |
| `packages/llm/src/model-intellect.ts` | intellect table lookup |
| `scripts/sync-model-cards.mts` | publisher validation |
| `scripts/lib/api-protocol.mts` | added-channel diff |
| `scripts/skillsbench-agent-lib.mts` (×2) | tool-arg property reads |
| `src/renderer/controller/persistence.ts` | removed-key patch |
| `prototypes/side-highlight/proto.js` | `location.hash` → treatment |

## Verification

- `pnpm run check` end to end on this base: **8,611 passing / 0
failing**.
- The plugin-service regression test confirmed failing on the old body
before the fix.
- Rebased onto `main` after #2421 landed; re-ran the full gate on the
rebased base.

No renderer-visible change (`persistence.ts` is a patch-shape helper
with the same output), so no visual eval.

## Docs

`docs/type-safety.md` gains a "Never use a dynamic key with `in`"
section; `AGENTS.md`'s type-safety paragraph names it in one clause.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01J9mJfbsrvEeTMUdxTKf8xZ

---
_Generated by [Claude
Code](https://claude.ai/code/session_01J9mJfbsrvEeTMUdxTKf8xZ)_

Co-authored-by: Claude <noreply@anthropic.com>
Problem 2 of #2424 — the last group that issue called out. **78 asserted
predicates, down from 85** (independent of #2528, which takes the other
12).

> **Not the zod migration #2424 proposed.** I measured that route first
and it cannot deliver its own benefit safely. Details below, because the
reasoning is the substance of this PR.

## The constraint, with a mechanism

A rejected line is not merely skipped. `parseSpineEntries` keeps its raw
text, but `rebuildSpinePreservingNonMessageLines` collects blob refs
**only from lines it could parse**, and `pruneStaleFiles` then
`unlinkSync`s every blob outside that set:

```ts
if (entry.line?.type === 'hook_run') preservedRefs.push(...hookRunBlobRefs(entry.line))
…
pruneStaleFiles(dir, files, preservedRefs)   // unlinkSync for anything not in `keep`
```

So tightening any check here **deletes** the stdout, stderr,
decision-detail and plan-artifact files that older threads still point
at, on that thread's next save. Any change had to be provably
no-stricter.

## Why zod was the wrong tool here

The advertised benefit is `z.infer` — the type derives from the schema,
so they can't disagree. That is unavailable at no-stricter strictness:

- `SpineMessageLine` declares ~20 fields; the predicate validates **5**.
To stay no-stricter the schema must leave the other 15 as
`z.unknown().optional()`, and `z.infer` then yields `unknown` where the
interface declares `ModelParameters`, `TurnOutcome`, `ThreadReview`… so
the result is still not assignable and **still needs an assertion**.
Net: 9 assertions → 9, plus 9 schemas and a zod parse on the thread-load
path.
- Getting `z.infer` to work means validating those fields — exactly the
tightening that triggers the deletion above.
- `z.object` also **strips unknown keys** (verified: `{a,unknownFuture}`
→ `{a}`), which would silently drop forward-compat fields a newer Copse
wrote.

## What this does instead

Each parser is derived from a per-field table, and the compiler checks
it two ways:

```ts
type RequiredFieldChecks<T, Loose extends keyof T = never> = {
  [K in RequiredKeys<T>]: FieldCheck<T, K, Loose>
}
```

- a required field with no entry → *"Property 'x' is missing"*
- an entry whose body proves the wrong type → *"Type predicate 'x is
string' is not assignable to 'value is number'"*

Each check is a bare arrow, so TypeScript **infers** its predicate from
the body rather than taking an assertion's word — the #1330 mechanism,
applied per field. Each line predicate then becomes an annotated `const`
binding a single call, which the compiler checks (TS2322) instead of
accepting.

`Loose` names the fields deliberately not fully proven, so every
tolerance appears **in the table's own type** rather than being an
absence a reader has to notice.

## It found a real bug immediately

`TurnOutcome.stopReason` is declared as a **nine-literal union** but has
only ever been validated as `typeof === 'string'`. `isTurnOutcome` was
returning `true` — and claiming `value is TurnOutcome` — for outcomes
whose `stopReason` is not in the type. The compiler rejected my faithful
transcription of it, which is the whole point of the exercise.

I did **not** tighten it: a newer Copse can write a stop reason this
build has no literal for, and rejecting that line drops the turn outcome
from the transcript. It is declared `Loose` with that reasoning
recorded.

## Proof that nothing moved

A 1035-line corpus — every single-field mutation (delete, wrong type,
null, empty, extra key) of a valid line of each of the 7 types, plus
malformed, unknown-type and non-object input — run through
`parseSpineLine` before and after:

```
corpus 1035 lines, accepted 158, rejected 877
IDENTICAL on all 1035 lines
```

Compared on accept/reject **and the exact returned object**, so a
stripped or added field would fail too. Re-verified after the
lint-driven redesign below.

## Two lint findings kept, not suppressed

- **`no-unnecessary-condition`** on the phase/result rule. It was right
in a way worth reading: the rule ran on the *narrowed* value, whose
`started` arm declares `result?: never`, so TypeScript believed the
check was dead — while at runtime that field is exactly what needs
inspecting. Cross-field rules now read the raw record. A validator
handed an already-validated value is the wrong shape.
- **`no-unnecessary-type-parameters`** on the `unchecked<V>()` wrapper I
first used for tolerated fields. Moving the tolerance into the table's
type instead removed the wrapper — and with it the last hand-written
assertion of the set.

## Verification

- `pnpm run check` end to end: **8,620 passing / 0 failing**.
- 300 thread-store tests pass; 9 new contract tests in
`spine-field-checks.test.ts` pin the tolerances as behaviour (absent vs
malformed `toolCalls`, unvalidated elements, an out-of-union
`stopReason`, optional-only-when-present, unknown-field round trip, the
phase/result correlation).
- One of those tests caught **my own** wrong expectation — a non-array
`toolCalls` was always rejected, only an absent one is defaulted — which
the differential run had already proven.

No renderer change, so no visual eval.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01J9mJfbsrvEeTMUdxTKf8xZ

---
_Generated by [Claude
Code](https://claude.ai/code/session_01J9mJfbsrvEeTMUdxTKf8xZ)_

Co-authored-by: Claude <noreply@anthropic.com>
)

Problem 1 of #2424. **73 asserted predicates, down from 85.**

> **This is not the fix #2424 proposed, and the issue was wrong.** I
filed it saying the twelve predicates needed a discriminated-union
change to `HookEventPayloads`, gated behind a decisions-log entry in
`docs/plans/hooks-and-feature-packs.md`. Measuring first showed a fix
that is local to one file, needs no type-model change, no decisions-log
entry, and is *more* type-safe than the union proposal. The issue has
been corrected.

## Why the predicates existed

`run` takes the hook and payload as two separate parameters:

```ts
async run<E extends HookEventName>(hook: CommandHook<E>, payload: HookEventPayloads[E], …)
```

Gating on `hook.event` narrows the hook and leaves `payload` at its
unresolved indexed-access type. TypeScript has no way to carry a
correlation across two parameters, so twelve predicates existed to
restate one the compiler could not see.

They restated it badly. With nothing but key presence to go on, siblings
collided:

```ts
'status' in payload && !('subagentType' in payload)   // stop, avoiding subagentStop
'filePath' in payload && !('applied' in payload)      // beforeDiffApply, avoiding afterDiffApply
```

Those negative clauses were load-bearing, absent from the types, and
unchecked — a predicate asserts `payload is T` and the compiler takes
its word.

## The fix

A per-event table. Indexing it with `hook.event` keeps both sides on the
same `E`, so the compiler resolves the pair itself:

```ts
const EVENT_DISPATCH: { [E in HookEventName]?: EventDispatch<E> } = { … }

const dispatch = EVENT_DISPATCH[hook.event]
if (!dispatch) return ABSTAIN
```

## Measured, not assumed

I probed four designs against `tsc` before writing any of it:

| design | result |
| --- | --- |
| today's shape — gate on `hook.event`, read `payload` | **error** —
confirms the premise |
| payload carries its own `event`, widen to the union | works, but needs
every payload + fire site changed |
| build the correlated union locally from a generic `E` | **error** — TS
can't prove the assignment |
| **per-event table indexed by `hook.event`** | **works, no platform
change** |

The discriminated-union option also fails on design grounds
independently of cost: `emit` already takes the event name, so every
fire site would repeat it.

The table is **strictly stronger** than the predicates. Verified by
sabotaging the real file:

- wiring `stop` to `marshalSubagentStopRequest` → *"Property
`subagentType` is missing in type `StopPayload` but required in type
`SubagentStopPayload`"* — the exact mistake the hand-written negative
clause was guarding
- `sessionStarted:` instead of `sessionStart:` → *"…does not exist in
type… Did you mean to write `sessionStart`?"*

That is execution-guidance rule 3 of
`docs/plans/hooks-and-feature-packs.md`: *prefer a compile error over a
review comment over a runtime check, in that order.*

## Why no decisions-log entry

No semantic change. The twelve runtime checks could only ever have fired
on a payload that did not match its own event, which the in-process fire
sites never construct. An event with no table entry abstains exactly as
the unwired branch did — the existing `compaction` abstain test covers
that path unchanged. Module layout is untouched (rule 4): everything
stays in `packages/hooks-dialects`.

## Verification

- `pnpm run check` end to end: **8,611 passing / 0 failing**.
- 465 hook tests pass, including the adapter and failClosed contract
tests.
- The runner loses **91 lines** (522 → 431); twelve near-identical
12-line blocks become one dispatch plus a table.

No renderer change, so no visual eval.

## Relationship to #2531

Problem 2 of #2424 (`spine-schema.ts`, 9 predicates) is now done
separately in #2531. The two are **independent** — either can merge
first, neither is stacked on the other, and they touch disjoint source
trees (`packages/hooks-dialects` here, `packages/thread-store` there).

They do both edit `scripts/type-predicate-inventory.test.ts`, so
**whichever merges second will conflict there**. Resolve it by
regenerating the list from the merged tree rather than hand-picking a
side, and update the header comment's count.

Together they land at **66 in 52 files**, down from 85 — verified by a
local trial merge rather than by adding the two arms, which would give
67 and be wrong (the two PRs each remove a predicate the other's count
also reflects).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01J9mJfbsrvEeTMUdxTKf8xZ

Co-authored-by: Claude <noreply@anthropic.com>
…-minor-patch group (#2540)

Bumps the npm-minor-patch group with 1 update:
[@wdio/local-runner](https://github.com/webdriverio/webdriverio/tree/HEAD/packages/wdio-local-runner).

Updates `@wdio/local-runner` from 9.31.3 to 9.31.5
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/webdriverio/webdriverio/releases">@​wdio/local-runner's
releases</a>.</em></p>
<blockquote>
<h2>v9.31.5 (2026-08-29)</h2>
<h4>:rocket: New Feature</h4>
<ul>
<li><code>wdio-utils</code>, <code>webdriverio</code>
<ul>
<li><a
href="https://redirect.github.com/webdriverio/webdriverio/pull/15538">#15538</a>
feat: UNSTABLE Review Multi-remote <code>select</code> implementations
and remove filter (<a
href="https://github.com/dprevost-LMI"><code>@​dprevost-LMI</code></a>)</li>
</ul>
</li>
</ul>
<h4>:bug: Bug Fix</h4>
<ul>
<li><code>wdio-utils</code>, <code>webdriverio</code>
<ul>
<li><a
href="https://redirect.github.com/webdriverio/webdriverio/pull/15538">#15538</a>
feat: UNSTABLE Review Multi-remote <code>select</code> implementations
and remove filter (<a
href="https://github.com/dprevost-LMI"><code>@​dprevost-LMI</code></a>)</li>
</ul>
</li>
<li><code>webdriverio</code>
<ul>
<li><a
href="https://redirect.github.com/webdriverio/webdriverio/pull/15535">#15535</a>
fix(webdriverio): handle BiDi no such node errors as stale (<a
href="https://github.com/Fnine59"><code>@​Fnine59</code></a>)</li>
<li><a
href="https://redirect.github.com/webdriverio/webdriverio/pull/15537">#15537</a>
fix(webdriverio): require all classic cookie filter attributes (<a
href="https://github.com/ML642"><code>@​ML642</code></a>)</li>
</ul>
</li>
</ul>
<h4>:memo: Documentation</h4>
<ul>
<li><code>wdio-protocols</code>, <code>webdriverio</code>
<ul>
<li><a
href="https://redirect.github.com/webdriverio/webdriverio/pull/15536">#15536</a>
Docs/actions api guidance (<a
href="https://github.com/ML642"><code>@​ML642</code></a>)</li>
</ul>
</li>
</ul>
<h4>Committers: 3</h4>
<ul>
<li>David Prevost (<a
href="https://github.com/dprevost-LMI"><code>@​dprevost-LMI</code></a>)</li>
<li>Fnine59 (<a
href="https://github.com/Fnine59"><code>@​Fnine59</code></a>)</li>
<li>Martsin Lazouski (<a
href="https://github.com/ML642"><code>@​ML642</code></a>)</li>
</ul>
<h2>v9.31.4 (2026-08-27)</h2>
<h4>:rocket: New Feature</h4>
<ul>
<li><code>webdriverio</code>
<ul>
<li><a
href="https://redirect.github.com/webdriverio/webdriverio/pull/15532">#15532</a>
feat: Beta ElementArray under feature flag for
<code>multiRemote.$$()</code> e.g. MultiRemoteElement[] (<a
href="https://github.com/dprevost-LMI"><code>@​dprevost-LMI</code></a>)</li>
</ul>
</li>
<li><code>wdio-allure-reporter</code>
<ul>
<li><a
href="https://redirect.github.com/webdriverio/webdriverio/pull/15278">#15278</a>
feat(wdio-allure-reporter): add support for global errors and global
attachments (<a
href="https://github.com/todti"><code>@​todti</code></a>)</li>
</ul>
</li>
</ul>
<h4>:bug: Bug Fix</h4>
<ul>
<li><code>webdriverio</code>
<ul>
<li><a
href="https://redirect.github.com/webdriverio/webdriverio/pull/15478">#15478</a>
fix(webdriverio): track document element per context (<a
href="https://github.com/Badisi"><code>@​Badisi</code></a>)</li>
</ul>
</li>
<li><code>webdriver</code>
<ul>
<li><a
href="https://redirect.github.com/webdriverio/webdriverio/pull/15516">#15516</a>
fix(webdriver): serialize IPv6 BiDi WebSocket candidates correctly (<a
href="https://github.com/ML642"><code>@​ML642</code></a>)</li>
</ul>
</li>
</ul>
<h4>:memo: Documentation</h4>
<ul>
<li><code>webdriverio</code>
<ul>
<li><a
href="https://redirect.github.com/webdriverio/webdriverio/pull/15531">#15531</a>
docs(webdriverio): document the $$ async iterator methods (<a
href="https://github.com/devangpratap"><code>@​devangpratap</code></a>)</li>
</ul>
</li>
</ul>
<h4>Committers: 5</h4>
<ul>
<li>Alex (<a
href="https://github.com/todti"><code>@​todti</code></a>)</li>
<li>David Prevost (<a
href="https://github.com/dprevost-LMI"><code>@​dprevost-LMI</code></a>)</li>
<li>Martsin Lazouski (<a
href="https://github.com/ML642"><code>@​ML642</code></a>)</li>
<li><a href="https://github.com/Badisi"><code>@​Badisi</code></a></li>
<li><a
href="https://github.com/devangpratap"><code>@​devangpratap</code></a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/webdriverio/webdriverio/blob/main/CHANGELOG.md">@​wdio/local-runner's
changelog</a>.</em></p>
<blockquote>
<h2>v9.31.5 (2026-08-29)</h2>
<h4>:rocket: New Feature</h4>
<ul>
<li><code>wdio-utils</code>, <code>webdriverio</code>
<ul>
<li><a
href="https://redirect.github.com/webdriverio/webdriverio/pull/15538">#15538</a>
feat: UNSTABLE Review Multi-remote <code>select</code> implementations
and remove filter (<a
href="https://github.com/dprevost-LMI"><code>@​dprevost-LMI</code></a>)</li>
</ul>
</li>
</ul>
<h4>:bug: Bug Fix</h4>
<ul>
<li><code>wdio-utils</code>, <code>webdriverio</code>
<ul>
<li><a
href="https://redirect.github.com/webdriverio/webdriverio/pull/15538">#15538</a>
feat: UNSTABLE Review Multi-remote <code>select</code> implementations
and remove filter (<a
href="https://github.com/dprevost-LMI"><code>@​dprevost-LMI</code></a>)</li>
</ul>
</li>
<li><code>webdriverio</code>
<ul>
<li><a
href="https://redirect.github.com/webdriverio/webdriverio/pull/15535">#15535</a>
fix(webdriverio): handle BiDi no such node errors as stale (<a
href="https://github.com/Fnine59"><code>@​Fnine59</code></a>)</li>
<li><a
href="https://redirect.github.com/webdriverio/webdriverio/pull/15537">#15537</a>
fix(webdriverio): require all classic cookie filter attributes (<a
href="https://github.com/ML642"><code>@​ML642</code></a>)</li>
</ul>
</li>
</ul>
<h4>:memo: Documentation</h4>
<ul>
<li><code>wdio-protocols</code>, <code>webdriverio</code>
<ul>
<li><a
href="https://redirect.github.com/webdriverio/webdriverio/pull/15536">#15536</a>
Docs/actions api guidance (<a
href="https://github.com/ML642"><code>@​ML642</code></a>)</li>
</ul>
</li>
</ul>
<h4>Committers: 3</h4>
<ul>
<li>David Prevost (<a
href="https://github.com/dprevost-LMI"><code>@​dprevost-LMI</code></a>)</li>
<li>Fnine59 (<a
href="https://github.com/Fnine59"><code>@​Fnine59</code></a>)</li>
<li>Martsin Lazouski (<a
href="https://github.com/ML642"><code>@​ML642</code></a>)</li>
</ul>
<h2>v9.31.4 (2026-08-27)</h2>
<h4>:rocket: New Feature</h4>
<ul>
<li><code>webdriverio</code>
<ul>
<li><a
href="https://redirect.github.com/webdriverio/webdriverio/pull/15532">#15532</a>
feat: Beta ElementArray under feature flag for
<code>multiRemote.$()</code> e.g. MultiRemoteElement[] (<a
href="https://github.com/dprevost-LMI"><code>@​dprevost-LMI</code></a>)</li>
</ul>
</li>
<li><code>wdio-allure-reporter</code>
<ul>
<li><a
href="https://redirect.github.com/webdriverio/webdriverio/pull/15278">#15278</a>
feat(wdio-allure-reporter): add support for global errors and global
attachments (<a
href="https://github.com/todti"><code>@​todti</code></a>)</li>
</ul>
</li>
</ul>
<h4>:bug: Bug Fix</h4>
<ul>
<li><code>webdriverio</code>
<ul>
<li><a
href="https://redirect.github.com/webdriverio/webdriverio/pull/15478">#15478</a>
fix(webdriverio): track document element per context (<a
href="https://github.com/Badisi"><code>@​Badisi</code></a>)</li>
</ul>
</li>
<li><code>webdriver</code>
<ul>
<li><a
href="https://redirect.github.com/webdriverio/webdriverio/pull/15516">#15516</a>
fix(webdriver): serialize IPv6 BiDi WebSocket candidates correctly (<a
href="https://github.com/ML642"><code>@​ML642</code></a>)</li>
</ul>
</li>
</ul>
<h4>:memo: Documentation</h4>
<ul>
<li><code>webdriverio</code>
<ul>
<li><a
href="https://redirect.github.com/webdriverio/webdriverio/pull/15531">#15531</a>
docs(webdriverio): document the $ async iterator methods (<a
href="https://github.com/devangpratap"><code>@​devangpratap</code></a>)</li>
</ul>
</li>
</ul>
<h4>Committers: 5</h4>
<ul>
<li>Alex (<a
href="https://github.com/todti"><code>@​todti</code></a>)</li>
<li>David Prevost (<a
href="https://github.com/dprevost-LMI"><code>@​dprevost-LMI</code></a>)</li>
<li>Martsin Lazouski (<a
href="https://github.com/ML642"><code>@​ML642</code></a>)</li>
<li><a href="https://github.com/Badisi"><code>@​Badisi</code></a></li>
<li><a
href="https://github.com/devangpratap"><code>@​devangpratap</code></a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/webdriverio/webdriverio/commit/4f39140405a3a54a44c391f97a6de1fc3af2d33d"><code>4f39140</code></a>
v9.31.5</li>
<li><a
href="https://github.com/webdriverio/webdriverio/commit/69c45e316f75ba2e40e83ef7475106ece26cf2e2"><code>69c45e3</code></a>
v9.31.4</li>
<li>See full diff in <a
href="https://github.com/webdriverio/webdriverio/commits/v9.31.5/packages/wdio-local-runner">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@wdio/local-runner&package-manager=npm_and_yarn&previous-version=9.31.3&new-version=9.31.5)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [github/codeql-action](https://github.com/github/codeql-action)
from 4.37.8 to 4.37.9.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/releases">github/codeql-action's
releases</a>.</em></p>
<blockquote>
<h2>v4.37.9</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.4">2.26.4</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4106">#4106</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/blob/main/CHANGELOG.md">github/codeql-action's
changelog</a>.</em></p>
<blockquote>
<h2>4.37.9 - 26 Aug 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.4">2.26.4</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4106">#4106</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/cdf488f595d80d6e07e03d4674febd5ab45fa938"><code>cdf488f</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4107">#4107</a>
from github/update-v4.37.9-920ba7cd1</li>
<li><a
href="https://github.com/github/codeql-action/commit/7243f38558d187dde99730d224bb47aa26a95306"><code>7243f38</code></a>
Update changelog for v4.37.9</li>
<li><a
href="https://github.com/github/codeql-action/commit/920ba7cd1596037e042122c00381eb16b397d68e"><code>920ba7c</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4106">#4106</a>
from github/update-bundle/codeql-bundle-v2.26.4</li>
<li><a
href="https://github.com/github/codeql-action/commit/ecfa6e16817b8f490bc9a59baa391baf4fa3e3c2"><code>ecfa6e1</code></a>
Add changelog note</li>
<li><a
href="https://github.com/github/codeql-action/commit/adcdf4a70d247343cf9c29e0f7a6658b51c3a2b1"><code>adcdf4a</code></a>
Update default bundle to codeql-bundle-v2.26.4</li>
<li><a
href="https://github.com/github/codeql-action/commit/486fec2a3ea2626afcd8c7e9208b4f515078dd7e"><code>486fec2</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4099">#4099</a>
from github/update-supported-enterprise-server-versions</li>
<li><a
href="https://github.com/github/codeql-action/commit/134624c67b20869c2aaa36dafa726375b78a5d76"><code>134624c</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4101">#4101</a>
from github/dependabot/npm_and_yarn/npm-minor-457d82...</li>
<li><a
href="https://github.com/github/codeql-action/commit/ff43db8f982a368288f117354fb8d046e937124c"><code>ff43db8</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4103">#4103</a>
from github/mergeback/v4.37.8-to-main-db488dde</li>
<li><a
href="https://github.com/github/codeql-action/commit/4605e03a74cf891614c4d76f82384a16c1c11816"><code>4605e03</code></a>
Rebuild</li>
<li><a
href="https://github.com/github/codeql-action/commit/099c869cad6bf3b88657154d4ae47ffed27e632d"><code>099c869</code></a>
Update changelog and version after v4.37.8</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/v4.37.8...v4.37.9">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github/codeql-action&package-manager=github_actions&previous-version=4.37.8&new-version=4.37.9)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [@napi-rs/keyring](https://github.com/Brooooooklyn/keyring-node)
from 1.3.0 to 2.0.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/Brooooooklyn/keyring-node/releases">@​napi-rs/keyring's
releases</a>.</em></p>
<blockquote>
<h2>v2.0.0</h2>
<h2>⚠️ Breaking Changes</h2>
<p>This release changes how credential store <strong>errors</strong> are
reported. Success and &quot;not found&quot; results are unchanged, but
provider failures that were previously silenced now throw (sync API) or
reject (async API).</p>
<h3>1. Reads: <code>getPassword()</code> / <code>getSecret()</code></h3>
<p>Previously, <strong>any</strong> failure reading the credential store
— a locked keychain, denied access, an OS error — was swallowed and
returned as <code>null</code> / <code>undefined</code>,
indistinguishable from &quot;no credential stored&quot;.</p>
<p>Now only a genuinely missing credential (<code>NoEntry</code>)
returns the absent result. Every other error throws or rejects:</p>
<table>
<thead>
<tr>
<th>Scenario</th>
<th>v1.x</th>
<th>v2.0.0</th>
</tr>
</thead>
<tbody>
<tr>
<td>Credential exists</td>
<td>value</td>
<td>value (unchanged)</td>
</tr>
<tr>
<td>Credential missing</td>
<td><code>null</code> / <code>undefined</code></td>
<td><code>null</code> / <code>undefined</code> (unchanged)</td>
</tr>
<tr>
<td>Store locked / inaccessible / OS error</td>
<td><code>null</code> / <code>undefined</code> ⚠️</td>
<td><strong>throws / rejects</strong></td>
</tr>
</tbody>
</table>
<h3>2. Deletes: <code>deleteCredential()</code> /
<code>deletePassword()</code></h3>
<p>Previously, <strong>any</strong> delete failure returned
<code>false</code>, so a failed delete looked identical to
&quot;credential was already gone&quot; — leaving callers unable to tell
whether the secret was actually removed.</p>
<p>Now <code>false</code> only means the credential did not exist
(<code>NoEntry</code>). A failed delete throws or rejects:</p>
<table>
<thead>
<tr>
<th>Scenario</th>
<th>v1.x</th>
<th>v2.0.0</th>
</tr>
</thead>
<tbody>
<tr>
<td>Credential deleted</td>
<td><code>true</code></td>
<td><code>true</code> (unchanged)</td>
</tr>
<tr>
<td>No credential to delete</td>
<td><code>false</code></td>
<td><code>false</code> (unchanged)</td>
</tr>
<tr>
<td>Delete failed (locked store, OS error)</td>
<td><code>false</code> ⚠️</td>
<td><strong>throws / rejects</strong></td>
</tr>
</tbody>
</table>
<p>A <code>false</code> result now guarantees the credential is absent
from the store.</p>
<h3>3. TypeScript: async <code>deletePassword()</code> return type</h3>
<p>The async <code>deletePassword()</code> alias is now correctly
declared as <code>Promise&lt;boolean&gt;</code> instead of
<code>Promise&lt;unknown&gt;</code>. This is a narrowing and is
source-compatible for typical usage, but code that treated the result as
<code>unknown</code> may need a small type adjustment.</p>
<p>These changes apply to both <code>Entry</code> (sync) and
<code>AsyncEntry</code> (async), including the
<code>deletePassword()</code> aliases.</p>
<hr />
<h2>🔧 Migration Guide</h2>
<h3>If you only check for absent credentials — no change needed</h3>
<pre lang="ts"><code>// Still works exactly as before: null means
&quot;not stored&quot;
const password = await entry.getPassword()
if (password === null) {
  // no credential stored
}
</code></pre>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/Brooooooklyn/keyring-node/commit/f3449416a1b4bf11b0570f0a49395aacc84c8608"><code>f344941</code></a>
2.0.0</li>
<li><a
href="https://github.com/Brooooooklyn/keyring-node/commit/648d7ceb9d9dcb37639a0ecd69cc24e2cec6c447"><code>648d7ce</code></a>
chore: bump up cross-platform-actions/action action to v1.5.0 (<a
href="https://redirect.github.com/Brooooooklyn/keyring-node/issues/140">#140</a>)</li>
<li><a
href="https://github.com/Brooooooklyn/keyring-node/commit/5df8cf5284d6179a4247317bcbce4b435ed8e276"><code>5df8cf5</code></a>
fix: propagate credential store errors instead of erasing them into
false/abs...</li>
<li><a
href="https://github.com/Brooooooklyn/keyring-node/commit/a8709c1381e604c22b9a01b78277a51d65447a69"><code>a8709c1</code></a>
chore: bump up cross-platform-actions/action action to v1.4.0 (<a
href="https://redirect.github.com/Brooooooklyn/keyring-node/issues/139">#139</a>)</li>
<li><a
href="https://github.com/Brooooooklyn/keyring-node/commit/58edf338fcb8ade10d71b6b08a5a7a5dd30cc140"><code>58edf33</code></a>
fix: preserve non-missing password read errors (<a
href="https://redirect.github.com/Brooooooklyn/keyring-node/issues/136">#136</a>)</li>
<li><a
href="https://github.com/Brooooooklyn/keyring-node/commit/8e1e4184419e14cec65206ded9e3737be05ec081"><code>8e1e418</code></a>
chore: bump up Yarn to v4.18.0 (<a
href="https://redirect.github.com/Brooooooklyn/keyring-node/issues/135">#135</a>)</li>
<li><a
href="https://github.com/Brooooooklyn/keyring-node/commit/df1f9b5f730ef58d5f66ca56f70ee6823852b830"><code>df1f9b5</code></a>
chore: bump up chalk version to v6 (<a
href="https://redirect.github.com/Brooooooklyn/keyring-node/issues/134">#134</a>)</li>
<li><a
href="https://github.com/Brooooooklyn/keyring-node/commit/3e7bcc484fd213da420ce0788370f0bcd0279357"><code>3e7bcc4</code></a>
chore: bump up typescript version to v7 (<a
href="https://redirect.github.com/Brooooooklyn/keyring-node/issues/132">#132</a>)</li>
<li><a
href="https://github.com/Brooooooklyn/keyring-node/commit/9ded330e3586555b5cba726cd9602fbb5efadcf3"><code>9ded330</code></a>
chore: bump up actions/setup-node action to v7 (<a
href="https://redirect.github.com/Brooooooklyn/keyring-node/issues/133">#133</a>)</li>
<li><a
href="https://github.com/Brooooooklyn/keyring-node/commit/f330874629298929eda4c4729d1987c7449b51ca"><code>f330874</code></a>
chore: bump up Yarn to v4.17.1 (<a
href="https://redirect.github.com/Brooooooklyn/keyring-node/issues/131">#131</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/Brooooooklyn/keyring-node/compare/v1.3.0...v2.0.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@napi-rs/keyring&package-manager=npm_and_yarn&previous-version=1.3.0&new-version=2.0.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
## Summary

- add an explicit, off-by-default macOS permission for Copse's native
`git_commit` subprocess to reach the single Unix socket named by
`SSH_AUTH_SOCK`
- preserve the existing filesystem and internet-deny sandbox, and
replace path-configured SSH signing keys with an inline public identity
so the private key and `.ssh` directory remain unreadable
- pin a one-line sandbox-runtime patch so the documented per-spawn
`allowUnixSockets` override reaches the macOS seatbelt profile
- explain the Git-hook/agent capability boundary in Settings and the
shell-permissions documentation

Closes
[#2320](#2320).

The dependency patch can be removed after
[anthropics/sandbox-runtime#420](anthropics/sandbox-runtime#420)
ships in a release.

## Security boundary

The grant is macOS-only, requires explicit user consent, validates that
`SSH_AUTH_SOCK` is an absolute normalized socket, and applies only to
the native commit spawn. Network domains remain denied. Git hooks
inherit the commit process and can ask ssh-agent to use any loaded key,
so the UI recommends `ssh-add -c`.

## Validation

- `pnpm test -- git-commit-signing` — 12 passed
- `pnpm test` — 8,555 passed
- `pnpm run typecheck`
- `pnpm run lint`
- `pnpm run format:check`
- `pnpm run check:dead-code`
- `pnpm run check:oracle`
- `pnpm run check:e2e-syntax`
- `pnpm run demo:site:check`
- `pnpm run build`
- `pnpm install --frozen-lockfile --ignore-scripts`
- focused Electron e2e: 2 passed
- `git diff --check`

## Visual evidence

![Commit signing
permission](https://raw.githubusercontent.com/copse-dev/agent-pane/copse/something-went-wrong-in-another-copse-thre-d07699/tests/e2e/screenshots/git-commit-signing-permission.png)

Co-Authored-By: Copse <noreply@copse.dev>
Copse-Models: acp:codex-acp#gpt-5.6-sol, acp:claude-acp#opus[1m]

---------

Co-authored-by: Copse <noreply@copse.dev>
Co-authored-by: copse-release-bot[bot] <304038887+copse-release-bot[bot]@users.noreply.github.com>
Co-authored-by: jonathanKingston <338988+jonathanKingston@users.noreply.github.com>
@copse-release-bot
copse-release-bot Bot merged commit 158f2ef into release Sep 7, 2026
49 checks passed
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.

1 participant