kairos-chain 3.73.0 → 3.74.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: 4344b2bf968bbb0b650c672522c8abdee9ae34daea4f06899e60cfaf815fa489
4
- data.tar.gz: e2a99d727e1ca7d404ecdd0945c29e9964220b1ec8aaca8be44f46c55b9a5e77
3
+ metadata.gz: 730e325cdf2caf9ce68964d25d379511f5b1080aa86b86378c928a06156fc567
4
+ data.tar.gz: 3c7fd4cc597b1fefd8403bd976786eba4e3e02c6665c2097d35940e243d6a2dd
5
5
  SHA512:
6
- metadata.gz: 9d47b320473eb188dcc9f57655f11cd152ce0cee6e160c714396ebcd508156a5696c0c0db8eeb7ace61956cfc5e61467507a051ce6d200552d95ebe3a7f9ebf1
7
- data.tar.gz: e38d3d417efb9be7c9e0274d9aca36ba7c1fd062f7ef0ecfc204536f86c99a9bbbbf025a52ee31525caab67b94a3dba88c41fcd3ab9d49313d6a609b10a2e0f8
6
+ metadata.gz: a78cf0336f37b5107f0991ca1f0e88a3cfeb911f1a998d1ffb5763e071d1ba9ffa88fde11d92fc2eaca9eeb0ffc948718e3bfa1f65ed0f87fa1adf3f1c36d40a
7
+ data.tar.gz: da3b0b85ec1518e1e07f4bd4199761128006fe57db205059b36f3145db7cd5ef8f51b9d95f24faa82a05756507244700d6fbe3655d3672d11b80e9b7eb4a697d
data/CHANGELOG.md CHANGED
@@ -4,6 +4,64 @@ 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.74.0] - 2026-08-18
8
+
9
+ ### Fixed
10
+
11
+ - **Every guard in `project_manager` sat on the reading side, and a value JSON
12
+ cannot represent walked past all of them and wedged the store.** Generation ran
13
+ before `File.write`, so the file survived and `@data` did not: the bad value
14
+ stayed in memory and every later save in the process failed identically. The
15
+ store went on silently accepting nothing while each caller saw only its own
16
+ error, and the digest counted items that were never written. Measured on a
17
+ probe: a title carrying a lone surrogate raised, and so did the next perfectly
18
+ ordinary write; 0 items on disk against 2 in memory. `Store#save` now treats
19
+ generation, `mkdir`, write and rename as one unit, and any failure restores
20
+ `@committed` — the last state that reached disk, held in memory and advanced
21
+ only after a write completes — before re-raising. Restoring from memory rather
22
+ than by re-reading the file matters because a `store.json` that had itself
23
+ become unreadable would otherwise leave the rollback with nothing to restore.
24
+ What rollback discards is stated rather than glossed: everything in memory since
25
+ the last successful save, including an edit written straight into a record
26
+ handed out by `items` or `fetch_*`. Records returned by the store are for
27
+ reading; go through a mutator to keep a change.
28
+ - **`pm.yml` is the one file an operator is invited to edit and `skillset upgrade`
29
+ never repairs it, so a bad edit was permanent — and it returned an error from
30
+ every pm tool at once.** `YAML.safe_load_file` is strict: 5 of 5 ordinary edits
31
+ raise (tab indent, unclosed quote, bare date, `&anchor`/`*alias`, `:symbol`),
32
+ and the raise landed one level above every guard the tools carry. The read now
33
+ falls back to the defaults — but falling back is only half a fix, because
34
+ defaults wearing the operator's settings are worse than the outage they replace.
35
+ The reason is kept in `pm_config_error`, `pm_digest` and `pm_record` carry it as
36
+ `config_error`, and the secretary is told to say it **before** the buckets and
37
+ to state that the report was built on defaults and is not comparable to a
38
+ configured one. A document that parses but is not a mapping is reported the same
39
+ way, `false` included; only `nil` means "no settings", which an empty file, a
40
+ newline-only file and a comment-only file all produce (3 of 3 measured).
41
+ `ToolHelpers#pm_digest` carried the same hole and had no caller, so it is
42
+ deleted rather than guarded.
43
+ - **Two rounds of review, and the second round's fixes came from findings the
44
+ first round's fixes created.** Round 1 returned 1 APPROVE of 3 seats with six
45
+ blocking findings; all six were checked against the code before acting and all
46
+ six were real. Round 2 returned 2 of 3. One correction runs the other way: a
47
+ finding claimed an operator's shortened record ttl would be silently replaced,
48
+ but the shipped value and the built-in default are the same 100 years, so only
49
+ an operator who changed it is affected — the gap was real, the stated magnitude
50
+ was not. Two residuals are shipped open, both unreachable from any of the five
51
+ tool entry points and both recorded at the call site as well as in the store:
52
+ rollback detaches previously returned records (0 of 3 holders are harmed today),
53
+ and `JSON.parse(@committed)` is assumed to succeed (tool arguments arrive parsed
54
+ from JSON, so no object with its own `to_json` can enter).
55
+ - **The mutant set grew from 8 to 14 and one of its own results was thrown out.**
56
+ Mutant 18-d first read as a survivor; the mutation was malformed, splitting the
57
+ `begin` block while leaving the original `rescue` attached to the second half,
58
+ so the mutant still rolled back and was equivalent rather than surviving.
59
+ Rewritten as a whole-method replacement it is killed. 14 of 14 killed, including
60
+ the two a reviewer predicted would survive. Suite: 78 runs, 259 assertions.
61
+ Still uncovered, and recorded as such: `secretary.md` is prose, so deleting the
62
+ paragraph it gained passes all 14 mutants — as would deleting any other rule in
63
+ that file, since nothing in this SkillSet inspects what the secretary produces.
64
+
7
65
  ## [3.73.0] - 2026-08-17
