agent-bios 0.9.4 → 0.9.5
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/claude/CLAUDE.md +1 -1
- package/claude/guides/learning-flow.md +2 -2
- package/claude/settings.json +60 -0
- package/codex/AGENTS.md +1 -1
- package/codex/guides/learning-flow.md +2 -2
- package/package.json +8 -2
- package/scripts/assemble.py +322 -0
- package/scripts/build-promotions.py +183 -0
- package/scripts/canary.sh +28 -0
- package/scripts/check-domains.py +242 -0
- package/scripts/ingest-learnings-export.py +258 -0
- package/scripts/install.sh +28 -0
package/claude/CLAUDE.md
CHANGED
|
@@ -150,4 +150,4 @@
|
|
|
150
150
|
|
|
151
151
|
## Session Learning
|
|
152
152
|
|
|
153
|
-
- `learn!` — session learning: when the user enters `learn!` in ANY session, capture durable lessons from THIS session for their own next session and the org. Read `${CLAUDE_CONFIG_DIR:-$HOME/.claude}/guides/learning-flow.md` and run its flow (admission bar → type A–G → intended consumption layer → the user's explicit approval), submitting approved learnings ONLY through `
|
|
153
|
+
- `learn!` — session learning: when the user enters `learn!` in ANY session, capture durable lessons from THIS session for their own next session and the org. Read `${CLAUDE_CONFIG_DIR:-$HOME/.claude}/guides/learning-flow.md` and run its flow (admission bar → type A–G → intended consumption layer → the user's explicit approval), submitting approved learnings ONLY through `agent-bios learn`, which owns the id/timestamp and writes both the prose and the JSON record. When a Session distill preset run (trigger `distill!`) is active, that mission owns capture — do not also run this; and never manufacture Type-G principles user-side (curator-only).
|
|
@@ -9,7 +9,7 @@ use_when:
|
|
|
9
9
|
core_rules:
|
|
10
10
|
- three gates BEFORE asking the user — admission bar, type, consumption layer
|
|
11
11
|
- the user approves every recorded learning; nothing is written without it
|
|
12
|
-
- submit ONLY through
|
|
12
|
+
- submit ONLY through `agent-bios learn`; never hand-write the record
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
# Session learning flow (`learn!`)
|
|
@@ -83,7 +83,7 @@ match your host):
|
|
|
83
83
|
echo '{"lesson":"…","domain":"builder-base","supporting_sessions":["<tool>:<session-short-id>"],
|
|
84
84
|
"criteria":["recurrent_error"],
|
|
85
85
|
"classification":{"type":"B","layer":"hook","meets_bar":true}}' \
|
|
86
|
-
|
|
|
86
|
+
| agent-bios learn --host <claude|codex>
|
|
87
87
|
|
|
88
88
|
The script (capability boundary) owns `learning_id` / `created` / `schema_version`,
|
|
89
89
|
validates against `config/learning.schema.json`, logs the JSON record, and writes
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"Stop": [
|
|
4
|
+
{
|
|
5
|
+
"matcher": ".*",
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"type": "command",
|
|
9
|
+
"command": "/opt/homebrew/bin/node /Users/kangmin/.claude/hooks/collect-session.js"
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"SessionEnd": [
|
|
15
|
+
{
|
|
16
|
+
"matcher": ".*",
|
|
17
|
+
"hooks": [
|
|
18
|
+
{
|
|
19
|
+
"type": "command",
|
|
20
|
+
"command": "/opt/homebrew/bin/node /Users/kangmin/.claude/hooks/collect-session.js"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
"PostToolUse": [
|
|
26
|
+
{
|
|
27
|
+
"matcher": ".*",
|
|
28
|
+
"hooks": [
|
|
29
|
+
{
|
|
30
|
+
"type": "command",
|
|
31
|
+
"command": "/opt/homebrew/bin/node /Users/kangmin/.claude/hooks/collect-session.js"
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
"PostToolUseFailure": [
|
|
37
|
+
{
|
|
38
|
+
"matcher": ".*",
|
|
39
|
+
"hooks": [
|
|
40
|
+
{
|
|
41
|
+
"type": "command",
|
|
42
|
+
"command": "/opt/homebrew/bin/node /Users/kangmin/.claude/hooks/collect-session.js"
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
],
|
|
47
|
+
"PreToolUse": [
|
|
48
|
+
{
|
|
49
|
+
"matcher": "Bash",
|
|
50
|
+
"hooks": [
|
|
51
|
+
{
|
|
52
|
+
"type": "command",
|
|
53
|
+
"command": "python3 /Users/kangmin/.claude/hooks/tooling-gotchas-hook.py"
|
|
54
|
+
}
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
]
|
|
58
|
+
},
|
|
59
|
+
"remoteControlAtStartup": false
|
|
60
|
+
}
|
package/codex/AGENTS.md
CHANGED
|
@@ -151,4 +151,4 @@
|
|
|
151
151
|
|
|
152
152
|
## Session Learning
|
|
153
153
|
|
|
154
|
-
- `learn!` — session learning: when the user enters `learn!` in ANY session, capture durable lessons from THIS session for their own next session and the org. Read `${CODEX_HOME:-$HOME/.codex}/guides/learning-flow.md` and run its flow (admission bar → type A–G → intended consumption layer → the user's explicit approval), submitting approved learnings ONLY through `
|
|
154
|
+
- `learn!` — session learning: when the user enters `learn!` in ANY session, capture durable lessons from THIS session for their own next session and the org. Read `${CODEX_HOME:-$HOME/.codex}/guides/learning-flow.md` and run its flow (admission bar → type A–G → intended consumption layer → the user's explicit approval), submitting approved learnings ONLY through `agent-bios learn`, which owns the id/timestamp and writes both the prose and the JSON record. When a Session distill preset run (trigger `distill!`) is active, that mission owns capture — do not also run this; and never manufacture Type-G principles user-side (curator-only).
|
|
@@ -9,7 +9,7 @@ use_when:
|
|
|
9
9
|
core_rules:
|
|
10
10
|
- three gates BEFORE asking the user — admission bar, type, consumption layer
|
|
11
11
|
- the user approves every recorded learning; nothing is written without it
|
|
12
|
-
- submit ONLY through
|
|
12
|
+
- submit ONLY through `agent-bios learn`; never hand-write the record
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
# Session learning flow (`learn!`)
|
|
@@ -83,7 +83,7 @@ match your host):
|
|
|
83
83
|
echo '{"lesson":"…","domain":"builder-base","supporting_sessions":["<tool>:<session-short-id>"],
|
|
84
84
|
"criteria":["recurrent_error"],
|
|
85
85
|
"classification":{"type":"B","layer":"hook","meets_bar":true}}' \
|
|
86
|
-
|
|
|
86
|
+
| agent-bios learn --host <claude|codex>
|
|
87
87
|
|
|
88
88
|
The script (capability boundary) owns `learning_id` / `created` / `schema_version`,
|
|
89
89
|
validates against `config/learning.schema.json`, logs the JSON record, and writes
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-bios",
|
|
3
|
-
"version": "0.9.
|
|
4
|
-
"releaseDate": "2026-07-
|
|
3
|
+
"version": "0.9.5",
|
|
4
|
+
"releaseDate": "2026-07-26",
|
|
5
5
|
"description": "A thin, low-level instruction layer for LLM CLI agents: one set of principles and behavior whichever model you run. Deploys into $HOME by copy via an explicit `agent-bios install`.",
|
|
6
6
|
"bin": {
|
|
7
7
|
"agent-bios": "scripts/install.sh"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"claude/CLAUDE.md",
|
|
11
|
+
"claude/settings.json",
|
|
11
12
|
"claude/guides/",
|
|
12
13
|
"claude/hooks/",
|
|
13
14
|
"claude/agents/",
|
|
@@ -21,6 +22,11 @@
|
|
|
21
22
|
"config/promotions.json",
|
|
22
23
|
"shell/agent-launch.zsh",
|
|
23
24
|
"scripts/agent-launch.py",
|
|
25
|
+
"scripts/assemble.py",
|
|
26
|
+
"scripts/check-domains.py",
|
|
27
|
+
"scripts/canary.sh",
|
|
28
|
+
"scripts/build-promotions.py",
|
|
29
|
+
"scripts/ingest-learnings-export.py",
|
|
24
30
|
"scripts/check-parity.sh",
|
|
25
31
|
"scripts/check-prompting-targets.sh",
|
|
26
32
|
"scripts/check-learning.py",
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Per-selection assembler: canonical corpus + domains.json -> deployed trees.
|
|
3
|
+
|
|
4
|
+
Claude side (central-managed tree, overwritten on every run):
|
|
5
|
+
<claude-dir>/central/bundle.md core+infra+selected-domain bullets, the
|
|
6
|
+
monolith's section structure preserved,
|
|
7
|
+
guide references rewritten to central/guides/
|
|
8
|
+
<claude-dir>/central/guides|hooks|agents/ audience-filtered copies
|
|
9
|
+
<claude-dir>/settings.json merge, not overwrite: entries whose hook
|
|
10
|
+
command lives under central/hooks/ are
|
|
11
|
+
central-owned (path IS the ownership
|
|
12
|
+
marker); user-owned entries untouched
|
|
13
|
+
Entry file (<claude-dir>/CLAUDE.md) is personal-owned: seeded once when absent
|
|
14
|
+
(or when it byte-matches the legacy deployed monolith), read-checked otherwise
|
|
15
|
+
— never rewritten. Exit 2 = entry file needs user action.
|
|
16
|
+
|
|
17
|
+
Codex side: AGENTS.md central region between markers is replaced; text outside
|
|
18
|
+
the markers is preserved (weaker-guarantee realization; AGENTS.md has no
|
|
19
|
+
imports). Codex guides deploy filtered to <codex-dir>/guides as today.
|
|
20
|
+
|
|
21
|
+
env-personal is never assembled. The domains gate must be green first.
|
|
22
|
+
"""
|
|
23
|
+
import argparse
|
|
24
|
+
import json
|
|
25
|
+
import pathlib
|
|
26
|
+
import re
|
|
27
|
+
import shutil
|
|
28
|
+
import subprocess
|
|
29
|
+
import sys
|
|
30
|
+
import time
|
|
31
|
+
|
|
32
|
+
REPO = pathlib.Path(__file__).resolve().parent.parent
|
|
33
|
+
MARK_START = "<!-- agent-bios:central:start -->"
|
|
34
|
+
MARK_END = "<!-- agent-bios:central:end -->"
|
|
35
|
+
IMPORT_LINE = "@central/bundle.md"
|
|
36
|
+
PERSONAL_IMPORT_LINE = "@personal/learnings.md"
|
|
37
|
+
CLAUDE_VAR = "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
|
|
38
|
+
CODEX_VAR = "${CODEX_HOME:-$HOME/.codex}"
|
|
39
|
+
CODEX_ONLY_PREFIX = "- Codex-only standing authorization:"
|
|
40
|
+
|
|
41
|
+
ENTRY_SEED = f"""# CLAUDE.md
|
|
42
|
+
|
|
43
|
+
{IMPORT_LINE}
|
|
44
|
+
{PERSONAL_IMPORT_LINE}
|
|
45
|
+
|
|
46
|
+
## Personal
|
|
47
|
+
<!-- Yours. The installer never rewrites this file after seeding. The first
|
|
48
|
+
import pulls in the centrally managed bundle; the second pulls in your
|
|
49
|
+
session learning file ({CLAUDE_VAR}/personal/learnings.md — automation-owned,
|
|
50
|
+
written by `learn!`). Add your own personal rules below. -->
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
# Automation-owned personal learnings file, pulled in by PERSONAL_IMPORT_LINE.
|
|
54
|
+
# Kept in sync with scripts/collect-learning.py (the light-flow submit tool),
|
|
55
|
+
# which appends learnings here; seeding it keeps the import from dangling.
|
|
56
|
+
PERSONAL_LEARNINGS_HEADER = """# Personal learnings
|
|
57
|
+
|
|
58
|
+
<!-- Automation-owned: written by the session learning flow (`learn!`,
|
|
59
|
+
scripts/collect-learning.py). Do NOT hand-edit — promote→migrate clears
|
|
60
|
+
applied items by learning_id when the org redistributes them. Your own
|
|
61
|
+
personal rules belong in the entry CLAUDE.md '## Personal' section, never
|
|
62
|
+
here. This file is pulled into context by the entry file's
|
|
63
|
+
`@personal/learnings.md` import. -->
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def die(msg, code=1):
|
|
68
|
+
print(f"assemble: {msg}", file=sys.stderr)
|
|
69
|
+
sys.exit(code)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def audience(entry):
|
|
73
|
+
tier = entry.get("tier")
|
|
74
|
+
if tier in ("core", "infra"):
|
|
75
|
+
return "UNIVERSAL"
|
|
76
|
+
if tier == "domain":
|
|
77
|
+
return frozenset(entry.get("domains", []))
|
|
78
|
+
return "NEVER" # env-personal
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def kept(entry, selection):
|
|
82
|
+
aud = audience(entry)
|
|
83
|
+
return aud == "UNIVERSAL" or (isinstance(aud, frozenset) and aud & selection)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def parse_monolith(text):
|
|
87
|
+
"""-> (title, [(header, [bullets])])"""
|
|
88
|
+
title, sections, cur = None, [], None
|
|
89
|
+
for ln in text.splitlines():
|
|
90
|
+
if ln.startswith("# ") and title is None:
|
|
91
|
+
title = ln
|
|
92
|
+
elif ln.startswith("## "):
|
|
93
|
+
cur = (ln, [])
|
|
94
|
+
sections.append(cur)
|
|
95
|
+
elif ln.startswith("- "):
|
|
96
|
+
if cur is None:
|
|
97
|
+
die("bullet before first section header")
|
|
98
|
+
cur[1].append(ln)
|
|
99
|
+
return title, sections
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def build_bundle(monolith_text, manifest, selection, tool):
|
|
103
|
+
entry_of = {}
|
|
104
|
+
for e in manifest["bullets"]:
|
|
105
|
+
entry_of[e["anchor"]] = e
|
|
106
|
+
title, sections = parse_monolith(monolith_text)
|
|
107
|
+
out, kept_anchors = [], set()
|
|
108
|
+
out.append(title if tool == "claude" else "# AGENTS.md")
|
|
109
|
+
for header, bullets in sections:
|
|
110
|
+
keep = []
|
|
111
|
+
for b in bullets:
|
|
112
|
+
matches = [a for a in entry_of if a in b]
|
|
113
|
+
if len(matches) != 1:
|
|
114
|
+
die(f"bullet has {len(matches)} manifest anchors (gate should have failed): {b[:60]!r}")
|
|
115
|
+
if kept(entry_of[matches[0]], selection):
|
|
116
|
+
keep.append(b)
|
|
117
|
+
kept_anchors.add(matches[0])
|
|
118
|
+
if keep:
|
|
119
|
+
out.extend(["", header, ""])
|
|
120
|
+
out.extend(keep)
|
|
121
|
+
text = "\n".join(out) + "\n"
|
|
122
|
+
if tool == "claude":
|
|
123
|
+
text = text.replace(f"{CLAUDE_VAR}/guides/", f"{CLAUDE_VAR}/central/guides/")
|
|
124
|
+
# Canary marker: a plain, verbatim-quotable line the activation canary
|
|
125
|
+
# asks a live session to echo back — proves the bundle entered context.
|
|
126
|
+
import hashlib
|
|
127
|
+
rev = hashlib.sha256(text.encode("utf-8")).hexdigest()[:8]
|
|
128
|
+
text += f"\nagent-bios-bundle-rev: {rev}\n"
|
|
129
|
+
else:
|
|
130
|
+
text = text.replace(CLAUDE_VAR, CODEX_VAR)
|
|
131
|
+
expected = {e["anchor"] for e in manifest["bullets"] if kept(e, selection)}
|
|
132
|
+
if kept_anchors != expected:
|
|
133
|
+
die(f"extracted set != manifest expectation ({len(kept_anchors)} vs {len(expected)}): "
|
|
134
|
+
f"missing={sorted(expected - kept_anchors)[:3]} extra={sorted(kept_anchors - expected)[:3]}")
|
|
135
|
+
return text, len(kept_anchors)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def filtered_files(manifest, key, selection):
|
|
139
|
+
return sorted(n for n, e in manifest.get(key, {}).items() if kept(e, selection))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def copy_filtered(src_dir, names, dest, rewrite=None, dry=False):
|
|
143
|
+
if not dry:
|
|
144
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
for n in names:
|
|
146
|
+
if dry:
|
|
147
|
+
print(f" [dry] copy {n} -> {dest}")
|
|
148
|
+
continue
|
|
149
|
+
body = (src_dir / n).read_text(encoding="utf-8")
|
|
150
|
+
if rewrite:
|
|
151
|
+
body = body.replace(*rewrite)
|
|
152
|
+
(dest / n).write_text(body, encoding="utf-8")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def merge_settings(claude_dir, hook_names, template_path, dry=False):
|
|
156
|
+
"""Path-marker merge: central-owned hook entries reference central/hooks/."""
|
|
157
|
+
marker = "/central/hooks/"
|
|
158
|
+
spath = claude_dir / "settings.json"
|
|
159
|
+
settings = json.loads(spath.read_text(encoding="utf-8")) if spath.exists() else {}
|
|
160
|
+
template = json.loads(template_path.read_text(encoding="utf-8")) if template_path.exists() else {}
|
|
161
|
+
hooks = settings.setdefault("hooks", {})
|
|
162
|
+
for event, entries in list(hooks.items()): # drop stale central-owned
|
|
163
|
+
hooks[event] = [en for en in entries
|
|
164
|
+
if not any(marker in h.get("command", "") for h in en.get("hooks", []))]
|
|
165
|
+
for event, entries in template.get("hooks", {}).items(): # re-add per selection
|
|
166
|
+
for en in entries:
|
|
167
|
+
cmds = [h.get("command", "") for h in en.get("hooks", [])]
|
|
168
|
+
owner = next((n for n in hook_names if any(c.endswith("/hooks/" + n) or f"/hooks/{n} " in c + " " for c in cmds)), None)
|
|
169
|
+
if owner is None:
|
|
170
|
+
continue
|
|
171
|
+
clone = json.loads(json.dumps(en))
|
|
172
|
+
for h in clone.get("hooks", []):
|
|
173
|
+
h["command"] = re.sub(r"\S*/hooks/" + re.escape(owner),
|
|
174
|
+
str(claude_dir / "central" / "hooks" / owner),
|
|
175
|
+
h["command"])
|
|
176
|
+
hooks.setdefault(event, []).append(clone)
|
|
177
|
+
if dry:
|
|
178
|
+
print(f" [dry] merge settings.json ({len(hook_names)} central hooks)")
|
|
179
|
+
return
|
|
180
|
+
if spath.exists():
|
|
181
|
+
shutil.copy2(spath, spath.with_suffix(f".json.bak-{time.strftime('%Y%m%d-%H%M%S')}"))
|
|
182
|
+
spath.write_text(json.dumps(settings, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def seed_personal_learnings(claude_dir, dry=False):
|
|
186
|
+
"""Create the automation-owned personal learnings file when seeding the entry,
|
|
187
|
+
so the entry's @personal/learnings.md import always resolves before the first
|
|
188
|
+
learn!. scripts/collect-learning.py appends to it thereafter."""
|
|
189
|
+
md = claude_dir / "personal" / "learnings.md"
|
|
190
|
+
if md.exists():
|
|
191
|
+
return
|
|
192
|
+
if dry:
|
|
193
|
+
print(" [dry] seed personal/learnings.md")
|
|
194
|
+
return
|
|
195
|
+
md.parent.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
md.write_text(PERSONAL_LEARNINGS_HEADER, encoding="utf-8")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def seed_entry(claude_dir, legacy_monolith, dry=False):
|
|
200
|
+
entry = claude_dir / "CLAUDE.md"
|
|
201
|
+
if not entry.exists():
|
|
202
|
+
if dry:
|
|
203
|
+
print(" [dry] seed entry CLAUDE.md")
|
|
204
|
+
seed_personal_learnings(claude_dir, dry)
|
|
205
|
+
return "seeded"
|
|
206
|
+
claude_dir.mkdir(parents=True, exist_ok=True)
|
|
207
|
+
entry.write_text(ENTRY_SEED, encoding="utf-8")
|
|
208
|
+
seed_personal_learnings(claude_dir, dry)
|
|
209
|
+
return "seeded"
|
|
210
|
+
body = entry.read_text(encoding="utf-8")
|
|
211
|
+
if IMPORT_LINE in body:
|
|
212
|
+
return "ok"
|
|
213
|
+
if body == legacy_monolith: # untouched legacy deploy squatting on the path
|
|
214
|
+
if dry:
|
|
215
|
+
print(" [dry] replace legacy deployed CLAUDE.md with seed (backup)")
|
|
216
|
+
seed_personal_learnings(claude_dir, dry)
|
|
217
|
+
return "seeded"
|
|
218
|
+
shutil.copy2(entry, entry.with_suffix(f".md.bak-legacy-{time.strftime('%Y%m%d-%H%M%S')}"))
|
|
219
|
+
entry.write_text(ENTRY_SEED, encoding="utf-8")
|
|
220
|
+
seed_personal_learnings(claude_dir, dry)
|
|
221
|
+
return "seeded"
|
|
222
|
+
return "needs-action" # user content without the import line: report, never rewrite
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def merge_codex(codex_dir, central_text, dry=False):
|
|
226
|
+
agents = codex_dir / "AGENTS.md"
|
|
227
|
+
region = f"{MARK_START}\n{central_text}{MARK_END}\n"
|
|
228
|
+
if agents.exists():
|
|
229
|
+
body = agents.read_text(encoding="utf-8")
|
|
230
|
+
if MARK_START in body and MARK_END in body:
|
|
231
|
+
pre, rest = body.split(MARK_START, 1)
|
|
232
|
+
_, post = rest.split(MARK_END, 1)
|
|
233
|
+
new = pre + region + post
|
|
234
|
+
else: # legacy whole-file deploy: back up, adopt markers, keep nothing implicit
|
|
235
|
+
if not dry:
|
|
236
|
+
shutil.copy2(agents, agents.with_suffix(f".md.bak-legacy-{time.strftime('%Y%m%d-%H%M%S')}"))
|
|
237
|
+
new = region + "\n## Personal\n"
|
|
238
|
+
else:
|
|
239
|
+
new = region + "\n## Personal\n"
|
|
240
|
+
if dry:
|
|
241
|
+
print(f" [dry] write AGENTS.md central region ({len(central_text)} bytes)")
|
|
242
|
+
return
|
|
243
|
+
codex_dir.mkdir(parents=True, exist_ok=True)
|
|
244
|
+
agents.write_text(new, encoding="utf-8")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def main():
|
|
248
|
+
ap = argparse.ArgumentParser()
|
|
249
|
+
ap.add_argument("--domains", help="comma-separated selection; overrides selection.json")
|
|
250
|
+
ap.add_argument("--claude-dir", default=None)
|
|
251
|
+
ap.add_argument("--codex-dir", default=None)
|
|
252
|
+
ap.add_argument("--state-dir", default=None)
|
|
253
|
+
ap.add_argument("--dry-run", action="store_true")
|
|
254
|
+
args = ap.parse_args()
|
|
255
|
+
|
|
256
|
+
import os
|
|
257
|
+
claude_dir = pathlib.Path(args.claude_dir or os.environ.get("CLAUDE_CONFIG_DIR") or pathlib.Path.home() / ".claude")
|
|
258
|
+
codex_dir = pathlib.Path(args.codex_dir or os.environ.get("CODEX_HOME") or pathlib.Path.home() / ".codex")
|
|
259
|
+
state_dir = pathlib.Path(args.state_dir or pathlib.Path.home() / ".local/share/agent-bios")
|
|
260
|
+
|
|
261
|
+
gate = subprocess.run([sys.executable, str(REPO / "scripts" / "check-domains.py")],
|
|
262
|
+
capture_output=True, text=True)
|
|
263
|
+
if gate.returncode != 0:
|
|
264
|
+
die("domains gate FAILED — fix manifest/corpus first:\n" + gate.stdout + gate.stderr)
|
|
265
|
+
|
|
266
|
+
manifest = json.loads((REPO / "config" / "domains.json").read_text(encoding="utf-8"))
|
|
267
|
+
if args.domains is not None:
|
|
268
|
+
selection = frozenset(d for d in args.domains.split(",") if d)
|
|
269
|
+
else:
|
|
270
|
+
sel_file = state_dir / "selection.json"
|
|
271
|
+
if not sel_file.exists():
|
|
272
|
+
die(f"no --domains and no {sel_file}; run onboarding or pass --domains")
|
|
273
|
+
selection = frozenset(json.loads(sel_file.read_text(encoding="utf-8"))["domains"])
|
|
274
|
+
unknown = selection - set(manifest["domains"])
|
|
275
|
+
if unknown:
|
|
276
|
+
die(f"unknown domains: {sorted(unknown)} (known: {sorted(manifest['domains'])})")
|
|
277
|
+
|
|
278
|
+
monolith = (REPO / "claude" / "CLAUDE.md").read_text(encoding="utf-8")
|
|
279
|
+
bundle, n_bullets = build_bundle(monolith, manifest, selection, "claude")
|
|
280
|
+
codex_src = (REPO / "codex" / "AGENTS.md").read_text(encoding="utf-8")
|
|
281
|
+
codex_bundle, _ = build_bundle(monolith, manifest, selection, "codex")
|
|
282
|
+
if "multi-agent-orchestration" in selection:
|
|
283
|
+
codex_only = next((ln for ln in codex_src.splitlines() if ln.startswith(CODEX_ONLY_PREFIX)), None)
|
|
284
|
+
if codex_only:
|
|
285
|
+
codex_bundle += codex_only + "\n"
|
|
286
|
+
|
|
287
|
+
guides = filtered_files(manifest, "guides", selection)
|
|
288
|
+
hooks = filtered_files(manifest, "hooks", selection)
|
|
289
|
+
agents = filtered_files(manifest, "agents", selection)
|
|
290
|
+
dry = args.dry_run
|
|
291
|
+
|
|
292
|
+
central = claude_dir / "central"
|
|
293
|
+
if dry:
|
|
294
|
+
print(f"[dry] bundle.md: {n_bullets} bullets; guides={guides} hooks={hooks} agents={agents}")
|
|
295
|
+
else:
|
|
296
|
+
central.mkdir(parents=True, exist_ok=True)
|
|
297
|
+
(central / "bundle.md").write_text(bundle, encoding="utf-8")
|
|
298
|
+
copy_filtered(REPO / "claude" / "guides", guides, central / "guides",
|
|
299
|
+
rewrite=(f"{CLAUDE_VAR}/guides/", f"{CLAUDE_VAR}/central/guides/"), dry=dry)
|
|
300
|
+
copy_filtered(REPO / "claude" / "hooks", hooks, central / "hooks", dry=dry)
|
|
301
|
+
copy_filtered(REPO / "claude" / "agents", agents, central / "agents", dry=dry)
|
|
302
|
+
merge_settings(claude_dir, hooks, REPO / "claude" / "settings.json", dry=dry)
|
|
303
|
+
entry_state = seed_entry(claude_dir, monolith, dry=dry)
|
|
304
|
+
|
|
305
|
+
merge_codex(codex_dir, codex_bundle, dry=dry)
|
|
306
|
+
copy_filtered(REPO / "codex" / "guides", guides, codex_dir / "guides", dry=dry)
|
|
307
|
+
|
|
308
|
+
if not dry:
|
|
309
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
310
|
+
(state_dir / "selection.json").write_text(
|
|
311
|
+
json.dumps({"version": 1, "domains": sorted(selection)}, indent=2) + "\n", encoding="utf-8")
|
|
312
|
+
|
|
313
|
+
print(f"ASSEMBLED: {n_bullets} bullets, {len(guides)} guides, {len(hooks)} hooks, "
|
|
314
|
+
f"{len(agents)} agents for selection {sorted(selection)}; entry={entry_state}")
|
|
315
|
+
if entry_state == "needs-action":
|
|
316
|
+
print(f"ACTION NEEDED: {claude_dir / 'CLAUDE.md'} is user-owned and lacks '{IMPORT_LINE}' — "
|
|
317
|
+
"add the import line manually; the installer will not rewrite your file.")
|
|
318
|
+
sys.exit(2)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
if __name__ == "__main__":
|
|
322
|
+
main()
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Derive the promotion manifest from the ledger (collection loop, Phase 4).
|
|
3
|
+
|
|
4
|
+
`config/promotions.json` names the personal learnings that have been PROMOTED
|
|
5
|
+
into the shared corpus, so the user-side migrate rule can clear the now-absorbed
|
|
6
|
+
personal copy (scripts/migrate-learnings.py). The manifest is DERIVED — never
|
|
7
|
+
hand-edited: a promoted user learning is a ledger entry with `status == placed`
|
|
8
|
+
AND a `learning_id` (the Phase 3 dedup key kept on merge). Session-distill
|
|
9
|
+
entries have no `learning_id`, so they are never in the manifest.
|
|
10
|
+
|
|
11
|
+
F3 hardening (the one silent-loss path in the safety design): the audience a
|
|
12
|
+
promotion belongs to (which users' bundles carry it) is derived from where the
|
|
13
|
+
bullet ACTUALLY landed — the placed entry's `placed_anchor`, resolved against
|
|
14
|
+
`config/domains.json` (the placement source of truth) — NOT the ledger's free
|
|
15
|
+
`domain` tag. A placed+learning_id entry with a missing or unresolvable
|
|
16
|
+
`placed_anchor` FAILS the build/--check, so a promotion can neither ship with a
|
|
17
|
+
wrong audience nor ship without actually landing in the corpus.
|
|
18
|
+
|
|
19
|
+
Manifest shape (no PII — learning_id + the derived audience):
|
|
20
|
+
{ "version": 1, "promotions": [
|
|
21
|
+
{ "learning_id": "<uuid>", "tier": "core|infra|domain", "domains": ["<key>", ...] } ] }
|
|
22
|
+
(`domains` is empty for the universal tiers core/infra.)
|
|
23
|
+
|
|
24
|
+
Release step: regenerate before a push. `--check` fails if the on-disk manifest
|
|
25
|
+
is stale vs the ledger (a check-parity gate). `--self-test` runs the derivation.
|
|
26
|
+
"""
|
|
27
|
+
import argparse
|
|
28
|
+
import json
|
|
29
|
+
import pathlib
|
|
30
|
+
import sys
|
|
31
|
+
|
|
32
|
+
REPO = pathlib.Path(__file__).resolve().parent.parent
|
|
33
|
+
LEDGER = REPO / "design" / "session-distill" / "ledger.json"
|
|
34
|
+
DOMAINS = REPO / "config" / "domains.json"
|
|
35
|
+
MANIFEST = REPO / "config" / "promotions.json"
|
|
36
|
+
FIXTURE = REPO / "design" / "collection-loop" / "fixtures" / "ledger-promote-sample.json"
|
|
37
|
+
MANIFEST_VERSION = 1
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ManifestError(Exception):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def resolve_audience(anchor, domains_manifest):
|
|
45
|
+
"""(tier, [domains]) for a placed anchor, or None if it does not resolve.
|
|
46
|
+
Bullets match by their `anchor` field; a whole guide/hook/agent matches by
|
|
47
|
+
key. This is the authoritative placement audience (mirrors what assemble.py
|
|
48
|
+
reads to decide who gets the bullet)."""
|
|
49
|
+
for b in domains_manifest.get("bullets", []):
|
|
50
|
+
if b.get("anchor") == anchor:
|
|
51
|
+
return b.get("tier"), list(b.get("domains", []))
|
|
52
|
+
for kind in ("guides", "hooks", "agents"):
|
|
53
|
+
entry = domains_manifest.get(kind, {}).get(anchor)
|
|
54
|
+
if entry is not None:
|
|
55
|
+
return entry.get("tier"), list(entry.get("domains", []))
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_manifest(ledger, domains_manifest):
|
|
60
|
+
"""Ledger + domains manifest -> the promotion manifest. Raises ManifestError
|
|
61
|
+
on a placed+learning_id entry whose placement can't be verified (missing or
|
|
62
|
+
unresolvable placed_anchor) — the F3 silent-loss guard. Sorted by
|
|
63
|
+
learning_id for a stable, deterministic artifact."""
|
|
64
|
+
promotions = []
|
|
65
|
+
for e in ledger.get("entries", []):
|
|
66
|
+
if not isinstance(e, dict):
|
|
67
|
+
continue
|
|
68
|
+
lid = e.get("learning_id")
|
|
69
|
+
if e.get("status") != "placed" or not (isinstance(lid, str) and lid):
|
|
70
|
+
continue
|
|
71
|
+
anchor = e.get("placed_anchor")
|
|
72
|
+
if not anchor:
|
|
73
|
+
raise ManifestError(
|
|
74
|
+
f"placed learning {e.get('id')!r} ({lid}) has no `placed_anchor` — "
|
|
75
|
+
"record where the bullet landed (a config/domains.json anchor/key) "
|
|
76
|
+
"before promoting")
|
|
77
|
+
aud = resolve_audience(anchor, domains_manifest)
|
|
78
|
+
if aud is None:
|
|
79
|
+
raise ManifestError(
|
|
80
|
+
f"placed_anchor {anchor!r} (learning {lid}) does not resolve in "
|
|
81
|
+
"config/domains.json — the promotion has no verifiable placement")
|
|
82
|
+
tier, domains = aud
|
|
83
|
+
promotions.append({"learning_id": lid.lower(), "tier": tier, "domains": domains})
|
|
84
|
+
promotions.sort(key=lambda p: p["learning_id"])
|
|
85
|
+
return {"version": MANIFEST_VERSION, "promotions": promotions}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def render(manifest):
|
|
89
|
+
return json.dumps(manifest, ensure_ascii=False, indent=2) + "\n"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _self_test():
|
|
93
|
+
ledger = json.loads(FIXTURE.read_text(encoding="utf-8"))
|
|
94
|
+
# Fixture domains manifest the fixture's placed_anchors resolve against.
|
|
95
|
+
domains = {
|
|
96
|
+
"bullets": [
|
|
97
|
+
{"anchor": "universal placed rule", "tier": "core", "domains": []},
|
|
98
|
+
{"anchor": "builder placed rule", "tier": "domain", "domains": ["builder-base"]},
|
|
99
|
+
],
|
|
100
|
+
"guides": {}, "hooks": {}, "agents": {},
|
|
101
|
+
}
|
|
102
|
+
m = build_manifest(ledger, domains)
|
|
103
|
+
by = {p["learning_id"]: p for p in m["promotions"]}
|
|
104
|
+
a1 = "0f8c1c2a-4d1e-4abc-9def-0000000000a1"
|
|
105
|
+
b2 = "0f8c1c2a-4d1e-4abc-9def-0000000000b2"
|
|
106
|
+
|
|
107
|
+
checks = [
|
|
108
|
+
("only placed+learning_id promoted (cardinality > 0)", set(by) == {a1, b2}),
|
|
109
|
+
("session-distill (no learning_id) excluded",
|
|
110
|
+
all(p["learning_id"] for p in m["promotions"])),
|
|
111
|
+
("incubating excluded",
|
|
112
|
+
"0f8c1c2a-4d1e-4abc-9def-0000000000c1" not in by),
|
|
113
|
+
("audience DERIVED from the anchor, not the ledger domain tag",
|
|
114
|
+
by[a1]["tier"] == "core" and by[a1]["domains"] == []
|
|
115
|
+
and by[b2]["tier"] == "domain" and by[b2]["domains"] == ["builder-base"]),
|
|
116
|
+
("sorted + deterministic",
|
|
117
|
+
[p["learning_id"] for p in m["promotions"]] == sorted(by)
|
|
118
|
+
and render(build_manifest(ledger, domains)) == render(m)),
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
# F3 guards: missing / unresolvable placed_anchor must FAIL.
|
|
122
|
+
def raises(mut):
|
|
123
|
+
led = json.loads(FIXTURE.read_text(encoding="utf-8"))
|
|
124
|
+
mut(led)
|
|
125
|
+
try:
|
|
126
|
+
build_manifest(led, domains)
|
|
127
|
+
return False
|
|
128
|
+
except ManifestError:
|
|
129
|
+
return True
|
|
130
|
+
|
|
131
|
+
checks.append(("missing placed_anchor fails",
|
|
132
|
+
raises(lambda l: l["entries"][0].pop("placed_anchor", None))))
|
|
133
|
+
checks.append(("unresolvable placed_anchor fails",
|
|
134
|
+
raises(lambda l: l["entries"][0].__setitem__("placed_anchor", "no-such-anchor"))))
|
|
135
|
+
|
|
136
|
+
failed = [n for n, ok in checks if not ok]
|
|
137
|
+
if failed:
|
|
138
|
+
for n in failed:
|
|
139
|
+
print(f"build-promotions --self-test: FAIL: {n}", file=sys.stderr)
|
|
140
|
+
sys.exit(1)
|
|
141
|
+
print(f"build-promotions --self-test: OK ({len(checks)} manifest-derivation checks)")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _load_and_build():
|
|
145
|
+
ledger = json.loads(LEDGER.read_text(encoding="utf-8"))
|
|
146
|
+
domains = json.loads(DOMAINS.read_text(encoding="utf-8"))
|
|
147
|
+
try:
|
|
148
|
+
return render(build_manifest(ledger, domains))
|
|
149
|
+
except ManifestError as e:
|
|
150
|
+
print(f"build-promotions: {e}", file=sys.stderr)
|
|
151
|
+
sys.exit(1)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def main():
|
|
155
|
+
ap = argparse.ArgumentParser(description="Derive config/promotions.json from the ledger.")
|
|
156
|
+
ap.add_argument("--check", action="store_true",
|
|
157
|
+
help="fail if config/promotions.json is stale vs the ledger (gate)")
|
|
158
|
+
ap.add_argument("--self-test", action="store_true")
|
|
159
|
+
args = ap.parse_args()
|
|
160
|
+
|
|
161
|
+
if args.self_test:
|
|
162
|
+
_self_test()
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
want = _load_and_build()
|
|
166
|
+
|
|
167
|
+
if args.check:
|
|
168
|
+
have = MANIFEST.read_text(encoding="utf-8") if MANIFEST.is_file() else ""
|
|
169
|
+
if have != want:
|
|
170
|
+
print("build-promotions --check: config/promotions.json is STALE vs the "
|
|
171
|
+
"ledger — run `python3 scripts/build-promotions.py` and commit.",
|
|
172
|
+
file=sys.stderr)
|
|
173
|
+
sys.exit(1)
|
|
174
|
+
print("build-promotions --check: promotions.json is current with the ledger")
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
MANIFEST.write_text(want, encoding="utf-8")
|
|
178
|
+
n = want.count('"learning_id"')
|
|
179
|
+
print(f"build-promotions: wrote {MANIFEST} ({n} promotion(s))")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
if __name__ == "__main__":
|
|
183
|
+
main()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Activation canary: proves the central bundle actually LOADS in a live
|
|
3
|
+
# session — file presence cannot detect a declined @import approval or a
|
|
4
|
+
# broken entry import line, so this asks a headless session to echo the
|
|
5
|
+
# bundle's rev marker back.
|
|
6
|
+
# Exit: 0 loaded | 1 not loading (or no bundle) | 3 cannot probe (no CLI).
|
|
7
|
+
set -u
|
|
8
|
+
CLAUDE_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
|
|
9
|
+
BUNDLE="$CLAUDE_DIR/central/bundle.md"
|
|
10
|
+
|
|
11
|
+
[ -f "$BUNDLE" ] || { echo "CANARY FAIL: no bundle at $BUNDLE (run: agent-bios install --domains ...)"; exit 1; }
|
|
12
|
+
expected="$(grep -m1 '^agent-bios-bundle-rev: ' "$BUNDLE")"
|
|
13
|
+
[ -n "$expected" ] || { echo "CANARY FAIL: bundle has no rev marker (reassemble with a current assemble.py)"; exit 1; }
|
|
14
|
+
command -v claude >/dev/null 2>&1 || { echo "CANARY SKIP: claude CLI not found — cannot probe activation"; exit 3; }
|
|
15
|
+
|
|
16
|
+
probe="Somewhere in your loaded instruction context there may be a line that starts with 'agent-bios-bundle-rev:'. Reply with ONLY that line, verbatim. If no such line is in your context, reply with exactly: BUNDLE-NOT-LOADED"
|
|
17
|
+
out="$(cd "$HOME" && claude -p "$probe" 2>/dev/null)"
|
|
18
|
+
|
|
19
|
+
if printf '%s' "$out" | grep -qF "$expected"; then
|
|
20
|
+
echo "CANARY PASS: central bundle is loading ($expected)"
|
|
21
|
+
exit 0
|
|
22
|
+
fi
|
|
23
|
+
echo "CANARY FAIL: central bundle is NOT loading in live sessions."
|
|
24
|
+
echo " expected marker: $expected"
|
|
25
|
+
echo " probe replied: $(printf '%s' "$out" | head -c 200)"
|
|
26
|
+
echo " Likely causes: the CLAUDE.md import approval was declined (open a session and re-approve imports),"
|
|
27
|
+
echo " or the entry file lost its '@central/bundle.md' line. Diagnose with: agent-bios verify"
|
|
28
|
+
exit 1
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Bijection/coverage gate: config/domains.json vs the canonical corpus.
|
|
3
|
+
|
|
4
|
+
The manifest is the sole classification authority (tagged-monolith layout);
|
|
5
|
+
claude/CLAUDE.md stays the sole text authority. This gate closes the
|
|
6
|
+
silent-drop window: a bullet reworded without a manifest update, or a
|
|
7
|
+
manifest entry whose anchor no longer matches, fails HERE, before landing.
|
|
8
|
+
|
|
9
|
+
Blocking checks (any violation exits 1, all violations listed):
|
|
10
|
+
1. schema shape + registry legality (tiers/domains from the manifest's own
|
|
11
|
+
registry; domains list non-empty iff tier == "domain")
|
|
12
|
+
2. bullet bijection: every manifest anchor matches exactly ONE `- ` bullet
|
|
13
|
+
in claude/CLAUDE.md, and every bullet is claimed by exactly ONE entry
|
|
14
|
+
3. file coverage: every file in claude/guides|hooks|agents claimed exactly
|
|
15
|
+
once; every claimed file exists on disk
|
|
16
|
+
4. router-guide co-package: a bullet referencing guides/<g>.md must have an
|
|
17
|
+
audience covered by that guide's audience (universal bullet -> universal
|
|
18
|
+
guide; domain bullet -> guide covering all its domains)
|
|
19
|
+
5. hook source_guide: a hook naming its source guide must carry the same
|
|
20
|
+
tier + domain set as that guide
|
|
21
|
+
6. non-vacuity: every subject set this gate judges is non-empty, so a green
|
|
22
|
+
run cannot be vacuous
|
|
23
|
+
|
|
24
|
+
Informational (non-blocking until budgets are set in the manifest): estimated
|
|
25
|
+
token size per package.
|
|
26
|
+
|
|
27
|
+
--self-test: runs negative controls (mutated manifests that MUST fail) and
|
|
28
|
+
exits 0 only if every mutation is caught. Proves the gate can fail.
|
|
29
|
+
"""
|
|
30
|
+
import json
|
|
31
|
+
import pathlib
|
|
32
|
+
import re
|
|
33
|
+
import sys
|
|
34
|
+
|
|
35
|
+
REPO = pathlib.Path(__file__).resolve().parent.parent
|
|
36
|
+
MANIFEST = REPO / "config" / "domains.json"
|
|
37
|
+
MONOLITH = REPO / "claude" / "CLAUDE.md"
|
|
38
|
+
FILE_SECTIONS = { # manifest key -> corpus dir, glob
|
|
39
|
+
"guides": ("claude/guides", "*.md"),
|
|
40
|
+
"hooks": ("claude/hooks", "*"),
|
|
41
|
+
"agents": ("claude/agents", "*.md"),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_manifest(path=MANIFEST):
|
|
46
|
+
with open(path, encoding="utf-8") as f:
|
|
47
|
+
return json.load(f)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def corpus_bullets(path=MONOLITH):
|
|
51
|
+
text = path.read_text(encoding="utf-8")
|
|
52
|
+
return [ln for ln in text.splitlines() if ln.startswith("- ")]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def audience(entry, errors, ctx):
|
|
56
|
+
"""Universal set marker, or the frozen domain set; records legality errors."""
|
|
57
|
+
tier = entry.get("tier")
|
|
58
|
+
domains = entry.get("domains", [])
|
|
59
|
+
if tier in ("core", "infra"):
|
|
60
|
+
if domains:
|
|
61
|
+
errors.append(f"{ctx}: tier {tier} must not list domains, got {domains}")
|
|
62
|
+
return "UNIVERSAL"
|
|
63
|
+
if tier == "env-personal":
|
|
64
|
+
if domains:
|
|
65
|
+
errors.append(f"{ctx}: env-personal must not list domains")
|
|
66
|
+
return "NEVER"
|
|
67
|
+
if tier == "domain":
|
|
68
|
+
if not domains:
|
|
69
|
+
errors.append(f"{ctx}: tier domain requires >=1 domain")
|
|
70
|
+
return frozenset(domains)
|
|
71
|
+
errors.append(f"{ctx}: illegal tier {tier!r}")
|
|
72
|
+
return "NEVER"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def covers(guide_aud, bullet_aud):
|
|
76
|
+
if guide_aud == "UNIVERSAL":
|
|
77
|
+
return True
|
|
78
|
+
if bullet_aud in ("UNIVERSAL", "NEVER"):
|
|
79
|
+
return False # universal bullet needs universal guide; NEVER refs nothing
|
|
80
|
+
return isinstance(guide_aud, frozenset) and guide_aud >= bullet_aud
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def run_gate(manifest, bullets, repo=REPO):
|
|
84
|
+
errors = []
|
|
85
|
+
tiers = set(manifest.get("tiers", []))
|
|
86
|
+
domains_reg = set(manifest.get("domains", {}))
|
|
87
|
+
if not tiers or not domains_reg:
|
|
88
|
+
errors.append("registry: tiers/domains registry empty")
|
|
89
|
+
|
|
90
|
+
def check_registry(entry, ctx):
|
|
91
|
+
if entry.get("tier") not in tiers:
|
|
92
|
+
errors.append(f"{ctx}: tier {entry.get('tier')!r} not in registry")
|
|
93
|
+
for d in entry.get("domains", []):
|
|
94
|
+
if d not in domains_reg:
|
|
95
|
+
errors.append(f"{ctx}: domain {d!r} not in registry")
|
|
96
|
+
|
|
97
|
+
# -- 2. bullet bijection ------------------------------------------------
|
|
98
|
+
mb = manifest.get("bullets", [])
|
|
99
|
+
if not bullets:
|
|
100
|
+
errors.append("non-vacuity: no bullets found in monolith")
|
|
101
|
+
if not mb:
|
|
102
|
+
errors.append("non-vacuity: manifest has no bullet entries")
|
|
103
|
+
if len(mb) != len(bullets):
|
|
104
|
+
errors.append(f"bijection: manifest has {len(mb)} bullet entries, monolith has {len(bullets)} bullets")
|
|
105
|
+
claimed = [0] * len(bullets)
|
|
106
|
+
seen_anchors = set()
|
|
107
|
+
for i, entry in enumerate(mb):
|
|
108
|
+
ctx = f"bullets[{i}] anchor={entry.get('anchor', '')!r:.60}"
|
|
109
|
+
check_registry(entry, ctx)
|
|
110
|
+
anchor = entry.get("anchor", "")
|
|
111
|
+
if not anchor:
|
|
112
|
+
errors.append(f"{ctx}: empty anchor")
|
|
113
|
+
continue
|
|
114
|
+
if anchor in seen_anchors:
|
|
115
|
+
errors.append(f"{ctx}: duplicate anchor")
|
|
116
|
+
seen_anchors.add(anchor)
|
|
117
|
+
hits = [j for j, b in enumerate(bullets) if anchor in b]
|
|
118
|
+
if len(hits) != 1:
|
|
119
|
+
errors.append(f"{ctx}: anchor matches {len(hits)} bullets (need exactly 1)")
|
|
120
|
+
for j in hits:
|
|
121
|
+
claimed[j] += 1
|
|
122
|
+
for j, n in enumerate(claimed):
|
|
123
|
+
if n != 1:
|
|
124
|
+
errors.append(f"bijection: bullet line {bullets[j][:70]!r} claimed {n} times (need exactly 1)")
|
|
125
|
+
|
|
126
|
+
# -- 3. file coverage ---------------------------------------------------
|
|
127
|
+
entries = {}
|
|
128
|
+
for key, (rel, glob) in FILE_SECTIONS.items():
|
|
129
|
+
section = manifest.get(key, {})
|
|
130
|
+
entries[key] = section
|
|
131
|
+
if not section:
|
|
132
|
+
errors.append(f"non-vacuity: manifest section {key!r} empty")
|
|
133
|
+
on_disk = {p.name for p in (repo / rel).glob(glob) if p.is_file()}
|
|
134
|
+
if not on_disk:
|
|
135
|
+
errors.append(f"non-vacuity: no files on disk under {rel}")
|
|
136
|
+
for name, entry in section.items():
|
|
137
|
+
check_registry(entry, f"{key}/{name}")
|
|
138
|
+
if name not in on_disk:
|
|
139
|
+
errors.append(f"{key}: claimed file {name} does not exist in {rel}")
|
|
140
|
+
for name in sorted(on_disk - set(section)):
|
|
141
|
+
errors.append(f"{key}: file {name} in {rel} not claimed by the manifest")
|
|
142
|
+
|
|
143
|
+
# -- 4. router-guide co-package ----------------------------------------
|
|
144
|
+
guide_aud = {n: audience(e, errors, f"guides/{n}") for n, e in entries["guides"].items()}
|
|
145
|
+
anchor_of = {id(e): e.get("anchor", "?") for e in mb}
|
|
146
|
+
ref_checks = 0
|
|
147
|
+
for entry in mb:
|
|
148
|
+
hits = [b for b in bullets if entry.get("anchor", "\0") in b]
|
|
149
|
+
if len(hits) != 1:
|
|
150
|
+
continue # already reported by bijection
|
|
151
|
+
b_aud = audience(entry, errors, f"bullet {entry.get('anchor', '')!r:.40}")
|
|
152
|
+
for g in re.findall(r"guides/([a-z0-9-]+\.md)", hits[0]):
|
|
153
|
+
ref_checks += 1
|
|
154
|
+
if g not in guide_aud:
|
|
155
|
+
errors.append(f"router: bullet {entry['anchor']!r:.40} references unclaimed guide {g}")
|
|
156
|
+
elif not covers(guide_aud[g], b_aud):
|
|
157
|
+
errors.append(
|
|
158
|
+
f"router: bullet {entry['anchor']!r:.40} (audience {b_aud}) references guide {g} "
|
|
159
|
+
f"(audience {guide_aud[g]}) — user can hold the router without the guide"
|
|
160
|
+
)
|
|
161
|
+
if ref_checks == 0:
|
|
162
|
+
errors.append("non-vacuity: no router->guide references were checked")
|
|
163
|
+
|
|
164
|
+
# -- 5. hook source_guide -----------------------------------------------
|
|
165
|
+
for name, entry in entries["hooks"].items():
|
|
166
|
+
src = entry.get("source_guide")
|
|
167
|
+
if src:
|
|
168
|
+
g = entries["guides"].get(src)
|
|
169
|
+
if g is None:
|
|
170
|
+
errors.append(f"hooks/{name}: source_guide {src} not in manifest guides")
|
|
171
|
+
elif (entry.get("tier"), sorted(entry.get("domains", []))) != (g.get("tier"), sorted(g.get("domains", []))):
|
|
172
|
+
errors.append(f"hooks/{name}: tier/domains differ from source guide {src}")
|
|
173
|
+
|
|
174
|
+
# -- informational token report ------------------------------------------
|
|
175
|
+
sizes = {}
|
|
176
|
+
for entry in mb:
|
|
177
|
+
key = entry.get("tier") if entry.get("tier") != "domain" else ",".join(entry.get("domains", ["?"])[:1])
|
|
178
|
+
hit = next((b for b in bullets if entry.get("anchor", "\0") in b), "")
|
|
179
|
+
sizes[key] = sizes.get(key, 0) + len(hit) // 4
|
|
180
|
+
for name, entry in entries["guides"].items():
|
|
181
|
+
key = entry.get("tier") if entry.get("tier") != "domain" else ",".join(entry.get("domains", ["?"])[:1])
|
|
182
|
+
p = repo / FILE_SECTIONS["guides"][0] / name
|
|
183
|
+
if p.is_file():
|
|
184
|
+
sizes[key] = sizes.get(key, 0) + len(p.read_text(encoding="utf-8")) // 4
|
|
185
|
+
return errors, sizes
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def self_test(manifest, bullets):
|
|
189
|
+
"""Negative controls: each mutation MUST make the gate fail."""
|
|
190
|
+
import copy
|
|
191
|
+
|
|
192
|
+
muts = []
|
|
193
|
+
m1 = copy.deepcopy(manifest)
|
|
194
|
+
m1["bullets"] = m1["bullets"][1:]
|
|
195
|
+
muts.append(("dropped bullet entry", m1, bullets))
|
|
196
|
+
m2 = copy.deepcopy(manifest)
|
|
197
|
+
m2["bullets"][0]["anchor"] = "zz-no-such-phrase-zz"
|
|
198
|
+
muts.append(("anchor matches nothing (reword drift)", m2, bullets))
|
|
199
|
+
m3 = copy.deepcopy(manifest)
|
|
200
|
+
m3["bullets"][1]["anchor"] = m3["bullets"][0]["anchor"]
|
|
201
|
+
muts.append(("duplicate anchor claim", m3, bullets))
|
|
202
|
+
m4 = copy.deepcopy(manifest)
|
|
203
|
+
first_guide = next(iter(m4["guides"]))
|
|
204
|
+
del m4["guides"][first_guide]
|
|
205
|
+
muts.append((f"unclaimed guide {first_guide}", m4, bullets))
|
|
206
|
+
b5 = bullets + ["- a brand new bullet the manifest never heard of"]
|
|
207
|
+
muts.append(("bullet added without manifest entry", copy.deepcopy(manifest), b5))
|
|
208
|
+
|
|
209
|
+
failed = []
|
|
210
|
+
for name, mm, bb in muts:
|
|
211
|
+
errs, _ = run_gate(mm, bb)
|
|
212
|
+
if not errs:
|
|
213
|
+
failed.append(name)
|
|
214
|
+
print(f"self-test [{'CAUGHT' if errs else 'MISSED'}] {name}")
|
|
215
|
+
return failed
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def main():
|
|
219
|
+
manifest = load_manifest()
|
|
220
|
+
bullets = corpus_bullets()
|
|
221
|
+
if "--self-test" in sys.argv:
|
|
222
|
+
missed = self_test(manifest, bullets)
|
|
223
|
+
if missed:
|
|
224
|
+
print(f"SELF-TEST FAIL: gate missed: {missed}")
|
|
225
|
+
return 1
|
|
226
|
+
print("SELF-TEST OK: every negative control was caught")
|
|
227
|
+
return 0
|
|
228
|
+
errors, sizes = run_gate(manifest, bullets)
|
|
229
|
+
for e in errors:
|
|
230
|
+
print(f"FAIL: {e}")
|
|
231
|
+
print("-- package token estimate (informational) --")
|
|
232
|
+
for k in sorted(sizes):
|
|
233
|
+
print(f" {k}: ~{sizes[k]} tokens")
|
|
234
|
+
if errors:
|
|
235
|
+
print(f"DOMAINS GATE FAIL: {len(errors)} violation(s)")
|
|
236
|
+
return 1
|
|
237
|
+
print(f"DOMAINS GATE OK: {len(bullets)} bullets bijective, all files claimed, routers co-packaged")
|
|
238
|
+
return 0
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
if __name__ == "__main__":
|
|
242
|
+
sys.exit(main())
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Curation intake — map a dashboard learnings export to ledger candidates.
|
|
3
|
+
|
|
4
|
+
Phase 3 of the collection loop (design/collection-loop/PHASE3-CURATION-DESIGN.md).
|
|
5
|
+
The curator exports RECEIVED learnings from the dashboard as ledger-compatible
|
|
6
|
+
JSON (GET /api/exports/learnings — verbatim payloads + provenance); THIS script
|
|
7
|
+
does the DETERMINISTIC half of intake:
|
|
8
|
+
|
|
9
|
+
* validates each exported record against config/learning.schema.json — the
|
|
10
|
+
single validation source, reused from scripts/check-learning.py (no second
|
|
11
|
+
schema), so an invalid/verbatim-but-nonconforming payload is caught here;
|
|
12
|
+
* buckets each row: VALID → a ledger-candidate entry; REJECTED → schema or
|
|
13
|
+
domain-membership failure (with the reasons); DEFERRED → schema_version != 1
|
|
14
|
+
(Phase 2 stores v2+ verbatim for forward-compat; the v1 intake cannot map it
|
|
15
|
+
yet — it is NOT a reject, it is re-exportable once a v2-aware intake lands);
|
|
16
|
+
* flags candidates whose learning_id already appears in ledger.json (a
|
|
17
|
+
deterministic dedup warning — string membership, not a semantic judgment);
|
|
18
|
+
* emits a curation WORKLIST the curator then works through by hand.
|
|
19
|
+
|
|
20
|
+
It does NOT do the SEMANTIC half — triage the domain, classify type/layer/
|
|
21
|
+
mechanism, or judge novelty vs the full canon. Those stay with the curator
|
|
22
|
+
(capability boundary); see design/collection-loop/CURATION-INTAKE.md.
|
|
23
|
+
|
|
24
|
+
PII boundary: the worklist carries `_provenance.user_email` (D3.3 — visibility
|
|
25
|
+
into who contributes what). The worklist is a LOCAL artifact — never commit it;
|
|
26
|
+
when merging a candidate into the git-tracked ledger.json, keep `learning_id`
|
|
27
|
+
(non-PII dedup key) and DROP `_provenance` (see the procedure doc).
|
|
28
|
+
|
|
29
|
+
Input: a learnings-export JSON file (positional arg; '-' or omitted = stdin).
|
|
30
|
+
Output: the worklist JSON to stdout, or to --out FILE.
|
|
31
|
+
"""
|
|
32
|
+
import argparse
|
|
33
|
+
import importlib.util
|
|
34
|
+
import json
|
|
35
|
+
import pathlib
|
|
36
|
+
import sys
|
|
37
|
+
|
|
38
|
+
REPO = pathlib.Path(__file__).resolve().parent.parent
|
|
39
|
+
LEDGER = REPO / "design" / "session-distill" / "ledger.json"
|
|
40
|
+
FIXTURE = REPO / "design" / "collection-loop" / "fixtures" / "export-sample.json"
|
|
41
|
+
SUPPORTED_SCHEMA_VERSION = 1
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def die(msg, code=1):
|
|
45
|
+
print(f"ingest-learnings-export: {msg}", file=sys.stderr)
|
|
46
|
+
sys.exit(code)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_checker():
|
|
50
|
+
"""Reuse scripts/check-learning.py as the single validation source."""
|
|
51
|
+
path = REPO / "scripts" / "check-learning.py"
|
|
52
|
+
spec = importlib.util.spec_from_file_location("check_learning", path)
|
|
53
|
+
module = importlib.util.module_from_spec(spec)
|
|
54
|
+
spec.loader.exec_module(module)
|
|
55
|
+
return module
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_ledger_learning_ids(path=LEDGER):
|
|
59
|
+
"""learning_ids already present in the ledger (dedup key). Entries sourced
|
|
60
|
+
from earlier learnings carry a top-level `learning_id`; the historical
|
|
61
|
+
session-distill entries do not, so they simply contribute nothing here.
|
|
62
|
+
A missing/unreadable ledger is not fatal — dedup just finds nothing."""
|
|
63
|
+
try:
|
|
64
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
65
|
+
except (OSError, json.JSONDecodeError):
|
|
66
|
+
return set()
|
|
67
|
+
out = set()
|
|
68
|
+
for e in data.get("entries", []):
|
|
69
|
+
lid = e.get("learning_id") if isinstance(e, dict) else None
|
|
70
|
+
if isinstance(lid, str) and lid:
|
|
71
|
+
out.add(lid.lower())
|
|
72
|
+
return out
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def map_candidate(payload, row):
|
|
76
|
+
"""A validated export row -> a ledger-candidate entry (ledger.json shape).
|
|
77
|
+
Deterministic fields the record carries are copied; curator-only fields are
|
|
78
|
+
null for the curator to fill. `_provenance` is worklist-only (strip before
|
|
79
|
+
the ledger merge)."""
|
|
80
|
+
cls = payload.get("classification") or {}
|
|
81
|
+
return {
|
|
82
|
+
"id": None, # curator assigns (e.g. S4-02)
|
|
83
|
+
"learning_id": payload.get("learning_id"), # kept in ledger = dedup key
|
|
84
|
+
"lesson": payload.get("lesson"),
|
|
85
|
+
"strength": None, # curator: recurrence
|
|
86
|
+
"verdict": None, # curator: novel|partial|principle
|
|
87
|
+
"criteria": payload.get("criteria", []),
|
|
88
|
+
"supporting_sessions": payload.get("supporting_sessions", []),
|
|
89
|
+
"domain": payload.get("domain"),
|
|
90
|
+
"proposed_domain": payload.get("proposed_domain"),
|
|
91
|
+
"context": payload.get("context"), # curator-facing evidence note
|
|
92
|
+
"classification": {
|
|
93
|
+
"type": cls.get("type"),
|
|
94
|
+
"underlying_value": None,
|
|
95
|
+
"reformulation": None,
|
|
96
|
+
"meets_promotion_bar": cls.get("meets_bar"),
|
|
97
|
+
"layer": cls.get("layer"),
|
|
98
|
+
"mechanism": None,
|
|
99
|
+
"token_est": None,
|
|
100
|
+
"consumer_note": None,
|
|
101
|
+
"split": None,
|
|
102
|
+
"verification": None,
|
|
103
|
+
"proposed": False, # ledger convention: boolean
|
|
104
|
+
},
|
|
105
|
+
"status": "candidate",
|
|
106
|
+
"_provenance": {
|
|
107
|
+
"user_email": row.get("user_email"), # PII — worklist only, strip on merge
|
|
108
|
+
"received_at": row.get("received_at"), # server receipt time
|
|
109
|
+
"created": payload.get("created"), # user capture time (distinct)
|
|
110
|
+
"schema_version": payload.get("schema_version"),
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def process_export(export, checker, ledger_ids):
|
|
116
|
+
"""Bucket every export row. Deterministic: input order preserved, no
|
|
117
|
+
timestamps, so the same input yields byte-identical output."""
|
|
118
|
+
if not isinstance(export, dict) or not isinstance(export.get("learnings"), list):
|
|
119
|
+
die("not a learnings-export (expected an object with a `learnings` array)")
|
|
120
|
+
|
|
121
|
+
validator = checker.build_validator()
|
|
122
|
+
domain_values = checker.valid_domain_values()
|
|
123
|
+
|
|
124
|
+
entries, rejected, deferred, duplicates, warnings = [], [], [], [], []
|
|
125
|
+
for i, row in enumerate(export["learnings"]):
|
|
126
|
+
if not isinstance(row, dict) or not isinstance(row.get("payload"), dict):
|
|
127
|
+
rejected.append({"index": i, "learning_id": None,
|
|
128
|
+
"reasons": ["export row has no payload object"]})
|
|
129
|
+
continue
|
|
130
|
+
payload = row["payload"]
|
|
131
|
+
lid = payload.get("learning_id")
|
|
132
|
+
|
|
133
|
+
sv = payload.get("schema_version")
|
|
134
|
+
if sv != SUPPORTED_SCHEMA_VERSION:
|
|
135
|
+
deferred.append({"index": i, "learning_id": lid, "schema_version": sv,
|
|
136
|
+
"note": "re-export once a v%s-aware intake exists "
|
|
137
|
+
"(row stays available via includeExported)" % sv})
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
errors = checker.validate_record(payload, validator, domain_values)
|
|
141
|
+
if errors:
|
|
142
|
+
rejected.append({"index": i, "learning_id": lid, "reasons": errors})
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
# server-bug detector: the export's domain column should mirror payload.domain.
|
|
146
|
+
if row.get("domain") != payload.get("domain"):
|
|
147
|
+
warnings.append({"index": i, "learning_id": lid,
|
|
148
|
+
"detail": "export domain column %r != payload.domain %r"
|
|
149
|
+
% (row.get("domain"), payload.get("domain"))})
|
|
150
|
+
|
|
151
|
+
cand = map_candidate(payload, row)
|
|
152
|
+
if isinstance(lid, str) and lid.lower() in ledger_ids:
|
|
153
|
+
cand["duplicate_in_ledger"] = True
|
|
154
|
+
duplicates.append({"index": i, "learning_id": lid})
|
|
155
|
+
entries.append(cand)
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
"source": "curation-intake",
|
|
159
|
+
"generated_from": export.get("source", "learnings-export"),
|
|
160
|
+
"counts": {"valid": len(entries), "rejected": len(rejected),
|
|
161
|
+
"deferred": len(deferred), "duplicates": len(duplicates),
|
|
162
|
+
"warnings": len(warnings)},
|
|
163
|
+
"warnings": warnings,
|
|
164
|
+
"rejected": rejected,
|
|
165
|
+
"deferred": deferred,
|
|
166
|
+
"entries": entries,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def run(export, out_path=None):
|
|
171
|
+
worklist = process_export(export, load_checker(), load_ledger_learning_ids())
|
|
172
|
+
text = json.dumps(worklist, ensure_ascii=False, indent=2) + "\n"
|
|
173
|
+
if out_path and out_path != "-":
|
|
174
|
+
pathlib.Path(out_path).write_text(text, encoding="utf-8")
|
|
175
|
+
c = worklist["counts"]
|
|
176
|
+
print(f"ingest-learnings-export: wrote {out_path} "
|
|
177
|
+
f"(valid={c['valid']} rejected={c['rejected']} deferred={c['deferred']} "
|
|
178
|
+
f"duplicates={c['duplicates']})", file=sys.stderr)
|
|
179
|
+
else:
|
|
180
|
+
sys.stdout.write(text)
|
|
181
|
+
return worklist
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _self_test():
|
|
185
|
+
"""Verify bucketing against the committed export fixture (the cross-repo
|
|
186
|
+
contract artifact): valid rows map (cardinality > 0), a bad-domain row is
|
|
187
|
+
rejected with a domain reason (negative control), a v2 row is deferred not
|
|
188
|
+
rejected, mapping preserves context + meets_bar rename, and output is
|
|
189
|
+
deterministic. Exits non-zero on any failure."""
|
|
190
|
+
export = json.loads(FIXTURE.read_text(encoding="utf-8"))
|
|
191
|
+
checker = load_checker()
|
|
192
|
+
w1 = process_export(export, checker, {"0f8c1c2a-4d1e-4abc-9def-000000000001"})
|
|
193
|
+
w2 = process_export(export, checker, {"0f8c1c2a-4d1e-4abc-9def-000000000001"})
|
|
194
|
+
|
|
195
|
+
valid_ids = {e["learning_id"] for e in w1["entries"]}
|
|
196
|
+
rej_reasons = " ".join(r for row in w1["rejected"] for r in row["reasons"])
|
|
197
|
+
deferred_svs = {d["schema_version"] for d in w1["deferred"]}
|
|
198
|
+
full = next((e for e in w1["entries"]
|
|
199
|
+
if e["learning_id"] == "0f8c1c2a-4d1e-4abc-9def-000000000002"), None)
|
|
200
|
+
|
|
201
|
+
checks = [
|
|
202
|
+
("valid rows mapped (cardinality > 0)", w1["counts"]["valid"] >= 3),
|
|
203
|
+
("bad-domain row rejected", w1["counts"]["rejected"] >= 1),
|
|
204
|
+
("reject reason names the domain (negative control)", "domain" in rej_reasons),
|
|
205
|
+
("v2 row deferred, not rejected", deferred_svs == {2}),
|
|
206
|
+
("deferred row absent from entries",
|
|
207
|
+
"0f8c1c2a-4d1e-4abc-9def-00000000000a" not in valid_ids),
|
|
208
|
+
("context preserved verbatim", full is not None and full["context"]
|
|
209
|
+
and "4분짜리" in full["context"]),
|
|
210
|
+
("meets_bar -> meets_promotion_bar",
|
|
211
|
+
full is not None and full["classification"]["meets_promotion_bar"] is True),
|
|
212
|
+
("classification.proposed is boolean false",
|
|
213
|
+
full is not None and full["classification"]["proposed"] is False),
|
|
214
|
+
("provenance carries user_email (worklist-only PII)",
|
|
215
|
+
full is not None and full["_provenance"]["user_email"] == "alice@day1company.co.kr"),
|
|
216
|
+
("provenance keeps both created and received_at",
|
|
217
|
+
full is not None and full["_provenance"]["created"] != full["_provenance"]["received_at"]),
|
|
218
|
+
("ledger dedup flags a known learning_id", w1["counts"]["duplicates"] == 1),
|
|
219
|
+
("deterministic (same input -> identical output)",
|
|
220
|
+
json.dumps(w1, ensure_ascii=False) == json.dumps(w2, ensure_ascii=False)),
|
|
221
|
+
]
|
|
222
|
+
failed = [name for name, ok in checks if not ok]
|
|
223
|
+
if failed:
|
|
224
|
+
for name in failed:
|
|
225
|
+
print(f"ingest-learnings-export --self-test: FAIL: {name}", file=sys.stderr)
|
|
226
|
+
sys.exit(1)
|
|
227
|
+
print(f"ingest-learnings-export --self-test: OK ({len(checks)} intake checks)")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def main():
|
|
231
|
+
ap = argparse.ArgumentParser(description="Map a learnings export to ledger candidates.")
|
|
232
|
+
ap.add_argument("export", nargs="?", default="-",
|
|
233
|
+
help="learnings-export JSON file ('-' or omitted = stdin)")
|
|
234
|
+
ap.add_argument("--out", default=None, help="write the worklist here (default: stdout)")
|
|
235
|
+
ap.add_argument("--self-test", action="store_true",
|
|
236
|
+
help="run the intake self-test against the fixture and exit")
|
|
237
|
+
args = ap.parse_args()
|
|
238
|
+
|
|
239
|
+
if args.self_test:
|
|
240
|
+
_self_test()
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
if args.export == "-":
|
|
244
|
+
raw = sys.stdin.read()
|
|
245
|
+
else:
|
|
246
|
+
try:
|
|
247
|
+
raw = pathlib.Path(args.export).read_text(encoding="utf-8")
|
|
248
|
+
except OSError as e:
|
|
249
|
+
die(f"cannot read export {args.export!r}: {e}")
|
|
250
|
+
try:
|
|
251
|
+
export = json.loads(raw)
|
|
252
|
+
except json.JSONDecodeError as e:
|
|
253
|
+
die(f"export is not valid JSON: {e}")
|
|
254
|
+
run(export, args.out)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
if __name__ == "__main__":
|
|
258
|
+
main()
|
package/scripts/install.sh
CHANGED
|
@@ -23,6 +23,9 @@ set -euo pipefail
|
|
|
23
23
|
# or env var. Detach stdin so no child (the codex-helm dry-run, pip, git) can
|
|
24
24
|
# block forever on an inherited idle stdin — that is what hangs an install under
|
|
25
25
|
# CI, pipes, and background runs, where stdin stays open but never delivers.
|
|
26
|
+
# `learn` is the one subcommand whose payload IS stdin, so keep the caller's on
|
|
27
|
+
# fd 3 first and hand it back only there; every other path still sees /dev/null.
|
|
28
|
+
exec 3<&0 2>/dev/null || exec 3</dev/null # tolerate a caller that closed fd 0
|
|
26
29
|
exec </dev/null
|
|
27
30
|
|
|
28
31
|
# Resolve this script through symlinks before locating the package: npm links the
|
|
@@ -562,6 +565,16 @@ PY
|
|
|
562
565
|
else
|
|
563
566
|
log "note: managed venv/textual unavailable (numbered-prompt fallback applies)"
|
|
564
567
|
fi
|
|
568
|
+
# A file this installer executes but never ships is invisible from a clone and
|
|
569
|
+
# fatal on npm, so the payload gate runs wherever it exists (maintainer-side).
|
|
570
|
+
if [ -x "$REPO/scripts/check-package.sh" ]; then
|
|
571
|
+
if "$REPO/scripts/check-package.sh" >/dev/null 2>&1; then
|
|
572
|
+
info "npm payload OK"
|
|
573
|
+
else
|
|
574
|
+
log "npm payload incomplete; run scripts/check-package.sh"
|
|
575
|
+
fail=1
|
|
576
|
+
fi
|
|
577
|
+
fi
|
|
565
578
|
# Repo-internal mirror parity is a maintainer gate; only meaningful from a clone.
|
|
566
579
|
if [ -d "$REPO/ko" ] && [ -x "$REPO/scripts/check-parity.sh" ]; then
|
|
567
580
|
if "$REPO/scripts/check-parity.sh" >/dev/null 2>&1; then info "repo mirror parity OK"; else log "repo mirror parity FAILED"; fail=1; fi
|
|
@@ -688,6 +701,9 @@ agent-bios — deploy the Claude/Codex instruction SSOT into $HOME (by copy).
|
|
|
688
701
|
agent-bios install deploy into this environment (backs up + verifies)
|
|
689
702
|
agent-bios onboard interactive domain selection + packaged install + activation canary
|
|
690
703
|
agent-bios verify check the deployed state matches the source
|
|
704
|
+
agent-bios learn submit a session learning (reads the JSON record on
|
|
705
|
+
stdin; this is what the learn! flow calls, and it
|
|
706
|
+
works from any directory, unlike a repo-relative path)
|
|
691
707
|
agent-bios status show what is installed and where
|
|
692
708
|
agent-bios update git pull + reinstall (clone), or print the npm update line
|
|
693
709
|
agent-bios uninstall remove deployed files and the zsh hook
|
|
@@ -713,6 +729,18 @@ EOF
|
|
|
713
729
|
# ---- dispatch ------------------------------------------------------------
|
|
714
730
|
CMD="${1:-help}"
|
|
715
731
|
if [ $# -gt 0 ]; then shift; fi
|
|
732
|
+
|
|
733
|
+
# `learn` forwards its arguments and stdin straight to the collector, so it must
|
|
734
|
+
# bypass the flag parser below (which rejects anything it does not know). This
|
|
735
|
+
# subcommand is the only PATH-reachable entry to capture: the corpus guide used
|
|
736
|
+
# to invoke scripts/collect-learning.py relative to the cwd, which works from a
|
|
737
|
+
# clone and silently fails for every other install.
|
|
738
|
+
if [ "$CMD" = "learn" ]; then
|
|
739
|
+
collector="$REPO/scripts/collect-learning.py"
|
|
740
|
+
[ -f "$collector" ] || { log "learn: collector missing at $collector"; exit 1; }
|
|
741
|
+
exec python3 "$collector" "$@" <&3
|
|
742
|
+
fi
|
|
743
|
+
|
|
716
744
|
WITH=""
|
|
717
745
|
DOMAINS_ARG=""
|
|
718
746
|
DOMAINS_SET=0
|