kairos-chain 3.65.0 → 3.66.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0b5f8504e1a59fe2e58603f654ad32a747381897a62dba70ba436388c5be3f55
4
- data.tar.gz: c4c86ca90fb56858a45c126aca497277846225c1b127549700be1e80484c0755
3
+ metadata.gz: b16a89f65901c9eefc5b08e5c2b1503817625c35847304332316ddb01292f8da
4
+ data.tar.gz: 7724c71a90c96aa624003663b4e7a7381552a08d0afa857ca19969fdea997875
5
5
  SHA512:
6
- metadata.gz: 3535630f908f30fc87319e960207ea5552de1f29284211cf24e4802171a7699b3ebcde4ebce74875341d368004ea04ccf36211f4b68037755907a1ef77550ae6
7
- data.tar.gz: a01fbf7861eac890a2d020ce8a0606970d41a7a9f3d0f1840f1db7a19580c87f77b77af1ffebd4028c538f4535b562db78adff1c847389c2fd6a162dda5fa3f4
6
+ metadata.gz: 3dc1c2f28e0b5b515f89be1bf7eb6714c796deae4ab8382907b0248abcabefc74e2510c372c3a3fc18eb2b8c7d36accd58535d2b9528398b83e6d2b2618e3a8b
7
+ data.tar.gz: 5d33acb30a5c7510015d76c7d9d398597988e284e5420752385888e0814b19ba6332528cb701e925e82fb414f7c08bc3a3930ee990065070756701bcd07dc4ee
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.65.0"
2
+ VERSION = "3.66.0"
3
3
  CHANGELOG_URL = "https://github.com/masaomi/KairosChain_2026/blob/main/CHANGELOG.md"
4
4
  end
@@ -50,6 +50,7 @@
50
50
  "Stop": [
51
51
  {
52
52
  "gate": "readable_gate",
53
+ "_description": "Measures the turn's last message against declared readability limits — length, headings, tables, undefined shorthand, a missing diagram — and reports to its log, or blocks the turn once blocking is true.",
53
54
  "section": "§ Readable output",
54
55
 
55
56
  "_blocking_note": "false on purpose in the example. The vocabulary rule below is the one thing here that cannot be got right by reading: whether a shape is coined shorthand or an ordinary technical word depends on what you write about. Start in report-only mode, read your log for a week, then set this to true. Shipping the example with true meant a consumer who copied it as instructed was blocked on the first message mentioning a character encoding.",
@@ -20,16 +20,42 @@ under `.kairos/hook_configs/`.
20
20
  Not auto-installed (not a core SkillSet): install it by name first —
21
21
  `system_upgrade command="apply" approved=true names=["kairos_hook_projector"]`.
22
22
 
23
- 1. Copy `mode_hooks/_EXAMPLE.json` beside your mode body as
24
- `<mode>.mode_hooks.json` (e.g. `.kairos/skills/masa.mode_hooks.json`).
25
- 2. Edit `mode_name` inside it to `<mode>` filename and field must agree
26
- and replace the example numbers with your own.
27
- 3. `mode_hooks_validate` reports what the copy still needs.
23
+ 1. `mode_hooks_add mode="<mode>" gate="readable_gate"` writes
24
+ `<mode>.mode_hooks.json` beside the mode body — creating it, or appending
25
+ to it; an entry with the same gate already on the event is refused, never
26
+ overwritten. Omitting `mode` targets the active mode, and on a stock init
27
+ that is `tutorial` a gem-shipped template body carrying no
28
+ readable-output norm — so name your mode. The entry comes from the
29
+ catalogue (`mode_hooks/_EXAMPLE.json`): event, section, blocking, and
30
+ starting params. Tune the numbers in the written file afterwards, they are
31
+ the mode's own — and edit `section` to a heading your mode body actually
32
+ contains: it is copied from the catalogue, it is a claim about your own
33
+ text, and the gate quotes it verbatim in every block reason. Called
34
+ without `gate` it lists the catalogue and writes nothing. It stops at the
35
+ declaration and never touches `.claude/settings.json`.
36
+ 2. Or by hand, for someone who wants to write the numbers themselves: copy
37
+ `mode_hooks/_EXAMPLE.json` beside your mode body as
38
+ `<mode>.mode_hooks.json` (e.g. `.kairos/skills/masa.mode_hooks.json`),
39
+ then edit `mode_name` inside it to `<mode>` — filename and field must
40
+ agree — edit `section` to a heading your mode body actually contains (the
41
+ gate quotes it verbatim in every block reason), and replace the example
42
+ numbers with your own.
43
+ 3. `mode_hooks_validate` reports what the declaration still needs.
28
44
  4. `mode_hooks_project` proposes by default and writes nothing; to install,
29
45
  call again with `apply=true` and the `confirm_sha256` from the proposal.
30
46
  5. `mode_hooks_validate`, run again, answers whether the gate is installed:
31
47
  its `installed` check compares declaration and `.claude/settings.json`.
32
48
 
49
+ The catalogue is gem-shipped and is not an extension point on the instance:
50
+ `system_upgrade` overwrites `mode_hooks/_EXAMPLE.json` on any reinstall — at
51
+ the same version, with no warning, without naming the file — so an edit to
52
+ the installed copy does not survive. Adding a gate kind is a core release
53
+ act: the catalogue entry, the kind in the compiler's known-gate table (until
54
+ then every call refuses it as `unknown_gate`), and the gate implementation
55
+ itself. Your declaration lives beside the mode body, outside the SkillSet,
56
+ for the same reason — the tool refuses to write declarations inside the
57
+ SkillSet directory because an upgrade would undo them.
58
+
33
59
  ## Removing a mode's hooks
34
60
 
35
61
  Two routes, and either works: empty the declaration's `hooks` object
@@ -54,6 +80,9 @@ installed command names them; delete them by hand if you want them gone.
54
80
 
55
81
  ## Tools
56
82
 
83
+ - `mode_hooks_add` — write or extend a mode's declaration from the catalogue
84
+ (`mode_hooks/_EXAMPLE.json`); append-only, stops at the declaration, never
85
+ touches `.claude/settings.json`. Without `gate`, lists the catalogue.
57
86
  - `mode_hooks_validate` (read-only) — mode body vs declaration drift, compile
58
87
  resolvability, and declared-vs-installed divergence in both directions.
59
88
  - `mode_hooks_project` — compile and install; touches only entries it placed
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kairos_hook_projector",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Projects an instruction mode's declared hooks into the harness configuration. Stage 1 compiles a mode_hooks declaration to a hook artifact plus a compile record. Stage 2 installs it: mode_hooks_project proposes by default and writes nothing, applying only when the caller echoes back the artifact hash from the proposal, and rewriting only hook groups carrying this SkillSet's marker for that mode — hand-added hooks and other modes' hooks are counted and left alone. Ships the generic readable_gate implementation; a mode supplies its own thresholds, the core supplies none.",
5
5
  "author": "Masaomi Hatakeyama",
6
6
  "layer": "L1",
@@ -15,12 +15,14 @@
15
15
  "hooks_status_readonly",
16
16
  "mode_hooks_validation",
17
17
  "mode_hooks_projection",
18
+ "mode_hooks_declaration",
18
19
  "readable_gate"
19
20
  ],
20
21
  "tool_classes": [
21
22
  "KairosMcp::SkillSets::KairosHookProjector::Tools::HooksStatus",
22
23
  "KairosMcp::SkillSets::KairosHookProjector::Tools::ModeHooksValidate",
23
- "KairosMcp::SkillSets::KairosHookProjector::Tools::ModeHooksProject"
24
+ "KairosMcp::SkillSets::KairosHookProjector::Tools::ModeHooksProject",
25
+ "KairosMcp::SkillSets::KairosHookProjector::Tools::ModeHooksAdd"
24
26
  ],
25
27
  "config_files": [],
26
28
  "knowledge_dirs": [],
