@theokit/sdk 5.5.0 → 5.6.0

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 (131) hide show
  1. package/CHANGELOG.md +601 -0
  2. package/dist/{agent-YWSIHHFM.cjs → agent-47NS6ZVL.cjs} +13 -13
  3. package/dist/{agent-YWSIHHFM.cjs.map → agent-47NS6ZVL.cjs.map} +1 -1
  4. package/dist/{agent-DyB_lzrx.d.cts → agent-82d_DrCL.d.cts} +11 -2
  5. package/dist/{agent-CjMVbhhY.d.ts → agent-G6g-uwcB.d.ts} +11 -2
  6. package/dist/{agent-G3T7Q3SQ.js → agent-JJZM2VIK.js} +12 -12
  7. package/dist/{agent-G3T7Q3SQ.js.map → agent-JJZM2VIK.js.map} +1 -1
  8. package/dist/{chunk-ODBID5TG.js → chunk-2UXQASTX.js} +225 -30
  9. package/dist/chunk-2UXQASTX.js.map +1 -0
  10. package/dist/{chunk-VPK6PHIE.cjs → chunk-52FDUJSV.cjs} +8 -8
  11. package/dist/{chunk-VPK6PHIE.cjs.map → chunk-52FDUJSV.cjs.map} +1 -1
  12. package/dist/{chunk-2XRAWOZZ.js → chunk-5NELQ6LB.js} +65 -3
  13. package/dist/chunk-5NELQ6LB.js.map +1 -0
  14. package/dist/{chunk-VY6NKMOA.cjs → chunk-7IZKTQ5G.cjs} +34 -6
  15. package/dist/chunk-7IZKTQ5G.cjs.map +1 -0
  16. package/dist/{chunk-7SZAV6QG.js → chunk-7MMTZBTT.js} +3 -3
  17. package/dist/{chunk-7SZAV6QG.js.map → chunk-7MMTZBTT.js.map} +1 -1
  18. package/dist/{chunk-SADXXGWU.js → chunk-BZ3YMAMP.js} +3 -3
  19. package/dist/{chunk-NQTNSHSB.cjs.map → chunk-BZ3YMAMP.js.map} +1 -1
  20. package/dist/{chunk-LX7SEXOQ.js → chunk-CA5VUAP3.js} +25 -7
  21. package/dist/chunk-CA5VUAP3.js.map +1 -0
  22. package/dist/{chunk-IU5N5224.cjs → chunk-EIOMN5VA.cjs} +8 -8
  23. package/dist/chunk-EIOMN5VA.cjs.map +1 -0
  24. package/dist/{chunk-M3ZGRGOG.js → chunk-FPIY5CLV.js} +3 -3
  25. package/dist/{chunk-M3ZGRGOG.js.map → chunk-FPIY5CLV.js.map} +1 -1
  26. package/dist/{chunk-UKJJRU7C.cjs → chunk-GCHZMH42.cjs} +283 -86
  27. package/dist/chunk-GCHZMH42.cjs.map +1 -0
  28. package/dist/{chunk-NQTNSHSB.cjs → chunk-GFFBXSQT.cjs} +5 -5
  29. package/dist/chunk-GFFBXSQT.cjs.map +1 -0
  30. package/dist/{chunk-2ZLQZHZC.cjs → chunk-HG4UN4MN.cjs} +4 -4
  31. package/dist/{chunk-2ZLQZHZC.cjs.map → chunk-HG4UN4MN.cjs.map} +1 -1
  32. package/dist/{chunk-N6OOOYFZ.js → chunk-HUDNLFY4.js} +4 -4
  33. package/dist/chunk-HUDNLFY4.js.map +1 -0
  34. package/dist/{chunk-LOHMT36V.cjs → chunk-LD6HASA5.cjs} +65 -2
  35. package/dist/chunk-LD6HASA5.cjs.map +1 -0
  36. package/dist/{chunk-QATRS7JD.cjs → chunk-MYJGWS2J.cjs} +26 -8
  37. package/dist/chunk-MYJGWS2J.cjs.map +1 -0
  38. package/dist/{chunk-S6B5XYC3.js → chunk-N2KAIZ5D.js} +33 -6
  39. package/dist/chunk-N2KAIZ5D.js.map +1 -0
  40. package/dist/{chunk-HW7SEELD.cjs → chunk-QRVS2PRE.cjs} +31 -8
  41. package/dist/chunk-QRVS2PRE.cjs.map +1 -0
  42. package/dist/{chunk-AYA65JA5.cjs → chunk-RWPLWMCZ.cjs} +25 -9
  43. package/dist/chunk-RWPLWMCZ.cjs.map +1 -0
  44. package/dist/{chunk-WS5ULCL4.js → chunk-STGSMJMJ.js} +3 -3
  45. package/dist/{chunk-WS5ULCL4.js.map → chunk-STGSMJMJ.js.map} +1 -1
  46. package/dist/{chunk-O7L7M42F.js → chunk-T3ZDEYTJ.js} +21 -5
  47. package/dist/chunk-T3ZDEYTJ.js.map +1 -0
  48. package/dist/{chunk-43YXGD3P.cjs → chunk-TY56BKSK.cjs} +8 -4
  49. package/dist/chunk-TY56BKSK.cjs.map +1 -0
  50. package/dist/chunk-UOLBAPDM.js +66 -0
  51. package/dist/chunk-UOLBAPDM.js.map +1 -0
  52. package/dist/{chunk-Z2JFX372.cjs → chunk-VUHXC74Q.cjs} +15 -15
  53. package/dist/{chunk-Z2JFX372.cjs.map → chunk-VUHXC74Q.cjs.map} +1 -1
  54. package/dist/{chunk-NSLHPAC7.js → chunk-X7EUUHXU.js} +6 -5
  55. package/dist/chunk-X7EUUHXU.js.map +1 -0
  56. package/dist/context/index.cjs +7 -7
  57. package/dist/context/index.js +3 -3
  58. package/dist/{context-JTGSBJT6.cjs → context-HR4KMXMA.cjs} +7 -7
  59. package/dist/{context-JTGSBJT6.cjs.map → context-HR4KMXMA.cjs.map} +1 -1
  60. package/dist/context-J5BJ3LBS.js +6 -0
  61. package/dist/{context-3HPU754C.js.map → context-J5BJ3LBS.js.map} +1 -1
  62. package/dist/{cron-Bgivg88c.d.cts → cron-DWv69ZSD.d.cts} +1 -1
  63. package/dist/{cron-CNUa7PDo.d.ts → cron-GynWtAax.d.ts} +1 -1
  64. package/dist/cron.cjs +12 -12
  65. package/dist/cron.d.cts +2 -2
  66. package/dist/cron.d.ts +2 -2
  67. package/dist/cron.js +11 -11
  68. package/dist/eval.cjs +11 -11
  69. package/dist/eval.js +10 -10
  70. package/dist/{index-manager-W7FDMGEG.js → index-manager-27WLNQEE.js} +5 -5
  71. package/dist/{index-manager-W7FDMGEG.js.map → index-manager-27WLNQEE.js.map} +1 -1
  72. package/dist/{index-manager-3UNPYH34.cjs → index-manager-BBHDKMQS.cjs} +6 -6
  73. package/dist/{index-manager-3UNPYH34.cjs.map → index-manager-BBHDKMQS.cjs.map} +1 -1
  74. package/dist/index.cjs +274 -40
  75. package/dist/index.cjs.map +1 -1
  76. package/dist/index.d.cts +168 -5
  77. package/dist/index.d.ts +168 -5
  78. package/dist/index.js +246 -23
  79. package/dist/index.js.map +1 -1
  80. package/dist/internal/memory/storage/index.cjs +32 -32
  81. package/dist/internal/memory/storage/index.js +3 -3
  82. package/dist/internal/memory/storage/memory-root.d.cts +27 -0
  83. package/dist/internal/memory/storage/memory-root.d.ts +27 -0
  84. package/dist/internal/persistence/index.cjs +4 -4
  85. package/dist/internal/persistence/index.js +1 -1
  86. package/dist/internal/runtime/compat/foreign-config-sources.d.ts +17 -4
  87. package/dist/internal/runtime/compat/managed-settings.d.ts +80 -0
  88. package/dist/internal/runtime/context/context-discovery-runner.d.ts +14 -0
  89. package/dist/internal/runtime/context/context-discovery.d.ts +37 -0
  90. package/dist/internal/runtime/context/context-manager.d.ts +21 -1
  91. package/dist/internal/runtime/context/yaml-frontmatter.d.ts +6 -3
  92. package/dist/internal/runtime/hooks/hooks-executor.d.ts +13 -1
  93. package/dist/internal/runtime/hooks/hooks-source.d.ts +36 -1
  94. package/dist/internal/runtime/skills/discover-skills.d.ts +4 -0
  95. package/dist/project.cjs +3 -3
  96. package/dist/project.js +1 -1
  97. package/dist/skills.cjs +5 -5
  98. package/dist/skills.js +2 -2
  99. package/dist/subagents-loader-CJFYQQU2.js +7 -0
  100. package/dist/{subagents-loader-AIVDQ2D5.js.map → subagents-loader-CJFYQQU2.js.map} +1 -1
  101. package/dist/subagents-loader-MOO7DC4E.cjs +16 -0
  102. package/dist/{subagents-loader-DN4LETGL.cjs.map → subagents-loader-MOO7DC4E.cjs.map} +1 -1
  103. package/dist/subagents-loader.cjs +4 -4
  104. package/dist/subagents-loader.d.cts +1 -1
  105. package/dist/subagents-loader.d.ts +1 -1
  106. package/dist/subagents-loader.js +3 -3
  107. package/dist/types/agent.d.ts +6 -1
  108. package/docs/error-codes.md +20 -18
  109. package/docs/harness-capability-map.md +9 -1
  110. package/package.json +1 -1
  111. package/dist/chunk-2XRAWOZZ.js.map +0 -1
  112. package/dist/chunk-43YXGD3P.cjs.map +0 -1
  113. package/dist/chunk-AYA65JA5.cjs.map +0 -1
  114. package/dist/chunk-HW7SEELD.cjs.map +0 -1
  115. package/dist/chunk-IU5N5224.cjs.map +0 -1
  116. package/dist/chunk-JNAA4G4H.js +0 -43
  117. package/dist/chunk-JNAA4G4H.js.map +0 -1
  118. package/dist/chunk-LOHMT36V.cjs.map +0 -1
  119. package/dist/chunk-LX7SEXOQ.js.map +0 -1
  120. package/dist/chunk-N6OOOYFZ.js.map +0 -1
  121. package/dist/chunk-NSLHPAC7.js.map +0 -1
  122. package/dist/chunk-O7L7M42F.js.map +0 -1
  123. package/dist/chunk-ODBID5TG.js.map +0 -1
  124. package/dist/chunk-QATRS7JD.cjs.map +0 -1
  125. package/dist/chunk-S6B5XYC3.js.map +0 -1
  126. package/dist/chunk-SADXXGWU.js.map +0 -1
  127. package/dist/chunk-UKJJRU7C.cjs.map +0 -1
  128. package/dist/chunk-VY6NKMOA.cjs.map +0 -1
  129. package/dist/context-3HPU754C.js +0 -6
  130. package/dist/subagents-loader-AIVDQ2D5.js +0 -7
  131. package/dist/subagents-loader-DN4LETGL.cjs +0 -16
