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

181 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-29 00:55 -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 

12from little_loops.init.core import strip_none_leaves 

13 

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

15_GITIGNORE_COMMENT = "# little-loops state files" 

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

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

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

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

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

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

22) 

23 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

49 "Bash(ll-adapt:*)", 

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` - Generate host-specific artefacts for a given host (``--host codex``, etc.) 

94- `ll-adapt-skills-for-codex` - Add Codex Skills API frontmatter to skills and bridge commands (alias for ll-adapt --host codex) 

95- `ll-adapt-agents-for-codex` - Generate `.codex/agents/*.toml` from `agents/*.md` (alias for ll-adapt --host codex) 

96 

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

98""" 

99 

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

101 

102 

103def load_existing_config(project_root: Path) -> dict[str, Any]: 

104 """Load the existing ll-config.json for *project_root* as a dict. 

105 

106 Resolves via :func:`little_loops.config.core.resolve_config_path` (so host 

107 state dirs like ``.codex/`` are honored), returning ``{}`` when no config is 

108 present or the file cannot be parsed. Shared by every ll-init write path that 

109 pre-populates from — and now merges with — the existing config. 

110 """ 

111 from little_loops.config.core import resolve_config_path 

112 

113 existing_path = resolve_config_path(project_root) 

114 if existing_path is None: 

115 return {} 

116 try: 

117 data = json.loads(existing_path.read_text(encoding="utf-8")) 

118 except (json.JSONDecodeError, OSError): 

119 return {} 

120 return data if isinstance(data, dict) else {} 

121 

122 

123def merge_with_existing( 

124 new_config: dict[str, Any], 

125 existing_config: dict[str, Any], 

126 force: bool, 

127) -> dict[str, Any]: 

128 """Layer *new_config* over *existing_config*, preserving unmodeled keys. 

129 

130 Fixes BUG-2310: re-running ll-init rebuilt only the keys ``build_config`` 

131 models and overwrote the file wholesale, silently destroying every other key 

132 the user had set (sprints, commands, documents, scratch_pad sub-config, 

133 history.compaction, context_monitor threshold, …). 

134 

135 When ``force`` is True (or there is no existing config) *new_config* is 

136 returned unchanged — the documented ``--force`` "reset to template defaults" 

137 contract. Otherwise the ``None``-stripped *new_config* is deep-merged over 

138 *existing_config* so unmodeled keys survive while modeled keys take the new 

139 values. Neither input is mutated. 

140 """ 

141 if force or not existing_config: 

142 return new_config 

143 from little_loops.config.core import deep_merge 

144 

145 return deep_merge(existing_config, strip_none_leaves(new_config)) 

146 

147 

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

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

150 

151 Args: 

152 config: Config dict produced by build_config(). 

153 ll_dir: Path to the .ll/ directory. 

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

155 """ 

156 if dry_run: 

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

158 return 

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

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

161 

162 

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

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

165 

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

167 

168 Args: 

169 project_root: Project root directory. 

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

171 

172 Returns: 

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

174 """ 

175 gitignore_path = project_root / ".gitignore" 

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

177 existing_lines = set(existing.splitlines()) 

178 

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

180 if not missing: 

181 return False 

182 

183 if dry_run: 

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

185 return True 

186 

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

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

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

190 elif existing: 

191 new_content = existing + "\n" + block 

192 else: 

193 new_content = block 

194 

195 atomic_write(gitignore_path, new_content) 

196 return True 

197 

198 

199def merge_settings( 

200 project_root: Path, 

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

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

203 dry_run: bool = False, 

204) -> None: 

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

206 

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

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

209 canonical list. 

210 

211 Args: 

212 project_root: Project root directory. 

213 settings_file: Relative path to target settings JSON file. 

214 extra_permissions: Additional entries inserted before the trailing 

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

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

217 """ 

218 target = project_root / settings_file 

219 if target.exists(): 

220 try: 

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

222 except json.JSONDecodeError: 

223 data = {} 

224 else: 

225 data = {} 

226 

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

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

229 

230 # Idempotency sweep — remove only canonical ll entries; preserve user-added Bash(ll-*) permissions. 

231 allow = [e for e in allow if e not in _LL_PERMISSIONS] 

232 if extra_permissions: 

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

234 

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

236 canonical = list(_LL_PERMISSIONS) 

237 if extra_permissions: 

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

239 

240 allow.extend(canonical) 

241 perms["allow"] = allow 

242 data["permissions"] = perms 

243 

244 if dry_run: 

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

246 if extra_permissions: 

247 for perm in extra_permissions: 

248 print(f" + {perm}") 

249 return 

250 

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

252 atomic_write_json(target, data) 

253 

254 

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

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

257 

258 Args: 

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

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

261 """ 

262 if dry_run: 

263 for sd in _ISSUE_SUBDIRS: 

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

265 return 

266 for sd in _ISSUE_SUBDIRS: 

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

268 

269 

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

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

272 

273 Args: 

274 ll_dir: The .ll/ directory. 

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

276 

277 Returns: 

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

279 """ 

280 lt_dir = ll_dir / "learning-tests" 

281 if lt_dir.exists(): 

282 return False 

283 if dry_run: 

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

285 return True 

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

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

288 return True 

289 

290 

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

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

293 

294 Args: 

295 ll_dir: The .ll/ directory. 

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

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

298 

299 Returns: 

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

301 """ 

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

303 if dest.exists(): 

304 return False 

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

306 if not src.exists(): 

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

308 return False 

309 if dry_run: 

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

311 return True 

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

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

314 return True 

315 

316 

317def deploy_design_tokens( 

318 ll_dir: Path, 

319 templates_dir: Path, 

320 active_profile: str = "default", 

321 dry_run: bool = False, 

322) -> bool: 

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

324 

325 Skips silently if the destination already exists. 

326 

327 Args: 

328 ll_dir: The .ll/ directory. 

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

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

331 written to config by this function). 

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

333 

334 Returns: 

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

336 """ 

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

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

339 if dest_profiles.exists(): 

340 return False 

341 if not src_profiles.exists(): 

342 print( 

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

344 file=sys.stderr, 

345 ) 

346 return False 

347 if dry_run: 

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

349 return True 

350 shutil.copytree(src_profiles, dest_profiles) 

351 return True 

352 

353 

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

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

356 

357 Args: 

358 ll_dir: The .ll/ directory. 

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

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

361 

362 Returns: 

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

364 """ 

365 dest = ll_dir / "templates" 

366 if dest.exists(): 

367 return False 

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

369 if not section_files: 

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

371 return False 

372 if dry_run: 

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

374 return True 

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

376 for f in section_files: 

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

378 return True 

379 

380 

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

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

383 

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

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

386 the section is already present. 

387 

388 Args: 

389 project_root: Project root directory. 

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

391 

392 Returns: 

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

394 """ 

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

396 root_claude = project_root / "CLAUDE.md" 

397 

398 if dot_claude.exists(): 

399 target = dot_claude 

400 elif root_claude.exists(): 

401 target = root_claude 

402 else: 

403 target = dot_claude 

404 

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

406 

407 if target.exists(): 

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

409 if _CLAUDE_MD_SECTION_MARKER in existing: 

410 return False 

411 if dry_run: 

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

413 return True 

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

415 atomic_write(target, new_content) 

416 else: 

417 if dry_run: 

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

419 return True 

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

421 atomic_write(target, _CLAUDE_MD_NEW_FILE_CONTENT) 

422 

423 return True 

424 

425 

426def _codex_template_path() -> Path: 

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

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

429 

430 

431def install_codex_adapter( 

432 project_root: Path, 

433 plugin_root: Path, 

434 force: bool = False, 

435 dry_run: bool = False, 

436) -> bool | None: 

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

438 

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

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

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

442 

443 Args: 

444 project_root: Project root directory. 

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

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

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

448 

449 Returns: 

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

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

452 """ 

453 template_path = _codex_template_path() 

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

455 

456 if not template_path.exists(): 

457 return None 

458 

459 if dest.exists() and not force: 

460 return False 

461 

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

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

464 

465 if dry_run: 

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

467 return True 

468 

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

470 atomic_write(dest, rendered) 

471 return True