Coverage for little_loops / init / writers.py: 15%

163 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-26 17:38 -0500

1"""File mutation helpers for headless ll-init.""" 

2 

3from __future__ import annotations 

4 

5import json 

6import shutil 

7import sys 

8from pathlib import Path 

9from typing import Any 

10 

11from little_loops.file_utils import atomic_write, atomic_write_json 

12 

13# Entries added to .gitignore by ll-init (idempotently) 

14_GITIGNORE_COMMENT = "# little-loops state files" 

15_GITIGNORE_ENTRIES: tuple[str, ...] = ( 

16 ".auto-manage-state.json", 

17 ".parallel-manage-state.json", 

18 ".ll/ll-context-state.json", 

19 ".ll/ll-sync-state.json", 

20 ".ll/ll-session-events.jsonl", 

21) 

22 

23# Canonical permission entries for .claude/settings*.json (Step 10 of the skill) 

24_LL_PERMISSIONS: tuple[str, ...] = ( 

25 "Bash(ll-action:*)", 

26 "Bash(ll-issues:*)", 

27 "Bash(ll-auto:*)", 

28 "Bash(ll-parallel:*)", 

29 "Bash(ll-sprint:*)", 

30 "Bash(ll-loop:*)", 

31 "Bash(ll-workflows:*)", 

32 "Bash(ll-messages:*)", 

33 "Bash(ll-history:*)", 

34 "Bash(ll-history-context:*)", 

35 "Bash(ll-deps:*)", 

36 "Bash(ll-sync:*)", 

37 "Bash(ll-verify-docs:*)", 

38 "Bash(ll-verify-package-data:*)", 

39 "Bash(ll-verify-skills:*)", 

40 "Bash(ll-check-links:*)", 

41 "Bash(ll-gitignore:*)", 

42 "Bash(ll-create-extension:*)", 

43 "Bash(ll-learning-tests:*)", 

44 "Bash(ll-logs:*)", 

45 "Bash(ll-session:*)", 

46 "Bash(ll-doctor:*)", 

47 "Bash(ll-ctx-stats:*)", 

48 "Bash(ll-adapt-skills-for-codex:*)", 

49 "Bash(ll-adapt-agents-for-codex:*)", 

50 "Bash(ll-harness:*)", 

51 "Write(.ll/ll-continue-prompt.md)", 

52) 

53 

54_ISSUE_SUBDIRS: tuple[str, ...] = ( 

55 "bugs", 

56 "features", 

57 "enhancements", 

58 "epics", 

59) 

60 

61# Sentinel string used to detect whether the ll section already exists in CLAUDE.md 

62_CLAUDE_MD_SECTION_MARKER = "## little-loops" 

63 

64# Canonical CLI Commands block appended/created by write_claude_md (Step 11 of the skill) 

65_CLAUDE_MD_COMMANDS_BLOCK = """\ 

66 

67## little-loops CLI Commands 

68 

69- `ll-action` - Invoke ll skills as one-shot commands with JSON-structured output 

70- `ll-harness` - One-shot runner evaluation (skill, cmd, mcp, prompt, dsl) with exit-code and semantic criteria 

71- `ll-auto` - Process all backlog issues sequentially in priority order 

72- `ll-parallel` - Process issues concurrently using isolated git worktrees 

73- `ll-sprint` - Define and execute curated issue sets with dependency-aware ordering 

74- `ll-loop` - Execute FSM-based automation loops 

75- `ll-workflows` - Identify multi-step workflow patterns from user message history 

76- `ll-messages` - Extract user messages from Claude Code logs 

77- `ll-history` - View completed issue statistics, analysis, and export topic-filtered excerpts from history 

78- `ll-history-context` - Render a `## Historical Context` block for an issue from `.ll/history.db` 

79- `ll-deps` - Cross-issue dependency analysis and validation 

80- `ll-sync` - Sync local issues with GitHub Issues 

81- `ll-verify-docs` - Verify documented counts match actual file counts 

82- `ll-verify-package-data` - Lint __file__ escapes and verify manifest assets are in-wheel 

83- `ll-verify-skills` - Check that no SKILL.md exceeds 500 lines 

84- `ll-check-links` - Check markdown documentation for broken links 

85- `ll-issues` - Issue management and visualization (next-id, list, show, path, sequence, impact-effort, refine-status, set-status, anchor-sweep, fingerprint, epic-progress, decisions) 

86- `ll-gitignore` - Suggest and apply `.gitignore` patterns based on untracked files 

87- `ll-create-extension` - Scaffold a new little-loops extension project 

88- `ll-generate-schemas` - Regenerate JSON Schema files for all LLEvent types (maintainer tool) 

89- `ll-learning-tests` - Query and manage the learning test registry (check/list/mark-stale) 

90- `ll-logs` - Discover, extract, and analyze (sequences, scan-failures) ll-relevant log entries from Claude project logs 

91- `ll-doctor` - Check host CLI capability support for little-loops features 

92- `ll-ctx-stats` - Show context-window analytics for the current project (per-tool byte vs. context savings; skill-health signals) 

93- `ll-adapt-skills-for-codex` - Add Codex Skills API frontmatter to skills and bridge commands for Codex discovery 

94- `ll-adapt-agents-for-codex` - Generate `.codex/agents/*.toml` from `agents/*.md` for Codex agent-select support 

95 

96Install: `pip install -e "./scripts[dev]"` 

97""" 

