brigade-cli 0.5.0__py3-none-any.whl

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 (69) hide show
  1. brigade/__init__.py +3 -0
  2. brigade/__main__.py +5 -0
  3. brigade/cli.py +258 -0
  4. brigade/config.py +65 -0
  5. brigade/doctor.py +393 -0
  6. brigade/fragments.py +64 -0
  7. brigade/handoff.py +23 -0
  8. brigade/ingest.py +298 -0
  9. brigade/install.py +217 -0
  10. brigade/prompt.py +135 -0
  11. brigade/py.typed +0 -0
  12. brigade/reconfigure.py +64 -0
  13. brigade/registry.py +39 -0
  14. brigade/scrub.py +90 -0
  15. brigade/selection.py +66 -0
  16. brigade/station.py +36 -0
  17. brigade/status.py +24 -0
  18. brigade/templates/claude/memory-handoffs/TEMPLATE.md +57 -0
  19. brigade/templates/codex/memory-handoffs/TEMPLATE.md +57 -0
  20. brigade/templates/depth/repo.json +12 -0
  21. brigade/templates/depth/workspace.json +30 -0
  22. brigade/templates/generic/harness-adapter-checklist.md +55 -0
  23. brigade/templates/generic/memory-contract.md +41 -0
  24. brigade/templates/harnesses/claude.json +12 -0
  25. brigade/templates/harnesses/codex.json +11 -0
  26. brigade/templates/harnesses/hermes.json +16 -0
  27. brigade/templates/harnesses/openclaw.json +17 -0
  28. brigade/templates/hermes/README.md +25 -0
  29. brigade/templates/hermes/memory-handoff.harness.json +36 -0
  30. brigade/templates/hermes/model-lanes.harness.json +17 -0
  31. brigade/templates/hermes/workspace.harness.json +30 -0
  32. brigade/templates/hooks/pre-push +36 -0
  33. brigade/templates/includes/publisher.json +15 -0
  34. brigade/templates/memory/cards/backup-restic.md +126 -0
  35. brigade/templates/memory/cards/chat-surface-crawlers.md +103 -0
  36. brigade/templates/memory/cards/content-safety.md +54 -0
  37. brigade/templates/memory/cards/handoff-flow.md +70 -0
  38. brigade/templates/memory/cards/memory-architecture.md +56 -0
  39. brigade/templates/memory/cards/memory-care-staleness.md +58 -0
  40. brigade/templates/memory/cards/memory-scanner.md +98 -0
  41. brigade/templates/memory/cards/multi-workspace-handoff-admin.md +63 -0
  42. brigade/templates/memory/cards/obsidian-notes.md +82 -0
  43. brigade/templates/memory/cards/pipeline-standups.md +88 -0
  44. brigade/templates/memory/cards/tokenjuice-output-compaction.md +106 -0
  45. brigade/templates/openclaw/README.md +40 -0
  46. brigade/templates/openclaw/acp-escalation.openclaw.json +33 -0
  47. brigade/templates/openclaw/model-aliases.openclaw.json +21 -0
  48. brigade/templates/openclaw/ollama-memory-search.openclaw.json +24 -0
  49. brigade/templates/policies/public-content.json +28 -0
  50. brigade/templates/policies/public-repo.json +27 -0
  51. brigade/templates/scripts/backup-restic.sh +156 -0
  52. brigade/templates/skills/note/SKILL.md +173 -0
  53. brigade/templates/workspace/AGENTS.md +146 -0
  54. brigade/templates/workspace/CLAUDE.md +48 -0
  55. brigade/templates/workspace/HEARTBEAT.md +41 -0
  56. brigade/templates/workspace/IDENTITY.md +27 -0
  57. brigade/templates/workspace/INSTALL_FOR_AGENTS.md +61 -0
  58. brigade/templates/workspace/MEMORY.md +102 -0
  59. brigade/templates/workspace/SAFETY_RULES.md +164 -0
  60. brigade/templates/workspace/SOUL.md +92 -0
  61. brigade/templates/workspace/TOOLS.md +116 -0
  62. brigade/templates/workspace/USER.md +88 -0
  63. brigade/templates.py +88 -0
  64. brigade_cli-0.5.0.dist-info/METADATA +211 -0
  65. brigade_cli-0.5.0.dist-info/RECORD +69 -0
  66. brigade_cli-0.5.0.dist-info/WHEEL +5 -0
  67. brigade_cli-0.5.0.dist-info/entry_points.txt +3 -0
  68. brigade_cli-0.5.0.dist-info/licenses/LICENSE +21 -0
  69. brigade_cli-0.5.0.dist-info/top_level.txt +1 -0