@@ -0,0 +1,541 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'minitest/autorun'
4
+ require 'json'
5
+ require 'tmpdir'
6
+ require 'fileutils'
7
+ require 'digest'
8
+
9
+ # Guarded, and it has to be. Three test files defined this stub unconditionally
10
+ # and reopened the class, so whichever loaded last decided the return shape for
11
+ # everybody. Loading all the files together made test_hooks_status fail — a
12
+ # failure invisible to a per-file run.
13
+ module KairosMcp
14
+ module Tools
15
+ class BaseTool
16
+ def initialize(safety = nil, registry: nil); end
17
+
18
+ def text_content(text)
19
+ [{ type: 'text', text: text }]
20
+ end
21
+ end
22
+ end
23
+ end unless defined?(::KairosMcp::Tools::BaseTool)
24
+
25
+ require_relative '../tools/mode_hooks_add'
26
+ require_relative '../lib/mode_hooks_compiler'
27
+
28
+ class TestModeHooksAdd < Minitest::Test
29
+ SKILLSET_ROOT = File.expand_path('..', __dir__)
30
+ COMPILER = KairosMcp::SkillSets::KairosHookProjector::ModeHooksCompiler
31
+
32
+ # A real mode body is Japanese with § headings. The read feeds the binding
33
+ # digest and the compile, and the section below lands verbatim in the
34
+ # declaration this tool rewrites on append — so these bytes make the reads
35
+ # and writes fail under a US-ASCII default locale if any encoding argument
36
+ # is dropped.
37
+ BODY = "**Version:** 0.1.0\n## § 形\n応答は 60 行以内。長くなるなら図を一枚。\n"
38
+
39
+ # Test double: the environment lookups are the impure surface. Catalogue,
40
+ # locator, compiler and the write are the real thing.
41
+ class Adder < KairosMcp::SkillSets::KairosHookProjector::Tools::ModeHooksAdd
42
+ attr_accessor :root, :catalogue_override, :skillset_root_override
43
+
44
+ private
45
+
46
+ def resolve_project_root = @root
47
+ def active_mode = 'testmode'
48
+ def mode_body_path(mode) = File.join(@root, 'skills', "#{mode}.md")
49
+ def catalogue_path = @catalogue_override || super
50
+ def skillset_root = @skillset_root_override || super
51
+ end
52
+
53
+ def with_adder(body: BODY)
54
+ Dir.mktmpdir do |dir|
55
+ a = Adder.new
56
+ a.root = dir
57
+ FileUtils.mkdir_p(File.join(dir, 'skills'))
58
+ FileUtils.mkdir_p(File.join(dir, '.claude'))
59
+ File.write(File.join(dir, 'skills', 'testmode.md'), body, encoding: 'UTF-8') if body
60
+ File.write(settings_path(dir),
61
+ JSON.generate('permissions' => { 'allow' => ['Bash(*)'] }),
62
+ encoding: 'UTF-8')
63
+ yield a, dir
64
+ end
65
+ end
66
+
67
+ def settings_path(dir)
68
+ File.join(dir, '.claude', 'settings.json')
69
+ end
70
+
71
+ def decl_path(dir, mode = 'testmode')
72
+ File.join(dir, 'skills', "#{mode}.mode_hooks.json")
73
+ end
74
+
75
+ # An existing declaration an author might have: a gate on the OTHER Stop
76
+ # event, an author note, Japanese in the section — everything an append
77
+ # must carry through untouched.
78
+ def existing_doc(event: 'SubagentStop', gate: 'readable_gate', mode_name: 'testmode',
79
+ params: { 'max_lines' => 40 }, binding: nil)
80
+ doc = {
81
+ 'mode_name' => mode_name, 'version' => '1',
82
+ '_comment' => ['the author wrote this note and it must survive'],
83
+ 'hooks' => { event => [{ 'gate' => gate, 'section' => '§ 形', 'params' => params }] }
84
+ }
85
+ doc['binding'] = binding if binding
86
+ doc
87
+ end
88
+
89
+ def write_decl(dir, doc)
90
+ File.write(decl_path(dir), JSON.pretty_generate(doc) + "\n", encoding: 'UTF-8')
91
+ end
92
+
93
+ def read_decl(dir)
94
+ JSON.parse(File.read(decl_path(dir), encoding: 'UTF-8'))
95
+ end
96
+
97
+ # The stub's return shape depends on which test file loaded first, so read
98
+ # the text out of either shape rather than depending on the winner.
99
+ def tool_text(response)
100
+ response.is_a?(Array) ? response.first[:text] : response.to_s
101
+ end
102
+
103
+ def run_tool(a, args)
104
+ JSON.parse(tool_text(a.call(args)))
105
+ end
106
+
107
+ def snapshot(dir)
108
+ Dir.glob(File.join(dir, '**/*'), File::FNM_DOTMATCH).sort.map do |f|
109
+ [f, File.file?(f) ? File.read(f, encoding: 'UTF-8') : :dir]
110
+ end
111
+ end
112
+
113
+ def underscore_keys(obj, found = [])
114
+ case obj
115
+ when Hash
116
+ found.concat(obj.keys.select { |k| k.to_s.start_with?('_') })
117
+ obj.each_value { |v| underscore_keys(v, found) }
118
+ when Array
119
+ obj.each { |v| underscore_keys(v, found) }
120
+ end
121
+ found
122
+ end
123
+
124
+ # --- the catalogue ---------------------------------------------------------
125
+
126
+ def test_the_catalogue_is_listed_when_gate_is_omitted_and_nothing_is_written
127
+ with_adder do |a, dir|
128
+ before = snapshot(dir)
129
+ out = run_tool(a, {})
130
+ assert_nil out['error'], "gate omitted must list, not error: #{out.inspect[0, 200]}"
131
+ assert_equal 'catalogue', out['action']
132
+ assert out['nothing_written']
133
+ shipped = out['gates'].find { |g| g['gate'] == 'readable_gate' }
134
+ refute_nil shipped, 'the shipped catalogue carries readable_gate'
135
+ assert_equal 'Stop', shipped['event']
136
+ # The stripped annotations took 1,746 characters of reasons with them
137
+ # and _description was empty, so the listing carried numbers with no
138
+ # meaning. The shipped entry must describe itself, in one line.
139
+ desc = shipped['description']
140
+ assert desc.is_a?(String) && !desc.strip.empty?,
141
+ "the shipped entry describes itself in the listing: #{shipped.inspect}"
142
+ refute_includes desc, "\n", 'the description is one line'
143
+ out['gates'].each do |g|
144
+ assert g['gate'] && g['event'], "every listed kind names its event: #{g.inspect}"
145
+ end
146
+ assert_equal before, snapshot(dir), 'listing the catalogue writes nothing'
147
+ end
148
+ end
149
+
150
+ def test_a_catalogue_description_is_surfaced_one_line_when_an_entry_carries_one
151
+ with_adder do |a, dir|
152
+ cat = File.join(dir, 'catalogue.json')
153
+ File.write(cat, JSON.generate(
154
+ 'mode_name' => 'example', 'version' => '1',
155
+ 'hooks' => { 'Stop' => [
156
+ { 'gate' => 'readable_gate', 'section' => '§ x', 'params' => {},
157
+ '_description' => ['one line about the gate',
158
+ 'a second line never shown'] },
159
+ { 'gate' => 'quiet_gate', 'section' => '§ y', 'params' => {} }
160
+ ] }
161
+ ), encoding: 'UTF-8')
162
+ a.catalogue_override = cat
163
+
164
+ gates = run_tool(a, {})['gates']
165
+ assert_equal 'one line about the gate',
166
+ gates.find { |g| g['gate'] == 'readable_gate' }['description']
167
+ refute gates.find { |g| g['gate'] == 'quiet_gate' }.key?('description'),
168
+ 'an entry with no description gets none invented for it'
169
+ end
170
+ end
171
+
172
+ # --- creating --------------------------------------------------------------
173
+
174
+ def test_creating_writes_the_declaration_beside_the_mode_body_and_it_compiles
175
+ with_adder do |a, dir|
176
+ out = run_tool(a, 'gate' => 'readable_gate') # mode defaults to the active one
177
+ assert_equal 'created', out['action'], out.inspect
178
+
179
+ doc = read_decl(dir)
180
+ assert_equal 'testmode', doc['mode_name']
181
+ refute doc.key?('binding'),
182
+ "create writes no binding: it is the author's opt-in (the " \
183
+ "catalogue's _binding_note), and one written unasked made the " \
184
+ 'first ordinary body edit condemn the live gate, with no tool ' \
185
+ 'route back'
186
+ entry = doc.dig('hooks', 'Stop', 0)
187
+ assert_equal 'readable_gate', entry['gate']
188
+ assert_equal 60, entry.dig('params', 'max_lines'),
189
+ "the catalogue's numbers arrive; there is no second copy of them"
190
+
191
+ compiled = COMPILER.new.compile(mode_name: 'testmode', document: doc, mode_body: BODY)
192
+ assert compiled.compiled?,
193
+ "what was written must compile: #{compiled.record['refusal'].inspect}"
194
+ assert_equal 1, compiled.record.dig('output', 'hook_count')
195
+ end
196
+ end
197
+
198
+ def test_the_result_names_the_file_the_entry_and_the_literal_next_command
199
+ with_adder do |a, dir|
200
+ out = run_tool(a, 'mode' => 'testmode', 'gate' => 'readable_gate')
201
+ assert_equal decl_path(dir), out['declaration'], 'the file written is named'
202
+ assert_equal 'Stop', out.dig('added', 'event')
203
+ assert_equal 'readable_gate', out.dig('added', 'gate')
204
+ assert_equal 'mode_hooks_project mode="testmode"', out['next_command'],
205
+ 'the caller is handed the exact next command, not a description of one'
206
+ refute out.key?('binding'),
207
+ 'the result echoes no binding, because none is written'
208
+ end
209
+ end
210
+
211
+ # The two catalogue fields a consumer must not leave as shipped, said where
212
+ # the consumer is — in the result of the call that wrote them. section is a
213
+ # claim about the mode's own text the tool cannot get right (G8), and
214
+ # blocking's shipped false carries a reason that was stripped with the
215
+ # annotations (G7).
216
+ def test_the_result_note_says_edit_section_and_why_blocking_starts_false
217
+ with_adder do |a, _dir|
218
+ out = run_tool(a, 'gate' => 'readable_gate')
219
+ assert_match(/section is copied from the catalogue/, out['note'],
220
+ 'the note must name section as needing editing')
221
+ assert_match(/heading the mode body actually contains/, out['note'],
222
+ 'the note must say what to edit it to')
223
+ assert_match(/blocking starts false on purpose/, out['note'],
224
+ "the note must carry _blocking_note's load-bearing sentence")
225
+ assert_match(/read the log for a week/, out['note'],
226
+ 'the note must say what to do before flipping blocking')
227
+ end
228
+ end
229
+
230
+ # The chain this witnesses the absence of: create wrote a binding unasked,
231
+ # one ordinary edit to the gated body made compile refuse binding_mismatch,
232
+ # check_installed collapsed to wanted = {}, and the operator's live,
233
+ # still-declared gate was relabelled stale with a remove-remedy. With no
234
+ # binding written, the declaration keeps compiling after the body moves on,
235
+ # and a second add refuses for the honest reason — the gate is already
236
+ # declared — not for a drift that never happened.
237
+ def test_a_body_edit_after_create_does_not_condemn_the_declaration
238
+ with_adder do |a, dir|
239
+ run_tool(a, 'gate' => 'readable_gate')
240
+ doc = read_decl(dir)
241
+
242
+ edited = BODY + "追記: 例は一つで足りる。\n" # an ordinary edit
243
+ File.write(File.join(dir, 'skills', 'testmode.md'), edited, encoding: 'UTF-8')
244
+
245
+ compiled = COMPILER.new.compile(mode_name: 'testmode', document: doc,
246
+ mode_body: edited)
247
+ assert compiled.compiled?,
248
+ 'a declaration this tool created must keep compiling after the ' \
249
+ "body is edited: #{compiled.record['refusal'].inspect}"
250
+
251
+ out = run_tool(a, 'gate' => 'readable_gate')
252
+ assert_equal 'gate_already_declared', out['error'],
253
+ 'after a body edit the second add refuses as a duplicate, ' \
254
+ "not binding_mismatch: #{out.inspect[0, 200]}"
255
+ end
256
+ end
257
+
258
+ def test_the_written_declaration_carries_no_annotation_keys
259
+ with_adder do |a, dir|
260
+ run_tool(a, 'gate' => 'readable_gate')
261
+ assert_empty underscore_keys(read_decl(dir)),
262
+ "the example's author notes must not be copied into a mode's " \
263
+ 'declaration — the ones inside params would reach the gate config'
264
+ end
265
+ end
266
+
267
+ # --- appending -------------------------------------------------------------
268
+
269
+ def test_appending_adds_the_entry_and_modifies_nothing_the_author_wrote
270
+ with_adder do |a, dir|
271
+ write_decl(dir, existing_doc) # readable_gate on SubagentStop, author note
272
+ before = read_decl(dir)
273
+
274
+ out = run_tool(a, 'gate' => 'readable_gate')
275
+ assert_equal 'appended', out['action'], out.inspect
276
+
277
+ after = read_decl(dir)
278
+ assert_equal before['hooks']['SubagentStop'], after['hooks']['SubagentStop'],
279
+ 'the existing entry survives unchanged, tuned numbers included'
280
+ assert_equal before['_comment'], after['_comment'],
281
+ "the author's own notes survive the rewrite"
282
+ assert_equal 1, after['hooks']['Stop'].length
283
+ assert_equal 'readable_gate', after['hooks']['Stop'][0]['gate']
284
+
285
+ compiled = COMPILER.new.compile(mode_name: 'testmode', document: after, mode_body: BODY)
286
+ assert compiled.compiled?, compiled.record['refusal'].inspect
287
+ assert_equal 2, compiled.record.dig('output', 'hook_count')
288
+ end
289
+ end
290
+
291
+ def test_a_gate_already_on_the_event_is_refused_and_tuned_thresholds_survive
292
+ with_adder do |a, dir|
293
+ run_tool(a, 'gate' => 'readable_gate')
294
+ doc = read_decl(dir)
295
+ doc['hooks']['Stop'][0]['params']['max_lines'] = 40 # the author tunes it
296
+ write_decl(dir, doc)
297
+ bytes = File.read(decl_path(dir), encoding: 'UTF-8')
298
+
299
+ out = run_tool(a, 'gate' => 'readable_gate')
300
+ assert_equal 'gate_already_declared', out['error'], out.inspect
301
+ assert out['nothing_written']
302
+ assert_equal bytes, File.read(decl_path(dir), encoding: 'UTF-8'),
303
+ 'append-only: a tuned threshold must never be overwritten'
304
+ end
305
+ end
306
+
307
+ def test_a_declaration_naming_a_different_mode_is_refused
308
+ with_adder do |a, dir|
309
+ write_decl(dir, existing_doc(mode_name: 'othermode'))
310
+ bytes = File.read(decl_path(dir), encoding: 'UTF-8')
311
+
312
+ out = run_tool(a, 'gate' => 'readable_gate')
313
+ assert_equal 'mode_name_mismatch', out['error'], out.inspect
314
+ assert_equal 'othermode', out['declared']
315
+ assert_equal 'testmode', out['requested']
316
+ assert out['nothing_written']
317
+ assert_equal bytes, File.read(decl_path(dir), encoding: 'UTF-8')
318
+ end
319
+ end
320
+
321
+ # A binding is the author's own opt-in record of which body they read; this
322
+ # tool never writes one. Where the author wrote one, two witnesses: a
323
+ # binding that compiles clean comes through exactly as written, and a
324
+ # drifted one refuses the append rather than being silently refreshed —
325
+ # refreshing it would erase the drift signal mode_hooks_validate raises.
326
+
327
+ def test_append_leaves_an_authors_binding_exactly_as_written
328
+ with_adder do |a, dir|
329
+ write_decl(dir, existing_doc(binding: { 'mode_version' => '9.9.9' }))
330
+
331
+ out = run_tool(a, 'gate' => 'readable_gate')
332
+ assert_equal 'appended', out['action'], out.inspect
333
+ assert_equal({ 'mode_version' => '9.9.9' }, read_decl(dir)['binding'],
334
+ 'no digest may be added to or refreshed in an existing binding')
335
+ end
336
+ end
337
+
338
+ def test_a_drifted_binding_refuses_the_append_rather_than_rebinding
339
+ with_adder do |a, dir|
340
+ write_decl(dir, existing_doc(
341
+ binding: { 'mode_body_sha256' => Digest::SHA256.hexdigest('yesterday') }
342
+ ))
343
+ bytes = File.read(decl_path(dir), encoding: 'UTF-8')
344
+
345
+ out = run_tool(a, 'gate' => 'readable_gate')
346
+ assert_equal 'existing_declaration_refused', out['error'], out.inspect
347
+ assert_equal 'binding_mismatch', out.dig('refusal', 'category')
348
+ assert out['nothing_written']
349
+ assert_equal bytes, File.read(decl_path(dir), encoding: 'UTF-8'),
350
+ 'silently refreshing the digest would erase the drift signal'
351
+ end
352
+ end
353
+
354
+ # --- refusals and the compile gate -----------------------------------------
355
+
356
+ def test_a_gate_the_catalogue_does_not_carry_is_an_error_naming_what_it_does
357
+ with_adder do |a, dir|
358
+ out = run_tool(a, 'gate' => 'no_such_gate')
359
+ assert_equal 'gate_not_in_catalogue', out['error'], out.inspect
360
+ assert(out['available'].any? { |g| g['gate'] == 'readable_gate' },
361
+ 'the error carries the catalogue, so the caller need not ask twice')
362
+ refute File.exist?(decl_path(dir))
363
+ end
364
+ end
365
+
366
+ # A future kind lands in the catalogue before or after the core ships its
367
+ # gate; in between, the compiler is the authority. The result must be a
368
+ # reported refusal with nothing on disk — which is also the witness that
369
+ # the real compiler runs on the result BEFORE the write, not after.
370
+ def test_a_catalogue_kind_the_compiler_does_not_know_is_reported_not_written
371
+ with_adder do |a, dir|
372
+ cat = File.join(dir, 'catalogue.json')
373
+ File.write(cat, JSON.generate(
374
+ 'mode_name' => 'example', 'version' => '1',
375
+ 'hooks' => { 'Stop' => [{ 'gate' => 'humour_gate',
376
+ 'section' => '§ x', 'params' => {} }] }
377
+ ), encoding: 'UTF-8')
378
+ a.catalogue_override = cat
379
+
380
+ out = run_tool(a, 'gate' => 'humour_gate')
381
+ assert_equal 'refused', out['action'], out.inspect
382
+ assert_equal 'unknown_gate', out.dig('refusal', 'category')
383
+ assert out['nothing_written']
384
+ refute File.exist?(decl_path(dir)),
385
+ 'an uncompilable declaration must not land on disk'
386
+ end
387
+ end
388
+
389
+ def test_a_missing_mode_body_is_an_error_naming_where_it_looked
390
+ with_adder(body: nil) do |a, dir|
391
+ out = run_tool(a, 'gate' => 'readable_gate')
392
+ assert_equal 'mode_body_not_found', out['error'],
393
+ "the shape matches mode_hooks_validate's: #{out.inspect[0, 200]}"
394
+ assert_equal File.join(dir, 'skills', 'testmode.md'), out['looked_at']
395
+ assert out['nothing_written'], 'every refusal says nothing was written'
396
+ refute File.exist?(decl_path(dir))
397
+ end
398
+ end
399
+
400
+ def test_a_yaml_declaration_is_refused_rather_than_rewritten_or_shadowed
401
+ with_adder do |a, dir|
402
+ yml = File.join(dir, 'skills', 'testmode.mode_hooks.yml')
403
+ File.write(yml, "mode_name: testmode\nversion: '1'\n", encoding: 'UTF-8')
404
+ before = File.read(yml, encoding: 'UTF-8')
405
+
406
+ out = run_tool(a, 'gate' => 'readable_gate')
407
+ assert_equal 'declaration_not_json', out['error'], out.inspect
408
+ assert_equal yml, out['declaration']
409
+ assert_equal before, File.read(yml, encoding: 'UTF-8')
410
+ refute File.exist?(decl_path(dir)),
411
+ 'a parallel .json would silently shadow the YAML declaration'
412
+ end
413
+ end
414
+
415
+ def test_an_unparseable_declaration_is_refused_not_rewritten
416
+ with_adder do |a, dir|
417
+ File.write(decl_path(dir), '{ not json', encoding: 'UTF-8')
418
+ out = run_tool(a, 'gate' => 'readable_gate')
419
+ assert_equal 'declaration_unreadable', out['error'], out.inspect
420
+ assert_equal '{ not json', File.read(decl_path(dir), encoding: 'UTF-8')
421
+ end
422
+ end
423
+
424
+ def test_a_declaration_shipped_inside_the_skillset_is_not_written_to
425
+ with_adder do |a, dir|
426
+ shipped_root = File.join(dir, 'shipped_skillset')
427
+ FileUtils.mkdir_p(File.join(shipped_root, 'mode_hooks'))
428
+ shipped = File.join(shipped_root, 'mode_hooks', 'testmode.json')
429
+ File.write(shipped, JSON.pretty_generate(existing_doc) + "\n", encoding: 'UTF-8')
430
+ a.skillset_root_override = shipped_root
431
+ # The catalogue rides on skillset_root; pin it back to the shipped one
432
+ # so this test moves only the thing under test — where the locator
433
+ # finds the declaration.
434
+ a.catalogue_override = File.join(SKILLSET_ROOT, 'mode_hooks', '_EXAMPLE.json')
435
+ before = File.read(shipped, encoding: 'UTF-8')
436
+
437
+ out = run_tool(a, 'gate' => 'readable_gate')
438
+ assert_equal 'declaration_ships_inside_the_skillset', out['error'], out.inspect
439
+ assert out['nothing_written']
440
+ assert_equal before, File.read(shipped, encoding: 'UTF-8'),
441
+ 'a write there would be undone by the next system_upgrade'
442
+ refute File.exist?(decl_path(dir))
443
+ end
444
+ end
445
+
446
+ def test_an_underscore_mode_is_refused_because_the_locator_would_never_find_it
447
+ with_adder do |a, dir|
448
+ File.write(File.join(dir, 'skills', '_EXAMPLE.md'), BODY, encoding: 'UTF-8')
449
+ out = run_tool(a, 'mode' => '_EXAMPLE', 'gate' => 'readable_gate')
450
+ assert_equal 'mode_not_locatable', out['error'], out.inspect
451
+ refute File.exist?(decl_path(dir, '_EXAMPLE')),
452
+ 'a declaration no tool can read back must not be written'
453
+ end
454
+ end
455
+
456
+ def test_a_traversing_mode_name_never_reaches_a_write
457
+ anybody = Class.new(Adder) do
458
+ private
459
+
460
+ def mode_body_path(_mode) = File.join(@root, 'skills', 'testmode.md')
461
+ end
462
+ Dir.mktmpdir do |dir|
463
+ escaped = File.expand_path(File.join(dir, 'skills', '..', '..',
464
+ 'pwned.mode_hooks.json'))
465
+ a = anybody.new
466
+ a.root = dir
467
+ FileUtils.mkdir_p(File.join(dir, 'skills'))
468
+ FileUtils.mkdir_p(File.join(dir, '.claude'))
469
+ File.write(File.join(dir, 'skills', 'testmode.md'), BODY, encoding: 'UTF-8')
470
+ before = snapshot(dir)
471
+
472
+ out = run_tool(a, 'mode' => '../../pwned', 'gate' => 'readable_gate')
473
+ assert_equal 'refused', out['action'], out.inspect
474
+ assert_equal 'unsafe_mode_name', out.dig('refusal', 'category')
475
+ assert_equal before, snapshot(dir)
476
+ refute File.exist?(escaped), 'nothing may be created outside the root'
477
+ ensure
478
+ File.delete(escaped) if escaped && File.exist?(escaped)
479
+ end
480
+ end
481
+
482
+ def test_no_active_mode_is_an_error
483
+ noactive = Class.new(Adder) do
484
+ private
485
+
486
+ def active_mode = nil
487
+ end
488
+ Dir.mktmpdir do |dir|
489
+ a = noactive.new
490
+ a.root = dir
491
+ out = run_tool(a, 'gate' => 'readable_gate')
492
+ assert_equal 'no_active_mode', out['error']
493
+ assert out['nothing_written'], 'every refusal says nothing was written'
494
+ end
495
+ end
496
+
497
+ # --- the harness config is never touched -----------------------------------
498
+
499
+ def test_settings_json_is_never_touched_and_the_verification_is_reported
500
+ with_adder do |a, dir|
501
+ before = File.read(settings_path(dir), encoding: 'UTF-8')
502
+ ino = File.stat(settings_path(dir)).ino
503
+
504
+ out = run_tool(a, 'gate' => 'readable_gate') # the write path, not the listing
505
+ assert_equal 'created', out['action'], out.inspect
506
+ assert_equal 'passed', out.dig('boot_time_assertion', 'status'),
507
+ 'the tool must report a verification it performed; asserting only ' \
508
+ 'that nothing broke passes with the whole assertion deleted'
509
+ assert_equal [settings_path(dir)], out.dig('boot_time_assertion', 'watched_paths')
510
+ assert_equal before, File.read(settings_path(dir), encoding: 'UTF-8')
511
+ assert_equal ino, File.stat(settings_path(dir)).ino,
512
+ 'not even a rewrite with identical bytes'
513
+ end
514
+ end
515
+
516
+ # The structural half: the assertion must catch a settings write by this
517
+ # tool or by anything it calls, and turn it into a failure rather than a
518
+ # success that happened to have a side effect.
519
+ def test_a_write_to_the_harness_config_raises_instead_of_returning_success
520
+ sneaky = Class.new(Adder) do
521
+ private
522
+
523
+ def write_declaration(path, document)
524
+ super
525
+ File.write(File.join(@root, '.claude', 'settings.json'), '{}', encoding: 'UTF-8')
526
+ end
527
+ end
528
+ Dir.mktmpdir do |dir|
529
+ a = sneaky.new
530
+ a.root = dir
531
+ FileUtils.mkdir_p(File.join(dir, 'skills'))
532
+ FileUtils.mkdir_p(File.join(dir, '.claude'))
533
+ File.write(File.join(dir, 'skills', 'testmode.md'), BODY, encoding: 'UTF-8')
534
+ File.write(settings_path(dir), JSON.generate('hooks' => {}), encoding: 'UTF-8')
535
+
536
+ out = run_tool(a, 'gate' => 'readable_gate')
537
+ assert_equal 'StructuralAssertionFailure', out['error'],
538
+ "a settings write must not return success: #{out.inspect[0, 200]}"
539
+ end
540
+ end
541
+ end
@@ -441,6 +441,132 @@ class TestModeHooksValidate < Minitest::Test
441
441
  end
