kairos-chain 3.79.0 → 3.81.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: 22b92a50151e63d6f243858700e0a0982dd569ce6df05133362dcda4077d6fd6
4
- data.tar.gz: d6d5559901257355633ba3a9d26a43af62293757b67f98c011bd795be4dee8f7
3
+ metadata.gz: 0bc251869ebf7ce64e94fbae5baf21ef0eea655b6d1c42d55dfb2f7ee7c2ccc0
4
+ data.tar.gz: aea009fc75974ff42a05e2bc54a352c19b1192157b9fe8eefa311ae9fd096c65
5
5
  SHA512:
6
- metadata.gz: 8f1bdd48fc2dd110b151e1bc2a8aff18278ea2bc0d97d801dcf3bdd086462e8d6d97b27fdd0a81b75dac820f84aa0786a076eeef84b8ae44d5012e1e435141c0
7
- data.tar.gz: 0c401f4330cff3dc4af3bf6a4277fcf68412e93fec79acc6eb50a12f7e19c84c6eec298ba5527938e8fc8e2dc983a192383f7babec0c1741dde0e47aca993985
6
+ metadata.gz: 498212b88feb4e563e745be00467f8e206aff2c2103761c12d21695a1829201f2edae98295f61e2aaf6695f9d5e82be4f9f24a2c3ff10e868965a8cc1f1eae91
7
+ data.tar.gz: efe4a6d07531e77648bd175452e72036a0261d0bfb3b426b5f571b54b14a1f3f1695acc3b7d0136b64f04c33eac0e9c04dcebfb58481373af0ddbed355a8d1c4
data/CHANGELOG.md CHANGED
@@ -4,6 +4,103 @@ 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.81.0] - 2026-08-27
8
+
9
+ ### Changed
10
+
11
+ - **`project_manager` ships its L2 comparison in Ruby, 0.7.0 -> 0.8.0.** The
12
+ three hook-side scripts move from Python to Ruby (`pm_l2_report.rb`,
13
+ `l2_scan.rb`, and the ported test), and the SessionStart hook it contributes
14
+ now invokes `ruby` rather than `python3`. python3 was never guaranteed on a
15
+ host the gem ships to; the interpreter the rest of the SkillSet already runs
16
+ on is. The port itself was committed before this release (`1ec9bd5`) and is
17
+ unchanged here — 505 execution comparisons over 101 input classes with zero
18
+ undeclared differences, 100 unit runs / 485 assertions green, 6/6 mutations
19
+ killed.
20
+
21
+ The version move is the load-bearing part of this release. Without it
22
+ `skillset upgrade` compares versions, finds them equal, reports UNCHANGED and
23
+ copies nothing, so an instance would take the new gem and keep the Python
24
+ scripts while the projected hook still names them.
25
+
26
+ - **`project_manager/plugin/hooks.json`: the bare `ruby` is now a ruling, not
27
+ an open question.** The comment block previously said the resolution was
28
+ undecided and named two candidates — the projector embedding an absolute
29
+ interpreter path, or this file checking `RUBY_VERSION`. Neither ships
30
+ (operator ruling, 2026-08-27). The comment now records why each was declined:
31
+ an embedded path has to be written by the projector, which turns an
32
+ interpreter choice into a core change and pins every host to one machine's
33
+ layout; a version check in this file trades the loud failure for a quiet one;
34
+ and `rbenv exec` fails outright wherever rbenv is absent — a Docker ruby
35
+ image, a homebrew install, asdf — which is strictly worse than the bare form,
36
+ since those hosts run the bare form fine.
37
+
38
+ Resolution therefore stays with the host's PATH, and the residual risk is
39
+ stated rather than hidden: `/usr/bin/ruby` 2.6.10 fails this script at parse
40
+ time with a 43-line syntax error and writes no page (measured). That failure
41
+ is visible at session start rather than silent, and the visibility is what
42
+ makes the bare form acceptable rather than merely convenient. The command
43
+ string is unchanged by this release; only the comment moved.
44
+
45
+ ## [3.80.0] - 2026-08-27
46
+
47
+ ### Changed
48
+
49
+ - **`readable_gate`: the measurement bound is the hook's own remaining time,
50
+ not a mode's declaration.** `measure_timeout_seconds` is retired, and its
51
+ whole observable surface goes with it — the clamp, the type-error clause,
52
+ and the silent death where `0` or a negative passed the Integer check,
53
+ reached `Regexp.timeout=` and killed the gate with exit 0, empty stdout and
54
+ no log file. A mode file must not be able to silence the gate by arithmetic
55
+ (operator ruling, 2026-08-27). A config still declaring the key flows
56
+ through `checked()` as an unknown key and is read by nobody.
57
+
58
+ Time protection is now `bounded_match`, the one seam every mode-supplied
59
+ match passes through: remaining computed from an absolute deadline fixed at
60
+ `main`'s first statement, a floor at one millisecond (`Regexp.timeout=`
61
+ silently stores nil for positive values under 1e-9), the grant set to the
62
+ remaining time, and the grant revoked as the match ends — no match runs
63
+ under an authorisation granted to another. Announce, specimen, shorthand and
64
+ both gloss operands route through it; `each_match` starts every match inside
65
+ the seam, so no ordering exists in which a match begins before the clock is
66
+ read. The recheck's poll is charged as time actually spent, which retires
67
+ the old static arithmetic that billed a first read for the four seconds only
68
+ a recheck uses. `HOOK_TIMEOUT_MARGIN` drops from 1.0 to 0.5 (start-up
69
+ measured at a 0.033s median), recovering half the band the old margin
70
+ discarded.
71
+
72
+ **Three declared output differences** against the pre-bound gate: a config
73
+ declaring the key loses its problem clause and the gate runs when no other
74
+ problem remains; the measurement-exceeded banner quotes elapsed time against
75
+ the live budget instead of a mode-declared value; and the budget itself
76
+ grows from a flat 5s to the deadline's remainder (≈9.4s on a first read,
77
+ ≈5.4s on a recheck), so a measurement finishing between 5s and the budget
78
+ now yields a verdict where it used to yield NOT RUN. Probed at 10, 12 and 14
79
+ configurations by three independent contexts: no undeclared difference.
80
+
81
+ Design frozen at v0.7 after six review rounds; implementation conformance
82
+ round 1 REVISE, round 2 APPROVE 3/3 with zero DOES NOT HOLD. Suite 152 runs
83
+ / 948 assertions green; the mutation harness gains 19 rows paired one-to-one
84
+ with the design's table (anchors verified 72/72), plus a label filter and
85
+ early exit so a narrowed sweep is possible and says so in its summary.
86
+
87
+ ### Fixed
88
+
89
+ - **`readable_gate`: the carry-over note's truncation fallback refuses to
90
+ follow a symlink.** When a stale note cannot be deleted the gate empties it
91
+ in place, and that write followed whatever the note's name pointed at: with
92
+ a symlink planted at the note key and the notes directory made unwritable so
93
+ the unlink is refused, the emptying truncated a file outside the notes
94
+ directory. The open now carries `File::NOFOLLOW`, so a final-component
95
+ symlink fails with `ELOOP` and lands in the existing refusal arm — the
96
+ leaning is to refuse, never to write through a name to somewhere the path
97
+ did not say. The leftover link stays unspent and every later recheck refuses
98
+ it. A hard link and an intermediate-component symlink still reach through;
99
+ both need the same outside hand and are declared rather than closed.
100
+
101
+ - `kairos_hook_projector` SkillSet version 0.5.0 → 0.6.0, so instances
102
+ actually receive the two changes above on `skillset upgrade`.
103
+
7
104
  ## [3.79.0] - 2026-08-26
