kairos-chain 3.67.0 → 3.69.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: 0506a7cc642b812cabab1bb2840d928e1a9f550bf2486466741a54b6d71ee74f
4
- data.tar.gz: bf1ff56bf44cf13d78c01dedd26d0bdc6188453dea18ca1975027f2b3a6c7f8d
3
+ metadata.gz: 4277c267574d3ac35dc096a5a6ce3371e074a89d6840a7274d5c3c81397cd643
4
+ data.tar.gz: 0a07d054c3f1c3266d63184bf35e29239b91fcfacbebe3a2bbc2290603aca200
5
5
  SHA512:
6
- metadata.gz: 7c4ef69fda47be727f4ba9da5f44ee95d6cf63f5cff559bb3b3b2cc7dfc8df67022f47086d6468b6dc332f41b95908745557e294d2bc8d1e4dec70a763ec208a
7
- data.tar.gz: 1838ad363f5bc95a8e5abdb1ca07c7be7891a0ab5410cbdc22456b5187bc4297f193d57f2a93b57578582704702b788eb2e5e78d5c4ff7c0f978bcc58c8cd4c5
6
+ metadata.gz: f40163bd119d1d39c20ac8c986e05fed26e38c445fe7454afb72d35c80f03df8282122674f50d2fc5183261cb8f5a87ec50b1ca71e5ed1a9711cd2a6a007e2c2
7
+ data.tar.gz: b12d79ed73d1bc51a00dc7910be24903b53fca4a5c6d6a1549e4c7cf5bb4b384552a0af7af284e40fa0d220cfdf94e879c433f91c418ffb357d8e890c96a4215
data/CHANGELOG.md CHANGED
@@ -4,6 +4,54 @@ All notable changes to the `kairos-chain` gem will be documented in this file.
4
4
 