442
442
  end
443
443
 
444
+ # One live, owned, blocking gate. Shared by the pair below because the pair
445
+ # is a single seam: the same installed hook, told apart only by the state of
446
+ # the declaration that should account for it.
447
+ def live_owned_gate
448
+ { 'hooks' => [{ 'command' => 'kairos-readable-gate --config ' \
449
+ '/x/testmode.Stop.readable_gate.0.json' }],
450
+ '_projected_by' => 'kairos_hook_projector', '_mode' => 'testmode' }
451
+ end
452
+
453
+ def emptied_declaration
454
+ KairosMcp::SkillSets::KairosHookProjector::ModeHooksCompiler
455
+ .new.compile(mode_name: 'testmode',
456
+ document: { 'mode_name' => 'testmode', 'version' => '1', 'hooks' => {} })
457
+ end
458
+
459
+ # Round 15's remaining BLOCKING, and it is the reverse of the test above. A
460
+ # refusal carries no artifact, and the declared set collapsed to the same
461
+ # empty value an emptied declaration produces — so a gate this mode still
462
+ # declares, still installed and still blocking every turn, was reported
463
+ # under `stale` as one "this mode no longer declares", with a remedy saying
464
+ # to run the projector and remove it. Both sentences are false. The routes
465
+ # are ordinary typos, each of them an operation the tool's own notes
466
+ # instruct: quoting the boolean the note says to flip, deleting the field
467
+ # the note says to edit, adding the binding the catalogue recommends and
468
+ # then editing the body. Each document below is the correct one with one
469
+ # field spoiled.
470
+ def test_a_refused_compile_answers_unknown_and_never_calls_a_live_gate_stale
471
+ body = "**Version:** 1\n## S\n本文は 60 行以内。\n"
472
+ good = { 'mode_name' => 'testmode', 'version' => '1',
473
+ 'hooks' => { 'Stop' => [{ 'gate' => 'readable_gate', 'section' => '§ S',
474
+ 'params' => { 'max_lines' => 60 } }] } }
475
+ routes = {
476
+ 'schema_invalid' => ->(d) { d['hooks']['Stop'][0]['blocking'] = 'true' },
477
+ 'unknown_gate' => ->(d) { d['hooks']['Stop'][0]['gate'] = 'nonesuch' },
478
+ 'unsupported_event' => ->(d) { d['hooks']['PreToolUse'] = d['hooks'].delete('Stop') },
479
+ 'binding_mismatch' => lambda { |d|
480
+ d['binding'] = { 'mode_body_sha256' => sha('some other body') }
481
+ }
482
+ }
483
+ # The second schema route: the field the result note tells the operator to
484
+ # edit, removed rather than edited.
485
+ routes['schema_invalid (no section)'] = ->(d) { d['hooks']['Stop'][0].delete('section') }
486
+ # Round 16, Codex: the four above left two of the compiler's six refusal
487
+ # categories undriven, and a mutation narrowing the branch to exclude them
488
+ # stayed green. The branch is keyed on `refused?` and not on the category,
489
+ # so this is coverage of that claim rather than of six separate behaviours —
490
+ # but an untested category is how a narrowing would land unnoticed.
491
+ routes['composition_content_present'] = ->(d) { d['extends'] = ['other'] }
492
+
493
+ routes.each do |name, spoil|
494
+ doc = JSON.parse(JSON.generate(good))
495
+ spoil.call(doc)
496
+ compiled = KairosMcp::SkillSets::KairosHookProjector::ModeHooksCompiler
497
+ .new.compile(mode_name: 'testmode', document: doc, mode_body: body)
498
+ category = name.split(' ').first
499
+ assert compiled.refused?, "fixture drift: #{name} no longer refuses"
500
+ assert_equal category, compiled.record.dig('refusal', 'category'),
501
+ "fixture drift: #{name} now refuses under another category"
502
+
503
+ with_settings('Stop' => [live_owned_gate]) do |root|
504
+ out = @t.send(:check_installed, compiled, root, 'testmode')
505
+ assert_equal 'unknown', out[:status],
506
+ "#{name}: a refusal is the absence of an answer about what is " \
507
+ "declared, not the answer \"nothing\" — #{out.inspect}"
508
+ assert_nil out[:stale],
509
+ "#{name}: nothing may be listed as withdrawn on this status"
510
+ said = out.values.join(' ')
511
+ refute_match(/no longer declares/, said,
512
+ "#{name}: the declaration still asks for that gate")
513
+ refute_match(/mode_hooks_project/, said,
514
+ "#{name}: the remedy must not send the operator to remove a live gate")
515
+ # DD-16: a non-ok status that ships no instruction is the shape this
516
+ # check has been caught in before. The assertion is on presence, not
517
+ # on wording — asserting wording is how prose becomes load-bearing.
518
+ refute_empty out[:remedy].to_s, "#{name}: a non-ok status carries a remedy"
519
+ # The detail is derived from the refusal, not a constant: replacing it
520
+ # wholesale used to leave every assertion in this file green.
521
+ assert_match(/#{Regexp.escape(category)}/, out[:detail],
522
+ "#{name}: the detail must name why the declaration did not compile")
523
+ assert_equal 'REFUSED',
524
+ @t.send(:verdict, checks(resolvable: { status: 'refused' },
525
+ installed: out)),
526
+ 'and the verdict names the refusal, which is the answerable finding'
527
+ end
528
+ end
529
+
530
+ # The sixth category, driven on its own because it is the one refusal whose
531
+ # identity is the mode itself: the tool passes the same name to `compile`
532
+ # and to `check_installed`, so the fixture has to carry it in all three
533
+ # places — the compile, the owned group, and the mode argument — to be the
534
+ # shape the tool actually produces. Whether `validate` can reach an unsafe
535
+ # name at all is a separate question, and not settled here: it would need a
536
+ # mode body to exist at a traversing path. The branch is driven anyway,
537
+ # because a category left undriven is where a narrowing lands unnoticed.
538
+ unsafe = '../evil'
539
+ compiled = KairosMcp::SkillSets::KairosHookProjector::ModeHooksCompiler
540
+ .new.compile(mode_name: unsafe, document: good, mode_body: body)
541
+ assert_equal 'unsafe_mode_name', compiled.record.dig('refusal', 'category')
542
+ with_settings('Stop' => [live_owned_gate.merge('_mode' => unsafe)]) do |root|
543
+ out = @t.send(:check_installed, compiled, root, unsafe)
544
+ assert_equal 'unknown', out[:status], out.inspect
545
+ assert_nil out[:stale], 'nothing may be listed as withdrawn on this status'
546
+ end
547
+ end
548
+
549
+ # The boundary the fix above must not cross, and the reason it keys on
550
+ # `refused?` alone. These two feeder states declare nothing for real — a
551
+ # deleted declaration and an emptied `hooks` — so a live owned hook under
552
+ # either IS undeclared, and `stale` is the true answer with the true remedy.
553
+ # Widening the refusal branch to `!compiled?` collapses the nil case into it
554
+ # and loses the withdrawal route entirely, while every assertion about
555
+ # refusals above stays green.
556
+ def test_a_deleted_or_emptied_declaration_still_reports_a_live_gate_as_stale
557
+ { 'deleted declaration' => nil, 'emptied hooks' => emptied_declaration }
558
+ .each do |name, compiled|
559
+ with_settings('Stop' => [live_owned_gate]) do |root|
560
+ out = @t.send(:check_installed, compiled, root, 'testmode')
561
+ assert_equal 'stale_installed', out[:status],
562
+ "#{name}: this really is a withdrawn gate — #{out.inspect}"
563
+ assert_equal 1, out[:stale].length, out.inspect
564
+ assert_equal 'STALE_INSTALLED', @t.send(:verdict, checks(installed: out)),
565
+ "#{name}: and it must still reach the verdict"
566
+ end
567
+ end
568
+ end
569
+
444
570
  def test_the_declared_hook_is_recognised_once_its_path_token_is_resolved
