@dzhechkov/p-replicator 1.5.16 → 1.5.18

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.
Files changed (21) hide show
  1. package/.dz-manifest.json +51 -23
  2. package/README.md +5 -3
  3. package/package.json +1 -1
  4. package/sbom.json +106 -36
  5. package/templates/.claude/skills/goap-research-ed25519/SKILL.md +340 -47
  6. package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +359 -3
  7. package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +386 -13
  8. package/templates/.claude/skills/goap-research-ed25519/scripts/fixture_legacy_v2_fact.json +23 -0
  9. package/templates/.claude/skills/goap-research-ed25519/scripts/fixtures_field_cases.json +133 -0
  10. package/templates/.claude/skills/goap-research-ed25519/scripts/goap_planner.py +314 -44
  11. package/templates/.claude/skills/goap-research-ed25519/scripts/learning_bridge.py +891 -280
  12. package/templates/.claude/skills/goap-research-ed25519/scripts/population_match.py +591 -0
  13. package/templates/.claude/skills/goap-research-ed25519/scripts/risk_statement.py +289 -0
  14. package/templates/.claude/skills/goap-research-ed25519/scripts/test_ed25519_verifier.py +57 -2
  15. package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +970 -306
  16. package/templates/.claude/skills/goap-research-ed25519/scripts/test_goap_planner.py +420 -0
  17. package/templates/.claude/skills/goap-research-ed25519/scripts/test_population_match.py +544 -0
  18. package/templates/.claude/skills/goap-research-ed25519/scripts/test_risk_absolute.py +239 -0
  19. package/templates/.claude/skills/goap-research-ed25519/scripts/test_signature_v3.py +554 -0
  20. package/templates/.claude/skills/goap-research-ed25519/scripts/test_suite_completeness.py +90 -0
  21. package/tests/snapshot/baseline.json +24 -9
@@ -3,56 +3,185 @@
3
3
  Self-learning bridge — health-advisor learns from its own retractions, via dz.
4
4
 
5
5
  WHY A BRIDGE AND NOT AN ENGINE. The learning loop (`dz recall` / `dz teach`) is
6
- SQLite FTS5 + a vector tier + embeddings. Vendoring that into a content skill pack
7
- would not just be heavy it would create a SECOND store, and a loop only compounds
8
- when recall and teach hit ONE. So harness-cli is an OPTIONAL, DETECTED dependency:
6
+ SQLite FTS5 + a vector tier + embeddings, and vendoring that into a content skill pack
7
+ would be heavy for no gain. So harness-cli is an OPTIONAL, DETECTED dependency:
9
8
  absent, this package behaves exactly as before and says so once; present, research
10
9
  sessions start by recalling the traps already caught and end by teaching new ones.
11
10
 
12
- WHAT IS WORTH TEACHING HERE (the loop points, by analogy with the pipeline's own
13
- QE step the moment a conclusion was WRONG is the most valuable signal there is):
11
+ THE TOPOLOGY: TWO STORES, ONE-WAY (ADR-004). Health lessons are WRITTEN only to
12
+ `<project>/.health-brain/.dz`. RECALL READS BOTH that store and the shared one, so the
13
+ loop still compounds — only the writes are split. Nothing copies back.
14
+
15
+ SUPERSEDED, kept because it explains the shape of everything below (ADR-003 is amended
16
+ rather than rewritten, and so is this). The paragraph above used to continue: "…it would
17
+ create a SECOND store, and a loop only compounds when recall and teach hit ONE." That
18
+ was the one-store topology, and ADR-004 replaced it after nine review rounds showed that
19
+ keeping medical lessons in the shared store and filtering them out of each command that
20
+ hands out lesson text is an ENUMERATION, not a guarantee — close four commands and
21
+ review names five more. A second store is now created ON PURPOSE: a store that never
22
+ receives the data cannot leak it from any command, including ones not yet written. The
23
+ compounding objection was answered by making RECALL read both stores rather than by
24
+ merging them. Do NOT "fix" this back to one store; that undoes the isolation the rest of
25
+ this file spends its length enforcing.
26
+
27
+ WHAT IS WORTH TEACHING (the loop points — the moment a conclusion was WRONG is the
28
+ most valuable signal there is):
14
29
  * a retraction — a conclusion that had to be withdrawn;
15
30
  * a population check that flipped a conclusion;
16
31
  * a preanalytical finding that explained an alarming value;
17
32
  * closing an open question.
18
33
 
19
- THE PRIVACY RULE IS AN INVARIANT, NOT A WISH.
20
- A lesson describes a METHOD, never a PATIENT:
21
- good"total testosterone is uninterpretable without SHBG"
22
- bad — "the patient has fasted 3 days weekly for 7 years, testosterone 8.04"
23
- Without this the learned store quietly becomes a medical record: it is a plain file
24
- on disk, it is shared across projects, and nobody consented to that. `check_lesson`
25
- REFUSES the second shape with a reason, so the caller must rewrite it as a method.
26
-
27
- HONEST SCOPE of that guard, stated at its real width: it enforces the SHAPE of a
28
- method lesson no digits (spelled-out values included), no identifiers, lower case
29
- after the first word, and never a person beside a number. It is a shape detector, not
30
- a de-identifier. A sentence that names nobody, quotes no figure and is written in
31
- lower case can still describe one person; the guard cannot see that, and neither can
32
- any regular expression. What keeps the store clean is the shape rule PLUS the four
33
- teach moments, which are moments of method. The guard fails toward refusal.
34
+ WHAT THIS FILE CHECKS, STATED EXACTLY.
35
+
36
+ `check_lesson` refuses IDENTIFIER FORMATS an email, a phone number, medical-record
37
+ vocabulary followed by a value, letters against a long run of digits. Those have a
38
+ shape, and a regex is reliable about shape in any language.
39
+
40
+ It does NOT decide whether a lesson describes a method or a person. It CANNOT: that is
41
+ meaning, not shape. An earlier version of this file tried, and seven rounds of
42
+ independent cross-model review graded it F while the finding count never converged
43
+ the proxy admitted `patient McDonald has HIV` while refusing `apoB`, and refused a
44
+ perfectly good Chinese lesson for containing "a capitalised word". Read that as the
45
+ general result it is: a pattern answering a question about meaning fails in both
46
+ directions at once.
47
+
48
+ So the judgement lives where it can be made with the agent, via the teach protocol
49
+ in SKILL.md — and `teach` requires `--confirm-method`, which this file records and does
50
+ NOT verify. The guarantee that patient data does not travel is neither of those, and it
51
+ is not the export hold-out either: it is ADR-004. Health lessons are WRITTEN to a
52
+ separate store (`<project>/.health-brain/.dz`) and never reach the shared one; recall
53
+ reads both, nothing copies back. A store that never receives the data cannot leak it
54
+ from any command, including ones not yet written. The export hold-out remains as a
55
+ second line for stray or legacy records.
34
56
 
35
57
  Usage (from a skill or by hand):
36
58
  python3 learning_bridge.py status
37
59
  python3 learning_bridge.py recall "transferrin saturation" [--limit 5]
