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/ingest.py ADDED
@@ -0,0 +1,298 @@
1
+ """`brigade ingest` - route .claude/memory-handoffs/*.md into canonical memory.
2
+
3
+ Conservative by design:
4
+ - auto-promote handoffs with safe card filenames + YAML frontmatter
5
+ - append-only routing for TOOLS.md, USER.md, rules/*.md, .learnings/*.md
6
+ - everything ambiguous lands in memory/handoff-inbox/ for manual review
7
+ - processed files move to .claude/memory-handoffs/processed/
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import shutil
13
+ import sys
14
+ from dataclasses import dataclass, field
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+ from typing import Dict, List
18
+
19
+ SECTION_RE = re.compile(r"^##\s+(?P<name>.+?)\s*$", re.MULTILINE)
20
+ SAFE_CARD_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+\.md$")
21
+ SAFE_RULE_PATH_RE = re.compile(r"^rules/[A-Za-z0-9._-]+\.md$")
22
+ SAFE_SPECIAL_TARGETS = {
23
+ "TOOLS.md",
24
+ "USER.md",
25
+ ".learnings/LEARNINGS.md",
26
+ ".learnings/ERRORS.md",
27
+ ".learnings/FEATURE_REQUESTS.md",
28
+ }
29
+
30
+ # Writer harness id -> inbox dir (mirror of install._WRITER_INBOX).
31
+ _WRITER_INBOXES = {
32
+ "claude": ".claude/memory-handoffs",
33
+ "codex": ".codex/memory-handoffs",
34
+ }
35
+
36
+ # Recognized handoff sections. Any section name outside this set is a signal
37
+ # that the parser split content at an internal `##` heading; route to inbox.
38
+ KNOWN_SECTIONS = {
39
+ "type",
40
+ "title",
41
+ "summary",
42
+ "durable facts",
43
+ "evidence",
44
+ "recommended memory action",
45
+ "target card",
46
+ "suggested card content",
47
+ "target document",
48
+ "suggested document content",
49
+ }
50
+
51
+
52
+ @dataclass
53
+ class IngestStats:
54
+ processed: int = 0
55
+ promoted: int = 0
56
+ routed: int = 0
57
+ inboxed: int = 0
58
+ skipped: int = 0
59
+ actions: List[str] = field(default_factory=list)
60
+
61
+
62
+ def _resolve_inbox_paths(target: Path) -> list[Path]:
63
+ """Return handoff inbox directories for `target` in deterministic order.
64
+
65
+ Reads `.brigade/config.json` when present and returns one inbox per
66
+ writer harness in the selection (alphabetical by harness id). Falls back
67
+ to the legacy `.claude/memory-handoffs/` path for pre-v0.3.0 installs.
68
+ """
69
+ from .config import load_config
70
+
71
+ cfg = load_config(target)
72
+ if cfg is None:
73
+ legacy = target / ".claude" / "memory-handoffs"
74
+ return [legacy] if legacy.is_dir() else []
75
+ paths: list[Path] = []
76
+ for h in sorted(cfg.selection.harnesses):
77
+ rel = _WRITER_INBOXES.get(h)
78
+ if rel and (target / rel).is_dir():
79
+ paths.append(target / rel)
80
+ return paths
81
+
82
+
83
+ def run(
84
+ target: Path,
85
+ dry_run: bool = False,
86
+ promote_cards: bool = False,
87
+ route_documents: bool = False,
88
+ ) -> int:
89
+ """Process handoffs.
90
+
91
+ `promote_cards` and `route_documents` are opt-in. With neither flag,
92
+ every handoff routes to the review inbox so a human picks the action.
93
+ Match the cookbook wrapper by passing both flags explicitly.
94
+ """
95
+ target = target.expanduser().resolve()
96
+ inbox_dir = target / "memory" / "handoff-inbox"
97
+
98
+ handoff_dirs = _resolve_inbox_paths(target)
99
+ if not handoff_dirs:
100
+ legacy = target / ".claude" / "memory-handoffs"
101
+ print(f"brigade ingest: no handoff inbox at {legacy}", file=sys.stderr)
102
+ return 2
103
+
104
+ stats = IngestStats()
105
+
106
+ for handoffs_dir in handoff_dirs:
107
+ processed_dir = handoffs_dir / "processed"
108
+ for path in _list_handoffs(handoffs_dir):
109
+ stats.processed += 1
110
+ sections = parse(path)
111
+ outcome = decide(
112
+ sections,
113
+ target=target,
114
+ promote_cards=promote_cards,
115
+ route_documents=route_documents,
116
+ )
117
+ action = _execute(
118
+ outcome,
119
+ handoff_path=path,
120
+ target=target,
121
+ sections=sections,
122
+ inbox_dir=inbox_dir,
123
+ processed_dir=processed_dir,
124
+ dry_run=dry_run,
125
+ )
126
+ stats.actions.append(action.summary)
127
+ if action.kind == "promoted":
128
+ stats.promoted += 1
129
+ elif action.kind == "routed":
130
+ stats.routed += 1
131
+ elif action.kind == "inboxed":
132
+ stats.inboxed += 1
133
+ elif action.kind == "skipped":
134
+ stats.skipped += 1
135
+
136
+ _report(stats, dry_run=dry_run)
137
+ return 0
138
+
139
+
140
+ def parse(path: Path) -> Dict[str, str]:
141
+ """Split a handoff into sections keyed by lowercased ## heading."""
142
+ body = path.read_text()
143
+ sections: Dict[str, str] = {}
144
+ last_name = None
145
+ last_pos = 0
146
+ for m in SECTION_RE.finditer(body):
147
+ if last_name is not None:
148
+ sections[last_name.lower()] = body[last_pos : m.start()].strip()
149
+ last_name = m.group("name")
150
+ last_pos = m.end()
151
+ if last_name is not None:
152
+ sections[last_name.lower()] = body[last_pos:].strip()
153
+ return sections
154
+
155
+
156
+ @dataclass
157
+ class Outcome:
158
+ kind: str # promoted | routed | inboxed | skipped
159
+ dest: Path | None = None
160
+ reason: str = ""
161
+
162
+
163
+ def decide(
164
+ sections: Dict[str, str],
165
+ target: Path,
166
+ promote_cards: bool,
167
+ route_documents: bool,
168
+ ) -> Outcome:
169
+ action = sections.get("recommended memory action", "").strip().lower()
170
+
171
+ stray = [s for s in sections if s not in KNOWN_SECTIONS]
172
+ if stray:
173
+ return Outcome(
174
+ "inboxed",
175
+ reason=f"unknown sections present (parser may have split content): {stray}",
176
+ )
177
+
178
+ if action in ("create-card", "update-card") and promote_cards:
179
+ card = sections.get("target card", "").strip()
180
+ content = sections.get("suggested card content", "")
181
+ if not SAFE_CARD_NAME_RE.match(card):
182
+ return Outcome("inboxed", reason=f"target card name unsafe: {card!r}")
183
+ if not content.lstrip().startswith("---"):
184
+ return Outcome("inboxed", reason="card content missing YAML frontmatter")
185
+ return Outcome("promoted", dest=target / "memory" / "cards" / card)
186
+
187
+ if action == "no-card" and route_documents:
188
+ document = sections.get("target document", "").strip()
189
+ content = sections.get("suggested document content", "")
190
+ if not document:
191
+ return Outcome("inboxed", reason="no-card handoff missing target document")
192
+ if not (document in SAFE_SPECIAL_TARGETS or SAFE_RULE_PATH_RE.match(document)):
193
+ return Outcome("inboxed", reason=f"target document not in safe list: {document!r}")
194
+ if not content.strip():
195
+ return Outcome("inboxed", reason="empty document content")
196
+ if "\n## " in ("\n" + content):
197
+ return Outcome(
198
+ "inboxed",
199
+ reason="document content contains `##` headings (would parse as new section)",
200
+ )
201
+ return Outcome("routed", dest=target / document)
202
+
203
+ if action == "":
204
+ return Outcome("inboxed", reason="missing `Recommended memory action`")
205
+ return Outcome("skipped", reason=f"action {action!r} not auto-handled")
206
+
207
+
208
+ @dataclass
209
+ class Action:
210
+ kind: str
211
+ summary: str
212
+
213
+
214
+ def _execute(
215
+ outcome: Outcome,
216
+ handoff_path: Path,
217
+ target: Path,
218
+ sections: Dict[str, str],
219
+ inbox_dir: Path,
220
+ processed_dir: Path,
221
+ dry_run: bool,
222
+ ) -> Action:
223
+ name = handoff_path.name
224
+
225
+ if outcome.kind == "promoted":
226
+ dest = outcome.dest # type: ignore[assignment]
227
+ assert dest is not None
228
+ content = sections.get("suggested card content", "").strip() + "\n"
229
+ summary = f"promote → {dest.relative_to(target)} ({name})"
230
+ if not dry_run:
231
+ dest.parent.mkdir(parents=True, exist_ok=True)
232
+ dest.write_text(content)
233
+ _archive(handoff_path, processed_dir)
234
+ return Action("promoted", summary)
235
+
236
+ if outcome.kind == "routed":
237
+ dest = outcome.dest # type: ignore[assignment]
238
+ assert dest is not None
239
+ content = sections.get("suggested document content", "").strip()
240
+ summary = f"append → {dest.relative_to(target)} ({name})"
241
+ if not dry_run:
242
+ dest.parent.mkdir(parents=True, exist_ok=True)
243
+ with dest.open("a") as f:
244
+ f.write("\n\n" + content + "\n")
245
+ _archive(handoff_path, processed_dir)
246
+ return Action("routed", summary)
247
+
248
+ if outcome.kind in ("inboxed", "skipped"):
249
+ slug = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
250
+ dest = inbox_dir / f"{slug}-{handoff_path.stem}.md"
251
+ verb = "inbox" if outcome.kind == "inboxed" else "skip-inbox"
252
+ summary = f"{verb} → {dest.relative_to(target)} ({name}; {outcome.reason})"
253
+ if not dry_run:
254
+ inbox_dir.mkdir(parents=True, exist_ok=True)
255
+ # Copy the original file verbatim so reviewers see what the
256
+ # harness actually wrote, not a reconstruction.
257
+ original = handoff_path.read_text()
258
+ header = (
259
+ f"<!-- routed from {handoff_path.name}\n"
260
+ f" reason: {outcome.reason}\n"
261
+ f" routed-at: {slug} -->\n\n"
262
+ )
263
+ dest.write_text(header + original)
264
+ _archive(handoff_path, processed_dir)
265
+ # Both inboxed and skipped are now archived; classify them uniformly.
266
+ return Action("inboxed", summary)
267
+
268
+ return Action("skipped", f"skip {name} ({outcome.reason})")
269
+
270
+
271
+ def _list_handoffs(handoffs_dir: Path) -> List[Path]:
272
+ out: List[Path] = []
273
+ for p in sorted(handoffs_dir.iterdir()):
274
+ if p.is_file() and p.suffix == ".md" and p.name != "TEMPLATE.md":
275
+ out.append(p)
276
+ return out
277
+
278
+
279
+ def _archive(src: Path, processed_dir: Path) -> None:
280
+ processed_dir.mkdir(parents=True, exist_ok=True)
281
+ dest = processed_dir / src.name
282
+ if dest.exists():
283
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
284
+ dest = processed_dir / f"{src.stem}-{stamp}{src.suffix}"
285
+ shutil.move(str(src), dest)
286
+
287
+
288
+ def _report(stats: IngestStats, dry_run: bool) -> None:
289
+ tag = "[dry-run] " if dry_run else ""
290
+ for line in stats.actions:
291
+ print(f" {tag}{line}")
292
+ print()
293
+ print(
294
+ f"{tag}Processed {stats.processed} Promoted {stats.promoted} "
295
+ f"Routed {stats.routed} Inboxed {stats.inboxed} Skipped {stats.skipped}"
296
+ )
297
+ if stats.processed == 0:
298
+ print(f"{tag}NO_UPDATES")
brigade/install.py ADDED
@@ -0,0 +1,217 @@
1
+ """install_selection - the new install engine.
2
+
3
+ Composes a depth manifest + N harness manifests + M include manifests
4
+ into a single deduped file/dir list, then copies+renders into target.
5
+ Persists the Selection to .brigade/config.json.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import shutil
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import List, Tuple
14
+
15
+ from .config import Config, write_config
16
+ from .selection import Selection
17
+ from .templates import (
18
+ harness_memory_owner,
19
+ is_text,
20
+ load_depth_manifest,
21
+ load_harness_manifest,
22
+ load_include_manifest,
23
+ render,
24
+ template_root,
25
+ )
26
+
27
+ GITIGNORE_BEGIN = "# >>> brigade gitignore block >>>"
28
+ GITIGNORE_END = "# <<< brigade gitignore block <<<"
29
+
30
+ # Writer harness -> inbox-dir prefix. Only writer harnesses have an inbox.
31
+ _WRITER_INBOX = {
32
+ "claude": ".claude/memory-handoffs",
33
+ "codex": ".codex/memory-handoffs",
34
+ }
35
+
36
+
37
+ def build_gitignore_block(selection: Selection) -> str:
38
+ lines = [
39
+ GITIGNORE_BEGIN,
40
+ "# Managed by `brigade init`. Edit between the markers to customize.",
41
+ "# Re-running `brigade init` replaces only the content between markers.",
42
+ "",
43
+ ]
44
+ for h in selection.harnesses:
45
+ inbox = _WRITER_INBOX.get(h)
46
+ if inbox:
47
+ lines.extend([
48
+ f"# {h}: handoffs are session-local and may contain private context.",
49
+ f"{inbox}/*",
50
+ f"!{inbox}/TEMPLATE.md",
51
+ f"!{inbox}/.gitkeep",
52
+ "",
53
+ ])
54
+ lines.extend([
55
+ "# Daily session logs are machine-local raw context.",
56
+ "memory/20[0-9][0-9]-[0-1][0-9]-[0-3][0-9].md",
57
+ "",
58
+ "# Review inbox: ambiguous handoffs awaiting human triage.",
59
+ "memory/handoff-inbox/",
60
+ "",
61
+ "# brigade local state (logs, scrub cache).",
62
+ ".brigade/logs/",
63
+ ".brigade/scrub-cache/",
64
+ GITIGNORE_END,
65
+ "",
66
+ ])
67
+ return "\n".join(lines)
68
+
69
+
70
+ def apply_gitignore(target: Path, selection: Selection) -> str:
71
+ """Insert or replace the managed block in target's .gitignore. Returns 'created' or 'updated'."""
72
+ gi = target / ".gitignore"
73
+ block = build_gitignore_block(selection)
74
+ if not gi.exists():
75
+ gi.write_text(block)
76
+ return "created"
77
+ existing = gi.read_text()
78
+ if GITIGNORE_BEGIN in existing and GITIGNORE_END in existing:
79
+ prefix, _, rest = existing.partition(GITIGNORE_BEGIN)
80
+ _, _, suffix = rest.partition(GITIGNORE_END)
81
+ # Strip a trailing newline from prefix and a leading newline from suffix to avoid drift.
82
+ new_text = prefix.rstrip("\n") + ("\n\n" if prefix.strip() else "") + block + suffix.lstrip("\n")
83
+ gi.write_text(new_text)
84
+ return "updated"
85
+ sep = "" if existing.endswith("\n") else "\n"
86
+ gi.write_text(existing + sep + "\n" + block)
87
+ return "updated"
88
+
89
+
90
+ def resolve_manifests(selection: Selection) -> Tuple[List[dict], List[str], List[str]]:
91
+ """Return (files, dirs, post_install_notes) for a Selection.
92
+
93
+ Files are deduped by `dst`: later manifests win, so a harness can
94
+ override a depth-baseline file by referencing the same dst.
95
+ """
96
+ files: List[dict] = []
97
+ dirs: List[str] = []
98
+ notes: List[str] = []
99
+
100
+ depth_manifest = load_depth_manifest(selection.depth)
101
+ files.extend(depth_manifest.get("files", []))
102
+ dirs.extend(depth_manifest.get("dirs", []))
103
+ notes.extend(depth_manifest.get("post_install_notes", []))
104
+
105
+ for harness_id in selection.harnesses:
106
+ m = load_harness_manifest(harness_id)
107
+ files.extend(m.get("files", []))
108
+ dirs.extend(m.get("dirs", []))
109
+ notes.extend(m.get("post_install_notes", []))
110
+
111
+ for include_id in selection.includes:
112
+ m = load_include_manifest(include_id)
113
+ files.extend(m.get("files", []))
114
+ dirs.extend(m.get("dirs", []))
115
+ notes.extend(m.get("post_install_notes", []))
116
+
117
+ # Dedupe files by dst (last-wins).
118
+ seen: dict[str, dict] = {}
119
+ for entry in files:
120
+ seen[entry["dst"]] = entry
121
+ deduped_files = list(seen.values())
122
+ deduped_dirs = sorted(set(dirs))
123
+
124
+ return deduped_files, deduped_dirs, notes
125
+
126
+
127
+ def install_selection(
128
+ target: Path,
129
+ selection: Selection,
130
+ force: bool = False,
131
+ dry_run: bool = False,
132
+ allow_home: bool = False,
133
+ ) -> int:
134
+ """Install a Selection into `target`. Returns process exit code."""
135
+ selection.validate()
136
+ target = target.expanduser().resolve()
137
+
138
+ if target == Path.home() and not allow_home:
139
+ print(
140
+ f"error: refusing to install directly into $HOME ({target}).",
141
+ file=sys.stderr,
142
+ )
143
+ return 5
144
+
145
+ files, dirs, notes = resolve_manifests(selection)
146
+
147
+ if dry_run:
148
+ print(f"[dry-run] target: {target}")
149
+ print(f"[dry-run] depth: {selection.depth}")
150
+ print(f"[dry-run] harnesses: {','.join(selection.harnesses) or '(none)'}")
151
+ print(f"[dry-run] owner: {selection.owner}")
152
+ print(f"[dry-run] includes: {','.join(selection.includes) or '(none)'}")
153
+ print(f"[dry-run] would create {len(dirs)} dir(s) and {len(files)} file(s)")
154
+ for d in dirs:
155
+ print(f" dir {target / d}")
156
+ for entry in files:
157
+ print(f" file {target / entry['dst']}")
158
+ return 0
159
+
160
+ target.mkdir(parents=True, exist_ok=True)
161
+
162
+ if not force:
163
+ conflicts = [target / f["dst"] for f in files if (target / f["dst"]).exists()]
164
+ if conflicts:
165
+ print("error: refusing to overwrite existing files (use --force):", file=sys.stderr)
166
+ for c in conflicts:
167
+ print(f" {c}", file=sys.stderr)
168
+ return 3
169
+
170
+ for d in dirs:
171
+ (target / d).mkdir(parents=True, exist_ok=True)
172
+
173
+ owner_label = harness_memory_owner(selection.owner, selection.owner)
174
+ context = {
175
+ "memory_owner": selection.owner,
176
+ "memory_owner_name": owner_label,
177
+ "harness": selection.owner,
178
+ }
179
+
180
+ root = template_root()
181
+ for entry in files:
182
+ src = root / entry["src"]
183
+ dst = target / entry["dst"]
184
+ if not src.is_file():
185
+ print(f"error: template missing: {src}", file=sys.stderr)
186
+ return 4
187
+ dst.parent.mkdir(parents=True, exist_ok=True)
188
+ if is_text(entry["src"]):
189
+ dst.write_text(render(src.read_text(), context))
190
+ else:
191
+ shutil.copyfile(src, dst)
192
+ mode_str = entry.get("mode")
193
+ if mode_str:
194
+ os.chmod(dst, int(mode_str, 8))
195
+
196
+ # Persist config.json.
197
+ write_config(target, Config(version=1, selection=selection))
198
+
199
+ result = apply_gitignore(target, selection)
200
+ print(f"brigade: gitignore {result}")
201
+
202
+ # Post-install output.
203
+ print(f"brigade: installed depth={selection.depth} harnesses={','.join(selection.harnesses) or '(none)'} -> {target}")
204
+ print(f"brigade: memory owner -> {owner_label}")
205
+ if "hermes" in selection.harnesses:
206
+ print(
207
+ "brigade: NOTE - the hermes adapter is experimental. "
208
+ "Validate against your real Hermes install before relying on it. "
209
+ "See CONTRIBUTING.md for graduation criteria.",
210
+ file=sys.stderr,
211
+ )
212
+ if notes:
213
+ print()
214
+ print("Next steps:")
215
+ for note in notes:
216
+ print(f" - {note}")
217
+ return 0
brigade/prompt.py ADDED
@@ -0,0 +1,135 @@
1
+ """Hand-rolled interactive prompt for harness/depth/include selection.
2
+
3
+ No external deps. Uses stdin line input + numbered toggles, so it works
4
+ over any TTY (no raw mode, no curses, no ANSI escape sequences required).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import sys
9
+ from typing import List
10
+
11
+ from .selection import (
12
+ KNOWN_HARNESSES,
13
+ KNOWN_DEPTHS,
14
+ KNOWN_INCLUDES,
15
+ Selection,
16
+ resolve_owner,
17
+ )
18
+
19
+
20
+ class NonInteractiveError(Exception):
21
+ """Raised when prompt_for_selection() runs without a TTY."""
22
+
23
+
24
+ _HARNESS_ORDER = ["claude", "codex", "openclaw", "hermes"]
25
+ _DEPTH_ORDER = ["repo", "workspace"]
26
+ _INCLUDE_ORDER = ["publisher"]
27
+
28
+ _HARNESS_LABELS = {
29
+ "claude": "Claude Code",
30
+ "codex": "Codex",
31
+ "openclaw": "OpenClaw",
32
+ "hermes": "Hermes (experimental)",
33
+ }
34
+
35
+ _DEPTH_LABELS = {
36
+ "repo": "repo (handoff flow + publish guard)",
37
+ "workspace": "workspace (full home: MEMORY.md, TOOLS.md, USER.md, ...)",
38
+ }
39
+
40
+ _INCLUDE_LABELS = {
41
+ "publisher": "publisher (content-guard policies for blog/social/docs)",
42
+ }
43
+
44
+
45
+ def prompt_for_selection() -> Selection:
46
+ if not sys.stdin.isatty():
47
+ raise NonInteractiveError(
48
+ "brigade init needs a TTY for the interactive prompt. "
49
+ "Pass --depth and --harnesses for scripting."
50
+ )
51
+
52
+ selected_harnesses = _toggle_prompt(
53
+ title="Which harnesses do you use?",
54
+ options=_HARNESS_ORDER,
55
+ labels=_HARNESS_LABELS,
56
+ defaults=["claude"],
57
+ )
58
+ depth = _single_prompt(
59
+ title="Depth?",
60
+ options=_DEPTH_ORDER,
61
+ labels=_DEPTH_LABELS,
62
+ default="repo",
63
+ )
64
+ selected_includes = _toggle_prompt(
65
+ title="Add-ons?",
66
+ options=_INCLUDE_ORDER,
67
+ labels=_INCLUDE_LABELS,
68
+ defaults=[],
69
+ )
70
+
71
+ owner = resolve_owner(selected_harnesses)
72
+ return Selection(
73
+ depth=depth,
74
+ harnesses=selected_harnesses,
75
+ owner=owner,
76
+ includes=selected_includes,
77
+ )
78
+
79
+
80
+ def _toggle_prompt(title, options, labels, defaults):
81
+ selected = list(defaults)
82
+ print()
83
+ print(title + " (type numbers separated by space/comma to toggle, enter to confirm)")
84
+ while True:
85
+ for i, opt in enumerate(options, start=1):
86
+ mark = "x" if opt in selected else " "
87
+ print(f" [{mark}] {i}. {labels.get(opt, opt)}")
88
+ raw = sys.stdin.readline()
89
+ if raw == "": # EOF
90
+ break
91
+ raw = raw.strip()
92
+ if not raw:
93
+ break
94
+ tokens = [t.strip() for t in raw.replace(",", " ").split() if t.strip()]
95
+ invalid = []
96
+ for t in tokens:
97
+ try:
98
+ idx = int(t)
99
+ except ValueError:
100
+ invalid.append(t)
101
+ continue
102
+ if not 1 <= idx <= len(options):
103
+ invalid.append(t)
104
+ continue
105
+ opt = options[idx - 1]
106
+ if opt in selected:
107
+ selected.remove(opt)
108
+ else:
109
+ selected.append(opt)
110
+ if invalid:
111
+ print(f" (ignored invalid: {' '.join(invalid)})")
112
+ # Preserve canonical order rather than toggle order.
113
+ return [o for o in options if o in selected]
114
+
115
+
116
+ def _single_prompt(title, options, labels, default):
117
+ print()
118
+ print(title + " (type a number, enter for default)")
119
+ for i, opt in enumerate(options, start=1):
120
+ marker = "*" if opt == default else " "
121
+ print(f" {marker} {i}. {labels.get(opt, opt)}")
122
+ raw = sys.stdin.readline()
123
+ if raw == "":
124
+ return default
125
+ raw = raw.strip()
126
+ if not raw:
127
+ return default
128
+ try:
129
+ idx = int(raw)
130
+ if 1 <= idx <= len(options):
131
+ return options[idx - 1]
132
+ except ValueError:
133
+ pass
134
+ print(f" (invalid; using default {default!r})")
135
+ return default
brigade/py.typed ADDED
File without changes