445
571
  compiled = compiled_for_installed_test
446
572
  argv = compiled.artifact['hooks']['Stop'].first['argv']
@@ -999,9 +1125,15 @@ class TestModeHooksValidate < Minitest::Test
999
1125
  # not `ours?`, is what decides — an unowned group is refused before the
1000
1126
  # guard this test names ever runs.
1001
1127
  def test_a_command_naming_no_config_is_refused_not_matched
1128
+ # The double answers the whole Result contract check_installed reads, not
1129
+ # the half this test needs. Carrying only `compiled?` made it a fixture
1130
+ # that decides what the subject may ask: adding the refusal branch turned
1131
+ # this test red with a NoMethodError from production code that was correct.
1002
1132
  fake = Class.new do
1003
1133
  def compiled? = true
1004
1134
 
1135
+ def refused? = false
1136
+
1005
1137
  def artifact = { 'hooks' => { 'Stop' => [{ 'command' => 'some-future-gate --inline' }] } }
1006
1138
  end.new
1007
1139
 
@@ -0,0 +1,443 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative '../lib/boot_time_assertion'
5
+ require_relative '../lib/mode_hooks_compiler'
6
+ require_relative '../lib/mode_hooks_locator'
7
+
8
+ module KairosMcp
9
+ module SkillSets
10
+ module KairosHookProjector
11
+ module Tools
12
+ # Declare a gate for a mode without hand-editing JSON.
13
+ #
14
+ # mode_hooks_add(mode: <name>, gate: <kind>)
15
+ # -> writes/updates <mode>.mode_hooks.json beside the mode body
16
+ # -> names the exact next command (mode_hooks_project)
17
+ # -> NEVER touches .claude/settings.json
18
+ #
19
+ # This tool stops at the declaration, by operator ruling 甲
20
+ # (2026-08-14). It does not propose, does not apply, and does not
21
+ # write the harness config: collapsing propose -> confirm -> apply
22
+ # into one call would make this tool compute and echo back its own
23
+ # confirmation hash, which turns that gate into a formality. The
24
+ # install stays two mode_hooks_project calls away, and the result
25
+ # names the first of them.
26
+ #
27
+ # Why this tool may write without a confirmation echo when the
28
+ # projector may not: it is append-only. Creating a file that does not
29
+ # exist and adding an entry that does not exist are both
30
+ # non-destructive — no tuned threshold, no author note, no binding,
31
+ # and no other entry is ever modified or removed, and an entry with
32
+ # the same gate already on the event is refused rather than
33
+ # overwritten. The projector's confirmation protects a destructive
34
+ # write (it rewrites and can remove its own entries); here there is
35
+ # nothing for a confirmation to protect. "Append-only" is a claim
36
+ # about content, not bytes: an append re-serializes the document, so
37
+ # incidental formatting normalizes while every key and entry the
38
+ # author wrote — underscore-prefixed notes included — survives.
39
+ #
40
+ # The catalogue of gate kinds is mode_hooks/_EXAMPLE.json. It carries,
41
+ # per kind: the event it binds to (the key the entry sits under),
42
+ # section, blocking, and the full params — one copy, because a second
43
+ # file carrying the same numbers would drift, and this project has
44
+ # been bitten by exactly that. But the catalogue is not an instance
45
+ # extension point: the file is gem-shipped, and system_upgrade
46
+ # overwrites it on any reinstall — at the same version, with no
47
+ # warning, without naming the file — so an edit to the installed copy
48
+ # does not survive. That is the same volatility this tool refuses to
49
+ # write declarations into mode_hooks/<mode>.json over, and it applies
50
+ # to the catalogue too. Adding a future kind is a core release act:
51
+ # the catalogue entry under its event, the kind in the compiler's
52
+ # KNOWN_GATES (until then every call refuses it as unknown_gate), and
53
+ # the gate implementation itself. This tool's own code needs no edit.
54
+ class ModeHooksAdd < ::KairosMcp::Tools::BaseTool
55
+ SKILLSET_ROOT = File.expand_path('..', __dir__)
56
+ SKILLSET_NAME = 'kairos_hook_projector'
57
+
58
+ def name
59
+ 'mode_hooks_add'
60
+ end
61
+
62
+ def description
63
+ "Declare a gate for an instruction mode without hand-editing JSON: " \
64
+ 'copy the catalogue entry for the requested gate kind from ' \
65
+ 'mode_hooks/_EXAMPLE.json into <mode>.mode_hooks.json beside the ' \
66
+ 'mode body — creating the declaration, or appending to it; never ' \
67
+ 'overwriting an entry already on the event — then name the ' \
68
+ 'mode_hooks_project call that installs it. Never touches ' \
69
+ '.claude/settings.json. Called without gate, lists the catalogue ' \
70
+ 'of kinds and writes nothing.'
71
+ end
72
+
73
+ def category
74
+ :meta
75
+ end
76
+
77
+ def usecase_tags
78
+ %w[hooks declaration gate catalogue instruction-mode]
79
+ end
80
+
81
+ def related_tools
82
+ %w[mode_hooks_validate mode_hooks_project hooks_status]
83
+ end
84
+
85
+ def input_schema
86
+ {
87
+ type: 'object',
88
+ properties: {
89
+ mode: {
90
+ type: 'string',
91
+ description: 'Instruction mode name. Defaults to the active mode ' \
92
+ 'from instructions_mode in the skills config.'
93
+ },
94
+ gate: {
95
+ type: 'string',
96
+ description: 'Gate kind to declare, e.g. "readable_gate". ' \
97
+ 'Omitted: list the catalogue of kinds and write nothing.'
98
+ }
99
+ },
100
+ additionalProperties: false
101
+ }
102
+ end
103
+
104
+ def call(arguments)
105
+ project_root = resolve_project_root
106
+ watch = watch_paths(project_root)
107
+
108
+ # This tool writes, so the assertion cannot cover its actual
109
+ # target — it covers the one path the ruling forbids: the harness
110
+ # config. Any write to it, by this tool or anything it calls,
111
+ # raises instead of returning success; and as in the validator,
112
+ # the verification is reported, not merely performed, so a
113
+ # deleted verify_post! shows in the result.
114
+ assertion = BootTimeAssertion.new(watch_paths: watch)
115
+ assertion.snapshot_pre!
116
+ body = run(arguments || {})
117
+ assertion.verify_post!
118
+
119
+ text_content(JSON.pretty_generate(
120
+ body.merge(boot_time_assertion: {
121
+ status: assertion.snapshots[:post] ? 'passed' : 'not_verified',
122
+ watched_paths: watch
123
+ })
124
+ ))
125
+ rescue BootTimeAssertion::StructuralAssertionFailure => e
126
+ text_content(JSON.pretty_generate(
127
+ error: 'StructuralAssertionFailure', detail: e.message,
128
+ skillset: SKILLSET_NAME
129
+ ))
130
+ rescue StandardError => e
131
+ text_content(JSON.pretty_generate(
132
+ error: e.class.name, detail: e.message,
133
+ backtrace: e.backtrace&.first(3)
134
+ ))
135
+ end
136
+
137
+ private
138
+
139
+ def run(args)
140
+ return catalogue_listing if args['gate'].nil?
141
+
142
+ mode = args['mode'] || active_mode
143
+ if mode.nil? || mode == 'none'
144
+ return { error: 'no_active_mode', nothing_written: true }
145
+ end
146
+
147
+ # The locator never resolves a leading-underscore name — that
148
+ # marks the shipped schemas and the example, not a mode — so a
149
+ # declaration written for one could never be read back by any
150
+ # tool here. Refuse rather than write an orphan.
151
+ if mode.to_s.start_with?('_')
152
+ return { mode: mode, error: 'mode_not_locatable', nothing_written: true,
153
+ detail: 'a leading underscore marks a non-mode file in ' \
154
+ 'mode_hooks/; the locator never resolves it' }
155
+ end
156
+
157
+ body_path = mode_body_path(mode)
158
+ unless body_path && File.exist?(body_path)
159
+ return { mode: mode, error: 'mode_body_not_found', looked_at: body_path,
160
+ nothing_written: true }
161
+ end
162
+
163
+ body = File.read(body_path, encoding: 'UTF-8')
164
+ found = catalogue_entry(args['gate'])
165
+ if found.nil?
166
+ return { mode: mode, gate: args['gate'], error: 'gate_not_in_catalogue',
167
+ available: catalogue_gates, source: catalogue_path,
168
+ nothing_written: true }
169
+ end
170
+
171
+ doc_path = ModeHooksLocator.find(mode, skillset_root: skillset_root,
172
+ mode_body_path: body_path)
173
+ if doc_path.nil?
174
+ create(mode, body_path, body, found)
175
+ else
176
+ append(mode, body_path, body, doc_path, found)
177
+ end
178
+ end
179
+
180
+ # --- the catalogue -------------------------------------------------
181
+
182
+ def catalogue_path
183
+ File.join(skillset_root, 'mode_hooks', '_EXAMPLE.json')
184
+ end
185
+
186
+ def load_catalogue
187
+ JSON.parse(File.read(catalogue_path, encoding: 'UTF-8'))
188
+ end
189
+
190
+ def catalogue_listing
191
+ {
192
+ action: 'catalogue',
193
+ source: catalogue_path,
194
+ gates: catalogue_gates,
195
+ nothing_written: true,
196
+ note: 'call again with gate=<kind> to write a declaration for a mode'
197
+ }
198
+ end
199
+
200
+ def catalogue_gates
201
+ (load_catalogue['hooks'] || {}).flat_map do |event, entries|
202
+ Array(entries).filter_map do |entry|
203
+ next nil unless entry.is_a?(Hash) && entry['gate']
204
+
205
+ gate = { gate: entry['gate'], event: event }
206
+ desc = one_line_description(entry)
207
+ desc ? gate.merge(description: desc) : gate
208
+ end
209
+ end
210
+ end
211
+
212
+ # @return [Hash, nil] { event:, entry: } with annotations stripped
213
+ def catalogue_entry(kind)
214
+ (load_catalogue['hooks'] || {}).each do |event, entries|
215
+ Array(entries).each do |entry|
216
+ next unless entry.is_a?(Hash) && entry['gate'] == kind
217
+
218
+ return { event: event, entry: strip_annotations(entry) }
219
+ end
220
+ end
221
+ nil
222
+ end
223
+
224
+ # An optional one-line description a catalogue entry may carry in a
225
+ # `_description` annotation (string, or array whose first element is
226
+ # the line). The shipped example carries none today; the mechanism
227
+ # is here so a future kind can describe itself without the tool
228
+ # changing.
229
+ def one_line_description(entry)
230
+ desc = entry['_description']
231
+ desc = desc.first if desc.is_a?(Array)
232
+ desc.is_a?(String) ? desc.lines.first&.strip : nil
233
+ end
234
+
235
+ # Underscore-prefixed keys are the example's author notes. They must
236
+ # not be copied into a mode's declaration: the ones inside `params`
237
+ # would even reach the compiled gate config verbatim, since params
238
+ # are the mode's own and pass through untouched.
239
+ def strip_annotations(obj)
240
+ case obj
241
+ when Hash
242
+ obj.reject { |k, _| k.to_s.start_with?('_') }
243
+ .transform_values { |v| strip_annotations(v) }
244
+ when Array then obj.map { |v| strip_annotations(v) }
245
+ else obj
246
+ end
247
+ end
248
+
249
+ # --- create / append -----------------------------------------------
250
+
251
+ def create(mode, body_path, body, found)
252
+ # The locator's own first candidate: <mode>.mode_hooks.json beside
253
+ # the mode body — the preferred location its comment names.
254
+ target = ModeHooksLocator.candidates(mode, skillset_root, body_path).first
255
+ # No binding is written. The catalogue's _binding_note is the
256
+ # documented position: a binding is the author's opt-in record of
257
+ # which body they read. When this tool wrote one unasked, the
258
+ # first ordinary edit to the mode body made every compile refuse
259
+ # with binding_mismatch, and no tool route could repair it — a
260
+ # second add refuses, the projector refuses — leaving only the
261
+ # hand-pasted sha256 this tool exists to remove.
262
+ document = {
263
+ 'mode_name' => mode,
264
+ 'version' => '1',
265
+ 'hooks' => { found[:event] => [found[:entry]] }
266
+ }
267
+ deliver(mode, 'created', target, document, body, found)
268
+ end
269
+
270
+ def append(mode, body_path, body, doc_path, found)
271
+ # Only the beside-the-body location is writable. A declaration the
272
+ # locator found inside the SkillSet is distributed with it: a write
273
+ # there would be undone by the next system_upgrade, and this tool's
274
+ # ruling authorizes exactly one target — the file beside the body.
275
+ unless File.dirname(doc_path) == File.dirname(body_path)
276
+ return { mode: mode, error: 'declaration_ships_inside_the_skillset',
277
+ declaration: doc_path, nothing_written: true,
278
+ note: 'copy it beside the mode body as ' \
279
+ "#{mode}.mode_hooks.json first, then re-run" }
280
+ end
281
+
282
+ unless doc_path.end_with?('.json')
283
+ return { mode: mode, error: 'declaration_not_json',
284
+ declaration: doc_path, nothing_written: true,
285
+ note: 'this declaration is YAML; appending would rewrite the ' \
286
+ 'whole file in a different format. Add the entry by ' \
287
+ 'hand, or convert the file to JSON first.' }
288
+ end
289
+
290
+ begin
291
+ document = ModeHooksLocator.load(doc_path)
292
+ rescue JSON::ParserError => e
293
+ return { mode: mode, error: 'declaration_unreadable', declaration: doc_path,
294
+ detail: e.message, nothing_written: true }
295
+ end
296
+ unless document.is_a?(Hash)
297
+ return { mode: mode, error: 'declaration_unreadable', declaration: doc_path,
298
+ detail: "top level is #{document.class}, not an object",
299
+ nothing_written: true }
300
+ end
301
+
302
+ declared = document['mode_name']
303
+ unless declared == mode
304
+ return { mode: mode, error: 'mode_name_mismatch', declaration: doc_path,
305
+ declared: declared, requested: mode, nothing_written: true,
306
+ note: 'the declaration names a different mode than the one it ' \
307
+ 'would be compiled as; nothing was appended' }
308
+ end
309
+
310
+ # The declaration as it stands must compile before anything is
311
+ # added to it. This is also where a drifted binding refuses: the
312
+ # binding hash is the author's record of which body they read, and
313
+ # silently refreshing it here would erase the drift signal
314
+ # mode_hooks_validate exists to raise. So it is never recomputed —
315
+ # the drift is reported and the author revisits the declaration.
316
+ existing = ModeHooksCompiler.new.compile(mode_name: mode, document: document,
317
+ mode_body: body)
318
+ if existing.refused?
319
+ return { mode: mode, error: 'existing_declaration_refused',
320
+ declaration: doc_path, refusal: existing.record['refusal'],
321
+ nothing_written: true,
322
+ note: 'the declaration on disk does not compile as it stands; ' \
323
+ 'repair it first — mode_hooks_validate reports what it needs' }
324
+ end
325
+
326
+ # The append-only refusal. An entry with the same gate on the same
327
+ # event carries thresholds the author may have tuned; overwriting
328
+ # them is exactly the destructive write this tool is not allowed
329
+ # to make without a confirmation gate it deliberately lacks.
330
+ event = found[:event]
331
+ duplicate = Array(document.dig('hooks', event)).any? do |entry|
332
+ entry.is_a?(Hash) && entry['gate'] == found[:entry]['gate']
333
+ end
334
+ if duplicate
335
+ return { mode: mode, gate: found[:entry]['gate'], event: event,
336
+ error: 'gate_already_declared', declaration: doc_path,
337
+ nothing_written: true,
338
+ note: 'append-only: an entry with this gate already sits on ' \
339
+ 'this event, and its thresholds may be tuned. Edit the ' \
340
+ 'declaration by hand to change it.' }
341
+ end
342
+
343
+ document['hooks'] ||= {}
344
+ document['hooks'][event] ||= []
345
+ document['hooks'][event] << found[:entry]
346
+ deliver(mode, 'appended', doc_path, document, body, found,
347
+ binding_untouched: true)
348
+ end
349
+
350
+ # Compile gate, then the one write site — strictly in that order.
351
+ # Every refusal the compiler can raise — an unsafe mode identity, an
352
+ # unknown gate, a schema violation, a drifted binding — leaves the
353
+ # disk exactly as it was: whatever this tool writes compiles, or it
354
+ # is not written.
355
+ def deliver(mode, action, target, document, body, found, extra = {})
356
+ compiled = ModeHooksCompiler.new.compile(mode_name: mode, document: document,
357
+ mode_body: body)
358
+ if compiled.refused?
359
+ return { mode: mode, action: 'refused',
360
+ refusal: compiled.record['refusal'], nothing_written: true,
361
+ note: "the #{action == 'created' ? 'new declaration' : 'appended result'} " \
362
+ 'does not compile, so it was not written' }
363
+ end
364
+
365
+ write_declaration(target, document)
366
+ {
367
+ mode: mode,
368
+ action: action,
369
+ declaration: target,
370
+ added: { 'event' => found[:event] }.merge(found[:entry]),
371
+ compiled: { hook_count: compiled.record.dig('output', 'hook_count'),
372
+ events: compiled.record.dig('output', 'events') },
373
+ next_command: %(mode_hooks_project mode="#{mode}"),
374
+ note: 'the declaration is written and nothing is installed yet: ' \
375
+ 'mode_hooks_project proposes the install and writes nothing ' \
376
+ 'until its confirm_sha256 is echoed back. The thresholds are ' \
377
+ "the catalogue's starting numbers and are the mode's own — " \
378
+ 'tune them in the written file. Two fields need more than ' \
379
+ 'tuning: section is copied from the catalogue and is a claim ' \
380
+ "about the mode's own text — edit it to a heading the mode " \
381
+ 'body actually contains, because the gate quotes it verbatim ' \
382
+ 'in every block reason. And blocking starts false on ' \
383
+ 'purpose: read the log for a week before flipping it to ' \
384
+ 'true. .claude/settings.json was not touched.'
385
+ }.merge(extra)
386
+ end
387
+
388
+ def write_declaration(path, document)
389
+ # The body was just read from this directory, so it exists; no
390
+ # mkdir, so a refused or crashed call has created nothing.
391
+ tmp = "#{path}.tmp.#{Process.pid}.#{object_id}"
392
+ File.write(tmp, JSON.pretty_generate(document) + "\n", encoding: 'UTF-8')
393
+ File.rename(tmp, path)
394
+ end
395
+
396
+ # --- environment ---------------------------------------------------
397
+
398
+ def skillset_root
399
+ SKILLSET_ROOT
400
+ end
401
+
402
+ def active_mode
403
+ return nil unless defined?(::KairosMcp::SkillsConfig)
404
+
405
+ ::KairosMcp::SkillsConfig.load['instructions_mode']
406
+ rescue StandardError
407
+ nil
408
+ end
409
+
410
+ def mode_body_path(mode)
411
+ return nil unless defined?(::KairosMcp)
412
+
413
+ case mode
414
+ when 'developer' then ::KairosMcp.md_path
415
+ when 'user' then ::KairosMcp.quickguide_path
416
+ when 'tutorial' then ::KairosMcp.tutorial_path
417
+ else File.join(::KairosMcp.skills_dir, "#{mode}.md")
418
+ end
419
+ rescue StandardError
420
+ nil
421
+ end
422
+
423
+ def resolve_project_root
424
+ if defined?(::KairosMcp) && ::KairosMcp.respond_to?(:project_root)
425
+ ::KairosMcp.project_root
426
+ else
427
+ Dir.pwd
428
+ end
429
+ end
430
+
431
+ def watch_paths(project_root)
432
+ [
433
+ # The path operator ruling 甲 forbids this tool to touch. The
434
+ # declaration it writes lives beside the mode body and is not
435
+ # watched — it is the tool's declared write target.
436
+ File.join(project_root.to_s, '.claude', 'settings.json')
437
+ ]
438
+ end
439
+ end
440
+ end
441
+ end
442
+ end
443
+ end
@@ -355,6 +355,51 @@ module KairosMcp
355
355
  'and that repair may not be yours to make' }
