@caddis/cli 0.2.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/bundles/antigravity-plugin/agents/claude-md-curator.md +1 -1
  2. package/bundles/antigravity-plugin/caddis_statusline.py +803 -0
  3. package/bundles/antigravity-plugin/claude-md/folder-agents.md.tmpl +21 -0
  4. package/bundles/antigravity-plugin/guard_agy.py +23 -4
  5. package/bundles/antigravity-plugin/plugin.json +1 -1
  6. package/bundles/antigravity-plugin/scripts/caddis_gate.py +524 -0
  7. package/bundles/antigravity-plugin/scripts/caddis_inventory.py +369 -0
  8. package/bundles/antigravity-plugin/scripts/caddis_migrate_dir.py +270 -0
  9. package/bundles/antigravity-plugin/scripts/caddis_spawn.py +300 -0
  10. package/bundles/antigravity-plugin/scripts/caddis_statusline.py +803 -0
  11. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +366 -0
  12. package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +924 -0
  13. package/bundles/antigravity-plugin/scripts/claude-deepseek.ps1 +4 -0
  14. package/bundles/antigravity-plugin/scripts/claude-deepseek.sh +3 -0
  15. package/bundles/antigravity-plugin/scripts/claude-glm.ps1 +4 -0
  16. package/bundles/antigravity-plugin/scripts/claude-glm.sh +3 -0
  17. package/bundles/antigravity-plugin/scripts/claude-oss.ps1 +64 -0
  18. package/bundles/antigravity-plugin/scripts/claude-oss.sh +60 -0
  19. package/bundles/antigravity-plugin/scripts/claudster_config.py +126 -0
  20. package/bundles/antigravity-plugin/scripts/claudster_doctor.py +356 -0
  21. package/bundles/antigravity-plugin/scripts/claudster_init.py +400 -0
  22. package/bundles/antigravity-plugin/scripts/claudster_migrate_rules.py +300 -0
  23. package/bundles/antigravity-plugin/scripts/dream_capture.py +249 -0
  24. package/bundles/antigravity-plugin/scripts/dream_memory.py +438 -0
  25. package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +261 -0
  26. package/bundles/antigravity-plugin/scripts/hook_log.py +156 -0
  27. package/bundles/antigravity-plugin/scripts/oss_ask.py +98 -0
  28. package/bundles/antigravity-plugin/scripts/oss_model.py +166 -0
  29. package/bundles/antigravity-plugin/scripts/oss_review.py +718 -0
  30. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +1429 -0
  31. package/bundles/antigravity-plugin/scripts/usage_review.py +1323 -0
  32. package/bundles/antigravity-plugin/session_end_agy.py +21 -2
  33. package/bundles/antigravity-plugin/skills/_registry.md +2 -0
  34. package/bundles/antigravity-plugin/skills/advisory-hub/reference/succession.md +22 -0
  35. package/bundles/antigravity-plugin/skills/fastapi-dev/SKILL.md +1 -1
  36. package/bundles/antigravity-plugin/skills/ship/SKILL.md +1 -1
  37. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +1 -1
  38. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +1 -1
  39. package/bundles/antigravity-plugin/skills/spawn-hub/SKILL.md +33 -0
  40. package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +213 -0
  41. package/bundles/antigravity-plugin/skills/statusline/SKILL.md +105 -0
  42. package/bundles/antigravity-plugin/skills/use-model/SKILL.md +15 -3
  43. package/bundles/antigravity-plugin/warm_start_agy.py +21 -2
  44. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  45. package/bundles/antigravity-plugin-extras/skills/architecture-design/SKILL.md +1 -1
  46. package/bundles/antigravity-plugin-extras/skills/caching-patterns/SKILL.md +1 -1
  47. package/bundles/antigravity-plugin-extras/skills/webapp-development/SKILL.md +1 -1
  48. package/bundles/manifest.json +3 -3
  49. package/dist/claude-deepseek.js +544 -0
  50. package/dist/claude-glm.js +544 -0
  51. package/dist/claude-oss.js +544 -0
  52. package/dist/cli.js +375 -46
  53. package/package.json +5 -2
  54. package/bundles/antigravity-plugin/statusline-command-agy.sh +0 -91