98 

99_CLAUDE_MD_NEW_FILE_CONTENT = "# Project Configuration\n" + _CLAUDE_MD_COMMANDS_BLOCK 

100 

101 

102def write_config(config: dict[str, Any], ll_dir: Path, dry_run: bool = False) -> None: 

103 """Write ll-config.json into *ll_dir*. 

104 

105 Args: 

106 config: Config dict produced by build_config(). 

107 ll_dir: Path to the .ll/ directory. 

108 dry_run: If True, print JSON to stdout; do not write files. 

109 """ 

110 if dry_run: 

111 print(json.dumps(config, indent=2)) 

112 return 

113 ll_dir.mkdir(parents=True, exist_ok=True) 

114 atomic_write_json(ll_dir / "ll-config.json", config) 

115 

116 

117def update_gitignore(project_root: Path, dry_run: bool = False) -> bool: 

118 """Idempotently append ll state-file patterns to .gitignore. 

119 

120 Only missing entries are appended; existing entries are never duplicated. 

121 

122 Args: 

123 project_root: Project root directory. 

124 dry_run: If True, print planned changes; do not modify files. 

125 

126 Returns: 

127 True if the file was created or modified; False if no changes needed. 

128 """ 

129 gitignore_path = project_root / ".gitignore" 

130 existing = gitignore_path.read_text(encoding="utf-8") if gitignore_path.exists() else "" 

131 existing_lines = set(existing.splitlines()) 

132 

133 missing = [e for e in _GITIGNORE_ENTRIES if e not in existing_lines] 

134 if not missing: 

135 return False 

136 

137 if dry_run: 

138 print(f"[update] .gitignore (+{len(missing)} entries)") 

139 return True 

140 

141 block = _GITIGNORE_COMMENT + "\n" + "\n".join(missing) + "\n" 

142 if existing and not existing.endswith("\n"): 

143 new_content = existing + "\n\n" + block 

144 elif existing: 

145 new_content = existing + "\n" + block 

146 else: 

147 new_content = block 

148 

149 atomic_write(gitignore_path, new_content) 

150 return True 

151 

152 

153def merge_settings( 

154 project_root: Path, 

155 settings_file: str = ".claude/settings.local.json", 

156 extra_permissions: list[str] | None = None, 

157 dry_run: bool = False, 

158) -> None: 

159 """Merge ll- CLI tool permissions into a Claude Code settings file. 

160 

161 Idempotency sweep: removes stale ``Bash(ll-*`` and 

162 ``Write(.ll/ll-continue-prompt.md)`` entries before re-appending the 

163 canonical list. 

164 

165 Args: 

166 project_root: Project root directory. 

167 settings_file: Relative path to target settings JSON file. 

168 extra_permissions: Additional entries inserted before the trailing 

169 ``Write(.ll/ll-continue-prompt.md)`` entry. 

170 dry_run: If True, print the target path; do not write. 

171 """ 

172 target = project_root / settings_file 

173 if target.exists(): 

174 try: 

175 data: dict[str, Any] = json.loads(target.read_text(encoding="utf-8")) 

176 except json.JSONDecodeError: 

177 data = {} 

178 else: 

179 data = {} 

180 

181 perms: dict[str, Any] = data.setdefault("permissions", {}) 

182 allow: list[str] = list(perms.get("allow", [])) 

183 

184 # Idempotency sweep 

185 allow = [e for e in allow if not e.startswith("Bash(ll-")] 

186 allow = [e for e in allow if e != "Write(.ll/ll-continue-prompt.md)"] 

187 if extra_permissions: 

188 allow = [e for e in allow if e not in extra_permissions] 

