@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.
- package/bundles/antigravity-plugin/agents/claude-md-curator.md +1 -1
- package/bundles/antigravity-plugin/caddis_statusline.py +803 -0
- package/bundles/antigravity-plugin/claude-md/folder-agents.md.tmpl +21 -0
- package/bundles/antigravity-plugin/guard_agy.py +23 -4
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +524 -0
- package/bundles/antigravity-plugin/scripts/caddis_inventory.py +369 -0
- package/bundles/antigravity-plugin/scripts/caddis_migrate_dir.py +270 -0
- package/bundles/antigravity-plugin/scripts/caddis_spawn.py +300 -0
- package/bundles/antigravity-plugin/scripts/caddis_statusline.py +803 -0
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +366 -0
- package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +924 -0
- package/bundles/antigravity-plugin/scripts/claude-deepseek.ps1 +4 -0
- package/bundles/antigravity-plugin/scripts/claude-deepseek.sh +3 -0
- package/bundles/antigravity-plugin/scripts/claude-glm.ps1 +4 -0
- package/bundles/antigravity-plugin/scripts/claude-glm.sh +3 -0
- package/bundles/antigravity-plugin/scripts/claude-oss.ps1 +64 -0
- package/bundles/antigravity-plugin/scripts/claude-oss.sh +60 -0
- package/bundles/antigravity-plugin/scripts/claudster_config.py +126 -0
- package/bundles/antigravity-plugin/scripts/claudster_doctor.py +356 -0
- package/bundles/antigravity-plugin/scripts/claudster_init.py +400 -0
- package/bundles/antigravity-plugin/scripts/claudster_migrate_rules.py +300 -0
- package/bundles/antigravity-plugin/scripts/dream_capture.py +249 -0
- package/bundles/antigravity-plugin/scripts/dream_memory.py +438 -0
- package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +261 -0
- package/bundles/antigravity-plugin/scripts/hook_log.py +156 -0
- package/bundles/antigravity-plugin/scripts/oss_ask.py +98 -0
- package/bundles/antigravity-plugin/scripts/oss_model.py +166 -0
- package/bundles/antigravity-plugin/scripts/oss_review.py +718 -0
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +1429 -0
- package/bundles/antigravity-plugin/scripts/usage_review.py +1323 -0
- package/bundles/antigravity-plugin/session_end_agy.py +21 -2
- package/bundles/antigravity-plugin/skills/_registry.md +2 -0
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/succession.md +22 -0
- package/bundles/antigravity-plugin/skills/fastapi-dev/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/spawn-hub/SKILL.md +33 -0
- package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +213 -0
- package/bundles/antigravity-plugin/skills/statusline/SKILL.md +105 -0
- package/bundles/antigravity-plugin/skills/use-model/SKILL.md +15 -3
- package/bundles/antigravity-plugin/warm_start_agy.py +21 -2
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/architecture-design/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/caching-patterns/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/webapp-development/SKILL.md +1 -1
- package/bundles/manifest.json +3 -3
- package/dist/claude-deepseek.js +544 -0
- package/dist/claude-glm.js +544 -0
- package/dist/claude-oss.js +544 -0
- package/dist/cli.js +375 -46
- package/package.json +5 -2
- 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
|