8
66
 
9
67
  ### Fixed
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.73.0"
2
+ VERSION = "3.74.0"
3
3
  CHANGELOG_URL = "https://github.com/masaomi/KairosChain_2026/blob/main/CHANGELOG.md"
4
4
  end
@@ -35,6 +35,12 @@ module ProjectManager
35
35
  def initialize(path: nil)
36
36
  @path = path || default_path
37
37
  @data = load_data
38
+ # The last state known to be writable, kept as generated JSON. A rollback
39
+ # restores from this rather than re-reading the file, so it cannot fail the
40
+ # way the write it undoes did — a store.json that has meanwhile become
41
+ # unreadable would otherwise leave the rollback with nothing to restore and
42
+ # the offending value still in place.
43
+ @committed = JSON.generate(@data)
38
44
  end
39
45
 
40
46
  def default_path
@@ -315,11 +321,50 @@ module ProjectManager
315
321
  { 'version' => 1, 'projects' => {}, 'items' => {}, 'attention_days' => {} }
316
322
  end
317
323
 
324
+ # The whole write is one unit — generation, mkdir, write, rename. Any failure
325
+ # restores the last state that reached disk and re-raises, so a caller reports
326
+ # a refused write instead of losing one. Before this, a value JSON cannot
327
+ # represent stayed in @data and every later save failed identically, so the
328
+ # store silently accepted nothing while each caller saw only its own error.
329
+ #
330
+ # Rollback discards everything in memory since the last successful save.
331
+ # Store's own mutators each end in save, so that is normally just the
332
+ # offending write — but `items`, `projects` and the fetch_* methods hand out
333
+ # the live hashes, and an edit written straight into one of those is discarded
334
+ # too. Records returned by this class are for reading; go through a mutator to
335
+ # keep a change.
336
+ #
337
+ # The guard belongs here rather than in apply_item_attrs: every writer passes
338
+ # through save, and an attribute-level check would leave project names,
339
+ # provenance entries and attention records unguarded.
340
+ #
341
+ # Two residuals, both raised in review and both left open because no current
342
+ # caller can reach them. Read these before adding a caller that does.
343
+ #
344
+ # 1. Rollback rebuilds @data, so a record handed out earlier by items,
345
+ # projects or fetch_* is detached from the store afterwards: reads from it
346
+ # go stale and writes into it reach nothing. Of the three tools that hold
347
+ # a returned record, only pm_record holds one across a save, and it uses
348
+ # the id string alone. A caller that keeps a record and edits it after a
349
+ # refused write would need this to restore in place instead.
350
+ # 2. JSON.parse(@committed) is assumed to succeed. It does, because
351
+ # @committed is only ever assigned the output of a generate that already
352
+ # round-tripped, and tool arguments arrive parsed from JSON so no object
353
+ # with its own to_json can enter. A value reaching @data from Ruby rather
354
+ # than from a tool could break that.
318
355
  def save