38
- python3 learning_bridge.py teach "<method lesson>" [--reward 0.8]
39
- python3 learning_bridge.py check "<candidate lesson>" # privacy guard only
60
+ python3 learning_bridge.py check "<candidate lesson>" # format check only
61
+ python3 learning_bridge.py teach "<the rule>" --confirm-method
40
62
  """
41
63
 
42
64
  from __future__ import annotations
43
65
 
44
66
  import argparse
67
+ import json
68
+ import os
69
+ import uuid
45
70
  import re
71
+ import unicodedata
46
72
  import shutil
47
73
  import subprocess
48
74
  import sys
75
+ import fnmatch
49
76
  from dataclasses import dataclass
50
- from typing import List, Optional, Sequence, Tuple
77
+ from typing import Callable, List, Optional, Sequence, Tuple
51
78
 
52
- # The domain every lesson from this package is tagged with, and the one recall
53
- # boosts. One shared store, one namespace inside it.
79
+ # The domain every lesson from this package is tagged with, and the one recall boosts.
80
+ # The tag is a LABEL, not the isolation: isolation is the separate store below.
54
81
  LEARNING_DOMAIN = "health-research"
55
82
 
83
+ # ============================================================================
84
+ # A SEPARATE STORE, WITH ONE-WAY TRANSFER (ADR-004).
85
+ #
86
+ # Health lessons are written to their OWN dz store and never to the shared one; recall
87
+ # reads BOTH. So knowledge flows engineering → medical and never the other way.
88
+ #
89
+ # WHY THIS REPLACED PER-COMMAND FILTERING. The previous design kept everything in one
90
+ # store and held the health domain out of each command that hands out lesson text. Nine
91
+ # rounds of review made the shape of that mistake unmistakable: rounds 1-7 caught me
92
+ # enumerating words and separators, rounds 8-9 caught me enumerating COMMANDS — I closed
93
+ # `--all`, `--all --stats`, `--usage` and `vector export`, and round 9 immediately
94
+ # produced `guard promote --json`, `epoch-replay --emit`, `vector harmonize`,
95
+ # `consolidate --prune-quarantine` and the `recall --forget` preview. Same defect, one
96
+ # storey up: an enumeration presented as a guarantee.
97
+ #
98
+ # A store that never receives the data cannot leak it from any command, present or
99
+ # future. That is a property of the architecture rather than of anyone's memory.
100
+ #
101
+ # THE COST, STATED. Cross-domain transfer now runs one way only. A medical insight can
102
+ # no longer surface while doing engineering work. That direction was the less valuable
103
+ # one — the traffic worth having is "a reviewer's evidence needs the same
104
+ # execute-don't-describe discipline as your own claims" arriving in a medical
105
+ # investigation — and one-way is the price of an isolation that does not depend on every
106
+ # future command remembering a rule.
107
+ HEALTH_BRAIN_DIRNAME = ".health-brain"
108
+
109
+
110
+ def health_brain(project: Optional[str] = None) -> str:
111
+ """The project directory whose `.dz` holds health lessons. A sibling of the shared
112
+ store, so it is obvious where the data is and trivial to inspect or delete.
113
+
114
+ REALPATH, not abspath. `abspath` does not resolve symlinks, so `ln -s . .health-brain`
115
+ made the "separate" store resolve to the shared one and every health lesson landed
116
+ exactly where the design says it never goes. A separation that a symlink undoes is
117
+ not a separation."""
118
+ root = os.path.realpath(project or os.getcwd())
119
+ return os.path.join(root, HEALTH_BRAIN_DIRNAME)
120
+
121
+
122
+ def _health_brain_is_distinct(project: Optional[str] = None) -> Tuple[bool, str]:
123
+ """Refuse to write when the health brain resolves to the shared project.
124
+
125
+ Checked at WRITE time, not only at path construction: the symlink can appear after
126
+ the process starts, and this is the one invariant the whole design rests on."""
127
+ root = os.path.realpath(project or os.getcwd())
128
+ brain = health_brain(project)
129
+ # Resolve BOTH the brain directory and the store inside it. Checking only the parent
130
+ # missed `ln -s ../.dz .health-brain/.dz`: `.health-brain` resolved distinctly while
131
+ # the directory dz actually writes to was the shared store. The path that matters is
132
+ # the one that receives the data, not the one on the way to it.
133
+ brain_literal = brain
134
+ resolved_brain = os.path.realpath(brain)
135
+ resolved_store = os.path.realpath(os.path.join(brain, ".dz"))
136
+ shared_store = os.path.realpath(os.path.join(root, ".dz"))
137
+ for label, resolved, forbidden in (
138
+ ("the shared project", resolved_brain, root),
139
+ ("the shared store", resolved_store, shared_store),
140
+ ):
141
+ if resolved.rstrip(os.sep) == forbidden.rstrip(os.sep):
142
+ return False, (
143
+ f"REFUSED — {HEALTH_BRAIN_DIRNAME} resolves to {label} ({forbidden}).\n"
144
+ " Health lessons must not land in the shared store, and a symlink here would "
145
+ "put them there. Remove or rename it, then teach again."
146
+ )
147
+ # CONTAINMENT, not merely difference. Comparing against OUR project let
148
+ # `ln -s ../../B/.dz A/.health-brain/.dz` through — B's shared store is not A's, so
149
+ # the check passed while the lesson landed in another project's shared store, and the
150
+ # count then grew there and confirmed "success". Asking "is the store inside the
151
+ # brain?" is one question with one answer, instead of a list of places it must not be.
152
+ # The brain itself must stay INSIDE our project. `A/.health-brain -> ../B` satisfied
153
+ # containment trivially (B/.dz is inside B) while every write landed in project B's
154
+ # ordinary shared store — the check verified the wrong pair. Two questions, not one:
155
+ # is the brain inside our project, and is the store inside the brain.
156
+ if os.path.commonpath([resolved_brain, root]) != root or resolved_brain == root:
157
+ return False, (
158
+ f"REFUSED — {HEALTH_BRAIN_DIRNAME} resolves OUTSIDE this project "
159
+ f"({resolved_brain}).\n"
160
+ " A symlink here sends health lessons into another project's store, where "
161
+ "they look like an ordinary success. Remove or rename it, then teach again."
162
+ )
163
+ # …and the brain must not BE another project. `/A/.health-brain -> /A/subproject`
164
+ # satisfied both containment checks (it sits inside A, and its .dz sits inside it)
165
+ # while every lesson landed in the subproject's ORDINARY shared store — and the
166
+ # canary, found through the same alias, certified it. Containment says where a path
167
+ # sits; it cannot say what the directory IS. A directory that already holds a dz
168
+ # store is somebody else's project, whatever its path.
169
+ if resolved_brain != brain_literal and os.path.isdir(os.path.join(resolved_store, "memory")):
170
+ return False, (
171
+ f"REFUSED — {HEALTH_BRAIN_DIRNAME} resolves to {resolved_brain}, which already "
172
+ "holds a dz store of its own.\n"
173
+ " That is another project's shared store, not this brain: health lessons "
174
+ "would land there and look like an ordinary success. Remove or rename the link."
175
+ )
176
+ if os.path.commonpath([resolved_store, resolved_brain]) != resolved_brain:
177
+ return False, (
178
+ f"REFUSED — {HEALTH_BRAIN_DIRNAME}/.dz resolves OUTSIDE the brain "
179
+ f"({resolved_store}).\n"
180
+ " A symlink here sends health lessons into someone else's store, where they "
181
+ "look like an ordinary success. Remove or rename it, then teach again."
182
+ )
183
+ return True, brain
184
+
56
185
  # THE WIRE CONTRACT between this bridge and `dz recall --domain`.
57
186
  #
58
187
  # The CURRENT dz ends its domain run with one of two note lines; their ABSENCE is how
@@ -94,280 +223,360 @@ DZ_MISSING_NOTE = (
94
223
  "the traps already caught and record new ones: npm i -g @dzhechkov/harness-cli"
95
224
  )
96
225
 
97
- # ---------------------------------------------------------------- privacy guard
226
+ # --------------------------------------------------------- shape check (narrow)
98
227
 
99
228
  # =============================================================================
100
- # THE GUARD IS AN ALLOWLIST, NOT A BLOCKLIST and that is a deliberate rewrite.
229
+ # WHAT THIS CHECK IS, AND WHAT IT DELIBERATELY IS NOT.
230
+ #
231
+ # It is NOT a privacy guard. An earlier version of this file tried to be one: it
232
+ # decided, from the text alone, whether a lesson described a METHOD or a PERSON.
233
+ # Seven rounds of independent cross-model review graded that design F, and the
234
+ # finding count never converged (11, 10, 5, 3, 6, 6, 8). Four of the rounds found the
235
+ # same CLASS of defect, because the design was wrong rather than incomplete:
101
236
  #
102
- # The first design listed personal-data SHAPES and refused those. A cross-model
103
- # review broke it in minutes, and the breakage was not a missing pattern but the
104
- # wrong SHAPE OF DESIGN:
105
- # * one stray word laundered anything — "TSH was 8.04 mIU/L WHEN fasting" passed,
106
- # because a threshold-marker exemption trusted the presence of a word to prove
107
- # the number was a guideline rule;
108
- # * every unit, date format, script and identifier outside the list walked
109
- # through: `MRN 84729163`, `Patient John Smith has HIV`, `130 µmol/L`,
110
- # `8 мая 2026`, an email, a phone number.
111
- # Enumerating what personal data LOOKS LIKE is an arms race whose every gap is a
112
- # leaked medical fact. So the rule is inverted:
237
+ # "is this a method or a record about a person?" is a question about MEANING,
238
+ # and every pattern that answers it is a proxy so it fails in BOTH directions.
113
239
  #
114
- # A METHOD LESSON CARRIES NO DIGITS, NO IDENTIFIERS,
115
- # AND IS WRITTEN IN LOWER CASE (acronyms excepted).
240
+ # The proof, from one round: the predicate "two or more capitals is an acronym"
241
+ # admitted `patient McDonald has HIV` and refused `apoB` — the very example this
242
+ # skill's own documentation offered as correct. Another round refused a legitimate
243
+ # Chinese method lesson with the message "a capitalised word", which is meaningless
244
+ # for Han script. And no pattern in any language sees the case that matters most:
245
+ # `the patient with situs inversus who ran a marathon` carries no name and no digit
246
+ # and identifies exactly one human being on earth.
116
247
  #
117
- # All three clauses are properties of the ACCEPTED form. That matters: "no names" was
118
- # still a blocklist in disguise, because a name is any word and no list of them exists
119
- # — which is precisely how `John Smith has HIV` walked through round 2. "Lower case"
120
- # is checkable in one line and has a one-keystroke fix.
248
+ # So the work is split by NATURE, and each part sits where it can actually be done:
121
249
  #
122
- # It is almost free: "a fast lowers total testosterone by roughly a third" is a fine
123
- # lesson without the figure. When a number genuinely IS the knowledge (a guideline
124
- # threshold), the author passes --allow-numbers and takes responsibility explicitly
125
- # a decision by a human, not an inference from a word.
250
+ # * FORMAT (here) email, phone, record numbers, long digit runs. Closed classes
251
+ # with a shape. A regex is genuinely reliable on these, in every language.
252
+ # * MEANING (SKILL.md) "is this a method?", quasi-identifiers. Judged by the
253
+ # agent already in the loop, which is a language model; asking a subprocess to
254
+ # answer what its own caller answers better was the original absurdity.
255
+ # * THE SEPARATION (ADR-004) — health lessons are written to their OWN store and
256
+ # never to the shared one. A store that never receives the data cannot hand it out
257
+ # from any command, including ones not yet written. The domain hold-out on the
258
+ # portable export REMAINS, but as a second line for stray or legacy records — not
259
+ # as the promise. Filtering each command that emits lesson text was itself an
260
+ # enumeration, and review produced five more surfaces the moment four were closed.
261
+ # THE GUARANTEE IS ADR-004: health lessons are written to a SEPARATE store and never
262
+ # reach the shared one. The hold-out is the second line, not the promise.
263
+ #
264
+ # Nothing here claims to detect personal data. It cannot, and saying otherwise was
265
+ # the actual defect.
126
266
  # =============================================================================
127
267
 
128
- # Any digit. Yes, any: a threshold, a dose, a year and a lab value are the same
129
- # character class, and no amount of context-sniffing reliably tells them apart.
130
- _DIGIT_RE = r"\d"
131
-
132
- # A value SPELLED OUT is still a value. Round 2 walked `John's TSH was eight point
133
- # zero four` through the digit rule untouched. Requiring two number-words in a row
134
- # (or a number-word beside "point") keeps the false-positive cost near zero: method
135
- # prose says "a third", "one marker", never "eight point zero four".
136
- _NUMBER_WORDS = (
137
- "zero|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|"
138
- "fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|"
139
- "sixty|seventy|eighty|ninety|hundred|thousand|"
140
- "ноль|один|одна|два|две|три|четыре|пять|шесть|семь|восемь|девять|десять|"
141
- "двадцать|тридцать|сорок|пятьдесят|шестьдесят|семьдесят|восемьдесят|девяносто|сто|тысяч\\w*"
142
- )
143
- _SPELLED_NUMBER_RE = (
144
- rf"\b(?:{_NUMBER_WORDS})\b[\s,-]+\b(?:point|целых|запятая|{_NUMBER_WORDS})\b"
145
- rf"|\bpoint\b[\s,-]+\b(?:{_NUMBER_WORDS})\b"
146
- )
268
+ def _luhn_ok(digits: str) -> bool:
269
+ """The Luhn (mod-10) check digit, ISO/IEC 7812. Card numbers carry it; an allocation
270
+ or a dilution series written in the same four-by-four shape almost never does."""
271
+ total = 0
272
+ for index, char in enumerate(reversed(digits)):
273
+ value = int(char)
274
+ if index % 2 == 1:
275
+ value *= 2
276
+ if value > 9:
277
+ value -= 9
278
+ total += value
279
+ return total % 10 == 0
147
280
 
148
- # Direct identifiers — refused regardless of --allow-numbers, because no method
149
- # lesson needs them and every one of them is a person.
150
- # NOTE the per-pattern case flag. The identifier scan originally ran WITHOUT
151
- # IGNORECASE, so `MRN 84729163` walked straight through while the lowercase spelling
152
- # was caught — but the NAME pattern must stay case-SENSITIVE, since capitalisation is
153
- # the whole signal. One shared flag could not serve both; a test caught the gap.
154
- _IDENTIFIER_PATTERNS: Sequence[Tuple[str, str, bool]] = (
155
- # (pattern, why, ignore_case)
156
- (r"[\w.+-]+@[\w-]+\.[\w.]+", "an email address", True),
157
- (r"\+?\d[\d\s().-]{7,}\d", "something shaped like a phone number", True),
158
- (r"\b(mrn|снилс|полис|истори\w* болезни|medical record (no|number)|record no)\b[:\s#]*\w*",
159
- "a medical-record identifier", True),
160
- # Round 4: the TOKEN check refused `ab1234567`, so the same identifier written
161
- # `ab—1234567` or `ab/1234567` was split by the tokenizer into a lower-case word
162
- # and a permitted number — laundered by punctuation. This reads the RAW text, where
163
- # the joiner is still visible. A SPACE is deliberately not a joiner here, or every
164
- # "the 2019 guideline" would be refused.
165
- (r"\b[A-Za-zА-Яа-я]{2,}[—–/._-]?\d{4,}\b",
166
- "letters sitting against a long run of digits — that is an identifier, however it is punctuated", False),
167
- )
168
281
 
169
- # THE CAPITALISATION RULE — round 2 forced this rewrite.
170
- #
171
- # The previous pattern hunted a NAME: two capitalised words in a row, exempting the
172
- # start of a sentence. Both halves were wrong, and both were measured:
173
- # * `John Smith has HIV` passed the exemption fired on the sentence start;
174
- # * `Patient John has HIV` passed — only one capitalised word followed "Patient".
175
- # Hunting names is a blocklist wearing an allowlist's clothes: a name is any word,
176
- # and there is no list of them. So stop hunting names and state a property of the
177
- # ACCEPTED form instead:
178
- #
179
- # A METHOD LESSON IS WRITTEN IN LOWER CASE (acronyms excepted).
180
- #
181
- # Any capitalised word after the first token is refused, whatever it is. This does
182
- # refuse `compare Testosterone Replacement Therapy against placebo` — deliberately.
183
- # The fix is one keystroke ("lower-case it"), the rule is one sentence, and the
184
- # false-negative it closes is a named person beside a diagnosis.
185
- #
186
- # ROUND 3 forced this one step further, and the finding was the DESIGN again, not the
187
- # pattern. `\b[A-ZА-Я][a-zа-я]{2,}\b` is still a blocklist: it enumerates which
188
- # capitalised words look like names, so `Li` (too short), `Élodie` (Latin-1 letter)
189
- # and `method—John` (em dash, no \s before it) all walked through. Every round of
190
- # widening the pattern buys one more round.
191
- #
192
- # So the check moves to the TOKEN and states what is ACCEPTED, in a form with no
193
- # residue: split on whitespace, strip surrounding punctuation, and accept a token only
194
- # if it is one of
195
- # * all lower case (letters, any script — `.islower()` answers for every alphabet);
196
- # * an ACRONYM — two or more upper-case letters in it (SHBG, HIV, apoB, HbA1c);
197
- # * pure punctuation, or the FIRST token of the lesson (an ordinary sentence start);
198
- # * a NUMBER, judged by the number rules below.
199
- # `John`, `Li`, `Élodie`, `Testosterone` each carry exactly one capital and are refused
200
- # without the guard ever asking what they mean — which is the point, since names cannot
201
- # be enumerated and lower-casing costs one keystroke.
202
- _TOKEN_EDGE_PUNCT = " \t\n\r.,;:!?()[]{}\"'«»„“”‘’—–-/\\|*_`"
203
-
204
-
205
- def _is_acronym(token: str) -> bool:
206
- """An acronym, as opposed to a name.
207
-
208
- Round 4 killed the previous rule ("two or more capitals"), which was wrong in BOTH
209
- directions at once: `Anne-Marie` and `McDonald` carry two capitals and sailed
210
- through as acronyms, while `apoB` carries one and was refused — contradicting the
211
- documentation in the same repository that offered it as the example.
212
-
213
- What actually separates the two is WHERE the first capital sits. A name is Title
214
- Case: capital first, lower case after. An acronym is either fully upper (SHBG, HIV,
215
- TSH) or starts lower and capitalises later (apoB, mmHg). So: accept a token whose
216
- first letter is lower case, or one whose letters are ALL upper case. Refuse
217
- anything that opens with a capital and then drops to lower case, whatever it is.
218
- """
219
- letters = [ch for ch in token if ch.isalpha()]
220
- if not letters:
221
- return False
222
- if not letters[0].isupper():
223
- return True # apoB, mmHg — lower-case opening is never a name
224
- return all(ch.isupper() for ch in letters) # SHBG, HIV, D
282
+ def _is_card_number(match: "re.Match") -> bool:
283
+ """SHAPE alone cannot separate `4111 1111 1111 1111` from `1000-1000-1000-1000` —
284
+ both are four groups of four, and round 17 was right that the second is an ordinary
285
+ allocation across trial arms. The discriminator is not a longer list of shapes: it is
286
+ the OUTCOME of the check digit the card format specifies. MEASURED: 4111111111111111
287
+ satisfies Luhn, 1000100010001000 does not.
225
288
 
