kairos-chain 3.77.0 → 3.78.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/CHANGELOG.md +23 -0
- data/lib/kairos_mcp/version.rb +1 -1
- data/templates/skillsets/kairos_hook_projector/hooks/readable_gate.rb +50 -16
- data/templates/skillsets/kairos_hook_projector/test/mutation_check_readable_gate.rb +44 -1
- data/templates/skillsets/kairos_hook_projector/test/test_readable_gate.rb +144 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3bdf5a0932b91258ccacf0520ca20eb252b7080c7ef3682f87edb8fdb07144c6
|
|
4
|
+
data.tar.gz: '09f911f59d0aba91f246f5ec6721cc4ee11f8534bd96ed73fc31edd3df1a78ff'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 884fe864157b0c5ea27cc9249de0378f4c8e53d0b724ef6c92e9adc85dd1a092e8c7291c1e0e778a20d3edf4058da92801968733f38017a73df60eb7b552ade3
|
|
7
|
+
data.tar.gz: c9061d247bee27b8525dae1d14f4acd1036e27d2c024b74228c43bc23211329285d418689aa6314c70f2e5f6060b9df4ad68e118870b680f6c411ab5d2de5cc7
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,29 @@ 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.78.0] - 2026-08-26
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **`readable_gate` — a failed note write no longer leaves an earlier turn's
|
|
12
|
+
note in place.** The carry-over note (shipped in 3.77.0) records which
|
|
13
|
+
record the gate blocked, so the recheck measures only what came after it. A
|
|
14
|
+
leftover note from an interrupted turn — 28 of 240 blocks draw no recheck —
|
|
15
|
+
survived the next block's failed note write, checked out as valid, and the
|
|
16
|
+
recheck judged the message that block had just made. A record with no uuid
|
|
17
|
+
was input enough. Every write failure now spends the leftover: deleted, or —
|
|
18
|
+
when deletion is refused — truncated in place, since deletion needs
|
|
19
|
+
directory-write while truncation needs only file-write, and an emptied note
|
|
20
|
+
fails the parse on every later read whatever the directory permits by then.
|
|
21
|
+
Only a leftover refusing both deletion and truncation survives, and the
|
|
22
|
+
design declares that state rather than claiming it closed. Found by
|
|
23
|
+
conformance review rounds 2-4: six reading contexts per round answering a
|
|
24
|
+
fixed claim list (21 → 4 → 2 → 1 claims), each finding reproduced end-to-end
|
|
25
|
+
before its fix, and round 4 closing with zero claims disputed. 133 tests /
|
|
26
|
+
815 assertions; 54 mutations killed 54/54; first-read stdout byte-identical
|
|
27
|
+
to the 3.74.0 baseline across up to 2,520 inputs measured by four
|
|
28
|
+
independent contexts.
|
|
29
|
+
|
|
7
30
|
## [3.77.0] - 2026-08-26
|
|
8
31
|
|
|
9
32
|
### Added
|
data/lib/kairos_mcp/version.rb
CHANGED
|
@@ -431,27 +431,61 @@ module KairosHookProjector
|
|
|
431
431
|
# Written to a temporary name and renamed, so a reader can never see a
|
|
432
432
|
# half-written note. The pid is in the temporary name because two modes'
|
|
433
433
|
# gates can run against one session at the same moment.
|
|
434
|
+
#
|
|
435
|
+
# After a block, the note is this block's or absent. A write that succeeds
|
|
436
|
+
# replaces any earlier note through the rename; a write that fails must not
|
|
437
|
+
# leave one either, because a leftover from an interrupted turn checks out
|
|
438
|
+
# as valid — fresh, same session, deletable — and the recheck then measures
|
|
439
|
+
# against the wrong block: round 2 of the conformance review demonstrated
|
|
440
|
+
# the message this turn blocked being judged a second time. So every
|
|
441
|
+
# failure path spends whatever note is already at the key.
|
|
434
442
|
def write_note(transcript_path, cfg, uuid)
|
|
435
|
-
return 'no uuid to record' if uuid.nil? || uuid.to_s.empty?
|
|
436
|
-
|
|
437
443
|
path = note_path(transcript_path, cfg)
|
|
438
444
|
return nil if path.nil? # no log declared: not a failure, a declared absence
|
|
439
445
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
446
|
+
return spend_stale_note(path, 'no uuid to record') if uuid.nil? || uuid.to_s.empty?
|
|
447
|
+
|
|
448
|
+
begin
|
|
449
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
450
|
+
tmp = "#{path}.#{Process.pid}.tmp"
|
|
451
|
+
File.write(
|
|
452
|
+
tmp,
|
|
453
|
+
JSON.generate(
|
|
454
|
+
'transcript' => note_key_path(transcript_path).scrub,
|
|
455
|
+
'mode' => cfg.mode_name.to_s.scrub,
|
|
456
|
+
'blocked_uuid' => uuid.to_s.scrub,
|
|
457
|
+
'at' => Time.now.to_f
|
|
458
|
+
)
|
|
449
459
|
)
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
460
|
+
File.rename(tmp, path)
|
|
461
|
+
nil
|
|
462
|
+
rescue StandardError => e
|
|
463
|
+
spend_stale_note(path, "#{e.class}: #{e.message}")
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# Deletion first, truncation as the fallback. "A leftover that cannot be
|
|
468
|
+
# deleted here cannot be spent at the recheck either" was round 3's found
|
|
469
|
+
# assumption: it identifies write-time state with read-time state, and a
|
|
470
|
+
# directory made writable again inside the block-to-recheck window let the
|
|
471
|
+
# recheck spend a leftover this path had already failed to delete. Deleting
|
|
472
|
+
# needs directory-write; truncating needs only file-write, so the fallback
|
|
473
|
+
# usually works exactly where the unlink fails, and an emptied note fails
|
|
474
|
+
# the parse on every later read whatever the directory permits by then.
|
|
475
|
+
# Only when both are refused does the leftover stay valid, and that state
|
|
476
|
+
# is declared in design §7 rather than closed.
|
|
477
|
+
def spend_stale_note(path, reason)
|
|
478
|
+
File.unlink(path)
|
|
479
|
+
"#{reason}; a stale note was deleted"
|
|
480
|
+
rescue Errno::ENOENT
|
|
481
|
+
reason
|
|
482
|
+
rescue StandardError
|
|
483
|
+
begin
|
|
484
|
+
File.write(path, '')
|
|
485
|
+
"#{reason}; a stale note could not be deleted and was emptied instead"
|
|
486
|
+
rescue StandardError => e
|
|
487
|
+
"#{reason}; a stale note could not be deleted (#{e.class})"
|
|
488
|
+
end
|
|
455
489
|
end
|
|
456
490
|
|
|
457
491
|
# [uuid this gate blocked, nil] or [nil, why not].
|
|
@@ -233,8 +233,10 @@ MUTATIONS = [
|
|
|
233
233
|
note(cfg, "NOTE-WRITE-FAILED: #{failure}")
|
|
234
234
|
out.delete('decision')
|
|
235
235
|
end}],
|
|
236
|
+
# Retargeted after round 2: the no-uuid return now spends the leftover first,
|
|
237
|
+
# so the anchor moved with it. The mutation is unchanged in spirit.
|
|
236
238
|
['N18 write_note records a uuid it was not given',
|
|
237
|
-
%q{ return 'no uuid to record' if uuid.nil? || uuid.to_s.empty?},
|
|
239
|
+
%q{ return spend_stale_note(path, 'no uuid to record') if uuid.nil? || uuid.to_s.empty?},
|
|
238
240
|
%q{ uuid = 'unknown' if uuid.nil? || uuid.to_s.empty?}],
|
|
239
241
|
['N19 the note is written whole rather than renamed into place',
|
|
240
242
|
%q{ File.rename(tmp, path)},
|
|
@@ -272,6 +274,47 @@ MUTATIONS = [
|
|
|
272
274
|
blocked_uuid, why_not = take_note(transcript_path, cfg)
|
|
273
275
|
blocked_uuid, why_not = take_note(transcript_path, cfg) if blocked_uuid},
|
|
274
276
|
],
|
|
277
|
+
|
|
278
|
+
# --- what round 2 of the conformance review found --------------------------
|
|
279
|
+
#
|
|
280
|
+
# A leftover note from an interrupted turn is valid on its face, so a block
|
|
281
|
+
# whose own note write fails must spend it — otherwise the recheck reads the
|
|
282
|
+
# leftover and judges the message this turn just blocked. One seat of six
|
|
283
|
+
# found it, by composing two declared behaviours (the 11.7% unconsumed-note
|
|
284
|
+
# population and the no-uuid write failure) that every fixture had exercised
|
|
285
|
+
# only in isolation.
|
|
286
|
+
['N29 the spend is a no-op that still reports success',
|
|
287
|
+
%q{ def spend_stale_note(path, reason)
|
|
288
|
+
File.unlink(path)},
|
|
289
|
+
%q{ def spend_stale_note(path, reason)
|
|
290
|
+
return reason if path
|
|
291
|
+
File.unlink(path)}],
|
|
292
|
+
['N30 the no-uuid failure path stops spending the leftover',
|
|
293
|
+
%q{ return spend_stale_note(path, 'no uuid to record') if uuid.nil? || uuid.to_s.empty?},
|
|
294
|
+
%q{ return 'no uuid to record' if uuid.nil? || uuid.to_s.empty?}],
|
|
295
|
+
['N31 the write\'s exception path stops spending the leftover',
|
|
296
|
+
%q{ spend_stale_note(path, "#{e.class}: #{e.message}")},
|
|
297
|
+
%q{ "#{e.class}: #{e.message}"}],
|
|
298
|
+
|
|
299
|
+
# --- what round 3 of the conformance review found --------------------------
|
|
300
|
+
#
|
|
301
|
+
# Unspendability is not stable: a directory that refuses the unlink at block
|
|
302
|
+
# time and permits it again before the recheck let the recheck spend the
|
|
303
|
+
# leftover this block had failed to delete. Three of five verifying contexts
|
|
304
|
+
# reached the same window independently. The truncation fallback is what
|
|
305
|
+
# closes it; this deletes the fallback and the window reopens.
|
|
306
|
+
['N32 the truncation fallback is deleted, so a lifted window revives the leftover',
|
|
307
|
+
%q{ rescue StandardError
|
|
308
|
+
begin
|
|
309
|
+
File.write(path, '')
|
|
310
|
+
"#{reason}; a stale note could not be deleted and was emptied instead"
|
|
311
|
+
rescue StandardError => e
|
|
312
|
+
"#{reason}; a stale note could not be deleted (#{e.class})"
|
|
313
|
+
end
|
|
314
|
+
end},
|
|
315
|
+
%q{ rescue StandardError => e
|
|
316
|
+
"#{reason}; a stale note could not be deleted (#{e.class})"
|
|
317
|
+
end}],
|
|
275
318
|
].freeze
|
|
276
319
|
|
|
277
320
|
def run_suite(root)
|
|
@@ -2479,4 +2479,148 @@ class TestReadableGate < Minitest::Test
|
|
|
2479
2479
|
assert_includes message, 'stamped ahead of the clock', message
|
|
2480
2480
|
end
|
|
2481
2481
|
end
|
|
2482
|
+
|
|
2483
|
+
# --- what round 2 of the conformance review found ---------------------------
|
|
2484
|
+
#
|
|
2485
|
+
# A leftover note from an interrupted turn checks out as valid: fresh, same
|
|
2486
|
+
# session, deletable. When the next block's own note write fails, the recheck
|
|
2487
|
+
# read that leftover and judged the message this turn had just blocked — no
|
|
2488
|
+
# foreign hook involved, no filesystem failure needed (a record with no uuid
|
|
2489
|
+
# is enough). After a block, the note must be this block's or absent.
|
|
2490
|
+
|
|
2491
|
+
def test_a_failed_note_write_also_spends_the_note_an_earlier_turn_left
|
|
2492
|
+
Dir.mktmpdir do |tmp|
|
|
2493
|
+
cfg_path = File.join(tmp, 'cfg.json')
|
|
2494
|
+
tx_path = File.join(tmp, 't.jsonl')
|
|
2495
|
+
log_path = File.join(tmp, 'gate.log')
|
|
2496
|
+
raw = { 'mode_name' => 'test', 'log_path' => log_path, 'max_headings' => 3 }
|
|
2497
|
+
File.write(cfg_path, JSON.generate(raw), encoding: 'UTF-8')
|
|
2498
|
+
File.write(tx_path, rows_json([row_for('assistant', text: BLOCKED, uuid: 'AAA'),
|
|
2499
|
+
row_for('assistant', text: BLOCKED)]),
|
|
2500
|
+
encoding: 'UTF-8')
|
|
2501
|
+
cfg_obj = G::Config.new(raw, cfg_path)
|
|
2502
|
+
assert_nil G.write_note(tx_path, cfg_obj, 'AAA'), 'the interrupted turn left its note'
|
|
2503
|
+
|
|
2504
|
+
out, = run_script(cfg_path, JSON.generate('transcript_path' => tx_path,
|
|
2505
|
+
'stop_hook_active' => false))
|
|
2506
|
+
assert_equal 'block', JSON.parse(out)['decision'], 'the turn is still blocked'
|
|
2507
|
+
log = File.read(log_path, encoding: 'UTF-8')
|
|
2508
|
+
assert_includes log, 'NOTE-WRITE-FAILED: no uuid to record; a stale note was deleted', log
|
|
2509
|
+
assert_empty Dir.glob(File.join(File.dirname(G.note_path(tx_path, cfg_obj)), '*')),
|
|
2510
|
+
'the leftover would name a block this turn did not make'
|
|
2511
|
+
|
|
2512
|
+
_out2, = run_script(cfg_path, JSON.generate('transcript_path' => tx_path,
|
|
2513
|
+
'stop_hook_active' => true))
|
|
2514
|
+
log = File.read(log_path, encoding: 'UTF-8')
|
|
2515
|
+
assert_includes log, 'SKIP-nonote', "the recheck must decline, not judge: #{log}"
|
|
2516
|
+
refute_includes log, 'RECHECK-',
|
|
2517
|
+
"a verdict here re-judges the message this turn blocked: #{log}"
|
|
2518
|
+
end
|
|
2519
|
+
end
|
|
2520
|
+
|
|
2521
|
+
# When the leftover cannot be spent either, the two failures must compose to
|
|
2522
|
+
# the same refusal: the leftover stays, and the recheck refuses it as the
|
|
2523
|
+
# note it cannot delete.
|
|
2524
|
+
def test_a_leftover_the_failed_write_cannot_spend_is_refused_at_the_recheck
|
|
2525
|
+
Dir.mktmpdir do |tmp|
|
|
2526
|
+
cfg_path = File.join(tmp, 'cfg.json')
|
|
2527
|
+
tx_path = File.join(tmp, 't.jsonl')
|
|
2528
|
+
logs = File.join(tmp, 'logs')
|
|
2529
|
+
FileUtils.mkdir_p(logs)
|
|
2530
|
+
log_path = File.join(logs, 'gate.log')
|
|
2531
|
+
raw = { 'mode_name' => 'test', 'log_path' => log_path, 'max_headings' => 3 }
|
|
2532
|
+
File.write(cfg_path, JSON.generate(raw), encoding: 'UTF-8')
|
|
2533
|
+
File.write(tx_path, rows_json([row_for('assistant', text: BLOCKED, uuid: 'AAA'),
|
|
2534
|
+
row_for('assistant', text: BLOCKED, uuid: 'BBB')]),
|
|
2535
|
+
encoding: 'UTF-8')
|
|
2536
|
+
cfg_obj = G::Config.new(raw, cfg_path)
|
|
2537
|
+
assert_nil G.write_note(tx_path, cfg_obj, 'AAA'), 'the interrupted turn left its note'
|
|
2538
|
+
notes = File.dirname(G.note_path(tx_path, cfg_obj))
|
|
2539
|
+
|
|
2540
|
+
File.chmod(0o555, notes) # neither the new write nor the spend can touch it
|
|
2541
|
+
begin
|
|
2542
|
+
out, = run_script(cfg_path, JSON.generate('transcript_path' => tx_path,
|
|
2543
|
+
'stop_hook_active' => false))
|
|
2544
|
+
assert_equal 'block', JSON.parse(out)['decision'], 'the turn is still blocked'
|
|
2545
|
+
log = File.read(log_path, encoding: 'UTF-8')
|
|
2546
|
+
assert_includes log, 'a stale note could not be deleted',
|
|
2547
|
+
"the second failure must be visible too: #{log}"
|
|
2548
|
+
|
|
2549
|
+
run_script(cfg_path, JSON.generate('transcript_path' => tx_path,
|
|
2550
|
+
'stop_hook_active' => true))
|
|
2551
|
+
ensure
|
|
2552
|
+
File.chmod(0o755, notes)
|
|
2553
|
+
end
|
|
2554
|
+
|
|
2555
|
+
log = File.read(log_path, encoding: 'UTF-8')
|
|
2556
|
+
assert_includes log, 'SKIP-nonote-unspent',
|
|
2557
|
+
"the unspendable leftover must be refused: #{log}"
|
|
2558
|
+
refute_includes log, 'RECHECK-',
|
|
2559
|
+
"a verdict here re-judges the message this turn blocked: #{log}"
|
|
2560
|
+
end
|
|
2561
|
+
end
|
|
2562
|
+
|
|
2563
|
+
# Round 3: unspendability is not stable. A directory that refuses the unlink
|
|
2564
|
+
# at block time and permits it again before the recheck let the recheck spend
|
|
2565
|
+
# the leftover this block had failed to delete — round 2's defect through a
|
|
2566
|
+
# transient window. Deleting needs directory-write; truncating needs only
|
|
2567
|
+
# file-write; an emptied note fails the parse on every later read, whatever
|
|
2568
|
+
# the directory permits by then.
|
|
2569
|
+
def test_a_leftover_spent_by_truncation_stays_spent_after_the_window_lifts
|
|
2570
|
+
Dir.mktmpdir do |tmp|
|
|
2571
|
+
cfg_path = File.join(tmp, 'cfg.json')
|
|
2572
|
+
tx_path = File.join(tmp, 't.jsonl')
|
|
2573
|
+
logs = File.join(tmp, 'logs')
|
|
2574
|
+
FileUtils.mkdir_p(logs)
|
|
2575
|
+
log_path = File.join(logs, 'gate.log')
|
|
2576
|
+
raw = { 'mode_name' => 'test', 'log_path' => log_path, 'max_headings' => 3 }
|
|
2577
|
+
File.write(cfg_path, JSON.generate(raw), encoding: 'UTF-8')
|
|
2578
|
+
File.write(tx_path, rows_json([row_for('assistant', text: BLOCKED, uuid: 'AAA'),
|
|
2579
|
+
row_for('assistant', text: BLOCKED, uuid: 'BBB')]),
|
|
2580
|
+
encoding: 'UTF-8')
|
|
2581
|
+
cfg_obj = G::Config.new(raw, cfg_path)
|
|
2582
|
+
assert_nil G.write_note(tx_path, cfg_obj, 'AAA'), 'the interrupted turn left its note'
|
|
2583
|
+
notes = File.dirname(G.note_path(tx_path, cfg_obj))
|
|
2584
|
+
|
|
2585
|
+
File.chmod(0o555, notes) # the write and the unlink both fail; file-write does not
|
|
2586
|
+
begin
|
|
2587
|
+
out, = run_script(cfg_path, JSON.generate('transcript_path' => tx_path,
|
|
2588
|
+
'stop_hook_active' => false))
|
|
2589
|
+
assert_equal 'block', JSON.parse(out)['decision'], 'the turn is still blocked'
|
|
2590
|
+
ensure
|
|
2591
|
+
File.chmod(0o755, notes) # the window lifts before the recheck
|
|
2592
|
+
end
|
|
2593
|
+
log = File.read(log_path, encoding: 'UTF-8')
|
|
2594
|
+
assert_includes log, 'a stale note could not be deleted and was emptied instead', log
|
|
2595
|
+
|
|
2596
|
+
run_script(cfg_path, JSON.generate('transcript_path' => tx_path,
|
|
2597
|
+
'stop_hook_active' => true))
|
|
2598
|
+
log = File.read(log_path, encoding: 'UTF-8')
|
|
2599
|
+
refute_includes log, 'RECHECK-',
|
|
2600
|
+
"the emptied leftover must not yield a verdict once the window lifts: #{log}"
|
|
2601
|
+
assert_includes log, 'SKIP-nonote-mismatch',
|
|
2602
|
+
"an emptied note fails the parse and is refused by name: #{log}"
|
|
2603
|
+
end
|
|
2604
|
+
end
|
|
2605
|
+
|
|
2606
|
+
# The exception path of the write — not just the no-uuid return — must spend
|
|
2607
|
+
# the leftover too. Driven in-process because the deterministic failure is a
|
|
2608
|
+
# directory squatting the temporary name, and that name carries the pid.
|
|
2609
|
+
def test_a_write_that_raises_spends_the_leftover_before_reporting
|
|
2610
|
+
Dir.mktmpdir do |tmp|
|
|
2611
|
+
c = G::Config.new({ 'mode_name' => 'test', 'log_path' => File.join(tmp, 'gate.log') },
|
|
2612
|
+
'<inline>')
|
|
2613
|
+
tx = File.join(tmp, 't.jsonl')
|
|
2614
|
+
assert_nil G.write_note(tx, c, 'AAA'), 'the interrupted turn left its note'
|
|
2615
|
+
path = G.note_path(tx, c)
|
|
2616
|
+
FileUtils.mkdir_p("#{path}.#{Process.pid}.tmp") # the write raises, the spend can work
|
|
2617
|
+
|
|
2618
|
+
failure = G.write_note(tx, c, 'BBB')
|
|
2619
|
+
refute_nil failure, 'the write must report its failure'
|
|
2620
|
+
assert_includes failure, 'a stale note was deleted', failure
|
|
2621
|
+
refute File.exist?(path), 'the leftover would name a block this turn did not make'
|
|
2622
|
+
assert_equal [nil, 'nonote'], G.take_note(tx, c),
|
|
2623
|
+
'the recheck must find nothing rather than the wrong block'
|
|
2624
|
+
end
|
|
2625
|
+
end
|
|
2482
2626
|
end
|