brigade/doctor.py ADDED
@@ -0,0 +1,393 @@
1
+ """`brigade doctor` - verify a target workspace is wired correctly."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Callable, List, Tuple
11
+
12
+ CheckResult = Tuple[str, str, str] # (status, name, detail)
13
+ OK = "OK"
14
+ WARN = "WARN"
15
+ FAIL = "FAIL"
16
+ MANUAL = "MANUAL"
17
+
18
+ from .station import DoctorContext
19
+
20
+
21
+ def build_context(target: Path, harness: str = "generic") -> DoctorContext:
22
+ target = target.expanduser().resolve()
23
+ from .config import load_config
24
+
25
+ sel = None
26
+ try:
27
+ cfg = load_config(target)
28
+ except (ValueError, json.JSONDecodeError):
29
+ cfg = None
30
+ if cfg is not None:
31
+ sel = cfg.selection
32
+ harnesses = list(sel.harnesses)
33
+ elif harness in ("openclaw", "hermes"):
34
+ harnesses = ["claude", harness]
35
+ else:
36
+ harnesses = ["claude"]
37
+ return DoctorContext(target=target, selection=sel, harnesses=harnesses)
38
+
39
+
40
+ def core_station_checks(ctx: DoctorContext) -> List[CheckResult]:
41
+ checks: List[CheckResult] = []
42
+ checks.extend(_check_workspace_files(ctx.target))
43
+ if "openclaw" in ctx.harnesses:
44
+ checks.extend(_check_openclaw())
45
+ if "hermes" in ctx.harnesses:
46
+ checks.extend(_check_hermes(ctx.target))
47
+ checks.extend(_check_orphan_inboxes(ctx.target, ctx.harnesses))
48
+ return checks
49
+
50
+
51
+ def memory_station_checks(ctx: DoctorContext) -> List[CheckResult]:
52
+ checks: List[CheckResult] = []
53
+ checks.extend(_check_handoff_inboxes(ctx.target, ctx.selection, ctx.harnesses))
54
+ checks.extend(_check_memory_care(ctx.target))
55
+ return checks
56
+
57
+
58
+ def guard_station_checks(ctx: DoctorContext) -> List[CheckResult]:
59
+ return _check_publish_gate(ctx.target)
60
+
61
+
62
+ def run(target: Path, harness: str = "generic") -> int:
63
+ from .registry import all_stations
64
+
65
+ ctx = build_context(target, harness)
66
+ print(f"brigade doctor: target {ctx.target}")
67
+ if ctx.selection is not None:
68
+ sel = ctx.selection
69
+ print(
70
+ f" harnesses: {', '.join(sel.harnesses) or '(none)'} "
71
+ f"(owner={sel.owner}, depth={sel.depth})"
72
+ )
73
+ else:
74
+ print(
75
+ f" harnesses: (legacy target, no config; assuming {', '.join(ctx.harnesses)})"
76
+ )
77
+
78
+ checks: List[CheckResult] = []
79
+ for station in all_stations():
80
+ if station.doctor is not None:
81
+ checks.extend(station.doctor(ctx))
82
+ return _report(checks)
83
+
84
+
85
+ def _check_workspace_files(target: Path) -> List[CheckResult]:
86
+ results: List[CheckResult] = []
87
+ required = ["AGENTS.md"]
88
+ optional = [
89
+ "CLAUDE.md",
90
+ "MEMORY.md",
91
+ "TOOLS.md",
92
+ "USER.md",
93
+ "SAFETY_RULES.md",
94
+ "INSTALL_FOR_AGENTS.md",
95
+ ]
96
+ for name in required:
97
+ path = target / name
98
+ if path.is_file():
99
+ results.append((OK, f"bootstrap: {name}", str(path)))
100
+ else:
101
+ results.append((FAIL, f"bootstrap: {name}", f"missing at {path}"))
102
+ for name in optional:
103
+ path = target / name
104
+ if path.is_file():
105
+ results.append((OK, f"bootstrap: {name}", str(path)))
106
+ else:
107
+ results.append((WARN, f"bootstrap: {name}", f"not present at {path}"))
108
+ return results
109
+
110
+
111
+ # Writer harness -> inbox-dir prefix. Only writer harnesses have an inbox.
112
+ _WRITER_INBOXES = {
113
+ "claude": ".claude/memory-handoffs",
114
+ "codex": ".codex/memory-handoffs",
115
+ }
116
+
117
+
118
+ def _check_handoff_inboxes(
119
+ target: Path, sel, selected_harnesses: List[str]
120
+ ) -> List[CheckResult]:
121
+ results: List[CheckResult] = []
122
+ writers = selected_harnesses
123
+ for h in writers:
124
+ rel = _WRITER_INBOXES.get(h)
125
+ if rel is None:
126
+ continue # reader harness, no inbox
127
+ inbox = target / rel
128
+ if inbox.is_dir():
129
+ results.append((OK, f"handoff: {h} inbox", str(inbox)))
130
+ else:
131
+ results.append((FAIL, f"handoff: {h} inbox", f"missing at {inbox}"))
132
+ tmpl = inbox / "TEMPLATE.md"
133
+ if tmpl.is_file():
134
+ results.append((OK, f"handoff: {h} TEMPLATE.md", str(tmpl)))
135
+ else:
136
+ results.append(
137
+ (WARN, f"handoff: {h} TEMPLATE.md", f"missing at {tmpl}")
138
+ )
139
+ processed = inbox / "processed"
140
+ if processed.is_dir():
141
+ results.append((OK, f"handoff: {h} processed/", str(processed)))
142
+ else:
143
+ results.append(
144
+ (WARN, f"handoff: {h} processed/", f"missing at {processed}")
145
+ )
146
+ cards = target / "memory" / "cards"
147
+ if cards.is_dir():
148
+ results.append((OK, "memory: cards/", str(cards)))
149
+ else:
150
+ results.append(
151
+ (
152
+ WARN,
153
+ "memory: cards/",
154
+ f"missing at {cards}; ingester cannot promote cards",
155
+ )
156
+ )
157
+ return results
158
+
159
+
160
+ def _check_orphan_inboxes(
161
+ target: Path, selected_harnesses: List[str]
162
+ ) -> List[CheckResult]:
163
+ results: List[CheckResult] = []
164
+ for h, rel in _WRITER_INBOXES.items():
165
+ if h in selected_harnesses:
166
+ continue
167
+ inbox = target / rel
168
+ if inbox.is_dir():
169
+ results.append(
170
+ (
171
+ WARN,
172
+ f"orphan: {h} inbox",
173
+ f"{inbox} exists but {h} is not in config; "
174
+ f"remove or add to config (unselected harness)",
175
+ )
176
+ )
177
+ return results
178
+
179
+
180
+ def _check_memory_care(target: Path) -> List[CheckResult]:
181
+ results: List[CheckResult] = []
182
+ decay_dir = target / "memory" / "cards" / "decay"
183
+ scan = decay_dir / "scan-latest.json"
184
+ queue = decay_dir / "refresh-queue.json"
185
+
186
+ if decay_dir.is_dir():
187
+ results.append((OK, "memory-care: decay/", str(decay_dir)))
188
+ else:
189
+ results.append(
190
+ (
191
+ WARN,
192
+ "memory-care: decay/",
193
+ f"missing at {decay_dir}; staleness scanner not wired",
194
+ )
195
+ )
196
+ return results
197
+
198
+ if scan.is_file():
199
+ detail = str(scan)
200
+ try:
201
+ data = json.loads(scan.read_text())
202
+ scan_date = data.get("scan_date")
203
+ counts = data.get("counts", {})
204
+ if scan_date:
205
+ detail = f"{scan} (scan_date={scan_date}, stale={counts.get('stale', 'unknown')})"
206
+ except json.JSONDecodeError:
207
+ detail = f"invalid JSON: {scan}"
208
+ results.append((WARN, "memory-care: scan-latest", detail))
209
+ else:
210
+ results.append((OK, "memory-care: scan-latest", detail))
211
+ else:
212
+ results.append((WARN, "memory-care: scan-latest", f"missing at {scan}"))
213
+
214
+ if queue.is_file():
215
+ detail = str(queue)
216
+ try:
217
+ data = json.loads(queue.read_text())
218
+ cards = data.get("cards", [])
219
+ if isinstance(cards, list):
220
+ detail = f"{queue} ({len(cards)} queued)"
221
+ except json.JSONDecodeError:
222
+ detail = f"invalid JSON: {queue}"
223
+ results.append((WARN, "memory-care: refresh-queue", detail))
224
+ else:
225
+ results.append((OK, "memory-care: refresh-queue", detail))
226
+ else:
227
+ results.append((WARN, "memory-care: refresh-queue", f"missing at {queue}"))
228
+
229
+ return results
230
+
231
+
232
+ def _check_publish_gate(target: Path) -> List[CheckResult]:
233
+ results: List[CheckResult] = []
234
+ hook = target / "hooks" / "pre-push"
235
+ if hook.is_file():
236
+ results.append((OK, "publish: hooks/pre-push", str(hook)))
237
+ if not os.access(hook, os.X_OK):
238
+ results.append(
239
+ (WARN, "publish: hooks/pre-push", "exists but not executable; run `chmod +x hooks/pre-push`")
240
+ )
241
+ else:
242
+ results.append((WARN, "publish: hooks/pre-push", f"missing at {hook}"))
243
+
244
+ scanner_dir = Path(os.environ.get("CONTENT_GUARD_DIR", str(Path.home() / "repos" / "content-guard")))
245
+ if scanner_dir.is_dir():
246
+ results.append((OK, "publish: content-guard", str(scanner_dir)))
247
+ else:
248
+ results.append(
249
+ (MANUAL, "publish: content-guard", f"not found at {scanner_dir}; install or set CONTENT_GUARD_DIR")
250
+ )
251
+ return results
252
+
253
+
254
+ def _check_openclaw() -> List[CheckResult]:
255
+ """Inspect ~/.openclaw/openclaw.json for the wiring brigade expects."""
256
+ results: List[CheckResult] = []
257
+ config = Path.home() / ".openclaw" / "openclaw.json"
258
+ if not config.is_file():
259
+ results.append((MANUAL, "openclaw: config", f"not found at {config}; install OpenClaw first"))
260
+ return results
261
+ try:
262
+ data = json.loads(config.read_text())
263
+ except json.JSONDecodeError as exc:
264
+ results.append((FAIL, "openclaw: config", f"invalid JSON: {exc}"))
265
+ return results
266
+ results.append((OK, "openclaw: config", str(config)))
267
+
268
+ plugins = data.get("plugins", {}).get("entries", {})
269
+ if plugins:
270
+ results.append((OK, "openclaw: plugins", f"{len(plugins)} entries"))
271
+ else:
272
+ results.append((WARN, "openclaw: plugins", "no plugin entries configured"))
273
+
274
+ primary = (
275
+ data.get("agents", {}).get("defaults", {}).get("model", {}).get("primary")
276
+ )
277
+ if primary:
278
+ results.append((OK, "openclaw: primary model", primary))
279
+ else:
280
+ results.append((WARN, "openclaw: primary model", "agents.defaults.model.primary unset"))
281
+
282
+ # jq sanity (optional)
283
+ if shutil.which("jq"):
284
+ results.append((OK, "openclaw: jq", "present"))
285
+ else:
286
+ results.append((WARN, "openclaw: jq", "missing; merge helpers will not work"))
287
+ results.extend(_check_openclaw_cron_jobs())
288
+ return results
289
+
290
+
291
+ def _check_openclaw_cron_jobs() -> List[CheckResult]:
292
+ results: List[CheckResult] = []
293
+ jobs_path = Path.home() / ".openclaw" / "cron" / "jobs.json"
294
+ if not jobs_path.is_file():
295
+ return [
296
+ (
297
+ WARN,
298
+ "openclaw: cron jobs",
299
+ f"not found at {jobs_path}; handoff ingest and memory-care schedules unknown",
300
+ )
301
+ ]
302
+
303
+ try:
304
+ data = json.loads(jobs_path.read_text())
305
+ except json.JSONDecodeError as exc:
306
+ return [(WARN, "openclaw: cron jobs", f"invalid JSON: {exc}")]
307
+
308
+ jobs = data.get("jobs", [])
309
+ if not isinstance(jobs, list):
310
+ return [(WARN, "openclaw: cron jobs", "jobs.json has no jobs array")]
311
+
312
+ expected = [
313
+ ("openclaw: handoff ingest cron", "Claude Memory Handoff Ingest"),
314
+ ("openclaw: card decay scanner", "Card Decay Scanner (Daily)"),
315
+ ("openclaw: card decay refresh", "Card Decay Auto-Refresh (Safe)"),
316
+ ]
317
+ for check_name, job_name in expected:
318
+ job = _find_job(jobs, job_name)
319
+ if job is None:
320
+ results.append((WARN, check_name, f"missing job named {job_name!r}"))
321
+ continue
322
+ if not job.get("enabled", False):
323
+ results.append((WARN, check_name, f"{job_name!r} exists but is disabled"))
324
+ continue
325
+ results.append((OK, check_name, _format_schedule(job.get("schedule"))))
326
+
327
+ weekly = _find_job(jobs, "Card Decay Deep Report (Weekly)")
328
+ if weekly is not None and weekly.get("enabled", False):
329
+ results.append((OK, "openclaw: card decay weekly", _format_schedule(weekly.get("schedule"))))
330
+ return results
331
+
332
+
333
+ def _find_job(jobs: list, name: str) -> dict | None:
334
+ for job in jobs:
335
+ if isinstance(job, dict) and job.get("name") == name:
336
+ return job
337
+ return None
338
+
339
+
340
+ def _format_schedule(schedule) -> str:
341
+ if not isinstance(schedule, dict):
342
+ return "enabled; schedule not specified"
343
+ kind = schedule.get("kind")
344
+ if kind == "cron":
345
+ return f"enabled; cron {schedule.get('expr', '<missing expr>')} {schedule.get('tz', '')}".strip()
346
+ if kind == "every":
347
+ every_ms = schedule.get("everyMs")
348
+ if isinstance(every_ms, int):
349
+ return f"enabled; every {every_ms // 60000} min"
350
+ return "enabled; every schedule"
351
+ return f"enabled; {kind or 'unknown'} schedule"
352
+
353
+
354
+ def _check_hermes(target: Path) -> List[CheckResult]:
355
+ results: List[CheckResult] = []
356
+ fragments_dir = target / ".brigade" / "hermes"
357
+ expected = [
358
+ "workspace.harness.json",
359
+ "memory-handoff.harness.json",
360
+ "model-lanes.harness.json",
361
+ ]
362
+ for name in expected:
363
+ path = fragments_dir / name
364
+ if path.is_file():
365
+ results.append((OK, f"hermes: {name}", str(path)))
366
+ else:
367
+ results.append((WARN, f"hermes: {name}", f"missing at {path}; run `brigade hermes-fragments`"))
368
+ results.append(
369
+ (MANUAL, "hermes: install validation", "Hermes adapter is experimental; validate against your install")
370
+ )
371
+ return results
372
+
373
+
374
+ def _report(checks: List[CheckResult]) -> int:
375
+ width = max((len(name) for _, name, _ in checks), default=20)
376
+ failed = 0
377
+ manual = 0
378
+ for status, name, detail in checks:
379
+ marker = {
380
+ OK: " [ok] ",
381
+ WARN: " [warn]",
382
+ FAIL: " [fail]",
383
+ MANUAL: " [todo]",
384
+ }[status]
385
+ print(f"{marker} {name.ljust(width)} {detail}")
386
+ if status == FAIL:
387
+ failed += 1
388
+ elif status == MANUAL:
389
+ manual += 1
390
+ print()
391
+ summary = f"summary: {len(checks)} checks, {failed} failed, {manual} manual"
392
+ print(summary)
393
+ return 1 if failed else 0
brigade/fragments.py ADDED
@@ -0,0 +1,64 @@
1
+ """`brigade openclaw-fragments` / `hermes-fragments` - write config fragments.
2
+
3
+ These never mutate a live config. They drop JSON fragments into the chosen
4
+ output directory so the user can `jq -s '.[0] * .[1]'` them in by hand.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import shutil
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ from .templates import template_root
13
+
14
+
15
+ HARNESS_FILES = {
16
+ "openclaw": [
17
+ "model-aliases.openclaw.json",
18
+ "ollama-memory-search.openclaw.json",
19
+ "acp-escalation.openclaw.json",
20
+ "README.md",
21
+ ],
22
+ "hermes": [
23
+ "workspace.harness.json",
24
+ "memory-handoff.harness.json",
25
+ "model-lanes.harness.json",
26
+ "README.md",
27
+ ],
28
+ }
29
+
30
+
31
+ def write_fragments(out: Path, harness: str) -> int:
32
+ if harness not in HARNESS_FILES:
33
+ print(f"brigade: unknown harness: {harness}", file=sys.stderr)
34
+ return 2
35
+
36
+ out = out.expanduser().resolve()
37
+ out.mkdir(parents=True, exist_ok=True)
38
+
39
+ src_dir = template_root() / harness
40
+ for name in HARNESS_FILES[harness]:
41
+ src = src_dir / name
42
+ if not src.is_file():
43
+ print(f"brigade: template missing: {src}", file=sys.stderr)
44
+ return 3
45
+ dest = out / name
46
+ shutil.copyfile(src, dest)
47
+
48
+ print(f"brigade: wrote {harness} fragments to {out}")
49
+ print()
50
+ print("Next steps:")
51
+ print(f" - inspect each fragment under {out}")
52
+ if harness == "openclaw":
53
+ print(
54
+ " - merge with: jq -s '.[0] * .[1]' ~/.openclaw/openclaw.json "
55
+ f"{out}/<fragment>.json > /tmp/merged.json"
56
+ )
57
+ print(
58
+ " - verify with: brigade doctor --target ~/.openclaw/workspace --harness openclaw"
59
+ )
60
+ elif harness == "hermes":
61
+ print(
62
+ " - the Hermes adapter is experimental; validate against your real Hermes install"
63
+ )
64
+ return 0
brigade/handoff.py ADDED
@@ -0,0 +1,23 @@
1
+ """`brigade handoff-template` - print the handoff TEMPLATE.md to stdout."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from .templates import template_root
8
+
9
+
10
+ def run(target: Path | None = None) -> int:
11
+ """Print the handoff template. If `target` is given and has its own
12
+ TEMPLATE.md, that copy is preferred (matches the user's installed version)."""
13
+ if target is not None:
14
+ local = target.expanduser().resolve() / ".claude" / "memory-handoffs" / "TEMPLATE.md"
15
+ if local.is_file():
16
+ sys.stdout.write(local.read_text())
17
+ return 0
18
+ packaged = template_root() / "claude" / "memory-handoffs" / "TEMPLATE.md"
19
+ if not packaged.is_file():
20
+ print("error: packaged TEMPLATE.md missing", file=sys.stderr)
21
+ return 1
22
+ sys.stdout.write(packaged.read_text())
23
+ return 0