kairos-chain 3.71.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: b8cf3acf9bb95eb62a4fc78d85359540a9089973819fce152278a962007e6faa
4
- data.tar.gz: bcffe011916e72faecc52aeecc68743a7ef25a96a108b509d9cd80531d04b542
3
+ metadata.gz: 730e325cdf2caf9ce68964d25d379511f5b1080aa86b86378c928a06156fc567
4
+ data.tar.gz: 3c7fd4cc597b1fefd8403bd976786eba4e3e02c6665c2097d35940e243d6a2dd
5
5
  SHA512:
6
- metadata.gz: 759b4cdfe1cc4d6031708d669820994f090d6bf946191d7b3823b9b1f8a3969f9e11193222e8103e065ae20eea290e218638913262ff83225c6250f5f12640b9
7
- data.tar.gz: 8823dd97245c068d475951c908bcdd77807c43b7b0ce6705680d9413ad52bee608bd246c2a97214df6e0253869f87187cce9aaa92f647289a7a1068156c9a01b
6
+ metadata.gz: a78cf0336f37b5107f0991ca1f0e88a3cfeb911f1a998d1ffb5763e071d1ba9ffa88fde11d92fc2eaca9eeb0ffc948718e3bfa1f65ed0f87fa1adf3f1c36d40a
7
+ data.tar.gz: da3b0b85ec1518e1e07f4bd4199761128006fe57db205059b36f3145db7cd5ef8f51b9d95f24faa82a05756507244700d6fbe3655d3672d11b80e9b7eb4a697d
data/CHANGELOG.md CHANGED
@@ -4,6 +4,226 @@ 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
+
65
+ ## [3.73.0] - 2026-08-17
66
+
67
+ ### Fixed
68
+
69
+ - **Round 2 of the same review returned 0 APPROVE of 3 valid seats and thirteen
70
+ more blocking findings, three of them saying round 1's fixes did not hold. The
71
+ response was to subtract.** Two of the six guards round 1 added are removed
72
+ rather than repaired. The `-o` output path is deleted: guarding it by comparing
73
+ resolved paths failed three ways — a case-only difference on a case-insensitive
74
+ filesystem, a hardlink, and any read input the check did not enumerate, which
75
+ included every L2 context and `config/pm.yml` — and each failure destroyed the
76
+ memo while the run printed that nothing had been written to it. The page now
77
+ always goes to `<data dir>/log/pm_l2_report.html`, beside the data directory so a
78
+ relocated instance still finds it, and under a name the operator's own `.gitignore`
79
+ already knows how to handle. The carried `exclude` is
80
+ also removed: carrying it was itself a round-1 fix, and since an exclude term is
81
+ a substring of document names while an inferred term is usually the item's own
82
+ record name, it suppressed the item's own primary record. It still applies to
83
+ the authored terms it was written beside, and the row says when it was not
84
+ applied.
85
+ - **The anti-flood cap bounds the row, not only each term.** Twelve terms each
86
+ under the twenty-document cap unioned to 124 of 1179 documents for one item, and
87
+ a note of the ordinary shape reached 23. A row over the cap is refused rather
88
+ than truncated, because truncation is silent and moves `last_activity` and the
89
+ headline figures with it; the row reports how many its terms reached.
90
+ - **Paths are derived from the script's own location** instead of by appending a
91
+ literal `.kairos`, which had reported a populated instance as empty at exit 0 on
92
+ every session of a relocated data directory. **A row with no comparison names
93
+ which of five things is missing**, separating an unparseable L2 date from an
94
+ unparseable memo marker, because they blame different files and collapsing them
95
+ stated a false fact for the second time. **Nested store shapes are checked**, not
96
+ only their absence: a `projects` or `items` value that was truthy and not an
97
+ object reached `.values()` and raised.
98
+ - **The test file is rewritten, not extended.** An audit applied 65 one-line
99
+ mutations to the round-1 suite and 36 survived — the whole of `main`'s wiring,
100
+ all cross-item aggregation, and the impossible-date guard the suite was named
101
+ for, whose fixture built a second document matching the same term so the valid
102
+ date sorted last and the impossible one never reached the parse point. 53 cases
103
+ now, and of the 30 mutations that map to a reported finding 29 are killed; the
104
+ survivor is equivalent. Four habits are stated in the file: exercise guards
105
+ through `main` in a subprocess, check that a fixture cannot satisfy its own
106
+ assertion, build several rows when testing aggregation, and assert messages and
107
+ exit codes by content. Nothing runs the suite automatically — `rake test`
108
+ collects Ruby files only.
109
+ - **Accepted rather than fixed, and recorded where they happen.** A context
110
+ declaring a parseable but absurd date such as `9999-12-31` flattens the other lag
111
+ bars, which is what bars relative to the widest lag mean. And what the report
112
+ says still depends on which `python3` resolves, since `date.fromisoformat`
113
+ accepts basic format from 3.11. Separately, two stale `.pyc` files sit inside one
114
+ installed SkillSet from earlier runs, so its `content_hash` differs from a clean
115
+ tree's; nothing here removes them.
116
+ - Live data after the change: 28 of 28 items comparable, 19 with L2 more recent,
117
+ median 29 days, widest 81 — identical to before, so nothing the subtraction
118
+ removed was carrying coverage.
119
+
120
+ ## [3.72.0] - 2026-08-17
121
+
122
+ **Built and never published.** Its contents ship in 3.73.0, which carries the second round of fixes to the same feature; the entry is kept because the commits it describes are in the history.
123
+
124
+ ### Fixed
125
+
126
+ - **Six defects in the session-start report below, all found by a pre-release
127
+ multi-LLM review and all demonstrated by running code rather than reasoning
128
+ about it.** The review returned 0 APPROVE of 3 counted seats. Two of the five
129
+ invariants the change declared were false as stated, which is why they are
130
+ listed here rather than deferred.
131
+ - *The read-only promise was prose.* `open(out, "w")` accepted any path, so
132
+ `-o <memo>` truncated `store.json` while the run printed that nothing had been
133
+ written to it. The output path is now refused if it resolves to the memo or
134
+ the mapping, via `realpath`, so a symlink cannot walk around it.
135
+ - *A read-only report changed the SkillSet's chain-recorded hash.*
136
+ `exec_module` writes bytecode, and `scripts/__pycache__/l2_scan.cpython-310.pyc`
137
+ landed inside the SkillSet directory — inside `Skillset#all_file_hashes`, hence
138
+ inside `content_hash`, hence inside what `skillset_manager` records as
139
+ `skillset_event` and verifies with `raise SecurityError`. The recorded hash was
140
+ a function of the local CPython build and of whether the report had run.
141
+ `sys.dont_write_bytecode` is now set around the import.
142
+ - *The anti-flood guard was capped on one tier only.* A document's name is
143
+ `name:` or `title:` or its basename, and 321 of 1177 contexts take it from a
144
+ free-text `title:`. One context titled `Review` made `review` a search term
145
+ reaching 351 records; titled `Context`, all 1178. Both tiers are now capped at
146
+ twenty documents, which costs nothing measurable: of 1125 distinct names none
147
+ reaches more than 20, and the two items that use inference return the same 2
148
+ and 17 records.
149
+ - *A non-string `touched_at` or `due` crashed the report.* `pm_item` writes both
150
+ through with no check beyond a JSON type and this SkillSet's own Ruby suite
151
+ writes the integer `20260701` to each, so slicing them raised `TypeError` — the
152
+ defect `lib/project_manager/parsed_time.rb` exists to prevent, re-acquired at a
153
+ fifth call site because the reader is in another language. Permanent once
154
+ triggered, since the value stays in the store.
155
+ - *One shape-valid impossible date took the whole run down.* `l2_scan` validates
156
+ that a declared date looks like a date, not that it exists, so `2026-02-30`
157
+ reached `date.fromisoformat` outside the `try` — the guard covered one end of
158
+ the interval only. Both ends now go through it.
159
+ - *An unreadable memo marker was reported as unreadable records.* An item with
160
+ seventeen perfectly datable records was described as "records found, none
161
+ datable", and it silently left the denominator: 28/28 matched with median 29
162
+ became 27/28 with median 28.5. The three causes — no terms, undatable records,
163
+ unreadable marker — are now counted and worded apart.
164
+ - **Smaller fixes from the same review.** The authored `exclude` is carried into
165
+ the inference fallback rather than dropped, so a distinction the operator wrote
166
+ down is not undone by the fallback. A mapping whose `include` is a string rather
167
+ than a list no longer expands to single-character terms (one of which matched
168
+ 1177 of 1177 documents, displayed as the operator's own authored mapping).
169
+ `store['projects']`, `mapping['items']` and dependency entries missing `kind` or
170
+ `ref` no longer raise. `--open` falls back to `xdg-open` and survives neither
171
+ binary existing, since the gem ships to Linux. The hook reads `KAIROS_DATA_DIR`
172
+ before `$CLAUDE_PROJECT_DIR/.kairos`, because the data directory is relocatable
173
+ and a relocated instance got a hook pointing at nothing. The hook no longer
174
+ discards stderr or forces a zero exit: doing both made every failure
175
+ indistinguishable from a session where nothing had drifted. Operator-facing text
176
+ no longer renders a Python list repr, and `--quiet` documents the two lines it
177
+ prints instead of three.
178
+ - **`test/test_pm_l2_report.py`, 30 cases, 27 of them red against the version that
179
+ shipped before them.** Of those 27, nine exercise the old behaviour directly and
180
+ eighteen fail because the guard function did not exist — that distinction is
181
+ recorded rather than counted as thirty demonstrations. The remaining three cover
182
+ HTML escaping and a degenerate render, which were already correct and are held
183
+ as regression guards. The suite drives the real `l2_scan.match` rather than a
184
+ copy of it, so the two views cannot disagree about what a term matched.
185
+
186
+ ### Added
187
+
188
+ - **The `project_manager` SkillSet (v0.6.0) reports the memo-versus-L2 drift once
189
+ per session, and no longer needs a human to author search terms first.** The
190
+ memo lags the context store and the lag is invisible from whichever side is
191
+ being read: on the development instance, 19 of 28 items have L2 activity more
192
+ recent than their last memo touch, median 29 days, widest 81. Derivation could
193
+ already measure that, but only when someone remembered to run it and only for
194
+ items a human had authored terms for. Two read-only additions.
195
+ `scripts/pm_l2_report.py` renders the comparison as one HTML page, and
196
+ `plugin/hooks.json` declares it as a `SessionStart` hook so the harness runs it
197
+ without anyone deciding to. `SessionStart` and not `Stop`: this is not a gate,
198
+ it decides nothing, and only Stop-family payloads carry the once-per-turn brake
199
+ `kairos_hook_projector`'s gates need. Delivery is by projection — `skillset
200
+ install` changes no host settings, and the MCP handshake projects on every host
201
+ start, so no core change was needed to reach the host. Whether the newly
202
+ written hook fires on that same start or the following one depends on when the
203
+ host reads its settings relative to the handshake; that ordering is not
204
+ measured, and `kairos-plugin-project` run by hand settles it. `skillset upgrade
205
+ --apply` is not a substitute: it projects only when it actually upgrades
206
+ something, and `project_manager` is not in the core set it upgrades. Measured
207
+ at 0.18s over 1172 contexts; the script writes exactly one file, its own
208
+ output, and the memo's hash is unchanged across a run.
209
+ - **Search terms fall back to inference from the item's own title and notes**
210
+ when the authored mapping has no entry for an item, or its entry matched
211
+ nothing — so no item goes unreported and L2 is never asked to be relabelled.
212
+ Inference cannot replace the mapping and does not try: 43 of 53 hand-authored
213
+ terms appear nowhere in any item's title or notes, having been written from
214
+ knowledge of the work. What it can do is refuse to flood. Only a token that is
215
+ itself the name of an existing L2 document, or a compound identifier reaching
216
+ at most twenty documents, is accepted; a bare English word is refused however
217
+ rare it looks. Accepting bare words returned 51 and 82 records for the two
218
+ items that have almost none, because a defect is described with words like
219
+ store, write, config and yaml, and those match hundreds of unrelated names as
220
+ substrings. Refusing them, the same two return 2 and 17, and all 28 items
221
+ become comparable. The cost is misses — inference alone found 87 records across
222
+ the 24 mapped items where the mapping found 296 — and the trade is deliberate:
223
+ a miss shows up as a smaller count, a spurious record does not show up at all.
224
+ Unchanged by this release: nothing writes to the memo (derivation v0.12 §1),
225
+ and neither the digest's shape nor the secretary's grant moved.
226
+
7
227
  ## [3.71.0] - 2026-08-17
8
228
 
9
229
  ### Added
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.71.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
@@ -62,6 +62,94 @@ Prefer the sub-agent for "what needs attention?", at session start, and for sche
62
62
  - **Layer discipline.** These tools never write L0 or L1.
63
63
  - **World-event dependencies are operator-cleared.** The system never auto-resolves them.
64
64
 
65
+ ## The session-start L2 comparison
66
+
67
+ `scripts/pm_l2_report.py` compares the memo against the L2 context store and writes one HTML page,
68
+ to `<data dir>/log/pm_l2_report.html`. `plugin/hooks.json` declares it as a `SessionStart` hook.
69
+
70
+ **Read-only by having no aim, not by checking one.** There is no output-path argument, so nothing
71
+ can point a write at the memo, the mapping, an L2 context, or `config/pm.yml`. An earlier version
72
+ took `-o` and guarded it by comparing resolved paths; that guard failed three ways — a case-only
73
+ difference on a case-insensitive filesystem, a hardlink, and any read input it did not know about —
74
+ and each failure destroyed the memo while the run printed that nothing had been written to it.
75
+ Deleting the argument closed all three. `sys.dont_write_bytecode` is set around the derivation
76
+ import for the same reason a second guard was not added: a `.pyc` inside the SkillSet sits inside
77
+ `Skillset#all_file_hashes` and therefore inside `content_hash`, the value recorded on chain.
78
+
79
+ **Paths come from this file's own location**, three levels up from `scripts/`, not from the name
80
+ `.kairos`. `l2_scan` derives its own by appending that literal, which reported a populated instance
81
+ as empty on every session whenever the data dir had been relocated, so its four path constants are
82
+ re-pointed after import.
83
+
84
+ **Nothing is trusted to have a type.** `pm_item` writes `due` and `touched_at` through with no check
85
+ beyond a JSON type, and this SkillSet's own Ruby suite writes the integer `20260701` to both. Dates
86
+ are parsed in exactly one function, which both ends of every interval go through: `l2_scan`
87
+ validates that a declared date *looks* like a date, never that it exists, so one context declaring
88
+ `2026-02-30` used to take the whole unattended run down.
89
+
90
+ Delivery is by **projection**, not by install, because install alone changes no host settings:
91
+
92
+ ```
93
+ kairos-chain skillset install project_manager files land; the host's settings are untouched
94
+ next start of the host the MCP handshake projects; the hook is written
95
+ ```
96
+
97
+ Whether the hook then fires on that same start or the following one depends on when the host reads
98
+ its settings relative to the handshake; observed firing on the following one. `kairos-plugin-project`
99
+ run by hand settles it, and `skillset upgrade --apply` is not a substitute — it projects only when
100
+ it actually upgrades something, and this SkillSet is not in the core set it upgrades.
101
+
102
+ ### Where search terms come from
103
+
104
+ The authored mapping first. When it has no entry for an item, or its entry matched nothing, terms
105
+ are inferred from that item's own title and notes, so no item goes unreported and L2 is never asked
106
+ to be relabelled. Inference cannot replace the mapping and does not try: 43 of 53 hand-authored
107
+ terms appear nowhere in any item's title or notes, having been written from knowledge of the work.
108
+
109
+ What it can do is refuse to flood, and it took three measurements to get that right, each from a
110
+ reviewer running the code:
111
+
112
+ | what was accepted | what happened | rule now |
113
+ |---|---|---|
114
+ | bare English words | 51 and 82 records for two items that have nearly none, because a defect is described with words like store, write, config and yaml | only a document name or a compound identifier containing an underscore |
115
+ | an uncapped document-name tier | a context titled `Review` made `review` a term reaching 351 records; titled `Context`, all 1178 | both tiers capped at twenty documents per term |
116
+ | a cap per term but not per row | twelve terms each under the cap unioned to 124 of 1179 documents; an ordinary note reached 23 | the row's whole union is capped too, and inference is **refused** rather than truncated |
117
+
118
+ Refused and not truncated, because truncation is silent and moves `last_activity` and the headline
119
+ figures with it. A refused row says its terms were too broad and how many they reached, and the page
120
+ shows the terms — a row claiming no term could be built while not showing what it tried is a claim
121
+ the operator cannot check where it is wrong.
122
+
123
+ **An authored `exclude` stops at the authored terms.** Carrying it into inference was itself a fix
124
+ in an earlier round, and it traded one wrong answer for another: an exclude term is a substring of
125
+ document names, and an inferred term is usually the item's own record name, so carrying it
126
+ suppressed the item's own primary record. The row says so rather than dropping it silently.
127
+
128
+ The cost of all this is misses — across the 24 mapped items, inference alone found 87 records where
129
+ the mapping found 296 — and the trade is deliberate: a miss shows up as a smaller count, a spurious
130
+ record does not show up at all.
131
+
132
+ ### Five reasons, not one
133
+
134
+ A row with no comparison names which side is missing: no usable terms, terms too broad, records
135
+ carrying no date, an L2 date that cannot be parsed, or a memo marker that cannot be parsed. The last
136
+ two are separate sentences because they blame different files. Collapsing them produced a false
137
+ statement twice — first an item with seventeen datable records reported as having none, then a memo
138
+ blamed for a date that L2 had written wrong — and each time the item also left the denominator, so
139
+ the headline figures shrank without saying so.
140
+
141
+ ### Tests
142
+
143
+ `test/test_pm_l2_report.py`. The bar is mutation, not redness against an older commit: breaking a
144
+ guard by one line must make the suite red. An audit of an earlier version applied 65 one-line
145
+ mutations and 36 survived, so guards are now exercised through `main()` in a subprocess, fixtures
146
+ are checked not to satisfy their own assertion, aggregation is tested with several items, and
147
+ messages and exit codes are asserted by content. Of the 30 mutations that map to a reported finding,
148
+ 29 are killed; the survivor is equivalent (deleting the absent-derivation branch leaves the generic
149
+ handler producing the same message, exit code and absence of a traceback).
150
+
151
+ Nothing runs this suite automatically — `rake test` collects Ruby files only.
152
+
65
153
  ## Relation to instruction modes
66
154
 
67
155
  The tools carry no disposition and depend on no particular instruction mode. This SkillSet does
@@ -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
 
@@ -0,0 +1,46 @@
1
+ {
2
+ "_comment": [
3
+ "Hooks this SkillSet contributes to the host harness. The plugin projector",
4
+ "merges this into the host's settings under a _projected_by tag, so",
5
+ "re-projection replaces only what it put there and leaves hand-written",
6
+ "hooks alone. Installing the SkillSet and re-projecting is the whole",
7
+ "delivery -- nobody edits settings.json by hand.",
8
+ "",
9
+ "SessionStart, not Stop. A Stop-family gate can brake itself once per turn",
10
+ "via stop_hook_active, which is why kairos_hook_projector restricts its",
11
+ "gates to Stop and SubagentStop; this is not a gate. It reports, it decides",
12
+ "nothing, and it runs once when a session opens.",
13
+ "",
14
+ "KAIROS_DATA_DIR before $CLAUDE_PROJECT_DIR/.kairos. The data directory is",
15
+ "relocatable -- KairosMcp.resolve_data_dir reads that variable, and",
16
+ "PluginProjector takes data_dir separately from project_root with a guard",
17
+ "that exists because the two are expected to differ. Hardcoding the second",
18
+ "form meant that on a relocated instance the hook pointed at nothing and,",
19
+ "with stderr discarded, said nothing about it.",
20
+ "",
21
+ "No 2>/dev/null and no || true. Suppressing both turned every failure --",
22
+ "absent script, unreadable store, a stored timestamp that is not a string --",
23
+ "into exit 0 with empty output, which is indistinguishable from a session",
24
+ "where nothing had drifted. The script now handles its own absences and",
25
+ "reports each in one line on stdout, so what is left uncaught is a genuine",
26
+ "defect and should be visible. Measured at 0.18s over 1172 contexts.",
27
+ "",
28
+ "The command does not begin with kairos-, so projection prints one",
29
+ "non-standard-command warning. That is the projector asking a human to look,",
30
+ "and this is what it looks like: a read-only report that refuses an output",
31
+ "path resolving to the memo, and writes only its own HTML."
32
+ ],
33
+ "hooks": {
34
+ "SessionStart": [
35
+ {
36
+ "hooks": [
37
+ {
38
+ "type": "command",
39
+ "command": "python3 \"${KAIROS_DATA_DIR:-$CLAUDE_PROJECT_DIR/.kairos}/skillsets/project_manager/scripts/pm_l2_report.py\" --quiet",
40
+ "timeout": 20
41
+ }
42
+ ]
43
+ }
44
+ ]
45
+ }
46
+ }