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.

Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/.ai-assistance/.gitignore +2 -0
  3. data/.ai-assistance/bridge/.gitignore +10 -0
  4. data/.ai-assistance/bridge/CLAUDE.md +96 -0
  5. data/.ai-assistance/bridge/archive/.gitkeep +0 -0
  6. data/.ai-assistance/bridge/inbox/.gitkeep +0 -0
  7. data/.ai-assistance/bridge/outbox/.gitkeep +0 -0
  8. data/.ai-assistance/capabilities/assumptions-log.md +23 -0
  9. data/.ai-assistance/scripts/bridge-inbox-check.sh +119 -0
  10. data/.ai-assistance/scripts/bridge-init.sh +86 -0
  11. data/.ai-assistance/scripts/confine-to-subtree.sh +58 -0
  12. data/.ai-assistance/scripts/dirty-tree-guard.sh +96 -0
  13. data/.ai-assistance/scripts/distill_procedural.py +602 -0
  14. data/.ai-assistance/scripts/log-mcp-access.sh +24 -0
  15. data/.ai-assistance/scripts/log-skill-usage.sh +79 -0
  16. data/.ai-assistance/scripts/log_mcp_access.py +158 -0
  17. data/.ai-assistance/scripts/observe-session.sh +13 -0
  18. data/.ai-assistance/scripts/observe_session.py +287 -0
  19. data/.ai-assistance/scripts/protect-host-paths.sh +135 -0
  20. data/.ai-assistance/scripts/scrub.py +1149 -0
  21. data/.ai-assistance/scripts/scrub.py.sha256 +6 -0
  22. data/.ai-assistance/scripts/surface-procedural.sh +9 -0
  23. data/.ai-assistance/scripts/surface_procedural.py +101 -0
  24. data/.ai-assistance/skills/ep-ai-manager/SKILL.md +519 -0
  25. data/.ai-assistance/skills/project-self-docs/SKILL.md +259 -0
  26. data/.ai-assistance/skills/project-self-docs/scripts/self_docs_scan.py +378 -0
  27. data/.ai-assistance/standards-version.json +12 -0
  28. data/.ai-assistance/version.json +8 -0
  29. data/.claude/.gitignore +2 -0
  30. data/.claude/settings.json +128 -0
  31. data/CHANGELOG.md +8 -5
  32. data/CLAUDE.md +95 -71
  33. data/docs/self-docs/ARCHITECTURE.md +145 -0
  34. data/docs/self-docs/CHANGES.jsonl +7 -0
  35. data/docs/self-docs/COMPLIANCE.md +66 -0
  36. data/docs/self-docs/CONVENTIONS.md +74 -0
  37. data/docs/self-docs/INTEGRATIONS.md +62 -0
  38. data/docs/self-docs/OPERATIONS.md +64 -0
  39. data/docs/self-docs/OVERVIEW.md +61 -0
  40. data/docs/self-docs/STATUS.md +71 -0
  41. data/docs/self-docs/self-docs-index.json +51 -0
  42. data/docs/worklog.md +48 -0
  43. data/lib/ecoportal/api/common/client/with_retry.rb +6 -0
  44. data/lib/ecoportal/api/version.rb +1 -1
  45. 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