289
+ Stated limit: a card number embedded in a LONGER run of groups (five or more) is not
290
+ detected, because the checksum is computed over the whole matched run rather than over
291
+ every window inside it. Sliding a window would trade one honest miss for a family of
292
+ false alarms on long numeric tables."""
293
+ digits = re.sub(r"\D", "", match.group(0))
294
+ # ISO/IEC 7812 puts a primary account number at 12-19 digits. Outside that range a
295
+ # Luhn hit is coincidence, not a card.
296
+ return 12 <= len(digits) <= 19 and _luhn_ok(digits)
226
297
 
227
- # Splitting on whitespace ALONE was the last hole round 3 found: `method—John has HIV`
228
- # passed because the em dash kept `method—John` as ONE token, and that token was first,
229
- # so it took the sentence-start exemption. Any punctuation that can JOIN two words is a
230
- # token boundary. A period is a boundary only between non-digits, so `8.04` survives.
231
- _TOKEN_SPLIT_RE = r"(?:[\s—–/\\|()\[\]{}<>\"«»„“”‘’\',;:!?*_`]|(?<!\d)[.](?!\d))+"
232
298
 
299
+ # The value that follows medical-record vocabulary: how it was INTRODUCED decides how
300
+ # much shape it needs. See _is_record_identifier.
301
+ _RECORD_VOCABULARY = (
302
+ r"(?:mrn|снилс|полис|истори\w*[\s-]+болезни"
303
+ r"|medical[\s-]+record[\s-]+(?:no|number)|record[\s-]+no)"
304
+ )
233
305
 
234
- def _tokens(raw: str) -> List[str]:
235
- return [t.strip(_TOKEN_EDGE_PUNCT) for t in re.split(_TOKEN_SPLIT_RE, raw) if t.strip(_TOKEN_EDGE_PUNCT)]
236
306
 
307
+ def _is_record_identifier(match: "re.Match") -> bool:
308
+ """Is the text after the vocabulary a VALUE, or is it prose that happens to contain a
309
+ number?
237
310
 
238
- # A token that mixes letters and digits is an IDENTIFIER SHAPE `ab1234567`,
239
- # `NCT04368728`, an accession, a record number. It is refused even under
240
- # --allow-numbers, because that flag exists for a THRESHOLD (`8.04`, `56h`), and a
241
- # threshold is a number with at most a short unit after it. Letters BEFORE digits is
242
- # not a measurement in any unit system; it is a label for one thing.
243
- _NUMBER_TOKEN_RE = r"^[<>≥≤~±]?\d+([.,]\d+)?[a-zа-яё%°]{0,4}$"
311
+ The honest distinction is HOW THE VALUE WAS INTRODUCED, not how long it is:
244
312
 
245
- # …but a few real lab acronyms carry a digit (`HbA1c`, `CYP2D6`). What separates them
246
- # from an accession is SIZE: an identifier has to be long enough to be unique. So a
247
- # mixed token is an identifier when it is long or digit-heavy, and a lab acronym
248
- # otherwise. The bound is a judgement call and is stated rather than hidden.
249
- _IDENTIFIER_MIN_LEN = 7
250
- _IDENTIFIER_MIN_DIGITS = 4
313
+ * A SEPARATOR introduces a value directly `MRN: 7`, `MRN 84729163`,
314
+ `MRN = 84729163`. After a separator the author has already said "a value follows",
315
+ so anything carrying a digit counts, however short. Round 16's fix required the
316
+ value to LOOK like an identifier (3+ digits, or letters and digits mixed) and so
317
+ let `MRN: 7` through; length was never the point.
318
+ * WITHOUT a separator only the token IMMEDIATELY after the label (whitespace and an
319
+ `is`/`are`/`no.` copula aside) can be the value, and it must still look like an
320
+ identifier. That is what keeps `compare medical record number in 3 hospitals` and
321
+ `medical record number use in 300 hospitals` out: the digit is reached across
322
+ PROSE WORDS, and prose is not a value however near it sits.
251
323
 
