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 +4 -4
- data/CHANGELOG.md +58 -0
- data/lib/kairos_mcp/version.rb +1 -1
- data/templates/skillsets/project_manager/lib/project_manager/store.rb +49 -4
- data/templates/skillsets/project_manager/lib/project_manager/tool_helpers.rb +45 -6
- data/templates/skillsets/project_manager/plugin/agents/secretary.md +7 -0
- data/templates/skillsets/project_manager/skillset.json +1 -1
- data/templates/skillsets/project_manager/test/test_project_manager.rb +332 -0
- data/templates/skillsets/project_manager/tools/pm_digest.rb +8 -1
- data/templates/skillsets/project_manager/tools/pm_record.rb +8 -4
- 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: 730e325cdf2caf9ce68964d25d379511f5b1080aa86b86378c928a06156fc567
|
|
4
|
+
data.tar.gz: 3c7fd4cc597b1fefd8403bd976786eba4e3e02c6665c2097d35940e243d6a2dd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/kairos_mcp/version.rb
CHANGED
|
@@ -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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|