kairos-chain 3.79.0 → 3.80.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 +59 -0
- data/lib/kairos_mcp/version.rb +1 -1
- data/templates/skillsets/kairos_hook_projector/hooks/readable_gate.rb +119 -86
- data/templates/skillsets/kairos_hook_projector/skillset.json +1 -1
- data/templates/skillsets/kairos_hook_projector/test/mutation_check_readable_gate.rb +134 -12
- data/templates/skillsets/kairos_hook_projector/test/test_readable_gate.rb +530 -43
- 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: e2c2d4657c313e3452dbdfaa619a7949d9682961fe4e9ccc15e772ff580f300a
|
|
4
|
+
data.tar.gz: f276e35c55991a4ea269309e7ff6f2a94208a4e86967dc9754685a258525645c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d10d880b482015c17e13014e432bf383cb8003f947ce1caabc4f3f24e775228facb81f042466917f530aa4c4018443cb33567c7fd8ba41c2e3a56149a4db2417
|
|
7
|
+
data.tar.gz: 2f4b29826187f483df3a95d601623f587e28eb5575670a235706c4bc7bc8bbc73d285f3ab60f9012fd0ae391a36f294c6a28866fb9a777d60bd1b361c7e9e987
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,65 @@ 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.80.0] - 2026-08-27
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **`readable_gate`: the measurement bound is the hook's own remaining time,
|
|
12
|
+
not a mode's declaration.** `measure_timeout_seconds` is retired, and its
|
|
13
|
+
whole observable surface goes with it — the clamp, the type-error clause,
|
|
14
|
+
and the silent death where `0` or a negative passed the Integer check,
|
|
15
|
+
reached `Regexp.timeout=` and killed the gate with exit 0, empty stdout and
|
|
16
|
+
no log file. A mode file must not be able to silence the gate by arithmetic
|
|
17
|
+
(operator ruling, 2026-08-27). A config still declaring the key flows
|
|
18
|
+
through `checked()` as an unknown key and is read by nobody.
|
|
19
|
+
|
|
20
|
+
Time protection is now `bounded_match`, the one seam every mode-supplied
|
|
21
|
+
match passes through: remaining computed from an absolute deadline fixed at
|
|
22
|
+
`main`'s first statement, a floor at one millisecond (`Regexp.timeout=`
|
|
23
|
+
silently stores nil for positive values under 1e-9), the grant set to the
|
|
24
|
+
remaining time, and the grant revoked as the match ends — no match runs
|
|
25
|
+
under an authorisation granted to another. Announce, specimen, shorthand and
|
|
26
|
+
both gloss operands route through it; `each_match` starts every match inside
|
|
27
|
+
the seam, so no ordering exists in which a match begins before the clock is
|
|
28
|
+
read. The recheck's poll is charged as time actually spent, which retires
|
|
29
|
+
the old static arithmetic that billed a first read for the four seconds only
|
|
30
|
+
a recheck uses. `HOOK_TIMEOUT_MARGIN` drops from 1.0 to 0.5 (start-up
|
|
31
|
+
measured at a 0.033s median), recovering half the band the old margin
|
|
32
|
+
discarded.
|
|
33
|
+
|
|
34
|
+
**Three declared output differences** against the pre-bound gate: a config
|
|
35
|
+
declaring the key loses its problem clause and the gate runs when no other
|
|
36
|
+
problem remains; the measurement-exceeded banner quotes elapsed time against
|
|
37
|
+
the live budget instead of a mode-declared value; and the budget itself
|
|
38
|
+
grows from a flat 5s to the deadline's remainder (≈9.4s on a first read,
|
|
39
|
+
≈5.4s on a recheck), so a measurement finishing between 5s and the budget
|
|
40
|
+
now yields a verdict where it used to yield NOT RUN. Probed at 10, 12 and 14
|
|
41
|
+
configurations by three independent contexts: no undeclared difference.
|
|
42
|
+
|
|
43
|
+
Design frozen at v0.7 after six review rounds; implementation conformance
|
|
44
|
+
round 1 REVISE, round 2 APPROVE 3/3 with zero DOES NOT HOLD. Suite 152 runs
|
|
45
|
+
/ 948 assertions green; the mutation harness gains 19 rows paired one-to-one
|
|
46
|
+
with the design's table (anchors verified 72/72), plus a label filter and
|
|
47
|
+
early exit so a narrowed sweep is possible and says so in its summary.
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
|
|
51
|
+
- **`readable_gate`: the carry-over note's truncation fallback refuses to
|
|
52
|
+
follow a symlink.** When a stale note cannot be deleted the gate empties it
|
|
53
|
+
in place, and that write followed whatever the note's name pointed at: with
|
|
54
|
+
a symlink planted at the note key and the notes directory made unwritable so
|
|
55
|
+
the unlink is refused, the emptying truncated a file outside the notes
|
|
56
|
+
directory. The open now carries `File::NOFOLLOW`, so a final-component
|
|
57
|
+
symlink fails with `ELOOP` and lands in the existing refusal arm — the
|
|
58
|
+
leaning is to refuse, never to write through a name to somewhere the path
|
|
59
|
+
did not say. The leftover link stays unspent and every later recheck refuses
|
|
60
|
+
it. A hard link and an intermediate-component symlink still reach through;
|
|
61
|
+
both need the same outside hand and are declared rather than closed.
|
|
62
|
+
|
|
63
|
+
- `kairos_hook_projector` SkillSet version 0.5.0 → 0.6.0, so instances
|
|
64
|
+
actually receive the two changes above on `skillset upgrade`.
|
|
65
|
+
|
|
7
66
|
## [3.79.0] - 2026-08-26
|
|
8
67
|
|
|
9
68
|
### Fixed
|
data/lib/kairos_mcp/version.rb
CHANGED
|
@@ -64,30 +64,26 @@ module KairosHookProjector
|
|
|
64
64
|
# the limit emits nothing at all — no verdict, no banner, no log line — so
|
|
65
65
|
# the gate stops enforcing and the measurement loses rows, both silently.
|
|
66
66
|
HOOK_TIMEOUT = 10.0
|
|
67
|
-
# Held back
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
HOOK_TIMEOUT_MARGIN =
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
#
|
|
82
|
-
#
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
|
|
87
|
-
# spends. The bound is therefore carved out of this design and carried in
|
|
88
|
-
# its own, and this line is back to the shape shipped since 6d7fe02.
|
|
89
|
-
MEASURE_TIMEOUT_CEILING =
|
|
90
|
-
HOOK_TIMEOUT - HOOK_TIMEOUT_MARGIN - (RECHECK_POLL_ATTEMPTS * POLL_DELAY)
|
|
67
|
+
# Held back for what the deadline cannot see: interpreter start-up before
|
|
68
|
+
# main's first clock read, and the output written after measurement ends.
|
|
69
|
+
# Start-up measured at 0.033s median over 8 runs through the projected
|
|
70
|
+
# `sh -c ruby …` form (2026-08-27, consistent with round 5's 0.033-0.039s);
|
|
71
|
+
# the log append and JSON emit are under 10ms. The previous value, 1.0, was
|
|
72
|
+
# about thirty times the measured start-up and discarded ~0.95s in which
|
|
73
|
+
# the pre-bound gate would have measured and blocked. Pinned by an equality
|
|
74
|
+
# assert, not by the wall-time band: the band's flake headroom and kill
|
|
75
|
+
# margin always sum to exactly this value, so no band can pin it robustly
|
|
76
|
+
# from both sides.
|
|
77
|
+
HOOK_TIMEOUT_MARGIN = 0.5
|
|
78
|
+
# The least remaining time bounded_match will grant. Two grounds. First,
|
|
79
|
+
# Regexp.timeout= silently stores nil — no bound at all — for positive
|
|
80
|
+
# values below 1e-9 (measured on Ruby 3.4.10: 1e-9 is kept, 7.5e-10 reads
|
|
81
|
+
# back nil), so a remaining that small must refuse rather than arm nothing.
|
|
82
|
+
# Second, a measurement with under a millisecond left cannot produce a
|
|
83
|
+
# verdict anyway; raising is the honest outcome. Pinned by equality in both
|
|
84
|
+
# directions: lowering it re-opens the silent-nil window, raising it
|
|
85
|
+
# quietly shrinks every budget.
|
|
86
|
+
MIN_TIMEOUT = 0.001
|
|
91
87
|
|
|
92
88
|
# Built from single-quoted strings so that `#{` in a pattern stays literal
|
|
93
89
|
# rather than becoming interpolation, and anchored with \A because Python's
|
|
@@ -110,12 +106,14 @@ module KairosHookProjector
|
|
|
110
106
|
'specimen_patterns' => [],
|
|
111
107
|
'vocab_min_lines' => 1,
|
|
112
108
|
'blocking' => true,
|
|
113
|
-
#
|
|
114
|
-
# bound
|
|
115
|
-
#
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
|
|
109
|
+
# measure_timeout_seconds is deliberately absent. It was a mode-declared
|
|
110
|
+
# bound on measurement time, and its whole observable surface — the
|
|
111
|
+
# clamp, the type-error clause, the 0-or-negative silent death, and the
|
|
112
|
+
# 5-second budget itself — was retired by the 2026-08-27 operator ruling
|
|
113
|
+
# ("no mode-facing bound setting"): a mode file must not be able to
|
|
114
|
+
# silence the gate by arithmetic. A config still declaring it is passed
|
|
115
|
+
# through checked() as an unknown key and read by nobody. Time protection
|
|
116
|
+
# is bounded_match's, derived from the hook's own remaining time.
|
|
119
117
|
'log_max_bytes' => 1024 * 1024,
|
|
120
118
|
'banner_prefix' => 'gate',
|
|
121
119
|
# nil, and pinned here, because nil is what note() reads as "this mode
|
|
@@ -126,7 +124,7 @@ module KairosHookProjector
|
|
|
126
124
|
}.freeze
|
|
127
125
|
|
|
128
126
|
INT_KEYS = %w[max_lines max_headings max_tables diagram_required_over_lines
|
|
129
|
-
vocab_min_lines
|
|
127
|
+
vocab_min_lines log_max_bytes].freeze
|
|
130
128
|
LIST_KEYS = %w[announce_patterns shorthand_patterns gloss_patterns
|
|
131
129
|
specimen_patterns].freeze
|
|
132
130
|
# log_path is deliberately absent. It was added here once, and the cost was
|
|
@@ -151,7 +149,7 @@ module KairosHookProjector
|
|
|
151
149
|
:max_lines, :max_headings, :max_tables, :diagram_over_lines,
|
|
152
150
|
:vocab_min_lines,
|
|
153
151
|
:blocking, :banner_prefix, :rewrite_instruction, :log_path,
|
|
154
|
-
:announce, :shorthand, :gloss, :specimen,
|
|
152
|
+
:announce, :shorthand, :gloss, :specimen,
|
|
155
153
|
:log_max_bytes
|
|
156
154
|
|
|
157
155
|
def initialize(raw, path)
|
|
@@ -188,18 +186,6 @@ module KairosHookProjector
|
|
|
188
186
|
# list. Text explaining the vocabulary rule has to name the shapes it
|
|
189
187
|
# governs, and naming them is not using them.
|
|
190
188
|
@specimen = merged['specimen_patterns'].map { |p| Regexp.new(p) }
|
|
191
|
-
# Clamped, not rejected: a mode that asks for 6 seconds gets what fits
|
|
192
|
-
# rather than a refusal it cannot act on. 4 seconds of recheck poll plus
|
|
193
|
-
# 6 of measurement is 10.4 against a 10-second hook limit, and the hook
|
|
194
|
-
# dies with no output, so the gate would silently stop enforcing for
|
|
195
|
-
# exactly the modes whose patterns are expensive enough to need the time.
|
|
196
|
-
requested = merged['measure_timeout_seconds']
|
|
197
|
-
@measure_timeout =
|
|
198
|
-
if requested.is_a?(Numeric) && requested > MEASURE_TIMEOUT_CEILING
|
|
199
|
-
MEASURE_TIMEOUT_CEILING
|
|
200
|
-
else
|
|
201
|
-
requested
|
|
202
|
-
end
|
|
203
189
|
@log_max_bytes = merged['log_max_bytes']
|
|
204
190
|
end
|
|
205
191
|
|
|
@@ -474,6 +460,16 @@ module KairosHookProjector
|
|
|
474
460
|
# the parse on every later read whatever the directory permits by then.
|
|
475
461
|
# Only when both are refused does the leftover stay valid, and that state
|
|
476
462
|
# is declared in design §7 rather than closed.
|
|
463
|
+
#
|
|
464
|
+
# The truncation refuses to follow a symlink. The note's name sits in
|
|
465
|
+
# operator space, and round 4 planted a link there — the directory then
|
|
466
|
+
# made unwritable so the unlink cannot spend it — and pointed the old
|
|
467
|
+
# File.write at a file outside the notes directory. NOFOLLOW turns that
|
|
468
|
+
# open into an ELOOP, which lands in the refusal arm below: the leaning is
|
|
469
|
+
# to refuse, never to write through a name to somewhere the path did not
|
|
470
|
+
# say. A hard link, or a link planted higher up the path, still reaches
|
|
471
|
+
# through — each needs the same outside hand that plants the link, and both
|
|
472
|
+
# are declared rather than closed.
|
|
477
473
|
def spend_stale_note(path, reason)
|
|
478
474
|
File.unlink(path)
|
|
479
475
|
"#{reason}; a stale note was deleted"
|
|
@@ -481,7 +477,7 @@ module KairosHookProjector
|
|
|
481
477
|
reason
|
|
482
478
|
rescue StandardError
|
|
483
479
|
begin
|
|
484
|
-
File.
|
|
480
|
+
File.open(path, File::WRONLY | File::TRUNC | File::NOFOLLOW).close
|
|
485
481
|
"#{reason}; a stale note could not be deleted and was emptied instead"
|
|
486
482
|
rescue StandardError => e
|
|
487
483
|
"#{reason}; a stale note could not be deleted (#{e.class})"
|
|
@@ -694,44 +690,74 @@ module KairosHookProjector
|
|
|
694
690
|
|
|
695
691
|
# --- measurement ---------------------------------------------------------
|
|
696
692
|
|
|
697
|
-
# The
|
|
698
|
-
#
|
|
699
|
-
#
|
|
700
|
-
#
|
|
701
|
-
#
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
693
|
+
# The one gate every mode-supplied match passes through. The invariant is
|
|
694
|
+
# over match operations, not call sites: no mode pattern ever starts a
|
|
695
|
+
# match without a bound computed from the time actually remaining, and no
|
|
696
|
+
# match runs under an authorisation granted to another — the grant is
|
|
697
|
+
# revoked as the match ends, whichever way it ends. Both halves are
|
|
698
|
+
# review-taught: naming three sites and bounding those left announce and
|
|
699
|
+
# both gloss operands unbounded, and granting without revoking let a match
|
|
700
|
+
# outside the seam inherit the previous grant, which made every bypass
|
|
701
|
+
# unobservable (measured at +65µs against a band needing +0.25s).
|
|
702
|
+
#
|
|
703
|
+
# The floor: a remaining below MIN_TIMEOUT raises instead of arming the
|
|
704
|
+
# bound, because Regexp.timeout= silently stores nil — no bound at all —
|
|
705
|
+
# for positive values under 1e-9, and a sub-millisecond measurement cannot
|
|
706
|
+
# finish regardless. The check precedes the match, so no ordering exists
|
|
707
|
+
# in which a match starts and the clock is read only after it.
|
|
708
|
+
#
|
|
709
|
+
# A nil deadline is the --report path: offline calibration, run by the
|
|
710
|
+
# operator outside the hook limit, deliberately unbounded.
|
|
711
|
+
#
|
|
712
|
+
# monotonic is read through the module helper and must stay that way: the
|
|
713
|
+
# floor fixture stubs it and counts the calls, so inlining the clock here
|
|
714
|
+
# is a mutation that fixture kills.
|
|
715
|
+
def bounded_match(regexp, string, pos, deadline)
|
|
716
|
+
return regexp.match(string, pos) if deadline.nil?
|
|
717
|
+
|
|
718
|
+
remaining = deadline - monotonic
|
|
719
|
+
raise MeasureTimeout if remaining < MIN_TIMEOUT
|
|
720
|
+
|
|
721
|
+
Regexp.timeout = remaining
|
|
722
|
+
begin
|
|
723
|
+
regexp.match(string, pos)
|
|
724
|
+
ensure
|
|
725
|
+
Regexp.timeout = nil
|
|
726
|
+
end
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
# In a named helper so the formula is assertable: an assert against an
|
|
730
|
+
# inline expression only re-derives it, which is a test that cannot fail.
|
|
731
|
+
def deadline_for(started)
|
|
732
|
+
started + (HOOK_TIMEOUT - HOOK_TIMEOUT_MARGIN)
|
|
733
|
+
end
|
|
706
734
|
|
|
735
|
+
def each_match(regexp, string, deadline)
|
|
736
|
+
pos = 0
|
|
737
|
+
while pos <= string.length && (m = bounded_match(regexp, string, pos, deadline))
|
|
707
738
|
yield m
|
|
708
739
|
pos = m.end(0) > m.begin(0) ? m.end(0) : m.begin(0) + 1
|
|
709
740
|
end
|
|
710
741
|
end
|
|
711
742
|
|
|
712
|
-
def specimen_spans(line, cfg, deadline
|
|
743
|
+
def specimen_spans(line, cfg, deadline)
|
|
713
744
|
spans = []
|
|
714
745
|
cfg.specimen.each do |pattern|
|
|
715
|
-
raise MeasureTimeout if deadline && monotonic > deadline
|
|
716
|
-
|
|
717
746
|
each_match(pattern, line, deadline) { |m| spans << [m.begin(0), m.end(0)] }
|
|
718
747
|
end
|
|
719
748
|
spans
|
|
720
749
|
end
|
|
721
750
|
|
|
722
|
-
# measure()
|
|
723
|
-
#
|
|
724
|
-
#
|
|
725
|
-
#
|
|
726
|
-
#
|
|
727
|
-
#
|
|
728
|
-
def measure_bounded(text, cfg)
|
|
729
|
-
return measure(text, cfg, nil) unless cfg.measure_timeout
|
|
730
|
-
|
|
751
|
+
# measure() with the deadline threaded through. This wrapper owns the two
|
|
752
|
+
# things that sit outside any single match: whatever Regexp.timeout the
|
|
753
|
+
# caller had is saved and restored here, outside every per-match grant,
|
|
754
|
+
# and a match cut by Ruby's own Regexp::TimeoutError maps to the same
|
|
755
|
+
# MeasureTimeout the deadline raises — one banner for both ways of
|
|
756
|
+
# running out.
|
|
757
|
+
def measure_bounded(text, cfg, deadline)
|
|
731
758
|
previous = Regexp.timeout
|
|
732
|
-
Regexp.timeout = cfg.measure_timeout
|
|
733
759
|
begin
|
|
734
|
-
measure(text, cfg,
|
|
760
|
+
measure(text, cfg, deadline)
|
|
735
761
|
rescue Regexp::TimeoutError
|
|
736
762
|
raise MeasureTimeout
|
|
737
763
|
ensure
|
|
@@ -744,7 +770,7 @@ module KairosHookProjector
|
|
|
744
770
|
end
|
|
745
771
|
|
|
746
772
|
# Pure. [metrics, failures]. The whole judgement lives here.
|
|
747
|
-
def measure(text, cfg, deadline
|
|
773
|
+
def measure(text, cfg, deadline)
|
|
748
774
|
raw = text.split("\n", -1)
|
|
749
775
|
|
|
750
776
|
prose = []
|
|
@@ -765,27 +791,20 @@ module KairosHookProjector
|
|
|
765
791
|
tables = prose.count { |l| TABLE_SEP.match(l) }
|
|
766
792
|
|
|
767
793
|
first = raw.find { |l| !l.strip.empty? } || ''
|
|
768
|
-
announced = !(cfg.announce && cfg.announce
|
|
794
|
+
announced = !(cfg.announce && bounded_match(cfg.announce, first, 0, deadline)).nil?
|
|
769
795
|
|
|
770
796
|
seen = {}
|
|
771
797
|
unglossed = []
|
|
772
798
|
if !cfg.shorthand.empty? && prose.length >= cfg.vocab_min_lines
|
|
773
799
|
prose.each_with_index do |line, i|
|
|
774
|
-
# No per-line check.
|
|
775
|
-
#
|
|
776
|
-
#
|
|
777
|
-
#
|
|
778
|
-
#
|
|
800
|
+
# No per-line or per-pattern check. Every match on this line starts
|
|
801
|
+
# inside bounded_match, which reads the clock before it; a check here
|
|
802
|
+
# would be a guard no fixture can redden — the shape this file
|
|
803
|
+
# deleted once already ("an unfalsifiable guard is not defence, it
|
|
804
|
+
# is a claim") and declines to reintroduce.
|
|
779
805
|
nxt = i + 1 < prose.length ? prose[i + 1] : ''
|
|
780
806
|
spans = specimen_spans(line, cfg, deadline)
|
|
781
807
|
cfg.shorthand.each do |pattern|
|
|
782
|
-
# Per pattern, not only per line. Regexp.timeout bounds one match,
|
|
783
|
-
# so a line carrying n patterns could overshoot the deadline by
|
|
784
|
-
# n times that bound while the clock was read only once, between
|
|
785
|
-
# lines. The check above still stands: it catches a line whose
|
|
786
|
-
# specimen scan alone is what runs long.
|
|
787
|
-
raise MeasureTimeout if deadline && monotonic > deadline
|
|
788
|
-
|
|
789
808
|
each_match(pattern, line, deadline) do |m|
|
|
790
809
|
tok = m.size > 1 ? m[1] : m[0]
|
|
791
810
|
next if tok.nil? || seen.key?(tok)
|
|
@@ -794,7 +813,8 @@ module KairosHookProjector
|
|
|
794
813
|
|
|
795
814
|
seen[tok] = true
|
|
796
815
|
after = line[m.end(0)..] || ''
|
|
797
|
-
if cfg.gloss && !(cfg.gloss
|
|
816
|
+
if cfg.gloss && !(bounded_match(cfg.gloss, after, 0, deadline) ||
|
|
817
|
+
bounded_match(cfg.gloss, nxt, 0, deadline))
|
|
798
818
|
unglossed << tok
|
|
799
819
|
end
|
|
800
820
|
end
|
|
@@ -990,6 +1010,14 @@ module KairosHookProjector
|
|
|
990
1010
|
end
|
|
991
1011
|
|
|
992
1012
|
def main(argv = ARGV, stdin = $stdin)
|
|
1013
|
+
# The first executable statement, before config or stdin are read: the
|
|
1014
|
+
# deadline is absolute, so everything main does — config load, transcript
|
|
1015
|
+
# reads, the recheck's poll sleeps — is charged against it as time
|
|
1016
|
+
# actually spent, and measurement bounds itself by whatever is left.
|
|
1017
|
+
# This is what retires the old static arithmetic that pre-charged a
|
|
1018
|
+
# first read for the four seconds only a recheck spends.
|
|
1019
|
+
started = monotonic
|
|
1020
|
+
deadline = deadline_for(started)
|
|
993
1021
|
config_path, report_paths = parse_args(argv)
|
|
994
1022
|
if config_path.nil?
|
|
995
1023
|
# Zero, not two. The header two hundred lines up says the exit status is
|
|
@@ -1084,13 +1112,18 @@ module KairosHookProjector
|
|
|
1084
1112
|
end
|
|
1085
1113
|
|
|
1086
1114
|
begin
|
|
1087
|
-
metrics, failures = measure_bounded(text, cfg)
|
|
1115
|
+
metrics, failures = measure_bounded(text, cfg, deadline)
|
|
1088
1116
|
rescue MeasureTimeout
|
|
1089
1117
|
lost = note(cfg, 'SKIP-measure-timeout')
|
|
1118
|
+
# Quotes the time actually spent and the budget it ran out of. The old
|
|
1119
|
+
# banner quoted the mode's declared bound — post-clamp, so a declared
|
|
1120
|
+
# 60 read "exceeded 5.0s" — and both the declaration and the clamp are
|
|
1121
|
+
# gone with the key.
|
|
1090
1122
|
emit(
|
|
1091
1123
|
'systemMessage' =>
|
|
1092
|
-
|
|
1093
|
-
|
|
1124
|
+
format('%s: NOT RUN — measurement ran out of the hook budget (%.1fs elapsed ' \
|
|
1125
|
+
"of %.1fs); check the mode's patterns for unbounded backtracking",
|
|
1126
|
+
cfg.banner_prefix, monotonic - started, deadline - started) +
|
|
1094
1127
|
(lost ? "; #{log_note(lost)}" : '')
|
|
1095
1128
|
)
|
|
1096
1129
|
return 0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kairos_hook_projector",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|
|
@@ -17,7 +17,14 @@
|
|
|
17
17
|
# The harness is this SkillSet's own suite, unmodified: `SCRIPT` in the test
|
|
18
18
|
# files resolves from `__dir__`, so the copied tests drive the copied hook.
|
|
19
19
|
#
|
|
20
|
-
# ruby test/mutation_check_readable_gate.rb
|
|
20
|
+
# ruby test/mutation_check_readable_gate.rb # full sweep
|
|
21
|
+
# ruby test/mutation_check_readable_gate.rb 'B\\d+' # rows whose label matches
|
|
22
|
+
#
|
|
23
|
+
# A label filter narrows the sweep to the rows named; the summary line then
|
|
24
|
+
# says FILTERED so a narrowed run can never be mistaken for a full one. The
|
|
25
|
+
# per-mutation suite run stops at the first file with a red — the question
|
|
26
|
+
# per row is binary — and the hook's own test file runs first, which is where
|
|
27
|
+
# a readable_gate mutation reds if it reds at all.
|
|
21
28
|
#
|
|
22
29
|
require 'fileutils'
|
|
23
30
|
require 'tmpdir'
|
|
@@ -58,9 +65,11 @@ MUTATIONS = [
|
|
|
58
65
|
['M27 HOOK_TIMEOUT drifts from the limit the compiler declares',
|
|
59
66
|
%q{ HOOK_TIMEOUT = 10.0},
|
|
60
67
|
%q{ HOOK_TIMEOUT = 20.0}],
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
68
|
+
# M28 and M30 were the margin mutations against the old constant (1.0). The
|
|
69
|
+
# 2026-08-27 bound redesign moved the margin to 0.5 and pinned it by
|
|
70
|
+
# equality, and B14/B15 below carry both directions against the new value.
|
|
71
|
+
# Retired rather than retargeted: keeping two rows per direction would
|
|
72
|
+
# double-count one anchor.
|
|
64
73
|
|
|
65
74
|
# --- round 3: the instrument the design reads its own evidence through -----
|
|
66
75
|
['M22 the waited token stops telling a wait from no wait',
|
|
@@ -74,9 +83,6 @@ MUTATIONS = [
|
|
|
74
83
|
sleep(POLL_DELAY)
|
|
75
84
|
next
|
|
76
85
|
end}],
|
|
77
|
-
['M30 the start-up headroom shrinks but stays positive',
|
|
78
|
-
%q{ HOOK_TIMEOUT_MARGIN = 1.0},
|
|
79
|
-
%q{ HOOK_TIMEOUT_MARGIN = 0.05}],
|
|
80
86
|
['M31 the recheck budget grows instead of shrinking',
|
|
81
87
|
%q{ RECHECK_POLL_ATTEMPTS = 40},
|
|
82
88
|
%q{ RECHECK_POLL_ATTEMPTS = 95}],
|
|
@@ -306,7 +312,7 @@ MUTATIONS = [
|
|
|
306
312
|
['N32 the truncation fallback is deleted, so a lifted window revives the leftover',
|
|
307
313
|
%q{ rescue StandardError
|
|
308
314
|
begin
|
|
309
|
-
File.
|
|
315
|
+
File.open(path, File::WRONLY | File::TRUNC | File::NOFOLLOW).close
|
|
310
316
|
"#{reason}; a stale note could not be deleted and was emptied instead"
|
|
311
317
|
rescue StandardError => e
|
|
312
318
|
"#{reason}; a stale note could not be deleted (#{e.class})"
|
|
@@ -315,10 +321,118 @@ MUTATIONS = [
|
|
|
315
321
|
%q{ rescue StandardError => e
|
|
316
322
|
"#{reason}; a stale note could not be deleted (#{e.class})"
|
|
317
323
|
end}],
|
|
324
|
+
|
|
325
|
+
# --- what round 4 of the conformance review found --------------------------
|
|
326
|
+
#
|
|
327
|
+
# The truncation is a write through a name in operator space. Two seats
|
|
328
|
+
# planted a symlink at the note key, made the directory unwritable so the
|
|
329
|
+
# unlink fails, and watched the old File.write empty a file outside the notes
|
|
330
|
+
# directory. This restores that write; the fix's NOFOLLOW is what it deletes.
|
|
331
|
+
['N33 the truncation follows a symlink to whatever it names',
|
|
332
|
+
%q{ File.open(path, File::WRONLY | File::TRUNC | File::NOFOLLOW).close},
|
|
333
|
+
%q{ File.write(path, '')}],
|
|
334
|
+
|
|
335
|
+
# --- the measurement bound: the seam and its witnesses (design v0.7 §5-12) --
|
|
336
|
+
#
|
|
337
|
+
# Every mutation here is paired with the fixture the design names as its
|
|
338
|
+
# killer; the pairing is the §5-12 table, and anchor verification before a
|
|
339
|
+
# sweep is what keeps it honest. Mutations that unbound a runaway make the
|
|
340
|
+
# affected fixtures run to run_script's 30s outer kill, so those rows cost
|
|
341
|
+
# minutes each in a sweep — declared, not accidental.
|
|
342
|
+
['B1 the floor predicate is loosened to <= 0',
|
|
343
|
+
%q{ raise MeasureTimeout if remaining < MIN_TIMEOUT},
|
|
344
|
+
%q{ raise MeasureTimeout if remaining <= 0}],
|
|
345
|
+
['B2 the raise (floor included) is deleted',
|
|
346
|
+
%q{ raise MeasureTimeout if remaining < MIN_TIMEOUT},
|
|
347
|
+
%q{ # raise deleted}],
|
|
348
|
+
['B3 the floor constant is lowered into the silent-nil window',
|
|
349
|
+
%q{ MIN_TIMEOUT = 0.001},
|
|
350
|
+
%q{ MIN_TIMEOUT = 1.0e-12}],
|
|
351
|
+
['B4 the floor constant is raised, quietly shrinking every budget',
|
|
352
|
+
%q{ MIN_TIMEOUT = 0.001},
|
|
353
|
+
%q{ MIN_TIMEOUT = 1.0}],
|
|
354
|
+
['B5 the grant is never armed',
|
|
355
|
+
%q{ Regexp.timeout = remaining
|
|
356
|
+
begin},
|
|
357
|
+
%q{ begin}],
|
|
358
|
+
['B6 the TimeoutError-to-MeasureTimeout mapping is deleted',
|
|
359
|
+
%q{ rescue Regexp::TimeoutError
|
|
360
|
+
raise MeasureTimeout},
|
|
361
|
+
%q{ rescue Regexp::TimeoutError
|
|
362
|
+
raise}],
|
|
363
|
+
['B7 the clock read is dropped from the remaining computation',
|
|
364
|
+
%q{ remaining = deadline - monotonic},
|
|
365
|
+
%q{ remaining = deadline}],
|
|
366
|
+
['B8 each_match reverts to matching before the seam checks',
|
|
367
|
+
%q{ while pos <= string.length && (m = bounded_match(regexp, string, pos, deadline))
|
|
368
|
+
yield m},
|
|
369
|
+
%q{ while pos <= string.length && (m = regexp.match(string, pos))
|
|
370
|
+
raise MeasureTimeout if deadline && monotonic > deadline
|
|
371
|
+
yield m}],
|
|
372
|
+
['B19 the seam inlines the clock, putting it beyond the floor fixture stub',
|
|
373
|
+
%q{ remaining = deadline - monotonic},
|
|
374
|
+
%q{ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)}],
|
|
375
|
+
# Five bypass targets, three dedicated rows: specimen and shorthand have no
|
|
376
|
+
# independent seam call site — both reach bounded_match only through
|
|
377
|
+
# each_match's single call — so their bypass IS the each_match reversion,
|
|
378
|
+
# which is B8. B10-B12 carry the three families with call sites of their own.
|
|
379
|
+
['B9 the announce call drops its deadline argument',
|
|
380
|
+
%q{ announced = !(cfg.announce && bounded_match(cfg.announce, first, 0, deadline)).nil?},
|
|
381
|
+
%q{ announced = !(cfg.announce && bounded_match(cfg.announce, first, 0, nil)).nil?}],
|
|
382
|
+
['B10 the announce match bypasses the seam entirely',
|
|
383
|
+
%q{ announced = !(cfg.announce && bounded_match(cfg.announce, first, 0, deadline)).nil?},
|
|
384
|
+
%q{ announced = !(cfg.announce && cfg.announce.match(first)).nil?}],
|
|
385
|
+
['B11 the gloss after-operand bypasses the seam',
|
|
386
|
+
%q{ if cfg.gloss && !(bounded_match(cfg.gloss, after, 0, deadline) ||
|
|
387
|
+
bounded_match(cfg.gloss, nxt, 0, deadline))},
|
|
388
|
+
%q{ if cfg.gloss && !(cfg.gloss.match(after) ||
|
|
389
|
+
bounded_match(cfg.gloss, nxt, 0, deadline))}],
|
|
390
|
+
['B12 the gloss nxt-operand bypasses the seam behind the short-circuit',
|
|
391
|
+
%q{ if cfg.gloss && !(bounded_match(cfg.gloss, after, 0, deadline) ||
|
|
392
|
+
bounded_match(cfg.gloss, nxt, 0, deadline))},
|
|
393
|
+
%q{ if cfg.gloss && !(bounded_match(cfg.gloss, after, 0, deadline) ||
|
|
394
|
+
cfg.gloss.match(nxt))}],
|
|
395
|
+
['B13 the deadline is recomputed at the measurement call instead of main head',
|
|
396
|
+
%q{ metrics, failures = measure_bounded(text, cfg, deadline)},
|
|
397
|
+
%q{ metrics, failures = measure_bounded(text, cfg, deadline_for(monotonic))}],
|
|
398
|
+
['B14 the margin collapses to zero',
|
|
399
|
+
%q{ HOOK_TIMEOUT_MARGIN = 0.5},
|
|
400
|
+
%q{ HOOK_TIMEOUT_MARGIN = 0.0}],
|
|
401
|
+
['B15 the margin swallows half the budget',
|
|
402
|
+
%q{ HOOK_TIMEOUT_MARGIN = 0.5},
|
|
403
|
+
%q{ HOOK_TIMEOUT_MARGIN = 5.0}],
|
|
404
|
+
['B16 the report path loses the nil-deadline passthrough',
|
|
405
|
+
%q{ return regexp.match(string, pos) if deadline.nil?
|
|
406
|
+
|
|
407
|
+
remaining = deadline - monotonic},
|
|
408
|
+
%q{ remaining = deadline - monotonic}],
|
|
409
|
+
['B17 the revocation is deleted, so a grant outlives its match',
|
|
410
|
+
%q{ Regexp.timeout = remaining
|
|
411
|
+
begin
|
|
412
|
+
regexp.match(string, pos)
|
|
413
|
+
ensure
|
|
414
|
+
Regexp.timeout = nil
|
|
415
|
+
end},
|
|
416
|
+
%q{ Regexp.timeout = remaining
|
|
417
|
+
regexp.match(string, pos)}],
|
|
418
|
+
['B18 the revocation is demoted to a trailing statement, leaking on the cut exit',
|
|
419
|
+
%q{ Regexp.timeout = remaining
|
|
420
|
+
begin
|
|
421
|
+
regexp.match(string, pos)
|
|
422
|
+
ensure
|
|
423
|
+
Regexp.timeout = nil
|
|
424
|
+
end},
|
|
425
|
+
%q{ Regexp.timeout = remaining
|
|
426
|
+
m = regexp.match(string, pos)
|
|
427
|
+
Regexp.timeout = nil
|
|
428
|
+
m}],
|
|
318
429
|
].freeze
|
|
319
430
|
|
|
320
|
-
def run_suite(root)
|
|
431
|
+
def run_suite(root, early_exit: true)
|
|
321
432
|
files = Dir[File.join(root, 'test', 'test_*.rb')].sort
|
|
433
|
+
# The hook's own suite first: a readable_gate mutation reds there if it reds
|
|
434
|
+
# at all, and the early exit below then skips the unrelated files.
|
|
435
|
+
files = files.partition { |f| File.basename(f) == 'test_readable_gate.rb' }.flatten
|
|
322
436
|
runs = 0
|
|
323
437
|
bad = 0
|
|
324
438
|
unparseable = []
|
|
@@ -332,11 +446,18 @@ def run_suite(root)
|
|
|
332
446
|
unparseable << File.basename(f)
|
|
333
447
|
bad += 1 unless st.success?
|
|
334
448
|
end
|
|
449
|
+
break if early_exit && bad.positive?
|
|
335
450
|
end
|
|
336
451
|
[runs, bad, unparseable, files.length]
|
|
337
452
|
end
|
|
338
453
|
|
|
339
454
|
exit_code = 0
|
|
455
|
+
label_filter = ARGV[0] && Regexp.new(ARGV[0])
|
|
456
|
+
selected = label_filter ? MUTATIONS.select { |l, _, _| l.match?(label_filter) } : MUTATIONS
|
|
457
|
+
if label_filter
|
|
458
|
+
puts format('FILTERED sweep: %d of %d rows match %s — NOT a full sweep',
|
|
459
|
+
selected.length, MUTATIONS.length, label_filter.inspect)
|
|
460
|
+
end
|
|
340
461
|
|
|
341
462
|
Dir.mktmpdir do |tmp|
|
|
342
463
|
root = File.join(tmp, File.basename(SKILLSET_ROOT))
|
|
@@ -344,7 +465,7 @@ Dir.mktmpdir do |tmp|
|
|
|
344
465
|
hook = File.join(root, 'hooks', 'readable_gate.rb')
|
|
345
466
|
pristine = File.read(hook)
|
|
346
467
|
|
|
347
|
-
runs, bad, unparseable, nfiles = run_suite(root)
|
|
468
|
+
runs, bad, unparseable, nfiles = run_suite(root, early_exit: false)
|
|
348
469
|
puts format('BASELINE (unmutated copy): %d test files, %d runs, %d failures+errors%s',
|
|
349
470
|
nfiles, runs, bad, unparseable.empty? ? '' : " [unparseable: #{unparseable.join(', ')}]")
|
|
350
471
|
puts
|
|
@@ -356,7 +477,7 @@ Dir.mktmpdir do |tmp|
|
|
|
356
477
|
killed = 0
|
|
357
478
|
survived = 0
|
|
358
479
|
not_applied = 0
|
|
359
|
-
|
|
480
|
+
selected.each do |label, from, to|
|
|
360
481
|
unless pristine.include?(from)
|
|
361
482
|
not_applied += 1
|
|
362
483
|
puts format('%-70s ANCHOR NOT FOUND', label)
|
|
@@ -381,7 +502,8 @@ Dir.mktmpdir do |tmp|
|
|
|
381
502
|
|
|
382
503
|
puts
|
|
383
504
|
puts "source restored byte-identical: #{File.read(hook) == pristine}"
|
|
384
|
-
|
|
505
|
+
scope = label_filter ? "FILTERED #{selected.length} of #{MUTATIONS.length}" : MUTATIONS.length.to_s
|
|
506
|
+
puts "killed #{killed}/#{scope}, survived #{survived}, anchor-not-found #{not_applied}"
|
|
385
507
|
exit_code = 1 if survived.positive? || not_applied.positive?
|
|
386
508
|
end
|
|
387
509
|
|