319
- FileUtils.mkdir_p(File.dirname(@path))
320
- tmp = "#{@path}.tmp"
321
- File.write(tmp, JSON.pretty_generate(@data))
322
- File.rename(tmp, @path)
356
+ json = nil
357
+ begin
358
+ json = JSON.pretty_generate(@data)
359
+ FileUtils.mkdir_p(File.dirname(@path))
360
+ tmp = "#{@path}.tmp"
361
+ File.write(tmp, json)
362
+ File.rename(tmp, @path)
363
+ rescue StandardError
364
+ @data = JSON.parse(@committed)
365
+ raise
366
+ end
367
+ @committed = json
323
368
  end
324
369
  end
325
370
  end
@@ -8,15 +8,54 @@ module ProjectManager
8
8
  @_pm_store ||= Store.new
9
9
  end
10
10
 
11
+ # Named so a test can point the reader at a file it wrote, and still drive
12
+ # the real pm_config rather than a stand-in for it.
13
+ def pm_config_path
14
+ File.expand_path('../../config/pm.yml', __dir__)
15
+ end
16
+
17
+ # pm.yml is the one file an operator is invited to edit, and `skillset
18
+ # upgrade` deliberately never overwrites it, so a bad edit is permanent
19
+ # rather than transient. YAML.safe_load_file is strict — a tab indent, an
20
+ # unclosed quote, a bare date, an &anchor and a :symbol each raise, five of
21
+ # five measured — and the raise happened here, one level above every guard
22
+ # the tools carry. One stray character took every pm tool out at once.
23
+ #
24
+ # A document that parses but is not a mapping is the same failure in another
25
+ # shape: it breaks the first caller that subscripts it. It is coerced here
26
+ # rather than at each call site, because a call-site guard is re-acquired by
27
+ # the next caller and pm_record's was never written.
28
+ #
29
+ # Falling back is not the whole fix. Defaults wearing the operator's settings
30
+ # are worse than the outage they replace, so the reason is kept and
31
+ # pm_config_error hands it to any caller that reports to a human.
11
32
  def pm_config
12
- @_pm_config ||= begin
13
- path = File.expand_path('../../config/pm.yml', __dir__)
14
- File.exist?(path) ? (YAML.safe_load_file(path) || {}) : {}
15
- end
33
+ return @_pm_config if defined?(@_pm_config)
34
+
35
+ @_pm_config_error = nil
36
+ loaded = File.exist?(pm_config_path) ? YAML.safe_load_file(pm_config_path) : {}
37
+ @_pm_config =
38
+ if loaded.is_a?(Hash)
39
+ loaded
40
+ else
41
+ # An empty file — no bytes, blank lines, or comments only — parses to
42
+ # nil, and that is the operator saying "no settings". Every other
43
+ # non-mapping is a mistake and is named, `false` included: it is an
44
+ # affirmative value that happens not to be a Hash, so treating it as
45
+ # emptiness would hand back defaults with nothing said.
46
+ @_pm_config_error = "pm.yml is #{loaded.class}, expected a mapping" unless loaded.nil?
47
+ {}
48
+ end
49
+ rescue StandardError => e
50
+ @_pm_config_error = "#{e.class}: #{e.message.to_s.lines.first.to_s.strip}"
51
+ @_pm_config = {}
16
52
  end
17
53
 
18
- def pm_digest
19
- Digest.new(pm_store, pm_config['digest'])
54
+ # nil when pm.yml was usable, or absent — absence is the documented default,
55
+ # not a failure. Otherwise, why the file was ignored.
56
+ def pm_config_error
57
+ pm_config
58
+ @_pm_config_error
20
59
  end
21
60
  end
22
61
  end
@@ -51,6 +51,13 @@ thresholds cannot be compared with yesterday's. If the operator asks for a diffe
51
51
  use it, and say in the report which value you used and that this report is not comparable to the
52
52
  default one.
53
53
 
54
+ The digest reads those defaults from `pm.yml`, and that file can be unreadable — a stray tab, an
55
+ unclosed quote, a bare date. When it is, the digest still answers, on built-in defaults rather than
56
+ the operator's settings, and returns a `config_error` field saying why. **If that field is present,
57
+ say so first, before the buckets**, and name the reason verbatim: this report was built on defaults,
58
+ so it is not comparable to one built on the configured thresholds, and the settings file needs
59
+ repair. Reporting the buckets without it presents defaults as though the operator had chosen them.
60
+
54
61
  The digest computes its buckets independently, so one item can appear in several of them. Report
