@dzhechkov/p-replicator 1.5.17 → 1.6.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.
- package/.dz-manifest.json +173 -65
- package/CHANGELOG.md +135 -0
- package/MULTIPLATFORM_ROADMAP.md +1 -1
- package/README/eng/01_quickstart.md +2 -2
- package/README/eng/02_user_guide.md +1 -1
- package/README/eng/03_admin_guide.md +2 -2
- package/README/eng/05_architecture.md +6 -2
- package/README/eng/README.md +1 -1
- package/README/ru/01_quickstart.md +2 -2
- package/README/ru/02_user_guide.md +1 -1
- package/README/ru/03_admin_guide.md +2 -2
- package/README/ru/05_architecture.md +1 -1
- package/README/ru/README.md +1 -1
- package/README/ru/html/index.html +7 -7
- package/README.md +41 -18
- package/bin/cli.js +0 -0
- package/package.json +11 -10
- package/sbom.json +347 -77
- package/src/utils.js +2 -0
- package/templates/.claude/agents/doc-validator.md +2 -1
- package/templates/.claude/agents/product-discoverer.md +1 -1
- package/templates/.claude/commands/next.md +16 -0
- package/templates/.claude/commands/replicate.md +126 -8
- package/templates/.claude/commands/start.md +19 -1
- package/templates/.claude/hooks/autocommit-insights.cjs +95 -10
- package/templates/.claude/hooks/autocommit-plans.cjs +95 -10
- package/templates/.claude/hooks/autocommit-roadmap.cjs +96 -13
- package/templates/.claude/hooks/check-ports.cjs +232 -0
- package/templates/.claude/hooks/session-insights.cjs +13 -1
- package/templates/.claude/hooks/state-update.cjs +13 -1
- package/templates/.claude/hooks/statusline.cjs +145 -18
- package/templates/.claude/rules/docker-ports.md +123 -0
- package/templates/.claude/rules/replicate-pipeline.md +5 -2
- package/templates/.claude/settings.json +5 -5
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/01-detect-parse.md +57 -14
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/02-analyze-map.md +9 -7
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/enhanced-recommendations.md +6 -4
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/extended-mapping.md +2 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/ddd-agents.md +2 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/ddd-skills.md +1 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/enhanced-claude-md.md +2 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-suggestions.md +11 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/start-command.md +1 -1
- package/templates/.claude/skills/goap-research-ed25519/SKILL.md +340 -47
- package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +359 -3
- package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +386 -13
- package/templates/.claude/skills/goap-research-ed25519/scripts/fixture_legacy_v2_fact.json +23 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/fixtures_field_cases.json +133 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/goap_planner.py +314 -44
- package/templates/.claude/skills/goap-research-ed25519/scripts/learning_bridge.py +890 -303
- package/templates/.claude/skills/goap-research-ed25519/scripts/population_match.py +591 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/risk_statement.py +289 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_ed25519_verifier.py +57 -2
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +969 -344
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_goap_planner.py +420 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_population_match.py +544 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_risk_absolute.py +239 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_signature_v3.py +554 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_suite_completeness.py +90 -0
- package/templates/.claude/skills/requirements-validator/SKILL.md +7 -0
- package/templates/.claude/skills/requirements-validator/references/scoring-system.md +28 -0
- package/templates/.claude/skills/sparc-prd-mini/SKILL.md +143 -1
- package/tests/e2e/lifecycle.test.js +21 -10
- package/tests/snapshot/baseline.json +51 -34
- package/tests/snapshot/update-baseline.js +2 -1
- package/tests/unit/adr-decision-coverage.test.js +137 -0
- package/tests/unit/adr-scanner-contract.test.js +108 -0
- package/tests/unit/autocommit-deletion.test.js +242 -0
- package/tests/unit/check-ports.test.js +184 -0
- package/tests/unit/db-port-rule.test.js +216 -0
- package/tests/unit/detect-parse-anchor.test.js +109 -0
- package/tests/unit/external-dependency-check.test.js +209 -0
- package/tests/unit/growth-module-b2b-gate.test.js +104 -0
- package/tests/unit/hooks-project-anchored.test.js +223 -0
- package/tests/unit/hooks-report-failures.test.js +207 -0
- package/tests/unit/pipeline-file-ownership.test.js +95 -0
- package/tests/unit/roadmap-one-schema.test.js +179 -0
- package/tests/unit/sparc-reconciliation.test.js +117 -0
- package/tests/unit/spec-pseudocode-traceability.test.js +146 -0
- package/tests/unit/statusline-honest-labels.test.js +178 -0
- package/tests/unit/statusline-two-roots.test.js +237 -0
- package/tests/unit/sync-templates-guard.test.js +209 -0
- package/tests/unit/utils.test.js +2 -2
- package/tests/unit/validation-gate-teeth.test.js +158 -0
- package/LICENSE +0 -21
|
@@ -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
|
|
7
|
-
would
|
|
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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
39
|
-
python3 learning_bridge.py
|
|
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
|
-
#
|
|
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,304 +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
|
-
#
|
|
226
|
+
# --------------------------------------------------------- shape check (narrow)
|
|
98
227
|
|
|
99
228
|
# =============================================================================
|
|
100
|
-
#
|
|
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
|
-
#
|
|
103
|
-
#
|
|
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
|
-
#
|
|
115
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
123
|
-
#
|
|
124
|
-
#
|
|
125
|
-
#
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
136
280
|
|
|
137
|
-
# A value SPELLED OUT is still a value. Round 2 walked `John's TSH was eight point
|
|
138
|
-
# zero four` through the digit rule untouched. Requiring two number-words in a row
|
|
139
|
-
# (or a number-word beside "point") keeps the false-positive cost near zero: method
|
|
140
|
-
# prose says "a third", "one marker", never "eight point zero four".
|
|
141
|
-
_NUMBER_WORDS = (
|
|
142
|
-
"zero|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|"
|
|
143
|
-
"fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|"
|
|
144
|
-
"sixty|seventy|eighty|ninety|hundred|thousand|"
|
|
145
|
-
"ноль|один|одна|два|две|три|четыре|пять|шесть|семь|восемь|девять|десять|"
|
|
146
|
-
"двадцать|тридцать|сорок|пятьдесят|шестьдесят|семьдесят|восемьдесят|девяносто|сто|тысяч\\w*"
|
|
147
|
-
)
|
|
148
|
-
_SPELLED_NUMBER_RE = (
|
|
149
|
-
rf"\b(?:{_NUMBER_WORDS})\b[\s,-]+\b(?:point|целых|запятая|{_NUMBER_WORDS})\b"
|
|
150
|
-
rf"|\bpoint\b[\s,-]+\b(?:{_NUMBER_WORDS})\b"
|
|
151
|
-
)
|
|
152
281
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
#
|
|
166
|
-
#
|
|
167
|
-
|
|
168
|
-
# the joiner is still visible. A SPACE is deliberately not a joiner here, or every
|
|
169
|
-
# "the 2019 guideline" would be refused.
|
|
170
|
-
# Any letter in ANY script, any single non-space separator, a long digit run. Round 5
|
|
171
|
-
# walked `ab:1234567` (colon was not in the joiner list) and `αβ—1234567` (Greek was
|
|
172
|
-
# not in the letter class) straight through. Enumerating joiners and alphabets is the
|
|
173
|
-
# same losing game as enumerating names; "not a space" and "any letter" are not.
|
|
174
|
-
(r"[^\W\d_]{2,}[^\w\s]?\d{4,}",
|
|
175
|
-
"letters sitting against a long run of digits — that is an identifier, however it is punctuated", False),
|
|
176
|
-
)
|
|
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.
|
|
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)
|
|
177
297
|
|
|
178
|
-
|
|
179
|
-
#
|
|
180
|
-
#
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
# and there is no list of them. So stop hunting names and state a property of the
|
|
186
|
-
# ACCEPTED form instead:
|
|
187
|
-
#
|
|
188
|
-
# A METHOD LESSON IS WRITTEN IN LOWER CASE (acronyms excepted).
|
|
189
|
-
#
|
|
190
|
-
# Any capitalised word after the first token is refused, whatever it is. This does
|
|
191
|
-
# refuse `compare Testosterone Replacement Therapy against placebo` — deliberately.
|
|
192
|
-
# The fix is one keystroke ("lower-case it"), the rule is one sentence, and the
|
|
193
|
-
# false-negative it closes is a named person beside a diagnosis.
|
|
194
|
-
#
|
|
195
|
-
# ROUND 3 forced this one step further, and the finding was the DESIGN again, not the
|
|
196
|
-
# pattern. `\b[A-ZА-Я][a-zа-я]{2,}\b` is still a blocklist: it enumerates which
|
|
197
|
-
# capitalised words look like names, so `Li` (too short), `Élodie` (Latin-1 letter)
|
|
198
|
-
# and `method—John` (em dash, no \s before it) all walked through. Every round of
|
|
199
|
-
# widening the pattern buys one more round.
|
|
200
|
-
#
|
|
201
|
-
# So the check moves to the TOKEN and states what is ACCEPTED, in a form with no
|
|
202
|
-
# residue: split on whitespace, strip surrounding punctuation, and accept a token only
|
|
203
|
-
# if it is one of
|
|
204
|
-
# * all lower case (letters, any script — `.islower()` answers for every alphabet);
|
|
205
|
-
# * an ACRONYM — two or more upper-case letters in it (SHBG, HIV, apoB, HbA1c);
|
|
206
|
-
# * pure punctuation, or the FIRST token of the lesson (an ordinary sentence start);
|
|
207
|
-
# * a NUMBER, judged by the number rules below.
|
|
208
|
-
# `John`, `Li`, `Élodie`, `Testosterone` each carry exactly one capital and are refused
|
|
209
|
-
# without the guard ever asking what they mean — which is the point, since names cannot
|
|
210
|
-
# be enumerated and lower-casing costs one keystroke.
|
|
211
|
-
_TOKEN_EDGE_PUNCT = " \t\n\r.,;:!?()[]{}\"'«»„“”‘’—–-/\\|*_`"
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
def _is_acronym(token: str) -> bool:
|
|
215
|
-
"""An acronym, as opposed to a name.
|
|
216
|
-
|
|
217
|
-
Round 4 killed the previous rule ("two or more capitals"), which was wrong in BOTH
|
|
218
|
-
directions at once: `Anne-Marie` and `McDonald` carry two capitals and sailed
|
|
219
|
-
through as acronyms, while `apoB` carries one and was refused — contradicting the
|
|
220
|
-
documentation in the same repository that offered it as the example.
|
|
221
|
-
|
|
222
|
-
What actually separates the two is WHERE the first capital sits. A name is Title
|
|
223
|
-
Case: capital first, lower case after. An acronym is either fully upper (SHBG, HIV,
|
|
224
|
-
TSH) or starts lower and capitalises later (apoB, mmHg). So: accept a token whose
|
|
225
|
-
first letter is lower case, or one whose letters are ALL upper case. Refuse
|
|
226
|
-
anything that opens with a capital and then drops to lower case, whatever it is.
|
|
227
|
-
"""
|
|
228
|
-
letters = [ch for ch in token if ch.isalpha()]
|
|
229
|
-
if not letters:
|
|
230
|
-
return False
|
|
231
|
-
if not letters[0].isupper():
|
|
232
|
-
return True # apoB, mmHg — lower-case opening is never a name
|
|
233
|
-
return all(ch.isupper() for ch in letters) # SHBG, HIV, D
|
|
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
|
+
)
|
|
234
305
|
|
|
235
306
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
# token boundary. A period is a boundary only between non-digits, so `8.04` survives.
|
|
240
|
-
_TOKEN_SPLIT_RE = r"(?:[\s—–/\\|()\[\]{}<>\"«»„“”‘’\',;:!?*_`]|(?<!\d)[.](?!\d))+"
|
|
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?
|
|
241
310
|
|
|
311
|
+
The honest distinction is HOW THE VALUE WAS INTRODUCED, not how long it is:
|
|
242
312
|
|
|
243
|
-
|
|
244
|
-
|
|
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.
|
|
245
323
|
|
|
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.
|
|
246
326
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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))
|
|
253
347
|
|
|
254
|
-
# …but a few real lab acronyms carry a digit (`HbA1c`, `CYP2D6`). What separates them
|
|
255
|
-
# from an accession is SIZE: an identifier has to be long enough to be unique. So a
|
|
256
|
-
# mixed token is an identifier when it is long or digit-heavy, and a lab acronym
|
|
257
|
-
# otherwise. The bound is a judgement call and is stated rather than hidden.
|
|
258
|
-
_IDENTIFIER_MIN_LEN = 7
|
|
259
|
-
_IDENTIFIER_MIN_DIGITS = 4
|
|
260
348
|
|
|
261
|
-
#
|
|
262
|
-
#
|
|
263
|
-
#
|
|
264
|
-
#
|
|
265
|
-
#
|
|
266
|
-
#
|
|
267
|
-
#
|
|
268
|
-
# textbook population lesson. Plural person nouns ARE the vocabulary of method lessons
|
|
269
|
-
# about populations; singular ones are the vocabulary of case notes. Number, not word
|
|
270
|
-
# list, is what separates a cohort from a patient, and grammar is checkable.
|
|
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.
|
|
271
356
|
#
|
|
272
|
-
#
|
|
273
|
-
#
|
|
274
|
-
#
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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.
|
|
282
501
|
)
|
|
283
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
|
+
|
|
284
510
|
@dataclass(frozen=True)
|
|
285
511
|
class LessonVerdict:
|
|
286
512
|
ok: bool
|
|
287
|
-
|
|
288
|
-
|
|
513
|
+
blockers: List[str]
|
|
514
|
+
notices: List[str]
|
|
289
515
|
|
|
290
516
|
@property
|
|
291
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}")
|
|
292
524
|
if self.ok:
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
# digits, no figures" even on the --allow-numbers path, where digits were
|
|
298
|
-
# present and consciously allowed — a message that contradicts the flag the
|
|
299
|
-
# caller just passed teaches the reader to stop reading messages.
|
|
300
|
-
if self.numbers_allowed:
|
|
301
|
-
return (
|
|
302
|
-
"no personal-data SHAPE detected (identifiers, capitalisation, person-plus-value). "
|
|
303
|
-
"Digits were ALLOWED by --allow-numbers and are present — you asserted the number "
|
|
304
|
-
"is knowledge, not a reading, and you own that call. This is not a safety "
|
|
305
|
-
"certificate: the guard checks shapes, not meaning."
|
|
306
|
-
)
|
|
307
|
-
return (
|
|
308
|
-
"no personal-data SHAPE detected (no digits, no identifiers, lower case). This is "
|
|
309
|
-
"not a safety certificate: the guard checks shapes, not meaning — a lower-case "
|
|
310
|
-
"sentence with no figures can still describe one person, and you remain "
|
|
311
|
-
"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."
|
|
312
529
|
)
|
|
313
|
-
return "
|
|
530
|
+
return "\n".join(lines)
|
|
531
|
+
|
|
314
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.
|
|
315
540
|
|
|
316
|
-
|
|
317
|
-
|
|
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)
|
|
318
547
|
|
|
319
|
-
`allow_numbers` is an EXPLICIT human decision for the case where a number IS the
|
|
320
|
-
knowledge (a guideline threshold). It is a flag a person passes, never something
|
|
321
|
-
inferred from the presence of a word — inferring it is exactly how the first
|
|
322
|
-
design was laundered ("TSH was 8.04 mIU/L WHEN fasting" passed because "when"
|
|
323
|
-
was read as a threshold marker).
|
|
324
548
|
|
|
325
|
-
|
|
326
|
-
|
|
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.
|
|
327
554
|
"""
|
|
328
|
-
|
|
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 "")
|
|
329
561
|
if not raw.strip():
|
|
330
|
-
return LessonVerdict(False, ["the lesson is empty"])
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
# Identifiers with a fixed shape (email, phone, medical-record vocabulary) are
|
|
334
|
-
# refused ALWAYS — --allow-numbers must not reach them. This list is genuinely a
|
|
335
|
-
# blocklist and is NOT the guard's backbone: the token allowlist below is what
|
|
336
|
-
# catches identifier shapes the list never heard of.
|
|
337
|
-
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:
|
|
338
565
|
flags = re.UNICODE | (re.IGNORECASE if ignore_case else 0)
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
"(a record number, an accession, a passport), not of a measurement, and no "
|
|
352
|
-
"flag accepts it"
|
|
353
|
-
)
|
|
354
|
-
break
|
|
355
|
-
if not has_alpha:
|
|
356
|
-
continue # a pure number is judged by the digit rule; punctuation carries nothing
|
|
357
|
-
if index == 0:
|
|
358
|
-
continue # an ordinary sentence start
|
|
359
|
-
# Capitalisation is checked for EVERY token carrying letters, digits or not.
|
|
360
|
-
# Round 5: digit-bearing tokens returned before this check, so `method Test2
|
|
361
|
-
# applies` passed — a Title Case name only had to carry a digit to become exempt.
|
|
362
|
-
if all(ch.islower() for ch in token if ch.isalpha()) or _is_acronym(token):
|
|
363
|
-
continue
|
|
364
|
-
reasons.append(
|
|
365
|
-
f"a capitalised word ({token!r}) after the first one — a method lesson is written "
|
|
366
|
-
"in lower case, because one capital is the shape of a name and names cannot be "
|
|
367
|
-
"listed. Lower-case it — an acronym passes either fully upper (SHBG, TSH) or opening "
|
|
368
|
-
"lower (apoB); only Title Case is refused"
|
|
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"
|
|
369
578
|
)
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
spelled = re.search(_SPELLED_NUMBER_RE, raw, flags=re.IGNORECASE | re.UNICODE)
|
|
373
|
-
has_digits = _has_digit(raw) or spelled is not None
|
|
374
|
-
if has_digits and not allow_numbers:
|
|
375
|
-
reasons.append(
|
|
376
|
-
"the lesson contains digits — a method lesson rarely needs them, and a number is where "
|
|
377
|
-
"readings hide. Rewrite it without the figure, or pass --allow-numbers if the number IS "
|
|
378
|
-
"the knowledge (a guideline threshold) and you take responsibility for it"
|
|
379
|
-
+ (f" (spelled out, but still a value: {spelled.group(0)!r})" if spelled else "")
|
|
380
|
-
)
|
|
381
|
-
|
|
382
|
-
# A person beside a value is a RECORD — even under an explicit --allow-numbers.
|
|
383
|
-
# Note the widened trigger: ANY single number word counts here, not just the
|
|
384
|
-
# two-word sequences that read as a value on their own. `one marker` alone is
|
|
385
|
-
# ordinary method prose; `his tsh was twelve` is a reading about a human being,
|
|
386
|
-
# and round 3 walked it through because a lone number word was not a "number".
|
|
387
|
-
lone_number_word = re.search(rf"\b(?:{_NUMBER_WORDS})\b", raw, flags=re.IGNORECASE | re.UNICODE)
|
|
388
|
-
carries_value = has_digits or lone_number_word is not None
|
|
389
|
-
if carries_value and re.search(_PERSON_RE, raw, flags=re.IGNORECASE | re.UNICODE):
|
|
390
|
-
reasons.append(
|
|
391
|
-
"a person is referred to alongside a number — that is a record about someone, "
|
|
392
|
-
"and no flag makes it a method"
|
|
393
|
-
)
|
|
394
|
-
return LessonVerdict(not reasons, reasons, numbers_allowed=allow_numbers)
|
|
579
|
+
return LessonVerdict(not blockers, blockers, notices)
|
|
395
580
|
|
|
396
581
|
|
|
397
582
|
# ---------------------------------------------------------------- dz detection
|
|
@@ -420,14 +605,28 @@ def status() -> str:
|
|
|
420
605
|
if code != 0:
|
|
421
606
|
return f"dz found at {exe}, but the learned store is not readable yet — teach the first lesson to create it."
|
|
422
607
|
total = out.splitlines()[0] if out else ""
|
|
423
|
-
|
|
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
|
+
)
|
|
424
623
|
|
|
425
624
|
|
|
426
625
|
def recall(query: str, limit: int = 5) -> str:
|
|
427
626
|
"""Recall traps already caught. Absent dz is a NOTE, never a failure — a research
|
|
428
627
|
session must not depend on an optional dependency.
|
|
429
628
|
|
|
430
|
-
|
|
629
|
+
TWO calls, one per store (health first, then shared), and no retry — an earlier version of this docstring
|
|
431
630
|
promised a fallback call that the code never made (round 2 caught the prose, not
|
|
432
631
|
the code, lying). No retry is needed: an older `dz` does not REJECT `--domain`, it
|
|
433
632
|
ignores the flag and exits 0 with unranked results, so the single call already
|
|
@@ -449,11 +648,39 @@ def recall(query: str, limit: int = 5) -> str:
|
|
|
449
648
|
# A guard that refuses safe input teaches people to work around the guard.
|
|
450
649
|
return "refusing a query that starts with '--': it would be read as an option by dz, not as text"
|
|
451
650
|
|
|
452
|
-
|
|
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])
|
|
453
659
|
if code == 127:
|
|
454
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.
|
|
455
673
|
if code != 0:
|
|
456
|
-
|
|
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)
|
|
457
684
|
|
|
458
685
|
# CAPABILITY, not exit code (Codex QE #3 — the sharpest finding of the round).
|
|
459
686
|
# The PREVIOUS dz did not reject `--domain`: its parser accepted any `--key value`
|
|
@@ -461,7 +688,11 @@ def recall(query: str, limit: int = 5) -> str:
|
|
|
461
688
|
# error-code test could never fire, and my own test had FABRICATED the failure it
|
|
462
689
|
# was checking — modelling a version that never existed. The observable difference
|
|
463
690
|
# is the boost note the new CLI prints; its absence is what "too old" looks like.
|
|
464
|
-
|
|
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):
|
|
465
696
|
return out.rstrip() + (
|
|
466
697
|
"\n note: this dz ranked WITHOUT the domain boost (the installed CLI predates "
|
|
467
698
|
"`dz recall --domain`, which ignores the flag silently rather than failing) — results "
|
|
@@ -470,24 +701,379 @@ def recall(query: str, limit: int = 5) -> str:
|
|
|
470
701
|
return out.rstrip() or "no prior lessons matched — this is new ground"
|
|
471
702
|
|
|
472
703
|
|
|
473
|
-
|
|
474
|
-
|
|
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
|
+
"""
|
|
475
1010
|
if lesson.startswith("--"):
|
|
476
1011
|
return 1, "refusing a lesson that starts with '--': dz would read it as an option, not as text"
|
|
477
|
-
verdict = check_lesson(lesson
|
|
1012
|
+
verdict = check_lesson(lesson)
|
|
478
1013
|
if not verdict.ok:
|
|
479
|
-
return 1, verdict.note
|
|
480
|
-
|
|
481
|
-
|
|
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."
|
|
482
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)
|
|
483
1038
|
code, out, err = _run_dz([
|
|
484
|
-
"teach", lesson, "--reward", str(float(reward)), "--domain", LEARNING_DOMAIN,
|
|
1039
|
+
"teach", lesson, "--reward", str(float(reward)), "--domain", LEARNING_DOMAIN,
|
|
1040
|
+
"--type", "lesson-learned", "--project", brain,
|
|
485
1041
|
])
|
|
486
1042
|
if code == 127:
|
|
487
1043
|
return 0, err # not an error: the package works without dz
|
|
488
1044
|
if code != 0:
|
|
489
1045
|
return 1, f"teach failed: {err.strip() or out.strip()}"
|
|
490
|
-
|
|
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}"
|
|
491
1077
|
|
|
492
1078
|
|
|
493
1079
|
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
@@ -500,11 +1086,12 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
|
500
1086
|
p_teach = sub.add_parser("teach")
|
|
501
1087
|
p_teach.add_argument("lesson")
|
|
502
1088
|
p_teach.add_argument("--reward", type=float, default=0.8)
|
|
503
|
-
p_teach.add_argument("--
|
|
504
|
-
help="
|
|
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.")
|
|
505
1093
|
p_check = sub.add_parser("check")
|
|
506
1094
|
p_check.add_argument("lesson")
|
|
507
|
-
p_check.add_argument("--allow-numbers", action="store_true")
|
|
508
1095
|
args = parser.parse_args(argv)
|
|
509
1096
|
|
|
510
1097
|
if args.cmd == "status":
|
|
@@ -514,10 +1101,10 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
|
514
1101
|
print(recall(args.query, args.limit))
|
|
515
1102
|
return 0
|
|
516
1103
|
if args.cmd == "check":
|
|
517
|
-
verdict = check_lesson(args.lesson
|
|
1104
|
+
verdict = check_lesson(args.lesson)
|
|
518
1105
|
print(verdict.note)
|
|
519
1106
|
return 0 if verdict.ok else 1
|
|
520
|
-
code, message = teach(args.lesson, args.reward,
|
|
1107
|
+
code, message = teach(args.lesson, args.reward, confirmed=args.confirm_method)
|
|
521
1108
|
print(message)
|
|
522
1109
|
return code
|
|
523
1110
|
|