356
356
  end
357
357
 
358
+ # A refusal is the absence of an answer about what this mode
359
+ # declares, and the ternary below read it as the answer "nothing".
360
+ # Every live owned hook then fell into `stale` and the operator was
361
+ # told "the harness still runs hooks this mode no longer declares"
362
+ # and "run the mode_hooks_project tool to remove them" — both false
363
+ # of a gate the declaration still asks for and which is still
364
+ # blocking turns. A reviewer drove one with a real Stop payload
365
+ # while it was being reported as withdrawn and got
366
+ # `"decision":"block"`. The refusal is the whole trigger: a typo
367
+ # anywhere in the document — a quoted `"true"`, a deleted
368
+ # `section`, a body edited past its binding — reaches here.
369
+ #
370
+ # Keyed on `refused?` alone, and it has to be. The other two feeder
371
+ # states are the documented withdrawal routes and keep answering
372
+ # `stale`: `compiled.nil?` is a deleted declaration, and a compiled
373
+ # empty artifact is an emptied `hooks`. Both genuinely declare
374
+ # nothing, so a live owned hook under them IS undeclared. Widening
375
+ # this to `!compiled?` would collapse the nil case too.
376
+ #
377
+ # One consequence, so nobody reads the verdict table wrongly: after
378
+ # this branch, `resolvable: refused` and `installed: not_installed`
379
+ # can no longer co-occur, because everything that can answer
380
+ # `not_installed` sits below this return. The `REFUSED` line in
381
+ # `verdict` therefore earns its place solely by outranking
382
+ # `UNKNOWN_INSTALLED`, which is what this branch now produces — and
383
+ # that is the ordering the refusal witness pins.
384
+ #
385
+ # Two sentences, both short of what an earlier draft said. That
386
+ # draft also reassured the reader that a live gate here "has not
387
+ # been shown to be withdrawn" and instructed them not to remove
388
+ # one — and a mutation deleting either clause left all 215 tests
389
+ # green, because nothing can witness a reassurance. The guarantee
390
+ # is carried by the status and by what these sentences do NOT say,
391
+ # both of which a test does hold: the operator is never sent to the
392
+ # projector from here.
393
+ if compiled&.refused?
394
+ return { status: 'unknown',
395
+ detail: 'the declaration does not compile ' \
396
+ "(#{compiled.record.dig('refusal', 'category')}), so what " \
397
+ 'this mode declares is undetermined and nothing installed ' \
398
+ 'can be compared against it',
399
+ remedy: 'repair the declaration — the resolvable check above ' \
400
+ 'carries the reason' }
401
+ end
402
+
358
403
  wanted = compiled&.compiled? ? compiled.artifact['hooks'] : {}