55
62
  each item exactly once, in the first bucket that claims it, in this fixed order:
56
63
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project_manager",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Project and work-item management: register projects and items, query them, produce a session-start digest with derived dormancy, and record irreversible project actions on the attestation chain. Ships a secretary-persona sub-agent (plugin/agents) so the secretarial disposition runs in its own context under any instruction mode, and a read-only scan (scripts/l2_scan.py) that reports, per item, what has recently been written near it in the L2 context store. The scan claims no attribution: tags name subjects and items name work that remains, so its output is evidence for the operator to judge. scripts/pm_l2_report.py renders the same comparison as one HTML page under <data dir>/log/ and is declared as a SessionStart hook in plugin/hooks.json, so a projected instance sees the memo-versus-L2 drift once per session. It takes no output path, which is how it is read-only: nothing can aim a write at the memo, the mapping, or an L2 context. When the authored mapping has nothing for an item, its search terms are inferred from the item's own title and notes, so L2 is never asked to be relabelled; inference accepts only a document name or an underscored identifier, caps both the reach of each term and the size of the row's whole result, and refuses rather than truncates when the row would be too broad. A row without a comparison names which of five things is missing, distinguishing an unreadable L2 date from an unreadable memo marker, because they blame different files. Holds an attention record: per closed judgment it stores what the operator was asked for, how long the shown text was, and the operators own answer to whether it was understood in one pass, plus a daily declaration of how many judgments the day has room for. The self-report is the calibrating measurement and may never be filled in by the agent that produced the output. Designs: docs/drafts/project_manager_plugin_projection_draft_v0.7_FROZEN.md and docs/drafts/project_manager_l2_derivation_draft_v0.12.md.",
5
5
  "author": "Masaomi Hatakeyama",
6
6
  "layer": "L1",
@@ -795,3 +795,335 @@ class TestProjectManagerAttention < Minitest::Test
795
795
  assert_match(/invalid attention kind/, out['error'])
796
796
  end
797
797
  end