5
5
  This project follows [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [3.69.0] - 2026-08-15
8
+
9
+ ### Added
10
+
11
+ - **The mutation experiment and the report shape ship with `minimum_nomic`**
12
+ (SkillSet v0.2.0). Until now the bench could play a game and score it, and the
13
+ score could not rank models: over five games scored twice, the spread between
14
+ judges was 1.35–1.50 points while the spread between the judged was 0.67–0.71,
15
+ and re-scoring the same cell moved it 0.73. Asking a model for a number
16
+ measures the asker.
17
+
18
+ `bin/mutate.rb` replaces that with a question whose answer we hold: copy a
19
+ finished game, reverse exactly one vote, and see whether the analyst notices.
20
+ It writes one arm per seat plus a `clean/` control — not optional, because
21
+ without it a reported contradiction cannot be told from a confabulated one —
22
+ and a manifest naming what was planted where. It refuses to overwrite an
23
+ output directory, to make anything but exactly one substitution, and to leave
24
+ a grammatical scar.
25
+
26
+ `bin/score_detections.rb` prints the evidence and **returns no number**. A
27
+ first pass over 81 verdicts by keyword matching undercounted one analyst by
28
+ four, because its findings were written purely as a contrast — "its reasoning
29
+ says it will vote for its proposal, but it votes against" — which contains no
30
+ keyword. A script returning a count here would have shipped that error
31
+ silently.
32
+
33
+ `report/report_template.html` carries the chapter shape the 2026-08 report
34
+ settled on after being rewritten once: TL;DR before chapter 1, the run diagram
35
+ immediately after, then method, results, discussion, appendices. Placeholders
36
+ are in capitals and each structural choice carries its reason in a comment.
37
+
38
+ Three defects were found by running it rather than by reading it, and are
39
+ fixed here. Vote phrasing is not stable between games, so the flat pattern
40
+ list matched nothing in a fresh game whose ballots read `**Vote:** IN FAVOR`;
41
+ detection is now a marker search followed by a polarity flip within a short
42
+ window. Substituting by first occurrence rewrote a *quoted rule* — "adopted if
43
+ more than half of the players vote in favor of it" — instead of the speaker's
44
+ ballot; substitution is now by offset with the expected text asserted there.
45
+ And a copied arm inherits the original game's analyses, written before
46
+ anything was planted, which the worksheet was presenting as readings of the
47
+ mutated record; every analysis kind is now offset by what the arm inherited.
48
+
49
+ Demonstrated end to end from an installed gem: `skillset install
50
+ minimum_nomic`, a 6-turn game (11.3 minutes, analysts included), `mutate.rb`
51
+ planting three arms and a control, `reanalyse.rb` on two of them, and
52
+ `score_detections.rb`. All three analysts caught the planted reversal; the
53
+ control produced no false alarm.
54
+
7
55
  ## [3.67.0] - 2026-08-15
8
56
 
9
57
  ### Added
data/bin/kairos-chain CHANGED
@@ -406,6 +406,131 @@ when 'upgrade'
406
406
  end
407
407
  exit
408
408
 
409
+ when 'gate'
410
+ # Readable-gate enablement in one call. The judgement lives in the
411
+ # kairos_hook_projector SkillSet, which is not a core SkillSet: this loads it
412
+ # from the instance and refuses when it is absent, the shape `attestation
413
+ # scan` already uses for synoptis. Loading it from the gem's templates instead
414
+ # would run a different copy than the one installed here.
415
+ ARGV.shift
416
+
417
+ action = ARGV.shift
418
+ if action.nil? || %w[-h --help help].include?(action)
419
+ puts <<~HELP
420
+ Usage: kairos-chain gate enable <mode> [--section "§ Heading"] [--apply]
421
+
422
+ Add the readable gate to an instruction mode: write the declaration
423
+ from the catalogue, then install it into .claude/settings.json.
424
+
425
+ Without --apply it proposes and writes nothing.
426
+
427
+ It does not read the mode body and decides nothing about it. The
428
+ section and the thresholds come from the catalogue and are the mode's
429
+ own to edit afterwards, in the declaration this prints. `blocking`
430
+ starts false, which is what the catalogue ships: run report-only,
431
+ read the log, then set it true and re-run with --apply.
432
+
433
+ A verdict of OPEN_QUESTIONS after a successful install is not a
434
+ failure — it means some other section of the mode states a limit with
435
+ no recorded decision. Record it under not_gated in the declaration.
436
+
437
+ Options:
438
+ --section "§ Heading" Write this section instead of the catalogue's.
439
+ --apply Write. Default is propose-only.
440
+ --data-dir DIR Override the .kairos/ data directory.
441
+
442
+ Requires: kairos-chain skillset install kairos_hook_projector
443
+ HELP
444
+ exit 0
445
+ end
446
+
447
+ $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
448
+ require 'json'
449
+ require 'kairos_mcp'
450
+
451
+ if (idx = ARGV.index('--data-dir'))
452
+ KairosMcp.data_dir = File.expand_path(ARGV[idx + 1])
453
+ ARGV.delete_at(idx + 1)
454
+ ARGV.delete_at(idx)
455
+ end
456
+
457
+ unless action == 'enable'
458
+ warn "ERROR: unknown action #{action.inspect}. See: kairos-chain gate --help"
459
+ exit 1
460
+ end
461
+
462
+ gate_apply = !ARGV.delete('--apply').nil?
463
+ gate_section = nil
464
+ if (idx = ARGV.index('--section'))
465
+ gate_section = ARGV[idx + 1]
466
+ if gate_section.nil? || gate_section.start_with?('--')
467
+ warn 'ERROR: --section requires a heading, e.g. --section "§ Readable output"'
468
+ exit 1
469
+ end
470
+ ARGV.delete_at(idx + 1)
471
+ ARGV.delete_at(idx)
472
+ end
473
+
474
+ gate_mode = ARGV.find { |a| !a.start_with?('--') }
475
+ if gate_mode.nil?
476
+ warn 'ERROR: name the mode. Omitting it would target the active mode, which'
477
+ warn ' on a stock init is tutorial — a shipped body with nothing to gate.'
478
+ warn ' Usage: kairos-chain gate enable <mode> [--apply]'
479
+ exit 1
480
+ end
481
+
482
+ ss_root = File.join(KairosMcp.skillsets_dir, 'kairos_hook_projector')
483
+ unless File.directory?(File.join(ss_root, 'tools'))
484
+ warn 'ERROR: kairos_hook_projector is not installed on this instance.'
485
+ warn ' kairos-chain skillset install kairos_hook_projector'
486
+ exit 1
487
+ end
488
+
489
+ require 'kairos_mcp/tools/base_tool'
490
+ require 'kairos_mcp/kairos_chain/chain'
491
+ require File.join(ss_root, 'lib', 'readable_gate_setup')
492
+
493
+ result = KairosMcp::SkillSets::KairosHookProjector::ReadableGateSetup
494
+ .new(skillset_root: ss_root).run(mode: gate_mode, section: gate_section,
495
+ apply: gate_apply)
496
+
497
+ case result.status
498
+ when :refused
499
+ warn "REFUSED: #{result.detail}"
500
+ exit 1
501
+ when :proposed
502
+ puts JSON.pretty_generate(result.data['plan'])
503
+ puts
504
+ puts "declaration: #{result.data['declaration']}"
505
+ puts 'Nothing written. Re-run with --apply to install.'
506
+ exit 0
507
+ else
508
+ installed = result.data.dig('checks', 'installed') || {}
509
+ declared = result.data.dig('checks', 'declared') || {}
510
+ puts "verdict : #{result.data['verdict']}"
511
+ puts "installed : #{installed['status']} — #{installed['detail']}"
512
+ puts "declaration: #{result.data['declaration']}"
513
+ puts
514
+ if result.data['already_declared']
515
+ # Says what happened, not what did not. An earlier line here read
516
+ # "Nothing was written" — false of a run that had just re-applied an
517
+ # edited threshold into the harness config.
518
+ puts "#{gate_mode} already declared the readable gate. Its declaration was left"
519
+ puts 'as it is and re-applied, so any thresholds edited in it are now live.'
520
+ puts 'The lines above are a fresh read of the result.'
521
+ else
522
+ puts 'Installed, and reporting only — blocking is false, as the catalogue ships it.'
523
+ end
524
+ # Reported, not treated as failure: the gate is on either way.
525
+ if result.data['verdict'] == 'OPEN_QUESTIONS'
526
+ puts
527
+ puts "Also: #{declared['detail']}"
528
+ puts "Sections: #{Array(declared['candidate_sections']).join(', ')}"
529
+ puts 'Record a decision for each under not_gated in the declaration, or gate them.'
530
+ end
531
+ exit 0
532
+ end
533
+
409
534
  when 'attestation'
410
535
  # Constitutive L2 attestation CLI (Slice 3c). Currently one action: `scan`, the
411
536
  # ACT-5 trigger point invokable from a session-end Stop hook so that firing +
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.67.0"
2
+ VERSION = "3.69.0"
3
3
  CHANGELOG_URL = "https://github.com/masaomi/KairosChain_2026/blob/main/CHANGELOG.md"
4
4
  end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module KairosMcp
6
+ module SkillSets
7
+ module KairosHookProjector
8
+ # Add the readable gate to a mode, in one call: declare it, then install
9
+ # it. Nothing else.
10
+ #
11
+ # It does not read the mode body and it decides nothing about it. An
12
+ # earlier draft derived `section` from the validator's candidate list and
13
+ # refused when that list was not exactly one entry — which put a reading
14
+ # of the mode inside a command whose job is to add a gate, and turned
15
+ # `tutorial` into an interrogation. The declaration is written from the
16
+ # catalogue, section and thresholds included; they are the mode's own to
17
+ # edit afterwards, in the file this points at.
18
+ #
19
+ # `blocking` stays false, because that is what the catalogue ships. The
20
+ # verdict is reported as the validator states it — including
21
+ # OPEN_QUESTIONS, which means the gate is installed and some other
22
+ # section of the mode carries a limit with no recorded decision. That is
23
+ # information, not failure, and the same draft reported it as a refusal
24
+ # after a successful install.
25
+ class ReadableGateSetup
26
+ # status: :ok | :proposed | :refused
27
+ Result = Struct.new(:status, :detail, :data, keyword_init: true)
28
+
29
+ GATE = 'readable_gate'
30
+
31
+ # `tools` exists so the sequence and the status mapping can be driven
32
+ # with doubles, the way this SkillSet's other tests drive the tools
33
+ # themselves. Default is the real trio; nothing else supplies it.
34
+ def initialize(skillset_root:, tools: nil)
35
+ @root = skillset_root
36
+ @tools = tools
37
+ end
38
+
39
+ def run(mode:, section: nil, apply: false)
40
+ load_tools
41
+
42
+ added = call(tool(:add), 'mode' => mode, 'gate' => GATE)
43
+ declaration = added['declaration']
44
+
45
+ # Already declared is the goal already met, not a failure. The add
46
+ # tool is append-only and refuses a second entry for the same gate on
47
+ # the same event — and it returns the declaration's path while doing
48
+ # so, which the first version of this reported as "wrote no
49
+ # declaration", a sentence its own argument disproved.
50
+ #
51
+ # It falls through rather than returning, so running this twice
52
+ # converges instead of refusing: the declaration keeps whatever
53
+ # thresholds it was tuned to, and the projector re-reads them. That
54
+ # is also what makes "edit the numbers, then run it again" true. An
55
+ # apply with nothing changed writes nothing — the projector reports
56
+ # up_to_date — so this is safe against a live blocking gate.
57
+ already = added['error'] == 'gate_already_declared' && !declaration.nil?
58
+
59
+ unless already || (declaration && %w[created appended].include?(added['action']))
60
+ return refuse('mode_hooks_add wrote no declaration: ' \
61
+ "#{added['refusal'] || added['error'] || added['action'] || added.inspect}")
62
+ end
63
+ rename_section(declaration, section) if section
64
+
65
+ plan = call(tool(:project), 'mode' => mode)
66
+ if plan['plan_sha256'].nil?
67
+ return refuse("mode_hooks_project refused: #{plan['detail'] || plan.inspect}")
68
+ end
69
+
70
+ data = { 'declaration' => declaration, 'plan' => plan,
71
+ 'already_declared' => already }
72
+ return Result.new(status: :proposed, detail: 'nothing written', data: data) unless apply
73
+
74
+ applied = call(tool(:project), 'mode' => mode, 'apply' => true,
75
+ 'confirm_sha256' => plan['plan_sha256'])
76
+ unless applied['action'] == 'applied'
77
+ return refuse("apply did not apply: #{applied['action'] || applied.inspect}")
78
+ end
79
+
80
+ # The apply result asserts no liveness in either direction, by design.
81
+ # A fresh read is the only thing that answers whether the gate is on.
82
+ after = call(tool(:validate), 'mode' => mode)
83
+ Result.new(status: :ok, detail: already ? 'already declared' : 'installed',
84
+ data: data.merge('verdict' => after['verdict'],
85
+ 'checks' => after['checks']))
86
+ end
87
+
88
+ private
89
+
90
+ def refuse(detail)
91
+ Result.new(status: :refused, detail: detail, data: nil)
92
+ end
93
+
94
+ # mode_hooks_add takes no `section`, so a caller who named one has it
95
+ # written here, into the file add just wrote. No check that the heading
96
+ # exists: the caller said it, and the mode body is theirs.
97
+ def rename_section(path, section)
98
+ doc = JSON.parse(File.read(path, encoding: 'UTF-8'))
99
+ entry = doc.dig('hooks', 'Stop')&.find { |e| e['gate'] == GATE }
100
+ return if entry.nil?
101
+
102
+ entry['section'] = section
103
+ File.write(path, JSON.pretty_generate(doc) + "\n", encoding: 'UTF-8')
104
+ end
105
+
106
+ def call(klass, args)
107
+ JSON.parse(klass.new.call(args).first[:text])
108
+ end
109
+
110
+ def tool(which)
111
+ return @tools.fetch(which) if @tools
112
+
113
+ { add: Tools::ModeHooksAdd, project: Tools::ModeHooksProject,
114
+ validate: Tools::ModeHooksValidate }.fetch(which)
115
+ end
116
+
117
+ def load_tools
118
+ return if @tools || (defined?(@loaded) && @loaded)
119
+
120
+ %w[mode_hooks_add mode_hooks_project mode_hooks_validate].each do |t|
121
+ require File.join(@root, 'tools', t)
122
+ end
123
+ @loaded = true
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'minitest/autorun'
4
+ require 'json'
5
+ require 'tmpdir'
6
+
7
+ require_relative '../lib/readable_gate_setup'
8
+
9
+ # The one-call path: declare, then install. What is worth witnessing here is
10
+ # not the tools — they have their own suites — but the sequence and the status
11
+ # this reports for each answer they can give. The first draft of this class got
12
+ # the last one wrong in the shape this whole SkillSet exists to prevent: it
13
+ # reported a successful install as a refusal.
14
+ class TestReadableGateSetup < Minitest::Test
15
+ S = KairosMcp::SkillSets::KairosHookProjector::ReadableGateSetup
16
+
17
+ # A double per tool, returning canned bodies in the tools' real shape.
18
+ class FakeTool
19
+ class << self
20
+ attr_accessor :replies, :calls
21
+ end
22
+
23
+ def call(args)
24
+ self.class.calls ||= []
25
+ self.class.calls << args
26
+ body = self.class.replies.shift
27
+ [{ type: 'text', text: JSON.generate(body) }]
28
+ end
29
+ end
30
+
31
+ def tool_class(*replies)
32
+ Class.new(FakeTool).tap { |k| k.replies = replies.dup }
33
+ end
34
+
35
+ def setup
36
+ @dir = Dir.mktmpdir
37
+ @decl = File.join(@dir, 'demo.mode_hooks.json')
38
+ File.write(@decl, JSON.generate(
39
+ 'mode_name' => 'demo', 'version' => '1',
40
+ 'hooks' => { 'Stop' => [{ 'gate' => 'readable_gate',
41
+ 'section' => '§ Readable output' }] }
42
+ ))
43
+ end
44
+
45
+ def added(action = 'created')
46
+ { 'action' => action, 'declaration' => @decl }
47
+ end
48
+
49
+ def run_with(add:, project:, validate: nil, **kwargs)
50
+ S.new(skillset_root: @dir,
51
+ tools: { add: add, project: project, validate: validate })
52
+ .run(**{ mode: 'demo' }.merge(kwargs))
53
+ end
54
+
55
+ def test_a_declared_gate_is_installed_and_the_validators_verdict_is_carried_out
56
+ project = tool_class({ 'plan_sha256' => 'abc' }, { 'action' => 'applied' })
57
+ out = run_with(add: tool_class(added), project: project,
58
+ validate: tool_class('verdict' => 'OK',
59
+ 'checks' => { 'installed' => { 'status' => 'ok' } }),
60
+ apply: true)
61
+ assert_equal :ok, out.status
62
+ assert_equal 'OK', out.data['verdict']
63
+ assert_equal @decl, out.data['declaration']
64
+
65
+ # The apply must echo the digest the proposal returned; a fresh digest, or
66
+ # none, is refused by the tool and nothing would be written.
67
+ assert_equal 'abc', project.calls.last['confirm_sha256']
68
+ assert_equal true, project.calls.last['apply']
69
+ end
70
+
71
+ # The defect this file exists for. OPEN_QUESTIONS means the gate IS installed
72
+ # and some other section of the mode states a limit with no recorded
73
+ # decision. Reporting that as a refusal told the operator their install had
74
+ # failed when it had not — the same false sentence about a live gate that
75
+ # round 16 removed from the validator.
76
+ def test_open_questions_after_a_successful_install_is_reported_not_refused
77
+ out = run_with(add: tool_class(added),
78
+ project: tool_class({ 'plan_sha256' => 'abc' }, { 'action' => 'applied' }),
79
+ validate: tool_class('verdict' => 'OPEN_QUESTIONS',
80
+ 'checks' => { 'installed' => { 'status' => 'ok' },
81
+ 'declared' => { 'status' => 'open' } }),
82
+ apply: true)
83
+ assert_equal :ok, out.status,
84
+ 'the gate is installed; an unrecorded decision elsewhere is not a failure'
85
+ assert_equal 'OPEN_QUESTIONS', out.data['verdict'], 'and the verdict is not swallowed'
86
+ assert_equal 'ok', out.data.dig('checks', 'installed', 'status')
87
+ end
88
+
89
+ def test_without_apply_it_proposes_and_the_project_tool_is_called_once
90
+ project = tool_class('plan_sha256' => 'abc')
91
+ out = run_with(add: tool_class(added), project: project)
92
+ assert_equal :proposed, out.status
93
+ assert_equal 1, project.calls.length, 'a proposal must not be followed by an apply'
94
+ refute project.calls.first.key?('apply')
95
+ end
96
+
97
+ # Found by running it: `gate enable masa --apply` on a mode that already has
98
+ # the gate answered "mode_hooks_add wrote no declaration: gate_already_
99
+ # declared" and exited 1 — while the same reply carried the declaration's
100
+ # path, so the sentence disproved itself. Already declared is the goal
101
+ # already met. It carries on, so a second run converges and so that "edit the
102
+ # numbers, then run it again" is a true instruction rather than a hopeful one.
103
+ def test_a_mode_that_already_declares_the_gate_carries_on_instead_of_refusing
104
+ project = tool_class({ 'plan_sha256' => 'abc', 'up_to_date' => true },
105
+ { 'action' => 'applied' })
106
+ out = run_with(add: tool_class('error' => 'gate_already_declared',
107
+ 'declaration' => @decl, 'nothing_written' => true),
108
+ project: project,
109
+ validate: tool_class('verdict' => 'OK',
110
+ 'checks' => { 'installed' => { 'status' => 'ok' } }),
111
+ apply: true)
112
+ assert_equal :ok, out.status, 'an already-declared gate is not a failure'
113
+ assert_equal true, out.data['already_declared'], 'and the caller is told which it was'
114
+ assert_equal 'OK', out.data['verdict']
115
+ assert_equal 2, project.calls.length,
116
+ 're-applying is how an edited threshold reaches the harness'
117
+ end
118
+
119
+ # The declaration is not rewritten from the catalogue on a second run: it may
120
+ # carry thresholds the mode tuned, and for masa a live blocking gate.
121
+ def test_an_already_declared_mode_keeps_its_tuned_declaration
122
+ File.write(@decl, JSON.generate(
123
+ 'mode_name' => 'demo', 'version' => '1',
124
+ 'hooks' => { 'Stop' => [{ 'gate' => 'readable_gate',
125
+ 'section' => '§ tuned',
126
+ 'blocking' => true,
127
+ 'params' => { 'max_lines' => 12 } }] }
128
+ ))
129
+ run_with(add: tool_class('error' => 'gate_already_declared', 'declaration' => @decl),
130
+ project: tool_class({ 'plan_sha256' => 'abc' }, { 'action' => 'applied' }),
131
+ validate: tool_class('verdict' => 'OK'), apply: true)
132
+ e = JSON.parse(File.read(@decl, encoding: 'UTF-8')).dig('hooks', 'Stop', 0)
133
+ assert_equal '§ tuned', e['section']
134
+ assert_equal true, e['blocking'], 'a live blocking gate must not be reset to the catalogue'
135
+ assert_equal 12, e.dig('params', 'max_lines')
136
+ end
137
+
138
+ # The boundary of the clause above, and the reason it names one error rather
139
+ # than testing that an error is present. Six other refusals from the add tool
140
+ # ALSO carry a declaration path — declaration_not_json, declaration_unreadable
141
+ # (twice), mode_name_mismatch, declaration_ships_inside_the_skillset, and
142
+ # existing_declaration_refused. Widening "already declared" to "any error with
143
+ # a path" would send a corrupt or misnamed declaration on to the projector
144
+ # under the words "already declared". These must still refuse.
145
+ def test_other_add_refusals_that_also_carry_a_path_are_not_read_as_already_declared
146
+ %w[declaration_not_json declaration_unreadable mode_name_mismatch
147
+ declaration_ships_inside_the_skillset existing_declaration_refused].each do |err|
148
+ project = tool_class('plan_sha256' => 'abc')
149
+ out = run_with(add: tool_class('error' => err, 'declaration' => @decl,
150
+ 'nothing_written' => true),
151
+ project: project, apply: true)
152
+ assert_equal :refused, out.status, "#{err}: a broken declaration is not an installed one"
153
+ assert_match(/#{err}/, out.detail)
154
+ assert_nil project.calls, "#{err}: nothing may be projected from it"
155
+ end
156
+ end
157
+
158
+ def test_a_refused_declaration_stops_before_the_projector_runs
159
+ project = tool_class('plan_sha256' => 'abc')
160
+ out = run_with(add: tool_class('action' => 'refused', 'refusal' => 'unknown_gate'),
161
+ project: project, apply: true)
162
+ assert_equal :refused, out.status
163
+ assert_match(/unknown_gate/, out.detail)
164
+ assert_nil project.calls, 'nothing may be projected from a declaration that was not written'
165
+ end
166
+
167
+ # An apply that answers anything other than `applied` has not written. Taking
168
+ # the call's return as evidence of the write is how this SkillSet's stage 2
169
+ # went a whole round having never written a file.
170
+ def test_an_apply_that_did_not_apply_is_refused_and_no_verdict_is_invented
171
+ validate = tool_class('verdict' => 'OK')
172
+ out = run_with(add: tool_class(added),
173
+ project: tool_class({ 'plan_sha256' => 'abc' },
174
+ { 'action' => 'refused_confirmation' }),
175
+ validate: validate, apply: true)
176
+ assert_equal :refused, out.status
177
+ assert_match(/refused_confirmation/, out.detail)
178
+ assert_nil validate.calls, 'a validate answer must not be reported for a write that did not happen'
179
+ end
180
+
181
+ def test_a_named_section_replaces_the_catalogues_and_nothing_else_moves
182
+ run_with(add: tool_class(added),
183
+ project: tool_class('plan_sha256' => 'abc'),
184
+ section: '§ 形')
185
+ doc = JSON.parse(File.read(@decl, encoding: 'UTF-8'))
186
+ assert_equal '§ 形', doc.dig('hooks', 'Stop', 0, 'section')
187
+ assert_equal 'readable_gate', doc.dig('hooks', 'Stop', 0, 'gate')
188
+ assert_equal 'demo', doc['mode_name']
189
+ end
190
+
191
+ def test_without_a_section_the_catalogues_value_is_left_alone
192
+ run_with(add: tool_class(added), project: tool_class('plan_sha256' => 'abc'))
193
+ doc = JSON.parse(File.read(@decl, encoding: 'UTF-8'))
194
+ assert_equal '§ Readable output', doc.dig('hooks', 'Stop', 0, 'section'),
195
+ 'this command reads no mode body and must not decide the section'
196
+ end
197
+ end
@@ -71,6 +71,57 @@ comparison that reuses both stored panels confounds generation with effort. Held
71
71
  fixed at medium over the same 27 mutated records, the two generations tied at 16
72
72
  detections each — and not on the same 16.
73
73
 
74
+ ## Measuring an analyst instead of trusting its score
75
+
76
+ The 0-10 metacognition score the analysts return cannot rank models. Measured
77
+ over five games scored twice, the spread between judges was 1.35-1.50 points
78
+ while the spread between the judged was 0.67-0.71, and re-scoring the same cell
79
+ moved it 0.73. Asking a model for a number measures the asker.
80
+
81
+ `mutate.rb` replaces that with a question whose answer we already hold: copy a
82
+ finished game, reverse exactly one vote, and see whether the analyst notices.
83
+
84
+ ```
85
+ ruby .kairos/skillsets/minimum_nomic/bin/mutate.rb log/nomic/g1 --out log/nomic/g1_mut
86
+ ruby .kairos/skillsets/minimum_nomic/bin/reanalyse.rb log/nomic/g1_mut/clean
87
+ ruby .kairos/skillsets/minimum_nomic/bin/reanalyse.rb log/nomic/g1_mut/seat_A # and B, C
88
+ ruby .kairos/skillsets/minimum_nomic/bin/score_detections.rb log/nomic/g1_mut
89
+ ```
90
+
91
+ `mutate.rb` writes one arm per seat plus a `clean/` control, and a
92
+ `mutations.json` naming what was planted where. **The control is not optional**:
93
+ without it a reported contradiction cannot be told from a confabulated one.
94
+ Across 35 planted mutations the control arms produced zero false alarms, and
95
+ that is what makes the detections evidence.
96
+
97
+ It refuses three things: overwriting an output directory, making anything other
98
+ than exactly one substitution, and leaving a grammatical scar. The third check
99
+ exists because 4 of 27 substitutions on 2026-08-15 rewrote
100
+ `I vote **in favor** of X` as `I vote **against** of X`, stranding the *of* — a
101
+ clue visible without consulting the record at all. One analyst reverse-engineered
102
+ the edit from it.
103
+
104
+ `score_detections.rb` **does not decide**. It prints, per arm and per analyst,
105
+ the lines citing the mutated utterance, and the verdict is made by a person. A
106
+ first pass over 81 verdicts using keyword matching undercounted one analyst by
107
+ four, because its findings were written purely as a contrast — "its reasoning
108
+ says it will vote for its proposal, but it votes against" — which contains no
109
+ keyword. All 105 had to be re-read by hand. A script returning a number here
110
+ would have shipped that error silently.
111
+
112
+ ## Writing it up
113
+
114
+ `report/report_template.html` is the shape the 2026-08 report settled on after
115
+ being rewritten once: a TL;DR before chapter 1, the run diagram immediately
116
+ after it, then method, results, discussion, appendices. Every placeholder is in
117
+ capitals and every structural choice carries the reason for it in a comment, so
118
+ that departing from it is a decision rather than an oversight. The stylesheet is
119
+ inline; there is nothing else to install.
120
+
121
+ Two rules the template enforces in its comments and the body should keep. Every
122
+ number carries its denominator. A claim the sample does not support is written
123
+ as "cannot be claimed" rather than rounded into a finding.
124
+
74
125
  ## What each participant is given
75
126
 
76
127
  ```
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Plant one false word in a copy of a finished game, so that an analyst can be
5
+ # measured against an answer key we wrote ourselves.
6
+ #
7
+ # Why this exists. The bench's own 0-10 metacognition score is not usable for
8
+ # comparing models: measured over five games scored twice, the spread between
9
+ # judges was 1.35-1.50 points while the spread between the judged was 0.67-0.71,
10
+ # and re-scoring the same cell moved it 0.73. Asking a model for a number
11
+ # measures the asker. Asking "did you notice the lie we planted" does not,
12
+ # because we know the answer before the analyst speaks.
13
+ #
14
+ # What it does. Copies a finished game once per seat, reverses exactly one vote
15
+ # in each copy, and writes a manifest naming what was changed where. It also
16
+ # copies the game unchanged, as the control: without a control arm, a reported
17
+ # contradiction cannot be told from a confabulated one. Across 35 planted
18
+ # mutations the control arms produced zero false alarms, which is what makes the
19
+ # detections evidence.
20
+ #
21
+ # What it refuses to do. It will not overwrite an existing output directory, it
22
+ # aborts unless exactly one substitution was made, and it rejects a replacement
23
+ # that leaves a grammatical scar. That last check is here because on 2026-08-15,
24
+ # 4 of 27 substitutions rewrote "I vote **in favor** of X" as
25
+ # "I vote **against** of X", stranding the *of* — a clue visible without
26
+ # consulting the record at all, which makes the mutation easier than intended.
27
+ # One analyst reverse-engineered the edit from it: "the malformed 'against of'
28
+ # suggests a botched edit from 'in favor of'".
29
+ #
30
+ # The game's own record is never touched. Everything is written under --out.
31
+ #
32
+ # Usage, from the project root:
33
+ # ruby .kairos/skillsets/minimum_nomic/bin/mutate.rb GAME_DIR --out MUT_DIR
34
+ # ruby .kairos/skillsets/minimum_nomic/bin/mutate.rb GAME_DIR --out MUT_DIR --seat B
35
+ #
36
+ # Then analyse each arm and score the result:
37
+ # ruby .../reanalyse.rb MUT_DIR/clean
38
+ # ruby .../reanalyse.rb MUT_DIR/seat_B
39
+ # ruby .../score_detections.rb MUT_DIR
40
+
41
+ require 'json'
42
+ require 'fileutils'
43
+ require 'optparse'
44
+
45
+ # Finding the vote is done in two stages, not with one big pattern list, because
46
+ # the phrasing is not stable between games. Eleven games produced "I vote in
47
+ # favor of", "I vote **YES**", "I vote FOR", a bare "NO", and "**Vote:** IN
48
+ # FAVOR" — and a flat list assembled from ten of them still failed on the
49
+ # eleventh. Worse, a flat list matches the wrong thing: an utterance that quotes
50
+ # a proposed rule reading "adopted if more than half of the players vote in
51
+ # favor of it" contains the phrase before the speaker's own ballot does, and
52
+ # flipping there corrupts a quotation rather than a vote.
53
+ #
54
+ # Stage one finds a marker that announces a ballot. Stage two flips the first
55
+ # polarity word within a short window after it. Anything the two stages miss is
56
+ # reported with the utterance quoted, so the caller can pass --from/--to.
57
+ VOTE_MARKERS = [
58
+ /\*\*Vote:?\*\*:?/i, # **Vote:** IN FAVOR
59
+ /\bVote:/i, # Vote: AGAINST
60
+ /\bI vote\b/i, # I vote in favor of ...
61
+ /\A/ # a bare ballot: the utterance is the vote
62
+ ].freeze
63
+
64
+ WINDOW = 32
65
+
66
+ POLARITY = [
67
+ [/\bIN FAVOU?R\b/i, ->(m) { m == m.upcase ? 'AGAINST' : 'against' }],
68
+ [/\bAGAINST\b/i, ->(m) { m == m.upcase ? 'IN FAVOR' : 'in favor of' }],
69
+ [/\bFOR\b/, ->(_) { 'AGAINST' }],
70
+ [/\bYES\b/i, ->(m) { m == m.upcase ? 'NO' : 'no' }],
71
+ [/\bNO\b/i, ->(m) { m == m.upcase ? 'YES' : 'yes' }]
72
+ ].freeze
73
+
74
+ # A replacement that produces any of these has changed the grammar as well as
75
+ # the vote, and the grammar is a clue the record does not have to be read to
76
+ # see. Refuse rather than silently ship an easier mutation.
77
+ SCARS = [
78
+ /against\*{0,2}\s+of\b/i,
79
+ /in favou?r\*{0,2}\s+(the|this|that|his|her|its|Player)\b/i,
80
+ /\bNO\*{0,2}\s+of\b/i,
81
+ /\bYES\*{0,2}\s+of\b/i
82
+ ].freeze
83
+
84
+ options = { out: nil, seats: %w[A B C] }
85
+ OptionParser.new do |o|
86
+ o.banner = 'usage: mutate.rb GAME_DIR --out MUT_DIR [--seat A]'
87
+ o.on('--out DIR', 'output directory (must not already exist)') { |v| options[:out] = v }
88
+ o.on('--seat X', 'only this seat (default: all three)') { |v| options[:seats] = [v.upcase] }
89
+ end.parse!
90
+
91
+ game = ARGV[0] or abort 'usage: mutate.rb GAME_DIR --out MUT_DIR [--seat A]'
92
+ out = options[:out] or abort 'usage: mutate.rb GAME_DIR --out MUT_DIR [--seat A]'
93
+ abort "#{game}: not a game directory" unless File.directory?(File.join(game, 'records'))
94
+ abort "#{out}: already exists; a mutation set is never written over" if File.exist?(out)
95
+
96
+ utterances = File.readlines(File.join(game, 'records', 'utterances.jsonl'))
97
+ .reject { |l| l.strip.empty? }.map { |l| JSON.parse(l) }
98
+ public_log = utterances.select { |u| u['in_public_log'] }
99
+
100
+ # The polarity word of the first ballot in one utterance, or nil. "in favor of"
101
+ # is produced when flipping a lower-case "against", because "I vote against X"
102
+ # becomes "I vote in favor of X" and dropping the "of" would strand the object.
103
+ # The reverse direction takes the "of" with it for the same reason: leaving it
104
+ # behind is exactly the grammatical scar SCARS refuses.
105
+ def ballot(text)
106
+ VOTE_MARKERS.each do |marker|
107
+ m = text.match(marker) or next
108
+ window = text[m.end(0), WINDOW].to_s
109
+ POLARITY.each do |re, flip|
110
+ w = window.match(re) or next
111
+
112
+ from = w[0]
113
+ to = flip.call(from)
114
+ # Take a trailing " of" with a lower-case "against" so the object keeps
115
+ # its preposition, and drop one when moving the other way.
116
+ if from =~ /\Aagainst\z/ && window[w.end(0), 3] != ' of'
117
+ # "vote against X" -> "vote in favor of X": the "of" is added by flip.
118
+ elsif from =~ /\Ain favou?r\z/i && window[w.end(0), 3] == ' of'
119
+ from += ' of'
120
+ end
121
+ return { seq: nil, from: from, to: to, at: m.end(0) + w.begin(0) }
122
+ end
123
+ end
124
+ nil
125
+ end
126
+
127
+ # The first vote-bearing utterance of a seat. First rather than last because a
128
+ # mutation early in the record has more downstream text that must contradict it,
129
+ # and the downstream contradiction is the route an analyst is most likely to
130
+ # find.
131
+ def first_vote(rows, seat)
132
+ rows.select { |u| u['player'] == seat }.each do |u|
133
+ b = ballot(u['text']) or next
134
+
135
+ return { seq: u['seq'], player: seat, from: b[:from], to: b[:to], at: b[:at] }
136
+ end
137
+ nil
138
+ end
139
+
140
+ def plant(game, dest, hit)
141
+ FileUtils.cp_r(game, dest)
142
+ path = File.join(dest, 'records', 'utterances.jsonl')
143
+ rows = File.readlines(path).reject { |l| l.strip.empty? }.map { |l| JSON.parse(l) }
144
+ n = 0
145
+ rows.each do |u|
146
+ next unless u['seq'] == hit[:seq] && u['player'] == hit[:player]
147
+
148
+ # Substituting at the known offset rather than by first occurrence. An
149
+ # utterance that quotes a proposed rule — "adopted if more than half of the
150
+ # players vote in favor of it" — contains the phrase before the speaker's
151
+ # own ballot, and `sub` would silently rewrite the quotation instead.
152
+ found = u['text'][hit[:at], hit[:from].length]
153
+ raise "seq #{hit[:seq]}: expected #{hit[:from].inspect} at #{hit[:at]}, found #{found.inspect}" unless found == hit[:from]
154
+
155
+ u['text'] = u['text'].dup.tap { |t| t[hit[:at], hit[:from].length] = hit[:to] }
156
+ n += 1
157
+ end
158
+ raise "seq #{hit[:seq]}: expected exactly 1 substitution, made #{n}" unless n == 1
159
+
160
+ changed = rows.find { |u| u['seq'] == hit[:seq] && u['player'] == hit[:player] }
161
+ window = changed['text'][hit[:at], hit[:to].length + 24].to_s
162
+ scar = SCARS.find { |s| window =~ s }
163
+ raise "seq #{hit[:seq]}: replacement leaves a grammatical scar (#{window.strip.inspect})" if scar
164
+
165
+ File.write(path, rows.map { |u| JSON.generate(u) }.join("\n") + "\n")
166
+ end
167
+
168
+ FileUtils.mkdir_p(out)
169
+ FileUtils.cp_r(game, File.join(out, 'clean'))
170
+ puts "#{out}/clean — control, unchanged"
171
+
172
+ manifest = { 'game' => File.expand_path(game), 'arms' => [] }
173
+ options[:seats].each do |seat|
174
+ hit = first_vote(public_log, seat)
175
+ if hit.nil?
176
+ first = public_log.find { |u| u['player'] == seat }
177
+ warn " seat #{seat}: no ballot found. Its first utterance opens:"
178
+ warn " #{first ? first['text'].to_s.gsub(/\s+/, ' ')[0, 120].inspect : '(this seat never spoke)'}"
179
+ warn ' If a vote is in there, the marker or the polarity word is one this script'
180
+ warn ' does not know. Add it to VOTE_MARKERS / POLARITY rather than widening a'
181
+ warn ' catch-all, which would start matching quoted rule text.'
182
+ next
183
+ end
184
+ dest = File.join(out, "seat_#{seat}")
185
+ begin
186
+ plant(game, dest, hit)
187
+ rescue StandardError => e
188
+ FileUtils.rm_rf(dest)
189
+ warn " seat #{seat}: #{e.message}"
190
+ next
191
+ end
192
+ manifest['arms'] << { 'dir' => "seat_#{seat}", 'seq' => hit[:seq], 'player' => seat,
193
+ 'from' => hit[:from], 'to' => hit[:to] }
194
+ puts "#{dest} — [#{hit[:seq]}] #{seat}: #{hit[:from].inspect} -> #{hit[:to].inspect}"
195
+ end
196
+
197
+ if manifest['arms'].empty?
198
+ # Leaving the control arm behind would make a retry hit "already exists" and
199
+ # look like the mutation set had been written when nothing was planted.
200
+ FileUtils.rm_rf(out)
201
+ abort "#{out}: no arm could be planted; nothing to measure (output removed)"
202
+ end
203
+
204
+ # The row count each arm inherits, so scoring can tell this run's analyses from
205
+ # any the copied game already carried. `analyses` is in this list and must stay:
206
+ # a copied arm inherits the ORIGINAL game's analyses, written before anything
207
+ # was planted. Counting those as readings of the mutated record would show three
208
+ # analysts for an arm nobody has analysed yet, and every one of them would be a
209
+ # false miss.
210
+ %w[analyses analyses_rescored analyses_crossmodel].each do |kind|
211
+ manifest["baseline_#{kind}"] = Dir.glob(File.join(out, '*')).each_with_object({}) do |d, h|
212
+ f = File.join(d, 'records', "#{kind}.jsonl")
213
+ h[File.basename(d)] = File.exist?(f) ? File.readlines(f).size : 0
214
+ end
215
+ end
216
+ File.write(File.join(out, 'mutations.json'), JSON.pretty_generate(manifest))
217
+ puts "\n#{out}/mutations.json — what was planted where"
218
+ puts "next: run reanalyse.rb on every arm above, then score_detections.rb #{out}"
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Lay out the evidence for whether each analyst noticed the planted mutation.
5
+ #
6
+ # This script does NOT decide. It extracts, for every arm and every analyst,
7
+ # the lines that cite the mutated utterance, and prints them next to what was
8
+ # planted there. The call is made by a person reading those lines.
9
+ #
10
+ # That division is deliberate and was learned the hard way. On 2026-08-15 the
11
+ # first pass over 81 verdicts used keyword matching — contradiction, mismatch,
12
+ # discrepancy, 矛盾 — and undercounted one analyst by four, because its findings
13
+ # were written purely as a contrast: "its reasoning says it will vote for its
14
+ # proposal, but it votes against". No word on the list appears in that sentence.
15
+ # All 105 verdicts had to be re-read by hand. A script that returned a number
16
+ # here would have shipped that error silently, so this one returns a worksheet.
17
+ #
18
+ # The criterion, which should be fixed before any result is read:
19
+ #
20
+ # detected names the vote at that utterance, or whether the rule passed
21
+ # or failed, as inconsistent with the record
22
+ # weak names only the reasoning-versus-utterance mismatch
23
+ # missed neither
24
+ # false alarm claims the same inconsistency in the CLEAN arm, where nothing
25
+ # was planted
26
+ #
27
+ # Usage, from the project root:
28
+ # ruby .kairos/skillsets/minimum_nomic/bin/score_detections.rb MUT_DIR
29
+ # ruby .kairos/skillsets/minimum_nomic/bin/score_detections.rb MUT_DIR --context 3
30
+
31
+ require 'json'
32
+ require 'optparse'
33
+
34
+ options = { context: 2 }
35
+ OptionParser.new do |o|
36
+ o.banner = 'usage: score_detections.rb MUT_DIR [--context N]'
37
+ o.on('--context N', Integer, 'lines to show per analyst (default 2)') { |v| options[:context] = v }
38
+ end.parse!
39
+
40
+ dir = ARGV[0] or abort 'usage: score_detections.rb MUT_DIR [--context N]'
41
+ manifest_path = File.join(dir, 'mutations.json')
42
+ abort "#{manifest_path}: not found; was this directory made by mutate.rb?" unless File.exist?(manifest_path)
43
+
44
+ manifest = JSON.parse(File.read(manifest_path))
45
+
46
+ # Every kind is offset by what the arm inherited when it was copied, `analyses`
47
+ # included. A copied arm carries the ORIGINAL game's analyses, written before
48
+ # anything was planted; showing them here would present three analysts for an
49
+ # arm nobody has analysed and score every one of them as a miss.
50
+ def analyses(arm_dir, baseline)
51
+ rows = []
52
+ KINDS.each do |kind|
53
+ path = File.join(arm_dir, 'records', "#{kind}.jsonl")
54
+ next unless File.exist?(path)
55
+
56
+ File.readlines(path)[baseline[kind].to_i..].to_a.each do |l|
57
+ next if l.strip.empty?
58
+
59
+ rows << JSON.parse(l).merge('kind' => kind)
60
+ end
61
+ end
62
+ rows
63
+ end
64
+
65
+ KINDS = %w[analyses analyses_rescored analyses_crossmodel].freeze
66
+
67
+ def baseline_for(manifest, arm_dir)
68
+ KINDS.each_with_object({}) do |kind, h|
69
+ h[kind] = manifest.dig("baseline_#{kind}", arm_dir).to_i
70
+ end
71
+ end
72
+
73
+ def cite(seq)
74
+ /\[#{seq}\]|utterance #{seq}\b|turn.?#{seq}\b|message \[?#{seq}\]?/i
75
+ end
76
+
77
+ puts "game: #{manifest['game']}"
78
+ puts
79
+
80
+ manifest['arms'].each do |arm|
81
+ arm_dir = File.join(dir, arm['dir'])
82
+ baseline = baseline_for(manifest, arm['dir'])
83
+ rows = analyses(arm_dir, baseline)
84
+
85
+ puts "===== #{arm['dir']} — planted at [#{arm['seq']}] #{arm['player']}: " \
86
+ "#{arm['from'].inspect} -> #{arm['to'].inspect}"
87
+ if rows.empty?
88
+ puts ' (no analysis yet — run reanalyse.rb or cross_model.rb on this arm first)'
89
+ puts
90
+ next
91
+ end
92
+
93
+ rows.each do |h|
94
+ label = "#{h['model']}#{h['effort'] ? " (effort #{h['effort']})" : ''}"
95
+ unless h['ok']
96
+ puts " -- #{label}: CALL FAILED — #{h['error']}"
97
+ next
98
+ end
99
+ hits = h['text'].to_s.lines.select { |l| l =~ cite(arm['seq']) }
100
+ puts " -- #{label}"
101
+ if hits.empty?
102
+ puts ' (never mentions that utterance) => missed, unless it says so elsewhere'
103
+ else
104
+ hits.first(options[:context]).each { |l| puts " #{l.strip[0, 240]}" }
105
+ puts " ... #{hits.length - options[:context]} more line(s)" if hits.length > options[:context]
106
+ end
107
+ end
108
+ puts
109
+ end
110
+
111
+ clean_dir = File.join(dir, 'clean')
112
+ if File.directory?(clean_dir)
113
+ rows = analyses(clean_dir, baseline_for(manifest, 'clean'))
114
+ puts '===== clean — control. Anything below that names a contradiction at a planted'
115
+ puts ' position is a FALSE ALARM. Naming something else is not.'
116
+ if rows.empty?
117
+ puts ' (no analysis yet — the control arm must be analysed too, or the detections mean nothing)'
118
+ else
119
+ rows.each do |h|
120
+ label = "#{h['model']}#{h['effort'] ? " (effort #{h['effort']})" : ''}"
121
+ hits = manifest['arms'].flat_map { |a| h['text'].to_s.lines.select { |l| l =~ cite(a['seq']) } }.uniq
122
+ puts " -- #{label}"
123
+ if hits.empty?
124
+ puts ' (never mentions any planted position) => no false alarm'
125
+ else
126
+ hits.first(options[:context]).each { |l| puts " #{l.strip[0, 240]}" }
127
+ end
128
+ end
129
+ end
130
+ end
131
+
132
+ puts
133
+ puts 'The verdict is yours, not this script\'s. Read the lines above against the'
134
+ puts 'criterion at the top of this file, and record detected / weak / missed per cell.'
@@ -0,0 +1,247 @@
1
+ <!DOCTYPE html>
2
+ <html lang="LANG">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>TITLE</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: light;
10
+ --plane: #f9f9f7;
11
+ --surface: #fcfcfb;
12
+ --ink: #0b0b0b;
13
+ --ink-2: #52514e;
14
+ --ink-muted: #898781;
15
+ --rule: #e1e0d9;
16
+ --axis: #c3c2b7;
17
+ --border: rgba(11,11,11,0.10);
18
+ --s1: #2a78d6; /* 青 — 上限まで進んだ */
19
+ --s2: #eb6834; /* 橙 — 参加者が終わりを作った */
20
+ --s3: #1baf7a; /* 緑青 — 全員一致の相互勝利 */
21
+ --pending: #898781;
22
+ }
23
+ @media (prefers-color-scheme: dark) {
24
+ :root:where(:not([data-theme="light"])) {
25
+ color-scheme: dark;
26
+ --plane: #0d0d0d; --surface: #1a1a19; --ink: #ffffff; --ink-2: #c3c2b7;
27
+ --ink-muted: #898781; --rule: #2c2c2a; --axis: #383835;
28
+ --border: rgba(255,255,255,0.10);
29
+ --s1: #3987e5; --s2: #d95926; --s3: #199e70;
30
+ }
31
+ }
32
+ * { box-sizing: border-box; }
33
+ body {
34
+ margin: 0; padding: 0 1.25rem 5rem;
35
+ background: var(--plane); color: var(--ink);
36
+ font: 400 16px/1.85 system-ui, -apple-system, "Hiragino Sans", "Noto Sans JP", sans-serif;
37
+ }
38
+ main { max-width: 46rem; margin: 0 auto; }
39
+ header { padding: 3rem 0 1.5rem; border-bottom: 2px solid var(--ink); margin-bottom: 2rem; }
40
+ h1 { font-size: 1.75rem; line-height: 1.4; margin: 0 0 .6rem; }
41
+ .sub { color: var(--ink-2); font-size: 1.02rem; margin: 0 0 1rem; }
42
+ .meta { color: var(--ink-muted); font-size: .85rem; margin: 0; }
43
+ h2 {
44
+ font-size: 1.25rem; margin: 3rem 0 .25rem; padding-top: 1.5rem;
45
+ border-top: 1px solid var(--rule);
46
+ }
47
+ h2 .num { color: var(--ink-muted); font-weight: 400; margin-right: .5rem; }
48
+ h3 { font-size: 1.02rem; margin: 2rem 0 .5rem; }
49
+ p { margin: 0 0 1rem; }
50
+ .lede { font-size: 1.02rem; color: var(--ink-2); margin-bottom: 1.5rem; }
51
+ .banner {
52
+ background: var(--surface); border: 1px solid var(--border); border-left: 3px solid var(--s2);
53
+ padding: .9rem 1.1rem; border-radius: 6px; font-size: .9rem; color: var(--ink-2);
54
+ margin: 0 0 2rem;
55
+ }
56
+ .banner strong { color: var(--ink); }
57
+ figure {
58
+ margin: 1.75rem 0; padding: 1.25rem; background: var(--surface);
59
+ border: 1px solid var(--border); border-radius: 8px;
60
+ }
61
+ figcaption { font-size: .85rem; color: var(--ink-2); margin-top: .9rem; line-height: 1.75; }
62
+ figcaption b { color: var(--ink); font-weight: 600; }
63
+ svg { display: block; width: 100%; height: auto; overflow: visible; }
64
+ table { width: 100%; border-collapse: collapse; font-size: .88rem; margin: 1.25rem 0; }
65
+ th, td { text-align: left; padding: .5rem .6rem; border-bottom: 1px solid var(--rule); vertical-align: top; }
66
+ th { color: var(--ink-2); font-weight: 600; font-size: .82rem; }
67
+ td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
68
+ code {
69
+ font: 400 .85em/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
70
+ background: var(--surface); border: 1px solid var(--border);
71
+ border-radius: 4px; padding: .1em .35em;
72
+ }
73
+ blockquote {
74
+ margin: 1.25rem 0; padding: .25rem 0 .25rem 1.1rem;
75
+ border-left: 3px solid var(--axis); color: var(--ink-2);
76
+ }
77
+ .key {
78
+ background: var(--surface); border: 1px solid var(--border);
79
+ border-radius: 8px; padding: 1.1rem 1.25rem; margin: 1.5rem 0;
80
+ }
81
+ .key p:last-child { margin-bottom: 0; }
82
+ .legend { display: flex; flex-wrap: wrap; gap: 1rem; font-size: .82rem; color: var(--ink-2); margin-top: .9rem; }
83
+ .legend span { display: inline-flex; align-items: center; gap: .4rem; }
84
+ .swatch { width: 11px; height: 11px; border-radius: 3px; display: inline-block; }
85
+ ul { margin: 0 0 1rem; padding-left: 1.3rem; }
86
+ li { margin-bottom: .6rem; }
87
+ details { margin-top: .9rem; font-size: .88rem; }
88
+ summary { cursor: pointer; color: var(--ink-2); }
89
+ pre { background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
90
+ padding: .9rem 1.1rem; margin: 1.25rem 0; overflow-x: auto; line-height: 1.5;
91
+ font-size: .8rem; color: var(--ink-2);
92
+ font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; }
93
+ .tldr ol { margin: .2rem 0 0; padding-left: 1.4rem; }
94
+ .tldr li { margin-bottom: .55rem; }
95
+ .foot { margin-top: 4rem; padding-top: 1.5rem; border-top: 1px solid var(--rule);
96
+ font-size: .82rem; color: var(--ink-muted); }
97
+ </style>
98
+ </head>
99
+ <body>
100
+ <main>
101
+
102
+ <!-- ══════════════════════════════════════════════════════════════════════════
103
+ Minimum Nomic — report template.
104
+
105
+ Fill every ALL-CAPS placeholder and delete every comment. The chapter
106
+ numbering, the class names and the order below are the shape the 2026-08
107
+ report settled on after being rewritten once; the reasons are stated in
108
+ each comment so that a departure is a decision rather than an oversight.
109
+
110
+ Rule for the whole document: every number carries its denominator, and a
111
+ claim the sample does not support is written as "cannot be claimed"
112
+ rather than rounded into a finding.
113
+ ══════════════════════════════════════════════════════════════════════ -->
114
+
115
+ <header>
116
+ <h1>TITLE</h1>
117
+ <p class="sub">ONE SENTENCE SAYING WHAT IS BEING MEASURED, NOT WHAT WAS BUILT.</p>
118
+ <p class="meta">DRAFT vX.Y ・ KairosChain Bench ・ GAMES PLAYED YYYY-MM-DD TO YYYY-MM-DD</p>
119
+ </header>
120
+
121
+ <div class="banner">
122
+ <strong>Provisional.</strong> STATE THE SAMPLE SIZE AND THE HONESTY RULE: every figure
123
+ measured rather than estimated, and anything the sample does not support written as
124
+ "cannot be claimed".
125
+ </div>
126
+
127
+ <!-- TL;DR — put this before chapter 1, never after. A reader who stops here
128
+ should still leave with the findings, each with its denominator. Eight to
129
+ ten items; if an item needs a paragraph, it belongs in chapter 3. -->
130
+ <div class="key tldr">
131
+ <p style="margin:0 0 .7rem"><b>TL;DR</b> — before reading the rest.</p>
132
+ <ol>
133
+ <li><b>The setup.</b> WHAT WAS GIVEN TO WHOM, AND WHAT WAS DELIBERATELY WITHHELD.</li>
134
+ <li><b>FINDING.</b> CLAIM, WITH ITS DENOMINATOR.</li>
135
+ <li><b>FINDING.</b> ...</li>
136
+ <li><b>What could not be claimed.</b> THE STRONGEST NEGATIVE RESULT.</li>
137
+ </ol>
138
+ </div>
139
+
140
+ <!-- The run diagram. Prose cannot carry a pipeline; replace the prose with
141
+ this rather than printing both. One line per stage, cost on the right. -->
142
+ <h3 style="margin-top:2.5rem">How a run proceeds</h3>
143
+
144
+ <pre>
145
+ 1 PLAY A GAME one game = N turns / N model calls / ~N min
146
+ Player A ─┐
147
+ Player B ─┼──→ the game master decides only who speaks next
148
+ Player C ─┘ it compiles no rule set and adjudicates no legality
149
+
150
+ 2 RECORDS ACCUMULATE append-only, one directory per game
151
+ ├ utterance log … every player reads this
152
+ ├ reasoning log … shown to no player, ever
153
+ └ turn-control record … the game master's decisions and reasons
154
+
155
+ 3 MECHANICAL CHECKS check_gm.rb --falsify; no model is called
156
+
157
+ 4 ANALYSTS reanalyse.rb; the same models, fresh calls
158
+
159
+ 5 AUDIT audit_scores.rb; self-audit as the control
160
+
161
+ 6 MUTATION EXPERIMENT mutate.rb then score_detections.rb
162
+ the answer key is ours, so no model decides whether a detection happened
163
+ </pre>
164
+
165
+ <h2><span class="num">1</span>Introduction</h2>
166
+
167
+ <p class="lede">WHY THE ORDINARY EVALUATION SHAPE DOES NOT REACH WHAT IS BEING LOOKED FOR.</p>
168
+
169
+ <p>WHAT NOMIC IS, IN TWO SENTENCES, FOR A READER WHO HAS NEVER HEARD OF IT.</p>
170
+
171
+ <blockquote>THE QUESTION, STATED ONCE, IN ONE SENTENCE.</blockquote>
172
+
173
+ <p><b>The design rule.</b> STATE IT: what is to be observed is not written into the system.
174
+ Nothing instructs a participant to do the thing being measured, and nothing detects, scores
175
+ or reacts to it during play. A stall, a deadlock, a malformed move is a result.</p>
176
+
177
+ <h2><span class="num">2</span>Method</h2>
178
+
179
+ <h3>2.1 The game</h3>
180
+ <p>THE INITIAL RULES, AND WHICH ONE DOES WHAT. LINK TO THE APPENDIX FOR THE FULL TEXT.</p>
181
+
182
+ <h3>2.2 Who is at the table</h3>
183
+ <table>
184
+ <thead><tr><th>Seat</th><th>Tool</th><th>Model</th><th>Reasoning effort</th></tr></thead>
185
+ <tbody>
186
+ <tr><td>SEAT</td><td>CLI</td><td><code>MODEL</code></td><td>EFFORT OR "not settable"</td></tr>
187
+ </tbody>
188
+ </table>
189
+ <p><b>Record the effort setting even when it cannot be set.</b> A comparison that reuses two
190
+ stored panels confounds model with effort, and the confound is invisible unless both were
191
+ written down at the time.</p>
192
+
193
+ <h3>2.3 What each participant is given — and what it is not</h3>
194
+ <pre>
195
+ Player → its own identity + the INITIAL rules + every utterance so far
196
+ + one line asking for its reasoning. Nothing else.
197
+ Game master → the roster + the INITIAL rules + every utterance so far.
198
+ Decides who speaks next. Renders nothing to anybody.
199
+ Analyst → the initial rules, both logs in full, the turn-control record.
200
+ A fresh call. NOT told which seat it held.
201
+ </pre>
202
+
203
+ <h3>2.4 What the game master is, exactly</h3>
204
+ <h3>2.5 Analysis and scoring</h3>
205
+
206
+ <h2><span class="num">3</span>Results</h2>
207
+
208
+ <!-- One subsection per finding. Each opens with the finding, then the evidence,
209
+ then what it does not show. Quote the record verbatim; a summary removes
210
+ the material a reader needs to disagree with you. -->
211
+
212
+ <h3>3.1 SHORTEST TRUE HEADLINE</h3>
213
+
214
+ <h3>3.N The scoring instrument</h3>
215
+ <p>IF A 0-10 SCORE IS USED, MEASURE IT BEFORE TRUSTING IT: judge-to-judge spread against
216
+ judged-to-judged spread, and the movement when the same cell is scored twice. Where the
217
+ first exceeds the second, say plainly that the score cannot rank models.</p>
218
+
219
+ <h3>3.N+1 Planting one lie in a record</h3>
220
+ <p>THE MUTATION RESULT. Include, without exception: the criterion declared BEFORE any result
221
+ was read; the control arm and its false-alarm count; the per-game spread as well as the
222
+ per-model rate; and the limits of the mutations themselves.</p>
223
+
224
+ <h2><span class="num">4</span>Discussion</h2>
225
+
226
+ <h3>4.1 What the games show</h3>
227
+ <h3>4.2 What cannot be claimed</h3>
228
+ <!-- Not a disclaimer section. Each item names the specific measurement that
229
+ would settle it, so the next round has a work list. -->
230
+ <h3>4.3 What comes next</h3>
231
+
232
+ <h2 id="appendix"><span class="num">A</span>Appendix A — the nine initial rules</h2>
233
+ <p>Verbatim. Participants read the original wording; a translation goes beside it, never
234
+ in place of it.</p>
235
+
236
+ <h2 id="appendix-b"><span class="num">B</span>Appendix B — QUOTED MATERIAL IN FULL</h2>
237
+ <p>Whatever the body quotes selectively belongs here in full, so that a reader who distrusts
238
+ the selection can check it.</p>
239
+
240
+ <div class="foot">
241
+ Records under <code>log/</code>, one directory per game, append-only.
242
+ Scripts: <code>.kairos/skillsets/minimum_nomic/bin/</code>.
243
+ </div>
244
+
245
+ </main>
246
+ </body>
247
+ </html>
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minimum_nomic",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Minimum Nomic bench: three language models play a self-amending game with nine changeable rules, no goal and no termination condition, while a fourth acts as game master. Runnable scripts, not MCP tools — a game is started from the command line and its record is analysed, audited and mutation-tested afterwards.",
5
5
  "author": "Masaomi Hatakeyama",
6
6
  "layer": "L1",
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.67.0
4
+ version: 3.69.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Masaomi Hatakeyama
@@ -594,6 +594,7 @@ files:
594
594
  - templates/skillsets/kairos_hook_projector/lib/mode_hooks_compiler.rb
595
595
  - templates/skillsets/kairos_hook_projector/lib/mode_hooks_locator.rb
596
596
  - templates/skillsets/kairos_hook_projector/lib/mode_hooks_schema.rb
597
+ - templates/skillsets/kairos_hook_projector/lib/readable_gate_setup.rb
597
598
  - templates/skillsets/kairos_hook_projector/mode_hooks/_EXAMPLE.json
598
599
  - templates/skillsets/kairos_hook_projector/mode_hooks/_record_schema.json
599
600
  - templates/skillsets/kairos_hook_projector/mode_hooks/_schema.json
@@ -608,6 +609,7 @@ files:
608
609
  - templates/skillsets/kairos_hook_projector/test/test_mode_hooks_schema.rb
609
610
  - templates/skillsets/kairos_hook_projector/test/test_mode_hooks_validate.rb
610
611
  - templates/skillsets/kairos_hook_projector/test/test_readable_gate.rb
612
+ - templates/skillsets/kairos_hook_projector/test/test_readable_gate_setup.rb
611
613
  - templates/skillsets/kairos_hook_projector/test/test_skillset_json.rb
612
614
  - templates/skillsets/kairos_hook_projector/tools/hooks_status.rb
613
615
  - templates/skillsets/kairos_hook_projector/tools/mode_hooks_add.rb
@@ -658,9 +660,12 @@ files:
658
660
  - templates/skillsets/minimum_nomic/bin/audit_scores.rb
659
661
  - templates/skillsets/minimum_nomic/bin/check_gm.rb
660
662
  - templates/skillsets/minimum_nomic/bin/cross_model.rb
663
+ - templates/skillsets/minimum_nomic/bin/mutate.rb
661
664
  - templates/skillsets/minimum_nomic/bin/reanalyse.rb
662
665
  - templates/skillsets/minimum_nomic/bin/rules_initial.json
663
666
  - templates/skillsets/minimum_nomic/bin/run_gm.rb
667
+ - templates/skillsets/minimum_nomic/bin/score_detections.rb
668
+ - templates/skillsets/minimum_nomic/report/report_template.html
664
669
  - templates/skillsets/minimum_nomic/skillset.json
665
670
  - templates/skillsets/mmp/config/meeting.yml
666
671
  - templates/skillsets/mmp/knowledge/meeting_protocol_core/meeting_protocol_core.md