@qvac/harness 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.claude/skills/asana/SKILL.md +86 -0
  2. package/.claude/skills/gh-stack/SKILL.md +181 -0
  3. package/.claude/skills/gh-stack/references/commands.md +177 -0
  4. package/.claude/skills/gh-stack/references/stack-design.md +97 -0
  5. package/.claude/skills/gh-stack/references/troubleshooting.md +159 -0
  6. package/.claude/skills/js-house-style/SKILL.md +671 -0
  7. package/.claude/skills/kvcache/SKILL.md +171 -0
  8. package/.claude/skills/pr-create/SKILL.md +180 -0
  9. package/.claude/skills/pr-review/SKILL.md +217 -0
  10. package/CLAUDE.md +39 -0
  11. package/README.md +26 -4
  12. package/dist/harness.bundle +8 -60
  13. package/dist/prebuilds-manifest.json +0 -8
  14. package/dist-lib/index.d.ts +4 -1
  15. package/dist-lib/index.js +23 -7
  16. package/dist-lib/lib/docx/index.d.ts +5 -0
  17. package/dist-lib/lib/docx/index.js +49 -0
  18. package/dist-lib/lib/models/model-pool.d.ts +1 -1
  19. package/dist-lib/lib/models/model-pool.js +3 -2
  20. package/dist-lib/lib/spawn.d.ts +1 -0
  21. package/dist-lib/lib/tools/exec/adapters/python/pyodide.js +2 -0
  22. package/dist-lib/lib/tools/exec/adapters/python/wheels.js +2 -1
  23. package/dist-lib/lib/tools/exec/adapters/python/worker/client.js +1 -1
  24. package/dist-lib/schema/generate.d.ts +6 -2
  25. package/dist-lib/schema/generate.js +10 -5
  26. package/docs/decisions/2026-05-29-kvcache-tool-turns.md +75 -0
  27. package/docs/decisions/2026-06-02-kvcache-tool-turns-reenabled.md +127 -0
  28. package/docs/decisions/2026-06-23-android-cpu-llm-defaults.md +126 -0
  29. package/docs/decisions/2026-07-29-assistant-owned-storage-layout.md +88 -0
  30. package/docs/decisions/2026-08-04-sidecar-orphan-reaping.md +79 -0
  31. package/docs/decisions/2026-08-11-pyodide-worker-thread.md +68 -0
  32. package/docs/decisions/2026-08-17-harness-hook-pipeline.md +84 -0
  33. package/docs/decisions/2026-08-17-harness-outbound-egress-guard.md +176 -0
  34. package/docs/decisions/2026-08-20-chat-prompt-prefix-is-stable.md +95 -0
  35. package/docs/decisions/2026-08-20-kvcache-static-placement.md +109 -0
  36. package/docs/decisions/2026-08-21-vendored-python-wheels.md +85 -0
  37. package/docs/decisions/2026-08-26-exec-is-not-gated-by-skills.md +64 -0
  38. package/docs/decisions/2026-08-27-writable-skills-overlay.md +56 -0
  39. package/docs/decisions/2026-09-01-skill-operation-manifests.md +53 -0
  40. package/docs/decisions/2026-09-01-usage-breakdown-is-measured.md +78 -0
  41. package/docs/decisions/2026-09-03-usage-breakdown-reported-by-the-harness.md +136 -0
  42. package/docs/decisions/INDEX.md +38 -0
  43. package/docs/decisions/TEMPLATE.md +32 -0
  44. package/docs/kvcache-reference.md +655 -0
  45. package/package.json +6 -2
  46. package/worker/harness-sidecar.mjs +3 -2