@@ -0,0 +1,300 @@
1
+ """claudster_migrate_rules — migrate a repo's rules hierarchy to AGENTS.md-canonical + CLAUDE.md shims.
2
+
3
+ `AGENTS.md` is the canonical rules file every agent reads (Claude Code, Codex CLI, Antigravity); each
4
+ `CLAUDE.md` becomes a thin `@AGENTS.md` import shim. This tool migrates an existing repo — root pair +
5
+ every subfolder `CLAUDE.md` — to that layout, safely and idempotently.
6
+
7
+ Behavior (per target repo):
8
+ • ROOT: CLAUDE.md's (fuller, possibly enriched) content becomes the canonical AGENTS.md, re-headed
9
+ agent-neutrally; CLAUDE.md becomes the shim. An existing AGENTS.md that is the old caddis mirror
10
+ (a subset of CLAUDE.md) is replaced. If the existing AGENTS.md carries content CLAUDE.md LACKS
11
+ (project enrichments on both sides), it is a CONFLICT — the root pair is skipped and reported
12
+ (exit 1); a human resolves the few by hand-merge, then re-runs to confirm.
13
+ • SUBFOLDERS: each <dir>/CLAUDE.md is `git mv`'d to <dir>/AGENTS.md (content verbatim) + a <dir>/CLAUDE.md shim.
14
+ • Idempotent (a re-run is a no-op); refuses a dirty tree (unless --allow-dirty); `--dry-run` is the
15
+ default (`--apply` executes); `git mv` when tracked; never touches archived `done/`-style files.
16
+ • --check (fork detector): flags any CLAUDE.md that is NOT a shim beside an AGENTS.md (drifted shim),
17
+ and any bare CLAUDE.md with no sibling AGENTS.md (a hand-created fork). Exit 1 on findings.
18
+
19
+ Usage:
20
+ python scripts/claudster_migrate_rules.py [target] # dry-run (default)
21
+ python scripts/claudster_migrate_rules.py [target] --apply # execute
22
+ python scripts/claudster_migrate_rules.py [target] --check # fork detector (gate)
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import os
28
+ import subprocess
29
+ import sys
30
+ from pathlib import Path
31
+
32
+ # Reuse the harness templates + folder shim so the migration output matches what the generator emits.
33
+ try:
34
+ from setup_project_ai import HARNESS_DIR, SUBFOLDER_SHIM, render as _render
35
+ except Exception: # pragma: no cover — standalone fallback if setup_project_ai isn't importable
36
+ HARNESS_DIR = None
37
+ SUBFOLDER_SHIM = ("# Folder conventions — canonical in AGENTS.md (imported below; Claude Code inlines it)\n"
38
+ "@AGENTS.md\n")
39
+
40
+ def _render(text, mapping): # type: ignore
41
+ for k, v in mapping.items():
42
+ text = text.replace("{{" + k + "}}", v)
43
+ return text
44
+
45
+ # Directories we never migrate into (vendored/generated + archived rules).
46
+ SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "__pycache__", ".mypy_cache", ".ruff_cache",
47
+ ".pytest_cache", "dist", "build", ".tanstack", "done", "archive", "archived"}
48
+
49
+ # Section headings whose bodies are pure template boilerplate — their wording differs between the old
50
+ # root.md.tmpl and agents.md.tmpl, so they are excluded from the divergence check (only PROJECT content
51
+ # is compared). "What this project is" is intentionally NOT here — its content IS compared.
52
+ _BOILERPLATE_HEADING_KEYS = (
53
+ "the laws", "the harness", "development harness", "context discipline", "conventions",
54
+ "where things live", "doc discipline", "resuming a session", "resources", "why subagents",
55
+ )
56
+
57
+ _CANONICAL_PREAMBLE = (
58
+ "> **This is the canonical rules file.** Every coding agent reads it — Claude Code (via the one-line\n"
59
+ "> `@AGENTS.md` import in `CLAUDE.md`), OpenAI Codex CLI, and Antigravity (`agy`). `CLAUDE.md` is a\n"
60
+ "> thin `@AGENTS.md` import shim; keep durable project rules HERE, never in the shim."
61
+ )
62
+
63
+
64
+ # ── git helpers ──────────────────────────────────────────────────────────────
65
+ def _git(target: Path, *args: str) -> subprocess.CompletedProcess:
66
+ return subprocess.run(["git", *args], cwd=str(target), capture_output=True, text=True)
67
+
68
+
69
+ def _in_git_repo(target: Path) -> bool:
70
+ r = _git(target, "rev-parse", "--is-inside-work-tree")
71
+ return r.returncode == 0 and r.stdout.strip() == "true"
72
+
73
+
74
+ def _is_dirty(target: Path) -> bool:
75
+ return bool(_git(target, "status", "--porcelain").stdout.strip())
76
+
77
+
78
+ def _is_tracked(target: Path, rel: str) -> bool:
79
+ return bool(_git(target, "ls-files", "--error-unmatch", rel).returncode == 0)
80
+
81
+
82
+ # ── content helpers ──────────────────────────────────────────────────────────
83
+ def is_shim(text: str) -> bool:
84
+ """A CLAUDE.md is a shim iff it carries a bare `@AGENTS.md` import line."""
85
+ return any(ln.strip() == "@AGENTS.md" for ln in text.splitlines())
86
+
87
+
88
+ def _project_name(claude_text: str, target: Path) -> str:
89
+ for ln in claude_text.splitlines():
90
+ if ln.startswith("# "):
91
+ title = ln[2:].strip()
92
+ # "<name> — Project Memory (root)" -> "<name>"
93
+ for sep in (" — ", " - ", " –– "):
94
+ if sep in title:
95
+ return title.split(sep, 1)[0].strip()
96
+ return title
97
+ return target.name
98
+
99
+
100
+ def _meaningful(text: str) -> set[str]:
101
+ """Normalized set of content lines (md markers/whitespace stripped), for subset comparison."""
102
+ out: set[str] = set()
103
+ for ln in text.splitlines():
104
+ s = ln.strip().lstrip("#>-*").strip()
105
+ # drop an ordered-list prefix like "1. "
106
+ while s[:1].isdigit():
107
+ s = s[1:]
108
+ s = s.lstrip(". ").strip()
109
+ if len(s) >= 8:
110
+ out.add(s.lower())
111
+ return out
112
+
113
+
114
+ def _checkable_lines(agents_text: str) -> set[str]:
115
+ """AGENTS.md's PROJECT content — meaningful lines from non-boilerplate-heading sections (and the
116
+ preamble is dropped). Compared against CLAUDE.md to detect divergence."""
117
+ lines = agents_text.splitlines()
118
+ # find section spans by "## " headings
119
+ heads = [i for i, l in enumerate(lines) if l.startswith("## ")]
120
+ out: set[str] = set()
121
+ for k, start in enumerate(heads):
122
+ heading = lines[start][3:].strip().lower()
123
+ if any(key in heading for key in _BOILERPLATE_HEADING_KEYS):
124
+ continue
125
+ end = heads[k + 1] if k + 1 < len(heads) else len(lines)
126
+ out |= _meaningful("\n".join(lines[start:end]))
127
+ return out
128
+
129
+
130
+ def root_conflict(claude_text: str, agents_text: str) -> list[str]:
131
+ """Return AGENTS.md project lines that CLAUDE.md lacks (the enrichment residue). Non-empty => the
132
+ two files diverged and must not be auto-merged."""
133
+ residue = _checkable_lines(agents_text) - _meaningful(claude_text)
134
+ return sorted(residue)
135
+
136
+
137
+ def reheaded_agents(claude_text: str, name: str) -> str:
138
+ """Canonical AGENTS.md from CLAUDE.md content: replace the shim/mirror preamble (everything before
139
+ the first `## ` heading) with a canonical header + preamble; keep the body (enrichments) verbatim."""
140
+ lines = claude_text.splitlines()
141
+ idx = next((i for i, l in enumerate(lines) if l.startswith("## ")), None)
142
+ body = "\n".join(lines[idx:]) if idx is not None else claude_text
143
+ header = f"# {name} — Project Memory (canonical rules)\n\n{_CANONICAL_PREAMBLE}\n\n"
144
+ text = header + body
145
+ return text if text.endswith("\n") else text + "\n"
146
+
147
+
148
+ def root_shim(name: str) -> str:
149
+ """The root CLAUDE.md shim — the harness root.md.tmpl rendered (reused, so no drift)."""
150
+ if HARNESS_DIR is not None:
151
+ tmpl = HARNESS_DIR / "claude-md" / "root.md.tmpl"
152
+ if tmpl.is_file():
153
+ return _render(tmpl.read_text(encoding="utf-8"), {"PROJECT_NAME": name})
154
+ return (f"# {name} — Project Memory\n\n@AGENTS.md\n\n"
155
+ "> **Canonical rules live in `AGENTS.md`** (imported above — Claude Code inlines it). This\n"
156
+ "> shim adds only Claude-native conveniences; durable rules go in `AGENTS.md`.\n")
157
+
158
+
159
+ # ── migration steps ──────────────────────────────────────────────────────────
160
+ def _write(target: Path, rel: str, content: str, apply: bool, report: list[str], verb: str):
161
+ report.append(f"{verb}: {rel}")
162
+ if apply:
163
+ dest = target / rel
164
+ dest.parent.mkdir(parents=True, exist_ok=True)
165
+ dest.write_text(content, encoding="utf-8")
166
+
167
+
168
+ def _move(target: Path, src_rel: str, dst_rel: str, apply: bool, report: list[str]):
169
+ report.append(f"move: {src_rel} -> {dst_rel}")
170
+ if not apply:
171
+ return
172
+ if _is_tracked(target, src_rel):
173
+ r = _git(target, "mv", src_rel, dst_rel)
174
+ if r.returncode == 0:
175
+ return
176
+ # untracked (or git mv failed): plain rename
177
+ (target / dst_rel).parent.mkdir(parents=True, exist_ok=True)
178
+ os.replace(target / src_rel, target / dst_rel)
179
+
180
+
181
+ def migrate_root(target: Path, apply: bool, report: list[str]) -> int:
182
+ """Migrate the root pair. Returns 1 on conflict (root skipped), else 0."""
183
+ claude = target / "CLAUDE.md"
184
+ agents = target / "AGENTS.md"
185
+ if not claude.exists() and not agents.exists():
186
+ report.append("root: no CLAUDE.md/AGENTS.md — nothing to migrate")
187
+ return 0
188
+ claude_text = claude.read_text(encoding="utf-8") if claude.exists() else ""
189
+ if claude.exists() and is_shim(claude_text) and agents.exists():
190
+ report.append("root: already migrated (CLAUDE.md is a shim) — no-op")
191
+ return 0
192
+ if not claude.exists():
193
+ report.append("root: AGENTS.md present, no CLAUDE.md — leaving as-is (already canonical or hand-authored)")
194
+ return 0
195
+ name = _project_name(claude_text, target)
196
+ if agents.exists():
197
+ conflict = root_conflict(claude_text, agents.read_text(encoding="utf-8"))
198
+ if conflict:
199
+ report.append("root: CONFLICT — AGENTS.md carries content CLAUDE.md lacks; skipping root pair. "
200
+ "Hand-merge into CLAUDE.md, then re-run. Divergent line(s): "
201
+ + "; ".join(conflict[:5]) + (" …" if len(conflict) > 5 else ""))
202
+ return 1
203
+ # safe: CLAUDE.md content -> canonical AGENTS.md; CLAUDE.md -> shim
204
+ _write(target, "AGENTS.md", reheaded_agents(claude_text, name), apply, report,
205
+ "rewrite (canonical)" if agents.exists() else "create (canonical)")
206
+ _write(target, "CLAUDE.md", root_shim(name), apply, report, "shim")
207
+ return 0
208
+
209
+
210
+ def _iter_subfolder_claude(target: Path):
211
+ for dirpath, dirnames, filenames in os.walk(target):
212
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
213
+ d = Path(dirpath)
214
+ if d == target:
215
+ continue # root handled separately
216
+ if "CLAUDE.md" in filenames:
217
+ yield d
218
+
219
+
220
+ def migrate_subfolders(target: Path, apply: bool, report: list[str]) -> None:
221
+ for d in _iter_subfolder_claude(target):
222
+ rel = d.relative_to(target).as_posix()
223
+ claude_text = (d / "CLAUDE.md").read_text(encoding="utf-8")
224
+ if is_shim(claude_text) and (d / "AGENTS.md").exists():
225
+ report.append(f"{rel}/: already migrated — no-op")
226
+ continue
227
+ if (d / "AGENTS.md").exists():
228
+ report.append(f"{rel}/: CONFLICT — both AGENTS.md and a non-shim CLAUDE.md present; skipped")
229
+ continue
230
+ _move(target, f"{rel}/CLAUDE.md", f"{rel}/AGENTS.md", apply, report)
231
+ _write(target, f"{rel}/CLAUDE.md", SUBFOLDER_SHIM, apply, report, "shim")
232
+
233
+
234
+ def check_forks(target: Path) -> list[str]:
235
+ """Fork detector: non-shim CLAUDE.md beside an AGENTS.md, or a bare CLAUDE.md with no sibling AGENTS.md."""
236
+ findings: list[str] = []
237
+ for dirpath, dirnames, filenames in os.walk(target):
238
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
239
+ if "CLAUDE.md" not in filenames:
240
+ continue
241
+ d = Path(dirpath)
242
+ rel = (d / "CLAUDE.md").relative_to(target).as_posix()
243
+ text = (d / "CLAUDE.md").read_text(encoding="utf-8", errors="replace")
244
+ if "AGENTS.md" in filenames:
245
+ if not is_shim(text):
246
+ findings.append(f"{rel}: CLAUDE.md is not a shim but a sibling AGENTS.md exists (drifted shim)")
247
+ else:
248
+ findings.append(f"{rel}: CLAUDE.md has no sibling AGENTS.md (hand-created fork — run /caddis:add-rules semantics)")
249
+ return findings
250
+
251
+
252
+ # ── main ─────────────────────────────────────────────────────────────────────
253
+ def main(argv: list[str] | None = None) -> int:
254
+ for _stream in (sys.stdout, sys.stderr): # Windows cp1252 can't encode em-dashes in our messages
255
+ if hasattr(_stream, "reconfigure"):
256
+ _stream.reconfigure(encoding="utf-8")
257
+ ap = argparse.ArgumentParser(description="Migrate a repo to AGENTS.md-canonical rules + CLAUDE.md shims.")
258
+ ap.add_argument("target", nargs="?", default=".", type=Path)
259
+ ap.add_argument("--apply", action="store_true", help="Execute (default is --dry-run).")
260
+ ap.add_argument("--allow-dirty", action="store_true", help="Proceed even if the working tree is dirty.")
261
+ ap.add_argument("--check", action="store_true", help="Fork detector only (gate mode); exit 1 on findings.")
262
+ args = ap.parse_args(argv)
263
+
264
+ target = args.target.resolve()
265
+ if not target.is_dir():
266
+ print(f"ERROR: target not a directory: {target}", file=sys.stderr)
267
+ return 2
268
+
269
+ if args.check:
270
+ findings = check_forks(target)
271
+ for f in findings:
272
+ print(f" fork: {f}")
273
+ if findings:
274
+ print(f"\n{len(findings)} rules-file fork(s) found.")
275
+ return 1
276
+ print("No rules-file forks — CLAUDE.md shims are clean.")
277
+ return 0
278
+
279
+ apply = args.apply
280
+ if not _in_git_repo(target):
281
+ print(f"ERROR: {target} is not a git repository (rules migration uses git mv).", file=sys.stderr)
282
+ return 2
283
+ if apply and _is_dirty(target) and not args.allow_dirty:
284
+ print("ERROR: working tree is dirty — commit/stash first, or pass --allow-dirty.", file=sys.stderr)
285
+ return 2
286
+
287
+ print(f"=== caddis-migrate-rules -> {target} {'(APPLY)' if apply else '(dry-run)'}")
288
+ report: list[str] = []
289
+ rc = migrate_root(target, apply, report)
290
+ migrate_subfolders(target, apply, report)
291
+ for line in report:
292
+ print(f" {line}")
293
+ if not apply:
294
+ print("\n(dry-run — no files changed; pass --apply to execute)")
295
+ print(f"\nresult: {'CONFLICT (root skipped)' if rc else 'ok'}")
296
+ return rc
297
+
298
+
299
+ if __name__ == "__main__":
300
+ raise SystemExit(main())
@@ -0,0 +1,249 @@
1
+ """Deterministic Dream Memory capture from a Stop transcript (fann Phase 5b).
2
+
3
+ Pure, filesystem-free extraction of *high-confidence* fact candidates from a parsed transcript,
4
+ plus privacy redaction. The hook (``session_end.py``) does the I/O — read the transcript, call
5
+ :func:`extract_facts`, then consolidate via ``dream_memory`` and rewrite the store. Keeping the
6
+ logic here (dicts in → dicts out) makes it unit-testable without a tree, mirroring the engine.
7
+
8
+ Two kinds are captured (the only ones inferable *without an LLM* — the rich kinds come from the
9
+ knowledge-transfer agent in 5d):
10
+ • **failure-mode** — a ``Bash`` tool call whose result is an error (``is_error``). One candidate
11
+ per distinct command per session; cross-session recurrence is what reinforces it.
12
+ • **workflow-success** — a *build/test* command that succeeded after the same command failed
13
+ earlier in the same session (the red→green signal).
14
+
15
+ PRIVACY (hard constraint, see the design doc): a fact must be safe to print at SessionStart and
16
+ (eventually) to commit. So we (1) **skip entirely** any command that touches a secret file
17
+ (``.env``, ``**/secrets/**``, ``*.pem``/``*.key``, credential files — guard.py's secret sense),
18
+ and (2) **redact** inline secrets (``KEY=…``, ``--token …``, ``Bearer …``, URLs with credentials,
19
+ known token shapes, long high-entropy blobs) from both the command head and the error line. The
20
+ dedup key derives from the *redacted* command, so a token can never leak into the store's key.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import hashlib
26
+ import re
27
+
28
+ from dream_memory import make_fact, normalize_key # sibling module in scripts/
29
+
30
+ # --------------------------------------------------------------------------- #
31
+ # Limits — keep the store lean and a single Stop's append bounded.
32
+ # --------------------------------------------------------------------------- #
33
+ MAX_FACTS_PER_RUN = 15 # cap candidates emitted from one transcript
34
+ _HEAD_LEN = 80 # command head kept (we store heads, not full args)
35
+ _ERR_LEN = 120 # first error line kept
36
+ _SUMMARY_LEN = 200 # final clamp on a summary
37
+
38
+
39
+ # --------------------------------------------------------------------------- #
40
+ # Privacy — redaction + secret-path skip. Mirrors guard.py's secret sense but
41
+ # operates on inline text (value-level scrubbing), not just file-path classing.
42
+ # --------------------------------------------------------------------------- #
43
+ _URL_CRED = re.compile(r"(\w[\w+.\-]*://)[^/\s:@]+:[^/\s@]+@")
44
+ _SENSITIVE_KEY = re.compile(
45
+ r"\b([A-Za-z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|PWD|CREDENTIAL|CREDENTIALS|AUTH|APIKEY|PRIVATE)[A-Za-z0-9_]*)"
46
+ r"(\s*[=:]\s*)(\"[^\"]*\"|'[^']*'|\S+)",
47
+ re.I,
48
+ )
49
+ _SENSITIVE_FLAG = re.compile(
50
+ r"(--?(?:password|passwd|pwd|token|secret|api[-_]?key|access[-_]?key|auth)\b)(\s+|=)(\"[^\"]*\"|'[^']*'|\S+)",
51
+ re.I,
52
+ )
53
+ # MySQL/MariaDB attached-password flag: `-pVALUE` glued to the flag (no separator).
54
+ # Scoped to mysql-family commands so `cp -pr`, `mkdir -p`, `docker -p 8080:80`,
55
+ # `ssh -p22` are never touched. Value may be quoted (`-p"my pass"`).
56
+ _MYSQL_PW = re.compile(
57
+ r"(\b(?:mysql|mysqldump|mariadb|mysqladmin)\b[^\n]*?\s-p)(\"[^\"]*\"|'[^']*'|\S+)",
58
+ re.I,
59
+ )
60
+ # curl basic-auth: `-u user:pass` / `--user user:pass` (value may be glued to -u).
61
+ # Scoped to curl so `python -u`, `sort -u`, `docker run -u 1000:1000` survive.
62
+ _CURL_USERPASS = re.compile(
63
+ r"(\bcurl\b[^\n]*?\s(?:--user|-u)\s*)(\"[^\"]*\"|'[^']*'|\S+)",
64
+ re.I,
65
+ )
66
+ # AWS credential keys given WHITESPACE-separated (e.g. `aws configure set
67
+ # aws_secret_access_key <k>`); the `=`/`:` form is already caught by _SENSITIVE_KEY.
68
+ _AWS_CRED = re.compile(
69
+ r"\b(aws_secret_access_key|aws_access_key_id|aws_session_token)\b"
70
+ r"(\s+|\s*[=:]\s*)(\"[^\"]*\"|'[^']*'|\S+)",
71
+ re.I,
72
+ )
73
+ _AUTH_HEADER = re.compile(r"\b(authorization|x-api-key)\b(\s*:\s*|\s+)(?:bearer\s+)?\S+", re.I)
74
+ _BEARER = re.compile(r"\bbearer\s+[A-Za-z0-9._\-]+", re.I)
75
+ _TOKEN_PREFIX = re.compile(
76
+ r"\b(?:gh[posru]_[A-Za-z0-9]{6,}|sk-[A-Za-z0-9]{6,}|xox[baprs]-[A-Za-z0-9-]{6,}|AKIA[0-9A-Z]{12,})"
77
+ )
78
+ # Long high-entropy blob (a letter AND a digit, ≥40 chars). Excludes '/' so file paths survive.
79
+ _LONG_TOKEN = re.compile(r"\b(?=[A-Za-z0-9+=_\-]*[A-Za-z])(?=[A-Za-z0-9+=_\-]*[0-9])[A-Za-z0-9+=_\-]{40,}\b")
80
+
81
+ _SECRET_PATH = re.compile(
82
+ r"(?:^|[\s=:'\"/\\])\.env(?:\.|$|[\s'\"/\\])"
83
+ r"|[/\\]\.?secrets?[/\\]"
84
+ r"|\b(?:id_rsa|id_dsa|id_ecdsa|id_ed25519|credentials|\.npmrc|\.pypirc|\.netrc|\.pgpass|\.htpasswd)\b"
85
+ r"|\.(?:pem|key|p12|pfx|keystore|jks)\b",
86
+ re.I,
87
+ )
88
+
89
+
90
+ def redact(text: str) -> str:
91
+ """Scrub inline secrets from ``text`` so it's safe to store, surface, and commit.
92
+
93
+ Order matters: credentialed URLs first, then whole-value header/bearer forms (these must run
94
+ before the generic ``KEY=…`` rule, else "Authorization" matches as a key and only eats the
95
+ word "Bearer", leaking the token), then explicit ``KEY=…`` / ``--token …`` assignments, then
96
+ known token prefixes, then a catch-all for long high-entropy blobs. Conservative on file paths
97
+ (the blob rule excludes ``/``) so an error line stays useful.
98
+ """
99
+ if not text:
100
+ return ""
101
+ t = str(text)
102
+ t = _URL_CRED.sub(r"\1***:***@", t)
103
+ t = _AUTH_HEADER.sub(r"\1: ***", t)
104
+ t = _BEARER.sub("bearer ***", t)
105
+ t = _SENSITIVE_KEY.sub(r"\1=***", t)
106
+ t = _AWS_CRED.sub(r"\1 ***", t)
107
+ t = _SENSITIVE_FLAG.sub(r"\1 ***", t)
108
+ t = _CURL_USERPASS.sub(r"\1***", t)
109
+ t = _MYSQL_PW.sub(r"\1***", t)
110
+ t = _TOKEN_PREFIX.sub("***", t)
111
+ t = _LONG_TOKEN.sub("***", t)
112
+ return t
113
+
114
+
115
+ def touches_secret(command: str) -> bool:
116
+ """True if ``command`` references a secret file/path — such a fact is never stored at all."""
117
+ return bool(_SECRET_PATH.search(command or ""))
118
+
119
+
120
+ # --------------------------------------------------------------------------- #
121
+ # Text shaping
122
+ # --------------------------------------------------------------------------- #
123
+ def command_head(command: str, max_len: int = _HEAD_LEN) -> str:
124
+ """Whitespace-collapsed, length-capped command head — we store heads, not full arg lists."""
125
+ c = " ".join((command or "").split())
126
+ return c[:max_len].rstrip()
127
+
128
+
129
+ def command_fp(command: str) -> str:
130
+ """Stable dedup fingerprint of the FULL command (whitespace-collapsed, lowercased), hashed so two
131
+ distinct commands that share a truncated 80-char head don't merge into one inflated fact. Kept
132
+ separate from the display head on purpose. Pass the ALREADY-REDACTED command so no secret is hashed."""
133
+ norm = " ".join((command or "").split()).lower()
134
+ return hashlib.sha1(norm.encode("utf-8")).hexdigest()[:16]
135
+
136
+
137
+ def first_error_line(output: str, max_len: int = _ERR_LEN) -> str:
138
+ """First non-empty line of tool output, length-capped — the error's gist, not the whole dump."""
139
+ for line in (output or "").splitlines():
140
+ s = line.strip()
141
+ if s:
142
+ return s[:max_len]
143
+ return ""
144
+
145
+
146
+ # Commands whose non-zero exit is routine control flow / a search miss — not a durable lesson.
147
+ _NOISE_FIRST_TOKEN = frozenset({
148
+ "grep", "rg", "test", "[", "find", "ls", "cat", "which", "type", "head", "tail", "diff",
149
+ })
150
+
151
+ # Build/test commands — only these qualify for the red→green workflow-success signal.
152
+ _BUILD_TEST = re.compile(
153
+ r"\b(?:pytest|tox|nox|unittest|jest|vitest|mocha|"
154
+ r"npm\s+(?:run\s+\S+|test|ci)|yarn\s+\S+|pnpm\s+\S+|"
155
+ r"vite(?:\s+build)?|tsc\b|go\s+(?:test|build)|cargo\s+(?:test|build)|"
156
+ r"make\b|gradle\b|mvn\b|dotnet\s+(?:test|build)|rspec|phpunit)\b",
157
+ re.I,
158
+ )
159
+
160
+
161
+ def _is_noise_failure(command: str) -> bool:
162
+ toks = (command or "").strip().split()
163
+ return bool(toks) and toks[0] in _NOISE_FIRST_TOKEN
164
+
165
+
166
+ # --------------------------------------------------------------------------- #
167
+ # Transcript walking
168
+ # --------------------------------------------------------------------------- #
169
+ def _content_blocks(record: dict) -> list[dict]:
170
+ """The list of content blocks in a transcript record (handles the nested ``message`` shape)."""
171
+ if not isinstance(record, dict):
172
+ return []
173
+ msg = record.get("message") if isinstance(record.get("message"), dict) else record
174
+ content = msg.get("content") if isinstance(msg, dict) else None
175
+ if isinstance(content, list):
176
+ return [b for b in content if isinstance(b, dict)]
177
+ return []
178
+
179
+
180
+ def _result_text(block: dict) -> str:
181
+ """Flatten a tool_result's content (string or list-of-{text}) to plain text."""
182
+ c = block.get("content")
183
+ if isinstance(c, str):
184
+ return c
185
+ if isinstance(c, list):
186
+ parts = [item["text"] for item in c if isinstance(item, dict) and isinstance(item.get("text"), str)]
187
+ return "\n".join(parts)
188
+ return ""
189
+
190
+
191
+ def extract_facts(records: list[dict], observed_at: str) -> list[dict]:
192
+ """Mine deterministic fact candidates from parsed transcript ``records`` (in order).
193
+
194
+ Returns at most :data:`MAX_FACTS_PER_RUN` ``make_fact`` dicts. Pure: no I/O, no mutation of
195
+ inputs. Secret-touching commands are skipped wholesale; all stored text is redacted.
196
+ """
197
+ # Pass 1: map Bash tool_use id → command.
198
+ uses: dict[str, str] = {}
199
+ for rec in records:
200
+ for b in _content_blocks(rec):
201
+ if b.get("type") == "tool_use" and b.get("name") == "Bash":
202
+ tid = b.get("id")
203
+ inp = b.get("input") if isinstance(b.get("input"), dict) else {}
204
+ cmd = inp.get("command")
205
+ if tid and isinstance(cmd, str) and cmd.strip():
206
+ uses[tid] = cmd
207
+
208
+ # Pass 2: walk results in order — failures first feed the red→green check for later successes.
209
+ facts: list[dict] = []
210
+ failed_keys: set[str] = set()
211
+ emitted: set[str] = set()
212
+
213
+ for rec in records:
214
+ for b in _content_blocks(rec):
215
+ if b.get("type") != "tool_result":
216
+ continue
217
+ cmd = uses.get(b.get("tool_use_id"))
218
+ if not cmd or touches_secret(cmd):
219
+ continue # unknown tool, or a secret-touching command we refuse to record
220
+
221
+ red = redact(cmd)
222
+ head = command_head(red)
223
+ key = normalize_key(head)
224
+ if not key:
225
+ continue
226
+ fp = command_fp(red) # full-command dedup identity (the display key stays the 80-char head)
227
+
228
+ if bool(b.get("is_error")):
229
+ if _is_noise_failure(cmd):
230
+ continue
231
+ failed_keys.add(fp)
232
+ if fp in emitted:
233
+ continue # one failure candidate per distinct command per session
234
+ emitted.add(fp)
235
+ errline = redact(first_error_line(_result_text(b)))
236
+ summary = (f"`{head}` failed: {errline}" if errline else f"`{head}` failed")[:_SUMMARY_LEN]
237
+ facts.append(make_fact("failure-mode", key, summary, observed_at, source="auto", fp=fp))
238
+ else:
239
+ # Success → only a fact if a build/test command recovered from an earlier failure.
240
+ ok_key = "ok:" + fp
241
+ if _BUILD_TEST.search(cmd) and fp in failed_keys and ok_key not in emitted:
242
+ emitted.add(ok_key)
243
+ summary = (f"`{head}` passes now — was failing earlier this session")[:_SUMMARY_LEN]
244
+ facts.append(make_fact("workflow-success", key, summary, observed_at, source="auto", fp=fp))
245
+
246
+ if len(facts) >= MAX_FACTS_PER_RUN:
247
+ return facts
248
+
249
+ return facts