252
- # A reference to ONE person. Alone it is fine — a method sentence may discuss patients
253
- # in the abstract. Beside a value it is load-bearing: a person plus a reading is a
254
- # record, and no --allow-numbers waves that through.
255
- #
256
- # SINGULAR ONLY, and that is the whole design. Round 3 was right that `the woman has
257
- # tsh 8.04` must be refused, but the first fix simply added `woman|man|adult|child` and
258
- # immediately refused `transferrin saturation above 45% warrants attention in men` — a
259
- # textbook population lesson. Plural person nouns ARE the vocabulary of method lessons
260
- # about populations; singular ones are the vocabulary of case notes. Number, not word
261
- # list, is what separates a cohort from a patient, and grammar is checkable.
324
+ The separator class is "not a letter, digit, underscore or space" — a complement, so
325
+ there is no list to fall behind, which is the mistake rounds 13-16 kept finding.
326
+
327
+ Stated limit: a sentence-ending period is a separator too, so `audit the MRN. 3 wards`
328
+ is refused. Over-refusing an ambiguous sentence is the direction this rule should err
329
+ in, and it is named here rather than papered over."""
330
+ gap = match.group("gap")
331
+ value = match.group("value")
332
+ # The NUMERO marker introduces a value as directly as `#` does it is the same
333
+ # word. It has to be named because NFKC folds `№` to the two letters `No`, so by the
334
+ # time this rule sees `MRN № 7` the separator has become part of the prose: the
335
+ # normalisation that stops `MRN‑ 7` from evading the check also hides this one.
336
+ introduced = (any(not (ch.isalnum() or ch == "_" or ch.isspace()) for ch in gap)
337
+ or re.search(r"\bno\.?\b", gap, flags=re.IGNORECASE) is not None)
338
+ if not introduced:
339
+ tokens = value.split()
340
+ value = tokens[0] if tokens else ""
341
+ if not _has_digit(value):
342
+ return False
343
+ if introduced:
344
+ return True
345
+ # …reached across whitespace only: it must look like an identifier rather than a count.
346
+ return bool(re.search(r"\d{3,}|[^\W\d_][^\w\s]?\d|\d[^\w\s]?[^\W\d_]", value))
347
+
348
+
349
+ # Direct identifiers: a FORMAT, not a meaning. These stay blocking — no method lesson
350
+ # needs one, and no confirmation flag should be able to wave one through.
351
+ # (pattern, why, ignore_case, validator) — the flag is PER PATTERN and that is
352
+ # load-bearing. A blanket re.IGNORECASE made the accession rule's `[A-ZА-Я]{2,}` match
353
+ # lower case too, so `a cohort of 123456789 people` was refused as an accession. An
354
+ # earlier round taught exactly this lesson about a different pattern; the rewrite dropped
355
+ # it and it came back.
262
356
  #
263
- # Honest scope: `the woman` is caught, `a 54 year old` is not, and this clause only ever
264
- # runs when --allow-numbers was passed (digits are refused outright otherwise). It is
265
- # the narrowest of the three clauses and the only one that is still an enumeration.
266
- _PERSON_RE = (
267
- r"\b(patient|subject|participant|client|donor|volunteer|"
268
- r"woman|man|individual|child|infant|adult|"
269
- r"he|she|him|his|her|hers|"
270
- r"пациент(?!ы|ов|ам|ами|ах)\w*|больн(ой|ого|ому|ым|ом)|испытуем(ый|ого|ому|ым|ом)|"
271
- r"мужчина|мужчины\b|женщина|женщины\b|ребёнок|ребенок|доброволец|"
272
- r"у него|у неё)\b"
357
+ # The VALIDATOR is the round-17 addition and it is the point: where a shape is genuinely
358
+ # ambiguous, a regex that decides alone must either over-refuse method prose or under-
359
+ # refuse identifiers. A validator lets the rule decide by OUTCOME (does the check digit
360
+ # hold?) or by STRUCTURE (was the value introduced by a separator, or reached across
361
+ # prose?) instead of by a longer list of shapes. It is `None` where the pattern alone is
362
+ # the whole rule.
363
+ _IDENTIFIER_PATTERNS: Sequence[Tuple[str, str, bool, Optional[Callable[["re.Match"], bool]]]] = (
364
+ # An email, including the quoted local part RFC 5321 allows.
365
+ # The quoted local part may itself contain `@` — `"john@doe"@example.com` is a legal
366
+ # address and the first version's own comment claimed to cover quoted forms.
367
+ # The domain may be an IP LITERAL — `"john"@[192.0.2.1]` is a legal address and the
368
+ # dotted-name branch never saw it.
369
+ # The domain may be a bare host (`user@localhost`) or an IP literal, and the quoted
370
+ # local part may contain an escaped quote. Requiring a dot in the domain missed the
371
+ # first; the earlier quoted-form comment promised more than the pattern delivered.
372
+ # The local part may use ANY RFC dot-atom character, not the hand-picked four:
373
+ # `customer!@localhost` is a legal address and walked past the class below.
374
+ # The DOMAIN must be a real domain: a dotted name, an IP literal, or exactly
375
+ # `localhost`. The bare-host branch was added for `user@localhost` and it swallowed
376
+ # every `<word>@<word>` in ordinary prose — `apoB@baseline` and `apoB@week12` are how
377
+ # a study writes its endpoints, and they were read as email addresses. `localhost` is
378
+ # not a list of hosts: it is the one name RFC 6761 reserves, which is why the branch
379
+ # existed at all. `customer!@localhost` stays refused.
380
+ (r'''(?:"(?:[^"\\]|\\.)+"|[\w.!#$%&'*+/=?^`{|}~-]+)@'''
381
+ r'''(?:\[[^\]]+\]|[\w-]+(?:\.[\w-]+)+|localhost(?![\w-]))''',
382
+ "an email address", True, None),
383
+ # A UUID and the 3-2-4 national-identifier grouping. A pattern for a CLOSED, SPECIFIED
384
+ # format is NOT the enumeration this file keeps being caught at: the names and words
385
+ # rounds 1-16 enumerated form an open set that grows with every language and every
386
+ # reviewer, while RFC 4122's 8-4-4-4-12 hex and the 3-2-4 shape are fixed by a
387
+ # standard and do not grow at all. Writing down a closed format is describing it;
388
+ # writing down "the identifiers I thought of" is guessing at the next member.
389
+ (r"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b",
390
+ "a UUID — a specified identifier format", True, None),
391
+ (r"(?<![\d-])\d{3}-\d{2}-\d{4}(?![\d-])",
392
+ "the 3-2-4 grouping of a national identifier", False, None),
393
+ # A phone number needs a country prefix OR THREE OR MORE digit groups. Two groups
394
+ # was the wrong bound in both directions: it refused `sample 500-1000 records` and
395
+ # `compare 100/1000 bootstrap samples` — ordinary ranges — while `555-1234` is the
396
+ # SAME SHAPE as a range and cannot be told apart from one. So a bare two-group form
397
+ # is deliberately allowed through and that limit is stated rather than papered over;
398
+ # grouped card numbers (4111 1111 1111 1111) are four groups and still caught.
399
+ # Every group at least three digits. `1000-100-10` (a dilution series) has a
400
+ # two-digit group and is not a phone; `555-123-4567` and `4111 1111 1111 1111` are.
401
+ # The country-prefix branch needs the same "groups of 3+" rule as the other one, or
402
+ # `+1 / 2 / 3 / 4` (concentration steps) reads as a phone number.
403
+ # After a `+`, what separates a phone from `+1 / 2 / 3 / 4` (concentration steps) is
404
+ # the TOTAL number of digits, not the size of each group: `+44/20/7946/0958` has
405
+ # two-digit groups and is plainly a phone. So the prefix branch counts (7 or more,
406
+ # the shortest real international number) and the unprefixed branch keeps the
407
+ # groups-of-three rule that lets `1000-100-10` through.
408
+ # The `+` may be followed by a SEPARATOR before the first digit: `+ (44) 20 7946 0958`
409
+ # is how the number is written on a business card, and requiring a digit immediately
410
+ # after the plus let it through.
411
+ (r"\+[\s()./-]*(?=(?:[\d\s()./-]*\d){7,})\d[\d\s()./-]{4,}\d",
412
+ "something shaped like a phone number", True, None),
413
+ # Four or more groups is a phone whatever their size — `01 42 68 53 00` is the
414
+ # French format and has five two-digit groups. Three groups still need 3+ digits
415
+ # each, which keeps `1000-100-10` (a dilution series) out.
416
+ # NO branch for runs of two-digit groups. `01 42 68 53 00` (a French phone) and
417
+ # `10 20 30 40 50 weeks` (a time course) are the SAME shape, and two attempts to
418
+ # separate them both failed: a unit LIST missed `weeks`, and inverting it to "any
419
+ # following word" matched `before`. So neither is classified, exactly as with
420
+ # `555-1234` against `500-1000` and `NCT 04368728` against `PCR 100000`.
421
+ # THREE groups of three is undecidable too: `1000-1000-1000` is a dilution series
422
+ # and `555-123-4567` a phone, and nothing in the shape tells them apart — the same
423
+ # verdict already reached for `555-1234` vs `500-1000`. Four groups is card-shaped…
424
+ # …but four groups of four is ALSO how `1000-1000-1000-1000` participants are split
425
+ # across trial arms, so the shape is where the rule STARTS, not where it decides.
426
+ # _is_card_number finishes it with the format's own check digit.
427
+ (r"\b\d{3,}(?:[\s.()/-]+\d{3,}){3,}",
428
+ "a card number — its Luhn check digit holds", True, _is_card_number),
429
+ # A LABEL decides what an otherwise-undecidable digit run is. `phone: 5551234567`
430
+ # says what it is in words, so no shape heuristic is needed — the same move the
431
+ # medical-record rule makes. This is how the shapes that are genuinely ambiguous on
432
+ # their own still get caught: the author's own label does the deciding.
433
+ (r"\b(phone|tel|telephone|mobile|cell|fax|факс|телефон|моб\w*)\b[^\w]{0,3}"
434
+ r"(?=[\d\s()./+-]{6,}\d)[\d\s()./+-]{6,}\d",
435
+ "a labelled phone number", True, None),
436
+ # Medical-record vocabulary, but only when a VALUE follows: the phrase alone
437
+ # ("audit medical record number quality") is ordinary method prose.
438
+ (r"\b" + _RECORD_VOCABULARY + r"\b"
439
+ # The value may be introduced by any punctuation or copula, not just `:` and `#`:
440
+ # `MRN № 84729163`, `MRN = 84729163` and `medical record number is 84729163` all
441
+ # walked past a hand-picked separator class.
442
+ # A COMMA introduces the value as readily as a colon does: `MRN, 84729163`.
443
+ # Any punctuation may introduce the value — period, semicolon, slash and comma all
444
+ # appeared in review. The class is now "not a letter or digit", which has no list to
445
+ # fall behind.
446
+ # Any run of NON-WORD characters may sit between the vocabulary and the value —
447
+ # spaces and punctuation in any order (` / `, `. `, `; `, `, `, ` № `). Ordering the
448
+ # two classes was itself a small enumeration, and ` / ` walked between them.
449
+ # The value may itself contain a space: `MRN: AB 1234`. Requiring the whole value to
450
+ # fit ONE [\w-]+ token let the labelled identifier through because `AB` carries no
451
+ # digit. Accept up to three tokens after the label and require a digit among them.
452
+ # The value may carry punctuation inside it: `MRN: AB/1234`. Restricting it to
453
+ # word/space/hyphen characters let a slash split the label from its own value.
454
+ # …and the value is COMPACT: the digit must appear within ~10 characters of the
455
+ # label. `AB 1234` and `AB/1234` qualify; `completeness across 3 hospitals` is prose
456
+ # whose digit sits far away, and the distance is what separates them. Requiring the
457
+ # digit in the FIRST token was too strict (it broke `MRN: AB 1234`), requiring it
458
+ # anywhere was too loose — the reach is the honest middle.
459
+ # …and the value must LOOK like an identifier: three or more digits, or letters and
460
+ # digits mixed. `compare medical record number in 3 hospitals` put a lone `3` inside
461
+ # the reach window, so nearness alone was not enough — a record number is not a
462
+ # one-digit count.
463
+ # ROUND 17 replaced the "~10 characters of reach" window with a captured GAP and a
464
+ # captured VALUE, judged by _is_record_identifier. The window measured DISTANCE IN
465
+ # CHARACTERS, which cannot see the difference between `MRN: 7` (a value, one
466
+ # character away, admitted) and `medical record number use in 300 hospitals` (prose,
467
+ # ten characters away, refused). Both errors were the same mistake: counting
468
+ # characters instead of asking what introduced the value.
469
+ r"(?P<gap>[^\w]*(?:(?:is|are|no\.?)\b[^\w]*)*)"
470
+ r"(?P<value>[\w-]+(?:[^\w\s]+[\w-]+)*(?:[ \t][\w-]+(?:[^\w\s]+[\w-]+)*){0,2})",
471
+ "a medical-record identifier", True, _is_record_identifier),
472
+ # Letters against a long digit run, in either order and however punctuated.
473
+ # Letters against a long digit run, in either order — but NOT when the digits are part
474
+ # of a HYPHENATED COMPOUND MODIFIER. `100000-fold` and `ISO15189-compliant` are the
475
+ # same construction: the digits belong to a word that describes something, not to a
476
+ # record about somebody. Round 16 spelled this exception as the unit list
477
+ # `-?fold|x\b|×`, which is an enumeration wearing an allowlist's clothes — round 17
478
+ # produced `ISO15189-compliant` immediately, exactly as rounds 13-16 produced the next
479
+ # `.gitignore` variant and the next unit word. The STRUCTURE that made `fold` special
480
+ # is the hyphen joining digits to a following word, so that is what the rule now says,
481
+ # in both directions (the second branch simply stops treating `-` as an in-token
482
+ # joiner). `×` keeps its own exclusion because it is multiplication notation, not a
483
+ # word.
484
+ # Stated limit: an identifier that is itself hyphenated into a word — `AB1234567-linked`
485
+ # — is not caught. That miss is the price of not maintaining a list of unit words, and
486
+ # a list is what review has refuted four times.
487
+ # A SINGLE leading letter counts: `A12345678` is a code, and requiring two letters
488
+ # was a bound with nothing behind it. `x`/`X` is excluded as the multiplication prefix
489
+ # (`x100000`), for the same reason `×` is.
490
+ (r"(?:[^\W\d_]{2,}|(?![xX])[^\W\d_])[^\w\s<>=≥≤~±×]*_*\d{5,}(?!-[^\W\d_])"
491
+ r"|(?<![×x])\d{5,}_*[^\w\s<>=≥≤~±×-]*[^\W\d_]{2,}",
492
+ "letters against a long run of digits — an identifier, however it is punctuated",
493
+ True, None),
494
+ # NO space-separated accession rule. It was added to catch `NCT 04368728` and it
495
+ # refused `PCR 100000 reads per sample should trigger contamination review` — an
496
+ # ordinary method lesson. The two are the SAME SHAPE: an upper-case token, a space, a
497
+ # long number. Like `555-1234` against `500-1000`, shape cannot separate them, so
498
+ # neither is classified and the limit is stated instead of guessed at. `MRN 84729163`
499
+ # is still caught, by the medical-record vocabulary rule above, which keys on meaning
500
+ # the standard gives those words rather than on the shape around them.
273
501
  )
274
502
 
503
+
504
+ def _has_digit(text: str) -> bool:
505
+ """One predicate for "digit", used everywhere. `isdigit` alone misses `²` and `Ⅻ`;
506
+ a round found the two halves of this file disagreeing about the word."""
507
+ return any(ch.isdigit() or ch.isnumeric() for ch in text)
508
+
509
+
275
510
  @dataclass(frozen=True)
276
511
  class LessonVerdict:
277
512
  ok: bool
278
- reasons: List[str]
513
+ blockers: List[str]
514
+ notices: List[str]
279
515
 
280
516
  @property
281
517
  def note(self) -> str:
518
+ lines: List[str] = []
519
+ if self.blockers:
520
+ lines.append("REFUSED — this carries an identifier, which no lesson needs:")
521
+ lines += [f" - {b}" for b in self.blockers]
522
+ for n in self.notices:
523
+ lines.append(f" NOTICED: {n}")
282
524
  if self.ok:
283
- # NOT "safe": a shape detector cannot certify safety, and saying so would
284
- # transfer responsibility it does not have (Codex QE #2). It reports the
285
- # absence of a detected shape the author still owns the content.
286
- return (
287
- "no personal-data SHAPE detected (no digits, no identifiers, lower case). This is "
288
- "not a safety certificate: the guard checks shapes, not meaning — a lower-case "
289
- "sentence with no figures can still describe one person, and you remain "
290
- "responsible for what it says."
525
+ lines.append(
526
+ " No identifier FORMAT found. That is all this check can tell you: it does not "
527
+ "know whether the sentence describes a method or a person. You do see the teach "
528
+ "protocol in SKILL.md."
291
529
  )
292
- return "REFUSED — this reads like a record about a person, not a method:\n - " + "\n - ".join(self.reasons)
530
+ return "\n".join(lines)
531
+
293
532
 
533
+ # Unicode has a whole block of dash-like characters and NFKC does NOT collapse them to
534
+ # ASCII: U+2011 (non-breaking hyphen) folds to U+2010, still not `-`. So `medical‑record
535
+ # number 84729163` slipped a vocabulary pattern spelled with an ASCII hyphen. Folding the
536
+ # dashes explicitly means the patterns can be written once, in ASCII, instead of every
537
+ # pattern carrying its own dash class.
538
+ def _fold(text: str) -> str:
539
+ """One canonical form for matching: compatibility-normalised, every dash ASCII.
294
540
 
