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.
@@ -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