package/CHANGELOG.md CHANGED
@@ -1,5 +1,606 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#659](https://github.com/usetheokit/theokit-sdk/pull/659) [`0aca2bf`](https://github.com/usetheokit/theokit-sdk/commit/0aca2bfd8bca0aa57f9929f0121c1bdf2159fc92) Thanks [@usetheodev](https://github.com/usetheodev)! - Two tiers no permission rule and no mode can reach: protected paths and critical paths.
8
+
9
+ Measured: `protectedPath` returned 0 files here and 0 in the dist; `criticalPath` the same, against a
10
+ control of 31/72 on the word `hooks`.
11
+
12
+ **Protected paths** are the circuit breaker that stops an agent editing its own configuration, the
13
+ git hooks or the shell rc files — `.git`, `.claude`, `.theokit`, `.ssh`, `.gnupg`, `.envrc`,
14
+ `.npmrc`, `.mcp.json`, `.pre-commit-config.yaml`, `.netrc`. An `allow`-broad setup wrote all of them
15
+ freely and the operator had no way to express the exception, because the concept was absent.
16
+
17
+ **Critical paths** are destructive operations on `/`, the home directory, the working directory and
18
+ its parents. The nearest analogue was `catastrophicShellReason` in `@theokit/sdk-tools`, reached
19
+ through an opt-in `denyCatastrophicCommands()` — and **an opt-in guard is not a floor**. The whole
20
+ point of this tier is that nothing overrides it; a function a consumer may forget to call makes the
21
+ guarantee a convention.
22
+
23
+ **Why a floor and not a deny rule.** A deny rule is ordered, and order is defeasible: it can be
24
+ shadowed by a broader rule above it, reordered, or simply not shipped. The floor is consulted before
25
+ any verdict is honoured and no rule can reach it. Each test pairs the refusal with an explicit allow
26
+ rule for the same call, because the point is not that the operation is refused — it is that the rule
27
+ loses.
28
+
29
+ **Substitutions are refused, not expanded.** The spec names `$(...)` and `"$VAR"/*`, and in both the
30
+ dangerous argument is not in the text being matched. Expanding would mean running the substitution,
31
+ and a floor that executes its input to decide whether the input is safe has the problem backwards.
32
+
33
+ **Segment-aware, never prefix-matching.** `/workspace-other` starts with `/work` as a string and is a
34
+ different directory; `.gitignore` contains `.git` and is a file projects edit routinely. A floor that
35
+ could not tell them apart would refuse ordinary work while claiming to protect something else.
36
+
37
+ The refusal **names the path and says which tier refused**, so it is not mistaken for a permissions
38
+ misconfiguration — an operator who reads "denied" goes looking at their rules and finds nothing wrong
39
+ with them.
40
+
41
+ **Reads are not gated**, deliberately: the tier is about writes and destruction, and refusing reads
42
+ would stop an agent inspecting the repository it was pointed at.
43
+
44
+ Stated rather than implied: this is **not a shell parser**. It recognises the destructive shapes the
45
+ spec names, on the arguments it names, and a determined obfuscation gets past it — `rm` reached
46
+ through a variable holding the command name, for instance. A floor described as complete would be
47
+ trusted as complete.
48
+
49
+ - [#660](https://github.com/usetheokit/theokit-sdk/pull/660) [`612da2f`](https://github.com/usetheokit/theokit-sdk/commit/612da2fcb96ce498973aeb60e05650e32f7c4216) Thanks [@usetheodev](https://github.com/usetheodev)! - A foreign root's rules now need the grant that already gates everything else in that directory.
50
+
51
+ `FileContextManager.initialize()` gated project-level context on
52
+ `settingSourcesIncludeProject || settings.manager === "file"` and consulted no foreign-dialect
53
+ grant. `compatSources` appeared nowhere under `src/internal/runtime/context/`. So a consumer who
54
+ enabled project scope for its OWN `.theokit/` and deliberately never declared `claude-code` still
55
+ received a cloned repository's `.claude/rules/*.md` in its system prompt — while the same
56
+ directory's hooks, skills, subagents and plugins were correctly withheld. Four surfaces failing
57
+ closed, and a fifth nobody had wired to the gate ([#652](https://github.com/usetheokit/theokit-sdk/issues/652)).
58
+
59
+ The root cause was not a missing `if`. `CompatSurface` was `"hooks" | "plugins" | "skills" |
60
+ "subagents"`: there was no member for instructions, so no grant could govern them and the gate had
61
+ nothing to consult. `"context"` is now a surface like the others, paired to its runtime list by the
62
+ existing compile-time exhaustiveness guard, and each discovery spec names the dialect whose grant
63
+ gates it. Adding a dialect is adding a row.
64
+
65
+ **This is a behaviour change, and the note is here rather than buried.** A consumer who declared no
66
+ compat source stops receiving `.claude/rules/*.md`. It is released as a minor because it aligns one
67
+ surface with the four that already fail closed, because the loss is announced at runtime by the
68
+ undeclared-source warning (which now names rules alongside the others), and because there is an
69
+ explicit way back: `compatSources: ["claude-code"]`, or `{ kind: "claude-code", import: ["context"] }`
70
+ for that surface alone. A reader who weighs the removal differently should say so before the cut.
71
+
72
+ **Release ordering, measured rather than assumed.** This gate must ship AFTER its consumers have a
73
+ name to grant. `@theokit/agents@13.4.0` — the version `TheoCode` resolves today — declares
74
+ `CompatSurface = 'commands' | 'hooks' | 'plugins' | 'skills' | 'subagents'`, with no `context`, and
75
+ `TheoCode` passes exactly that list as its narrowed `import`. Cutting this release first would take
76
+ `.claude/rules` from it silently, with no word it could write to ask for them back. The order is:
77
+ `@theokit/agents` publishes the vocabulary, consumers declare `context`, then this.
78
+
79
+ **What is deliberately NOT gated**, because an undocumented gap reads as an oversight:
80
+ `AGENTS.md`, `GEMINI.md` and `.cursor/rules/*.mdc` are every bit as foreign, and `adaptersFor`
81
+ registers no adapter for any of them — so `compatSources` has no spelling that admits one, and
82
+ gating them would strand three formats with no way to restore them. `CLAUDE.md` is left ungated by
83
+ judgement rather than by limit: the grant gates the foreign ROOT, that file sits at the repository
84
+ root beside the other three, and projects with no `.claude/` at all use it as a generic
85
+ agent-instructions file. Whether a repo-root instruction file should require an opt-in is a product
86
+ decision affecting every consumer, not a bug fix.
87
+
88
+ - [#661](https://github.com/usetheokit/theokit-sdk/pull/661) [`f94d92c`](https://github.com/usetheokit/theokit-sdk/commit/f94d92c396c20c6f7ebeb7572894eb8c9cbb8b0a) Thanks [@usetheodev](https://github.com/usetheodev)! - `AGENTS.local.md`, `CLAUDE.local.md` and `THEO.local.md` are discovered, and composed last.
89
+
90
+ The gitignored companion is where an operator keeps the standing corrections too personal or too
91
+ situational to commit. Nothing discovered it. Measured 2026-09-12: a grep for the four `.local`
92
+ spellings returned **0 files** across this package's source, against a control of 23 for
93
+ `CLAUDE.md`. The failure is the silent kind — the file exists, it is named the documented way,
94
+ nothing loads it, and nothing complains, so the agent behaves exactly as it would if the operator
95
+ had written nothing.
96
+
97
+ **Order, and the cost of it, stated rather than discovered later.** The three specs sit above every
98
+ public one (priorities 70/75/80) because a correction has to be composed after the rule it corrects,
99
+ and they keep the public chain's relative order among themselves so both halves read the same way.
100
+ `applyAggregateCap` fills the budget in ascending priority, so the highest numbers are the first
101
+ dropped when the total cap is reached — placing the private chain last therefore makes it the first
102
+ to go under pressure. The alternative, a low number to protect it, would compose the operator's
103
+ refinement *before* the general rule and invert its meaning, which is the defect this closes. The
104
+ table already accepts that trade: `.theokit/THEO.md`, the most specific public file, sits at 60 and
105
+ is equally droppable.
106
+
107
+ **Three and not six.** A private companion pairs with a public file this seam reads, and the
108
+ documented convention is THEO / AGENTS / CLAUDE. `GEMINI.local.md` and a private `.cursor/rules` are
109
+ not part of it, and inventing them would publish a convention nobody writes.
110
+
111
+ **Ungated**, like the repo-root files beside them. `CLAUDE.local.md` sits at the repository root
112
+ rather than inside `.claude/`, so it follows `CLAUDE.md` and not `claude-rules` — the grant added in
113
+ [#652](https://github.com/usetheokit/theokit-sdk/issues/652) gates the foreign *root*, not the files beside it.
114
+
115
+ The chains stay independent: a private file never replaces its public sibling. One falling back to
116
+ the other is the trap, where adding a `THEO.md` would silently orphan an existing `AGENTS.local.md`.
117
+
118
+ - [#659](https://github.com/usetheokit/theokit-sdk/pull/659) [`0aca2bf`](https://github.com/usetheokit/theokit-sdk/commit/0aca2bfd8bca0aa57f9929f0121c1bdf2159fc92) Thanks [@usetheodev](https://github.com/usetheodev)! - An organisation can impose policy on an agent. `managed-settings.json` is read, and the project cannot switch it off.
119
+
120
+ Measured: `grep -rl managed-settings` returned 0 here, 0 in the 4.52.1 dist and 0 in the 5.5.0 dist,
121
+ against a control of 31 files for `hooks`. Claude Code defines the file as settings a user "cannot
122
+ override, except for limited exceptions". An organisation that deployed one got it dropped in
123
+ silence, while the same file was enforced by the tool it was written for.
124
+
125
+ **This was not an incomplete feature — it was an ignored security control**, and the failure
126
+ direction is permit.
127
+
128
+ The decision behind it is recorded in `packages/agents/README.md` § "Who decides policy": an
129
+ operator who did not write the code CAN impose policy on it. Hooks, MCP servers, permissions and
130
+ skill execution were each a value the *programmer* passed at build time — defensible for a framework,
131
+ indefensible for anything an organisation deploys, because the person answerable for what an agent
132
+ may do on a machine had no way to say so.
133
+
134
+ Precedence, highest first: `managed-settings.json` → the project's `settings.json` →
135
+ `defineAgent({ … })`.
136
+
137
+ **The first control lifted is `disableAllHooks`**, because its absence is the hardest to notice: a
138
+ hook that does not run looks identical to a hook that ran and approved. It is a **veto, not a
139
+ merge** — a project file setting `disableAllHooks: false` loses, since a tier the layer below can
140
+ switch off is not a tier. It is also checked before `settingSourcesIncludeProject`, which is the
141
+ programmer choosing whether to read the project's files at all.
142
+
143
+ **Unknown keys are reported, never carried.** An organisation writing `forceModel` into the policy
144
+ and getting silence would conclude the model is forced; carrying the key through would spread that
145
+ belief downstream. The reports go through `diagFailure`, not `diag` — `diag` returns immediately when
146
+ no sink is installed, and most consumers never install one, so a policy channel using it would be
147
+ silent by default about the one thing this tier exists to make certain.
148
+
149
+ `readManagedSettings` and `ManagedSettings` cross the barrel so a HOST can read the policy the
150
+ runtime enforces instead of guessing at it. This is not the only reader: `@theokit/agents` ships from
151
+ a separate repository against a *published* version of this package, so it carries its own reader of
152
+ the same file. One FORMAT is the contract; two readers that release independently is a consequence of
153
+ the repository boundary.
154
+
155
+ Platform paths are Claude Code's own (`/etc/claude-code/`, `/Library/Application Support/ClaudeCode/`,
156
+ `%PROGRAMDATA%\ClaudeCode\`), so an organisation that already deployed a policy does not have to
157
+ deploy a second copy under a different name.
158
+
159
+ `permissionMode` and `permissions` join `disableAllHooks` and `disableSkillShellExecution` as keys an
160
+ operator may impose. `plan` is the one the posture key exists for — an explore-only run where edits
161
+ are structurally refused; a plan-mode *tool* already existed and it is something the model may call,
162
+ and the difference is who decides. A posture outside the four is reported and ignored: `"readonly"`
163
+ is what somebody writes when they mean `plan`, and applying it by shape would enforce a posture
164
+ nobody defined while dropping it silently would leave them believing edits are refused.
165
+
166
+ - [#659](https://github.com/usetheokit/theokit-sdk/pull/659) [`0aca2bf`](https://github.com/usetheokit/theokit-sdk/commit/0aca2bfd8bca0aa57f9929f0121c1bdf2159fc92) Thanks [@usetheodev](https://github.com/usetheodev)! - A permission policy can be data an operator ships, reviews and diffs. It could only be a compiled-in function.
167
+
168
+ Measured: `allowedTools` and `disallowedTools` returned 0 files in the agents layer, and the closest
169
+ facility was `CommandPolicy = (command: string) => string | null` — a code predicate. The spec's rule
170
+ language had no counterpart: `Bash(` 0/0, `domain:` 1/0 and that one in prose.
171
+
172
+ The consequence is not a missing convenience. Every policy was a function, so it could not be
173
+ audited, diffed, reviewed in a pull request, or varied per environment — and `Read(./.env)`, the
174
+ spec's own paste-ready secret-exclusion example, could not be expressed at all.
175
+
176
+ **The grammar is decided as a whole**, one shape with a self-describing specifier:
177
+
178
+ | Written | Matches |
179
+ |---|---|
180
+ | `Bash` | every call to `Bash` |
181
+ | `Bash(npm run test:*)` | the argument starts with `npm run test:` |
182
+ | `Bash(npm audit)` | the argument equals `npm audit` — not a prefix |
183
+ | `Read(path:./.env)` | the argument, read as a path, matches the glob |
184
+ | `WebFetch(domain:example.com)` | the argument's HOST equals `example.com` |
185
+
186
+ The specifier says how to read itself. Per-tool magic — knowing that `Read` means a path — cannot
187
+ work where the consumer brings their own tools: a rule naming a tool this SDK has never heard of must
188
+ still be readable.
189
+
190
+ **A domain matches the HOST, never a substring.** `example.com.evil.test` is a different host, and a
191
+ substring match here would be an open redirect in policy form.
192
+
193
+ **A path glob is anchored at both ends.** Unanchored fails in both directions — a file outside the
194
+ protected tree matches because the pattern appears in its path, and a file inside escapes by having
195
+ anything appended. It is built from the literal with every other metacharacter escaped, so a policy
196
+ line cannot smuggle a regular expression into the matcher.
197
+
198
+ **Deny is emitted first**, then `ask`, then `allow`. The engine is first-match, so emission order *is*
199
+ precedence — and a narrow deny must survive a broad allow, or `Read` plus `Read(path:./.env)` would
200
+ read the secret the second line exists to protect.
201
+
202
+ **The engine is untouched.** A specifier becomes one rule per conventional argument name
203
+ (`command`, `file_path`, `path`, `url`, `query`) rather than a matcher that inspects the whole call:
204
+ `ArgMatcher` receives a single value, and `#argsMatch` fails a matcher whose argument is absent — an
205
+ invariant with its own history ([#367](https://github.com/usetheokit/theokit-sdk/issues/367), where a predicate invoked with `undefined` widened an allow
206
+ rule written to narrow). Widening that to add a grammar would trade a tested invariant for a parser.
207
+
208
+ **The limit is stated, not guessed around.** A tool whose argument is named something else cannot be
209
+ narrowed by specifier. "Read whichever argument is the only string" was the alternative and is worse:
210
+ a rule would match an argument the operator never named, widening an allow rule exactly as often as
211
+ it narrows a deny one. A bare tool name always works and matches every call.
212
+
213
+ A rule this grammar cannot read is **refused**, not dropped. A policy line an operator wrote and the
214
+ runtime silently ignored is the belief-in-an-absent-protection this tier exists to remove.
215
+
216
+ **B-038 is decided by this change rather than inherited by it.** The engine is first-match over an
217
+ array while the format groups by category, so a ported file listing `allow` above `deny` for the same
218
+ tool would silently invert and the narrower deny would never be reached. The decision is that the
219
+ LOADER reorders, not that the engine gains category evaluation: the array semantics are what every
220
+ existing consumer already built rules against, and changing how it walks them would move ground under
221
+ code nobody asked to change. A fixture writing `allow` first pins it.
222
+
223
+ **A rule written in the documented MCP spelling now matches.** The reference names an MCP tool
224
+ `mcp__server__tool` with a double underscore; this runtime names the same tool
225
+ `mcp_server_tool`, single, because the name is sanitised for the provider. Every permission rule an
226
+ operator copied from the documentation missed its target **silently** — the deny read as configured
227
+ and the tool ran.
228
+
229
+ Normalised in the RULE, never in the runtime name: the runtime spelling is what the model sees and
230
+ what the provider validates, and changing it would break every rule already written against it and
231
+ every consumer matching it, to fix a mismatch that costs one substitution at parse time. Scoped to
232
+ the `mcp__` prefix rather than rewriting every double underscore, because a tool outside MCP is
233
+ entitled to one in its own name.
234
+
235
+ - [#659](https://github.com/usetheokit/theokit-sdk/pull/659) [`ddb9306`](https://github.com/usetheokit/theokit-sdk/commit/ddb930614c55e1aab2f970e148c8c7be46c8f104) Thanks [@usetheodev](https://github.com/usetheodev)! - The protected-path and critical-path floors now fire from the runtime, before the permission engine
236
+ and before the consumer's `canUseTool` gate.
237
+
238
+ They did not. `permission-floors.ts` was imported by `src/index.ts` and by nothing else, so a tier
239
+ whose own documentation says it sits "above every permission rule and every mode" was consulted only
240
+ if a consumer remembered to call `permissionFloorReason` themselves. A guarantee that depends on
241
+ being remembered is as strong as the memory, which is the failure this slice was written to remove.
242
+
243
+ `permission-plugin.ts` — the `pre_tool_call` decision point — now asks the floor FIRST. An `allow`
244
+ rule, an allowing gate and `permissionMode: "bypassPermissions"` together no longer reach a write
245
+ into `.claude/` or `.theokit/`, nor a destructive operation on a critical root. `bypassPermissions`
246
+ not reaching the floor is deliberate: a mode that skips it is a mode that can rewrite the policy
247
+ meant to bound it.
248
+
249
+ The `@theokit/sdk` bundle budget moves 27000 → 28000 gzipped as a direct consequence: the runtime
250
+ now carries a security tier it did not carry before (27338, 98% of the new ceiling). The headroom is
251
+ 662 bytes, so unintended growth still trips the gate.
252
+
253
+ ### Patch Changes
254
+
255
+ - [#659](https://github.com/usetheokit/theokit-sdk/pull/659) [`4f0d74a`](https://github.com/usetheokit/theokit-sdk/commit/4f0d74a1056c3ed1d1fa1621d0dae06da95eb46e) Thanks [@usetheodev](https://github.com/usetheodev)! - Block-style YAML lists in frontmatter now parse instead of vanishing.
256
+
257
+ `parseSimpleYaml` is line-oriented: it splits each line on the first `:` and coerces what follows.
258
+ A continuation line `- item` has no colon, so it was skipped outright, while the key line above it
259
+ had an empty value and coerced to `undefined`. The key disappeared entirely — a reader saw
260
+ `paths:` on disk, found no behaviour, and had nothing to grep for.
261
+
262
+ Two things made this worth fixing rather than documenting:
263
+
264
+ - **The file's own docblock recommended the shape it could not read.** Listing the inline form's
265
+ comma limitation, it advised "Use multi-line lists or reword if you need this." Multi-line lists
266
+ were the one shape this parser did not support.
267
+ - **Two parsers in this package disagreed about the same frontmatter.** The sibling
268
+ `context-yaml-lite.ts` already reads block lists, and its comment records that adding them was a
269
+ repair rather than a feature. Which loader read a file decided whether its list existed.
270
+
271
+ Blank and `#` lines do not end a list; the first other line does, and the caller resumes there — so
272
+ the key written after a block list is no longer swallowed by it. A bare `key:` with no items under
273
+ it still yields `undefined`, which is what a caller's Zod default relies on.
274
+
275
+ The collection and the key/value split moved into `collectBlockList` and `splitEntry`. That is not
276
+ tidying: inlining the collection put `parseSimpleYaml` at a cognitive complexity of 24 against the
277
+ repository's limit of 10, measured on the same file path where the pre-change version raised no
278
+ such diagnostic.
279
+
280
+ - [#659](https://github.com/usetheokit/theokit-sdk/pull/659) [`4f0d74a`](https://github.com/usetheokit/theokit-sdk/commit/4f0d74a1056c3ed1d1fa1621d0dae06da95eb46e) Thanks [@usetheodev](https://github.com/usetheodev)! - A subagent frontmatter field that belongs to Claude Code now says so, and names its siblings.
281
+
282
+ **The failure itself is unchanged, deliberately.** A file that declares frontmatter and gets it
283
+ wrong is a broken agent and still fails the load — the reason is recorded a few lines above, where
284
+ skipping was added only for files with _no_ frontmatter: "a file that HAS frontmatter and gets it
285
+ wrong is a broken agent and still fails loudly, which is what keeps a typo'd `sandbox` from
286
+ returning as a silent gate through this door." Isolating the failure per file would hand that risk
287
+ back.
288
+
289
+ What changes is the diagnosis. A user migrating a `.claude/agents/` tree learned one key per round
290
+ trip: fix `memory`, meet `permissionMode`, fix that, meet `maxTurns` — with nothing saying the set
291
+ was finite or that the tree was simply written for another runtime. The error now distinguishes
292
+ "another runtime's field" from "never heard of this", and lists the other eleven that will behave
293
+ the same way.
294
+
295
+ That distinction already exists here for `INERT_CLAUDE_CODE_FIELDS`, described as "the difference
296
+ between 'we know this one and it does nothing' and 'we have never heard of this' — two facts a bare
297
+ allow-everything would collapse into one." This adds a third fact beside them and changes only the
298
+ message, never the verdict.
299
+
300
+ - [#659](https://github.com/usetheokit/theokit-sdk/pull/659) [`820fc9c`](https://github.com/usetheokit/theokit-sdk/commit/820fc9c74e76947f8ef1204bf9fab6e5c1367cb9) Thanks [@usetheodev](https://github.com/usetheodev)! - A hook field this runtime does not implement is now refused instead of dropped in silence.
301
+
302
+ `parseClaudeCodeCommand` read `type`, `command` and `timeout` and discarded everything else on the
303
+ entry — `if`, `args`, `statusMessage`, `once`, `async`, `asyncRewake`, `shell` — with no error and
304
+ no warning.
305
+
306
+ **`if` is the field that makes this a defect rather than a missing feature.** For the others the
307
+ loss is a convenience. For `if` it is the opposite of what the operator wrote: a deny hook narrowed
308
+ to one dangerous command shape silently becomes a deny hook over *every* call of that tool. The
309
+ guard still runs, so nothing looks broken; it simply applies where it was told not to.
310
+
311
+ The fix is refusal, not implementation. Implementing `if` means adopting a condition language whose
312
+ semantics nobody here has decided; refusing the field costs one throw and cannot be wrong about what
313
+ the operator meant. The direction is what matters — a dropped `if` fails open, a refused `if` fails
314
+ closed and names the field that stopped the load.
315
+
316
+ `packages/agents`, reading the same file one layer up, already took this side: its `hookSpecSchema`
317
+ is `.strict()` and refuses an unknown key loudly. Two layers disagreed about whether a field was an
318
+ error, and the permissive one was the layer that actually ran the hook.
319
+
320
+ The error separates "a Claude Code hook field this runtime does not implement" from "never heard of
321
+ this", and lists the siblings that will behave the same way — so an operator migrating a `.claude/`
322
+ tree does not learn one key per round trip. A non-command `type` still fails for its own reason: the
323
+ new check runs after the existing ones.
324
+
325
+ - [#659](https://github.com/usetheokit/theokit-sdk/pull/659) [`4f0d74a`](https://github.com/usetheokit/theokit-sdk/commit/4f0d74a1056c3ed1d1fa1621d0dae06da95eb46e) Thanks [@usetheodev](https://github.com/usetheodev)! - `skill_read` can read a skill discovered on disk. It could not, which made the entire on-disk skills surface decorative.
326
+
327
+ `SkillReadTool.create` took `ReadonlyArray<InlineSkill>` — the shape that carries `instructions` on
328
+ the object. A disk-discovered skill is a `Skill`, whose own declaration says "the skill BODY is never
329
+ included", so it did not fit through the door at all. The result: a `SKILL.md` was listed by name and
330
+ description in the `<skills>` block, the model asked to read it, and got back a heading and an empty
331
+ body — measured, the handler returned `"# Skill: deploy\n\n"`.
332
+
333
+ The instructions under the frontmatter are the skill. Every other skill defect in this backlog is a
334
+ field inside a file whose body never showed up.
335
+
336
+ `create` now accepts `ReadableSkill` — a skill that carries its body, or one that knows where its
337
+ body is — and the handler resolves the difference. Passing inline skills is unchanged.
338
+
339
+ **Read when the model asks, not at construction.** Eager reading would turn "this agent knows about
340
+ twelve skills" into twelve file reads at startup, to answer a question the model usually does not
341
+ ask. The handler's contract already allowed `Promise<string>`, so laziness cost one `await` and no
342
+ new API.
343
+
344
+ The handler is deliberately NOT `async`. Marking the whole function async turns the input schema's
345
+ synchronous throw into a rejected promise, and this module's contract is that malformed input "fails
346
+ at the trust boundary via the schema". Measured while making this change: two trust-boundary tests
347
+ went from throwing to returning `undefined`. Only the body read is asynchronous — parse and the
348
+ not-found answer stay exactly as synchronous as they were.
349
+
350
+ A disk skill's `references/` directory is now read too, for the same reason inline skills already
351
+ render theirs in full: the two shapes describe the same thing, and one of them arriving empty was the
352
+ asymmetry. A document that cannot be read is skipped rather than failing the whole read — a skill
353
+ should not become unreadable because something beside it is.
354
+
355
+ `${CLAUDE_SKILL_DIR}` resolves to the skill's own directory — the reason skills are directories
356
+ rather than single files. A skill ships scripts and reference documents beside its `SKILL.md`, and
357
+ without the placeholder the body had no expressible path to them: the skill does not know where it
358
+ was installed, and neither does its author at the time of writing.
359
+
360
+ Only for a skill read from disk. An inline skill has no directory, so the text is left exactly as
361
+ written — an honest limit rather than a guess. Leaving it says "this does not apply here"; inventing
362
+ a path would hand the model a command that fails somewhere plausible.
363
+
364
+ - [#659](https://github.com/usetheokit/theokit-sdk/pull/659) [`820fc9c`](https://github.com/usetheokit/theokit-sdk/commit/820fc9c74e76947f8ef1204bf9fab6e5c1367cb9) Thanks [@usetheodev](https://github.com/usetheodev)! - A hook emitting the **documented** deny shape is now honoured instead of being read as `allow`.
365
+
366
+ `hooks-source.ts` advertises a config shape "identical to Claude Code's `settings.json` hooks", so a
367
+ consumer writes the guard that documentation specifies:
368
+
369
+ ```json
370
+ {
371
+ "hookSpecificOutput": {
372
+ "hookEventName": "PreToolUse",
373
+ "permissionDecision": "deny",
374
+ "permissionDecisionReason": "…"
375
+ }
376
+ }
377
+ ```
378
+
379
+ `parseDecisionFromStdout` read only a **top-level** `decision` and accepted only
380
+ `deny` / `feedback` / `allow`. The nested shape has no top-level `decision` at all, so it fell past
381
+ every branch to the final `return { decision: "allow" }`. The JSON parsed, nothing warned, and the
382
+ tool call proceeded.
383
+
384
+ Measured against four inputs before the fix: the documented shape → allow; the deprecated-but-
385
+ documented `{"decision":"block"}` → allow; `Stop` with `block` → allow; only this runtime's own
386
+ `{"decision":"deny"}` denied.
387
+
388
+ Three spellings mean deny and all three are now read: the nested `permissionDecision`, the
389
+ deprecated `block`, and the native `deny`. A nested `ask` projects to deny — collapsing it to allow
390
+ would be the same fail-open one value over, since this runtime has no third state to put the
391
+ question to.
392
+
393
+ **Deliberately unchanged**: an unrecognised shape still resolves to `allow`. Making it deny would
394
+ refuse every hook that prints diagnostics and happens to emit JSON — a behaviour change with its own
395
+ blast radius, and its own measurement. The three documented denials are unambiguous; that case is
396
+ not.
397
+
398
+ The direction is what made this expensive. A missing hook event is discoverable: the user sees
399
+ nothing happen and investigates. A veto that silently does not fire is indistinguishable from a veto
400
+ that fired and approved.
401
+
402
+ - [#659](https://github.com/usetheokit/theokit-sdk/pull/659) [`820fc9c`](https://github.com/usetheokit/theokit-sdk/commit/820fc9c74e76947f8ef1204bf9fab6e5c1367cb9) Thanks [@usetheodev](https://github.com/usetheodev)! - A hook event the runtime does not fire is now reported even when the host installed no diagnostics
403
+ sink.
404
+
405
+ The loader maps four Claude Code event names and skips the rest. Skipping is honest — the runtime
406
+ genuinely does not fire the others — but the notice went through `warnOnce` → `diag`, and `diag` is
407
+ silent by default. So an operator declaring a `PreCompact` guard got nothing: no hook, no message,
408
+ and no way to learn either.
409
+
410
+ `diag`'s silence is right for chatter: a library must not assume the host's stderr is a free-form
411
+ log, because in a TUI it is the render surface. A configuration the operator **wrote** and this
412
+ runtime will not honour is not chatter. `warnFailureOnce` routes it through `diagFailure`, the
413
+ channel that already exists for exactly this and whose docblock records the precedent — `[#189](https://github.com/usetheokit/theokit-sdk/issues/189)`,
414
+ where an MCP server failed to start, the only report went to `diag()`, the embedding UI never read
415
+ it, and "the user saw an agent with missing tools and no reason given".
416
+
417
+ A dropped hook is that shape with a sharper edge, because the missing thing is a guard: the operator
418
+ declared a refusal, it silently does not exist, and nothing distinguishes that from a refusal that
419
+ ran and approved.
420
+
421
+ A sink still takes precedence when one is installed — this only changes what happens when none is.
422
+
423
+ **A note for whoever writes the next test here.** `vitest.setup.ts` installs a stderr-forwarding
424
+ sink for the duration of every test, so the default path is the one shape this suite never
425
+ exercises. A test written the obvious way passes before the fix and proves nothing; the one added
426
+ here removes the sink in `beforeEach` to stand in for a consumer that never called
427
+ `setDiagnosticsSink`.
428
+
429
+ - [#659](https://github.com/usetheokit/theokit-sdk/pull/659) [`820fc9c`](https://github.com/usetheokit/theokit-sdk/commit/820fc9c74e76947f8ef1204bf9fab6e5c1367cb9) Thanks [@usetheodev](https://github.com/usetheodev)! - A native hook can locate its own project, and a ported hook script receives the field names it was written against.
430
+
431
+ **The project directory, in both dialects.** `CLAUDE_PROJECT_DIR` was supplied to commands imported
432
+ from Claude Code ([#522](https://github.com/usetheokit/theokit-sdk/issues/522), after a hook written the documented way expanded to a leading `/` and denied
433
+ every turn). The native dialect got `{}`, on the reasoning that a `.theokit/` hook "is written
434
+ against THIS runtime and inherits it already" — true of the runtime's *behaviour*, not of a project
435
+ *path*. Nothing in the inherited environment says where the project is, so a native hook had to
436
+ depend on the process cwd: the exact dependency the foreign fix removed. `THEOKIT_PROJECT_DIR` is
437
+ the native counterpart, under the native spelling, because a ported script reaches for the name its
438
+ own docs use.
439
+
440
+ **The stdin payload.** It carried this runtime's field names only. A script ported from Claude Code
441
+ reads `tool_name`, `tool_input`, `tool_response`, `hook_event_name` and `cwd` — it got `undefined`
442
+ for every one, and **ran**, deciding on nothing while looking like a working guard. That is worse
443
+ than a script that fails: the operator's evidence that the guard works is identical either way.
444
+
445
+ The documented names are added **beside** the existing ones, never instead. Both dialects execute
446
+ through one path, so renaming would break every native script to fix the ported ones.
447
+
448
+ Only what this runtime knows. `session_id`, `transcript_path`, `permission_mode` and `prompt_id`
449
+ stay absent because their values would have to be invented — a script that branches on an invented
450
+ session id branches on a lie. Same trade as `CLAUDE_PLUGIN_ROOT`, which stays unset for the same
451
+ reason one module over.
452
+
453
+ - [#659](https://github.com/usetheokit/theokit-sdk/pull/659) [`4f0d74a`](https://github.com/usetheokit/theokit-sdk/commit/4f0d74a1056c3ed1d1fa1621d0dae06da95eb46e) Thanks [@usetheodev](https://github.com/usetheodev)! - Skill frontmatter's two authorization fields are read instead of discarded, and one of them is now enforced.
454
+
455
+ `buildFrontmatter` kept exactly `name`, `description`, `category` and `dependencies`. `user-invocable`
456
+ and `disable-model-invocation` survived the YAML parser and were dropped one function later, so a
457
+ template could declare a restriction the runtime could not honour — and the default is disclose-all.
458
+ `create-theokit` ships seven skills carrying `user-invocable: false`, and all seven were inert.
459
+
460
+ **`disable-model-invocation: true` is now enforced.** A skill reaches the model through exactly one
461
+ place — the system-prompt context — so the declaration is a filter there. This is the capability the
462
+ format names when it says you do not want the model deciding to deploy because the code looks ready:
463
+ a side-effecting skill a human may run and the model may not propose. It is a **disclosure** rule,
464
+ not an execution rule — `skills.get(name)` still resolves a hidden skill, because a caller naming one
465
+ has already made the decision the field exists to keep away from the model. Widening it to execution
466
+ would break the case the field is for.
467
+
468
+ **`user-invocable: false` is carried, deliberately not enforced here.** This SDK has no user-facing
469
+ invocation surface for skills; there is no slash command. Reading `agent.skills.list()` as "the user"
470
+ would be a guess — a host may call it to build a picker or to introspect, and those want opposite
471
+ answers. The declaration now travels to the host that knows, instead of being thrown away.
472
+
473
+ A value the dialect cannot read is refused rather than ignored. This dialect coerces only the
474
+ literals `true` and `false`, so `disable-model-invocation: yes` — a valid YAML boolean — arrived as
475
+ the string `"yes"`, compared unequal to `true`, and the skill was disclosed. The author wrote a
476
+ restriction and got the default. A restriction that fails open is worse than an absent one, because
477
+ the author stops looking; the skill is now reported as invalid and excluded, naming the value.
478
+
479
+ - [#659](https://github.com/usetheokit/theokit-sdk/pull/659) [`429ddff`](https://github.com/usetheokit/theokit-sdk/commit/429ddff2bcfff7ab750257e6dd13de9dd91eb0a8) Thanks [@usetheodev](https://github.com/usetheodev)! - Fixes a ReDoS in the critical-path floor's destructive-command matcher (CodeQL: polynomial regular
480
+ expression on uncontrolled data).
481
+
482
+ ```
483
+ /(?:^|[;&|]\s*)\s*(rm|rmdir|shred|mkfs\S*|dd)\s/
484
+ ^^^ ^^^ adjacent quantifiers over overlapping classes
485
+ and an unbounded \S* inside the alternation
486
+ ```
487
+
488
+ Measured on the inputs CodeQL named: 60 000 spaces after a `;` took **2 094ms**, and 12 000 `&mkfs`
489
+ repetitions took **600ms** — clean quadratic growth. After the rewrite, 0.36ms and 0.33ms.
490
+
491
+ It mattered because of the commit beside it. While nothing called the floor, a slow matcher was a
492
+ latent cost; wiring the floor into `permission-plugin.ts` put it in front of **every tool call**, so
493
+ one crafted argument stalls the agent instead of being refused by it. Making a guard reachable and
494
+ making its cost matter are the same act.
495
+
496
+ The separator no longer consumes whitespace the following `\s*` already consumes, and the `mkfs`
497
+ suffix is a bounded dotted variant (`mkfs.ext4`, `mkfs.xfs`, `mkfs.btrfs`) rather than "any run of
498
+ non-space". Verified identical across thirteen cases spanning every branch.
499
+
500
+ Also documented, because measuring it turned it up: a DEVICE NODE is not a critical path.
501
+ `mkfs.ext4 /dev/sda` and `dd of=/dev/sda` pass this floor — the tier is about the working directory,
502
+ the home directory and the filesystem root, and `/dev/sda` is none of them. A reader who sees `mkfs`
503
+ in the pattern reasonably concludes otherwise, so a test now pins the real behaviour.
504
+
505
+ - [#659](https://github.com/usetheokit/theokit-sdk/pull/659) [`4f0d74a`](https://github.com/usetheokit/theokit-sdk/commit/4f0d74a1056c3ed1d1fa1621d0dae06da95eb46e) Thanks [@usetheodev](https://github.com/usetheodev)! - The order nested instruction files reach the prompt is pinned, and the one real divergence from the spec is stated.
506
+
507
+ A parity survey reported three defects here. Re-measured by execution, two did not hold:
508
+
509
+ - **`.claude/rules/*.md` is read** — spec `claude-rules`, priority 47. A probe through `runDiscovery`
510
+ over a temp project returned one source carrying the rule. The original zero-file grep was against
511
+ a layer that does not do the reading.
512
+ - **Precedence is not reversed.** `walkUpForFile` returns nearest-first, and that is not what the
513
+ model sees: `applyAggregateCap` re-sorts by priority and then by absolute path, so a three-level
514
+ project measured `walk=[DEEP,SUB,ROOT]` and `prompt=[ROOT,SUB,DEEP]` — the spec's root-down order,
515
+ where the nearer file refines the wider one instead of being buried under it.
516
+
517
+ **The correct behaviour was correct by accident**, which is why this changeset exists. Root-down falls
518
+ out of a tie-break written for prompt-cache determinism (EC-J), and nothing stated it. Change the
519
+ tie-break, or name a subdirectory lexically smaller than its parent, and a repository-wide
520
+ instruction starts being read after the nested one meant to refine it — with nothing to catch it. It
521
+ is now pinned by a test that also mutation-checks the inverse.
522
+
523
+ **The divergence that is real:** the walk stops at the git root, while the spec continues to every
524
+ directory above cwd. Kept, with the reason — a `CLAUDE.md` in a home directory or in `/tmp` would
525
+ silently apply to every repository underneath it, and an instruction file nobody in the project wrote
526
+ is the one case where finding more is worse than finding less. The operator-home question stays a
527
+ deliberate, separate decision rather than a side effect of how far a loop runs.
528
+
529
+ - [#659](https://github.com/usetheokit/theokit-sdk/pull/659) [`3ae3766`](https://github.com/usetheokit/theokit-sdk/commit/3ae37660a5a012968115e3e611155cf74397ec23) Thanks [@usetheodev](https://github.com/usetheodev)! - States, next to the write floor that does exist, that there is no read-side equivalent — and pins it
530
+ with a test so the claim and the behaviour cannot drift apart.
531
+
532
+ `permissionFloorReason` refuses a WRITE into `.claude/`, `.theokit/`, `.ssh` or `.gnupg` under every
533
+ allow rule. A READ of the same paths is refused by nothing: reads are governed by the rule language
534
+ and by nothing above it. `Read(path:./.env)` works and deny-before-allow means a deny cannot be
535
+ overtaken — but every such rule has to be written, nothing is refused by default, and a bare `Read`
536
+ allow grants reading any path the process can open.
537
+
538
+ The asymmetry was invisible from the module. `PROTECTED_SEGMENTS` reads like a list of protected
539
+ paths and is only half that: `.ssh` cannot be written through any rule, and can be read through an
540
+ ordinary allow.
541
+
542
+ `additionalDirectories` — the reference's key for an operator to WIDEN what an agent may read — has
543
+ no equivalent, and cannot have one while there is no fence to widen. Measured: zero occurrences in
544
+ this package and zero in `@theokit/agents`.
545
+
546
+ No fence was added, and not because one is unwanted. `permissionFloorReason` sees a tool name and an
547
+ argument map; a fence matching on those alone would miss every read that reaches the filesystem
548
+ another way — a shell command, a plugin, an MCP server. Shell reads are already confined by
549
+ `SandboxMode`, and a second containment vocabulary here would leave two answers to "may this be
550
+ read" that disagree at the edges. Deciding which layer owns that boundary is a measured decision,
551
+ not a docblock.
552
+
553
+ - [#659](https://github.com/usetheokit/theokit-sdk/pull/659) [`820fc9c`](https://github.com/usetheokit/theokit-sdk/commit/820fc9c74e76947f8ef1204bf9fab6e5c1367cb9) Thanks [@usetheodev](https://github.com/usetheodev)! - The hook loader stops claiming parity it does not have, and states the set it does.
554
+
555
+ The module docblock advertised a config shape "identical to Claude Code's `settings.json` hooks". The
556
+ SHAPE is identical; the event COVERAGE is four of the thirty-three documented events. Measured by
557
+ execution — a `hooks.json` declaring all thirty-three, through the shipped loader, yielded
558
+ `PreToolUse`, `PostToolUse`, `UserPromptSubmit` and `Stop`. Thirteen of the sixteen the spec marks
559
+ "Can block? Yes" were among the missing.
560
+
561
+ **The map is deliberately not grown.** Mapping a name the runtime does not fire is strictly worse
562
+ than refusing it: an operator declaring `PreCompact` today gets a report saying it will not fire;
563
+ with the name mapped they would get silence and a guard that never runs — a declared veto that does
564
+ not exist. The map grows when the seam exists, one event at a time.
565
+
566
+ `CLAUDE_CODE_EVENT_MAP` is now exported so the supported set is stated rather than implied, and a
567
+ test derives its expectation from it: adding a seam turns that test red, which is where the docblock
568
+ claim gets updated with it. The same test lists the fourteen unwired blocking events in priority
569
+ order — an unwired veto loses a capability, an unwired observer loses a signal — so the next person
570
+ picking one up does not re-derive which is which.
571
+
572
+ `postRun` has no entry on purpose: it fires per RUN, and no documented Claude Code event means that.
573
+ `SessionEnd` is the near miss, and a session is not a run.
574
+
575
+ - [#659](https://github.com/usetheokit/theokit-sdk/pull/659) [`4f0d74a`](https://github.com/usetheokit/theokit-sdk/commit/4f0d74a1056c3ed1d1fa1621d0dae06da95eb46e) Thanks [@usetheodev](https://github.com/usetheodev)! - `MEMORY.md` says, at both ends, which of two contracts it is.
576
+
577
+ The Claude Code CLI's is a plain file under its own home, capped at 200 lines / 25 KB on read and
578
+ swept on `cleanupPeriodDays`. This SDK's is the durable-memory subsystem — a SQLite+FTS5 store under
579
+ `.theokit/memory/`, with `memory_search` / `memory_get` tools, no index cap, and a different
580
+ directory entirely. Same filename, different directory, different semantics.
581
+
582
+ **A parity survey reported `CLAUDE_CONFIG_DIR` as absent; it is not.** Measured across both packages:
583
+ it is read in `claudeProjectMemoryDir`, which resolves the CLI's memory directory for interop, keyed
584
+ by git root. The original grep ran only against `@theokit/agents`, where it is genuinely absent, and
585
+ reported it absent everywhere — the third item in this release whose evidence was measured in the
586
+ wrong place or under the wrong name.
587
+
588
+ `CLAUDE_CONFIG_DIR`'s scope is now stated: it names the CLI's home so this reader finds the right
589
+ directory, and it relocates no user-level root of this product's own, because there is none — the
590
+ config roots resolved elsewhere are project-relative. A blank value is treated as unset rather than
591
+ as a root, pinned by a test: `CLAUDE_CONFIG_DIR=""` is what an unset shell variable expands to in a
592
+ wrapper script, and reading it as a root produces `/projects/…`, which exists on no machine and fails
593
+ silently as "the CLI has no memories here".
594
+
595
+ **What remains unimplemented, and why that is a decision.** `autoMemoryEnabled`,
596
+ `autoMemoryDirectory`, `CLAUDE_CODE_DISABLE_AUTO_MEMORY`, `cleanupPeriodDays` and the read cap have
597
+ no counterpart. Interop is one-way on purpose: a memory the CLI recorded stays visible, and this
598
+ runtime does not write into a store another product owns the lifecycle of. A `cleanupPeriodDays`
599
+ implemented here would delete files the CLI expects to find.
600
+
601
+ The filename is kept rather than renamed — one of the two is another product's, and renaming it here
602
+ would break the interop the reader exists for.
603
+
3
604
  ## 5.5.0
4
605
 
5
606
  ### Minor Changes