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 +4 -4
- data/lib/kairos_mcp/version.rb +1 -1
- data/templates/skillsets/kairos_hook_projector/mode_hooks/_EXAMPLE.json +1 -0
- data/templates/skillsets/kairos_hook_projector/plugin/SKILL.md +34 -5
- data/templates/skillsets/kairos_hook_projector/skillset.json +4 -2
- data/templates/skillsets/kairos_hook_projector/test/test_mode_hooks_add.rb +541 -0
- data/templates/skillsets/kairos_hook_projector/test/test_mode_hooks_validate.rb +132 -0
- data/templates/skillsets/kairos_hook_projector/tools/mode_hooks_add.rb +443 -0
- data/templates/skillsets/kairos_hook_projector/tools/mode_hooks_validate.rb +45 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b16a89f65901c9eefc5b08e5c2b1503817625c35847304332316ddb01292f8da
|
|
4
|
+
data.tar.gz: 7724c71a90c96aa624003663b4e7a7381552a08d0afa857ca19969fdea997875
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3dc1c2f28e0b5b515f89be1bf7eb6714c796deae4ab8382907b0248abcabefc74e2510c372c3a3fc18eb2b8c7d36accd58535d2b9528398b83e6d2b2618e3a8b
|
|
7
|
+
data.tar.gz: 5d33acb30a5c7510015d76c7d9d398597988e284e5420752385888e0814b19ba6332528cb701e925e82fb414f7c08bc3a3930ee990065070756701bcd07dc4ee
|
data/lib/kairos_mcp/version.rb
CHANGED
|
@@ -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.
|
|
24
|
-
`<mode>.mode_hooks.json`
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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.
|
|
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.
|
|
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
|