798
+
799
+ # The write side (2026-08-18). Every guard in this SkillSet used to sit on the
800
+ # reading side — parse_time, whole_number, the config containers — and a value
801
+ # that cannot be turned into JSON walked straight past all of them. Generation
802
+ # failed before File.write, so the file survived and @data did not: the bad value
803
+ # stayed in memory and every later save failed identically, which read to the
804
+ # caller as one error and to the store as a permanent refusal to record anything.
805
+ class TestProjectManagerStoreWriteGuard < Minitest::Test
806
+ # Valid Ruby strings and numbers that JSON cannot represent. Both arrive
807
+ # through ordinary tool arguments; neither is caught by the status/salience
808
+ # enum checks, which are the only write-side validation that existed.
809
+ LONE_SURROGATE = "bad\xED\xA0\x80title".dup.force_encoding('UTF-8')
810
+
811
+ def setup
812
+ @dir = Dir.mktmpdir
813
+ @path = File.join(@dir, 'store.json')
814
+ @store = ProjectManager::Store.new(path: @path)
815
+ @now = Time.utc(2026, 8, 18, 12, 0, 0)
816
+ @prj = @store.register_project(name: 'p', now: @now)
817
+ @kept = @store.add_item(project_id: @prj['id'], title: 'kept', now: @now)
818
+ end
819
+
820
+ def teardown
821
+ FileUtils.remove_entry(@dir)
822
+ end
823
+
824
+ def on_disk
825
+ JSON.parse(File.read(@path))
826
+ end
827
+
828
+ def test_an_unwritable_title_is_refused_rather_than_written
829
+ assert_raises(JSON::GeneratorError) do
830
+ @store.add_item(project_id: @prj['id'], title: LONE_SURROGATE, now: @now)
831
+ end
832
+ end
833
+
834
+ # The defect proper: not that the bad write failed, but that every good write
835
+ # after it failed too, on disk and in memory, without anything saying so.
836
+ def test_the_store_still_accepts_writes_after_a_refused_one
837
+ begin
838
+ @store.add_item(project_id: @prj['id'], title: LONE_SURROGATE, now: @now)
839
+ rescue JSON::GeneratorError
840
+ nil
841
+ end
842
+
843
+ later = @store.add_item(project_id: @prj['id'], title: 'written after', now: @now)
844
+ assert_equal 'written after', @store.fetch_item(later['id'])['title']
845
+ assert_equal 2, on_disk['items'].size, 'the good writes reached disk'
846
+ assert_equal %w[kept written\ after].sort,
847
+ on_disk['items'].values.map { |i| i['title'] }.sort
848
+ end
849
+
850
+ # A refused write must not survive in memory either, or a reader counts items
851
+ # that were never recorded — which is how the digest reported phantom work.
852
+ def test_a_refused_write_leaves_nothing_behind_in_memory
853
+ begin
854
+ @store.add_item(project_id: @prj['id'], title: LONE_SURROGATE, now: @now)
855
+ rescue JSON::GeneratorError
856
+ nil
857
+ end
858
+
859
+ assert_equal 1, @store.items.size
860
+ assert_equal 'kept', @store.items.first['title']
861
+ refute_includes @store.items.map { |i| i['title'] }, LONE_SURROGATE
862
+ end
863
+
864
+ # Infinity reaches JSON through any numeric field and fails generation the same
865
+ # way. Different value, same path, so it must be refused by the same guard
866
+ # rather than by a check written for strings.
867
+ def test_an_unrepresentable_number_is_refused_the_same_way
868
+ assert_raises(JSON::GeneratorError) do
869
+ @store.update_item(@kept['id'], { 'notes' => Float::INFINITY }, now: @now)
870
+ end
871
+ assert_equal 1, @store.items.size
872
+ @store.update_item(@kept['id'], { 'notes' => 'fine' }, now: @now)
873
+ assert_equal 'fine', on_disk['items'][@kept['id']]['notes']
874
+ end
875
+
876
+ # Rolling back restores @committed, which advances only after a write finishes.
877
+ # An earlier write that had been accepted must therefore survive the rollback of
878
+ # a later refused one.
879
+ def test_rollback_does_not_discard_an_earlier_accepted_write
880
+ @store.update_item(@kept['id'], { 'salience' => 'high' }, now: @now)
881
+ begin
882
+ @store.add_item(project_id: @prj['id'], title: LONE_SURROGATE, now: @now)
883
+ rescue JSON::GeneratorError
884
+ nil
885
+ end
886
+ assert_equal 'high', @store.fetch_item(@kept['id'])['salience']
887
+ assert_equal 'high', on_disk['items'][@kept['id']]['salience']
888
+ end
889
+
890
+ # A failure after generation — no space, a read-only directory, a rename that
891
+ # cannot complete — leaves the same poisoned @data as a generation failure did.
892
+ # Guarding only generation would let a write reported as failed reappear in the
893
+ # next successful save.
894
+ def test_a_failure_after_generation_rolls_back_too
895
+ File.chmod(0o500, @dir)
896
+ begin
897
+ assert_raises(SystemCallError) do
898
+ @store.add_item(project_id: @prj['id'], title: 'never written', now: @now)
899
+ end
900
+ ensure
901
+ File.chmod(0o700, @dir)
902
+ end
903
+
904
+ assert_equal 1, @store.items.size, 'the refused write stayed in memory'
905
+ refute_includes @store.items.map { |i| i['title'] }, 'never written'
906
+ @store.add_item(project_id: @prj['id'], title: 'after', now: @now)
907
+ assert_equal %w[after kept], on_disk['items'].values.map { |i| i['title'] }.sort
908
+ end
909
+
910
+ # Rollback restores from an in-memory copy of the last written state, not from
911
+ # the file. Re-reading would fail exactly when it is needed most — a store.json
912
+ # that has become unreadable would leave the bad value in place and wedge the
913
+ # process, which is the defect this guard exists to remove.
914
+ def test_rollback_survives_an_unreadable_file_on_disk
915
+ File.write(@path, 'not json at all')
916
+ begin
917
+ @store.add_item(project_id: @prj['id'], title: LONE_SURROGATE, now: @now)
918
+ rescue JSON::GeneratorError
919
+ nil
920
+ end
921
+ assert_equal 1, @store.items.size
922
+ assert_equal 'kept', @store.items.first['title']
923
+ end
924
+
925
+ # Honest boundary, pinned so it cannot be quietly claimed otherwise: rollback
926
+ # drops everything in memory since the last successful save, and the fetch_*
927
+ # methods hand out the live hash, so an edit written straight into one is
928
+ # dropped as well. Go through a mutator to keep a change.
929
+ def test_an_edit_written_straight_into_a_returned_record_is_discarded
930
+ @store.fetch_item(@kept['id'])['assignee'] = 'written behind the mutators'
931
+ begin
932
+ @store.add_item(project_id: @prj['id'], title: LONE_SURROGATE, now: @now)
933
+ rescue JSON::GeneratorError
934
+ nil
935
+ end
936
+ refute @store.fetch_item(@kept['id']).key?('assignee'),
937
+ 'the direct edit survived a rollback the comment says discards it'
938
+ end
939
+
940
+ # The guard sits in save, not in the item attribute path, so writes that never
941
+ # touch an item attribute are covered too.
942
+ def test_a_project_name_is_covered_by_the_same_guard
943
+ assert_raises(JSON::GeneratorError) do
944
+ @store.update_project(@prj['id'], { 'name' => LONE_SURROGATE }, now: @now)
945
+ end
946
+ assert_equal 'p', @store.fetch_project(@prj['id'])['name']
947
+ @store.update_project(@prj['id'], { 'name' => 'renamed' }, now: @now)
948
+ assert_equal 'renamed', on_disk['projects'][@prj['id']]['name']
949
+ end
950
+ end
951
+
952
+ load File.expand_path('../tools/pm_record.rb', __dir__)
953
+
954
+ # pm.yml is the only file an operator is invited to edit and `skillset upgrade`
955
+ # never repairs it, so a bad edit is permanent. safe_load_file is strict and the
956
+ # raise landed above every guard the tools carry, so one stray tab returned an
957
+ # error from every pm tool at once.
958
+ class TestPmConfigReadGuard < Minitest::Test
959
+ # Ordinary edits, each measured to raise a different Psych error.
960
+ BAD_YAML = {
961
+ 'tab indent' => "digest:\n\tapproaching_days: 7\n",
962
+ 'unclosed quote' => "digest:\n approaching_days: 'oops\n",
963
+ 'bare date' => "when: 2026-08-18\n",
964
+ 'anchor and alias' => "a: &x 1\nb: *x\n",
965
+ 'symbol' => "k: :sym\n"
966
+ }.freeze
967
+
968
+ def setup
969
+ @dir = Dir.mktmpdir
970
+ @path = File.join(@dir, 'pm.yml')
971
+ end
972
+
973
+ def teardown
974
+ FileUtils.remove_entry(@dir)
975
+ end
976
+
977
+ # A fresh host each time: pm_config memoises, which is the point of it.
978
+ def host(write: nil)
979
+ File.write(@path, write) unless write.nil?
980
+ path = @path
981
+ obj = Object.new
982
+ obj.extend(ProjectManager::ToolHelpers)
983
+ obj.define_singleton_method(:pm_config_path) { path }
984
+ obj
985
+ end
986
+
987
+ def test_an_unreadable_file_falls_back_to_defaults_instead_of_raising
988
+ BAD_YAML.each do |label, body|
989
+ h = host(write: body)
990
+ assert_equal({}, h.pm_config, "#{label} did not fall back")
991
+ refute_nil h.pm_config_error, "#{label} fell back without saying why"
992
+ end
993
+ end
994
+
995
+ # Falling back silently would hand the operator thresholds nobody chose and
996
+ # call them settings. The reason has to name the failure, not just exist.
997
+ def test_the_reason_names_the_failure
998
+ assert_match(/Psych::SyntaxError/, host(write: BAD_YAML['tab indent']).pm_config_error)
999
+ assert_match(/Psych::DisallowedClass/, host(write: BAD_YAML['bare date']).pm_config_error)
1000
+ end
1001
+
1002
+ # A document that parses but is not a mapping breaks the first caller that
1003
+ # subscripts it — pm_record has no guard of its own.
1004
+ def test_a_non_mapping_document_is_coerced_and_reported
1005
+ ['just a string', "- 1\n- 2\n", '42'].each do |body|
1006
+ h = host(write: body)
1007
+ assert_equal({}, h.pm_config, "#{body.inspect} was not coerced")
1008
+ refute_nil h.pm_config_error, "#{body.inspect} was coerced silently"
1009
+ end
1010
+ end
1011
+
1012
+ def test_an_absent_or_empty_file_is_not_a_failure
1013
+ assert_equal({}, host.pm_config)
1014
+ assert_nil host.pm_config_error, 'an absent file is the documented default'
1015
+
1016
+ # All three parse to nil, so emptiness is recognised by the parse result and
1017
+ # nothing else has to be exempted from the non-mapping report.
1018
+ ['', "\n", "# only a comment\n"].each do |body|
1019
+ h = host(write: body)
1020
+ assert_equal({}, h.pm_config)
1021
+ assert_nil h.pm_config_error, "#{body.inspect} is the operator saying \"no settings\""
1022
+ end
1023
+ end
1024
+
1025
+ # `false` parses, is not a mapping, and is not emptiness. Exempting it would
1026
+ # hand back defaults with nothing said — the failure this guard exists to stop.
1027
+ def test_a_literal_false_is_reported_rather_than_read_as_emptiness
1028
+ h = host(write: "false\n")
1029
+ assert_equal({}, h.pm_config)
1030
+ refute_nil h.pm_config_error, 'literal false was swallowed as "no settings"'
1031
+ assert_match(/FalseClass/, h.pm_config_error)
1032
+ end
1033
+
1034
+ # The guard must not eat a good file: the shipped pm.yml has to survive it.
1035
+ def test_a_usable_file_is_read_and_reports_nothing
1036
+ h = host(write: "digest:\n dormancy_days: 30\nrecord_ttl_seconds: 99\n")
1037
+ assert_equal 30, h.pm_config['digest']['dormancy_days']
1038
+ assert_equal 99, h.pm_config['record_ttl_seconds']
1039
+ assert_nil h.pm_config_error
1040
+ end
1041
+
1042
+ def test_the_shipped_pm_yml_is_readable
1043
+ shipped = File.expand_path('../config/pm.yml', __dir__)
1044
+ h = host
1045
+ h.define_singleton_method(:pm_config_path) { shipped }
1046
+ assert_nil h.pm_config_error, 'the file this SkillSet ships must pass its own guard'
1047
+ assert_equal 14, h.pm_config['digest']['dormancy_days']
1048
+ end
1049
+
1050
+ # The tool is the path an agent actually takes. It must answer, and it must
1051
+ # carry the reason rather than presenting defaults as the operator's settings.
1052
+ def test_the_digest_tool_answers_and_carries_the_reason
1053
+ dir = Dir.mktmpdir
1054
+ store = ProjectManager::Store.new(path: File.join(dir, 'store.json'))
1055
+ prj = store.register_project(name: 'p', now: Time.utc(2026, 8, 18))
1056
+ store.add_item(project_id: prj['id'], title: 'ember',
1057
+ now: Time.utc(2026, 8, 18) - (40 * 86_400), salience: 'high')
1058
+
1059
+ File.write(@path, BAD_YAML['tab indent'])
1060
+ path = @path
1061
+ t = KairosMcp::SkillSets::ProjectManagerSkillSet::Tools::PmDigest.allocate
1062
+ t.define_singleton_method(:pm_config_path) { path }
1063
+ t.define_singleton_method(:pm_store) { store }
1064
+
1065
+ out = JSON.parse(t.call({}))
1066
+ refute out.key?('error'), "an unreadable pm.yml still took the tool down: #{out['error'].inspect}"
1067
+ assert_equal 1, out['dormant_neglected'].size, 'the defaults still produced a digest'
1068
+ assert_match(/Psych::SyntaxError/, out['config_error'].to_s)
1069
+ ensure
1070
+ FileUtils.remove_entry(dir) if dir
1071
+ end
1072
+
1073
+ def test_the_digest_tool_says_nothing_when_the_file_is_fine
1074
+ dir = Dir.mktmpdir
1075
+ store = ProjectManager::Store.new(path: File.join(dir, 'store.json'))
1076
+ store.register_project(name: 'p', now: Time.utc(2026, 8, 18))
1077
+
1078
+ File.write(@path, "digest:\n dormancy_days: 14\n")
1079
+ path = @path
1080
+ t = KairosMcp::SkillSets::ProjectManagerSkillSet::Tools::PmDigest.allocate
1081
+ t.define_singleton_method(:pm_config_path) { path }
1082
+ t.define_singleton_method(:pm_store) { store }
1083
+
1084
+ out = JSON.parse(t.call({}))
1085
+ refute out.key?('config_error'), 'a healthy file must not be reported as ignored'
1086
+ ensure
1087
+ FileUtils.remove_entry(dir) if dir
1088
+ end
1089
+
1090
+ # pm_record reads the record ttl from the same file, and its records are meant
1091
+ # to be permanent. A silent default there would tell an operator who shortened
1092
+ # the ttl that their value was applied when it was not.
1093
+ def record_tool(store)
1094
+ path = @path
1095
+ t = KairosMcp::SkillSets::ProjectManagerSkillSet::Tools::PmRecord.allocate
1096
+ t.define_singleton_method(:pm_config_path) { path }
1097
+ t.define_singleton_method(:pm_store) { store }
1098
+ t.define_singleton_method(:issue_attestation) { |_project, _args| { proof_id: 'proof_1' } }
1099
+ t
1100
+ end
1101
+
1102
+ def test_the_record_tool_carries_the_reason
1103
+ dir = Dir.mktmpdir
1104
+ store = ProjectManager::Store.new(path: File.join(dir, 'store.json'))
1105
+ prj = store.register_project(name: 'p', now: Time.utc(2026, 8, 18))
1106
+
1107
+ File.write(@path, BAD_YAML['bare date'])
1108
+ out = JSON.parse(record_tool(store).call({ 'project_id' => prj['id'],
1109
+ 'action' => 'shipped', 'summary' => 's' }))
1110
+ refute out.key?('error'), "an unreadable pm.yml took pm_record down: #{out['error'].inspect}"
1111
+ assert_equal true, out['recorded']
1112
+ assert_match(/Psych::DisallowedClass/, out['config_error'].to_s)
1113
+ ensure
1114
+ FileUtils.remove_entry(dir) if dir
1115
+ end
1116
+
1117
+ def test_the_record_tool_says_nothing_when_the_file_is_fine
1118
+ dir = Dir.mktmpdir
1119
+ store = ProjectManager::Store.new(path: File.join(dir, 'store.json'))
1120
+ prj = store.register_project(name: 'p', now: Time.utc(2026, 8, 18))
1121
+
1122
+ File.write(@path, "record_ttl_seconds: 99\n")
1123
+ out = JSON.parse(record_tool(store).call({ 'project_id' => prj['id'],
1124
+ 'action' => 'shipped', 'summary' => 's' }))
1125
+ refute out.key?('config_error'), 'a healthy file must not be reported as ignored'
1126
+ ensure
1127
+ FileUtils.remove_entry(dir) if dir
1128
+ end
1129
+ end
@@ -59,7 +59,14 @@ module KairosMcp
59
59
  config['dormancy_days'] = arguments['dormancy_days'] if arguments['dormancy_days']