8
105
 
9
106
  ### Fixed
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.79.0"
2
+ VERSION = "3.81.0"
3
3
  CHANGELOG_URL = "https://github.com/masaomi/KairosChain_2026/blob/main/CHANGELOG.md"
4
4
  end
@@ -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 from the pair for interpreter start-up and for the poll's own
68
- # overhead. Measured 2026-08-22 on Ruby 3.4.10: 0.030s for the first read's
69
- # 15 tail reads, 0.075s for the recheck's 40 plus turn_marker, and roughly
70
- # 0.3s more for interpreter start-up. An earlier version of this line said
71
- # 0.2-0.6s for the poll alone, which the shipped code cannot reproduce.
72
- #
73
- # Round 5 measured start-up itself at a median 0.033s through the projected
74
- # `sh -c ruby …` form, so 1.0 is about thirty times what it holds back. Left
75
- # as it stands: the value is the measurement bound's, and the bound is no
76
- # longer part of this design. See docs/drafts on the bound.
77
- HOOK_TIMEOUT_MARGIN = 1.0
78
- # What is left for measurement once the recheck has spent its budget. A mode
79
- # asking for more than this is honoured up to here and no further: the
80
- # alternative is a mode file being able to silence the gate by arithmetic.
81
- #
82
- # A constant, and wrong in a way that is known and recorded: it charges a
83
- # first read for the four seconds only a recheck spends. Rounds 4 and 5
84
- # replaced it twice — one ceiling per read, then a clock read at the top of
85
- # main — and round 5's review falsified both, the second because a bound
86
- # applied per match bounds the number this file quotes and not the time it
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
- # A mode-supplied pattern with nested quantifiers backtracks without
114
- # bound: one such pattern had not returned after two minutes on a
115
- # 74-character line, burning the hook's whole budget every turn. Ruby
116
- # bounds each match with Regexp.timeout; the deadline in measure() keeps
117
- # the total bounded too, which is what the Python SIGALRM did.
118
- 'measure_timeout_seconds' => 5,
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 measure_timeout_seconds log_max_bytes].freeze
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, :measure_timeout,
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.write(path, '')
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 deadline reaches here because one pattern can produce many matches on
698
- # one line, and the specimen scan runs its own patterns before the shorthand
699
- # loop is entered at all. Bounding only the shorthand loop, which is what the
700
- # first fix did, left both of those unbounded — the same invariant-as-an-AND
701
- # with one half done that this work keeps producing.
702
- def each_match(regexp, string, deadline = nil)
703
- pos = 0
704
- while pos <= string.length && (m = regexp.match(string, pos))
705
- raise MeasureTimeout if deadline && monotonic > deadline
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 = nil)
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() under a wall-clock bound.
723
- #
724
- # Two bounds, because they catch different things. Regexp.timeout stops one
725
- # pattern that backtracks without returning. The deadline inside measure
726
- # stops many patterns that each return slowly — which is what the Python
727
- # SIGALRM bounded, and what a per-operation limit alone would not.
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, monotonic + cfg.measure_timeout)
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 = nil)
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.match(first)).nil?
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. There was one here, and round 3 showed nothing
775
- # could falsify it: the specimen scan and the loop between patterns
776
- # both read the clock, and one of them runs before anything on a line
777
- # can be slow. An unfalsifiable guard is not defence, it is a claim,
778
- # so it is deleted rather than given a test that cannot fail.
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.match(after) || cfg.gloss.match(nxt))
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
- "#{cfg.banner_prefix}: NOT RUN — measurement exceeded #{cfg.measure_timeout}s; " \
1093
- "check the mode's patterns for unbounded backtracking" +
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.5.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",