295
- def check_lesson(text: str, allow_numbers: bool = False) -> LessonVerdict:
296
- """Refuse anything not shaped like a METHOD lesson. Fails toward refusal.
541
+ Dashes are folded by Unicode CATEGORY (`Pd`), not by a table of the ones I happened
542
+ to think of review walked U+058A and U+2E3A through a hand-written list, which is
543
+ the enumeration mistake this whole feature has been unlearning. `Pd` is the closed
544
+ set the standard maintains."""
545
+ folded = unicodedata.normalize("NFKC", text)
546
+ return "".join("-" if unicodedata.category(ch) == "Pd" else ch for ch in folded)
297
547
 
298
- `allow_numbers` is an EXPLICIT human decision for the case where a number IS the
299
- knowledge (a guideline threshold). It is a flag a person passes, never something
300
- inferred from the presence of a word — inferring it is exactly how the first
301
- design was laundered ("TSH was 8.04 mIU/L WHEN fasting" passed because "when"
302
- was read as a threshold marker).
303
548
 
304
- The right response to a refusal is to rewrite the lesson as the rule it taught,
305
- which is also the more useful form: a reading helps once, a rule helps every time.
549
+ def check_lesson(text: str) -> LessonVerdict:
550
+ """Refuse formatted identifiers; observe the rest out loud; judge nothing.
551
+
552
+ The judgement this file used to attempt now lives in SKILL.md, addressed to the
553
+ agent — which can read meaning, and which is already running.
306
554
  """
307
- raw = text or ""
555
+ # NFKC FIRST. Review walked `555‑1234` and `medical‑record number 84729163` through
556
+ # by using U+2011 (non-breaking hyphen) where the patterns expected ASCII, and a
557
+ # fullwidth spelling defeats every character class the same way. Compatibility
558
+ # normalisation folds those presentation variants onto the characters they stand for,
559
+ # so the patterns match one canonical form instead of chasing separators.
560
+ raw = _fold(text or "")
308
561
  if not raw.strip():
309
- return LessonVerdict(False, ["the lesson is empty"])
310
- reasons: List[str] = []
311
-
312
- # Identifiers with a fixed shape (email, phone, medical-record vocabulary) are
313
- # refused ALWAYS — --allow-numbers must not reach them. This list is genuinely a
314
- # blocklist and is NOT the guard's backbone: the token allowlist below is what
315
- # catches identifier shapes the list never heard of.
316
- for pattern, why, ignore_case in _IDENTIFIER_PATTERNS:
562
+ return LessonVerdict(False, ["the lesson is empty"], [])
563
+ blockers: List[str] = []
564
+ for pattern, why, ignore_case, validator in _IDENTIFIER_PATTERNS:
317
565
  flags = re.UNICODE | (re.IGNORECASE if ignore_case else 0)