189 

190 # Build canonical list (insert extras before trailing Write entry) 

191 canonical = list(_LL_PERMISSIONS) 

192 if extra_permissions: 

193 canonical = canonical[:-1] + list(extra_permissions) + [canonical[-1]] 

194 

195 allow.extend(canonical) 

196 perms["allow"] = allow 

197 data["permissions"] = perms 

198 

199 if dry_run: 

200 print(f"[update] {settings_file}") 

201 return 

202 

203 target.parent.mkdir(parents=True, exist_ok=True) 

204 atomic_write_json(target, data) 

205 

206 

207def make_issue_dirs(base_dir: Path, dry_run: bool = False) -> None: 

208 """Create the standard issue-tracking subdirectories under *base_dir*. 

209 

210 Args: 

211 base_dir: Root issues directory (e.g., .issues/). 

212 dry_run: If True, print planned mkdirs; do not create directories. 

213 """ 

214 if dry_run: 

215 for sd in _ISSUE_SUBDIRS: 

216 print(f"[mkdir] {base_dir / sd}") 

217 return 

218 for sd in _ISSUE_SUBDIRS: 

219 (base_dir / sd).mkdir(parents=True, exist_ok=True) 

220 

221 

222def make_learning_tests_dir(ll_dir: Path, dry_run: bool = False) -> bool: 

223 """Create .ll/learning-tests/ with a .gitkeep placeholder. 

224 

225 Args: 

226 ll_dir: The .ll/ directory. 

227 dry_run: If True, print planned mkdir; do not create directories. 

228 

229 Returns: 

230 True if the directory was created; False if it already existed. 

231 """ 

232 lt_dir = ll_dir / "learning-tests" 

233 if lt_dir.exists(): 

234 return False 

235 if dry_run: 

236 print(f"[mkdir] {lt_dir}") 

237 return True 

238 lt_dir.mkdir(parents=True, exist_ok=True) 

239 (lt_dir / ".gitkeep").touch() 

240 return True 

241 

242 

243def deploy_goals(ll_dir: Path, templates_dir: Path, dry_run: bool = False) -> bool: 

244 """Deploy the goals template to .ll/ll-goals.md (skip if already present). 

245 

246 Args: 

247 ll_dir: The .ll/ directory. 

248 templates_dir: templates/ directory containing ll-goals-template.md. 

249 dry_run: If True, print planned write; do not copy files. 

250 

251 Returns: 

252 True if deployed; False if already existed or source not found. 

253 """ 

254 dest = ll_dir / "ll-goals.md" 

255 if dest.exists(): 

256 return False 

257 src = templates_dir / "ll-goals-template.md" 

258 if not src.exists(): 

259 print(f" Warning: goals template source not found at {src}", file=sys.stderr) 

260 return False 

261 if dry_run: 

262 print(f"[write] {dest} (from {src.name})") 

263 return True 

264 ll_dir.mkdir(parents=True, exist_ok=True) 

265 atomic_write(dest, src.read_text(encoding="utf-8")) 

266 return True 

267 

268 

269def deploy_design_tokens( 

270 ll_dir: Path, 

271 templates_dir: Path, 

272 active_profile: str = "default", 

273 dry_run: bool = False, 

274) -> bool: 

275 """Mirror templates/design-tokens/profiles/ into .ll/design-tokens/profiles/. 

276 

277 Skips silently if the destination already exists. 

278 

279 Args: 

280 ll_dir: The .ll/ directory. 

281 templates_dir: templates/ directory containing design-tokens/profiles/. 

282 active_profile: Name of the active profile (for display only; not 

283 written to config by this function). 

284 dry_run: If True, print planned write; do not copy files. 

285 

286 Returns: 

287 True if deployed; False if already existed or source not found. 

288 """ 

289 src_profiles = templates_dir / "design-tokens" / "profiles" 

290 dest_profiles = ll_dir / "design-tokens" / "profiles" 

291 if dest_profiles.exists(): 

292 return False 

293 if not src_profiles.exists(): 

294 print( 

295 f" Warning: design-token profiles source not found at {src_profiles}", 

296 file=sys.stderr, 

297 ) 

298 return False 

299 if dry_run: 

300 print(f"[write] {dest_profiles}/ (design-token profiles)") 

301 return True 

302 shutil.copytree(src_profiles, dest_profiles) 

303 return True 

304 

305 

306def deploy_issue_templates(ll_dir: Path, templates_dir: Path, dry_run: bool = False) -> bool: 

