boost-skill-cli 1.0.1__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.
- boost_cli/__init__.py +5 -0
- boost_cli/__main__.py +8 -0
- boost_cli/cli.py +213 -0
- boost_cli/commands/__init__.py +0 -0
- boost_cli/commands/configuration.py +1087 -0
- boost_cli/commands/discovery.py +650 -0
- boost_cli/commands/info.py +595 -0
- boost_cli/commands/intelligence.py +1048 -0
- boost_cli/commands/pkg.py +828 -0
- boost_cli/commands/quality.py +1041 -0
- boost_cli/commands/taps.py +203 -0
- boost_cli/commands/team.py +679 -0
- boost_cli/core/__init__.py +0 -0
- boost_cli/core/agents.py +33 -0
- boost_cli/core/ai.py +115 -0
- boost_cli/core/catalog.py +144 -0
- boost_cli/core/config.py +112 -0
- boost_cli/core/frontmatter.py +160 -0
- boost_cli/core/gitutil.py +69 -0
- boost_cli/core/journal.py +70 -0
- boost_cli/core/lockfile.py +116 -0
- boost_cli/core/output.py +99 -0
- boost_cli/core/paths.py +111 -0
- boost_cli/core/policy.py +63 -0
- boost_cli/core/registry.py +112 -0
- boost_cli/core/store.py +247 -0
- boost_cli/core/util.py +139 -0
- boost_cli/errors.py +11 -0
- boost_skill_cli-1.0.1.dist-info/METADATA +831 -0
- boost_skill_cli-1.0.1.dist-info/RECORD +34 -0
- boost_skill_cli-1.0.1.dist-info/WHEEL +5 -0
- boost_skill_cli-1.0.1.dist-info/entry_points.txt +2 -0
- boost_skill_cli-1.0.1.dist-info/licenses/LICENSE +675 -0
- boost_skill_cli-1.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1048 @@
|
|
|
1
|
+
"""Intelligence commands: distill, simulate, infer, absorb, evolve,
|
|
2
|
+
context, focus, impact.
|
|
3
|
+
|
|
4
|
+
Every command works without AI via honest heuristics (a single warning
|
|
5
|
+
notes the fallback); when the AI bridge is available the results get
|
|
6
|
+
qualitatively better. Session state lives in ~/.boost/state/.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import difflib
|
|
12
|
+
import fnmatch
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import tempfile
|
|
16
|
+
import textwrap
|
|
17
|
+
from collections import Counter
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import List, Optional, Tuple
|
|
20
|
+
|
|
21
|
+
from ..core import ai, catalog, frontmatter, gitutil, journal, lockfile, paths, store, util
|
|
22
|
+
from ..core import output as out
|
|
23
|
+
from ..errors import BoostError
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------- helpers
|
|
26
|
+
|
|
27
|
+
_warned_fallback = False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _note_fallback() -> None:
|
|
31
|
+
"""Warn once per invocation that the AI path is unavailable."""
|
|
32
|
+
global _warned_fallback
|
|
33
|
+
if not _warned_fallback:
|
|
34
|
+
out.warn(ai.fallback_note())
|
|
35
|
+
_warned_fallback = True
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _tilde(p) -> str:
|
|
39
|
+
s = str(p)
|
|
40
|
+
for h in {str(paths.home()), str(paths.home().resolve())}:
|
|
41
|
+
if s.startswith(h):
|
|
42
|
+
return "~" + s[len(h):]
|
|
43
|
+
return s
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _skill_text(name: str) -> Tuple[str, str]:
|
|
47
|
+
"""A skill's SKILL.md text -> (text, origin). Installed store preferred."""
|
|
48
|
+
if lockfile.get_skill(name):
|
|
49
|
+
p = store.skill_store_dir(name) / "SKILL.md"
|
|
50
|
+
if p.exists():
|
|
51
|
+
return p.read_text(encoding="utf-8", errors="replace"), "installed"
|
|
52
|
+
entry = catalog.resolve_one(name)
|
|
53
|
+
p = store.source_dir_for(entry) / "SKILL.md"
|
|
54
|
+
return p.read_text(encoding="utf-8", errors="replace"), "tap %s" % entry["tap"]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _bump_patch(v: str) -> str:
|
|
58
|
+
major, minor, patch = util.semver_tuple(v)
|
|
59
|
+
return "%d.%d.%d" % (major, minor, patch + 1)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _load_state(fname: str, default: dict) -> dict:
|
|
63
|
+
p = paths.state_dir() / fname
|
|
64
|
+
if not p.exists():
|
|
65
|
+
return json.loads(json.dumps(default))
|
|
66
|
+
try:
|
|
67
|
+
return json.loads(p.read_text())
|
|
68
|
+
except (json.JSONDecodeError, OSError):
|
|
69
|
+
return json.loads(json.dumps(default))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _save_state(fname: str, data: dict) -> None:
|
|
73
|
+
paths.ensure_dirs()
|
|
74
|
+
(paths.state_dir() / fname).write_text(json.dumps(data, indent=2) + "\n")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _install_generated(name: str, text: str) -> None:
|
|
78
|
+
"""Write a generated SKILL.md to a tempdir and install it as `name`."""
|
|
79
|
+
with tempfile.TemporaryDirectory(prefix="boost-gen-") as td:
|
|
80
|
+
src = Path(td) / name
|
|
81
|
+
src.mkdir()
|
|
82
|
+
(src / "SKILL.md").write_text(text)
|
|
83
|
+
res = store.install_from_path(src, name=name, tap_label="local")
|
|
84
|
+
out.ok("installed %s → %s" % (name, _tilde(res.dest)))
|
|
85
|
+
if res.linked:
|
|
86
|
+
out.info(out.c("linked into: %s" % ", ".join(res.linked), out.DIM))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _write_generated(dest: Path, text: str) -> bool:
|
|
90
|
+
"""Write a generated file, confirming before overwriting. False = declined."""
|
|
91
|
+
if dest.exists() and not out.confirm("overwrite %s?" % _tilde(dest)):
|
|
92
|
+
out.info("aborted — %s left untouched" % _tilde(dest))
|
|
93
|
+
return False
|
|
94
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
dest.write_text(text)
|
|
96
|
+
out.ok("wrote %s" % _tilde(dest))
|
|
97
|
+
return True
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _current_branch(cwd: Optional[Path] = None) -> Optional[str]:
|
|
101
|
+
"""Current git branch of cwd, or None when not in a repo / no git."""
|
|
102
|
+
if not gitutil.has_git():
|
|
103
|
+
return None
|
|
104
|
+
proc = gitutil.run(["rev-parse", "--abbrev-ref", "HEAD"],
|
|
105
|
+
cwd=cwd or Path.cwd(), check=False)
|
|
106
|
+
return proc.stdout.strip() if proc.returncode == 0 else None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---------------------------------------------------------------- distill
|
|
110
|
+
|
|
111
|
+
def cmd_distill(argv: List[str]) -> int:
|
|
112
|
+
ap = argparse.ArgumentParser(
|
|
113
|
+
prog="boost distill",
|
|
114
|
+
description="Merge multiple skills into one deduplicated skill")
|
|
115
|
+
ap.add_argument("names", nargs="+", metavar="NAME",
|
|
116
|
+
help="two or more skills to merge")
|
|
117
|
+
ap.add_argument("-o", "--output", metavar="NEWNAME",
|
|
118
|
+
help="name for the merged skill (default: <first>-distilled)")
|
|
119
|
+
ap.add_argument("--install", action="store_true",
|
|
120
|
+
help="install the merged skill instead of writing a file")
|
|
121
|
+
args = ap.parse_args(argv)
|
|
122
|
+
|
|
123
|
+
names = list(dict.fromkeys(args.names))
|
|
124
|
+
if len(names) < 2:
|
|
125
|
+
raise BoostError("distill needs at least two distinct skills",
|
|
126
|
+
hint="e.g. `boost distill tdd-workflow commit-messages`")
|
|
127
|
+
sources = []
|
|
128
|
+
for name in names:
|
|
129
|
+
text, origin = _skill_text(name)
|
|
130
|
+
meta, body = frontmatter.parse(text)
|
|
131
|
+
sources.append({"name": name, "origin": origin, "text": text,
|
|
132
|
+
"meta": meta, "body": body})
|
|
133
|
+
new = args.output or (names[0] + "-distilled")
|
|
134
|
+
|
|
135
|
+
out.heading("distilling %s → %s" % (", ".join(names), new))
|
|
136
|
+
merged = _distill_ai(new, sources) if ai.available() else None
|
|
137
|
+
if merged is None:
|
|
138
|
+
_note_fallback()
|
|
139
|
+
merged = _distill_merge(new, sources)
|
|
140
|
+
|
|
141
|
+
if args.install:
|
|
142
|
+
_install_generated(new, merged)
|
|
143
|
+
else:
|
|
144
|
+
dest = Path.cwd() / new / "SKILL.md"
|
|
145
|
+
if not _write_generated(dest, merged):
|
|
146
|
+
return 1
|
|
147
|
+
out.info(out.c("install it with `boost import ./%s`" % new, out.DIM))
|
|
148
|
+
journal.log("distill", new, sources=names)
|
|
149
|
+
return 0
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _distill_ai(new: str, sources: List[dict]) -> Optional[str]:
|
|
153
|
+
blocks = "\n\n".join("### SOURCE SKILL: %s\n\n%s" % (s["name"], s["text"])
|
|
154
|
+
for s in sources)
|
|
155
|
+
reply = ai.ask_author(
|
|
156
|
+
"Merge the following %d skills into ONE skill file.\n"
|
|
157
|
+
"Requirements:\n"
|
|
158
|
+
"- YAML frontmatter with name: %s, a one-line description, "
|
|
159
|
+
"version: 1.0.0, and the union of the source tags\n"
|
|
160
|
+
"- a deduplicated body: keep every distinct rule exactly once under "
|
|
161
|
+
"clear headings, drop redundant or contradictory duplicates\n\n%s"
|
|
162
|
+
% (len(sources), new, blocks),
|
|
163
|
+
system="You are an expert author of SKILL.md files for AI coding "
|
|
164
|
+
"agents. Reply with ONLY the complete merged SKILL.md content.")
|
|
165
|
+
if not reply:
|
|
166
|
+
return None
|
|
167
|
+
text = ai.extract_markdown(reply)
|
|
168
|
+
meta, _ = frontmatter.parse(text)
|
|
169
|
+
return text if meta.get("name") else None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _distill_merge(new: str, sources: List[dict]) -> str:
|
|
173
|
+
"""Mechanical merge: union tags, dedupe exact-duplicate body lines."""
|
|
174
|
+
tags: List[str] = []
|
|
175
|
+
for s in sources:
|
|
176
|
+
for t in s["meta"].get("tags") or []:
|
|
177
|
+
if t not in tags:
|
|
178
|
+
tags.append(t)
|
|
179
|
+
desc = next((str(s["meta"].get("description") or "").strip()
|
|
180
|
+
for s in sources if s["meta"].get("description")), "")
|
|
181
|
+
meta: dict = {"name": new,
|
|
182
|
+
"description": (desc or "Merged skill") + " (distilled)",
|
|
183
|
+
"version": "1.0.0"}
|
|
184
|
+
if tags:
|
|
185
|
+
meta["tags"] = tags
|
|
186
|
+
seen = set()
|
|
187
|
+
sections = ["# %s\n\nDistilled from: %s."
|
|
188
|
+
% (new, ", ".join(s["name"] for s in sources))]
|
|
189
|
+
for s in sources:
|
|
190
|
+
kept = []
|
|
191
|
+
for raw in s["body"].splitlines():
|
|
192
|
+
key = raw.strip()
|
|
193
|
+
if key:
|
|
194
|
+
if key in seen:
|
|
195
|
+
continue
|
|
196
|
+
seen.add(key)
|
|
197
|
+
kept.append(raw)
|
|
198
|
+
sections.append("## From %s\n\n%s" % (s["name"], "\n".join(kept).strip()))
|
|
199
|
+
body = re.sub(r"\n{3,}", "\n\n", "\n\n".join(sections))
|
|
200
|
+
return frontmatter.dump(meta) + "\n\n" + body.strip() + "\n"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ---------------------------------------------------------------- simulate
|
|
204
|
+
|
|
205
|
+
_RULE_RE = re.compile(r"^(always|never|must|do not|don't)\b", re.I)
|
|
206
|
+
_LIST_RE = re.compile(r"^([-*+]|\d+[.)])\s+")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def cmd_simulate(argv: List[str]) -> int:
|
|
210
|
+
ap = argparse.ArgumentParser(
|
|
211
|
+
prog="boost simulate",
|
|
212
|
+
description="Preview how a skill would change Claude's behavior")
|
|
213
|
+
ap.add_argument("name", metavar="NAME")
|
|
214
|
+
ap.add_argument("--task", metavar="TEXT",
|
|
215
|
+
help="task to simulate (default: a typical coding task)")
|
|
216
|
+
args = ap.parse_args(argv)
|
|
217
|
+
|
|
218
|
+
text, origin = _skill_text(args.name)
|
|
219
|
+
task = args.task or "a typical coding task in this repo"
|
|
220
|
+
out.heading("simulating %s %s" % (args.name, out.c("(%s)" % origin, out.DIM)))
|
|
221
|
+
|
|
222
|
+
if ai.available():
|
|
223
|
+
reply = ai.ask(
|
|
224
|
+
"Here is a skill file that changes how an AI coding agent "
|
|
225
|
+
"behaves:\n\n%s\n\nTask: %s\n\nIn at most 6 bullets total, "
|
|
226
|
+
"contrast the agent's behavior WITHOUT this skill vs WITH this "
|
|
227
|
+
"skill on that task. Be concrete and terse." % (text, task))
|
|
228
|
+
if reply:
|
|
229
|
+
for line in reply.splitlines():
|
|
230
|
+
out.info(line)
|
|
231
|
+
return 0
|
|
232
|
+
_note_fallback()
|
|
233
|
+
|
|
234
|
+
_, body = frontmatter.parse(text)
|
|
235
|
+
meta, _ = frontmatter.parse(text)
|
|
236
|
+
out.kv("task", task)
|
|
237
|
+
rules = _imperative_rules(body)
|
|
238
|
+
out.info("Without it: default behavior — none of the rules below are enforced.")
|
|
239
|
+
out.info(out.c("With %s active, Claude would:" % args.name, out.BOLD))
|
|
240
|
+
if rules:
|
|
241
|
+
for rule in rules[:8]:
|
|
242
|
+
out.info(" • " + rule)
|
|
243
|
+
if len(rules) > 8:
|
|
244
|
+
out.info(out.c(" … and %d more rules" % (len(rules) - 8), out.DIM))
|
|
245
|
+
else:
|
|
246
|
+
out.info(" • (no imperative rules found in the skill body)")
|
|
247
|
+
desc = str(meta.get("description") or "").strip()
|
|
248
|
+
if desc:
|
|
249
|
+
out.info(out.c('likely triggers when the task involves: "%s"'
|
|
250
|
+
% desc[:100], out.DIM))
|
|
251
|
+
return 0
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _imperative_rules(body: str) -> List[str]:
|
|
255
|
+
"""Lines that read as rules: Always/Never/Must/Do not + numbered steps."""
|
|
256
|
+
rules: List[str] = []
|
|
257
|
+
for raw in body.splitlines():
|
|
258
|
+
line = raw.strip()
|
|
259
|
+
stripped = _LIST_RE.sub("", line)
|
|
260
|
+
numbered = bool(re.match(r"^\d+[.)]\s+", line))
|
|
261
|
+
if _RULE_RE.match(stripped):
|
|
262
|
+
rule = _norm_rule(stripped)
|
|
263
|
+
elif numbered and len(stripped.split()) >= 2:
|
|
264
|
+
rule = "follow: " + _norm_rule(stripped)
|
|
265
|
+
else:
|
|
266
|
+
continue
|
|
267
|
+
if rule and rule not in rules:
|
|
268
|
+
rules.append(rule)
|
|
269
|
+
return rules
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _norm_rule(text: str) -> str:
|
|
273
|
+
t = re.sub(r"[*_`]", "", text).strip().rstrip(".")
|
|
274
|
+
return (t[:1].lower() + t[1:]) if t else t
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ---------------------------------------------------------------- infer
|
|
278
|
+
|
|
279
|
+
_CONV_RE = re.compile(
|
|
280
|
+
r"^(feat|fix|chore|docs|refactor|test|style|perf|build|ci|revert)"
|
|
281
|
+
r"(\(.+?\))?!?:\s")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def cmd_infer(argv: List[str]) -> int:
|
|
285
|
+
ap = argparse.ArgumentParser(
|
|
286
|
+
prog="boost infer",
|
|
287
|
+
description="Generate a SKILL.md from your codebase patterns")
|
|
288
|
+
ap.add_argument("--path", default=".", metavar="DIR",
|
|
289
|
+
help="codebase to analyze (default: current directory)")
|
|
290
|
+
ap.add_argument("--name", default="project-conventions", metavar="N",
|
|
291
|
+
help="skill name (default: project-conventions)")
|
|
292
|
+
ap.add_argument("-o", "--output", metavar="FILE", help="write to a file")
|
|
293
|
+
ap.add_argument("--install", action="store_true",
|
|
294
|
+
help="install the generated skill")
|
|
295
|
+
args = ap.parse_args(argv)
|
|
296
|
+
|
|
297
|
+
root = paths.expand(args.path).resolve()
|
|
298
|
+
if not root.is_dir():
|
|
299
|
+
raise BoostError("%s is not a directory" % _tilde(root))
|
|
300
|
+
name = util.slugify(args.name)
|
|
301
|
+
facts = _probe_repo(root)
|
|
302
|
+
|
|
303
|
+
text = _infer_ai(name, root, facts) if ai.available() else None
|
|
304
|
+
if text is None:
|
|
305
|
+
_note_fallback()
|
|
306
|
+
text = _infer_template(name, facts)
|
|
307
|
+
|
|
308
|
+
if args.install:
|
|
309
|
+
_install_generated(name, text)
|
|
310
|
+
journal.log("infer", name, path=str(root))
|
|
311
|
+
elif args.output:
|
|
312
|
+
if not _write_generated(paths.expand(args.output), text):
|
|
313
|
+
return 1
|
|
314
|
+
journal.log("infer", name, path=str(root))
|
|
315
|
+
else:
|
|
316
|
+
print(text, end="" if text.endswith("\n") else "\n")
|
|
317
|
+
return 0
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _probe_repo(root: Path) -> dict:
|
|
321
|
+
languages, frameworks = _stack_from_discovery(root) or _local_stack(root)
|
|
322
|
+
facts: dict = {
|
|
323
|
+
"languages": languages,
|
|
324
|
+
"frameworks": frameworks,
|
|
325
|
+
"test_dirs": [d for d in ("tests", "test", "spec", "__tests__")
|
|
326
|
+
if (root / d).is_dir()],
|
|
327
|
+
"formatters": _formatter_configs(root),
|
|
328
|
+
}
|
|
329
|
+
facts["commit_style"] = _commit_style(root)
|
|
330
|
+
return facts
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _stack_from_discovery(root: Path) -> Optional[Tuple[List[str], List[str]]]:
|
|
334
|
+
"""Use discovery's shared stack prober when present; normalize its shape."""
|
|
335
|
+
try:
|
|
336
|
+
from .discovery import detect_stack
|
|
337
|
+
result = detect_stack(root)
|
|
338
|
+
except Exception:
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
def _names(val) -> List[str]:
|
|
342
|
+
items = []
|
|
343
|
+
for x in (val or []):
|
|
344
|
+
n = x.get("name") if isinstance(x, dict) else x
|
|
345
|
+
if n and str(n) not in items:
|
|
346
|
+
items.append(str(n))
|
|
347
|
+
return items
|
|
348
|
+
|
|
349
|
+
if isinstance(result, dict):
|
|
350
|
+
langs = _names(result.get("languages") or result.get("langs"))
|
|
351
|
+
fws = _names(result.get("frameworks") or result.get("stack"))
|
|
352
|
+
elif isinstance(result, (list, tuple, set)):
|
|
353
|
+
langs, fws = _names(result), []
|
|
354
|
+
else:
|
|
355
|
+
return None
|
|
356
|
+
return (langs, fws) if (langs or fws) else None
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _local_stack(root: Path) -> Tuple[List[str], List[str]]:
|
|
360
|
+
langs: List[str] = []
|
|
361
|
+
fws: List[str] = []
|
|
362
|
+
|
|
363
|
+
def add(lst: List[str], item: str) -> None:
|
|
364
|
+
if item not in lst:
|
|
365
|
+
lst.append(item)
|
|
366
|
+
|
|
367
|
+
def read(p: Path) -> str:
|
|
368
|
+
try:
|
|
369
|
+
return p.read_text(encoding="utf-8", errors="replace")
|
|
370
|
+
except OSError:
|
|
371
|
+
return ""
|
|
372
|
+
|
|
373
|
+
if (root / "package.json").exists():
|
|
374
|
+
add(langs, "javascript")
|
|
375
|
+
try:
|
|
376
|
+
pkg = json.loads(read(root / "package.json") or "{}")
|
|
377
|
+
except json.JSONDecodeError:
|
|
378
|
+
pkg = {}
|
|
379
|
+
deps: dict = {}
|
|
380
|
+
for key in ("dependencies", "devDependencies"):
|
|
381
|
+
deps.update(pkg.get(key) or {})
|
|
382
|
+
for fw in ("react", "next", "vue", "svelte", "angular", "express"):
|
|
383
|
+
if fw in deps:
|
|
384
|
+
add(fws, fw)
|
|
385
|
+
if "typescript" in deps or (root / "tsconfig.json").exists():
|
|
386
|
+
add(langs, "typescript")
|
|
387
|
+
if any((root / f).exists() for f in
|
|
388
|
+
("pyproject.toml", "requirements.txt", "setup.py")):
|
|
389
|
+
add(langs, "python")
|
|
390
|
+
blob = read(root / "pyproject.toml") + read(root / "requirements.txt")
|
|
391
|
+
for fw in ("django", "flask", "fastapi", "pytest"):
|
|
392
|
+
if fw in blob.lower():
|
|
393
|
+
add(fws, fw)
|
|
394
|
+
for marker, lang in (("Cargo.toml", "rust"), ("go.mod", "go"),
|
|
395
|
+
("Gemfile", "ruby"), ("pom.xml", "java"),
|
|
396
|
+
("build.gradle", "java"), ("composer.json", "php")):
|
|
397
|
+
if (root / marker).exists():
|
|
398
|
+
add(langs, lang)
|
|
399
|
+
return langs, fws
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _formatter_configs(root: Path) -> List[str]:
|
|
403
|
+
found: List[str] = []
|
|
404
|
+
if (root / ".editorconfig").exists():
|
|
405
|
+
found.append(".editorconfig")
|
|
406
|
+
try:
|
|
407
|
+
py = (root / "pyproject.toml").read_text(encoding="utf-8",
|
|
408
|
+
errors="replace")
|
|
409
|
+
except OSError:
|
|
410
|
+
py = ""
|
|
411
|
+
if ((root / "ruff.toml").exists() or (root / ".ruff.toml").exists()
|
|
412
|
+
or "[tool.ruff" in py):
|
|
413
|
+
found.append("ruff")
|
|
414
|
+
if "[tool.black" in py:
|
|
415
|
+
found.append("black")
|
|
416
|
+
if any((root / n).exists() for n in
|
|
417
|
+
(".prettierrc", ".prettierrc.json", ".prettierrc.yaml",
|
|
418
|
+
".prettierrc.yml", ".prettierrc.js", "prettier.config.js")):
|
|
419
|
+
found.append("prettier")
|
|
420
|
+
if any((root / n).exists() for n in
|
|
421
|
+
(".eslintrc", ".eslintrc.json", ".eslintrc.js", "eslint.config.js")):
|
|
422
|
+
found.append("eslint")
|
|
423
|
+
return found
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _commit_style(root: Path) -> Optional[Tuple[int, int]]:
|
|
427
|
+
"""(conventional, total) over the last 20 commit subjects, or None."""
|
|
428
|
+
if not gitutil.has_git():
|
|
429
|
+
return None
|
|
430
|
+
proc = gitutil.run(["log", "-20", "--pretty=%s"], cwd=root, check=False)
|
|
431
|
+
if proc.returncode != 0:
|
|
432
|
+
return None
|
|
433
|
+
subjects = [s for s in proc.stdout.splitlines() if s.strip()]
|
|
434
|
+
if not subjects:
|
|
435
|
+
return None
|
|
436
|
+
return sum(1 for s in subjects if _CONV_RE.match(s)), len(subjects)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _commit_rule(style: Tuple[int, int]) -> str:
|
|
440
|
+
conv, total = style
|
|
441
|
+
if conv / total >= 0.5:
|
|
442
|
+
return ("Write Conventional Commits (`type(scope): subject`) — "
|
|
443
|
+
"%d of the last %d subjects follow it." % (conv, total))
|
|
444
|
+
return ("Commit subjects are freeform (%d/%d conventional); keep them "
|
|
445
|
+
"short and imperative." % (conv, total))
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _infer_ai(name: str, root: Path, facts: dict) -> Optional[str]:
|
|
449
|
+
listing = ", ".join(sorted(p.name for p in root.iterdir()
|
|
450
|
+
if not p.name.startswith("."))[:30])
|
|
451
|
+
detected = dict(facts)
|
|
452
|
+
if detected.get("commit_style"):
|
|
453
|
+
detected["commit_style"] = _commit_rule(detected["commit_style"])
|
|
454
|
+
reply = ai.ask_author(
|
|
455
|
+
"Draft a SKILL.md that teaches an AI coding agent THIS repository's "
|
|
456
|
+
"conventions. Detected facts (JSON): %s\nTop-level files: %s\n\n"
|
|
457
|
+
"Requirements: YAML frontmatter with name: %s, description, "
|
|
458
|
+
"version: 1.0.0; a body with concrete, imperative rules grounded in "
|
|
459
|
+
"the detected facts only — do not invent tooling that is not listed."
|
|
460
|
+
% (json.dumps(detected), listing, name),
|
|
461
|
+
system="You are an expert author of SKILL.md files for AI coding "
|
|
462
|
+
"agents. Reply with ONLY the complete SKILL.md content.")
|
|
463
|
+
if not reply:
|
|
464
|
+
return None
|
|
465
|
+
text = ai.extract_markdown(reply)
|
|
466
|
+
meta, _ = frontmatter.parse(text)
|
|
467
|
+
return text if meta.get("name") else None
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _infer_template(name: str, facts: dict) -> str:
|
|
471
|
+
desc = "Working conventions for this repository"
|
|
472
|
+
if facts["languages"]:
|
|
473
|
+
desc += " (%s)" % ", ".join(facts["languages"][:3])
|
|
474
|
+
meta = {"name": name, "description": desc, "version": "1.0.0",
|
|
475
|
+
"tags": ["conventions", "project"]}
|
|
476
|
+
lines = ["# %s" % name.replace("-", " ").title(), "",
|
|
477
|
+
"How to work in this repository. Generated by `boost infer` from",
|
|
478
|
+
"detected project patterns — review before relying on it.", ""]
|
|
479
|
+
if facts["languages"] or facts["frameworks"]:
|
|
480
|
+
lines += ["## Stack", ""]
|
|
481
|
+
if facts["languages"]:
|
|
482
|
+
lines.append("- Languages: %s" % ", ".join(facts["languages"]))
|
|
483
|
+
if facts["frameworks"]:
|
|
484
|
+
lines.append("- Frameworks: %s" % ", ".join(facts["frameworks"]))
|
|
485
|
+
lines.append("")
|
|
486
|
+
if facts["test_dirs"]:
|
|
487
|
+
lines += ["## Testing", "",
|
|
488
|
+
"- Tests live in %s — add new tests there and mirror the "
|
|
489
|
+
"source layout." % ", ".join("`%s/`" % d
|
|
490
|
+
for d in facts["test_dirs"]),
|
|
491
|
+
""]
|
|
492
|
+
if facts["formatters"]:
|
|
493
|
+
lines += ["## Formatting", "",
|
|
494
|
+
"- Respect the configured formatters: %s. Run them before "
|
|
495
|
+
"committing." % ", ".join(facts["formatters"]), ""]
|
|
496
|
+
if facts["commit_style"]:
|
|
497
|
+
lines += ["## Commits", "", "- " + _commit_rule(facts["commit_style"]), ""]
|
|
498
|
+
if len(lines) <= 5:
|
|
499
|
+
lines += ["## Conventions", "",
|
|
500
|
+
"- No strong conventions detected; edit this file with "
|
|
501
|
+
"your own rules.", ""]
|
|
502
|
+
return frontmatter.dump(meta) + "\n\n" + "\n".join(lines).rstrip() + "\n"
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
# ---------------------------------------------------------------- absorb
|
|
506
|
+
|
|
507
|
+
_TRIVIAL = {"yes", "no", "ok", "okay", "continue", "go ahead", "sounds good",
|
|
508
|
+
"thanks", "thank you", "sure", "yep", "do it", "looks good",
|
|
509
|
+
"please continue", "try again"}
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def cmd_absorb(argv: List[str]) -> int:
|
|
513
|
+
ap = argparse.ArgumentParser(
|
|
514
|
+
prog="boost absorb",
|
|
515
|
+
description="Turn recurring chat-history patterns into a skill")
|
|
516
|
+
ap.add_argument("--history", metavar="PATH",
|
|
517
|
+
help="history .jsonl file or directory of them")
|
|
518
|
+
ap.add_argument("--limit", type=int, default=5, metavar="N",
|
|
519
|
+
help="max patterns to absorb (default: 5)")
|
|
520
|
+
ap.add_argument("--install", action="store_true",
|
|
521
|
+
help="install the generated skill")
|
|
522
|
+
args = ap.parse_args(argv)
|
|
523
|
+
|
|
524
|
+
if args.history:
|
|
525
|
+
src = paths.expand(args.history)
|
|
526
|
+
if not src.exists():
|
|
527
|
+
raise BoostError("no history at %s" % _tilde(src))
|
|
528
|
+
else:
|
|
529
|
+
src = paths.home() / ".claude" / "history.jsonl"
|
|
530
|
+
if not src.exists():
|
|
531
|
+
src = paths.home() / ".claude" / "projects"
|
|
532
|
+
if not src.exists():
|
|
533
|
+
out.info("no chat history found under ~/.claude — nothing to absorb")
|
|
534
|
+
out.info(out.c("point at a .jsonl file or directory with "
|
|
535
|
+
"--history PATH", out.DIM))
|
|
536
|
+
return 0
|
|
537
|
+
files = [src] if src.is_file() else sorted(src.rglob("*.jsonl"))
|
|
538
|
+
if not files:
|
|
539
|
+
out.info("no .jsonl history files under %s" % _tilde(src))
|
|
540
|
+
return 0
|
|
541
|
+
|
|
542
|
+
patterns = _recurring_patterns(files, args.limit)
|
|
543
|
+
if not patterns:
|
|
544
|
+
out.info("no recurring patterns in %d history file(s) — absorb needs "
|
|
545
|
+
"the same request 3+ times" % len(files))
|
|
546
|
+
return 0
|
|
547
|
+
|
|
548
|
+
name = "absorbed-patterns"
|
|
549
|
+
out.heading("recurring patterns from %d history file(s)" % len(files))
|
|
550
|
+
out.table([(p, "%dx" % n) for p, n in patterns],
|
|
551
|
+
headers=("PATTERN", "SEEN"))
|
|
552
|
+
print()
|
|
553
|
+
|
|
554
|
+
text = _absorb_ai(name, patterns) if ai.available() else None
|
|
555
|
+
if text is None:
|
|
556
|
+
_note_fallback()
|
|
557
|
+
text = _absorb_template(name, patterns)
|
|
558
|
+
if args.install:
|
|
559
|
+
_install_generated(name, text)
|
|
560
|
+
else:
|
|
561
|
+
print(text, end="" if text.endswith("\n") else "\n")
|
|
562
|
+
return 0
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _recurring_patterns(files: List[Path], limit: int) -> List[Tuple[str, int]]:
|
|
566
|
+
counts: Counter = Counter()
|
|
567
|
+
for f in files:
|
|
568
|
+
try:
|
|
569
|
+
lines = f.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
570
|
+
except OSError:
|
|
571
|
+
continue
|
|
572
|
+
for line in lines:
|
|
573
|
+
try:
|
|
574
|
+
obj = json.loads(line)
|
|
575
|
+
except json.JSONDecodeError:
|
|
576
|
+
continue
|
|
577
|
+
for text in _user_texts(obj):
|
|
578
|
+
for sent in _norm_sentences(text):
|
|
579
|
+
counts[sent] += 1
|
|
580
|
+
hits = [(s, n) for s, n in counts.items()
|
|
581
|
+
if n >= 3 and 4 <= len(s.split()) <= 30
|
|
582
|
+
and len(s) >= 15 and s not in _TRIVIAL]
|
|
583
|
+
hits.sort(key=lambda x: (-x[1], x[0]))
|
|
584
|
+
return hits[:limit]
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _user_texts(obj) -> List[str]:
|
|
588
|
+
"""Best-effort user-message text from one history event."""
|
|
589
|
+
if not isinstance(obj, dict):
|
|
590
|
+
return []
|
|
591
|
+
node = obj["message"] if isinstance(obj.get("message"), dict) else obj
|
|
592
|
+
role = node.get("role") or node.get("type") or obj.get("type") or obj.get("role")
|
|
593
|
+
if role != "user":
|
|
594
|
+
return []
|
|
595
|
+
content = node.get("content", obj.get("content"))
|
|
596
|
+
if isinstance(content, str):
|
|
597
|
+
return [content]
|
|
598
|
+
if isinstance(content, list):
|
|
599
|
+
return [str(item.get("text") or "") for item in content
|
|
600
|
+
if isinstance(item, dict) and item.get("type") == "text"]
|
|
601
|
+
return []
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _norm_sentences(text: str) -> List[str]:
|
|
605
|
+
sents = []
|
|
606
|
+
for chunk in re.split(r"[.!?\n]+", text):
|
|
607
|
+
s = re.sub(r"[^a-z0-9\s']", " ", chunk.lower())
|
|
608
|
+
s = re.sub(r"\s+", " ", s).strip()
|
|
609
|
+
if s:
|
|
610
|
+
sents.append(s)
|
|
611
|
+
return sents
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _absorb_ai(name: str, patterns: List[Tuple[str, int]]) -> Optional[str]:
|
|
615
|
+
listing = "\n".join("- (seen %dx) %s" % (n, p) for p, n in patterns)
|
|
616
|
+
reply = ai.ask_author(
|
|
617
|
+
"These requests recur in a developer's AI chat history:\n%s\n\n"
|
|
618
|
+
"Write ONE SKILL.md named %s that codifies them as standing rules "
|
|
619
|
+
"so they never need to be repeated. YAML frontmatter with name, "
|
|
620
|
+
"description, version: 1.0.0; body of concrete imperative rules."
|
|
621
|
+
% (listing, name),
|
|
622
|
+
system="You are an expert author of SKILL.md files for AI coding "
|
|
623
|
+
"agents. Reply with ONLY the complete SKILL.md content.")
|
|
624
|
+
if not reply:
|
|
625
|
+
return None
|
|
626
|
+
text = ai.extract_markdown(reply)
|
|
627
|
+
meta, _ = frontmatter.parse(text)
|
|
628
|
+
return text if meta.get("name") else None
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _absorb_template(name: str, patterns: List[Tuple[str, int]]) -> str:
|
|
632
|
+
meta = {"name": name,
|
|
633
|
+
"description": "Recurring instructions from your chat history, "
|
|
634
|
+
"codified as standing rules",
|
|
635
|
+
"version": "1.0.0", "tags": ["habits", "workflow"]}
|
|
636
|
+
body = ["# Absorbed Patterns", "",
|
|
637
|
+
"These requests came up repeatedly in chat history (%d recurring "
|
|
638
|
+
"patterns). Treat them as standing instructions:" % len(patterns),
|
|
639
|
+
""]
|
|
640
|
+
body += ["- %s %s" % (p.capitalize() + ".", "(seen %dx)" % n)
|
|
641
|
+
for p, n in patterns]
|
|
642
|
+
return frontmatter.dump(meta) + "\n\n" + "\n".join(body) + "\n"
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
# ---------------------------------------------------------------- evolve
|
|
646
|
+
|
|
647
|
+
def cmd_evolve(argv: List[str]) -> int:
|
|
648
|
+
ap = argparse.ArgumentParser(
|
|
649
|
+
prog="boost evolve",
|
|
650
|
+
description="Iteratively improve a skill from feedback")
|
|
651
|
+
ap.add_argument("name", metavar="NAME")
|
|
652
|
+
ap.add_argument("--feedback", required=True, metavar="TEXT",
|
|
653
|
+
help="what should change, in plain English")
|
|
654
|
+
ap.add_argument("--apply", action="store_true",
|
|
655
|
+
help="write the revision to the installed skill")
|
|
656
|
+
args = ap.parse_args(argv)
|
|
657
|
+
|
|
658
|
+
entry = lockfile.get_skill(args.name)
|
|
659
|
+
if not entry:
|
|
660
|
+
raise BoostError("%s is not installed — evolve works on installed "
|
|
661
|
+
"skills" % args.name,
|
|
662
|
+
hint="install it first with `boost install %s`" % args.name)
|
|
663
|
+
skill_md = store.skill_store_dir(args.name) / "SKILL.md"
|
|
664
|
+
if not skill_md.exists():
|
|
665
|
+
raise BoostError("%s has no SKILL.md in the store" % args.name,
|
|
666
|
+
hint="repair with `boost sync`")
|
|
667
|
+
old = skill_md.read_text(encoding="utf-8", errors="replace")
|
|
668
|
+
old_meta, _ = frontmatter.parse(old)
|
|
669
|
+
old_ver = str(old_meta.get("version") or "0.0.0")
|
|
670
|
+
|
|
671
|
+
out.heading("evolving %s %s" % (args.name, out.c("(v%s)" % old_ver, out.DIM)))
|
|
672
|
+
new = _evolve_ai(old, old_ver, args.feedback) if ai.available() else None
|
|
673
|
+
if new is None:
|
|
674
|
+
_note_fallback()
|
|
675
|
+
new = _evolve_append(old, old_ver, args.feedback)
|
|
676
|
+
_print_diff(old, new)
|
|
677
|
+
new_meta, _ = frontmatter.parse(new)
|
|
678
|
+
new_ver = str(new_meta.get("version") or old_ver)
|
|
679
|
+
|
|
680
|
+
if not args.apply:
|
|
681
|
+
out.info(out.c("re-run with --apply to write these changes", out.DIM))
|
|
682
|
+
return 0
|
|
683
|
+
skill_md.write_text(new)
|
|
684
|
+
entry["sha256"] = util.sha256_dir(store.skill_store_dir(args.name))
|
|
685
|
+
entry["updated_at"] = util.now_iso()
|
|
686
|
+
entry["version"] = new_ver
|
|
687
|
+
lockfile.set_skill(args.name, entry)
|
|
688
|
+
journal.log("evolve", args.name, version=new_ver)
|
|
689
|
+
out.ok("evolved %s → v%s" % (args.name, new_ver))
|
|
690
|
+
return 0
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _evolve_ai(old: str, old_ver: str, feedback: str) -> Optional[str]:
|
|
694
|
+
reply = ai.ask_author(
|
|
695
|
+
"Revise this SKILL.md based on user feedback. Keep everything that "
|
|
696
|
+
"still holds, integrate the feedback as concrete rules, and bump the "
|
|
697
|
+
"patch version (current: %s).\n\nFEEDBACK: %s\n\nSKILL.md:\n\n%s"
|
|
698
|
+
% (old_ver, feedback, old),
|
|
699
|
+
system="You are an expert author of SKILL.md files for AI coding "
|
|
700
|
+
"agents. Reply with ONLY the complete revised SKILL.md content.")
|
|
701
|
+
if not reply:
|
|
702
|
+
return None
|
|
703
|
+
text = ai.extract_markdown(reply)
|
|
704
|
+
meta, body = frontmatter.parse(text)
|
|
705
|
+
if not meta.get("name"):
|
|
706
|
+
return None
|
|
707
|
+
if not util.semver_gt(str(meta.get("version") or ""), old_ver):
|
|
708
|
+
meta["version"] = _bump_patch(old_ver)
|
|
709
|
+
text = frontmatter.dump(meta) + "\n\n" + body.strip() + "\n"
|
|
710
|
+
return text
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _evolve_append(old: str, old_ver: str, feedback: str) -> str:
|
|
714
|
+
"""Heuristic revision: feedback appended as a dated rules section."""
|
|
715
|
+
meta, body = frontmatter.parse(old)
|
|
716
|
+
meta["version"] = _bump_patch(old_ver)
|
|
717
|
+
bullets = [s.strip().rstrip(".")
|
|
718
|
+
for s in re.split(r"(?<=[.!?])\s+|\n+|;\s*", feedback)
|
|
719
|
+
if s.strip()]
|
|
720
|
+
section = ("## Feedback (%s)\n\n" % util.now_iso()[:10]
|
|
721
|
+
+ "\n".join("- %s." % b for b in bullets))
|
|
722
|
+
return (frontmatter.dump(meta) + "\n\n" + body.strip()
|
|
723
|
+
+ "\n\n" + section + "\n")
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _print_diff(old: str, new: str) -> None:
|
|
727
|
+
diff = difflib.unified_diff(old.splitlines(), new.splitlines(),
|
|
728
|
+
fromfile="a/SKILL.md", tofile="b/SKILL.md",
|
|
729
|
+
lineterm="")
|
|
730
|
+
for line in diff:
|
|
731
|
+
if line.startswith(("+++", "---")):
|
|
732
|
+
print(out.c(line, out.BOLD))
|
|
733
|
+
elif line.startswith("@@"):
|
|
734
|
+
print(out.c(line, out.CYAN))
|
|
735
|
+
elif line.startswith("+"):
|
|
736
|
+
print(out.c(line, out.GREEN))
|
|
737
|
+
elif line.startswith("-"):
|
|
738
|
+
print(out.c(line, out.RED))
|
|
739
|
+
else:
|
|
740
|
+
print(line)
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
# ---------------------------------------------------------------- context
|
|
744
|
+
|
|
745
|
+
_CONTEXT_STATE = "context.json"
|
|
746
|
+
_CONTEXT_DEFAULT = {"enabled": False, "rules": []}
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def cmd_context(argv: List[str]) -> int:
|
|
750
|
+
ap = argparse.ArgumentParser(
|
|
751
|
+
prog="boost context", description="Branch-aware skill activation")
|
|
752
|
+
sub = ap.add_subparsers(dest="action",
|
|
753
|
+
metavar="status|enable|disable|map|unmap|apply")
|
|
754
|
+
sp = sub.add_parser("status", help="show rules, enabled flag & branch")
|
|
755
|
+
sp.add_argument("--json", action="store_true")
|
|
756
|
+
sub.add_parser("enable", help="turn on branch-aware activation (runs apply)")
|
|
757
|
+
sub.add_parser("disable", help="turn it off and relink all mapped skills")
|
|
758
|
+
sp = sub.add_parser("map", help="map a branch pattern to skills")
|
|
759
|
+
sp.add_argument("pattern", help="fnmatch branch pattern, e.g. 'feature/*'")
|
|
760
|
+
sp.add_argument("skills", help="comma-separated skill names")
|
|
761
|
+
sp = sub.add_parser("unmap", help="remove a branch pattern rule")
|
|
762
|
+
sp.add_argument("pattern")
|
|
763
|
+
sub.add_parser("apply", help="link/unlink mapped skills for this branch")
|
|
764
|
+
args = ap.parse_args(argv)
|
|
765
|
+
|
|
766
|
+
state = _load_state(_CONTEXT_STATE, _CONTEXT_DEFAULT)
|
|
767
|
+
action = args.action or "status"
|
|
768
|
+
|
|
769
|
+
if action == "status":
|
|
770
|
+
return _context_status(state, getattr(args, "json", False))
|
|
771
|
+
if action == "map":
|
|
772
|
+
skills = [s.strip() for s in args.skills.split(",") if s.strip()]
|
|
773
|
+
if not skills:
|
|
774
|
+
raise BoostError("no skills given",
|
|
775
|
+
hint="e.g. `boost context map 'feature/*' tdd-workflow`")
|
|
776
|
+
missing = [s for s in skills if not lockfile.get_skill(s)]
|
|
777
|
+
rules = [r for r in state["rules"] if r.get("pattern") != args.pattern]
|
|
778
|
+
rules.append({"pattern": args.pattern, "skills": skills})
|
|
779
|
+
state["rules"] = rules
|
|
780
|
+
_save_state(_CONTEXT_STATE, state)
|
|
781
|
+
journal.log("context", args.pattern, op="map", skills=skills)
|
|
782
|
+
out.ok("mapped %s → %s" % (args.pattern, ", ".join(skills)))
|
|
783
|
+
if missing:
|
|
784
|
+
out.info(out.c("not installed yet: %s" % ", ".join(missing), out.DIM))
|
|
785
|
+
return 0
|
|
786
|
+
if action == "unmap":
|
|
787
|
+
rules = [r for r in state["rules"] if r.get("pattern") != args.pattern]
|
|
788
|
+
if len(rules) == len(state["rules"]):
|
|
789
|
+
raise BoostError("no rule for pattern %r" % args.pattern,
|
|
790
|
+
hint="see rules with `boost context status`")
|
|
791
|
+
state["rules"] = rules
|
|
792
|
+
_save_state(_CONTEXT_STATE, state)
|
|
793
|
+
journal.log("context", args.pattern, op="unmap")
|
|
794
|
+
out.ok("unmapped %s" % args.pattern)
|
|
795
|
+
return 0
|
|
796
|
+
if action == "enable":
|
|
797
|
+
state["enabled"] = True
|
|
798
|
+
_save_state(_CONTEXT_STATE, state)
|
|
799
|
+
journal.log("context", "enable")
|
|
800
|
+
out.ok("branch-aware activation enabled")
|
|
801
|
+
if not state["rules"]:
|
|
802
|
+
out.warn("no rules configured — add one with "
|
|
803
|
+
"`boost context map 'feature/*' skill1,skill2`")
|
|
804
|
+
return 0
|
|
805
|
+
return _context_apply(state)
|
|
806
|
+
if action == "disable":
|
|
807
|
+
restored = 0
|
|
808
|
+
inst = lockfile.installed()
|
|
809
|
+
for name in sorted(_mentioned_skills(state)):
|
|
810
|
+
entry = inst.get(name)
|
|
811
|
+
if entry and not entry.get("quarantined"):
|
|
812
|
+
if store.link_agents(name).linked:
|
|
813
|
+
restored += 1
|
|
814
|
+
state["enabled"] = False
|
|
815
|
+
_save_state(_CONTEXT_STATE, state)
|
|
816
|
+
journal.log("context", "disable", restored=restored)
|
|
817
|
+
out.ok("branch-aware activation disabled — %d skill(s) relinked" % restored)
|
|
818
|
+
return 0
|
|
819
|
+
# apply
|
|
820
|
+
if not state.get("enabled"):
|
|
821
|
+
out.warn("context is disabled (`boost context enable`) — applying anyway")
|
|
822
|
+
return _context_apply(state)
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def _mentioned_skills(state: dict) -> set:
|
|
826
|
+
return {s for r in state.get("rules", []) for s in r.get("skills", [])}
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def _context_status(state: dict, as_json: bool) -> int:
|
|
830
|
+
branch = _current_branch()
|
|
831
|
+
if as_json:
|
|
832
|
+
print(json.dumps({"enabled": bool(state.get("enabled")),
|
|
833
|
+
"branch": branch, "rules": state.get("rules", [])}))
|
|
834
|
+
return 0
|
|
835
|
+
out.heading("branch-aware skill activation")
|
|
836
|
+
out.kv("enabled", "yes" if state.get("enabled") else "no")
|
|
837
|
+
out.kv("branch", branch or "(not in a git repository)")
|
|
838
|
+
rules = state.get("rules", [])
|
|
839
|
+
if not rules:
|
|
840
|
+
out.info("no rules — add one with `boost context map 'feature/*' skill1,skill2`")
|
|
841
|
+
return 0
|
|
842
|
+
rows = [(r.get("pattern", "?"), ", ".join(r.get("skills", [])),
|
|
843
|
+
"*" if branch and fnmatch.fnmatch(branch, r.get("pattern", "")) else "")
|
|
844
|
+
for r in rules]
|
|
845
|
+
out.table(rows, headers=("PATTERN", "SKILLS", "MATCH"))
|
|
846
|
+
return 0
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _context_apply(state: dict) -> int:
|
|
850
|
+
branch = _current_branch()
|
|
851
|
+
if branch is None:
|
|
852
|
+
out.info("not inside a git repository — nothing to apply")
|
|
853
|
+
return 0
|
|
854
|
+
rules = state.get("rules", [])
|
|
855
|
+
matched = [r for r in rules if fnmatch.fnmatch(branch, r.get("pattern", ""))]
|
|
856
|
+
active = {s for r in matched for s in r.get("skills", [])}
|
|
857
|
+
inst = lockfile.installed()
|
|
858
|
+
linked, unlinked, missing = [], [], []
|
|
859
|
+
for name in sorted(_mentioned_skills(state)):
|
|
860
|
+
entry = inst.get(name)
|
|
861
|
+
if not entry:
|
|
862
|
+
missing.append(name)
|
|
863
|
+
continue
|
|
864
|
+
if entry.get("quarantined"):
|
|
865
|
+
continue
|
|
866
|
+
if name in active:
|
|
867
|
+
if store.link_agents(name).linked:
|
|
868
|
+
linked.append(name)
|
|
869
|
+
elif store.unlink_agents(name):
|
|
870
|
+
unlinked.append(name)
|
|
871
|
+
out.kv("branch", branch)
|
|
872
|
+
out.kv("matched", ", ".join(r["pattern"] for r in matched) or "(no patterns)")
|
|
873
|
+
if linked:
|
|
874
|
+
out.ok("active: " + ", ".join(linked))
|
|
875
|
+
if unlinked:
|
|
876
|
+
out.info("sidelined: " + ", ".join(unlinked))
|
|
877
|
+
if not linked and not unlinked:
|
|
878
|
+
out.info("nothing to change")
|
|
879
|
+
if missing:
|
|
880
|
+
out.info(out.c("mapped but not installed: %s" % ", ".join(missing), out.DIM))
|
|
881
|
+
if linked or unlinked:
|
|
882
|
+
journal.log("context", branch, op="apply",
|
|
883
|
+
linked=linked or None, unlinked=unlinked or None)
|
|
884
|
+
return 0
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
# ---------------------------------------------------------------- focus
|
|
888
|
+
|
|
889
|
+
_FOCUS_STATE = "focus.json"
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def cmd_focus(argv: List[str]) -> int:
|
|
893
|
+
ap = argparse.ArgumentParser(
|
|
894
|
+
prog="boost focus",
|
|
895
|
+
description="Temporarily prioritize skills for a work session")
|
|
896
|
+
ap.add_argument("skills", nargs="*", metavar="SKILL",
|
|
897
|
+
help="skills to focus on")
|
|
898
|
+
ap.add_argument("--clear", action="store_true",
|
|
899
|
+
help="end the session and relink everything")
|
|
900
|
+
ap.add_argument("--status", action="store_true", help="show current focus")
|
|
901
|
+
ap.add_argument("--json", action="store_true")
|
|
902
|
+
args = ap.parse_args(argv)
|
|
903
|
+
|
|
904
|
+
state_path = paths.state_dir() / _FOCUS_STATE
|
|
905
|
+
if args.clear:
|
|
906
|
+
if args.skills:
|
|
907
|
+
raise BoostError("--clear takes no skill arguments")
|
|
908
|
+
restored = 0
|
|
909
|
+
for name, entry in sorted(lockfile.installed().items()):
|
|
910
|
+
if entry.get("quarantined"):
|
|
911
|
+
continue
|
|
912
|
+
if store.link_agents(name).linked:
|
|
913
|
+
restored += 1
|
|
914
|
+
if state_path.exists():
|
|
915
|
+
state_path.unlink()
|
|
916
|
+
journal.log("focus", "clear", restored=restored)
|
|
917
|
+
out.ok("focus cleared — %d skill(s) restored" % restored)
|
|
918
|
+
return 0
|
|
919
|
+
|
|
920
|
+
if args.status or not args.skills:
|
|
921
|
+
state = _load_state(_FOCUS_STATE, {})
|
|
922
|
+
if args.json:
|
|
923
|
+
print(json.dumps({"active": state.get("active", []),
|
|
924
|
+
"since": state.get("since")}))
|
|
925
|
+
return 0
|
|
926
|
+
if state.get("active"):
|
|
927
|
+
out.info("⌁ focus: %s %s"
|
|
928
|
+
% (", ".join(state["active"]),
|
|
929
|
+
out.c("(since %s)" % util.rel_time(state.get("since", "")),
|
|
930
|
+
out.DIM)))
|
|
931
|
+
out.info(out.c("end it with `boost focus --clear`", out.DIM))
|
|
932
|
+
else:
|
|
933
|
+
out.info("no focus session — start one with `boost focus SKILL...`")
|
|
934
|
+
return 0
|
|
935
|
+
|
|
936
|
+
names = list(dict.fromkeys(args.skills))
|
|
937
|
+
inst = lockfile.installed()
|
|
938
|
+
for name in names:
|
|
939
|
+
entry = inst.get(name)
|
|
940
|
+
if not entry:
|
|
941
|
+
raise BoostError("%s is not installed — focus works on installed "
|
|
942
|
+
"skills" % name, hint="see `boost list`")
|
|
943
|
+
if entry.get("quarantined"):
|
|
944
|
+
raise BoostError("%s is quarantined" % name,
|
|
945
|
+
hint="release it first with `boost quarantine`")
|
|
946
|
+
sidelined = 0
|
|
947
|
+
for name, entry in sorted(inst.items()):
|
|
948
|
+
if name in names or entry.get("quarantined"):
|
|
949
|
+
continue
|
|
950
|
+
if store.unlink_agents(name):
|
|
951
|
+
sidelined += 1
|
|
952
|
+
for name in names:
|
|
953
|
+
store.link_agents(name)
|
|
954
|
+
_save_state(_FOCUS_STATE, {"active": names, "since": util.now_iso()})
|
|
955
|
+
journal.log("focus", ",".join(names))
|
|
956
|
+
out.info("⌁ focus: %s %s"
|
|
957
|
+
% (", ".join(names),
|
|
958
|
+
out.c("(other %d skills sidelined)" % sidelined, out.DIM)))
|
|
959
|
+
return 0
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
# ---------------------------------------------------------------- impact
|
|
963
|
+
|
|
964
|
+
def cmd_impact(argv: List[str]) -> int:
|
|
965
|
+
ap = argparse.ArgumentParser(
|
|
966
|
+
prog="boost impact",
|
|
967
|
+
description="Measure a skill's influence on code quality")
|
|
968
|
+
ap.add_argument("name", nargs="?", metavar="NAME",
|
|
969
|
+
help="one skill (default: all installed)")
|
|
970
|
+
ap.add_argument("--json", action="store_true")
|
|
971
|
+
args = ap.parse_args(argv)
|
|
972
|
+
|
|
973
|
+
inst = lockfile.installed()
|
|
974
|
+
if args.name:
|
|
975
|
+
if args.name not in inst:
|
|
976
|
+
raise BoostError("%s is not installed" % args.name,
|
|
977
|
+
hint="see `boost list`")
|
|
978
|
+
names = [args.name]
|
|
979
|
+
else:
|
|
980
|
+
names = sorted(inst)
|
|
981
|
+
if not names:
|
|
982
|
+
if args.json:
|
|
983
|
+
print(json.dumps({"note": _IMPACT_NOTE, "skills": []}))
|
|
984
|
+
else:
|
|
985
|
+
out.info("no skills installed — nothing to measure")
|
|
986
|
+
return 0
|
|
987
|
+
|
|
988
|
+
in_repo = False
|
|
989
|
+
if gitutil.has_git():
|
|
990
|
+
proc = gitutil.run(["rev-parse", "--is-inside-work-tree"],
|
|
991
|
+
cwd=Path.cwd(), check=False)
|
|
992
|
+
in_repo = proc.returncode == 0 and proc.stdout.strip() == "true"
|
|
993
|
+
|
|
994
|
+
rows, data = [], []
|
|
995
|
+
for name in names:
|
|
996
|
+
entry = inst[name]
|
|
997
|
+
since = entry.get("installed_at") or ""
|
|
998
|
+
commits, files = _repo_activity(since) if in_repo else (None, None)
|
|
999
|
+
events = len(journal.events(subject=name))
|
|
1000
|
+
rows.append((name, util.rel_time(since) if since else "?",
|
|
1001
|
+
"—" if commits is None else str(commits), str(events)))
|
|
1002
|
+
data.append({"skill": name, "installed_at": since or None,
|
|
1003
|
+
"commits_since": commits, "files_touched": files,
|
|
1004
|
+
"events": events})
|
|
1005
|
+
|
|
1006
|
+
if args.json:
|
|
1007
|
+
print(json.dumps({"note": _IMPACT_NOTE, "skills": data}))
|
|
1008
|
+
return 0
|
|
1009
|
+
out.heading("impact" + ((" of %s" % args.name) if args.name else ""))
|
|
1010
|
+
out.table(rows, headers=("SKILL", "INSTALLED", "COMMITS SINCE", "EVENTS"))
|
|
1011
|
+
if args.name and in_repo and data[0]["files_touched"] is not None:
|
|
1012
|
+
out.kv("files touched", data[0]["files_touched"])
|
|
1013
|
+
if args.name:
|
|
1014
|
+
if ai.available():
|
|
1015
|
+
reply = ai.ask(
|
|
1016
|
+
"In one cautious paragraph (under 80 words), assess what can "
|
|
1017
|
+
"and cannot be concluded about this AI-coding skill's impact "
|
|
1018
|
+
"from correlational data only: %s. Do not overclaim."
|
|
1019
|
+
% json.dumps(data[0]))
|
|
1020
|
+
if reply:
|
|
1021
|
+
print()
|
|
1022
|
+
print(textwrap.indent(textwrap.fill(reply, width=76), " "))
|
|
1023
|
+
else:
|
|
1024
|
+
_note_fallback()
|
|
1025
|
+
out.dim(" " + _IMPACT_NOTE)
|
|
1026
|
+
return 0
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
_IMPACT_NOTE = "correlation, not causation — commits since install in this repo"
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def _repo_activity(since: str) -> Tuple[Optional[int], Optional[int]]:
|
|
1033
|
+
"""(commit count, unique files touched) in cwd's repo since a date."""
|
|
1034
|
+
if not since:
|
|
1035
|
+
return None, None
|
|
1036
|
+
commits = files = None
|
|
1037
|
+
proc = gitutil.run(["rev-list", "--count", "--since=" + since, "HEAD"],
|
|
1038
|
+
cwd=Path.cwd(), check=False)
|
|
1039
|
+
if proc.returncode == 0:
|
|
1040
|
+
try:
|
|
1041
|
+
commits = int(proc.stdout.strip() or 0)
|
|
1042
|
+
except ValueError:
|
|
1043
|
+
pass
|
|
1044
|
+
proc = gitutil.run(["log", "--name-only", "--since=" + since,
|
|
1045
|
+
"--pretty=format:"], cwd=Path.cwd(), check=False)
|
|
1046
|
+
if proc.returncode == 0:
|
|
1047
|
+
files = len({ln.strip() for ln in proc.stdout.splitlines() if ln.strip()})
|
|
1048
|
+
return commits, files
|