ecoportal-api 0.10.16 → 0.10.17
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.
Potentially problematic release.
This version of ecoportal-api might be problematic. Click here for more details.
- checksums.yaml +4 -4
- data/.ai-assistance/.gitignore +2 -0
- data/.ai-assistance/bridge/.gitignore +10 -0
- data/.ai-assistance/bridge/CLAUDE.md +96 -0
- data/.ai-assistance/bridge/archive/.gitkeep +0 -0
- data/.ai-assistance/bridge/inbox/.gitkeep +0 -0
- data/.ai-assistance/bridge/outbox/.gitkeep +0 -0
- data/.ai-assistance/capabilities/assumptions-log.md +23 -0
- data/.ai-assistance/scripts/bridge-inbox-check.sh +119 -0
- data/.ai-assistance/scripts/bridge-init.sh +86 -0
- data/.ai-assistance/scripts/confine-to-subtree.sh +58 -0
- data/.ai-assistance/scripts/dirty-tree-guard.sh +96 -0
- data/.ai-assistance/scripts/distill_procedural.py +602 -0
- data/.ai-assistance/scripts/log-mcp-access.sh +24 -0
- data/.ai-assistance/scripts/log-skill-usage.sh +79 -0
- data/.ai-assistance/scripts/log_mcp_access.py +158 -0
- data/.ai-assistance/scripts/observe-session.sh +13 -0
- data/.ai-assistance/scripts/observe_session.py +287 -0
- data/.ai-assistance/scripts/protect-host-paths.sh +135 -0
- data/.ai-assistance/scripts/scrub.py +1149 -0
- data/.ai-assistance/scripts/scrub.py.sha256 +6 -0
- data/.ai-assistance/scripts/surface-procedural.sh +9 -0
- data/.ai-assistance/scripts/surface_procedural.py +101 -0
- data/.ai-assistance/skills/ep-ai-manager/SKILL.md +519 -0
- data/.ai-assistance/skills/project-self-docs/SKILL.md +259 -0
- data/.ai-assistance/skills/project-self-docs/scripts/self_docs_scan.py +378 -0
- data/.ai-assistance/standards-version.json +12 -0
- data/.ai-assistance/version.json +8 -0
- data/.claude/.gitignore +2 -0
- data/.claude/settings.json +128 -0
- data/CHANGELOG.md +8 -5
- data/CLAUDE.md +95 -71
- data/docs/self-docs/ARCHITECTURE.md +145 -0
- data/docs/self-docs/CHANGES.jsonl +7 -0
- data/docs/self-docs/COMPLIANCE.md +66 -0
- data/docs/self-docs/CONVENTIONS.md +74 -0
- data/docs/self-docs/INTEGRATIONS.md +62 -0
- data/docs/self-docs/OPERATIONS.md +64 -0
- data/docs/self-docs/OVERVIEW.md +61 -0
- data/docs/self-docs/STATUS.md +71 -0
- data/docs/self-docs/self-docs-index.json +51 -0
- data/docs/worklog.md +48 -0
- data/lib/ecoportal/api/common/client/with_retry.rb +6 -0
- data/lib/ecoportal/api/version.rb +1 -1
- metadata +40 -1
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
distill_procedural.py -- Procedural-memory DISTILL stage.
|
|
4
|
+
|
|
5
|
+
Standard: standards/workflows/procedural-memory.md ("Distill" + D1 lifecycle).
|
|
6
|
+
|
|
7
|
+
Mines the machine-local Observe stream (.ai-assistance/local/observe-<ISO-week>.jsonl --
|
|
8
|
+
the dedicated stream observe_session.py writes; NOT the kpi/usage heartbeat stream) into
|
|
9
|
+
`procedural` memory entries, and runs the D1 lifecycle over existing entries: confidence
|
|
10
|
+
re-evaluation, retirement (confidence floor / staleness), forced RETIRE/CONFIRM markers, and
|
|
11
|
+
archive-with-tombstone + cool-off so a retired routine cannot silently reappear.
|
|
12
|
+
|
|
13
|
+
SCHEDULING: this is a deterministic, zero-LLM, machine-local pass -- safe on a clock. The
|
|
14
|
+
standard OliveTin action "epai: Procedural distill (daily)" runs it daily (see
|
|
15
|
+
templates/olivetin/config.yaml.template); a plain cron line works identically:
|
|
16
|
+
15 6 * * * python <repo>/templates/ai-assistance/scripts/distill_procedural.py --cwd <repo> --apply
|
|
17
|
+
It never spawns a claude session (standards/tooling/self-spawning-automation.md), never
|
|
18
|
+
commits, and is no-op-safe: with nothing to do it prints one "0 records distilled" line
|
|
19
|
+
and exits 0.
|
|
20
|
+
|
|
21
|
+
BACKFILL (--backfill-from-feedback): one-shot verification seeding per the 2026-07-20
|
|
22
|
+
inventory. Reads hand-written `type: feedback` records from --memory-dir and writes
|
|
23
|
+
`type: procedural` PROPOSALS to --output-dir (never into the live memory dir); a human
|
|
24
|
+
reviews and moves approved proposals into memory. This gives the Act/surface stage a
|
|
25
|
+
known-present positive signal (feedback-verify-actuators-against-positive-signal).
|
|
26
|
+
|
|
27
|
+
The DETERMINISTIC lifecycle (confidence arithmetic, retirement, archival, tombstones) lives
|
|
28
|
+
here in full. The pattern MINER here is a conservative v1 co-occurrence heuristic (event B
|
|
29
|
+
reliably follows event A within a session); the richer semantic mining -- and the
|
|
30
|
+
reasoning-trait / blind-spot subtypes -- are performed by the `procedural-memory` skill (an
|
|
31
|
+
LLM task) over the same evidence log. Because the evidence layer is stable, either miner can
|
|
32
|
+
be re-run over the existing archive without re-collecting data.
|
|
33
|
+
|
|
34
|
+
CONSENT: refuses to read the log unless the developer opted in (procedural-observe.enabled
|
|
35
|
+
marker or PROCEDURAL_OBSERVE=1), mirroring the Observe hook.
|
|
36
|
+
|
|
37
|
+
SAFE BY DEFAULT: dry-run (prints the plan, writes nothing). Pass --apply to write.
|
|
38
|
+
|
|
39
|
+
Provisional constants (tunable in the reviewed phase; see the standard):
|
|
40
|
+
INITIAL_CONFIDENCE=0.6 CONF_UP=+0.1 CONF_DOWN=-0.2 FLOOR=0.3
|
|
41
|
+
STALENESS_DAYS=90 COOLOFF_DAYS=30 MIN_CONSISTENCY=0.8 MIN_OCCURRENCES=3
|
|
42
|
+
"""
|
|
43
|
+
import argparse
|
|
44
|
+
import json
|
|
45
|
+
import os
|
|
46
|
+
import re
|
|
47
|
+
import sys
|
|
48
|
+
import tempfile
|
|
49
|
+
from datetime import datetime, timezone, date
|
|
50
|
+
|
|
51
|
+
INITIAL_CONFIDENCE = 0.6
|
|
52
|
+
CONF_UP = 0.1
|
|
53
|
+
CONF_DOWN = 0.2
|
|
54
|
+
FLOOR = 0.3
|
|
55
|
+
STALENESS_DAYS = 90
|
|
56
|
+
COOLOFF_DAYS = 30
|
|
57
|
+
MIN_CONSISTENCY = 0.8
|
|
58
|
+
MIN_OCCURRENCES = 3
|
|
59
|
+
|
|
60
|
+
_FIELD = {
|
|
61
|
+
"trigger": re.compile(r"(?im)^\*\*Trigger:\*\*\s*(.+?)\s*$"),
|
|
62
|
+
"action": re.compile(r"(?im)^\*\*Inferred action:\*\*\s*(.+?)\s*$"),
|
|
63
|
+
"confidence": re.compile(r"(?im)^\*\*Confidence:\*\*\s*([0-9.]+)"),
|
|
64
|
+
"first": re.compile(r"(?im)^\*\*First-observed:\*\*\s*([0-9]{4}-[0-9]{2}-[0-9]{2})"),
|
|
65
|
+
"last": re.compile(r"(?im)^\*\*Last-observed:\*\*\s*([0-9]{4}-[0-9]{2}-[0-9]{2})"),
|
|
66
|
+
"subtype": re.compile(r"(?im)^\*\*Subtype:\*\*\s*(\w[\w-]*)"),
|
|
67
|
+
"evkey": re.compile(r"(?im)^\*\*Evidence-key:\*\*\s*(\S+)"),
|
|
68
|
+
}
|
|
69
|
+
_RETIRE = re.compile(r"<!--\s*RETIRE:\s*(.+?)\s*-->")
|
|
70
|
+
_CONFIRM = re.compile(r"<!--\s*CONFIRM:\s*(.+?)\s*-->")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _today(override=None):
|
|
74
|
+
if override:
|
|
75
|
+
return date.fromisoformat(override)
|
|
76
|
+
return datetime.now(timezone.utc).date()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _consent(local_dir):
|
|
80
|
+
if os.environ.get("PROCEDURAL_OBSERVE", "").lower() in ("1", "true", "yes"):
|
|
81
|
+
return True
|
|
82
|
+
return os.path.exists(os.path.join(local_dir, "procedural-observe.enabled"))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _project_slug(cwd):
|
|
86
|
+
# Mirror Claude Code's project-dir slug: path with : \ / replaced by -.
|
|
87
|
+
ab = os.path.abspath(cwd)
|
|
88
|
+
return ab.replace(":", "-").replace("\\", "-").replace("/", "-")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# --- evidence: mine candidate routines from the observe stream ----------------
|
|
92
|
+
|
|
93
|
+
def _read_events(evidence_dir):
|
|
94
|
+
"""Return {session_id: [event_key, ...]} ordered by timestamp. Event key = component/action.
|
|
95
|
+
Reads ONLY the dedicated observe-*.jsonl stream -- the kpi/usage-*.jsonl heartbeat stream
|
|
96
|
+
is deliberately not evidence (it buried the behavioral signal; 2026-07-20 inventory)."""
|
|
97
|
+
sessions = {}
|
|
98
|
+
if not os.path.isdir(evidence_dir):
|
|
99
|
+
return sessions
|
|
100
|
+
rows = []
|
|
101
|
+
for fn in sorted(os.listdir(evidence_dir)):
|
|
102
|
+
if not (fn.startswith("observe-") and fn.endswith(".jsonl")):
|
|
103
|
+
continue
|
|
104
|
+
try:
|
|
105
|
+
with open(os.path.join(evidence_dir, fn), encoding="utf-8") as fh:
|
|
106
|
+
for line in fh:
|
|
107
|
+
line = line.strip()
|
|
108
|
+
if not line:
|
|
109
|
+
continue
|
|
110
|
+
try:
|
|
111
|
+
r = json.loads(line)
|
|
112
|
+
except ValueError:
|
|
113
|
+
continue
|
|
114
|
+
comp = r.get("component", "")
|
|
115
|
+
act = r.get("action", "")
|
|
116
|
+
if not comp or act == "session-summary":
|
|
117
|
+
continue # aggregate rows carry no sequence signal
|
|
118
|
+
rows.append((r.get("session_id", ""), r.get("ts", ""), f"{comp}/{act}"))
|
|
119
|
+
except OSError:
|
|
120
|
+
continue
|
|
121
|
+
rows.sort(key=lambda t: (t[0], t[1]))
|
|
122
|
+
for sid, _ts, ev in rows:
|
|
123
|
+
sessions.setdefault(sid, []).append(ev)
|
|
124
|
+
return sessions
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _mine_pairs(sessions):
|
|
128
|
+
"""Co-occurrence: for each event A, consistency = P(next distinct event is B).
|
|
129
|
+
Returns {(A,B): {"count":n,"opportunities":m,"consistency":r}} for pairs meeting thresholds."""
|
|
130
|
+
opp = {} # A -> count of A occurrences that had a following event
|
|
131
|
+
pair = {} # (A,B) -> count
|
|
132
|
+
for evs in sessions.values():
|
|
133
|
+
for i in range(len(evs) - 1):
|
|
134
|
+
a, b = evs[i], evs[i + 1]
|
|
135
|
+
if a == b:
|
|
136
|
+
continue
|
|
137
|
+
opp[a] = opp.get(a, 0) + 1
|
|
138
|
+
pair[(a, b)] = pair.get((a, b), 0) + 1
|
|
139
|
+
out = {}
|
|
140
|
+
for (a, b), n in pair.items():
|
|
141
|
+
m = opp.get(a, 0)
|
|
142
|
+
r = (n / m) if m else 0.0
|
|
143
|
+
if n >= MIN_OCCURRENCES and r >= MIN_CONSISTENCY:
|
|
144
|
+
out[(a, b)] = {"count": n, "opportunities": m, "consistency": round(r, 3)}
|
|
145
|
+
return out
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _pair_occurrences(sessions):
|
|
149
|
+
"""Return dict A -> (fired, followed_by_any) is not enough; we need per-entry re-eval.
|
|
150
|
+
Provide: for an evidence-key 'A>>B', did A fire in window, and did B follow A?"""
|
|
151
|
+
opp = {}
|
|
152
|
+
pair = {}
|
|
153
|
+
for evs in sessions.values():
|
|
154
|
+
for i in range(len(evs) - 1):
|
|
155
|
+
a, b = evs[i], evs[i + 1]
|
|
156
|
+
if a == b:
|
|
157
|
+
continue
|
|
158
|
+
opp[a] = opp.get(a, 0) + 1
|
|
159
|
+
pair[(a, b)] = pair.get((a, b), 0) + 1
|
|
160
|
+
return opp, pair
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# --- existing entries ---------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
def _split_frontmatter(text):
|
|
166
|
+
if text.startswith("---"):
|
|
167
|
+
end = text.find("\n---", 3)
|
|
168
|
+
if end != -1:
|
|
169
|
+
return text[3:end].strip(), text[end + 4:].lstrip("\n")
|
|
170
|
+
return "", text
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _is_procedural(front):
|
|
174
|
+
return re.search(r"(?im)^\s*type:\s*procedural\b", front) is not None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _parse_entry(path):
|
|
178
|
+
try:
|
|
179
|
+
with open(path, encoding="utf-8") as fh:
|
|
180
|
+
text = fh.read()
|
|
181
|
+
except OSError:
|
|
182
|
+
return None
|
|
183
|
+
front, body = _split_frontmatter(text)
|
|
184
|
+
if not _is_procedural(front):
|
|
185
|
+
return None
|
|
186
|
+
nm = re.search(r"(?im)^\s*name:\s*(.+?)\s*$", front)
|
|
187
|
+
e = {"path": path, "text": text, "front": front, "body": body,
|
|
188
|
+
"name": nm.group(1).strip() if nm else os.path.basename(path)[:-3]}
|
|
189
|
+
for k, rx in _FIELD.items():
|
|
190
|
+
m = rx.search(body)
|
|
191
|
+
e[k] = m.group(1).strip() if m else None
|
|
192
|
+
e["confidence"] = float(e["confidence"]) if e["confidence"] else INITIAL_CONFIDENCE
|
|
193
|
+
e["subtype"] = e["subtype"] or "routine"
|
|
194
|
+
return e
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _set_field(body, label, value):
|
|
198
|
+
rx = re.compile(r"(?im)^(\*\*" + re.escape(label) + r":\*\*\s*).+?$")
|
|
199
|
+
repl = r"\g<1>" + value.replace("\\", "\\\\")
|
|
200
|
+
if rx.search(body):
|
|
201
|
+
return rx.sub(lambda m: m.group(1) + value, body, count=1)
|
|
202
|
+
return body.rstrip() + f"\n**{label}:** {value}\n"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# --- main ---------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
def run(memory_dir, evidence_dir, worklog, apply, today):
|
|
208
|
+
plan = {"retired": [], "confirmed": [], "up": [], "down": [], "new": [], "skipped_cooloff": []}
|
|
209
|
+
tombstone_path = os.path.join(memory_dir, "retired", "tombstones.json")
|
|
210
|
+
tombstones = {}
|
|
211
|
+
if os.path.exists(tombstone_path):
|
|
212
|
+
try:
|
|
213
|
+
tombstones = {t["evkey"]: t for t in json.load(open(tombstone_path, encoding="utf-8"))}
|
|
214
|
+
except (OSError, ValueError, KeyError):
|
|
215
|
+
tombstones = {}
|
|
216
|
+
|
|
217
|
+
# Forced markers from worklog + memory files.
|
|
218
|
+
marker_text = ""
|
|
219
|
+
if worklog and os.path.exists(worklog):
|
|
220
|
+
try:
|
|
221
|
+
marker_text += open(worklog, encoding="utf-8").read()
|
|
222
|
+
except OSError:
|
|
223
|
+
pass
|
|
224
|
+
entries = []
|
|
225
|
+
if os.path.isdir(memory_dir):
|
|
226
|
+
for fn in sorted(os.listdir(memory_dir)):
|
|
227
|
+
if fn.endswith(".md") and fn != "MEMORY.md":
|
|
228
|
+
e = _parse_entry(os.path.join(memory_dir, fn))
|
|
229
|
+
if e:
|
|
230
|
+
entries.append(e)
|
|
231
|
+
marker_text += e["text"]
|
|
232
|
+
force_retire = {m.strip().lower() for m in _RETIRE.findall(marker_text)}
|
|
233
|
+
force_confirm = {m.strip().lower() for m in _CONFIRM.findall(marker_text)}
|
|
234
|
+
|
|
235
|
+
sessions = _read_events(evidence_dir)
|
|
236
|
+
opp, pair = _pair_occurrences(sessions)
|
|
237
|
+
tstr = today.isoformat()
|
|
238
|
+
|
|
239
|
+
existing_keys = set()
|
|
240
|
+
for e in entries:
|
|
241
|
+
nm = (e["name"] or "").lower()
|
|
242
|
+
evkey = e.get("evkey")
|
|
243
|
+
if evkey:
|
|
244
|
+
existing_keys.add(evkey)
|
|
245
|
+
retire = False
|
|
246
|
+
reason = None
|
|
247
|
+
|
|
248
|
+
# 1) forced markers (honoured before arithmetic)
|
|
249
|
+
if nm in force_retire:
|
|
250
|
+
retire, reason = True, "forced"
|
|
251
|
+
elif nm in force_confirm:
|
|
252
|
+
e["confidence"] = 1.0
|
|
253
|
+
e["last"] = tstr
|
|
254
|
+
plan["confirmed"].append(e["name"])
|
|
255
|
+
|
|
256
|
+
# 2) confidence re-evaluation from evidence (only if we can map the trigger)
|
|
257
|
+
if not retire and evkey and ">>" in evkey and nm not in force_confirm:
|
|
258
|
+
a, b = evkey.split(">>", 1)
|
|
259
|
+
if opp.get(a, 0) > 0: # trigger fired in window
|
|
260
|
+
if pair.get((a, b), 0) > 0: # action followed
|
|
261
|
+
e["confidence"] = min(1.0, e["confidence"] + CONF_UP)
|
|
262
|
+
e["last"] = tstr
|
|
263
|
+
plan["up"].append(e["name"])
|
|
264
|
+
else: # trigger fired but action did NOT follow
|
|
265
|
+
e["confidence"] = max(0.0, e["confidence"] - CONF_DOWN)
|
|
266
|
+
plan["down"].append(e["name"])
|
|
267
|
+
# trigger did not fire -> unchanged, no last-observed refresh
|
|
268
|
+
|
|
269
|
+
# 3) retirement (confidence floor / staleness). A CONFIRM'd entry is rescued: it is
|
|
270
|
+
# exempt from retirement in the same pass (its conf is 1.0 and last is refreshed
|
|
271
|
+
# above, but guard explicitly so the intent survives future reordering).
|
|
272
|
+
if not retire and nm not in force_confirm:
|
|
273
|
+
if e["confidence"] < FLOOR:
|
|
274
|
+
retire, reason = True, "confidence floor"
|
|
275
|
+
elif e.get("last"):
|
|
276
|
+
try:
|
|
277
|
+
age = (today - date.fromisoformat(e["last"])).days
|
|
278
|
+
if age > STALENESS_DAYS:
|
|
279
|
+
retire, reason = True, "staleness"
|
|
280
|
+
except ValueError:
|
|
281
|
+
pass
|
|
282
|
+
|
|
283
|
+
if retire:
|
|
284
|
+
plan["retired"].append({"name": e["name"], "reason": reason, "evkey": evkey})
|
|
285
|
+
if apply:
|
|
286
|
+
_do_retire(e, memory_dir, reason, tstr, tombstones)
|
|
287
|
+
elif apply:
|
|
288
|
+
_write_back(e)
|
|
289
|
+
|
|
290
|
+
# 4) mine new candidate routines (skip cool-off + already-present)
|
|
291
|
+
mined = _mine_pairs(sessions)
|
|
292
|
+
for (a, b), stats in sorted(mined.items()):
|
|
293
|
+
evkey = f"{a}>>{b}"
|
|
294
|
+
if evkey in existing_keys:
|
|
295
|
+
continue
|
|
296
|
+
ts = tombstones.get(evkey)
|
|
297
|
+
if ts:
|
|
298
|
+
try:
|
|
299
|
+
if (today - date.fromisoformat(ts["retired"])).days <= COOLOFF_DAYS:
|
|
300
|
+
plan["skipped_cooloff"].append(evkey)
|
|
301
|
+
continue
|
|
302
|
+
except (ValueError, KeyError):
|
|
303
|
+
pass
|
|
304
|
+
plan["new"].append({"evkey": evkey, **stats})
|
|
305
|
+
if apply:
|
|
306
|
+
_write_new(memory_dir, a, b, stats, tstr)
|
|
307
|
+
|
|
308
|
+
# Write tombstones only when there is (or was) something to record -- a scheduled
|
|
309
|
+
# no-op run must leave zero filesystem traces (the 2026-07-21 no-op-commit lesson).
|
|
310
|
+
if apply and (tombstones or os.path.exists(tombstone_path)):
|
|
311
|
+
os.makedirs(os.path.dirname(tombstone_path), exist_ok=True)
|
|
312
|
+
_atomic_write(tombstone_path, json.dumps(list(tombstones.values()), indent=2))
|
|
313
|
+
return plan
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _atomic_write(path, text):
|
|
317
|
+
"""Write text to path atomically (temp file in the same dir + os.replace), so an
|
|
318
|
+
interrupted scheduled run can never truncate/corrupt a state file (MEMORY.md,
|
|
319
|
+
tombstones.json, a memory entry). Best-effort: swallows OSError like the callers did."""
|
|
320
|
+
d = os.path.dirname(path) or "."
|
|
321
|
+
tmp = None
|
|
322
|
+
try:
|
|
323
|
+
fd, tmp = tempfile.mkstemp(dir=d, prefix=".distill-", suffix=".tmp")
|
|
324
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
325
|
+
fh.write(text)
|
|
326
|
+
os.replace(tmp, path)
|
|
327
|
+
except OSError:
|
|
328
|
+
if tmp and os.path.exists(tmp):
|
|
329
|
+
try:
|
|
330
|
+
os.remove(tmp)
|
|
331
|
+
except OSError:
|
|
332
|
+
pass
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _write_back(e):
|
|
336
|
+
body = e["body"]
|
|
337
|
+
body = _set_field(body, "Confidence", f"{e['confidence']:.2f}")
|
|
338
|
+
if e.get("last"):
|
|
339
|
+
body = _set_field(body, "Last-observed", e["last"])
|
|
340
|
+
_atomic_write(e["path"], "---\n" + e["front"] + "\n---\n\n" + body.lstrip("\n"))
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _do_retire(e, memory_dir, reason, tstr, tombstones):
|
|
344
|
+
retired_dir = os.path.join(memory_dir, "retired")
|
|
345
|
+
os.makedirs(retired_dir, exist_ok=True)
|
|
346
|
+
note = f"\n<!-- RETIRED {tstr}: {reason} -->\n"
|
|
347
|
+
fname = os.path.basename(e["path"])
|
|
348
|
+
_atomic_write(os.path.join(retired_dir, fname), e["text"].rstrip() + note)
|
|
349
|
+
try:
|
|
350
|
+
os.remove(e["path"])
|
|
351
|
+
except OSError:
|
|
352
|
+
pass
|
|
353
|
+
if e.get("evkey"):
|
|
354
|
+
tombstones[e["evkey"]] = {"evkey": e["evkey"], "name": e["name"],
|
|
355
|
+
"retired": tstr, "reason": reason}
|
|
356
|
+
# Remove the entry's line from MEMORY.md if present. Match the exact filename in the link,
|
|
357
|
+
# or the name as a whole token -- a bare substring test would also delete a similarly-named
|
|
358
|
+
# entry (retiring "routine-foo" must NOT drop "routine-foo-bar").
|
|
359
|
+
mem_index = os.path.join(memory_dir, "MEMORY.md")
|
|
360
|
+
if os.path.exists(mem_index):
|
|
361
|
+
try:
|
|
362
|
+
lines = open(mem_index, encoding="utf-8").read().splitlines()
|
|
363
|
+
name_re = re.compile(r"(?<![\w-])" + re.escape(e["name"]) + r"(?![\w-])")
|
|
364
|
+
kept = [ln for ln in lines if fname not in ln and not name_re.search(ln)]
|
|
365
|
+
_atomic_write(mem_index, "\n".join(kept) + "\n")
|
|
366
|
+
except OSError:
|
|
367
|
+
pass
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _slug(a, b):
|
|
371
|
+
s = re.sub(r"[^a-z0-9]+", "-", f"{a}-to-{b}".lower()).strip("-")
|
|
372
|
+
full = "routine-" + s
|
|
373
|
+
if len(full) > 60:
|
|
374
|
+
# Preserve uniqueness when truncating, else two long distinct pairs could collide
|
|
375
|
+
# on the first 60 chars and the second would be silently skipped.
|
|
376
|
+
import hashlib
|
|
377
|
+
return full[:55] + "-" + hashlib.sha256(full.encode("utf-8")).hexdigest()[:4]
|
|
378
|
+
return full
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _write_new(memory_dir, a, b, stats, tstr):
|
|
382
|
+
name = _slug(a, b)
|
|
383
|
+
path = os.path.join(memory_dir, name + ".md")
|
|
384
|
+
if os.path.exists(path):
|
|
385
|
+
return
|
|
386
|
+
entry = (
|
|
387
|
+
"---\n"
|
|
388
|
+
f"name: {name}\n"
|
|
389
|
+
f"description: \"Distilled routine: after {a}, {b} reliably follows.\"\n"
|
|
390
|
+
"metadata:\n"
|
|
391
|
+
" type: procedural\n"
|
|
392
|
+
"---\n\n"
|
|
393
|
+
f"## {a} -> {b}\n\n"
|
|
394
|
+
f"**Subtype:** routine\n"
|
|
395
|
+
f"**Trigger:** event `{a}` occurs\n"
|
|
396
|
+
f"**Inferred action:** `{b}` follows\n"
|
|
397
|
+
f"**Confidence:** {INITIAL_CONFIDENCE:.2f}\n"
|
|
398
|
+
f"**First-observed:** {tstr}\n"
|
|
399
|
+
f"**Last-observed:** {tstr}\n"
|
|
400
|
+
f"**Evidence-key:** {a}>>{b}\n\n"
|
|
401
|
+
f"Observed {stats['count']} times in {stats['opportunities']} opportunities "
|
|
402
|
+
f"(consistency {stats['consistency']}). Distilled by distill_procedural.py; "
|
|
403
|
+
f"probationary until re-confirmed. This is an INFERENCE, not an asserted fact.\n"
|
|
404
|
+
)
|
|
405
|
+
_atomic_write(path, entry)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
# --- backfill: seed procedural PROPOSALS from hand-written feedback records ----
|
|
409
|
+
|
|
410
|
+
BACKFILL_LIMIT = 5
|
|
411
|
+
BACKFILL_CONFIDENCE = 0.6
|
|
412
|
+
|
|
413
|
+
_HOWTO = re.compile(r"(?is)\*\*How to apply:\*\*\s*(.+?)(?:\n\s*\n|\Z)")
|
|
414
|
+
_DESC = re.compile(r"(?im)^description:\s*(.+?)\s*$")
|
|
415
|
+
_ISO_DATE = re.compile(r"\b(20[0-9]{2}-[0-9]{2}-[0-9]{2})\b")
|
|
416
|
+
_WHEN_SPLIT = re.compile(r"(?is)^when\s+(.+?)[,:]\s+(.+)$")
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _is_feedback(front):
|
|
420
|
+
return re.search(r"(?im)^\s*type:\s*feedback\b", front) is not None
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _squash(text, limit=240):
|
|
424
|
+
text = re.sub(r"\s+", " ", text).strip()
|
|
425
|
+
if len(text) > limit:
|
|
426
|
+
text = text[:limit].rsplit(" ", 1)[0].rstrip(",;") + " ..."
|
|
427
|
+
return text
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _unquote(s):
|
|
431
|
+
s = s.strip()
|
|
432
|
+
if len(s) >= 2 and s[0] == s[-1] and s[0] in ("\"", "'"):
|
|
433
|
+
s = s[1:-1]
|
|
434
|
+
return s.replace('\\"', '"')
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _backfill_candidates(memory_dir):
|
|
438
|
+
"""Deterministic scan: every `type: feedback` record carrying a **How to apply:**
|
|
439
|
+
line is behavior-shaped enough to propose a routine from. Sorted by filename."""
|
|
440
|
+
out = []
|
|
441
|
+
if not os.path.isdir(memory_dir):
|
|
442
|
+
return out
|
|
443
|
+
for fn in sorted(os.listdir(memory_dir)):
|
|
444
|
+
if not fn.endswith(".md") or fn == "MEMORY.md":
|
|
445
|
+
continue
|
|
446
|
+
try:
|
|
447
|
+
text = open(os.path.join(memory_dir, fn), encoding="utf-8").read()
|
|
448
|
+
except OSError:
|
|
449
|
+
continue
|
|
450
|
+
front, body = _split_frontmatter(text)
|
|
451
|
+
if not _is_feedback(front):
|
|
452
|
+
continue
|
|
453
|
+
how = _HOWTO.search(body)
|
|
454
|
+
if not how:
|
|
455
|
+
continue
|
|
456
|
+
dm = _DESC.search(front)
|
|
457
|
+
desc = _squash(_unquote(dm.group(1))) if dm else ""
|
|
458
|
+
how_text = _squash(how.group(1), limit=400)
|
|
459
|
+
wm = _WHEN_SPLIT.match(how_text)
|
|
460
|
+
if wm:
|
|
461
|
+
# bare clause, no "when " prefix: the Act stage renders "when {trigger}"
|
|
462
|
+
trigger = _squash(wm.group(1))
|
|
463
|
+
action = _squash(wm.group(2))
|
|
464
|
+
else:
|
|
465
|
+
trigger = desc or _squash(fn[:-3].replace("-", " ").replace("_", " "))
|
|
466
|
+
if trigger.lower().startswith("when "):
|
|
467
|
+
trigger = trigger[5:]
|
|
468
|
+
action = how_text
|
|
469
|
+
dates = sorted(_ISO_DATE.findall(body))
|
|
470
|
+
stem = fn[:-3]
|
|
471
|
+
for pre in ("feedback-", "feedback_"):
|
|
472
|
+
if stem.startswith(pre):
|
|
473
|
+
stem = stem[len(pre):]
|
|
474
|
+
break
|
|
475
|
+
out.append({
|
|
476
|
+
"src": fn,
|
|
477
|
+
"name": "procedural-" + stem,
|
|
478
|
+
"title": stem.replace("-", " ").replace("_", " "),
|
|
479
|
+
"trigger": trigger,
|
|
480
|
+
"action": action,
|
|
481
|
+
"first": dates[0] if dates else None,
|
|
482
|
+
})
|
|
483
|
+
return out
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _write_proposal(output_dir, cand, tstr):
|
|
487
|
+
path = os.path.join(output_dir, cand["name"] + ".md")
|
|
488
|
+
if os.path.exists(path):
|
|
489
|
+
return path, False
|
|
490
|
+
first = cand["first"] or tstr
|
|
491
|
+
entry = (
|
|
492
|
+
"---\n"
|
|
493
|
+
f"name: {cand['name']}\n"
|
|
494
|
+
f"description: \"PROPOSAL backfilled from {cand['src']} -- human review required "
|
|
495
|
+
"before adoption into live memory.\"\n"
|
|
496
|
+
"metadata:\n"
|
|
497
|
+
" type: procedural\n"
|
|
498
|
+
" source: backfill-from-feedback\n"
|
|
499
|
+
"---\n\n"
|
|
500
|
+
f"## {cand['title']}\n\n"
|
|
501
|
+
"**Subtype:** routine\n"
|
|
502
|
+
f"**Trigger:** {cand['trigger']}\n"
|
|
503
|
+
f"**Inferred action:** {cand['action']}\n"
|
|
504
|
+
f"**Confidence:** {BACKFILL_CONFIDENCE:.2f}\n"
|
|
505
|
+
f"**First-observed:** {first}\n"
|
|
506
|
+
f"**Last-observed:** {tstr}\n"
|
|
507
|
+
f"**Evidence-key:** feedback:{cand['src']}\n\n"
|
|
508
|
+
f"Backfilled from the hand-written feedback record `{cand['src']}` by "
|
|
509
|
+
"distill_procedural.py --backfill-from-feedback (verification seed, 2026-07-20 "
|
|
510
|
+
"inventory fix 3). This is an INFERENCE PROPOSAL, not an asserted fact: a human "
|
|
511
|
+
"moves it into the live memory dir (and indexes it in MEMORY.md) to adopt it, "
|
|
512
|
+
"or deletes it to reject it.\n"
|
|
513
|
+
)
|
|
514
|
+
_atomic_write(path, entry)
|
|
515
|
+
return path, True
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def backfill_from_feedback(memory_dir, output_dir, today, limit=BACKFILL_LIMIT):
|
|
519
|
+
"""Read feedback records from memory_dir; write procedural PROPOSALS to output_dir.
|
|
520
|
+
NEVER writes into memory_dir. Idempotent: an existing proposal file is left alone."""
|
|
521
|
+
mem = os.path.abspath(memory_dir)
|
|
522
|
+
out = os.path.abspath(output_dir)
|
|
523
|
+
if out == mem or (out + os.sep).startswith(mem + os.sep):
|
|
524
|
+
raise ValueError("--output-dir must not be (or live inside) the memory dir "
|
|
525
|
+
"(proposals are staged for human review, never written into live memory)")
|
|
526
|
+
cands = _backfill_candidates(memory_dir)[:max(0, limit)]
|
|
527
|
+
written, skipped = [], []
|
|
528
|
+
if cands:
|
|
529
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
530
|
+
tstr = today.isoformat()
|
|
531
|
+
for c in cands:
|
|
532
|
+
path, wrote = _write_proposal(output_dir, c, tstr)
|
|
533
|
+
(written if wrote else skipped).append((c["name"], path))
|
|
534
|
+
return written, skipped
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def main(argv=None):
|
|
538
|
+
ap = argparse.ArgumentParser(description="Procedural-memory distill pass (D1 lifecycle + v1 miner).")
|
|
539
|
+
ap.add_argument("--cwd", default=os.getcwd())
|
|
540
|
+
ap.add_argument("--memory-dir", help="override the project memory dir (testing / backfill input)")
|
|
541
|
+
ap.add_argument("--evidence-dir", "--usage-dir", dest="evidence_dir",
|
|
542
|
+
help="override the observe-stream dir (default: .ai-assistance/local)")
|
|
543
|
+
ap.add_argument("--worklog", help="worklog path to scan for RETIRE/CONFIRM markers")
|
|
544
|
+
ap.add_argument("--today", help="ISO date override (testing)")
|
|
545
|
+
ap.add_argument("--apply", action="store_true", help="write changes (default: dry-run)")
|
|
546
|
+
ap.add_argument("--backfill-from-feedback", action="store_true",
|
|
547
|
+
help="propose procedural records from `type: feedback` records; requires "
|
|
548
|
+
"--memory-dir (input) and --output-dir (staging; never live memory)")
|
|
549
|
+
ap.add_argument("--output-dir", help="backfill only: where proposals are written")
|
|
550
|
+
ap.add_argument("--limit", type=int, default=BACKFILL_LIMIT,
|
|
551
|
+
help=f"backfill only: max proposals to write (default {BACKFILL_LIMIT})")
|
|
552
|
+
a = ap.parse_args(argv)
|
|
553
|
+
|
|
554
|
+
if a.backfill_from_feedback:
|
|
555
|
+
if not a.memory_dir or not a.output_dir:
|
|
556
|
+
print("[distill] --backfill-from-feedback requires --memory-dir (input) and "
|
|
557
|
+
"--output-dir (proposal staging dir)")
|
|
558
|
+
return 2
|
|
559
|
+
try:
|
|
560
|
+
written, skipped = backfill_from_feedback(
|
|
561
|
+
a.memory_dir, a.output_dir, _today(a.today), a.limit)
|
|
562
|
+
except ValueError as e:
|
|
563
|
+
print(f"[distill] {e}")
|
|
564
|
+
return 2
|
|
565
|
+
for name, path in written:
|
|
566
|
+
print(f"[distill] proposal written: {name} -> {path}")
|
|
567
|
+
for name, _path in skipped:
|
|
568
|
+
print(f"[distill] proposal skipped (exists): {name}")
|
|
569
|
+
print(f"[distill] backfill: {len(written)} proposal(s) written, {len(skipped)} skipped "
|
|
570
|
+
"-- review and move approved ones into the live memory dir yourself.")
|
|
571
|
+
return 0
|
|
572
|
+
|
|
573
|
+
local_dir = os.path.join(a.cwd, ".ai-assistance", "local")
|
|
574
|
+
evidence_dir = a.evidence_dir or local_dir
|
|
575
|
+
if not a.memory_dir and not _consent(local_dir):
|
|
576
|
+
print("[distill] observe consent not set (no procedural-observe.enabled / "
|
|
577
|
+
"PROCEDURAL_OBSERVE) -- 0 records distilled (refusing to read the observe stream).")
|
|
578
|
+
return 0
|
|
579
|
+
memory_dir = a.memory_dir or os.path.join(
|
|
580
|
+
os.path.expanduser("~"), ".claude", "projects", _project_slug(a.cwd), "memory")
|
|
581
|
+
worklog = a.worklog or os.path.join(a.cwd, "docs", "worklog.md")
|
|
582
|
+
|
|
583
|
+
plan = run(memory_dir, evidence_dir, a.worklog and worklog, a.apply, _today(a.today))
|
|
584
|
+
mode = "APPLIED" if a.apply else "DRY-RUN"
|
|
585
|
+
total = sum(len(v) for v in plan.values())
|
|
586
|
+
if total == 0:
|
|
587
|
+
# No-op-safe scheduled run: exactly one clear line, zero writes, exit 0.
|
|
588
|
+
print(f"[distill] {mode} -- 0 records distilled (memory={memory_dir})")
|
|
589
|
+
return 0
|
|
590
|
+
print(f"[distill] {mode} -- memory={memory_dir}")
|
|
591
|
+
for k in ("new", "up", "down", "confirmed", "retired", "skipped_cooloff"):
|
|
592
|
+
v = plan[k]
|
|
593
|
+
if not v:
|
|
594
|
+
continue
|
|
595
|
+
print(f" {k:16} {len(v)}")
|
|
596
|
+
for item in v:
|
|
597
|
+
print(f" - {item}")
|
|
598
|
+
return 0
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
if __name__ == "__main__":
|
|
602
|
+
sys.exit(main())
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# log-mcp-access.sh -- PostToolUse hook (matcher: mcp__.*) that DETERMINISTICALLY
|
|
3
|
+
# records every MCP tool call to the local access-audit ledger. Deterministic capture
|
|
4
|
+
# slice of standards/security/access-audit-ledger.md (v0 local store).
|
|
5
|
+
#
|
|
6
|
+
# Standard: standards/security/access-audit-ledger.md ("Record schema" -- fields marked
|
|
7
|
+
# CAPTURED are exactly what this script writes; nothing else).
|
|
8
|
+
#
|
|
9
|
+
# Wired in .claude/settings.json (this repo) and templates/claude-settings/settings.json.*:
|
|
10
|
+
# "PostToolUse": [{ "matcher": "mcp__.*",
|
|
11
|
+
# "hooks": [{ "type": "command",
|
|
12
|
+
# "command": "bash .ai-assistance/scripts/log-mcp-access.sh 2>/dev/null || true" }] }]
|
|
13
|
+
#
|
|
14
|
+
# Reads: stdin -- the PostToolUse JSON (tool_name, tool_input, tool_response, session_id, cwd)
|
|
15
|
+
# Writes: .ai-assistance/local/audit/access-<YYYY-WNN>.jsonl (one appended line)
|
|
16
|
+
#
|
|
17
|
+
# NOTE: uses `python -c`-equivalent invocation of a sibling .py file (NOT a heredoc) so the
|
|
18
|
+
# hook's stdin -- the PostToolUse JSON -- reaches python unaltered.
|
|
19
|
+
# Fails OPEN and SILENT on any error: an audit logger must never wedge, slow, or deny a
|
|
20
|
+
# tool call. Always exits 0 with no stdout.
|
|
21
|
+
PY=python3
|
|
22
|
+
command -v python3 >/dev/null 2>&1 || PY=python
|
|
23
|
+
"$PY" "$(dirname "$0")/log_mcp_access.py" 2>/dev/null || true
|
|
24
|
+
exit 0
|