60
60
  config['approaching_days'] = arguments['approaching_days'] if arguments['approaching_days']
61
61
  digest = ::ProjectManager::Digest.new(pm_store, config)
62
- text_content(JSON.pretty_generate(digest.compute))
62
+ out = digest.compute
63
+ # An unreadable pm.yml no longer takes this tool down, and that is
64
+ # only half a fix: defaults that look like the operator's settings
65
+ # are worse than the outage. The reason travels with the digest so
66
+ # the consumer says the file was ignored instead of reporting
67
+ # thresholds nobody chose.
68
+ out[:config_error] = pm_config_error if pm_config_error
69
+ text_content(JSON.pretty_generate(out))
63
70
  rescue StandardError => e
64
71
  text_content(JSON.pretty_generate({ error: e.message }))
65
72
  end
@@ -83,10 +83,14 @@ module KairosMcp
83
83
  pm_store.update_project(project['id'], { 'status' => arguments['apply_project_status'] })
84
84
  end
85
85
 
86
- text_content(JSON.pretty_generate({
87
- recorded: true, action: arguments['action'],
88
- project_id: project['id'], attestation: attestation
89
- }))
86
+ out = { recorded: true, action: arguments['action'],
87
+ project_id: project['id'], attestation: attestation }
88
+ # The ttl above comes from pm.yml. When that file cannot be read the
89
+ # default stands in for it silently, so an operator who shortened the
90
+ # ttl would believe their value was applied to a record that is
91
+ # meant to be permanent. The reason travels with the receipt.
92
+ out[:config_error] = pm_config_error if pm_config_error
93
+ text_content(JSON.pretty_generate(out))
90
94
  rescue StandardError => e
91
95
  text_content(JSON.pretty_generate({ error: e.message }))
92
96
  end
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.73.0
4
+ version: 3.74.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Masaomi Hatakeyama