318
- if re.search(pattern, raw, flags=flags):
319
- reasons.append(why)
320
-
321
- tokens = _tokens(raw)
322
- for index, token in enumerate(tokens):
323
- has_digit = any(ch.isdigit() for ch in token)
324
- has_alpha = any(ch.isalpha() for ch in token)
325
- digit_count = sum(1 for ch in token if ch.isdigit())
326
- looks_like_id = len(token) >= _IDENTIFIER_MIN_LEN or digit_count >= _IDENTIFIER_MIN_DIGITS
327
- if has_digit and has_alpha and looks_like_id and not re.match(_NUMBER_TOKEN_RE, token):
328
- reasons.append(
329
- f"{token!r} mixes letters and digits that is the shape of an identifier "
330
- "(a record number, an accession, a passport), not of a measurement, and no "
331
- "flag accepts it"
332
- )
333
- break
334
- if has_digit or not has_alpha:
335
- continue # numbers are judged below; punctuation-only tokens carry nothing
336
- if index == 0:
337
- continue # an ordinary sentence start
338
- if token.islower() or _is_acronym(token):
339
- continue
340
- reasons.append(
341
- f"a capitalised word ({token!r}) after the first one — a method lesson is written "
342
- "in lower case, because one capital is the shape of a name and names cannot be "
343
- "listed. Lower-case it — an acronym passes either fully upper (SHBG, TSH) or opening "
344
- "lower (apoB); only Title Case is refused"
345
- )
346
- break
347
-
348
- spelled = re.search(_SPELLED_NUMBER_RE, raw, flags=re.IGNORECASE | re.UNICODE)
349
- has_digits = re.search(_DIGIT_RE, raw) is not None or spelled is not None
350
- if has_digits and not allow_numbers:
351
- reasons.append(
352
- "the lesson contains digits — a method lesson rarely needs them, and a number is where "
353
- "readings hide. Rewrite it without the figure, or pass --allow-numbers if the number IS "
354
- "the knowledge (a guideline threshold) and you take responsibility for it"
355
- + (f" (spelled out, but still a value: {spelled.group(0)!r})" if spelled else "")
566
+ # finditer, not search: a validator may reject the FIRST match while a later one
567
+ # in the same lesson is a real identifier. Stopping at the first candidate would
568
+ # make the check depend on where in the sentence the decoy sits.
569
+ for match in re.finditer(pattern, raw, flags=flags):
570
+ if validator is None or validator(match):
571
+ blockers.append(why)
572
+ break
573
+ notices: List[str] = []
574
+ if _has_digit(raw):
575
+ notices.append(
576
+ "the lesson contains a number — is it knowledge (a guideline threshold) or a "
577
+ "reading from one person? Only the second is a problem, and only you can tell"
356
578
  )
357
-
358
- # A person beside a value is a RECORD — even under an explicit --allow-numbers.
359
- # Note the widened trigger: ANY single number word counts here, not just the
360
- # two-word sequences that read as a value on their own. `one marker` alone is
361
- # ordinary method prose; `his tsh was twelve` is a reading about a human being,
362
- # and round 3 walked it through because a lone number word was not a "number".
363
- lone_number_word = re.search(rf"\b(?:{_NUMBER_WORDS})\b", raw, flags=re.IGNORECASE | re.UNICODE)
364
- carries_value = has_digits or lone_number_word is not None
365
- if carries_value and re.search(_PERSON_RE, raw, flags=re.IGNORECASE | re.UNICODE):
366
- reasons.append(
367
- "a person is referred to alongside a number — that is a record about someone, "
368
- "and no flag makes it a method"
369
- )
370
- return LessonVerdict(not reasons, reasons)
579
+ return LessonVerdict(not blockers, blockers, notices)
371
580
 
372
581
 
373
582
  # ---------------------------------------------------------------- dz detection
@@ -396,14 +605,28 @@ def status() -> str:
396
605
  if code != 0:
397
606
  return f"dz found at {exe}, but the learned store is not readable yet — teach the first lesson to create it."
398
607
  total = out.splitlines()[0] if out else ""
399
- return f"self-learning ON via {exe}\n {total.strip()}\n lessons from this package are tagged domain={LEARNING_DOMAIN}"
608
+ brain = health_brain()
609
+ # --include-domain, for the same reason _brain_count needs it: `recall --all` applies
610
+ # the export hold-out and would report an occupied health brain as empty. The fix was
611
+ # made in _brain_count and NOT carried to its neighbour — a fix applied to one call
612
+ # site is not a fix.
613
+ hcode, hout, _ = _run_dz(["recall", "--all", "--stats", "--project", brain,
614
+ "--include-domain", LEARNING_DOMAIN], timeout=90)
615
+ htotal = (hout.splitlines()[0] if hout else "").strip() if hcode == 0 else "not created yet"
616
+ return (
617
+ f"self-learning ON via {exe}\n"
618
+ f" health brain {brain}\n {htotal}\n"
619
+ f" shared brain {total.strip()}\n"
620
+ f" lessons from this package are WRITTEN ONLY to the health brain (domain={LEARNING_DOMAIN});\n"
621
+ f" recall reads both, so engineering lessons transfer in and medical ones never leave"
622
+ )
400
623
 
401
624
 
402
625
  def recall(query: str, limit: int = 5) -> str:
403
626
  """Recall traps already caught. Absent dz is a NOTE, never a failure — a research
404
627
  session must not depend on an optional dependency.
405
628
 
406
- There is exactly ONE call, and no retry — an earlier version of this docstring
629
+ TWO calls, one per store (health first, then shared), and no retry — an earlier version of this docstring
407
630
  promised a fallback call that the code never made (round 2 caught the prose, not
408
631
  the code, lying). No retry is needed: an older `dz` does not REJECT `--domain`, it
409
632
  ignores the flag and exits 0 with unranked results, so the single call already
@@ -425,11 +648,39 @@ def recall(query: str, limit: int = 5) -> str:
425
648
  # A guard that refuses safe input teaches people to work around the guard.
426
649
  return "refusing a query that starts with '--': it would be read as an option by dz, not as text"
427
650
 
428
- code, out, err = _run_dz(["recall", query, "--domain", LEARNING_DOMAIN, "--limit", str(limit)])
651
+ # BOTH stores, in one direction: the health brain first (its lessons are the ones
652
+ # about this work), then the shared brain for cross-domain transfer. Nothing here
653
+ # writes; `teach` only ever touches the health brain, which is what makes the flow
654
+ # one-way rather than a convention someone has to remember.
655
+ brain = health_brain()
656
+ parts: List[str] = []
657
+ code, out_health, err = _run_dz(["recall", query, "--domain", LEARNING_DOMAIN,
658
+ "--limit", str(limit), "--project", brain])
429
659
  if code == 127:
430
660
  return err
661
+ if code == 0 and out_health.strip():
662
+ parts.append("— health brain —\n" + out_health.rstrip())
663
+
664
+ shared_code, shared_out, shared_err = _run_dz(["recall", query, "--limit", str(limit)])
665
+ if shared_code == 0 and shared_out.strip():
666
+ parts.append("— shared brain (engineering lessons transfer INTO this work; "
667
+ "medical lessons never leave the health brain) —\n" + shared_out.rstrip())
668
+
669
+ # EACH failure is named. Reporting only when BOTH stores fail swallowed a corrupt
670
+ # health store whenever the shared one answered — and worse, the missing health
671
+ # section then fell through to the "your CLI predates --domain" branch, which is a
672
+ # diagnosis of the wrong thing. A partial loop must say which half is missing.
431
673
  if code != 0:
432
- return f"recall unavailable ({err.strip() or 'unknown error'}) — proceeding WITHOUT prior lessons"
674
+ parts.append(f" health brain UNAVAILABLE ({err.strip() or 'unknown error'}) — "
675
+ "its lessons are NOT in this result —")
676
+ if shared_code != 0:
677
+ parts.append(f"— shared brain unavailable ({shared_err.strip() or 'unknown error'}) — "
678
+ "cross-domain transfer is NOT in this result —")
679
+ if code != 0 and shared_code != 0:
680
+ return f"recall unavailable ({err.strip() or shared_err.strip() or 'unknown error'}) — proceeding WITHOUT prior lessons"
681
+ if not parts:
682
+ return "no prior lessons matched — this is new ground"
683
+ out = "\n".join(parts)
433
684
 
434
685
  # CAPABILITY, not exit code (Codex QE #3 — the sharpest finding of the round).
435
686
  # The PREVIOUS dz did not reject `--domain`: its parser accepted any `--key value`
@@ -437,7 +688,11 @@ def recall(query: str, limit: int = 5) -> str:
437
688
  # error-code test could never fire, and my own test had FABRICATED the failure it
438
689
  # was checking — modelling a version that never existed. The observable difference
439
690
  # is the boost note the new CLI prints; its absence is what "too old" looks like.
440
- if not _boost_note_present(out):
691
+ # Judge the capability on the HEALTH call's output only, and only when that call
692
+ # SUCCEEDED. `--domain` is passed to that call alone, so checking the merged text
693
+ # meant a failed health store produced a missing note and the code then blamed an
694
+ # old CLI — a confident diagnosis of the wrong thing, which is worse than silence.
695
+ if code == 0 and not _boost_note_present(out_health):
441
696
  return out.rstrip() + (
442
697
  "\n note: this dz ranked WITHOUT the domain boost (the installed CLI predates "
443
698
  "`dz recall --domain`, which ignores the flag silently rather than failing) — results "
@@ -446,24 +701,379 @@ def recall(query: str, limit: int = 5) -> str:
446
701
  return out.rstrip() or "no prior lessons matched — this is new ground"
447
702
 
448
703
 
449
- def teach(lesson: str, reward: float = 0.8, allow_numbers: bool = False) -> Tuple[int, str]:
450
- """Record a METHOD lesson. The privacy guard runs FIRST and can refuse."""
704
+ # The paths inside the brain whose fate the ignore file decides. `.dz` alone is not
705
+ # enough: a rule may leave the directory ignored and still re-expose what is under it.
706
+ _PROTECTED_PATHS = (".dz", ".dz/memory", ".dz/memory/patterns.jsonl")
707
+
708
+
709
+ def _gitignore_rule_matches(rule: str, path: str) -> bool:
710
+ """Does one gitignore rule MATCH this path, by git's matching rules?"""
711
+ rule = rule.rstrip("/") # a trailing `/` means "directories only"
712
+ if not rule:
713
+ return False
714
+ if rule.startswith("/"):
715
+ rule, anchored = rule[1:], True
716
+ else:
717
+ anchored = "/" in rule
718
+ if anchored:
719
+ # Anchored to the directory holding the ignore file: match the whole path, and
720
+ # also anything beneath a directory the rule names.
721
+ return fnmatch.fnmatch(path, rule) or fnmatch.fnmatch(path, rule + "/*")
722
+ # No slash: git matches the pattern against the BASENAME at any depth. Matching any
723
+ # component also covers "an ancestor directory is (un)ignored", which is the case
724
+ # `!.dz/` exploits.
725
+ return any(fnmatch.fnmatch(part, rule) for part in path.split("/"))
726
+
727
+
728
+ def _gitignore_ignores(text: str, path: str) -> bool:
729
+ """Is `path` ignored, applying the rules the way git does: LAST MATCHING RULE WINS.
730
+
731
+ WHAT THIS DOES NOT MODEL, stated rather than implied:
732
+ * `**` is approximated — fnmatch's `*` already crosses `/`, so `a/**/b` is looser
733
+ here than in git, and `**` never matches "zero directories" specially;
734
+ * bracket expressions (`[a-z]`) and backslash escapes are left to fnmatch, whose
735
+ dialect is close to git's but not identical;
736
+ * "directories only" (a trailing `/`) is accepted but not ENFORCED — this function
737
+ is given path strings, not a filesystem, so it cannot ask whether a path is a
738
+ directory. The effect is to treat such a rule as matching slightly more, which
739
+ errs toward refusing;
740
+ * rules from OUTSIDE this file — a parent `.gitignore`, `.git/info/exclude`,
741
+ `core.excludesFile` — are not consulted, so a parent may still un-ignore what
742
+ this file ignores;
743
+ * a file already TRACKED by git is never ignored, whatever any rule says. That is a
744
+ property of the index, not of the rules, and no reading of this file can see it.
745
+ Each of those makes the answer approximate in a direction that is either safe or
746
+ named; none of them is the round-16 defect, which was applying the wrong rule
747
+ entirely."""
748
+ ignored = False
749
+ for line in text.splitlines():
750
+ rule = line.strip()
751
+ if not rule or rule.startswith("#"):
752
+ continue
753
+ negated = rule.startswith("!")
754
+ if negated:
755
+ rule = rule[1:]
756
+ if _gitignore_rule_matches(rule, path):
757
+ ignored = not negated
758
+ return ignored
759
+
760
+
761
+ def _protect_brain(brain: str) -> None:
762
+ """Create the health brain and make it SELF-IGNORING for git.
763
+
764
+ A `.gitignore` holding `*` INSIDE the directory ignores everything in it, including
765
+ itself, without touching the user's own ignore file. That matters: the monorepo rule
766
+ that protected this during development ships with nothing — a consumer who installs
767
+ the package gets a plaintext medical store one `git add -A` away from a push, and the
768
+ ADR promised protection from exactly that.
769
+
770
+ Self-contained beats editing someone else's `.gitignore`: it needs no cooperation
771
+ from the project, survives the file being rewritten, and travels with the directory
772
+ if it is moved. Written only when absent, so a deliberate change is never clobbered.
773
+ """
774
+ os.makedirs(brain, exist_ok=True)
775
+ marker = os.path.join(brain, ".gitignore")
776
+ # PRESENCE IS NOT VALIDITY. `os.path.exists` accepted a DIRECTORY named `.gitignore`
777
+ # as a satisfied rule, so the protection read as present while git ignored nothing —
778
+ # found by the test written for the OSError case, which is the argument for writing
779
+ # the test rather than reasoning about the code.
780
+ # A SYMLINK is not a rule git will read. Git does not follow `.gitignore` symlinks in
781
+ # the working tree, so a link pointing at a file containing `*` satisfied `isfile()`
782
+ # and `open()` while git ignored nothing — the check saw a rule git never sees. Third
783
+ # variant of the same defect: first a directory, then an empty file, now a link.
784
+ # `islink` is asked FIRST because it is the only question `isfile` cannot answer.
785
+ if os.path.islink(marker):
786
+ raise RuntimeError(
787
+ f"{marker} is a SYMLINK. Git does not follow a symlinked .gitignore, so the "
788
+ "rule it points at would never apply and this store would be staged by a "
789
+ "routine `git add -A`. Replace it with a regular file containing `*`."
790
+ )
791
+ if os.path.exists(marker) and not os.path.isfile(marker):
792
+ raise RuntimeError(
793
+ f"{marker} exists but is not a file, so it cannot hold the ignore rule. "
794
+ "Refusing to write health lessons into a directory that a routine "
795
+ "`git add -A` would stage."
796
+ )
797
+ # A FILE is not a RULE. An empty (or unrelated) .gitignore satisfied `isfile` while
798
+ # ignoring nothing — the previous fix closed the "directory" case and left the case
799
+ # that actually happens: a pre-existing or hand-edited file. Presence is not validity,
800
+ # one level up from where I fixed it last time.
801
+ if os.path.isfile(marker):
802
+ try:
803
+ existing = open(marker, encoding="utf-8").read()
804
+ except OSError as exc:
805
+ raise RuntimeError(
806
+ f"cannot read the ignore rule at {marker} ({exc}), so it cannot be "
807
+ "verified. Refusing to write health lessons into a directory whose "
808
+ "protection is unknown."
809
+ ) from exc
810
+ # LAST MATCH WINS, exactly as git resolves it — AGAINST A SPECIFIC PATH.
811
+ # Round 16 read "last match wins" as "any later `!` cancels the `*`", which is a
812
+ # different rule and a wrong one: it refused
813
+ # *
814
+ # !README.md
815
+ # a file that ignores the store perfectly well, because `!README.md` never matches
816
+ # `.dz` and so never becomes the last MATCHING rule. One wrong model replaced
817
+ # another. _gitignore_ignores evaluates the rules the way git does, per path.
818
+ if not all(_gitignore_ignores(existing, path) for path in _PROTECTED_PATHS):
819
+ raise RuntimeError(
820
+ f"{marker} does not ignore this directory IN FORCE: git applies the LAST "
821
+ "rule that MATCHES a path, and for at least one of "
822
+ f"{', '.join(_PROTECTED_PATHS)} that rule is either absent or a `!` "
823
+ "negation that re-exposes the store. A routine `git add -A` would stage "
824
+ "it. Leave a plain `*` last, or delete the file so it can be recreated."
825
+ )
826
+ if not os.path.isfile(marker):
827
+ try:
828
+ with open(marker, "w", encoding="utf-8") as handle:
829
+ handle.write(
830
+ "# The health brain holds lessons drawn from one person's medical\n"
831
+ "# investigations. `*` ignores everything here, including this file, so a\n"
832
+ "# routine `git add -A` cannot stage it. Delete this line only if you mean\n"
833
+ "# to commit medical data.\n*\n"
834
+ )
835
+ except OSError as exc:
836
+ # NOT best-effort. ADR-004 promises protection from a routine `git add -A`,
837
+ # and a swallowed failure here leaves a plaintext medical store staged by the
838
+ # next one while the write reports success — a promise kept in prose only.
839
+ raise RuntimeError(
840
+ f"cannot create the ignore rule at {marker} ({exc}). Refusing to write "
841
+ "health lessons into a directory that a routine `git add -A` would stage."
842
+ ) from exc
843
+
844
+
845
+ # A harmless probe written BEFORE the real lesson, to prove `--project` is honoured.
846
+ # It carries no medical content by construction, so if the older-CLI defect fires, what
847
+ # lands in the shared store is this self-describing string and not a patient's finding.
848
+ _CANARY_TEXT = "health brain routing self-test — safe to delete"
849
+
850
+
851
+ def _project_write_lands(brain: str, project: Optional[str] = None) -> Tuple[bool, str]:
852
+ """Prove the write goes where we asked, BEFORE writing the real lesson.
853
+
854
+ The previous design counted before and after the REAL write, so an older CLI (which
855
+ accepts `--project` and writes from the current directory anyway, returning 0) was
856
+ detected only once the lesson was already in the shared store — the check returned
857
+ failure about a leak it had just permitted. "Fails closed" described the return
858
+ status, not the mutation.
859
+
860
+ A canary moves the cost of that detection onto a meaningless string. If it does not
861
+ land, the real lesson is never written and the caller is told exactly what to look
862
+ for in the shared store.
863
+
864
+ TWO QUESTIONS, NOT ONE (round 17). Presence in the brain was the whole test, and
865
+ presence was checked THROUGH THE BRAIN PATH — so any alias that makes the brain path
866
+ and the shared path reach the same store certified that store as the brain. The
867
+ layout that proved it: `.health-brain` and `.health-brain/.dz` are real directories
868
+ while `.health-brain/.dz/memory` is a symlink to `../../.dz/memory`. Every path check
869
+ in _health_brain_is_distinct resolves the brain and its `.dz`, both of which are
870
+ genuinely distinct; the aliasing lives one level DEEPER, where dz actually keeps the
871
+ data. The canary, its lookup, both counts and the real teach then travelled the same
872
+ alias, and the bridge reported "recorded in the health brain" about a lesson sitting
873
+ in the shared one — the exact thing ADR-004 exists to prevent.
874
+
875
+ The answer is not another path-shape check. A path check can only refuse the shapes
876
+ it was told about, and this file's whole history is review producing the next shape:
877
+ a deeper symlink, a bind mount, a `dz` bug, a filesystem alias nobody has named yet.
878
+ So the probe now asks about the OUTCOME instead: the canary must be PRESENT in the
879
+ brain and ABSENT from the shared store. Those two facts together pin down that the
880
+ two stores are different stores, whatever mechanism might have made them the same.
881
+ The canary is nonce-bearing and deliberately non-medical, so looking for it in the
882
+ shared store is safe — that is why it can be used this way.
883
+ """
884
+ # A UNIQUE canary, looked up BY ITS OWN TEXT. Counting `after > before` accepted any
885
+ # growth, so under mixed CLI versions a concurrent process could satisfy this check
886
+ # while THIS process's canary went to the shared store — and the real lesson followed
887
+ # it. A count is a fact about the store; only the probe's own presence is a fact
888
+ # about this write.
889
+ nonce = uuid.uuid4().hex[:12]
890
+ canary = f"{_CANARY_TEXT} [{nonce}]"
891
+ code, _, err = _run_dz([
892
+ "teach", canary, "--reward", "0.1", "--domain", LEARNING_DOMAIN,
893
+ "--type", "lesson-learned", "--project", brain, "--no-mirror",
894
+ ])
895
+ if code == 127:
896
+ return True, "" # dz absent — handled by the caller, not a routing failure
897
+ if code != 0:
898
+ return False, f"the routing self-test could not run ({err.strip() or 'unknown error'})"
899
+ if not _canary_present(brain, canary):
900
+ return False, (
901
+ "this `dz` does not honour --project on a write.\n"
902
+ f" A self-test lesson was written and did NOT appear in {brain}, which means\n"
903
+ " it went to the SHARED store instead. Your lesson was NOT written.\n"
904
+ f' Find and remove the stray probe: dz recall "{canary}"\n'
905
+ " Then upgrade: npm i -g @dzhechkov/harness-cli"
906
+ )
907
+ # …and ABSENT from the shared store. Presence in the brain alone is satisfied by any
908
+ # alias that makes the two paths one store; absence from the shared store is not.
909
+ shared = os.path.realpath(project or os.getcwd())
910
+ shared_readable, shared_ids = _canary_lookup(shared, canary)
911
+ if shared_readable and shared_ids:
912
+ # Clean up through BOTH paths: if they are one store either call removes it, and
913
+ # if they are somehow two, both are left clean. A refusal must not leave probes
914
+ # behind any more than a success does.
915
+ _forget_canary(shared, canary)
916
+ _forget_canary(brain, canary)
917
+ return False, (
918
+ "the health brain and the SHARED store are the SAME store.\n"
919
+ f" A self-test lesson was written to {brain} and then FOUND in the shared\n"
920
+ f" store at {shared}. Some alias makes one store answer to both paths — a\n"
921
+ " symlink INSIDE the brain (`.health-brain/.dz/memory` is the one that has\n"
922
+ " been seen), a bind mount, or a `dz` that resolves --project elsewhere.\n"
923
+ " Your lesson was NOT written: ADR-004 says health lessons never reach the\n"
924
+ " shared store, and here they would.\n"
925
+ f' The probe is nonce [{nonce}]; if any copy survived this cleanup, find and\n'
926
+ f' remove it: dz recall "{canary}"\n'
927
+ f" Then remove the alias under {brain} and teach again."
928
+ )
929
+ _forget_canary(brain, canary)
930
+ return True, ""
931
+
932
+
933
+ def _canary_present(brain: str, canary: str) -> bool:
934
+ """Is THIS probe in the brain? Asked by exact text, not by a count."""
935
+ return _canary_lookup(brain, canary)[1] != []
936
+
937
+
938
+ def _canary_lookup(project: str, canary: str) -> Tuple[bool, List[str]]:
939
+ """(was the store READABLE?, the ids this probe has inside it).
940
+
941
+ The two answers are kept apart because absence and unreadability are different
942
+ facts. An unreadable SHARED store is not evidence that the probe leaked into it —
943
+ and it is positive evidence that the two paths are not one store, since the very
944
+ same command answered for the brain a moment earlier.
945
+ """
946
+ code, out, _ = _run_dz(["recall", "--usage", "--json", "--project", project,
947
+ "--include-domain", LEARNING_DOMAIN], timeout=90)
948
+ if code != 0:
949
+ return False, []
950
+ try:
951
+ report = json.loads(out)
952
+ except (ValueError, TypeError):
953
+ return False, []
954
+ found: List[str] = []
955
+
956
+ def walk(node: object) -> None:
957
+ if isinstance(node, dict):
958
+ if node.get("pattern") == canary and node.get("dzId"):
959
+ found.append(str(node["dzId"]))
960
+ for value in node.values():
961
+ walk(value)
962
+ elif isinstance(node, list):
963
+ for value in node:
964
+ walk(value)
965
+
966
+ walk(report)
967
+ return True, list(dict.fromkeys(found))
968
+
969
+
970
+ def _forget_canary(brain: str, canary: str) -> None:
971
+ """Remove the probe. `--forget` takes dzIds, NOT text.
972
+
973
+ The first version passed the canary TEXT and the probe stayed in the brain forever —
974
+ caught by running the flow, not by the suite: the mock returned success for `forget`,
975
+ so the test blessed a cleanup that never cleaned. Failure here is harmless (the text
976
+ says what it is), so it never blocks teaching — but it should still actually work.
977
+ """
978
+ for dz_id in _canary_lookup(brain, canary)[1]:
979
+ _run_dz(["recall", "--forget", dz_id, "--apply", "--project", brain])
980
+
981
+
982
+ def _brain_count(brain: str) -> Optional[int]:
983
+ """How many lessons the health brain holds, or None if it cannot be read.
984
+
985
+ `--include-domain` is REQUIRED here, and forgetting it made the verification defeat
986
+ itself: `recall --all` applies the export hold-out, which withholds exactly this
987
+ domain, so a health-only brain reported 0 before AND 0 after a successful write and
988
+ the bridge declared failure. A check built on top of another safety measure has to
989
+ account for that measure — and this counting is a local read of our own store, not an
990
+ export, which is precisely the case the opt-in exists for.
991
+ """
992
+ code, out, _ = _run_dz(["recall", "--all", "--stats", "--project", brain,
993
+ "--include-domain", LEARNING_DOMAIN], timeout=90)
994
+ if code != 0:
995
+ return None
996
+ match = re.search(r"(\d+)\s+learned pattern", out)
997
+ return int(match.group(1)) if match else None
998
+
999
+
1000
+ def teach(lesson: str, reward: float = 0.8, confirmed: bool = False) -> Tuple[int, str]:
1001
+ """Record a METHOD lesson. Two things must hold, and they are different in kind.
1002
+
1003
+ The FORMAT check runs here and can refuse — identifiers have a shape.
1004
+ The MEANING check is `confirmed`: the caller asserts it performed the teach
1005
+ protocol in SKILL.md (write the rule without the case; read it back hunting for
1006
+ the one person). This file does NOT verify that assertion and does not pretend
1007
+ to — it records that a judging agent made the call, which is the honest
1008
+ description of what happened.
1009
+ """
451
1010
  if lesson.startswith("--"):
452
1011
  return 1, "refusing a lesson that starts with '--': dz would read it as an option, not as text"
453
- verdict = check_lesson(lesson, allow_numbers=allow_numbers)
1012
+ verdict = check_lesson(lesson)
454
1013
  if not verdict.ok:
455
- return 1, verdict.note + (
456
- "\n\nRewrite it as the general rule it taught. The method form is also the more useful "
457
- "lesson: a reading helps once, a rule helps every time."
1014
+ return 1, verdict.note
1015
+ if not confirmed:
1016
+ return 1, (
1017
+ verdict.note
1018
+ + "\n\nNOT RECORDED. This lesson has not been confirmed as a method.\n"
1019
+ " 1. Write the RULE it taught, not the case — if the rule cannot be written\n"
1020
+ " without the specific reading, there is no lesson yet, only a finding.\n"
1021
+ " 2. Read it back: could someone who knows this person recognise them?\n"
1022
+ " A rare combination identifies without any name or number.\n"
1023
+ " 3. Re-run with --confirm-method to assert you did both.\n"
1024
+ " The full protocol, with examples, is in SKILL.md."
458
1025
  )
1026
+ distinct, brain = _health_brain_is_distinct()
1027
+ if not distinct:
1028
+ return 1, brain
1029
+ try:
1030
+ _protect_brain(brain)
1031
+ except RuntimeError as exc:
1032
+ return 1, f"REFUSED — {exc}"
1033
+ # PRE-FLIGHT: prove the routing works before the real lesson is anywhere.
1034
+ routes, why = _project_write_lands(brain)
1035
+ if not routes:
1036
+ return 1, f"REFUSED — {why}"
1037
+ before = _brain_count(brain)
459
1038
  code, out, err = _run_dz([
460
- "teach", lesson, "--reward", str(float(reward)), "--domain", LEARNING_DOMAIN, "--type", "lesson-learned",
1039
+ "teach", lesson, "--reward", str(float(reward)), "--domain", LEARNING_DOMAIN,
1040
+ "--type", "lesson-learned", "--project", brain,
461
1041
  ])
462
1042
  if code == 127:
463
1043
  return 0, err # not an error: the package works without dz
464
1044
  if code != 0:
465
1045
  return 1, f"teach failed: {err.strip() or out.strip()}"
466
- return 0, f"recorded (domain={LEARNING_DOMAIN})"
1046
+
1047
+ # VERIFY THE WRITE LANDED rather than trusting the exit code. An older CLI (0.3.173)
1048
+ # parsed `--project` and then wrote from the current directory anyway, returning 0 —
1049
+ # so this bridge would have reported a lesson safely in the health brain while it sat
1050
+ # in the shared one. `recall` already carried that lesson (probe the capability, never
1051
+ # trust an exit code); the write path did not until review said so.
1052
+ #
1053
+ # Counting before and after is deliberately cruder than parsing a path: a count is
1054
+ # what every version reports the same way. If it did not move, the lesson went
1055
+ # somewhere we did not ask for, and the reader is told to go and look.
1056
+ after = _brain_count(brain)
1057
+ # FAIL CLOSED when the count is unavailable. Requiring BOTH numbers to be integers
1058
+ # meant the verification was skipped in exactly the case it could not verify — the
1059
+ # same shape as a gate that cannot run its test and reports green. An unreadable
1060
+ # brain is not evidence the write landed.
1061
+ if before is None or after is None:
1062
+ return 1, (
1063
+ "FAILED — the write could not be VERIFIED: the health brain's count is "
1064
+ f"unreadable ({brain}).\n"
1065
+ " `dz` reported success, but this bridge cannot confirm the lesson landed "
1066
+ "there rather than in the shared store, so it does not claim it did."
1067
+ )
1068
+ if after <= before:
1069
+ return 1, (
1070
+ "FAILED — `dz` reported success but the health brain did not grow.\n"
1071
+ f" The lesson may have gone to the SHARED store instead of {brain}.\n"
1072
+ " An older CLI accepts --project on a write and ignores it. Check the shared\n"
1073
+ " store, then upgrade: npm i -g @dzhechkov/harness-cli"
1074
+ )
1075
+ tail = ("\n" + "\n".join(f" NOTICED: {n}" for n in verdict.notices)) if verdict.notices else ""
1076
+ return 0, f"recorded in the health brain ({brain}), confirmed by the caller{tail}"
467
1077
 
468
1078
 
469
1079
  def main(argv: Optional[Sequence[str]] = None) -> int:
@@ -476,11 +1086,12 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
476
1086
  p_teach = sub.add_parser("teach")
477
1087
  p_teach.add_argument("lesson")
478
1088
  p_teach.add_argument("--reward", type=float, default=0.8)
479
- p_teach.add_argument("--allow-numbers", action="store_true",
480
- help="the number IS the knowledge (a guideline threshold) an explicit human decision")
1089
+ p_teach.add_argument("--confirm-method", action="store_true", dest="confirm_method",
1090
+ help="you performed the teach protocol in SKILL.md: wrote the RULE rather "
1091
+ "than the case, and read it back hunting for the one person. This "
1092
+ "file cannot verify that and does not pretend to.")
481
1093
  p_check = sub.add_parser("check")
482
1094
  p_check.add_argument("lesson")
483
- p_check.add_argument("--allow-numbers", action="store_true")
484
1095
  args = parser.parse_args(argv)
485
1096
 
486
1097
  if args.cmd == "status":
@@ -490,10 +1101,10 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
490
1101
  print(recall(args.query, args.limit))
491
1102
  return 0
492
1103
  if args.cmd == "check":
493
- verdict = check_lesson(args.lesson, allow_numbers=args.allow_numbers)
1104
+ verdict = check_lesson(args.lesson)
494
1105
  print(verdict.note)
495
1106
  return 0 if verdict.ok else 1
496
- code, message = teach(args.lesson, args.reward, allow_numbers=args.allow_numbers)
1107
+ code, message = teach(args.lesson, args.reward, confirmed=args.confirm_method)
497
1108
  print(message)
498
1109
  return code
499
1110