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.
- brigade/__init__.py +3 -0
- brigade/__main__.py +5 -0
- brigade/cli.py +258 -0
- brigade/config.py +65 -0
- brigade/doctor.py +393 -0
- brigade/fragments.py +64 -0
- brigade/handoff.py +23 -0
- brigade/ingest.py +298 -0
- brigade/install.py +217 -0
- brigade/prompt.py +135 -0
- brigade/py.typed +0 -0
- brigade/reconfigure.py +64 -0
- brigade/registry.py +39 -0
- brigade/scrub.py +90 -0
- brigade/selection.py +66 -0
- brigade/station.py +36 -0
- brigade/status.py +24 -0
- brigade/templates/claude/memory-handoffs/TEMPLATE.md +57 -0
- brigade/templates/codex/memory-handoffs/TEMPLATE.md +57 -0
- brigade/templates/depth/repo.json +12 -0
- brigade/templates/depth/workspace.json +30 -0
- brigade/templates/generic/harness-adapter-checklist.md +55 -0
- brigade/templates/generic/memory-contract.md +41 -0
- brigade/templates/harnesses/claude.json +12 -0
- brigade/templates/harnesses/codex.json +11 -0
- brigade/templates/harnesses/hermes.json +16 -0
- brigade/templates/harnesses/openclaw.json +17 -0
- brigade/templates/hermes/README.md +25 -0
- brigade/templates/hermes/memory-handoff.harness.json +36 -0
- brigade/templates/hermes/model-lanes.harness.json +17 -0
- brigade/templates/hermes/workspace.harness.json +30 -0
- brigade/templates/hooks/pre-push +36 -0
- brigade/templates/includes/publisher.json +15 -0
- brigade/templates/memory/cards/backup-restic.md +126 -0
- brigade/templates/memory/cards/chat-surface-crawlers.md +103 -0
- brigade/templates/memory/cards/content-safety.md +54 -0
- brigade/templates/memory/cards/handoff-flow.md +70 -0
- brigade/templates/memory/cards/memory-architecture.md +56 -0
- brigade/templates/memory/cards/memory-care-staleness.md +58 -0
- brigade/templates/memory/cards/memory-scanner.md +98 -0
- brigade/templates/memory/cards/multi-workspace-handoff-admin.md +63 -0
- brigade/templates/memory/cards/obsidian-notes.md +82 -0
- brigade/templates/memory/cards/pipeline-standups.md +88 -0
- brigade/templates/memory/cards/tokenjuice-output-compaction.md +106 -0
- brigade/templates/openclaw/README.md +40 -0
- brigade/templates/openclaw/acp-escalation.openclaw.json +33 -0
- brigade/templates/openclaw/model-aliases.openclaw.json +21 -0
- brigade/templates/openclaw/ollama-memory-search.openclaw.json +24 -0
- brigade/templates/policies/public-content.json +28 -0
- brigade/templates/policies/public-repo.json +27 -0
- brigade/templates/scripts/backup-restic.sh +156 -0
- brigade/templates/skills/note/SKILL.md +173 -0
- brigade/templates/workspace/AGENTS.md +146 -0
- brigade/templates/workspace/CLAUDE.md +48 -0
- brigade/templates/workspace/HEARTBEAT.md +41 -0
- brigade/templates/workspace/IDENTITY.md +27 -0
- brigade/templates/workspace/INSTALL_FOR_AGENTS.md +61 -0
- brigade/templates/workspace/MEMORY.md +102 -0
- brigade/templates/workspace/SAFETY_RULES.md +164 -0
- brigade/templates/workspace/SOUL.md +92 -0
- brigade/templates/workspace/TOOLS.md +116 -0
- brigade/templates/workspace/USER.md +88 -0
- brigade/templates.py +88 -0
- brigade_cli-0.5.0.dist-info/METADATA +211 -0
- brigade_cli-0.5.0.dist-info/RECORD +69 -0
- brigade_cli-0.5.0.dist-info/WHEEL +5 -0
- brigade_cli-0.5.0.dist-info/entry_points.txt +3 -0
- brigade_cli-0.5.0.dist-info/licenses/LICENSE +21 -0
- 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
|