307 """Copy bundled *-sections.json files to .ll/templates/ (skip if already present). 

308 

309 Args: 

310 ll_dir: The .ll/ directory. 

311 templates_dir: templates/ directory containing *-sections.json files. 

312 dry_run: If True, print planned write; do not copy files. 

313 

314 Returns: 

315 True if deployed; False if already existed or no section files found. 

316 """ 

317 dest = ll_dir / "templates" 

318 if dest.exists(): 

319 return False 

320 section_files = list(templates_dir.glob("*-sections.json")) 

321 if not section_files: 

322 print(f"Warning: no *-sections.json files found in {templates_dir}", file=sys.stderr) 

323 return False 

324 if dry_run: 

325 print(f"[write] {dest}/ (issue section templates)") 

326 return True 

327 dest.mkdir(parents=True, exist_ok=True) 

328 for f in section_files: 

329 shutil.copy2(f, dest / f.name) 

330 return True 

331 

332 

333def write_claude_md(project_root: Path, dry_run: bool = False) -> bool: 

334 """Append the canonical ## little-loops CLI Commands block to CLAUDE.md. 

335 

336 Detection order: .claude/CLAUDE.md, then CLAUDE.md. If neither exists, 

337 creates .claude/CLAUDE.md. Idempotent: returns False without writing if 

338 the section is already present. 

339 

340 Args: 

341 project_root: Project root directory. 

342 dry_run: If True, print planned action; do not write files. 

343 

344 Returns: 

345 True if the file was created or modified; False if no changes needed. 

346 """ 

347 dot_claude = project_root / ".claude" / "CLAUDE.md" 

348 root_claude = project_root / "CLAUDE.md" 

349 

350 if dot_claude.exists(): 

351 target = dot_claude 

352 elif root_claude.exists(): 

353 target = root_claude 

354 else: 

355 target = dot_claude 

356 

357 rel = str(target.relative_to(project_root)) 

358 

359 if target.exists(): 

360 existing = target.read_text(encoding="utf-8") 

361 if _CLAUDE_MD_SECTION_MARKER in existing: 

362 return False 

363 if dry_run: 

364 print(f"[update] {rel} (append ## little-loops CLI Commands)") 

365 return True 

366 new_content = existing.rstrip("\n") + "\n" + _CLAUDE_MD_COMMANDS_BLOCK 

367 atomic_write(target, new_content) 

368 else: 

369 if dry_run: 

370 print(f"[write] {rel} (ll- CLI command documentation)") 

371 return True 

372 target.parent.mkdir(parents=True, exist_ok=True) 

373 atomic_write(target, _CLAUDE_MD_NEW_FILE_CONTENT) 

374 

375 return True 

376 

377 

378def _codex_template_path() -> Path: 

379 """Return the in-package path to the Codex adapter hooks.json template.""" 

380 return Path(__file__).parent.parent / "hooks" / "adapters" / "codex" / "hooks.json" 

381 

382 

383def install_codex_adapter( 

384 project_root: Path, 

385 plugin_root: Path, 

386 force: bool = False, 

387 dry_run: bool = False, 

388) -> bool | None: 

389 """Write .codex/hooks.json from the in-package Codex adapter template. 

390 

391 Reads ``little_loops/hooks/adapters/codex/hooks.json`` from the installed 

392 package, substitutes ``{{LL_PLUGIN_ROOT}}`` with the ``little_loops`` 

393 package directory, and writes the result to ``<project_root>/.codex/hooks.json``. 

394 

395 Args: 

396 project_root: Project root directory. 

397 plugin_root: Unused; kept for call-site compatibility. 

398 force: If True, overwrite an existing .codex/hooks.json. 

399 dry_run: If True, print planned write; do not modify files. 

400 

401 Returns: 

402 True if written; False if skipped (dest already exists without --force); 

403 None if the source template is missing (package install corrupted). 

404 """ 

405 template_path = _codex_template_path() 

406 dest = project_root / ".codex" / "hooks.json" 

407 

408 if not template_path.exists(): 

409 return None 

410 

411 if dest.exists() and not force: 

412 return False 

413 

414 package_root = str(Path(__file__).parent.parent) 

415 rendered = template_path.read_text(encoding="utf-8").replace("{{LL_PLUGIN_ROOT}}", package_root) 

416 

417 if dry_run: 

418 print("[write] .codex/hooks.json") 

419 return True 

420 

421 dest.parent.mkdir(parents=True, exist_ok=True) 

422 atomic_write(dest, rendered) 

423 return True