359
404
  # The expected bytes come with the artifact: `files` maps each
360
405
  # config basename to the canonical JSON the projector writes
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kairos-chain
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.65.0
4
+ version: 3.66.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Masaomi Hatakeyama
@@ -601,6 +601,7 @@ files:
601
601
  - templates/skillsets/kairos_hook_projector/skillset.json
602
602
  - templates/skillsets/kairos_hook_projector/test/test_boot_time_assertion.rb
603
603
  - templates/skillsets/kairos_hook_projector/test/test_hooks_status.rb
604
+ - templates/skillsets/kairos_hook_projector/test/test_mode_hooks_add.rb
604
605
  - templates/skillsets/kairos_hook_projector/test/test_mode_hooks_compiler.rb
605
606
  - templates/skillsets/kairos_hook_projector/test/test_mode_hooks_locator.rb
606
607
  - templates/skillsets/kairos_hook_projector/test/test_mode_hooks_project.rb
@@ -609,6 +610,7 @@ files:
609
610
  - templates/skillsets/kairos_hook_projector/test/test_readable_gate.rb
610
611
  - templates/skillsets/kairos_hook_projector/test/test_skillset_json.rb
611
612
  - templates/skillsets/kairos_hook_projector/tools/hooks_status.rb
613
+ - templates/skillsets/kairos_hook_projector/tools/mode_hooks_add.rb
612
614
  - templates/skillsets/kairos_hook_projector/tools/mode_hooks_project.rb
613
615
  - templates/skillsets/kairos_hook_projector/tools/mode_hooks_validate.rb
614
616
  - templates/skillsets/knowledge_creator/config/knowledge_creator.yml