@@ -0,0 +1,95 @@
1
+ ---
2
+ id: ADR-2026-08-20-chat-prompt-prefix-is-stable
3
+ status: active
4
+ introduced_by: PR-1714
5
+ supersedes: []
6
+ ---
7
+
8
+ # A chat's prompt prefix changes only when the user changes something
9
+
10
+ ## Decision
11
+
12
+ The system prompt and the tool block a chat sends are byte-identical from one turn to the
13
+ next unless the user changed something: agent config, an installed skill, a device tool
14
+ toggle. The runtime never varies them on its own.
15
+
16
+ `promptPrefixHash` (`lib/capabilities/prompt-prefix.ts`) is the identity of
17
+ that prefix. It rides the harness's per-round `round start` log next to `turns` and `tools`,
18
+ and a harness test asserts consecutive turns of an unchanged agent hash identically while
19
+ enabling a tool moves the hash.
20
+
21
+ The corollary, where a capability is resolved from more than one input: **one predicate
22
+ answers it for every consumer.** Skill activation is resolved by `skillIsActive`
23
+ (`lib/capabilities/completion.ts`), read both by the active catalog that
24
+ fills the skills section and grants tools, and by the skill loader's per-argument allowlist.
25
+ A skill in one and not the other either hides a capability the model was told about or
26
+ advertises one the loader then refuses.
27
+
28
+ ## Context
29
+
30
+ The prefix is what the KV cache matches on, so a prefix that moves reprocesses the whole
31
+ chat. That cost is acceptable once, for a change the user made. It is not acceptable per
32
+ turn, and a prefix that moves on its own is usually a capability appearing or disappearing
33
+ rather than a cache problem.
34
+
35
+ The defect that motivated this: `@mention` activation was resolved from the current turn
36
+ only, so a follow-up turn that named no skill lost the skill, the `skill` tool, and every
37
+ tool the skill granted. `@presentations` built a deck; "edit it to make the background
38
+ orange" reached a model with no way to act. The skills section and two tool definitions
39
+ left the prefix on every such turn, which was observable in the cache numbers and observed
40
+ by nobody.
41
+
42
+ The second half is the same failure one level down. Activation had two implementations — the
43
+ catalog filter and the loader's allowlist — and repairing only the first would have listed a
44
+ skill in the prompt that the loader answered with `skill not enabled`.
45
+
46
+ ## Alternatives Considered
47
+
48
+ **Sorting the tool block, or otherwise normalizing the prefix, to absorb variation.** It
49
+ would hide exactly the signal this record exists to expose: KV positions are absolute, so an
50
+ inserted or reordered tool invalidates everything after it whatever we hash. The prefix
51
+ should be stable because nothing varies it, not because the hash forgives variation.
52
+
53
+ ## Consequences
54
+
55
+ - A change that makes the system prompt or the tool block vary within a chat is a defect,
56
+ whatever motivates it, and the test says so.
57
+ - A mid-chat `CACHE_INIT` or a `cacheTokens` collapse is now attributable: either the logged
58
+ prefix moved, and the config change that moved it can be named, or it did not, and the
59
+ cause is elsewhere.
60
+ - Adding a third consumer of skill activation means extending `SkillActivation`, not
61
+ re-deriving activation locally.
62
+ - **This record does not decide skill-activation semantics.** SKILL-3 has since been
63
+ rewritten: an agent is born with every ready skill and a mention grants nothing, so the
64
+ migration below no longer has a grant to persist. As written at the time,
65
+ [SKILL-3](../product/skills.md#skill-3) required an `@mention` to enable the skill on the
66
+ agent — durably, visibly, and removably — and
67
+ [`qvac-app → docs/plan/tool-safety/p1.md`](../plan/tool-safety/p1.md) §1a-i owns that migration,
68
+ including moving mention detection out of `completion()` to where the user message is
69
+ minted. Until it lands, the harness keeps a chat's already-loaded skills active so a
70
+ follow-up turn does not lose them. That is a runtime repair for the prefix invariant above,
71
+ not the durable mechanism, and it becomes redundant for the mention path once a mention
72
+ persists onto `enabledSkills`.
73
+ - **Accepted risk until §1a-i lands: a mention grant is invisible and cannot be revoked.** The
74
+ only record that the user granted it is the chat transcript, so the Skills UI cannot show it,
75
+ and removing the skill from the agent (`removeSkillFromAgentLists`, which clears both
76
+ `enabledSkills` and `installedSkills`) leaves it active in every chat that already loaded it,
77
+ with the tools it grants, `exec` included. No harness-side signal can fix this: removal clears
78
+ both lists, so a revoked skill is byte-identical to a never-enabled one, and sending
79
+ `installedSkills` to the runtime would not distinguish them either. Uninstalling the skill from
80
+ the _device_ does revoke it everywhere, because the entry then leaves `skillCatalog`.
81
+ - That is a regression in revocability, not just an unbuilt half of SKILL-3. Before, a mention
82
+ grant was revoked by doing nothing, because it evaporated on the next turn, which is the
83
+ defect this record exists to fix. Persisting a mention onto the agent is what makes the grant
84
+ durable _and_ revocable; keeping only the durable half is the interim cost.
85
+ - Not addressed: `historyFromChunks` (`@qvac/core → lib/assistant.ts`) replays only `skill` tool
86
+ results, so a tool-produced file's `attachmentId` never reaches a later turn. Also not
87
+ addressed: the `@mention` preload rewrites the last user turn in memory only, so that turn
88
+ diverges from its persisted form on every later run — a second divergence point downstream
89
+ of the prefix.
90
+
91
+ ## When Agents Should Read This
92
+
93
+ Read this before changing what a run puts in the system prompt or the tool list, when adding
94
+ a consumer of skill activation, or when a chat turns out to send a different prefix on
95
+ different turns.
@@ -0,0 +1,109 @@
1
+ ---
2
+ id: ADR-2026-08-20-kvcache-static-placement
3
+ status: active
4
+ introduced_by: PR-1770
5
+ supersedes: [ADR-2026-06-02-kvcache-tool-turns-reenabled]
6
+ ---
7
+
8
+ # Chat models load with static tool placement and no sliding window
9
+
10
+ ## Decision
11
+
12
+ `model-pool.ts` no longer passes `toolsMode` or `n_discarded` in `modelConfig`
13
+ (static placement in PR-1711, the sliding window in PR-1770).
14
+ Models load with `tools: true` only, which selects the SDK's **static**
15
+ placement — the default, and since SDK 0.18 the only one, because that release
16
+ deletes dynamic mode and rejects the key. The per-turn payload is
17
+ `history.slice(savedCount)`, the unsent tail.
18
+ `kvCache: 'chat-<id>'` stays on every completion, unchanged.
19
+
20
+ The two removals ship as separate PRs, deliberately: the dynamic-tools defect
21
+ had to reach a release on its own. Each key removal changes `modelConfig`,
22
+ which re-keys the SDK's identity-by-config `modelId`, so every chat
23
+ cold-primes once per removal — one wave per release rather than one wave for
24
+ both. That cost is accepted in exchange for shipping the fix early.
25
+
26
+ ## Context
27
+
28
+ ADR-2026-06-02 chose `toolsMode: 'dynamic'` on the strength of the addon's
29
+ compact-tools anchoring. The 2026-08-18 investigation
30
+ (`docs/kvcache-reference.md`) established that on every shipped model the addon
31
+ half of that feature never ran: the `tools_compact` gate matches architecture
32
+ `qwen3` exactly, and the catalog is `qwen35`/`qwen36`, so the addon logged
33
+ `tools_compact is not supported … ignoring` on every load while the SDK still
34
+ sliced as-if-dynamic. Consequences, measured:
35
+
36
+ - the previous answer was duplicated into the KV every warm turn, and the tool
37
+ block was re-sent with nothing trimming it — three one-line exchanges filled
38
+ a 2048 window;
39
+ - after any cache reset (deleted `.bin`, `configHash`/`modelId` move, new
40
+ device) the dynamic slice still sent only the last exchange, so the model
41
+ silently lost everything older — TTFT unchanged, no error, answer
42
+ confabulated. Under static placement the same reset costs one proportional
43
+ prefill and the fact survives (measured: `promptTokens` 16→65, TTFT
44
+ 41→96 ms).
45
+
46
+ The sliding window fails the same way from the other side, and it fails on the
47
+ shipped stack. **A slide is invisible to the SDK**: the cache session records a
48
+ turn's full message count on success and the next turn sends
49
+ `history.slice(savedCount)`, so nothing observes the addon discarding tokens in
50
+ C++ — the discarded span is gone from the live cache and from the saved `.bin`
51
+ while the app still counts it as sent, and the model silently loses the middle
52
+ of the chat. Not sliding also obsoletes the two slide-time addon bugs we
53
+ reported (the reasoning-span wipe, the single-step prefill slide), and 0 is the
54
+ addon's own documented default, so nothing is being opted out of.
55
+
56
+ Upstream has since deleted dynamic mode on both sides (SDK `main` rejects the
57
+ `toolsMode` key at parse time; the addon drops `tools_compact` after 0.39.4),
58
+ and the addon team asked (Slack, 2026-08-19) that `n_discarded != 0` never be
59
+ passed because context shifting is dropped in a future addon. That last one is
60
+ guidance about an unreleased version and carries no weight here: the shipped
61
+ 0.39.4 documents the knob as live, default 0, with no deprecation note. The
62
+ decision rests on the defect above, which lands today.
63
+
64
+ ## Consequences
65
+
66
+ - A cold cache now falls back to the full history: chats survive cache resets
67
+ at TTFT cost instead of silently forgetting.
68
+ - With no sliding window, a full context surfaces `ContextOverflowError`, and
69
+ the overflow recovery ladder (`lib/context-window.ts`,
70
+ drop-oldest-turns) becomes effective — under dynamic slicing it was inert.
71
+ The planned compact flow (MIGRATION, KV cache follow-ups) is its successor.
72
+ - The tool block is **not** in the primed prefix on the SDK we ship (0.18.2;
73
+ QVAC-23251 landed in 0.17.1) — the prime is the system prompt alone, and the
74
+ block travels with a turn, written once and skipped thereafter. Its names still
75
+ join `configHash`, so a tool-set change (a skill activation, an `enabledTools`
76
+ or `disabledToolsets` edit) re-keys the cache file and costs a cold prime —
77
+ once per change, since the prefix is otherwise stable for the chat
78
+ ([2026-08-20-chat-prompt-prefix-is-stable](./2026-08-20-chat-prompt-prefix-is-stable.md)).
79
+ - **Dropping `n_discarded` is what lets that block be skipped at all.** The
80
+ shipped SDK sets `toolBlockEvictable = (modelConfig.n_discarded ?? 0) > 0`,
81
+ because with sliding on the addon's discard window opens exactly where the
82
+ block sits and nothing protects it. So while core-v2 passed the key, the whole
83
+ tool block was re-sent on every turn — measured ~331 tokens for a 4-tool agent.
84
+ This is the second present-day reason for the removal, alongside a slide being
85
+ invisible to the SDK's own `savedCount` accounting.
86
+ - With `n_discarded` at 0 the addon's decode-time slide never engages either: a
87
+ generation that fits at prefill but crosses `ctx_size` mid-stream cannot
88
+ slide, and the overflow ladder cannot recover it — the ladder only retries a
89
+ round that has not yet yielded tokens — so a mid-decode exhaustion ends the
90
+ turn. Rare (bounded by generation length, not chat length); the compact flow's
91
+ proactive trigger is the structural answer. That round still fails with the
92
+ harness's own `CONTEXT_OVERFLOW_MESSAGE` rather than the SDK's text, which
93
+ names the `modelId` hash and blames the prompt — and which the compaction
94
+ trigger would not recognise.
95
+ - **Each removal strands the previous cache file, and nothing sweeps it.** The
96
+ file lives at `{kvCacheDir}/{cacheKey}/{modelId}/{configHash}.bin`, so a
97
+ `modelId` re-key leaves the old `{modelId}/` directory unreferenced — 21–26 MB
98
+ at ctx 2048, more at the desktop default. The SDK's retention sweep cannot
99
+ reach it: eviction plans only keys marked by `markAutoCacheKey`, and the
100
+ custom-key path (`beginCustom`) never marks, so `chat-<id>` is outside
101
+ retention permanently and by design (a config revert may revive the file).
102
+ Pre-existing — every `configHash` move already did this — but two releases in
103
+ a row now do it to every chat at once. The reclaim is the MIGRATION
104
+ "Delete on re-key" item, which deletes the whole key directory (every
105
+ `modelId`/`configHash` under it) on chat deletion and agent re-key; a chat
106
+ that is never deleted and whose agent is never edited keeps its stale
107
+ directories until then.
108
+ - One cold prime per chat on first run after the upgrade (the `modelId`
109
+ re-key) — expected, not a regression.
@@ -0,0 +1,85 @@
1
+ ---
2
+ id: ADR-2026-08-21-vendored-python-wheels
3
+ status: active
4
+ introduced_by: PR-1757
5
+ supersedes: []
6
+ ---
7
+
8
+ # Python packages ship with the app, pinned by hash
9
+
10
+ ## Decision
11
+
12
+ Every Python distribution a skill declares in `packages` is vendored into the
13
+ app: `wheels/manifest.json` pins each wheel with its sha256,
14
+ `scripts/vendor-python-wheels.mjs` downloads and verifies them at build time,
15
+ and the desktop build copies the tree to `dist/main/python-wheels`. The harness
16
+ receives the directory on argv and installs from it, so a run declaring a pinned
17
+ package makes no network request at all.
18
+
19
+ Two channels need two mechanisms. Lockfile wheels (micropip, pillow, lxml,
20
+ fonttools, typing-extensions, cryptography and its deps) are copied into
21
+ Pyodide's `packageCacheDir` before the interpreter boots, because `loadPackage`
22
+ reads that directory and nothing else. PyPI wheels (fpdf2, pypdf, openpyxl,
23
+ python-docx, python-pptx and their deps) are staged into the interpreter and
24
+ installed by `emfs:` path with `deps=False`, because the manifest already names
25
+ the whole closure and dependency resolution is the step that would reach PyPI.
26
+
27
+ ## Context
28
+
29
+ The PDF skill failed on an offline device: `micropip.install` resolves names
30
+ against pypi.org, so `fpdf2` could not be fetched. micropip reports an
31
+ unreachable index as `Can't fetch metadata for 'fpdf2'`, which reads as a wrong
32
+ package name — the model answered it by guessing `fpdf`, `pdf2`, `pdfkit` and
33
+ `reportlab` in turn and never produced a file. Every python-backed skill had the
34
+ same defect, and PyPI wheels were never persisted, so even a device that had
35
+ been online re-downloaded them on the next launch.
36
+
37
+ Pinning was already the skills' convention for reproducibility. Vendoring the
38
+ pinned set turns that into a supply-chain guarantee: the app cannot install a
39
+ version that was not reviewed, and the runtime makes no PyPI request at all.
40
+
41
+ Measured cost for the whole set: 17 wheels, 8,314,167 bytes on disk — 1.8 MB of
42
+ PyPI wheels, 3.9 MB of lockfile wheels, and 2.3 MB for the cryptography closure
43
+ the PDF skill's AES flows need. Two macOS DMGs built from the same tree, one with
44
+ the wheels staged and one without, put the shipped cost at **8,245,247 bytes
45
+ (+3.6%)**: 229,209,450 → 237,454,697. Wheels are zip archives, so the DMG's LZFSE
46
+ pass cannot compress them further and the installer grows by very nearly their
47
+ raw size. Against the 24 MB of Pyodide runtime the app already ships, they are the
48
+ cheap part; lxml, long assumed to be the dominant cost, is 1.6 MB.
49
+
50
+ ## Alternatives Considered
51
+
52
+ - **Point Pyodide's `packageCacheDir` at the read-only vendored tree.** Pyodide
53
+ writes downloaded wheels into that directory, so an unvendored package would
54
+ fail on the write rather than on the download, and the online fallback would
55
+ break. Seeding a writable cache keeps both paths working.
56
+ - **Install the vendored wheels and let micropip resolve the rest.** A partial
57
+ install pins versions the resolver then has to work around, and it still needs
58
+ the network for the remainder. The plan is all-or-nothing per request: any
59
+ unvendored package sends the whole install back to PyPI, dependencies included.
60
+ - **Ship the wheels inside `node_modules/pyodide`.** Its `indexURL` is bypassed
61
+ the moment `packageCacheDir` is set, and `bun install` would wipe the tree.
62
+
63
+ ## Consequences
64
+
65
+ - A pin change is two edits and a regeneration: the version in `SKILL.md`, the
66
+ version in the manifest, then `node scripts/vendor-python-wheels.mjs --update`.
67
+ The `python-wheels` test suite fails when those disagree, because a silent
68
+ disagreement reverts that skill to online-only.
69
+ - Bumping Pyodide invalidates the lockfile wheels — they carry its ABI tag. The
70
+ vendoring script refuses to run when the manifest and `node_modules/pyodide`
71
+ name different versions.
72
+ - A package no skill declares still installs from PyPI and still needs a
73
+ connection. micropip reports a missing name and an unreachable index
74
+ identically, so `packageFetchFailedHint` names both causes and tells the model
75
+ not to try other spellings, which is the part that ends the loop.
76
+ - Mobile is unchanged: no Pyodide runtime ships there, so no wheels are passed.
77
+ Vendoring for mobile means shipping the interpreter first.
78
+ - The desktop build needs network access at package time to fill the wheel tree.
79
+ A hash mismatch fails the build rather than shipping an unreviewed wheel.
80
+
81
+ ## When Agents Should Read This
82
+
83
+ Read this when changing the Python exec adapter's install path, the vendoring
84
+ script or manifest, the `packages` pins in a skill, the Pyodide dependency, or
85
+ the desktop build steps that stage harness assets.
@@ -0,0 +1,64 @@
1
+ ---
2
+ id: ADR-2026-08-26-exec-is-not-gated-by-skills
3
+ status: active
4
+ introduced_by: PR-1854
5
+ supersedes: []
6
+ ---
7
+
8
+ # A skill no longer decides what `exec` may run; approval does
9
+
10
+ ## Decision
11
+
12
+ `exec` is reachable whenever the agent's Tools settings reach it, and it runs whatever
13
+ command it is given. The two gates that made a skill the source of that permission are
14
+ gone:
15
+
16
+ - the call-time lock that refused a granted tool until the granting skill's body had
17
+ been loaded in the same chat, and
18
+ - the per-turn binary allowlist, which built the set of runnable programs out of the
19
+ `exec(...)` scopes of the active skills and refused everything else.
20
+
21
+ What remains in front of a command is the sanitizer — one program per call, no
22
+ chaining, pipes, redirection or shell expansion — and the approval prompt, which every
23
+ `exec` write raises unless the agent is in Skip All Approvals.
24
+
25
+ A skill's `exec(bin)` scope still means something narrower: it names the bin whose
26
+ `cli.schema.json` validates argv and carries the `x-effect: read` annotations that let
27
+ a read run unattended. It no longer decides whether the command may run at all.
28
+
29
+ ## Context
30
+
31
+ The agent's Tools tab lists Exec as a capability the user switches on, with the hint
32
+ "Lets QV.AC run commands on the device" ([PERM-10](../product/tool-permissions.md#perm-10)).
33
+ That switch writes the tool's name into the agent's allowlist and nothing else. It
34
+ could not add a binary to the allowlist or load a skill body, so switching Exec on left
35
+ `exec` hidden from the model, and a model that reached it anyway got told to load a
36
+ skill it may not have. The user's switch said yes and the gate said no.
37
+
38
+ Both gates were built when a skill was the only way a tool became available
39
+ (PR-1426 for the lock, and the scope work before it). That premise is what changed.
40
+
41
+ ## Consequences
42
+
43
+ - **Approval is the boundary.** With no argv schema for an arbitrary bin, `shellEffect`
44
+ cannot classify a command as a read, so it classifies write and the call prompts. An
45
+ agent on Skip All Approvals runs shell commands unattended; that mode already carried
46
+ that meaning for the granted binaries, and now carries it for all of them.
47
+ - **`exec(python)` no longer gates Pyodide.** `language: "python"` runs without any
48
+ grant. The realm it runs in is unchanged, so
49
+ [PERM-7](../product/tool-permissions.md#perm-7) is exactly as met, or unmet, as it
50
+ was; what changed is that reaching it no longer requires a skill. The mitigation is
51
+ still [tool-safety p3](../plan/tool-safety/p3.md).
52
+ - **The argv schema now reads backwards.** `resolveShellOps` looks the bin up in
53
+ `grantedOps`, which only active skills fill. With apple-notes on,
54
+ `osascript -e 'do shell script "id"'` is refused before approval; with it off, the same
55
+ command sanitizes clean and spawns. Enabling a skill makes `exec` narrower than leaving
56
+ it off. That is kept deliberately: the schema is how a skill states the shape it expects
57
+ its own CLI to be called in, and it bounds nothing that running the command directly
58
+ would not already reach. It stops being defensible the moment anything treats it as a
59
+ security control.
60
+ - **The model is told the truth.** `exec`'s description no longer promises an allowlist,
61
+ and a loaded skill's tool note no longer claims its scopes are the only runnable
62
+ executables.
63
+ - The `skillGated` and `scopeRequired` wire fields survive, reported off, so a build on
64
+ the other side of the sidecar hop reads both gates as open rather than missing.
@@ -0,0 +1,56 @@
1
+ ---
2
+ id: ADR-2026-08-27-writable-skills-overlay
3
+ status: active
4
+ introduced_by: PR-1857
5
+ supersedes: []
6
+ ---
7
+
8
+ # A user-added skill layers over the bundled base and is trusted only by shadow
9
+
10
+ Skills load from two roots. The bundled base is the read-only, version-pinned set
11
+ shipped with the app. Over it sits a writable, per-user **overlay** at
12
+ `<sdk-home>/skills`, resolved in `lib/tools/skills/overlay-dir.ts`
13
+ from the `QVAC_CONFIG_PATH` env var that `configureSdkStorage` publishes on both
14
+ runtimes — read via env rather than importing from `assistant`, which would be a
15
+ circular dependency, and needing no new caller argument, so desktop and mobile
16
+ provide it just by configuring storage. `loadSkillCatalog` in
17
+ `lib/tools/skills/catalog.ts` reads both roots and merges them.
18
+
19
+ ## Decision
20
+
21
+ **The merge key is the folder name, and the overlay wins.** A skill's identity is
22
+ its folder, not its front-matter `name` — because the `skill` tool reads a skill by
23
+ folder path (`/<name>/SKILL.md`, `lib/tools/skill.ts`). Keying the
24
+ catalog on the folder makes the merge key and the on-disk read lookup the same key,
25
+ so they can never disagree. (Keying on `meta.name` did not: an overlay `gmail/`
26
+ declaring `name: pirate` would file itself as a _new_, untrusted `pirate` entry
27
+ while its files still shadowed the trusted base `gmail` at read time — so loading
28
+ `gmail` served the overlay body under the base's grants, bypassing the merge below.
29
+ A divergent `meta.name` is now ignored.) An overlay folder matching a bundled folder
30
+ _shadows_ it; any other folder is a _new_ skill.
31
+
32
+ **A new overlay skill is untrusted.** A skill's front matter is a grant: loading it
33
+ unlocks tools, widens the credential egress allow-list, and classifies MCP methods
34
+ as reads. A file anyone can drop on disk must not do those things silently. So a
35
+ new overlay skill is excluded from `credentialGrants`
36
+ (`lib/tools/skills/credential-grants.ts`) and from `mcpReadMethods`
37
+ (`catalog.ts`) — it still loads and runs, but its http/exec calls go through the
38
+ normal per-call approval gate, and its declared reads count as writes. This is the
39
+ `origin: 'overlay'` marker, which is local-only and never crosses the wire.
40
+
41
+ **A shadow overrides instructions, never capability.** Editing a first-party skill
42
+ is the point of the overlay, but the edit must not be able to re-arm it. `mergeOverlay`
43
+ takes the overlay copy's body and display metadata but pins its `allowList`,
44
+ `credentials`, `mcpReads`, and `platform` to the trusted base. So an edit can't add
45
+ an egress host, can't break the skill's auth by dropping a field, and can't claim an
46
+ OS the base doesn't support — and a shadow is _not_ marked `origin: 'overlay'`,
47
+ because its capability surface is the base's.
48
+
49
+ **An undeclared platform means "here", not "nowhere".** The base treats a skill with
50
+ no `platform` as supported nowhere, because every bundled skill lists its platforms
51
+ explicitly. A user dropping a bare `SKILL.md` (e.g. one from the Claude or Codex
52
+ skills repos) does not know that incantation, and requiring it would mean every
53
+ such skill shows as "not available on this device". So a _new_ overlay skill with no
54
+ declared platform defaults to every platform the app targets; the other gates
55
+ (missing tools, model capability) still apply, so this only lifts the OS gate. This
56
+ is what makes "drop a folder and it works" true.
@@ -0,0 +1,53 @@
1
+ ---
2
+ id: ADR-2026-09-01-skill-operation-manifests
3
+ status: active
4
+ introduced_by: PR-000
5
+ supersedes: []
6
+ ---
7
+
8
+ # A provider write is a compiled skill operation, not a shaping hook in `http_request`
9
+
10
+ ## Decision
11
+
12
+ A skill declares its write operations in a sibling `operations.json` manifest. The harness compiles
13
+ each entry into a typed tool (`gmail_send`, `gmail_draft`, `calendar_create_meet_event`,
14
+ `calendar_add_meet`) that validates arguments, builds the provider payload, and delegates the call to
15
+ `http_request`. `http_request` itself carries no provider knowledge: the per-host request/response
16
+ shaping registry is removed, and an oversized JSON response is reduced by a generic per-node pruner.
17
+
18
+ A compiled tool is owned by the skill whose bundled manifest defined it. The tool gate matches on the
19
+ owning skill, not the tool name, so a sibling skill or a writable-overlay skill that declares the same
20
+ name in `tools:` cannot expose it. A writable overlay never contributes operations of its own; editing
21
+ a bundled skill keeps the base manifest's tools, exactly as it keeps the base credentials and
22
+ allow-list.
23
+
24
+ ## Context
25
+
26
+ Gmail, Calendar, and Docs each needed request shaping (RFC 2822 assembly, base64url, conference
27
+ requests) and response shaping. That knowledge lived inside the generic `http_request` tool as a
28
+ host-keyed registry, so every new provider widened a shared tool, and the model still had to write raw
29
+ URLs and bodies for a write it could not verify.
30
+
31
+ Shaping also meant the response cap was provider-specific. A provider with no registry entry got a raw
32
+ character slice, which cuts JSON mid-token and drops shallow identifiers the model needs next.
33
+
34
+ ## Alternatives Considered
35
+
36
+ - **Keep the shaping registry, add a provider per file.** Rejected: provider coupling in a generic
37
+ tool, and it never gives the model a typed schema for the write.
38
+ - **Let an overlay skill ship its own `operations.json`.** Rejected: a user-editable file would mint a
39
+ tool with the base skill's credentials — privilege escalation by rename.
40
+
41
+ ## Consequences
42
+
43
+ - A new provider write is a manifest entry plus a builder, never an `http_request` change.
44
+ - Every operation tool is `externalWrite`, non-auto-approved, and skill-gated; it passes through the
45
+ same egress guard, private-IP check, and approval path as a raw `http_request`.
46
+ - Response truncation is uniform: parseable JSON in, parseable JSON out, for every host.
47
+ - The manifest describes one HTTP call. Multi-step or paginated flows stay in skill instructions.
48
+ - Revisit if a provider needs a write whose payload cannot be expressed by a named builder.
49
+
50
+ ## When Agents Should Read This
51
+
52
+ Read this when changing the paths, workflows, runtime boundaries, or platform
53
+ behavior listed for this ADR in `docs/decisions/INDEX.md`.
@@ -0,0 +1,78 @@
1
+ ---
2
+ id: ADR-2026-09-01-usage-breakdown-is-measured
3
+ status: superseded
4
+ introduced_by: PR-1953
5
+ superseded_by: ADR-2026-09-03-usage-breakdown-reported-by-the-harness
6
+ supersedes: []
7
+ ---
8
+
9
+ # The Usage breakdown divides a measured total, it never estimates tokens
10
+
11
+ ## Decision
12
+
13
+ Only the Usage modal's total is a token count: `cacheTokens` from the turn's SDK
14
+ metrics. Every row below it is that total split by character share, at a
15
+ chars-per-token ratio fitted per chat against the SDK's own count on the first
16
+ turn that reports one. No row converts characters to tokens with a hardcoded
17
+ ratio, and the fit is never recomputed on a later turn.
18
+
19
+ Two costs the model receives but we do not author are declared as measured
20
+ constants rather than absorbed: the chat template's tool preamble
21
+ (`FRAMING_CHARS_BY_DIALECT`, read through `toolBlockFramingChars`) and one
22
+ exchange's fixed overhead (`TURN_OVERHEAD_TOKENS`, charged to the turn via the
23
+ fit's denominator). Only the first is keyed by dialect — a template either has a
24
+ tool preamble or has none, whereas every template wraps its turns, so declaring
25
+ zero overhead would be further from the truth than Qwen's number is elsewhere.
26
+
27
+ ## Context
28
+
29
+ The SDK exposes no tokenizer — `@qvac/sdk` 0.18.2 ships tokenization only for
30
+ VLA, whose docs tell consumers to tokenize their own input — so a real count is
31
+ unavailable and `cacheTokens` is the only ground truth.
32
+
33
+ A single hardcoded ratio cannot work. Measured on Qwen3.5-4B, real density is
34
+ 3.17 chars-per-token for a prompt with no tools and 4.23 for a seven-tool
35
+ prompt, because tool schemas are dense JSON and the rest is prose. The retired
36
+ 3.5 constant therefore overshot the measured total, and the scaling fallback
37
+ squeezed every row to fit — which is why the rows appeared to grow between turns
38
+ while Conversation collapsed to single digits.
39
+
40
+ Character counting is also blind to two real costs: the template's own tool
41
+ section, and per-turn role wrappers plus the harness time annotation. Left
42
+ unattributed they are spread across the rows that do have characters, which
43
+ overstated a tool-less agent's System Prompt by 47%.
44
+
45
+ ## Alternatives Considered
46
+
47
+ - **Better hardcoded ratios per segment.** Rejected: it replaces one invented
48
+ constant with three, all model-specific, and all silently wrong on a new model.
49
+ - **A two-point fit** using the delta between the first two turns to measure the
50
+ per-turn cost directly. Correct and constant-free, but it reports nothing until
51
+ a chat has two turns.
52
+ - **Keying the framing globally rather than by dialect.** Rejected: dialect is
53
+ the axis the template varies on, and an unmeasured model should declare zero
54
+ rather than borrow another template's number.
55
+
56
+ ## Consequences
57
+
58
+ - The rows sum exactly to the measured total on every turn, and the prompt rows
59
+ hold still across turns of one chat. Both are load-bearing: QA asserts them.
60
+ - Getting a constant wrong is cosmetic. It shifts the split between rows and can
61
+ never move the total, the sum, or the flatness — so a stale value degrades
62
+ silently, and only a real-model measurement can catch it.
63
+ - The estimator must keep mirroring the production selection functions
64
+ (`permittedTools`, `enabledTools`, `skillIsActive`); a drift test pins this.
65
+ - The fit's numerator must keep pace with what the cache holds. Tool calls and
66
+ their output are re-sent and counted; generated `thinking` is not, and
67
+ `cacheTokens` excludes it (measured: 408 generated tokens against 162 reported).
68
+ - Re-measure a constant by diffing the addon's `PROMPT_SEND` dump against
69
+ `systemPromptChars` + `toolsChars`. Drive it from `@qvac/core → examples/chat-tui.mjs`
70
+ over a pty with `QVAC_STORAGE` pointed at a scratch dir, and read `N ctx` from
71
+ the footer.
72
+ - Revisit if the SDK ever exposes a token-count or prompt-render op: that would
73
+ retire the fit and both constants.
74
+
75
+ ## When Agents Should Read This
76
+
77
+ Read this when changing the paths, workflows, runtime boundaries, or platform
78
+ behavior listed for this ADR in `docs/decisions/INDEX.md`.